feat(watch): shadow graded wedge score alongside the fixed timer - #6
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
What landed - the watcher records wedge settlements ( Residual risk - the Verified - |
…/fm-pi-watch-extension.test.sh, assertion "Pi must deliver the actionable wake after bounded hung-successor recovery" (confirmed by pulling the full job log for check run 101334942108; the provided excerpt started after that test). It is a timing flake in the test, not a defect in this PR's code: the PR's only watcher-family change to bin/fm-watch-arm.sh is a comment, and the test substitutes its own fm-watch-arm.sh fixture. Root cause: the test set FM_PI_ARM_READY_TIMEOUT_MS=250. That budget bounds a successor that never reports ready, so it also bounds how long the spawned `bash -lc ... exec fm-watch-arm.sh` child has to append its `arm=<pid>` row. Under CI load a cold login shell exceeds it, the extension SIGTERMs the attempt before the script runs, the row is lost, and the exact-count assertion ("expected one successor plus two retries, got N") fails. Reproduced locally on the sibling OpenCode case by lowering the budget to 30ms, yielding exactly the CI-shaped failure. Fix (tests/fm-pi-watch-extension.test.sh only): raised the readiness budget 250ms -> 2000ms in both hung-successor cases (Pi and OpenCode — identical fixture and defect), raised the prompt-wait poll bound 500 -> 2000 iterations (5s -> 20s) so it still covers the longer 3 x 2s recovery, and added a comment at both sites explaining the budget must stay far above child start-up. No production code changed, so the fixed 240s escalation and shadow wedge plumbing are untouched. Verification: Pi hung-successor 3/3 green, OpenCode hung-successor 3/3 green, all Pi tests and all remaining OpenCode tests in the file green except two that fail in this sandbox for environmental reasons unrelated to the change (OpenCode session-lock and turn-end-guard cases; both pass in CI on this branch's earlier green run). `bash -n` clean; ShellCheck is not installed here so bin/fm-lint.sh was skipped. Also deleted a stray untracked scratch file tests/ztmp-hung.sh left in the worktree
| wedge_shadow_resumed "$win" "$(window_to_task "$win" "$STATE")" \ | ||
| "$STATE/.stale-since-$key" || true |
There was a problem hiding this comment.
🟡 Pause cleanup records false resumptions
When an over-age busy pane leaves pause tracking, clear_pause_tracking records a zero-second resumption despite continued inactivity. wedge_timer_check creates the timer immediately before this cleanup. The false healthy sample distorts every later lane score.
Learn more
Pause tracking can coexist with a busy pane whose completed-turn age exceeds BUSY_TURN_MAX_SECS. In that case, the busy-pane branch calls wedge_timer_check, which creates a missing timer, then immediately calls clear_pause_tracking. The new call records that freshly created timer as a resume even though no turn completed and the pane remains busy. The recorded idle time is usually zero seconds. The scorer clamps it to one second before taking its logarithm, making it an extreme low healthy observation for the lane.
Example: A paused pane becomes busy but completes no turn for over an hour. Its first qualifying poll creates .stale-since-*; pause cleanup immediately appends {"idle_secs":0,"outcome":"resumed"} although the pane is still inactive.
Recommended fix: Remove settlement recording from clear_pause_tracking. Record resumed settlements only in branches that observe a genuine pane/hash recovery while an existing wedge timer remains active.
Was this helpful? React with 👍 or 👎 to provide feedback.
| window = row.get("window", "") | ||
| if row.get("outcome") == "escalated": | ||
| if window not in unresolved: | ||
| unresolved.add(window) | ||
| count += 1 | ||
| elif row.get("outcome") == "resumed": | ||
| unresolved.discard(window) |
There was a problem hiding this comment.
🟡 Later wedge incidents disappear
After an escalation resolves before monitoring restarts, _escalation_incidents suppresses every later wedge on that window. The escalation path deletes its timer before a resume can be recorded. Incident rates and graded flags remain understated.
Learn more
The scorer considers an incident unresolved until it reads a resumed row for the same window. However, the escalation path deletes the only timer used to generate that row and exits monitoring. If the pane recovers before the next monitoring cycle, wedge_shadow_resumed sees no timer and records nothing. The window therefore remains forever in _escalation_incidents' in-memory unresolved set whenever history is replayed. Every genuinely separate later escalation for that window is collapsed into the first one.
Example: Window session:fm-build escalates Monday, recovers before monitoring restarts, and wedges again Tuesday. The log contains two escalated rows but no intervening resumed row, so the scorer reports one incident instead of two.
Recommended fix: Persist incident resolution independently of the deleted timer, or record a recovery marker whenever a previously escalated window is observed active. Ensure repeated polls for one continuous wedge still collapse while later wedge episodes count separately.
Was this helpful? React with 👍 or 👎 to provide feedback.
| test_settle_records_project_lane | ||
| test_score_requires_minimum_observations | ||
| test_score_flags_graded_and_fixed_thresholds | ||
| test_fixed_flag_follows_the_supplied_threshold | ||
| test_repeated_escalations_count_as_one_incident | ||
| test_settle_missing_meta_uses_unknown_lane | ||
| test_settlement_log_stays_bounded |
There was a problem hiding this comment.
| .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch | ||
| .hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch | ||
| .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete | ||
| .watch-triage.log watcher's observational debug log (size-capped); never relied on, safe to delete |
Intent
Add plumbing-only wedge settlement recording and shadow graded wedge scoring to FirstMate, lift ARGYLE gates.py verbatim with provenance, preserve the fixed 240-second escalation behavior, and validate and deliver the existing branch through the no-mistakes pipeline.
What Changed
bin/fm-watch.shgainswedge_shadow_settle/wedge_shadow_score/wedge_shadow_resumedhelpers that record one settlement row per wedge escalation and per pane resume, and log a graded score line to the triage log. All three are gated onFM_WEDGE_SHADOW(default on) and onpython3being present, and they never feed back into escalation — the fixedFM_STALE_ESCALATE_SECStimer remains the sole decision path.bin/fm-wedge-score.pyowns thestate/.wedge-settlements.jsonlschema, the task→lane rule (read from<task>.metaproject=, elseunknown), and the log's 256 KiB size bound. Itsscoresubcommand fits a lognormal over resumed idle times per lane, counts repeated escalations of an unresolved window as a single incident, and reportsgraded_flagbesidefixed_flag, returninginsufficient databelow--min-obs.bin/argyle_gates.pyis lifted verbatim from nakatomi with a provenance header and supplies the pure-stdlibnorm_cdf/norm_ppfused by the score.tests/fm-wedge-score.test.sh(7 cases: lane recording, minimum-observation refusal, graded/fixed flags, caller-supplied threshold, repeat-escalation incident collapsing, missing-meta lane, log bounding) and registers it inbin/fm-test-run.sh. Docs recordFM_WEDGE_SHADOWindocs/configuration.mdand restate.watch-triage.logas the watcher's general bounded observational log rather than an absorbed-wake-only log.Risk Assessment
Testing
I drove the real bin/fm-watch.sh in a hermetic sandbox and captured the surfaces an operator actually sees: a resumed pane appends a settlement row with its true idle, a pane wedged past the threshold prints the same
stale: ... possible wedgewake, queues the same drain entry, appends anescalatedrow, and logs oneshadow wedge score: {...}line into state/.watch-triage.log. The decisive check is scenario 3 — on a lane where long healthy idles are normal the graded comparator returns graded_flag=false while fixed_flag=true, and the watcher escalates anyway, showing the score is observational only; with FM_WEDGE_SHADOW=0 nothing is recorded and the wake is identical, and with FM_STALE_ESCALATE_SECS unset the shipped 240s bound still absorbs at ~200s and escalates at ~260s. Alongside that, the new fm-wedge-score suite passes, is picked up by fm-test-run.sh, and proved non-vacuous: four seeded regressions in the scorer each made it fail (source restored, worktree clean). The 40-test fm-watch-triage regression suite is green, and the lifted argyle_gates primitives match reference normal values to ~1e-9 — though the upstream nakatomi repo is absent on this host, so verbatim-ness rests on the in-file provenance header rather than a diff. No screenshots: every surface here is a CLI wake line and a state-directory log file, so the transcripts and JSONL rows are the end-user artifact.Evidence: End-to-end watcher transcript: settlements, shadow score, and preserved 240s escalation (5 scenarios)
Evidence: E2E driver script used to produce the transcript (evidence dir, not committed)
Evidence: Mutation check proving the new subtests fail on a scorer regression
Evidence: Lifted ARGYLE gates: provenance header and numeric validation of the two live functions
Evidence: Shadow-only proof: graded comparator disagrees, fixed timer escalates regardless
Evidence: Escalation surface: wake, triage-log score line, settlement row, queued wake
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-wedge-score.py:25- fm-wedge-score.py resolves state asos.path.join(home, "state")(line 25) and data asos.path.join(home, "data")(lines 39, 63), ignoring FM_STATE_OVERRIDE/FM_DATA_OVERRIDE, which every other bin/ script honors via${FM_STATE_OVERRIDE:-$FM_HOME/state}/${FM_DATA_OVERRIDE:-$FM_HOME/data}. Concrete reachable path: tests/fm-watch-triage.test.sh:508 starts the real fm-watch.sh with FM_STATE_OVERRIDE="$state" and FM_HOME unset, and forces an escalation (stale-since backdated 500s). fm-watch.sh:63 then resolves FM_HOME to the repo root (the live firstmate home), so wedge_shadow_settle appends a synthetic escalated row for a fixture window into the LIVE $FM_HOME/data/wedge-settlements.jsonl and looks the lane up in the live $FM_HOME/state, outside the test's temp sandbox. Simultaneously, wedge_shadow_score (fm-watch.sh:286) reads the overridden $STATE for the lane, so settle records lane=unknown while score queries the real lane and the shadow score degrades to permanent "insufficient data" under any override. Remedy: honor FM_STATE_OVERRIDE/FM_DATA_OVERRIDE in _lane_for_task/_settle/_read_rows (or pass the resolved dirs from the shell).bin/fm-watch.sh:291- wedge_shadow_score invokes the scorer without --fixed-threshold, so fm-wedge-score.py:142 falls back to a hardcoded 240 while the shell's real escalation bound is STALE_ESCALATE_SECS=${FM_STALE_ESCALATE_SECS:-240} (fm-watch.sh:140). The score is only ever called from the escalation branch, i.e. exactly when the fixed timer just fired, so fixed_flag must be true there by definition. With FM_STALE_ESCALATE_SECS=120 (a documented knob, docs/configuration.md:572), escalation happens at age=120, fm-wedge-score.py:89 computes 120 >= 240 -> false, and the triage log records"fixed_flag":falsefor the very poll that escalated the pane - a wrong label emitted without any error. Remedy: pass --fixed-threshold "$STALE_ESCALATE_SECS" from fm-watch.sh:291 (or drop fixed_flag, which is constant-true at the only call site).tests/fm-wedge-score.test.sh:48- test_score_requires_minimum_observations (line 48) and test_score_flags_graded_and_fixed_thresholds (line 74) run their assertions insidepython3 - "$out" <<'PY'heredocs whose exit status is discarded: the test file sets onlyset -u, tests/lib.sh does notset -e, and bin/fm-test-run.sh:1532 judges a script solely by its exit code. So if the scoring logic regresses - e.g. graded_flag comes back True for idle 50, or score returns a number instead of None below min-obs - Python raises AssertionError to stderr and the very next statement still callspass, the script exits 0, and the suite reports ok. Both subtests are currently incapable of failing. The sibling tests at lines 22-32 and 88-99 use the correct pattern (capture output, then[ "$out" = ok ] || fail). Remedy: append|| fail "..."to those two heredoc invocations, or switch them to the capture-and-compare pattern used by the other two.bin/fm-wedge-score.py:81- Thehealthysample (lines 81-85) is right-censored by the same timer it is meant to shadow-grade, and the escalation rows are per-poll rather than per-incident. wedge_timer_check deletes the stale-since file at escalation (fm-watch.sh:336), so a "resumed" row can only ever record an idle up to STALE_ESCALATE_SECS + POLL (~255s at the defaults), while _score is only ever invoked from the escalation branch with idle >= 240. mu/sigma are therefore fit to a distribution truncated exactly at the boundary being evaluated, and the graded score can never observe the healthy long tail (a pane legitimately idle for 10 minutes) - the precise failure mode of the fixed 240s timer that a shadow experiment exists to detect. Separately, a single wedge that persists an hour re-escalates every 240s across watcher restarts and contributes ~15 "escalated" rows, so base_rate = (n_esc+1)/(n_total+2) (line 98) measures escalation polls, not distinct incidents, and drifts upward with one bad pane, lowering the graded bar via norm_ppf(1-base_rate). Numerically both directions are reachable: 8 resumes spread 30..255s with 3 escalations scores +0.54 (graded_flag true) at idle=240, while 8 resumes clustered at 240..255 score -1.36 (graded_flag false) for the same input. The remedy - recording per-incident settlements and/or a censor-aware fit - adds new state and schema beyond this change's plumbing-only intent, so it needs your authorization rather than an in-place patch; the alternative is to accept and document that the shadow number is a biased comparator.bin/fm-watch.sh:288- Simplification: wedge_shadow_score re-derives the lane in shell (grep '^project=' "$meta" | cut -d= -f2-, line 288), a second definition of the rule that fm-wedge-score.py:_lane_for_task already owns, even though the header of that script declares it the single owner of the settlement schema. Nothing in the intent requires two implementations, and they already disagree in two ways: the shell reads $STATE (override-aware) while Python reads $home/state (not), andgrepemits every matching line while Python returns the first, so a meta file with twoproject=lines yields a multi-line --lane argument that matches no stored row and forces permanent "insufficient data". Remedy: delete the shell-side derivation and let the scorer resolve the lane from --task, so the rule exists once.bin/fm-wedge-score.py:131- Simplification: the required--keyoption (line 131) and thekeyfield it writes (line 43) are a pure function of--window- wedge_shadow_settle computes it asprintf '%s' "$win" | tr ':/.' '___'(fm-watch.sh:276), a third copy of the key derivation already inlined twice in fm-watch.sh. No stated requirement needs a settlement row to carry both the window and its own slug, and the scorer never reads thekeyfield. Remedy: drop--keyand thekeyfield and derive the slug fromwindowat read time if it is ever needed.bin/fm-wedge-score.py:126- Simplification: the top-level--home(line 126) is a second definition of the same option the subparsers already declare (lines 129, 139), and it is silently discarded rather than merged. CPython's _SubParsersAction copies the subparser's namespace over the parent's, sofm-wedge-score.py --home /x settle ...yields home=None and _home() falls back to $FM_HOME or the CWD - I verified this on python3.12:--home /x settle-> Namespace(home=None),settle --home /x-> Namespace(home='/x'). The pre-subcommand form is accepted, produces no error, and writes the settlement to the wrong home. The intent needs one --home spelling, and the current callers all pass it after the subcommand, so the narrower form is enough. Remedy: delete the top-level --home.bin/fm-wedge-score.py:50- wedge-settlements.jsonl is appended to on every resume and every escalation (line 50) with no rotation or retention bound, and _read_rows (line 63) re-parses the whole file on each escalation. This is the one new watcher-owned log that is unbounded: its siblings in the same path are bounded (triage_log trims at TRIAGE_LOG_MAX_BYTES, fm-push-transition-lib.sh:60 trims the delivery log). Growth is slow (~150 bytes/row) so this is not urgent, but it never self-heals. Flagging as ask-user because the smallest honest remedy adds retention/rotation machinery - new durable behavior beyond the change's plumbing-only intent - not because the defect is subtle.bin/fm-watch.sh:274- FM_WEDGE_SHADOW (default 1) is a new operator-facing watcher knob with no entry in docs/configuration.md, where its immediate siblings FM_STALE_ESCALATE_SECS (line 572) and FM_WEDGE_DEMAND_INSPECT_COUNT (line 575) are both documented. Nothing enforces this, but an operator who needs to turn the shadow recording off in the field has no documented way to discover the switch. Remedy: add one line to the watcher block of docs/configuration.md.🔧 Fix: fix wedge shadow state resolution, thresholds, and tests
6 issues (3 warnings, 3 infos) still open:
bin/fm-watch.sh:398- clear_pause_tracking is a tracking-reset helper, not a resume site, so hanging wedge_shadow_resumed off it labels a non-resume as "resumed". Concrete reachable sequence: window w has .paused-KEY from an earlier handle_paused_stale (which removed .stale-since-KEY); on a later poll the pane is busy (busy_now=0), its hash is unchanged, n>=2, and busy_turn_over_age is true. Line 1099-1100 then calls wedge_timer_check, which finds no since-file and writesdate +%s > .stale-since-KEY. Line 1105 immediately sees[ -e "$pf" ] && [ "$n" -ge 2 ]and calls clear_pause_tracking, which at line 398 runs wedge_shadow_resumed against the since-file that was created microseconds earlier -> idle = 0 -> a row{"outcome":"resumed","idle_secs":0}is appended for a pane that is busy and has completed no turn in over BUSY_TURN_MAX_SECS. In _score that row becomes math.log(max(0,1.0)) = 0.0, an extreme low outlier against a healthy sample whose logs sit near 3.7-4.6, dragging mu down and inflating sigma for the whole lane, i.e. a wrong value in the emitted score with no error. Secondary cost: the$(window_to_task "$win" "$STATE")argument is evaluated unconditionally, re-running the full $STATE/*.meta scan (two greps per meta) on every clear_pause_tracking call even when FM_WEDGE_SHADOW=0, although $task is already in hand at every call site (fm-watch.sh:967). Remedy: delete the wedge_shadow_resumed call from clear_pause_tracking. It is a no-op at its other three call sites - lines 1102 and 1115 already record the resume and remove the since-file before lines 1105/1125 run, and theworking)branch at line 1069 is only reached after the hash changed, which already cleared the since-file at line 1115 - so nothing genuine is lost.bin/fm-wedge-score.py:98- _escalation_incidents assumes every escalated window is eventually discharged by aresumedrow, but the watcher usually never writes that row, so the dedup permanently swallows every repeat incident for a window. At fm-watch.sh:332 the escalation deletes the since-file and then wake() exits the process. When firstmate acts on that wake and the crew resumes, the next watcher run takes theh != prevbranch and calls wedge_shadow_resumed (fm-watch.sh:1115) with a since-file that no longer exists, so it returns early and noresumedrow is written.unresolvedtherefore keeps that window forever and every later escalation of the same window hitsif window not in unresolvedand is not counted. handle_paused_stale (fm-watch.sh:369) has the same shape: it rm's .stale-since-KEY with no settlement row. Concrete wrong output: a lane with 8 resumed rows where window s:w wedges, escalates, is fixed, and wedges again a day later yields n_esc=1 -> base_rate=(1+1)/(9+2)=0.1818 -> norm_ppf(1-0.1818)=0.908, where the true two incidents give base_rate=(2+1)/(10+2)=0.25 -> norm_ppf(0.75)=0.674; any z in (0.674, 0.908) is reported graded_flag=false when correct counting says true. The bias is systematic and grows with fleet age. Related: line 329 settles the escalated row before line 330 scores it, so the very event being graded is already inside its own base rate. The smallest honest remedies both extend the change - either durably tracking which windows are unresolved so a resume can be recorded after the since-file is gone, or adding a time-gap heuristic to separate incidents - so this needs your authorization rather than an in-place patch; the alternative is to drop _escalation_incidents and accept per-poll escalation counting with the bias documented alongside the censoring note already in the header.tests/fm-watch-triage.test.sh:510- All seven new tests drive bin/fm-wedge-score.py directly; nothing exercises the fm-watch.sh -> scorer seam, which is exactly where both fixed defects lived (the settlement log being written under the live FM_HOME instead of FM_STATE_OVERRIDE, and --fixed-threshold not being passed). The reported state-override failure has no reproduction: no test asserts that an escalation writes into the sandbox state dir. This is directly testable in the existing fixture - test_terminal_stale_overridden_by_run_step Phase B (line 505-512) already backdates .stale-since-KEY by 500s, runs the real watcher with FM_STATE_OVERRIDE="$state" and FM_STALE_ESCALATE_SECS=240, and waits for the escalation - so one added assertion that "$state/.wedge-settlements.jsonl" gained a row with outcome=escalated would fail before the fix and pass after it. Without it, a regression that silently drops --state or --fixed-threshold degrades to an empty score and no triage line, with every test still green.AGENTS.md:120- AGENTS.md section 2 is the exhaustive state/ inventory - it enumerates watcher artifacts down to individual dotfiles (.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* ...on line 120,.watch-triage.logon its own line 121) - and the new watcher-ownedstate/.wedge-settlements.jsonl(plus its transient.tmpsibling from _trim) is absent. docs/configuration.md:576 documents the FM_WEDGE_SHADOW knob but the layout inventory that tells firstmate which state files are watcher internals never to touch does not list the file, so a future cleanup or a reader of the inventory has no record of it. Remedy: add one line beside .watch-triage.log describing it as the watcher's size-capped wedge settlement sample, safe to delete (forces the score back to "insufficient data"). CLAUDE.md is a symlink to AGENTS.md, so one edit covers both.bin/fm-wedge-score.py:25- The fix round relocated the settlement log from $FM_HOME/data/wedge-settlements.jsonl to $STATE/.wedge-settlements.jsonl. The original finding's remedy was only to honor the state/data overrides ("or pass the resolved dirs from the shell"); the directory change is an additional design call. AGENTS.md section 2 and docs/configuration.md:13-14 - the single owner of the home layout - split these deliberately:data/holds durable private fleet records,state/holds volatile runtime records, and AGENTS.md:121 tells firstmate the neighboring watcher log is "safe to delete". This log is not volatile in that sense: it is a multi-session accumulating sample that must reach min_obs=8 resumed rows per lane before the score reports anything, so clearing state/ silently resets every lane to "insufficient data". The header comment at lines 4-7 asserts the state/ placement as the rationale, but the layout owner was not updated to match. Either move it back under a FM_DATA_OVERRIDE-aware data/ path, or confirm the sample is intentionally disposable and say so where the layout is defined.bin/argyle_gates.py:88- Informational, no action expected: only norm_cdf and norm_ppf (lines 43-71) are reachable from FirstMate. sharpe, _moments, GateResult, deflated_sharpe, block_permutation, oos_calibration, promotion_gate, EULER_GAMMA and the random/dataclasses imports - about 170 of 241 lines - are dead here. The User intent explicitly requires the lift be "verbatim with provenance", and the header (lines 1-4) records the upstream repo, branch, path and commit and directs fixes upstream first, so this is the required form, not a component to trim. Noting it so it is not re-flagged: the dead surface is intent-mandated. I confirmed the two live functions are safe at every call site - base_rate = (n_esc+1)/(n_total+2) is strictly inside (0,1), so norm_ppf's ValueError guard at line 49-50 is unreachable from _score.✅ **Test** - passed
✅ No issues found.
bash tests/fm-wedge-score.test.sh— 7 subtests covering settle rows/lane resolution, min-obs gating, graded vs fixed flags, caller-supplied threshold, escalation-incident dedup, and log trimmingbin/fm-test-run.sh tests/fm-wedge-score.test.sh— confirms the new file is routed by the runner as family=pure-contract-unitbin/fm-test-run.sh --check-coverage— coverage guard satisfied (total=137) with the new script classifiedbash tests/fm-watch-triage.test.sh— 40-test watcher regression suite, including every wedge-timer absorb/escalate path the change touchesManual E2E:bash ~/.no-mistakes/evidence/01M1S2H988FP2CT6NK6N0T3FPP/e2e-wedge-shadow.sh <repo> <evidence-dir>— drives realbin/fm-watch.shover 5 scenarios (resume settlement, escalation + shadow score + queued wake, graded/fixed disagreement,FM_WEDGE_SHADOW=0, shipped 240s default on a fresh fleet)Mutation check: 4 targeted mutations ofbin/fm-wedge-score.py(min-obs bypass, invertedgraded_flag, hardcoded 240s threshold, removed incident dedup), each re-runningtests/fm-wedge-score.test.sh; every mutation was caught and the source restored viagit checkout --Numeric validation of the liftedbin/argyle_gates.pyprimitivesnorm_ppf/norm_cdfagainst known normal-distribution reference values and the p∈(0,1) domain guard🔧 **Document** - 1 issue found → auto-fixed ✅
docs/architecture.md:63- Two surfaces state that state/.watch-triage.log is exclusively the absorbed-wake debug log (docs/architecture.md:63 "state/.watch-triage.log remains exclusively the absorbed-wake debug log"; docs/watcher-continuity.md:56 "remains only the watcher's bounded absorbed-wake debug log and carries no lifecycle semantics"). This change adds a non-absorb line to that log: wedge_shadow_score writes "shadow wedge score: ..." via triage_log at escalation time (bin/fm-watch.sh:288), i.e. on the surfaced path, not an absorbed one. I did not edit either sentence because the drift is pre-existing rather than introduced here - bin/fm-watch.sh:885,887 already wrote non-absorb "merged PR poll retirement ..." lines before this branch - and because the load-bearing part of both claims (it is a bounded debug log with no lifecycle semantics, contrasted against the state/.watch-cycle-exits.log ledger; AGENTS.md:121 "never relied on, safe to delete") is still true. Fixing it properly means picking one owner for the triage-log role and reducing the other two mentions (architecture.md, watcher-continuity.md, AGENTS.md:121) to pointers, which is a three-surface consolidation beyond this change's scope. Proposed follow-up: reword the owner to "the watcher's bounded debug log, never a lifecycle ledger" and point the duplicates at it.🔧 Fix: clarify triage log role for non-absorb watcher lines
✅ Re-checked - no issues remain.
🔧 Fix: no lint fixes needed; ShellCheck absence was environmental
1 warning still open:
✅ **Push** - passed
✅ No issues found.