Skip to content

feat(adf): rename legacy agents.md safely (#318 6/6) - #359

Merged
stackbilt-admin merged 2 commits into
mainfrom
auto/feature/issue-302-legacy-agents
Oct 16, 2026
Merged

stackbilt-admin merged 2 commits into
mainfrom
auto/feature/issue-302-legacy-agents

Conversation

@stackbilt-admin

Copy link
Copy Markdown
Member

Closes #302
Refs #318

Summary

Final PR (6 of 6) of the #318 blueprint series. It closes #302 gap 1: repos bootstrapped before #297 track a lowercase agents.md, and nothing renamed it.

  • charter adf vendor rename-legacy [--dry-run] [--ai-dir <dir>] [--format json] renames agents.md to AGENTS.md. It first moves the VENDORS key: it re-keys agents.md: M, or removes a same-mode duplicate through the new removeManifestBullet and the same re-parse guard. Then it renames in two steps through agents.md.charter-rename, using git mv when the file is tracked and fs.renameSync otherwise. If either step fails, it undoes the completed step and puts the record back.
  • Doctor adds three INFO rows to the adf vendor mode check, read from the git index only (ls-files -- ':(icase)agents.md' plus an exact-case filter): legacy-tracked, legacy-both-distinct and legacy-both-one-file. legacy-spelling now names rename-legacy.
  • Note 8. compile --target agents|all --write --mode compiled on a legacy-keyed AGENTS.md now reports refused-legacy-spelling (exit 1, nothing rendered or recorded). This is a top-of-loop guard plus one new status value, re-applied onto the merged PR5 write loop. Without --mode, the remedy is prefixed with the rename. vendor set AGENTS.md compiled now gives a specific message that names the rename.

Before → after

Case main @ a50de35 PR6
git tracks only agents.md silent doctor INFO legacy-tracked: run adf vendor rename-legacy
Both spellings tracked as two files silent INFO legacy-both-distinct: merge into AGENTS.md, then git rm agents.md
Two index entries, one file (ignorecase) silent INFO legacy-both-one-file: git rm --cached agents.md
VENDORS keys AGENTS.md as agents.md INFO "rename comes with #302" INFO naming rename-legacy (and remedy set)
compile --target agents --write --mode compiled (legacy key) generic refused-record refused-legacy-spelling, exit 1, names the rename
vendor set AGENTS.md compiled (legacy key) generic modeNotAllowed specific message, exit 1
adf vendor rename-legacy n/a record re-keyed first, then a staged two-step git mv. Refuses on both-distinct, dirty, tmp clash, record conflict or record-write

Operator sign-offs

  • CP-1 (index effect): a tracked agents.md is renamed with two git mv calls, which leaves exactly R100 agents.md -> AGENTS.md staged and stages no other path.
  • CP-2: refuses dirty when agents.md has staged or unstaged changes against HEAD (git status --porcelain -- agents.md).
  • CP-3: manifest.adf is left unstaged. The output names git add .ai/manifest.adf [AGENTS.md] && git commit.
  • CP-4 (content effect): the only content edit is an exact # agents.md first line (LF or CRLF), rewritten to # AGENTS.md and left unstaged, like the manifest. Mentions elsewhere are only reported.
  • CP-5: a same-mode duplicate entry is removed via removeManifestBullet plus the re-parse guard. Differing modes refuse record-conflict.
  • CP-6: all new doctor rows are INFO.
  • CP-7: nothing calls the rename automatically (bootstrap, tidy, compile, hook).

Invariants and where they are tested

All tests are in packages/cli/src/__tests__/adf-vendor-legacy.test.ts (42 tests). Each git repo is a fresh mkdtemp dir, and the tests assert git's top level before any git write and unset inherited GIT_DIR/GIT_INDEX_FILE.

  • INV-P6-1 (index only, same on both checkouts): mocked ls-files with exact-case filtering (Agents.md ignored); mocked identity split; real git with update-index --cacheinfo for two entries on one file; two distinct files on case-sensitive hosts.
  • INV-P6-2 (INFO, no CI change): doctor --ci exits 0, and every legacy row is INFO.
  • INV-P6-3 (no content change beyond the title): a non-exact title and its body are untouched; the CRLF title keeps its line ending; the exact-title rewrite equals the current template.
  • INV-P6-4 (record before rename, both restored on failure): real git with an injected failure on the second git mv; an fs-method failure; a failing undo that names the manual recovery; a guard skip (record-write) that renames nothing.
  • INV-P6-5 (stages only the rename): unrelated unstaged and untracked work stays exactly as it was. diff --cached is only R100.
  • INV-P6-6 (dry run writes nothing): fs listing, manifest bytes and git status are identical after a dry run.
  • INV-P6-7 (compile and set name the rename): refused-legacy-spelling, the remedy prefix in skip and refuse paths, the vendor set message, and neither generic message.
  • INV-P6-8: adf.ts stays at 1027/1041 (help line edited in place). adf-migrate.ts is untouched (780/792). Checked by adf evidence --auto-measure.
  • INV-P6-9: agents.md stays in POINTER_ONLY_VENDOR_FILES and AGENT_CONFIG_FILES. A CP-7 test runs doctor, compile --check, vendor status and tidy --dry-run, and the index still holds agents.md.

Other tests cover the removeManifestBullet unit (LF, CRLF, last line with no trailing newline, missing item), idempotence, record-only, the non-git and no-manifest case (P5), the usage error (exit 2), one JSON document on stdout, and the S6 reference report.

The e2e run used the built CLI in scratch repos: dry-run, real run, commit, doctor clean, dirty refusal, distinct index entries, untracked fs rename, tmp clash, a real index-lock failure mid-rename with full revert, and compile refused-legacy-spelling.

NOT in PR6

  • Auto-renaming from bootstrap, tidy, compile or the hook.
  • Deleting or merging a second distinct agents.md.
  • Editing references to agents.md in other files (report-only).
  • Dropping agents.md from any discovery list.

Implemented via constellation-loop against a locked blueprint.

🤖 Generated with Claude Code

…loses #302)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@stackbilt-admin stackbilt-admin left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES_REQUESTED

Adversarial review of #359 (PR 6/6, closes #302) at a6347d4. I built it in an isolated worktree and ran every scenario in fresh git init repos under a scratch dir, asserting the toplevel before any write. I compared against an origin/main archive build. Two small required changes. The git-safety core, the revert logic and the compile/doctor regression surface all held up.

Required changes

  1. packages/cli/src/commands/adf-vendor.ts:1094: detection depends on pathspec magic. When that fails, a tracked file gets an fs rename that is reported as [ok]. With GIT_LITERAL_PATHSPECS=1 set (some tooling and IDE wrappers export it), :(icase)agents.md is matched literally and returns nothing. So index.lower is false, and method (line 1275) falls through to fs. Reproduced: rename-legacy prints [ok] Renamed agents.md -> AGENTS.md (fs) and exits 0, but git still tracks lowercase agents.md (status M agents.md, nothing staged). That is the #302 bug, now reported as fixed. Doctor also silently drops legacy-tracked in that environment, which breaks INV-P6-1. Fix: query exact literal paths. I verified that on NTFS with core.ignorecase=true, git ls-files -- AGENTS.md does not match an agents.md entry, and git ls-files -- agents.md does. So git ls-files -z -- agents.md AGENTS.md plus the existing exact filter gives the same answer on every checkout, needs no magic, and is unaffected by GIT_LITERAL_PATHSPECS. The exact filter already covers GIT_ICASE_PATHSPECS. An alternative is to strip the GIT_*_PATHSPECS env vars for this call. Update the test at adf-vendor-legacy.test.ts:201 and the doc sentence at docs/cli-reference.md:194. The locked contract names :(icase), so this changes a contract detail. Whichever fix is chosen, it needs a test that runs with GIT_LITERAL_PATHSPECS=1.

  2. Docs disagree with deviation (a). See CHANGELOG.md:28 and docs/cli-reference.md:710. Both say the output names git add .ai/manifest.adf && git commit. When the title is rewritten, which is the common case for a pre-#297 file, the hint actually is git add .ai/manifest.adf AGENTS.md && git commit. Say "names manifest.adf (and AGENTS.md when its title line was rewritten)". packages/cli/README.md has the same omission in the vendor paragraph ("manifest.adf is left for you to git add").

Rulings on deviations

  • (a) Hint names AGENTS.md when the title was rewritten: accept. The contract's hint would leave the CP-4 edit uncommitted while the staged blob still carries # agents.md. Verified in scenario D (read-only file, title write fails): the hint then names only the manifest, which is correct. The docs must follow (required 2).
  • (b) rm for an untracked second file: accept. git rm on an untracked path errors, so rm is the only correct remedy. The JSON remedy matches the text.
  • (c) Own message for P4 (two index entries, one file): accept. I verified the remedy works. On a --cacheinfo two-entry index, git rm --cached agents.md removes only the lowercase entry (literal pathspecs are exact-case under ignorecase) and leaves a staged R agents.md -> AGENTS.md. The refusal enum stays both-distinct, as the contract's P4 says. A comment saying so would help JSON consumers.
  • (d) Test-only git, rename and sameFile options: accept. They are not reachable from the CLI, because vendorRenameLegacy passes only dryRun and print. @stackbilt/cli's exports map exposes only dist/index, which does not re-export adf-vendor. I suggest tagging them @internal.
  • (e) A second legacy agents.md entry in VENDORS is not cleaned up: accept as a follow-up. Observed: with agents.md: unmanaged followed by agents.md: pointer, the first run re-keys the winning (later) line. The stale unmanaged line becomes live, though AGENTS.md still wins via recordedSpelling, so the effective mode is unchanged. Doctor reports alias-recorded (delete the agents.md line). A second rename-legacy then refuses record-conflict and names "agents.md as unmanaged", which is confusing but safe. Better: refuse up front when problems contains a duplicate for agents.md.

What I verified (all green unless noted)

Git safety

  • Pre-staged unrelated files (M other.txt, A new.txt) stay staged, and an unstaged other2.txt stays unstaged. Only R100 agents.md -> AGENTS.md is added to the index. A manifest with a pre-staged edit ends as MM, so the command never stages it.
  • core.hooksPath pointing at a failing pre-commit hook: no hook runs.
  • These cases all behave the same: a linked worktree (.git file, and the main worktree stays clean), a repo path with spaces, and core.ignorecase=false on NTFS (the committed index shows AGENTS.md).
  • Staged-only change to agents.md: dirty, and the state is byte-identical.
  • An idempotent second run reports unchanged. docs/agents.md alone gives no row and no rename.
  • -- is present on every git mv.

Revert

  • Clean undo with an index lock on step 2 that is released before the undo: index, ls-files -s, porcelain, directory listing, manifest and diffs are byte-identical to the start.
  • The lock stays held, so the undo also fails: the manifest is reverted, and the command prints Recover by hand: git mv -- agents.md.charter-rename agents.md, which is exact.
  • The manifest changes concurrently before the failure: the file is moved back, the manifest is left alone, and the command prints put back the line ' - agents.md: pointer'. The "only if unchanged since the write" check works.
  • Non-cone sparse checkout with agents.md outside it: the first git mv fails and the state is byte-identical.
  • The title-write failure keeps the staged rename, sets titleUpdated: false and warns, which is acceptable.

Detection

  • No row in a non-git directory or for an untracked agents.md.
  • legacy-tracked and legacy-both-one-file are INFO, and doctor exit codes match main in every case (0 for --adf-only --ci, and the same full --ci code).
  • This repo's own doctor (PR-head archive, PR build vs main build): text and JSON are identical.
  • I could not test legacy-both-distinct on a case-sensitive FS here. The unit tests inject sameFile.

compile / vendor set

  • Normal bootstrapped repo, fresh copy per build, output and resulting tree hashed. These ten invocations are identical to main (stdout/stderr, exit code and every written file): --target agents --write [--mode compiled] [--format json], --target all --write [--format json], --target all --write --mode compiled --format json, --target all --check [--format json], --target claude --write --mode compiled --force.
  • The diff of the PR5 loop is additive only: one guard, one enum value and the legacy remedy/line. No PR5 status or guard was dropped.
  • vendor set AGENTS.md compiled on a legacy key exits 1 with setLegacy.

removeManifestBullet

  • Correct for LF, CRLF, a last line without a trailing newline (LF and CRLF, where the preceding \r is stripped so the no-newline state is kept), a duplicate VENDORS section (the last one is edited), and an out-of-range index or a missing key (both null).
  • Removing the only item turns the section into text '', so the caller's re-parse guard rejects it. That path is unreachable from rename-legacy, which removes only when two entries exist.

Tests

  • Full vitest run: 1384 passed and 3 failed. All 3 failures (bootstrap, context-refresh, serve-context, Windows path separators) fail identically on main, so they are pre-existing and unrelated.

Non-blocking notes

  1. cwd is the root. Run from a subdirectory with --ai-dir ../.ai, --dry-run plans git-mv for docs/agents.md because every path is relative to cwd. That matches the rest of Charter, but this is the one command that runs git mv. Consider refusing when path.resolve(aiDir) is not <cwd>/.ai-shaped, or at least document "run from the project root".
  2. The commit hint. git add … && git commit also commits any unrelated files the user had pre-staged (scenario A), and git add .ai/manifest.adf stages any unrelated unstaged manifest edits. git commit -- agents.md AGENTS.md .ai/manifest.adf would commit exactly the rename set.
  3. Sparse-checkout errors are truncated. The first-line truncation of git's error gives The following paths and/or pathspecs matched paths that exist., which is misleading. The rest, "outside of your sparse-checkout definition", is on line 2. Join the first two lines, or detect skip-worktree up front.
  4. Index-read failure inside a repo. When legacyIndexEntries returns null inside a git work tree (ls-files failed), the code falls back to fs for what may be a tracked file. Consider refusing when isGitRepo() is true but the index read failed.
  5. Message order on title failure. The title-write warning prints before the [ok] Renamed … line, and references then lists AGENTS.md:1. A one-line "title line left as # agents.md" after the ok line would read better.
  6. Duplicate doctor rows. On a case-insensitive legacy repo, doctor prints two INFO rows (legacy-spelling and legacy-tracked) with the same remedy. That is fine, but they could be merged into one line.
  7. The text compile refusal without --mode. It still says Run: charter adf compile --target agents --write --mode compiled, which would now be refused, followed by the "rename it first" line. The JSON remedy is right. Consider putting the rename first in the text too.

Main checkout: no command was run against it. All writes were in the reviewer worktree and in scratch repos.

…ndly (refs #302)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stackbilt-admin
stackbilt-admin merged commit 581465c into main Oct 16, 2026
5 checks passed
@stackbilt-admin
stackbilt-admin deleted the auto/feature/issue-302-legacy-agents branch October 16, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AGENTS.md migration gaps for repos bootstrapped before #297

2 participants