From 8d8ba1018093db0f6abc1e0687a503b812820a6f Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 09:53:04 -0700 Subject: [PATCH 1/9] feat(brief): add a gate-merge delivery mode and a shared-machine rule 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 `, 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. --- .agents/skills/project-management/SKILL.md | 2 + AGENTS.md | 1 + bin/fm-brief.sh | 82 +++++++++++++-- bin/fm-merge-local.sh | 2 +- bin/fm-project-mode.sh | 9 +- bin/fm-promote.sh | 10 +- bin/fm-spawn.sh | 21 ++-- bin/fm-teardown.sh | 3 + docs/architecture.md | 4 +- tests/fm-brief.test.sh | 112 ++++++++++++++++++++- tests/fm-task-delivery.test.sh | 21 +++- 11 files changed, 237 insertions(+), 30 deletions(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 8feb522bd0..fe6529da15 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -41,6 +41,7 @@ Choose that posture when adding or creating the project: - `no-mistakes` runs the full validation pipeline before a PR. - `direct-PR` pushes and opens a PR without the no-mistakes pipeline. - `local-only` has no required remote or PR and lands only through the approved local fast-forward path. +- `gate-merge` has the crewmate land its own work by running the project's own merge gate, with no PR and no firstmate merge; register it only when the captain has made that gate the project's standing way to land work, and record the exact gate command in the entry's note so every brief passes the same `bin/fm-brief.sh --gate `. - `no-mistakes-prod-only` is a conditional policy rather than one flat mode: genuinely internal-only tooling, automation, contributor or operator process, and release or submission work ships `direct-PR`, while product-facing, mixed, and uncertain work ships `no-mistakes`. `no-mistakes-prod-only` is the default for a newly added or created remote-backed project when the captain specifies nothing, and a project with no remote defaults to `local-only`. @@ -58,6 +59,7 @@ Confirm the source URL, local project name, delivery posture, and autonomy postu Clone into `projects/` and add the registry entry only after the destination is known to be unused. A `no-mistakes` or `no-mistakes-prod-only` project must have an `origin` remote and must complete the initialization procedure below, because a conditional policy's product-facing work runs the pipeline while its internal-only work still takes the direct PR. A `direct-PR` project needs an `origin` remote but skips no-mistakes initialization. +A `gate-merge` project owns its landing path, so it skips no-mistakes initialization and needs whatever remote its own gate pushes to. A `local-only` project may have no remote and skips no-mistakes initialization. ## Create a project diff --git a/AGENTS.md b/AGENTS.md index 8d13eeb0b6..c80cb0d86b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,6 +298,7 @@ The path's worker, automated gates, and captain approval remain authoritative: - **no-mistakes** runs the full pipeline through a PR, then waits for the configured merge authority. - **direct-PR** has the worker push and open a PR without the no-mistakes pipeline, then waits for the configured merge authority. - **local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority before firstmate uses the guarded fast-forward merge path. +- **gate-merge** has the worker land its own work by running the project's own merge gate, named per task as `bin/fm-brief.sh --gate `; registering that posture is the captain's standing authorization for that gate, firstmate never merges the work itself, and a failing gate parks the branch. Delivery mode and `yolo` are orthogonal. With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index a873c84051..08a7f44056 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -6,7 +6,7 @@ # description, acceptance criteria, and context, and may adjust other sections # when the task genuinely deviates (e.g. working an existing external PR instead # of shipping a new one). -# Usage: fm-brief.sh --mode [--herdr-lab] +# Usage: fm-brief.sh --mode [--gate ] [--herdr-lab] # fm-brief.sh --scout [--herdr-lab] # fm-brief.sh --secondmate {...|--no-projects} # --scout writes the scout contract instead: the deliverable is a report at @@ -34,8 +34,14 @@ # direct-PR implement -> push + open PR via gh-axi (no pipeline) -> configured merge authority # local-only implement on branch, stop and report "ready in branch" (no push/PR); # the configured merge authority approves, firstmate merges to local main +# gate-merge implement on branch, then land it by running the project's own merge +# gate (no PR, no pipeline, no firstmate merge); registering that posture +# is the captain's standing authorization for the gate to land the work # no-mistakes-prod-only is a registry policy, not a task mode; resolve it to one of -# the three concrete modes at intake before calling this script. +# the four concrete modes at intake before calling this script. +# --gate names the exact command that lands the work. It is REQUIRED by and +# exclusive to gate-merge: the scaffold is project-agnostic and never guesses a project's +# gate, and a gate passed to any other mode is refused rather than silently dropped. # The generated ship brief records the chosen mode as a fixed machine-readable # "Delivery contract: mode=" line. bin/fm-spawn.sh reads that line and refuses # to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the @@ -106,6 +112,8 @@ HERDR_LAB=0 NO_PROJECTS=0 MODE= MODE_SET=0 +GATE= +GATE_SET=0 POS=() want_value= for a in "$@"; do @@ -115,6 +123,7 @@ for a in "$@"; do esac case "$want_value" in mode) MODE=$a; MODE_SET=1 ;; + gate) GATE=$a; GATE_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -127,6 +136,8 @@ for a in "$@"; do --no-projects) NO_PROJECTS=1 ;; --mode) want_value=mode ;; --mode=*) MODE=${a#--mode=}; MODE_SET=1 ;; + --gate) want_value=gate ;; + --gate=*) GATE=${a#--gate=}; GATE_SET=1 ;; # yolo never reaches the worker: it is firstmate's approval authority, not a # brief input. Refuse it loudly so it is never silently dropped here and then # believed to have been recorded. @@ -140,20 +151,33 @@ done # missing or invalid value stops the scaffold rather than silently defaulting. if [ "$KIND" = ship ]; then [ "$MODE_SET" -eq 1 ] || { - echo "error: ship briefs require --mode ; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2 + echo "error: ship briefs require --mode ; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2 exit 1 } case "$MODE" in - no-mistakes|direct-PR|local-only) ;; + no-mistakes|direct-PR|local-only|gate-merge) ;; no-mistakes-prod-only) echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR at intake" >&2 exit 1 ;; - *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; + *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only, gate-merge (got '$MODE')" >&2; exit 1 ;; esac elif [ "$MODE_SET" -eq 1 ]; then echo "error: --mode applies only to ship briefs; a scout delivers a report and a secondmate charter is not a delivery contract" >&2 exit 1 fi + +# The landing command is the whole substance of a gate-merge definition of done, and +# this scaffold never reads data/projects.md, so it must be given rather than guessed. +# Every other mode lands through a firstmate-owned path and would silently discard it. +if [ "$KIND" = ship ] && [ "$MODE" = gate-merge ]; then + { [ "$GATE_SET" -eq 1 ] && [ -n "$GATE" ]; } || { + echo "error: --mode gate-merge requires --gate ; pass the project's own landing command (for example --gate ./scripts/merge-gate.sh) so the definition of done names the exact command that lands the work" >&2 + exit 1 + } +elif [ "$GATE_SET" -eq 1 ]; then + echo "error: --gate applies only to --mode gate-merge; every other delivery path lands through firstmate or a PR, so a gate command here would be recorded nowhere" >&2 + exit 1 +fi ID=${POS[0]} if [ "$KIND" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then @@ -266,6 +290,26 @@ fi REPO=${POS[1]} +# Shared-machine rule, rendered into every crewmate brief (ship and scout alike). +# Worktree isolation covers a working tree and nothing else: the process table and +# the repo's `.git` are shared by every concurrent lane on this host. Both halves are +# live incidents - a broad `pkill -f vite` killed four sibling crews' agent sessions +# outright, and a bare `git stash pop` pops whatever a sibling pushed last, leaving no +# reflog entry to recover from. +# Spawning each crew into its own process group would not help: `pkill -f`/`pgrep -f` +# select on the command line, and a process in its own session and process group is +# still matched from outside it, so the instruction is the control that fits. +# shellcheck disable=SC2016 # single quotes are deliberate: `$!` and the backticked commands are literal brief text for the reading agent, never expanded at scaffold time. +SHARED_MACHINE_RULE=$(printf '%s\n' \ +'8. SHARED MACHINE: other crews work on this host and in sibling worktrees of this repo at the' \ +' same time, and worktree isolation does not cover either the process table or `.git`.' \ +' Never `pkill`/`killall` by pattern - a pattern as ordinary as `vite` or `node` also matches' \ +' sibling crews'"'"' processes and their agent sessions. Kill only exact pids you started yourself' \ +' (capture `$!` when you background a dev server, then `kill "$pid"`).' \ +' Never bare `git stash`/`git stash pop`/`git stash drop` - the stash stack lives in the shared' \ +' `.git` and a popped stash leaves no reflog entry, so you can destroy a sibling'"'"'s uncommitted' \ +' work unrecoverably. Commit work in progress to your own branch instead.') + if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") # shellcheck disable=SC2016 # single quotes are deliberate: these lines are literal brief text whose backtick-wrapped $(...) and "$HERDR_LAB_SESSION" snippets must reach the reading agent verbatim, not expand at scaffold time; only the '"$VAR"' break-outs interpolate. @@ -335,6 +379,7 @@ The report is the only thing that survives, so anything worth keeping must be in 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon. +$SHARED_MACHINE_RULE # Definition of done Write your findings to \`$DATA/$ID/report.md\`. @@ -347,10 +392,15 @@ echo "scaffolded: $BRIEF (scout; replace {TASK})" exit 0 fi -# Ship task: shape Setup / Rule 1 / Definition of done by this task's explicit +# Ship task: shape Setup / rules 1-2 / Definition of done by this task's explicit # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own # explicit --mode before launching. +# Rules 1 and 2 must never contradict the definition of done: a worker told both to +# land its own work and never to push would stall or improvise. Rule 2 is worktree +# isolation, and gate-merge is the one mode with an authorized way to act outside the +# worktree - the gate itself, never the worker by hand. +RULE2='2. Stay inside this worktree; modify nothing outside it.' case "$MODE" in direct-PR) SETUP2="" @@ -362,6 +412,23 @@ This task ships **direct-PR**: you raise the PR yourself, without the no-mistake The task is complete only when committed on your branch. When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop. Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome. +EOF + ;; + gate-merge) + SETUP2="" + RULE1="1. The merge gate named in Definition of done is the only thing that lands your work: never merge by hand, never push \`main\` or your branch yourself, and never open a PR." + RULE2='2. Stay inside this worktree; modify nothing outside it yourself. Running the merge gate below is the one authorized exception, and the gate handles everything outside this worktree on its own.' + IFS= read -r -d '' DOD <> $STATUS_FILE\` @@ -451,6 +518,7 @@ $RULE1 7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving every lane/home, so restarting it kills other lanes' in-flight pipeline runs. On ANY no-mistakes daemon error, append \`blocked: {the daemon error}\` and stop; only firstmate manages the daemon. +$SHARED_MACHINE_RULE # Project memory If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index fdc8011488..cea7f24c80 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -23,7 +23,7 @@ META="$STATE/$ID.meta" PROJ=$(grep '^project=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) -[ "$MODE" = local-only ] || { echo "error: task $ID is mode=$MODE, not local-only; merge PR tasks with bin/fm-pr-merge.sh after approval" >&2; exit 1; } +[ "$MODE" = local-only ] || { echo "error: task $ID is mode=$MODE, not local-only; merge PR tasks with bin/fm-pr-merge.sh after approval, and let a gate-merge task land through its own project gate" >&2; exit 1; } default_branch() { local ref branch diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 6a97ce2dfe..a4b0e7a868 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Resolve a project's REGISTERED delivery posture from the data/projects.md registry. # Prints two words to stdout: " " where mode is one of -# no-mistakes|direct-PR|local-only and yolo is on|off. +# no-mistakes|direct-PR|local-only|gate-merge and yolo is on|off. # # MECHANICAL CONSUMERS ONLY. This answers "what posture did the captain register # for this project", never "how does this task ship". A task's delivery mode and @@ -20,6 +20,11 @@ # no-mistakes full pipeline -> PR -> configured merge authority (default) # direct-PR push + PR via gh-axi, no pipeline # local-only local branch, no remote/PR, guarded local merge +# gate-merge the crewmate lands its own work by running the project's +# own merge gate; no PR and no firstmate merge. Registering +# it is the captain's standing authorization for that gate. +# The gate command itself is not registry data: firstmate +# passes it per task as bin/fm-brief.sh's --gate. # no-mistakes-prod-only a conditional policy, not a task mode: firstmate # classifies each task's surface at intake (the # project-management skill owns that classification). @@ -81,7 +86,7 @@ fi mode=${parsed%% *} yolo=${parsed##* } case "$mode" in - no-mistakes|direct-PR|local-only|no-mistakes-prod-only) ;; + no-mistakes|direct-PR|local-only|gate-merge|no-mistakes-prod-only) ;; *) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off ;; esac case "$yolo" in on|off) ;; *) yolo=off ;; esac diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 92c3e53448..2558af66bb 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -12,7 +12,7 @@ # read the scout's report (AGENTS.md section 7); data/projects.md holds the # captain's standing posture as context, and this script never looks it up. # no-mistakes-prod-only is a registry policy rather than a task mode and is refused. -# Usage: fm-promote.sh --mode --yolo +# Usage: fm-promote.sh --mode --yolo set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -47,9 +47,9 @@ for a in "$@"; do esac done [ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } -[ "${#POS[@]}" -ge 1 ] || { echo "usage: fm-promote.sh --mode --yolo " >&2; exit 1; } +[ "${#POS[@]}" -ge 1 ] || { echo "usage: fm-promote.sh --mode --yolo " >&2; exit 1; } [ "$MODE_SET" -eq 1 ] || { - echo "error: promotion requires --mode ; decide it now from the scout's findings and the project's registered posture in data/projects.md" >&2 + echo "error: promotion requires --mode ; decide it now from the scout's findings and the project's registered posture in data/projects.md" >&2 exit 1 } [ "$YOLO_SET" -eq 1 ] || { @@ -57,11 +57,11 @@ done exit 1 } case "$MODE" in - no-mistakes|direct-PR|local-only) ;; + no-mistakes|direct-PR|local-only|gate-merge) ;; no-mistakes-prod-only) echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR" >&2 exit 1 ;; - *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; + *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only, gate-merge (got '$MODE')" >&2; exit 1 ;; esac case "$YOLO" in on|off) ;; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 44d1184b09..7ea75cf459 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] +# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] # fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --secondmate # --mode and --yolo are this task's delivery contract, REQUIRED for every ship @@ -310,7 +310,7 @@ esac # and record no delivery posture; secondmate spawns hardcode theirs. if [ "$KIND" = ship ]; then [ "$MODE_SET" -eq 1 ] || { - echo "error: ship spawns require --mode ; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2 + echo "error: ship spawns require --mode ; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2 exit 1 } [ "$YOLO_SET" -eq 1 ] || { @@ -318,11 +318,11 @@ if [ "$KIND" = ship ]; then exit 1 } case "$MODE" in - no-mistakes|direct-PR|local-only) ;; + no-mistakes|direct-PR|local-only|gate-merge) ;; no-mistakes-prod-only) echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR at intake" >&2 exit 1 ;; - *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; + *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only, gate-merge (got '$MODE')" >&2; exit 1 ;; esac case "$YOLO" in on|off) ;; @@ -1329,11 +1329,16 @@ else fi [ -f "$BRIEF" ] || { echo "error: no brief at $BRIEF" >&2; exit 1; } -delivery_rigor_rank() { # -> 3 (most rigor) .. 1 (least); 0 = not a task mode +# Ranked by how much gating stands between the worker and landed work: the pipeline +# and an approval (no-mistakes), an approval on a reviewable PR (direct-PR), an +# approval on a ready branch (local-only), then an automated project gate the worker +# runs itself with no approval step at all (gate-merge). +delivery_rigor_rank() { # -> 4 (most rigor) .. 1 (least); 0 = not a task mode case "$1" in - no-mistakes) echo 3 ;; - direct-PR) echo 2 ;; - local-only) echo 1 ;; + no-mistakes) echo 4 ;; + direct-PR) echo 3 ;; + local-only) echo 2 ;; + gate-merge) echo 1 ;; *) echo 0 ;; esac } diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ee45cca864..755783b346 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -839,6 +839,9 @@ backlog_refresh_reminder() { *) if [ "$MODE" = local-only ]; then done_cmd="tasks-axi done $ID --note \"local main\"" + elif [ "$MODE" = gate-merge ]; then + # gate-merge lands through the project's own gate, so there is no PR to record. + done_cmd="tasks-axi done $ID --note \"landed via the project merge gate\"" else pr=$PR_URL if [ -n "$pr" ]; then diff --git a/docs/architecture.md b/docs/architecture.md index 60ee3a5020..a68d0fa432 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,7 +158,7 @@ The helper's header owns the exact signal detection, relocated-home limitation, ## Two task shapes -Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks leave standalone investigation reports at `data//report.md` and never push. +Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, `local-only`, or `gate-merge`); scout tasks leave standalone investigation reports at `data//report.md` and never push. The intake and authority contract in `AGENTS.md` owns when separate scout research is warranted. ## Dispatch profiles @@ -217,7 +217,7 @@ The `data/secondmates.md` line contract is owned by the [`secondmate-provisionin ## Delivery modes are explicit per task -`no-mistakes` tasks run the full validation pipeline, `direct-PR` tasks open PRs without that pipeline, and `local-only` tasks stay local until firstmate performs an approved fast-forward merge. +`no-mistakes` tasks run the full validation pipeline, `direct-PR` tasks open PRs without that pipeline, `local-only` tasks stay local until firstmate performs an approved fast-forward merge, and `gate-merge` tasks are landed by the worker itself through the project's own merge gate, whose command the brief carries per task. Each task's mode and `yolo` posture are firstmate's decision at intake and are passed explicitly to `bin/fm-brief.sh`, `bin/fm-spawn.sh`, and `bin/fm-promote.sh`, which refuse a ship task that does not carry them. A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `data/projects.md` records each project's standing posture and optional `+yolo` flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index a348e2d345..66d05fa49b 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -199,10 +199,15 @@ test_ship_modes_generate_clean_briefs() { home="$TMP_ROOT/ship-home" write_registry "$home" - for id_mode in "brief-nomistakes-a1:no-mistakes" "brief-directpr-a2:direct-PR" "brief-localonly-a3:local-only"; do + for id_mode in "brief-nomistakes-a1:no-mistakes" "brief-directpr-a2:direct-PR" "brief-localonly-a3:local-only" "brief-gatemerge-a4:gate-merge"; do id=${id_mode%%:*} mode=${id_mode##*:} - FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$mode" >/dev/null 2>&1; status=$? + if [ "$mode" = gate-merge ]; then + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$mode" \ + --gate ./scripts/merge-gate.sh >/dev/null 2>&1; status=$? + else + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$mode" >/dev/null 2>&1; status=$? + fi expect_code 0 "$status" "fm-brief.sh $id --mode $mode should exit 0" brief="$home/data/$id/brief.md" assert_present "$brief" "$id: brief was not scaffolded" @@ -214,7 +219,103 @@ test_ship_modes_generate_clean_briefs() { "$id: brief missing nonterminal working:/setup-complete gate protection" assert_no_grep "EOF" "$brief" "$id: brief leaked a heredoc EOF marker (unterminated heredoc)" done - pass "fm-brief.sh: no-mistakes/direct-PR/local-only briefs generate cleanly" + pass "fm-brief.sh: no-mistakes/direct-PR/local-only/gate-merge briefs generate cleanly" +} + +# A gate-merge worker lands its own work, so the rules and the definition of done +# must agree that it runs the gate. The historical hand-patched shape - a generated +# "never push to any remote" rule sitting above a definition of done whose final step +# is a push - is exactly what stalls or improvises a worker, so both halves are pinned. +test_gate_merge_brief_lands_through_the_gate_without_contradiction() { + local home id brief rules + home="$TMP_ROOT/gate-merge-home" + mkdir -p "$home/data" + id="brief-gate-merge-e1" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" gate-proj --mode gate-merge \ + --gate './scripts/merge-gate.sh' >/dev/null 2>&1 \ + || fail "gate-merge brief should scaffold when --gate is supplied" + brief="$home/data/$id/brief.md" + grep -qx "Delivery contract: mode=gate-merge" "$brief" \ + || fail "gate-merge brief did not record its machine-readable delivery contract line" + assert_grep 'Run the gate from THIS worktree, with your branch checked out: `./scripts/merge-gate.sh`' "$brief" \ + "gate-merge definition of done did not name the exact landing command" + assert_grep "Firstmate never merges it for you" "$brief" \ + "gate-merge brief did not say the worker lands its own work" + assert_grep "retry IN-TURN" "$brief" \ + "gate-merge brief did not require in-turn retry while another gate run is live" + assert_grep "A failing gate parks your branch" "$brief" \ + "gate-merge brief lost the parked-branch outcome" + + # The rules block must not contradict the gate. Both historical rule-1 shapes are + # refused by name, and rule 2 must carve out the gate it tells the worker to run. + rules=$(awk '/^# Rules$/{flag=1;next} /^# /{flag=0} flag' "$brief") + assert_not_contains "$rules" "Never push to any remote and never open a PR." \ + "gate-merge rules forbade the push its own definition of done performs" + assert_not_contains "$rules" "firstmate handles the merge" \ + "gate-merge rules handed the merge back to firstmate" + assert_contains "$rules" "The merge gate named in Definition of done is the only thing that lands your work" \ + "gate-merge rule 1 did not point at the gate as the single landing path" + assert_contains "$rules" "Running the merge gate below is the one authorized exception" \ + "gate-merge rule 2 did not except the gate from strict worktree confinement" + pass "fm-brief.sh: a gate-merge brief lands through the gate and its rules agree with it" +} + +# The scaffold is project-agnostic and never reads data/projects.md, so the landing +# command must be supplied rather than guessed, and a gate handed to a path that +# lands through firstmate or a PR must be refused instead of silently discarded. +test_gate_flag_is_required_by_and_exclusive_to_gate_merge() { + local home out status label args expect n=0 + home="$TMP_ROOT/gate-flag-home" + mkdir -p "$home/data" + while IFS='|' read -r label args expect; do + [ -n "$label" ] || continue + n=$((n + 1)) + # shellcheck disable=SC2086 # args is an intentional word-split arg list + out=$(FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "brief-gate-flag-$n" some-proj $args 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "$label: expected a non-zero exit" + assert_contains "$out" "$expect" "$label: refusal did not explain the contract" + assert_absent "$home/data/brief-gate-flag-$n/brief.md" "$label: refused scaffold still wrote a brief" + done <<'ROWS' +gate-merge without a gate|--mode gate-merge|requires --gate +gate-merge with an empty gate|--mode gate-merge --gate=|requires --gate +gate on a no-mistakes brief|--mode no-mistakes --gate ./scripts/merge-gate.sh|--gate applies only to --mode gate-merge +gate on a local-only brief|--mode local-only --gate ./scripts/merge-gate.sh|--gate applies only to --mode gate-merge +gate on a scout brief|--scout --gate ./scripts/merge-gate.sh|--gate applies only to --mode gate-merge +empty --gate value|--mode gate-merge --gate|requires a value +ROWS + pass "fm-brief.sh: --gate is required by gate-merge and refused everywhere else" +} + +# Worktree isolation covers a working tree and nothing else, so every crewmate brief +# carries the shared-machine rule: a broad pattern kill reaches sibling crews' agent +# sessions, and the stash stack lives in the shared .git. Both are live incidents, and +# both apply to every project and to scouts as much as to ship tasks. +test_shared_machine_rule_is_in_every_crewmate_brief() { + local home id brief kind + home="$TMP_ROOT/shared-machine-home" + mkdir -p "$home/data" + for kind in no-mistakes direct-PR local-only gate-merge scout; do + id="brief-shared-machine-$kind" + case "$kind" in + scout) FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --scout >/dev/null 2>&1 ;; + gate-merge) FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode gate-merge \ + --gate ./scripts/merge-gate.sh >/dev/null 2>&1 ;; + *) FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$kind" >/dev/null 2>&1 ;; + esac + brief="$home/data/$id/brief.md" + assert_present "$brief" "$kind: brief was not scaffolded" + assert_grep "SHARED MACHINE" "$brief" "$kind: brief lost the shared-machine rule" + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + assert_grep 'Never `pkill`/`killall` by pattern' "$brief" \ + "$kind: brief did not forbid a broad pattern kill" + assert_grep "Kill only exact pids you started yourself" "$brief" \ + "$kind: brief did not give the safe alternative to a pattern kill" + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + assert_grep 'Never bare `git stash`' "$brief" \ + "$kind: brief did not forbid the shared stash stack" + done + pass "fm-brief.sh: every crewmate brief carries the shared-machine kill and stash rule" } # A ship task's delivery mode is firstmate's per-task decision, so a missing or @@ -238,7 +339,7 @@ test_ship_mode_is_required_and_closed_set() { done <<'ROWS' missing --mode||ship briefs require --mode empty --mode value|--mode|requires a value -unknown mode value|--mode nope|must be one of no-mistakes, direct-PR, local-only +unknown mode value|--mode nope|must be one of no-mistakes, direct-PR, local-only, gate-merge conditional policy is not a task mode|--mode no-mistakes-prod-only|classify this task's surface ROWS pass "fm-brief.sh: ship --mode is required and closed-set validated" @@ -715,6 +816,9 @@ test_no_heredoc_in_command_substitution test_help_includes_entire_header test_ship_modes_generate_clean_briefs test_ship_mode_is_required_and_closed_set +test_gate_merge_brief_lands_through_the_gate_without_contradiction +test_gate_flag_is_required_by_and_exclusive_to_gate_merge +test_shared_machine_rule_is_in_every_crewmate_brief test_ship_mode_is_explicit_not_registry test_delivery_flags_are_refused_where_they_do_not_apply test_faster_paths_use_configured_authority_without_stacked_review diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 34df6fe748..229f7bdcad 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -81,7 +81,7 @@ EOF missing both flags||ship spawns require --mode missing --yolo|--mode no-mistakes|ship spawns require --yolo missing --mode|--yolo off|ship spawns require --mode -unknown mode|--mode nope --yolo off|must be one of no-mistakes, direct-PR, local-only +unknown mode|--mode nope --yolo off|must be one of no-mistakes, direct-PR, local-only, gate-merge unknown yolo|--mode no-mistakes --yolo maybe|--yolo must be on or off conditional policy as a task mode|--mode no-mistakes-prod-only --yolo off|classify this task's surface ROWS @@ -179,6 +179,9 @@ no-mistakes project shipped no-mistakes|- proj [no-mistakes] - fixture (added 20 local-only project shipped no-mistakes|- proj [local-only] - fixture (added 2026-01-01)|no-mistakes|quiet|local-only conditional policy shipped direct-PR|- proj [no-mistakes-prod-only] - fixture (added 2026-01-01)|direct-PR|quiet|no-mistakes-prod-only unregistered project resolves to the no-mistakes standing default|- other [no-mistakes] - fixture (added 2026-01-01)|direct-PR|notice|no-mistakes +no-mistakes project shipped gate-merge|- proj [no-mistakes] - fixture (added 2026-01-01)|gate-merge|notice|no-mistakes +gate-merge project shipped gate-merge|- proj [gate-merge] - fixture (added 2026-01-01)|gate-merge|quiet|gate-merge +gate-merge project shipped no-mistakes|- proj [gate-merge] - fixture (added 2026-01-01)|no-mistakes|quiet|gate-merge ROWS pass "fm-spawn: a rigor downgrade against the registered posture is announced, never blocked" } @@ -235,6 +238,16 @@ test_promote_requires_and_records_the_delivery_contract() { assert_grep 'yolo=on' "$meta" "promotion did not record the decided approval posture" assert_contains "$out" "ship instructions for mode=direct-PR" "promotion hint did not carry the decided mode" [ "$(grep -c '^mode=' "$meta")" = 1 ] || fail "promotion left more than one mode= line in the task record" + + # A scout on a gate-merge project promotes to the same landing path its project + # already uses, so the mode must be accepted and recorded like any other. + meta="$home/state/promote-d2.meta" + printf 'window=fm-promote-d2\nkind=scout\nworktree=/tmp/wt\n' > "$meta" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" promote-d2 --mode gate-merge --yolo off 2>&1) + status=$? + expect_code 0 "$status" "a gate-merge promotion should succeed" + assert_grep 'mode=gate-merge' "$meta" "promotion did not record the gate-merge delivery mode" + assert_contains "$out" "ship instructions for mode=gate-merge" "promotion hint did not carry the gate-merge mode" pass "fm-promote: promotion requires the delivery contract and records it exactly once" } @@ -250,6 +263,7 @@ test_project_mode_maps_the_conditional_policy() { - yoloproj [no-mistakes-prod-only +yolo] - fixture (added 2026-01-01) - flatproj [direct-PR] - fixture (added 2026-01-01) - typoproj [no-mistakez] - fixture (added 2026-01-01) +- gateproj [gate-merge] - fixture (added 2026-01-01) EOF out=$(FM_HOME="$home" "$PROJECT_MODE" prodproj 2>/dev/null) [ "$out" = "no-mistakes off" ] || fail "conditional policy did not map to its most rigorous leg (got '$out')" @@ -265,6 +279,11 @@ EOF out=$(FM_HOME="$home" "$PROJECT_MODE" --raw flatproj 2>/dev/null) [ "$out" = "direct-PR off" ] || fail "--raw altered a flat registered mode (got '$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" gateproj 2>/dev/null) + [ "$out" = "gate-merge off" ] || fail "a registered gate-merge posture did not survive the parser (got '$out')" + err=$(FM_HOME="$home" "$PROJECT_MODE" gateproj 2>&1 >/dev/null) + [ -z "$err" ] || fail "a registered gate-merge posture warned as unknown: $err" + out=$(FM_HOME="$home" "$PROJECT_MODE" typoproj 2>/dev/null) [ "$out" = "no-mistakes off" ] || fail "a typo'd mode no longer falls back to the most rigorous default" err=$(FM_HOME="$home" "$PROJECT_MODE" typoproj 2>&1 >/dev/null) From df11cbad30eac652e45fde3c4c4d4a8b5cb60b7a Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 11:10:49 -0700 Subject: [PATCH 2/9] test(brief): mark the literal backticks in the gate-command assertion --- tests/fm-brief.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 66d05fa49b..fb3add5049 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -237,6 +237,7 @@ test_gate_merge_brief_lands_through_the_gate_without_contradiction() { brief="$home/data/$id/brief.md" grep -qx "Delivery contract: mode=gate-merge" "$brief" \ || fail "gate-merge brief did not record its machine-readable delivery contract line" + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text assert_grep 'Run the gate from THIS worktree, with your branch checked out: `./scripts/merge-gate.sh`' "$brief" \ "gate-merge definition of done did not name the exact landing command" assert_grep "Firstmate never merges it for you" "$brief" \ From ab91bb15178ff28698b25f91ccc34e0686e7651f Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 14:38:37 -0700 Subject: [PATCH 3/9] no-mistakes(review): make brief rule 5 mode-shaped and list gate-merge in README --- README.md | 2 +- bin/fm-brief.sh | 15 +++++++++++--- tests/fm-brief.test.sh | 46 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 52715da3f3..673aa10b3a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. -- **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. +- **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, `local-only`, or `gate-merge`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, either locally or as a whole home on an SSH-reachable host, with guarded updates and recovery that never turns an unavailable remote route into a local replacement. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional Relay** - opt in with one local `.env` pairing token so firstmate can answer your public mentions on X and Discord alike, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-Relay behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 08a7f44056..6da4ad5287 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -396,11 +396,15 @@ fi # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own # explicit --mode before launching. -# Rules 1 and 2 must never contradict the definition of done: a worker told both to +# Rules 1, 2 and 5 must never contradict the definition of done: a worker told both to # land its own work and never to push would stall or improvise. Rule 2 is worktree # isolation, and gate-merge is the one mode with an authorized way to act outside the -# worktree - the gate itself, never the worker by hand. +# worktree - the gate itself, never the worker by hand. Rule 5 is the stop-on-repeat +# guard, and gate-merge's definition of done deliberately retries a queued gate in +# turn, so that documented wait must not read as the same obstacle hit twice. RULE2='2. Stay inside this worktree; modify nothing outside it.' +# shellcheck disable=SC2016 # literal backticks belong to the generated brief text +RULE5='5. If you hit the same obstacle twice, append `blocked: {why}` and stop; firstmate will help.' case "$MODE" in direct-PR) SETUP2="" @@ -418,6 +422,11 @@ EOF SETUP2="" RULE1="1. The merge gate named in Definition of done is the only thing that lands your work: never merge by hand, never push \`main\` or your branch yourself, and never open a PR." RULE2='2. Stay inside this worktree; modify nothing outside it yourself. Running the merge gate below is the one authorized exception, and the gate handles everything outside this worktree on its own.' + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + RULE5='5. If you hit the same obstacle twice, append `blocked: {why}` and stop; firstmate will help. + The one exception is the gate queue: while another gate run holds the lock, a repeated refusal is the + documented wait under Definition of done, not the same obstacle twice, so keep retrying in-turn for that + whole window and append `blocked: {the gate refusal}` only once the window is exhausted.' IFS= read -r -d '' DOD </dev/null 2>&1 ;; + *) FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$kind" >/dev/null 2>&1 ;; + esac + brief="$home/data/$id/brief.md" + assert_present "$brief" "$kind: brief was not scaffolded" + rule5=$(awk '/^5\. /{flag=1} /^6\. /{flag=0} flag' "$brief") + [ "$rule5" = "$baseline" ] \ + || fail "$kind: rule 5 must stay the unchanged stop-on-repeat rule, got:"$'\n'"$rule5" + done + + id="brief-rule5-gate-merge" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" gate-proj --mode gate-merge \ + --gate ./scripts/merge-gate.sh >/dev/null 2>&1 \ + || fail "gate-merge brief should scaffold when --gate is supplied" + brief="$home/data/$id/brief.md" + rule5=$(awk '/^5\. /{flag=1} /^6\. /{flag=0} flag' "$brief") + assert_contains "$rule5" "$baseline" \ + "gate-merge rule 5 dropped the baseline stop-on-repeat rule" + assert_contains "$rule5" "The one exception is the gate queue" \ + "gate-merge rule 5 did not exempt the documented gate-queue retry window" + assert_contains "$rule5" "not the same obstacle twice" \ + "gate-merge rule 5 did not say a repeated gate refusal is the documented wait, not a repeat obstacle" + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + assert_contains "$rule5" 'append `blocked: {the gate refusal}` only once the window is exhausted' \ + "gate-merge rule 5 must still end in blocked: once the retry window is exhausted" + pass "fm-brief.sh: rule 5 exempts only the gate-merge retry window and is unchanged elsewhere" +} + # The scaffold is project-agnostic and never reads data/projects.md, so the landing # command must be supplied rather than guessed, and a gate handed to a path that # lands through firstmate or a PR must be refused instead of silently discarded. @@ -818,6 +863,7 @@ test_help_includes_entire_header test_ship_modes_generate_clean_briefs test_ship_mode_is_required_and_closed_set test_gate_merge_brief_lands_through_the_gate_without_contradiction +test_stop_on_repeat_rule_is_mode_shaped test_gate_flag_is_required_by_and_exclusive_to_gate_merge test_shared_machine_rule_is_in_every_crewmate_brief test_ship_mode_is_explicit_not_registry From 6a868afce58f2bddacbfb6738169cd440b35930b Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 15:39:10 -0700 Subject: [PATCH 4/9] no-mistakes(document): cover gate-merge in README's project-boundary claim --- README.md | 2 +- tests/fm-teardown.test.sh | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 673aa10b3a..9435c806a1 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, either locally or as a whole home on an SSH-reachable host, with guarded updates and recovery that never turns an unavailable remote route into a local replacement. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional Relay** - opt in with one local `.env` pairing token so firstmate can answer your public mentions on X and Discord alike, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-Relay behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. -- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. +- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority, or behind the project's own merge gate on a `gate-merge` project. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 5530e83dd0..ef59630be3 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -598,6 +598,23 @@ test_teardown_prompts_tasks_axi_done_when_compatible() { pass "teardown prompts tasks-axi backlog refresh when compatible" } +# A gate-merge task lands through the project's own merge gate, so it never has a +# PR to record. Prompting for one would leave firstmate holding a `--pr PR_URL` +# placeholder that can never be filled, so the reminder records the landing instead. +test_teardown_records_a_landed_note_for_gate_merge() { + local case_dir out + case_dir=$(make_case tasks-axi-gate-merge) + write_meta "$case_dir" gate-merge ship + add_compatible_tasks_axi "$case_dir" + + out=$(run_teardown "$case_dir") || fail "teardown failed for a gate-merge task" + printf '%s\n' "$out" | grep -F 'tasks-axi done task-x1 --note "landed via the project merge gate"' >/dev/null \ + || fail "teardown did not record the gate-merge landing note: $out" + printf '%s\n' "$out" | grep -F -- '--pr' >/dev/null \ + && fail "teardown prompted for a PR a gate-merge task will never have: $out" + pass "teardown records a landed note for gate-merge instead of prompting for a PR" +} + test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present() { local case_dir out case_dir=$(make_case tasks-axi-manual-optout) @@ -2500,6 +2517,7 @@ EOF test_local_only_fork_remote_allows test_teardown_prompts_tasks_axi_done_when_compatible +test_teardown_records_a_landed_note_for_gate_merge test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows From c604da004b82e3e22d4ce6c1539c1f75efd57c5c Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 16:35:12 -0700 Subject: [PATCH 5/9] no-mistakes(review): enforce the registered gate-merge posture and gate at spawn --- .agents/skills/project-management/SKILL.md | 3 +- bin/fm-brief.sh | 23 +++-- bin/fm-project-mode.sh | 71 ++++++++++---- bin/fm-spawn.sh | 34 ++++++- tests/fm-brief.test.sh | 11 +++ tests/fm-task-delivery.test.sh | 106 +++++++++++++++++++-- 6 files changed, 216 insertions(+), 32 deletions(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index fe6529da15..d199d621e5 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -41,7 +41,8 @@ Choose that posture when adding or creating the project: - `no-mistakes` runs the full validation pipeline before a PR. - `direct-PR` pushes and opens a PR without the no-mistakes pipeline. - `local-only` has no required remote or PR and lands only through the approved local fast-forward path. -- `gate-merge` has the crewmate land its own work by running the project's own merge gate, with no PR and no firstmate merge; register it only when the captain has made that gate the project's standing way to land work, and record the exact gate command in the entry's note so every brief passes the same `bin/fm-brief.sh --gate `. +- `gate-merge` has the crewmate land its own work by running the project's own merge gate, with no PR and no firstmate merge; register it only when the captain has made that gate the project's standing way to land work, and record the exact gate command in the entry's note as ``gate=`` `` so every brief passes the same `bin/fm-brief.sh --gate `. +That recorded command is the authorization rather than a reminder: `bin/fm-spawn.sh` refuses a `gate-merge` spawn whose project is registered under a conflicting posture, and refuses one whose brief lands with any command other than the registered one, so a gate changes by editing the entry and never by hand-patching a brief. - `no-mistakes-prod-only` is a conditional policy rather than one flat mode: genuinely internal-only tooling, automation, contributor or operator process, and release or submission work ships `direct-PR`, while product-facing, mixed, and uncertain work ships `no-mistakes`. `no-mistakes-prod-only` is the default for a newly added or created remote-backed project when the captain specifies nothing, and a project with no remote defaults to `local-only`. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 6da4ad5287..a968c9d1ec 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -43,9 +43,12 @@ # exclusive to gate-merge: the scaffold is project-agnostic and never guesses a project's # gate, and a gate passed to any other mode is refused rather than silently dropped. # The generated ship brief records the chosen mode as a fixed machine-readable -# "Delivery contract: mode=" line. bin/fm-spawn.sh reads that line and refuses -# to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the -# recorded task metadata cannot drift apart. +# "Delivery contract: mode=" line, and a gate-merge brief records its landing +# command as a second fixed "Delivery contract: gate=" line. bin/fm-spawn.sh +# reads both: it refuses to launch a ship task whose explicit --mode disagrees, and it +# refuses a gate-merge task whose recorded gate is not the one the project's registry +# entry authorizes. An adjusted brief and the recorded task delivery cannot drift apart, +# and the gate stays the captain's registered command rather than a per-brief string. # Ship briefs begin with a worktree-isolation assertion before the branch step. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. @@ -168,10 +171,12 @@ fi # The landing command is the whole substance of a gate-merge definition of done, and # this scaffold never reads data/projects.md, so it must be given rather than guessed. +# It is not a free-form string: bin/fm-spawn.sh refuses to launch the task unless the +# recorded gate is the one the project's registry entry authorizes. # Every other mode lands through a firstmate-owned path and would silently discard it. if [ "$KIND" = ship ] && [ "$MODE" = gate-merge ]; then { [ "$GATE_SET" -eq 1 ] && [ -n "$GATE" ]; } || { - echo "error: --mode gate-merge requires --gate ; pass the project's own landing command (for example --gate ./scripts/merge-gate.sh) so the definition of done names the exact command that lands the work" >&2 + echo "error: --mode gate-merge requires --gate ; pass the landing command recorded in the project's data/projects.md entry as gate=\`\` (for example --gate ./scripts/merge-gate.sh) so the definition of done names the exact command that lands the work" >&2 exit 1 } elif [ "$GATE_SET" -eq 1 ]; then @@ -308,7 +313,8 @@ SHARED_MACHINE_RULE=$(printf '%s\n' \ ' (capture `$!` when you background a dev server, then `kill "$pid"`).' \ ' Never bare `git stash`/`git stash pop`/`git stash drop` - the stash stack lives in the shared' \ ' `.git` and a popped stash leaves no reflog entry, so you can destroy a sibling'"'"'s uncommitted' \ -' work unrecoverably. Commit work in progress to your own branch instead.') +' work unrecoverably. Commit work in progress in this worktree instead - a commit here is yours alone,' \ +' whether you are on your own branch or at the detached HEAD a scout worktree runs at.') if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") @@ -395,7 +401,9 @@ fi # Ship task: shape Setup / rules 1-2 / Definition of done by this task's explicit # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own -# explicit --mode before launching. +# explicit --mode before launching, and a gate-merge DOD adds the fixed +# "Delivery contract: gate=" line that the same spawn checks against the +# gate command the project's registry entry authorizes. # Rules 1, 2 and 5 must never contradict the definition of done: a worker told both to # land its own work and never to push would stall or improvise. Rule 2 is worktree # isolation, and gate-merge is the one mode with an authorized way to act outside the @@ -423,13 +431,14 @@ EOF RULE1="1. The merge gate named in Definition of done is the only thing that lands your work: never merge by hand, never push \`main\` or your branch yourself, and never open a PR." RULE2='2. Stay inside this worktree; modify nothing outside it yourself. Running the merge gate below is the one authorized exception, and the gate handles everything outside this worktree on its own.' # shellcheck disable=SC2016 # literal backticks belong to the generated brief text - RULE5='5. If you hit the same obstacle twice, append `blocked: {why}` and stop; firstmate will help. + RULE5="$RULE5"' The one exception is the gate queue: while another gate run holds the lock, a repeated refusal is the documented wait under Definition of done, not the same obstacle twice, so keep retrying in-turn for that whole window and append `blocked: {the gate refusal}` only once the window is exhausted.' IFS= read -r -d '' DOD < - (added ) -> no-mistakes off (legacy default) @@ -22,9 +23,9 @@ # local-only local branch, no remote/PR, guarded local merge # gate-merge the crewmate lands its own work by running the project's # own merge gate; no PR and no firstmate merge. Registering -# it is the captain's standing authorization for that gate. -# The gate command itself is not registry data: firstmate -# passes it per task as bin/fm-brief.sh's --gate. +# it is the captain's standing authorization for that gate, +# and the entry's note records the exact gate command as +# gate=`` so every task lands the same way. # no-mistakes-prod-only a conditional policy, not a task mode: firstmate # classifies each task's surface at intake (the # project-management skill owns that classification). @@ -38,9 +39,18 @@ # --raw prints the registered annotation unmapped, so a caller that must tell a # conditional policy apart from a flat mode sees "no-mistakes-prod-only" itself. # +# --gate is a separate accessor, not a widening of the two-word line above, so the +# mechanical consumers keep reading exactly two words. It prints the gate command +# recorded in the matched entry's note, taken from the first gate=`` on that +# line and empty when the entry records none, and it exits non-zero when the registered +# posture could not be read at all: no registry file, no entry for the project, or an +# entry whose mode annotation is not a recognized mode. bin/fm-spawn.sh uses that exit +# status to tell an unambiguous registry conflict, which refuses a gate-merge spawn, +# from a posture it simply could not verify, which only warns. +# # An unknown/missing project or unknown mode falls back to "no-mistakes off" and warns # to stderr, so a typo never silently drops the gate. -# Usage: fm-project-mode.sh [--raw] +# Usage: fm-project-mode.sh [--raw|--gate] set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -49,22 +59,31 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/projects.md" RAW=0 -if [ "${1:-}" = "--raw" ]; then - RAW=1 - shift -fi -NAME=${1:?usage: fm-project-mode.sh [--raw] } +GATE_ONLY=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --raw) RAW=1; shift ;; + --gate) GATE_ONLY=1; shift ;; + *) break ;; + esac +done +NAME=${1:?usage: fm-project-mode.sh [--raw|--gate] } if [ ! -f "$REG" ]; then + if [ "$GATE_ONLY" -eq 1 ]; then + echo "warn: no registry at $REG; no registered posture to read for $NAME" >&2 + exit 3 + fi echo "warn: no registry at $REG; defaulting $NAME to no-mistakes off" >&2 echo "no-mistakes off" exit 0 fi -# awk emits " " (one line) or nothing if the project is absent. +# awk emits "\t\t" (one line) or nothing if the project is absent. +# The tab separator keeps a gate command's own spaces intact. parsed=$(awk -v n="$NAME" ' $1=="-" && $2==n { - mode="no-mistakes"; yolo="off"; + mode="no-mistakes"; yolo="off"; gate=""; if ($3 ~ /^\[/) { s=""; for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break } @@ -73,23 +92,43 @@ parsed=$(awk -v n="$NAME" ' if (a[1] != "" && a[1] != "+yolo") mode = a[1]; for (j=1; j<=k; j++) if (a[j]=="+yolo") yolo="on"; } - print mode, yolo; exit + p = index($0, "gate=`"); # the note records the landing command backticked + if (p > 0) { + rest = substr($0, p + 6); + q = index(rest, "`"); + if (q > 1) gate = substr(rest, 1, q - 1); + } + printf "%s\t%s\t%s\n", mode, yolo, gate; exit } ' "$REG") if [ -z "$parsed" ]; then + if [ "$GATE_ONLY" -eq 1 ]; then + echo "warn: project \"$NAME\" not in registry; no registered posture to read" >&2 + exit 3 + fi echo "warn: project \"$NAME\" not in registry; defaulting to no-mistakes off" >&2 echo "no-mistakes off" exit 0 fi -mode=${parsed%% *} -yolo=${parsed##* } +IFS=$'\t' read -r mode yolo gate <&2; mode=no-mistakes; yolo=off ;; + *) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off; mode_known=0 ;; esac case "$yolo" in on|off) ;; *) yolo=off ;; esac + +# An entry whose mode annotation is unreadable is not a posture anyone can act on, so +# --gate reports it as unverified rather than handing back a gate under a guessed mode. +if [ "$GATE_ONLY" -eq 1 ]; then + [ "$mode_known" -eq 1 ] || exit 3 + printf '%s\n' "$gate" + exit 0 +fi # A conditional policy is not a task mode. Mechanical callers get its most # rigorous leg; --raw callers get the annotation itself (see the header). if [ "$RAW" -eq 0 ] && [ "$mode" = no-mistakes-prod-only ]; then diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 7ea75cf459..064eba3f2b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -13,7 +13,11 @@ # instructions and the recorded task delivery cannot drift apart; a brief # scaffolded before that line existed warns once and launches on the flag. When # the explicit mode carries less rigor than the project's standing posture, a -# loud one-line deviation notice is printed and the spawn continues. +# loud one-line deviation notice is printed and the spawn continues. gate-merge +# is the one exception: it lands on the default branch with no approval step, so +# a spawn REFUSES it when the project is registered under a conflicting posture, +# or when the brief's recorded gate is not the command that project's registry +# entry authorizes. A project with no readable entry still only gets the notice. # no-mistakes-prod-only is a registry policy rather than a task mode and is # refused as a flag value. # --harness is the explicit per-spawn harness/profile adapter. The old @@ -1362,7 +1366,33 @@ if [ "$KIND" = ship ]; then # is why the notice names the standing posture rather than the registry line. A # conditional policy is excluded: both of its legs are legitimate classifications. STANDING_MODE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1) || STANDING_MODE= - if [ -n "$STANDING_MODE" ] && [ "$STANDING_MODE" != no-mistakes-prod-only ] \ + if [ "$MODE" = gate-merge ]; then + # gate-merge is the one mode whose worker lands on the default branch with no + # approval step in front of it, so its authorization is checked rather than + # announced: the registry entry IS that authorization (AGENTS.md section 7), and + # the entry's recorded gate is the only command allowed to do the landing. A + # posture that could not be read at all still proceeds on a notice, because + # refusing every unregistered project would block legitimate first-time work. + if REGISTERED_GATE=$("$FM_ROOT/bin/fm-project-mode.sh" --gate "$PROJ_NAME" 2>/dev/null); then + if [ "$STANDING_MODE" != gate-merge ]; then + echo "error: $ID passed --mode gate-merge but $PROJ_NAME is registered $STANDING_MODE; gate-merge lands work on the default branch with no approval step in front of it, so it ships only for a project the captain registered gate-merge - correct the flag, or have the captain re-register $PROJ_NAME first" >&2 + exit 1 + fi + BRIEF_GATE=$(sed -n 's/^Delivery contract: gate=//p' "$BRIEF" | head -n 1) + if [ -z "$REGISTERED_GATE" ]; then + echo "error: $PROJ_NAME is registered gate-merge but its registry entry records no gate command; record the exact command in the entry's note as gate=\`\` so every task lands the same way, then re-scaffold $ID's brief with that --gate" >&2 + exit 1 + elif [ -z "$BRIEF_GATE" ]; then + echo "error: $BRIEF records no gate command line; re-scaffold it with bin/fm-brief.sh --mode gate-merge --gate '$REGISTERED_GATE' so the worker lands with the gate $PROJ_NAME's registry entry authorizes" >&2 + exit 1 + elif [ "$BRIEF_GATE" != "$REGISTERED_GATE" ]; then + echo "error: gate mismatch for $ID: the brief lands with '$BRIEF_GATE' but $PROJ_NAME's registry entry authorizes '$REGISTERED_GATE'; re-scaffold the brief with the registered gate, or have the captain change the registry entry - never hand-patch one brief" >&2 + exit 1 + fi + else + echo "notice: $ID ships mode=gate-merge but $PROJ_NAME has no readable registry entry, so neither the captain's standing authorization for a gate landing nor this brief's gate command could be verified against the registry; proceed only on a current explicit captain instruction, and register $PROJ_NAME so the next task is checked" >&2 + fi + elif [ -n "$STANDING_MODE" ] && [ "$STANDING_MODE" != no-mistakes-prod-only ] \ && [ "$(delivery_rigor_rank "$MODE")" -lt "$(delivery_rigor_rank "$STANDING_MODE")" ]; then echo "notice: $ID ships mode=$MODE while the standing posture for $PROJ_NAME is $STANDING_MODE - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state" >&2 fi diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 23fc940540..8464f7c850 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -237,6 +237,10 @@ test_gate_merge_brief_lands_through_the_gate_without_contradiction() { brief="$home/data/$id/brief.md" grep -qx "Delivery contract: mode=gate-merge" "$brief" \ || fail "gate-merge brief did not record its machine-readable delivery contract line" + # The gate is recorded machine-readably too: bin/fm-spawn.sh is where data/projects.md + # is readable, and it refuses to launch a brief that lands with an unauthorized gate. + grep -qx "Delivery contract: gate=./scripts/merge-gate.sh" "$brief" \ + || fail "gate-merge brief did not record its machine-readable gate command line" # shellcheck disable=SC2016 # literal backticks belong to the generated brief text assert_grep 'Run the gate from THIS worktree, with your branch checked out: `./scripts/merge-gate.sh`' "$brief" \ "gate-merge definition of done did not name the exact landing command" @@ -360,6 +364,13 @@ test_shared_machine_rule_is_in_every_crewmate_brief() { # shellcheck disable=SC2016 # literal backticks belong to the generated brief text assert_grep 'Never bare `git stash`' "$brief" \ "$kind: brief did not forbid the shared stash stack" + # The safe alternative must name something the reader actually has. A scout + # worktree runs at a detached HEAD and never creates a branch, so advice that + # names only a branch invites it to improvise back toward `git stash`. + assert_grep "Commit work in progress in this worktree instead" "$brief" \ + "$kind: brief did not give the safe alternative to the shared stash stack" + assert_grep "whether you are on your own branch or at the detached HEAD" "$brief" \ + "$kind: the stash alternative named a branch a scout worktree does not have" done pass "fm-brief.sh: every crewmate brief carries the shared-machine kill and stash rule" } diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 229f7bdcad..83d0938b64 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -7,7 +7,9 @@ # validate them against a closed set, and the spawn additionally refuses to launch # when the brief it is about to hand the worker records a different mode. Scout # spawns carry no delivery posture at all. The registry keeps only the captain's -# standing posture, for the mechanical consumers and for one advisory notice. +# standing posture, for the mechanical consumers and for one advisory notice - except +# for gate-merge, whose worker lands on the default branch with no approval step, so +# there the registered posture and the registered gate command are enforced. # # Every spawn case here stops before any endpoint exists: the delivery checks run # ahead of backend creation, and a fake `tmux` that exits non-zero backstops the @@ -40,12 +42,13 @@ make_home() { # [...] printf '%s\n' "$home|$projects/proj|$fakebin" } -write_brief() { # [] - local home=$1 id=$2 mode=${3:-} +write_brief() { # [] [] + local home=$1 id=$2 mode=${3:-} gate=${4:-} mkdir -p "$home/data/$id" { printf 'You are a crewmate.\n\n# Definition of done\n' [ -z "$mode" ] || printf 'Delivery contract: mode=%s\n' "$mode" + [ -z "$gate" ] || printf 'Delivery contract: gate=%s\n' "$gate" } > "$home/data/$id/brief.md" } @@ -179,13 +182,84 @@ no-mistakes project shipped no-mistakes|- proj [no-mistakes] - fixture (added 20 local-only project shipped no-mistakes|- proj [local-only] - fixture (added 2026-01-01)|no-mistakes|quiet|local-only conditional policy shipped direct-PR|- proj [no-mistakes-prod-only] - fixture (added 2026-01-01)|direct-PR|quiet|no-mistakes-prod-only unregistered project resolves to the no-mistakes standing default|- other [no-mistakes] - fixture (added 2026-01-01)|direct-PR|notice|no-mistakes -no-mistakes project shipped gate-merge|- proj [no-mistakes] - fixture (added 2026-01-01)|gate-merge|notice|no-mistakes -gate-merge project shipped gate-merge|- proj [gate-merge] - fixture (added 2026-01-01)|gate-merge|quiet|gate-merge gate-merge project shipped no-mistakes|- proj [gate-merge] - fixture (added 2026-01-01)|no-mistakes|quiet|gate-merge ROWS pass "fm-spawn: a rigor downgrade against the registered posture is announced, never blocked" } +# gate-merge is the one mode whose worker lands on the default branch with no approval +# step in front of it, so it is the one downgrade that is checked instead of announced: +# a conflicting registered posture refuses, and the brief may land only with the gate +# command that project's registry entry authorizes. A posture that cannot be read at +# all still only warns, because refusing every unregistered project would block +# legitimate first-time work. +test_gate_merge_spawn_is_checked_against_the_registry() { + local rec home proj fakebin out status + local gate='./scripts/merge-gate.sh --push' + + rec=$(make_home gate-conflict "- proj [no-mistakes] - fixture (added 2026-01-01)") + IFS='|' read -r home proj fakebin </dev/null) [ "$out" = "no-mistakes off" ] || fail "conditional policy did not map to its most rigorous leg (got '$out')" @@ -284,6 +359,24 @@ EOF err=$(FM_HOME="$home" "$PROJECT_MODE" gateproj 2>&1 >/dev/null) [ -z "$err" ] || fail "a registered gate-merge posture warned as unknown: $err" + # --gate is a separate accessor, so the two-word line every mechanical consumer + # reads stays exactly two words even for an entry that records a gate command. + out=$(FM_HOME="$home" "$PROJECT_MODE" --gate gateproj 2>/dev/null) + [ "$out" = "./scripts/merge-gate.sh --push" ] || fail "--gate did not read the recorded landing command (got '$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" --gate gatelessproj 2>/dev/null) + [ -z "$out" ] || fail "--gate invented a gate for an entry that records none (got '$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" --gate flatproj 2>/dev/null) + [ -z "$out" ] || fail "--gate invented a gate for a non-gate-merge entry (got '$out')" + + # A posture that cannot be read at all is reported by exit status, so a caller can + # tell "the captain registered something conflicting" from "nothing to check against". + FM_HOME="$home" "$PROJECT_MODE" --gate missingproj >/dev/null 2>&1 \ + && fail "--gate reported success for a project with no registry entry" + FM_HOME="$home" "$PROJECT_MODE" --gate typoproj >/dev/null 2>&1 \ + && fail "--gate reported success for an entry whose mode annotation is unreadable" + FM_HOME="$TMP_ROOT/project-mode/absent" "$PROJECT_MODE" --gate gateproj >/dev/null 2>&1 \ + && fail "--gate reported success with no registry file at all" + out=$(FM_HOME="$home" "$PROJECT_MODE" typoproj 2>/dev/null) [ "$out" = "no-mistakes off" ] || fail "a typo'd mode no longer falls back to the most rigorous default" err=$(FM_HOME="$home" "$PROJECT_MODE" typoproj 2>&1 >/dev/null) @@ -295,6 +388,7 @@ test_ship_spawn_requires_a_valid_delivery_contract test_scout_and_secondmate_refuse_delivery_flags test_spawn_refuses_a_brief_mode_mismatch test_spawn_notices_a_rigor_downgrade_against_the_registry +test_gate_merge_spawn_is_checked_against_the_registry test_scout_records_no_delivery_posture test_promote_requires_and_records_the_delivery_contract test_project_mode_maps_the_conditional_policy From 7a48cd74bcf1adc4e3ba3549cce886dbae582f82 Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 16:45:17 -0700 Subject: [PATCH 6/9] no-mistakes(review): verify the registered gate in both brief occurrences --- bin/fm-brief.sh | 5 +++ bin/fm-project-mode.sh | 8 ++++- bin/fm-spawn.sh | 20 ++++++++---- docs/architecture.md | 5 +-- docs/scripts.md | 2 +- tests/fm-task-delivery.test.sh | 57 +++++++++++++++++++++++++++++----- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index a968c9d1ec..95218fb585 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -49,6 +49,11 @@ # refuses a gate-merge task whose recorded gate is not the one the project's registry # entry authorizes. An adjusted brief and the recorded task delivery cannot drift apart, # and the gate stays the captain's registered command rather than a per-brief string. +# A gate-merge definition of done also carries the command in the fixed "Run the gate +# from THIS worktree, with your branch checked out: ``" step, so the worker +# reads what to run without chasing metadata. That step is a fixed shape on purpose: +# bin/fm-spawn.sh checks it against the same registry entry, so neither occurrence can +# be hand-patched into an unauthorized landing command. # Ship briefs begin with a worktree-isolation assertion before the branch step. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index c1bc001608..b78eab4adb 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -118,7 +118,13 @@ EOF mode_known=1 case "$mode" in no-mistakes|direct-PR|local-only|gate-merge|no-mistakes-prod-only) ;; - *) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off; mode_known=0 ;; + *) + if [ "$GATE_ONLY" -eq 1 ]; then + echo "warn: unknown mode \"$mode\" for $NAME; its posture is unreadable, so no gate command is reported" >&2 + else + echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2 + fi + mode=no-mistakes; yolo=off; mode_known=0 ;; esac case "$yolo" in on|off) ;; *) yolo=off ;; esac diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 064eba3f2b..de2e5293d8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -16,8 +16,10 @@ # loud one-line deviation notice is printed and the spawn continues. gate-merge # is the one exception: it lands on the default branch with no approval step, so # a spawn REFUSES it when the project is registered under a conflicting posture, -# or when the brief's recorded gate is not the command that project's registry -# entry authorizes. A project with no readable entry still only gets the notice. +# or when either place the brief carries its gate command - the machine-readable +# contract line or the step the worker follows - is not the command that project's +# registry entry authorizes. A project with no readable entry still only gets the +# notice. # no-mistakes-prod-only is a registry policy rather than a task mode and is # refused as a flag value. # --harness is the explicit per-spawn harness/profile adapter. The old @@ -1378,15 +1380,21 @@ if [ "$KIND" = ship ]; then echo "error: $ID passed --mode gate-merge but $PROJ_NAME is registered $STANDING_MODE; gate-merge lands work on the default branch with no approval step in front of it, so it ships only for a project the captain registered gate-merge - correct the flag, or have the captain re-register $PROJ_NAME first" >&2 exit 1 fi + # The command appears twice in a gate-merge brief: the machine-readable contract + # line, and the step the worker actually follows. Verifying only one of them + # would leave the other hand-patchable into an unauthorized landing command, so + # both must be present and both must be the registered gate. BRIEF_GATE=$(sed -n 's/^Delivery contract: gate=//p' "$BRIEF" | head -n 1) + # shellcheck disable=SC2016 # single quotes are deliberate: the backticks are the brief's literal step shape, not a command substitution. + BRIEF_GATE_STEP=$(sed -n 's/^Run the gate from THIS worktree, with your branch checked out: `\(.*\)`$/\1/p' "$BRIEF" | head -n 1) if [ -z "$REGISTERED_GATE" ]; then echo "error: $PROJ_NAME is registered gate-merge but its registry entry records no gate command; record the exact command in the entry's note as gate=\`\` so every task lands the same way, then re-scaffold $ID's brief with that --gate" >&2 exit 1 - elif [ -z "$BRIEF_GATE" ]; then - echo "error: $BRIEF records no gate command line; re-scaffold it with bin/fm-brief.sh --mode gate-merge --gate '$REGISTERED_GATE' so the worker lands with the gate $PROJ_NAME's registry entry authorizes" >&2 + elif [ -z "$BRIEF_GATE" ] || [ -z "$BRIEF_GATE_STEP" ]; then + echo "error: $BRIEF does not record its gate command in both required places (the machine-readable \"Delivery contract: gate=\" line and the \"Run the gate from THIS worktree\" step the worker follows); re-scaffold it with bin/fm-brief.sh --mode gate-merge --gate '$REGISTERED_GATE' so the worker lands with the gate $PROJ_NAME's registry entry authorizes" >&2 exit 1 - elif [ "$BRIEF_GATE" != "$REGISTERED_GATE" ]; then - echo "error: gate mismatch for $ID: the brief lands with '$BRIEF_GATE' but $PROJ_NAME's registry entry authorizes '$REGISTERED_GATE'; re-scaffold the brief with the registered gate, or have the captain change the registry entry - never hand-patch one brief" >&2 + elif [ "$BRIEF_GATE" != "$REGISTERED_GATE" ] || [ "$BRIEF_GATE_STEP" != "$REGISTERED_GATE" ]; then + echo "error: gate mismatch for $ID: $PROJ_NAME's registry entry authorizes '$REGISTERED_GATE' but the brief records gate='$BRIEF_GATE' and tells the worker to run '$BRIEF_GATE_STEP'; re-scaffold the brief with the registered gate, or have the captain change the registry entry - never hand-patch one brief" >&2 exit 1 fi else diff --git a/docs/architecture.md b/docs/architecture.md index a68d0fa432..6094638585 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -217,11 +217,12 @@ The `data/secondmates.md` line contract is owned by the [`secondmate-provisionin ## Delivery modes are explicit per task -`no-mistakes` tasks run the full validation pipeline, `direct-PR` tasks open PRs without that pipeline, `local-only` tasks stay local until firstmate performs an approved fast-forward merge, and `gate-merge` tasks are landed by the worker itself through the project's own merge gate, whose command the brief carries per task. +`no-mistakes` tasks run the full validation pipeline, `direct-PR` tasks open PRs without that pipeline, `local-only` tasks stay local until firstmate performs an approved fast-forward merge, and `gate-merge` tasks are landed by the worker itself through the project's own merge gate, whose exact command the project's registry entry records and every brief carries unchanged. Each task's mode and `yolo` posture are firstmate's decision at intake and are passed explicitly to `bin/fm-brief.sh`, `bin/fm-spawn.sh`, and `bin/fm-promote.sh`, which refuse a ship task that does not carry them. A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `data/projects.md` records each project's standing posture and optional `+yolo` flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. -`bin/fm-project-mode.sh` remains 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. +`gate-merge` is the one exception, because it lands work on the default branch with no approval step in front of it: a spawn refuses it when the project is registered under a conflicting posture, and refuses it when either place the brief carries its gate command is not the command the project's registry entry authorizes, while a project with no readable entry still only gets the notice. +`bin/fm-project-mode.sh` remains the one registry parser, serving the mechanical consumers that have no task in hand - fleet sync's `local-only` skip and home seeding's refusal and no-mistakes initialization - plus the separate `--gate` accessor that gives the spawn the registered gate command for the task in hand. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. For target project repos shipped through their own no-mistakes pipeline, commits under `.no-mistakes/evidence/` are the pipeline's PR-viewable validation evidence and are expected to stay in the crew branch until the evidence-hosting design changes. The firstmate repo itself is the exception: its `.no-mistakes/` directory is local state, stays gitignored, and is rejected by CI if tracked. diff --git a/docs/scripts.md b/docs/scripts.md index 2d2d789461..2f97f78563 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -57,7 +57,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | -| `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding | +| `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding, and expose its recorded `gate-merge` command through the separate `--gate` accessor the spawn checks a task against | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 83d0938b64..e7dcbf5f90 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -42,13 +42,18 @@ make_home() { # [...] printf '%s\n' "$home|$projects/proj|$fakebin" } -write_brief() { # [] [] - local home=$1 id=$2 mode=${3:-} gate=${4:-} +# A gate-merge brief carries its gate command twice, so the fixture writes both and +# lets them differ: the step defaults to the contract line's command, and an explicit +# fifth argument stands in for a hand-patched or missing step. +write_brief() { # [] [] [] + local home=$1 id=$2 mode=${3:-} gate=${4:-} step=${5-${4-}} mkdir -p "$home/data/$id" { printf 'You are a crewmate.\n\n# Definition of done\n' [ -z "$mode" ] || printf 'Delivery contract: mode=%s\n' "$mode" [ -z "$gate" ] || printf 'Delivery contract: gate=%s\n' "$gate" + # shellcheck disable=SC2016 # the literal backticks are the brief's fixed step shape + [ -z "$step" ] || printf 'Run the gate from THIS worktree, with your branch checked out: `%s`\n' "$step" } > "$home/data/$id/brief.md" } @@ -190,9 +195,11 @@ ROWS # gate-merge is the one mode whose worker lands on the default branch with no approval # step in front of it, so it is the one downgrade that is checked instead of announced: # a conflicting registered posture refuses, and the brief may land only with the gate -# command that project's registry entry authorizes. A posture that cannot be read at -# all still only warns, because refusing every unregistered project would block -# legitimate first-time work. +# command that project's registry entry authorizes. The brief carries that command in +# two places - the machine-readable contract line and the step the worker follows - so +# both are checked; verifying one would leave the other hand-patchable into an +# unauthorized landing. A posture that cannot be read at all still only warns, because +# refusing every unregistered project would block legitimate first-time work. test_gate_merge_spawn_is_checked_against_the_registry() { local rec home proj fakebin out status local gate='./scripts/merge-gate.sh --push' @@ -238,15 +245,31 @@ EOF status=$? [ "$status" -ne 0 ] || fail "a gate that disagrees with the registry should exit non-zero" assert_contains "$out" "gate mismatch for delivery-gate-e4" "the gate refusal did not name the task" - assert_contains "$out" "the brief lands with './scripts/merge-gate.sh' but proj's registry entry authorizes '$gate'" \ + assert_contains "$out" "proj's registry entry authorizes '$gate' but the brief records gate='./scripts/merge-gate.sh'" \ "the gate refusal did not show both sides of the disagreement" assert_absent "$home/state/delivery-gate-e4.meta" "a refused gate-merge spawn wrote task metadata" - write_brief "$home" delivery-gate-e5 gate-merge + # The step under Definition of done is what the worker actually runs, so patching it + # alone - leaving the machine-readable contract line registry-clean - must refuse too. + write_brief "$home" delivery-gate-e5 gate-merge "$gate" './hand-patched.sh' out=$(run_spawn "$home" "$fakebin" delivery-gate-e5 "$proj" claude --mode gate-merge --yolo off) status=$? + [ "$status" -ne 0 ] || fail "a hand-patched gate step should exit non-zero even when the contract line is clean" + assert_contains "$out" "tells the worker to run './hand-patched.sh'" \ + "the refusal did not name the unauthorized command the worker would have run" + assert_absent "$home/state/delivery-gate-e5.meta" "a refused gate-merge spawn wrote task metadata" + + write_brief "$home" delivery-gate-e7 gate-merge "$gate" '' + out=$(run_spawn "$home" "$fakebin" delivery-gate-e7 "$proj" claude --mode gate-merge --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "a brief whose gate step was removed should exit non-zero" + assert_contains "$out" "both required places" "the refusal did not name the two places the gate must appear" + + write_brief "$home" delivery-gate-e8 gate-merge + out=$(run_spawn "$home" "$fakebin" delivery-gate-e8 "$proj" claude --mode gate-merge --yolo off) + status=$? [ "$status" -ne 0 ] || fail "a gate-merge brief recording no gate command should exit non-zero" - assert_contains "$out" "records no gate command line" "the refusal did not name the brief's missing gate line" + assert_contains "$out" "both required places" "the refusal did not name the brief's missing gate lines" rec=$(make_home gate-unrecorded "- proj [gate-merge] - fixture (added 2026-01-01)") IFS='|' read -r home proj fakebin </dev/null 2>&1 \ + || fail "the gate-merge scaffold should succeed with a registered gate" + out=$(run_spawn "$home" "$fakebin" delivery-gate-e9 "$proj" claude --mode gate-merge --yolo off) + assert_not_contains "$out" "gate mismatch" "a freshly scaffolded brief disagreed with the spawn's gate check" + assert_not_contains "$out" "both required places" \ + "the spawn could not find both gate occurrences in a freshly scaffolded brief" pass "fm-spawn: a gate-merge spawn ships only on the registered posture and the registered gate" } @@ -374,6 +410,11 @@ EOF && fail "--gate reported success for a project with no registry entry" FM_HOME="$home" "$PROJECT_MODE" --gate typoproj >/dev/null 2>&1 \ && fail "--gate reported success for an entry whose mode annotation is unreadable" + # The --gate path exits without defaulting, so its diagnostic must not claim one was + # applied: a human running the accessor directly is the only reader of this stderr. + err=$(FM_HOME="$home" "$PROJECT_MODE" --gate typoproj 2>&1 >/dev/null) + assert_contains "$err" "unknown mode" "--gate stopped naming the unreadable annotation" + assert_not_contains "$err" "defaulting" "--gate claimed a default it never applied" FM_HOME="$TMP_ROOT/project-mode/absent" "$PROJECT_MODE" --gate gateproj >/dev/null 2>&1 \ && fail "--gate reported success with no registry file at all" From 7fb3290e9b00cd5379a3c04ec1ac24798a0a47d2 Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 16:52:07 -0700 Subject: [PATCH 7/9] no-mistakes(review): require the registered gate exactly once per brief shape --- bin/fm-brief.sh | 5 +- bin/fm-spawn.sh | 31 +++++++----- tests/fm-task-delivery.test.sh | 91 +++++++++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 95218fb585..a23a8c4777 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -52,8 +52,9 @@ # A gate-merge definition of done also carries the command in the fixed "Run the gate # from THIS worktree, with your branch checked out: ``" step, so the worker # reads what to run without chasing metadata. That step is a fixed shape on purpose: -# bin/fm-spawn.sh checks it against the same registry entry, so neither occurrence can -# be hand-patched into an unauthorized landing command. +# bin/fm-spawn.sh requires each shape exactly once and checks it against the same +# registry entry, so no occurrence can be hand-patched, injected, or appended into an +# unauthorized landing command. # Ship briefs begin with a worktree-isolation assertion before the branch step. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index de2e5293d8..6396a9a2dd 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -17,9 +17,9 @@ # is the one exception: it lands on the default branch with no approval step, so # a spawn REFUSES it when the project is registered under a conflicting posture, # or when either place the brief carries its gate command - the machine-readable -# contract line or the step the worker follows - is not the command that project's -# registry entry authorizes. A project with no readable entry still only gets the -# notice. +# contract line or the step the worker follows - does not appear exactly once +# naming the command that project's registry entry authorizes. A project with no +# readable entry still only gets the notice. # no-mistakes-prod-only is a registry policy rather than a task mode and is # refused as a flag value. # --harness is the explicit per-spawn harness/profile adapter. The old @@ -1381,20 +1381,27 @@ if [ "$KIND" = ship ]; then exit 1 fi # The command appears twice in a gate-merge brief: the machine-readable contract - # line, and the step the worker actually follows. Verifying only one of them - # would leave the other hand-patchable into an unauthorized landing command, so - # both must be present and both must be the registered gate. - BRIEF_GATE=$(sed -n 's/^Delivery contract: gate=//p' "$BRIEF" | head -n 1) + # line, and the step the worker actually follows. Every occurrence a worker could + # act on must be the registered gate, so each shape must appear EXACTLY once and + # that one must match. Comparing only some occurrence would leave the others + # hand-patchable into an unauthorized landing command, whether by editing the + # generated line, injecting one above it, or appending another below it. # shellcheck disable=SC2016 # single quotes are deliberate: the backticks are the brief's literal step shape, not a command substitution. - BRIEF_GATE_STEP=$(sed -n 's/^Run the gate from THIS worktree, with your branch checked out: `\(.*\)`$/\1/p' "$BRIEF" | head -n 1) + BRIEF_STEP_MATCH='^Run the gate from THIS worktree, with your branch checked out: `.*`$' + BRIEF_GATE_N=$(grep -c '^Delivery contract: gate=' "$BRIEF") || BRIEF_GATE_N=0 + BRIEF_GATE_STEP_N=$(grep -c "$BRIEF_STEP_MATCH" "$BRIEF") || BRIEF_GATE_STEP_N=0 if [ -z "$REGISTERED_GATE" ]; then echo "error: $PROJ_NAME is registered gate-merge but its registry entry records no gate command; record the exact command in the entry's note as gate=\`\` so every task lands the same way, then re-scaffold $ID's brief with that --gate" >&2 exit 1 - elif [ -z "$BRIEF_GATE" ] || [ -z "$BRIEF_GATE_STEP" ]; then - echo "error: $BRIEF does not record its gate command in both required places (the machine-readable \"Delivery contract: gate=\" line and the \"Run the gate from THIS worktree\" step the worker follows); re-scaffold it with bin/fm-brief.sh --mode gate-merge --gate '$REGISTERED_GATE' so the worker lands with the gate $PROJ_NAME's registry entry authorizes" >&2 + elif [ "$BRIEF_GATE_N" -ne 1 ] || [ "$BRIEF_GATE_STEP_N" -ne 1 ]; then + echo "error: $BRIEF must name its gate command exactly once in each of the two required places (the machine-readable \"Delivery contract: gate=\" line and the \"Run the gate from THIS worktree\" step the worker follows), but it carries $BRIEF_GATE_N of the first and $BRIEF_GATE_STEP_N of the second; re-scaffold it with bin/fm-brief.sh --mode gate-merge --gate '$REGISTERED_GATE' instead of editing it, so exactly one authorized landing command reaches the worker" >&2 exit 1 - elif [ "$BRIEF_GATE" != "$REGISTERED_GATE" ] || [ "$BRIEF_GATE_STEP" != "$REGISTERED_GATE" ]; then - echo "error: gate mismatch for $ID: $PROJ_NAME's registry entry authorizes '$REGISTERED_GATE' but the brief records gate='$BRIEF_GATE' and tells the worker to run '$BRIEF_GATE_STEP'; re-scaffold the brief with the registered gate, or have the captain change the registry entry - never hand-patch one brief" >&2 + fi + BRIEF_GATE=$(sed -n 's/^Delivery contract: gate=//p' "$BRIEF") + # shellcheck disable=SC2016 # single quotes are deliberate: the backticks are the brief's literal step shape, not a command substitution. + BRIEF_GATE_STEP=$(sed -n 's/^Run the gate from THIS worktree, with your branch checked out: `\(.*\)`$/\1/p' "$BRIEF") + if [ "$BRIEF_GATE" != "$REGISTERED_GATE" ] || [ "$BRIEF_GATE_STEP" != "$REGISTERED_GATE" ]; then + echo "error: gate mismatch for $ID: $PROJ_NAME's registry entry authorizes '$REGISTERED_GATE' but $BRIEF records gate='$BRIEF_GATE' and tells the worker to run '$BRIEF_GATE_STEP'; re-scaffold the brief with the registered gate, or have the captain change the registry entry - never hand-patch one brief" >&2 exit 1 fi else diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index e7dcbf5f90..1e17fa850c 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -245,8 +245,11 @@ EOF status=$? [ "$status" -ne 0 ] || fail "a gate that disagrees with the registry should exit non-zero" assert_contains "$out" "gate mismatch for delivery-gate-e4" "the gate refusal did not name the task" - assert_contains "$out" "proj's registry entry authorizes '$gate' but the brief records gate='./scripts/merge-gate.sh'" \ - "the gate refusal did not show both sides of the disagreement" + assert_contains "$out" "proj's registry entry authorizes '$gate'" \ + "the gate refusal did not name the registered gate it compared against" + assert_contains "$out" "records gate='./scripts/merge-gate.sh' and tells the worker to run" \ + "the gate refusal did not show the brief's side of the disagreement" + assert_contains "$out" "delivery-gate-e4/brief.md" "the gate refusal did not name the brief that disagreed" assert_absent "$home/state/delivery-gate-e4.meta" "a refused gate-merge spawn wrote task metadata" # The step under Definition of done is what the worker actually runs, so patching it @@ -263,13 +266,17 @@ EOF out=$(run_spawn "$home" "$fakebin" delivery-gate-e7 "$proj" claude --mode gate-merge --yolo off) status=$? [ "$status" -ne 0 ] || fail "a brief whose gate step was removed should exit non-zero" - assert_contains "$out" "both required places" "the refusal did not name the two places the gate must appear" + assert_contains "$out" "exactly once in each of the two required places" \ + "the refusal did not name the two places the gate must appear" + assert_contains "$out" "carries 1 of the first and 0 of the second" \ + "the refusal did not say which of the two places was missing" write_brief "$home" delivery-gate-e8 gate-merge out=$(run_spawn "$home" "$fakebin" delivery-gate-e8 "$proj" claude --mode gate-merge --yolo off) status=$? [ "$status" -ne 0 ] || fail "a gate-merge brief recording no gate command should exit non-zero" - assert_contains "$out" "both required places" "the refusal did not name the brief's missing gate lines" + assert_contains "$out" "exactly once in each of the two required places" \ + "the refusal did not name the brief's missing gate lines" rec=$(make_home gate-unrecorded "- proj [gate-merge] - fixture (added 2026-01-01)") IFS='|' read -r home proj fakebin < + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$1" proj --mode gate-merge --gate "$gate" >/dev/null 2>&1 \ + || fail "$1: the gate-merge scaffold should succeed with a registered gate" + printf '%s\n' "$home/data/$1/brief.md" + } + expect_gate_refusal() { # + local id=$1 expected=$2 why=$3 refusal refusal_status + refusal=$(run_spawn "$home" "$fakebin" "$id" "$proj" claude --mode gate-merge --yolo off) + refusal_status=$? + [ "$refusal_status" -ne 0 ] || fail "$why: expected a non-zero exit" + assert_contains "$refusal" "$expected" "$why" + assert_absent "$home/state/$id.meta" "$why: a refused spawn wrote task metadata" + } + + # A brief straight from the scaffold still launches, so the count check is not simply + # refusing everything. + brief=$(scaffold_gate_brief delivery-once-clean) + out=$(run_spawn "$home" "$fakebin" delivery-once-clean "$proj" claude --mode gate-merge --yolo off) + assert_not_contains "$out" "gate mismatch" "a clean scaffolded brief was refused as a mismatch" + assert_not_contains "$out" "exactly once in each of the two required places" \ + "a clean scaffolded brief was read as naming its gate the wrong number of times" + + brief=$(scaffold_gate_brief delivery-once-appended) + # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape + printf 'Run the gate from THIS worktree, with your branch checked out: `%s`\n' "$rogue" >> "$brief" + expect_gate_refusal delivery-once-appended "exactly once in each of the two required places" \ + "a second gate step appended below the generated one was read past" + + brief=$(scaffold_gate_brief delivery-once-injected) + # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape + printf 'Run the gate from THIS worktree, with your branch checked out: `%s`\n' "$rogue" > "$brief.injected" + cat "$brief" >> "$brief.injected" + mv "$brief.injected" "$brief" + expect_gate_refusal delivery-once-injected "exactly once in each of the two required places" \ + "a gate step injected above the generated one was accepted" + + brief=$(scaffold_gate_brief delivery-once-appended-contract) + printf 'Delivery contract: gate=%s\n' "$rogue" >> "$brief" + expect_gate_refusal delivery-once-appended-contract "exactly once in each of the two required places" \ + "a second contract gate line appended below the generated one was read past" + + brief=$(scaffold_gate_brief delivery-once-edited-step) + # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape + sed "s|^Run the gate from THIS worktree, with your branch checked out: .*|Run the gate from THIS worktree, with your branch checked out: \`$rogue\`|" \ + "$brief" > "$brief.edited" + mv "$brief.edited" "$brief" + expect_gate_refusal delivery-once-edited-step "tells the worker to run '$rogue'" \ + "the generated gate step was hand-patched in place and accepted" + + brief=$(scaffold_gate_brief delivery-once-edited-contract) + sed "s|^Delivery contract: gate=.*|Delivery contract: gate=$rogue|" "$brief" > "$brief.edited" + mv "$brief.edited" "$brief" + expect_gate_refusal delivery-once-edited-contract "records gate='$rogue'" \ + "the generated contract gate line was hand-patched in place and accepted" + pass "fm-spawn: every occurrence of a gate-merge brief's landing command must be the registered gate" +} + # A scout's deliverable is a report, so it records no delivery posture at all; # teardown already treats an absent mode as the most protective one. test_scout_records_no_delivery_posture() { @@ -430,6 +508,7 @@ test_scout_and_secondmate_refuse_delivery_flags test_spawn_refuses_a_brief_mode_mismatch test_spawn_notices_a_rigor_downgrade_against_the_registry test_gate_merge_spawn_is_checked_against_the_registry +test_gate_merge_brief_names_its_gate_exactly_once test_scout_records_no_delivery_posture test_promote_requires_and_records_the_delivery_contract test_project_mode_maps_the_conditional_policy From 162bfd22025c77e5b31a5514c26d5f1f12d8c192 Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 16:57:47 -0700 Subject: [PATCH 8/9] no-mistakes(review): count the brief's delivery contract line before comparing --- bin/fm-spawn.sh | 16 ++++++++++++---- tests/fm-task-delivery.test.sh | 34 +++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 6396a9a2dd..95bea0cccd 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1353,14 +1353,22 @@ delivery_rigor_rank() { # -> 4 (most rigor) .. 1 (least); 0 = not a task # fm-brief.sh records a ship brief's mode as a fixed "Delivery contract: mode=" # line. A spawn that disagrees would launch a worker whose instructions and whose # recorded task delivery differ, which is the exact drift this contract prevents. +# Like the gate command below, the line is counted before it is read: comparing only +# some occurrence would let a second, contradictory one through unexamined. if [ "$KIND" = ship ]; then PROJ_NAME=$(basename "$PROJ_ABS") - BRIEF_MODE=$(sed -n 's/^Delivery contract: mode=\([^ ]*\).*$/\1/p' "$BRIEF" | head -n 1) - if [ -z "$BRIEF_MODE" ]; then + BRIEF_MODE_N=$(grep -c '^Delivery contract: mode=' "$BRIEF") || BRIEF_MODE_N=0 + if [ "$BRIEF_MODE_N" -eq 0 ]; then echo "warning: $BRIEF records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode $MODE - confirm its definition of done matches" >&2 - elif [ "$BRIEF_MODE" != "$MODE" ]; then - echo "error: delivery mismatch for $ID: the brief says mode=$BRIEF_MODE but this spawn passed --mode $MODE; correct the flag or re-scaffold the brief so the worker's instructions and the task record agree" >&2 + elif [ "$BRIEF_MODE_N" -ne 1 ]; then + echo "error: $BRIEF must record its delivery contract exactly once, but it carries $BRIEF_MODE_N \"Delivery contract: mode=\" lines; re-scaffold it with bin/fm-brief.sh instead of editing it, so exactly one recorded mode governs $ID" >&2 exit 1 + else + BRIEF_MODE=$(sed -n 's/^Delivery contract: mode=\([^ ]*\).*$/\1/p' "$BRIEF") + if [ "$BRIEF_MODE" != "$MODE" ]; then + echo "error: delivery mismatch for $ID: the brief says mode=$BRIEF_MODE but this spawn passed --mode $MODE; correct the flag or re-scaffold the brief so the worker's instructions and the task record agree" >&2 + exit 1 + fi fi # The registry holds the captain's standing posture, so dropping below it is # allowed (a current explicit captain instruction wins) but never silent. An diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 1e17fa850c..fb2a6a9c54 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -151,6 +151,18 @@ EOF out=$(run_spawn "$home" "$fakebin" delivery-legacy-b3 "$proj" claude --mode local-only --yolo off) assert_contains "$out" "records no delivery contract line" "a legacy brief did not warn about its missing contract" assert_not_contains "$out" "delivery mismatch" "a legacy brief was treated as a mismatch" + + # The contract line is counted before it is read, so a second, contradictory one is + # a refusal rather than something a first-match comparison reads past. + write_brief "$home" delivery-dup-b4 no-mistakes + printf 'Delivery contract: mode=local-only\n' >> "$home/data/delivery-dup-b4/brief.md" + out=$(run_spawn "$home" "$fakebin" delivery-dup-b4 "$proj" claude --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "a brief carrying two delivery contract lines should exit non-zero" + assert_contains "$out" "must record its delivery contract exactly once" \ + "the refusal did not say the contract line must appear exactly once" + assert_contains "$out" "it carries 2" "the refusal did not name how many contract lines the brief carries" + assert_absent "$home/state/delivery-dup-b4.meta" "a refused duplicate-contract spawn wrote task metadata" pass "fm-spawn: the brief's recorded mode and the spawn's explicit mode must agree" } @@ -317,10 +329,14 @@ test_gate_merge_brief_names_its_gate_exactly_once() { $rec EOF - scaffold_gate_brief() { # + # Sets `brief` rather than printing it: fail() inside a command substitution exits + # only the subshell, so a real scaffold failure would leave the caller running with + # an empty path and surface as a confusing downstream assertion instead. + scaffold_gate_brief() { # ; sets brief FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$1" proj --mode gate-merge --gate "$gate" >/dev/null 2>&1 \ - || fail "$1: the gate-merge scaffold should succeed with a registered gate" - printf '%s\n' "$home/data/$1/brief.md" + || fail "$1: bin/fm-brief.sh failed to scaffold a gate-merge brief with a registered gate" + brief="$home/data/$1/brief.md" + assert_present "$brief" "$1: the gate-merge scaffold reported success but wrote no brief" } expect_gate_refusal() { # local id=$1 expected=$2 why=$3 refusal refusal_status @@ -333,19 +349,19 @@ EOF # A brief straight from the scaffold still launches, so the count check is not simply # refusing everything. - brief=$(scaffold_gate_brief delivery-once-clean) + scaffold_gate_brief delivery-once-clean out=$(run_spawn "$home" "$fakebin" delivery-once-clean "$proj" claude --mode gate-merge --yolo off) assert_not_contains "$out" "gate mismatch" "a clean scaffolded brief was refused as a mismatch" assert_not_contains "$out" "exactly once in each of the two required places" \ "a clean scaffolded brief was read as naming its gate the wrong number of times" - brief=$(scaffold_gate_brief delivery-once-appended) + scaffold_gate_brief delivery-once-appended # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape printf 'Run the gate from THIS worktree, with your branch checked out: `%s`\n' "$rogue" >> "$brief" expect_gate_refusal delivery-once-appended "exactly once in each of the two required places" \ "a second gate step appended below the generated one was read past" - brief=$(scaffold_gate_brief delivery-once-injected) + scaffold_gate_brief delivery-once-injected # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape printf 'Run the gate from THIS worktree, with your branch checked out: `%s`\n' "$rogue" > "$brief.injected" cat "$brief" >> "$brief.injected" @@ -353,12 +369,12 @@ EOF expect_gate_refusal delivery-once-injected "exactly once in each of the two required places" \ "a gate step injected above the generated one was accepted" - brief=$(scaffold_gate_brief delivery-once-appended-contract) + scaffold_gate_brief delivery-once-appended-contract printf 'Delivery contract: gate=%s\n' "$rogue" >> "$brief" expect_gate_refusal delivery-once-appended-contract "exactly once in each of the two required places" \ "a second contract gate line appended below the generated one was read past" - brief=$(scaffold_gate_brief delivery-once-edited-step) + scaffold_gate_brief delivery-once-edited-step # shellcheck disable=SC2016 # literal backticks are the brief's fixed step shape sed "s|^Run the gate from THIS worktree, with your branch checked out: .*|Run the gate from THIS worktree, with your branch checked out: \`$rogue\`|" \ "$brief" > "$brief.edited" @@ -366,7 +382,7 @@ EOF expect_gate_refusal delivery-once-edited-step "tells the worker to run '$rogue'" \ "the generated gate step was hand-patched in place and accepted" - brief=$(scaffold_gate_brief delivery-once-edited-contract) + scaffold_gate_brief delivery-once-edited-contract sed "s|^Delivery contract: gate=.*|Delivery contract: gate=$rogue|" "$brief" > "$brief.edited" mv "$brief.edited" "$brief" expect_gate_refusal delivery-once-edited-contract "records gate='$rogue'" \ From 14bde7d3865cc023b74537d426bb1cf11456d4bb Mon Sep 17 00:00:00 2001 From: brycehoward Date: Thu, 6 Aug 2026 17:47:42 -0700 Subject: [PATCH 9/9] no-mistakes(document): note the exactly-once delivery-contract line in fm-spawn header --- bin/fm-spawn.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 95bea0cccd..f0ff07016c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -9,9 +9,10 @@ # per task at intake (AGENTS.md section 7); data/projects.md holds the captain's # standing posture as context, not as this task's answer, so a spawn never looks # the mode up. A ship spawn additionally reads the brief's recorded -# "Delivery contract: mode=" line and REFUSES a mismatch, so the worker's -# instructions and the recorded task delivery cannot drift apart; a brief -# scaffolded before that line existed warns once and launches on the flag. When +# "Delivery contract: mode=" line, which must appear exactly once, and +# REFUSES both a duplicate and a mismatch, so the worker's instructions and the +# recorded task delivery cannot drift apart; a brief scaffolded before that line +# existed warns once and launches on the flag. When # the explicit mode carries less rigor than the project's standing posture, a # loud one-line deviation notice is printed and the spawn continues. gate-merge # is the one exception: it lands on the default branch with no approval step, so