feat(bin): add a gate-merge delivery mode and a shared-machine crew rule - #1849
Open
brycehowardd wants to merge 9 commits into
Open
feat(bin): add a gate-merge delivery mode and a shared-machine crew rule#1849brycehowardd wants to merge 9 commits into
brycehowardd wants to merge 9 commits into
Conversation
A project whose crews land their own work through the project's own merge
gate could not be expressed as a delivery mode, so every ship brief for one
had its whole definition of done replaced by hand before dispatch, and the
generated rule 1 ("never push to any remote") contradicted a gate whose last
step is a push. Separately, no brief warned against broad pattern kills,
after one crew's `pkill -f vite` killed four sibling crews' agent sessions.
- `bin/fm-brief.sh` gains `--mode gate-merge`, whose definition of done has
the worker land its own work by running the project's own gate: run it from
the task worktree, never touch the primary checkout, retry in-turn while
another gate run holds the lock instead of idling on a declared wait, treat
a failing gate as a parked branch, and report an unreadable outcome rather
than assuming it landed. Rules 1 and 2 are shaped by the same mode, so the
rules and the definition of done can no longer disagree.
- The landing command arrives as `--gate <command>`, required by and exclusive
to gate-merge. The scaffold stays project-agnostic and never guesses a gate,
and a gate handed to any other path is refused rather than dropped.
- Every crewmate brief, ship and scout alike, now carries a shared-machine
rule: no pattern kills (kill only exact pids you started) and no bare stash
operations (the stash stack lives in the shared .git). Per-crew process
groups would not have helped - `pkill -f` selects on the command line and
matches straight through a process-group boundary.
- `fm-spawn.sh`, `fm-promote.sh`, and `fm-project-mode.sh` accept the new mode
so it is expressible per project as `[gate-merge]`; the spawn's rigor ranking
places it below the approval-gated modes, so shipping it on a more rigorous
project still prints the deviation notice. Teardown's backlog reminder
records a landed note instead of prompting for a PR URL.
- Existing no-mistakes, direct-PR, local-only, scout, and charter output is
byte-identical apart from the added shared-machine rule.
…n fm-spawn header
brycehowardd
force-pushed
the
fm/fm-brief-broad-kill-guard
branch
from
August 7, 2026 00:55
42de19f to
14bde7d
Compare
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
The developer wanted bin/fm-brief.sh, firstmate's crewmate-brief scaffold, fixed so that briefs for a gate-merge project no longer needed hand-editing before every dispatch (roughly 18 hand-patched briefs in one night). Three specific defects had to be addressed: add a shared-machine rule next to the existing shared-stash rule forbidding pattern kills (pkill -f / killall) and requiring crews to kill only exact pids they started via $!, after one crew's
pkill -f vitekilled four sibling crews' sessions on 2026-08-05; make delivery mode expressible per project as gate-merge so the generated definition of done tells crews to land their own work via the merge gate instead of stopping at a ready branch for firstmate to merge; and stop generated rule 1 ("never push to any remote and never open a PR") from contradicting a gate whose final step is a push, so rule 1 and the DoD agree. Stated acceptance criteria were that a gate-merge brief scaffolds correctly with no hand editing, the broad-kill rule appears for every project, existing local-only and direct-PR behavior stays unchanged, and the scaffold's own tests cover the new mode; the developer also asked whether fm-spawn should give each crew its own process group. Constraints included reading .agents/skills/firstmate-coding-guidelines/SKILL.md before editing, working only inside the disposable worktree, and consulting data/learnings.md entries from 2026-07-29, 2026-07-30, and 2026-08-05. During the later validation run the developer decided which review findings to fix (gate-retry-vs-rule-5, making rule 5 mode-shaped to exempt the documented gate-queue retry, plus the stale README mode enumeration) and explicitly declined three others as separate work items, forbidding any edit to the AGENTS.md section 7 authority wording as a captain boundary; they also barred self-installing tools, working around lint, or using --yes.What Changed
gate-mergeas a fourth delivery mode across the task-delivery scripts:fm-brief.shtakes a required--gate <command>(refused on every other mode) and emits a gate-shaped definition of done plus aDelivery contract: gate=<command>line,fm-promote.sh/fm-spawn.shaccept the new mode,fm-project-mode.shparses the registry'sgate=`<command>`note behind a separate--gateaccessor,fm-teardown.shrecords a landed-via-gate done note, andfm-merge-local.shpoints gate-merge tasks at their own gate.fm-spawn.shnow requires the brief's mode line and both gate occurrences (the contract line and the worker's "Run the gate from THIS worktree" step) to appear exactly once, compares each against the command the project's registry entry authorizes, refuses a gate-merge spawn whose project is registered under a conflicting posture, and falls back to a notice only when no registry entry is readable; the rigor ranking gains gate-merge as the least-gated mode.pkill -f/killall, kill only exact pids captured from$!, no baregit stashagainst the shared.git- reshapes rules 1, 2 and 5 so a gate-merge brief no longer contradicts its own gate, and updates README, AGENTS.md,docs/architecture.md,docs/scripts.mdand the project-management skill. Tests cover the new mode infm-brief,fm-task-deliveryandfm-teardown;fm-backend.test.shalso gainedfm-line-cap-lib.shin its old-bin sibling list to fix a pre-existing harness failure surfaced by the run.Risk Assessment
✅ Low: This commit is a small, well-tested symmetry fix that applies an already-reviewed pattern to one more read and repairs a masked test failure, leaving the branch converged with only a one-line header omission outstanding.
Testing
I drove the change the way firstmate actually uses it: registered a gate-merge project, scaffolded a brief in one command, and read the generated markdown a crewmate would receive - the definition of done names the exact registered gate command, rule 1 now points at the gate instead of forbidding the push the DoD requires (the old contradictory string is absent), rule 2 excepts the gate, and rule 5 exempts only the documented gate-queue retry window while staying byte-identical in every other mode and in scout briefs. The shared-machine rule forbidding pattern kills and bare stashes appears exactly once in all four ship modes and in scout briefs. fm-spawn then refused a hand-patched landing command, an appended second one, and a gate-merge spawn against a project registered otherwise, while the authorized brief cleared every delivery check and left no task metadata behind. A local-only brief generated from the base commit differs from the HEAD one only by the new shared-machine rule, confirming existing behavior is unchanged. Every targeted test script passed, including tests/fm-backend.test.sh which was the pre-existing failure fixed in the previous round. No screenshot or rendered-UI artifact applies: this product's user surface is a CLI plus the generated brief markdown, both captured verbatim as transcript and file artifacts.
Evidence: gate-merge operator walkthrough (full CLI transcript)
Evidence: generated gate-merge brief (the crewmate-facing document, no hand editing)
Evidence: generated local-only brief (unchanged path, now carrying rule 8)
Evidence: generated scout brief (shared-machine rule reaches scouts too)
Evidence: generated gate-merge definition of done and rules (excerpt)
Evidence: fm-spawn refusals for an unauthorized gate (excerpt)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-home-seed.sh:469- The secondmate seed guards in clone_project (:469) and validate_seed_project (:496) are denylists that only refuselocal-only, so a newly-registeredgate-mergeproject is silently accepted into a secondmate home even though their own error text and docs/configuration.md:179 both say secondmate routes cover onlyno-mistakesanddirect-PR. The remote counterparts are allowlists (bin/fm-remote-home-seed.sh:146, bin/fm-remote-home-provision.sh:216) and hard-fail a gate-merge project with "unsupported delivery mode". Local and remote secondmate provisioning therefore disagree for the new mode:fm-home-seed.shseeds it,fm-remote-home-seed.shrefuses it. Decide whether gate-merge projects are routable to secondmates (a secondmate's crew would then run the captain's landing gate from a separate clone) and make both paths and docs/configuration.md agree; converting the local guards to the same allowlist shape the remote ones use is the fail-closed option.bin/fm-spawn.sh:1367- gate-merge is the first mode where the worker itself lands work on the default branch with no approval step, but the only thing standing between a mistaken intake and that outcome is this advisory notice on stderr. tests/fm-task-delivery.test.sh confirms the intended behavior: a project registeredno-mistakesshipped asgate-mergeprints a notice and proceeds. For every previous mode a wrong --mode at worst got a pushed branch or an unmerged PR, with a merge authority still in front of the default branch; here it grants autonomous landing authority. Compounding it, nothing ties--mode gate-mergeor the arbitrary--gate <command>string to the project's registered posture or to the gate command that .agents/skills/project-management/SKILL.md:44 requires be recorded in the registry note - fm-brief.sh deliberately never reads the registry, and fm-spawn.sh never re-derives the gate. Consider whether a gate-merge spawn should require the project to actually be registered gate-merge (hard refusal rather than a notice), since AGENTS.md:301 makes that registration the captain's standing authorization for the gate.bin/fm-brief.sh:426- The gate-merge branch re-types the baseline rule-5 sentence verbatim instead of extending the default assigned four lines earlier at :407, so the same string now lives in two places and an edit to one silently diverges from the other. Appending instead keeps a single source: RULE5="$RULE5"$'\n'' The one exception is the gate queue: ...'. The new test's assert_contains "$rule5" "$baseline" would catch a divergence, but only after it ships in a hand-checked brief.bin/fm-brief.sh:311- The shared-machine rule's safe alternative to stashing is "Commit work in progress to your own branch instead", and it is rendered into scout briefs too (:382). A scout brief never creates a branch: its Setup says the worktree is at a detached HEAD (:355) and there is nogit checkout -bstep, unlike the ship brief's :503. The advice still works mechanically (a detached-HEAD commit is fine, and the worktree is scratch), but the one line that gives the alternative to a destructive shared-.git operation names something a scout does not have, which is the kind of small mismatch that invites a worker to improvise back towardgit stash.🔧 Fix: enforce the registered gate-merge posture and gate at spawn
3 issues (2 warnings, 1 info) still open:
bin/fm-brief.sh:444- The gate-merge brief records the landing command twice: the machine-readableDelivery contract: gate=$GATEat :441 and the prose instructionRun the gate from THIS worktree, with your branch checked out: \$GATE`` at :444. bin/fm-spawn.sh:1381 verifies only the first. The worker obeys the second - it is the imperative sentence under Definition of done, and the contract line is explicitly framed as machine-readable metadata. So a brief whose prose line is hand-patched to a different command, with the contract line left alone, passes every new check and the worker runs the unauthorized command with rule 2's explicit "the gate handles everything outside this worktree" carve-out. That is exactly the hand-patching this enforcement exists to remove, and duplicating the value is what reopens it. Either have fm-spawn also require the registered gate to appear in the prose line, or stop repeating the command in the prose and have the DoD point at the contract line as the single authority.docs/architecture.md:224- Line 224 still says "a ship spawn that drops below the registered rigor prints a deviation notice and continues", which is now false for gate-merge - bin/fm-spawn.sh:1378 exits 1 on a conflicting registered posture. Line 225 says bin/fm-project-mode.sh is "the one registry parser for the mechanical consumers that have no task in hand: fleet sync's local-only skip and home seeding's refusal and no-mistakes initialization", but the new --gate accessor is consumed by a task-in-hand caller, which the parser's own header at bin/fm-project-mode.sh:12-13 now states. docs/scripts.md:59 carries the same stale "for fleet sync and home seeding" scope. AGENTS.md, both script headers, and .agents/skills/project-management/SKILL.md were all updated in this commit; the architecture doc the README points to as full detail was not, so it now asserts notice-and-proceed for the one mode that refuses.bin/fm-project-mode.sh:121- The unknown-mode branch warns "defaulting to no-mistakes off" and sets mode=no-mistakes, but on the --gate path the very next block at :127 exits 3 with no output instead of defaulting - that is the intended behavior (an unreadable annotation is not a posture anyone can act on), yet the diagnostic tells the reader the opposite happened. bin/fm-spawn.sh discards this stderr with 2>/dev/null, so the only reader is a human running the accessor directly, who is told a default was applied and then gets empty stdout and a non-zero status. Make the warn text conditional on GATE_ONLY, or emit the unverified-posture wording alongside the exit 3.🔧 Fix: verify the registered gate in both brief occurrences
1 warning still open:
bin/fm-spawn.sh:1387- Both gate extractions end inhead -n 1(:1387 for the contract line, :1389 for the worker's step), so only the first occurrence of each fixed shape is compared against REGISTERED_GATE. Appending a secondRun the gate from THIS worktree, with your branch checked out:./hand-patched.sh`` line below the generated one leaves the verified first copy registry-clean, passes every check, and hands the worker a brief containing an unauthorized landing command under rule 2's explicit outside-the-worktree carve-out - the same class of hand edit this commit set out to make impossible, just by appending rather than editing in place. The asymmetry is worth noting: an occurrence injected ABOVE the generated one (for example through the {TASK} placeholder, which is filled in after scaffolding and sits earlier in the file) is caught, because head -n 1 then picks the injected value and it fails the equality check. Only a later duplicate slips through. Closing it is one line: require exactly one match of each shape (grep -c ... = 1) before comparing, or compare every extracted value rather than the first.🔧 Fix: require the registered gate exactly once per brief shape
2 infos still open:
bin/fm-spawn.sh:1358- This commit removed every positional read from the gate comparison path (exactly-once counts, then extraction with no head/tail), but the sibling BRIEF_MODE extraction three lines above the same block still ends inhead -n 1. A brief carrying twoDelivery contract: mode=lines has only its first compared against the explicit --mode, so a second, contradictory one passes unnoticed - the same brief/spawn drift this block exists to prevent, on the other contract line. The impact is materially lower than the gate case and this line predates the branch: the mode is validated against a closed set and against --mode, so a duplicate cannot grant authority the way an extra landing command can, and for a gate-merge task the gate checks are independent and strict. Raising it only because firstmate's instruction for this round said to leave no positional assumption in the comparison path; scoping that to the gate is a defensible reading, but it is worth a deliberate decision rather than an oversight. Applying the same exactly-once count to the mode line would make the block uniform.tests/fm-task-delivery.test.sh:336- scaffold_gate_brief ends in|| fail "..."but returns the brief path on stdout, so all six call sites invoke it asbrief=$(scaffold_gate_brief <id>). fail() doesexit 1, which inside a command substitution terminates only the subshell - the test script keeps running with brief set to the empty string. bin/fm-test-run.sh:1497 decides pass/fail purely from the script's exit code, so a genuine bin/fm-brief.sh failure here cannot fail the run through this path; it instead surfaces later asprintf ... >> ""and a spawn refusal about a missing brief, and the eventual assertion message points at the wrong cause. The neighbouring e9 case at :290 calls fm-brief.sh directly with|| failoutside a substitution and does not have this problem. Have the helper assign a caller-visible variable instead of printing the path, or guard the call sites with[ -n "$brief" ] || fail ....🔧 Fix: count the brief's delivery contract line before comparing
1 info still open:
bin/fm-spawn.sh:12- The usage header enumerates the outcomes for the brief's mode line as exactly two - "REFUSES a mismatch" and "a brief scaffolded before that line existed warns once and launches on the flag" - but this commit added a third at :1363, refusing a brief that carries two or more "Delivery contract: mode=" lines. The gap is visible inside the same paragraph: :19-21 of this header was updated in the previous round to state the gate's exactly-once rule explicitly, so the header now documents exactly-once for one contract line and not for its sibling. The new rule is currently captured only in the inline comment at :1355-1356, which --help does not print and which AGENTS.md section 11 does not treat as the contract surface. One clause on the mode sentence, matching the wording already used for the gate, makes the header complete.🔧 **Test** - 1 issue found → auto-fixed ✅
tests/fm-backend.test.sh:144- tests/fm-backend.test.sh failed deterministically onfm-send --key: old vs new exit code: expected exit 1, got 0, aborting the script after ~3s. Root cause is a stale test-harness list, not this branch: base commit 6c206ed added. "$SCRIPT_DIR/fm-line-cap-lib.sh"to bin/fm-send.sh:103 but did not add fm-line-cap-lib.sh to OLD_BIN_UNCHANGED_SIBLINGS, so build_old_bin()'s synthetic "old bin" could not source it and died under set -eu (rc_old=1) while the current tree exited 0. bin/fm-send.sh, bin/fm-line-cap-lib.sh and tests/fm-backend.test.sh are all untouched by 6c206ed..0e18a21, so this is pre-existing on origin/main. Fixed by adding fm-line-cap-lib.sh to the sibling list; the script now runs its full assertion set (26.5s) and passes.bin/fm-test-run.sh --changed --base 6c206ed(46 scripts; 1 failure, diagnosed and fixed below)bin/fm-test-run.sh tests/fm-brief.test.sh tests/fm-task-delivery.test.sh tests/fm-teardown.test.shbin/fm-test-run.sh tests/fm-backend.test.sh(x4 before the fix to confirm determinism, x2 after)Manual E2E: registered- acme-app [gate-merge] - ... gate=./scripts/merge-gate.sh --push...in data/projects.md, thenbin/fm-project-mode.sh acme-appandbin/fm-project-mode.sh --gate acme-appManual E2E:bin/fm-brief.sh acme-42 acme-app --mode gate-merge --gate './scripts/merge-gate.sh --push'and inspected the generated brief.md verbatimManual E2E:bin/fm-spawn.sh acme-42 <proj> --mode gate-merge --yolo offagainst (a) a matching registered gate, (b) a hand-patchedRun the gate from THIS worktree...step, (c) an injected second landing command, (d) a project registeredno-mistakes, (e) an unregistered project; asserted nostate/<id>.metawas written on each refusalManual E2E refusals:bin/fm-brief.sh ... --mode gate-mergewith no--gate, and--mode local-only --gate ./scripts/merge-gate.shRegression diff: generated no-mistakes / direct-PR / local-only / scout briefs withbin/fm-brief.shat base 6c206ed vs target 0e18a21 and diffed themEdge case: registered and scaffolded a gate containing flags and&&(npm run gate -- --push && echo ok) and confirmed it round-trips registry -> brief -> spawn with no mismatchRepro of the pre-existing failure: rebuilt build_old_bin()'s synthetic old bin/ by hand and ran both fm-send.sh copies with stderr visible🔧 Fix: add fm-line-cap-lib.sh to fm-backend test old-bin siblings
✅ Re-checked - no issues remain.
./tests/fm-brief.test.sh(all 24 cases, including the four new gate-merge/rule-5/--gate/shared-machine cases)./tests/fm-task-delivery.test.sh(spawn gate-merge posture + gate-occurrence enforcement, promote, project-mode)./tests/fm-backend.test.sh(regression check on the round-1 old-bin sibling-list fix)./tests/fm-teardown.test.sh(gate-merge done-note branch)./tests/fm-documentation-audiences.test.sh(README/AGENTS.md/docs prose surfaces)./tests/fm-secondmate-safety.test.sh(fm-project-mode consumer after its parser refactor)Manual operator walkthrough: registered agate-mergeproject in a temp firstmate home, ranbin/fm-project-mode.sh acme-gateand--gate acme-gate, scaffolded a brief withbin/fm-brief.sh gate-task-1 acme-gate --mode gate-merge --gate ./scripts/merge-gate.sh, and read back the generated DoD and rules verbatimManual refusal checks:bin/fm-brief.sh --mode gate-mergewithout--gate,--gateon alocal-onlybrief, and--mode nopeManual shared-machine check: generated no-mistakes / direct-PR / local-only / gate-merge / scout briefs and counted theSHARED MACHINErule in eachManual spawn enforcement:bin/fm-spawn.sh <id> <proj> claude --mode gate-merge --yolo offagainst a hand-patched gate step, an appended second gate step, a project registeredno-mistakes, and the authorized brief (faketmuxbackstop, so nothing was created)Manual regression diff: generated alocal-onlybrief from base6c206ed(viagit archive) and diffed it against the HEAD-generated onebin/fm-home-seed.sh:468- The new gate-merge posture is not covered by the secondmate-routing contract, and the local and remote seed paths now disagree. docs/configuration.md:179, .agents/skills/secondmate-provisioning/SKILL.md:151-152, and bin/fm-home-seed.sh's own error text all state that secondmate routes support only no-mistakes and direct-PR projects, but the local guard at bin/fm-home-seed.sh:468 and :495 refuses only local-only, so a gate-merge project seeds into a secondmate home while bin/fm-remote-home-seed.sh:146 and bin/fm-remote-home-provision.sh:216 refuse the same project outright. Resolving this needs a behavior decision (either add gate-merge to the local seed refusal, or widen the documented contract), so I did not edit the docs to assert either answer.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.