Skip to content

feat(afk): make recurring composer deferrals record their own cause - #17

Open
sbracewell64 wants to merge 25 commits into
mainfrom
fm/composer-defer-diagnostic-fallback
Open

feat(afk): make recurring composer deferrals record their own cause#17
sbracewell64 wants to merge 25 commits into
mainfrom
fm/composer-defer-diagnostic-fallback

Conversation

@sbracewell64

Copy link
Copy Markdown
Owner

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

  • The away-mode daemon (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. After FM_COMPOSER_DEFER_DIAG_COUNT identical verdicts (default 20, 0 disables), it writes a diagnostic record — verdict, reader, streak, native busy state, and the offending row's sanitized bytes — to state/.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.sh gains an opt-in diagnostic sink (fm_composer_diag_record, armed via FM_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 with cat can never replay terminal escapes and never leaks an unbounded captain draft.
  • Away-mode entry/return now manage the new artifacts: fm-afk-return.sh surfaces 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: cleared

=== supervisor pane (never changes: the captain left a draft in the composer) ===
╭────────────────────────────────────────╮
│ > draft: reply to the board before 6pm │
╰────────────────────────────────────────╯

=== 5 daemon inject attempts (FM_COMPOSER_DEFER_DIAG_COUNT=5) ===
attempt 1: deferred; streak file: [1 pending]; diagnostic: absent
attempt 2: deferred; streak file: [2 pending]; diagnostic: absent
attempt 3: deferred; streak file: [3 pending]; diagnostic: absent
attempt 4: deferred; streak file: [4 pending]; diagnostic: absent
attempt 5: deferred; streak file: [5 pending]; diagnostic: RECORDED

=== state/.subsuper-composer-defer-diag (the record the next investigation starts from) ===
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 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d 20 e2 94 82 raw_text=... > draft: reply to the board before 6pm ... content_len=38 content_hex=3e 20 64 72 61 66 74 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d content_text=> draft: reply to the board before 6pm

=== nothing was ever typed into the pane holding the draft ===
sent.log bytes: 0

=== max-defer escape fires (housekeeping, FM_MAX_DEFER_SECS=60, digest 600s old) ===
--- wedge marker state/.subsuper-inject-wedged ---
fm away-mode inject WEDGED: 600s undelivered as of 2026-07-29T23:47:21-0400
The supervisor pane could not accept an escalation. Buffered items:
needs-decision: crewmate blocked on API key rotation
Recurring composer verdict diagnostic:
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 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d 20 e2 94 82 raw_text=... > draft: reply to the board before 6pm ... content_len=38 content_hex=3e 20 64 72 61 66 74 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d content_text=> draft: reply to the board before 6pm
--- summary handed to the OS notifier (no row bytes, ever) ---
osascript	away-mode escalations WEDGED 600s undelivered (composer verdict=pending reader=fm_tmux_composer_row_state streak=5/5) - see /tmp/fm-defer-demo.lkQked/state/.subsuper-inject-wedged

--- daemon log trail (grep composer-defer) ---
    [2026-07-29T23:47:21-0400] composer-defer-diag 2026-07-29T23:47:21-0400
    [2026-07-29T23:47:21-0400] ERROR: away-mode escalation undelivered 600s (composer verdict=pending reader=fm_tmux_composer_row_state streak=5/5); inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.

=== captain returns: bin/fm-afk-return.sh begin surfaces the cause as catch-up evidence ===
fm-afk-return: catch-up must finish before the captain request
catch-up wedge: fm away-mode inject WEDGED: 600s undelivered as of 2026-07-29T23:47:21-0400
catch-up composer-defer: composer-defer-diag 2026-07-29T23:47:21-0400
catch-up composer-defer:   target=firstmate:0 backend=tmux verdict=pending streak=5/5 native_busy=unknown
catch-up composer-defer:   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 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d 20 e2 94 82 raw_text=... > draft: reply to the board before 6pm ... content_len=38 content_hex=3e 20 64 72 61 66 74 3a 20 72 65 70 6c 79 20 74 6f 20 74 68 65 20 62 6f 61 72 64 20 62 65 66 6f 72 65 20 36 70 6d content_text=> draft: reply to the board before 6pm
catch-up escalation: needs-decision: crewmate blocked on API key rotation
firstmate-actionable blocker: repair-task [key=api-key] firstmate can refresh the synthetic token
fm-afk-return: handle each blocker now, or close it with resolved [key=...] and append a durable reclassification reason, then run bin/fm-afk-return.sh check

=== blocker resolved -> return check clears the diagnostic with the delivery artifacts ===
.subsuper-composer-defer-diag: cleared
.subsuper-composer-defer-streak: cleared
.subsuper-composer-defer-read: cleared
Evidence: Demo script (reproducible manual verification)
#!/usr/bin/env bash
# Manual end-to-end demo of the recurring-deferral diagnostic
# (branch fm/composer-defer-diagnostic-fallback).
#
# Scenario: away mode is active, an escalation is buffered, and the supervisor
# pane's composer permanently holds a captain draft ("pending" on every poll) -
# the systematic-misclassification shape that wedged delivery for 9.5 hours.
# We drive the daemon's real inject path five times (threshold 5), then the
# max-defer housekeeping escape, then the away-return catch-up.
set -u
ROOT=${1:?repo root}
WORK=$(mktemp -d /tmp/fm-defer-demo.XXXXXX)
trap 'rm -rf "$WORK"' EXIT
state="$WORK/state"; fakebin="$WORK/fakebin"
mkdir -p "$state" "$fakebin"

DRAFT='draft: reply to the board before 6pm'

# Fake tmux: the pane's composer box forever holds the captain's draft.
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "${1:-}" in
  display-message)
    for a in "$@"; do case "$a" in *cursor_y*) printf '1\n'; exit 0 ;; esac; done
    for a in "$@"; do [ "$a" = "-p" ] && { printf 'fakepane\n'; break; }; done
    exit 0 ;;
  capture-pane) cat "$FM_FAKE_COMPOSER" 2>/dev/null; exit 0 ;;
  list-windows) exit 0 ;;
  send-keys)
    shift; lit=0
    while [ "$#" -gt 0 ]; do
      case "$1" in
        -l) lit=1 ;;
        Enter) printf '[ENTER]\n' >> "$FM_FAKE_SENT" ;;
        -t) shift ;;
        *) [ "$lit" = 1 ] && printf '%s\n' "$1" >> "$FM_FAKE_SENT" ;;
      esac; shift
    done
    exit 0 ;;
esac
exit 1
SH
chmod +x "$fakebin/tmux"
width=$(( ${#DRAFT} + 4 )); border=; i=0
while [ "$i" -lt "$width" ]; do border="${border}─"; i=$((i+1)); done
printf '╭%s╮\n│ > %s │\n╰%s╯\n' "$border" "$DRAFT" "$border" > "$WORK/composer"
: > "$WORK/sent.log"

# Notifier seam recorder: what an OS notifier would have been handed.
cat > "$WORK/alarm-rec" <<'SH'
#!/usr/bin/env bash
printf '%s\t%s\n' "${1:-}" "${2:-}" >> "${DEMO_ALARM_LOG:?}"
SH
chmod +x "$WORK/alarm-rec"
export DEMO_ALARM_LOG="$WORK/alarm.log"
export FM_WEDGE_ALARM_EXEC="$WORK/alarm-rec"
export FM_WEDGE_ALARM_CHANNEL=osascript

export PATH="$fakebin:$PATH"
export FM_FAKE_COMPOSER="$WORK/composer" FM_FAKE_SENT="$WORK/sent.log"
export FM_INJECT_CONFIRM_SLEEP=0.05
export FM_COMPOSER_DEFER_DIAG_COUNT=5
export LOG="$WORK/daemon.log"

# shellcheck disable=SC1091
. "$ROOT/bin/fm-supervise-daemon.sh"

escalate_add "$state" "needs-decision: crewmate blocked on API key rotation"
afk_enter "$state"

echo '=== supervisor pane (never changes: the captain left a draft in the composer) ==='
cat "$WORK/composer"
echo
echo "=== 5 daemon inject attempts (FM_COMPOSER_DEFER_DIAG_COUNT=5) ==="
for i in 1 2 3 4 5; do
  inject_msg "away digest: needs-decision: crewmate blocked on API key rotation" "$state" >/dev/null 2>&1
  streak=$(cat "$state/.subsuper-composer-defer-streak" 2>/dev/null | tr '\t' ' ')
  diag_state=absent; [ -s "$state/.subsuper-composer-defer-diag" ] && diag_state=RECORDED
  printf 'attempt %s: deferred; streak file: [%s]; diagnostic: %s\n' "$i" "$streak" "$diag_state"
done
echo
echo '=== state/.subsuper-composer-defer-diag (the record the next investigation starts from) ==='
cat "$state/.subsuper-composer-defer-diag"
echo
echo '=== nothing was ever typed into the pane holding the draft ==='
printf 'sent.log bytes: %s\n' "$(wc -c < "$WORK/sent.log" | tr -d ' ')"
echo
echo '=== max-defer escape fires (housekeeping, FM_MAX_DEFER_SECS=60, digest 600s old) ==='
echo $(( $(date +%s) - 600 )) > "$state/.subsuper-escalations.since"
FM_ESCALATE_BATCH_SECS=99999 FM_MAX_DEFER_SECS=60 housekeeping "$state" >/dev/null 2>&1
echo '--- wedge marker state/.subsuper-inject-wedged ---'
cat "$state/.subsuper-inject-wedged"
echo '--- summary handed to the OS notifier (no row bytes, ever) ---'
cat "$WORK/alarm.log"
echo
echo '--- daemon log trail (grep composer-defer) ---'
grep -E 'composer-defer|ERROR' "$WORK/daemon.log" | sed 's/^/    /'
echo
echo '=== captain returns: bin/fm-afk-return.sh begin surfaces the cause as catch-up evidence ==='
ret="$WORK/return"; mkdir -p "$ret/bin" "$ret/home/state" "$ret/home/data" "$ret/home/config"
cp "$ROOT/bin/fm-afk-return.sh" "$ROOT/bin/fm-wake-lib.sh" "$ROOT/bin/fm-classify-lib.sh" "$ret/bin/"
cat > "$ret/bin/fm-afk-launch.sh" <<'SH'
#!/usr/bin/env bash
[ "${1:-}" = stop ] || exit 2
rm -f "$FM_HOME/state/.afk" "$FM_HOME/state/.afk-daemon-terminal"
SH
cat > "$ret/bin/fm-wake-drain.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$ret/bin/"*.sh
# Carry the REAL artifacts the daemon just produced into the return's state.
cp "$state/.subsuper-escalations" "$state/.subsuper-inject-wedged" \
   "$state/.subsuper-composer-defer-diag" "$state/.subsuper-composer-defer-streak" \
   "$ret/home/state/" 2>/dev/null
date +%s > "$ret/home/state/.afk"
cat > "$ret/home/state/repair-task.meta" <<EOF
window=synthetic:fm-repair-task
backend=tmux
kind=ship
EOF
printf 'blocked [key=api-key]: firstmate can refresh the synthetic token\n' > "$ret/home/state/repair-task.status"
out=$(FM_HOME="$ret/home" FM_STATE_OVERRIDE="$ret/home/state" "$ret/bin/fm-afk-return.sh" begin 2>&1) || true
printf '%s\n' "$out"
echo
echo '=== blocker resolved -> return check clears the diagnostic with the delivery artifacts ==='
printf 'resolved [key=api-key]: refreshed the synthetic token\n' >> "$ret/home/state/repair-task.status"
FM_HOME="$ret/home" FM_STATE_OVERRIDE="$ret/home/state" "$ret/bin/fm-afk-return.sh" check >/dev/null 2>&1 || true
for f in .subsuper-composer-defer-diag .subsuper-composer-defer-streak .subsuper-composer-defer-read; do
  if [ -e "$ret/home/state/$f" ]; then printf '%s: STILL PRESENT\n' "$f"; else printf '%s: cleared\n' "$f"; fi
done

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 order read -r count verdict &lt; &#34;$(_composer_defer_streak_file &#34;$1&#34;)&#34; 2&gt;/dev/null does 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 before 2&gt;/dev/null takes 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 to 2&gt;/dev/null &lt; &#34;$file&#34; or guarding with [ -r &#34;$file&#34; ] 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 for pending (fm_tmux_composer_state returns immediately on a pending row) but not for the geometry-ambiguous unknown case: there every box row classifies empty, the verdict comes from the ambiguity flag, and tail -1 in 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 real inject_msg deferrals to the threshold, ran housekeeping past max-defer, then ran the real bin/fm-afk-return.sh begin/check against the daemon-produced artifacts
  • Constraint 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)
⚠️ **Document** - 1 info
  • ℹ️ 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.

kunchenguid and others added 25 commits July 27, 2026 12:28
* 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
)

* fix: scope validation corrections by accepted behavior

* no-mistakes(review): Classify stale delivery evidence as an autonomous correction
…#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants