Skip to content

Make filed work reach the captain by name, not just the record - #1885

Open
Ndidi wants to merge 2 commits into
kunchenguid:mainfrom
Ndidi:fm/captain-instruction-capture-receipt
Open

Make filed work reach the captain by name, not just the record#1885
Ndidi wants to merge 2 commits into
kunchenguid:mainfrom
Ndidi:fm/captain-instruction-capture-receipt

Conversation

@Ndidi

@Ndidi Ndidi commented Aug 7, 2026

Copy link
Copy Markdown

The failure this fixes is not storage

On 2026-08-06 the captain answered four decisions. All four were recorded, routed and filed, one carrying their verbatim wording into a decision record on disk. Storage worked perfectly. The captain still concluded, reasonably, that nothing had been captured.

Twice in two days, the same shape:

  1. Firstmate stated three options once inside a long message, then referred to "A, B and C" for two hours as though the captain had them in view. The terminal had scrolled; they did not.
  2. Asked "what's the task number for this piece of work", firstmate led with "it has no number yet" and explained two numbering systems. The task existed, had existed since the previous day, and carried the captain's own words.

Both times the information existed. Both times firstmate answered from its own view of state, which the captain cannot see, and the answer made a present thing look absent.

So this PR adds nothing that writes another record. It makes the record's name reach the captain.

Candidates considered, and why the chosen one holds

The task offered three shapes. All three are here, but only one of them is load-bearing, and the ordering matters.

A stated obligation in AGENTS.md section 9 (added). Necessary as the discovery layer - without it nothing tells firstmate the tool exists - but insufficient on its own, and it is the weakest class of fix available. An equivalent rule already exists in one home's private preferences, and a rule that lives only there is exactly the drift this repo keeps paying for. It cannot be the answer by itself.

A captain-facing rendering (/bearings - deliberately unchanged). The task asked whether the gap is the tool or the pointing. It is neither: it is the moment. /bearings is already good and already renders newly filed work under Charted Next, so there is nothing to rebuild. But it is invoked by the captain, and the captain's requirement is that they not have to ask - asking is the step that failed. /bearings serves catch-up after a break. It cannot serve the instant an instruction is given, which is where both failures happened. No changes made to it.

A turn-end check (added, but not in the form proposed). The proposed form compares "captain-instruction content in the turn" against backlog mutations. That would be a natural-language classifier at a safety boundary: unprovable, and it would fire on every conversational turn until firstmate learned to dismiss it. The predicate here is deterministic instead - a backlog identity exists now that did not exist before - so it fires only when a receipt is genuinely owed and never on a turn that filed nothing.

What actually carries the fix

The strongest prior art in this repo is fm-send --resolve-key: it closes a decision at answer time rather than relying on the worker to remember a closing line afterwards. Bookkeeping as a side effect of the action, not a separate skippable step. The task rightly warns that a receipt firstmate must remember to type is the weakest class of fix.

So the obligation is derived, never declared. bin/fm-capture-receipt.sh reads the home's own data/backlog.md and compares its identity set against a durable baseline. An identity that was not there before is a receipt the captain is owed - whoever filed it, and however. There is no register verb, deliberately: "remember to declare it" is the same class of step that already failed.

Three parts, each closing a hole the others leave:

Part Closes
The ledger hands over the exact sentence Firstmate pastes a name instead of composing one from its own view of state
The turn-end guard blocks while a receipt is unsurfaced A receipt that was never composed at all
Session start re-surfaces what is still owed A receipt lost to compaction, a restart, or an ignored block

What this cannot do, stated plainly

Captain-facing chat has no tool boundary, so no hook can confirm the sentence actually reached the captain. delivered <identity> is an attestation, and running it without having named the identity is a false statement to the captain rather than a shortcut. Verifying the last mile would mean parsing a harness transcript format - vendor-specific across the six supported primaries and fragile - so it is deliberately not attempted. The guard closes the much larger hole; this residue is named rather than papered over.

Safety properties

  • Cannot wedge a session. The guard blocks at most once per identity, ever, recorded in the owed record rather than a session counter. N filings cost at most N forced continuations across the life of the home. A firstmate that ignores the block is not re-blocked; the debt stays owed and resurfaces at session start.
  • Independent of harness loop-guard semantics. That bound holds identically on every primary without relying on stop_hook_active or its variants.
  • No new harness-dependent signal. The check lives inside bin/fm-turnend-guard.sh, which every enabled primary already routes its turn end through, and reuses the existing proven exit-2 contract. One integration point reaches all six harnesses; no adapter changed.
  • Supervision is untouched. The check sits before the supervision predicate precisely so no exit path of the Claude block-budget accounting is edited. A receipt block defers the supervision block by one model turn and never replaces it - and under Claude the Stop-owned auto-arm fires on the same event regardless, so recovery is not delayed at all. All 63 pre-existing turn-end cases pass unchanged.
  • Fails in the safe direction. A ledger it cannot mark allows the turn end rather than refusing every turn from then on. A mistyped identity is refused before anything is cleared, so it cannot retire the wrong debt.
  • Scoped to the main home. A secondmate never addresses the captain and its backlog receives handed-off items it must not receipt, so the check is skipped there. A home with no data/backlog.md creates nothing.
  • Adoption owes nothing. The first reconcile in a home takes the whole existing backlog as already known, so this never lands claiming an entire backlog as unpaid debt - which matters because the delivery posture is upstream-PR-only and homes adopt this whenever they next pull.

Deliberate non-optimisation

There is no mtime fast path. Skipping the parse when the backlog "looks unchanged" is the obvious optimisation and it is wrong here: a filing whose write lands in the same whole second as the previous reconcile leaves mtime identical, so the receipt would be silently skipped until some later edit happened to move the clock. A missed receipt is the exact failure this exists to prevent. This was caught by a test, not by review.

Incidental fix: the test coverage guard was broken

bin/fm-test-run.sh --check-coverage fails on the pristine base commit (verified by stashing all changes and re-running at 70aeba8). Every list is written with LC_ALL=C sort, but every comm ran in the ambient locale, so the set arithmetic compared C-collated input under different collation rules - reporting a wrong missing/extra set rather than failing honestly. Fixed by running comm under the same collation. It is included here rather than split out because it is the guard that verifies this PR's own new test file is covered by a lane; with it broken, that could not be checked. The guard now passes and reports total=131 parallel=24 serial=96 serial_shards=4 herdr=11.

Verification

  • tests/fm-capture-receipt.test.sh - 15 new cases: derivation, adoption owing nothing, exact parsing (indented task bodies are not items; both backend renderings resolve; a non-slug identity is refused rather than sanitized into a path), once-per-identity blocking, all-or-nothing attestation, and the unwritable-ledger fail direction.
  • tests/fm-turnend-guard.test.sh - 4 new integration cases, 67 total passing. Explicitly covers that a receipt block defers the supervision block rather than replacing it.
  • tests/fm-session-start.test.sh - 1 new case, 44 total passing.
  • Portable parallel lanes 1 and 2: pass, no failures.
  • Portable serial lane: all 96 suites ran; tests/fm-capture-receipt.test.sh passed inside it (exit=0). Three suites failed, all pre-existing - see below.
  • bin/fm-lint.sh: clean. bin/fm-doc-audience-check.sh: ok surfaces=65 local_links=212.

Three pre-existing failures, reported rather than folded in

Each was re-run standalone on this branch and then on the pristine base commit 70aeba8 with no changes present. All three fail identically on untouched main, at the same assertion, so none is a regression from this work:

Suite Failing assertion Reproduces on 70aeba8
fm-bootstrap backend=orca should require only the Orca-specific missing tool yes
fm-pi-watch-extension OpenCode watch plugin must arm only when this session owns the fleet lock yes
fm-remote-backlog-handoff remote atomic receipt did not deliver ios-a before the dropped acknowledgement yes

The fm-bootstrap one has a clear host-dependent cause worth recording: the case asserts bootstrap reports MISSING: orca, but it relies on the host having no binary called orca. On this machine /usr/bin/orca exists and is the GNOME screen reader, unrelated to the Orca runtime backend, so bootstrap correctly reports nothing missing and the assertion sees empty output. Any Linux desktop with the accessibility tool installed reproduces it; the case should stub an absent orca rather than depend on the host lacking that name.

None is fixed here. They belong to the bootstrap, OpenCode-watcher, and remote-handoff surfaces respectively, and folding unrelated repairs into a captain-etiquette change would obscure both.

Propagation

This reaches a running home only when upstream merges and that home pulls, which is why the interim rule already lives in data/captain.md. Nothing here assumes instant propagation: a home without the script simply has no second block reason, and adopting it is silent.

firstmate added 2 commits August 7, 2026 11:15
…e sorted with

bin/fm-test-run.sh --check-coverage fails on a plain checkout. Every list it
builds is written with LC_ALL=C sort, but every comm that reads those lists back
ran in the ambient locale. comm then applies the locale's collation rules to
C-collated input: it warns that the input is unsorted, and its set arithmetic
stops being trustworthy, so the guard can report a wrong missing/extra set
instead of failing honestly.

Run each comm under LC_ALL=C so both sides of every comparison use one
collation, and record the reason beside the function so it is not undone.
The captain answered four decisions on 2026-08-06. All four were recorded,
routed and filed, one carrying their verbatim wording into a record on disk.
Storage worked. They still concluded, reasonably, that nothing had been
captured, because firstmate answers from its own view of state and a record the
captain cannot name reads to them as no record at all.

Derive the obligation from the filing itself rather than from firstmate
remembering to declare it. bin/fm-capture-receipt.sh compares this home's
data/backlog.md against a durable baseline, so an identity that was not there
before is a receipt the captain is owed, whoever filed it and however. The
ledger hands over the exact sentence to send, the turn-end guard blocks while
one is still unsurfaced, and session start re-raises whatever outlived that
block. There is deliberately no register verb: "remember to declare it" is the
same class of step that already failed.

This follows fm-send --resolve-key, which closes a decision at answer time
rather than trusting a later closing line: bookkeeping as a side effect of the
action rather than a separate step that can be skipped. Captain-facing chat has
no tool boundary, so confirming the sentence reached the captain is not
possible and delivery stays an attestation; that limit is documented rather
than hidden.

The check lives in bin/fm-turnend-guard.sh, which every enabled primary already
routes its turn end through, so one integration point covers all six harnesses
with no adapter change and no new harness-dependent signal. It sits before the
supervision predicate so no exit path of the Claude block-budget accounting is
edited, and a receipt block defers the supervision block by one model turn
rather than replacing it. It blocks at most once per identity, recorded in the
owed record rather than a session counter, so it can nag but never wedge, and a
ledger it cannot mark allows the turn end instead of refusing every turn after.

There is no mtime fast path. A filing whose write lands in the same whole
second as the previous reconcile leaves mtime identical, which would silently
skip the receipt until some later edit moved the clock, and a missed receipt is
the exact failure this prevents.

AGENTS.md section 9 carries the obligation, including that an answer about
tracking must never lead with an absence when the thing exists. /bearings is
left untouched: it already renders newly filed work, but it is invoked by the
captain, and asking is the step that failed.
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