Skip to content

feat(spawn): add a per-task --base branch for ship and scout spawns - #3

Closed
talktejas wants to merge 1 commit into
mainfrom
fm/fm-spawn-per-task-base
Closed

talktejas wants to merge 1 commit into
mainfrom
fm/fm-spawn-per-task-base

Conversation

@talktejas

@talktejas talktejas commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Intent

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 registry file at the start of an effort and remembering to restore it at the end. The second mate that carries the rule pointed out, unprompted, that this is exactly how the rule fails quietly: a forgotten restore bases the next unrelated fix on a dead integration branch and nothing reports it.

So the ruling is sound and the tooling makes it fragile. You are closing that gap.

Concretely, in firstmate's own tooling: a task worktree's base branch today comes only from the shared per-project registry record, so honouring the ruling means editing that shared record at the start of an effort and remembering to restore it afterwards. Close the gap by giving a spawn an explicit per-task base branch that governs that one task only, is never written back to the shared registry, 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.

What Changed

  • bin/fm-spawn.sh accepts --base <branch>, which takes precedence over the project's data/projects.md base= record and the repository default when a pooled worktree is freshened, without writing anything back to the registry; a base that does not resolve refuses the spawn naming the source instead of falling back, and the flag is refused alongside --relaunch and on --secondmate. Batch dispatch forwards it with the other shared args.
  • The resolved base is recorded as base= in the task's state/<id>.meta, and preserve_relaunch_meta carries the existing record forward so a relaunch keeps the branch the task was created against.
  • Local-only base resolution no longer relies on $MODE alone: since scouts are refused --mode, the spawn falls back to the project's registered posture via fm-project-mode.sh --raw, so a scout on a local-only project resolves the local branch rather than a stale origin/<branch>. A worktree with no origin configured also honours an explicit base.
  • Tests cover explicit base winning over the registry, the registry file staying unmodified, refusal on an unresolvable base, scout spawns, local-only/no-origin resolution, and the secondmate refusal; the relaunch test asserts the recorded base= survives. Docs (docs/architecture.md, docs/scripts.md) and the project-management skill describe the per-spawn base and posture lookup.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The flag is well-bounded and fails closed on every unresolvable base, the authorized posture fix is correct and covered by a test that genuinely fails against the old logic, base= is additive metadata with no strict consumer, and the only surviving issues are two inaccurate documentation sentences.

Testing

I stood up throwaway firstmate homes and real git clones (a main-defaulted remote with a three-commit-ahead effort branch and a cold detached pool worktree) and dispatched real bin/fm-spawn.sh invocations against them. The headline user story works: --base integration/multicurrency lands the cold slot on that branch, records base=integration/multicurrency in state/&lt;id&gt;.meta, and leaves data/projects.md byte-identical, while the very next unrelated spawn with no flag still lands on the registry's standing base. The three failure modes the intent names all refuse loudly — a typo'd base stops the spawn naming the branch and writes no task record instead of quietly falling back to main, --relaunch --base refuses and keeps the task's own recorded branch, and --secondmate --base refuses. Both fixes selected in review round 1 were verified as real regressions: the scout/local-only base resolution fails against the pre-fix script and passes now, and the previously un-isolated secondmate test no longer creates state in the ambient firstmate home. The change is CLI- and git-state-facing with no rendered UI, so the evidence is CLI transcripts and resulting git/meta/registry state rather than screenshots.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Dispatching a ship spawn with --base integration/multicurrency starts the worker on that branch, not the registry's main ✅ pass live base-flag-walkthrough.txt section 1: worktree HEAD cd0fa4f == origin/integration/multicurrency, commit "effort part 3"
An explicit --base leaves the shared data/projects.md registry byte-identical ✅ pass live base-flag-walkthrough.txt section 1 (registry line UNTOUCHED); test_explicit_base_is_not_written_back in fm-spawn-base-branch.log
The very next unrelated spawn with no --base still uses the project's standing registry base ✅ pass live base-flag-walkthrough.txt section 2: hotfix-1 lands on origin/main, records base=main
Adversarial: a --base naming a branch that does not resolve refuses the spawn by name and launches no worker ✅ pass live base-flag-walkthrough.txt section 3: exit=1, error names integration/multicurency, worktree untouched, no state/mc-part-2.meta written
The resolved base is recorded with the task and a relaunch reuses it rather than accepting a new one ✅ pass live base-flag-walkthrough.txt section 4 (--relaunch --base refused, base=integration/multicurrency preserved); durable task metadata survives replacement launch publication in fm-control-relaunch.log
Adversarial: --secondmate has no task worktree and refuses --base ✅ pass live base-flag-walkthrough.txt section 5: exit=1 with the secondmate-specific refusal message
A scout accepts --base and, on a local-only project, resolves the local integration branch instead of a stale origin ref ✅ pass live test_scout_accepts_an_explicit_base and test_scout_on_a_local_only_project_uses_the_local_branch pass at ffc71d3; the latter fails against the pre-fix script (scout-local-only-before.log)
Adversarial: the secondmate refusal test no longer writes into the developer's ambient firstmate home ✅ pass live secondmate-isolation-leak-before.log shows the pre-fix invocation creating state/ in the ambient home; the full fixed suite run leaves no state/ directory and a clean git status
Evidence: End-user walkthrough of --base against the real fm-spawn.sh CLI

Source: End-user walkthrough of --base against the real fm-spawn.sh CLI

Registry (shared, captain's standing base):
- acme [no-mistakes base=main] - ships off main (added 2026-09-17)
Repo default branch: main   Effort branch: integration/multicurrency (3 commits ahead)

──────── 1. Part one of the effort: dispatch a ship spawn onto the integration branch
$ fm-spawn.sh mc-part-1 <proj> --mode no-mistakes --yolo off --base integration/multicurrency
warning: /tmp/fm-base-demo.ssLPgo/case/home/data/mc-part-1/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
spawned mc-part-1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-mc-part-1 worktree=/tmp/fm-base-demo.ssLPgo/case/cold-slot
worktree HEAD      -> cd0fa4f  effort part 3
origin/integration -> cd0fa4f
recorded in state/mc-part-1.meta -> base=integration/multicurrency
registry line now  -> - acme [no-mistakes base=main] - ships off main (added 2026-09-17)   (UNTOUCHED)

──────── 2. An unrelated fix dispatched right after: no --base, so the standing registry base still governs
$ fm-spawn.sh hotfix-1 <proj> --mode no-mistakes --yolo off
warning: /tmp/fm-base-demo.ssLPgo/case/home/data/hotfix-1/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
spawned hotfix-1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-hotfix-1 worktree=/tmp/fm-base-demo.ssLPgo/case/cold-slot
worktree HEAD -> 0f7471d  initial
origin/main   -> 0f7471d
recorded base -> base=main

──────── 3. Adversarial: a typo'd integration branch must refuse, not fall back to main
$ fm-spawn.sh mc-part-2 <proj> --mode no-mistakes --yolo off --base integration/multicurency
warning: /tmp/fm-base-demo.ssLPgo/case/home/data/mc-part-2/launch-brief.md records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode no-mistakes - confirm its definition of done matches
fatal: couldn't find remote ref refs/heads/integration/multicurency
error: could not fetch explicit base 'origin/integration/multicurency' for pooled worktree '/tmp/fm-base-demo.ssLPgo/case/cold-slot'; refusing to launch rather than falling back to another base
exit=1
worktree HEAD -> 0f7471d (still the untouched slot, no worker launched)
state/mc-part-2.meta exists? no

──────── 4. Adversarial: --relaunch may not be handed a different base
$ fm-spawn.sh mc-part-1 --relaunch --base main
error: --relaunch reuses the task's recorded base branch; --base cannot override it
exit=1
mc-part-1 still recorded as -> base=integration/multicurrency

──────── 5. Adversarial: a secondmate has no task worktree, so the flag is refused
$ fm-spawn.sh sm-1 --secondmate --base integration/multicurrency
error: --base applies only to ship and scout spawns; a secondmate launches in its own home, not a task worktree
exit=1
Evidence: fm-spawn-base-branch test suite output

Source: fm-spawn-base-branch test suite output

ok - base= parses alongside the delivery mode without changing any existing line's mode
ok - a cold slot for a develop-based project starts the worker on develop
ok - a project with no base record still starts from the repo default branch
ok - a project whose default branch is the right base passes the assertion
ok - an unregistered project falls back to the default branch and spawns as today
ok - a base= record naming a branch the repo does not have is refused
ok - a local-only project starts the worker from the local default branch
ok - a scout also starts from the project's recorded base branch
ok - an explicit --base wins over the project's registry base record
ok - an explicit --base is never written back to the shared registry
ok - the registry base still applies when --base is absent
ok - an explicit base the project cannot resolve refuses instead of falling back
ok - a scout also starts from an explicit --base
ok - a scout on a local-only project starts from the local base branch
ok - --relaunch refuses --base and keeps the task's own recorded base
ok - --secondmate refuses --base
# all fm-spawn-base-branch tests passed
Evidence: fm-control-relaunch test suite output (base= survives relaunch)

Source: fm-control-relaunch test suite output (base= survives relaunch)

ok - fm-control relaunch: a same-harness relaunch replaces the agent in the same endpoint and worktree
ok - fm-control relaunch: a linked spawning home preserves committed and unfinished work in the recorded copy
ok - fm-control relaunch: durable task metadata survives replacement launch publication
ok - fm-control relaunch: delivery and concurrent task metadata publication serialize
ok - fm-control relaunch: disabling tracing clears metadata and pane context
ok - fm-control relaunch: the progress note lands in the instructions the replacement reads
ok - fm-control relaunch: a ship task refuses without the progress note its replacement needs
ok - fm-control relaunch: switching harness is one ordinary relaunch, and the old wiring goes with the old agent
ok - fm-control relaunch: a harness switch resets model and effort unless they are named too
ok - fm-control relaunch: a prefixed recorded harness can switch adapters transactionally
ok - fm-control relaunch: a prefixed command requires an explicit replacement harness
ok - fm-control relaunch: a same-harness relaunch keeps the profile axes it was running with
ok - native Ultra relaunch preserves its profile and rejects an unsupported model before stopping
ok - fm-control relaunch: explicit model and effort win over the recorded ones
ok - fm-control relaunch: refuses to relaunch onto an adapter with no verified mechanics
ok - fm-control relaunch: the retired incarnation's global turn-end token is revoked
ok - fm-control relaunch: wiring cleanup failure refuses replacement arming
ok - fm-control-lib: one owner resolves each harness's turn-end registry entry, and refuses a malformed token
ok - fm-control relaunch: a secondmate relaunch re-resolves its durable configured harness pin
ok - fm-control relaunch: invalid configured effort is ignored before stop
ok - fm-control relaunch: an adapter unverified for this task kind refuses before the agent is stopped
ok - fm-control relaunch: explicit secondmate harness resets unnamed profile axes
ok - fm-control relaunch: a ship task keeps its recorded harness instead of re-reading crew config
ok - fm-spawn --relaunch: with no explicit harness it reuses the task's recorded one, never the crew default
ok - fm-spawn --relaunch: wiring armed under a prefixed harness name is still retired
ok - fm-spawn --relaunch: switching away from muse retires its session binding
ok - fm-spawn --relaunch: switching away from cursor retires its session binding
ok - fm-control relaunch: an unaccountable local copy refuses before the agent is touched
ok - fm-control relaunch: a worker with nothing to work from is never launched
ok - fm-control relaunch: a refusal before the agent is stopped leaves the durable record untouched
ok - fm-control relaunch: checkpoint inspection failures refuse before stopping
ok - fm-control relaunch: a launch failure after the stop keeps the prior record and reports the real state
ok - fm-control relaunch: unpublished rollback keeps concurrent durable metadata
ok - fm-control relaunch: post-publication failure keeps the new durable record
ok - fm-control relaunch: partial stop reconciles actual agent state
ok - fm-control relaunch: failed journal replacement preserves durable phase
ok - fm-spawn relaunch: prepublication abort removes replacement state
ok - fm-control relaunch: the checkpoint records the exact unlanded work it preserved
ok - fm-control relaunch: a secondmate's child work is accounted for and its charter is left alone
ok - fm-control relaunch: a secondmate home that is not this secondmate's is refused
ok - fm-control relaunch: unreadable and untraversable child state fails checkpoint
ok - fm-control relaunch: two control actions on one task serialize instead of interleaving
ok - fm-spawn relaunch: direct entry participates in lifecycle serialization
ok - fm-promote: promotion participates in lifecycle serialization
ok - fm-spawn --relaunch: refuses to launch a second agent into a live endpoint
ok - fm-spawn --relaunch: symlinked records refuse before inspection
ok - fm-spawn --relaunch: keeps its early meta lock continuous
ok - fm-spawn --relaunch: pending closes refuse before replacement begins
ok - fm-spawn --relaunch: every identity axis comes from the record, and a contradicting flag refuses
ok - fm-spawn --relaunch: an unrecorded task is refused
ok - fm-spawn --relaunch: refuses to start a replacement outside the copy holding its work
ok - relaunch re-reads the backlog item instead of blindly re-running the transition
ok - relaunch heals an item that drifted out of In flight while the task stayed live
Evidence: Scout/local-only base: failure against the pre-fix spawn script

Source: Scout/local-only base: failure against the pre-fix spawn script

=== new scout/local-only test run against PRE-FIX bin/fm-spawn.sh (284be65) ===
not ok - a scout on a local-only project should spawn: fatal: couldn't find remote ref refs/heads/integration/x
exit=1
Evidence: Secondmate test isolation: ambient-home leak before the fix

Source: Secondmate test isolation: ambient-home leak before the fix

=== PRE-FIX (284be65) secondmate invocation: no FM_HOME/FM_STATE isolation ===
$ ./bin/fm-spawn.sh sm-base-b14 --secondmate --base develop
error: --base applies only to ship and scout spawns; a secondmate launches in its own home, not a task worktree
exit=1

--- ambient home (FM_ROOT = this checkout) state/ afterwards ---
.
..
Evidence: Refusal transcript (unresolvable explicit base)
$ fm-spawn.sh mc-part-2 <proj> --mode no-mistakes --yolo off --base integration/multicurency
fatal: couldn't find remote ref refs/heads/integration/multicurency
error: could not fetch explicit base 'origin/integration/multicurency' for pooled worktree '.../cold-slot'; refusing to launch rather than falling back to another base
exit=1
worktree HEAD -> 0f7471d (still the untouched slot, no worker launched)
state/mc-part-2.meta exists? no

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ⚠️ tests/fm-spawn-base-branch.test.sh:423 - test_secondmate_refuses_an_explicit_base invokes "$SPAWN" with none of the env isolation every other test in this file uses (FM_HOME, FM_STATE_OVERRIDE, FM_DATA_OVERRIDE, FM_PROJECTS_OVERRIDE, FM_CONFIG_OVERRIDE, FM_SPAWN_NO_GUARD=1, PATH=$FAKEBIN_DIR:$PATH). bin/fm-spawn.sh:458 therefore runs the real bin/fm-guard.sh against the developer's or CI runner's ambient FM_HOME before the --base refusal at bin/fm-spawn.sh:591, and fm-guard.sh writes the episode marker state/.guard-watcher-stale-banner into that real home. Fix: wrap the invocation in the same env prefix used by test_relaunch_refuses_an_explicit_base.
  • ⚠️ bin/fm-spawn.sh:2523 - The local-only branch keys off MODE, but --mode is refused on scout spawns (bin/fm-spawn.sh:586) so MODE is empty for every scout. A scout spawned into a local-only project that still has an origin configured resolves target=origin/<base> instead of the local branch. Concrete sequence: local-only project, effort branch integration/x created and merged locally by bin/fm-merge-local.sh (never pushed); fm-spawn.sh s1 &lt;proj&gt; --scout --base integration/x either refuses (origin/integration/x does not exist, so the new flag is unusable for scouts on local-only projects) or resets the worktree to a stale origin/integration/x and the scout audits an old tree - the silent-wrong-base failure the flag exists to prevent. The registry-base path has the same hole since 11217bc; the smallest honest remedy is deciding how a scout learns the project's local-only posture (e.g. consulting fm-project-mode.sh for the resolution rule only), which extends the change beyond its stated intent and needs authorization.
  • ℹ️ bin/fm-spawn.sh:2545 - The comment states the recorded base= is read by "recovery, relaunch, and teardown", but no consumer reads base= from state/<id>.meta anywhere in bin/ or lib/. A relaunch reuses the recorded worktree and merely carries the line forward through preserve_relaunch_meta (base= is correctly absent from its owned-key list). The recording still satisfies the intent's requirement that a relaunch keep the branch the task was created against; only the comment's claimed consumers are inaccurate.

🔧 Fix applied.
3 infos still open:

  • ℹ️ bin/fm-spawn.sh:80 - The usage header says --base "resolves exactly as a registry base= does: origin/<branch> for a shared clone, the local branch in local-only mode or when no origin is configured." Both halves are now inaccurate. (a) With no origin configured, a registry base= is not applied at all: freshen_spawn_worktree_base returns 0 early at bin/fm-spawn.sh:2484 unless BASE_ARG is set, so only --base governs there - the two do not resolve identically. (b) The local-vs-origin decision is no longer "local-only mode": bin/fm-spawn.sh:2528-2529 resolves $posture from $MODE and, when that is empty (every scout), from the project's registered posture. Correct the two sentences to describe the posture-based rule and the no-origin asymmetry; no code change.
  • ℹ️ bin/fm-spawn.sh:85 - The header says "--relaunch reuses that record like every other identity axis", and docs/architecture.md:314 says "a relaunch reuses that record". Nothing reads base= from state/<id>.meta anywhere in bin/ (grep-verified); base= is absent from preserve_relaunch_meta's owned-key list, so a relaunch merely copies the line forward. The observable outcome is still right - a relaunch reuses the recorded worktree, so the task keeps its branch - but the stated mechanism is not. This is the same false-consumer claim the prior fix round corrected at bin/fm-spawn.sh:2549; these two copies were missed. Restate both as "the record is carried forward with the reused worktree".
  • ℹ️ tests/fm-spawn-base-branch.test.sh:469 - Beyond the three prescribed fixes, this round moved test_scout_also_starts_from_the_recorded_base from after the "# all fm-spawn-base-branch tests passed" banner into the test list. The author's commit invoked it after the banner, so a failure there would have been reported as passing. The repair is correct and in scope for the file; noting it so it is not mistaken for an unreviewed drive-by.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Dispatching a ship spawn with --base integration/multicurrency starts the worker on that branch, not the registry's main ✅ pass live base-flag-walkthrough.txt section 1: worktree HEAD cd0fa4f == origin/integration/multicurrency, commit "effort part 3"
An explicit --base leaves the shared data/projects.md registry byte-identical ✅ pass live base-flag-walkthrough.txt section 1 (registry line UNTOUCHED); test_explicit_base_is_not_written_back in fm-spawn-base-branch.log
The very next unrelated spawn with no --base still uses the project's standing registry base ✅ pass live base-flag-walkthrough.txt section 2: hotfix-1 lands on origin/main, records base=main
Adversarial: a --base naming a branch that does not resolve refuses the spawn by name and launches no worker ✅ pass live base-flag-walkthrough.txt section 3: exit=1, error names integration/multicurency, worktree untouched, no state/mc-part-2.meta written
The resolved base is recorded with the task and a relaunch reuses it rather than accepting a new one ✅ pass live base-flag-walkthrough.txt section 4 (--relaunch --base refused, base=integration/multicurrency preserved); durable task metadata survives replacement launch publication in fm-control-relaunch.log
Adversarial: --secondmate has no task worktree and refuses --base ✅ pass live base-flag-walkthrough.txt section 5: exit=1 with the secondmate-specific refusal message
A scout accepts --base and, on a local-only project, resolves the local integration branch instead of a stale origin ref ✅ pass live test_scout_accepts_an_explicit_base and test_scout_on_a_local_only_project_uses_the_local_branch pass at ffc71d3; the latter fails against the pre-fix script (scout-local-only-before.log)
Adversarial: the secondmate refusal test no longer writes into the developer's ambient firstmate home ✅ pass live secondmate-isolation-leak-before.log shows the pre-fix invocation creating state/ in the ambient home; the full fixed suite run leaves no state/ directory and a clean git status
  • bash tests/fm-spawn-base-branch.test.sh — 16 checks, all pass
  • bash tests/fm-control-relaunch.test.sh — includes the new base= survives-relaunch assertion, all pass
  • Manual CLI walkthrough: five real fm-spawn.sh dispatches against a main-defaulted clone with an integration/multicurrency branch (explicit base, no-flag registry fallback, typo'd base refusal, --relaunch --base refusal, --secondmate --base refusal)
  • Regression proof: new tests/fm-spawn-base-branch.test.sh run against the pre-fix bin/fm-spawn.sh from 284be65 — a scout on a local-only project fails there, passes at ffc71d3
  • Isolation proof: pre-fix un-isolated secondmate invocation creates state/ in the ambient firstmate home; the fixed suite run leaves the ambient home untouched
✅ **Document** - passed

✅ No issues found.

⏭️ **Lint** - skipped
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.


CI attribution

Checks on head 0d6ea01d. Nothing below needs re-deriving.

Check Result Owned or inherited Basis
Stock macOS Bash snapshot compatibility ✅ green inherited, fixed here Drifted pin: .github/workflows/ci.yml:403 asserted -eq 18 while tests/fm-fleet-snapshot-view.test.sh emits 20 ok - lines. Pin and its message updated to 20.
Behavior portable serial 4 ❌ red inherited, not fixed here Fails identically on this branch's untouched base commit 11217bc — see CI run 34445426890, job 102769078629, byte-identical assertion lines and the same total=31 failed=1 summary.
Behavior portable parallel 1 ⚠️ cancelled no verdict The provider cancelled it rather than judging it. Its log's only error is The operation was canceled; it has no failing assertion of its own. An absent verdict is not treated as a pass.
Lint, Repo invariants, Test coverage guard, Behavior tests (Herdr), Behavior timing aggregate, serial 1/2/3/5, parallel 2, PR must be raised via no-mistakes ✅ green — —

What this PR owns

bin/fm-spawn.sh and its two colocated test files, plus the docs/ and skill lines describing base resolution. A scoped diff of 11217bc..HEAD against tests/fm-bearings-board-render.test.sh, tests/assets/board-render-harness.mjs, bin/fm-bearings* and lib/ is empty.

Serial 4 is a real defect, not a stale assertion

The cause has since been identified independently: a restricted expression in the board-render harness. It is invisible on a developer machine — the suite passes all 20 assertions locally in this worktree — and reproduces only on the CI runner, which is why it reads as environment-specific here. The fix is going through the pipeline on its own branch. This branch will be rebased onto it, after which these checks should go green on their own merits.

Duplicated fix, deliberately

The macOS snapshot pin fix is also on another branch. It is carried here on purpose: without it this branch sits red waiting on a branch that is itself still red. Both branches set the same value, so the duplication conflicts with nothing and costs nothing.

Workflow linting

The workflow half of the lint step did not run locally: actionlint 1.7.12 is not installed on this machine, and installing it was out of scope. ShellCheck passed clean at the pinned 0.11.0. CI runs the workflow lint itself, and its Lint check is green above.

talktejas pushed a commit that referenced this pull request Sep 17, 2026
…kunchenguid#3578)

* fix(bin): let verified harness ancestry outrank retained markers (#3)

* fix(bin): let a structural harness ancestor outrank a retained marker

bin/fm-harness.sh treated a verified environment marker as unconditionally
authoritative, so a Codex session started from an environment that had retained
CLAUDECODE=1 detected as claude. Session start then emitted Claude's Stop-owned
supervision protocol to a Codex primary, and every turn end was blocked for
missing Claude recovery.

The defect is the precedence boundary, not any one harness. codex, opencode,
kimi, and muse publish no identity marker at all, so with markers winning
outright any retained CLAUDECODE renamed them; the Cursor-before-Claude ordering
was a point patch on the same class of problem, and the launch-time marker
clearing only ever covered sessions fm-spawn started.

Markers and ancestry are now separate evidence layers that detect_own arbitrates:

- no ancestry match, or no marker: the single available layer answers, unchanged;
- same harness family: the marker's finer verdict stands, so a launch-selected
  pi-signed is not flattened to pi by an ancestry walk that can only see the
  shared launcher name;
- different harness with a structural (command-name) ancestor: ancestry wins,
  because only ancestry proves who owns the process tree;
- different harness with only a bare-interpreter script-path match: the marker
  wins, since a harness-shaped path in some node process's arguments is weaker
  evidence than a harness publishing its own identity.

The correction is symmetric: a retained CURSOR_AGENT no longer renames a claude
worker nested under cursor either.

Adds fm-harness.sh ancestry [<pid>], ancestry evidence with no marker layer, so
a real harness process can be asked what the walk makes of it.

tests/fm-harness-precedence.test.sh is the portable regression, built from real
renamed processes with no harness installed. Every case drives the two layers
apart and asserts each alone as well as the combination, so no case can pass
vacuously; it also pins Codex's real two-process install topology, since the fix
depends on the native binary being what a tool subprocess meets first. The
opt-in drift guard gains the matching live half: each installed harness's real
running process must still be identified by the ancestry walk, and it fails
naming the harness and version when a release changes that name.

Documentation follows the corrected contract in the script header, the
harness-adapters detection section, the codex, opencode, kimi, and cursor
references, and a dated verification record.

* fix(tests): drop the unused argument pass-through in the shim-topology helper

bin/fm-lint.sh refused the branch: run_shim declared a `[ancestry]` argument and
forwarded "$@", but every call site that varies the environment or passes the
ancestry subcommand invokes the shim entry point directly, so the helper is only
ever called with no arguments (ShellCheck SC2120/SC2119).

Behavior is unchanged: with no arguments "$@" expanded to nothing.

* fix(bin): examine the top of the process chain instead of assuming init

harness_ancestry stopped as soon as the next pid was 1, on the assumption that
pid 1 is always init and can never be a harness.
Inside a PID namespace that assumption inverts: the harness itself is pid 1, so
the walk never examined the one process that proves who owns the tree, reported
no ancestry at all, and handed the verdict straight back to a retained marker.

A real Codex session under `codex sandbox`, holding CLAUDECODE=1 and
CLAUDE_CODE_ENTRYPOINT=cli, is exactly that shape: it resolved claude and
rendered Claude's Stop-owned supervision protocol even with the marker-vs-ancestry
precedence boundary in place.
The same probe now resolves codex and renders the Codex foreground checkpoint.

A host's real pid 1 (init, systemd, launchd) matches no harness name, so
examining it costs one ps call and can introduce no false positive; the walk
still stops once that top process has been read, and a non-numeric or zero ppid
still ends it.

tests/fm-harness-precedence.test.sh pins the namespace shape with a fake ps that
reports every process as bash with ppid 1 and pid 1 as the harness.
The case asserts the marker still answers alone when pid 1 is host-shaped, so it
cannot pass vacuously, and it fails against the previous stop condition.

* docs(verification): record the real-Codex retained-marker evidence

The existing record proved the precedence boundary with the portable regression
and recorded each installed harness's process name behind the ancestry walk, but
it had no evidence from a real Codex process actually holding a retained Claude
marker, which is the failure the boundary exists for.

Adds the dated before/after result from codex-cli 0.152.0 under `codex sandbox`,
with the exact command and the decisive verdict and rendered protocol on each
side, and records the second boundary that shape exposed: the walk must examine
the top of the process chain, because inside a PID namespace the harness is pid 1.
Refreshes the portable regression's observed output for the case it gained.

* no-mistakes(review): blind ancestry in marker-pinned harness tests

* no-mistakes(review): blind ancestry in the Pi guard-routing test

* no-mistakes(review): classify precedence suite, dedupe ps stub, soften claims

* no-mistakes(review): model the spawn-and-wait Codex shim topology

* no-mistakes(document): correct stale muse marker-clearing detection claims

* no-mistakes: apply CI fixes

* fix(bin): examine the top of the chain in the lock and nudge walks too

The pid-1 defect corrected in bin/fm-harness.sh survived unchanged in the two
other harness-ancestry walks, on the exact topology the branch verified against
a real Codex process.

bin/fm-session-lock-lib.sh's fm_harness_ancestry_pids stopped as soon as the next
pid was 1, so a firstmate whose harness is pid 1 of its own PID namespace could
not find that harness at all and did not recognize its own session lock.
bin/fm-sessionstart-nudge.sh carried the same stop plus a blanket rejection of a
lock pid of 1, so the same session was told to run session start again on every
turn.

Both walks now compare the top process before stopping, matching the shape used
in bin/fm-harness.sh.
For the lock walk this is safe because fm_harness_process_matches rejects a
host's real pid 1.
For the nudge, `kill -0` still gates the lock pid, and on a host an unprivileged
`kill -0 1` fails, so a lock file that wrongly names pid 1 leaves the hook silent
rather than acting on init.

Each walk gains one regression case. The lock case drives a deterministic process
table whose pid 1 is the harness and asserts a host-shaped pid 1 still finds
nothing, so it cannot pass vacuously. The nudge case needs a real PID namespace,
because the builtin `kill -0` gate cannot be reached through a fake ps, and it
first proves the same fixture nudges with no lock present; it skips explicitly
where unprivileged namespaces are unavailable.

* no-mistakes(review): assert comm-strength detection from subprocess vantage in drift guard

* fix(bin): verify the live harness guard at the strength the guarantee needs

The marker-versus-ancestry boundary this branch ships is a strength claim:
detect_own hands an args-strength verdict straight back to a retained foreign
marker, so a harness is only protected where the ancestry walk reaches it at
comm strength.

The installed-harness drift guard probed the pane process alone. Under an
interpreter shim the pane process IS the shim, whose own script path is args
strength, while the native binary that carries comm strength is its child. The
guard therefore observed args for Codex, passed, and would have kept passing if
a release stopped spawning that native child at all, while real sessions
silently regressed to the original bug.

fm-harness.sh gains `ancestry-subtree`, which asks the walk from the pane
process and every descendant of it, the vantage a tool subprocess actually
occupies. The guard now requires comm strength somewhere in that set and
requires every vantage to name the same harness.

This supersedes the preceding commit's in-guard leaf walk, which reached the
same vantage but left the logic inside the test file, where CI could not pin it
and nothing else could reuse it. A harness-dependent check needs both halves:
`tests/fm-harness-precedence.test.sh` now carries a portable case proving the
subtree probe reaches a strength the top-of-session probe cannot, mutation
checked twice, once against the pre-change script and once by disabling
descendant enumeration. The subtree walk also avoids depending on tty and
process-group semantics that differ between Linux and macOS.

Verified live: codex-cli 0.152.0 reports [args codex;comm codex] and Claude Code
2.1.257 reports [comm claude].

* no-mistakes(review): narrow drift guard to the upward vantage path

* no-mistakes(review): judge only comm-strength vantages in drift guard

* no-mistakes(document): drop duplicated rationale in detection precedence evidence

* no-mistakes(review): fix pid-1 nudge case vacuity and descent no-arg expansion

* no-mistakes(document): drop branch-relative phrasing in detection precedence evidence

* no-mistakes(review): guard remaining empty positional expansions in fm-harness

* no-mistakes(document): scope cursor marker-ordering claim to the marker layer

* no-mistakes(review): Prefer comm-strength leaves in equal-depth descent ties

* no-mistakes(document): Document comm-strength descent tie-break

---------

* no-mistakes(review): Blind ancestry in stale gemini/rovo marker-precedence tests

* no-mistakes(document): Add missing equal-depth-tie test line to precedence evidence transcript

* no-mistakes(review): Fix stale/vacuous agy precedence test, add agy to precedence suite and docs

* no-mistakes(document): Fix stale kimi.md marker doc missed by ancestry-precedence fix

---------

Co-authored-by: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com>
@talktejas
talktejas force-pushed the fm/fm-spawn-per-task-base branch 2 times, most recently from bfa22de to 7b8310b Compare September 17, 2026 20:58
A worker's base branch could only come from the project's standing base=
record in data/projects.md. Keeping an effort on one integration branch
therefore meant editing that shared registry at the start and remembering
to restore it at the end; a forgotten restore silently bases the next
unrelated task on a dead integration branch, and nothing reports it.

--base names the base for one ship or scout spawn and is never written
back, so the registry keeps holding the project's standing base.
Precedence is --base, then the registry base=, then the repository
default, unchanged below the flag. An explicit base the project cannot
resolve refuses the spawn naming the branch instead of falling back, and
an origin-less copy now honours an explicit base rather than silently
skipping it.

The resolved base is recorded as base= in the task record, so recovery,
relaunch, and teardown read the branch the task was created against
rather than re-resolving a registry that may have moved on. --relaunch
reuses that record and refuses --base for the same reason it refuses
--backend; --secondmate has no task worktree and refuses it too.
@talktejas
talktejas force-pushed the fm/fm-spawn-per-task-base branch from 7b8310b to f42df17 Compare September 20, 2026 05:46
talktejas added a commit that referenced this pull request Sep 20, 2026
* feat(spawn): add a per-spawn --base branch for ship and scout tasks

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.

* no-mistakes(review): honour a task's recorded base at landing and teardown

* no-mistakes(review): record a task base only when explicitly dispatched

* no-mistakes(document): correct local-landing docs for per-task base
@talktejas

Copy link
Copy Markdown
Owner Author

Superseded by #8, which carries the same content on a clean branch off main and is merged. This branch could not be taken through the pipeline: its private mirror record could not be reconciled after its head was replaced outside the pipeline.

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