Skip to content

fix(spawn): stop clobbering a project's tracked .claude/settings.local.json#888

Closed
CodeAhmedJamil wants to merge 6 commits into
kunchenguid:mainfrom
CodeAhmedJamil:fm/fm-spawn-settings-clobber
Closed

fix(spawn): stop clobbering a project's tracked .claude/settings.local.json#888
CodeAhmedJamil wants to merge 6 commits into
kunchenguid:mainfrom
CodeAhmedJamil:fm/fm-spawn-settings-clobber

Conversation

@CodeAhmedJamil

Copy link
Copy Markdown

Problem

bin/fm-spawn.sh wrote the claude crewmate turn-end hook by unconditionally overwriting
<worktree>/.claude/settings.local.json:

cat > "$WT/.claude/settings.local.json" <<EOF
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '$TURNEND'"}]}]}}
EOF
exclude_path '.claude/settings.local.json'

meshery/meshery (and meshery-cloud) tracks .claude/settings.local.json - a 72-line file
carrying enabledMcpjsonServers, disabledMcpjsonServers, a SessionStart hook, and several
PreToolUse hooks. Every claude spawn into such a worktree truncated that tracked file to a single
line, destroying the project's own configuration inside the worktree.

Observed repeatedly on 2026-07-21/22: four meshery worktrees each showed
M .claude/settings.local.json with a 73 +-------- diffstat. It blocked teardown three separate
times (correctly - it is indistinguishable from real uncommitted work) and any crewmate running
git add -A would have committed the truncation.

The second, subtler half: the exclude_path mitigation appends the path to .git/info/exclude,
which only suppresses untracked files. For a tracked file git still reports it modified, so the
mitigation silently did nothing in exactly the case that mattered.

Fix

Follow the precedent the codebase already set for the other harnesses - pi keeps its turn-end
extension in state/ and grok keeps its hook global under ~/.grok/hooks/, both deliberately
outside the worktree to avoid polluting the project. Claude's arm was the outlier.

  • Move claude's Stop hook out of the worktree entirely. It now lives in
    state/<id>.claude-settings.json and is loaded via --settings on the launch command, so the
    worktree's own .claude/settings.local.json is never written. A project that tracks that file is
    byte-identical after a spawn; a project without one keeps working unchanged; the turn-end hook
    still fires, so supervision still wakes. Secondmates keep the plain form.
  • Sibling-arm audit. The only worktree-resident hook writes left are opencode's
    .opencode/plugins/fm-turn-end.js and grok's .fm-grok-turnend; both are now guarded by
    refuse_if_tracked, which stops the spawn rather than clobber a tracked project file.
  • Fix the tracked-vs-untracked assumption at the source. exclude_path is now documented as
    tidiness-only, and refuse_if_tracked is the real guard. prune_obsolete_exclude_entries removes
    the now-stale .claude/settings.local.json line firstmate used to add to the shared
    .git/info/exclude, so repos already touched heal over time - those stale lines were actively
    masking tracked-file changes in meshery and meshery-cloud.
  • Guard the abort/unwind path so a refused spawn only unwinds work it created and never resets
    another task's uncommitted work or a temp root it did not create; git errors during unwind fail
    safe. Teardown cleans state/<id>.claude-settings.json at parity with the pi extension.

Tests

Regression coverage colocated in tests/, extending the existing runners rather than adding a new
one:

  • tests/fm-spawn-dispatch-profile.test.sh - a spawn into a worktree where
    .claude/settings.local.json is a tracked file with existing content leaves that file
    byte-identical (the case that actually bit us), plus the no-file and untracked-file cases and the
    refuse_if_tracked sibling-arm behavior.
  • tests/fm-teardown.test.sh - the dirty-copy unwind guard and claude-settings cleanup parity.
  • C-locale glyph regression tests pinning LC_ALL=C for the composer prompt-classification fix.

Validation

Validated end-to-end through the no-mistakes pipeline: automated review, tests, docs, and lint all
green. Lint's local run reported only ShellCheck not found (an environment gap on the run host,
exit 127 - not a code finding); CI runs the same bin/fm-lint.sh with ShellCheck present.

Notes

  • Delivered from a fork because our accounts lack direct push access to kunchenguid/firstmate.
  • Out of scope and intentionally untouched: worktree allocation ignoring recorded task ownership
    (tracked separately as fm-worktree-realloc-unlanded), which touches the same file.

…l.json

Symptom
-------
Every claude crewmate spawn into a meshery/meshery worktree truncated the
project's tracked .claude/settings.local.json - 72 lines of enabledMcpjsonServers,
disabledMcpjsonServers, a SessionStart hook and several PreToolUse hooks - down to
a single line. Observed on four worktrees on 2026-07-21/22, each showing
` M .claude/settings.local.json` with `73 +--------`, and it blocked fm-teardown
three separate times because the change is indistinguishable from real work.

Root cause
----------
Two assumptions, both wrong for a tracked path:

1. bin/fm-spawn.sh wrote the claude turn-end Stop hook with an unconditional
   `cat > "$WT/.claude/settings.local.json"`. That path is not firstmate's to
   own: it is a standard Claude Code file that projects legitimately commit.
2. The mitigation, `exclude_path`, appends to .git/info/exclude, which suppresses
   UNTRACKED files only. For a tracked file git still reports a modification, so
   the mitigation did nothing in exactly the case that mattered.

Fix
---
Follow the precedent already set twice in this file - pi keeps its turn-end
extension in state/, grok keeps its hook global under ~/.grok/hooks - and move
claude's hook out of the worktree entirely. fm-spawn now writes
state/<id>.claude-settings.json and loads it with `claude --settings <path>`.
`--settings` merges an additional settings file with the project's own rather
than replacing it, so the project keeps every hook and MCP server it declared and
the file is never touched. Chosen over merge-in-place and write-only-if-absent
because it removes the write from the worktree instead of making it more careful:
nothing to clobber, nothing to exclude, nothing to clean up, and no JSON merge
logic in shell.

The wrong assumption is fixed at its source, not just at the claude call site.
exclude_path's untracked-only limitation is now stated where it is defined, and
the two hooks that genuinely must live in a worktree (opencode's
.opencode/plugins/fm-turn-end.js, grok's .fm-grok-turnend) go through a new
refuse_if_tracked guard that stops the spawn rather than overwriting committed
project content. Both are firstmate-branded names, so a collision is pathological
rather than expected - but silently destroying project data is never the right
response to it, and the refusal happens before meta is written, so no
half-recorded task is left behind.

Adjacent fix, same root cause: fm-teardown removed the same three paths with an
unconditional `rm -f`, duplicated at four call sites. Since firstmate no longer
writes .claude/settings.local.json, that rm could only ever delete a project's
own file. The four copies are now one remove_worktree_hook_artifacts helper that
skips any path the project tracks, and state/<id>.claude-settings.json is added
to teardown's state cleanup.

Watch for
---------
codex and pi already carry their turn-end signal on the launch command and were
never affected. Secondmate claude launches have no per-task turn-end hook and
keep the plain launch form. Teardown's `?? .claude/` dirty filter is deliberately
left in place: it still covers claude's own runtime files and any worktree
spawned before this change.

Evidence
--------
Regression tests fail against the old code and pass against the new:

  $ bash tests/fm-spawn-dispatch-profile.test.sh   # before
  not ok - tracked project settings: spawn rewrote the project's tracked
           .claude/settings.local.json
  now: {"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '.../
       turnend-claude-tracked-z17.turn-ended'"}]}]}}

  $ bash tests/fm-teardown.test.sh                 # before, guard removed
  not ok - hook-artifacts: cleanup deleted the project's tracked
           .claude/settings.local.json

  $ bash tests/fm-spawn-dispatch-profile.test.sh   # after
  ok - claude turn-end hook leaves a tracked project .claude/settings.local.json
       byte-identical
  ok - claude turn-end hook adds no worktree artifact for a project without settings
  ok - a worktree-resident turn-end hook refuses to overwrite a tracked project file
  # all fm-spawn-dispatch-profile tests passed   (19 ok, 0 fail)

  $ bash tests/fm-teardown.test.sh                 # after
  ok - teardown removes untracked turn-end artifacts and preserves tracked project files
  (33 ok, 0 fail)

End-to-end against a fixture whose .claude/settings.local.json is tracked with
meshery-shaped content, driving the real bin/fm-spawn.sh:

  sha before spawn: bae75cd6f5fc974aebde8d24f441a607fcb58b3aceae823e775765d5eb7c4c21
  launch generated: CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude \
    --dangerously-skip-permissions --settings '<home>/state/<id>.claude-settings.json' \
    "$(cat '<home>/data/<id>/brief.md')"
  sha after spawn:  bae75cd6f5fc974aebde8d24f441a607fcb58b3aceae823e775765d5eb7c4c21
  git status:       (clean)

Running that exact command for real (Claude Code 2.1.217) from the worktree:

  turn-ended before: No such file or directory
  ... claude replies OK ...
  turn-ended after:  -rw-r--r-- 0 Jul 22 08:23 <home>/state/<id>.turn-ended
  sha after run:     bae75cd6f5fc974aebde8d24f441a607fcb58b3aceae823e775765d5eb7c4c21
  git status:        (clean)

A separate run with a project Stop hook present confirmed --settings merges
rather than replaces: both the external firstmate hook and the project's own
Stop hook fired, and the project file stayed byte-identical.

Lint:

  $ bin/fm-lint.sh
  fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0)
  FM_LINT_EXIT=0

Suites run green: fm-spawn-dispatch-profile (19), fm-teardown (33),
fm-grok-harness (3), fm-spawn-batch (3), fm-spawn-worktree-settle (2),
fm-secondmate-harness (40), fm-secondmate-safety (59), fm-backend (28),
fm-crew-state (47), fm-instruction-owners (9), fm-brief (14),
fm-pi-watch-extension (30).

Docs updated: harness-adapters records the verified --settings merge behavior and
the refuse-if-tracked rule; docs/orca-backend.md's spawn step no longer claims
every harness installs a hook into the worktree.

Signed-off-by: Ahmed Jamil <197998703+CodeAhmedJamil@users.noreply.github.com>
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