Skip to content

fix(bin): allowlist the opencode turn-end plugin in teardown's dirty check - #1269

Open
sbracewell64 wants to merge 1 commit into
kunchenguid:mainfrom
sbracewell64:fm/opencode-missing-from-teardown-allowlist-2
Open

fix(bin): allowlist the opencode turn-end plugin in teardown's dirty check#1269
sbracewell64 wants to merge 1 commit into
kunchenguid:mainfrom
sbracewell64:fm/opencode-missing-from-teardown-allowlist-2

Conversation

@sbracewell64

Copy link
Copy Markdown

Intent

Fix a reproduced defect in firstmate's teardown safety net: bin/fm-teardown.sh's dirty-check allowlist covered .claude/ and .fm-(grok|kimi)-turnend but not .opencode/plugins/fm-turn-end.js, one of the per-harness turn-end artifacts bin/fm-spawn.sh writes into a task worktree. The primary defense is fm-spawn's exclude_path (info/exclude), which returns silently when info/exclude cannot be resolved; the allowlist is the defense-in-depth layer behind it. When that exclude write does not take on an opencode worktree, an untracked .opencode/ survived the filter and teardown refused an otherwise clean tree as 'uncommitted changes'. Reproduced 2026-07-26 in a phase-1 investigation report.

Deliberate decisions a reviewer reading only the diff would not know:

  1. The investigation report recommended the simpler one-term fix of adding '.opencode/' to the regex. That was deliberately REJECTED. The instruction was explicitly to not loosen the dirty check and to allowlist only the exact firstmate-owned plugin path, because a bare directory term would wave through any untracked crewmate work living under .opencode/. The regex therefore names the exact file, anchored with a trailing $.

  2. Switching the status call to --untracked-files=all is not scope creep; it is required for the fix to work at all. git collapses a fully-untracked directory to a bare '?? .opencode/' line, so an exact-path allowlist term could never match it (verified directly: -unormal prints '?? .opencode/', -uall prints '?? .opencode/plugins/fm-turn-end.js'). Expanding untracked files only ever adds lines to the status output, so it cannot hide real dirty work. It is NOT claimed to change anything about .claude/: that term is an un-anchored directory prefix, so per-file lines such as '?? .claude/crew.txt' still match it and remain allowlisted exactly as before. Behavior for untracked work under .claude/ is unchanged by this diff.

  3. The .claude/ allowlist term is deliberately left as an un-anchored directory prefix rather than narrowed to the exact settings.local.json path. Narrowing it is out of scope for this minimal fix and carries a false-refusal risk if the claude harness ever writes another file under .claude/ in a task worktree. IMPORTANT ADJUDICATION: a prior review round of this same change raised exactly this point - that .claude/ remains a pre-existing hole where untracked crew work is waved through - as an ask-user finding. It was escalated and the decision came back as approve-as-is: do NOT extend scope to anchor the .claude/ term, and instead correct the commit message, which had claimed a side effect the code did not deliver. That correction has been made (the commit message no longer claims the .claude/ hole was closed; it now states explicitly that .claude/ behavior is unchanged and that narrowing it is out of scope). So the .claude/ prefix is a settled, authorized decision here, not an oversight.

  4. The regression test is the point of the task, not an add-on: this is a defense-in-depth layer nothing exercised, which is exactly why it drifted silently. It therefore pins ALL FOUR worktree-resident artifacts at once (claude, opencode, grok, kimi) rather than just the opencode one, so the next harness added to fm-spawn cannot drift the same way. A second negative case asserts that an untracked crewmate file sitting beside the plugin still refuses, proving the allowlist stays per-path and not per-directory. That negative case deliberately exercises .opencode/ (the anchored term), since .claude/ is intentionally a prefix per point 3.

  5. The test pins the repo's core.excludesFile to /dev/null on purpose. This machine's global gitignore contains **/.claude/settings.local.json, a confound the investigation report identified; without the pin the .claude portion of the assertion would be silently vacuous on a developer box while still running on CI. Verified that the pin makes the artifact visible and is inherited by linked worktrees.

  6. Verified the test actually catches the regression: with the pre-fix script restored, the new case fails with exactly the reported symptom (expected exit 0, got 1); with the fix it passes. Full suite 34/34, sibling endpoint-safety suite 5/5, bin/fm-lint.sh clean.

  7. Scope was deliberately kept minimal. The report also identified that the turn-end artifact list is duplicated across 8 sites in fm-teardown.sh and fm-spawn.sh and suggested a shared helper to prevent recurrence; that larger refactor is explicitly NOT part of this task. No docs were changed because no documentation surface documents this allowlist - per the repo's knowledge-placement rules it is a script-internal mechanic, so the rationale lives in a comment at the check itself rather than becoming a second copy that can drift. AGENTS.md was intentionally not touched for the same reason.

  8. Process note explaining the branch name: an earlier run of this work failed at the push step because the GitHub token lacked the 'workflow' scope while the push carried upstream .github/workflows/ci.yml changes. That credential has been repaired. A stale custody record from that failed run then blocked re-running on the original branch (recovery refused in both directions), so this identical validated tree, with only the commit message corrected, was moved to a fresh branch name ending -2 on an explicit authorization. Nothing was ever pushed and no PR was opened for the earlier attempt.

What Changed

  • bin/fm-teardown.sh's dirty-check allowlist now recognizes .opencode/plugins/fm-turn-end.js — the spawn-written opencode turn-end artifact — as an exact, $-anchored path, so teardown no longer refuses an otherwise clean opencode worktree as "uncommitted changes" when the info/exclude write did not take. The existing .claude/ prefix term is deliberately unchanged.
  • The git status --porcelain call now passes --untracked-files=all, required for the exact-path term to work: without it git collapses a fully-untracked directory to a bare ?? .opencode/ line that the anchored pattern can never match. Expanding untracked output only adds lines, so it cannot hide real dirty work.
  • tests/fm-teardown.test.sh adds two regression cases: one pins all four worktree-resident turn-end artifacts (claude, opencode, grok, kimi) as non-dirty so the next harness added to fm-spawn cannot drift the same way, and a negative case proves an untracked crewmate file beside the plugin still refuses teardown (the allowlist stays per-path, not per-directory). The suite pins core.excludesFile to /dev/null so a developer's global gitignore cannot make the assertions vacuous. The pipeline confirmed the new case fails against the pre-fix script and the full suite (34/34) plus the sibling endpoint-safety suite (5/5) pass with the fix.

Risk Assessment

✅ Low: A minimal, well-commented two-file fix whose allowlist was verified against every worktree-resident artifact fm-spawn.sh actually writes, with regression tests pinning all four artifacts plus a negative case proving the allowlist stays per-path; --untracked-files=all can only add status lines so it cannot mask real dirty work.

Testing

Ran the fm-teardown suite (34/34) and endpoint-safety suite (5/5), proved the new regression test fails against the pre-fix script with the exact reported symptom, and manually reproduced the end-user scenario with the real fm-teardown CLI: pre-fix a clean opencode worktree is refused as 'uncommitted changes', post-fix it tears down cleanly, and real untracked crewmate work beside the plugin still refuses — all intent constraints verified, no issues found.

Evidence: Pre-fix CLI transcript: clean opencode worktree refused as uncommitted changes

=== git status of the task worktree (what teardown's dirty check sees) === ?? .claude/settings.local.json ?? .fm-grok-turnend ?? .fm-kimi-turnend ?? .opencode/plugins/fm-turn-end.js === fm-teardown task-x1 === REFUSED: worktree /tmp/fm-teardown-demo.uVjAOY/case/wt has uncommitted changes. uncommitted changes present Commit them (or get the captain's explicit OK to discard, then --force). === exit code: 1 ===

=== git status of the task worktree (what teardown's dirty check sees) ===
?? .claude/settings.local.json
?? .fm-grok-turnend
?? .fm-kimi-turnend
?? .opencode/plugins/fm-turn-end.js

=== fm-teardown task-x1 ===
REFUSED: worktree /tmp/fm-teardown-demo.uVjAOY/case/wt has uncommitted changes.
uncommitted changes present
Commit them (or get the captain's explicit OK to discard, then --force).

=== exit code: 1 ===
Evidence: Post-fix CLI transcript: same worktree tears down cleanly

=== git status of the task worktree (what teardown's dirty check sees) === ?? .claude/settings.local.json ?? .fm-grok-turnend ?? .fm-kimi-turnend ?? .opencode/plugins/fm-turn-end.js === fm-teardown task-x1 === /tmp/fm-teardown-demo.ap9Vio/case/project: synced 61236b7..3bd5539 teardown task-x1 complete (window firstmate:fm-task-x1, worktree /tmp/fm-teardown-demo.ap9Vio/case/wt) === exit code: 0 ===

=== git status of the task worktree (what teardown's dirty check sees) ===
?? .claude/settings.local.json
?? .fm-grok-turnend
?? .fm-kimi-turnend
?? .opencode/plugins/fm-turn-end.js

=== fm-teardown task-x1 ===
/tmp/fm-teardown-demo.ap9Vio/case/project: synced 61236b7..3bd5539
teardown task-x1 complete (window firstmate:fm-task-x1, worktree /tmp/fm-teardown-demo.ap9Vio/case/wt)
Backlog: task-x1 just finished. Run tasks-axi done task-x1 --pr https://github.com/example/repo/pull/7, then run tasks-axi ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due.

=== exit code: 0 ===
Evidence: Post-fix safety check: untracked crewmate file beside the plugin still refuses

?? .opencode/notes.md ?? .opencode/plugins/fm-turn-end.js === fm-teardown task-x1 === REFUSED: worktree /tmp/fm-teardown-demo.6YTSh8/case/wt has uncommitted changes. === exit code: 1 ===

=== git status of the task worktree (what teardown's dirty check sees) ===
?? .claude/settings.local.json
?? .fm-grok-turnend
?? .fm-kimi-turnend
?? .opencode/notes.md
?? .opencode/plugins/fm-turn-end.js

=== fm-teardown task-x1 ===
REFUSED: worktree /tmp/fm-teardown-demo.6YTSh8/case/wt has uncommitted changes.
uncommitted changes present
Commit them (or get the captain's explicit OK to discard, then --force).

=== exit code: 1 ===
Evidence: New regression test fails against the pre-fix script (proves it catches the defect)

not ok - turnend-artifacts: teardown should ignore firstmate's own spawn-written turn-end artifacts: expected exit 0, got 1

ok - content fallback refreshes origin default before comparing trees
ok - dirty worktree is refused even when its committed work has landed (dirty always wins)
not ok - turnend-artifacts: teardown should ignore firstmate's own spawn-written turn-end artifacts: expected exit 0, got 1
Evidence: Reproducible manual demo script used for the CLI transcripts
#!/usr/bin/env bash
# Manual end-to-end demo for the opencode turn-end allowlist fix.
# Recreates the reproduced defect scenario: a no-mistakes ship task whose PR is
# merged and landed, whose worktree is otherwise clean, but which still carries
# the per-harness turn-end artifacts fm-spawn writes (with NO info/exclude
# entries, the reproduced failure mode). Then runs bin/fm-teardown.sh exactly as
# a user would and prints the transcript.
set -euo pipefail

REPO=$1            # firstmate repo root (provides bin/fm-teardown.sh under test)
DEMO_ROOT=$(mktemp -d /tmp/fm-teardown-demo.XXXXXX)
trap 'rm -rf "$DEMO_ROOT"' EXIT
case_dir="$DEMO_ROOT/case"
fakebin="$case_dir/fakebin"
mkdir -p "$case_dir/state" "$case_dir/config" "$fakebin"
export FM_GATE_REFUSE_BYPASS=1

# Mocks for post-check teardown steps (treehouse return, tmux window kill).
printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/treehouse"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fakebin/tmux"
chmod +x "$fakebin/treehouse" "$fakebin/tmux"

# Bare origin + project clone + task worktree.
git init -q --bare "$case_dir/origin.git"
git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main
git clone -q "$case_dir/origin.git" "$case_dir/_seed" 2>/dev/null
git -C "$case_dir/_seed" -c user.email=t@t -c user.name=t commit -q --allow-empty -m "origin baseline"
git -C "$case_dir/_seed" push -q origin main
rm -rf "$case_dir/_seed"
git clone -q "$case_dir/origin.git" "$case_dir/project"
git -C "$case_dir/project" remote set-head origin main 2>/dev/null || true
git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main
touch "$case_dir/state/.last-watcher-beat"

# Task meta: a no-mistakes ship task with a recorded PR.
cat > "$case_dir/state/task-x1.meta" <<EOF
window=firstmate:fm-task-x1
endpoint_task_id=task-x1
worktree=$case_dir/wt
project=$case_dir/project
kind=ship
mode=no-mistakes
pr=https://github.com/example/repo/pull/7
EOF

# The crewmate's work: committed, squash-landed on origin main, PR merged.
printf 'hello\n' > "$case_dir/wt/feature.txt"
git -C "$case_dir/wt" add feature.txt
git -C "$case_dir/wt" -c user.email=t@t -c user.name=t commit -q -m "add feature"
tmp="$case_dir/_land"
git clone -q "$case_dir/origin.git" "$tmp"
printf 'hello\n' > "$tmp/feature.txt"
git -C "$tmp" add feature.txt
git -C "$tmp" -c user.email=t@t -c user.name=t commit -q -m "squash feature"
git -C "$tmp" push -q origin HEAD:main
rm -rf "$tmp"
pr_head=$(git -C "$case_dir/wt" rev-parse HEAD)
cat > "$fakebin/gh-axi" <<'SH'
#!/usr/bin/env bash
case "${1:-} ${2:-}" in
  "pr list") printf '%s\n' "count: 1 (showing first 1)" "pull_requests[1]{number,state}:" "  7,merged" ; exit 0 ;;
  "pr view") printf '%s\n' "pull_request:" "  number: 7" "  state: merged" '  merged: "2026-06-26T00:00:00Z"' ; exit 0 ;;
esac
exit 0
SH
cat > "$fakebin/gh" <<SH
#!/usr/bin/env bash
case "\${1:-} \${2:-}" in
  "pr view")
    case " \$* " in
      *"state,headRefOid"*) printf '%s\t%s\n' 'MERGED' '$pr_head' ; exit 0 ;;
      *"headRefOid"*) printf '%s\n' '$pr_head' ; exit 0 ;;
    esac
    ;;
esac
echo "error: pull request not found" >&2
exit 1
SH
chmod +x "$fakebin/gh-axi" "$fakebin/gh"

# The reproduced failure mode: every per-harness turn-end artifact fm-spawn
# writes into the worktree, with NO info/exclude entries hiding them. Pin
# core.excludesFile so a machine-global ignore cannot mask the demo.
git -C "$case_dir/project" config core.excludesFile /dev/null
mkdir -p "$case_dir/wt/.claude" "$case_dir/wt/.opencode/plugins"
printf '%s\n' '{"hooks":{"Stop":[]}}' > "$case_dir/wt/.claude/settings.local.json"
printf '%s\n' 'export const FmTurnEnd = async () => ({})' > "$case_dir/wt/.opencode/plugins/fm-turn-end.js"
printf '%s\n' 'token=fm.abcdefghijkl' > "$case_dir/wt/.fm-grok-turnend"
printf '%s\n' 'token=fm.abcdefghijkl' > "$case_dir/wt/.fm-kimi-turnend"

# Optional second arg: also drop a real (crewmate-owned) untracked file beside the
# opencode plugin, which must still refuse.
if [ "${2:-}" = "--with-crew-file" ]; then
  printf '%s\n' 'real work' > "$case_dir/wt/.opencode/notes.md"
fi

echo "=== git status of the task worktree (what teardown's dirty check sees) ==="
git -C "$case_dir/wt" status --porcelain --untracked-files=all
echo
echo "=== fm-teardown task-x1 ==="
set +e
FM_ROOT_OVERRIDE="$REPO" \
FM_STATE_OVERRIDE="$case_dir/state" \
FM_CONFIG_OVERRIDE="$case_dir/config" \
PATH="$fakebin:$PATH" \
  "$REPO/bin/fm-teardown.sh" task-x1
rc=$?
set -e
echo
echo "=== exit code: $rc ==="

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-teardown.sh:692 - The .claude/ allowlist term remains an un-anchored directory prefix, so untracked crewmate work under .claude/ (e.g. ?? .claude/crew.txt) is still waved through by the dirty check — unlike the new anchored .opencode/plugins/fm-turn-end.js$ term. This is a pre-existing gap, explicitly adjudicated approve-as-is per the author's intent (point 3), and the commit message correctly states .claude/ behavior is unchanged. Recording only so the tradeoff is on the review record.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-teardown.test.sh at the target commit — all 34 cases pass, including the two new ones: test_spawn_turnend_artifacts_are_not_dirty_work and test_untracked_work_beside_turnend_artifacts_refuses
  • Regression proof: restored base-commit bin/fm-teardown.sh (99533c5) and re-ran bash tests/fm-teardown.test.sh — the new case fails with exactly the reported symptom (expected exit 0, got 1), confirming the test catches the defect
  • Manual end-to-end reproduction (demo-teardown.sh): built a real no-mistakes ship task worktree with a landed/merged PR plus all four spawn-written turn-end artifacts and no info/exclude entries, then ran bin/fm-teardown.sh task-x1 — pre-fix it REFUSED as 'uncommitted changes' (exit 1); post-fix it completes teardown (exit 0)
  • Manual negative case: same scenario plus an untracked crewmate file .opencode/notes.md beside the plugin — teardown still REFUSES (exit 1), proving the allowlist is per-path, not per-directory
  • bash tests/fm-teardown-endpoint-safety.test.sh — sibling suite sharing fm-teardown, 5/5 pass
  • Checked the diff against every intent constraint: exact \.opencode/plugins/fm-turn-end\.js$ anchored term (no bare directory term), --untracked-files=all on the status call, .claude/ prefix left unchanged, test pins all four harness artifacts and core.excludesFile /dev/null, no doc changes
⚠️ **Document** - 1 info
  • ℹ️ docs/decision-hold-lifecycle.md:81 - Pre-existing (not caused by this change): docs/decision-hold-lifecycle.md quotes 'all teardown safety cases passed' as tests/fm-teardown.test.sh output, but the suite prints only per-case 'ok - ...' lines and no such summary at both base and target commits. It is dated maintainer-verification evidence; refreshing it requires re-running and re-recording the evidence in a follow-up, not a doc-only edit here.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…check

bin/fm-teardown.sh's dirty check allowlists the turn-end scaffolding
fm-spawn writes into a task worktree, but that hand-maintained list had
drifted: .opencode/plugins/fm-turn-end.js was missing. When the
info/exclude write does not take, an opencode worktree left an untracked
.opencode/ surviving the filter, so teardown refused an otherwise clean
tree as uncommitted changes.

git collapses a fully-untracked directory to a bare "?? .opencode/" line,
which can neither match an exact path nor prove the directory holds
nothing else, so the status call now expands untracked files. Expanding
only ever adds lines, so it cannot hide real dirty work. The .claude/
term stays an un-anchored directory prefix on purpose, so behavior for
untracked work under .claude/ is unchanged: it is still allowlisted, and
narrowing it to the exact settings.local.json path is deliberately out of
scope for this fix.

Add regression coverage pinning every worktree-resident artifact at once,
since this is a defense-in-depth layer nothing exercised, plus a negative
case proving the allowlist stays per-path rather than per-directory. The
test pins core.excludesFile so a machine-global ignore of .claude/ cannot
silently make the case vacuous.
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