fix(spawn): stop clobbering a project's tracked .claude/settings.local.json#888
Closed
CodeAhmedJamil wants to merge 6 commits into
Closed
fix(spawn): stop clobbering a project's tracked .claude/settings.local.json#888CodeAhmedJamil wants to merge 6 commits into
CodeAhmedJamil wants to merge 6 commits into
Conversation
…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>
…l safe on git errors
…aude-settings state file
…in configuration.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
bin/fm-spawn.shwrote the claude crewmate turn-end hook by unconditionally overwriting<worktree>/.claude/settings.local.json:meshery/meshery(andmeshery-cloud) tracks.claude/settings.local.json- a 72-line filecarrying
enabledMcpjsonServers,disabledMcpjsonServers, aSessionStarthook, and severalPreToolUsehooks. Every claude spawn into such a worktree truncated that tracked file to a singleline, 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.jsonwith a73 +--------diffstat. It blocked teardown three separatetimes (correctly - it is indistinguishable from real uncommitted work) and any crewmate running
git add -Awould have committed the truncation.The second, subtler half: the
exclude_pathmitigation 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 deliberatelyoutside the worktree to avoid polluting the project. Claude's arm was the outlier.
Stophook out of the worktree entirely. It now lives instate/<id>.claude-settings.jsonand is loaded via--settingson the launch command, so theworktree's own
.claude/settings.local.jsonis never written. A project that tracks that file isbyte-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.
.opencode/plugins/fm-turn-end.jsand grok's.fm-grok-turnend; both are now guarded byrefuse_if_tracked, which stops the spawn rather than clobber a tracked project file.exclude_pathis now documented astidiness-only, and
refuse_if_trackedis the real guard.prune_obsolete_exclude_entriesremovesthe now-stale
.claude/settings.local.jsonline firstmate used to add to the shared.git/info/exclude, so repos already touched heal over time - those stale lines were activelymasking tracked-file changes in meshery and meshery-cloud.
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.jsonat parity with the pi extension.Tests
Regression coverage colocated in
tests/, extending the existing runners rather than adding a newone:
tests/fm-spawn-dispatch-profile.test.sh- a spawn into a worktree where.claude/settings.local.jsonis a tracked file with existing content leaves that filebyte-identical (the case that actually bit us), plus the no-file and untracked-file cases and the
refuse_if_trackedsibling-arm behavior.tests/fm-teardown.test.sh- the dirty-copy unwind guard and claude-settings cleanup parity.LC_ALL=Cfor 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.shwith ShellCheck present.Notes
kunchenguid/firstmate.(tracked separately as
fm-worktree-realloc-unlanded), which touches the same file.