From bf890e6ce092f0476632da4fafb4de127fd46163 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 06:00:29 -0500 Subject: [PATCH 1/2] feat(adf): doctor, score and bootstrap read the vendor mode record (refs #318) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.md | 1 + docs/cli-reference.md | 37 +- packages/cli/README.md | 5 +- .../__tests__/adf-vendor-consumers.test.ts | 6 +- .../__tests__/adf-vendor-doctor-score.test.ts | 844 ++++++++++++++++++ packages/cli/src/__tests__/adf-vendor.test.ts | 48 +- packages/cli/src/commands/adf-compile.ts | 8 +- packages/cli/src/commands/adf-migrate.ts | 69 +- packages/cli/src/commands/adf-vendor.ts | 220 ++++- packages/cli/src/commands/adf.ts | 4 +- packages/cli/src/commands/bootstrap.ts | 88 +- packages/cli/src/commands/doctor.ts | 240 ++++- packages/cli/src/commands/score.ts | 101 ++- 14 files changed, 1543 insertions(+), 136 deletions(-) create mode 100644 packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b50c94..4407e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,21 @@ The format is based on Keep a Changelog and follows Semantic Versioning. - **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 `, or `adf vendor set pointer && adf vendor restore ` for compiled output). With `--yes`, a hand-authored file is then migrated into `.ai/` by phase 4 as it would be without `--force`. 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. +- **`charter doctor` reads each vendor file's mode, and can now fail `--ci` and the pre-commit hook in repos that record modes** (`#318`, part 3) — a new `adf vendor mode` check compares the `VENDORS` record with the files on disk. It WARNs, and so fails `doctor --ci` and the pre-commit hook (`doctor --adf-only --ci`), when a recorded `pointer` or `compiled` file looks like another mode or is missing, when an entry is malformed, names an unknown file or a mode that file cannot have, or when `VENDORS` is not a list. Each line names the fix, the content-preserving one first. A repo without a `VENDORS` section gets one `INFO` line suggesting `charter adf vendor init` and no new WARN. JSON adds `rows` to that check only. `adf agent config` no longer warns about a hand-authored file recorded `unmanaged`, its PASS line counts thin pointers and compiled files separately (compiled output used to be called a thin pointer), and it, `adf vendor bloat` and `adf cold start` check each physical file once, so `AGENTS.md` and `agents.md` on a case-insensitive filesystem are no longer counted twice. Bloat and cold start look at pointers only. +- **`charter score` counts a thin pointer as substantive when `.ai/` is** (`#318`, part 3) — score numbers in JSON change, and only upward. A pointer `CLAUDE.md`, `.cursorrules` or `AGENTS.md` used to score as "thin" (−5, and "Expand `CLAUDE.md` beyond a thin pointer") however much its `.ai/` held. It is now substantive when its text is, or when the file `adf compile` would render for it is (compiled in memory; nothing is written). Hand-authored and compiled files are scored on their text as before, so no score goes down. `signals.agentConfig` adds `mode`, `source` and `substantiveVia` per file. Recommendations no longer tell you to edit a pointer or compiled `CLAUDE.md`; a pointer with little to route to gets "Add repo commands and guardrails to `.ai/core.adf` (or run: charter adf populate)". +- **`charter adf migrate` without `--source` skips a file recorded `unmanaged`** (`#318`, part 3) — naming the file with `--source` still migrates it, and now changes its entry to `pointer` before the pointer is written (back to `unmanaged` if the migration fails), so doctor reports no drift afterwards. +- **`adf compile --check` names `charter adf vendor restore ` instead of `git checkout -- `** (`#318`, part 3) — for a missing recorded pointer, and for a recorded pointer that now holds another mode's content. + ### Added +- **`charter adf vendor restore `** (`#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, or your own sections in a compiled file. 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. `--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 ` 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`. ### Fixed +- **`charter bootstrap --force` no longer destroys a hand-authored `CLAUDE.md` before it can be migrated** (`#318`, part 3) — phase 3 overwrote every existing pointer-file name with the template under `--force`, before phase 4 (migrate) ran. Vendor files are not backed up (only `.ai/*.adf` go to `.ai/.backup/`), so phase 4 then found a pointer, migrated nothing, and the file's rules were gone with no `.pre-adf-migrate.bak`. The file is now skipped in phase 3, and with `--yes` phase 4 migrates it into `.ai/` and writes the backup. - **`charter adf compile --check` and `charter adf tidy` read each vendor file's mode** (`#318`, part 2; `#302`) — `--check` compared every target byte-for-byte against compiled output, so a thin pointer was reported `[stale]` forever. Each target is now checked by its mode. A compiled file is `fresh` or `stale` as before. A pointer is `pointer-ok` when `adf tidy` would extract nothing from it, and `pointer-bloated` (exit 1, remedy `charter adf tidy --source `) when it would. An unmanaged file is `skipped-unmanaged` (exit 0). A recorded `compiled` or `pointer` file that looks like another mode on disk is `mode-drift` (exit 1), with both fixes printed: `charter adf vendor set ` to keep the file, or the command that restores it. A missing file is `missing`, and a recorded pointer that is missing names `git checkout -- ` or `adf vendor set unmanaged`. No remedy suggests `compile --write` for a pointer or unmanaged file. JSON keeps `stale` (which now also lists bloated and drifted files), `current`, `missing`, `upToDate` and `nextActions`, and adds `skipped` and `targets`. `adf tidy` processes a file only when it is a pointer: no compile banner, at least one pointer marker, and not recorded otherwise. The banner still wins whatever the record says (`#296`). A file recorded `compiled` is skipped with status `compiled`. Its warning lists the content-preserving fix first (`adf vendor set pointer`, then `adf tidy --source `) and says how many items regenerating would discard; `--check` mode-drift does the same. An `unmanaged` file (recorded, or hand-authored with no marker) now has status `unmanaged` instead of `not-found`, and its warning appears when it is recorded or named with `--source`. A recorded pointer with no marker is skipped rather than guessed at. File rows gain `vendorFile`, `mode`, `source` and `skipReason`. A `--source` path that is not a repo-root vendor name keeps the old marker logic. Tidy now finds files through the shared discovery helper, so `AGENTS.md` and `agents.md` that are one file on a case-insensitive filesystem are walked once, not twice. With no `SYNC` entries, `charter adf sync` now points to `charter adf vendor status` in text and in JSON `nextActions`, rather than ending at "Wrote empty lock file". Record entry problems are still reported only by `adf vendor status`. `doctor`, `score`, `bootstrap` and both pre-commit hooks are unchanged and are later parts of `#318`. - **`charter doctor` now names the fix when `core.adf` or `state.adf` is missing from `DEFAULT_LOAD`** (`#241`) — the `adf required wiring` warning said only `Missing from DEFAULT_LOAD: core.adf`. It now says to move the module from `ON_DEMAND` to `DEFAULT_LOAD` in `.ai/manifest.adf` when it is registered there, or to add it (or run `charter adf create --load default`) when it is not registered at all. The rule itself is unchanged and is filename-only — `core.adf` and `state.adf` are required in `DEFAULT_LOAD`, and module content is not consulted — but it now lives in one shared helper that `doctor` and `bootstrap`'s orphan registration both call, so `adf migrate` can adopt the same classification when it starts registering the modules it creates (`#239`). - **`charter validate --ci` no longer counts a whitespace-only trailer value** (`#340`) — the trailer parser in `@stackbilt/git` matched values with `\s*.+`, which backtracks to accept `Governed-By: `, so a commit the commit-msg gate from `#299` rejects still counted as governed in CI. Values must now contain a non-whitespace character, matching the hook, which sees the message after `git stripspace` has dropped trailing blanks. As in the hook, such a line also ends the terminal trailer block, so trailers above it are not counted either. Whitespace before or after a real value is still accepted and trimmed, as before. diff --git a/README.md b/README.md index 040abf3..a58483b 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ charter adf migrate # Migrate existing configs charter adf compile --target all --write # Re-render compiled-mode CLAUDE.md, AGENTS.md, .cursorrules, GEMINI.md charter adf compile --target all --write --mode compiled # Switch pointer files to compiled output charter adf vendor status # Show each vendor file's mode (pointer, compiled, unmanaged) +charter adf vendor restore CLAUDE.md # Rewrite one pointer from its template (refuses if content would be lost) charter adf patch --ops # Apply structured patch operations to an ADF file charter adf sync --check # Verify files match lock charter adf fmt .ai/core.adf --write # Reformat to canonical form diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2cc87fe..79b01cc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -92,8 +92,11 @@ npx charter bootstrap --skip-install --skip-doctor # minimal - `--security-sensitive` — generate `SECURITY.md`, seed hard-fail drift denies in `.charter/patterns/security-deny.json`, and warn in `doctor` when no `security*` or `l4*` test file exists - `--skip-install` — skip dependency installation phase - `--skip-doctor` — skip health check phase +- `--force` — re-scaffold `.ai/*.adf` (the old files are backed up to `.ai/.backup/`) and rewrite Charter thin pointers from the current template. It never overwrites a hand-authored vendor file (one with no Charter marker), compiled output (the `adf compile` banner, or a file recorded `compiled`), or a file recorded `unmanaged`: each is skipped with a warning naming the next step. Vendor files are not backed up, so a hand-authored file is left for phase 4 to migrate into `.ai/` (with `--yes`). Valid `VENDORS` entries are carried over to the re-scaffolded `manifest.adf`. - `-y, --yes` — accept all prompts +**Vendor modes.** Phase 3 decides each pointer file from its `VENDORS` entry (see [`charter adf vendor`](#charter-adf-vendor)) and its markers, first match wins: recorded `compiled` or a compile banner on disk → skipped (absent and recorded `compiled` → skipped, run `adf compile --write`); recorded `unmanaged` → left alone; absent → pointer written; no Charter marker → skipped as custom content, with or without `--force`; a Charter pointer → kept, or rewritten under `--force`. Phase 4 migrates only hand-authored files with no `VENDORS` entry; a file recorded `unmanaged` is not migrated, and the warning names `charter adf migrate --source ` for doing it anyway. + ### charter setup Bootstraps `.charter/` config and optionally writes CI workflow scaffolding. For full onboarding, prefer `charter bootstrap` which orchestrates setup + ADF init + install + doctor. @@ -156,6 +159,26 @@ are present; doctor names it and asks for a re-install. The local hook checks only the commit being written, so CI should still run `charter validate --ci`; the workflow generated by `charter setup --ci github` includes that step. +#### Vendor file modes + +The `adf vendor mode` check compares each vendor file's `VENDORS` entry (see [`charter adf vendor`](#charter-adf-vendor)) with the markers in the file. It runs under `--adf-only`, so the pre-commit hook runs it too. It is one check whose status is its worst row, and `--format json` adds a `rows` array to that check only: `kind`, `status`, `file`, `recordedMode`, `inferredMode`, `source`, `raw`, `remedy`. + +| Row | When | Status | +|---|---|---| +| `recorded-ok` | entry matches the file (a recorded `unmanaged` file always matches) | PASS | +| `recorded-unmanaged-absent` | recorded `unmanaged`, no file | PASS | +| `inferred` | file with no entry; one line for all of them, naming `charter adf vendor init` | INFO | +| `recorded-drift` | recorded `pointer` or `compiled`, but the file looks like another mode. The content-preserving fix (`adf vendor set `) is named first; for a recorded `compiled` file the regenerate command says what it would discard | WARN | +| `recorded-absent` | recorded `pointer` or `compiled`, no file. Recreate with `adf vendor restore ` or `adf compile --target --write` | WARN | +| `entry-problem` | a malformed, unknown-file or not-allowed entry (WARN); a duplicate entry, where the last one wins (INFO) | WARN / INFO | +| `alias-recorded` | both `AGENTS.md` and `agents.md` recorded for one file | INFO | +| `legacy-spelling` | the file `AGENTS.md` recorded under the legacy name `agents.md` | INFO | +| `section-not-list` | `VENDORS` does not read as a list | WARN | + +A repo without a `VENDORS` section gets only the `inferred` INFO line, so upgrading never fails `doctor --ci`. A WARN appears only where an explicit record is contradicted or ignored. Doctor never writes the record or a vendor file. + +The `adf agent config`, `adf vendor bloat` and `adf cold start` checks read the record too. Each physical file is checked once (`AGENTS.md` and `agents.md` on a case-insensitive filesystem are one file). A hand-authored file recorded `unmanaged` no longer warns that it "contains stack rules", and the PASS line counts thin pointers and compiled files separately. Bloat and cold start look only at pointers: a file in pointer mode that looks like a pointer on disk. + #### Tracked telemetry Charter appends to `/telemetry/events.ndjson` on every run. In a @@ -236,6 +259,8 @@ npx charter score --badge --write # also write .charter/badge.json - `--badge` — print shields.io endpoint JSON instead of the full report - `--badge --write` — additionally write the badge JSON to `.charter/badge.json` +**Agent config and vendor modes.** `CLAUDE.md`, `.cursorrules` and `AGENTS.md` (or `agents.md`) score higher when they are substantive. A hand-authored or compiled file is judged on its own text. A thin pointer is as substantive as the compiled file it routes to: score compiles that target from `.ai/` in memory (nothing is written) and counts the pointer as substantive when either its text or that output is. Scores can therefore only rise compared with earlier versions. `--format json` adds `mode`, `source` and `substantiveVia` (`file`, `ai-dir` or `none`) to each of these files under `signals.agentConfig`. Recommendations never tell you to hand-edit a pointer or a compiled `CLAUDE.md`; when one has little to route to, the recommendation is to add content to `/core.adf` or run `charter adf populate`. The `VENDORS` section is ignored when checking manifest path references. + **Grounding and path references.** The grounding dimension counts how many file paths referenced in your docs actually resolve. A reference that does not exist at its literal path is retried by filename: if exactly one file in the repository has that basename *and* the referenced path's @@ -366,6 +391,8 @@ npx charter adf migrate --no-backup # skip .pre-adf-migrate.bak fil - `--no-backup` — skip creating backup files - `--ai-dir ` — path to ADF directory (default: `.ai`) +Without `--source`, a file recorded `unmanaged` in `VENDORS` is skipped with a note. Naming it with `--source` migrates it and changes its entry to `pointer` before the pointer is written; if the migration then fails, the entry goes back to `unmanaged`. + Every module that receives content is registered in `manifest.adf`, after the module merge and before the pointer is written, so the `CLAUDE.md` module index lists it. `core.adf` and `state.adf` go to `DEFAULT_LOAD`. Other modules go to `ON_DEMAND`, with up to 6 triggers inferred from the headings routed there. The heading word the router matched (e.g. `deployment`) counts double, and words that already trigger another module are left out. When nothing usable is left, the module's lowercase file stem is the trigger. Registration is append-only: an existing entry is never rewritten or moved, and its triggers are never merged. New bullets are inserted as text after the section's last item. Every other byte of the manifest is left as it was, including comments, section order and line endings. Already-registered modules are reported with their existing section and triggers. When `core.adf` is listed under `ON_DEMAND`, migrate warns instead of moving it. When `manifest.adf` is missing, registration is skipped with a warning to run `charter adf init`. The plan shows one `REGISTER` line per module, for example `REGISTER .ai/manifest.adf (infra.adf → ON_DEMAND: deployment, docker [planned])`, and `--format json` adds `sources[].registrations[]` with `module`, `loadPolicy`, `triggers`, `triggerSources`, `reason`, `status` (`planned`, `added`, `already-registered` or `skipped`) and, when skipped, `skipReason`. A `--dry-run` across several sources reports each module once. It does not simulate an earlier source's new triggers when routing a later source, though a real run does. ### charter adf fmt @@ -509,7 +536,7 @@ When every target is skipped, compile says that nothing was written and how to s | Status | When | Exit | |---|---|---| | `fresh` / `stale` | compiled: bytes equal / differ (remedy: `compile --target --write`) | 0 / 1 | -| `missing` | no file on disk (a recorded pointer suggests `git checkout -- ` or `adf vendor set unmanaged`) | 1 | +| `missing` | no file on disk (a recorded pointer suggests `adf vendor restore ` or `adf vendor set unmanaged`) | 1 | | `pointer-ok` | pointer with nothing `adf tidy` would extract | 0 | | `pointer-bloated` | pointer carrying content that belongs in `.ai/` (remedy: `adf tidy --source `) | 1 | | `mode-drift` | recorded `compiled` or `pointer`, but the file looks like another mode. Both fixes are printed, the content-preserving one first; regenerating says how many items it would discard | 1 | @@ -606,6 +633,7 @@ npx charter adf vendor status --all --format json # include absent names npx charter adf vendor set CLAUDE.md compiled # record one file's mode npx charter adf vendor set CLAUDE.md pointer --dry-run npx charter adf vendor init # record inferred modes for unrecorded files +npx charter adf vendor restore CLAUDE.md # rewrite one pointer from its template ``` ``` @@ -617,15 +645,16 @@ npx charter adf vendor init # record inferred modes for - `status`: read-only. A file with a valid entry shows its recorded mode, plus `(recorded; file looks compiled)` when the markers on disk disagree. A file without one shows the mode inferred from its markers, the way `adf tidy` classifies it: the compile banner means `compiled`, a pointer marker means `pointer`, and anything else is `unmanaged`. Malformed, duplicate (the last entry wins), unknown-file and not-allowed entries are reported and ignored. A missing manifest exits 0 with a warning. - `set `: records or replaces one entry, editing only that line. Only `CLAUDE.md`, `AGENTS.md`, `.cursorrules` and `GEMINI.md` may be `compiled`. `agents.md` and `copilot-instructions.md` have no compile target, so `compiled` is refused with exit 1. An unknown file or mode, or a missing manifest, exits 2. The file does not have to exist yet. - `init`: records the inferred mode of every vendor file on disk that has no entry. It never changes an existing entry, and a second run writes nothing. +- `restore `: rewrites one file as its thin pointer template (`CLAUDE.md` gets the hybrid template with the module index filled in, as bootstrap writes it). It runs only when the file's mode is `pointer` (recorded or inferred), or the file is absent with no entry, and it refuses (exit 1, file untouched) whenever content would be lost: `mode-not-pointer` (record the intent first with `vendor set pointer`), `hand-authored` (run `adf migrate --source `), `pointer-bloated` (run `adf tidy --source `), and `retained-divergent` (a compiled file carrying your own `## Environment` or protocol section). Retained sections of a pointer are carried over verbatim and listed. It is the fix for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded before the file is written; if that cannot be recorded, nothing is written (`record-write`), and if the file write fails, the manifest is put back byte for byte. A result identical to the file on disk is `unchanged` and writes nothing. `--dry-run` reports `planned`. An unknown file name exits 2. JSON output: `file`, `status` (`restored`, `planned`, `unchanged`, `refused`), `refusal`, `keptSections`, `remedy`; text lines go to stderr under `--format json`. One physical file has one entry. When two vendor names are the same file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link), every writer records it under the spelling already recorded, or `AGENTS.md` when neither is. `status` shows it once. `set` names the spelling it used. -- `--dry-run`: with `set` or `init`, report the change without writing +- `--dry-run`: with `set`, `init` or `restore`, report the change without writing - `--all`: with `status`, also list vendor names that are absent and unrecorded - `--ai-dir `: custom `.ai/` directory (default: `.ai`) `bootstrap` and `adf init --emit-pointers` record `pointer` for each pointer file they write. `adf migrate` records `pointer` for each source it replaces with a pointer, and `adf compile --write` records `compiled` for each target it writes (with `--mode compiled`, replacing the entry). The other writers only add missing entries. If a file is already recorded with another mode, the entry is kept and a warning names `adf vendor set` as the way to change it. Nothing is recorded under `--dry-run`, and a failed record write is only a warning, except under `adf compile --write --mode compiled`, where the target is refused (`refused-record`) and its file is not written. Every manifest edit is text-level and keeps line endings and every other byte, and the block it writes is already in `adf fmt` canonical form. It is re-parsed before writing, and if the result would change anything else, nothing is written. -`adf compile` (`--check` and `--write`) and `adf tidy` act on the record; see their sections. `doctor` and the pre-commit hook still decide from the markers in each file, and `score` skips the section when it checks manifest path references. Later changes under [#318](https://github.com/Stackbilt-dev/charter/issues/318) make them read it. +`adf compile` (`--check` and `--write`), `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record; see their sections. The pre-commit hook still decides from the markers in each file; a later change under [#318](https://github.com/Stackbilt-dev/charter/issues/318) makes it read the record. `score` skips the section when it checks manifest path references. ### charter blast @@ -903,7 +932,7 @@ npx charter surface --schema db/schema.sql # explicit schema path | `--preset ` | Stack preset (`worker`, `frontend`, `backend`, `fullstack`, `docs`) | | `--detect-only` | Setup mode: detect stack/preset and exit | | `--no-dependency-sync` | Setup mode: do not rewrite `@stackbilt/cli` devDependency | -| `--force` | Overwrite existing files (hooks, ADF modules, config) | +| `--force` | Overwrite existing files (hooks, ADF modules, config). `bootstrap --force` never overwrites a hand-authored or compiled vendor file | ## Exit Codes diff --git a/packages/cli/README.md b/packages/cli/README.md index babf14c..a6a4d28 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,6 +80,7 @@ charter bootstrap # detect stack, create .ai/, migrate existing agent rules charter adf compile --target all --write # re-render compiled-mode vendor files from .ai/ charter adf compile --target all --write --mode compiled # switch pointer files to compiled output charter adf vendor status # show each vendor file's mode (pointer, compiled, unmanaged) +charter adf vendor restore CLAUDE.md # rewrite one pointer from its template (refuses if content would be lost) charter # quick value/risk snapshot + next action charter why # why teams adopt Charter and expected payoff charter setup # bootstrap .charter/ directory + policy baseline @@ -220,6 +221,7 @@ Scaffold `.charter/` config templates only. Supports `--preset worker|frontend|b Validate CLI installation, `.charter/` config, governance gate wiring, and ADF readiness (manifest existence, module parseability, sync lock status). Strict trailer configuration warns when no active hook or GitHub Actions workflow enforces trailers: a hook or workflow that invokes `charter validate --ci`, or the commit-msg trailer gate installed by `charter hook install --commit-msg`. A commit-msg hook installed before the gate existed only normalizes trailers that are already present; re-run the install to replace it. Use `charter doctor --adf-only --ci` for strict ADF wiring gates in automation. +The `adf vendor mode` check compares each vendor file's `VENDORS` entry in `manifest.adf` with the file on disk. Files with no entry get one `INFO` line (record them with `charter adf vendor init`), so upgrading never fails `--ci`. A recorded mode the file contradicts, a recorded file that is missing, or a malformed or unknown entry is a `WARN`, which fails `--ci` and the pre-commit hook; each line names the fix. A hand-authored file recorded `unmanaged` no longer triggers the `adf agent config` warning. ### `charter validate` @@ -321,6 +323,7 @@ charter adf migrate [--dry-run] [--source ] [--no-backup] charter adf vendor status [--all] [--ai-dir ] charter adf vendor set [--dry-run] [--ai-dir ] charter adf vendor init [--dry-run] [--ai-dir ] +charter adf vendor restore [--dry-run] [--ai-dir ] ``` - `init`: Scaffold `.ai/` with `manifest.adf`, `core.adf`, `state.adf`, and starter `frontend.adf`/`backend.adf` on-demand module stubs. Core module includes a 500-line LOC guardrail metric by default. @@ -334,7 +337,7 @@ charter adf vendor init [--dry-run] [--ai-dir ] - `evidence`: Validate all metric ceilings in the merged document and produce a structured pass/fail evidence report. `--auto-measure` counts lines in files referenced by the manifest METRICS section. `--context` or `--context-file` inject external metric overrides that take precedence over auto-measured and document values. In `--ci` mode, exits 1 on constraint failures (warnings don't fail). Also reports stale-baseline warnings (baseline vs current delta + recommended ceiling) when baseline values drift significantly. The governance workflow template runs this automatically on PRs when `.ai/manifest.adf` is present. - `metrics recalibrate`: Re-measure current LOC from manifest metric sources, propose and apply new ceilings using configurable headroom, and append rationale records to `BUDGET_RATIONALES`. Requires explicit rationale (`--reason`) unless `--auto-rationale` is used. - `migrate`: Scan existing agent config files (CLAUDE.md, .cursorrules, agents.md, GEMINI.md, copilot-instructions.md), classify content using the ADX-002 decision tree, and migrate into ADF modules. `--dry-run` previews the migration plan without writing files. `--source ` targets a single file. `--no-backup` skips `.pre-adf-migrate.bak` creation. `--merge-strategy` controls deduplication: `dedupe` (default, skip items already in ADF), `append` (always add), or `replace`. Environment-specific rules (WSL, PATH, credential helpers) are retained in the thin pointer. Each module that receives content is registered in `manifest.adf`: `core.adf` and `state.adf` under `DEFAULT_LOAD`, the rest under `ON_DEMAND` with triggers inferred from the migrated headings. Registration is append-only, so existing entries are never rewritten. The `CLAUDE.md` module index is refreshed to match, and the plan shows a `REGISTER` line per module. -- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry. No command acts on the record yet (`#318`). +- `vendor`: Show or record whether each vendor file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `GEMINI.md`, `agents.md`, `copilot-instructions.md`) is a thin `pointer`, `compiled` output, or `unmanaged`. The record is a `VENDORS` list in `manifest.adf` (` - CLAUDE.md: pointer`). `status` is read-only and shows each file's recorded mode, or the mode inferred from its markers when none is recorded. `set` records one file's mode; only the four compile targets may be `compiled`. `init` records the inferred mode of every vendor file on disk that has no entry yet, and never changes an existing one. `bootstrap`, `adf init --emit-pointers`, `adf migrate` and `adf compile --write` also record the mode of each file they write, without changing an existing entry (an explicit `adf migrate --source ` changes `unmanaged` to `pointer`). `restore ` rewrites one pointer from its template, keeping retained sections such as `## Environment`, and refuses whenever content would be lost (hand-authored content, bloat, or your own sections in a compiled file). `adf compile`, `adf tidy`, `doctor`, `score`, `bootstrap` and `adf migrate` act on the record: for example, `bootstrap --force` never replaces a compiled or hand-authored vendor file, and bare `adf migrate` skips a file recorded `unmanaged` (`#318`). ### `charter blast` diff --git a/packages/cli/src/__tests__/adf-vendor-consumers.test.ts b/packages/cli/src/__tests__/adf-vendor-consumers.test.ts index 67b4c2d..56230e6 100644 --- a/packages/cli/src/__tests__/adf-vendor-consumers.test.ts +++ b/packages/cli/src/__tests__/adf-vendor-consumers.test.ts @@ -179,7 +179,7 @@ describe('compile --check is mode-aware', () => { const r = await run(['compile', '--target', 'all', '--check']); expect(r.code).toBe(1); expect(r.out).toMatch(/ {2}\[bloated\] CLAUDE\.md — thin pointer carries \d+ item\(s\) that belong in \.ai\/\.\n {10}Run: charter adf tidy --source CLAUDE\.md/); - expect(r.out).toContain(' [missing] AGENTS.md — recorded as a pointer but not found on disk.\n Restore it (git checkout -- AGENTS.md), or stop tracking it: charter adf vendor set AGENTS.md unmanaged'); + expect(r.out).toContain(' [missing] AGENTS.md — recorded as a pointer but not found on disk.\n Restore it (charter adf vendor restore AGENTS.md), or stop tracking it: charter adf vendor set AGENTS.md unmanaged'); expect(r.out).toContain(' [skip] .cursorrules is unmanaged (inferred); not checked'); expect(r.out).toContain(' [drift] GEMINI.md — recorded as compiled but looks pointer on disk.\n Keep the file as it is: charter adf vendor set GEMINI.md pointer\n Or make it match the record: charter adf compile --target gemini --write'); }); @@ -199,13 +199,13 @@ describe('compile --check is mode-aware', () => { expect(j.json.nextActions).toEqual([]); }); - it('a recorded pointer carrying the compile banner is mode-drift, restored by git checkout', async () => { + it('a recorded pointer carrying the compile banner is mode-drift, restored by adf vendor restore', async () => { scaffold(vendors('CLAUDE.md: pointer')); fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); const r = await runJson(['compile', '--target', 'claude', '--check']); expect(r.code).toBe(1); expect(r.json.targets[0]).toMatchObject({ status: 'mode-drift', mode: 'pointer', remedy: 'charter adf vendor set CLAUDE.md compiled' }); - expect(r.json.nextActions).toEqual(['charter adf vendor set CLAUDE.md compiled', 'git checkout -- CLAUDE.md']); + expect(r.json.nextActions).toEqual(['charter adf vendor set CLAUDE.md compiled', 'charter adf vendor restore CLAUDE.md']); }); it('recorded compiled that now looks hand-authored: the restore remedy needs --force', async () => { diff --git a/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts new file mode 100644 index 0000000..ff1b6c8 --- /dev/null +++ b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts @@ -0,0 +1,844 @@ +/** + * doctor, score and bootstrap read the vendor-mode record (#318, PR 3 of 6). + * + * Pins the PR3 contract: doctor's `adf vendor mode` check (one row per file, + * worst-row status, `rows` in JSON), the mode-aware agent config / bloat / cold + * start checks, score's compile-parity substantive rule, bootstrap's pointer + * decision table and migrate filter, `adf migrate`'s recorded-unmanaged rules, + * and `adf vendor restore`. Each INV-P3-n has at least one test that fails if + * the invariant breaks. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { COMPILE_BANNER_MARKER } from '@stackbilt/adf'; +import type { CLIOptions } from '../index'; +import { CLIError } from '../index'; +import { + adfCommand, + POINTER_AGENTS_MD, + POINTER_CLAUDE_MD, + POINTER_CLAUDE_MD_HYBRID, + POINTER_CURSORRULES, + POINTER_GEMINI_MD, +} from '../commands/adf'; +import { doctorCommand, DOCTOR_MSG, ROW_STATUS } from '../commands/doctor'; +import type { VendorModeRow } from '../commands/doctor'; +import { scoreCommand, SCORE_POPULATE_AI_DIR } from '../commands/score'; +import { bootstrapCommand, pointerAction } from '../commands/bootstrap'; +import { restoredPointer } from '../commands/adf-vendor'; + +// doctor's `git repository` check must not depend on the host temp dir. +vi.mock('../git-helpers', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, isGitRepo: () => true }; +}); + +const TEXT: CLIOptions = { configPath: '.charter', format: 'text', ciMode: false, yes: false }; +const JSON_OPTS: CLIOptions = { ...TEXT, format: 'json' }; + +const BASE_MANIFEST = [ + 'ADF: 0.1', + '', + '\u{1F3AF} ROLE: Test project', + '', + '\u{1F4E6} DEFAULT_LOAD:', + ' - core.adf', + ' - state.adf', + '', +].join('\n'); + +const CORE_ADF = 'ADF: 0.1\n\n\u{1F4CB} CONTEXT:\n - TypeScript monorepo built with pnpm workspaces\n - Deployed to Cloudflare Workers\n\n⚠️ CONSTRAINTS [load-bearing]:\n - No secrets in commits\n - Run pnpm test before every push\n'; +const STATE_ADF = 'ADF: 0.1\n\n\u{1F9E0} STATE:\n CURRENT: Active development\n'; + +const BLOATED_CLAUDE = `${POINTER_CLAUDE_MD}\n## Testing\n\n- Always run pnpm test before pushing to main.\n- Never skip the integration suite in CI.\n`; +const HAND_WRITTEN = '# My CLAUDE.md\n\n## Rules\n\n- Always use pnpm for installs.\n- Never commit .env files.\n'; +/** A pointer whose own text is thin: only a marker and a title. */ +const THIN_CLAUDE = '# CLAUDE.md\n\nDo not duplicate rules from .ai/\n'; + +const originalCwd = process.cwd(); +let tmp = ''; +let out: string[] = []; +let err: string[] = []; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-vendor-pr3-')); + process.chdir(tmp); + out = []; + err = []; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.map(String).join(' ')); }); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.map(String).join(' ')); }); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function vendors(...entries: string[]): string { + return `${BASE_MANIFEST}\n\u{1F3F7} VENDORS:\n${entries.map(e => ` - ${e}\n`).join('')}`; +} + +function scaffold(manifest = BASE_MANIFEST, core = CORE_ADF): void { + fs.mkdirSync('.ai', { recursive: true }); + fs.writeFileSync('.ai/manifest.adf', manifest); + fs.writeFileSync('.ai/core.adf', core); + fs.writeFileSync('.ai/state.adf', STATE_ADF); +} + +const manifestText = (): string => fs.readFileSync('.ai/manifest.adf', 'utf-8'); +const read = (file: string): string => fs.readFileSync(file, 'utf-8'); + +/** Every file under .ai/ and every vendor file at the root, to prove a command wrote nothing. */ +function snapshot(): Record { + const files = [ + ...fs.readdirSync('.ai').map(f => path.join('.ai', f)), + ...['CLAUDE.md', 'AGENTS.md', 'agents.md', '.cursorrules', 'GEMINI.md', 'copilot-instructions.md'].filter(f => fs.existsSync(f)), + ].sort(); + return Object.fromEntries(files.map(f => [f, read(f)])); +} + +function aliasedAgents(content: string): void { + fs.writeFileSync('agents.md', content); + // A hard link is one physical file on every filesystem, so this is the same + // fixture whether or not the host is case-insensitive. + if (!fs.existsSync('AGENTS.md')) fs.linkSync('agents.md', 'AGENTS.md'); +} + +async function run(args: string[], options: CLIOptions = TEXT): Promise<{ code: number; out: string; err: string }> { + out = []; + err = []; + const code = await adfCommand(options, args); + return { code, out: out.join('\n'), err: err.join('\n') }; +} + +async function compiledOutput(target: string): Promise { + return (await run(['compile', '--target', target])).out; +} + +interface Check { name: string; status: string; details: string; rows?: VendorModeRow[] } +interface DoctorJson { status: string; checks: Check[] } + +async function doctor(ci = false): Promise<{ code: number; json: DoctorJson; text: string }> { + out = []; + const code = await doctorCommand({ ...JSON_OPTS, ciMode: ci }, ['--adf-only']); + const json = JSON.parse(out.join('\n')) as DoctorJson; + out = []; + await doctorCommand(TEXT, ['--adf-only']); + return { code, json, text: out.join('\n') }; +} + +const check = (j: DoctorJson, name: string): Check | undefined => j.checks.find(c => c.name === name); +const vendorCheck = (j: DoctorJson): Check => { + const c = check(j, 'adf vendor mode'); + if (!c) throw new Error('no adf vendor mode check'); + return c; +}; + +// ============================================================================ +// 1. doctor: adf vendor mode +// ============================================================================ + +describe('doctor: adf vendor mode rows', () => { + it('INV-P3-2: no VENDORS section gives one INFO line and no new WARN; --ci still exits 0', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + fs.writeFileSync('GEMINI.md', `\nold\n`); + const r = await doctor(true); + expect(r.code).toBe(0); + const c = vendorCheck(r.json); + expect(c.status).toBe('INFO'); + expect(c.rows?.map(x => [x.kind, x.file, x.status])).toEqual([ + ['inferred', 'CLAUDE.md', 'INFO'], ['inferred', 'AGENTS.md', 'INFO'], ['inferred', 'GEMINI.md', 'INFO'], + ]); + expect(c.details).toBe('3 vendor file(s) have no recorded mode (CLAUDE.md, AGENTS.md, GEMINI.md); modes are inferred from file content. Record them with: charter adf vendor init'); + expect(r.json.checks.filter(x => x.status === 'WARN')).toEqual([]); + // Text mode prints it as an [info] line. + expect(r.text).toContain(' [info] adf vendor mode: 3 vendor file(s) have no recorded mode'); + }); + + it('INV-P3-2: every other check keeps its status for a no-VENDORS repo', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + fs.writeFileSync('AGENTS.md', `${POINTER_AGENTS_MD}\n${Array.from({ length: 90 }, (_, i) => `line ${i}`).join('\n')}\n`); + fs.writeFileSync('GEMINI.md', `\nold\n`); + const r = await doctor(); + const statuses = Object.fromEntries(r.json.checks.filter(c => c.name !== 'adf vendor mode').map(c => [c.name, c.status])); + // The statuses main @ 9ce61b4 gives for these files. + expect(statuses).toMatchObject({ + 'adf agent config': 'WARN', + 'adf vendor bloat': 'WARN', + 'adf manifest parse': 'PASS', + }); + expect(check(r.json, 'adf agent config')?.details).toBe('CLAUDE.md contains stack rules that should live in .ai/. Run: charter adf migrate --dry-run'); + expect(check(r.json, 'adf vendor bloat')?.details).toContain('AGENTS.md: '); + expect(vendorCheck(r.json).status).toBe('INFO'); + }); + + it('recorded-ok: every row PASS gives the all-recorded line and a PASS check', async () => { + scaffold(vendors('CLAUDE.md: pointer', 'AGENTS.md: unmanaged', 'GEMINI.md: compiled')); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + fs.writeFileSync('AGENTS.md', '# My agents\n'); + fs.writeFileSync('GEMINI.md', await compiledOutput('gemini')); + const r = await doctor(true); + expect(r.code).toBe(0); + const c = vendorCheck(r.json); + expect(c.status).toBe('PASS'); + expect(c.rows?.every(x => x.kind === 'recorded-ok' && x.status === 'PASS')).toBe(true); + expect(c.details).toBe('All 3 vendor file(s) have a recorded mode that matches the file on disk.'); + }); + + it('recorded-drift (pointer recorded, compiled on disk) WARNs, fails --ci, and the restore remedy clears it', async () => { + scaffold(vendors('CLAUDE.md: pointer')); + fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); + const before = await doctor(true); + expect(before.code).toBe(1); + const c = vendorCheck(before.json); + expect(c.status).toBe('WARN'); + expect(c.rows?.[0]).toEqual({ + kind: 'recorded-drift', status: 'WARN', file: 'CLAUDE.md', recordedMode: 'pointer', inferredMode: 'compiled', + source: 'recorded', raw: null, remedy: 'charter adf vendor set CLAUDE.md compiled', + }); + expect(c.details).toBe('CLAUDE.md is recorded as a pointer but looks compiled on disk. Accept the file: charter adf vendor set CLAUDE.md compiled (or rewrite the pointer: charter adf vendor restore CLAUDE.md)'); + + expect((await run(['vendor', 'restore', 'CLAUDE.md'])).code).toBe(0); + const after = await doctor(true); + expect(after.code).toBe(0); + expect(vendorCheck(after.json)).toMatchObject({ status: 'PASS' }); + }); + + it('recorded-drift (compiled recorded): the content-preserving fix comes first and the loss is named', async () => { + scaffold(vendors('CLAUDE.md: compiled', 'AGENTS.md: compiled')); + fs.writeFileSync('CLAUDE.md', BLOATED_CLAUDE); + fs.writeFileSync('AGENTS.md', '# Agents I wrote\n'); + const c = vendorCheck((await doctor()).json); + const lines = c.details.split('\n '); + expect(lines[0]).toMatch(/^CLAUDE\.md is recorded as compiled but looks pointer on disk\. Accept the file: charter adf vendor set CLAUDE\.md pointer {2}\(or regenerate it: charter adf compile --target claude --write; discards \d+ item\(s\) not in \.ai\/\)$/); + expect(lines[1]).toBe('AGENTS.md is recorded as compiled but looks unmanaged on disk. Accept the file: charter adf vendor set AGENTS.md unmanaged (or regenerate it: charter adf compile --target agents --write --force; discards its hand-authored content)'); + expect(c.rows?.map(x => x.remedy)).toEqual(['charter adf vendor set CLAUDE.md pointer', 'charter adf vendor set AGENTS.md unmanaged']); + }); + + it('recorded-absent WARNs with the mode-specific recreate command; recorded-unmanaged-absent is PASS', async () => { + scaffold(vendors('CLAUDE.md: pointer', 'GEMINI.md: compiled', 'AGENTS.md: unmanaged')); + const r = await doctor(true); + expect(r.code).toBe(1); + const c = vendorCheck(r.json); + expect(c.rows?.map(x => [x.kind, x.status, x.inferredMode])).toEqual([ + ['recorded-absent', 'WARN', null], ['recorded-unmanaged-absent', 'PASS', null], ['recorded-absent', 'WARN', null], + ]); + expect(c.details.split('\n ')).toEqual([ + 'CLAUDE.md is recorded as pointer but is not on disk. Recreate it: charter adf vendor restore CLAUDE.md (or stop tracking it: charter adf vendor set CLAUDE.md unmanaged)', + 'GEMINI.md is recorded as compiled but is not on disk. Recreate it: charter adf compile --target gemini --write (or stop tracking it: charter adf vendor set GEMINI.md unmanaged)', + ]); + // Both recreate commands work. + expect((await run(['vendor', 'restore', 'CLAUDE.md'])).code).toBe(0); + expect((await run(['compile', '--target', 'gemini', '--write'])).code).toBe(0); + expect(vendorCheck((await doctor()).json).status).toBe('PASS'); + }); + + it('entry problems: malformed, unknown-file and mode-not-allowed WARN; a duplicate is INFO', async () => { + scaffold(vendors('CLAUDE.md pointer', 'README.md: pointer', 'copilot-instructions.md: compiled', 'AGENTS.md: pointer', 'AGENTS.md: unmanaged')); + const manifestPath = path.join('.ai', 'manifest.adf'); + const c = vendorCheck((await doctor()).json); + const problems = c.rows?.filter(x => x.kind === 'entry-problem') ?? []; + expect(problems.map(x => [x.raw, x.status, x.file])).toEqual([ + ['CLAUDE.md pointer', 'WARN', 'CLAUDE.md'], + ['README.md: pointer', 'WARN', null], + ['copilot-instructions.md: compiled', 'WARN', 'copilot-instructions.md'], + ['AGENTS.md: pointer', 'INFO', 'AGENTS.md'], + ]); + expect(c.status).toBe('WARN'); + expect(c.details).toContain(`VENDORS entry 'README.md: pointer' in ${manifestPath} is ignored (unknown-file). Fix or delete that line in ${manifestPath}.`); + expect(c.details).toContain(`VENDORS lists AGENTS.md more than once in ${manifestPath}; the last entry ('AGENTS.md: pointer') wins. Delete the others.`); + }); + + it('a duplicate alone is INFO, so --ci passes (worst-row aggregation)', async () => { + scaffold(vendors('CLAUDE.md: pointer', 'CLAUDE.md: pointer')); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + const r = await doctor(true); + expect(r.code).toBe(0); + expect(vendorCheck(r.json).status).toBe('INFO'); + expect(vendorCheck(r.json).rows?.map(x => x.status)).toEqual(['PASS', 'INFO']); + }); + + it('alias-recorded and legacy-spelling are INFO rows', async () => { + scaffold(vendors('AGENTS.md: pointer', 'agents.md: pointer')); + aliasedAgents(POINTER_AGENTS_MD); + let c = vendorCheck((await doctor(true)).json); + expect(c.rows?.map(x => [x.kind, x.file, x.status])).toEqual([['recorded-ok', 'AGENTS.md', 'PASS'], ['alias-recorded', 'agents.md', 'INFO']]); + expect(c.details).toBe('VENDORS records both AGENTS.md and agents.md, which are the same file on disk; AGENTS.md is used. Delete the agents.md line.'); + + fs.writeFileSync('.ai/manifest.adf', vendors('agents.md: pointer')); + c = vendorCheck((await doctor(true)).json); + expect(c.rows?.map(x => [x.kind, x.file, x.status])).toEqual([['recorded-ok', 'agents.md', 'PASS'], ['legacy-spelling', 'agents.md', 'INFO']]); + expect(c.details).toBe("VENDORS records the legacy name agents.md for the file AGENTS.md; `adf compile --target agents --mode compiled` cannot switch it. Change the line to ' - AGENTS.md: pointer' (an automatic rename comes with #302)."); + }); + + it('section-not-list WARNs once', async () => { + scaffold(`${BASE_MANIFEST}\n\u{1F3F7} VENDORS: not a list\n`); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + const r = await doctor(true); + expect(r.code).toBe(1); + const c = vendorCheck(r.json); + expect(c.rows?.map(x => x.kind)).toEqual(['section-not-list']); + expect(c.details).toBe(`VENDORS in ${path.join('.ai', 'manifest.adf')} does not read as a list, so every vendor mode is inferred. Every line under it must look like " - CLAUDE.md: pointer".`); + }); + + it('INV-P3-3: inferred modes never WARN, whatever the files look like', () => { + expect(ROW_STATUS.inferred).toBe('INFO'); + const warnKinds = Object.entries(ROW_STATUS).filter(([, s]) => s === 'WARN').map(([k]) => k).sort(); + expect(warnKinds).toEqual(['entry-problem', 'recorded-absent', 'recorded-drift', 'section-not-list']); + }); + + it('INV-P3-3: a mixed no-record repo (hand-authored, bloated, compiled) has an INFO vendor mode check', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + fs.writeFileSync('AGENTS.md', `${POINTER_AGENTS_MD}\n## Testing\n\n- Always run pnpm test.\n`); + fs.writeFileSync('.cursorrules', `\n`); + expect(vendorCheck((await doctor()).json).status).toBe('INFO'); + }); + + it('INV-P3-12: only the adf vendor mode check carries rows, with exactly the row schema keys', async () => { + scaffold(vendors('CLAUDE.md: pointer')); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + const { json } = await doctor(); + for (const c of json.checks) { + expect(Object.keys(c)).toEqual(c.name === 'adf vendor mode' ? ['name', 'status', 'details', 'rows'] : ['name', 'status', 'details']); + } + expect(Object.keys(vendorCheck(json).rows?.[0] ?? {})).toEqual(['kind', 'status', 'file', 'recordedMode', 'inferredMode', 'source', 'raw', 'remedy']); + }); + + it('is not emitted when the manifest is missing, and adds nothing with no vendor files', async () => { + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + expect(check((await doctor()).json, 'adf vendor mode')).toBeUndefined(); + scaffold(); + fs.rmSync('CLAUDE.md'); + expect(check((await doctor()).json, 'adf vendor mode')).toBeUndefined(); + }); + + it('INV-P3-1: doctor writes neither the manifest nor any vendor file', async () => { + scaffold(vendors('CLAUDE.md: pointer', 'AGENTS.md: compiled', 'README.md: x')); + fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); + fs.writeFileSync('GEMINI.md', HAND_WRITTEN); + aliasedAgents(POINTER_AGENTS_MD); + const before = snapshot(); + await doctor(true); + await doctorCommand(TEXT, []); + expect(snapshot()).toEqual(before); + }); +}); + +// ============================================================================ +// 2. doctor: mode-aware agent config, bloat and cold start +// ============================================================================ + +describe('doctor: agent config, bloat and cold start read the record', () => { + it('a recorded-unmanaged hand-authored file drops the agent-config WARN', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + expect(check((await doctor()).json, 'adf agent config')?.status).toBe('WARN'); + fs.writeFileSync('.ai/manifest.adf', vendors('CLAUDE.md: unmanaged')); + const c = check((await doctor()).json, 'adf agent config'); + expect(c).toEqual({ + name: 'adf agent config', status: 'PASS', + details: '0 thin pointer(s) and 0 compiled file(s) route to .ai/; 1 file(s) recorded unmanaged are left alone.', + }); + }); + + it('PASS wording counts pointers and compiled files separately', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); + fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); + fs.writeFileSync('GEMINI.md', await compiledOutput('gemini')); + expect(check((await doctor()).json, 'adf agent config')?.details).toBe('2 thin pointer(s) and 1 compiled file(s) route to .ai/.'); + }); + + it('walks AGENTS.md and agents.md once when they are one file', async () => { + scaffold(); + aliasedAgents(`${POINTER_AGENTS_MD}\n${Array.from({ length: 90 }, (_, i) => `line ${i}`).join('\n')}\n`); + const { json } = await doctor(); + expect(check(json, 'adf agent config')?.details).toBe('1 thin pointer(s) and 0 compiled file(s) route to .ai/.'); + const bloat = check(json, 'adf vendor bloat')?.details ?? ''; + expect(bloat.match(/agents\.md: /gi)).toHaveLength(1); + expect(vendorCheck(json).rows).toHaveLength(1); + + // Hand-authored: listed once in the WARN, not as "agents.md, AGENTS.md". + aliasedAgents('# Agents I wrote\n'); + expect(check((await doctor()).json, 'adf agent config')?.details).toBe('AGENTS.md contains stack rules that should live in .ai/. Run: charter adf migrate --dry-run'); + }); + + it('bloat and cold start scan pointers only: a recorded-compiled or recorded-unmanaged pointer-shaped file is not scanned', async () => { + scaffold(BASE_MANIFEST.replace(' - state.adf\n', '')); + fs.rmSync('.ai/state.adf'); + const bloated = `${POINTER_AGENTS_MD}\n${Array.from({ length: 90 }, (_, i) => `line ${i}`).join('\n')}\n`; + fs.writeFileSync('AGENTS.md', bloated); + expect(check((await doctor()).json, 'adf vendor bloat')?.status).toBe('WARN'); + fs.writeFileSync('.ai/manifest.adf', `${BASE_MANIFEST.replace(' - state.adf\n', '')}\n\u{1F3F7} VENDORS:\n - AGENTS.md: unmanaged\n`); + const { json } = await doctor(); + expect(check(json, 'adf vendor bloat')).toBeUndefined(); + expect(check(json, 'adf cold start')).toBeUndefined(); + }); + + it('cold start no longer counts compiled output as a thin pointer', async () => { + fs.mkdirSync('.ai', { recursive: true }); + // No modules at all: cold start applies to a short, keyword-free pointer. + fs.writeFileSync('.ai/manifest.adf', 'ADF: 0.1\n\n\u{1F3AF} ROLE: Test\n'); + fs.writeFileSync('GEMINI.md', `\nold\n`); + fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); + const cold = (await doctor()).json.checks.filter(c => c.name === 'adf cold start'); + expect(cold.map(c => c.details.split(' ')[0])).toEqual(['.cursorrules']); + }); +}); + +// ============================================================================ +// 3. score +// ============================================================================ + +interface AgentSignal { exists: boolean; substantive: boolean; mode: string | null; source: string | null; substantiveVia: string } +interface ScoreJson { + score: { total: number }; + categories: Array<{ id: string; score: number; summary: string }>; + recommendations: string[]; + signals: { + agentConfig: { claude: AgentSignal; cursorRules: AgentSignal & { files: string[] }; agents: AgentSignal & { file?: string } }; + grounding: { pathReferences: { broken: string[] } }; + }; +} + +async function score(): Promise { + out = []; + await scoreCommand(JSON_OPTS, []); + return JSON.parse(out.join('\n')) as ScoreJson; +} + +describe('score: a pointer is as substantive as the compiled file it routes to', () => { + it('a thin pointer with a populated .ai/ gains substantive via ai-dir, and no Expand recommendation', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); + const j = await score(); + expect(j.signals.agentConfig.claude).toEqual({ exists: true, substantive: true, mode: 'pointer', source: 'inferred', substantiveVia: 'ai-dir' }); + expect(j.recommendations.join('\n')).not.toContain('Expand `CLAUDE.md`'); + expect(j.categories.find(c => c.id === 'agent-config')?.summary).toContain('CLAUDE.md is a thin pointer to .ai/'); + }); + + it('INV-P3-7: score never decreases, against the text-only (main) rule, across fixtures', async () => { + const fixtures: Array<{ name: string; setup: () => Promise | void }> = [ + { name: 'thin pointer', setup: () => { scaffold(); fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); } }, + { name: 'hybrid pointer', setup: () => { scaffold(); fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); } }, + { name: 'compiled', setup: async () => { scaffold(); fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); } }, + { name: 'unmanaged', setup: () => { scaffold(); fs.writeFileSync('CLAUDE.md', '# Mine\n'); } }, + { name: 'empty .ai/', setup: () => { fs.mkdirSync('.ai'); fs.writeFileSync('.ai/manifest.adf', BASE_MANIFEST); fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); } }, + ]; + for (const fixture of fixtures) { + for (const f of ['CLAUDE.md', '.cursorrules', 'AGENTS.md']) fs.rmSync(f, { force: true }); + fs.rmSync('.ai', { recursive: true, force: true }); + await fixture.setup(); + const now = await score(); + // Recording every file unmanaged applies the text-only rule main used for all of them. + const manifest = manifestText(); + fs.writeFileSync('.ai/manifest.adf', `${manifest}\n\u{1F3F7} VENDORS:\n - CLAUDE.md: unmanaged\n - .cursorrules: unmanaged\n - AGENTS.md: unmanaged\n`); + const main = await score(); + fs.writeFileSync('.ai/manifest.adf', manifest); + const flags = (j: ScoreJson) => [j.signals.agentConfig.claude, j.signals.agentConfig.cursorRules, j.signals.agentConfig.agents].map(s => s.substantive); + expect(now.score.total, fixture.name).toBeGreaterThanOrEqual(main.score.total); + flags(now).forEach((v, i) => expect(Number(v), `${fixture.name} #${i}`).toBeGreaterThanOrEqual(Number(flags(main)[i]))); + } + }); + + it('the thin-pointer fixture scores strictly higher than the text-only rule (+5 per file)', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); + fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); + const now = await score(); + fs.writeFileSync('.ai/manifest.adf', vendors('CLAUDE.md: unmanaged', '.cursorrules: unmanaged')); + const main = await score(); + const agentConfig = (j: ScoreJson) => j.categories.find(c => c.id === 'agent-config')?.score ?? 0; + expect(agentConfig(now) - agentConfig(main)).toBe(8); // CLAUDE.md +5, .cursorrules 2 -> 5 + expect(now.signals.agentConfig.cursorRules).toMatchObject({ substantive: true, mode: 'pointer', substantiveVia: 'ai-dir', files: ['.cursorrules'] }); + }); + + it('INV-P3-8: a thin pointer or compiled CLAUDE.md is never told to be hand-edited', async () => { + // A pointer to an .ai/ with nothing to compile: not substantive either way. + fs.mkdirSync('.ai'); + fs.writeFileSync('.ai/manifest.adf', BASE_MANIFEST); + fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); + let j = await score(); + expect(j.signals.agentConfig.claude).toMatchObject({ substantive: false, mode: 'pointer', substantiveVia: 'none' }); + const populate = SCORE_POPULATE_AI_DIR.replace('{file}', 'CLAUDE.md').replace(/\{aiDir\}/g, '.ai'); + expect(j.recommendations).toContain(populate); + for (const rec of j.recommendations) expect(rec).not.toMatch(/`CLAUDE\.md`(?! routes)/); + + fs.writeFileSync('CLAUDE.md', `\n`); + j = await score(); + expect(j.signals.agentConfig.claude).toMatchObject({ substantive: false, mode: 'compiled', substantiveVia: 'none' }); + expect(j.recommendations).toContain(populate); + for (const rec of j.recommendations) expect(rec).not.toMatch(/`CLAUDE\.md`(?! routes)/); + }); + + it('a thin hand-authored CLAUDE.md keeps the Expand recommendation', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', '# Mine\n'); + const j = await score(); + expect(j.signals.agentConfig.claude).toMatchObject({ substantive: false, mode: 'unmanaged', substantiveVia: 'none' }); + expect(j.recommendations.join('\n')).toContain('Expand `CLAUDE.md` beyond a thin pointer'); + }); + + it('additive JSON: existing keys keep their place; .cursor/rules has no mode', async () => { + scaffold(); + fs.mkdirSync('.cursor/rules', { recursive: true }); + fs.writeFileSync('.cursor/rules/a.mdc', 'Always run pnpm lint before committing changes to this repository.\nKeep it tidy.\nThird line.\n'); + const j = await score(); + expect(Object.keys(j.signals.agentConfig.claude)).toEqual(['exists', 'substantive', 'mode', 'source', 'substantiveVia']); + expect(j.signals.agentConfig.claude).toEqual({ exists: false, substantive: false, mode: null, source: null, substantiveVia: 'none' }); + expect(j.signals.agentConfig.cursorRules).toEqual({ exists: true, substantive: true, mode: null, source: null, substantiveVia: 'file', files: ['.cursor/rules/a.mdc'] }); + }); + + it('VENDORS stays out of path grounding, and score writes nothing (INV-P3-1)', async () => { + scaffold(vendors('CLAUDE.md: pointer', 'copilot-instructions.md: pointer')); + fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); + const before = snapshot(); + const j = await score(); + expect(j.signals.grounding.pathReferences.broken.join(' ')).not.toContain('copilot-instructions.md'); + expect(snapshot()).toEqual(before); + expect(fs.existsSync('.ai/.adf.lock')).toBe(false); + }); +}); + +// ============================================================================ +// 4. bootstrap +// ============================================================================ + +describe('bootstrap: pointer decision table', () => { + const table: Array<{ record: string | null; content: string | null; noForce: string; force: string }> = [ + { record: 'compiled', content: 'BANNER', noForce: 'skip-compiled', force: 'skip-compiled' }, + { record: 'compiled', content: null, noForce: 'skip-recorded-compiled-absent', force: 'skip-recorded-compiled-absent' }, + { record: 'compiled', content: POINTER_CLAUDE_MD_HYBRID, noForce: 'skip-compiled', force: 'skip-compiled' }, + { record: null, content: 'BANNER', noForce: 'skip-compiled', force: 'skip-compiled' }, + { record: 'pointer', content: 'BANNER', noForce: 'skip-compiled', force: 'skip-compiled' }, + { record: 'unmanaged', content: HAND_WRITTEN, noForce: 'skip-recorded-unmanaged', force: 'skip-recorded-unmanaged' }, + { record: 'unmanaged', content: null, noForce: 'skip-recorded-unmanaged', force: 'skip-recorded-unmanaged' }, + { record: null, content: null, noForce: 'write-pointer', force: 'write-pointer' }, + { record: 'pointer', content: null, noForce: 'write-pointer', force: 'write-pointer' }, + { record: null, content: HAND_WRITTEN, noForce: 'skip-custom', force: 'skip-custom' }, + { record: 'pointer', content: HAND_WRITTEN, noForce: 'skip-custom', force: 'skip-custom' }, + { record: null, content: POINTER_CLAUDE_MD_HYBRID, noForce: 'keep-managed', force: 'write-pointer' }, + { record: 'pointer', content: POINTER_CLAUDE_MD_HYBRID, noForce: 'keep-managed', force: 'write-pointer' }, + ]; + + it('every row, with and without --force (INV-P3-4, INV-P3-5)', () => { + for (const row of table) { + scaffold(row.record ? vendors(`CLAUDE.md: ${row.record}`) : BASE_MANIFEST); + fs.rmSync('CLAUDE.md', { force: true }); + if (row.content) fs.writeFileSync('CLAUDE.md', row.content === 'BANNER' ? `\nold\n` : row.content); + const label = `${row.record ?? 'none'} / ${row.content ? row.content.slice(0, 12) : 'absent'}`; + expect(pointerAction('.ai', 'CLAUDE.md', false), label).toBe(row.noForce); + expect(pointerAction('.ai', 'CLAUDE.md', true), `${label} --force`).toBe(row.force); + } + }); + + it('INV-P3-4: bootstrap --force leaves a compiled CLAUDE.md alone and says how to make it a pointer', async () => { + fs.mkdirSync('.ai'); + const compiled = `\nold compiled\n`; + fs.writeFileSync('CLAUDE.md', compiled); + out = []; + expect(await bootstrapCommand({ ...JSON_OPTS, yes: true }, ['--force', '--yes', '--skip-install', '--skip-doctor', '--format', 'json'])).toBe(0); + expect(read('CLAUDE.md')).toBe(compiled); + const payload = JSON.parse(out.join('\n')) as { steps: Array<{ name: string; warnings: string[] }> }; + expect(payload.steps.find(s => s.name === 'adf-init')?.warnings).toContain( + 'CLAUDE.md is compiled output from .ai/ (compile banner); bootstrap will not replace it with a pointer. To make it a pointer: charter adf vendor set CLAUDE.md pointer && charter adf vendor restore CLAUDE.md', + ); + // No pointer was recorded for it. + expect(manifestText()).not.toContain('CLAUDE.md: pointer'); + }); + + it('INV-P3-5: a hand-authored CLAUDE.md survives --force --yes, is migrated in phase 4, and is recorded pointer after', async () => { + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + out = []; + expect(await bootstrapCommand({ ...JSON_OPTS, yes: true }, ['--force', '--yes', '--skip-install', '--skip-doctor', '--format', 'json'])).toBe(0); + const payload = JSON.parse(out.join('\n')) as { steps: Array<{ name: string; warnings: string[]; details: Record }> }; + const init = payload.steps.find(s => s.name === 'adf-init'); + expect(init?.warnings).toContain("CLAUDE.md has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Run 'charter adf migrate --source CLAUDE.md' to move its rules into .ai/ first."); + expect(init?.details.pointers).not.toContain('CLAUDE.md (hybrid pointer)'); + const migrate = payload.steps.find(s => s.name === 'migrate'); + expect(migrate?.details).toMatchObject({ sources: ['CLAUDE.md'], migrated: 1 }); + // The rules moved into .ai/, the file is now a pointer, and the record followed. + const ai = fs.readdirSync('.ai').filter(f => f.endsWith('.adf')).map(f => read(path.join('.ai', f))).join('\n'); + expect(ai).toContain('Never commit .env files'); + expect(read('CLAUDE.md')).toContain('DO NOT add rules, constraints, or context to this file'); + expect(manifestText()).toContain(' - CLAUDE.md: pointer\n'); + expect(fs.readFileSync('CLAUDE.md.pre-adf-migrate.bak', 'utf-8')).toBe(HAND_WRITTEN); + }); + + it('INV-P3-6: bootstrap neither overwrites nor migrates a recorded-unmanaged file', async () => { + scaffold(vendors('CLAUDE.md: unmanaged')); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + out = []; + expect(await bootstrapCommand({ ...JSON_OPTS, yes: true }, ['--force', '--yes', '--skip-install', '--skip-doctor', '--format', 'json'])).toBe(0); + expect(read('CLAUDE.md')).toBe(HAND_WRITTEN); + const payload = JSON.parse(out.join('\n')) as { steps: Array<{ name: string; warnings: string[]; status: string }> }; + expect(payload.steps.find(s => s.name === 'adf-init')?.warnings).toContain('CLAUDE.md is recorded as unmanaged; leaving it as is.'); + const migrate = payload.steps.find(s => s.name === 'migrate'); + expect(migrate?.status).toBe('skip'); + expect(migrate?.warnings).toContain('CLAUDE.md is recorded as unmanaged; not migrating. To migrate it anyway: charter adf migrate --source CLAUDE.md'); + // --force re-scaffolds manifest.adf; the record is carried over. + expect(manifestText()).toContain(' - CLAUDE.md: unmanaged\n'); + expect(fs.readdirSync('.ai/.backup').some(f => f.startsWith('manifest.adf.'))).toBe(true); + }); + + it('--force carries every valid record entry over the re-scaffolded manifest', async () => { + scaffold(vendors('GEMINI.md: compiled', 'AGENTS.md: unmanaged', 'CLAUDE.md: pointer')); + await bootstrapCommand({ ...TEXT, yes: true }, ['--force', '--yes', '--skip-install', '--skip-doctor']); + const record = manifestText().slice(manifestText().indexOf('VENDORS:')); + expect(record).toBe('VENDORS:\n - GEMINI.md: compiled\n - AGENTS.md: unmanaged\n - CLAUDE.md: pointer\n - .cursorrules: pointer\n - copilot-instructions.md: pointer\n'); + expect(fs.existsSync('GEMINI.md')).toBe(false); + expect(fs.existsSync('AGENTS.md')).toBe(false); + }); + + it('without --force, the custom-content warning is unchanged (#305)', async () => { + fs.writeFileSync('AGENTS.md', '# Agents\n\n- Always run the linter.\n'); + await bootstrapCommand(TEXT, ['--skip-install', '--skip-doctor']); + expect(out.join('\n')).toContain("AGENTS.md has custom content; skipping pointer. Run 'charter adf migrate --source AGENTS.md'"); + }); +}); + +// ============================================================================ +// 5. adf migrate +// ============================================================================ + +describe('adf migrate and recorded unmanaged', () => { + it('INV-P3-6: bare discovery skips a recorded-unmanaged file (text and JSON) and leaves it untouched', async () => { + scaffold(vendors('CLAUDE.md: unmanaged')); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + fs.writeFileSync('GEMINI.md', '# Gemini\n\n## Rules\n\n- Prefer small diffs.\n'); + const text = await run(['migrate', '--no-backup']); + expect(text.code).toBe(0); + expect(text.out).toContain('Skipped: CLAUDE.md is recorded as unmanaged; skipped. Name it explicitly to migrate it: charter adf migrate --source CLAUDE.md'); + expect(read('CLAUDE.md')).toBe(HAND_WRITTEN); + // GEMINI.md, not named and not recorded, is still migrated. + expect(read('GEMINI.md')).not.toContain('Prefer small diffs'); + const json = await run(['migrate', '--dry-run', '--format', 'json'], JSON_OPTS); + const parsed = JSON.parse(json.out) as { sources: Array<{ source: string; skipped: boolean; skipReason?: string }> }; + expect(parsed.sources.find(s => s.source === 'CLAUDE.md')).toMatchObject({ skipped: true }); + }); + + it('CP-6: explicit --source migrates it and upserts pointer, so doctor shows no drift', async () => { + scaffold(vendors('CLAUDE.md: unmanaged')); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + const r = await run(['migrate', '--source', 'CLAUDE.md', '--no-backup']); + expect(r.code).toBe(0); + expect(manifestText()).toBe(vendors('CLAUDE.md: pointer')); + expect(read('CLAUDE.md')).toContain('DO NOT add rules, constraints, or context to this file'); + const c = vendorCheck((await doctor()).json); + expect(c.rows?.map(x => x.kind)).toEqual(['recorded-ok']); + }); + + it('record before write: a failed migration restores the unmanaged entry', async () => { + if (typeof process.getuid === 'function' && process.getuid() === 0) return; + scaffold(vendors('CLAUDE.md: unmanaged')); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + fs.chmodSync('CLAUDE.md', 0o444); + try { + await expect(run(['migrate', '--source', 'CLAUDE.md', '--no-backup'])).rejects.toThrow(); + expect(manifestText()).toBe(vendors('CLAUDE.md: unmanaged')); + expect(read('CLAUDE.md')).toBe(HAND_WRITTEN); + } finally { + fs.chmodSync('CLAUDE.md', 0o644); + } + }); + + it('an explicit --source on an unrecorded file records add-if-absent as before', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + expect((await run(['migrate', '--source', 'CLAUDE.md', '--no-backup'])).code).toBe(0); + expect(manifestText()).toBe(vendors('CLAUDE.md: pointer')); + }); +}); + +// ============================================================================ +// 6. adf vendor restore +// ============================================================================ + +interface RestoreJson { file: string; status: string; refusal: string | null; keptSections: string[]; remedy: string | null } + +async function restore(file: string, extra: string[] = []): Promise<{ code: number; json: RestoreJson; err: string }> { + const r = await run(['vendor', 'restore', file, ...extra, '--format', 'json'], JSON_OPTS); + return { code: r.code, json: JSON.parse(r.out) as RestoreJson, err: r.err }; +} + +describe('adf vendor restore', () => { + it('refuses a recorded compiled or unmanaged file, and an inferred compiled one (mode-not-pointer)', async () => { + scaffold(vendors('CLAUDE.md: compiled', 'AGENTS.md: unmanaged')); + fs.writeFileSync('CLAUDE.md', `\n`); + fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); + fs.writeFileSync('GEMINI.md', `\n`); + const before = snapshot(); + const text = await run(['vendor', 'restore', 'CLAUDE.md']); + expect(text.code).toBe(1); + expect(text.err).toContain(' [warn] CLAUDE.md is compiled (recorded); restore writes a pointer. Record that intent first: charter adf vendor set CLAUDE.md pointer'); + expect((await restore('AGENTS.md')).json).toEqual({ file: 'AGENTS.md', status: 'refused', refusal: 'mode-not-pointer', keptSections: [], remedy: 'charter adf vendor set AGENTS.md pointer' }); + expect((await restore('GEMINI.md')).json.refusal).toBe('mode-not-pointer'); + expect(snapshot()).toEqual(before); + }); + + it('refuses hand-authored content, recorded pointer or not (hand-authored)', async () => { + scaffold(vendors('AGENTS.md: pointer')); + fs.writeFileSync('CLAUDE.md', HAND_WRITTEN); + fs.writeFileSync('AGENTS.md', '# Agents I wrote\n'); + const before = snapshot(); + const claude = await restore('CLAUDE.md'); + expect(claude.code).toBe(1); + expect(claude.json).toMatchObject({ refusal: 'hand-authored', remedy: 'charter adf migrate --source CLAUDE.md' }); + expect(claude.err).toContain(' [warn] CLAUDE.md has hand-authored content that .ai/ does not hold. Move it first: charter adf migrate --source CLAUDE.md'); + expect((await restore('AGENTS.md')).json.refusal).toBe('hand-authored'); + expect(snapshot()).toEqual(before); + }); + + it('refuses a bloated pointer (pointer-bloated)', async () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', BLOATED_CLAUDE); + const r = await restore('CLAUDE.md'); + expect(r.json).toMatchObject({ status: 'refused', refusal: 'pointer-bloated', remedy: 'charter adf tidy --source CLAUDE.md' }); + expect(r.err).toMatch(/ {2}\[warn\] CLAUDE\.md carries \d+ item\(s\) beyond the pointer\. Fold them into \.ai\/ first: charter adf tidy --source CLAUDE\.md/); + expect(read('CLAUDE.md')).toBe(BLOATED_CLAUDE); + }); + + it('refuses a compiled file carrying divergent retained sections (retained-divergent)', async () => { + scaffold(vendors('CLAUDE.md: pointer')); + const compiled = `${await compiledOutput('claude')}\n## Environment\n- Use WSL for builds\n`; + fs.writeFileSync('CLAUDE.md', compiled); + const r = await restore('CLAUDE.md'); + expect(r.json).toMatchObject({ status: 'refused', refusal: 'retained-divergent' }); + expect(r.err).toContain(' [warn] CLAUDE.md carries your content under ## Environment, which the pointer template would drop. Move it into a .ai/ module first.'); + expect(read('CLAUDE.md')).toBe(compiled); + }); + + it('refuses when the record cannot be written, and writes no file (record-write)', async () => { + scaffold(`${BASE_MANIFEST}\n\u{1F3F7} VENDORS: not a list\n`); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD); + const before = snapshot(); + const r = await restore('CLAUDE.md'); + expect(r.json).toMatchObject({ status: 'refused', refusal: 'record-write', remedy: 'charter adf vendor status' }); + expect(snapshot()).toEqual(before); + }); + + it('keeps retained sections verbatim and reports them', async () => { + scaffold(); + const notes = `${POINTER_CLAUDE_MD.replace('\n', '- Use WSL for builds\n')}\n## Release Protocol\n\nTag, then push.\n`; + fs.writeFileSync('CLAUDE.md', notes); + const text = await run(['vendor', 'restore', 'CLAUDE.md']); + expect(text.code).toBe(0); + expect(text.out).toContain(' [ok] Restored CLAUDE.md as a thin pointer (kept ## Environment, ## Release Protocol)'); + const restored = read('CLAUDE.md'); + expect(restored).toContain('## Environment\n- Use WSL for builds\n'); + expect(restored).toContain('## Release Protocol\n\nTag, then push.\n'); + expect(restored).toContain('| core.adf | _(always loaded)_ | DEFAULT |\n'); + expect(restored.match(/## Module Index/g)).toHaveLength(1); + expect(manifestText()).toBe(vendors('CLAUDE.md: pointer')); + }); + + it('an unchanged pointer is byte-equal: status unchanged, nothing written', async () => { + await bootstrapCommand({ ...TEXT, yes: true }, ['--yes', '--skip-install', '--skip-doctor']); + const before = snapshot(); + for (const f of ['CLAUDE.md', 'AGENTS.md', '.cursorrules', 'GEMINI.md', 'copilot-instructions.md']) { + const r = await restore(f); + expect(r.json, f).toMatchObject({ status: 'unchanged', refusal: null }); + } + expect(snapshot()).toEqual(before); + expect(restoredPointer('CLAUDE.md', read('CLAUDE.md'), '.ai')).toBe(read('CLAUDE.md')); + }); + + it('restores an absent unrecorded file, recording pointer before the write', async () => { + scaffold(); + const r = await restore('GEMINI.md'); + expect(r.code).toBe(0); + expect(r.json).toEqual({ file: 'GEMINI.md', status: 'restored', refusal: null, keptSections: [], remedy: null }); + expect(read('GEMINI.md')).toBe(POINTER_GEMINI_MD); + expect(manifestText()).toBe(vendors('GEMINI.md: pointer')); + }); + + it('--dry-run plans and writes nothing', async () => { + scaffold(vendors('CLAUDE.md: pointer')); + fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); + const before = snapshot(); + const text = await run(['vendor', 'restore', 'CLAUDE.md', '--dry-run']); + expect(text.code).toBe(0); + expect(text.out).toContain(' [dry-run] Would restore CLAUDE.md as a thin pointer'); + expect((await restore('CLAUDE.md', ['--dry-run'])).json.status).toBe('planned'); + expect(snapshot()).toEqual(before); + }); + + it('a failed file write puts the manifest back (record revert)', async () => { + if (typeof process.getuid === 'function' && process.getuid() === 0) return; + scaffold(); + fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD); + const manifest = manifestText(); + fs.chmodSync('CLAUDE.md', 0o444); + try { + const text = await run(['vendor', 'restore', 'CLAUDE.md']); + expect(text.code).toBe(1); + expect(text.err).toMatch(/ {2}\[warn\] Could not write CLAUDE\.md: .+\. Its new mode record was removed again\./); + expect(manifestText()).toBe(manifest); + expect(read('CLAUDE.md')).toBe(POINTER_CLAUDE_MD); + } finally { + fs.chmodSync('CLAUDE.md', 0o644); + } + }); + + it('usage errors and unknown files are exit 2 (CLIError)', async () => { + scaffold(); + await expect(run(['vendor', 'restore'])).rejects.toBeInstanceOf(CLIError); + await expect(run(['vendor', 'restore', 'README.md'])).rejects.toThrow("Unknown vendor file 'README.md'"); + try { + await run(['vendor', 'restore', 'README.md']); + } catch (e) { + expect((e as CLIError).exitCode).toBe(2); + } + }); + + it('INV-P3-9: across every refusal fixture no byte is lost', async () => { + const fixtures: Array<[string, string, string]> = [ + [vendors('CLAUDE.md: compiled'), 'CLAUDE.md', HAND_WRITTEN], + [BASE_MANIFEST, 'CLAUDE.md', HAND_WRITTEN], + [BASE_MANIFEST, 'CLAUDE.md', BLOATED_CLAUDE], + [vendors('CLAUDE.md: pointer'), 'CLAUDE.md', HAND_WRITTEN], + ]; + for (const [manifest, file, content] of fixtures) { + scaffold(manifest); + fs.writeFileSync(file, content); + const before = snapshot(); + const r = await restore(file); + expect(r.json.status).toBe('refused'); + expect(snapshot()).toEqual(before); + } + }); +}); + +// ============================================================================ +// 7. compile messages name restore +// ============================================================================ + +describe('adf compile remedies name adf vendor restore', () => { + it('a missing recorded pointer: following the remedy passes --check', async () => { + scaffold(vendors('AGENTS.md: pointer')); + const r = await run(['compile', '--target', 'agents', '--check', '--format', 'json'], JSON_OPTS); + const j = JSON.parse(r.out) as { targets: Array<{ remedy: string }>; nextActions: string[] }; + expect(j.targets[0].remedy).toBe('charter adf vendor restore AGENTS.md'); + expect(j.nextActions.join('\n')).not.toContain('git checkout'); + expect((await run(j.targets[0].remedy.replace(/^charter adf /, '').split(' '))).code).toBe(0); + expect((await run(['compile', '--target', 'agents', '--check'])).code).toBe(0); + }); +}); + +// Literal templates the doctor rows use (kept in one place so a rename is caught). +describe('doctor message templates', () => { + it('match the addendum wording', () => { + expect(DOCTOR_MSG.allRecorded).toBe('All {n} vendor file(s) have a recorded mode that matches the file on disk.'); + expect(DOCTOR_MSG.agentConfigPass).toBe('{pointers} thin pointer(s) and {compiled} compiled file(s) route to .ai/{unmanagedNote}.'); + }); +}); diff --git a/packages/cli/src/__tests__/adf-vendor.test.ts b/packages/cli/src/__tests__/adf-vendor.test.ts index 63a2184..7a33460 100644 --- a/packages/cli/src/__tests__/adf-vendor.test.ts +++ b/packages/cli/src/__tests__/adf-vendor.test.ts @@ -30,11 +30,9 @@ import { VENDOR_FILES, withoutVendorsSection, } from '../commands/adf-vendor'; -import { scoreCommand } from '../commands/score'; import type { VendorFile } from '../commands/adf-vendor'; import { insertManifestBullet } from '../commands/adf-manifest-register'; import { bootstrapCommand } from '../commands/bootstrap'; -import { doctorCommand } from '../commands/doctor'; const TEXT: CLIOptions = { configPath: '.charter', format: 'text', ciMode: false, yes: false }; const JSON_OPTS: CLIOptions = { ...TEXT, format: 'json' }; @@ -681,15 +679,16 @@ describe('implicit writers', () => { expect(manifestText()).toBe(broken); }); - it('bootstrap keeps an existing entry for a pointer it writes', async () => { + it('bootstrap keeps an existing entry, and writes no pointer where compiled is recorded (#318 PR3)', async () => { const args = ['--yes', '--skip-install', '--skip-doctor']; scaffold(`${BASE_MANIFEST}\n\u{1F3F7} VENDORS:\n - GEMINI.md: compiled\n`); out = []; expect(await bootstrapCommand({ ...JSON_OPTS, yes: true }, [...args, '--format', 'json'])).toBe(0); const payload = JSON.parse(out.join('\n')) as { steps: Array<{ name: string; warnings: string[] }> }; expect(payload.steps.find(s => s.name === 'adf-init')?.warnings).toContain( - 'GEMINI.md is recorded as compiled but bootstrap wrote it as pointer; record kept. To change it: charter adf vendor set GEMINI.md pointer', + 'GEMINI.md is recorded as compiled but is not on disk; not writing a pointer. Generate it: charter adf compile --target gemini --write', ); + expect(fs.existsSync('GEMINI.md')).toBe(false); expect(manifestText()).toContain(' - GEMINI.md: compiled\n - CLAUDE.md: pointer\n'); }); }); @@ -698,7 +697,8 @@ describe('implicit writers', () => { // 7. Consumers that do not read the record yet (INV-1) // ============================================================================ -describe('doctor, score and compile stdout ignore the record (INV-1, until PR3)', () => { +// doctor and score read the record since #318 PR3 (adf-vendor-doctor-score.test.ts). +describe('compile stdout ignores the record (INV-1)', () => { type Run = { code: number; out: string; err: string }; /** Run `steps` in two fresh repos, identical except for a VENDORS section. */ @@ -750,38 +750,6 @@ describe('doctor, score and compile stdout ignore the record (INV-1, until PR3)' const r = await run(['compile', '--target', 'claude']); expect(r.out).not.toContain('VENDORS'); }); - - it('doctor is unchanged', async () => { - const doctor = async (options: CLIOptions, args: string[]): Promise => { - out = []; - err = []; - const code = await doctorCommand(options, args); - return { code, out: out.join('\n'), err: err.join('\n') }; - }; - await compare(setupVendorFiles, [ - () => doctor(TEXT, []), - () => doctor(JSON_OPTS, []), - ]); - }); - - it('score is unchanged, including non-unique vendor basenames', async () => { - const score = async (options: CLIOptions): Promise => { - out = []; - err = []; - const code = await scoreCommand(options, []); - return { code, out: out.join('\n'), err: err.join('\n') }; - }; - await compare(() => { - setupVendorFiles(); - // A monorepo with per-package agent files: the basenames are not unique. - fs.mkdirSync(path.join('packages', 'a'), { recursive: true }); - fs.writeFileSync(path.join('packages', 'a', 'CLAUDE.md'), '# Package a\n'); - fs.writeFileSync(path.join('packages', 'a', 'AGENTS.md'), '# Package a\n'); - }, [ - () => score(JSON_OPTS), - () => score(TEXT), - ]); - }); }); describe('withoutVendorsSection', () => { @@ -861,9 +829,11 @@ describe('vendor file identity', () => { aliasedAgents('# Agents\n\n## Rules\n\n- Always use pnpm for installs.\n'); const r = await run(['migrate', '--source', 'agents.md', '--no-backup']); expect(r.code).toBe(0); - expect(r.out).toContain('AGENTS.md is recorded as unmanaged but adf migrate wrote it as pointer; record kept.'); + // An explicit --source on a recorded-unmanaged file switches it to pointer + // (#318 PR3, CP-6), under the spelling already recorded. + expect(r.out).not.toContain('record kept'); const vendors = parseAdf(manifestText()).sections.find(s => s.key === 'VENDORS'); - expect(vendors?.content).toEqual({ type: 'list', items: ['AGENTS.md: unmanaged'] }); + expect(vendors?.content).toEqual({ type: 'list', items: ['AGENTS.md: pointer'] }); }); it('status shows one row when both spellings are recorded, and says which is used', async () => { diff --git a/packages/cli/src/commands/adf-compile.ts b/packages/cli/src/commands/adf-compile.ts index 3e25b4e..0d1d6d2 100644 --- a/packages/cli/src/commands/adf-compile.ts +++ b/packages/cli/src/commands/adf-compile.ts @@ -71,7 +71,7 @@ export const MSG = { checkSkippedUnmanaged: ' [skip] {file} is unmanaged ({source}); not checked', checkStale: ' [stale] {file} — out of date with {aiDir}/ source.\n Run: charter adf compile --target {target} --write', checkMissingCompiled: ' [missing] {file} — not found on disk.\n Run: charter adf compile --target {target} --write', - checkMissingPointer: ' [missing] {file} — recorded as a pointer but not found on disk.\n Restore it (git checkout -- {file}), or stop tracking it: charter adf vendor set {file} unmanaged', + checkMissingPointer: ' [missing] {file} — recorded as a pointer but not found on disk.\n Restore it (charter adf vendor restore {file}), or stop tracking it: charter adf vendor set {file} unmanaged', checkPointerBloated: ' [bloated] {file} — thin pointer carries {n} item(s) that belong in {aiDir}/.\n Run: charter adf tidy --source {file}', checkModeDrift: ' [drift] {file} — recorded as {recorded} but looks {inferred} on disk.\n Keep the file as it is: {keep}\n Or make it match the record: {restore}', } as const; @@ -273,7 +273,7 @@ function retainedSectionMap(content: string): Map { * destroy content no `.ai/` module holds. The guard refuses instead; `--force` * remains the way through. */ -function divergentRetainedHeadings(existing: string, filename: string): string[] { +export function divergentRetainedHeadings(existing: string, filename: string): string[] { const templates = pointerTemplatesFor(filename); const pristine = new Set(); for (const template of templates) { @@ -559,7 +559,7 @@ function runCheckMode( print(fill(MSG.checkMissingCompiled, { file, target })); } else if (!ts.exists && ts.state === 'pointer') { row.status = 'missing'; - row.remedy = `git checkout -- ${file}`; + row.remedy = `charter adf vendor restore ${file}`; nextActions.push(row.remedy, `charter adf vendor set ${file} unmanaged`); print(fill(MSG.checkMissingPointer, { file })); } else if (ts.disagrees && ts.state !== 'unmanaged' && ts.inferredMode) { @@ -572,7 +572,7 @@ function runCheckMode( const keep = [`charter adf vendor set ${file} ${ts.inferredMode}`, ...(n > 0 ? [`charter adf tidy --source ${file}`] : [])]; const restore = ts.state === 'compiled' ? `${compileRemedy}${ts.inferredMode === 'unmanaged' ? ' --force' : ''}` - : `git checkout -- ${file}`; + : `charter adf vendor restore ${file}`; const discards = ts.state !== 'compiled' ? '' : ts.inferredMode === 'unmanaged' ? ' (discards its hand-authored content)' : n > 0 ? ` (discards ${n} item(s) not in ${displayAiDir}/)` : ''; diff --git a/packages/cli/src/commands/adf-migrate.ts b/packages/cli/src/commands/adf-migrate.ts index bef9114..9e68a96 100644 --- a/packages/cli/src/commands/adf-migrate.ts +++ b/packages/cli/src/commands/adf-migrate.ts @@ -35,7 +35,9 @@ import { } from './adf'; import { registerModules, toRegisterAction, dedupePlannedRegistrations } from './adf-manifest-register'; import type { ModuleRegistration, RegisterAction } from './adf-manifest-register'; -import { recordWrittenVendorModes } from './adf-vendor'; +import { isVendorFile, recordedModeOf, recordVendorModes, recordWrittenVendorModes } from './adf-vendor'; + +export const MIGRATE_RECORDED_UNMANAGED = '{file} is recorded as unmanaged; skipped. Name it explicitly to migrate it: charter adf migrate --source {file}'; // ============================================================================ // Constants @@ -125,10 +127,12 @@ export async function adfMigrateCommand(options: CLIOptions, args: string[]): Pr throw new CLIError(`Invalid --merge-strategy: ${mergeStrategy}. Use append, dedupe, or replace.`); } - // Detect source files + // Detect source files. Bare discovery leaves a file recorded unmanaged alone; + // naming it with --source migrates it (#318). const sources = sourceFile ? [sourceFile] : discoverAgentConfigFiles(); + const leftAlone = new Set(sourceFile ? [] : sources.filter(f => isVendorFile(f) && recordedModeOf(aiDir, f) === 'unmanaged')); if (sources.length === 0) { if (options.format === 'json') { @@ -144,6 +148,12 @@ export async function adfMigrateCommand(options: CLIOptions, args: string[]): Pr const plannedModules = new Set(); for (const source of sources) { + if (leftAlone.has(source)) { + const detail = MIGRATE_RECORDED_UNMANAGED.replace(/\{file\}/g, source); + const lineCount = fs.readFileSync(source, 'utf-8').split('\n').length; + results.push({ source, skipped: true, skipReason: detail, lineCount, sectionCount: 0, actions: [{ type: 'SKIP', target: source, detail }], registrations: [] }); + continue; + } const result = migrateSource(source, aiDir, mergeStrategy, dryRun, noBackup, keepSummary, options, coexist); if (dryRun) dedupePlannedRegistrations(result, plannedModules); results.push(result); @@ -362,31 +372,50 @@ export function migrateSource( const register = () => registerModules(manifestPath, plan.targetModules, plan.migrateItems, { dryRun, onWarning: warn }); let registration = dryRun ? register() : undefined; + // Callers drop recorded-unmanaged files from discovery, so reaching one here is + // an explicit --source: an intent change. Record pointer first (upsert), and + // restore the unmanaged entry if the migration then fails (#318, CP-6). + const vendorName = path.relative(process.cwd(), fullPath).split(path.sep).join('/'); + const switchMode = !dryRun && isVendorFile(vendorName) && recordedModeOf(aiDir, vendorName) === 'unmanaged'; + const upsert = (mode: 'pointer' | 'unmanaged') => isVendorFile(vendorName) + && recordVendorModes(manifestPath, [{ file: vendorName, mode }], 'upsert', { onWarning: warn }).results.every(r => r.status !== 'skipped'); + if (switchMode && !upsert('pointer')) { + const detail = 'Its mode could not be recorded as pointer; nothing was migrated. Run: charter adf vendor status'; + return { source: sourcePath, skipped: true, skipReason: detail, lineCount: lines.length, sectionCount: 0, actions: [{ type: 'SKIP', target: sourcePath, detail }], registrations: [] }; + } + // Apply if not dry-run + let pointerWritten = false; if (!dryRun) { - // Backup - if (!noBackup) { - fs.writeFileSync(`${fullPath}.pre-adf-migrate.bak`, content); - } + try { + // Backup + if (!noBackup) { + fs.writeFileSync(`${fullPath}.pre-adf-migrate.bak`, content); + } - // Ensure .ai/ exists - fs.mkdirSync(aiDir, { recursive: true }); + // Ensure .ai/ exists + fs.mkdirSync(aiDir, { recursive: true }); - // Merge into ADF modules - for (const [module, sectionGroups] of Object.entries(moduleGroups)) { - const modulePath = path.join(aiDir, module); - applyMigrationToModule(modulePath, sectionGroups, mergeStrategy); - } + // Merge into ADF modules + for (const [module, sectionGroups] of Object.entries(moduleGroups)) { + const modulePath = path.join(aiDir, module); + applyMigrationToModule(modulePath, sectionGroups, mergeStrategy); + } - registration = register(); + registration = register(); - // Write pointer with retained STAY items (and optional architecture summary) - writePointerWithRetained(fullPath, sourcePath, plan.stayItems, keepSummary ? plan : undefined, coexist, aiDir); - recordWrittenVendorModes(aiDir, [sourcePath], 'pointer', 'adf migrate', warn); + // Write pointer with retained STAY items (and optional architecture summary) + writePointerWithRetained(fullPath, sourcePath, plan.stayItems, keepSummary ? plan : undefined, coexist, aiDir); + pointerWritten = true; + recordWrittenVendorModes(aiDir, [sourcePath], 'pointer', 'adf migrate', warn); - // A non-CLAUDE.md source can add modules after CLAUDE.md's pointer was written. - if (path.basename(sourcePath) !== 'CLAUDE.md' && registration.registrations.some(r => r.status === 'added')) { - updateModuleIndex('CLAUDE.md', aiDir); + // A non-CLAUDE.md source can add modules after CLAUDE.md's pointer was written. + if (path.basename(sourcePath) !== 'CLAUDE.md' && registration.registrations.some(r => r.status === 'added')) { + updateModuleIndex('CLAUDE.md', aiDir); + } + } catch (err: unknown) { + if (switchMode && !pointerWritten && !upsert('unmanaged')) warn(`${vendorName} is still recorded as pointer; restore it with: charter adf vendor set ${vendorName} unmanaged`); + throw err; } } diff --git a/packages/cli/src/commands/adf-vendor.ts b/packages/cli/src/commands/adf-vendor.ts index f9483f6..e6875c7 100644 --- a/packages/cli/src/commands/adf-vendor.ts +++ b/packages/cli/src/commands/adf-vendor.ts @@ -23,8 +23,19 @@ import type { AdfDocument, ManifestVendorEntry } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; -import { POINTER_MARKERS } from './adf'; -import { fileIdentity } from './adf-migrate'; +import { + MODULE_INDEX_END, + MODULE_INDEX_START, + POINTER_AGENTS_MD, + POINTER_CLAUDE_MD_HYBRID, + POINTER_COPILOT_MD, + POINTER_CURSORRULES, + POINTER_GEMINI_MD, + POINTER_MARKERS, +} from './adf'; +import { fileIdentity, generateModuleIndex } from './adf-migrate'; +import { divergentRetainedHeadings } from './adf-compile'; +import { countExtractable, readRetainedSections } from './adf-tidy'; import { insertManifestBullet, lastSectionIndex, replaceManifestBullet, sectionHeaders } from './adf-manifest-register'; // ============================================================================ @@ -132,6 +143,18 @@ export const MSG = { setUnchanged: ' [ok] {file} already recorded as {mode}', initOk: ' [ok] Recorded {n} vendor mode(s) in {manifestPath}', initNothing: ' [ok] All vendor files on disk already have a recorded mode.', + usageRestore: 'Usage: charter adf vendor restore [--dry-run] [--ai-dir ] [--format json]', + restoreOk: ' [ok] Restored {file} as a thin pointer{kept}', + restorePlanned: ' [dry-run] Would restore {file} as a thin pointer{kept}', + restoreUnchanged: ' [ok] {file} is already the thin pointer template', + restoreModeNotPointer: ' [warn] {file} is {mode} ({source}); restore writes a pointer. Record that intent first: charter adf vendor set {file} pointer', + restoreHandAuthored: ' [warn] {file} has hand-authored content that .ai/ does not hold. Move it first: charter adf migrate --source {file}', + restoreBloated: ' [warn] {file} carries {n} item(s) beyond the pointer. Fold them into .ai/ first: charter adf tidy --source {file}', + restoreRetained: ' [warn] {file} carries your content under {headings}, which the pointer template would drop. Move it into a .ai/ module first.', + restoreRecord: ' [warn] {file} was not restored: its mode could not be recorded as pointer in {manifestPath}. Fix the manifest or run: charter adf vendor status', + restoreWriteFailed: ' [warn] Could not write {file}: {error}.{note}', + restoreReverted: ' Its new mode record was removed again.', + restoreNotReverted: ' Its new mode record (pointer) was kept; delete that VENDORS line by hand if you do not want it.', } as const; function fill(template: string, values: Record): string { @@ -280,6 +303,15 @@ export function recordedSpelling( return groups.find(g => g.includes(file))?.find(n => recorded.has(n)) ?? file; } +/** + * The mode of the valid VENDORS entry for `file`'s physical file (under any of + * its spellings), or null when there is none. Never writes. + */ +export function recordedModeOf(aiDir: string, file: VendorFile): VendorMode | null { + const { recorded } = readVendorRecord(path.join(aiDir, 'manifest.adf')); + return recorded.get(recordedSpelling(file, recorded))?.mode ?? null; +} + function readVendorFile(file: VendorFile): string | null { try { return fs.readFileSync(path.resolve(file), 'utf-8'); @@ -575,9 +607,11 @@ export function adfVendorCommand(options: CLIOptions, args: string[]): number { return vendorSet(options, manifestPath, rest, dryRun); case 'init': return vendorInit(options, manifestPath, dryRun); + case 'restore': + return vendorRestore(options, aiDir, rest, dryRun); default: throw new CLIError( - `${sub ? `Unknown adf vendor subcommand: ${sub}. ` : ''}Usage: charter adf vendor [--ai-dir ] [--format json]`, + `${sub ? `Unknown adf vendor subcommand: ${sub}. ` : ''}Usage: charter adf vendor [--ai-dir ] [--format json]`, ); } } @@ -708,3 +742,183 @@ function vendorInit(options: CLIOptions, manifestPath: string, dryRun: boolean): const failed = results.some(r => r.status === 'skipped' && r.skipReason !== 'already-recorded' && r.skipReason !== 'file-absent'); return failed ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; } + +// ============================================================================ +// adf vendor restore (#318) +// ============================================================================ + +export type RestoreRefusal = 'mode-not-pointer' | 'hand-authored' | 'pointer-bloated' | 'retained-divergent' | 'record-write'; + +export interface RestoreResult { + file: VendorFile; + status: 'restored' | 'planned' | 'unchanged' | 'refused'; + refusal: RestoreRefusal | null; + /** Retained headings carrying the user's own content, carried over verbatim. */ + keptSections: string[]; + remedy: string | null; +} + +/** The template each vendor name is restored to (CLAUDE.md: the hybrid one bootstrap writes). */ +function restoreTemplate(file: VendorFile): string { + switch (file) { + case 'CLAUDE.md': return POINTER_CLAUDE_MD_HYBRID; + case 'AGENTS.md': + case 'agents.md': return POINTER_AGENTS_MD; + case '.cursorrules': return POINTER_CURSORRULES; + case 'GEMINI.md': return POINTER_GEMINI_MD; + default: return POINTER_COPILOT_MD; + } +} + +/** A retained-sections block without its `## Module Index` section (the template carries its own). */ +function withoutModuleIndexSection(block: string): string { + const out: string[] = []; + let skipping = false; + for (const line of block.split('\n')) { + if (line.trim().startsWith('## ')) skipping = line.trim() === '## Module Index'; + if (!skipping) out.push(line); + } + while (out.length > 0 && out[out.length - 1].trim() === '') out.pop(); + while (out.length > 0 && out[0].trim() === '') out.shift(); + return out.length > 0 ? `\n\n${out.join('\n')}\n` : ''; +} + +/** + * The pointer `restore` writes: the template, with the current file's retained + * sections re-appended verbatim (as tidy's restorePointer does) and, for + * CLAUDE.md, the module index filled in (as bootstrap's updateModuleIndex does). + * A clean bootstrap pointer therefore comes back byte-equal. Pure: never writes. + */ +export function restoredPointer(file: VendorFile, current: string | null, aiDir: string): string { + let pointer = restoreTemplate(file); + const hasIndex = pointer.includes(MODULE_INDEX_START); + let retained = current === null ? '' : readRetainedSections(current); + if (hasIndex) retained = withoutModuleIndexSection(retained); + if (retained) pointer = pointer.replace(/\n## Environment[\s\S]*$/, '').trimEnd() + retained; + const index = hasIndex ? generateModuleIndex(aiDir) : ''; + return index + ? pointer.replace(`${MODULE_INDEX_START}\n${MODULE_INDEX_END}`, `${MODULE_INDEX_START}\n${index}${MODULE_INDEX_END}`) + : pointer; +} + +function readText(p: string): string | null { + try { + return fs.readFileSync(p, 'utf-8'); + } catch { + return null; + } +} + +export interface RestoreOptions { + dryRun?: boolean; + /** Receives each result line; isWarning marks a refusal or failure. */ + print?: (line: string, isWarning: boolean) => void; + onWarning?: (message: string) => void; +} + +/** + * Rewrite one vendor file as its pointer template, refusing whenever content + * would be lost (INV-P3-9). Allowed iff the resolved mode is pointer, or the + * file is absent with no valid entry. A file with no valid entry gets `pointer` + * recorded (add-if-absent, through recordVendorModes) BEFORE the file is + * written: a skipped record means no file write, and a failed file write puts + * the manifest's previous bytes back. + */ +export function restoreVendorFile(aiDir: string, file: VendorFile, options: RestoreOptions = {}): RestoreResult { + const print = options.print ?? (() => undefined); + const manifestPath = path.join(aiDir, 'manifest.adf'); + const r = resolveVendorMode(aiDir, file); + const ev = r.evidence; + const current = ev.exists ? readText(path.resolve(file)) : null; + const handAuthored = ev.inferredMode === 'unmanaged' && (current ?? '').trim() !== ''; + const refuse = (refusal: RestoreRefusal, remedy: string | null, line: string): RestoreResult => { + print(line, true); + return { file, status: 'refused', refusal, keptSections: [], remedy }; + }; + const migrateRemedy = `charter adf migrate --source ${file}`; + + if (r.mode !== 'pointer' && !(!ev.exists && r.source === 'inferred')) { + if (r.source === 'inferred' && handAuthored) return refuse('hand-authored', migrateRemedy, fill(MSG.restoreHandAuthored, { file })); + return refuse('mode-not-pointer', `charter adf vendor set ${file} pointer`, + fill(MSG.restoreModeNotPointer, { file, mode: r.mode, source: r.source })); + } + if (handAuthored) return refuse('hand-authored', migrateRemedy, fill(MSG.restoreHandAuthored, { file })); + if (ev.inferredMode === 'pointer') { + const n = countExtractable(file, aiDir); + if (n > 0) return refuse('pointer-bloated', `charter adf tidy --source ${file}`, fill(MSG.restoreBloated, { file, n })); + } + if (ev.inferredMode === 'compiled' && current !== null) { + const divergent = divergentRetainedHeadings(current, file); + if (divergent.length > 0) { + return refuse('retained-divergent', null, fill(MSG.restoreRetained, { file, headings: divergent.join(', ') })); + } + } + + // Only a pointer's retained sections are the user's; a compiled file's come from .ai/. + const keptSections = ev.inferredMode === 'pointer' && current !== null ? divergentRetainedHeadings(current, file) : []; + const kept = keptSections.length > 0 ? ` (kept ${keptSections.join(', ')})` : ''; + const next = restoredPointer(file, ev.inferredMode === 'pointer' ? current : null, aiDir); + if (next === current) { + print(fill(MSG.restoreUnchanged, { file }), false); + return { file, status: 'unchanged', refusal: null, keptSections, remedy: null }; + } + if (options.dryRun) { + print(fill(MSG.restorePlanned, { file, kept }), false); + return { file, status: 'planned', refusal: null, keptSections, remedy: null }; + } + + // Record before write (PR2 pattern). A recorded pointer needs no record write. + const before = readText(manifestPath); + let after: string | null = null; + if (r.source === 'inferred') { + let recorded = false; + try { + const outcome = recordVendorModes(manifestPath, [{ file, mode: 'pointer' }], 'add-if-absent', { onWarning: options.onWarning }); + recorded = outcome.results.every(x => x.status !== 'skipped'); + if (outcome.written) after = readText(manifestPath); + } catch (err: unknown) { + options.onWarning?.(`Vendor modes were not recorded: ${err instanceof Error ? err.message : String(err)}`); + } + if (!recorded) return refuse('record-write', 'charter adf vendor status', fill(MSG.restoreRecord, { file, manifestPath })); + } + try { + fs.writeFileSync(path.resolve(file), next); + } catch (err: unknown) { + // Revert the entry just added by putting the manifest's previous bytes back, + // only if nothing else has touched it since. + let note = ''; + if (after !== null && before !== null) { + let reverted = false; + try { + if (readText(manifestPath) === after) { + fs.writeFileSync(manifestPath, before); + reverted = true; + } + } catch { + reverted = false; + } + note = reverted ? MSG.restoreReverted : MSG.restoreNotReverted; + } + const error = err instanceof Error ? err.message : String(err); + return refuse('record-write', `charter adf vendor restore ${file}`, fill(MSG.restoreWriteFailed, { file, error, note })); + } + print(fill(MSG.restoreOk, { file, kept }), false); + return { file, status: 'restored', refusal: null, keptSections, remedy: null }; +} + +function vendorRestore(options: CLIOptions, aiDir: string, rest: string[], dryRun: boolean): number { + if (rest.length !== 1) throw new CLIError(MSG.usageRestore); + const [file] = rest; + if (!isVendorFile(file)) throw new CLIError(fill(MSG.unknownFile, { file })); + const json = options.format === 'json'; + const warnings: string[] = []; + const result = restoreVendorFile(aiDir, file, { + dryRun, + onWarning: w => warnings.push(w), + // JSON stdout stays one document: text lines go to stderr there. + print: (line, isWarning) => (json || isWarning ? console.error(line) : console.log(line)), + }); + for (const w of warnings) console.error(json ? `Warning: ${w}` : ` Warning: ${w}`); + if (json) console.log(JSON.stringify(result, null, 2)); + return result.status === 'refused' ? EXIT_CODE.POLICY_VIOLATION : EXIT_CODE.SUCCESS; +} diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index d271046..797bcd7 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -972,9 +972,9 @@ function printHelp(): void { console.log(' --force: with --write, overwrite hand-authored content (never changes a file\'s mode)'); console.log(' Default (no --write/--check): print to stdout (single target only)'); console.log(''); - console.log(' charter adf vendor | init> [--ai-dir ] [--dry-run]'); + console.log(' charter adf vendor | init | restore > [--ai-dir ] [--dry-run]'); console.log(' Show or record whether each vendor file is a pointer, compiled output, or unmanaged'); - console.log(' (VENDORS in manifest.adf). adf compile and adf tidy act on it.'); + console.log(' (VENDORS in manifest.adf). restore rewrites one pointer, refusing on any content loss.'); console.log(''); console.log(' charter adf suggest [--min-occurrences ] [--window-minutes ] [--ai-dir ]'); console.log(' [--emit-ops ]'); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index ba60304..05251d7 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -46,9 +46,11 @@ import { manifestForPreset, } from './adf'; import { loadPatterns } from '../config'; -import { parseAdf, parseManifest } from '@stackbilt/adf'; +import { parseAdf, parseManifest, TARGET_FILENAMES } from '@stackbilt/adf'; +import type { CompileTarget } from '@stackbilt/adf'; import { discoverAgentConfigFiles, migrateSource, updateModuleIndex } from './adf-migrate'; -import { recordWrittenVendorModes } from './adf-vendor'; +import { inferVendorMode, readVendorRecord, recordedModeOf, recordVendorModes, recordWrittenVendorModes, resolveVendorMode } from './adf-vendor'; +import type { VendorFile } from './adf-vendor'; import { registerModules } from './adf-manifest-register'; import type { SourceMigrationResult } from './adf-migrate'; @@ -975,9 +977,17 @@ function runAdfInitPhase( try { const aiDir = '.ai'; + // --force re-scaffolds manifest.adf (backing it up), which would drop the + // VENDORS record the pointer and migrate phases act on (#318). Carry its valid + // entries over through the shared writer, add-if-absent. + const priorRecord = readVendorRecord(path.join(aiDir, 'manifest.adf')).recorded; const scaffoldResult = writeAdfScaffolds(aiDir, force, preset); files.push(...scaffoldResult.files); warnings.push(...scaffoldResult.warnings); + if (priorRecord.size > 0) { + const entries = [...priorRecord].sort((a, b) => a[1].index - b[1].index).map(([file, { mode }]) => ({ file, mode })); + recordVendorModes(path.join(aiDir, 'manifest.adf'), entries, 'add-if-absent', { onWarning: w => warnings.push(w) }); + } // Detect orphaned ADF modules not registered in manifest (#65) const manifestPath2 = path.join(aiDir, 'manifest.adf'); @@ -1014,12 +1024,24 @@ function runAdfInitPhase( const writtenPointers: string[] = []; for (const pf of pointerFiles) { const pointerPath = path.resolve(pf.name); - const exists = fs.existsSync(pointerPath); - if (!exists || force) { + const action = pointerAction(aiDir, pf.name as VendorFile, force); + if (action === 'write-pointer') { fs.writeFileSync(pointerPath, pf.content); pointers.push(pf.label); writtenPointers.push(pf.name); - } else if (!isAlreadyThinPointer(pointerPath)) { + } else if (action === 'skip-compiled' || action === 'skip-recorded-compiled-absent') { + const target = (Object.keys(TARGET_FILENAMES) as CompileTarget[]).find(t => TARGET_FILENAMES[t] === pf.name) ?? 'all'; + const source = recordedModeOf(aiDir, pf.name as VendorFile) === 'compiled' ? 'recorded' : 'compile banner'; + warnings.push(action === 'skip-compiled' + ? fill(BOOTSTRAP_MSG.skipCompiled, { file: pf.name, source }) + : fill(BOOTSTRAP_MSG.skipRecordedCompiledAbsent, { file: pf.name, target })); + } else if (action === 'skip-recorded-unmanaged') { + warnings.push(fill(BOOTSTRAP_MSG.skipRecordedUnmanaged, { file: pf.name })); + } else if (action === 'skip-custom' && force) { + // --force no longer overwrites a hand-authored file: vendor files are not + // backed up, so its rules would be gone before phase 4 could migrate them. + warnings.push(fill(BOOTSTRAP_MSG.skipCustomForce, { file: pf.name })); + } else if (action === 'skip-custom') { // File has custom content — don't overwrite. Name the narrow remedy rather // than sending the user to --force, which also re-scaffolds .ai/*.adf and // overwrites custom modules (#305). --source scopes migration to the one @@ -1080,11 +1102,17 @@ function runMigratePhase( const aiDir = '.ai'; try { - // Find agent config files that aren't already thin pointers + // Migrate hand-authored files with no recorded mode (#318). A file recorded + // unmanaged is left alone with a note; any other recorded file is not a source. const sources = discoverAgentConfigFiles().filter(f => { const fullPath = path.resolve(f); const content = fs.readFileSync(fullPath, 'utf-8'); - return !CHARTER_OWNED_MARKERS.some(marker => content.includes(marker)); + if (CHARTER_OWNED_MARKERS.some(marker => content.includes(marker))) return false; + const resolved = resolveVendorMode(aiDir, f as VendorFile); + if (resolved.source === 'recorded' && resolved.mode === 'unmanaged') { + warnings.push(fill(BOOTSTRAP_MSG.migrateRecordedUnmanaged, { file: f })); + } + return resolved.source === 'inferred'; }); if (sources.length === 0) { @@ -1399,16 +1427,52 @@ function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); } +export const BOOTSTRAP_MSG = { + skipCompiled: '{file} is compiled output from .ai/ ({source}); bootstrap will not replace it with a pointer. To make it a pointer: charter adf vendor set {file} pointer && charter adf vendor restore {file}', + skipCustomForce: "{file} has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Run 'charter adf migrate --source {file}' to move its rules into .ai/ first.", + skipRecordedUnmanaged: '{file} is recorded as unmanaged; leaving it as is.', + skipRecordedCompiledAbsent: '{file} is recorded as compiled but is not on disk; not writing a pointer. Generate it: charter adf compile --target {target} --write', + migrateRecordedUnmanaged: '{file} is recorded as unmanaged; not migrating. To migrate it anyway: charter adf migrate --source {file}', +} as const; + +function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => values[key] ?? whole); +} + +export type BootstrapPointerAction = + | 'write-pointer' + | 'keep-managed' + | 'skip-compiled' + | 'skip-custom' + | 'skip-recorded-unmanaged' + | 'skip-recorded-compiled-absent'; + /** - * Check if a file is already a thin ADF pointer. + * Phase 3's decision for one pointer file (#318). `record` is the valid VENDORS + * entry for the file's physical file; the evidence is the file at the repo root. + * The first matching row wins: + * recorded compiled → skip-compiled (absent: skip-recorded-compiled-absent) + * compile banner on disk → skip-compiled + * recorded unmanaged → skip-recorded-unmanaged + * absent → write-pointer + * no Charter marker (custom) → skip-custom, with or without --force + * pointer markers → keep-managed, or write-pointer under --force */ -function isAlreadyThinPointer(filePath: string): boolean { +export function pointerAction(aiDir: string, file: VendorFile, force: boolean): BootstrapPointerAction { + const record = recordedModeOf(aiDir, file); + let content: string | null = null; try { - const content = fs.readFileSync(filePath, 'utf-8'); - return CHARTER_OWNED_MARKERS.some(marker => content.includes(marker)); + content = fs.readFileSync(path.resolve(file), 'utf-8'); } catch { - return false; + content = null; } + const ev = inferVendorMode(file, content); + if (record === 'compiled') return ev.exists ? 'skip-compiled' : 'skip-recorded-compiled-absent'; + if (ev.banner) return 'skip-compiled'; + if (record === 'unmanaged') return 'skip-recorded-unmanaged'; + if (!ev.exists) return 'write-pointer'; + if (ev.pointerMarkers.length === 0) return 'skip-custom'; + return force ? 'write-pointer' : 'keep-managed'; } /** diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 4af0d6d..383bbab 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -13,7 +13,11 @@ import { parseAdf, parseManifest, stripCharterSentinels, evaluateLocBudgets, mat import type { LocBudgetRule } from '@stackbilt/adf'; import { isGitRepo, runGit } from '../git-helpers'; import { CHARTER_OWNED_MARKERS } from './adf'; -import { COMPILE_BANNER_MARKER } from '@stackbilt/adf'; +import { TARGET_FILENAMES } from '@stackbilt/adf'; +import type { CompileTarget } from '@stackbilt/adf'; +import { countExtractable } from './adf-tidy'; +import { readVendorRecord, resolveVendorModes, vendorFileGroups } from './adf-vendor'; +import type { ModeSource, VendorEntryProblem, VendorFile, VendorMode } from './adf-vendor'; import { checkGateEnforcement } from './doctor-gate-enforcement'; import { findMissingDefaultLoad, formatMissingDefaultLoad, REQUIRED_DEFAULT_LOAD_MODULES } from './adf-wiring'; @@ -23,6 +27,8 @@ interface DoctorResult { name: string; status: 'PASS' | 'WARN' | 'INFO'; details: string; + /** Only on the `adf vendor mode` check (#318). */ + rows?: VendorModeRow[]; }>; } @@ -130,6 +136,180 @@ function checkTelemetryTracking(configPath: string): DoctorResult['checks'][numb }; } +// ============================================================================ +// adf vendor mode (#318) +// ============================================================================ + +export const DOCTOR_MSG = { + allRecorded: 'All {n} vendor file(s) have a recorded mode that matches the file on disk.', + inferred: '{n} vendor file(s) have no recorded mode ({files}); modes are inferred from file content. Record them with: charter adf vendor init', + // The content-preserving fix comes first, as in `adf compile --check`: regenerating + // a recorded-compiled file that now looks like a pointer or hand-authored file + // discards what it holds, and an unmanaged one also needs --force. + driftCompiled: '{file} is recorded as compiled but looks {inferred} on disk. Accept the file: charter adf vendor set {file} {inferred} (or regenerate it: charter adf compile --target {target} --write{force}{discards})', + driftPointer: '{file} is recorded as a pointer but looks {inferred} on disk. Accept the file: charter adf vendor set {file} {inferred} (or rewrite the pointer: charter adf vendor restore {file})', + recordedAbsent: '{file} is recorded as {recorded} but is not on disk. Recreate it: {recreate} (or stop tracking it: charter adf vendor set {file} unmanaged)', + entryProblem: "VENDORS entry '{raw}' in {manifestPath} is ignored ({problem}). Fix or delete that line in {manifestPath}.", + entryDuplicate: "VENDORS lists {file} more than once in {manifestPath}; the last entry ('{raw}') wins. Delete the others.", + alias: 'VENDORS records both {file} and {other}, which are the same file on disk; {file} is used. Delete the {other} line.', + legacySpelling: "VENDORS records the legacy name agents.md for the file AGENTS.md; `adf compile --target agents --mode compiled` cannot switch it. Change the line to ' - AGENTS.md: {mode}' (an automatic rename comes with #302).", + sectionNotList: 'VENDORS in {manifestPath} does not read as a list, so every vendor mode is inferred. Every line under it must look like " - CLAUDE.md: pointer".', + agentConfigPass: '{pointers} thin pointer(s) and {compiled} compiled file(s) route to .ai/{unmanagedNote}.', + unmanagedNote: '; {n} file(s) recorded unmanaged are left alone', +} as const; + +/** The order `adf agent config` has always listed vendor files in. */ +const AGENT_CONFIG_ORDER: readonly string[] = ['CLAUDE.md', '.cursorrules', 'agents.md', 'AGENTS.md', 'GEMINI.md', 'copilot-instructions.md']; + +export type VendorModeRowKind = + | 'recorded-ok' + | 'recorded-drift' + | 'recorded-absent' + | 'recorded-unmanaged-absent' + | 'inferred' + | 'entry-problem' + | 'alias-recorded' + | 'legacy-spelling' + | 'section-not-list'; + +type CheckStatus = 'PASS' | 'WARN' | 'INFO'; + +export const ROW_STATUS: Record = { + 'recorded-ok': 'PASS', + 'recorded-drift': 'WARN', + 'recorded-absent': 'WARN', + 'recorded-unmanaged-absent': 'PASS', + inferred: 'INFO', + 'entry-problem': 'WARN', + 'alias-recorded': 'INFO', + 'legacy-spelling': 'INFO', + 'section-not-list': 'WARN', +}; + +/** An explicit entry being ignored WARNs; a duplicate resolves deterministically (last wins). */ +const ENTRY_PROBLEM_STATUS: Record = { + malformed: 'WARN', + 'unknown-file': 'WARN', + 'mode-not-allowed': 'WARN', + duplicate: 'INFO', +}; + +export interface VendorModeRow { + kind: VendorModeRowKind; + status: CheckStatus; + file: VendorFile | null; + recordedMode: VendorMode | null; + /** null iff the file is absent. */ + inferredMode: VendorMode | null; + source: ModeSource | null; + /** The VENDORS item text, for entry problems. */ + raw: string | null; + remedy: string | null; +} + +function fill(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in values ? String(values[key]) : whole)); +} + +const WORST: CheckStatus[] = ['WARN', 'INFO', 'PASS']; + +function compileTargetOf(file: VendorFile): string { + return (Object.keys(TARGET_FILENAMES) as CompileTarget[]).find(t => TARGET_FILENAMES[t] === file) ?? 'all'; +} + +/** + * One row per vendor file (per physical file) plus one per record problem. + * Read-only: doctor never writes the record or a vendor file (INV-P3-1). WARN + * only where an explicit record is contradicted or ignored (INV-P3-3). + */ +export function vendorModeRows(aiDir: string, manifestPath: string): Array<{ row: VendorModeRow; detail: string | null }> { + const record = readVendorRecord(manifestPath); + // Key order follows the row schema: kind, status, file, recordedMode, inferredMode, source, raw, remedy. + const base: VendorModeRow = { kind: 'recorded-ok', status: 'PASS', file: null, recordedMode: null, inferredMode: null, source: null, raw: null, remedy: null }; + if (record.readProblem === 'section-not-list') { + return [{ row: { ...base, kind: 'section-not-list', status: 'WARN' }, detail: fill(DOCTOR_MSG.sectionNotList, { manifestPath }) }]; + } + const out: Array<{ row: VendorModeRow; detail: string | null }> = []; + for (const r of resolveVendorModes(aiDir)) { + const ev = r.evidence; + const row: VendorModeRow = { + ...base, kind: 'recorded-ok', status: 'PASS', file: r.file, inferredMode: ev.inferredMode, source: r.source, + recordedMode: r.source === 'recorded' ? r.mode : null, + }; + let detail: string | null = null; + const target = compileTargetOf(r.file); + if (r.source === 'inferred') { + row.kind = 'inferred'; + row.remedy = 'charter adf vendor init'; + } else if (r.mode === 'unmanaged') { + row.kind = ev.exists ? 'recorded-ok' : 'recorded-unmanaged-absent'; + } else if (!ev.exists) { + row.kind = 'recorded-absent'; + row.remedy = r.mode === 'compiled' ? `charter adf compile --target ${target} --write` : `charter adf vendor restore ${r.file}`; + detail = fill(DOCTOR_MSG.recordedAbsent, { file: r.file, recorded: r.mode, recreate: row.remedy }); + } else if (r.disagrees && ev.inferredMode) { + row.kind = 'recorded-drift'; + row.remedy = `charter adf vendor set ${r.file} ${ev.inferredMode}`; + if (r.mode === 'compiled') { + const n = ev.inferredMode === 'pointer' ? countExtractable(r.file, aiDir) : 0; + const discards = ev.inferredMode === 'unmanaged' ? '; discards its hand-authored content' + : n > 0 ? `; discards ${n} item(s) not in ${aiDir}/` : ''; + detail = fill(DOCTOR_MSG.driftCompiled, { + file: r.file, inferred: ev.inferredMode, target, force: ev.inferredMode === 'unmanaged' ? ' --force' : '', discards, + }); + } else { + detail = fill(DOCTOR_MSG.driftPointer, { file: r.file, inferred: ev.inferredMode }); + } + } + row.status = ROW_STATUS[row.kind]; + out.push({ row, detail }); + } + + const inferred = out.filter(o => o.row.kind === 'inferred'); + if (inferred.length > 0) { + // One line for every inferred file: they share one remedy. + inferred[0].detail = fill(DOCTOR_MSG.inferred, { n: inferred.length, files: inferred.map(o => o.row.file).join(', ') }); + } + + for (const p of record.problems) { + const status = ENTRY_PROBLEM_STATUS[p.problem]; + const detail = p.problem === 'duplicate' + ? fill(DOCTOR_MSG.entryDuplicate, { file: p.file ?? '', manifestPath, raw: p.raw }) + : fill(DOCTOR_MSG.entryProblem, { raw: p.raw, manifestPath, problem: p.problem }); + out.push({ row: { ...base, kind: 'entry-problem', status, file: p.file, raw: p.raw }, detail }); + } + + for (const group of vendorFileGroups()) { + const names = group.filter(n => record.recorded.has(n)); + if (names.length > 1) { + for (const other of names.slice(1)) { + const row: VendorModeRow = { ...base, kind: 'alias-recorded', status: 'INFO', file: other, recordedMode: record.recorded.get(other)?.mode ?? null, source: 'recorded' }; + out.push({ row, detail: fill(DOCTOR_MSG.alias, { file: names[0], other }) }); + } + } else if (names[0] === 'agents.md' && group.includes('AGENTS.md')) { + const mode = record.recorded.get('agents.md')?.mode ?? 'pointer'; + const row: VendorModeRow = { ...base, kind: 'legacy-spelling', status: 'INFO', file: 'agents.md', recordedMode: mode, source: 'recorded' }; + out.push({ row, detail: fill(DOCTOR_MSG.legacySpelling, { mode }) }); + } + } + return out; +} + +/** The `adf vendor mode` check: worst row status, one detail line per non-PASS row. */ +function checkVendorModes(aiDir: string, manifestPath: string): DoctorResult['checks'][number] | undefined { + const entries = vendorModeRows(aiDir, manifestPath); + if (entries.length === 0) return undefined; + const rows = entries.map(e => e.row); + const status = WORST.find(s => rows.some(r => r.status === s)) ?? 'PASS'; + const lines = entries.filter(e => e.row.status !== 'PASS' && e.detail).map(e => e.detail as string); + return { + name: 'adf vendor mode', + status, + details: lines.length > 0 ? lines.join('\n ') : fill(DOCTOR_MSG.allRecorded, { n: rows.length }), + rows, + }; +} + export async function doctorCommand(options: CLIOptions, args: string[] = []): Promise { const checks: DoctorResult['checks'] = []; const adfOnly = args.includes('--adf-only'); @@ -303,36 +483,42 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P : `Missing or unparseable: ${onDemandIssues.join(', ')}`, }); - // Agent config pointer check: flag files with stack rules that should be in .ai/ - const AGENT_CONFIG_FILES = ['CLAUDE.md', '.cursorrules', 'agents.md', 'AGENTS.md', 'GEMINI.md', 'copilot-instructions.md']; - const nonPointerFiles: string[] = []; - const pointerFiles: Array<{ file: string; content: string }> = []; - for (const file of AGENT_CONFIG_FILES) { - if (fs.existsSync(file)) { - const content = fs.readFileSync(file, 'utf-8'); - const isPointer = CHARTER_OWNED_MARKERS.some(marker => content.includes(marker)); - if (!isPointer) { - nonPointerFiles.push(file); - } else { - pointerFiles.push({ file, content }); - } - } - } + // Vendor file modes: the VENDORS record against the markers on disk (#318). + const vendorModeCheck = checkVendorModes(aiDir, manifestPath); + if (vendorModeCheck) checks.push(vendorModeCheck); + + // Agent config pointer check: flag files with stack rules that should be in .ai/. + // One entry per physical file (AGENTS.md and agents.md can be one file), in + // the order this check has always listed them. A file recorded unmanaged is + // the user's own and is left alone (#318). + const vendorFiles = resolveVendorModes(aiDir) + .filter(r => r.evidence.exists) + .sort((a, b) => AGENT_CONFIG_ORDER.indexOf(a.file) - AGENT_CONFIG_ORDER.indexOf(b.file)); + const recordedUnmanaged = vendorFiles.filter(r => r.source === 'recorded' && r.mode === 'unmanaged'); + const managed = vendorFiles.filter(r => !recordedUnmanaged.includes(r)); + // Same ownership test as before the record: no banner and no pointer marker. + const nonPointerFiles = managed.filter(r => !r.evidence.banner && r.evidence.pointerMarkers.length === 0).map(r => r.file); + // Bloat and cold start look at pointers only: resolved pointer, and pointer-shaped on disk. + const pointerFiles = managed + .filter(r => r.mode === 'pointer' && r.evidence.inferredMode === 'pointer') + .map(r => ({ file: r.file, content: fs.readFileSync(r.file, 'utf-8') })); if (nonPointerFiles.length > 0) { checks.push({ name: 'adf agent config', status: 'WARN', details: `${nonPointerFiles.join(', ')} contain${nonPointerFiles.length === 1 ? 's' : ''} stack rules that should live in .ai/. Run: charter adf migrate --dry-run`, }); - } else { - const pointerCount = pointerFiles.length; - if (pointerCount > 0) { - checks.push({ - name: 'adf agent config', - status: 'PASS', - details: `${pointerCount} agent config file(s) are thin pointers to .ai/.`, - }); - } + } else if (vendorFiles.length > 0) { + const compiled = managed.filter(r => r.evidence.banner).length; + checks.push({ + name: 'adf agent config', + status: 'PASS', + details: fill(DOCTOR_MSG.agentConfigPass, { + pointers: managed.length - compiled, + compiled, + unmanagedNote: recordedUnmanaged.length > 0 ? fill(DOCTOR_MSG.unmanagedNote, { n: recordedUnmanaged.length }) : '', + }), + }); } // Vendor file bloat detection: line count, section overlap, keyword density @@ -363,8 +549,8 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P } for (const { file, content } of pointerFiles) { - // Compile-output files contain ADF content by design — skip bloat scan. - if (content.includes(COMPILE_BANNER_MARKER)) continue; + // Compile output never reaches here (pointerFiles is pointers only); a + // record that contradicts the file is `adf vendor mode`'s to report. // Strip charter-managed sentinel blocks before scanning for bloat/keywords. const strippedContent = stripCharterSentinels(content); diff --git a/packages/cli/src/commands/score.ts b/packages/cli/src/commands/score.ts index 0ac5a2b..45ae30f 100644 --- a/packages/cli/src/commands/score.ts +++ b/packages/cli/src/commands/score.ts @@ -7,15 +7,16 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { parseAdf, parseManifest } from '@stackbilt/adf'; -import type { AdfDocument } from '@stackbilt/adf'; +import { compileAdf, parseAdf, parseManifest } from '@stackbilt/adf'; +import type { AdfDocument, CompileTarget } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { EXIT_CODE } from '../index'; import { getFlag } from '../flags'; import { detectPackageManager, inferProjectName, loadPackageContexts } from './setup'; import { hasCommits, isGitRepo, runGit } from '../git-helpers'; import { CHARTER_OWNED_MARKERS } from './adf'; -import { withoutVendorsSection } from './adf-vendor'; +import { resolveVendorMode, withoutVendorsSection } from './adf-vendor'; +import type { ModeSource, VendorFile, VendorMode } from './adf-vendor'; type Grade = 'A' | 'B' | 'C' | 'D' | 'F'; type BadgeColor = 'brightgreen' | 'green' | 'yellowgreen' | 'yellow' | 'orange' | 'red'; @@ -61,11 +62,24 @@ interface ScoreCategory { summary: string; } +/** + * One scored vendor file. mode, source and substantiveVia are additive (#318): + * a pointer is as substantive as the compiled file it routes to. + */ +interface AgentFileSignal { + exists: boolean; + substantive: boolean; + /** null when absent, or for a file with no mode (.cursor/rules/*). */ + mode: VendorMode | null; + source: ModeSource | null; + substantiveVia: 'file' | 'ai-dir' | 'none'; +} + interface ScoreSignals { agentConfig: { - claude: { exists: boolean; substantive: boolean }; - cursorRules: { exists: boolean; substantive: boolean; files: string[] }; - agents: { exists: boolean; substantive: boolean; file?: string }; + claude: AgentFileSignal; + cursorRules: AgentFileSignal & { files: string[] }; + agents: AgentFileSignal & { file?: string }; manifest: { exists: boolean; path: string }; alternateAgentFiles: string[]; }; @@ -289,6 +303,7 @@ const MAX_TEXT_FILE_BYTES = 256 * 1024; const GIT_TIMESTAMP_FILE_LIMIT = 4000; const MS_PER_DAY = 24 * 60 * 60 * 1000; const CI_MIN_SCORE = 60; +export const SCORE_POPULATE_AI_DIR = '`{file}` routes to `{aiDir}/`, which has little project-specific content yet. Add repo commands and guardrails to `{aiDir}/core.adf` (or run: charter adf populate).'; export async function scoreCommand(options: CLIOptions, args: string[] = []): Promise { if (args.includes('--help') || args.includes('-h')) { @@ -376,13 +391,18 @@ function buildScoreReport(inventory: RepoInventory, aiDir: string): ScoreReport const alternateAgentFiles = AGENT_FILES.filter((file) => inventory.fileSet.has(file) && file !== 'CLAUDE.md' && !/^agents\.md$/i.test(file)); const claudeContent = readText('CLAUDE.md'); const claudeExists = inventory.fileSet.has('CLAUDE.md'); - const claudeSubstantive = claudeExists && isSubstantiveInstruction(claudeContent); + const claude = agentFileSignal(claudeExists ? 'CLAUDE.md' : null, claudeContent, 'claude', aiDir); + const claudeSubstantive = claude.substantive; const cursorFiles = inventory.fileSet.has('.cursorrules') ? ['.cursorrules'] : inventory.files.filter((file) => file.startsWith('.cursor/rules/')); const cursorContent = cursorFiles.map((file) => readText(file)).join('\n'); const cursorExists = cursorFiles.length > 0; - const cursorSubstantive = cursorExists && isSubstantiveInstruction(cursorContent); + // Only .cursorrules has a mode; .cursor/rules/* is scored on its text as before. + const cursor = cursorFiles[0] === '.cursorrules' + ? agentFileSignal('.cursorrules', cursorContent, 'cursor', aiDir) + : agentFileSignal(null, cursorContent, 'cursor', aiDir, cursorExists); + const cursorSubstantive = cursor.substantive; const agentsFile = inventory.fileSet.has('AGENTS.md') ? 'AGENTS.md' : inventory.fileSet.has('agents.md') @@ -390,7 +410,8 @@ function buildScoreReport(inventory: RepoInventory, aiDir: string): ScoreReport : undefined; const agentsContent = agentsFile ? readText(agentsFile) : ''; const agentsExists = !!agentsFile; - const agentsSubstantive = agentsExists && isSubstantiveInstruction(agentsContent); + const agents = agentFileSignal((agentsFile as VendorFile | undefined) ?? null, agentsContent, 'agents', aiDir); + const agentsSubstantive = agents.substantive; const manifestExists = manifestPathInInventory ? inventory.fileSet.has(manifestPathInInventory) : fileExists(manifestPath); @@ -611,7 +632,7 @@ function buildScoreReport(inventory: RepoInventory, aiDir: string): ScoreReport 'Agent config', agentConfigScore, 25, - agentConfigSummary(claudeExists, claudeSubstantive, cursorFiles, cursorSubstantive, agentsFile, agentsSubstantive, manifestExists, manifestPath), + agentConfigSummary(claude, cursorFiles, cursorSubstantive, agentsFile, agentsSubstantive, manifestExists, manifestPath, aiDir), ), createCategory( 'grounding', @@ -664,6 +685,7 @@ function buildScoreReport(inventory: RepoInventory, aiDir: string): ScoreReport manifestPath, claudeExists, claudeSubstantive, + claudeMode: claude.mode, cursorFiles, agentsFile, manifestExists, @@ -683,9 +705,9 @@ function buildScoreReport(inventory: RepoInventory, aiDir: string): ScoreReport }), signals: { agentConfig: { - claude: { exists: claudeExists, substantive: claudeSubstantive }, - cursorRules: { exists: cursorExists, substantive: cursorSubstantive, files: cursorFiles }, - agents: { exists: agentsExists, substantive: agentsSubstantive, file: agentsFile }, + claude, + cursorRules: { ...cursor, files: cursorFiles }, + agents: { ...agents, file: agentsFile }, manifest: { exists: manifestExists, path: manifestPath }, alternateAgentFiles, }, @@ -1082,6 +1104,38 @@ function hasDocumentedConstraints(content: string): boolean { || /\b(non-negotiable|must always|never do|guardrails?)\b/i.test(content); } +/** + * Score one vendor file (#318). Unmanaged or compiled: its own text, as before. + * Pointer: its text OR the output compiling its target would give, so a pointer + * is as substantive as the compiled file it routes to. Monotonic: never less + * substantive than the text alone (INV-P3-7). The compile runs in memory with a + * read-only reader, so nothing is written; a compile error leaves the term false. + */ +function agentFileSignal( + file: VendorFile | null, + content: string, + target: CompileTarget, + aiDir: string, + existsWithoutMode = false, +): AgentFileSignal { + if (!file) { + const substantive = existsWithoutMode && isSubstantiveInstruction(content); + return { exists: existsWithoutMode, substantive, mode: null, source: null, substantiveVia: substantive ? 'file' : 'none' }; + } + const { mode, source } = resolveVendorMode(aiDir, file); + if (isSubstantiveInstruction(content)) return { exists: true, substantive: true, mode, source, substantiveVia: 'file' }; + if (mode === 'pointer') { + let compiled = ''; + try { + compiled = compileAdf({ target, aiDir, readFile: (p: string) => fs.readFileSync(p, 'utf-8') }).output; + } catch { + compiled = ''; + } + if (isSubstantiveInstruction(compiled)) return { exists: true, substantive: true, mode, source, substantiveVia: 'ai-dir' }; + } + return { exists: true, substantive: false, mode, source, substantiveVia: 'none' }; +} + function isSubstantiveInstruction(content: string): boolean { if (!content) return false; let normalized = content; @@ -1352,19 +1406,19 @@ function toCategoryStatus(score: number, max: number): CategoryStatus { } function agentConfigSummary( - claudeExists: boolean, - claudeSubstantive: boolean, + claude: AgentFileSignal, cursorFiles: string[], cursorSubstantive: boolean, agentsFile: string | undefined, agentsSubstantive: boolean, manifestExists: boolean, manifestPath: string, + aiDir: string, ): string { const parts: string[] = []; - parts.push(claudeExists - ? claudeSubstantive ? 'CLAUDE.md is substantive' : 'CLAUDE.md exists but is thin' - : 'CLAUDE.md missing'); + parts.push(!claude.exists ? 'CLAUDE.md missing' + : claude.substantiveVia === 'ai-dir' ? `CLAUDE.md is a thin pointer to ${aiDir}/` + : claude.substantive ? 'CLAUDE.md is substantive' : 'CLAUDE.md exists but is thin'); parts.push(cursorFiles.length > 0 ? cursorSubstantive ? `${cursorFiles[0]} present` : `${cursorFiles[0]} exists but is thin` : '.cursorrules missing'); @@ -1441,6 +1495,7 @@ function buildRecommendations(input: { manifestPath: string; claudeExists: boolean; claudeSubstantive: boolean; + claudeMode: VendorMode | null; cursorFiles: string[]; agentsFile: string | undefined; manifestExists: boolean; @@ -1459,6 +1514,8 @@ function buildRecommendations(input: { freshnessSignals: ScoreSignals['freshness']; }): string[] { const recommendations: RecommendationCandidate[] = []; + // A pointer or compiled CLAUDE.md is never edited by hand (INV-P3-8). + const claudeEditable = input.claudeMode !== 'pointer' && input.claudeMode !== 'compiled'; if (!input.manifestExists) { recommendations.push({ @@ -1480,7 +1537,9 @@ function buildRecommendations(input: { } else if (!input.claudeSubstantive) { recommendations.push({ priority: 6, - text: 'Expand `CLAUDE.md` beyond a thin pointer so it captures the repo-specific commands and guardrails an agent needs on first read.', + text: !claudeEditable + ? SCORE_POPULATE_AI_DIR.replace('{file}', 'CLAUDE.md').replace(/\{aiDir\}/g, input.aiDir) + : 'Expand `CLAUDE.md` beyond a thin pointer so it captures the repo-specific commands and guardrails an agent needs on first read.', }); } @@ -1508,7 +1567,7 @@ function buildRecommendations(input: { if (input.commandSignals.length === 0) { recommendations.push({ priority: 7, - text: 'Document at least one runnable setup/test command in `README.md`, `CLAUDE.md`, or your ADF files.', + text: `Document at least one runnable setup/test command in \`README.md\`, ${claudeEditable ? '`CLAUDE.md`, ' : ''}or your ADF files.`, }); } else if (input.commandSignals.some((signal) => !signal.runnable)) { recommendations.push({ @@ -1527,7 +1586,7 @@ function buildRecommendations(input: { if (!input.constraintsDefined) { recommendations.push({ priority: 7, - text: `Define non-negotiable constraints in \`${input.aiDir}/core.adf\` or \`CLAUDE.md\` so agents have explicit load-bearing rules.`, + text: `Define non-negotiable constraints in \`${input.aiDir}/core.adf\`${claudeEditable ? ' or `CLAUDE.md`' : ''} so agents have explicit load-bearing rules.`, }); } From 1a6d900e38c5a9aaafca6cc83e9e17b786948954 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 16 Oct 2026 06:20:57 -0500 Subject: [PATCH 2/2] fix(adf): vendor restore refuses any content loss; doctor duplicate wording (refs #318) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- docs/cli-reference.md | 4 +- .../__tests__/adf-vendor-doctor-score.test.ts | 128 +++++++++++++++++- packages/cli/src/commands/adf-vendor.ts | 113 ++++++++++++++-- packages/cli/src/commands/adf.ts | 2 +- packages/cli/src/commands/bootstrap.ts | 3 +- packages/cli/src/commands/doctor.ts | 2 +- packages/cli/src/commands/score.ts | 2 +- 8 files changed, 236 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4407e18..58feb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. - **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 `, or `adf vendor set pointer && adf vendor restore ` for compiled output). With `--yes`, a hand-authored file is then migrated into `.ai/` by phase 4 as it would be without `--force`. 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. +- **`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 `, or `adf vendor set pointer && adf vendor restore ` 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. - **`charter doctor` reads each vendor file's mode, and can now fail `--ci` and the pre-commit hook in repos that record modes** (`#318`, part 3) — a new `adf vendor mode` check compares the `VENDORS` record with the files on disk. It WARNs, and so fails `doctor --ci` and the pre-commit hook (`doctor --adf-only --ci`), when a recorded `pointer` or `compiled` file looks like another mode or is missing, when an entry is malformed, names an unknown file or a mode that file cannot have, or when `VENDORS` is not a list. Each line names the fix, the content-preserving one first. A repo without a `VENDORS` section gets one `INFO` line suggesting `charter adf vendor init` and no new WARN. JSON adds `rows` to that check only. `adf agent config` no longer warns about a hand-authored file recorded `unmanaged`, its PASS line counts thin pointers and compiled files separately (compiled output used to be called a thin pointer), and it, `adf vendor bloat` and `adf cold start` check each physical file once, so `AGENTS.md` and `agents.md` on a case-insensitive filesystem are no longer counted twice. Bloat and cold start look at pointers only. - **`charter score` counts a thin pointer as substantive when `.ai/` is** (`#318`, part 3) — score numbers in JSON change, and only upward. A pointer `CLAUDE.md`, `.cursorrules` or `AGENTS.md` used to score as "thin" (−5, and "Expand `CLAUDE.md` beyond a thin pointer") however much its `.ai/` held. It is now substantive when its text is, or when the file `adf compile` would render for it is (compiled in memory; nothing is written). Hand-authored and compiled files are scored on their text as before, so no score goes down. `signals.agentConfig` adds `mode`, `source` and `substantiveVia` per file. Recommendations no longer tell you to edit a pointer or compiled `CLAUDE.md`; a pointer with little to route to gets "Add repo commands and guardrails to `.ai/core.adf` (or run: charter adf populate)". - **`charter adf migrate` without `--source` skips a file recorded `unmanaged`** (`#318`, part 3) — naming the file with `--source` still migrates it, and now changes its entry to `pointer` before the pointer is written (back to `unmanaged` if the migration fails), so doctor reports no drift afterwards. @@ -20,13 +20,13 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Added -- **`charter adf vendor restore `** (`#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, or your own sections in a compiled file. 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. `--dry-run` and `--format json` are supported. +- **`charter adf vendor restore `** (`#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 ` 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`. ### Fixed -- **`charter bootstrap --force` no longer destroys a hand-authored `CLAUDE.md` before it can be migrated** (`#318`, part 3) — phase 3 overwrote every existing pointer-file name with the template under `--force`, before phase 4 (migrate) ran. Vendor files are not backed up (only `.ai/*.adf` go to `.ai/.backup/`), so phase 4 then found a pointer, migrated nothing, and the file's rules were gone with no `.pre-adf-migrate.bak`. The file is now skipped in phase 3, and with `--yes` phase 4 migrates it into `.ai/` and writes the backup. +- **`charter bootstrap --force` no longer destroys a hand-authored `CLAUDE.md` before it can be migrated** (`#318`, part 3) — phase 3 overwrote every existing pointer-file name with the template under `--force`, before phase 4 (migrate) ran. Vendor files are not backed up (only `.ai/*.adf` go to `.ai/.backup/`), so phase 4 then found a pointer, migrated nothing, and the file's rules were gone with no `.pre-adf-migrate.bak`. The file is now skipped in phase 3, and with `--yes` phase 4 migrates it into `.ai/` and writes the backup (when it has no `VENDORS` entry). - **`charter adf compile --check` and `charter adf tidy` read each vendor file's mode** (`#318`, part 2; `#302`) — `--check` compared every target byte-for-byte against compiled output, so a thin pointer was reported `[stale]` forever. Each target is now checked by its mode. A compiled file is `fresh` or `stale` as before. A pointer is `pointer-ok` when `adf tidy` would extract nothing from it, and `pointer-bloated` (exit 1, remedy `charter adf tidy --source `) when it would. An unmanaged file is `skipped-unmanaged` (exit 0). A recorded `compiled` or `pointer` file that looks like another mode on disk is `mode-drift` (exit 1), with both fixes printed: `charter adf vendor set ` to keep the file, or the command that restores it. A missing file is `missing`, and a recorded pointer that is missing names `git checkout -- ` or `adf vendor set unmanaged`. No remedy suggests `compile --write` for a pointer or unmanaged file. JSON keeps `stale` (which now also lists bloated and drifted files), `current`, `missing`, `upToDate` and `nextActions`, and adds `skipped` and `targets`. `adf tidy` processes a file only when it is a pointer: no compile banner, at least one pointer marker, and not recorded otherwise. The banner still wins whatever the record says (`#296`). A file recorded `compiled` is skipped with status `compiled`. Its warning lists the content-preserving fix first (`adf vendor set pointer`, then `adf tidy --source `) and says how many items regenerating would discard; `--check` mode-drift does the same. An `unmanaged` file (recorded, or hand-authored with no marker) now has status `unmanaged` instead of `not-found`, and its warning appears when it is recorded or named with `--source`. A recorded pointer with no marker is skipped rather than guessed at. File rows gain `vendorFile`, `mode`, `source` and `skipReason`. A `--source` path that is not a repo-root vendor name keeps the old marker logic. Tidy now finds files through the shared discovery helper, so `AGENTS.md` and `agents.md` that are one file on a case-insensitive filesystem are walked once, not twice. With no `SYNC` entries, `charter adf sync` now points to `charter adf vendor status` in text and in JSON `nextActions`, rather than ending at "Wrote empty lock file". Record entry problems are still reported only by `adf vendor status`. `doctor`, `score`, `bootstrap` and both pre-commit hooks are unchanged and are later parts of `#318`. - **`charter doctor` now names the fix when `core.adf` or `state.adf` is missing from `DEFAULT_LOAD`** (`#241`) — the `adf required wiring` warning said only `Missing from DEFAULT_LOAD: core.adf`. It now says to move the module from `ON_DEMAND` to `DEFAULT_LOAD` in `.ai/manifest.adf` when it is registered there, or to add it (or run `charter adf create --load default`) when it is not registered at all. The rule itself is unchanged and is filename-only — `core.adf` and `state.adf` are required in `DEFAULT_LOAD`, and module content is not consulted — but it now lives in one shared helper that `doctor` and `bootstrap`'s orphan registration both call, so `adf migrate` can adopt the same classification when it starts registering the modules it creates (`#239`). - **`charter validate --ci` no longer counts a whitespace-only trailer value** (`#340`) — the trailer parser in `@stackbilt/git` matched values with `\s*.+`, which backtracks to accept `Governed-By: `, so a commit the commit-msg gate from `#299` rejects still counted as governed in CI. Values must now contain a non-whitespace character, matching the hook, which sees the message after `git stripspace` has dropped trailing blanks. As in the hook, such a line also ends the terminal trailer block, so trailers above it are not counted either. Whitespace before or after a real value is still accepted and trimmed, as before. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 79b01cc..8dac3d4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -170,7 +170,7 @@ The `adf vendor mode` check compares each vendor file's `VENDORS` entry (see [`c | `inferred` | file with no entry; one line for all of them, naming `charter adf vendor init` | INFO | | `recorded-drift` | recorded `pointer` or `compiled`, but the file looks like another mode. The content-preserving fix (`adf vendor set `) is named first; for a recorded `compiled` file the regenerate command says what it would discard | WARN | | `recorded-absent` | recorded `pointer` or `compiled`, no file. Recreate with `adf vendor restore ` or `adf compile --target --write` | WARN | -| `entry-problem` | a malformed, unknown-file or not-allowed entry (WARN); a duplicate entry, where the last one wins (INFO) | WARN / INFO | +| `entry-problem` | a malformed, unknown-file or not-allowed entry (WARN); a duplicate entry, which names the ignored line to delete because a later entry wins (INFO) | WARN / INFO | | `alias-recorded` | both `AGENTS.md` and `agents.md` recorded for one file | INFO | | `legacy-spelling` | the file `AGENTS.md` recorded under the legacy name `agents.md` | INFO | | `section-not-list` | `VENDORS` does not read as a list | WARN | @@ -645,7 +645,7 @@ npx charter adf vendor restore CLAUDE.md # rewrite one pointer from i - `status`: read-only. A file with a valid entry shows its recorded mode, plus `(recorded; file looks compiled)` when the markers on disk disagree. A file without one shows the mode inferred from its markers, the way `adf tidy` classifies it: the compile banner means `compiled`, a pointer marker means `pointer`, and anything else is `unmanaged`. Malformed, duplicate (the last entry wins), unknown-file and not-allowed entries are reported and ignored. A missing manifest exits 0 with a warning. - `set `: records or replaces one entry, editing only that line. Only `CLAUDE.md`, `AGENTS.md`, `.cursorrules` and `GEMINI.md` may be `compiled`. `agents.md` and `copilot-instructions.md` have no compile target, so `compiled` is refused with exit 1. An unknown file or mode, or a missing manifest, exits 2. The file does not have to exist yet. - `init`: records the inferred mode of every vendor file on disk that has no entry. It never changes an existing entry, and a second run writes nothing. -- `restore `: rewrites one file as its thin pointer template (`CLAUDE.md` gets the hybrid template with the module index filled in, as bootstrap writes it). It runs only when the file's mode is `pointer` (recorded or inferred), or the file is absent with no entry, and it refuses (exit 1, file untouched) whenever content would be lost: `mode-not-pointer` (record the intent first with `vendor set pointer`), `hand-authored` (run `adf migrate --source `), `pointer-bloated` (run `adf tidy --source `), and `retained-divergent` (a compiled file carrying your own `## Environment` or protocol section). Retained sections of a pointer are carried over verbatim and listed. It is the fix for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded before the file is written; if that cannot be recorded, nothing is written (`record-write`), and if the file write fails, the manifest is put back byte for byte. A result identical to the file on disk is `unchanged` and writes nothing. `--dry-run` reports `planned`. An unknown file name exits 2. JSON output: `file`, `status` (`restored`, `planned`, `unchanged`, `refused`), `refusal`, `keptSections`, `remedy`; text lines go to stderr under `--format json`. +- `restore `: rewrites one file as its thin pointer template (`CLAUDE.md` gets the hybrid template with the module index filled in, as bootstrap writes it). It runs only when the file's mode is `pointer` (recorded or inferred), or the file is absent with no entry, and it refuses (exit 1, file untouched) whenever content would be lost: `mode-not-pointer` (record the intent first with `vendor set pointer`), `hand-authored` (run `adf migrate --source `), `pointer-bloated` (run `adf tidy --source `), `retained-divergent` (a compiled file carrying your own `## Environment` or protocol section), and `compiled-differs` (a compiled file that differs from what `.ai/` compiles to now, checked in memory; keep it with `vendor set compiled`). For a pointer, every non-blank line must survive: a line that is not from a Charter pointer template (current or earlier) or the generated module index, and that the new pointer would not carry, is refused as `hand-authored` with the first few lines named. Retained sections of a pointer are carried over verbatim and listed. It is the fix for a recorded pointer that now holds compiled output. A file with no entry gets `pointer` recorded before the file is written; if that cannot be recorded, nothing is written (`record-write`), and if the file write fails, the manifest is put back byte for byte (`write-failed`, with `writeError`). A result identical to the file on disk is `unchanged` and writes nothing. `--dry-run` reports `planned`. An unknown file name exits 2. JSON output: `file`, `status` (`restored`, `planned`, `unchanged`, `refused`), `refusal`, `keptSections`, `remedy`, `writeError`; text lines go to stderr under `--format json`. One physical file has one entry. When two vendor names are the same file (`AGENTS.md` and `agents.md` on a case-insensitive filesystem, or a hard link), every writer records it under the spelling already recorded, or `AGENTS.md` when neither is. `status` shows it once. `set` names the spelling it used. - `--dry-run`: with `set`, `init` or `restore`, report the change without writing diff --git a/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts index ff1b6c8..9daafd2 100644 --- a/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts +++ b/packages/cli/src/__tests__/adf-vendor-doctor-score.test.ts @@ -255,7 +255,21 @@ describe('doctor: adf vendor mode rows', () => { ]); expect(c.status).toBe('WARN'); expect(c.details).toContain(`VENDORS entry 'README.md: pointer' in ${manifestPath} is ignored (unknown-file). Fix or delete that line in ${manifestPath}.`); - expect(c.details).toContain(`VENDORS lists AGENTS.md more than once in ${manifestPath}; the last entry ('AGENTS.md: pointer') wins. Delete the others.`); + expect(c.details).toContain(`VENDORS lists AGENTS.md more than once in ${manifestPath}; 'AGENTS.md: pointer' is ignored because a later entry wins. Delete that line.`); + }); + + it('following the duplicate advice keeps the resolved mode', async () => { + scaffold(vendors('AGENTS.md: pointer', 'AGENTS.md: unmanaged')); + fs.writeFileSync('AGENTS.md', '# Agents I wrote\n'); + const before = vendorCheck((await doctor()).json); + const resolved = before.rows?.find(x => x.file === 'AGENTS.md' && x.kind !== 'entry-problem')?.recordedMode; + expect(resolved).toBe('unmanaged'); + const dup = before.rows?.find(x => x.kind === 'entry-problem'); + expect(before.details).toContain(`'${dup?.raw}' is ignored because a later entry wins. Delete that line.`); + // Delete exactly the line the advice names. + fs.writeFileSync('.ai/manifest.adf', manifestText().replace(` - ${dup?.raw}\n`, '')); + const after = vendorCheck((await doctor()).json); + expect(after.rows?.map(x => [x.kind, x.recordedMode])).toEqual([['recorded-ok', resolved]]); }); it('a duplicate alone is INFO, so --ci passes (worst-row aggregation)', async () => { @@ -433,10 +447,20 @@ describe('score: a pointer is as substantive as the compiled file it routes to', { name: 'hybrid pointer', setup: () => { scaffold(); fs.writeFileSync('CLAUDE.md', POINTER_CLAUDE_MD_HYBRID); fs.writeFileSync('AGENTS.md', POINTER_AGENTS_MD); } }, { name: 'compiled', setup: async () => { scaffold(); fs.writeFileSync('CLAUDE.md', await compiledOutput('claude')); } }, { name: 'unmanaged', setup: () => { scaffold(); fs.writeFileSync('CLAUDE.md', '# Mine\n'); } }, + { + // A monorepo with per-package agent files: the basenames are not unique. + name: 'monorepo', setup: () => { + scaffold(); + fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); + fs.mkdirSync(path.join('packages', 'a'), { recursive: true }); + fs.writeFileSync(path.join('packages', 'a', 'CLAUDE.md'), '# Package a\n'); + fs.writeFileSync(path.join('packages', 'a', 'AGENTS.md'), '# Package a\n'); + }, + }, { name: 'empty .ai/', setup: () => { fs.mkdirSync('.ai'); fs.writeFileSync('.ai/manifest.adf', BASE_MANIFEST); fs.writeFileSync('CLAUDE.md', THIN_CLAUDE); fs.writeFileSync('.cursorrules', POINTER_CURSORRULES); } }, ]; for (const fixture of fixtures) { - for (const f of ['CLAUDE.md', '.cursorrules', 'AGENTS.md']) fs.rmSync(f, { force: true }); + for (const f of ['CLAUDE.md', '.cursorrules', 'AGENTS.md', 'packages']) fs.rmSync(f, { recursive: true, force: true }); fs.rmSync('.ai', { recursive: true, force: true }); await fixture.setup(); const now = await score(); @@ -563,7 +587,7 @@ describe('bootstrap: pointer decision table', () => { expect(await bootstrapCommand({ ...JSON_OPTS, yes: true }, ['--force', '--yes', '--skip-install', '--skip-doctor', '--format', 'json'])).toBe(0); const payload = JSON.parse(out.join('\n')) as { steps: Array<{ name: string; warnings: string[]; details: Record }> }; const init = payload.steps.find(s => s.name === 'adf-init'); - expect(init?.warnings).toContain("CLAUDE.md has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Run 'charter adf migrate --source CLAUDE.md' to move its rules into .ai/ first."); + expect(init?.warnings).toContain("CLAUDE.md has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Phase 4 below migrates its rules into .ai/ and then writes the pointer."); expect(init?.details.pointers).not.toContain('CLAUDE.md (hybrid pointer)'); const migrate = payload.steps.find(s => s.name === 'migrate'); expect(migrate?.details).toMatchObject({ sources: ['CLAUDE.md'], migrated: 1 }); @@ -664,7 +688,7 @@ describe('adf migrate and recorded unmanaged', () => { // 6. adf vendor restore // ============================================================================ -interface RestoreJson { file: string; status: string; refusal: string | null; keptSections: string[]; remedy: string | null } +interface RestoreJson { file: string; status: string; refusal: string | null; keptSections: string[]; remedy: string | null; writeError: string | null } async function restore(file: string, extra: string[] = []): Promise<{ code: number; json: RestoreJson; err: string }> { const r = await run(['vendor', 'restore', file, ...extra, '--format', 'json'], JSON_OPTS); @@ -681,7 +705,7 @@ describe('adf vendor restore', () => { const text = await run(['vendor', 'restore', 'CLAUDE.md']); expect(text.code).toBe(1); expect(text.err).toContain(' [warn] CLAUDE.md is compiled (recorded); restore writes a pointer. Record that intent first: charter adf vendor set CLAUDE.md pointer'); - expect((await restore('AGENTS.md')).json).toEqual({ file: 'AGENTS.md', status: 'refused', refusal: 'mode-not-pointer', keptSections: [], remedy: 'charter adf vendor set AGENTS.md pointer' }); + expect((await restore('AGENTS.md')).json).toEqual({ file: 'AGENTS.md', status: 'refused', refusal: 'mode-not-pointer', keptSections: [], remedy: 'charter adf vendor set AGENTS.md pointer', writeError: null }); expect((await restore('GEMINI.md')).json.refusal).toBe('mode-not-pointer'); expect(snapshot()).toEqual(before); }); @@ -757,7 +781,7 @@ describe('adf vendor restore', () => { scaffold(); const r = await restore('GEMINI.md'); expect(r.code).toBe(0); - expect(r.json).toEqual({ file: 'GEMINI.md', status: 'restored', refusal: null, keptSections: [], remedy: null }); + expect(r.json).toEqual({ file: 'GEMINI.md', status: 'restored', refusal: null, keptSections: [], remedy: null, writeError: null }); expect(read('GEMINI.md')).toBe(POINTER_GEMINI_MD); expect(manifestText()).toBe(vendors('GEMINI.md: pointer')); }); @@ -785,6 +809,11 @@ describe('adf vendor restore', () => { expect(text.err).toMatch(/ {2}\[warn\] Could not write CLAUDE\.md: .+\. Its new mode record was removed again\./); expect(manifestText()).toBe(manifest); expect(read('CLAUDE.md')).toBe(POINTER_CLAUDE_MD); + // JSON tells a failed file write apart from a skipped record (record-write). + const j = await restore('CLAUDE.md'); + expect(j.json.refusal).toBe('write-failed'); + expect(j.json.writeError).toBeTruthy(); + expect(manifestText()).toBe(manifest); } finally { fs.chmodSync('CLAUDE.md', 0o644); } @@ -819,6 +848,93 @@ describe('adf vendor restore', () => { }); }); +describe('adf vendor restore keeps every user line (review round 1)', () => { + /** A bootstrapped repo whose hybrid CLAUDE.md is then edited by `edit`. */ + async function bootstrapped(edit: (text: string) => string): Promise { + await bootstrapCommand({ ...TEXT, yes: true }, ['--yes', '--skip-install', '--skip-doctor']); + const edited = edit(read('CLAUDE.md')); + expect(edited).not.toBe(read('CLAUDE.md')); + fs.writeFileSync('CLAUDE.md', edited); + return edited; + } + + const repros: Array<[string, (t: string) => string, string]> = [ + ['an edited title', t => t.replace('# CLAUDE.md', '# Acme Payments — agent notes'), '# Acme Payments — agent notes'], + ['an extra blockquote line', t => t.replace('> See `.ai/manifest.adf` for the module routing manifest.\n', '> See `.ai/manifest.adf` for the module routing manifest.\n> Escalate prod incidents to #oncall-payments.\n'), '> Escalate prod incidents to #oncall-payments.'], + ['edited Session Start text', t => t.replace(/Call `charter_context`[^\n]*\n/, 'Our CI token lives in 1Password vault Eng-Prod.\n'), 'Our CI token lives in 1Password vault Eng-Prod.'], + ['prose after the module-index end marker', t => t.replace('\n', '\nDeploys go through the release train on Tuesdays.\n'), 'Deploys go through the release train on Tuesdays.'], + ]; + + for (const [name, edit, line] of repros) { + it(`INV-P3-9: refuses a pointer with ${name}, naming the line, and writes nothing`, async () => { + const edited = await bootstrapped(edit); + const before = snapshot(); + const text = await run(['vendor', 'restore', 'CLAUDE.md']); + expect(text.code).toBe(1); + expect(text.err).toContain(` [warn] CLAUDE.md has 1 line(s) the pointer template would drop: '${line}'.`); + expect(text.out).not.toContain('Restored'); + const j = await restore('CLAUDE.md'); + expect(j.json).toMatchObject({ status: 'refused', refusal: 'hand-authored' }); + expect(read('CLAUDE.md')).toBe(edited); + expect(snapshot()).toEqual(before); + }); + } + + it('never reports Module Index as kept; prose under it is refused, not dropped', async () => { + await bootstrapped(t => t.replace('## Module Index\n', '## Module Index\nRead payments.adf first.\n')); + const r = await restore('CLAUDE.md'); + expect(r.json.refusal).toBe('hand-authored'); + expect(r.err).toContain("'Read payments.adf first.'"); + + // An Environment note survives, and only Environment is listed as kept. + await bootstrapped(t => t.replace('', '- Use WSL for builds')); + fs.writeFileSync('CLAUDE.md', `${POINTER_CLAUDE_MD.replace('', '- Use WSL for builds')}`); + const ok = await restore('CLAUDE.md'); + expect(ok.json).toMatchObject({ status: 'restored', keptSections: ['## Environment'] }); + expect(read('CLAUDE.md')).toContain('- Use WSL for builds'); + }); + + it('a successful restore keeps every non-template line of the old file', async () => { + scaffold(); + const old = `${POINTER_CLAUDE_MD.replace('', '- Use WSL for builds\n- Node 20 via fnm')}\n## Release Protocol\n\nTag, then push.\n`; + fs.writeFileSync('CLAUDE.md', old); + expect((await restore('CLAUDE.md')).json.status).toBe('restored'); + const now = read('CLAUDE.md'); + for (const l of ['- Use WSL for builds', '- Node 20 via fnm', '## Release Protocol', 'Tag, then push.']) expect(now).toContain(l); + }); + + it('a pristine pointer from an earlier template version still restores', async () => { + scaffold(); + // POINTER_CLAUDE_MD as first shipped (8cbfd78), and the pre-#155 one without Session Start. + const v1 = '# Project Context\n\n> This project uses [ADF](https://github.com/Stackbilt-dev/charter) for AI agent context management.\n> All stack rules, constraints, and architectural guidance live in `.ai/`.\n> **Do not duplicate ADF rules here.** Only pre-ADF bootstrap content belongs in this file.\n\nSee `.ai/manifest.adf` for the module routing manifest.\n\n## Environment\n\n'; + fs.writeFileSync('CLAUDE.md', v1); + expect((await restore('CLAUDE.md')).json).toMatchObject({ status: 'restored', refusal: null }); + expect(read('CLAUDE.md')).toContain(''); + const v2 = POINTER_CLAUDE_MD.replace(/## Session Start[\s\S]*?(?=## Environment)/, ''); + fs.writeFileSync('CLAUDE.md', v2); + expect((await restore('CLAUDE.md')).json.status).toBe('restored'); + // The old lowercase-titled agents pointer. + fs.writeFileSync('AGENTS.md', '# agents.md\n\n> **DO NOT add instructions to this file.**\n> All agent instructions are managed in `.ai/` by Charter.\n> See `.ai/manifest.adf` for the module routing manifest.\n'); + expect((await restore('AGENTS.md')).json.status).toBe('restored'); + }); + + it('refuses a recorded pointer whose compiled body was hand-edited (compiled-differs); a fresh compile restores', async () => { + scaffold(vendors('CLAUDE.md: pointer')); + const compiled = await compiledOutput('claude'); + const edited = compiled.replace(/\n(?=[^\n]*$)/, '\n- HAND EDIT: call Bob before deploying\n'); + expect(edited).not.toBe(compiled); + fs.writeFileSync('CLAUDE.md', edited); + const before = snapshot(); + const r = await restore('CLAUDE.md'); + expect(r.code).toBe(1); + expect(r.json).toMatchObject({ status: 'refused', refusal: 'compiled-differs', remedy: 'charter adf vendor set CLAUDE.md compiled' }); + expect(r.err).toContain('charter adf compile --target claude --check'); + expect(snapshot()).toEqual(before); + fs.writeFileSync('CLAUDE.md', compiled); + expect((await restore('CLAUDE.md')).json.status).toBe('restored'); + }); +}); + // ============================================================================ // 7. compile messages name restore // ============================================================================ diff --git a/packages/cli/src/commands/adf-vendor.ts b/packages/cli/src/commands/adf-vendor.ts index e6875c7..77be5eb 100644 --- a/packages/cli/src/commands/adf-vendor.ts +++ b/packages/cli/src/commands/adf-vendor.ts @@ -18,8 +18,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { parseAdf, parseManifest, COMPILE_BANNER_MARKER } from '@stackbilt/adf'; -import type { AdfDocument, ManifestVendorEntry } from '@stackbilt/adf'; +import { compileAdf, parseAdf, parseManifest, COMPILE_BANNER_MARKER, TARGET_FILENAMES } from '@stackbilt/adf'; +import type { AdfDocument, CompileTarget, ManifestVendorEntry } from '@stackbilt/adf'; import type { CLIOptions } from '../index'; import { CLIError, EXIT_CODE } from '../index'; import { getFlag } from '../flags'; @@ -27,6 +27,7 @@ import { MODULE_INDEX_END, MODULE_INDEX_START, POINTER_AGENTS_MD, + POINTER_CLAUDE_MD, POINTER_CLAUDE_MD_HYBRID, POINTER_COPILOT_MD, POINTER_CURSORRULES, @@ -151,6 +152,8 @@ export const MSG = { restoreHandAuthored: ' [warn] {file} has hand-authored content that .ai/ does not hold. Move it first: charter adf migrate --source {file}', restoreBloated: ' [warn] {file} carries {n} item(s) beyond the pointer. Fold them into .ai/ first: charter adf tidy --source {file}', restoreRetained: ' [warn] {file} carries your content under {headings}, which the pointer template would drop. Move it into a .ai/ module first.', + restoreLost: ' [warn] {file} has {n} line(s) the pointer template would drop: {lines}. Move them into a .ai/ module (or under ## Environment), or delete them, first.', + restoreCompiledDiffers: ' [warn] {file} differs from what .ai/ compiles to (a hand edit, or .ai/ changed since), and restoring the pointer would lose that difference. Check it with: charter adf compile --target {target} --check. To keep the file as it is: charter adf vendor set {file} compiled', restoreRecord: ' [warn] {file} was not restored: its mode could not be recorded as pointer in {manifestPath}. Fix the manifest or run: charter adf vendor status', restoreWriteFailed: ' [warn] Could not write {file}: {error}.{note}', restoreReverted: ' Its new mode record was removed again.', @@ -747,7 +750,14 @@ function vendorInit(options: CLIOptions, manifestPath: string, dryRun: boolean): // adf vendor restore (#318) // ============================================================================ -export type RestoreRefusal = 'mode-not-pointer' | 'hand-authored' | 'pointer-bloated' | 'retained-divergent' | 'record-write'; +export type RestoreRefusal = + | 'mode-not-pointer' + | 'hand-authored' + | 'pointer-bloated' + | 'retained-divergent' + | 'compiled-differs' + | 'record-write' + | 'write-failed'; export interface RestoreResult { file: VendorFile; @@ -756,6 +766,8 @@ export interface RestoreResult { /** Retained headings carrying the user's own content, carried over verbatim. */ keptSections: string[]; remedy: string | null; + /** Set iff refusal is write-failed: the file write itself failed (as compile --write's writeError). */ + writeError: string | null; } /** The template each vendor name is restored to (CLAUDE.md: the hybrid one bootstrap writes). */ @@ -801,6 +813,64 @@ export function restoredPointer(file: VendorFile, current: string | null, aiDir: : pointer; } +/** + * Lines of earlier Charter pointer templates (before #25 strengthened the + * wording, and the old lowercase agents.md title), plus the generic pointers + * `adf migrate` and `adf tidy` write for a name with no template. A pointer + * made of these lines holds nothing of the user's, so restore may drop them. + */ +const LEGACY_POINTER_LINES = [ + '# Project Context', + '> This project uses [ADF](https://github.com/Stackbilt-dev/charter) for AI agent context management.', + '> All stack rules, constraints, and architectural guidance live in `.ai/`.', + '> **Do not duplicate ADF rules here.** Only pre-ADF bootstrap content belongs in this file.', + '> **Do not duplicate ADF rules here.**', + 'See `.ai/manifest.adf` for the module routing manifest.', + '# Cursor Rules', + 'All rules and constraints are in .ai/ — see .ai/manifest.adf for routing.', + 'Do not add stack rules here. This file exists only as a pointer.', + 'See: .ai/core.adf for universal constraints.', + '# Agent Guidelines', + 'All architectural rules, constraints, and guidance live in `.ai/`.', + 'Module manifest: .ai/manifest.adf', + 'Universal rules: .ai/core.adf', + 'Current state: .ai/state.adf', + 'Do not duplicate rules from .ai/ modules into this file or other agent config files.', + '> All project rules are managed in `.ai/` by Charter.', +]; + +/** Every line some Charter pointer template has ever held (trimmed, non-blank). */ +function charterTemplateLines(): Set { + const templates = [ + POINTER_CLAUDE_MD, POINTER_CLAUDE_MD_HYBRID, POINTER_AGENTS_MD, POINTER_CURSORRULES, POINTER_GEMINI_MD, POINTER_COPILOT_MD, + ...LEGACY_POINTER_LINES, ...VENDOR_FILES.map(name => `# ${name}`), + ]; + return new Set(templates.flatMap(t => t.split('\n')).map(l => l.trim()).filter(Boolean)); +} + +/** The generated module index (between its markers, inclusive) is Charter's, not the user's. */ +function withoutModuleIndexBlock(content: string): string[] { + const out: string[] = []; + let inIndex = false; + for (const line of content.split('\n')) { + const t = line.trim(); + if (t === MODULE_INDEX_START) inIndex = true; + if (!inIndex) out.push(t); + if (t === MODULE_INDEX_END) inIndex = false; + } + return out; +} + +/** + * The non-blank lines of `current` that `next` would not keep: not in `next`, + * not from any Charter pointer template, and not the generated module index. + */ +export function linesRestoreWouldDrop(current: string, next: string): string[] { + const kept = new Set(next.split('\n').map(l => l.trim())); + const template = charterTemplateLines(); + return withoutModuleIndexBlock(current.replace(/\r\n/g, '\n')).filter(l => l && !kept.has(l) && !template.has(l)); +} + function readText(p: string): string | null { try { return fs.readFileSync(p, 'utf-8'); @@ -833,7 +903,7 @@ export function restoreVendorFile(aiDir: string, file: VendorFile, options: Rest const handAuthored = ev.inferredMode === 'unmanaged' && (current ?? '').trim() !== ''; const refuse = (refusal: RestoreRefusal, remedy: string | null, line: string): RestoreResult => { print(line, true); - return { file, status: 'refused', refusal, keptSections: [], remedy }; + return { file, status: 'refused', refusal, keptSections: [], remedy, writeError: null }; }; const migrateRemedy = `charter adf migrate --source ${file}`; @@ -852,19 +922,44 @@ export function restoreVendorFile(aiDir: string, file: VendorFile, options: Rest if (divergent.length > 0) { return refuse('retained-divergent', null, fill(MSG.restoreRetained, { file, headings: divergent.join(', ') })); } + // Compiled output that .ai/ no longer reproduces holds something .ai/ does not + // (a hand edit, or rules since removed from .ai/). Compiled in memory; nothing is written. + const target = (Object.keys(TARGET_FILENAMES) as CompileTarget[]).find(t => TARGET_FILENAMES[t] === file); + let fresh: string | null = null; + try { + fresh = target ? compileAdf({ target, aiDir, readFile: (f: string) => fs.readFileSync(f, 'utf-8') }).output : null; + } catch { + fresh = null; + } + const norm = (t: string): string => t.replace(/\r\n/g, '\n').trimEnd(); + if (fresh === null || norm(fresh) !== norm(current)) { + return refuse('compiled-differs', `charter adf vendor set ${file} compiled`, + fill(MSG.restoreCompiledDiffers, { file, target: target ?? 'all' })); + } } // Only a pointer's retained sections are the user's; a compiled file's come from .ai/. - const keptSections = ev.inferredMode === 'pointer' && current !== null ? divergentRetainedHeadings(current, file) : []; + // The template brings its own Module Index, so that heading is never "kept". + const keptSections = ev.inferredMode === 'pointer' && current !== null + ? divergentRetainedHeadings(current, file).filter(h => h !== '## Module Index') + : []; const kept = keptSections.length > 0 ? ` (kept ${keptSections.join(', ')})` : ''; const next = restoredPointer(file, ev.inferredMode === 'pointer' ? current : null, aiDir); + // Any line of the user's that the new pointer would not carry is a loss: refuse. + if (ev.inferredMode === 'pointer' && current !== null) { + const lost = linesRestoreWouldDrop(current, next); + if (lost.length > 0) { + const shown = lost.slice(0, 3).map(l => `'${l}'`).join(', ') + (lost.length > 3 ? `, and ${lost.length - 3} more` : ''); + return refuse('hand-authored', null, fill(MSG.restoreLost, { file, n: lost.length, lines: shown })); + } + } if (next === current) { print(fill(MSG.restoreUnchanged, { file }), false); - return { file, status: 'unchanged', refusal: null, keptSections, remedy: null }; + return { file, status: 'unchanged', refusal: null, keptSections, remedy: null, writeError: null }; } if (options.dryRun) { print(fill(MSG.restorePlanned, { file, kept }), false); - return { file, status: 'planned', refusal: null, keptSections, remedy: null }; + return { file, status: 'planned', refusal: null, keptSections, remedy: null, writeError: null }; } // Record before write (PR2 pattern). A recorded pointer needs no record write. @@ -900,10 +995,10 @@ export function restoreVendorFile(aiDir: string, file: VendorFile, options: Rest note = reverted ? MSG.restoreReverted : MSG.restoreNotReverted; } const error = err instanceof Error ? err.message : String(err); - return refuse('record-write', `charter adf vendor restore ${file}`, fill(MSG.restoreWriteFailed, { file, error, note })); + return { ...refuse('write-failed', `charter adf vendor restore ${file}`, fill(MSG.restoreWriteFailed, { file, error, note })), writeError: error }; } print(fill(MSG.restoreOk, { file, kept }), false); - return { file, status: 'restored', refusal: null, keptSections, remedy: null }; + return { file, status: 'restored', refusal: null, keptSections, remedy: null, writeError: null }; } function vendorRestore(options: CLIOptions, aiDir: string, rest: string[], dryRun: boolean): number { diff --git a/packages/cli/src/commands/adf.ts b/packages/cli/src/commands/adf.ts index 797bcd7..80be31a 100644 --- a/packages/cli/src/commands/adf.ts +++ b/packages/cli/src/commands/adf.ts @@ -974,7 +974,7 @@ function printHelp(): void { console.log(''); console.log(' charter adf vendor | init | restore > [--ai-dir ] [--dry-run]'); console.log(' Show or record whether each vendor file is a pointer, compiled output, or unmanaged'); - console.log(' (VENDORS in manifest.adf). restore rewrites one pointer, refusing on any content loss.'); + console.log(' (VENDORS in manifest.adf). adf compile and adf tidy act on it. restore rewrites one pointer, refusing on any content loss.'); console.log(''); console.log(' charter adf suggest [--min-occurrences ] [--window-minutes ] [--ai-dir ]'); console.log(' [--emit-ops ]'); diff --git a/packages/cli/src/commands/bootstrap.ts b/packages/cli/src/commands/bootstrap.ts index 05251d7..d1e4a47 100644 --- a/packages/cli/src/commands/bootstrap.ts +++ b/packages/cli/src/commands/bootstrap.ts @@ -1040,7 +1040,7 @@ function runAdfInitPhase( } else if (action === 'skip-custom' && force) { // --force no longer overwrites a hand-authored file: vendor files are not // backed up, so its rules would be gone before phase 4 could migrate them. - warnings.push(fill(BOOTSTRAP_MSG.skipCustomForce, { file: pf.name })); + warnings.push(fill(options.yes && recordedModeOf(aiDir, pf.name as VendorFile) === null ? BOOTSTRAP_MSG.skipCustomForceYes : BOOTSTRAP_MSG.skipCustomForce, { file: pf.name })); } else if (action === 'skip-custom') { // File has custom content — don't overwrite. Name the narrow remedy rather // than sending the user to --force, which also re-scaffolds .ai/*.adf and @@ -1430,6 +1430,7 @@ function hashContent(content: string): string { export const BOOTSTRAP_MSG = { skipCompiled: '{file} is compiled output from .ai/ ({source}); bootstrap will not replace it with a pointer. To make it a pointer: charter adf vendor set {file} pointer && charter adf vendor restore {file}', skipCustomForce: "{file} has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Run 'charter adf migrate --source {file}' to move its rules into .ai/ first.", + skipCustomForceYes: '{file} has custom content; skipping pointer even with --force, because bootstrap does not back up vendor files. Phase 4 below migrates its rules into .ai/ and then writes the pointer.', skipRecordedUnmanaged: '{file} is recorded as unmanaged; leaving it as is.', skipRecordedCompiledAbsent: '{file} is recorded as compiled but is not on disk; not writing a pointer. Generate it: charter adf compile --target {target} --write', migrateRecordedUnmanaged: '{file} is recorded as unmanaged; not migrating. To migrate it anyway: charter adf migrate --source {file}', diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 383bbab..b8be9d8 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -150,7 +150,7 @@ export const DOCTOR_MSG = { driftPointer: '{file} is recorded as a pointer but looks {inferred} on disk. Accept the file: charter adf vendor set {file} {inferred} (or rewrite the pointer: charter adf vendor restore {file})', recordedAbsent: '{file} is recorded as {recorded} but is not on disk. Recreate it: {recreate} (or stop tracking it: charter adf vendor set {file} unmanaged)', entryProblem: "VENDORS entry '{raw}' in {manifestPath} is ignored ({problem}). Fix or delete that line in {manifestPath}.", - entryDuplicate: "VENDORS lists {file} more than once in {manifestPath}; the last entry ('{raw}') wins. Delete the others.", + entryDuplicate: "VENDORS lists {file} more than once in {manifestPath}; '{raw}' is ignored because a later entry wins. Delete that line.", alias: 'VENDORS records both {file} and {other}, which are the same file on disk; {file} is used. Delete the {other} line.', legacySpelling: "VENDORS records the legacy name agents.md for the file AGENTS.md; `adf compile --target agents --mode compiled` cannot switch it. Change the line to ' - AGENTS.md: {mode}' (an automatic rename comes with #302).", sectionNotList: 'VENDORS in {manifestPath} does not read as a list, so every vendor mode is inferred. Every line under it must look like " - CLAUDE.md: pointer".', diff --git a/packages/cli/src/commands/score.ts b/packages/cli/src/commands/score.ts index 45ae30f..ab4be08 100644 --- a/packages/cli/src/commands/score.ts +++ b/packages/cli/src/commands/score.ts @@ -1567,7 +1567,7 @@ function buildRecommendations(input: { if (input.commandSignals.length === 0) { recommendations.push({ priority: 7, - text: `Document at least one runnable setup/test command in \`README.md\`, ${claudeEditable ? '`CLAUDE.md`, ' : ''}or your ADF files.`, + text: `Document at least one runnable setup/test command in \`README.md\`${claudeEditable ? ', `CLAUDE.md`,' : ''} or your ADF files.`, }); } else if (input.commandSignals.some((signal) => !signal.runnable)) { recommendations.push({