Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ grok loads PROJECT hooks (`<worktree>/.grok/hooks/`, `<worktree>/.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 (`<worktree>/.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/<id>.turn-ended`.
`fm-spawn` writes that per-task pointer (`<worktree>/.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/<id>.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.
Expand Down
1 change: 0 additions & 1 deletion .claude/settings.local.json

This file was deleted.

15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
77 changes: 77 additions & 0 deletions tests/fm-hook-artifacts-not-tracked.test.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading