diff --git a/AGENTS.md b/AGENTS.md index 8b5403096da..89ed596281c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -338,7 +338,7 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou ### Validate -For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. +For a no-mistakes ship, treat the generated brief's non-terminal `needs-validation:` handoff as the trigger to validate on the same worker, using the harness invocation owned by `harness-adapters`. The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 116cfd23e9a..20b53710e36 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -79,6 +79,7 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} +NEEDS_VALIDATION_VERB=${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT} resolve_directory_input() { local name=$1 path=$2 resolved @@ -374,6 +375,7 @@ fi # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own # explicit --mode before launching. +STATUS_STATES="working, needs-decision, blocked, $PAUSED_VERB, done, failed" case "$MODE" in direct-PR) SETUP2="" @@ -401,14 +403,15 @@ The configured merge authority approves the ready branch, then firstmate merges EOF ;; *) # no-mistakes + STATUS_STATES="working, $NEEDS_VALIDATION_VERB, needs-decision, blocked, $PAUSED_VERB, done, failed" SETUP2=" 2. Run \`no-mistakes doctor\`; if it reports the repo is not initialized here, run \`no-mistakes init\`." RULE1='1. Never push to the default branch. Never merge a PR.' IFS= read -r -d '' DOD <> $STATUS_FILE\` - States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. + States: $STATUS_STATES. Each append wakes firstmate, so report sparingly: only phase changes a supervisor would act on (setup done, bug reproduced, fix implemented, validation passed) and the needs-decision/blocked/paused/done/failed states. No step-by-step FYI progress lines; firstmate reads your pane for that. A mid-task \`working:\` line (including setup complete) is nonterminal: do not end the - turn after it; continue the same stage until a defined \`done:\` gate under Definition of done. + turn after it; continue the same stage until a stop/report gate defined under Definition of done. Use \`$PAUSED_VERB: {why}\` - distinct from \`blocked:\` - ONLY when you are deliberately idling on a 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 diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 9915ece7d29..7d5ba6d6bd2 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -50,18 +50,24 @@ case $- in *u*) _fm_classify_nounset=on ;; *) _fm_classify_nounset=off ;; esac [ "$_fm_classify_nounset" = on ] || set +u unset _fm_classify_nounset +# The no-mistakes implementation-handoff verb. It is captain-relevant because +# firstmate must trigger validation, but deliberately non-terminal: only the +# later done: PR checks green event reports completed validation. +FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT='needs-validation' + # Captain-relevant status verbs. A status line carrying any of these is work # firstmate must see. Lines without these verbs are no-verb signals: the watcher # absorbs them only with positive provably-working evidence, while the daemon uses -# its away-mode classification. FM_CAPTAIN_RE overrides the whole set when a home -# needs a custom verb vocabulary; absent, this default applies. +# its away-mode classification. FM_CAPTAIN_RE replaces the general set when a home +# needs a custom verb vocabulary, but cannot suppress the required implementation +# handoff; absent, this default applies. # # Free-text tokens (PR ready, checks green, ready in branch, merged) exist only for # legacy lines that lack a standard terminal verb. status_is_captain_relevant is # verb-aware: a nonterminal working: or paused: line never becomes captain-relevant # merely because its prose contains one of those tokens (for example # "working: rebased onto merged #76"). -FM_CLASSIFY_CAPTAIN_RE_DEFAULT='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' +FM_CLASSIFY_CAPTAIN_RE_DEFAULT="done:|${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT}:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged" # The deliberate-external-wait verb. A crew (or firstmate steering it) appends # paused: @@ -100,8 +106,9 @@ last_status_line() { } # 0 if the given (last) status line's leading verb is a real terminal captain verb -# (done, needs-decision, blocked, failed). Free-text tokens alone never count here; -# callers that need legacy free-text matching use status_is_captain_relevant. +# (done, needs-decision, blocked, failed). The captain-relevant needs-validation +# handoff is deliberately absent. Free-text tokens alone never count here; callers +# that need legacy free-text matching use status_is_captain_relevant. status_is_terminal_verb() { local line=$1 verb [ -n "$line" ] || return 1 @@ -126,6 +133,9 @@ status_is_captain_relevant() { working|resolved|captain-held|"${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT}") return 1 ;; + "${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT}") + return 0 + ;; esac if [ -z "${FM_CAPTAIN_RE+x}" ]; then case "$verb" in @@ -135,6 +145,16 @@ status_is_captain_relevant() { printf '%s' "$line" | grep -qiE "${FM_CAPTAIN_RE:-$FM_CLASSIFY_CAPTAIN_RE_DEFAULT}" } +# 0 if a status line is the non-terminal no-mistakes implementation handoff. +# The marker is separate from done: so a supervisor and deterministic state +# reader can tell "trigger validation" from "validation finished" by verb alone. +status_is_validation_handoff() { # + local line=$1 verb + [ -n "$line" ] || return 1 + verb=$(status_line_verb "$line") + [ "$verb" = "${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT}" ] +} + # 0 if a status line's leading verb is the pause verb (paused: ). A pure # read of the line itself, so the daemon's classify_stale can reuse the last line # it already read without a fm-crew-state.sh call. Matches only the verb before the @@ -1109,10 +1129,11 @@ EOF # It is never authoritative current crew state, and consumers must not let an open # phase outrank a structured home snapshot or fm-crew-state result. _fm_status_open_activities_stream() { - local line verb key note resolve held open='' stripped pause + local line verb key note resolve held open='' stripped pause needs_validation resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} pause=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + needs_validation=${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT} while IFS= read -r line || [ -n "$line" ]; do stripped=${line//[[:space:]]/} [ -n "$stripped" ] || continue @@ -1125,7 +1146,7 @@ _fm_status_open_activities_stream() { [ -n "$open" ] && open="${open}"$'\n' open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' ;; - done|failed|needs-decision|blocked|"$resolve"|"$held") + done|"$needs_validation"|failed|needs-decision|blocked|"$resolve"|"$held") open=$(_fm_decision_drop "$open" "$key") [ -n "$open" ] && open="${open}"$'\n' ;; diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index df627b487f2..be86e666069 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -2,7 +2,7 @@ # fm-crew-state.sh - deterministic read of a crew's CURRENT state. # # Why this exists: state/.status is an append-only, best-effort EVENT LOG. -# Crews append only wake-worthy transitions (done/needs-decision/blocked/paused/failed) +# Crews append only wake-worthy transitions (needs-validation/done/needs-decision/blocked/paused/failed) # and nothing when they silently resume, so `tail -1` of that log reports the # last EVENT, not the current STATE. After firstmate resolves a needs-decision # or blocked and the crew resumes (responds to the gate, the pipeline fixes, it @@ -135,6 +135,10 @@ map_log_state() { # echo paused return fi + if status_is_validation_handoff "$1"; then + echo parked + return + fi case "$(status_line_verb "$1")" in working) echo working ;; needs-decision) echo parked ;; @@ -370,7 +374,7 @@ nm_ci_checks_state() { # status` answer was not this crew's own branch, attribution always failed and # the caller fell straight through to the pane/log fallback below. (The # PRIMARY cause of the 2026-07 herdr false-surface incidents turned out to be -# a separate bug in bin/fm-watch.sh's stale_is_terminal precedence - see that +# a separate bug in bin/fm-watch.sh's stale-actionable precedence - see that # file's history - but this cross-branch path was independently confirmed # dead code and is worth having actually work.) # @@ -562,10 +566,13 @@ if [ "$HAVE_RUN" = 1 ]; then fi fi - # Reconcile the status log. A needs-decision/blocked log line that the run-step - # has moved past (anything but a genuinely parked run) is deterministically - # stale: the gate resolved and the run resumed or finished. + # Reconcile the status log. A needs-validation handoff is superseded by any + # attributed validation run. A needs-decision/blocked line that the run-step has + # moved past (anything but a genuinely parked run) is deterministically stale. case "$LOG_VERB" in + "${FM_CLASSIFY_NEEDS_VALIDATION_VERB:-$FM_CLASSIFY_NEEDS_VALIDATION_VERB_DEFAULT}") + RUN_DETAIL="$RUN_DETAIL${SEP}status-log superseded by validation run" + ;; needs-decision|blocked) if [ "$RUN_STATE" != parked ]; then if [ "$RUN_STATE" = working ]; then diff --git a/docs/architecture.md b/docs/architecture.md index cd0318c097b..4bb59aafad0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,7 @@ During no-mistakes' `ci` monitor phase, it also reads the ci step log tail becau The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later re-arm, failed-check, or issue marker returns the crew to working. Only when no matching run exists does it consult semantic busy state; exact busy reports working, exact idle permits fallback to a status-log event whose verb maps to a recognized run-state, and unknown or a dead pane stays unknown instead of trusting a stale log. Decision-only events such as `resolved` never become current state or leak their prose into the current-state detail. -In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. +In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason, while the generated brief's non-terminal `needs-validation:` implementation handoff reports `parked` until an attributed validation run supersedes it. The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. diff --git a/docs/configuration.md b/docs/configuration.md index a861b406781..82cf18c43ef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -700,7 +700,8 @@ FM_WATCH_CYCLE_LOG_MAX_BYTES=262144 # size cap for the arm-owned watcher lifec FM_WATCH_CYCLE_LOG_KEEP_LINES=1000 # newest complete lifecycle rows considered when the ledger is capped FM_WATCHER_STALE_GRACE=300 # defaults to FM_GUARD_GRACE; seconds a live watcher lock may have a stale beacon before re-arm errors FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals into one wake -FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; nonterminal progress verbs remain excluded even when their prose matches +FM_CLASSIFY_NEEDS_VALIDATION_VERB=needs-validation # leading status verb for the non-terminal no-mistakes implementation handoff; firstmate triggers validation on the same worker +FM_CAPTAIN_RE='done:|needs-validation:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; routine nonterminal progress verbs remain excluded even when their prose matches FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless they declare the pause verb FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/.turn-ended marker, or its state/.meta spawn record before any turn completes, before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart; a declared external wait or verified captain-held transfer takes the FM_PAUSE_RESURFACE_SECS recheck below instead diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index a4342d758f0..c9041674623 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -322,7 +322,7 @@ test_faster_paths_use_configured_authority_without_stacked_review() { # Pin the specific line the bug lived on: the no-mistakes DOD's no-mistakes # reference must render as plain prose with no dangling apostrophe artifact. test_no_mistakes_dod_wording() { - local home id brief + local home id brief handoff_line validation_line final_line handoff_text legacy_handoff_text final_text home="$TMP_ROOT/wording-home" mkdir -p "$home/data" id="brief-wording-b1" @@ -345,13 +345,32 @@ test_no_mistakes_dod_wording() { "no-mistakes DOD must keep direct requirements and exclude generic scaffold boilerplate from --intent" assert_grep "exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific" "$brief" \ "no-mistakes DOD must exclude non-task-specific scaffold boilerplate from --intent" + assert_grep 'States: working, needs-validation, needs-decision, blocked, paused, done, failed.' "$brief" \ + "no-mistakes brief did not advertise its distinct implementation handoff state" + # shellcheck disable=SC2016 # Literal backticks and braces are generated brief output. + handoff_text='append `needs-validation: {summary}` to the status file and stop.' + # shellcheck disable=SC2016 # Literal backticks and braces are generated brief output. + legacy_handoff_text='append `done: {summary}` to the status file and stop.' + # shellcheck disable=SC2016 # Literal backticks and braces are generated brief output. + final_text='append `done: PR {url} checks green` and stop. You are finished.' + assert_grep "$handoff_text" "$brief" \ + "no-mistakes DOD did not render the non-terminal implementation handoff" + assert_no_grep "$legacy_handoff_text" "$brief" \ + "no-mistakes DOD still reused done: for the pre-validation implementation handoff" + assert_grep "$final_text" "$brief" \ + "no-mistakes DOD changed the terminal checks-green completion event" + handoff_line=$(grep -nF "$handoff_text" "$brief" | cut -d: -f1) + validation_line=$(grep -nF 'You drive no-mistakes by responding to its gates' "$brief" | cut -d: -f1) + final_line=$(grep -nF "$final_text" "$brief" | cut -d: -f1) + [ "$handoff_line" -lt "$validation_line" ] && [ "$validation_line" -lt "$final_line" ] \ + || fail "no-mistakes DOD no longer orders implementation handoff, triggered validation, and terminal PR completion" # The apostrophe in "firstmate's authority check" is now structurally safe # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of # being reworded or escaped away. test_no_heredoc_in_command_substitution # guards the structure that makes it safe. assert_grep "firstmate's authority check" "$brief" \ "no-mistakes DOD lost the apostrophe prose that the structural fix makes parse-safe" - pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" + pass "fm-brief.sh: no-mistakes DOD keeps two-phase handoff/validation semantics and parse-safe prose" } test_ship_project_memory_wording() { @@ -669,7 +688,7 @@ test_herdr_lab_contract_applies_to_scouts_but_not_secondmates() { } test_pause_verb_override_renders_all_brief_scaffolds() { - local home kind id brief + local home kind id brief expected_states home="$TMP_ROOT/pause-verb-home" mkdir -p "$home/data" @@ -679,18 +698,21 @@ test_pause_verb_override_renders_all_brief_scaffolds() { ship) FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ "$ROOT/bin/fm-brief.sh" "$id" firstmate --mode no-mistakes >/dev/null 2>&1 + expected_states='States: working, needs-validation, needs-decision, blocked, awaiting, done, failed.' ;; scout) FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ "$ROOT/bin/fm-brief.sh" "$id" firstmate --scout >/dev/null 2>&1 + expected_states='States: working, needs-decision, blocked, awaiting, done, failed.' ;; secondmate) FM_HOME="$home" FM_CLASSIFY_PAUSED_VERB=awaiting \ "$ROOT/bin/fm-brief.sh" "$id" --secondmate --no-projects >/dev/null 2>&1 + expected_states='States: working, needs-decision, blocked, awaiting, done, failed.' ;; esac brief="$home/data/$id/brief.md" - assert_grep "States: working, needs-decision, blocked, awaiting, done, failed." "$brief" \ + assert_grep "$expected_states" "$brief" \ "$kind brief did not render the configured pause verb in its states list" # shellcheck disable=SC2016 # Literal backticks and braces must remain unexpanded. assert_grep 'Use `awaiting: {why}`' "$brief" \ diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 602b3e5cfc3..07ee7617385 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -389,6 +389,23 @@ test_stale_blocked_superseded() { pass "stale blocked over active run is superseded" } +# A validation run is the authoritative evidence that firstmate acted on the +# non-terminal implementation handoff, so the older status line is superseded. +test_validation_handoff_superseded_by_active_run() { + reset_fakes + local d; d=$(new_case validation-handoff-superseded) + make_repo_on_branch "$d/wt" fm/feat-bv + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-bv.meta" "window=fm:fm-feat-bv" "worktree=$d/wt" "kind=ship" + printf 'needs-validation: implementation committed\n' > "$d/state/feat-bv.status" + FM_FAKE_AXI_STATUS="$(run_running fm/feat-bv)" + local out; out=$(run_crew_state "$d" feat-bv) + assert_contains "$out" "state: working" "triggered validation -> working despite handoff log" + assert_contains "$out" "source: run-step" "triggered validation -> run-step source" + assert_contains "$out" "superseded by validation run" "implementation handoff not reconciled against active validation" + pass "an active validation run supersedes the non-terminal implementation handoff" +} + # (c) genuine parked run + needs-decision log AGREE -> parked, NOT superseded test_genuine_parked_not_superseded() { reset_fakes @@ -768,7 +785,7 @@ test_other_branch_run_ignored() { make_repo_on_branch "$d/wt" fm/feat-g make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-g.meta" "window=fm:fm-feat-g" "worktree=$d/wt" "kind=ship" "harness=claude" - printf 'done: implemented, ready to validate\n' > "$d/state/feat-g.status" + printf 'needs-validation: implemented, ready to validate\n' > "$d/state/feat-g.status" FM_FAKE_AXI_STATUS="$(run_running fm/some-other)" FM_FAKE_RUNS_LIST="$(cat <<'EOF' running fm/some-other aaaaaaa 2026-07-02 22:10 @@ -779,8 +796,9 @@ EOF local out; out=$(run_crew_state "$d" feat-g) assert_not_contains "$out" "source: run-step" "another branch's run not misattributed" assert_contains "$out" "source: status-log" "no own run -> falls back to status-log" - assert_contains "$out" "state: done" "falls back to the log verb" - pass "another branch's run is ignored, falls back" + assert_contains "$out" "state: parked" "implementation handoff remains parked pending this branch's validation" + assert_not_contains "$out" "state: done" "implementation handoff must not read as terminal" + pass "another branch's run is ignored and the implementation handoff remains parked" } # (f) no run for this crew + a busy pane -> working via pane @@ -1411,6 +1429,7 @@ test_missing_run_head_falls_back_to_current_state() { test_active_run_is_authoritative test_stale_needs_decision_superseded test_stale_blocked_superseded +test_validation_handoff_superseded_by_active_run test_genuine_parked_not_superseded test_scalar_gate_parked_not_superseded test_gate_block_parked_not_superseded diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 962f143464d..93d8cb47ca1 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -106,7 +106,7 @@ test_classify_terminal_signal_escalates() { local dir state kw out dir=$(make_supercase classify-terminal) state="$dir/state" - for kw in "done: PR https://x/y/pull/1" "needs-decision: pick A" "blocked: no perms" \ + for kw in "done: PR https://x/y/pull/1" "needs-validation: implementation committed" "needs-decision: pick A" "blocked: no perms" \ "failed: rc 2" "PR ready https://x/y/pull/2" "checks green" \ "ready in branch fm/t1" "merged"; do printf 'working\n%s\n' "$kw" > "$state/t.status" diff --git a/tests/fm-wake-drain-unread-status.test.sh b/tests/fm-wake-drain-unread-status.test.sh index ca0e2ba5ec0..7c5f16e13be 100755 --- a/tests/fm-wake-drain-unread-status.test.sh +++ b/tests/fm-wake-drain-unread-status.test.sh @@ -288,6 +288,53 @@ test_empty_queue_does_not_swallow_later_signal_annotation() { pass "an empty-queue drain preserves routine status for a later signal annotation" } +test_dod_states_are_distinct_in_generated_brief_and_drain_annotations() { + local dir state home brief out legacy_handoff legacy_terminal new_handoff new_terminal + dir=$(make_case dod-state-evidence) + state="$dir/state" + home="$dir/brief-home" + brief="$home/data/dod-state-evidence/brief.md" + out="$dir/drain.out" + mkdir -p "$home/data" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" dod-state-evidence evidence-proj --mode no-mistakes >/dev/null 2>&1 \ + || fail "no-mistakes brief generation failed for state evidence" + + legacy_handoff='done: implementation committed' + legacy_terminal='done: PR https://example.test/pr/7 checks green' + new_handoff='needs-validation: implementation committed' + new_terminal='done: PR https://example.test/pr/7 checks green' + printf '%s\n' "$legacy_handoff" > "$state/legacy-handoff.status" + printf '%s\n' "$legacy_terminal" > "$state/legacy-terminal.status" + printf '%s\n' "$new_handoff" > "$state/new-handoff.status" + printf '%s\n' "$new_terminal" > "$state/new-terminal.status" + append_wake "$state" signal legacy-handoff.status "signal: legacy-handoff.status" \ + || fail "legacy handoff signal queueing failed" + append_wake "$state" signal legacy-terminal.status "signal: legacy-terminal.status" \ + || fail "legacy terminal signal queueing failed" + append_wake "$state" signal new-handoff.status "signal: new-handoff.status" \ + || fail "new handoff signal queueing failed" + append_wake "$state" signal new-terminal.status "signal: new-terminal.status" \ + || fail "new terminal signal queueing failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \ + || fail "drain failed for before/after state evidence" + + assert_grep "append \`needs-validation: {summary}\` to the status file and stop." "$brief" \ + "generated brief did not emit the non-terminal handoff" + assert_grep "append \`done: PR {url} checks green\` and stop. You are finished." "$brief" \ + "generated brief did not emit terminal completion" + assert_grep 'legacy-handoff.status: done: implementation committed' "$out" \ + "drain did not expose the legacy handoff form" + assert_grep 'legacy-terminal.status: done: PR https://example.test/pr/7 checks green' "$out" \ + "drain did not expose the legacy terminal form" + assert_grep 'new-handoff.status: needs-validation: implementation committed' "$out" \ + "drain did not expose the mechanically distinct handoff form" + assert_grep 'new-terminal.status: done: PR https://example.test/pr/7 checks green' "$out" \ + "drain did not expose the terminal completion form" + printf 'before | implementation handoff: %s | terminal completion: %s\n' "$legacy_handoff" "$legacy_terminal" + printf 'after | implementation handoff: %s | terminal completion: %s\n' "$new_handoff" "$new_terminal" + pass "generated brief and drain annotations expose distinct before/after done states" +} + test_routine_working_lines_stay_silent_on_the_empty_queue() { local dir state out dir=$(make_case silent-working) @@ -318,4 +365,5 @@ test_snapshot_does_not_ack_a_later_append test_retired_task_id_starts_new_status_unread test_open_decisions_fold_is_unchanged test_empty_queue_does_not_swallow_later_signal_annotation +test_dod_states_are_distinct_in_generated_brief_and_drain_annotations test_routine_working_lines_stay_silent_on_the_empty_queue diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 1bee5cab9bb..c19978214fb 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -183,6 +183,8 @@ test_signal_reason_is_actionable_classifier() { signal_reason_is_actionable "$state/d.status" || fail "a failed: line was not actionable" printf 'merged\n' > "$state/e.status" signal_reason_is_actionable "$state/e.status" || fail "a legacy merged line was not actionable" + printf 'needs-validation: implementation committed\n' > "$state/f.status" + signal_reason_is_actionable "$state/f.status" || fail "a needs-validation: handoff was not actionable" pass "signal_reason_is_actionable: benign absorbed, captain verbs and coalesced batches surfaced" } @@ -190,14 +192,18 @@ test_stale_is_terminal_classifier() { local dir state dir=$(make_case classify-stale); state="$dir/state" printf 'done: ready in branch fm/x\n' > "$state/term.status" - stale_is_terminal "sess:fm-term" "$state" || fail "terminal stale status not classified terminal" + stale_is_terminal "sess:fm-term" "$state" || fail "terminal stale status not classified actionable" fm_write_meta "$state/herdr-term.meta" "window=default:w1:p2" "backend=herdr" printf 'done: ready in branch fm/herdr\n' > "$state/herdr-term.status" stale_is_terminal "default:w1:p2" "$state" || fail "terminal herdr stale status not resolved through metadata" + printf 'needs-validation: implementation committed\n' > "$state/handoff.status" + stale_is_terminal "sess:fm-handoff" "$state" || fail "non-terminal validation handoff not classified actionable" + status_is_terminal_verb "$(last_status_line "$state/handoff.status")" \ + && fail "actionable validation handoff was classified terminal" printf 'working: compiling\n' > "$state/nonterm.status" - stale_is_terminal "sess:fm-nonterm" "$state" && fail "non-terminal stale classified terminal" - stale_is_terminal "sess:fm-missing" "$state" && fail "stale with no status classified terminal" - pass "stale_is_terminal: terminal status surfaces, non-terminal and no-status are benign" + stale_is_terminal "sess:fm-nonterm" "$state" && fail "routine non-terminal stale classified actionable" + stale_is_terminal "sess:fm-missing" "$state" && fail "stale with no status classified actionable" + pass "stale_is_terminal treats needs-validation as actionable but non-terminal" } test_scan_captain_relevant_statuses_classifier() { @@ -206,20 +212,28 @@ test_scan_captain_relevant_statuses_classifier() { printf 'working: a\n' > "$state/one.status" printf 'blocked: no perms\n' > "$state/two.status" printf 'done: PR https://x/y/pull/1\n' > "$state/three.status" + printf 'needs-validation: implementation committed\n' > "$state/four.status" out=$(scan_captain_relevant_statuses "$state") printf '%s' "$out" | grep -F "two.status" >/dev/null || fail "scan missed a blocked: status" printf '%s' "$out" | grep -F "three.status" >/dev/null || fail "scan missed a done: status" printf '%s' "$out" | grep -F "one.status" >/dev/null && fail "scan surfaced a benign working: status" + printf '%s' "$out" | grep -F "four.status" >/dev/null || fail "scan missed a needs-validation: handoff" pass "scan_captain_relevant_statuses lists only captain-relevant statuses" } test_classifier_primitives() { - local dir state open activity + local dir state open activity legacy_captain_re dir=$(make_case classify-primitives); state="$dir/state" printf 'working: a\n\ndone: b\n\n' > "$state/x.status" [ "$(last_status_line "$state/x.status")" = "done: b" ] || fail "last_status_line did not return the last non-blank line" status_is_captain_relevant "done: b" || fail "done: not recognized as captain-relevant" status_is_captain_relevant "needs-decision [key=q1]: b" || fail "keyed needs-decision not recognized as captain-relevant" + status_is_captain_relevant "needs-validation: implementation committed" \ + || fail "needs-validation: handoff not recognized as captain-relevant" + status_is_validation_handoff "needs-validation: implementation committed" \ + || fail "needs-validation: handoff verb not recognized" + status_is_terminal_verb "needs-validation: implementation committed" \ + && fail "needs-validation: implementation handoff was classed as terminal" status_is_captain_relevant "working: b" && fail "working: wrongly recognized as captain-relevant" # Incident regression: free-text "merged" inside a nonterminal working: line must # not become captain-relevant (AFK false-terminal path). @@ -234,6 +248,8 @@ test_classifier_primitives() { || fail "genuine done: checks green not captain-relevant" status_is_terminal_verb "done: PR https://x/pull/76 checks green" \ || fail "done: not a terminal verb" + status_is_validation_handoff "done: PR https://x/pull/76 checks green" \ + && fail "terminal done: PR event was classed as an implementation handoff" status_is_terminal_verb "working: rebased onto merged #76" \ && fail "working: wrongly classed as terminal verb" status_is_captain_relevant "merged" || fail "legacy bare merged free-text not captain-relevant" @@ -244,6 +260,12 @@ test_classifier_primitives() { [ "$(window_to_task "default:w1:p2" "$state")" = "herdr-task" ] || fail "window_to_task did not resolve opaque backend target through metadata" FM_CAPTAIN_RE='custom-verb:' status_is_captain_relevant "custom-verb: x" || fail "FM_CAPTAIN_RE override not honored" FM_CAPTAIN_RE='custom-verb:' status_is_captain_relevant "done: x" && fail "FM_CAPTAIN_RE override did not replace the default verb set" + legacy_captain_re='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' + FM_CAPTAIN_RE="$legacy_captain_re" status_is_captain_relevant "needs-validation: implementation committed" \ + || fail "a legacy FM_CAPTAIN_RE override suppressed the required validation handoff" + FM_CLASSIFY_NEEDS_VALIDATION_VERB=validate-next FM_CAPTAIN_RE="$legacy_captain_re" \ + status_is_captain_relevant "validate-next: implementation committed" \ + || fail "the overridden validation handoff verb was suppressed by FM_CAPTAIN_RE" FM_CAPTAIN_RE='merged|custom-verb:' status_is_captain_relevant "working: rebased onto merged #76" \ && fail "FM_CAPTAIN_RE override bypassed working: suppression" FM_CAPTAIN_RE='checks green|custom-verb:' status_is_captain_relevant "paused: checks green pending approval" \ @@ -267,6 +289,8 @@ resolved [key=phase7]: Phase 7 completed and moved to Done paused [key=legal]: awaiting external counsel resolved [key=legal]: legal item returned to the queue working [key=phase8]: Phase 8 started +working [key=phase9]: Phase 9 implementation +needs-validation [key=phase9]: Phase 9 committed EOF activity=$(status_open_activities "$state/activity.status") printf '%s' "$activity" | grep -F $'phase8\tworking\tPhase 8 started' >/dev/null \ @@ -277,6 +301,8 @@ EOF && fail "a same-key terminal event did not supersede the older working phase" printf '%s' "$activity" | grep -F $'legal\t' >/dev/null \ && fail "a keyed resolved event did not close the declared pause" + printf '%s' "$activity" | grep -F $'phase9\t' >/dev/null \ + && fail "a keyed needs-validation handoff did not close the implementation activity" printf 'working: legacy start\ndone: legacy completion\n' > "$state/legacy-activity.status" [ -z "$(status_open_activities "$state/legacy-activity.status")" ] \ || fail "a legacy terminal event did not supersede the default working phase" @@ -769,12 +795,12 @@ test_terminal_stale_surfaced() { pass "a stale pane sitting on a terminal status is surfaced (queue + exit)" } -# --- stale pane, STALE terminal status overridden by an active run: absorbed --- +# --- stale pane, STALE actionable status overridden by an active run: absorbed --- # Regression for the 2026-07 herdr false-surface incidents: a crew's own status # log gets no new entry once firstmate hands it to a no-mistakes validation # (AGENTS.md's sparse status-reporting contract), so the log keeps showing its -# pre-validation "done:" line as the LAST line for the run's entire (possibly -# many-minutes) duration. stale_is_terminal alone has no run-step awareness and +# pre-validation needs-validation: line as the LAST line for the run's entire +# (possibly many-minutes) duration. stale_is_terminal alone has no run-step awareness and # would treat that leftover as still-current every time the pane goes quiet, # immediately surfacing a crew that is actively validating. crew_is_provably_working # must get a chance to override a captain-relevant-but-stale status line, exactly @@ -786,10 +812,10 @@ test_stale_terminal_status_overridden_by_active_run() { window="test:fm-validating" printf 'no-mistakes axi run: validating...' > "$capture_file" printf 'window=%s\nkind=ship\n' "$window" > "$state/validating.meta" - # The crew reported done BEFORE firstmate triggered no-mistakes validation; + # The crew requested validation BEFORE firstmate triggered no-mistakes; # this line never gets superseded by a newer status-log entry while the # pipeline itself runs. - printf 'done: implementation complete, ready to validate\n' > "$state/validating.status" + printf 'needs-validation: implementation complete and committed\n' > "$state/validating.status" sig=$(seen_sig "$state/validating.status"); printf '%s' "$sig" > "$state/.seen-validating_status" key=$(printf '%s' "$window" | tr ':/.' '___') pane_hash=$(hash_text "no-mistakes axi run: validating...") @@ -798,16 +824,16 @@ test_stale_terminal_status_overridden_by_active_run() { export FM_FAKE_CREW_STATE='state: working · source: run-step · validating (running)' # Phase A: a high escalation threshold means the first sighting is absorbed, - # not surfaced, despite the captain-relevant "done:" status-log line. + # not surfaced, despite the captain-relevant needs-validation: status-log line. PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! if ! wait_poll_cycle "$state" "$pid"; then - reap "$pid"; fail "watcher exited for a stale terminal-looking status the run-step overrides (should absorb): $(cat "$out")" + reap "$pid"; fail "watcher exited for a stale actionable status the run-step overrides (should absorb): $(cat "$out")" fi - [ ! -s "$out" ] || fail "the overridden stale terminal status printed a wake reason during absorb" - [ ! -s "$state/.wake-queue" ] || fail "the overridden stale terminal status enqueued a wake during absorb" + [ ! -s "$out" ] || fail "the overridden stale actionable status printed a wake reason during absorb" + [ ! -s "$state/.wake-queue" ] || fail "the overridden stale actionable status enqueued a wake during absorb" [ "$(cat "$state/.stale-$key" 2>/dev/null || true)" = "$pane_hash" ] || fail "stale suppressor not advanced on absorb" [ -s "$state/.stale-since-$key" ] || fail "stale-since escalation timer was not recorded on absorb" [ ! -e "$state/.hb-surfaced-validating" ] || fail "an absorbed wake must not mark the status line as surfaced" @@ -822,11 +848,11 @@ test_stale_terminal_status_overridden_by_active_run() { FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! - wait_for_exit "$pid" 100 || fail "watcher did not escalate an overridden stale terminal status past the threshold" + wait_for_exit "$pid" 100 || fail "watcher did not escalate an overridden stale actionable status past the threshold" grep -F "stale: $window" "$out" >/dev/null || fail "escalation did not print a stale wake" grep -F "possible wedge" "$out" >/dev/null || fail "escalation did not flag a possible wedge" unset FM_FAKE_CREW_STATE - pass "a stale terminal-looking status is overridden and absorbed while a run is actively working, then wedge-escalated" + pass "a stale actionable status is overridden while validation runs, then wedge-escalated" } # --- non-terminal stale, crew provably working: absorbed, then wedge-escalated --- @@ -2102,7 +2128,7 @@ test_terminal_first_sight_drops_a_finished_write_deferral_chain() { mkdir -p "$wt/src" printf 'no-mistakes axi run: validating...' > "$capture_file" printf 'window=%s\nkind=ship\nworktree=%s\n' "$window" "$wt" > "$state/chain-first.meta" - printf 'done: implementation complete, ready to validate\n' > "$state/chain-first.status" + printf 'needs-validation: implementation complete and committed\n' > "$state/chain-first.status" sig=$(seen_sig "$state/chain-first.status"); printf '%s' "$sig" > "$state/.seen-chain-first_status" key=$(printf '%s' "$window" | tr ':/.' '___') pane_hash=$(hash_text "no-mistakes axi run: validating...") @@ -2122,7 +2148,7 @@ test_terminal_first_sight_drops_a_finished_write_deferral_chain() { FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & pid=$! if ! wait_poll_cycle "$state" "$pid"; then - reap "$pid"; fail "the overridden terminal status was not absorbed on first sight: $(cat "$out")" + reap "$pid"; fail "the overridden actionable status was not absorbed on first sight: $(cat "$out")" fi [ "$(cat "$state/.stale-$key" 2>/dev/null || true)" = "$pane_hash" ] \ || { reap "$pid"; fail "the first-sight absorb did not advance the stale suppressor"; }