Skip to content

feat(bin): record captain decision deferrals as dated answers - #2

Merged
twilwa merged 15 commits into
mainfrom
fm/fm-decision-defer-close-mode
Sep 21, 2026
Merged

twilwa merged 15 commits into
mainfrom
fm/fm-decision-defer-close-mode

Conversation

@twilwa

@twilwa twilwa commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Intent

Fix decision defer handling before any new review interface is built.

The captain's words, 2026-09-20: "Approved: ... fix decision defer and stale-answer handling before new nvim/bb UI."

A completed investigation found this defect and the recommendation was adopted. The defect: "later" is a modeled outcome of a captain decision that no channel can actually express. The keyed answer intake accepts close modes done and release only. Chat cannot express defer, the board cannot express defer, and the documented workaround is to abandon the answer and hand-write a separate re-hold with a date, which records no answer at all.

Proceed on high-confidence work only; hold low-confidence branches and present options rather than guessing.

Out of scope: building any keyboard or review interface. That was recommended against and is not authorized.

What Changed

  • fm-captain-hold.sh gains a deferral outcome: answer --defer-until YYYY-MM-DD and the keyed intake's defer mode (fifth <until> field) record the captain's words as a deferred resolution block, then re-date the call's existing hold through tasks-axi hold --until without closing it — preserving the Captain hold set: stamp and age basis, carrying the hold's existing reason forward, publishing only the resolved ... deferred until <date> parent line, and keeping any pending reconcile request open. The date joins the decision digest, so the same words with a new date are a new answer while exact redelivery stays idempotent; answers reports deferred: <id> until <date> and counts it separately in deferred=.
  • Every channel can now express "later": fm-send.sh --defer-until (rejected for status-log keys, which the intake does not own), board decision options carry an explicit until: that fm-bearings-board.sh validates as a real calendar day and the template renders as "deferred until ", and fm-procevent-lavish.sh relays close: "defer" plus its until into the intake.
  • Adds a shared fm_valid_calendar_day validator in fm-classify-lib.sh used by both the hold intake and the send-time preflight; updates AGENTS.md, the captain-hold and bearings docs/skills so a recorded deferral replaces the hand-written re-hold workaround; and adds tests covering the keyed defer path, retained reconcile requests, board payload validation, and board render/answer-context output.

Risk Assessment

✅ Low: The defer mode is well-bounded to one keyed-answer intake and its two channels, every prior round's fix verifies as correct in the current source, and the new behavior is covered by tests that execute the real scripts and template rather than inspecting their text.

Testing

Stood up throwaway firstmate homes against the real tasks-axi backlog and drove the decision-defer path through every channel this change adds it to: the direct fm-captain-hold.sh answer --defer-until, the chat channel (fm-send.sh --resolve-key --defer-until), and the board (a real fm-bearings-board.sh build served by a live lavish-axi session, the shipped board script's own rendering and queued answer context, through fm-procevent-lavish.sh into the one keyed intake). The defect is first reproduced on the base commit, where both channels refuse "later" and no answer is recorded at all. On the target commit a deferral records the captain's words with its date, re-dates the existing hold without restarting the call's age, drops out of the live Captain's Call as a dated Charted Next gate, returns to Captain's Call once the date passes, and stays answerable; the board card shows the date it commits the captain to and the queued confirmation repeats it. Adversarial drives confirm the guards - impossible calendar days, a missing date, a date on done or release, --release together with --defer-until, deferring an already-closed call, and deferring the reserved reconcile value are all refused with the call left untouched, and fm-send refuses an invalid date before any text reaches the crew pane. The lifecycle guards hold too: a deferred record does not satisfy the scout completion gate, an out-of-band close over a deferral still repairs into a terminal record, a pending board re-check request survives the deferral, and a secondmate deferral publishes one resolved line without re-announcing the postponed question. The three targeted suites owning these surfaces (captain-hold lifecycle, bearings board, board render) all pass. No screenshot or GIF was captured because this host has no browser binary at all - no chromium, chrome, or firefox, and no playwright or puppeteer browser cache - and installing one would reach outside the worktree boundary; the reviewer-visible substitute is the actual built board HTML from this run, which opens in any browser and renders the deferring card, plus the option and confirmation text produced by the shipped board script itself. The live lavish session and armed process-event source were ended and retired in this same turn, and the worktree is clean.

  • Live validation: ✅ go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
On the base commit, no channel can express a dated "later" and nothing is recorded ✅ pass live Section 0 of defer-channels-drive.txt: on base 9a0e566 the keyed intake reports skipped: sample-release-timing (unknown close mode defer 2026-10-15) and the direct path exits 2 with usage text carry…
The direct answer path records "later" as a real answer and dates the same hold ✅ pass live Section 1 of defer-channels-drive.txt: fm-captain-hold.sh answer sample-release-timing --decision-file later.txt --defer-until 2026-08-01 prints deferred: ... until 2026-08-01; tasks-axi shows sta…
A deferred call leaves the live Captain's Call as a dated gate and comes back on its date ✅ pass live Section 1 of defer-channels-drive.txt: fm-fleet-snapshot at 2026-07-20 reports captain_actionable false, hold_bucket dated, hold_until 2026-08-01; at 2026-08-02 the same call is captain_actionable tru…
The chat channel answers a captain call with "later" and a date ✅ pass live Section 2 of defer-channels-drive.txt: fm-send.sh sample-chat-review --resolve-key sample-chat-defer --defer-until 2026-10-15 &#34;later, after the release&#34; leaves the call queued, held, dated 2026-10-1…
The chat preflight refuses an impossible calendar day before the answer is delivered ✅ pass live Section 2 of defer-channels-drive.txt: --defer-until 2026-09-31 exits with error: --defer-until requires a YYYY-MM-DD date: 2026-09-31; zero bytes reached the crew pane and the target call carries…
A board option carrying a date travels from the captain's click to a dated, recorded deferral ✅ pass live Section 3 of defer-channels-drive.txt: a real fm-bearings-board.sh build establishes and arms a live lavish-axi session; the shipped board script queues context `{"close":"defer","until":"2026-10-15…
The board card shows the captain the date the deferring answer commits the call to ✅ pass live Section 3 of defer-channels-drive.txt plus bearings-board-defer.html: the shipped board script renders the deferring option with deferred until 2026-10-15 beneath its hint while the three non-deferr…
Malformed, conflicting, and out-of-mode defer inputs are refused with the call untouched ✅ pass live Section 4 of defer-channels-drive.txt: --release with --defer-until is refused as mutually exclusive; 2026-09-31 and 2026-02-30 are refused as non-calendar days; a defer row with no date is skippe…
A postponement is not accepted as proof the call was answered ✅ pass live Section 5 of defer-channels-drive.txt: after a deferral and a bare out-of-band tasks-axi done, fm-captain-hold.sh verify exits 1 saying the call is neither held nor closed with a recorded answer;…
A deferral keeps a pending board re-check obligation open ✅ pass live Section 6 of defer-channels-drive.txt: reconcile list reports the same pending request and reconcile-requests: 1 both before and after the deferral, because the call itself stays open.
A secondmate deferral publishes one resolved line and re-announces nothing to the parent ✅ pass live Section 7 of defer-channels-drive.txt: the parent channel shows needs-decision [key=captain-hold-defer-call-1] followed by resolved [key=captain-hold-defer-call-1]: ... deferred until 2026-10-01 w…
Evidence: Product drive transcript: defer across every channel

Source: Product drive transcript: defer across every channel

Decision defer, driven against the running product
==================================================
branch fm/fm-decision-defer-close-mode   base 9a0e566   target c7fc69a
Every command below is the real CLI an operator runs, against a real tasks-axi
backlog in a throwaway home, on 2026-09-20.


==============================================================
0. THE DEFECT, REPRODUCED ON THE BASE COMMIT (9a0e566)
==============================================================


--- the captain says 'later' on the chat/keyed intake ---
$ fm-captain-hold.sh answers --source "captain chat"   <<< later ... defer 2026-10-15
skipped: sample-release-timing (unknown close mode defer 2026-10-15)
answers: closed=0 skipped=1
rc=1

--- the captain says 'later' on the direct answer path ---
$ fm-captain-hold.sh answer sample-release-timing --decision-file later.txt --defer-until 2026-10-15
fm-captain-hold.sh - deterministic mechanics for tasks held for the captain.

(the whole usage text follows; base has no --defer-until)
rc=2

--- the call after both attempts: no answer recorded anywhere ---
Captain hold set: 2026-09-20T12:08:40Z


==============================================================
1. TARGET: THE DIRECT CLI RECORDS 'LATER' AS AN ANSWER AND DATES THE HOLD
==============================================================

$ fm-captain-hold.sh answer sample-release-timing --decision-file later.txt --defer-until 2026-08-01
deferred: sample-release-timing until 2026-08-01

--- the call: still queued, still captain-held, now dated, with the answer recorded ---
  state: queued
  held: no
  hold_reason: captain timing choice pending
  hold_until: 2026-08-01
Captain hold set: 2026-07-14T12:00:00Z

Resolution recorded by fm-captain-hold.
Decision digest: 3e97d023bb82296eb358bbb61b9803cd7e14983227d6fdd8c8bf489eace7f2e3
Resolution mode: deferred
Deferred until: 2026-08-01

Captain decision:
Later - revisit after the 2.0 release ships.

--- /bearings before the date (snapshot clock 2026-07-20): a dated gate, not a live card ---
[{"captain_actionable":false,"hold_bucket":"dated","hold_until":"2026-08-01","hold_set":"2026-07-14T12:00:00Z","hold_age_days":6}]

--- /bearings after the date (snapshot clock 2026-08-02): the call is due again, aged from its ORIGINAL hold date ---
[{"captain_actionable":true,"hold_bucket":"live","hold_until":"2026-08-01","hold_age_days":19}]

--- a call whose date has really elapsed is back in the live Captain's Call feed ---
deferred: sample-due-today until 2026-09-19
{"live_captains_call":["sample-release-timing","sample-due-today"],"charted_gates":[]}

--- and the postponed call is still answerable for real once its date passes ---
answered: sample-release-timing
  state: done
Resolution recorded by fm-captain-hold.
Decision digest: 1d5d05dae8a73dbbcd146340645d6f8b16a3ac30b1fe4041e0c5304681a3ba09
Resolution mode: answered

Captain decision:
Swap the vendor now.

Resolution recorded by fm-captain-hold.
Decision digest: 3e97d023bb82296eb358bbb61b9803cd7e14983227d6fdd8c8bf489eace7f2e3
Resolution mode: deferred
Deferred until: 2026-08-01

Captain decision:
Later - revisit after the 2.0 release ships.


==============================================================
2. THE CHAT CHANNEL CAN NOW SAY 'LATER'
==============================================================

$ fm-send.sh sample-chat-review --resolve-key sample-chat-defer --defer-until 2026-10-15 "later, after the release"
fm-send: doorbell not typed because the agent in firstmate:fm-sample-chat-review has exited; the steer is durably recorded at /tmp/fm-defer-lab/run/chat/state/sample-chat-review.inbox/001.msg for recovery (stuck-crewmate-recovery), and the watcher will not re-ring a dead pane

--- the captain call after the chat answer ---
  state: queued
  held: yes
  hold_until: 2026-10-15
Captain hold set: 2026-09-20T12:08:56Z

Resolution recorded by fm-captain-hold.
Decision digest: e1fed3dbdc633fe837e376b69b7aafd5d63927c2cd614e4e42d43070735fe71c
Resolution mode: deferred
Deferred until: 2026-10-15

Captain decision:
Captain answered this call through a firstmate answer sent to sample-chat-review.
Task: sample-chat-defer
Answer: later, after the release

Origin: sample-chat-review

--- an impossible calendar day is refused BEFORE the answer is delivered ---
$ fm-send.sh sample-chat-review --resolve-key sample-chat-defer-2 --defer-until 2026-09-31 "later on an impossible date"
/tmp/fm-defer-lab/run/chat/refused.err:WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
/tmp/fm-defer-lab/run/chat/refused.err:error: --defer-until requires a YYYY-MM-DD date: 2026-09-31
rc=1
bytes delivered to the crew pane: 0
resolution records on the call: 0


==============================================================
3. THE BOARD CAN NOW SAY 'LATER', END TO END
==============================================================


--- the board is built and served to the captain by the real lavish-axi ---
$ fm-bearings-board.sh build payload.json
board: /tmp/fm-defer-lab/run/board/.lavish/bearings-board.html
session:
session: live
served: /tmp/fm-defer-lab/run/board/.lavish/bearings-board.html
bound: lavish-f48fc0077c4a1bf8
armed: lavish-f48fc0077c4a1bf8
listening: live
$ lavish-axi   (open sessions)
  /tmp/fm-defer-lab/run/board/.lavish/bearings-board.html,open,"http://firstmate-vps.tail7f759.ts.net:4387/session/f48fc0077c4a1bf8",0

--- what the captain sees on the card, produced by the shipped board script itself ---
[
  {
    "value": "swap-now",
    "label": "Swap now",
    "hint": "Two days of migration work this sprint",
    "until": null
  },
  {
    "value": "later",
    "label": "Revisit after the 2.0 release",
    "hint": "Keep the cap until then",
    "until": "deferred until 2026-10-15"
  },
  {
    "value": "drop",
    "label": "Stay on the current vendor",
    "hint": "Accept the cap permanently",
    "until": null
  },
  {
    "value": "reconcile",
    "label": "Reconcile",
    "hint": "Re-check the latest state, then close this with evidence or keep it open with a note",
    "until": null
  }
]

--- the captain picks the deferring option; this is the answer the page queues ---
{
  "confirmation": "Swap the PDF vendor now or later? -> later (deferred until 2026-10-15)",
  "prompt": "Captain's Call answer - Swap the PDF vendor now or later?: later (deferred until 2026-10-15)",
  "context": {
    "schema": "fm-bearings-answer.v1",
    "question": "sample-vendor-swap",
    "selection": "later",
    "note": "",
    "close": "defer",
    "until": "2026-10-15"
  }
}

--- that exact queued context, captured as a board result, becomes keyed intake rows ---
$ fm-procevent-lavish.sh answers <captured board result>   (key TAB answer TAB label TAB mode TAB until)
sample-vendor-swap  <TAB>  later  <TAB>  Swap the PDF vendor now or later? -> later (deferred until 2026-10-15)  <TAB>  defer  <TAB>  2026-10-15

$ fm-captain-hold.sh answers --source "captured board result"
deferred: sample-vendor-swap until 2026-10-15
answers: closed=0 deferred=1 skipped=0

--- the captain call after the board answer ---
  state: queued
  held: yes
  hold_until: 2026-10-15
Captain hold set: 2026-09-20T12:09:08Z

Resolution recorded by fm-captain-hold.
Decision digest: efe9aca71a2c5f21e24f773164d6e2a52feac67467b267c589d000d533c12b21
Resolution mode: deferred
Deferred until: 2026-10-15

Captain decision:
Captain answered this call through captured board result.
Task: sample-vendor-swap
Answer: later
Answer as shown to the captain: Swap the PDF vendor now or later? -> later (deferred until 2026-10-15)


==============================================================
4. THE GUARDS AROUND THE NEW CLOSE MODE
==============================================================


--- --release and --defer-until together ---
fm-captain-hold: --release and --defer-until are mutually exclusive

--- an impossible calendar day on the direct path ---
fm-captain-hold: --defer-until must be a YYYY-MM-DD date: 2026-09-31

--- the keyed intake: impossible day / missing day / a date on done / a date on release ---
skipped: sample-guard-call (invalid defer date 2026-02-30)
answers: closed=0 deferred=0 skipped=1
skipped: sample-guard-call (defer close mode requires a YYYY-MM-DD date)
answers: closed=0 deferred=0 skipped=1
skipped: sample-guard-call (close mode done does not accept a deferral date)
answers: closed=0 deferred=0 skipped=1
skipped: sample-guard-call (close mode release does not accept a deferral date)
answers: closed=0 deferred=0 skipped=1

--- the reserved reconcile value still cannot become a close of any kind ---
refused: sample-guard-call (reconcile requests require a bound captured source)
answers: closed=0 deferred=0 skipped=1

--- a deferral cannot reopen a call that is already closed ---
fm-captain-hold: task sample-closed-call is already closed; --defer-until cannot reopen it

--- after every refusal the guard call is untouched: no record, no date ---
  state: queued
  held: yes
  hold_until: "-"
Captain hold set: 2026-09-20T12:09:25Z


==============================================================
5. A POSTPONEMENT IS NOT PROOF THE CALL WAS ANSWERED
==============================================================

deferred: sample-deferred-then-closed until 2026-12-01

--- someone closes the deferred call out of band with a bare tasks-axi done ---
(closed outside fm-captain-hold)

--- the scout completion gate refuses to call that call resolved ---
$ fm-captain-hold.sh verify sample-scout-run
fm-captain-hold: captain-held task sample-deferred-then-closed is neither held for the captain nor closed with a recorded captain answer
rc=1

--- and the captain's real answer can still be recorded on top of the deferral ---
repaired: sample-deferred-then-closed
Resolution recorded by fm-captain-hold.
Decision digest: fbd18d6eefb6efe5e8addfba17b2a18f48f9fd2d055d1eebb8324112973dd2a0
Resolution mode: repaired

Captain decision:
Proceed with the vendor swap after all.

Resolution recorded by fm-captain-hold.
Decision digest: 0331ea3abb3e68de7785d8b369c5384e8319c94a2d72a30e5ef6f156193191f8
Resolution mode: deferred
Deferred until: 2026-12-01

Captain decision:
Later - revisit after the 2.0 release.

Origin: sample-scout-run
$ fm-captain-hold.sh verify sample-scout-run
verified: sample-scout-run captain-call inventory
rc=0


==============================================================
6. A DEFERRAL KEEPS A PENDING BOARD RE-CHECK OBLIGATION OPEN
==============================================================


--- pending re-check requests before the deferral ---
sample-direct-defer	requested=2026-09-20T12:09:49Z	source=captured board result
reconcile-requests: 1
deferred: sample-direct-defer until 2026-12-01

--- and after it - the call stays open, so its re-check obligation stays open too ---
sample-direct-defer	requested=2026-09-20T12:09:49Z	source=captured board result
reconcile-requests: 1


==============================================================
7. INSIDE A SECONDMATE HOME, A DEFERRAL PUBLISHES ONE RESOLVED LINE
==============================================================

$ fm-captain-hold.sh hold defer-call --title "Choose the defer timing" --reason "timing choice pending"
$ fm-captain-hold.sh answer defer-call --decision-file d.txt --defer-until 2026-10-01
deferred: defer-call until 2026-10-01

--- the parent channel the captain reads ---
needs-decision [key=captain-hold-defer-call-1]: captain hold defer-call: timing choice pending
resolved [key=captain-hold-defer-call-1]: captain hold defer-call: deferred until 2026-10-01
  (the postponed question is not re-announced as a fresh needs-decision)

--- the real answer, given later, closes the call's next occurrence ---
$ fm-captain-hold.sh answer defer-call --decision-file d2.txt
answered: defer-call
needs-decision [key=captain-hold-defer-call-1]: captain hold defer-call: timing choice pending
resolved [key=captain-hold-defer-call-1]: captain hold defer-call: deferred until 2026-10-01
resolved [key=captain-hold-defer-call-2]: captain hold defer-call: answered
Evidence: The board built and served in this run, with the deferring card

Source: The board built and served in this run, with the deferring card

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bearings - fleet board</title>
<style>
/* ============================================================
   Bearings board - Firstmate Design System, inlined for portability.
   Tokens copied verbatim from myfirstmate priv/static/assets/app.css
   (the compiled adoption of .agents/skills/firstmate-design).
   ============================================================ */
@import url("https://fonts.googleapis.com/css2?family=Chango&family=Jost:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap");

:root {
  --rust-700: #8f2f17; --rust-600: #a93a1f; --rust-500: #c0452a;
  --rust-400: #d35f3f; --rust-300: #e08365; --rust-100: #f4d8c9; --rust-050: #fbece3;
  --navy-700: #1a2238; --navy-600: #222c49; --navy-500: #2a3656;
  --navy-300: #6c7796; --navy-100: #d9deea;
  --gold-600: #b5791c; --gold-500: #e0a52e; --gold-300: #f0d38c; --gold-100: #f8ecc9;
  --ocean-600: #2f6688; --ocean-500: #3c7ea6; --ocean-200: #b6d4e2; --ocean-050: #e8f1f5;
  --sea-700: #234e3a; --sea-500: #2f6b4f; --sea-200: #b9d4c5; --sea-050: #e9f2ec;
  --paper-000: #fbf4e2; --paper-100: #f6ecd3; --paper-200: #f0e3c4; --paper-300: #e7d6ae;
  --cream-line: #ddc89c;
  --ink-900: #241c14; --ink-700: #3f3224; --ink-500: #6f5e46; --ink-300: #9c8a6c;
  --white: #fffdf7;
  --bg-page: var(--paper-100);
  --surface-card: var(--white);
  --surface-card-warm: var(--paper-000);
  --text-strong: var(--ink-900); --text-body: var(--ink-700);
  --text-muted: var(--ink-500); --text-faint: var(--ink-300);
  --border-default: var(--cream-line); --border-soft: var(--paper-300);
  --status-online: var(--sea-500); --status-online-soft: var(--sea-050);
  --status-warn: var(--gold-600); --status-warn-soft: var(--gold-100);
  --status-danger: var(--rust-600); --status-danger-soft: var(--rust-050);
  --status-info: var(--ocean-500); --status-info-soft: var(--ocean-050);
  --font-display: "Chango", "Cooper Black", Rockwell, Georgia, serif;
  --font-sans: "Jost", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --fs-h3: 1.4rem; --fs-h4: 1.15rem; --fs-base: 1rem; --fs-sm: 0.9375rem;
  --fs-xs: 0.8125rem; --fs-2xs: 0.6875rem;
  --ls-caps: 0.16em;
  --radius-xs: 6px; --radius-sm: 9px; --radius-md: 12px; --radius-banner: 7px;
  --radius-lg: 18px; --radius-pill: 999px;
  --shadow-sm: 0 1px 2px rgba(36, 28, 20, 0.06), 0 4px 10px rgba(36, 28, 20, 0.06);
  --shadow-hard: 4px 4px 0 var(--ink-900);
  --shadow-hard-sm: 3px 3px 0 var(--ink-900);
  --texture-paper: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E");
  --focus-ring: 0 0 0 3px var(--gold-300);
  --container-app: 1180px;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: var(--font-sans);
  color: var(--text-body);
  background: var(--bg-page);
  background-image: var(--texture-paper);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }

/* ---- fm-badge (enamel sign-pin), verbatim contract ---- */
.fm-badge {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--fs-2xs); font-weight: 800; line-height: 1;
  padding: 5px 9px 4px; text-transform: uppercase; letter-spacing: 0.07em;
  border-radius: var(--radius-xs); border: 1.5px solid var(--ink-900);
  box-shadow: 2px 2px 0 var(--ink-900); white-space: nowrap;
}
.fm-badge--online  { background: var(--sea-500);  color: var(--paper-000); }
.fm-badge--warn    { background: var(--gold-500); color: var(--navy-700); }
.fm-badge--danger  { background: var(--rust-600); color: var(--paper-000); }
.fm-badge--info    { background: var(--ocean-500); color: var(--paper-000); }
.fm-badge--neutral { background: var(--paper-000); color: var(--ink-900); }
.fm-badge--solid   { background: var(--rust-500); color: var(--paper-000); }

/* ---- fm-btn ---- */
.fm-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  font-family: var(--font-sans); font-weight: 800; line-height: 1; white-space: nowrap;
  border: 2px solid transparent; border-radius: var(--radius-banner); cursor: pointer;
  text-decoration: none; transition: background 120ms, border-color 120ms, transform 120ms, box-shadow 120ms;
}
.fm-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.fm-btn:active { transform: translateY(1px); }
.fm-btn--sm { font-size: var(--fs-xs); padding: 8px 16px; }
.fm-btn--primary { background: var(--rust-500); color: var(--white); border-color: var(--ink-900); box-shadow: var(--shadow-hard-sm); }
.fm-btn--primary:hover { background: var(--rust-600); }
.fm-btn--gold { background: var(--gold-500); color: var(--navy-700); border-color: var(--ink-900); box-shadow: var(--shadow-hard-sm); }
.fm-btn--gold:hover { background: var(--gold-600); color: var(--white); }
.fm-btn[disabled] { opacity: 0.5; cursor: not-allowed; }

/* ---- fm-card ---- */
.fm-card {
  background: var(--surface-card); border: 1px solid var(--border-default);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden;
}
.fm-card--poster { border: 2px solid var(--ink-900); box-shadow: var(--shadow-hard); border-radius: var(--radius-md); }
.fm-card--warm { background: var(--surface-card-warm); }

/* ---- fm-sign (eyebrow section label) ---- */
.fm-sign {
  display: inline-flex; align-items: center; gap: 8px; font-weight: 800;
  text-transform: uppercase; letter-spacing: var(--ls-caps); font-size: var(--fs-xs);
  line-height: 1; white-space: nowrap;
}
.fm-sign--eyebrow { color: var(--rust-500); }
.fm-sign--muted { color: var(--ink-500); }
.fm-sign svg { width: 14px; height: 14px; }
.fm-ico { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.18em; }

/* ============================================================
   Board chrome (bb-*) - built on the same tokens
   ============================================================ */
.bb-nav {
  position: sticky; top: 0; z-index: 20;
  background: color-mix(in srgb, var(--paper-100) 86%, transparent);
  backdrop-filter: saturate(150%) blur(10px);
  border-bottom: 1px solid var(--border-default);
}
.bb-nav__inner {
  max-width: var(--container-app); margin: 0 auto; padding: 0 28px; height: 64px;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.bb-brand { display: inline-flex; align-items: center; gap: 12px; }
.bb-brand__disc {
  display: grid; place-items: center; width: 34px; height: 34px; flex: none;
  border-radius: 999px; background: var(--rust-500); color: var(--paper-000);
  border: 2px solid var(--ink-900); box-shadow: var(--shadow-hard-sm);
}
.bb-brand__disc svg { width: 60%; height: 60%; }
.bb-brand__wm { font-family: var(--font-display); font-size: 23px; color: var(--text-strong); line-height: 1; }
.bb-meta-mono { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-faint); white-space: nowrap; }

.bb-main {
  max-width: var(--container-app); margin: 0 auto;
  padding: 26px 28px 72px; display: flex; flex-direction: column; gap: 22px;
}

/* stat strip */
.bb-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.bb-stat {
  display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; min-width: 0;
  background: var(--surface-card-warm); border: 1px solid var(--border-soft); border-radius: var(--radius-md);
}
.bb-stat--call { background: var(--rust-050); border: 2px solid var(--rust-500); }
.bb-stat__num { font-family: var(--font-mono); font-size: var(--fs-h3); font-weight: 600; line-height: 1; color: var(--text-strong); }
.bb-stat--call .bb-stat__num { color: var(--rust-600); }
.bb-stat__label { font-family: var(--fon

... [22627 bytes truncated] ...

e;
      if (!displayAnswer) return;
      if (deferUntil) displayAnswer += " (deferred until " + deferUntil + ")";
      if (utf8ByteLength(displayAnswer) > 512) {
        answerLimit.textContent = "Answer is too long to queue (512 bytes maximum).";
        answerLimit.classList.add("is-visible");
        return;
      }
      if (window.lavish && window.lavish.queuePrompt) {
        /* The versioned context keeps the selected option separate from its
           note. A selected option's own until is a dated defer; the card's
           close remains the default for ordinary completion and release. */
        var ctxData = {
          schema: "fm-bearings-answer.v1",
          question: item.key,
          selection: value || "",
          note: note
        };
        var close = deferUntil ? "defer" : item.close;
        if (close) ctxData.close = close;
        if (deferUntil) ctxData.until = deferUntil;
        window.lavish.queuePrompt(
          "Captain's Call answer - " + item.title + ": " + displayAnswer,
          { tag: "choice", text: item.title + " -> " + displayAnswer, element: form,
            data: ctxData }
        );
      }
      card.classList.add("is-queued");
      /* deal the next card once this one is answered */
      setTimeout(function () { showCard(active < cards.length - 1 ? active + 1 : active); }, 450);
    });

    pad.appendChild(form);
    card.appendChild(pad);
    deck.appendChild(card);
  });

  /* stack navigation: one card at a time, dealt off the pile */
  var cards = Array.prototype.slice.call(deck.children);
  var active = 0;
  var stackCount = document.getElementById("bb-stack-count");
  var stackPrev = document.getElementById("bb-stack-prev");
  var stackNext = document.getElementById("bb-stack-next");
  var stackNav = stackCount.parentNode;
  function showCard(i) {
    active = Math.max(0, Math.min(cards.length - 1, i));
    cards.forEach(function (c, j) { c.hidden = j !== active; });
    var answered = deck.querySelectorAll(".is-queued").length;
    stackCount.textContent = "card " + (active + 1) + " of " + cards.length +
      (answered ? " · " + answered + " answered" : "");
    stackPrev.disabled = active === 0;
    stackNext.disabled = active === cards.length - 1;
    /* the pile thins as the deal approaches the bottom */
    deck.classList.toggle("bb-call--penult", active === cards.length - 2);
    deck.classList.toggle("bb-call--last", active === cards.length - 1);
  }
  if (cards.length) {
    stackPrev.addEventListener("click", function () { showCard(active - 1); });
    stackNext.addEventListener("click", function () { showCard(active + 1); });
    showCard(0);
  } else {
    deck.classList.add("bb-call--empty");
    deck.appendChild(el("div", "bb-empty", "Nothing needs your action right now."));
    stackNav.hidden = true;
  }

  /* Underway */
  var uw = document.getElementById("bb-underway");
  if (!data.underway.length) uw.appendChild(el("div", "bb-empty", "Nothing is underway."));
  data.underway.forEach(function (t) {
    var row = el("div", "bb-row");
    row.appendChild(badge(t.state === "working" ? "online" : "info", t.state));
    var main = el("div", "bb-row__main");
    /* the snapshot's durable name-or-id label leads the row so a scan says
       WHICH task this is; the run status keeps its place on the second line */
    main.appendChild(el("div", "bb-row__title", t.name));
    /* captain-facing rows name the repo; the internal task id shows only when
       no repo is known */
    main.appendChild(el("div", "bb-row__sub",
      t.doing + " · " + t.kind + " · " + (t.repo || t.id)));
    row.appendChild(main);
    uw.appendChild(row);
  });

  /* Landed */
  var ld = document.getElementById("bb-landed");
  if (!data.landed.length) ld.appendChild(el("div", "bb-empty", "No recent completions are in the current baseline."));
  data.landed.forEach(function (t) {
    var row = el("div", "bb-row");
    var chk = el("span", "bb-row__check");
    chk.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
    row.appendChild(chk);
    var main = el("div", "bb-row__main");
    main.appendChild(el("div", "bb-row__title", t.what));
    main.appendChild(el("div", "bb-row__sub", (t.repo || t.id) + " · " + t.owner));
    row.appendChild(main);
    if (t.pr_url) {
      var a = el("a", "bb-row__pr", "#" + t.pr_url.split("/").pop());
      a.href = t.pr_url; a.title = t.pr_url; a.target = "_blank"; a.rel = "noopener";
      row.appendChild(a);
    }
    ld.appendChild(row);
  });

  /* Charted Next + dispatch picker */
  var ch = document.getElementById("bb-charted");
  var bar = document.getElementById("bb-dispatch");
  var barCount = document.getElementById("bb-dispatch-count");
  var barBtn = document.getElementById("bb-dispatch-btn");
  var anyPickable = false;

  function pickedIds() {
    return Array.prototype.map.call(ch.querySelectorAll(".bb-pick:checked"), function (c) { return c.value; });
  }
  function refreshBar(limitMessage) {
    var n = pickedIds().length;
    barCount.textContent = limitMessage || (n ? n + " picked for dispatch" : "pick queued work to dispatch");
    barBtn.disabled = !n;
  }

  if (!chartedQueued(data.charted).length && !chartedMoreQueued) {
    ch.appendChild(el("div", "bb-empty", "Nothing is queued."));
  }
  chartedOrder(data.charted).forEach(function (t) {
    var row = el("div", "bb-row");
    if (t.dispatchable && !isWarning(t)) {
      anyPickable = true;
      var pick = document.createElement("input");
      pick.type = "checkbox"; pick.className = "bb-pick"; pick.value = t.id;
      pick.setAttribute("aria-label", "Pick " + t.id + " for dispatch");
      pick.addEventListener("change", function () {
        if (pick.checked && utf8ByteLength(pickedIds().join(",")) > 512) {
          pick.checked = false;
          refreshBar("Selection limit reached (512 bytes maximum). ");
          return;
        }
        refreshBar();
      });
      row.appendChild(pick);
    } else {
      row.appendChild(el("span", "bb-pick-spacer"));
    }
    var main = el("div", "bb-row__main");
    main.appendChild(el("div", "bb-row__title", t.title));
    /* second line keeps the title uncrowded in the half-width column */
    var chSub = t.repo || t.id;
    main.appendChild(el("div", "bb-row__sub", t.reason ? t.reason + " · " + chSub : chSub));
    row.appendChild(main);
    if (isWarning(t)) row.appendChild(badge("danger", "needs repair"));
    else if (t.reason) row.appendChild(badge("warn", "waiting"));
    ch.appendChild(row);
  });
  var chartedShown = chartedQueued(data.charted).length;
  var chartedTotal = chartedShown + chartedMoreQueued;
  document.getElementById("bb-charted-sub").textContent =
    chartedMoreQueued ? "showing " + chartedShown + " of " + chartedTotal : "";
  if (chartedMoreQueued) {
    ch.appendChild(el("span", "bb-morechip", "+" + chartedMoreQueued + " more queued - ask firstmate for the full chart"));
  }
  if (chartedMoreWarnings) {
    ch.appendChild(el("span", "bb-morechip", "+" + chartedMoreWarnings + " more repair warning" + (chartedMoreWarnings === 1 ? "" : "s") + " - ask firstmate for the full chart"));
  }

  if (anyPickable) {
    bar.hidden = false;
    refreshBar();
    barBtn.addEventListener("click", function () {
      var ids = pickedIds();
      var dispatchAnswer = ids.join(",");
      if (!ids.length) return;
      if (utf8ByteLength(dispatchAnswer) > 512) {
        refreshBar("Selection is too long to queue (512 bytes maximum). ");
        return;
      }
      if (window.lavish && window.lavish.queuePrompt) {
        window.lavish.queuePrompt(
          "Dispatch order - start this queued work now: " + ids.join(", "),
          { tag: "choice", text: "Dispatch: " + ids.join(", "), element: bar,
            queueKey: "dispatch.charted",
            data: { question: "dispatch.charted", answer: dispatchAnswer } }
        );
      }
      bar.classList.add("is-queued");
    });
  }
  } catch (e) {
    renderBoardError("The board data could not be rendered");
  }
})();
</script>
</body>
</html>
Evidence: Bearings board build suite results

Source: Bearings board build suite results

ok - path prints the stable home-scoped board location
ok - build refuses malformed payloads before touching the board
ok - charted kind is optional and accepts queued and warning
ok - build injects the payload, binds any-origin, then arms the source
not-autohandled: lavish-dd691270d91691a9 (left for the handler; still unacknowledged)
ok - registration can consume answers only after any-origin binding exists
ok - build establishes the Lavish session before binding and arming
ok - rebuild refreshes the board in place without double-arming
ok - build refuses a template without exactly one data slot
ok - a board build reopens a session the captain ended instead of arming a dead one
ok - build reopens a session that ends between establish and listing
ok - build refuses to arm a poll on a session that stays ended
ok - a rebuild starts a listener when an already-armed board has none
ok - build drops decision cards whose subject already landed and keeps open ones
ok - build keeps remote decisions absent from the main backlog
ok - build fails when reconcile cannot prove a live listener
ok - every decision card carries exactly one reconcile choice
ok - build refuses a payload that occupies the reserved reconcile value
ok - build reserves reconcile across non-decision cards
ok - build accepts an optional option date and refuses one that is not a calendar day
Evidence: Bearings board render suite results

Source: Bearings board render suite results

ok - an underway row leads with the task name and still reports its run status
ok - an underway identifier label is not replaced by run status
ok - charted next renders the most recently filed work first
ok - charted rows with no filed date follow the dated rows in payload order
ok - a warning row badges needs repair while queued work keeps waiting
ok - the charted next count counts queued work only, and still renders warnings
ok - a warning-only board reports nothing queued and still shows the warning
ok - omitted warnings remain separate from omitted queued work
ok - an omitted kind renders exactly as queued work always did
ok - an option date emits a dated defer while the card close governs every other answer
ok - a deferring option shows its date on the card and in the queued answer
Evidence: Board answer context the shipped page queues for a deferring option
$ node tests/assets/board-render-harness.mjs <built board> '[{"question":"sample-vendor-swap","selection":"later","note":""}]'
{
"confirmation": "Swap the PDF vendor now or later? -> later (deferred until 2026-10-15)",
"context": { "schema": "fm-bearings-answer.v1", "question": "sample-vendor-swap", "selection": "later", "note": "", "close": "defer", "until": "2026-10-15" }
}

$ fm-procevent-lavish.sh answers <captured board result>
sample-vendor-swap later Swap the PDF vendor now or later? -> later (deferred until 2026-10-15) defer 2026-10-15

$ fm-captain-hold.sh answers --source "captured board result"
deferred: sample-vendor-swap until 2026-10-15
answers: closed=0 deferred=1 skipped=0

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed ✅
  • ⚠️ bin/fm-send.sh:786 - The chat channel's post-delivery failure message instructs the operator to do the exact thing this change exists to prevent. fm_send_feed_resolved_holds (bin/fm-send.sh:773-789) now builds a defer row when RESOLVE_DEFER_UNTIL is set (lines 776-780), but the failure arm at line 786 was left unchanged: "the answer was delivered to $T, but this captain-held task could not be closed: X. Close it with fm-captain-hold.sh answer - do not resend the answer." Concrete reachable sequence: the captain runs fm-send.sh &lt;task&gt; --resolve-key X --defer-until 2026-10-15 &#34;later, after the release&#34;. The preflight passes because X is open and captain-held (fm_send_hold_resolved_id, bin/fm-send.sh:600-605). The text is delivered. The intake then refuses - for example X carries a pending board reconcile request and publish_parent_resolution cannot reach the parent (bin/fm-captain-hold.sh:1535-1538, the failure guard this change deliberately preserved), so command_answer exits nonzero, command_answers reports skipped: and exits nonzero, and fm-send prints line 786. An operator who follows that instruction literally runs fm-captain-hold.sh answer X --decision-file ... with no --defer-until, which writes an answered record and closes the call the captain explicitly postponed - the precise outcome the intent's defect statement calls fabricating a closure. Note the deferral has in fact already been recorded and the hold re-dated at that point; only the parent line is missing. Smallest honest remedy, which corrects what the change already does: when RESOLVE_DEFER_UNTIL is set, say the call could not be deferred and name answer --defer-until &#34;$RESOLVE_DEFER_UNTIL&#34; as the recovery command.
  • ℹ️ .agents/skills/bearings/assets/board-template.html:571 - The 512-byte queue guard now measures display-only decoration. Line 571 appends " (deferred until <date>)" - 28 bytes - to displayAnswer BEFORE the utf8ByteLength(displayAnswer) > 512 check at line 572. Concrete case: a card with a deferring option and allow_freeform true. The captain selects later (5 bytes) and types a 500-byte note. displayAnswer is 508 bytes undecorated and 536 after the append, so the board refuses with "Answer is too long to queue (512 bytes maximum)." even though the payload that actually faces a hard downstream limit is the note alone (next unless length($note) &lt;= 512, bin/fm-procevent-lavish.sh:483), which is within range; the selection is separately capped at 128 by the slug rule and the label is truncated rather than rejected. The identical answer on a non-deferring option queues fine. Remedy is mechanical: run the byte check on the undecorated displayAnswer, then append the date for the prompt and confirmation text. Reported at info because the guard was already an imprecise proxy - it has always counted value + &#34; - &#34; - and this change only widens the gap, and only for deferring options.
  • ⚠️ bin/fm-send.sh:514 - Simplification: the change introduces two spellings of one flag. --defer-until &lt;date&gt; (bin/fm-send.sh:502-513) and the --defer-until=&lt;date&gt; alias (lines 514-521) each carry their own duplicate-detection branch, six extra lines for a second way to say the same thing. No intent requirement needs a second spelling - the intent requires only that chat be able to express a dated defer, which the space form satisfies, and --fire-and-forget in the very same argument loop ships with the space form alone, so this codebase does not treat both spellings as mandatory. The strictly narrower form is --defer-until &lt;date&gt; only; recommend removing the = variant and its duplicate guard rather than keeping and hardening it. Flagging rather than fixing because the alias mirrors the adjacent --resolve-key= convention in the same loop and may be a deliberate authoring choice about the chat flag surface.
  • ⚠️ bin/fm-captain-hold.sh:1085 - Simplification: the fallback [ -n &#34;$defer_reason&#34; ] || defer_reason=&#34;captain deferred until $defer_until&#34; is an unreachable component, and fabricating a hold reason is not something the intent requires. defer_reason is read at line 1084 from the show row of a task the surrounding branch has already proven is hold_kind = captain (line 1078, reached only under the hold_kind = captain arm at line 1128). tasks-axi hold makes --reason mandatory (usage: tasks-axi hold &lt;id&gt; --reason &#34;&lt;text&gt;&#34; [flags]), and I confirmed against the installed backend that hold_reason survives both a live hold and an elapsed date gate (held: no, hold_reason: captain expired pending, hold_kind: captain) - the elapsed case being the one state defer_answered's own comment singles out. So the empty branch cannot occur. If it somehow did, the honest outcome is a loud failure from tasks_axi hold rather than a synthesized reason, which would then become the Charted Next gate text a snapshot renders for that call (fm-fleet-snapshot.sh hold_bucket reads hold_reason). Recommend removing the fallback and passing the existing reason through unchanged.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
On the base commit, no channel can express a dated "later" and nothing is recorded ✅ pass live Section 0 of defer-channels-drive.txt: on base 9a0e566 the keyed intake reports skipped: sample-release-timing (unknown close mode defer 2026-10-15) and the direct path exits 2 with usage text carry…
The direct answer path records "later" as a real answer and dates the same hold ✅ pass live Section 1 of defer-channels-drive.txt: fm-captain-hold.sh answer sample-release-timing --decision-file later.txt --defer-until 2026-08-01 prints deferred: ... until 2026-08-01; tasks-axi shows sta…
A deferred call leaves the live Captain's Call as a dated gate and comes back on its date ✅ pass live Section 1 of defer-channels-drive.txt: fm-fleet-snapshot at 2026-07-20 reports captain_actionable false, hold_bucket dated, hold_until 2026-08-01; at 2026-08-02 the same call is captain_actionable tru…
The chat channel answers a captain call with "later" and a date ✅ pass live Section 2 of defer-channels-drive.txt: fm-send.sh sample-chat-review --resolve-key sample-chat-defer --defer-until 2026-10-15 &#34;later, after the release&#34; leaves the call queued, held, dated 2026-10-1…
The chat preflight refuses an impossible calendar day before the answer is delivered ✅ pass live Section 2 of defer-channels-drive.txt: --defer-until 2026-09-31 exits with error: --defer-until requires a YYYY-MM-DD date: 2026-09-31; zero bytes reached the crew pane and the target call carries…
A board option carrying a date travels from the captain's click to a dated, recorded deferral ✅ pass live Section 3 of defer-channels-drive.txt: a real fm-bearings-board.sh build establishes and arms a live lavish-axi session; the shipped board script queues context `{"close":"defer","until":"2026-10-15…
The board card shows the captain the date the deferring answer commits the call to ✅ pass live Section 3 of defer-channels-drive.txt plus bearings-board-defer.html: the shipped board script renders the deferring option with deferred until 2026-10-15 beneath its hint while the three non-deferr…
Malformed, conflicting, and out-of-mode defer inputs are refused with the call untouched ✅ pass live Section 4 of defer-channels-drive.txt: --release with --defer-until is refused as mutually exclusive; 2026-09-31 and 2026-02-30 are refused as non-calendar days; a defer row with no date is skippe…
A postponement is not accepted as proof the call was answered ✅ pass live Section 5 of defer-channels-drive.txt: after a deferral and a bare out-of-band tasks-axi done, fm-captain-hold.sh verify exits 1 saying the call is neither held nor closed with a recorded answer;…
A deferral keeps a pending board re-check obligation open ✅ pass live Section 6 of defer-channels-drive.txt: reconcile list reports the same pending request and reconcile-requests: 1 both before and after the deferral, because the call itself stays open.
A secondmate deferral publishes one resolved line and re-announces nothing to the parent ✅ pass live Section 7 of defer-channels-drive.txt: the parent channel shows needs-decision [key=captain-hold-defer-call-1] followed by resolved [key=captain-hold-defer-call-1]: ... deferred until 2026-10-01 w…
  • bash tests/fm-captain-hold-lifecycle.test.sh (55 ok, 0 not ok) - covers test_keyed_defer_records_answer_and_dates_the_hold, test_deferred_answers_keep_pending_reconcile_requests, test_out_of_band_close_is_recordable, test_secondmate_home_publishes_holds_and_answers, test_bound_channel_answers_close_at_answer_time, test_chat_channel_feeds_the_same_keyed_answer_intake
  • bash tests/fm-bearings-board.test.sh - covers test_build_accepts_an_optional_option_date
  • bash tests/fm-bearings-board-render.test.sh - covers test_an_option_date_emits_the_dated_defer_answer_context and test_a_deferring_option_shows_the_date_it_commits_the_call_to
  • Manual base-commit reproduction: git archive 9a0e566 bin into a scratch tree, then fm-captain-hold.sh answers with a defer row and fm-captain-hold.sh answer --defer-until against a real tasks-axi home
  • Manual: bin/fm-captain-hold.sh answer &lt;id&gt; --decision-file &lt;file&gt; --defer-until &lt;date&gt; with tasks-axi show &lt;id&gt; --full
  • Manual: bin/fm-fleet-snapshot.sh --json at FM_SNAPSHOT_NOW before and after the defer date, and bin/fm-bearings-snapshot.sh --json on a deferral whose date has really elapsed
  • Manual: bin/fm-send.sh &lt;task&gt; --resolve-key &lt;key&gt; --defer-until &lt;date&gt; &#34;later, after the release&#34; with a valid and an impossible date, checking the delivery log and the call's records
  • Manual: bin/fm-bearings-board.sh build &lt;payload&gt; against real lavish-axi, node tests/assets/board-render-harness.mjs &lt;built board&gt; with and without a captain selection, bin/fm-procevent-lavish.sh answers &lt;captured result&gt;, bin/fm-captain-hold.sh answers --source &#34;captured board result&#34;
  • Manual guards: --release with --defer-until, --defer-until 2026-09-31, keyed rows defer\t2026-02-30, defer with no date, done\t&lt;date&gt;, release\t&lt;date&gt;, reconcile\tdefer\t&lt;date&gt;, and a defer against an already-closed call
  • Manual: tasks-axi done over a deferred call, then bin/fm-captain-hold.sh verify &lt;origin&gt; and bin/fm-captain-hold.sh answer repair
  • Manual: bin/fm-captain-hold.sh reconcile list before and after a deferral over a pending board request
  • Manual: secondmate-home parent channel (state/channel-mate.status) across a deferral and the later real answer
⚠️ **Document** - 1 info
  • ℹ️ docs/captain-hold-lifecycle.md:217 - Judgment call worth a follow-up, not a gap left in this change. The board's rendered surface has no owner document: tests/fm-bearings-board-render.test.sh was named in no documentation before this change, even though it pins captain-facing display behavior the bearings skill authors against (Underway name-first rows, Charted Next filed ordering, warning rows badged as repairs). This change added a new captain-facing display - a deferring option's date on the card and in the queued confirmation - so I recorded its evidence in docs/captain-hold-lifecycle.md's verification record, beside the board answer-path coverage it sits closest to, and added the suite to that record's refresh command list. That is the narrowest safe placement available now, but the board's rendered surface arguably belongs to a bearings-owned evidence surface rather than the captain-hold lifecycle document, and relocating the whole render suite's record there is a documentation consolidation outside this change's scope.
⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

Summary by Sourcery

Record dated captain deferrals consistently across all answer channels without closing the underlying decision.

New Features:

  • Record captain decision deferrals as dated, non-terminal answers through direct, chat, and board channels.
  • Expose deferral dates in Bearings board options and propagate them through captured answer contexts.

Bug Fixes:

  • Prevent deferred decisions from being treated as closed, losing pending reconcile obligations, or fabricating later answers.
  • Reject invalid, missing, conflicting, past, and same-day deferral dates before mutation or delivery.

Enhancements:

  • Preserve the original hold age, reason, and parent decision lifecycle across deferrals while keeping retries idempotent.
  • Add shared calendar validation and enforce unique board option values.

Documentation:

  • Update captain-hold, Bearings, secondmate, and agent guidance to define recorded deferrals and replace the manual re-hold workaround.

Tests:

  • Expand lifecycle, chat, process-event, board build, and board rendering coverage for dated deferrals and their validation.

@sourcery-ai

sourcery-ai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR makes “later” a dated, durable captain answer across direct, chat, and Bearings board channels: it records the captain’s words and date, preserves the existing hold’s lifecycle while postponing resurfacing, routes all channels through the shared keyed intake, and adds validation, documentation, and end-to-end regression coverage.

Sequence diagram for recording a dated captain deferral

sequenceDiagram
    actor Captain
    participant Channel as DirectChatOrBoard
    participant Intake as fm_captain_hold_answers
    participant Hold as tasks_axi
    participant Parent as ParentChannel

    Captain->>Channel: Select or submit later with date
    Channel->>Intake: keyed answer with defer and until
    Intake->>Intake: validate calendar day and decision digest
    Intake->>Intake: write deferred resolution with captain words
    Intake->>Hold: hold --until date with existing reason
    Hold-->>Intake: keep call open and preserve hold age
    Intake->>Parent: publish resolved deferred until date
    Intake-->>Channel: deferred id until date
Loading

Flow diagram for a deferred captain call lifecycle

flowchart LR
    A[Captain call held] --> B[Record captain words as deferred]
    B --> C[Keep existing captain hold and age basis]
    C --> D[Charted Next dated gate]
    D --> E{Deferral date reached?}
    E -- No --> D
    E -- Yes --> F[Return to Captain's Call]
    F --> G[Terminal answer or release]
Loading

File-Level Changes

Change Details Files
Added dated captain-decision deferrals as a first-class, recorded answer outcome while keeping the original call open and preserving its hold lifecycle.
  • Added direct answer --defer-until handling and keyed defer intake with a required fifth date field.
  • Persisted deferred resolution blocks, included the date in answer digests for idempotency, re-dated the existing captain hold, preserved its reason and age stamp, and kept reconcile requests open.
  • Updated verification and parent-channel behavior so deferrals are non-terminal and publish only the resolved deferral line.
bin/fm-captain-hold.sh
docs/captain-hold-lifecycle.md
.agents/skills/captain-hold-lifecycle/SKILL.md
docs/secondmate-parent-channel.md
Enabled dated deferrals across chat and captured board-answer channels through shared validation and the existing keyed intake.
  • Added shared real-calendar-day validation and chat --defer-until preflight, including rejection for status-log keys and invalid or conflicting inputs.
  • Relayed board/process-event close: defer and until fields into the keyed intake while retaining reconcile protections.
  • Updated send failure guidance and channel documentation for deferred outcomes.
bin/fm-classify-lib.sh
bin/fm-send.sh
bin/fm-procevent-lavish.sh
bin/fm-bearings-board.sh
AGENTS.md
Extended the Bearings board schema and renderer to represent option-specific deferral dates visibly and in queued answer context.
  • Validated optional option dates as actual calendar days during board builds.
  • Rendered deferred until <date> on deferring options and emitted versioned context with close: defer and until, overriding the card default only for that option.
  • Expanded the render harness to observe queued answer payloads and added board validation/render regression coverage.
.agents/skills/bearings/assets/board-template.html
.agents/skills/bearings/SKILL.md
tests/assets/board-render-harness.mjs
tests/fm-bearings-board.test.sh
tests/fm-bearings-board-render.test.sh
Expanded lifecycle and integration tests to cover deferral semantics, guards, replay behavior, and cross-channel delivery.
  • Covered live and expired holds, preserved age/reason, repeated deferrals with changed dates, out-of-band close repair, parent publication, and reconcile retention.
  • Added live validation evidence for direct CLI, chat, board, process-event, snapshot, and secondmate flows.
tests/fm-captain-hold-lifecycle.test.sh
.no-mistakes/evidence/fm/fm-decision-defer-close-mode/defer-channels-drive.txt
.no-mistakes/evidence/fm/fm-decision-defer-close-mode/captain-hold-lifecycle.tap.txt
.no-mistakes/evidence/fm/fm-decision-defer-close-mode/fm-bearings-board.tap.txt
.no-mistakes/evidence/fm/fm-decision-defer-close-mode/fm-bearings-board-render.tap.txt

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-21T01:50:19.607568Z bfcdcea Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="bin/fm-send.sh" line_range="778-783" />
<code_context>
     echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE, and no captain-held task '$k' or '$RESOLVE_TASK_ID-decision-$k' still open (already closed or mistyped). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2
     exit 1
   done
+  if [ -n "$RESOLVE_DEFER_UNTIL" ]; then
+    fm_valid_calendar_day "$RESOLVE_DEFER_UNTIL" || {
+      echo "error: --defer-until requires a YYYY-MM-DD date: $RESOLVE_DEFER_UNTIL" >&2
</code_context>
<issue_to_address>
**issue (bug_risk):** When the keyed intake records the deferral but fails while publishing the parent resolution, `fm-send.sh` reports that the captain-held task could not be closed and instructs the operator to run a plain `fm-captain-hold.sh answer`. Following that recovery instruction records a terminal answer and closes a call that the captain explicitly postponed.

**Triggers:** When a deferred chat answer reaches a captain-held task whose parent-resolution publication fails.

**Suggested fix:** Use defer-specific recovery text such as `fm-captain-hold.sh answer <id> --decision-file <file> --defer-until <date>`, and explain that the answer must not be resent or converted to a plain close.
</issue_to_address>

### Comment 2
<location path=".agents/skills/bearings/assets/board-template.html" line_range="569-574" />
<code_context>
+      var deferUntil = selectedOption && selectedOption.until ? selectedOption.until : "";
       var displayAnswer = value ? (note ? value + " - " + note : value) : note;
       if (!displayAnswer) return;
+      if (deferUntil) displayAnswer += " (deferred until " + deferUntil + ")";
       if (utf8ByteLength(displayAnswer) > 512) {
         answerLimit.textContent = "Answer is too long to queue (512 bytes maximum).";
         answerLimit.classList.add("is-visible");
</code_context>
<issue_to_address>
**issue (bug_risk):** The board's 512-byte queue guard counts the added ` (deferred until <date>)` display decoration, so a deferring answer with a note that fits the underlying 512-byte answer limit is rejected solely because of the date text. The same answer without a deferring option is accepted.

**Triggers:** When a deferring option is selected with a large but valid freeform note near the 512-byte limit.

**Suggested fix:** Perform the byte-limit check on the undecorated answer payload, then append the deferral date only when constructing the captain-facing prompt and confirmation.

```suggestion
      var answerPayload = value ? (note ? value + " - " + note : value) : note;
      if (!answerPayload) return;
      if (utf8ByteLength(answerPayload) > 512) {
        answerLimit.textContent = "Answer is too long to queue (512 bytes maximum).";
        answerLimit.classList.add("is-visible");
        return;
      }
      var displayAnswer = answerPayload;
      if (deferUntil) displayAnswer += " (deferred until " + deferUntil + ")";
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread bin/fm-send.sh Outdated
Comment thread .agents/skills/bearings/assets/board-template.html

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e5ff17f4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/fm-send.sh
echo "error: duplicate --defer-until" >&2
exit 1
}
RESOLVE_DEFER_UNTIL=${1#--defer-until=}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject empty defer dates before delivering the answer

When a caller passes an empty expansion such as --defer-until="$date", this assignment leaves RESOLVE_DEFER_UNTIL indistinguishable from an absent flag. The later date validation is skipped and fm_send_feed_resolved_holds emits an ordinary answer row, so the message is delivered and the captain-held task is closed instead of being deferred or rejected. Track whether the flag was supplied separately and validate even an empty value before delivery.

AGENTS.md reference: AGENTS.md:L534-L534

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8220e2d6998ae67253f87a73db40465714ab8d47. RESOLVE_DEFER_UNTIL_SET now records that either flag spelling was supplied even when its value is empty, and validation runs before delivery. tests/fm-send-resolve-key.test.sh covers empty spaced and equals forms, duplicate detection, and the no-send/no-close behavior.

@twilwa

twilwa commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Validation triage for final head bfcdceac23e93eed36b73993ccc1327345b207d2

The four changed test files pass. Their combined runtime was 17m42.691s. A safe serial suite ran 169 files in 3h24m07.370s. It recorded 15 non-zero exits and four legitimate prerequisite skips.

I compared each non-zero exit with origin/main at 4797c99e3faa713b65bad29bffdb92a17dba5128 in a clean isolated clone. The result was 0 branch regressions, 13 failures reproduced on origin/main, and 2 live-home leaks. The two live-home cases were fm-turnend-guard.test.sh and fm-watcher-lock.test.sh; both passed their failing assertions after removing the inherited live FM_HOME. The relevant source and test files are byte-identical between this branch and origin/main.

No test, test selection, custody rule, or pipeline requirement was changed or relaxed. Per TES-97, this lane is shipping through the direct-PR path without another no-mistakes run.

@twilwa

twilwa commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bfcdceac23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/fm-captain-hold.sh Outdated
write_resolution_record "$id" "$outcome" "$body" "$defer_until"
if [ -n "$defer_until" ]; then
defer_answered "$id" "$defer_until" "$defer_reason"
publish_parent_resolution "$id" "$occurrence" "deferred until $defer_until"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reopen deferred secondmate calls when their date arrives

When a secondmate-owned call is deferred, this publishes resolved for its only parent-channel needs-decision, but no path publishes a replacement when hold_until arrives. The parent fold therefore considers the call closed indefinitely; the newly actionable call exists only in the secondmate backlog and may remain invisible until someone explicitly runs Bearings or inspects that home. Preserve a dated/open parent decision or arrange a due-date parent-channel publication so the decision actually resurfaces.

AGENTS.md reference: AGENTS.md:L519-L519

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change here: this finding treats the parent status fold as the current-state owner, but it is only the idempotent event receipt. bin/fm-fleet-snapshot.sh reads every registered secondmate's structured backlog and recomputes hold_bucket/captain_actionable from hold_until; bin/fm-bearings-snapshot.sh then promotes that secondmate backlog hold into Captain's Call when the date is due. The call therefore resurfaces through the canonical review path even though the original parent occurrence is resolved. This is the same polling contract as bare hold --until; there is no timed parent-channel wake to preserve. tests/fm-bearings-snapshot.test.sh covers both registered-secondmate structured captain holds and dated-to-due captain-call projection. Republishing a new status decision here would add a second lifecycle owner and would make the postponed question live before its date.

Comment thread bin/fm-send.sh
echo "error: --defer-until requires a YYYY-MM-DD date: $RESOLVE_DEFER_UNTIL" >&2
exit 1
}
RESOLVE_DEFER_TODAY=$(fm_utc_calendar_day "${FM_CAPTAIN_HOLD_NOW:-}") || {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the preflight day when recording a delivered defer

If a send crosses midnight UTC—especially a remote send, which may wait up to its 30-second transport bound—this preflight can accept tomorrow's date, deliver the answer, and then fm-captain-hold.sh answers recomputes the date after midnight and rejects the same value as today. The captain's words have then reached the worker without being recorded or deferring the call. Carry the preflight observation into the post-delivery intake so both checks use one UTC boundary.

AGENTS.md reference: AGENTS.md:L534-L534

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe2f0784. fm-send.sh now carries its validated preflight UTC day into the sole keyed-answer intake, and both answers and its answer subprocess validate and reuse that observation instead of reading the clock again after delivery. The lifecycle regression advances only date -u +%Y-%m-%d between preflight and intake: it failed before the fix with the answer already delivered, and now records the deferred resolution and accepted date. The full captain-hold lifecycle suite, the full fm-send --resolve-key suite, lint, and documentation-audience checks pass.

@twilwa
twilwa merged commit 0ec2f6c into main Sep 21, 2026
19 of 21 checks passed
twilwa added a commit that referenced this pull request Sep 25, 2026
* Add keyed decision defer mode

* no-mistakes(review): Fix defer date identity, hold age, parent channel, reporting

* no-mistakes(review): Derive board defer from the option's until alone

* no-mistakes(review): Show the defer date on the board card

* Fix deferred decision lifecycle edges

* no-mistakes(review): Drop fabricated defer hold reason fallback

* no-mistakes(document): Correct stale captain-defer docs for the recorded answer path

* Fix defer intake failure edges

* Require future dates for decision defers

* no-mistakes(review): Narrow UTC day parsing; fix elapsed-defer recovery guidance

* no-mistakes(review): Refuse duplicate board option values; fix defer recovery wording

* Stabilize chat defer hold assertion

* Keep chat defer date stable across midnight

* Refactor defer validation for bounded lint
twilwa added a commit that referenced this pull request Sep 25, 2026
…merge handoff (#16)

* feat(bin): pin resolver model and persist dispatch decision receipts (#1)

* Fix dispatch resolver model and receipts

* no-mistakes(review): Drop model-drift branch, harden receipt lock and brief join

* no-mistakes(review): Scope receipt recording to clear, report failed joins, measure latency

* no-mistakes(review): Narrow dispatch clause and concurrency test, shrink lock budget

* no-mistakes(review): Accept --project on the join, assert drop-or-append concurrency

* no-mistakes(review): Split lock budgets by path, drop receipt size bound

* no-mistakes(review): Record brief_path as spelled, drop abs_path normalization

* no-mistakes(review): Pin model in contract, bound receipt latency, record reason

* no-mistakes(review): Report dropped resolution receipts, project profile agreement, drop dispatch_id

* no-mistakes(review): Enforce append-only cmp, complete join example, govern latency bound

* no-mistakes(review): Keep no-rules exit 0 without jq, dedupe error default

* no-mistakes(review): Refuse symlinked receipts path, drop dead no_rules jq argument

* no-mistakes(document): Document receipt identity, symlink refusal, jq exit narrowing

* fix(bin): refuse unknown flags and stray --key arguments in fm-send (#3)

* fix(bin): refuse an unrecognised fm-send flag instead of sending it as text

fm-send's option loop ended in an unconditional `*) break ;;`, so any token
it did not recognise - including one obviously shaped as a flag - fell out of
the loop and became the positional message body. A steer invoked with a flag
that does not exist was durably written into a live worker's steering inbox as
the literal flag string while fm-send exited 0, so the worker was mis-steered
and the caller got a success code and no diagnostic.

The accepted set is now an allowlist rather than a pattern. --key is a real,
supported flag parsed after this loop and must keep falling through it
untouched, so a blanket "starts with -- and matched no case arm, therefore
refuse" rule would have broken it.

A bare -- ends flag parsing, which is how a message whose text starts with --
is sent. That separator is threaded to the two --key dispatch points so text
after it is text everywhere rather than being re-parsed as a flag. A
single-dash word was never a flag here and still needs no separator.

The refusal exits before anything is marked, recorded, rung, or typed, the
same discipline the header already applies to an empty message.

* no-mistakes(review): drop -- end-of-flags separator, keep pure flag allowlist

* no-mistakes(document): document fm-send's flag allowlist and leading-`--` message limit

* docs(bin): drop the flag-allowlist commentary from fm-send's source

The header block in bin/fm-send.sh is that script's documented contract.
Recording the no-end-of-flags-separator limitation there amends that
contract and turns a deliberate, narrow behaviour change into a
documented guarantee the project would then owe. The rationale comment
above the option loop goes for the same reason: the limitation describes
a decision, which belongs in the pull request, not in the source, where
it reads as a promise.

Removes only those thirteen comment lines. The refusal itself is
unchanged: the option loop remains a pure allowlist, --key still falls
through to its own plane untouched, there is no end-of-flags handling,
the usage line is unmodified, and the tests are untouched.

* fix(bin): refuse trailing arguments after fm-send's --key

The option loop breaks at --key without consuming what follows it, and
the key path reads only the key itself, so every remaining argument was
discarded in silence while the key was still delivered and the command
still exited 0. `fm-send.sh lane --key Enter --not-a-real-flag` sent
Enter and reported success. That is the same silent-delivery shape the
unknown-flag refusal in this change exists to remove, so the key path
contradicted the contract on that one path.

The same ordering bypassed the --fire-and-forget incompatibility:
FIRE_AND_FORGET_ID is only set when the flag precedes --key, so
`--key Enter --fire-and-forget x` passed both existing guards.

The key path now refuses any trailing argument before delivering the
key, naming the offending token in the wording already used for an
unknown flag in flag position, and names --fire-and-forget specifically
so that incompatibility holds on either ordering. Adds regression
coverage for both orderings and for a trailing plain word; both new
tests fail before this commit and pass after it.

* Add head-keyed PR review and post-merge QA gates (#4)

* Add head-keyed PR review policy ledger

* Add post-merge browser QA gate

* Fix PR review and post-merge gates

* Close remaining PR review gate gaps

* Harden migration risk and QA evidence parsing

* Close PR review guard bypasses

* Tighten review evidence boundaries

* Bind final review authorization

* Invalidate stale review dispositions

* Harden review evidence validation

* feat(bin): record captain decision deferrals as dated answers (#2)

* Add keyed decision defer mode

* no-mistakes(review): Fix defer date identity, hold age, parent channel, reporting

* no-mistakes(review): Derive board defer from the option's until alone

* no-mistakes(review): Show the defer date on the board card

* Fix deferred decision lifecycle edges

* no-mistakes(review): Drop fabricated defer hold reason fallback

* no-mistakes(document): Correct stale captain-defer docs for the recorded answer path

* Fix defer intake failure edges

* Require future dates for decision defers

* no-mistakes(review): Narrow UTC day parsing; fix elapsed-defer recovery guidance

* no-mistakes(review): Refuse duplicate board option values; fix defer recovery wording

* Stabilize chat defer hold assertion

* Keep chat defer date stable across midnight

* Refactor defer validation for bounded lint

* fix(bin): route ask-user gates back to firstmate as needs-decision (#5)

* fix(brief): forbid validation auto-accept

* no-mistakes(review): restore fleet-wide --yes ban, add ask-user routing sentence

* no-mistakes(ci): Fixed a flaky test that failed the "Behavior portable serial 4" shard. Failure: tests/fm-pi-branch-extension.test.sh -> test_captain_outcome_processing_turn_is_sequence_keyed_and_re_presented, with "Error: supervision branch prompt settled but produced no durable outcome for its claimed wake rows" (thrown at .pi/extensions/fm-branch-supervision.ts:1548). Nothing in this PR's diff (the --yes DoD line, the harness-adapters sentence, three brief assertions) touches that extension or test; the other two check runs on the same head commit (99a0187) passed. It is a pre-existing race that surfaces on a slow/loaded runner. Root cause: in fm-branch-supervision.ts a wake builds the branch session (ensureBranch), then runs several awaited subprocesses (flushMirror, actingAsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, away-posture read-back) and only then snapshots reportRevisionBeforePrompt immediately before session.prompt(...); after the prompt settles it requires that revision to have advanced. The test synchronized on the wrong point: `settle(() => __fmSessions.length === 2, "replacement branch session")`. Session creation precedes that snapshot, so when the extension's pre-prompt work is slower than the test's report append, report2's durable append lands before the snapshot and the wake rejects its own settled prompt as outcome-less. The routine wake earlier in the same test already waits on __fmPrompts.length === 1 and is unaffected. Fix (tests/fm-pi-branch-extension.test.sh:1377, 9 insertions / 1 deletion): wait for the wake prompt as well as the replacement session, matching the routine wake's own idiom, with a comment naming why the built session is not the synchronization point. No production code changed; no new machinery. Verification: reproduced the exact CI error deterministically by temporarily injecting a delay ahead of reportRevisionBeforePrompt (delays 100/200/300/400/500/700 ms all failed with the identical message); that injection was reverted (git status shows only the test file modified). With the fix the test passes under injected delays of 100, 400 and 1500 ms. Full file run: exit 0, 45 tests passing. 24 parallel runs of the target test: 24/24 pass. shellcheck -x on the changed file is clean, and this PR's own tests (tests/fm-brief.test.sh, tests/fm-ask-user-authority.test.sh) still pass. The change is left uncommitted in the worktree, since prior rounds' commits on this branch were made by the executor rather than this phase

* refactor(agents): move conditional workflows into skills (#6)

* docs: audit AGENTS.md size and ownership

* docs: slim always-loaded Firstmate contract

* no-mistakes(review): drop audit doc, dedupe skill triggers, fix stale pointers

* no-mistakes(review): fix yolo brief split, state guard, and stale pointers

* no-mistakes(review): restore backstop wake duty, dedupe trigger, repoint pointers

* no-mistakes(document): Repoint stale brief guidance comment

* docs: cover omitted conditional skill load triggers

* fix: bind resolver requests to immutable brief snapshots

* fix(bin): bound session-start cleanup, defer summary publication, and avoid jq argv overflow (#10)

* fix: bound startup reconciliation and large fleet input

* no-mistakes(review): Drop redundant contribution-input EXIT trap in fleet snapshot

* no-mistakes(test): Widen cleanup deadline test budget to avoid load flakes

* no-mistakes(document): Document startup summary deferral and herdr cleanup deadline

* no-mistakes(ci): Lint 1 failed because ShellCheck SC2329 ("function never invoked") fired at tests/fm-herdr-session-cleanup.test.sh:356. That line is a subshell copy of fixture_workspaces that replaces the file's main version. The fake herdr command calls fixture_workspaces indirectly when it answers `workspace list` and `api snapshot`, and ShellCheck can't see that call. The fix is one comment line above the replacement: `# shellcheck disable=SC2329 # invoked indirectly by the fake herdr workspace list.` The same file already does this for its other indirectly-called replacements (lines 43 and 49), as do tests/fm-daemon.test.sh and tests/fm-bootstrap.test.sh. No behavior changed. Checked locally: `bin/fm-lint.sh tests/fm-herdr-session-cleanup.test.sh` passes with pinned ShellCheck 0.11.0 and full extended analysis, and `bash tests/fm-herdr-session-cleanup.test.sh` passes every test, including the journal-read-count, deadline, lock and identity tests. The change is not committed

* fix: reclaim cleanup locks after hard timeout

* no-mistakes(review): Use shared fm_lock receipts lock; synthesize ledger fixtures

(cherry picked from commit 5118fbce1f5ba294d74ec0862913a5c4bce7129d)

* no-mistakes(document): Document cleanup lock reclaim and receipt state path

(cherry picked from commit 53740853205c45ae4c8b835656224a60708998d6)

* no-mistakes(review): Skip torn receipt lines, clear lock record, list --defer-until

* no-mistakes(review): Start each receipt append on its own line

* no-mistakes(document): Document torn receipt-line handling in dispatch receipts

* no-mistakes(document): Mark dispatch receipt cost figures historical, pending remeasurement

* no-mistakes(ci): ci-2 (Lint 2), caused by this PR, fixed. Invariant: a function only ever called by a trap must carry `# shellcheck disable=SC2329`, or the full-analysis lint fails. This PR added `reap_zombie_owner` in tests/fm-herdr-session-cleanup.test.sh, called only by `trap reap_zombie_owner EXIT`, without that directive. A local run of `bin/fm-lint.sh --partition 2of2` with the pinned ShellCheck 0.11.0 exited 1 with that single SC2329 finding (line 454). In CI the job was stopped (exit 143) at about 10.5 minutes, before it printed the finding; main's partition 2 took 441 s. Fix: added the directive, worded like the file's existing ones (lines 43, 49, 365). No other sites: that was the only partition-2 finding, and partition 1 passed in CI. Verified: `shellcheck --norc --external-sources -- tests/fm-herdr-session-cleanup.test.sh` exits 0. Not rerun: the full 24-minute partition after the fix, and the test itself (Test stays skipped). The fix is uncommitted in the worktree. ci-1 (Behavior portable serial 3), not caused by this PR, flaky, no change. The only failure is tests/fm-watch-checkpoint.test.sh, "watch lock pid survived quiet checkpoint timeout". bin/fm-watch.sh takes its singleton lock at line 2327 but only sets up its cleanup-on-exit trap at 2456; a timeout in between leaves .watch.lock/pid behind. Reproduced locally: `timeout 0.6`–`1.0` leaves the pid file, 0.2/0.4/1.5/2 s do not. fm-watch.sh, fm-watch-checkpoint.sh and the test are unchanged from base 040b337. The only changed file the watcher uses (fm-captain-hold.sh) runs at wake time, not during startup. The same code passed on main. Closing the gap means changing upstream watcher code, beyond this carry-forward; worth fixing separately. ci-3 (PR must be raised via no-mistakes), not caused by the code, no change. It fails with "Required no-mistakes pipeline steps are not completed: test (status=skipped)", which is expected because the user intent keeps Test skipped. ci-4 (Review changed files (advisory)), external, no change. It fails with "No OpenRouter API key configured": a missing repository secret, not a code defect

* fix: make reviewed-head merge handoff opt-in

* no-mistakes(review): Keep collector inline feedback; refuse held direct merges

* no-mistakes(review): Attribute ledger merge checks; name configured high-stakes model
twilwa added a commit that referenced this pull request Sep 26, 2026
…'s Git common directory (#8)

* feat(bin): pin resolver model and persist dispatch decision receipts (#1)

* Fix dispatch resolver model and receipts

* no-mistakes(review): Drop model-drift branch, harden receipt lock and brief join

* no-mistakes(review): Scope receipt recording to clear, report failed joins, measure latency

* no-mistakes(review): Narrow dispatch clause and concurrency test, shrink lock budget

* no-mistakes(review): Accept --project on the join, assert drop-or-append concurrency

* no-mistakes(review): Split lock budgets by path, drop receipt size bound

* no-mistakes(review): Record brief_path as spelled, drop abs_path normalization

* no-mistakes(review): Pin model in contract, bound receipt latency, record reason

* no-mistakes(review): Report dropped resolution receipts, project profile agreement, drop dispatch_id

* no-mistakes(review): Enforce append-only cmp, complete join example, govern latency bound

* no-mistakes(review): Keep no-rules exit 0 without jq, dedupe error default

* no-mistakes(review): Refuse symlinked receipts path, drop dead no_rules jq argument

* no-mistakes(document): Document receipt identity, symlink refusal, jq exit narrowing

* fix(bin): refuse unknown flags and stray --key arguments in fm-send (#3)

* fix(bin): refuse an unrecognised fm-send flag instead of sending it as text

fm-send's option loop ended in an unconditional `*) break ;;`, so any token
it did not recognise - including one obviously shaped as a flag - fell out of
the loop and became the positional message body. A steer invoked with a flag
that does not exist was durably written into a live worker's steering inbox as
the literal flag string while fm-send exited 0, so the worker was mis-steered
and the caller got a success code and no diagnostic.

The accepted set is now an allowlist rather than a pattern. --key is a real,
supported flag parsed after this loop and must keep falling through it
untouched, so a blanket "starts with -- and matched no case arm, therefore
refuse" rule would have broken it.

A bare -- ends flag parsing, which is how a message whose text starts with --
is sent. That separator is threaded to the two --key dispatch points so text
after it is text everywhere rather than being re-parsed as a flag. A
single-dash word was never a flag here and still needs no separator.

The refusal exits before anything is marked, recorded, rung, or typed, the
same discipline the header already applies to an empty message.

* no-mistakes(review): drop -- end-of-flags separator, keep pure flag allowlist

* no-mistakes(document): document fm-send's flag allowlist and leading-`--` message limit

* docs(bin): drop the flag-allowlist commentary from fm-send's source

The header block in bin/fm-send.sh is that script's documented contract.
Recording the no-end-of-flags-separator limitation there amends that
contract and turns a deliberate, narrow behaviour change into a
documented guarantee the project would then owe. The rationale comment
above the option loop goes for the same reason: the limitation describes
a decision, which belongs in the pull request, not in the source, where
it reads as a promise.

Removes only those thirteen comment lines. The refusal itself is
unchanged: the option loop remains a pure allowlist, --key still falls
through to its own plane untouched, there is no end-of-flags handling,
the usage line is unmodified, and the tests are untouched.

* fix(bin): refuse trailing arguments after fm-send's --key

The option loop breaks at --key without consuming what follows it, and
the key path reads only the key itself, so every remaining argument was
discarded in silence while the key was still delivered and the command
still exited 0. `fm-send.sh lane --key Enter --not-a-real-flag` sent
Enter and reported success. That is the same silent-delivery shape the
unknown-flag refusal in this change exists to remove, so the key path
contradicted the contract on that one path.

The same ordering bypassed the --fire-and-forget incompatibility:
FIRE_AND_FORGET_ID is only set when the flag precedes --key, so
`--key Enter --fire-and-forget x` passed both existing guards.

The key path now refuses any trailing argument before delivering the
key, naming the offending token in the wording already used for an
unknown flag in flag position, and names --fire-and-forget specifically
so that incompatibility holds on either ordering. Adds regression
coverage for both orderings and for a trailing plain word; both new
tests fail before this commit and pass after it.

* Add head-keyed PR review and post-merge QA gates (#4)

* Add head-keyed PR review policy ledger

* Add post-merge browser QA gate

* Fix PR review and post-merge gates

* Close remaining PR review gate gaps

* Harden migration risk and QA evidence parsing

* Close PR review guard bypasses

* Tighten review evidence boundaries

* Bind final review authorization

* Invalidate stale review dispositions

* Harden review evidence validation

* feat(bin): record captain decision deferrals as dated answers (#2)

* Add keyed decision defer mode

* no-mistakes(review): Fix defer date identity, hold age, parent channel, reporting

* no-mistakes(review): Derive board defer from the option's until alone

* no-mistakes(review): Show the defer date on the board card

* Fix deferred decision lifecycle edges

* no-mistakes(review): Drop fabricated defer hold reason fallback

* no-mistakes(document): Correct stale captain-defer docs for the recorded answer path

* Fix defer intake failure edges

* Require future dates for decision defers

* no-mistakes(review): Narrow UTC day parsing; fix elapsed-defer recovery guidance

* no-mistakes(review): Refuse duplicate board option values; fix defer recovery wording

* Stabilize chat defer hold assertion

* Keep chat defer date stable across midnight

* Refactor defer validation for bounded lint

* fix(bin): route ask-user gates back to firstmate as needs-decision (#5)

* fix(brief): forbid validation auto-accept

* no-mistakes(review): restore fleet-wide --yes ban, add ask-user routing sentence

* no-mistakes(ci): Fixed a flaky test that failed the "Behavior portable serial 4" shard. Failure: tests/fm-pi-branch-extension.test.sh -> test_captain_outcome_processing_turn_is_sequence_keyed_and_re_presented, with "Error: supervision branch prompt settled but produced no durable outcome for its claimed wake rows" (thrown at .pi/extensions/fm-branch-supervision.ts:1548). Nothing in this PR's diff (the --yes DoD line, the harness-adapters sentence, three brief assertions) touches that extension or test; the other two check runs on the same head commit (99a0187) passed. It is a pre-existing race that surfaces on a slow/loaded runner. Root cause: in fm-branch-supervision.ts a wake builds the branch session (ensureBranch), then runs several awaited subprocesses (flushMirror, actingAsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, away-posture read-back) and only then snapshots reportRevisionBeforePrompt immediately before session.prompt(...); after the prompt settles it requires that revision to have advanced. The test synchronized on the wrong point: `settle(() => __fmSessions.length === 2, "replacement branch session")`. Session creation precedes that snapshot, so when the extension's pre-prompt work is slower than the test's report append, report2's durable append lands before the snapshot and the wake rejects its own settled prompt as outcome-less. The routine wake earlier in the same test already waits on __fmPrompts.length === 1 and is unaffected. Fix (tests/fm-pi-branch-extension.test.sh:1377, 9 insertions / 1 deletion): wait for the wake prompt as well as the replacement session, matching the routine wake's own idiom, with a comment naming why the built session is not the synchronization point. No production code changed; no new machinery. Verification: reproduced the exact CI error deterministically by temporarily injecting a delay ahead of reportRevisionBeforePrompt (delays 100/200/300/400/500/700 ms all failed with the identical message); that injection was reverted (git status shows only the test file modified). With the fix the test passes under injected delays of 100, 400 and 1500 ms. Full file run: exit 0, 45 tests passing. 24 parallel runs of the target test: 24/24 pass. shellcheck -x on the changed file is clean, and this PR's own tests (tests/fm-brief.test.sh, tests/fm-ask-user-authority.test.sh) still pass. The change is left uncommitted in the worktree, since prior rounds' commits on this branch were made by the executor rather than this phase

* fix(spawn): bind worker pool allocations to clone custody

* no-mistakes(ci): Updated the verified CI Treehouse pin from v2.0.1 to v2.3.0 with official platform checksums. The Herdr failures were caused by v2.0.1 lacking the required `--root` capability. Verified installer download/checksum/version, `--root` support, lint, clone-custody regression, and dispatch-resolve regression. The portable failure was an unrelated transient broken-pipe race in unchanged code and passed locally

* no-mistakes(ci): Fixed the flaky broken-pipe failure in bin/fm-quota-axi-lib.sh by replacing the private process-substitution lookup with a direct case mapping. This preserves all provider mappings while preventing an early consumer exit from closing the producer pipe and leaking `printf: write error: Broken pipe` to stderr. Verified with tests/fm-dispatch-resolve.test.sh, bin/fm-lint.sh, and git diff --check; all passed

* no-mistakes(review): Move pool root outside homes; drop fork fixtures

* no-mistakes(document): Point architecture doc at real Treehouse custody regression

* no-mistakes(ci): ci-1 (Behavior portable serial 8): tests/fm-tangle-guard.test.sh still expected the old `treehouse get --root '<root>'` command, but this PR sends `treehouse --root '<root>' get` (bin/fm-spawn.sh:4049; `--root` is a global Treehouse flag, so both orders are valid). Updated the test to expect the new order; no production code changed. The failure reproduced locally before the fix and the script exits 0 after it. No other test, doc or script uses the old order. ci-2 (PR must be raised via no-mistakes): attestation failure because the pipeline's required `test` step is skipped (the Test agent timed out and the re-run was declined). Not caused by the code; the outer pipeline must re-run and complete the test step

---------

Co-authored-by: Firstmate Crew <crew@firstmate.local>
twilwa added a commit that referenced this pull request Sep 26, 2026
…mary landing (#26)

* feat(bin): pin resolver model and persist dispatch decision receipts (#1)

* Fix dispatch resolver model and receipts

* no-mistakes(review): Drop model-drift branch, harden receipt lock and brief join

* no-mistakes(review): Scope receipt recording to clear, report failed joins, measure latency

* no-mistakes(review): Narrow dispatch clause and concurrency test, shrink lock budget

* no-mistakes(review): Accept --project on the join, assert drop-or-append concurrency

* no-mistakes(review): Split lock budgets by path, drop receipt size bound

* no-mistakes(review): Record brief_path as spelled, drop abs_path normalization

* no-mistakes(review): Pin model in contract, bound receipt latency, record reason

* no-mistakes(review): Report dropped resolution receipts, project profile agreement, drop dispatch_id

* no-mistakes(review): Enforce append-only cmp, complete join example, govern latency bound

* no-mistakes(review): Keep no-rules exit 0 without jq, dedupe error default

* no-mistakes(review): Refuse symlinked receipts path, drop dead no_rules jq argument

* no-mistakes(document): Document receipt identity, symlink refusal, jq exit narrowing

* fix(bin): refuse unknown flags and stray --key arguments in fm-send (#3)

* fix(bin): refuse an unrecognised fm-send flag instead of sending it as text

fm-send's option loop ended in an unconditional `*) break ;;`, so any token
it did not recognise - including one obviously shaped as a flag - fell out of
the loop and became the positional message body. A steer invoked with a flag
that does not exist was durably written into a live worker's steering inbox as
the literal flag string while fm-send exited 0, so the worker was mis-steered
and the caller got a success code and no diagnostic.

The accepted set is now an allowlist rather than a pattern. --key is a real,
supported flag parsed after this loop and must keep falling through it
untouched, so a blanket "starts with -- and matched no case arm, therefore
refuse" rule would have broken it.

A bare -- ends flag parsing, which is how a message whose text starts with --
is sent. That separator is threaded to the two --key dispatch points so text
after it is text everywhere rather than being re-parsed as a flag. A
single-dash word was never a flag here and still needs no separator.

The refusal exits before anything is marked, recorded, rung, or typed, the
same discipline the header already applies to an empty message.

* no-mistakes(review): drop -- end-of-flags separator, keep pure flag allowlist

* no-mistakes(document): document fm-send's flag allowlist and leading-`--` message limit

* docs(bin): drop the flag-allowlist commentary from fm-send's source

The header block in bin/fm-send.sh is that script's documented contract.
Recording the no-end-of-flags-separator limitation there amends that
contract and turns a deliberate, narrow behaviour change into a
documented guarantee the project would then owe. The rationale comment
above the option loop goes for the same reason: the limitation describes
a decision, which belongs in the pull request, not in the source, where
it reads as a promise.

Removes only those thirteen comment lines. The refusal itself is
unchanged: the option loop remains a pure allowlist, --key still falls
through to its own plane untouched, there is no end-of-flags handling,
the usage line is unmodified, and the tests are untouched.

* fix(bin): refuse trailing arguments after fm-send's --key

The option loop breaks at --key without consuming what follows it, and
the key path reads only the key itself, so every remaining argument was
discarded in silence while the key was still delivered and the command
still exited 0. `fm-send.sh lane --key Enter --not-a-real-flag` sent
Enter and reported success. That is the same silent-delivery shape the
unknown-flag refusal in this change exists to remove, so the key path
contradicted the contract on that one path.

The same ordering bypassed the --fire-and-forget incompatibility:
FIRE_AND_FORGET_ID is only set when the flag precedes --key, so
`--key Enter --fire-and-forget x` passed both existing guards.

The key path now refuses any trailing argument before delivering the
key, naming the offending token in the wording already used for an
unknown flag in flag position, and names --fire-and-forget specifically
so that incompatibility holds on either ordering. Adds regression
coverage for both orderings and for a trailing plain word; both new
tests fail before this commit and pass after it.

* Add head-keyed PR review and post-merge QA gates (#4)

* Add head-keyed PR review policy ledger

* Add post-merge browser QA gate

* Fix PR review and post-merge gates

* Close remaining PR review gate gaps

* Harden migration risk and QA evidence parsing

* Close PR review guard bypasses

* Tighten review evidence boundaries

* Bind final review authorization

* Invalidate stale review dispositions

* Harden review evidence validation

* feat(bin): record captain decision deferrals as dated answers (#2)

* Add keyed decision defer mode

* no-mistakes(review): Fix defer date identity, hold age, parent channel, reporting

* no-mistakes(review): Derive board defer from the option's until alone

* no-mistakes(review): Show the defer date on the board card

* Fix deferred decision lifecycle edges

* no-mistakes(review): Drop fabricated defer hold reason fallback

* no-mistakes(document): Correct stale captain-defer docs for the recorded answer path

* Fix defer intake failure edges

* Require future dates for decision defers

* no-mistakes(review): Narrow UTC day parsing; fix elapsed-defer recovery guidance

* no-mistakes(review): Refuse duplicate board option values; fix defer recovery wording

* Stabilize chat defer hold assertion

* Keep chat defer date stable across midnight

* Refactor defer validation for bounded lint

* fix(bin): route ask-user gates back to firstmate as needs-decision (#5)

* fix(brief): forbid validation auto-accept

* no-mistakes(review): restore fleet-wide --yes ban, add ask-user routing sentence

* no-mistakes(ci): Fixed a flaky test that failed the "Behavior portable serial 4" shard. Failure: tests/fm-pi-branch-extension.test.sh -> test_captain_outcome_processing_turn_is_sequence_keyed_and_re_presented, with "Error: supervision branch prompt settled but produced no durable outcome for its claimed wake rows" (thrown at .pi/extensions/fm-branch-supervision.ts:1548). Nothing in this PR's diff (the --yes DoD line, the harness-adapters sentence, three brief assertions) touches that extension or test; the other two check runs on the same head commit (99a0187) passed. It is a pre-existing race that surfaces on a slow/loaded runner. Root cause: in fm-branch-supervision.ts a wake builds the branch session (ensureBranch), then runs several awaited subprocesses (flushMirror, actingAsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, away-posture read-back) and only then snapshots reportRevisionBeforePrompt immediately before session.prompt(...); after the prompt settles it requires that revision to have advanced. The test synchronized on the wrong point: `settle(() => __fmSessions.length === 2, "replacement branch session")`. Session creation precedes that snapshot, so when the extension's pre-prompt work is slower than the test's report append, report2's durable append lands before the snapshot and the wake rejects its own settled prompt as outcome-less. The routine wake earlier in the same test already waits on __fmPrompts.length === 1 and is unaffected. Fix (tests/fm-pi-branch-extension.test.sh:1377, 9 insertions / 1 deletion): wait for the wake prompt as well as the replacement session, matching the routine wake's own idiom, with a comment naming why the built session is not the synchronization point. No production code changed; no new machinery. Verification: reproduced the exact CI error deterministically by temporarily injecting a delay ahead of reportRevisionBeforePrompt (delays 100/200/300/400/500/700 ms all failed with the identical message); that injection was reverted (git status shows only the test file modified). With the fix the test passes under injected delays of 100, 400 and 1500 ms. Full file run: exit 0, 45 tests passing. 24 parallel runs of the target test: 24/24 pass. shellcheck -x on the changed file is clean, and this PR's own tests (tests/fm-brief.test.sh, tests/fm-ask-user-authority.test.sh) still pass. The change is left uncommitted in the worktree, since prior rounds' commits on this branch were made by the executor rather than this phase

* fix(spawn): bind worker pool allocations to clone custody

* no-mistakes(ci): Updated the verified CI Treehouse pin from v2.0.1 to v2.3.0 with official platform checksums. The Herdr failures were caused by v2.0.1 lacking the required `--root` capability. Verified installer download/checksum/version, `--root` support, lint, clone-custody regression, and dispatch-resolve regression. The portable failure was an unrelated transient broken-pipe race in unchanged code and passed locally

* no-mistakes(ci): Fixed the flaky broken-pipe failure in bin/fm-quota-axi-lib.sh by replacing the private process-substitution lookup with a direct case mapping. This preserves all provider mappings while preventing an early consumer exit from closing the producer pipe and leaking `printf: write error: Broken pipe` to stderr. Verified with tests/fm-dispatch-resolve.test.sh, bin/fm-lint.sh, and git diff --check; all passed

* feat(secondmate): seed local-only projects as bound child clones with primary-owned landing

A local-only project has no forge, so a secondmate home could not hold one at
all: bin/fm-home-seed.sh refused it and the routing prose sent that work back to
the primary. Seed it instead as an independent local clone of the primary's own
clone, pinned to its current default-branch commit, with no origin, no
publication remote, no borrowed object storage and no no-mistakes
initialization, recorded by a durable versioned binding inside the existing seed
transaction. Fleet sync keeps skipping it and the whole-home remote route still
refuses it.

Custody splits along the same line the design drew. The child keeps its task,
branch, worktree and endpoint; the landing stays with the primary that seeded
the copy. bin/fm-local-handoff.sh offer publishes an immutable head-pinned offer
carrying the commit as a git bundle, and the existing guarded entrypoint
bin/fm-merge-local.sh consumes it as a pinned delegated input under its own
per-task control lock, incarnation recheck and captain-hold check, rather than
gaining a second acceptance system. No worker record is read, written or
invented for the child. The primary alone fast-forwards its local default
branch, then publishes a landing receipt into the child home.

Only that receipt opens ordinary teardown, and bin/fm-teardown.sh re-proves the
receipt's commit is still contained in the primary's default branch before
accepting it; a child-local merge or a branch pushed anywhere is not that proof.
Receipt recovery after a landing whose acknowledgement failed is idempotent and
never merges. Missing or stale identities, dirty or diverged work, a changed
head, a changed route, a damaged record and an interrupted transaction all
refuse and preserve the work.

tests/fm-local-handoff.test.sh drives the real scripts against isolated
temporary homes over ten cases covering the bound seed, the two seed refusals
that remain, the child's inability to land its own clone, offer pinning and
republication, the guarded delegated landing and its receipt, the unpinned and
stale approval refusals, idempotent receipt recovery, the teardown gate and the
fail-closed record parsing, plus a held landing row blocking the landing. The
obsolete refusal case in tests/fm-secondmate-safety.test.sh is removed with the
behavior it asserted; the unchanged whole-home remote refusal stays covered by
tests/fm-remote-secondmate-lifecycle-e2e.test.sh. Test inventory entries are
additive only.

* fix(secondmate): pin local-only landings to a parent-owned approval record

The review found that an approval released by the captain could be inherited
by any later child head, that a receipt could be satisfied by a substituted
clone, that a refused landing left an imported ref behind, and that an absent
worktree skipped the receipt gate entirely.

Add one durable record, fm-local-landing.v1, written only by the new
bin/fm-local-handoff.sh request subcommand while the captain's row is still
held, and require the delegated landing to match that record's pinned offer,
head, and identity. The landing guard now also refuses an unreadable hold
status, a record already marked landed, and a project that has left local-only
custody, and deletes its private import ref on every refusal path.

The receipt proof derives the containment repository from the child's own
parent route and project binding and additionally requires the parent's own
landed record, so a receipt naming another clone proves nothing. Cleanup of a
bound local-only task now faces that gate even when its worktree is already
gone.

* fix(secondmate): make a published local-only landing pin immutable

A request could publish its landing record after the captain's row had
already been released, so an answer given for one head was inherited by
another. The pin is now published create-only, and the whole check,
publication, and re-read of the row runs under the landing's existing
per-landing control lock, which bin/fm-merge-local.sh and
bin/fm-captain-hold.sh already take. A record that exists is reported
rather than replaced: the identical identity repeats it, a different head
refuses, and a landed record refuses outright. A row released outside
that lock withdraws this call's own record byte for byte.

Each approval therefore owns its own landing row; a moved head needs a
new row rather than a re-pin.

* test(secondmate): prove the answer waits on the pin's own lock

The case that covered a captain's answer overlapping a landing pin in
flight asserted only that the answer had not completed after a fixed
three-second window. That assertion passes whenever the answer has
simply not finished yet, so on a host where an uncontended release
already costs more than three seconds it would have passed with the
serialization removed entirely.

Replace it with positive evidence. The fixture wrapper that freezes a
publication now records the publishing process's pid, and the case
asserts that the landing's own control lock is held by that process, or
an ancestor of it, while the answer is running. The absence window
stays as independent corroboration but is now scaled to a baseline the
case measures on this host with the same command on its own row, and
the boundary at the release instant plus the row's state after the
answer completes are checked too. The frozen wrapper also ends with the
case that installed it, so a case that fails inside its own window no
longer leaves a publication spinning behind it.

With the request's lock acquisition removed from bin/fm-local-handoff.sh
the case now fails at that assertion rather than at a timer.

* no-mistakes(review): Close landing rows after receipts; align routing and receipt checks

* no-mistakes(review): Keep receipt recovery idempotent after landing row archival

* no-mistakes(review): Refuse receipt recovery before writing when landing row missing

* no-mistakes(review): Gate every recovery write on a present, unheld landing row

* no-mistakes(review): Drop import refs on every exit; fail broken landing fixtures

* no-mistakes(document): Align seeding docs with bound local-only secondmate clones

* no-mistakes(ci): ci-2 (Behavior portable serial 8), fixed. tests/fm-gotmp.test.sh failed with "teardown exited non-zero with a valid tasktmp". Invariant: a test that runs the real bin/fm-teardown.sh from a fake bin folder must provide every library teardown loads. This PR made teardown load bin/fm-local-handoff-lib.sh, but the test's two fake bin folders (make_fake_root and the inline copy near line 170; the third case reuses make_fake_root) never got it, so teardown exited at startup. I reproduced this locally. No other test in tests/ links teardown into a fake folder, and the library's own dependencies (fm-secondmate-parent-lib.sh, fm-secondmate-registry-lib.sh) were already linked. Fix: link fm-local-handoff-lib.sh in both folders, with a comment matching the file's style. No production code changed. Verified: bash tests/fm-gotmp.test.sh passes all 3 cases and shellcheck is clean. ci-1 (Behavior portable serial 2), not caused by this PR. tests/fm-remote-secondmate-lifecycle-e2e.test.sh printed ALL TESTS PASSED, then exited 1 only because its cleanup rm -rf hit "Directory not empty" while a leftover background process was still writing. This PR doesn't touch that test or the watcher/remote code it runs. The same cleanup failure hit unrelated branch fm/fm-opencode-2-adapter (run 36213627355), so the test was already flaky. A local run on this loaded host (load average about 8.5) also failed: it hit the watcher's 30-second relaunch time limit, then the same cleanup failure. Making it reliable means finding which leftover process keeps writing, which is separate work outside this change. ci-3 (PR must be raised via no-mistakes), not caused by the code. The attestation check failed because the pipeline's test step had status=skipped, which depends on the pipeline run's state

* Guard bound local-only landing by offered head and call identity

* no-mistakes(review): Accept defer-then-release pins and tolerate deleted task branches

* no-mistakes(review): Accept legacy date-only answered stamps for pinned landings

* no-mistakes(document): Sync hold-stamp and teardown branch docs with fixes
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.

1 participant