feat(afk): make recurring composer deferrals record their own cause - #17
Open
sbracewell64 wants to merge 25 commits into
Open
feat(afk): make recurring composer deferrals record their own cause#17sbracewell64 wants to merge 25 commits into
sbracewell64 wants to merge 25 commits into
Conversation
* feat: add verified pi-signed adapter * no-mistakes(review): Correct pi-signed maintainer verification date * no-mistakes(review): Correct remaining pi-signed verification dates * no-mistakes(review): Preserve authoritative pi-signed runtime identity * no-mistakes(document): Document pi-signed shared adapter semantics * no-mistakes: apply CI fixes
* fix(pi): rearm watcher across same-process session transitions Pi emits session_shutdown for ordinary /new, /resume, and /fork replacement as well as terminal quit. The primary watcher extension latched a module-level stopping flag on every shutdown, so a replacement session in the same process could not arm monitoring until Pi restarted. Own arm authority per session generation so only the active live generation may start, stop, or rearm the child. Replacement sessions can arm again without restarting Pi, stale prior-generation callbacks cannot mutate the active cycle, and real quit still blocks late rearm. * no-mistakes(review): Preserve Pi generation isolation and exit cleanup * no-mistakes(document): Correct Pi watcher transition documentation
* Consume quota-axi pace signals in dispatch profile array selection. Add quota-array-dispatch as the single owner of the pace-aware candidate choice, keep AGENTS.md to the intake boundary and load trigger, and cover the acceptance cases with sanitized schemaVersion 3 fixtures. * no-mistakes(review): Stop and report genuine quota dispatch ties * no-mistakes(document): Document quota pace freshness and uncertainty
…nguid#1171) * fix(grok): adapt Stop continuation to runtime capability * no-mistakes(review): Reject ambiguous Grok Stop payloads * no-mistakes(review): Reject duplicate Grok fields and accept spaced tmux sessions * no-mistakes(review): Enforce exact tmux cleanup selectors * no-mistakes(test): Fix historical tmux fixture and validate Grok Stop * no-mistakes: apply CI fixes
* fix(brief): make DOD scaffolding parse-safe on stock macOS Bash 3.2 fm-brief.sh built each Definition-of-done block and the not-enabled Herdr declaration with `VAR=$(cat <<EOF ... EOF)`. On Bash 3.2 (macOS /bin/bash) the lexer scans for the command substitution's closing `)` textually and tracks quote state through the heredoc body, so a single apostrophe, unbalanced quote, or unbalanced paren in that prose breaks parsing of the whole script. Every ship-brief scaffold (no-mistakes, direct-PR, local-only) failed with `unexpected EOF while looking for matching )`. Bash 4+ parses it fine, so the breakage stayed invisible everywhere except stock macOS. Replace all four command-substitution heredocs with `IFS= read -r -d '' VAR <<EOF || true`. That removes the `$(...)` wrapper and the entire defect class regardless of future prose, and preserves the variable expansion the direct-PR and local-only bodies need. `read` keeps the heredoc's trailing newline that `$(...)` used to strip, so trim one newline to keep every generated brief byte-identical to prior output. Guard the structure, not one historical phrase: a new test rejects any heredoc nested in a command substitution anywhere in fm-brief.sh, where the old assertion pinned a single apostrophe phrase and so missed the reintroduction. Extend the stock-macOS Bash CI job from parsing one script to the whole maintained shell surface (bin/*.sh, bin/backends/*.sh, tests/*.sh), matching bin/fm-lint.sh's canonical file set so parse scope and lint scope cannot drift apart. * no-mistakes(review): Captain: harden Bash structure and inventory guards * no-mistakes(document): Align stock macOS Bash contributor checks * no-mistakes(lint): Suppress deliberate SC2016 literal fixture warnings
* fix(test): pin teardown tmux baseline to historical kill selectors merge-base HEAD main collapses to HEAD after the exact-selector change lands on the default branch, so the old teardown fixture was accidentally exercising current exact targets. Resolve a content-historical permissive tmux adapter from first-parent history and force that post-squash topology inside the conformance case so main and feature branches keep the same old-vs-new contract. * no-mistakes(lint): Suppress intentional literal-pattern ShellCheck warnings
…id#1197) Cut the runtime skill to the compact pace-aware selection procedure plus minimum owner pointers. Keep every distinct decision rule and move expanded acceptance scenarios to deterministic fixture ownership assertions. Size: 170/1374/10187 -> 63/544/4068 (about 63%/60%/60% reduction).
…1219) * Inherit config/backend into secondmate homes with deliberate-override preservation Add backend to the shared inheritable config allowlist so launch, locked bootstrap, and config-push converge a primary pin into secondmate homes as each home local future-spawn default. Track last-inherited bytes in a private state provenance marker so deliberate per-home overrides survive present and absent primary convergence, keep --backend and FM_BACKEND stronger, and extend the existing inheritance tests plus docs and skill claims. * no-mistakes(review): Preserve equal unprovenanced backend overrides * no-mistakes(review): Preserve symlink overrides and verify spawn precedence * no-mistakes(review): Snapshot backend inheritance for consistent provenance * no-mistakes(review): Simplify backend inheritance to primary-authoritative convergence * no-mistakes(document): Document inherited backend override preservation * fix: restore primary-authoritative backend inheritance after document regression The document step reintroduced provenance and deliberate per-home override semantics after review had simplified config/backend to plain primary-authoritative allowlist membership. Restore the primary-always-wins path: present overwrites, absent removes, no provenance marker, and docs/tests match that contract. * no-mistakes(review): Add divergent backend precedence regression fixtures * no-mistakes(document): Document backend inheritance contract
* fix(pi): remove Calm's exclusive Pi upper-version ceiling
tests/fm-calm-pi-extension.test.sh gated on a closed PI_COMPAT_VERSIONS
allowlist ("0.81.1 0.82.0") that refused any other installed Pi, and docs
described that range as "supported" rather than verified evidence. The
Calm CHANGELOG shows no API introduced at either version, so there is no
evidence for a real minimum; the presentation adapters already probe the
exact method they patch rather than checking a version.
Replace the allowlist with dated version evidence that never rejects a
newer Pi, and make each presentation adapter degrade independently with
a diagnostic if a future Pi removes its API, instead of the whole Calm
extension failing to load. Rewrite the feasibility doc's "Pi 0.81.1
through 0.82.0" phrasing to state it as verified evidence, not a
ceiling.
* no-mistakes(review): Probe missing Calm adapter exports safely
* no-mistakes(document): Document Calm's unbounded Pi compatibility
…enguid#1204) * fix(guard): allow session-local todo tools in the primary The delegation-shape guard denied TaskCreate and TaskUpdate because their normalized names contain the `task` stem. Those tools write only the harness's session-local todo list, which has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that outlives the session. That is not the unaccounted work the guard exists to stop, so the stem match was a false positive, and the deny text told the primary to run bin/fm-brief.sh and bin/fm-spawn.sh to create a todo entry. Add a separately-reasoned PLAN_ONLY_TOOLS exact-name exclusion rather than widening OBSERVE_ONLY_TOOLS, whose documented contract is tools that only observe or stop existing work. Both lists stay exact-name so neither can widen by substring. Tests cover the two allowed names and six near-miss names that a substring or shortened-stem widening would release; both mutations were watched red. * no-mistakes(review): drop session-local todo tools from recommended deny list * no-mistakes: apply CI fixes
…claude pid (kunchenguid#1206) * fix(session-lock): resolve Claude bg-spare ancestry to the outermost claude pid fm_harness_ancestry_pid() previously returned the first ancestor process whose command matched a verified harness name. Claude Code's Stop hook fires as a bg-spare worker several levels below the session's actual lock-owning claude process (hook shell -> claude bg-spare -> claude bg-pty-host -> claude -> claude(lock)), so the first match was the bg-spare worker, not the lock owner. fm_session_lock_owned_by_self() then never matched state/.lock, and the Claude Stop auto-arm silently treated its own primary session as an unrelated live owner and never armed the watcher. The walk now keeps going past a claude-named match, looking for a still more ancestral claude-named match, and stops the instant a non-match follows an already-found match (bounding it to a contiguous run rather than the literal ancestry top, so an unrelated claude-named process further up the real process tree is never mistaken for part of this session's own nested chain). Every other harness keeps the original first-match-wins behavior, since e.g. Pi's shared signed-wrapper ancestry actually holds the session at the inner engine pid, not an outer wrapper pid. Hop limit raised from 8 to 16 to cover the deeper bg-spare chain. * no-mistakes(review): Add nested-claude-ancestry regression test; fix nudge doc depth claim * no-mistakes: apply CI fixes
* fix: confirm watcher startup on MSYS * no-mistakes(review): gate MSYS arm ready timeout, cache uname, harden locale test * no-mistakes(review): validate OpenCode ready timeout, make uname cache internal
…d#1195) * fix(spawn): forward firstmate's CLAUDE_CONFIG_DIR to claude crewmates Crewmate panes are created by a long-lived tmux/herdr daemon that does not inherit firstmate's current environment. When firstmate runs under a non-default CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split), a bare `claude` in the crewmate pane fell back to the default ~/.claude store and launched unauthenticated, blocking the crewmate before it could do any work. fm-spawn now prefixes the claude launch with firstmate's own resolved CLAUDE_CONFIG_DIR when set, so the crewmate uses the same credential/config store firstmate is authenticated with. An unset value is the single-store default and adds no prefix; non-claude harnesses are unaffected. Adds three tests in fm-spawn-dispatch-profile.test.sh (forwarded-when-set, omitted-when-unset, non-claude-ignored) and pins CLAUDE_CONFIG_DIR in the test helper so launch assertions no longer depend on the developer's environment. * no-mistakes: apply CI fixes
…guid#1233) * fix: preserve dispatch harness identity * no-mistakes(review): Fix Grok counterfactual tuple validation * no-mistakes(document): Scope dispatch authentication to selected tuple * fix: restore dispatch instruction budget * no-mistakes(review): Scope dispatch authentication after candidate selection
* fix(bin): handle dash-leading harness process names (#2) * fix: handle dash-leading harness process names * no-mistakes(review): Make dash-leading harness regression hermetic * fix: preserve secondmate reply routes across relative homes Resolve relative home, data, and state inputs before durable charter generation, and fail when caller-relative directories cannot be resolved. Use absolute paths at the related spawn, AFK daemon, and X-mode cross-process handoffs so later processes cannot reinterpret them from another working directory. * no-mistakes(review): Preserve absolute overrides and normalize relative durable paths * no-mistakes(review): Normalize relative home before deriving durable paths * no-mistakes(document): Document relative durable-path normalization * no-mistakes(review): Captain: Ignore inherited CDPATH during relative path normalization * no-mistakes(lint): Fix empty CDPATH assignments for ShellCheck
* Add internal status skill * no-mistakes(document): register /status skill in documentation-audiences inventory * no-mistakes(lint): replace grep|wc -l with grep -c in status skill test * test: silence literal status skill patterns * Refactor bearings default to chat-only --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
* docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * docs: add captain-approved project operation exception to hard rule 1 Firstmate stays read-only over projects by default, but when the captain clearly approves a concrete project operation and scope in the moment, firstmate may perform exactly that approved operation with its own tools. The approval is never inferred, broadened, or standing, and it does not relax the existing force, discard, unlanded-work, or merge-authority boundaries. * no-mistakes(review): Clarify captain-approved project operation boundaries * no-mistakes(document): Clarify captain-approved project operation scope * docs: cover directories and preserve the operation-or-scope alternative Widen the captain-approved project operation exception in AGENTS.md to files or directories, and restore the explicit operation-or-scope alternative that a prior pipeline auto-fix had collapsed into "and". Rework project-management SKILL.md's Remove section, which previously told firstmate to refuse project removal until a guarded helper existed; that helper was never built, so the text directly contradicted the new instruction-only exception. It now points at the exception plus the existing removal preflight it still requires unchanged. Update the one instruction-owners test assertion that hard-coded the sentence removed above, so the suite tracks current, not obsolete, text. * no-mistakes(review): Align project removal preflight with approved exception * no-mistakes(document): Align project removal documentation with approved exception * fix: restore removal test byte-for-byte and preserve the default sentence tests/fm-instruction-owners.test.sh had been changed to assert different text; restore it byte-for-byte to origin/main. project-management SKILL.md's Remove section now keeps the exact default "Never issue a raw removal command from Firstmate." sentence that test still asserts, immediately followed by the already-approved captain-operation-or-scope exception, so the default and the exception both stay explicit and consistent. * no-mistakes(document): Align project-write boundary documentation
…henguid#1275) * Route project intake through secondmate scopes * no-mistakes(test): Guard all main-home project registry mutations * no-mistakes(document): Consolidate secondmate routing documentation * no-mistakes: apply CI fixes * Restore new-project routing scope * no-mistakes(document): Clarify secondmate routing for new-project intake * no-mistakes: apply CI fixes
…#1282) * test: remove source-content assertions * no-mistakes(review): Replace source assertions with runtime behavior coverage * no-mistakes(review): Isolate Kimi task temp runtime coverage * no-mistakes(document): Refresh test cleanup documentation * no-mistakes: apply CI fixes
The away-mode max-defer escape retried the same guarded delivery path and could only alarm. Against a systematic composer misclassification that retry is guaranteed to fail identically, so a 9.5-hour wedge produced an alarm reporting only how long delivery had been stuck: no record of which verdict recurred, what the offending composer row held, or which reader produced it. The follow-up investigation had to start from scratch. After FM_COMPOSER_DEFER_DIAG_COUNT consecutive identical non-empty verdicts (default 20, one FM_MAX_DEFER_SECS window at the default tick, so the first diagnostic lands with the first wedge alarm), the daemon now records the recurring verdict, the offending row's sanitized bounded bytes, the reader that produced it, and the backend's own busy state. The record goes to state/.subsuper-composer-defer-diag, the daemon log as the durable trail, and into the wedge marker beside the buffered items it already carried; fm-afk-return.sh surfaces it as catch-up evidence and clears it with the other delivery artifacts. Every non-empty verdict is eligible, including unknown: a dead-shell or unreadable supervisor pane wedges delivery just as permanently. That class has no offending row, so a bounded sanitized pane tail is recorded instead. Implementation notes: - The diagnostic sink (bin/fm-composer-lib.sh) is opt-in via FM_COMPOSER_DIAG_FILE and inert otherwise, so a healthy fleet pays nothing. Each reader calls it once where it already holds the raw row and the ghost-stripped content, so no row-finding logic is duplicated into the daemon and the record format has one owner. - The daemon arms the sink only once already deep in a streak, so the recorded row comes from the same read as the recorded verdict rather than a second one. - R2 asks for the diagnostic on a pane the backend independently reports idle. That needs no new gate: inject_msg already returns early on pane_is_busy, which consults the backend's native busy state before the harness footer. The native state is recorded as evidence instead, because only some backends expose one and gating on an affirmative idle would make the diagnostic never fire on tmux, which the diagnosed defect affected identically. - Bytes are recorded as hex, so a record read with cat cannot replay a pane's own escape sequences, and each field is bounded because the row can hold the captain's unsent draft. For the same reason the alert summary carries only the verdict, reader, and streak: it is passed to an OS notifier or a configured command: directive. - The streak is durable so a restart mid-wedge cannot hide an ongoing misclassification by restarting the count. Only tmux and herdr can be supervisor panes (the daemon refuses others at startup), so those two readers are the complete affected set. Orca, cmux, and zellij composer surfaces were inspected and left unchanged; the sink is inert without the env var, so their behavior is unchanged bit for bit. This is orthogonal to the Unicode-blank normalization being shipped in parallel and adds none of it. The new fixture deliberately avoids U+00A0: once blanks normalize, an NBSP row reads empty and an NBSP fixture would silently stop reproducing the identical-verdict condition. Stable non-blank content models what this change actually targets, the next systematic misclassification rather than the one already being fixed. No fallback delivery route is included, deliberately. R2 offered the diagnostic or a composer-independent delivery route, and no route preserves the hard property that we never type into a pane that might hold real input: - Typing once the verdict is "provably chrome" needs a general proof that does not exist. Blank-only rows are provable, which is exactly the parallel normalization fix, not a delivery route. - herdr agent prompt is a real primitive, but herdr drives a PTY and that command sits on the same socket API as pane send-text/send-keys. If it appends it merges with the captain's draft; if it clears first it destroys the draft. Both violate the property. This premise is reasoned from herdr's architecture rather than empirically verified; the conclusion does not depend on which of the two behaviors is real, because both fail. - Baseline identity (snapshot a proven-empty row, treat a byte-identical row as chrome) is sound but never arms in the wedge it would fix: proof needs a confirmed submit or an affirmative empty verdict, and the incident had zero of both across 9.5 hours. - Cross-pane identity against an idle crewmate pane rests on crewmate panes being machine-only, which AGENTS.md hard rule 4 contradicts, and ends in typing into the captain's pane on a probabilistic argument. - Delivering into a fresh pane is safe by construction but the agent in it is a second firstmate that the per-home session lock refuses, so it cannot act on the decisions being escalated. With firstmate's composer unreachable by proof, the only remaining actor is the captain, and reaching the captain is already the wedge alarm's job. So the escape's destination becomes an informative record and a verdict-carrying alarm, which is R2's stated accepted minimum.
… diag sink comment
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Implement R2 from the completed away-mode escalation wedge diagnosis (data/away-injection-wedge-diagnosis/report.md): give the away-mode delivery guard's max-defer escape somewhere to escape to.
Background. Away mode buffers captain-relevant escalations and injects them into firstmate's own pane. The max-defer escape at bin/fm-supervise-daemon.sh retried the identical guarded delivery path and could only alarm. A 9.5-hour wedge (three consecutive occurrences) proved that a systematic composer misclassification defers forever with zero diagnostic value: the buffered escalation recorded no record of WHICH verdict recurred or WHAT the offending composer row contained, so the follow-up investigation had to start from scratch. The diagnosed root cause was Claude Code rendering its empty composer as the prompt glyph plus U+00A0, which bash's [[:space:]] trims cannot see.
Goal of THIS change. Make that class of wedge self-diagnosing. After N consecutive identical non-empty composer verdicts, record a distinguishable diagnostic: the verdict, the offending row's sanitized bounded bytes, and the reader that produced it, so the next investigation starts at the answer.
Deliberate decisions made and approved by the supervising firstmate in a mandatory design review before implementation (design at data/composer-defer-diagnostic-fallback/design.md). A reviewer reading only the diff would not know these were chosen on purpose:
NO fallback delivery route is implemented, deliberately. R2 offered either the diagnostic or a composer-independent delivery route. Six candidate routes were evaluated and every one violates the hard safety property that we never type into a pane that might hold real input: typing on a "provably chrome" verdict needs a general proof that does not exist; herdr agent prompt drives the same PTY so it either merges with or destroys the captain's draft; baseline identity is sound but can never arm in the wedge it would fix (proof needs a confirmed submit or an affirmative empty verdict, and the incident had zero of both across 9.5 hours); cross-pane identity against a crewmate pane rests on an assumption AGENTS.md hard rule 4 contradicts and ends in typing on a probabilistic argument; a fresh pane hosts a second firstmate that the per-home session lock refuses. R2 itself names the diagnostic alone as the accepted minimum in that case, and firstmate approved shipping exactly that. Do NOT flag the absence of a delivery route as incomplete work.
The diagnostic does NOT gate on the backend independently reporting idle, even though R2's wording mentions it. That needs no new gate: inject_msg already returns early on pane_is_busy, which consults the backend's native busy state before the harness busy footer, so every deferral reaching the composer guard is already proven not-busy by both signals. The native state is recorded as evidence instead, because only herdr exposes a real native busy state (tmux returns unknown) and gating on an affirmative idle would make the diagnostic never fire on the tmux reference backend, which the diagnosed defect affected identically.
The new test fixture deliberately does NOT use the U+00A0 that caused the diagnosed wedge. The root-cause fix (R1) is being shipped in parallel and normalizes Unicode blanks in the shared classifier, so once it lands an NBSP row reads empty and an NBSP fixture would silently stop reproducing the identical-verdict condition. Stable non-blank content models what R2 actually targets: the NEXT systematic misclassification, not the one R1 fixes. This change adds NO blank normalization of its own and must not duplicate R1's.
Row bytes are recorded as hex rather than raw, and every field is bounded to 120 bytes with an explicit truncation marker. Both are safety choices, not style: a composer row can contain the terminal's own escape sequences and a diagnostic record is read with cat, so hex can never replay an escape into the reader's terminal; and the row can hold the captain's own unsent draft, so it must be bounded. For the same reason the wedge alert summary carries only the verdict, reader, and streak and never the row bytes, because that string is passed to an OS notifier or a configured command: directive.
The diagnostic sink lives in bin/fm-composer-lib.sh and is opt-in via FM_COMPOSER_DIAG_FILE, inert otherwise. Each reader calls it once where it already holds both the raw styled row and the ghost-stripped content. This is to honor the repo's one-owner rule: re-deriving composer row location in the daemon would duplicate exactly the logic that has drifted across adapters twice before. The daemon arms the sink only once already deep in a streak, so the recorded row comes from the same read as the recorded verdict rather than a second read.
Scope also includes two additions firstmate explicitly approved beyond R2's literal wording: a bounded sanitized pane tail when the reader found no candidate composer row at all (the dead-shell / unreadable class, which has no offending row but wedges just as permanently), and surfacing the record as away-return catch-up evidence so the captain's next investigation does not have to rediscover it.
Only tmux and herdr can be supervisor panes (the daemon refuses others loudly at startup), so those two readers are the complete affected set. The orca, cmux, and zellij composer surfaces were inspected and deliberately left unchanged; the sink is inert without the env var so their behavior is unchanged bit for bit.
Verification already performed locally: bin/fm-lint.sh clean, bin/fm-doc-audience-check.sh clean, and 52 suites run. Sixteen new tests added across tests/fm-daemon.test.sh, tests/fm-composer-lib.test.sh, and tests/fm-afk-return.test.sh, all green. Four suites fail, and each was individually reproduced as identical at the base commit a2d5f26, so none is caused by this change: fm-calm-pi-extension, fm-pi-watch-extension, and fm-turnend-guard share one node ESM environment fault in the Pi tooling, and fm-watcher-lock is a watcher-singleton timing test perturbed by sibling firstmate lanes' live watchers on this host. tests/fm-backend-tmux-smoke.test.sh was excluded because it needs a real tmux server and hangs at the base commit too, and the real-herdr-gated family was excluded because this task's brief was scaffolded without the Herdr lab guard and must not drive Herdr lifecycle.
What Changed
bin/fm-supervise-daemon.sh) now tracks consecutive identical non-empty composer verdicts across deferred escalation deliveries in a durable streak file that survives daemon restarts. AfterFM_COMPOSER_DEFER_DIAG_COUNTidentical verdicts (default 20,0disables), it writes a diagnostic record — verdict, reader, streak, native busy state, and the offending row's sanitized bytes — tostate/.subsuper-composer-defer-diag, the daemon log, and the wedge-alarm marker. The wedge alert summary carries only the verdict, reader, and streak, never row bytes; when the reader found no candidate row at all (dead-shell/unreadable pane), a bounded sanitized pane tail is recorded instead. Deliberately no fallback delivery route is added — the diagnostic alone is the approved scope.bin/fm-composer-lib.shgains an opt-in diagnostic sink (fm_composer_diag_record, armed viaFM_COMPOSER_DIAG_FILE, inert otherwise) that the tmux and herdr composer readers call at the point they already hold both the raw styled row and the ghost-stripped content. Row bytes are rendered as hex and every field is bounded to 120 bytes with an explicit truncation marker, so a record read withcatcan never replay terminal escapes and never leaks an unbounded captain draft.fm-afk-return.shsurfaces the diagnostic as catch-up evidence and clears it, and a fresh away entry clears stale diagnostics too, with the launcher's transactional backup/rollback extended to cover them. Docs (docs/wedge-alarm.md,docs/configuration.md,docs/architecture.md, the afk skill) describe the new behavior, and 16 new tests cover the threshold trigger, streak reset/restart survival, sanitization bounds, wedge-marker/alarm content, and return-side surfacing. The no-mistakes review pass also fixed a stderr leak from the streak-file read that would have printed on every healthy delivery.Risk Assessment
✅ Low: The round-1 fix commit resolves both prior findings precisely as instructed — a readability guard replacing the broken redirection-order suppression with identical fallback semantics plus a behavioral stderr regression test, and a comment-only correction of the diag sink contract — leaving the branch intent-conformant with no remaining issues.
Testing
Ran the three touched suites (fm-composer-lib, fm-daemon, fm-afk-return — all green, covering the 16 new tests) and a manual end-to-end demo through the real daemon inject path: five identical pending deferrals build the durable streak, the diagnostic records at the threshold with verdict/reader/bounded hex row bytes and native busy state as evidence, nothing is typed into the pane holding the draft, the wedge marker and daemon log carry the record, the notifier summary carries only verdict/reader/streak (no row bytes), and away-return surfaces the record as catch-up evidence then clears it; diff checks confirmed no delivery route and no blank normalization were added.
Evidence: End-to-end wedge lifecycle transcript (deferrals → diagnostic → wedge alarm → return catch-up → clear)
=== 5 daemon inject attempts (FM_COMPOSER_DEFER_DIAG_COUNT=5) === attempt 1: deferred; streak file: [1 pending]; diagnostic: absent ... attempt 5: deferred; streak file: [5 pending]; diagnostic: RECORDED === state/.subsuper-composer-defer-diag === composer-defer-diag 2026-07-29T23:47:21-0400 target=firstmate:0 backend=tmux verdict=pending streak=5/5 native_busy=unknown rows_evaluated=1 reader=fm_tmux_composer_row_state raw_len=46 raw_hex=e2 94 82 20 3e 20 64 72 61 66 74 ... content_text=> draft: reply to the board before 6pm === nothing typed into the pane holding the draft === sent.log bytes: 0 --- summary handed to the OS notifier (no row bytes) --- osascript away-mode escalations WEDGED 600s undelivered (composer verdict=pending reader=fm_tmux_composer_row_state streak=5/5) - see .../.subsuper-inject-wedged === fm-afk-return.sh begin === catch-up composer-defer: composer-defer-diag 2026-07-29T23:47:21-0400 ... === after resolved blocker -> check === .subsuper-composer-defer-diag: cleared .subsuper-composer-defer-streak: cleared .subsuper-composer-defer-read: clearedEvidence: Demo script (reproducible manual verification)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 2 issues found → auto-fixed ✅
bin/fm-supervise-daemon.sh:1131- In_composer_defer_streak_read, the redirection orderread -r count verdict < "$(_composer_defer_streak_file "$1")" 2>/dev/nulldoes not suppress the error when the streak file is missing: bash processes redirections left to right, so the failed input redirect prints "No such file or directory" to the original stderr before2>/dev/nulltakes effect (verified empirically). The arming check in inject_msg (line 1302) calls this on every inject attempt with the default threshold of 20, and the streak file is absent on a healthy fleet, so every normal escalation delivery emits an unsuppressed error line to the daemon's stderr (its foreground pane). Fix by reordering to2>/dev/null < "$file"or guarding with[ -r "$file" ]before the read.bin/fm-composer-lib.sh:271- The sink contract comment in fm_composer_diag_record says the daemon reports the LAST record because "the readers return on the first row that classifies non-empty." That holds forpending(fm_tmux_composer_state returns immediately on a pending row) but not for the geometry-ambiguousunknowncase: there every box row classifiesempty, the verdict comes from the ambiguity flag, andtail -1in composer_defer_diag_write reports an innocuous last box row as the offending one. The record still carries rows_evaluated and real pane bytes, so it remains diagnostic; noting the nuance in case a future investigation reads the reported row as authoritative for verdict=unknown streaks.🔧 Fix: silence missing-streak-file stderr leak; correct diag sink comment
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-composer-lib.test.sh— 14 ok (5 new diag-sink tests: inert without env var, hex byte capture, no replayable escapes, 120-byte bounding with truncation marker, one line per row)bash tests/fm-daemon.test.sh— 111 ok, exit 0 (12 new tests: threshold trigger, stderr silence, refresh cadence, streak reset/restart-survival, disable via 0, no leaked temp reads, pane-tail for unreadable composer, wedge marker carries diagnostic, alarm summary excludes row bytes)bash tests/fm-afk-return.test.sh— 7 ok, exit 0 (new: return surfaces and clears the composer-defer diagnostic)Manual end-to-end demo (defer-diag-demo.sh): sourced the real daemon in library mode against a fake tmux pane whose composer forever holds a captain draft; drove 5 realinject_msgdeferrals to the threshold, ranhousekeepingpast max-defer, then ran the realbin/fm-afk-return.sh begin/checkagainst the daemon-produced artifactsConstraint checks against the diff: no fallback delivery route added, no blank/NBSP normalization added, diag sink opt-in via FM_COMPOSER_DIAG_FILE, daemon fixture uses non-blank content (not U+00A0)bin/fm-afk-launch.sh:369- fm_afk_clear_stale_artifacts in bin/fm-afk-launch.sh clears only .subsuper-escalations, .subsuper-escalations.since, and .subsuper-inject-wedged on a fresh away entry; the new .subsuper-composer-defer-diag/-streak/-read artifacts are cleared only by bin/fm-afk-return.sh, so a prior session's diagnostic or streak count can survive into a fresh away session if the return script never ran (crash or manual .afk removal). Effect is diagnostic-only, but a code follow-up may be warranted; documentation now attributes clearing to the return script, which matches current behavior.🔧 Fix: clear composer-defer diagnostics on fresh away entry too
1 info still open:
bin/fm-afk-launch.sh:362- Judgment call, already applied: the instruction literally asked only to extend fm_afk_clear_stale_artifacts (which lives in bin/fm-afk-start.sh, sourced by the launcher, not in bin/fm-afk-launch.sh as the instruction stated), but the launcher's transactional entry backs up and restores every artifact that clear removes, so the backup loops and fm_afk_launch_restore_backup were also extended with the three composer-defer artifacts. Without that, a failed away entry would restore the delivery artifacts yet permanently destroy the prior session's diagnostic evidence, breaking the launcher's existing rollback invariant. Signature, call sites, and away-entry ordering are unchanged, and the rollback regression test now covers the new artifacts.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.