diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 8feb522bd0..9690b7d695 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -42,12 +42,18 @@ Choose that posture when adding or creating the project: - `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. - `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`. +- `+hardened` is the highest-rigor choice on this list, adding the quality gate that runs before validation; it rides alongside one of the flat modes above rather than replacing it, so a hardened project is registered as `[no-mistakes +hardened]`, `[direct-PR +hardened]`, or `[local-only +hardened]`. `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`. State that resolved default while confirming the source, local name, and posture instead of asking the captain to choose from scratch, and record a flat mode instead whenever they ask for one. Existing registry entries keep the meaning they already have and are never migrated or reinterpreted, so a legacy entry with no bracket stays `no-mistakes`. Registering a conditional policy is a one-time choice and never requires classifying any change; the per-task surface classification happens at each task's intake, and internal-only is never inferred from file location or project name. +`+hardened` is off for every project unless the captain asks for it, so a project registered without it is `standard`. +Refuse `+hardened` together with `no-mistakes-prod-only` and tell the captain to pick a flat delivery mode instead. +A conditional policy decides per task, so a quality standard that covers only part of a project is a posture nobody can state in one sentence. +`AGENTS.md` section 7 owns how each task's quality resolves at intake, and `bin/fm-project-mode.sh --quality` owns how the registered token is read. + The optional `+yolo` posture changes routine approval authority but does not change the delivery mode. Default it off for every project and every posture, and enable it only on the captain's explicit instruction. `AGENTS.md` section 7 owns the complete authority boundary and exceptions when it is on. diff --git a/AGENTS.md b/AGENTS.md index 81dc761f8c..17166ec38c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -286,6 +286,7 @@ Resolve every ship task's concrete delivery mode and yolo posture at intake, and A current explicit captain instruction wins; otherwise the project's registry entry is the captain's standing posture, and dropping below its rigor needs a reason you can state. On a `no-mistakes-prod-only` project, classify the task's surface: 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`; never infer internal-only from file location or project name. An unregistered project or absent registry resolves to `no-mistakes` with yolo off, and the registration gap goes to the captain. +A task's quality posture resolves at intake with the same precedence, a current explicit captain instruction first, then the project's registered posture, then `standard`, with the one-line reason for any deviation recorded in the same backlog note. Record the resulting mode, yolo, and the one-line reason for any deviation in the backlog item note. Treat file or subsystem overlap as a risk signal rather than an automatic reason to wait, and dispatch isolated work immediately with no concurrency cap when each change can be independently implemented and validated and the selected delivery path can reconcile ordinary rebases or conflicts. diff --git a/README.md b/README.md index 8ed5226b17..4446ed5a39 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 one of those plus `+hardened` for the highest-rigor quality gate, 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 595557523e..3941dd259a 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 [--quality ] [--herdr-lab] # fm-brief.sh --scout [--herdr-lab] # fm-brief.sh --dreamer [--herdr-lab] # fm-brief.sh --secondmate {...|--no-projects} @@ -48,9 +48,22 @@ # "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. +# --quality is the task's quality posture, resolved at intake the same way (AGENTS.md +# section 7) from the project's registered "+hardened" annotation, and it defaults to +# standard so every existing call site scaffolds exactly as before: +# standard the ordinary path: implement, then the mode's definition of done +# hardened a clean loop then a harden loop, both against the base commit fixed at +# spawn, both before validation, driven by bin/fm-quality.sh +# A hardened brief carries the sibling machine-readable line +# "Quality contract: quality=hardened" plus one short quality-gate section; a standard +# brief carries neither, so an absent line means standard and a standard brief stays +# byte-identical to what this scaffold produced before --quality existed. bin/fm-spawn.sh +# checks that line against its own --quality exactly as it checks the mode line. +# --quality is refused on scout, dreamer and secondmate scaffolds, for the same reason +# --mode is. # 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. +# --mode is refused on scout, dreamer and secondmate scaffolds: a scout or dreamer +# delivers a report rather than a merge, and a charter is not a delivery contract. # There is no --yolo flag here. The worker never owns approval decisions, so yolo is # a spawn-time and firstmate-side input only (AGENTS.md section 7). # Every scaffold's status protocol distinguishes the configured @@ -115,6 +128,8 @@ HERDR_LAB=0 NO_PROJECTS=0 MODE= MODE_SET=0 +QUALITY=standard +QUALITY_SET=0 POS=() want_value= for a in "$@"; do @@ -124,6 +139,7 @@ for a in "$@"; do esac case "$want_value" in mode) MODE=$a; MODE_SET=1 ;; + quality) QUALITY=$a; QUALITY_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -137,6 +153,8 @@ for a in "$@"; do --no-projects) NO_PROJECTS=1 ;; --mode) want_value=mode ;; --mode=*) MODE=${a#--mode=}; MODE_SET=1 ;; + --quality) want_value=quality ;; + --quality=*) QUALITY=${a#--quality=}; QUALITY_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. @@ -164,6 +182,19 @@ elif [ "$MODE_SET" -eq 1 ]; then echo "error: --mode applies only to ship briefs; a scout or dreamer delivers a report and a secondmate charter is not a delivery contract" >&2 exit 1 fi + +# Quality posture. Unlike --mode it has a safe default, so it is optional and only +# its VALUE is closed-set validated; a typo must never quietly scaffold a standard +# brief for a task firstmate resolved as hardened. +if [ "$KIND" = ship ]; then + case "$QUALITY" in + standard|hardened) ;; + *) echo "error: --quality must be one of standard, hardened (got '$QUALITY')" >&2; exit 1 ;; + esac +elif [ "$QUALITY_SET" -eq 1 ]; then + echo "error: --quality applies only to ship briefs; a scout or dreamer delivers a report and a secondmate charter is not a delivery contract" >&2 + exit 1 +fi [ "${#POS[@]}" -ge 1 ] || { echo "error: task id is required" >&2; exit 1; } ID=${POS[0]} @@ -479,17 +510,40 @@ echo "scaffolded: $BRIEF (dreamer; replace {TASK})" exit 0 fi +# The DOD's machine-readable contract header, owned in one place so the three +# mode bodies below cannot drift apart. A standard task emits the delivery line +# alone, exactly as this scaffold did before --quality existed; a hardened task +# adds the sibling quality line that bin/fm-spawn.sh checks against its own +# explicit --quality before launching, the same way it checks the delivery line. +CONTRACT_LINES="Delivery contract: mode=$MODE" +if [ "$QUALITY" = hardened ]; then + CONTRACT_LINES="$CONTRACT_LINES +Quality contract: quality=hardened" +fi + +# The hardened task's extra instructions. Deliberately short: bin/fm-quality.sh +# and its --help own the loop's mechanics, and a second copy here would drift. +IFS= read -r -d '' QUALITY_SECTION <" line that bin/fm-spawn.sh checks against its own -# explicit --mode before launching. +# delivery mode, validated above. Each body opens with $CONTRACT_LINES, built once +# just above. case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' IFS= read -r -d '' DOD <"\` to start, and \`no-mistakes axi respond\` for each gate. @@ -544,6 +598,14 @@ esac # briefs stay byte-identical to the historical Bash 5 output. DOD=${DOD%$'\n'} +# A standard task's brief body is unchanged by --quality existing: nothing is +# prepended, so it stays byte-identical to the pre-quality scaffold. +if [ "$QUALITY" = hardened ]; then + DOD="$QUALITY_SECTION + +$DOD" +fi + cat > "$BRIEF" < - (added ) -> no-mistakes off (legacy default) # - [] - (added ) -> off # - [ +yolo] - (added ) -> on +# - [ +yolo +hardened] - ... -> on, quality hardened +# +# Bracket grammar: the first token that does not begin with "+" is the mode, and +# every "+" token is position-independent. A "+" this version does not +# recognize is ignored rather than refused, so an older firstmate reading a newer +# registry keeps resolving the posture it does understand. # # Registered modes: # no-mistakes full pipeline -> PR -> configured merge authority (default) @@ -30,12 +36,34 @@ # AGENTS.md section 7 is the single owner of authority exceptions, including # ask-user contract expansion and stronger captain boundaries. # +# +hardened = the registered quality posture. From the captain's side this is the +# fourth option on the same list he picks from when he registers a project, after +# no-mistakes, direct-PR and local-only; mechanically it is a separate token, so a +# hardened project still carries one of those modes too. It is read with --quality +# rather than through the two-word line, which is unchanged. +# Absent means "standard": the ordinary path, with no extra quality loop. +# # --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. # +# --quality prints ONE word instead, "standard" or "hardened". It is a separate +# output path precisely so the two-word stdout contract above stays untouched. +# # 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] +# The quality posture resolves independently of that fallback. +# A missing registry file, or a project absent from the registry, does yield "standard". +# An unrecognised mode token resets only the mode and the yolo flag and keeps a +# "+hardened" parsed beside it, because a typo in the mode must not silently drop the +# quality gate too; the unknown-mode warning still goes to stderr. +# "+hardened" beside "no-mistakes-prod-only" is the opposite case and drops to +# "standard" with its own stderr warning: a hardened project must pick a flat delivery +# mode, because a conditional policy decides per task and a quality standard covering +# only part of a project is not a statable posture +# (.agents/skills/project-management/SKILL.md "Delivery posture"). Unlike the typo, +# that combination parses cleanly and was ruled out on purpose. The mode still resolves +# to no-mistakes-prod-only, the two-word stdout is unchanged, and the exit stays 0. +# Usage: fm-project-mode.sh [--raw] [--quality] set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -44,50 +72,75 @@ 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] } +QUALITY_ONLY=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --raw) RAW=1; shift ;; + --quality) QUALITY_ONLY=1; shift ;; + *) break ;; + esac +done +NAME=${1:?usage: fm-project-mode.sh [--raw] [--quality] } + +# One owner of the output shape, so the two-word default and the one-word +# --quality answer cannot drift apart across the fallback paths below. +emit() { # + if [ "$QUALITY_ONLY" -eq 1 ]; then + echo "$3" + else + echo "$1 $2" + fi +} if [ ! -f "$REG" ]; then echo "warn: no registry at $REG; defaulting $NAME to no-mistakes off" >&2 - echo "no-mistakes off" + emit no-mistakes off standard exit 0 fi -# awk emits " " (one line) or nothing if the project is absent. +# awk emits " " (one line) or nothing if the project is +# absent. A "+" token is never a mode, in any position, so the mode is the +# first bracket token that does not begin with "+". parsed=$(awk -v n="$NAME" ' $1=="-" && $2==n { - mode="no-mistakes"; yolo="off"; + mode="no-mistakes"; yolo="off"; quality="standard"; have_mode=0; if ($3 ~ /^\[/) { s=""; for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break } gsub(/^\[|\]$/, "", s); # strip the surrounding brackets k = split(s, a, " "); - if (a[1] != "" && a[1] != "+yolo") mode = a[1]; - for (j=1; j<=k; j++) if (a[j]=="+yolo") yolo="on"; + for (j=1; j<=k; j++) { + if (a[j]=="+yolo") yolo="on"; + else if (a[j]=="+hardened") quality="hardened"; + else if (a[j] != "" && substr(a[j], 1, 1) != "+" && !have_mode) { mode=a[j]; have_mode=1 } + } } - print mode, yolo; exit + print mode, yolo, quality; exit } ' "$REG") if [ -z "$parsed" ]; then echo "warn: project \"$NAME\" not in registry; defaulting to no-mistakes off" >&2 - echo "no-mistakes off" + emit no-mistakes off standard exit 0 fi -mode=${parsed%% *} -yolo=${parsed##* } +read -r mode yolo quality <&2; mode=no-mistakes; yolo=off ;; esac case "$yolo" in on|off) ;; *) yolo=off ;; esac +case "$quality" in standard|hardened) ;; *) quality=standard ;; esac +if [ "$mode" = no-mistakes-prod-only ] && [ "$quality" = hardened ]; then + echo "warn: +hardened is refused alongside the conditional policy no-mistakes-prod-only for $NAME; a hardened project must pick a flat delivery mode (no-mistakes, direct-PR or local-only), so defaulting quality to standard" >&2 + quality=standard +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 mode=no-mistakes fi -echo "$mode $yolo" +emit "$mode" "$yolo" "$quality" diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 0ed1fd0616..868feb6f54 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -9,8 +9,15 @@ # A scout records no delivery posture, so promotion is where this task's delivery # contract is decided: --mode and --yolo are REQUIRED and written into the meta # alongside the kind= flip. Firstmate resolves both at promotion time, having just -# 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. +# read the scout's report (AGENTS.md section 7). data/projects.md holds the captain's +# standing posture, and this script reads only its quality half, only to print one +# advisory notice on stderr after a successful promotion; the delivery mode stays the +# caller's explicit decision. +# A promoted task deliberately records no quality= and no base_sha=. The base commit +# cannot be captured here, because the promoted worker resets to a clean +# default-branch base only afterwards, and a hardened record with no anchor would look +# complete to the quality loop while being unanchored. Both keys belong to the task +# that owns the base-capture question (bin/fm-quality.sh). # no-mistakes-prod-only is a registry policy rather than a task mode and is refused. # Usage: fm-promote.sh --mode --yolo set -eu @@ -120,6 +127,19 @@ TMP= fm_lock_release "$META_LOCK" META_LOCK_HELD=0 +# The quality sibling of the standing-posture notice in bin/fm-spawn.sh: advisory +# only, printed after the record is already rewritten so it can never affect the +# promotion. A record with no project=, a missing registry, or a failed lookup simply +# skips it, exactly as the spawn notice tolerates an empty standing mode. +PROMOTED_PROJECT=$(sed -n 's/^project=//p' "$META" | tail -n 1) +PROMOTED_PROJECT=${PROMOTED_PROJECT##*/} +if [ -n "$PROMOTED_PROJECT" ]; then + STANDING_QUALITY=$("$FM_ROOT/bin/fm-project-mode.sh" --quality "$PROMOTED_PROJECT" 2>/dev/null) || STANDING_QUALITY= + if [ "$STANDING_QUALITY" = hardened ]; then + echo "notice: $ID promotes carrying no quality posture while the standing posture for $PROMOTED_PROJECT is hardened - 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 +fi + HOME_Q=$(printf '%q' "$FM_HOME") echo "promoted $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" echo "next: FM_HOME=$HOME_Q bin/fm-send.sh fm-$ID ''" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 685c133c22..f07250ad43 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 [--quality ] [--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 @@ -16,6 +16,15 @@ # loud one-line deviation notice is printed and the spawn continues. # no-mistakes-prod-only is a registry policy rather than a task mode and is # refused as a flag value. +# --quality is this task's quality posture, resolved at intake the same way from +# the project's registered "+hardened" annotation (bin/fm-project-mode.sh +# --quality). Unlike --mode it has a safe default, so it is optional on a ship +# spawn and defaults to standard, and it is refused on --scout and --secondmate +# spawns. A ship spawn reads the brief's "Quality contract: quality=" +# line and REFUSES a mismatch exactly as it does for the delivery line. An absent +# line reads as standard rather than as a legacy gap, so it agrees silently with +# --quality standard, while --quality hardened against a brief that never told +# the worker to run the loop is a refusal. # fm-spawn.sh --relaunch [--harness ] [--model ] [--effort ] # --relaunch launches a replacement agent for an EXISTING task into that # task's own recorded endpoint and worktree instead of creating either. It is @@ -191,6 +200,11 @@ # A ship task records the explicit mode/yolo it was passed; a secondmate spawn records # mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the # success line and state/.meta omit them. +# A ship task additionally records quality= (the resolved posture) and base_sha= (the +# commit its worktree starts from). base_sha is captured ONCE, at the fresh spawn, and +# a relaunch reads it back rather than recapturing it: the hardened quality loop commits +# each round, so a base recaptured later - or a gate reading HEAD~1 - would narrow its +# view to the newest commits while still reporting success. # Every fresh spawn or relaunch records a new spawn_gen= incarnation token so durable # consumers can distinguish a replacement worker that reuses the same task id. # When the home session's frozen trace-context decision is enabled (see @@ -287,6 +301,8 @@ EFFORT= BACKEND_ARG= MODE= YOLO= +QUALITY= +BASE_SHA= TRACEPARENT_ARG= HARNESS_SET=0 MODEL_SET=0 @@ -294,6 +310,7 @@ EFFORT_SET=0 BACKEND_SET=0 MODE_SET=0 YOLO_SET=0 +QUALITY_SET=0 TRACEPARENT_SET=0 RELAUNCH=0 POS=() @@ -310,6 +327,7 @@ for a in "$@"; do backend) BACKEND_ARG=$a; BACKEND_SET=1 ;; mode) MODE=$a; MODE_SET=1 ;; yolo) YOLO=$a; YOLO_SET=1 ;; + quality) QUALITY=$a; QUALITY_SET=1 ;; traceparent) TRACEPARENT_ARG=$a; TRACEPARENT_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac @@ -332,6 +350,8 @@ for a in "$@"; do --mode=*) MODE=${a#--mode=}; MODE_SET=1 ;; --yolo) want_value=yolo ;; --yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;; + --quality) want_value=quality ;; + --quality=*) QUALITY=${a#--quality=}; QUALITY_SET=1 ;; --traceparent) want_value=traceparent ;; --traceparent=*) TRACEPARENT_ARG=${a#--traceparent=}; TRACEPARENT_SET=1 ;; *) POS+=("$a") ;; @@ -344,6 +364,7 @@ done [ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; } [ "$MODE_SET" -eq 0 ] || [ -n "$MODE" ] || { echo "error: --mode requires a non-empty value" >&2; exit 1; } [ "$YOLO_SET" -eq 0 ] || [ -n "$YOLO" ] || { echo "error: --yolo requires a non-empty value" >&2; exit 1; } +[ "$QUALITY_SET" -eq 0 ] || [ -n "$QUALITY" ] || { echo "error: --quality requires a non-empty value" >&2; exit 1; } [ "$TRACEPARENT_SET" -eq 0 ] || [ -n "$TRACEPARENT_ARG" ] || { echo "error: --traceparent requires a non-empty value" >&2; exit 1; } # A parent-delivered carrier replaces this home's own resolution, so it is # refused unless it is a secondmate spawn carrying a strictly valid W3C value. @@ -372,6 +393,7 @@ if [ "$RELAUNCH" -eq 1 ]; then [ "$KIND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded kind; --scout/--secondmate cannot override it" >&2; exit 1; } [ "$MODE_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded delivery mode; --mode cannot override it" >&2; exit 1; } [ "$YOLO_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded yolo posture; --yolo cannot override it" >&2; exit 1; } + [ "$QUALITY_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded quality posture; --quality cannot override it" >&2; exit 1; } else # Delivery contract (AGENTS.md section 7). A ship task's mode and yolo are # firstmate's per-task decision, so they are required and closed-set validated @@ -397,6 +419,14 @@ else on|off) ;; *) echo "error: --yolo must be on or off (got '$YOLO')" >&2; exit 1 ;; esac + # Quality has a safe default, so it is optional; only its value is closed-set + # validated, because a typo must never quietly ship a task firstmate resolved + # as hardened down the standard path. + [ "$QUALITY_SET" -eq 1 ] || QUALITY=standard + case "$QUALITY" in + standard|hardened) ;; + *) echo "error: --quality must be one of standard, hardened (got '$QUALITY')" >&2; exit 1 ;; + esac else [ "$MODE_SET" -eq 0 ] || { echo "error: --mode applies only to ship spawns; a scout delivers a report and a secondmate records its own fixed posture" >&2 @@ -406,6 +436,10 @@ else echo "error: --yolo applies only to ship spawns; a scout delivers a report and a secondmate records its own fixed posture" >&2 exit 1 } + [ "$QUALITY_SET" -eq 0 ] || { + echo "error: --quality applies only to ship spawns; a scout delivers a report and a secondmate records its own fixed posture" >&2 + exit 1 + } fi fi @@ -770,6 +804,8 @@ spawn_abort_cleanup() { echo "kind=$KIND" [ -z "${MODE:-}" ] || echo "mode=$MODE" [ -z "${YOLO:-}" ] || echo "yolo=$YOLO" + [ -z "${QUALITY:-}" ] || echo "quality=$QUALITY" + [ -z "${BASE_SHA:-}" ] || echo "base_sha=$BASE_SHA" echo "tasktmp=${TASK_TMP:-}" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" @@ -887,6 +923,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * # spanning several modes is two invocations rather than a silent mixed dispatch. [ "$MODE_SET" -eq 0 ] || shared_args+=(--mode "$MODE") [ "$YOLO_SET" -eq 0 ] || shared_args+=(--yolo "$YOLO") + [ "$QUALITY_SET" -eq 0 ] || shared_args+=(--quality "$QUALITY") for pair in "${POS[@]}"; do case "$pair" in *=*) : ;; @@ -1031,6 +1068,15 @@ if [ "$RELAUNCH" -eq 1 ]; then [ -n "$KIND" ] || KIND=ship MODE=$(fm_meta_get "$RELAUNCH_META" mode) YOLO=$(fm_meta_get "$RELAUNCH_META" yolo) + # Read back, never recaptured: the loop's whole measurement is anchored on the + # base this task actually started from (see the header). A ship task recorded + # before quality existed carries no quality= line, and absent means standard - + # the same reading the brief check applies - so it is normalized here rather + # than left empty and refused against its own brief. Its base_sha stays absent + # rather than being invented from a HEAD the worker has already moved. + QUALITY=$(fm_meta_get "$RELAUNCH_META" quality) + [ "$KIND" != ship ] || [ -n "$QUALITY" ] || QUALITY=standard + BASE_SHA=$(fm_meta_get "$RELAUNCH_META" base_sha) RELAUNCH_WT=$(fm_meta_get "$RELAUNCH_META" worktree) [ -n "$RELAUNCH_WT" ] && [ -d "$RELAUNCH_WT" ] || { echo "error: task $ID's recorded worktree '${RELAUNCH_WT:-none}' is missing; refusing to relaunch without the local copy its work lives in" >&2 @@ -1726,6 +1772,17 @@ if [ "$KIND" = ship ]; 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 + # The same agreement check for the quality posture. A standard brief carries no + # quality line at all, so an absent line IS the standard posture rather than a + # legacy gap: --quality standard agrees with it silently, and --quality hardened + # against a brief that never gave the worker the quality-gate section is the + # drift this refuses. + BRIEF_QUALITY=$(sed -n 's/^Quality contract: quality=\([^ ]*\).*$/\1/p' "$BRIEF" | head -n 1) + [ -n "$BRIEF_QUALITY" ] || BRIEF_QUALITY=standard + if [ "$BRIEF_QUALITY" != "$QUALITY" ]; then + echo "error: quality mismatch for $ID: the brief says quality=$BRIEF_QUALITY but this spawn passed --quality $QUALITY; correct the flag or re-scaffold the brief so the worker's instructions and the task record agree" >&2 + exit 1 + 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 # unregistered project resolves to the same no-mistakes standing default, which @@ -1736,6 +1793,13 @@ if [ "$KIND" = ship ]; then && [ "$(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 + # The same notice for the quality posture, which is a plain two-value token: there + # is no conditional policy to exclude, so a hardened standing posture shipped as a + # standard task is the only downgrade there is. Advisory only, like the mode notice. + STANDING_QUALITY=$("$FM_ROOT/bin/fm-project-mode.sh" --quality "$PROJ_NAME" 2>/dev/null) || STANDING_QUALITY= + if [ "$STANDING_QUALITY" = hardened ] && [ "$QUALITY" = standard ]; then + echo "notice: $ID ships quality=$QUALITY while the standing posture for $PROJ_NAME is $STANDING_QUALITY - 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 fi BRIEF_DIR_REAL=$(cd "$(dirname "$BRIEF")" && pwd -P) @@ -2812,6 +2876,16 @@ else fi fi +# The immutable anchor for a hardened task's quality loop, captured once, here, +# while the worktree still sits on the base it was reset to. Every later phase +# measures a diff against THIS commit; a relaunch reads it back from the record +# above rather than recapturing it, because by then the loop has committed rounds +# of its own and a fresh capture would narrow the gate while still reporting +# success. A worktree git cannot read leaves it absent rather than wrong. +if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" = ship ]; then + BASE_SHA=$(git -C "$WT" rev-parse HEAD 2>/dev/null || true) +fi + META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W SPAWN_GEN="s$(date +%s).${BASHPID:-$$}.$RANDOM" @@ -2826,7 +2900,7 @@ fi preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness kind mode yolo tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") + split("window endpoint_task_id worktree project harness kind mode yolo quality base_sha tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) @@ -2841,6 +2915,8 @@ preserve_relaunch_meta() { echo "kind=$KIND" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" + [ -z "$QUALITY" ] || echo "quality=$QUALITY" + [ -z "$BASE_SHA" ] || echo "base_sha=$BASE_SHA" echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" diff --git a/docs/architecture.md b/docs/architecture.md index 520c881893..b8b8b56de7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -249,9 +249,9 @@ The `data/secondmates.md` line contract is owned by the [`secondmate-provisionin `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. 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. +A ship brief records its mode as a fixed machine-readable line, and a hardened ship brief records its quality posture as a sibling line; the spawn refuses to launch on a value that disagrees with either, so the worker's instructions and the recorded task contract cannot diverge. +`data/projects.md` records each project's standing posture, its optional `+hardened` quality posture, and its optional `+yolo` flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy, which `+hardened` may not ride; a ship spawn or a promotion that drops below either registered posture prints a deviation notice and continues. +`bin/fm-project-mode.sh` remains the one registry parser, both for the standing-posture notices above and 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. 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. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. diff --git a/docs/scripts.md b/docs/scripts.md index 11e06955f2..5467dec040 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -62,7 +62,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 and quality postures from `data/projects.md` | | `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository 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 97368085ce..9f69d5c65b 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -961,6 +961,97 @@ test_task_id_reuse_refused_and_preserves_retained_report() { pass "fm-brief: task id reuse is refused, reports directory contents, gives a way forward, and preserves retained artifacts" } +# --- quality posture -------------------------------------------------------- + +# The load-bearing case for the quality wiring: a ship brief scaffolded WITHOUT +# --quality must be the brief this scaffold produced before --quality existed. +# Proven by executing the real scaffold twice - once with no flag, once with the +# explicit default - and comparing the generated files byte for byte, plus the +# two negative assertions that say what "unchanged" means here: no contract line +# and no quality-gate section reach a standard worker. +test_standard_quality_leaves_the_ship_brief_untouched() { + local home brief_default brief_explicit mode n=0 + home="$TMP_ROOT/quality-standard-home" + mkdir -p "$home/data" + for mode in no-mistakes direct-PR local-only; do + n=$((n + 1)) + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "brief-qstd-d$n" some-proj --mode "$mode" >/dev/null 2>&1 \ + || fail "$mode: a ship brief with no --quality should scaffold" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "brief-qexp-d$n" some-proj --mode "$mode" --quality standard >/dev/null 2>&1 \ + || fail "$mode: an explicit --quality standard brief should scaffold" + brief_default="$home/data/brief-qstd-d$n/brief.md" + brief_explicit="$home/data/brief-qexp-d$n/brief.md" + # The task id is the only text that legitimately differs between the two. + sed "s/brief-qexp-d$n/brief-qstd-d$n/g" "$brief_explicit" > "$home/normalized-d$n" + cmp -s "$brief_default" "$home/normalized-d$n" \ + || fail "$mode: --quality standard changed the generated brief (diff: $(diff "$brief_default" "$home/normalized-d$n" | head -5))" + assert_no_grep "Quality contract:" "$brief_default" \ + "$mode: a standard brief recorded a quality contract line" + assert_no_grep "# Quality gate" "$brief_default" \ + "$mode: a standard brief carried the hardened quality-gate section" + grep -qx "Delivery contract: mode=$mode" "$brief_default" \ + || fail "$mode: the delivery contract line did not survive the quality wiring" + done + pass "fm-brief.sh: a standard ship brief is byte-identical with and without --quality, and carries no quality text" +} + +# A hardened brief must tell the worker the four things the loop depends on, and +# must record the machine-readable sibling line bin/fm-spawn.sh checks. Each fact +# is asserted on the generated file, not on the scaffold's source. +test_hardened_brief_records_the_contract_and_the_gate() { + local home brief mode n=0 + home="$TMP_ROOT/quality-hardened-home" + mkdir -p "$home/data" + for mode in no-mistakes direct-PR local-only; do + n=$((n + 1)) + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "brief-qhard-e$n" some-proj --mode "$mode" --quality hardened >/dev/null 2>&1 \ + || fail "$mode: a hardened ship brief should scaffold" + brief="$home/data/brief-qhard-e$n/brief.md" + grep -qx "Delivery contract: mode=$mode" "$brief" \ + || fail "$mode: the hardened brief lost its delivery contract line" + grep -qx "Quality contract: quality=hardened" "$brief" \ + || fail "$mode: the hardened brief did not record its machine-readable quality contract line" + assert_grep "# Quality gate" "$brief" "$mode: the hardened brief carried no quality-gate section" + assert_grep 'base_sha=' "$brief" "$mode: the hardened brief did not name the fixed base commit" + # shellcheck disable=SC2016 # A literal backticked phrase from the brief, matched fixed-string. + assert_grep 'never against `HEAD~1`' "$brief" "$mode: the hardened brief did not warn off HEAD~1" + assert_grep 'clean loop first, then the harden loop' "$brief" "$mode: the hardened brief did not order the two loops" + assert_grep 'before you start on that definition of done' "$brief" "$mode: the hardened brief did not put the loops before the definition of done" + assert_grep 'fm-quality.sh' "$brief" "$mode: the hardened brief did not name the script that drives the loop" + assert_grep 'Do not hand-roll either loop' "$brief" "$mode: the hardened brief did not forbid hand-rolling the loop" + assert_grep 'real product defect' "$brief" "$mode: the hardened brief did not say to report a defect rather than test around it" + assert_no_grep "EOF" "$brief" "$mode: the hardened brief leaked a heredoc EOF marker" + assert_grep "{TASK}" "$brief" "$mode: the hardened brief lost the {TASK} placeholder" + done + pass "fm-brief.sh: a hardened ship brief records the quality contract line and the short quality-gate section" +} + +# --quality has a safe default, so it is optional - but a typo must stop the +# scaffold rather than quietly producing a standard brief for a task firstmate +# resolved as hardened, and a scout, dreamer, or charter must refuse it outright +# rather than accepting and discarding it. +test_quality_is_closed_set_and_refused_where_it_does_not_apply() { + local home out status label args expect + home="$TMP_ROOT/quality-refused-home" + mkdir -p "$home/data" + while IFS='|' read -r label args expect; do + [ -n "$label" ] || continue + # shellcheck disable=SC2086 # args is an intentional word-split arg list + out=$(FM_HOME="$home" "$ROOT/bin/fm-brief.sh" $args 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "$label: expected a non-zero exit" + assert_contains "$out" "$expect" "$label: refusal did not explain why" + done <<'ROWS' +unknown quality value|brief-qref-f1 some-proj --mode no-mistakes --quality nope|--quality must be one of standard, hardened +empty quality value|brief-qref-f2 some-proj --mode no-mistakes --quality|requires a value +quality on a scout brief|brief-qref-f3 some-proj --scout --quality hardened|--quality applies only to ship briefs +quality on a dreamer brief|brief-qref-f4 some-proj --dreamer --quality hardened|--quality applies only to ship briefs +quality on a secondmate charter|brief-qref-f5 --secondmate --no-projects --quality hardened|--quality applies only to ship briefs +ROWS + assert_absent "$home/data/brief-qref-f1/brief.md" "a refused quality value still wrote a brief" + pass "fm-brief.sh: --quality is closed-set validated and refused on scout, dreamer, and charter scaffolds" +} + test_script_parses test_no_heredoc_in_command_substitution test_help_includes_entire_header @@ -985,3 +1076,6 @@ test_status_protocol_shows_documented_decision_key_placement test_scout_and_secondmate_load_decision_hold_policy test_scout_and_secondmate_scaffold test_task_id_reuse_refused_and_preserves_retained_report +test_standard_quality_leaves_the_ship_brief_untouched +test_hardened_brief_records_the_contract_and_the_gate +test_quality_is_closed_set_and_refused_where_it_does_not_apply diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 9a7b4285ba..6d2f073737 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -297,6 +297,54 @@ test_relaunch_preserves_durable_task_metadata() { pass "fm-control relaunch: durable task metadata survives replacement launch publication" } +# The quality posture and its base commit are the two records a hardened task's +# quality loop reads back, so a replacement agent must inherit both unchanged. +# base_sha is the one that is easy to get wrong and hard to notice: each loop +# round commits, so a base recaptured at relaunch would quietly move forward and +# narrow every later measurement to the newest work while still reporting success. +# This drives the worktree's HEAD past the recorded base before relaunching, so a +# recapture would be visible rather than coincidentally equal. +test_relaunch_reuses_the_quality_posture_and_base_commit() { + local dir out rc base moved + dir=$(new_case quality-anchor rl40) + add_ship_task "$dir" rl40 claude + base=$(git -C "$dir/wt" rev-parse HEAD) + { + printf 'quality=hardened\n' + printf 'base_sha=%s\n' "$base" + } >> "$dir/home/state/rl40.meta" + # A hardened task's instructions carry the contract lines the spawn re-checks + # on every launch, so the replacement worker cannot be handed instructions that + # disagree with the task's own record. + { + printf '\n# Definition of done\n' + printf 'Delivery contract: mode=no-mistakes\n' + printf 'Quality contract: quality=hardened\n' + } >> "$dir/home/data/rl40/brief.md" + + # A round of the loop lands on the branch, exactly as it would in real work. + printf 'a killed mutant\n' > "$dir/wt/round-1.txt" + git -C "$dir/wt" add round-1.txt + git -C "$dir/wt" -c user.email=t@example.com -c user.name=t commit --quiet -m "quality round 1" + moved=$(git -C "$dir/wt" rev-parse HEAD) + [ "$moved" != "$base" ] || fail "the fixture failed to move HEAD past the recorded base" + + out=$(run_control "$dir" rl40 relaunch --note "continuing the quality loop"); rc=$? + expect_code 0 "$rc" "a hardened task should relaunch"$'\n'"$out" + [ "$(meta_field "$dir" rl40 quality)" = hardened ] \ + || fail "the quality posture must survive relaunch, got '$(meta_field "$dir" rl40 quality)'" + [ "$(meta_field "$dir" rl40 base_sha)" = "$base" ] \ + || fail "base_sha must be read back, not recaptured: got '$(meta_field "$dir" rl40 base_sha)', expected $base" + [ "$(meta_field "$dir" rl40 base_sha)" != "$moved" ] \ + || fail "base_sha was recaptured at relaunch and now points at the loop's own newest commit" + [ "$(grep -c '^quality=' "$dir/home/state/rl40.meta")" = 1 ] \ + || fail "relaunch left more than one quality= line in the task record" + [ "$(grep -c '^base_sha=' "$dir/home/state/rl40.meta")" = 1 ] \ + || fail "relaunch left more than one base_sha= line in the task record" + [ "$(meta_field "$dir" rl40 mode)" = no-mistakes ] || fail "the delivery mode must survive alongside it" + pass "fm-control relaunch: the quality posture survives and the base commit is read back, never recaptured" +} + test_relaunch_serializes_concurrent_durable_metadata_publication() { local dir control_pid link_pid rc i=0 traceparent prepare ready exported release dir=$(new_case metadata-race rl28) @@ -1284,6 +1332,9 @@ test_spawn_relaunch_refuses_contradicting_flags() { out=$(run_spawn "$dir" rl16 --relaunch --scout); rc=$? expect_code 1 "$rc" "--scout should be refused alongside --relaunch" assert_contains "$out" "recorded kind" "the refusal should name the recorded kind rule" + out=$(run_spawn "$dir" rl16 --relaunch --quality hardened); rc=$? + expect_code 1 "$rc" "--quality should be refused alongside --relaunch" + assert_contains "$out" "recorded quality posture" "the refusal should name the recorded quality rule" out=$(run_spawn "$dir" rl16 "$dir/proj" --relaunch); rc=$? expect_code 1 "$rc" "a project positional should be refused alongside --relaunch" assert_contains "$out" "takes the task id only" "the refusal should name the positional rule" @@ -1314,6 +1365,7 @@ test_spawn_relaunch_refuses_a_pane_outside_the_worktree() { test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_preserves_durable_task_metadata +test_relaunch_reuses_the_quality_posture_and_base_commit test_relaunch_serializes_concurrent_durable_metadata_publication test_disabled_relaunch_clears_prior_trace_context test_relaunch_appends_the_progress_note_to_the_instructions diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 34df6fe748..2012d32971 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -9,9 +9,14 @@ # 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. # -# 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 -# cases that are meant to get past them, so no window or worktree is ever created. +# The delivery-check cases stop before any endpoint exists: those checks run ahead +# of backend creation, and a fake `tmux` that exits non-zero backstops the cases +# that are meant to get past them, so no window or worktree is ever created. The +# metadata and standing-posture cases are the opposite on purpose: make_spawning_home +# builds a real git worktree and a fake `tmux` that exits 0 and answers the +# pane-path query, so run_spawning carries a ship spawn all the way to its durable +# record. Those cases really do create things, including /tmp/fm- outside +# TMP_ROOT, so they own the teardown in delivery_cleanup below. set -u # shellcheck source=tests/lib.sh @@ -22,6 +27,37 @@ PROMOTE="$ROOT/bin/fm-promote.sh" PROJECT_MODE="$ROOT/bin/fm-project-mode.sh" TMP_ROOT=$(fm_test_tmproot fm-task-delivery) +# A spawn that gets all the way to metadata also creates /tmp/fm-, which is +# outside TMP_ROOT and therefore outside fm_test_tmproot's cleanup. Track and +# remove each one so this suite leaks nothing on a shared host. +# +# The tracking goes through a `$$`-keyed file, not an array, for the reason +# tests/lib.sh gives under "self-cleaning temp root": every spawn here is invoked +# as `out=$(run_spawning ...)`, which forks a subshell, so an array append made +# inside it dies with that subshell and never reaches this shell. `$$` stays the +# invoking shell's PID across that boundary, so the file does reach cleanup. +# +# This trap replaces the shared EXIT trap tests/lib.sh arms at source time, so it +# ends by calling fm_test_cleanup itself: TMP_ROOT and lib.sh's own registry still +# go, and they go on the failing path too, because fail() exits. +SPAWNED_TASK_REGISTRY=$(mktemp "${TMPDIR:-/tmp}/.fm-task-delivery-tmps.$$.XXXXXX") +track_spawned_task_tmp() { # + printf '%s\n' "$1" >> "$SPAWNED_TASK_REGISTRY" 2>/dev/null || true +} +delivery_cleanup() { + local d + if [ -f "$SPAWNED_TASK_REGISTRY" ]; then + while IFS= read -r d; do + [ -z "$d" ] || rm -rf "$d" + done < "$SPAWNED_TASK_REGISTRY" + rm -f "$SPAWNED_TASK_REGISTRY" + fi + fm_test_cleanup +} +trap delivery_cleanup EXIT +trap 'delivery_cleanup; exit 130' INT +trap 'delivery_cleanup; exit 143' TERM + # A home with one registered project, one project directory, and a fake tmux that # refuses, so a spawn that clears the delivery checks still creates nothing. # Echoes "||". @@ -40,12 +76,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:-} quality=${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 "$quality" ] || printf 'Quality contract: quality=%s\n' "$quality" } > "$home/data/$id/brief.md" } @@ -238,6 +275,57 @@ test_promote_requires_and_records_the_delivery_contract() { pass "fm-promote: promotion requires the delivery contract and records it exactly once" } +# A promoted task carries no quality posture at all, so the registry's standing +# posture is announced rather than silently lost. Advisory only: the promotion still +# happens, and a record with no project= to look up promotes quietly. +test_promote_notices_the_standing_quality_posture() { + local home meta out status + home="$TMP_ROOT/promote-quality/home" + mkdir -p "$home/state" "$home/data" + + run_promote() { # + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + "$PROMOTE" "$1" --mode direct-PR --yolo on 2>&1 + } + + # 1. A hardened project: the notice fires and the promotion still lands. + printf '%s\n' '- proj [no-mistakes +hardened] - fixture (added 2026-01-01)' > "$home/data/projects.md" + meta="$home/state/promote-q1.meta" + printf 'window=fm-promote-q1\nkind=scout\nworktree=/tmp/wt\nproject=%s/projects/proj\n' "$home" > "$meta" + out=$(run_promote promote-q1) + status=$? + expect_code 0 "$status" "the quality notice must not block a promotion"$'\n'"$out" + assert_contains "$out" "the standing posture for proj is hardened" \ + "no notice when a hardened project's scout was promoted" + assert_grep 'kind=ship' "$meta" "the announced promotion did not restore ship teardown protection" + assert_grep 'mode=direct-PR' "$meta" "the announced promotion did not record the decided delivery mode" + assert_grep 'yolo=on' "$meta" "the announced promotion did not record the decided approval posture" + assert_no_grep 'quality=' "$meta" "a promoted task recorded a quality posture it cannot anchor" + assert_no_grep 'base_sha=' "$meta" "a promoted task recorded a base commit it cannot capture" + + # 2. The same promotion against a project with no +hardened token stays quiet. + printf '%s\n' '- proj [no-mistakes] - fixture (added 2026-01-01)' > "$home/data/projects.md" + meta="$home/state/promote-q2.meta" + printf 'window=fm-promote-q2\nkind=scout\nworktree=/tmp/wt\nproject=%s/projects/proj\n' "$home" > "$meta" + out=$(run_promote promote-q2) + status=$? + expect_code 0 "$status" "a standard project's promotion should succeed"$'\n'"$out" + assert_not_contains "$out" "standing posture" \ + "a project with no registered quality posture printed a notice" + + # 3. A record with no project= line has nothing to look up and promotes silently. + printf '%s\n' '- proj [no-mistakes +hardened] - fixture (added 2026-01-01)' > "$home/data/projects.md" + meta="$home/state/promote-q3.meta" + printf 'window=fm-promote-q3\nkind=scout\nworktree=/tmp/wt\n' > "$meta" + out=$(run_promote promote-q3) + status=$? + expect_code 0 "$status" "a promotion with no project= should still succeed"$'\n'"$out" + assert_not_contains "$out" "standing posture" \ + "a record with no project= resolved a standing posture from somewhere" + assert_grep 'kind=ship' "$meta" "a promotion with no project= did not rewrite the record" + pass "fm-promote: a hardened standing posture is announced on promotion, never blocked" +} + # The registry parser survives for the mechanical consumers only. It accepts the # conditional policy, maps it to its most rigorous leg for them, and exposes the # raw annotation for the one caller that must tell a policy from a flat mode. @@ -272,11 +360,386 @@ EOF pass "fm-project-mode: the conditional policy is accepted, mapped for mechanical callers, and readable raw" } +# The registry's quality posture is the fourth thing a captain can put on a +# project line, and --quality is the only way to read it. Every row here is +# exercised through the real script against a real registry file. +test_project_mode_reads_the_registered_quality_posture() { + local home out label line expect n=0 + home="$TMP_ROOT/project-quality/home" + mkdir -p "$home/data" + while IFS='|' read -r label line expect; do + [ -n "$label" ] || continue + n=$((n + 1)) + printf '%s\n' "$line" > "$home/data/projects.md" + out=$(FM_HOME="$home" "$PROJECT_MODE" --quality qproj 2>/dev/null) + [ "$out" = "$expect" ] || fail "$label: --quality printed '$out', expected '$expect'" + done <<'ROWS' +no annotation at all|- qproj - fixture (added 2026-01-01)|standard +mode only|- qproj [direct-PR] - fixture (added 2026-01-01)|standard +mode and yolo only|- qproj [local-only +yolo] - fixture (added 2026-01-01)|standard +hardened after the mode|- qproj [no-mistakes +hardened] - fixture (added 2026-01-01)|hardened +hardened after mode and yolo|- qproj [direct-PR +yolo +hardened] - fixture (added 2026-01-01)|hardened +hardened between mode and yolo|- qproj [direct-PR +hardened +yolo] - fixture (added 2026-01-01)|hardened +hardened before the mode|- qproj [+hardened local-only +yolo] - fixture (added 2026-01-01)|hardened +hardened on a conditional policy is refused and drops to standard|- qproj [no-mistakes-prod-only +hardened] - fixture (added 2026-01-01)|standard +an unrecognized flag is ignored, not refused|- qproj [direct-PR +from-the-future] - fixture (added 2026-01-01)|standard +ROWS + # An absent project and an absent registry both resolve to the safe posture + # rather than inheriting the previous row's answer. + out=$(FM_HOME="$home" "$PROJECT_MODE" --quality never-registered 2>/dev/null) + [ "$out" = standard ] || fail "an unregistered project resolved quality '$out', expected standard" + out=$(FM_HOME="$TMP_ROOT/project-quality/no-such-home" "$PROJECT_MODE" --quality qproj 2>/dev/null) + [ "$out" = standard ] || fail "an absent registry resolved quality '$out', expected standard" + pass "fm-project-mode: --quality reads +hardened from any bracket position and defaults to standard" +} + +# The registry is the only way to turn the quality gate on, so this reader is where +# the registration rule is backed mechanically +# (.agents/skills/project-management/SKILL.md "Delivery posture"). +hardened rides a +# flat mode; alongside the conditional policy it is refused, because a policy that +# decides per task cannot carry one statable quality posture. The refusal follows the +# unknown-mode precedent: warn on stderr, resolve to the safe value, leave the +# two-word stdout its three callers parse alone, and exit 0. +test_project_mode_refuses_hardened_on_the_conditional_policy() { + local home out err status label line quality words n=0 + home="$TMP_ROOT/project-hardened-policy/home" + mkdir -p "$home/data" + while IFS='|' read -r label line quality words; do + [ -n "$label" ] || continue + n=$((n + 1)) + printf '%s\n' "$line" > "$home/data/projects.md" + out=$(FM_HOME="$home" "$PROJECT_MODE" --quality qproj 2>/dev/null) + status=$? + expect_code 0 "$status" "$label: --quality exited non-zero" + [ "$out" = "$quality" ] || fail "$label: --quality printed '$out', expected '$quality'" + out=$(FM_HOME="$home" "$PROJECT_MODE" qproj 2>/dev/null) + [ "$out" = "$words" ] || fail "$label: the two-word stdout printed '$out', expected '$words'" + err=$(FM_HOME="$home" "$PROJECT_MODE" --quality qproj 2>&1 >/dev/null) + case "$label" in + refused*) + assert_contains "$err" "+hardened is refused" "$label: the refused combination printed no warning" + assert_contains "$err" "flat delivery mode" "$label: the warning did not say how to fix the registry line" ;; + *) + assert_not_contains "$err" "+hardened is refused" "$label: a legitimate registry line was warned about" ;; + esac + done <<'ROWS' +hardened rides no-mistakes|- qproj [no-mistakes +hardened] - fixture (added 2026-01-01)|hardened|no-mistakes off +hardened rides direct-PR|- qproj [direct-PR +hardened] - fixture (added 2026-01-01)|hardened|direct-PR off +hardened rides local-only|- qproj [local-only +hardened] - fixture (added 2026-01-01)|hardened|local-only off +hardened rides a flat mode with yolo|- qproj [direct-PR +yolo +hardened] - fixture (added 2026-01-01)|hardened|direct-PR on +refused alongside the conditional policy|- qproj [no-mistakes-prod-only +hardened] - fixture (added 2026-01-01)|standard|no-mistakes off +refused alongside the conditional policy with yolo|- qproj [no-mistakes-prod-only +yolo +hardened] - fixture (added 2026-01-01)|standard|no-mistakes on +the conditional policy without hardened stays quiet|- qproj [no-mistakes-prod-only] - fixture (added 2026-01-01)|standard|no-mistakes off +ROWS + # --raw still reports the registered annotation: only the quality posture drops. + printf '%s\n' '- qproj [no-mistakes-prod-only +hardened] - fixture (added 2026-01-01)' > "$home/data/projects.md" + out=$(FM_HOME="$home" "$PROJECT_MODE" --raw qproj 2>/dev/null) + [ "$out" = "no-mistakes-prod-only off" ] \ + || fail "the refusal changed the raw annotation to '$out', expected 'no-mistakes-prod-only off'" + pass "fm-project-mode: +hardened rides a flat mode and is refused on the conditional policy" +} + +# The load-bearing registry case. Three callers parse this script's two words +# (bin/fm-fleet-sync.sh, bin/fm-home-seed.sh, bin/fm-spawn.sh), so adding the +# quality posture must leave that stdout exactly as it was: still two words, the +# same two words, for every annotation form including the new one. +test_project_mode_two_word_contract_survives_the_quality_posture() { + local home out label line expect n=0 + home="$TMP_ROOT/project-twoword/home" + mkdir -p "$home/data" + while IFS='|' read -r label line expect; do + [ -n "$label" ] || continue + n=$((n + 1)) + printf '%s\n' "$line" > "$home/data/projects.md" + out=$(FM_HOME="$home" "$PROJECT_MODE" qproj 2>/dev/null) + [ "$out" = "$expect" ] || fail "$label: printed '$out', expected '$expect'" + [ "$(printf '%s' "$out" | wc -w)" -eq 2 ] || fail "$label: stdout was not exactly two words ('$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" --raw qproj 2>/dev/null) + [ "$(printf '%s' "$out" | wc -w)" -eq 2 ] || fail "$label: --raw stdout was not exactly two words ('$out')" + done <<'ROWS' +no annotation at all|- qproj - fixture (added 2026-01-01)|no-mistakes off +mode only|- qproj [direct-PR] - fixture (added 2026-01-01)|direct-PR off +mode and yolo|- qproj [local-only +yolo] - fixture (added 2026-01-01)|local-only on +yolo only|- qproj [+yolo] - fixture (added 2026-01-01)|no-mistakes on +conditional policy|- qproj [no-mistakes-prod-only] - fixture (added 2026-01-01)|no-mistakes off +conditional policy with yolo|- qproj [no-mistakes-prod-only +yolo] - fixture (added 2026-01-01)|no-mistakes on +unrecognized flag ignored|- qproj [direct-PR +from-the-future] - fixture (added 2026-01-01)|direct-PR off +hardened does not disturb the mode|- qproj [direct-PR +hardened] - fixture (added 2026-01-01)|direct-PR off +hardened does not disturb mode or yolo|- qproj [local-only +yolo +hardened] - fixture (added 2026-01-01)|local-only on +hardened first still resolves the mode behind it|- qproj [+hardened local-only +yolo] - fixture (added 2026-01-01)|local-only on +ROWS + # A typo'd mode keeps warning and keeps falling back, rather than being + # silently rescued by the new flag scan. + printf '%s\n' "- qproj [no-mistakez +hardened] - fixture (added 2026-01-01)" > "$home/data/projects.md" + out=$(FM_HOME="$home" "$PROJECT_MODE" qproj 2>/dev/null) + [ "$out" = "no-mistakes off" ] || fail "a typo'd mode alongside +hardened resolved '$out'" + out=$(FM_HOME="$home" "$PROJECT_MODE" qproj 2>&1 >/dev/null) + assert_contains "$out" "unknown mode" "a typo'd mode alongside +hardened stopped warning" + # The quality posture resolves on its own, so the mode fallback does not take the + # +hardened down with it: a typo in the mode must not silently drop the gate. + out=$(FM_HOME="$home" "$PROJECT_MODE" --quality qproj 2>/dev/null) + [ "$out" = hardened ] \ + || fail "a typo'd mode dropped the registered quality posture to '$out', expected hardened" + pass "fm-project-mode: the two-word stdout its three callers parse is unchanged by the quality posture" +} + +# A scout has no quality loop to run and a charter is not a delivery contract, so +# --quality is refused there rather than accepted and quietly ignored. A ship +# spawn accepts it but validates the value, because a typo must never ship a +# hardened task down the standard path. +test_scout_and_secondmate_refuse_the_quality_flag() { + local rec home proj fakebin out status + rec=$(make_home quality-refused) + IFS='|' read -r home proj fakebin <|||". +make_spawning_home() { # + local name=$1 dir home proj wt fakebin + dir="$TMP_ROOT/$name" + home="$dir/home" + proj="$dir/proj" + wt="$dir/wt" + fakebin=$(fm_fakebin "$dir/fake") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf 'claude\n' > "$home/config/crew-harness" + printf '%s\n' "$$" > "$home/state/.lock" + touch "$home/state/.last-watcher-beat" + fm_git_worktree "$proj" "$wt" "wt-$name" + printf '%s\n' "$home|$proj|$wt|$fakebin" +} + +run_spawning() { # + local home=$1 wt=$2 fakebin=$3 + shift 3 + track_spawned_task_tmp "/tmp/fm-$1" + # `env -u` keeps the recorded key set hermetic against an ambient + # FM_TRACE_CONTEXT, which would otherwise add a traceparent= line. + env -u FM_TRACE_CONTEXT \ + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_BACKEND=tmux FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" 2>&1 +} + +meta_value() { # + sed -n "s/^$2=//p" "$1" | tail -n 1 +} + +# The quality posture and the base commit have to reach the task's durable record, +# because that record is what the quality loop reads back later. base_sha must be +# the commit the worktree actually starts from - the whole loop measures diffs +# against it - so this asserts it against a real `git rev-parse HEAD`, not a shape. +# +# The load-bearing half is the standard task: a spawn with no --quality must write +# the record it always wrote, with nothing removed, nothing changed, and only the +# two new additive lines present. +test_spawn_records_the_quality_posture_and_base_commit() { + local rec home proj wt fakebin meta out status base prespawn keys + rec=$(make_spawning_home quality-meta) + IFS='|' read -r home proj wt fakebin < "$home/data/projects.md" + write_brief "$home" quality-standing-i1 no-mistakes + out=$(run_spawning "$home" "$wt" "$fakebin" quality-standing-i1 "$proj" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "the quality notice must not block the spawn"$'\n'"$out" + assert_contains "$out" "ships quality=standard while the standing posture for proj is hardened" \ + "no notice when a hardened project shipped a standard task" + meta="$home/state/quality-standing-i1.meta" + assert_present "$meta" "the announced spawn wrote no task record" + [ "$(meta_value "$meta" quality)" = standard ] \ + || fail "the notice changed the recorded posture to '$(meta_value "$meta" quality)'" + + # 2. The same spawn against a project carrying no +hardened token stays quiet. + printf '%s\n' '- proj [no-mistakes] - fixture (added 2026-01-01)' > "$home/data/projects.md" + write_brief "$home" quality-standing-i2 no-mistakes + out=$(run_spawning "$home" "$wt" "$fakebin" quality-standing-i2 "$proj" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "a standard project spawn should succeed"$'\n'"$out" + assert_not_contains "$out" "ships quality=" \ + "a project with no registered quality posture printed a quality notice" + pass "fm-spawn: a standard task under a hardened standing posture is announced, never blocked" +} + 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_scout_records_no_delivery_posture test_promote_requires_and_records_the_delivery_contract +test_promote_notices_the_standing_quality_posture test_project_mode_maps_the_conditional_policy +test_project_mode_reads_the_registered_quality_posture +test_project_mode_refuses_hardened_on_the_conditional_policy +test_project_mode_two_word_contract_survives_the_quality_posture +test_scout_and_secondmate_refuse_the_quality_flag +test_spawn_refuses_a_brief_quality_mismatch +test_spawn_records_the_quality_posture_and_base_commit +test_spawn_notices_a_quality_downgrade_against_the_registry echo "# all fm-task-delivery tests passed"