diff --git a/.gitignore b/.gitignore index 3eece43c35f..61b2f2ac3f1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ scratchpad* .no-mistakes/ +.omc/ .lavish/ .fm-secondmate-home .fm-secondmate-parent diff --git a/AGENTS.md b/AGENTS.md index 9744ba02473..47a4e5b0b9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Hard rules, in priority order: You may maintain this repo's private operational state directly. Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and public `skills/`. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. -This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. +This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`, and `.omc/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. Never add an agent name as a commit co-author. @@ -158,6 +158,7 @@ state/ runtime records and signals; gitignored .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch .no-mistakes/ local validation state and evidence; gitignored +.omc/ local session and operator-console state; gitignored ``` A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 296159ce04e..cdd2c726eb3 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -107,6 +107,9 @@ # # --contribution-input prints only the canonical backlog/tasks ownership pair, # without worker observations or cross-home collection, for the home-local poll. +# --backlog-json prints only the canonical backlog projection, without task +# metadata or merge-authority resolution, for home-local consumers that read +# backlog rows alone. # Compatibility: JSON is the primary machine-readable surface. # Human views must render this output instead of parsing state files again. set -u @@ -232,6 +235,9 @@ Print a structured snapshot of the firstmate fleet. JSON is the stable machine-readable output contract. The default snapshot refreshes only its parent-side remote-summary cache as an observational side effect. +--backlog-json emits the canonical local backlog projection only, without task +metadata or cross-home collection. + --contribution-input emits the canonical local backlog/tasks ownership pair only, without worker observations or cross-home collection. @@ -283,6 +289,7 @@ OUTPUT_MODE=json case "${1:---json}" in --json) ;; --secondmate-home-summary) OUTPUT_MODE=secondmate-home-summary ;; + --backlog-json) OUTPUT_MODE=backlog_json ;; --contribution-input) OUTPUT_MODE=contribution-input ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; @@ -1972,6 +1979,11 @@ contribution_tasks_json() { done | jq -s . } +if [ "$OUTPUT_MODE" = backlog_json ]; then + printf '%s\n' "$BACKLOG_JSON" + exit 0 +fi + if [ "$OUTPUT_MODE" = contribution-input ]; then # Reuse the canonical backlog parser, without observing workers or other homes. contribution_tasks=$(contribution_tasks_json) || { echo "fm-fleet-snapshot: contribution task read failed" >&2; exit 1; } diff --git a/bin/fm-hold-reverify.sh b/bin/fm-hold-reverify.sh index b6f14d522ad..a0894b7dc25 100755 --- a/bin/fm-hold-reverify.sh +++ b/bin/fm-hold-reverify.sh @@ -3,7 +3,6 @@ # # Usage: # fm-hold-reverify.sh [check] run one bounded re-verification sweep -# fm-hold-reverify.sh classify print the verdict for one hold's facts # fm-hold-reverify.sh arm write and register the standing check # fm-hold-reverify.sh disarm remove the standing check # fm-hold-reverify.sh --help print this help @@ -16,7 +15,8 @@ # remaining work was wrong. The rot concentrates in age. # # This script re-checks each aged captain hold against shipped reality and reports -# it in the SAME reconciliation vocabulary captain-hold-lifecycle already owns: +# it with one of four verdicts, each a proposal for the reconciliation seam +# captain-hold-lifecycle owns rather than a closure: # dead / still_live / not_a_decision / unestablishable. It reports only. It never # calls `answer` and never calls `reconcile close`/`reconcile note`, so it can # never close a captain call; only the captain's own words or an explicit @@ -37,8 +37,10 @@ # # THE REPORT IS THE DELIVERABLE # A sweep writes state/hold-reverify/docket.json (schema fm-hold-reverify-docket.v1) -# listing every examined hold with its verdict, structured evidence, and a short -# reason, and prints ONE line (the wake) only when the finding set changes. +# listing every examined hold with its verdict and the structured fields that +# decided it - the row's state and recorded hold reason, its recorded pull request +# and that request's state, and whether the row records a merged completion - and +# prints ONE line (the wake) only when the finding set changes. # state/.hold-reverify stores the last sweep's epoch and a digest of the # {id:verdict} set, mirroring state/.tool-updates, so a new or changed finding # wakes once while an unchanged sweep stays silent. A sweep killed by the @@ -62,9 +64,9 @@ # # WHAT IT READS # Aged holds come from the canonical local backlog projection rather than a second -# parser: `fm-fleet-snapshot.sh --contribution-input` reuses the canonical backlog -# parser WITHOUT observing workers or other homes, so the sweep stays local and -# bounded. A hold's recorded pull request is read through bin/fm-pr-lib.sh, which +# parser: `fm-fleet-snapshot.sh --backlog-json` reuses the canonical backlog +# parser WITHOUT task metadata, worker observations, or other homes, so the +# sweep stays local and bounded. A hold's recorded pull request is read through bin/fm-pr-lib.sh, which # is the same gh-then-gh-axi path every other surface uses. A redundant local # origin/main fetch is deliberately NOT performed: the forge merge state and the # row's own recorded completion are the authoritative landing signals, and a clone @@ -113,7 +115,6 @@ usage() { cat <<'EOF' Usage: fm-hold-reverify.sh [check] run one bounded re-verification sweep - fm-hold-reverify.sh classify print the verdict for one hold's facts fm-hold-reverify.sh arm write and register state/hold-reverify.check.sh fm-hold-reverify.sh disarm remove the standing check, its trust binding, and the record fm-hold-reverify.sh --help print this help @@ -183,10 +184,17 @@ if [ "$BUDGET_SECS" -gt "$BUDGET_MAX" ]; then BUDGET_CUT_FROM=$BUDGET_SECS BUDGET_SECS=$BUDGET_MAX fi -# The local projection is a fast bounded child of the same sweep budget, so it -# can never consume more than the sweep has left. -SNAPSHOT_BOUND=5 -[ "$SNAPSHOT_BOUND" -le "$BUDGET_SECS" ] || SNAPSHOT_BOUND=$BUDGET_SECS +# The backlog-only projection is a bounded child of the same sweep budget. At +# large fleet sizes the contribution-input pair can spend most of its time on +# per-task merge-authority resolution the sweep never reads; backlog-json avoids +# that work (sub-second on the home that timed out at five seconds before). +# FM_HOLD_REVERIFY_BUDGET_SECS governs the projection bound; reserve probe time +# inside the sweep budget the same way BUDGET_MAX reserves it for FM_CHECK_TIMEOUT. +SNAPSHOT_BOUND=$BUDGET_SECS +if [ "$SNAPSHOT_BOUND" -gt "$((BUDGET_SECS - PROBE_MIN_SECS))" ]; then + SNAPSHOT_BOUND=$((BUDGET_SECS - PROBE_MIN_SECS)) +fi +[ "$SNAPSHOT_BOUND" -ge 1 ] || SNAPSHOT_BOUND=1 # --- small helpers ---------------------------------------------------------- @@ -291,13 +299,15 @@ gather_facts() { reason=$(printf '%s\n' "$hold" | jq -r '.hold_reason // ""') pr_url=$(printf '%s\n' "$hold" | jq -r '.pr_url // ""') merged=$(printf '%s\n' "$hold" | jq -r 'if .completion_merged == true then "true" else "false" end') - if [ -n "$pr_url" ]; then + if [ "$state" = "done" ] || [ -z "$reason" ]; then + pr_state=none + elif [ -n "$pr_url" ]; then if fm_pr_url_parse "$pr_url" && [ "$FM_PR_PROVIDER" = github ] \ && [ -n "$FM_PR_OWNER" ] && [ -n "$FM_PR_REPO" ] && [ -n "$FM_PR_NUMBER" ]; then owner=$FM_PR_OWNER repo=$FM_PR_REPO number=$FM_PR_NUMBER - if out=$(read_record_bounded "$owner" "$repo" "$number" "$PROBE_SECS"); then + if out=$(read_record_bounded "$owner" "$repo" "$number" "$(probe_bound)"); then record_state=${out%% *} case "$record_state" in MERGED) pr_state=merged ;; @@ -333,6 +343,32 @@ SNAPSHOT_ERROR= budget_exhausted() { [ "$(real_epoch)" -ge "$DEADLINE" ]; } +# probe_bound: clamp each forge read to the sweep budget remaining, so no probe +# can run past the end of the sweep (bin/fm-tool-update-check.sh probe_bound). +probe_bound() { + local left + left=$((DEADLINE - $(real_epoch))) + if [ "$left" -lt "$PROBE_MIN_SECS" ]; then + printf '%s\n' "$PROBE_MIN_SECS" + elif [ "$left" -lt "$PROBE_SECS" ]; then + printf '%s\n' "$left" + else + printf '%s\n' "$PROBE_SECS" + fi +} + +snapshot_bound() { + local left bound=$SNAPSHOT_BOUND + if [ "$DEADLINE" -gt 0 ]; then + left=$((DEADLINE - $(real_epoch))) + if [ "$left" -lt "$bound" ]; then + bound=$left + fi + fi + [ "$bound" -ge 1 ] || bound=1 + printf '%s\n' "$bound" +} + sweep_cleanup() { [ -z "$FINDINGS_FILE" ] || rm -f -- "$FINDINGS_FILE" FINDINGS_FILE= @@ -343,15 +379,17 @@ snapshot_holds() { local snapshot snapshot=$(FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \ FM_CONFIG_OVERRIDE="$CONFIG" \ - fm_run_timed "$SNAPSHOT_BOUND" "$SNAPSHOT_BIN" --contribution-input 2>/dev/null) || return 1 + fm_run_timed "$(snapshot_bound)" "$SNAPSHOT_BIN" --backlog-json 2>/dev/null) || return 1 [ -n "$snapshot" ] || return 1 printf '%s\n' "$snapshot" | jq -c --argjson age "$AGE_DAYS" ' - (.backlog.records // [])[] + [(.records // [])[] | select(.structured == true) | select(.hold_kind == "captain") | select(.hold_age_days != null and .hold_age_days >= $age) | {id, title, state, hold_reason, hold_age_days, pr_url, - completion_merged: (.completion.verb == "merged")}' || return 1 + completion_merged: (.completion.verb == "merged")}] + | sort_by(-.hold_age_days) + | .[]' || return 1 } action_check() { @@ -600,11 +638,6 @@ case "${1:-check}" in [ "$#" -le 1 ] || die_usage "check takes no arguments" action_check ;; - classify) - [ "$#" -eq 2 ] || die_usage "classify requires one facts JSON file" - [ -f "$2" ] && [ ! -L "$2" ] || die_usage "classify facts file is unavailable: $2" - classify_facts "$(cat "$2")" - ;; arm) [ "$#" -eq 1 ] || die_usage "arm takes no arguments" action_arm diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 44e8da93bcc..1c1f406cc30 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -385,7 +385,8 @@ family_for_basename() { printf '%s\n' afk ;; fm-bearings-board-render.test.sh|fm-bearings-snapshot.test.sh|fm-contributions.test.sh|\ - fm-fleet-snapshot-view.test.sh|fm-home-summary-refresh.test.sh) + fm-fleet-snapshot-view.test.sh|fm-home-summary-refresh.test.sh|\ + fm-hold-reverify.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) @@ -1563,7 +1564,7 @@ families_for_changed_path() { printf '%s\n' live-harness-optin ;; bin/fm-bearings-snapshot.sh|bin/fm-fleet-snapshot.sh|bin/fm-fleet-view.sh|bin/fm-contributions.sh|bin/fm-contributions.jq|\ - bin/fm-home-summary-refresh.sh) + bin/fm-home-summary-refresh.sh|bin/fm-hold-reverify.sh) printf '%s\n' snapshot-bearings ;; bin/fm-install-herdr.sh|bin/fm-install-treehouse.sh|bin/fm-herdr-ci-cleanup.sh) diff --git a/docs/configuration.md b/docs/configuration.md index e36e794851f..116b39ae29b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -626,18 +626,18 @@ A budget that is not a whole number from 1 to 120 is still refused outright. ## Captain-hold re-verification A captain call is an ordinary backlog task held for the captain, and its list rots with age: hundreds of holds had never been re-checked, so the captain's list was mostly ghosts and every count of remaining work was wrong. -`bin/fm-hold-reverify.sh` re-checks each aged hold against shipped reality and reports it in the reconciliation vocabulary `captain-hold-lifecycle` already owns: `dead`, `still_live`, `not_a_decision`, or `unestablishable`. +`bin/fm-hold-reverify.sh` re-checks each aged hold against shipped reality and reports it with one of four verdicts: `dead`, `still_live`, `not_a_decision`, or `unestablishable`. It reports only. -It never calls `answer` and never closes or annotates a call, so only the captain's own words or an explicit evidence-backed reconciliation can resolve one. +It never calls `answer` and never closes or annotates a call, so only the captain's own words or an explicit evidence-backed reconciliation - the seam the `captain-hold-lifecycle` skill owns - can resolve one. A hold is `dead` when shipped reality resolves the subject - its recorded pull request is merged, or its row records a merged completion. It is `still_live` when the recorded pull request is open, `not_a_decision` when the row carries no live captain question (already Done, or no hold reason), and `unestablishable` otherwise. `dead` is never inferred from absence or from an unreadable source, and a closed-unmerged pull request stays `unestablishable` rather than reading as dead. -Aged holds come from the canonical local backlog projection (`fm-fleet-snapshot.sh --contribution-input`), and a recorded pull request is read through `bin/fm-pr-lib.sh`; no second backlog parser and no redundant `origin/main` clone fetch are involved. +Aged holds come from the canonical local backlog projection (`fm-fleet-snapshot.sh --backlog-json`, which omits task metadata and merge-authority resolution), and a recorded pull request is read through `bin/fm-pr-lib.sh`; no second backlog parser and no redundant `origin/main` clone fetch are involved. `check` is a plain custom watcher check, so it stays in the check-fires-then-firstmate-decides flow that the process-event `when` adapter explicitly excludes for an action whose right form depends on what the condition finds. Arm it once per home with `bin/fm-hold-reverify.sh arm`, which writes `state/hold-reverify.check.sh` and binds its bytes with `bin/fm-check-register.sh` so the watcher dispatches it on its normal cadence and turns its one line into a `check:` wake. `disarm` removes the shim, its trust binding, and the report record. -Each sweep writes `state/hold-reverify/docket.json` (schema `fm-hold-reverify-docket.v1`) with every examined hold's verdict, evidence, and reason, and prints one line only when the finding set changes. +Each sweep writes `state/hold-reverify/docket.json` (schema `fm-hold-reverify-docket.v1`) with every examined hold's verdict and the structured evidence it was decided from, and prints one line only when the finding set changes. `state/.hold-reverify` records the sweep epoch and a digest of the `{id: verdict}` set, so a new or changed finding is reported once while an unchanged sweep stays silent. A sweep the watcher kills writes no record and is retried. diff --git a/docs/scripts.md b/docs/scripts.md index ef45d68dfa2..23c062b642f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -32,6 +32,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-backlog-receive.sh` | Idempotently ingest one confined remote handoff outbox through tasks-axi | | `fm-captain-hold.sh` | Hold tasks for the captain, record the captain's answers, gate investigation completion, and report record divergence between the status log and the backlog | | `fm-decision-hold.sh` | One-release compatibility shim mapping the retired decision commands onto fm-captain-hold.sh | +| `fm-hold-reverify.sh` | Standing re-verification of aged captain holds: `arm` registers a watcher check that re-checks them against shipped reality and reports each as dead, still_live, not_a_decision, or unestablishable without ever closing a call, `disarm` removes it | | `fm-brief.sh` | Scaffold ship (explicit `--mode`), scout, secondmate-charter, and Herdr-lab briefs, with Captain's intent and Firstmate spec subsections on ship/scout | | [`fm-dod-lib.sh`](../bin/fm-dod-lib.sh) | Own ship/scout worker role scope, ship definitions of done, and the no-mistakes `--intent` contract | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | diff --git a/tests/fm-hold-reverify.test.sh b/tests/fm-hold-reverify.test.sh index 42928de4125..1febe0ae01e 100755 --- a/tests/fm-hold-reverify.test.sh +++ b/tests/fm-hold-reverify.test.sh @@ -340,6 +340,90 @@ test_sweep_defers_beyond_the_hold_cap() { pass "fm-hold-reverify: the hold cap bounds the sweep and discloses what it deferred" } +test_cap_defers_the_newest_aged_hold_not_the_oldest() { + local home out + home=$(make_home cap-order) + write_backlog "$home" </dev/null \ + || fail "the cap must examine the oldest holds and defer the newest" + pass "fm-hold-reverify: the hold cap defers the newest aged holds, not the oldest" +} + +test_recorded_merged_completion_reports_dead() { + local home out + home=$(make_home merged-completion) + write_backlog "$home" < "$wrapper" < "$facts" - assert_equals dead "$("$CHECK" classify "$facts")" "merged facts classify dead" - - printf '%s\n' '{"state":"queued","hold_reason":"q","pr_state":"open","completion_merged":false}' > "$facts" - assert_equals still_live "$("$CHECK" classify "$facts")" "open facts classify still_live" - - printf '%s\n' '{"state":"queued","hold_reason":"","pr_state":"none","completion_merged":false}' > "$facts" - assert_equals not_a_decision "$("$CHECK" classify "$facts")" "a questionless record is not a decision" - - printf '%s\n' '{"state":"done","hold_reason":"q","pr_state":"none","completion_merged":false}' > "$facts" - assert_equals not_a_decision "$("$CHECK" classify "$facts")" "a closed record is not a live decision" - - printf '%s\n' '{"state":"queued","hold_reason":"q","pr_state":"closed","completion_merged":true}' > "$facts" - assert_equals dead "$("$CHECK" classify "$facts")" "a recorded merged completion is dead" - - printf '%s\n' '{"state":"queued","hold_reason":"q","pr_state":"none","completion_merged":false}' > "$facts" - assert_equals unestablishable "$("$CHECK" classify "$facts")" "facts with no evidence are unestablishable" - pass "fm-hold-reverify: classify reports the right verdict for each fact shape" -} - # --- arming ------------------------------------------------------------------ test_arm_writes_and_registers_and_disarm_removes() { @@ -431,7 +488,9 @@ test_young_hold_is_not_examined test_repeat_is_silent_and_change_wakes test_cadence_gate_suppresses_until_the_interval_elapses test_sweep_defers_beyond_the_hold_cap +test_cap_defers_the_newest_aged_hold_not_the_oldest +test_recorded_merged_completion_reports_dead +test_aged_holds_report_when_task_metadata_would_exceed_the_projection_bound test_unreadable_projection_reports_once -test_classify_prints_the_verdict_for_facts test_arm_writes_and_registers_and_disarm_removes test_help_and_usage