fix(bin): allowlist the opencode turn-end plugin in teardown's dirty check - #1269
Open
sbracewell64 wants to merge 1 commit into
Open
Conversation
…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.
5 tasks
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.
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:
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 $.
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.
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.
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.
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.
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.
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.
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 theinfo/excludewrite did not take. The existing.claude/prefix term is deliberately unchanged.git status --porcelaincall 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.shadds 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 pinscore.excludesFileto/dev/nullso 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 ===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 ===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 ===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 1Evidence: Reproducible manual demo script used for the CLI transcripts
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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.shat the target commit — all 34 cases pass, including the two new ones:test_spawn_turnend_artifacts_are_not_dirty_workandtest_untracked_work_beside_turnend_artifacts_refusesRegression proof: restored base-commitbin/fm-teardown.sh(99533c5) and re-ranbash tests/fm-teardown.test.sh— the new case fails with exactly the reported symptom (expected exit 0, got 1), confirming the test catches the defectManual 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 ranbin/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.mdbeside the plugin — teardown still REFUSES (exit 1), proving the allowlist is per-path, not per-directorybash tests/fm-teardown-endpoint-safety.test.sh— sibling suite sharing fm-teardown, 5/5 passChecked the diff against every intent constraint: exact\.opencode/plugins/fm-turn-end\.js$anchored term (no bare directory term),--untracked-files=allon the status call,.claude/prefix left unchanged, test pins all four harness artifacts andcore.excludesFile /dev/null, no doc changesdocs/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.