Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 66 additions & 24 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,83 @@ set -euo pipefail
echo '[pre-commit] Running TypeScript check (pnpm exec tsc --noEmit)...'
pnpm exec tsc --noEmit

# Vendor file auto-tidy: extract bloat from staged vendor config files
# Vendor file auto-tidy (#320): tidy only the staged vendor files, then re-stage only the files tidy wrote.
# Charter pre-commit vendor tidy: scoped to staged files
# Skip with CHARTER_SKIP_TIDY=1
# This block never fails the commit; the ADF evidence gate after it may.
# TIDY_SKIPPED lists the vendor files it skipped, so the gate can say why it blocks.
TIDY_SKIPPED=""
if [ "${CHARTER_SKIP_TIDY:-0}" != "1" ] && [ -f ".ai/manifest.adf" ]; then
VENDOR_FILES="CLAUDE.md .cursorrules AGENTS.md GEMINI.md copilot-instructions.md"
STAGED_VENDORS=""
VENDOR_FILES="CLAUDE.md .cursorrules AGENTS.md agents.md GEMINI.md copilot-instructions.md"
STAGED=$(git diff --cached --name-only --diff-filter=d)
SOURCES=""
NAMES=""
for vf in $VENDOR_FILES; do
if git diff --cached --name-only | grep -qx "$vf"; then
# Excluding compiled output here means a commit staging only compiled files
# skips tidy and the "git add .ai/*.adf" re-stage entirely. When tidy does run,
# it skips compiled files itself (#319).
if printf '%s\n' "$STAGED" | grep -xF "$vf" >/dev/null; then
# Fast path: compiled output is never tidied (#319), so a commit staging only
# compiled files never invokes the CLI.
if [ -f "$vf" ] && grep -q 'GENERATED by charter adf compile' "$vf"; then
continue
fi
STAGED_VENDORS="$STAGED_VENDORS $vf"
if ! git diff --quiet -- "$vf"; then
echo "[pre-commit] $vf has unstaged changes, so auto-tidy is skipped for it in this commit."
echo "[pre-commit] If it carries bloat, the ADF evidence gate may still block this commit. Ways out: stage or stash the unstaged changes; or run: charter adf tidy --source $vf, then git add the result; or bypass the hooks with git commit --no-verify."
TIDY_SKIPPED="$TIDY_SKIPPED $vf"
continue
fi
SOURCES="$SOURCES --source $vf"
NAMES="$NAMES $vf"
fi
done

if [ -n "$STAGED_VENDORS" ]; then
echo "[pre-commit] Checking vendor config files:$STAGED_VENDORS"
TIDY_OUTPUT=$(node packages/cli/dist/bin.js adf tidy --dry-run --format json 2>/dev/null || echo '{}')
EXTRACTED=$(echo "$TIDY_OUTPUT" | grep -o '"totalExtracted": *[0-9][0-9]*' | grep -o '[0-9][0-9]*' || echo "0")

if [ "$EXTRACTED" -gt 0 ] 2>/dev/null; then
echo "[pre-commit] Found $EXTRACTED items beyond thin pointer — auto-tidying..."
node packages/cli/dist/bin.js adf tidy 2>/dev/null

# Re-stage tidied vendor files and modified .adf modules
for vf in $STAGED_VENDORS; do
if [ -f "$vf" ]; then
git add "$vf"
if [ -n "$SOURCES" ]; then
echo "[pre-commit] Checking staged vendor files:$NAMES"
PLAN=$(node packages/cli/dist/bin.js adf tidy --dry-run --print-paths $SOURCES 2>/dev/null) || { echo "[pre-commit] adf tidy --dry-run failed; skipping auto-tidy."; PLAN=""; }
if printf '%s\n' "$PLAN" | grep -E '^(vendor|module) ' >/dev/null; then
BLOCKED=""
IGNORED=""
set -f
for p in $(printf '%s\n' "$PLAN" | sed -n 's/^module //p'); do
# A gitignored module (say, an .ai/ kept out of git) is never staged, so it is not WIP.
if git check-ignore -q -- "$p"; then
IGNORED="$IGNORED $p"
elif ! git diff --quiet -- "$p" || { [ -e "$p" ] && ! git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; }; then
BLOCKED="$BLOCKED $p"
fi
done
git add .ai/*.adf 2>/dev/null || true

echo "[pre-commit] Vendor files tidied and re-staged."
set +f
if [ -n "$BLOCKED" ]; then
echo "[pre-commit] Auto-tidy skipped for this commit: tidy would write into files with unstaged or untracked work:$BLOCKED"
echo "[pre-commit] The ADF evidence gate may still block this commit while$NAMES carries bloat. Ways out: stage or stash that work; or run: charter adf tidy$SOURCES, then git add the result; or bypass the hooks with git commit --no-verify."
TIDY_SKIPPED="$TIDY_SKIPPED$NAMES"
else
echo "[pre-commit] Moving content beyond the thin pointer into .ai/ ..."
WROTE=$(node packages/cli/dist/bin.js adf tidy --print-paths $SOURCES 2>/dev/null) || { echo "[pre-commit] adf tidy failed; nothing re-staged."; WROTE=""; }
EXTRA=""
UNSTAGED=""
set -f
for line in $(printf '%s\n' "$WROTE" | sed 's/ /:/'); do
f="${line#*:}"
if ! printf '%s\n' "$PLAN" | sed 's/ /:/' | grep -xF "$line" >/dev/null; then
EXTRA="$EXTRA $f"
elif git check-ignore -q -- "$f"; then
:
elif ! git add -- "$f" 2>/dev/null; then
UNSTAGED="$UNSTAGED $f"
fi
done
set +f
if [ -n "$IGNORED" ]; then
echo "[pre-commit] Gitignored, so left as-is and not staged:$IGNORED"
fi
if [ -n "$EXTRA" ]; then
echo "[pre-commit] Tidy also wrote files its dry run did not plan; left unstaged, review and stage them yourself:$EXTRA"
fi
if [ -n "$UNSTAGED" ]; then
echo "[pre-commit] Could not stage, left in the working tree; stage them yourself:$UNSTAGED"
fi
echo "[pre-commit] Vendor files tidied; re-staged only the planned files tidy wrote."
fi
else
echo "[pre-commit] Vendor config files are clean."
fi
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning.

### Changed

- **The pre-commit hook tidies only the staged vendor files and re-stages only the files tidy wrote** (`#320`, `#318` part 4) — re-run `charter hook install --pre-commit` to pick up staged-only tidy. An installed hook keeps its old behaviour until you do, and `charter doctor` reports it with an INFO `pre-commit hook` row (INFO, so `--ci` does not fail). Reinstalling overwrites a Charter-managed hook without `--force`, as before. The old hook ran a whole-tree `adf tidy` whenever any vendor file was staged, then ran `git add` on every staged vendor file and on all of `.ai/*.adf`. That swept unrelated `.ai/` edits into the commit and rewrote vendor files that were not part of it. The new hook passes each staged vendor file to `adf tidy` with `--source` and stages only the paths that both the dry run and the real run report. A staged vendor file that also has unstaged changes is skipped with a message, because tidy works on the working tree and staging its result would commit those hunks. If tidy would write into a module that has unstaged changes or is untracked, nothing is tidied for that commit and a message says why. A gitignored module (for example an `.ai/` kept out of git) is not treated as work in progress: tidy writes it, and the hook says it was left unstaged. Any file the hook cannot stage, and any file the real run wrote that the dry run did not plan, is named and left unstaged rather than aborting the hook. A staged lowercase `agents.md` is now tidied (`#302`), and a commit staging only compiled output still makes no CLI call. The tidy block itself never fails a commit, but the ADF evidence gate after it can, and it is unchanged. In the generated hook (no `verify:adf` script), vendor bloat that a skipped tidy leaves in the working tree still fails `charter doctor --adf-only --ci`. The skip message now says so and names the ways out: stage or stash the unstaged work; run `charter adf tidy --source <file>` and `git add` the result; or `git commit --no-verify`. When the gate then fails, one line says which skipped file still carries how many items. A new hook with an older local CLI that does not know `--print-paths` reports the files as clean and tidies nothing, so upgrade the CLI too.
- **BREAKING: `charter adf compile --write` no longer converts pointer files without `--mode compiled`** (`#318`, part 2) — compile used to overwrite any Charter thin pointer with compiled output, silently changing its mode. Each target's mode now comes from the `VENDORS` record, or from its markers when there is no entry, and `--write` only writes targets that are compiled or absent. Under `--target all`, pointer and unmanaged targets are skipped (exit 0), and each `[skip]` line names the command that converts it. A single such `--target` is refused (exit 1). `--force` alone never changes a mode. `--mode compiled` records `compiled` before writing. If that cannot be recorded, nothing is written (`refused-record`). If the write then fails, the record is restored and the run continues. A converted file is reported as `Written CLAUDE.md (mode: pointer -> compiled)`. JSON output adds `skipped` and per-target `targets`. A compiled-only repo with no `VENDORS` section compiles as before.

- **`charter bootstrap --force` no longer overwrites hand-authored or compiled vendor files** (`#318`, part 3) — this changes what a documented flag does. `--force` still re-scaffolds `.ai/*.adf` (with backups in `.ai/.backup/`) and still rewrites Charter thin pointers from the current template. It now skips a `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md` or `copilot-instructions.md` that has no Charter marker, carries the compile banner, or is recorded `compiled` or `unmanaged` in `VENDORS`, with a warning naming the next step (`adf migrate --source <file>`, or `adf vendor set <file> pointer && adf vendor restore <file>` for compiled output). With `--yes`, phase 4 then migrates a hand-authored file that has no `VENDORS` entry into `.ai/`, as it would without `--force`, and the phase 3 warning says so; a hand-authored file that already has an entry is left as it is. Phase 4 no longer migrates a file recorded `unmanaged`, and valid `VENDORS` entries are carried over to the re-scaffolded `manifest.adf`. To replace a hand-authored file without migrating it, delete it and re-run bootstrap.
Expand All @@ -20,6 +21,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning.

### Added

- **`charter adf tidy --source` is repeatable, and `--print-paths` lists the files tidy writes** (`#320`) — each `--source` is processed in argument order, and two names for one file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link) are tidied once, under the first name. A `--source` with no value is now a usage error (exit 2); it used to tidy every vendor file silently. `--print-paths` prints one `vendor <path>` or `module <path>` line per file tidy writes (or would write, under `--dry-run`) and nothing else on stdout; warnings go to stderr. It cannot be combined with `--format json` (exit 2). JSON output adds `paths` (`vendor` and `modules`); `modulesModified` is unchanged. A single `--source` gives the same output as before.
- **`charter adf vendor restore <file>`** (`#318`, part 3) — rewrites one vendor file as its thin pointer template (`CLAUDE.md` gets the hybrid template with its module index, as bootstrap writes it), carrying `## Environment` and other retained sections over verbatim. It runs only for a file in `pointer` mode, or an absent file with no entry, and refuses (exit 1, nothing written) whenever content would be lost: hand-authored content, content `adf tidy` would extract, any line of a pointer that is not from a Charter template and that the new pointer would not carry (an edited title, an extra note, edited `## Session Start` text), your own sections in a compiled file, or a compiled file that differs from what `.ai/` compiles to now (checked in memory). It is the fix doctor names for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded first; a failed file write puts the manifest back and is reported as `write-failed` with `writeError`. `--dry-run` and `--format json` are supported.
- **`charter adf migrate` registers the modules it routes content into** (`#239`, completes the migrate half of `#241`) — migrate could create `infra.adf`, `frontend.adf` or `core.adf` without adding them to `.ai/manifest.adf` or the `CLAUDE.md` module index, so no agent knew to load them and `charter doctor` failed until someone wired them by hand. Each target module that is not already in the manifest is now registered after the module merge and before the pointer is written, so the hybrid `CLAUDE.md` index includes it. A source other than `CLAUDE.md` that adds a module refreshes that index afterwards. `core.adf` and `state.adf` go to `DEFAULT_LOAD`, through the same classification `doctor` checks. Other modules go to `ON_DEMAND` with up to six triggers inferred from the headings of the items routed there. The heading word the router matched counts double, a named module's curated triggers come first, words that already trigger another module are dropped, and the lowercase module stem is the fallback. To supply that heading word, `@stackbilt/adf`'s `RoutingTrace` gains an optional `headingKeyword`. `classifyElement` now always sets `routingTrace`, even without a trigger map, in which case `candidateScores` is `{}`. That is an additive change, but code that read a defined `routingTrace` as "content routing ran" should check `candidateScores` instead. Registration is append-only. New bullets are inserted as text after the section's last item, in that item's indentation and bullet style, and a missing section is appended at the end of the file. Comments, HTML comments, tables, section order, blank lines and CRLF or LF line endings are left byte-for-byte. Before writing, the edited manifest is re-parsed and must equal the old one plus exactly the new entries; otherwise nothing is written and a warning is printed. An existing entry is never rewritten, moved or given new triggers, and a re-run writes nothing. An existing entry is reported as it stands, in its real section with its triggers as spelled. A section header with no items counts as an empty list and is filled. `core.adf` under `ON_DEMAND` is left in place with a warning naming the move. A missing `manifest.adf` warns to run `charter adf init`, an unparseable one warns to fix it or run `charter doctor`, and in both cases registration is skipped. The plan prints a `REGISTER` line per module, and `--format json` adds `sources[].registrations[]` (`module`, `loadPolicy`, `triggers`, `triggerSources`, `reason`, `status`, `skipReason`). `--dry-run` writes nothing and reports each planned module once across sources. As a known gap, it does not simulate an earlier source's new triggers when routing a later one. Bootstrap's two orphan writers now use the same shared writer, which also removes a line-splice ON_DEMAND writer that could register a module twice. That brings three behavior changes. Orphan triggers are now the lowercase stem (`Observability.adf` gets `observability`). A `DEFAULT_LOAD` that is not a list is now skipped with a warning, where it used to have a raw `DEFAULT_LOAD:` block appended. And with `--format json`, writer warnings now appear in the phase 3 step's `warnings`. A bootstrap `--yes` run registers migrate's modules in phase 4, so a re-run finds no orphans. `charter adf create` keeps its own upsert writer, and the stale `charter adf register` hint in bootstrap is tracked separately in `#347`.
- **A record of each vendor file's mode, and `charter adf vendor status|set|init`** (`#318`, part 1) — nothing recorded whether `CLAUDE.md`, `AGENTS.md`, `.cursorrules` or `GEMINI.md` was meant to be a thin pointer to `.ai/` or `adf compile` output, so every command guessed from the markers in the file. `.ai/manifest.adf` can now carry a `VENDORS` list (` - CLAUDE.md: pointer`) that gives each file's mode as `pointer`, `compiled` or `unmanaged`. `charter adf vendor status` is read-only. It shows each file's recorded mode, or the mode inferred from its markers when none is recorded, and flags a recorded mode that the file on disk contradicts. Inference follows `adf tidy`: the banner means `compiled`, a pointer marker means `pointer`, and anything else is `unmanaged`. `charter adf vendor set <file> <mode>` records one file, and `charter adf vendor init` records the inferred mode of every unrecorded file on disk. `agents.md` and `copilot-instructions.md` may never be `compiled`, since no compile target writes them. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` now record the mode of each vendor file they write. They only add a missing entry, so an existing one is kept with a warning. Under `--dry-run` nothing is recorded. A record that cannot be written produces a warning and changes no exit code or JSON. Under `--format json` that warning goes into bootstrap's phase-3 `warnings`, and to stderr for the other writers. The writer reuses the text-level helpers from `#239`, so other bytes and line endings are kept, and the edit is re-parsed before writing. `@stackbilt/adf`'s `parseManifest` gains a permissive `vendors` field. `VENDORS` gets the bare 🏷 decoration (U+1F3F7 without U+FE0F, which the parser drops, so a written block stays `adf fmt` canonical), sorts after `SYNC`, and is never rendered into compiled output. When `AGENTS.md` and `agents.md` are one physical file (case-insensitive filesystem or hard link), every writer records it once, under the spelling already recorded. `charter score` leaves the section out of its manifest path-reference check. In this part no command acted on the record yet. Part 2 (above) makes `compile` and `tidy` read it, and `bootstrap`, `migrate`, `doctor`, `score` and both pre-commit hooks still decide as before until later parts of `#318`.
Expand Down
Loading
Loading