Repository navigation
feat(spawn): add a per-task --base branch for ship and scout spawns - #8
Merged
Merged
Conversation
Supersedes #3, which cannot be revalidated: that branch's head was replaced outside the pipeline, and no-mistakes then refuses to reconcile its private mirror record for the branch. Same content, clean branch from main. A task worktree's base branch comes from the project's own declaration, so honouring the captain's integration-branch ruling means editing shared state at the start of an effort and remembering to restore it at the end - and a forgotten restore bases the next unrelated fix on a dead integration branch with nothing reporting it. bin/fm-spawn.sh now accepts --base <branch>, which governs one spawn only: it takes precedence over bin/fm-project-base.sh's resolution and the repository default when a pooled worktree is freshened, writes nothing back, refuses the spawn naming the source rather than falling back when the branch does not resolve, and is refused alongside --relaunch and --secondmate. The resolved base is recorded as base= in the task's metadata so a relaunch keeps the branch the task was created against.
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
This branch replaces PR 3 #3, which cannot be revalidated: its head was replaced outside the pipeline, so no-mistakes refuses to reconcile that branch's private mirror record. Same content, one clean commit from current main.
The captain approved merging both firstmate pull requests, but neither can merge as it stands:
Both fixes matter daily: without the first, every worker starts from the project's default branch and the captain's integration-branch rule depends on hand-patched instructions; without the second, spawns fail with "pooled worktree not clean" because a live worker's copy is handed to a new task.
This branch is PR 3. Its own ask, which the approval above is an approval of: the captain ruled on 2026-09-17 that a multi-part effort must accumulate on one integration branch and merge into the active branch only once, checked as a whole. He gave that ruling after a night in which fifteen separate changes were merged into a product's develop branch one at a time, none of them ever compiled, and he found the 35 compile errors himself by failing to sign in to his own demo. Honouring that ruling with today's tooling means editing a shared project-wide base-branch record at the start of an effort and remembering to restore it at the end, and a forgotten restore bases the next unrelated fix on a dead integration branch with nothing reporting it. Close that gap by giving a spawn an explicit per-task base branch that governs that one task only, is never written back to the shared record, refuses rather than silently falling back when the named branch does not resolve, and is recorded with the task so a relaunch keeps the branch the task was created against.
Captain's ruling on conflicts, given while this rebase was under way: upstream (kunchenguid/firstmate) code wins wherever upstream has something; where it has nothing, resolve on your own. Upstream has NOT landed either fix - the per-task base branch is still open issue kunchenguid#3411 and the pool-reassignment bug is open issues kunchenguid#1441 and kunchenguid#1573, with several competing unmerged PRs (kunchenguid#4828, kunchenguid#4071, kunchenguid#4909, kunchenguid#4468, kunchenguid#3896). So: when a conflict hunk is upstream's own change to shared code, take upstream's version and re-apply our behaviour on top of it; only our two features are ours to keep. Do not adopt an unmerged upstream PR.
What Changed
bin/fm-spawn.shaccepts--base <branch>on ship and scout spawns: it overrides the project's registry/default base for that spawn alone, is never written back to shared state, refuses the spawn (naming the branch) when it does not resolve instead of falling back, and is rejected alongside--relaunchor--secondmate. A spawn that carries no--modeof its own reads the project's registered posture to decide betweenorigin/<branch>and the local branch.base=in the task's meta and carried forward by--relaunch;bin/fm-merge-local.shnow fast-forwards that recorded branch andbin/fm-teardown.shmeasures merged work against it, both falling back to the project's standing base when no base was recorded or the recorded branch has disappeared.docs/architecture.md,docs/scripts.md, and the project-management skill, and extended the spawn, teardown, and control-relaunch shell tests to cover flag parsing, resolution precedence, refusal cases, meta recording, and the landing/cleanup paths.Risk Assessment
✅ Low: The per-task base is opt-in and recorded only for an explicitly dispatched spawn, every new resolution path fails closed with a refusal rather than a silent fallback, the landing remains gated on an exact checkout match plus --ff-only so a wrong base can only refuse and never mis-merge, tasks dispatched without the flag are unchanged, and each new path has a regression test that fails against the pre-fix code.
Testing
I stood up sandbox firstmate homes and real git projects and drove the actual CLIs rather than only running unit assertions. The headline journey is the one the captain's ruling is about: an effort accumulating on integration/x, a third part dispatched against it with --base, landed with fm-merge-local.sh, and cleaned up — integration/x advanced, the project's standing main never moved, and the shared registry was never rewritten. The adversarial passes confirmed the flag fails closed (unresolvable branch, empty value, relaunch override, secondmate) and that a refused dispatch leaves no task record and no moved worktree. The two behaviour-preservation cases the last review round required both hold live: a no-flag task follows the project's current declaration even after it changes mid-flight, and a vanished recorded base falls back instead of refusing forever. The repo's three affected test files also pass in full. Evidence is CLI transcripts — this change has no UI surface, so no screenshots apply.
bash tests/fm-spawn-base-branch.test.shcase "--secondmate refuses --base" (drives the real fm-spawn.sh)bash tests/fm-spawn-base-branch.test.shcases "a scout also starts from an explicit --base" and "a scout on a local-only project starts from the local base branch", both driving the real fm-spawn.sh…Evidence: Effort lifecycle transcript: dispatch against integration/x, land there, main untouched, cleanup accepts
Source: Effort lifecycle transcript: dispatch against integration/x, land there, main untouched, cleanup accepts
Evidence: Regression transcript: a task with no --base follows the project's current declaration after it changes mid-flight
Source: Regression transcript: a task with no --base follows the project's current declaration after it changes mid-flight
Evidence: Boundary transcript: cleanup falls back when the task's recorded base branch has been deleted
Source: Boundary transcript: cleanup falls back when the task's recorded base branch has been deleted
Evidence: tests/fm-spawn-base-branch.test.sh output
Source: tests/fm-spawn-base-branch.test.sh output
Evidence: Landing refuses to move the standing branch (from the lifecycle transcript)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-spawn.sh:2937- The per-task base stops at spawn:base=is written to state/<id>.meta but no consumer reads it. bin/fm-merge-local.sh:98 and bin/fm-teardown.sh:1743 still resolve the project's standing base via bin/fm-project-base.sh. Concrete sequence: a local-only project registeredbase=main, spawn task A with--base integration/x; the worker branches fm/A off integration/x and commits. At landing, fm-merge-local.sh computes DEFAULT=main, requires the project checkout to be on main, finds main is an ancestor of fm/A, and fast-forwards main over the whole integration branch - the single task silently lands every accumulated commit of the effort into main, which is exactly the "merge into the active branch only once, checked as a whole" rule the flag exists to protect. Teardown has the mirror problem:git log HEAD --not mainreports commits that are already merged into integration/x as unmerged and refuses cleanup. The remedy (teaching merge-local and teardown to read the task's recorded base) extends the change beyond its stated spawn-only scope, so it needs authorization rather than being an auto-fix; scoping the flag to non-local-only projects, or documenting the local-only landing gap, are the alternatives.bin/fm-spawn.sh:2911- Simplification / unrequired component: the newposturefallback (fm-project-mode.sh --rawwhen $MODE is empty) changes base resolution for spawns that pass no--baseat all. Before this commit a scout on a local-only project with a registrybase=resolved againstorigin/<base>; now it resolves against the local branch. That is a separate behavior change from the intent's per-task--base(nothing in the intent requires re-deciding the origin-vs-local choice for registry-based scouts), and it also adds a registry read plus a subprocess on every spawn. The narrower form that satisfies the intent is to consult the registered posture only when$BASE_ARGis set - i.e.[ -n "$BASE_ARG" ] && [ -z "$MODE" ]- leaving the existing registry path byte-identical; if the scout/local-only resolution is genuinely a bug worth fixing, it belongs in its own change with its own regression test rather than riding along here.🔧 Fix applied.
2 warnings still open:
bin/fm-spawn.sh:2890- --base is fed to git without the branch-name validation bin/fm-project-base.sh:45 applies to the repo-file tier ('A branch name reaching git from a repository file is a trust boundary'). On the local-only / no-origin path target=$default and the only check isrev-parse --verify "$target^{commit}"(line 2923), which accepts any revision. Concrete sequence: a local-only project with origin configured,fm-spawn.sh t1 proj --mode local-only --yolo off --base origin/develop(or a tag, or HEAD~3). posture=local-only so target="origin/develop"; rev-parse resolves it,reset --hardsucceeds, the assertion at 2932 passes, andbase=origin/developis written to state/t1.meta. The spawn reports success. At landing bin/fm-merge-local.sh:106 looks up refs/heads/origin/develop, does not find it, and refuses with "was created against base branch 'origin/develop', which no longer exists" - a branch that never existed - leaving the task unlandable without hand-editing meta; bin/fm-teardown.sh:1750 likewise cannot resolve it and silently measures against the standing base instead. The intent requires the flag to name a branch and to refuse when the named branch does not resolve; a tag or remote-tracking ref satisfies neither. Remedy: validate BASE_ARG the way fm-project-base.sh's emit_if_valid does (charset plusgit check-ref-format --branch) and resolve the local-only target as refs/heads/<branch> rather than a bare revision, so a non-branch refuses at spawn instead of producing an unlandable task. The shared-clone path is safe only incidentally, because its fetch refspec+refs/heads/$default:...rejects the same input.bin/fm-spawn.sh:2939- The fix round records base= for every ship/scout spawn, not only for one dispatched with --base: SPAWN_RESOLVED_BASE=$default is set whatever base_source was (explicit, recorded, or default). Because bin/fm-merge-local.sh:104 and bin/fm-teardown.sh:1747 now prefer that record, every local-only task freezes its landing target at spawn time, and merge-local additionally HARD-REFUSES (lines 105-109) when the recorded branch is gone, where teardown falls back. Concrete regression for a task that never used the flag: a local-only project with an origin configured (so freshen_spawn_worktree_base does not return early at 2868) spawns task t1 while .firstmate-base declares develop, recording base=develop; the project then lands its adoption of main and deletes develop. Before this change bin/fm-merge-local.sh resolved the current declaration (main) and the fast-forward succeeded; now it reads base=develop, cannot verify refs/heads/develop, and refuses permanently, so an in-flight task can only be landed by hand-editing state/<id>.meta. The authorized remedy was 'read the task's own recorded base= and fall back to the project's standing base only when the task has none'; recording a base for tasks that were dispatched without one, plus the new refusal branch that has no counterpart in teardown, exceeds that. The narrower form that satisfies the intent is to set SPAWN_RESOLVED_BASE only when base_source=explicit, which leaves every non---base task's landing and cleanup byte-identical to before. It is the extra machinery, not a repair to it, that needs the author's decision.🔧 Fix applied.
1 info still open:
bin/fm-spawn.sh:2849- The intent states this branch is "Same content, one clean commit from current main". HEAD now carries three commits above the base: the author's feature commit 7335c69 plus two pipeline-authored fix commits (c808f59, 9b8522f). Nothing is functionally wrong - the fix commits are this run's own review fixes - but if the "one clean commit" shape is part of what makes PR 3 replaceable, the branch needs squashing before it is pushed. That is the author's call, not something review should do.✅ **Test** - passed
✅ No issues found.
bash tests/fm-spawn-base-branch.test.shcase "--secondmate refuses --base" (drives the real fm-spawn.sh)bash tests/fm-spawn-base-branch.test.shcases "a scout also starts from an explicit --base" and "a scout on a local-only project starts from the local base branch", both driving the real fm-spawn.sh…bash tests/fm-spawn-base-branch.test.sh(24 cases incl. the new explicit-base, scout, relaunch, secondmate, and landing cases)bash tests/fm-teardown.test.sh(full file, incl. newtest_local_only_merged_to_task_base_allows)bash tests/fm-control-relaunch.test.sh(incl.base=surviving relaunch)Live lifecycle driver:fm-spawn.sh <id> <proj> --mode local-only --yolo off --base integration/x→ worker commit →fm-merge-local.sh <id>→fm-teardown.sh <id>Live adversarial driver:fm-spawn.sh ... --base integration/typo,--basewith no value,fm-spawn.sh <id> --relaunch --base mainLive regression driver: spawn with no--base, project adoptsrelease/2026-10via.firstmate-basemid-flight, thenfm-merge-local.sh+fm-teardown.shLive boundary driver: task meta carryingbase=integration/doneafter that branch was deleted, thenfm-teardown.sh✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.