diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 8feb522bd0..d199d621e5 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -41,6 +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 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`. @@ -58,6 +60,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/README.md b/README.md index 52715da3f3..9435c806a1 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ 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. -- **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/bin/fm-brief.sh b/bin/fm-brief.sh index a873c84051..a23a8c4777 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,12 +34,27 @@ # 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 -# 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. +# 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 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. @@ -106,6 +121,8 @@ HERDR_LAB=0 NO_PROJECTS=0 MODE= MODE_SET=0 +GATE= +GATE_SET=0 POS=() want_value= for a in "$@"; do @@ -115,6 +132,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 +145,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 +160,35 @@ 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. +# 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 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 + 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 +301,27 @@ 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 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") # 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 +391,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 +404,21 @@ 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. +# 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 +# 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="" @@ -362,6 +430,29 @@ 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.' + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + 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 <> $STATUS_FILE\` @@ -443,7 +534,7 @@ $RULE1 known external wait you expect to clear on its own (an upstream release, a rate-limit reset, a scheduled window): firstmate then leaves your idle pane alone and rechecks it on a long cadence instead of treating it as a possible wedge. Use \`blocked:\` when you are stuck and need help. -5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help. +$RULE5 6. If a decision belongs above the implementation worker (product choices, destructive actions, ask-user findings), append \`needs-decision: {summary of options}\` and stop. Firstmate will apply the configured authority and reply with the decision. A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work. @@ -451,6 +542,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..b78eab4adb 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 @@ -9,7 +9,8 @@ # bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7). # The consumers are bin/fm-fleet-sync.sh (skip local-only clones), # bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and -# bin/fm-spawn.sh's advisory registry-deviation notice. +# bin/fm-spawn.sh's advisory registry-deviation notice and its gate-merge +# authorization check. # # Registry line format (data/projects.md): # - - (added ) -> no-mistakes off (legacy default) @@ -20,6 +21,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, +# 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). @@ -33,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)" @@ -44,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 } @@ -68,23 +92,49 @@ 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 ;; + no-mistakes|direct-PR|local-only|gate-merge|no-mistakes-prod-only) ;; + *) + 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 + +# 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-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..f0ff07016c 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 @@ -9,11 +9,18 @@ # 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. +# 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 either place the brief carries its gate command - the machine-readable +# 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 @@ -310,7 +317,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 +325,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 +1336,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 } @@ -1342,14 +1354,22 @@ delivery_rigor_rank() { # -> 3 (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 @@ -1357,7 +1377,46 @@ 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 + # The command appears twice in a gate-merge brief: the machine-readable contract + # 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_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 [ "$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 + 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 + 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/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..6094638585 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,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, 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 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-brief.test.sh b/tests/fm-brief.test.sh index a348e2d345..8464f7c850 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,160 @@ 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" + # 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" + 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" +} + +# Rule 5 stops a worker that hits the same obstacle twice, and the gate-merge +# definition of done deliberately retries a queued gate in turn for about eight +# minutes. Left unshaped, the two halves contradict each other on the second +# consecutive lock refusal - the same self-consistency defect as the historical +# rule 1, and the same stall this mode exists to prevent. Rule 5 is therefore +# mode-shaped: byte-identical for every other brief, and exempting only the +# documented gate-queue wait for gate-merge, which still ends in `blocked:`. +test_stop_on_repeat_rule_is_mode_shaped() { + local home id brief kind rule5 baseline + home="$TMP_ROOT/rule5-home" + mkdir -p "$home/data" + # shellcheck disable=SC2016 # literal backticks belong to the generated brief text + baseline='5. If you hit the same obstacle twice, append `blocked: {why}` and stop; firstmate will help.' + + for kind in no-mistakes direct-PR local-only scout; do + id="brief-rule5-$kind" + case "$kind" in + scout) FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --scout >/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. +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" + # 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" } # A ship task's delivery mode is firstmate's per-task decision, so a missing or @@ -238,7 +396,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 +873,10 @@ 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_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 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..fb2a6a9c54 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,18 @@ make_home() { # [...] printf '%s\n' "$home|$projects/proj|$fakebin" } -write_brief() { # [] - local home=$1 id=$2 mode=${3:-} +# 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" } @@ -81,7 +89,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 @@ -143,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" } @@ -179,10 +199,197 @@ 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 +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. 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' + + rec=$(make_home gate-conflict "- proj [no-mistakes] - 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" "exactly once in each of the two required places" \ + "the spawn did not find exactly one of each gate occurrence in a freshly scaffolded brief" + pass "fm-spawn: a gate-merge spawn ships only on the registered posture and the registered gate" +} + +# The gate check must hold for EVERY occurrence a worker could act on, not for whichever +# one a positional read happens to land on. An extra copy is itself a refusal, so the +# check cannot be defeated by appending a second landing command below the generated +# one, by injecting one above it (the {TASK} placeholder is filled in after scaffolding +# and sits earlier in the file), or by editing the generated line in place. +test_gate_merge_brief_names_its_gate_exactly_once() { + local rec home proj fakebin brief out status + local gate='./scripts/merge-gate.sh --push' + local rogue='./hand-patched.sh' + rec=$(make_home gate-occurrences "- proj [gate-merge] - fixture, gate=\`$gate\` (added 2026-01-01)") + IFS='|' read -r home proj fakebin < ; sets brief + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$1" proj --mode gate-merge --gate "$gate" >/dev/null 2>&1 \ + || 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 + 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. + 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" + + 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" + + 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" + + 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" + + 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" + + 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() { @@ -235,6 +442,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 +467,8 @@ 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, gate=`./scripts/merge-gate.sh --push` (added 2026-01-01) +- gatelessproj [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 +484,34 @@ 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" + + # --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" + # 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" + 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) @@ -276,6 +523,8 @@ 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_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 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