From 118c2561f44abf6068a0ee7af2742553508e5f7a Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 19:43:52 -0700 Subject: [PATCH 1/2] fix: untrack .claude/settings.local.json, gitignore per-task hook artifacts bin/fm-spawn.sh's hook-install writes were already correct (unconditional cat >/printf > on every spawn, verified by the existing tests/fm-spawn-reused-worktree-hooks.test.sh, which passes as-is). The actual bug: PR #20 accidentally committed .claude/settings.local.json - a per-task worktree-resident file that binds one task incarnation's id and busy-gen - onto the default branch, with the content of a torn-down task (teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9). Every worktree checked out since then, fresh or handed back from the treehouse pool, starts with that stale file already tracked at HEAD. fm-spawn.sh's runtime overwrite papers over it in the live working tree, but the underlying tracked blob keeps re-seeding new checkouts, and it also makes every such worktree read as dirty in fm-teardown.sh's landed-work check (git status reports it modified, not untracked, so the existing `^\?\? \.claude/` exclusion never matches it). This is what a spawned crewmate actually observed in firstmate-ed399d/6 and /1: hooks still naming the dead task, which touch the wrong state/.turn-ended and report busy/idle under the wrong id. Fix: git rm --cached the file (keeping the live working-tree copy) and gitignore it plus the same class of per-task artifact (.opencode/plugins/fm-busy-state.js, the older fm-turn-end.js name, and the grok/kimi turnend token pointers) so none of them can ever be committed again - bin/fm-spawn.sh's own exclude_path() calls are a per-worktree-local backstop for files git has never tracked, but cannot undo an accidental commit once one lands on the default branch. Adds tests/fm-hook-artifacts-not-tracked.test.sh as a permanent guard: every per-task hook artifact must be gitignored and never tracked, with a control asserting the shared .claude/settings.json stays tracked and visible. --- .claude/settings.local.json | 1 - .gitignore | 15 ++++ tests/fm-hook-artifacts-not-tracked.test.sh | 77 +++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) delete mode 100644 .claude/settings.local.json create mode 100755 tests/fm-hook-artifacts-not-tracked.test.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 270b130e3b..0000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1 +0,0 @@ -{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9' busy --gen 'g1785595991.38675.22869' --source claude-hook --event user-prompt-submit 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"touch '/Users/trilliumsmith/code/firstmate/state/teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9.turn-ended'; '/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9' idle --gen 'g1785595991.38675.22869' --source claude-hook --event stop 2>/dev/null || true"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9' idle --gen 'g1785595991.38675.22869' --source claude-hook --event stop-failure 2>/dev/null || true"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"'/Users/trilliumsmith/code/firstmate/bin/fm-busy-event.sh' apply '/Users/trilliumsmith/code/firstmate/state' 'teardown-stale-teardown-tooling-cannot-reclaim-stale-wi-c9' idle --gen 'g1785595991.38675.22869' --source claude-hook --event session-end 2>/dev/null || true"}]}]}} diff --git a/.gitignore b/.gitignore index 1e5e8642ef..660ab10ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,18 @@ __pycache__/ *.pyc .env config/ + +# Per-task worktree-resident hook artifacts written by bin/fm-spawn.sh's +# busy-hook install (and scrubbed by bin/fm-teardown.sh on return to the +# treehouse pool). These bind one task incarnation's id/gen and must never be +# committed: a pooled worktree's next tenant checks out whatever is in git +# history, so a commit here would freeze one task's stale hooks into every +# future worktree derived from it (bin/fm-spawn.sh already calls +# exclude_path() for the same paths as a per-worktree-local backstop, but that +# only suppresses `git status`/`add -A` for a file git has never tracked - it +# cannot undo an accidental commit). +/.claude/settings.local.json +/.opencode/plugins/fm-busy-state.js +/.opencode/plugins/fm-turn-end.js +/.fm-grok-turnend +/.fm-kimi-turnend diff --git a/tests/fm-hook-artifacts-not-tracked.test.sh b/tests/fm-hook-artifacts-not-tracked.test.sh new file mode 100755 index 0000000000..6d0200df5a --- /dev/null +++ b/tests/fm-hook-artifacts-not-tracked.test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Per-task worktree-resident hook artifacts (bin/fm-spawn.sh's busy-hook +# install; bin/fm-teardown.sh's matching scrub) must never be committed to the +# firstmate repo. Each one binds one task incarnation's id and busy-gen; if one +# is ever accidentally tracked (a plain `git add -A` sweeps it in, same as +# happened once with .claude/settings.local.json), every future worktree - a +# fresh clone or a treehouse pool worktree handed back for reuse - checks out +# that stale content as its baseline. fm-spawn.sh's own unconditional +# hook-rewrite then papers over it in the live working tree, but the +# underlying tracked blob keeps re-seeding every new checkout with a dead +# task's id/gen, and any step that resets the worktree to HEAD before that +# rewrite runs re-exposes the stale hook directly. +# +# This is a repo-hygiene guard, not a fm-spawn.sh logic test - it protects the +# same invariant that tests/fm-spawn-reused-worktree-hooks.test.sh proves for +# fm-spawn.sh's own write path, but against the file becoming tracked in the +# first place, which a synthetic fixture repo can never reproduce. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +# Keep in sync with the exclude_path() call sites in bin/fm-spawn.sh and the +# rm -f scrub list in bin/fm-teardown.sh. +HOOK_ARTIFACTS=" +.claude/settings.local.json +.opencode/plugins/fm-busy-state.js +.opencode/plugins/fm-turn-end.js +.fm-grok-turnend +.fm-kimi-turnend +" + +test_hook_artifacts_gitignored() { + local path + for path in $HOOK_ARTIFACTS; do + git -C "$ROOT" check-ignore -q "$path" \ + || fail "git does not ignore $path (a per-task hook artifact must be gitignored so it can never be committed)" + done + pass "every per-task hook artifact path is gitignored" +} + +test_hook_artifacts_never_tracked() { + local path tracked + for path in $HOOK_ARTIFACTS; do + tracked=$(git -C "$ROOT" ls-files -- "$path") + [ -z "$tracked" ] \ + || fail "$path is tracked in git - a committed per-task hook artifact freezes one task's stale hooks into every future worktree checkout" + done + pass "no per-task hook artifact is tracked in the repo" +} + +test_unrelated_claude_settings_stays_tracked() { + # Control: the shared, non-per-task .claude/settings.json must remain + # tracked and visible, so the coverage above is proven by contrast rather + # than an overreaching ignore rule swallowing all of .claude/. + local tracked + tracked=$(git -C "$ROOT" ls-files -- .claude/settings.json) + [ -n "$tracked" ] \ + || fail ".claude/settings.json is not tracked (the ignore rule must not overreach into all of .claude/)" + git -C "$ROOT" check-ignore -q .claude/settings.json \ + && fail "git unexpectedly ignores .claude/settings.json (the shared, non-per-task settings file)" + pass "the shared .claude/settings.json stays tracked and visible" +} + +test_hook_artifacts_gitignored +test_hook_artifacts_never_tracked +test_unrelated_claude_settings_stays_tracked + +echo "all fm-hook-artifacts-not-tracked tests passed" From 80554e1cfbe7d751e78d3b75c4252eb1ec0a83b3 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 19:51:55 -0700 Subject: [PATCH 2/2] no-mistakes(document): Fix stale git-info/exclude-only claim about hook-artifact gitignoring --- .agents/skills/harness-adapters/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index afe9eb4440..b8a0e51ecc 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -343,7 +343,7 @@ grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/setting GLOBAL hooks in `~/.grok/hooks/` are always trusted and load on first launch. So `fm-spawn` installs ONE firstmate-owned global hook, `~/.grok/hooks/fm-turn-end.json`, plus the companion `~/.grok/hooks/fm-turn-end.sh`, guarded as a no-op for every non-firstmate grok session. Its `Stop` command fires only when the current workspace holds a `.fm-grok-turnend` token pointer that matches the firstmate-owned hook registry under `~/.grok/hooks/fm-turn-end.d/`. -`fm-spawn` writes that per-task pointer (`/.fm-grok-turnend`, gitignored via git info/exclude like the other harnesses' worktree hook files) and a matching registry entry naming this task's `state/.turn-ended`. +`fm-spawn` writes that per-task pointer (`/.fm-grok-turnend`, gitignored by the tracked root `.gitignore` like the other harnesses' worktree hook files, with `fm-spawn`'s own per-worktree `git info/exclude` call as a local backstop) and a matching registry entry naming this task's `state/.turn-ended`. The hook reads `$GROK_WORKSPACE_ROOT`, which is always set for hooks and equals the worktree. This keeps the hook outside the worktree, needs no trust grant, and writes only firstmate-owned files. `fm-teardown` removes the worktree pointer before returning a pooled worktree.