Skip to content

fix(bin): add CLAUDE.md symlink hygiene guard - #2401

Open
V4f1k wants to merge 13 commits into
kunchenguid:mainfrom
V4f1k:fm/crew-claude-symlink
Open

fix(bin): add CLAUDE.md symlink hygiene guard#2401
V4f1k wants to merge 13 commits into
kunchenguid:mainfrom
V4f1k:fm/crew-claude-symlink

Conversation

@V4f1k

@V4f1k V4f1k commented Aug 14, 2026

Copy link
Copy Markdown

Intent

Investigate why jmp-hub's CLAUDE.md -> AGENTS.md symlink (a captain-decided one-source-of-truth convention; the symlink direction must never be reversed or removed) keeps turning into a plain file on fm/* worker branches, causing repeated 'distinct types on each side' merge conflicts (documented in PR #621). The leading hypothesis to test first was that no-mistakes' prompt-injection defense neutralizes AGENTS.md/CLAUDE.md by writing through the symlink before running the gate agent, which would explain why every pipeline-processed branch is affected. That hypothesis had to be confirmed or refuted with hard evidence (commit SHAs, authorship, git log -p), not argued from plausibility. Investigation in a read-only clone of jmp-hub at projects/jmp-hub (never modified) found: CLAUDE.md became a symlink only in commit 80f54e46 (PR #612, 2026-08-05 02:28, author V4f1k). Checked all 103 current origin/fm/* branches: every branch whose CLAUDE.md is a plain file provably does NOT have 80f54e46 as an ancestor (it simply predates the symlink conversion and hasn't synced since - not corruption). Every branch descended from 80f54e46 still has the correct symlink. No no-mistakes-authored commit (grep for 'no-mistakes(document/review):' commits) ever changes CLAUDE.md's file mode, and the currently-running no-mistakes worktree for jmp-hub still has an intact symlink. This refutes the leading hypothesis with direct evidence. The actual mechanism: merging/rebasing a pre-conversion branch against a post-conversion base hits git's ordinary 'distinct types on each side' conflict on CLAUDE.md (exactly reproduced in PR #621's merge commit 7166a1ec, which resolved it correctly by keeping the symlink), and that manual conflict resolution is error-prone - fm/don768-kanban-brana's merge (86204b0f) dropped CLAUDE.md entirely instead of keeping the symlink, requiring a manual restore the next day (5dd137c0). This is not a bug in firstmate's own bin/ tooling (bin/fm-ensure-agents-md.sh was already confirmed symlink-aware and ruled out first) nor in no-mistakes; it is an operational hazard of syncing stale branches, so per the task's explicit instructions the deliverable is a safety net rather than a root-cause code fix: bin/fm-claude-symlink-check.sh is a new, project-agnostic, read-only guard script. It silently skips (exit 0) any repo whose resolved base branch does not manage CLAUDE.md as a symlink, so it is a no-op everywhere except a project that has actually adopted this convention. Where the base does manage it as a symlink, it verifies the worktree's CLAUDE.md still matches (present, is a symlink, correct target) and on failure prints the exact recovery commands (git checkout -- CLAUDE.md, or ln -sfn CLAUDE.md) and exits 1. It auto-detects the base branch (preferring the freshest locally-available remote-tracking ref, falling back to a local branch) or accepts an explicit ref for testing, mirroring the existing default_branch() pattern already duplicated in bin/fm-ff-lib.sh and bin/fm-fleet-sync.sh (deliberately not refactored into a shared lib - that consolidation is out of scope for this fix per explicit instructions to avoid repo-wide cleanup as a side effect). It is wired into bin/fm-brief.sh's shared 'Project memory' section so every ship-mode brief (no-mistakes, direct-PR, local-only) instructs the worker to run it right before reporting done - i.e. before a PR ever exists, not just left available. Test coverage: tests/fm-claude-symlink-check.test.sh (7 scenarios: matching symlink passes, regular-file demotion fails with both recovery commands present, missing file fails, wrong-target symlink fails, a repo with no symlink policy skips silently, a repo with no CLAUDE.md at all skips silently, and auto-detection of origin's default branch works) plus a new assertion in tests/fm-brief.test.sh confirming the hygiene-check section renders in all three delivery modes. bin/fm-test-run.sh gained one line classifying the new test into the existing pure-contract-unit family (same family as the closely related fm-ensure-agents-md.test.sh). Deliberately out of scope, per explicit instruction: do not touch jmp-hub at all (read-only investigation only, verified via git log/ls-tree/diff against the local read-only clone and its fetched remote branches, never committing there), do not un-break or rewrite history on any of the 34+ affected fm/* branches (most are dead; rewriting other workers' branches is not this task's job), do not reverse or remove the CLAUDE.md -> AGENTS.md symlink direction (captain's standing decision), and no repo-wide renaming/cleanup beyond this fix's own files. Full local verification already done directly (captain's standing instruction: the full bin/fm-test-run.sh suite is CI's job via --check-coverage in ci.yml, not duplicated locally) - bin/fm-lint.sh is clean on every touched script (fm-claude-symlink-check.sh, fm-brief.sh, fm-test-run.sh), the new and touched test files pass standalone and through the real bin/fm-test-run.sh harness, and the pure-contract-unit family run is green except two pre-existing, environment-dependent failures (missing tasks-axi and the @earendil-works/pi-coding-agent npm package in this session) confirmed unrelated to this diff by isolated reruns - neither failing test file references any file this change touches.

What Changed

  • Added bin/fm-claude-symlink-check.sh, a project-agnostic read-only guard that detects broken CLAUDE.md symlinks and prints recovery commands.
  • Wired the guard into all ship-mode briefs and documented it in docs/scripts.md.
  • Added comprehensive guard and brief-rendering coverage, registering the new test in the existing test family.

Risk Assessment

✅ Low: Captain, the change is bounded and I found no material source-verifiable regressions or intent violations.

Testing

Fresh focused tests passed standalone and through the real harness. Manual CLI evidence confirms failure diagnostics, recovery guidance, committed-restore enforcement, and silent no-policy behavior; generated briefs contain the guard in all three delivery modes. No linters or full-suite tests were run, and the worktree is clean.

Evidence: CLI guard failure and recovery transcript
$ bin/fm-claude-symlink-check.sh <broken worktree>
exit=1
error: CLAUDE.md in /private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy is a regular file, but main manages it as a symlink -> AGENTS.md.
This is the classic 'distinct types on each side' merge fallout: a pre-conversion branch clobbered the symlink.
Restore it: git -C '/private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy' checkout 'main' -- 'CLAUDE.md'   (or: ln -sfn -- 'AGENTS.md' '/private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy/CLAUDE.md')
$ git checkout main -- CLAUDE.md; bin/fm-claude-symlink-check.sh <uncommitted restore>
exit=1
error: the working tree is fine, but your branch tip still carries CLAUDE.md as a regular file (mode 100644), while main manages it as a symlink -> AGENTS.md.
That is what a PR would carry, so the 'distinct types on each side' conflict would come back.
Commit the restored symlink: git -C '/private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy' --literal-pathspecs add -f -- 'CLAUDE.md' && git -C '/private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy' --literal-pathspecs commit -m 'fix: restore the CLAUDE.md symlink' -- 'CLAUDE.md'
$ bin/fm-claude-symlink-check.sh <committed restore>
exit=0
ok: CLAUDE.md -> AGENTS.md matches main in /private/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/tmp.o8ktVF2HQK/symlink-policy (working tree and branch tip)
$ bin/fm-claude-symlink-check.sh <repo without symlink policy>
exit=0 output_bytes=0
Evidence: Generated hygiene sections for all delivery modes
--- mode=no-mistakes ---
# Repo hygiene check
Before you report done, run `'/Users/computer/.no-mistakes/worktrees/2f3dd046dbd1/01M015H2ZEZ25CD1XYMNGYXP7M/bin/fm-claude-symlink-check.sh' .`. For direct-PR delivery, run this before pushing or opening the PR.
It is silent and exits 0 in almost every project; it only speaks up when the resolved base branch manages `CLAUDE.md` as a symlink to its expected target and your branch lost that symlink or its target - a known git hazard where syncing a branch whose history predates the symlink against a base that already has it hits a "distinct types on each side" conflict, easy to mis-resolve by dropping the file instead of keeping the symlink.
It checks your working tree and your branch tip, so a restore only counts once you commit it.
If it reports an error, run the recovery command it prints, then re-run the check until it passes.

# Definition of done
--- mode=direct-PR ---
# Repo hygiene check
Before you report done, run `'/Users/computer/.no-mistakes/worktrees/2f3dd046dbd1/01M015H2ZEZ25CD1XYMNGYXP7M/bin/fm-claude-symlink-check.sh' .`. For direct-PR delivery, run this before pushing or opening the PR.
It is silent and exits 0 in almost every project; it only speaks up when the resolved base branch manages `CLAUDE.md` as a symlink to its expected target and your branch lost that symlink or its target - a known git hazard where syncing a branch whose history predates the symlink against a base that already has it hits a "distinct types on each side" conflict, easy to mis-resolve by dropping the file instead of keeping the symlink.
It checks your working tree and your branch tip, so a restore only counts once you commit it.
If it reports an error, run the recovery command it prints, then re-run the check until it passes.

# Definition of done
--- mode=local-only ---
# Repo hygiene check
Before you report done, run `'/Users/computer/.no-mistakes/worktrees/2f3dd046dbd1/01M015H2ZEZ25CD1XYMNGYXP7M/bin/fm-claude-symlink-check.sh' .`. For direct-PR delivery, run this before pushing or opening the PR.
It is silent and exits 0 in almost every project; it only speaks up when the resolved base branch manages `CLAUDE.md` as a symlink to its expected target and your branch lost that symlink or its target - a known git hazard where syncing a branch whose history predates the symlink against a base that already has it hits a "distinct types on each side" conflict, easy to mis-resolve by dropping the file instead of keeping the symlink.
It checks your working tree and your branch tip, so a restore only counts once you commit it.
If it reports an error, run the recovery command it prints, then re-run the check until it passes.

# Definition of done
Evidence: Targeted harness transcript
FM_TEST_BEGIN 2026-08-14T22:30:40Z tests/fm-claude-symlink-check.test.sh family=pure-contract-unit expected_gate_skip=none
ok - fm-claude-symlink-check.sh: matching CLAUDE.md symlink passes
ok - fm-claude-symlink-check.sh: CLAUDE.md demoted to a regular file fails with recovery commands
ok - fm-claude-symlink-check.sh: missing CLAUDE.md fails
ok - fm-claude-symlink-check.sh: symlink pointing at the wrong target fails
ok - fm-claude-symlink-check.sh: dangling CLAUDE.md symlink fails
ok - fm-claude-symlink-check.sh: run from a subdirectory it still checks the repo root
ok - fm-claude-symlink-check.sh: an unresolvable base ref errors instead of skipping
ok - fm-claude-symlink-check.sh: a restore only passes once it is committed
ok - fm-claude-symlink-check.sh: the printed recovery command commits only CLAUDE.md from every restore path
ok - fm-claude-symlink-check.sh: a branch tip that dropped CLAUDE.md fails
ok - fm-claude-symlink-check.sh: branch tips must retain the regular symlink target
ok - fm-claude-symlink-check.sh: branch-tip recovery force-adds an ignored target
ok - fm-claude-symlink-check.sh: branch-tip recovery preserves target work
ok - fm-claude-symlink-check.sh: recovery restores a missing target with a regular branch-tip CLAUDE.md
ok - fm-claude-symlink-check.sh: the worktree target must be a regular non-symlink file
ok - fm-claude-symlink-check.sh: staged CLAUDE.md deletion fails
ok - fm-claude-symlink-check.sh: staged symlink-target deletion fails
ok - fm-claude-symlink-check.sh: index-target recovery preserves worktree edits
ok - fm-claude-symlink-check.sh: intent-to-add targets fail closed
ok - fm-claude-symlink-check.sh: recovery commands quote every repository-controlled operand
ok - fm-claude-symlink-check.sh: generated Git recovery commands use literal target pathspecs
ok - fm-claude-symlink-check.sh: repo without a CLAUDE.md symlink policy skips silently
ok - fm-claude-symlink-check.sh: repo with no CLAUDE.md at all skips silently
ok - fm-claude-symlink-check.sh: auto-detects the origin default branch when no base-ref is given
ok - fm-claude-symlink-check.sh: a dangling origin/HEAD falls back to an available default
FM_TEST_END 2026-08-14T22:31:05Z tests/fm-claude-symlink-check.test.sh exit=0 duration_ms=24728 gate_skip=false
FM_TEST_BEGIN 2026-08-14T22:31:05Z tests/fm-brief.test.sh family=pure-contract-unit expected_gate_skip=none
ok - fm-brief.sh: bash -n succeeds
/var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T//fm-brief.gUuSx1/heredoc-in-substitution.sh:2
ok - fm-brief.sh: no heredoc is nested inside a command substitution (Bash 3.2 parse-safe)
ok - fm-brief.sh: --help renders the complete header
ok - fm-brief.sh: no-mistakes/direct-PR/local-only briefs generate cleanly
ok - fm-brief.sh: ship --mode is required and closed-set validated
ok - fm-brief.sh: the explicit ship mode wins over the registered posture
ok - fm-brief.sh: --yolo and scout/secondmate --mode are refused, never silently dropped
ok - fm-brief.sh: faster paths use configured authority without stacked review
ok - fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe
ok - fm-brief.sh: ship project-memory wording carries the AGENTS.md authoring bar
ok - fm-brief.sh: every ship mode renders the CLAUDE.md symlink guard before Definition of done
ok - fm-brief.sh: --herdr-lab emits the complete hard safety contract
ok - fm-brief.sh: --herdr-lab uses its quoted Firstmate-owned helper path
ok - fm-brief.sh: ship and scout scaffolds make omitted Herdr intent fail-visible
ok - fm-brief.sh: Herdr lab contract covers scouts and rejects secondmate misuse
ok - fm-brief.sh: --no-projects scaffolds a project-less charter and guards misuse
ok - fm-brief.sh: marked requests avoid generic acknowledgements and preserve material reporting
ok - fm-brief.sh: relative directory inputs ignore CDPATH, render stable absolute charter paths, or fail loudly
ok - fm-brief.sh: custom pause verb renders in every scaffold
ok - fm-brief.sh: investigation and visual-review completions load the shared decision policy
ok - fm-brief: scout and secondmate code paths still scaffold well-formed briefs
FM_TEST_END 2026-08-14T22:31:09Z tests/fm-brief.test.sh exit=0 duration_ms=4303 gate_skip=false
FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=29326
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=2 duration_ms=29031 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-claude-symlink-check.test.sh duration_ms=24728
FM_TEST_SLOWEST rank=2 script=tests/fm-brief.test.sh duration_ms=4303
fm-test-run: wrote timing artifact: /var/folders/mz/3dbzbvkd1375djgpnq7r7hj40000gp/T/no-mistakes-evidence/01M015H2ZEZ25CD1XYMNGYXP7M/harness-targeted.json

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-claude-symlink-check.test.sh
  • bash tests/fm-brief.test.sh
  • bin/fm-test-run.sh --json .../harness-targeted.json tests/fm-claude-symlink-check.test.sh tests/fm-brief.test.sh
  • Manual Git fixture: broken worktree, uncommitted restore, committed recovery, and no-policy skip
  • Manual generation of no-mistakes, direct-PR, and local-only brief sections
  • git status --porcelain=v1 --untracked-files=all
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

V4f1k added 13 commits August 14, 2026 18:54
Vedoucí hypotéza (no-mistakes přepisuje CLAUDE.md skrz symlink) byla
vyvrácena: žádný commit z pipeline (`no-mistakes(document/review)`) nemění
typ souboru, a aktuálně běžící worktree no-mistakes pro jmp-hub má symlink
neporušený.

Skutečná příčina: CLAUDE.md se v jmp-hubu stalo symlinkem na AGENTS.md až
commitem 80f54e46 (PR kunchenguid#612, 5. 8. 2026 02:28, V4f1k). Každá větev fm/*
založená před tímto commitem nese ve svojí historii starý CLAUDE.md jako
obyčejný soubor - to není nic rozbité, jen ještě nedorovnané. Když se
taková větev slučuje/rebasuje na základ, který už symlink má, Git na
CLAUDE.md nahlásí konflikt "distinct types on each side" (přesně log z PR
kunchenguid#621, merge 7166a1ec). Řešení konfliktu je ruční a chybové: v
fm/don768-kanban-brana (86204b0f) byl soubor při řešení konfliktu úplně
smazán a musel se druhý den ručně obnovit (5dd137c0). Ověřeno na všech
103 aktuálních fm/* větvích: každá s obyčejným souborem CLAUDE.md
prokazatelně nemá 80f54e46 v historii; žádná větev odvozená od symlinku
ho neztratila.

Příčina je tedy běžné git chování při slučování zastaralých větví, ne
chyba v našem bin/ kódu ani v no-mistakes - proto jen pojistka:

- bin/fm-claude-symlink-check.sh: projekt-agnostická kontrola, tiše
  přeskočí repozitáře bez symlinkované CLAUDE.md politiky, jinak ověří
  CLAUDE.md ve worktree proti symlinku v základové větvi a při rozjetí
  vypíše přesný obnovovací příkaz.
- bin/fm-brief.sh: každý ship brief (všechny tři delivery mody) teď
  pracovníka pošle kontrolu spustit těsně před `done`, tedy dřív než PR.
- Testy: tests/fm-claude-symlink-check.test.sh (7 scénářů) a rozšíření
  tests/fm-brief.test.sh o ověření, že se sekce generuje ve všech třech
  modech. bin/fm-test-run.sh: zařazeno do rodiny pure-contract-unit.
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.

1 participant