Repository navigation
feat(adf): rename legacy agents.md safely (#318 6/6) - #359
Conversation
…loses #302) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stackbilt-admin
left a comment
There was a problem hiding this comment.
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
-
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]. WithGIT_LITERAL_PATHSPECS=1set (some tooling and IDE wrappers export it),:(icase)agents.mdis matched literally and returns nothing. Soindex.loweris false, andmethod(line 1275) falls through tofs. Reproduced:rename-legacyprints[ok] Renamed agents.md -> AGENTS.md (fs)and exits 0, but git still tracks lowercaseagents.md(statusM agents.md, nothing staged). That is the #302 bug, now reported as fixed. Doctor also silently dropslegacy-trackedin that environment, which breaks INV-P6-1. Fix: query exact literal paths. I verified that on NTFS withcore.ignorecase=true,git ls-files -- AGENTS.mddoes not match anagents.mdentry, andgit ls-files -- agents.mddoes. Sogit ls-files -z -- agents.md AGENTS.mdplus the existing exact filter gives the same answer on every checkout, needs no magic, and is unaffected byGIT_LITERAL_PATHSPECS. The exact filter already coversGIT_ICASE_PATHSPECS. An alternative is to strip theGIT_*_PATHSPECSenv vars for this call. Update the test atadf-vendor-legacy.test.ts:201and the doc sentence atdocs/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 withGIT_LITERAL_PATHSPECS=1. -
Docs disagree with deviation (a). See
CHANGELOG.md:28anddocs/cli-reference.md:710. Both say the output namesgit add .ai/manifest.adf && git commit. When the title is rewritten, which is the common case for a pre-#297 file, the hint actually isgit add .ai/manifest.adf AGENTS.md && git commit. Say "namesmanifest.adf(andAGENTS.mdwhen its title line was rewritten)".packages/cli/README.mdhas the same omission in thevendorparagraph ("manifest.adfis left for you togit 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)
rmfor an untracked second file: accept.git rmon an untracked path errors, sormis the only correct remedy. The JSONremedymatches the text. - (c) Own message for P4 (two index entries, one file): accept. I verified the remedy works. On a
--cacheinfotwo-entry index,git rm --cached agents.mdremoves only the lowercase entry (literal pathspecs are exact-case under ignorecase) and leaves a stagedR agents.md -> AGENTS.md. The refusal enum staysboth-distinct, as the contract's P4 says. A comment saying so would help JSON consumers. - (d) Test-only
git,renameandsameFileoptions: accept. They are not reachable from the CLI, becausevendorRenameLegacypasses onlydryRunandprint.@stackbilt/cli'sexportsmap exposes onlydist/index, which does not re-exportadf-vendor. I suggest tagging them@internal. - (e) A second legacy
agents.mdentry in VENDORS is not cleaned up: accept as a follow-up. Observed: withagents.md: unmanagedfollowed byagents.md: pointer, the first run re-keys the winning (later) line. The staleunmanagedline becomes live, thoughAGENTS.mdstill wins viarecordedSpelling, so the effective mode is unchanged. Doctor reportsalias-recorded(delete the agents.md line). A secondrename-legacythen refusesrecord-conflictand names "agents.md as unmanaged", which is confusing but safe. Better: refuse up front whenproblemscontains aduplicateforagents.md.
What I verified (all green unless noted)
Git safety
- Pre-staged unrelated files (
M other.txt,A new.txt) stay staged, and an unstagedother2.txtstays unstaged. OnlyR100 agents.md -> AGENTS.mdis added to the index. A manifest with a pre-staged edit ends asMM, so the command never stages it. core.hooksPathpointing at a failing pre-commit hook: no hook runs.- These cases all behave the same: a linked worktree (
.gitfile, and the main worktree stays clean), a repo path with spaces, andcore.ignorecase=falseon NTFS (the committed index showsAGENTS.md). - Staged-only change to
agents.md:dirty, and the state is byte-identical. - An idempotent second run reports
unchanged.docs/agents.mdalone gives no row and no rename. --is present on everygit 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.mdoutside it: the firstgit mvfails and the state is byte-identical. - The title-write failure keeps the staged rename, sets
titleUpdated: falseand warns, which is acceptable.
Detection
- No row in a non-git directory or for an untracked
agents.md. legacy-trackedandlegacy-both-one-fileare INFO, and doctor exit codes match main in every case (0 for--adf-only --ci, and the same full--cicode).- This repo's own doctor (PR-head archive, PR build vs main build): text and JSON are identical.
- I could not test
legacy-both-distincton a case-sensitive FS here. The unit tests injectsameFile.
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 compiledon a legacy key exits 1 withsetLegacy.
removeManifestBullet
- Correct for LF, CRLF, a last line without a trailing newline (LF and CRLF, where the preceding
\ris 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
- cwd is the root. Run from a subdirectory with
--ai-dir ../.ai,--dry-runplansgit-mvfordocs/agents.mdbecause every path is relative to cwd. That matches the rest of Charter, but this is the one command that runsgit mv. Consider refusing whenpath.resolve(aiDir)is not<cwd>/.ai-shaped, or at least document "run from the project root". - The commit hint.
git add … && git commitalso commits any unrelated files the user had pre-staged (scenario A), andgit add .ai/manifest.adfstages any unrelated unstaged manifest edits.git commit -- agents.md AGENTS.md .ai/manifest.adfwould commit exactly the rename set. - 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. - Index-read failure inside a repo. When
legacyIndexEntriesreturns null inside a git work tree (ls-files failed), the code falls back tofsfor what may be a tracked file. Consider refusing whenisGitRepo()is true but the index read failed. - Message order on title failure. The title-write warning prints before the
[ok] Renamed …line, andreferencesthen listsAGENTS.md:1. A one-line "title line left as# agents.md" after the ok line would read better. - Duplicate doctor rows. On a case-insensitive legacy repo, doctor prints two INFO rows (
legacy-spellingandlegacy-tracked) with the same remedy. That is fine, but they could be merged into one line. - The text compile refusal without
--mode. It still saysRun: 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>
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]renamesagents.mdtoAGENTS.md. It first moves theVENDORSkey: it re-keysagents.md: M, or removes a same-mode duplicate through the newremoveManifestBulletand the same re-parse guard. Then it renames in two steps throughagents.md.charter-rename, usinggit mvwhen the file is tracked andfs.renameSyncotherwise. If either step fails, it undoes the completed step and puts the record back.adf vendor modecheck, read from the git index only (ls-files -- ':(icase)agents.md'plus an exact-case filter):legacy-tracked,legacy-both-distinctandlegacy-both-one-file.legacy-spellingnow namesrename-legacy.compile --target agents|all --write --mode compiledon a legacy-keyedAGENTS.mdnow reportsrefused-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 compilednow gives a specific message that names the rename.Before → after
agents.mdlegacy-tracked: runadf vendor rename-legacylegacy-both-distinct: merge into AGENTS.md, thengit rm agents.mdlegacy-both-one-file:git rm --cached agents.mdagents.mdrename-legacy(andremedyset)compile --target agents --write --mode compiled(legacy key)refused-recordrefused-legacy-spelling, exit 1, names the renamevendor set AGENTS.md compiled(legacy key)adf vendor rename-legacygit mv. Refuses on both-distinct, dirty, tmp clash, record conflict or record-writeOperator sign-offs
agents.mdis renamed with twogit mvcalls, which leaves exactlyR100 agents.md -> AGENTS.mdstaged and stages no other path.dirtywhenagents.mdhas staged or unstaged changes against HEAD (git status --porcelain -- agents.md).manifest.adfis left unstaged. The output namesgit add .ai/manifest.adf [AGENTS.md] && git commit.# agents.mdfirst line (LF or CRLF), rewritten to# AGENTS.mdand left unstaged, like the manifest. Mentions elsewhere are only reported.removeManifestBulletplus the re-parse guard. Differing modes refuserecord-conflict.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 inheritedGIT_DIR/GIT_INDEX_FILE.ls-fileswith exact-case filtering (Agents.mdignored); mocked identity split; real git withupdate-index --cacheinfofor two entries on one file; two distinct files on case-sensitive hosts.doctor --ciexits 0, and every legacy row is INFO.git mv; an fs-method failure; a failing undo that names the manual recovery; a guard skip (record-write) that renames nothing.diff --cachedis onlyR100.git statusare identical after a dry run.refused-legacy-spelling, the remedy prefix in skip and refuse paths, thevendor setmessage, and neither generic message.adf.tsstays at 1027/1041 (help line edited in place).adf-migrate.tsis untouched (780/792). Checked byadf evidence --auto-measure.agents.mdstays inPOINTER_ONLY_VENDOR_FILESandAGENT_CONFIG_FILES. A CP-7 test runs doctor,compile --check,vendor statusandtidy --dry-run, and the index still holdsagents.md.Other tests cover the
removeManifestBulletunit (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
agents.md.agents.mdin other files (report-only).agents.mdfrom any discovery list.Implemented via constellation-loop against a locked blueprint.
🤖 Generated with Claude Code