Skip to content

feat(bin): make the captain-note plane and away mode aware of the pinnace phone channel - #5875

Closed
cbcotton wants to merge 7 commits into
kunchenguid:mainfrom
cbcotton:fm/crowsnest-pinnace-plumbing-p1
Closed

cbcotton wants to merge 7 commits into
kunchenguid:mainfrom
cbcotton:fm/crowsnest-pinnace-plumbing-p1

Conversation

@cbcotton

Copy link
Copy Markdown

Intent

Ship the three small firstmate-repo plumbing changes that finish the pinnace (the crowsnest mobile feature the captain merged as PR #2). These make firstmate's own message plane and away mode aware of the pinnace phone channel. The full design is in the approved plan at ~/Dev/firstmate/data/crowsnest-mobile-plan-g1/report.md under R3 and steps P1-P3; implement exactly those three, no more.

P1 - note provenance in the captain-note plane. In bin/fm-inbox.sh, add note --source <token> (validated [A-Za-z0-9._-]+, default stays text) and a repeatable --meta <key>=<value>, written through the existing extra slot, so a note can read source=pinnace with node=<tailnet machine> and login=<tailnet login>; receipts already returns source. Update the script header and --help, and the one docs/voice-relay.md line that say keeps voice.

P2 - the pinnace as the recorded reach channel. In bin/fm-afk-contract.sh, when a new opt-in flag config/pinnace exists in the home, record reach_channels: pinnace and print a matching reach-announced sentence at away entry (today none is written and required by validation); validation accepts none|pinnace; a version-2 record with none still validates. Register config/pinnace in docs/configuration.md (first line the captain's tailnet login; presence turns the channel on), and update the away skill and docs/architecture.md reach lines.

P3 - how the first mate treats a pinnace note. In .agents/skills/afk/SKILL.md and AGENTS.md's captain-inbox-note handling, state that a source=pinnace note is the captain speaking: act on it with ordinary chat authority, keep the away posture, confirm-first for the never-set (destructive, irreversible, security-sensitive), and always answer it with bin/fm-inbox.sh reply <id> in one short section-9 outcome message before acknowledging.

Out of scope: any crowsnest-repo change (that shipped in PR #2), the Android client, and anything beyond these three changes.

What Changed

  • bin/fm-inbox.sh note gains --source <token>, validated as [A-Za-z0-9._-]+ and defaulting to text. It also gains a repeatable --meta <key>=<value>, written through the existing extra header slot. A meta key must pass the same token rule and cannot reuse a fixed header name (id, at, source, announce_marker, request_id), and its value must be non-empty with no line breaks. This lets the pinnace queue a note with source=pinnace, node= and login=, and receipts returns the source. The script header, the shared usage/--help text and docs/voice-relay.md now describe these flags.
  • bin/fm-afk-contract.sh checks for an opt-in config/pinnace flag when it writes a record (the path can be overridden with FM_CONFIG_OVERRIDE):
    • If the flag exists, the record gets reach_channels: pinnace, a pinnace reach sentence, and the phrase "the pinnace is the reach channel" in the entry announcement and read-back. Without it, the record is hold-for-return only, as before.
    • Validation accepts none|pinnace.
    • The announcement's instructions sentence no longer says that anything needing the captain waits for their return. That is left to the recorded reach sentence.
    • fm-afk-launch.sh and the fm-session-start.sh AFK digest no longer hard-code "hold-for-return only".
    • docs/configuration.md registers config/pinnace: its first line is the captain's tailnet login, it is local, gitignored and not inherited by secondmate homes, and a standing record keeps the channel it was written with. The AGENTS.md config listing and the reach lines in docs/architecture.md are updated to match.
  • The .agents/skills/afk/SKILL.md away skill and AGENTS.md's captain-inbox-note handling now say to read a note's source from receipts. A source=pinnace note counts as the captain speaking: the first mate acts on it with ordinary chat authority, keeps the away posture, and asks for confirmation first on anything destructive, irreversible or security-sensitive. It always answers with bin/fm-inbox.sh reply <id> in one short section-9 outcome message before acknowledging the note. Tests in tests/fm-inbox.test.sh and tests/fm-afk-contract.test.sh cover the new flags and the pinnace reach channel.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: Three small, well-bounded plumbing changes whose concrete input traces (a pinnace note with meta headers through write and receipts; a pinnace-flag record through write, validate, refresh, replace, readback, and announcement) produce correct results, with every extra edit on the branch authorized by an earlier recorded decision and no crowsnest or Android scope touched.

Testing

Baseline: bin/fm-test-run.sh on tests/fm-inbox.test.sh and tests/fm-afk-contract.test.sh both passed. Live: two driver scripts exercised bin/fm-inbox.sh note/receipts/reply/drain and bin/fm-afk-launch.sh enter/stop plus bin/fm-afk-contract.sh field/validate/readback against disposable lab homes with and without config/pinnace, including adversarial inputs; bin/fm-session-start.sh was run against a pinnace lab home to check the AFK digest line; a throwaway Claude primary was launched on a private tmux socket with FM_HOME set to a marked lab home, driven through /afk, two externally queued pinnace notes delivered by the real away daemon, and an unmarked return message, with pane transcripts, durable inbox state, and the primary's own command log captured. The lab primary hit the account's Fable usage limit mid-run and was switched to Opus 5.5 to avoid spending paid credits. All driven scenarios passed; the lab, its tmux server, and its daemon were torn down and the worktree is clean. tests/fm-afk-launch.test.sh was not run because it creates sessions on the default tmux server, which the runbook forbids touching; its enter-path assertion was covered by the P2 driver.

  • Live validation: ✅ go - 18 of 19 scenarios driven live against the product
Scenario Result Live Evidence
P1: the phone queues a note with --source pinnace and --meta node/login; the record carries those headers above the body, receipts return source=pinnace with the body unchanged, and exactly one inbox… ✅ pass live p1-inbox-provenance-transcript.txt sections 1, 1a, 1b, 1d
P1: a retry with the same pinnace request id replays the original note (outcome replay, same id, one note on disk) with its source intact ✅ pass live p1-inbox-provenance-transcript.txt section 1c
P1: a pinnace note with its body on stdin (the server's form) records the provenance headers ✅ pass live p1-inbox-provenance-transcript.txt section 2
P1: a plain terminal note keeps the default source=text and no extra headers ✅ pass live p1-inbox-provenance-transcript.txt section 3
P1: human list and drain output still print only the note id and body, never the source header ✅ pass live p1-inbox-provenance-transcript.txt section 4
P1 adversarial: source tokens with a space, a path, an empty value, a newline, or a semicolon, and meta lines reusing a fixed header (source, id, at, announce_marker, request_id), lacking '=', with an… ✅ pass live p1-inbox-provenance-transcript.txt section 5 (note count 3 before and 4 after, the one addition being the flag-as-token case reported as an info finding)
P1: note --help and the script header document --source and --meta ✅ pass live p1-inbox-provenance-transcript.txt section 7
P1: the first mate's reply and acknowledgement of a pinnace note both appear in receipts ✅ pass live p1-inbox-provenance-transcript.txt section 8
P1: fm-inbox.sh say still records source=voice ⏸️ untested no say transcribes audio through AWS Bedrock and requires config/inbox-region, config/inbox-stt-model, and AWS credentials, none of which exist in this environment; provide a configured lab home with Bed…
P2: with config/pinnace present, /afk entry through fm-afk-launch.sh writes reach_channels: pinnace, announces 'the pinnace is the reach channel. Orders from your phone reach me...' with no 'waits for… ✅ pass live p2-afk-reach-channel-transcript.txt sections A, A1, A2, A3
P2: the real afk skill in a Claude primary on a pinnace home writes the pinnace record and relays the recorded reach sentence with no hold-for-return contradiction ✅ pass live p3-lab-pane-1-afk-entry.txt and p3-lab-primary-commands.txt (Bash #2)
P2: without the flag, entry with and without words writes reach_channels: none and announces 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' exac… ✅ pass live p2-afk-reach-channel-transcript.txt sections B and B1
P2 adversarial: a standing version-2 none record still validates after the flag appears, a refresh keeps none and announces the recorded channel rather than the flag, new words re-record pinnace, remo… ✅ pass live p2-afk-reach-channel-transcript.txt section C
P2: presence is the switch: an empty config/pinnace turns the channel on; a directory of that name does not ✅ pass live p2-afk-reach-channel-transcript.txt section D
P2: a hand-written version 1 record with none still validates and reads back ✅ pass live p2-afk-reach-channel-transcript.txt section E
P2: the return archives a pinnace record cleanly, both through fm-afk-launch.sh stop and through the real skill's return in the Claude lab ✅ pass live p2-afk-reach-channel-transcript.txt section A4 and p3-lab-pane-3-return.txt
P2: the session-start AFK digest no longer hardcodes 'hold-for-return only' and points to readback for the mandate and the reach channel ✅ pass live p2-session-start-afk-subsection.txt
P3: a real Claude first mate in the away posture receives a pinnace note via the away daemon's record-backed doorbell, verifies it, reads the source from fm-inbox.sh receipts, replies with fm-inbox.sh… ✅ pass live p3-lab-pane-2-pinnace-note-handled.txt, p3-pinnace-note-1-durable-state.txt, p3-lab-primary-commands.txt (Bash #8 to #10)
P3 adversarial: a destructive phone order ('delete the whole data directory') is held: nothing is deleted, the reply asks for a confirming note, the posture stays away, and the note is acknowledged ✅ pass live p3-pinnace-note-2-never-set-hold.txt, p3-lab-primary-commands.txt (Bash #11 to #13)
Evidence: P1 live transcript: note provenance, receipts, replay, list/drain, adversarial refusals, reply and ack

Source: P1 live transcript: note provenance, receipts, replay, list/drain, adversarial refusals, reply and ack


\### 1. the pinnace queues an order with provenance (request-id as the plan prescribes)
$ fm-inbox.sh note --source pinnace --meta node=mac.home --meta login=captain@example.com --request-id pinnace:0d3f4c1a --json merge the windows fix when green
{"schema":"fm-inbox-note.v1","outcome":"created","id":"1790489608-7abgEo","request_id":"pinnace:0d3f4c1a","saved":true,"announced":true,"acknowledged":false,"path":"/var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.fOQNGR/state/inbox/1790489608-7abgEo.note"}
[exit 0]

\### 1a. the durable record (1790489608-7abgEo.note) carries source, node, and login headers above the body
id=1790489608-7abgEo
at=2026-09-27T06:13:28Z
source=pinnace
announce_marker=1
request_id=pinnace:0d3f4c1a
node=mac.home
login=captain@example.com
--
merge the windows fix when green

\### 1b. receipts return source=pinnace and the unchanged body
$ fm-inbox.sh receipts
{"schema":"fm-inbox-receipts.v1","home":"T/fm-lab.fOQNGR","generated":"2026-09-27T06:13:28Z","pending":[{"id":"1790489608-7abgEo","at":"2026-09-27T06:13:28Z","source":"pinnace","request_id":"pinnace:0d3f4c1a","body":"merge the windows fix when green","acknowledged":false,"announced":true,"reply":null}],"handled":[],"replies":[],"reply_cursor":"","omitted":[]}
[exit 0]

\### 1c. a retry of the same request id replays the original note with its source intact
$ fm-inbox.sh note --source pinnace --meta node=mac.home --meta login=captain@example.com --request-id pinnace:0d3f4c1a --json merge the windows fix when green
{"schema":"fm-inbox-note.v1","outcome":"replay","id":"1790489608-7abgEo","request_id":"pinnace:0d3f4c1a","saved":true,"announced":true,"acknowledged":false,"path":"/var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.fOQNGR/state/inbox/1790489608-7abgEo.note"}
[exit 0]
notes on disk: 1

\### 1d. the wake queue holds exactly one inbox wake for it
1
1790489608	1	check	inbox:1790489608-7abgEo	check: captain inbox note 1790489608-7abgEo - merge the windows fix when green

\### 2. body on stdin, the way the pinnace server sends it
{"schema":"fm-inbox-note.v1","outcome":"created","id":"1790489609-3lRTFM","request_id":null,"saved":true,"announced":true,"acknowledged":false,"path":"/var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.fOQNGR/state/inbox/1790489609-3lRTFM.note"}
[exit 0]
id=1790489609-3lRTFM
at=2026-09-27T06:13:29Z
source=pinnace
announce_marker=1
node=phone
login=captain@example.com
--

\### 3. a plain terminal note keeps the default source=text and no extra headers
$ fm-inbox.sh note typed at the desk
queued 1790489609-oBAuKj
  typed at the desk
  firstmate will pick this up at its next check.
[exit 0]
id=1790489609-oBAuKj
at=2026-09-27T06:13:29Z
source=text
announce_marker=1
--

\### 4. human list and drain output print only the id and the body (no source header)
$ fm-inbox.sh list
1790489608-7abgEo
    merge the windows fix when green
1790489609-3lRTFM
    What is the fleet doing right now?
1790489609-oBAuKj
    typed at the desk
[exit 0]
$ fm-inbox.sh drain
1790489608-7abgEo
    merge the windows fix when green
1790489609-3lRTFM
    What is the fleet doing right now?
1790489609-oBAuKj
    typed at the desk

Ack with: fm-inbox.sh drain --ack <id>...
[exit 0]

\### 5. adversarial: malformed source tokens and forged or malformed meta are refused and write nothing
$ fm-inbox.sh note --source pin nace --json nope
fm-inbox: invalid source token (use characters: A-Za-z0-9._-)
[exit 1]
$ fm-inbox.sh note --source ../x --json nope
fm-inbox: invalid source token (use characters: A-Za-z0-9._-)
[exit 1]
$ fm-inbox.sh note --source  --json nope
fm-inbox: invalid source token (use characters: A-Za-z0-9._-)
[exit 1]
$ fm-inbox.sh note --source pinnace
id=forged --json nope
fm-inbox: invalid source token (use characters: A-Za-z0-9._-)
[exit 1]
$ fm-inbox.sh note --source pinnace;rm --json nope
fm-inbox: invalid source token (use characters: A-Za-z0-9._-)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta source=forged --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta id=forged --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta at=1999 --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta announce_marker=0 --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta request_id=x --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta novalue --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta node= --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta =value --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta bad key=x --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta node=phone
id=forged --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source pinnace --meta node=phone
id=forged --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
$ fm-inbox.sh note --source --json nope
queued 1790489609-KdnM5s
  nope
  firstmate will pick this up at its next check.
[exit 0]
$ fm-inbox.sh note --meta --json nope
fm-inbox: invalid --meta (use <key>=<value>: a key of A-Za-z0-9._- that is not a fixed header name, and a non-empty value with no line break)
[exit 1]
notes before refusals: 3, after: 4
inbox wakes after refusals: 4

\### 6. edge: a value may itself contain '=' and a key may use dots and dashes
$ fm-inbox.sh note --source pinnace --meta tailnet.node-name=mac=home --json edge
{"schema":"fm-inbox-note.v1","outcome":"created","id":"1790489609-w717KZ","request_id":null,"saved":true,"announced":true,"acknowledged":false,"path":"/var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.fOQNGR/state/inbox/1790489609-w717KZ.note"}
[exit 0]
id=1790489609-w717KZ
at=2026-09-27T06:13:29Z
source=pinnace
announce_marker=1
tailnet.node-name=mac=home
--

\### 7. --help documents the provenance flags
$ fm-inbox.sh note --help
fm-inbox: usage: fm-inbox.sh note [--request-id <id>] [--source <token>] [--meta <key>=<value>]... [--json] [--] <text>... (or: note -)
[exit 1]
$ fm-inbox.sh --help | grep -n "source\|--meta"
22:  fm-inbox.sh note [--request-id <id>] [--source <token>] [--meta <key>=<value>]... [--json] [--] <text>...
23:  fm-inbox.sh note [--request-id <id>] [--source <token>] [--meta <key>=<value>]... [--json] -   (body from stdin)
49:`note --source <token>` records which channel spoke, as the `source` header
52:and nothing else. Each `--meta <key>=<value>` adds one more header line
55:rule and may not reuse a fixed header name (id, at, source, announce_marker,

\### 8. the first mate answers the pinnace note with reply, then acknowledges; receipts show both
$ fm-inbox.sh reply 1790489608-7abgEo Merged the windows fix; it was green at its live head.
replied 1790489608-7abgEo
[exit 0]
$ fm-inbox.sh drain --ack 1790489608-7abgEo
acked 1790489608-7abgEo
[exit 0]
$ fm-inbox.sh receipts
{"schema":"fm-inbox-receipts.v1","home":"T/fm-lab.fOQNGR","generated":"2026-09-27T06:13:30Z","pending":[{"id":"1790489609-w717KZ","at":"2026-09-27T06:13:29Z","source":"pinnace","request_id":null,"body":"edge","acknowledged":false,"announced":true,"reply":null},{"id":"1790489609-oBAuKj","at":"2026-09-27T06:13:29Z","source":"text","request_id":null,"body":"typed at the desk","acknowledged":false,"announced":true,"reply":null},{"id":"1790489609-KdnM5s","at":"2026-09-27T06:13:29Z","source":"--json","request_id":null,"body":"nope","acknowledged":false,"announced":true,"reply":null},{"id":"1790489609-3lRTFM","at":"2026-09-27T06:13:29Z","source":"pinnace","request_id":null,"body":"What is the fleet doing right now?","acknowledged":false,"announced":true,"reply":null}],"handled":[{"id":"1790489608-7abgEo","at":"2026-09-27T06:13:28Z","source":"pinnace","request_id":"pinnace:0d3f4c1a","body":"merge the windows fix when green","acknowledged":true,"announced":true,"reply":{"id":"1790489608-7abgEo","at":"2026-09-27T06:13:30Z","body":"Merged the windows fix; it was green at its live head.","cursor":"000000000001"}}],"replies":[{"id":"1790489608-7abgEo","at":"2026-09-27T06:13:30Z","body":"Merged the windows fix; it was green at its live head.","cursor":"000000000001"}],"reply_cursor":"000000000001","omitted":[]}
[exit 0]

\### teardown
lab removed: /var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.fOQNGR
Evidence: P1 driver script (reproducible)

Source: P1 driver script (reproducible)

#!/usr/bin/env bash
# P1 live driver: note provenance on the captain-note plane, driven through the
# real bin/fm-inbox.sh against a disposable lab home (FM_HOME only, no overrides).
set -u
ROOT=$(pwd)
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX")
bin/fm-lab-home.sh create "$LAB" >/dev/null || exit 1
INBOX() { env -u NO_MISTAKES_GATE -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE -u FM_CONFIG_OVERRIDE FM_HOME="$LAB" "$ROOT/bin/fm-inbox.sh" "$@"; }
say() { printf '\n### %s\n' "$*"; }
run() { printf '$ fm-inbox.sh %s\n' "$*"; INBOX "$@"; printf '[exit %s]\n' "$?"; }

say "1. the pinnace queues an order with provenance (request-id as the plan prescribes)"
run note --source pinnace --meta node=mac.home --meta login=captain@example.com --request-id pinnace:0d3f4c1a --json "merge the windows fix when green"
ID1=$(ls "$LAB/state/inbox"/*.note | head -1 | xargs -I{} basename {} .note)
say "1a. the durable record ($ID1.note) carries source, node, and login headers above the body"
cat "$LAB/state/inbox/$ID1.note"
say "1b. receipts return source=pinnace and the unchanged body"
run receipts
say "1c. a retry of the same request id replays the original note with its source intact"
run note --source pinnace --meta node=mac.home --meta login=captain@example.com --request-id pinnace:0d3f4c1a --json "merge the windows fix when green"
printf 'notes on disk: %s\n' "$(ls "$LAB/state/inbox"/*.note | wc -l | tr -d ' ')"
say "1d. the wake queue holds exactly one inbox wake for it"
grep -c 'inbox:' "$LAB/state/.wake-queue"
grep 'inbox:' "$LAB/state/.wake-queue"

say "2. body on stdin, the way the pinnace server sends it"
printf 'What is the fleet doing right now?\n' | INBOX note --source pinnace --meta node=phone --meta login=captain@example.com --json -
printf '[exit %s]\n' "$?"
ID2=$(ls -t "$LAB/state/inbox"/*.note | head -1 | xargs -I{} basename {} .note)
sed -n '1,/^--$/p' "$LAB/state/inbox/$ID2.note"

say "3. a plain terminal note keeps the default source=text and no extra headers"
run note "typed at the desk"
ID3=$(ls -t "$LAB/state/inbox"/*.note | head -1 | xargs -I{} basename {} .note)
sed -n '1,/^--$/p' "$LAB/state/inbox/$ID3.note"

say "4. human list and drain output print only the id and the body (no source header)"
run list
run drain

say "5. adversarial: malformed source tokens and forged or malformed meta are refused and write nothing"
before=$(ls "$LAB/state/inbox"/*.note | wc -l | tr -d ' ')
run note --source 'pin nace' --json "nope"
run note --source '../x' --json "nope"
run note --source '' --json "nope"
run note --source "$(printf 'pinnace\nid=forged')" --json "nope"
run note --source 'pinnace;rm' --json "nope"
run note --source pinnace --meta 'source=forged' --json "nope"
run note --source pinnace --meta 'id=forged' --json "nope"
run note --source pinnace --meta 'at=1999' --json "nope"
run note --source pinnace --meta 'announce_marker=0' --json "nope"
run note --source pinnace --meta 'request_id=x' --json "nope"
run note --source pinnace --meta 'novalue' --json "nope"
run note --source pinnace --meta 'node=' --json "nope"
run note --source pinnace --meta '=value' --json "nope"
run note --source pinnace --meta 'bad key=x' --json "nope"
run note --source pinnace --meta "$(printf 'node=phone\nid=forged')" --json "nope"
run note --source pinnace --meta "$(printf 'node=phone\rid=forged')" --json "nope"
run note --source --json "nope"
run note --meta --json "nope"
after=$(ls "$LAB/state/inbox"/*.note | wc -l | tr -d ' ')
printf 'notes before refusals: %s, after: %s\n' "$before" "$after"
printf 'inbox wakes after refusals: %s\n' "$(grep -c 'inbox:' "$LAB/state/.wake-queue")"

say "6. edge: a value may itself contain '=' and a key may use dots and dashes"
run note --source pinnace --meta 'tailnet.node-name=mac=home' --json "edge"
ID6=$(ls -t "$LAB/state/inbox"/*.note | head -1 | xargs -I{} basename {} .note)
sed -n '1,/^--$/p' "$LAB/state/inbox/$ID6.note"

say "7. --help documents the provenance flags"
run note --help
printf '$ fm-inbox.sh --help | grep -n "source\\|--meta"\n'
INBOX --help | grep -n 'source\|--meta'

say "8. the first mate answers the pinnace note with reply, then acknowledges; receipts show both"
run reply "$ID1" "Merged the windows fix; it was green at its live head."
run drain --ack "$ID1"
run receipts

say "teardown"
rm -rf "$LAB"
printf 'lab removed: %s\n' "$LAB"
Evidence: P2 live transcript: pinnace and no-flag entries, refresh, replacement, flag removal, unknown channel, v1 record, return

Source: P2 live transcript: pinnace and no-flag entries, refresh, replacement, flag removal, unknown channel, v1 record, return


\### A. config/pinnace present: /afk entry records the pinnace and announces it
$ fm-afk-launch.sh enter --words merge the windows fix when green --expected-return 2026-09-28T08:00:00Z --spend 2
Away posture recorded at 2026-09-27T06:15:09Z: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: 2026-09-28T08:00:00Z. Spend cap: 2 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:09Z
  expected return: 2026-09-28T08:00:00Z
  spend cap: 2 concurrent workers
  reach: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there.
  your words (verbatim):
    merge the windows fix when green
[exit 0]

\### A1. the record on disk
version: 2
entered: 2026-09-27T06:15:09Z
entered_epoch: 1790489709
expected_return: 2026-09-28T08:00:00Z
reach_channels: pinnace
reach_announced: Orders from your phone reach me while you are away, and anything that needs you is answered there.
spend_max_concurrent_workers: 2
confirmed: 2026-09-27T06:15:09Z
confirmed_epoch: 1790489709
words: |-
  merge the windows fix when green

\### A2. field, validate, readback
$ fm-afk-contract.sh field reach_channels
pinnace
[exit 0]
$ fm-afk-contract.sh validate
[exit 0]
$ fm-afk-contract.sh readback
Away posture (recorded):
  entered: 2026-09-27T06:15:09Z
  expected return: 2026-09-28T08:00:00Z
  spend cap: 2 concurrent workers
  reach: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there.
  your words (verbatim):
    merge the windows fix when green
[exit 0]

\### A3. a bare /afk refresh on the pinnace home announces the recorded channel with no 'waits for your return' tail
$ fm-afk-launch.sh enter
fm-afk-contract: away posture already recorded at 2026-09-27T06:15:09Z; a refresh leaves it untouched
Away posture recorded at 2026-09-27T06:15:09Z: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: 2026-09-28T08:00:00Z. Spend cap: 2 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:09Z
  expected return: 2026-09-28T08:00:00Z
  spend cap: 2 concurrent workers
  reach: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there.
  your words (verbatim):
    merge the windows fix when green
[exit 0]

\### A4. the return archives a pinnace record cleanly
$ fm-afk-launch.sh stop
fm-afk-launch: away-posture record archived at /var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.exnxsK/state/afk-contracts/1790489709.afk-contract
fm-afk-launch: away mode stopped; no daemon terminal was running, .afk cleared, and the posture record archived
[exit 0]
1790489709.afk-contract
archived reach_channels: pinnace

\### B. no flag: entry is exactly the hold-for-return record and announcement as before
$ fm-afk-launch.sh enter --words merge the windows fix when green
Away posture recorded at 2026-09-27T06:15:10Z: hold-for-return only. No phone channel is configured; anything that needs you waits for your return. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: not given. Spend cap: 4 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:10Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.
  your words (verbatim):
    merge the windows fix when green
[exit 0]
$ fm-afk-contract.sh field reach_channels
none
[exit 0]
$ grep -c pinnace state/.afk-contract
0
[exit 1]

\### B1. a bare /afk with no words and no flag
$ fm-afk-launch.sh enter
Away posture recorded at 2026-09-27T06:15:10Z: hold-for-return only. No phone channel is configured; anything that needs you waits for your return. No away instructions were recorded; the away session acts on standing authority only. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: not given. Spend cap: 4 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:10Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.
  your words: (none)
[exit 0]

\### C. a standing version-2 none record survives the flag appearing; refresh keeps it; new words re-record; flag removal keeps a pinnace record valid; unknown channel refused
version: 2, reach_channels: none
$ fm-afk-contract.sh validate
[exit 0]
$ fm-afk-launch.sh enter
fm-afk-contract: away posture already recorded at 2026-09-27T06:15:10Z; a refresh leaves it untouched
Away posture recorded at 2026-09-27T06:15:10Z: hold-for-return only. No phone channel is configured; anything that needs you waits for your return. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: not given. Spend cap: 4 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:10Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.
  your words (verbatim):
    merge it when green
[exit 0]
after refresh with flag present, reach_channels: none
$ fm-afk-launch.sh enter --words now the phone is on
fm-afk-contract: replaced the earlier away posture; its record is archived at /var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.lhAppw/state/afk-contracts/1790489710-superseded-1790489711.afk-contract
Away posture recorded at 2026-09-27T06:15:11Z: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: not given. Spend cap: 4 concurrent workers.
Away posture (recorded):
  entered: 2026-09-27T06:15:10Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there.
  your words (verbatim):
    now the phone is on
[exit 0]
after new words, reach_channels: pinnace
1790489710-superseded-1790489711.afk-contract
$ fm-afk-contract.sh validate
[exit 0]
$ fm-afk-contract.sh readback
Away posture (recorded):
  entered: 2026-09-27T06:15:10Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there.
  your words (verbatim):
    now the phone is on
[exit 0]
$ fm-afk-contract.sh validate
fm-afk-contract: record /var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.lhAppw/state/.afk-contract has no valid reach_channels
[exit 1]
$ fm-afk-launch.sh enter
fm-afk-contract: record /var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.lhAppw/state/.afk-contract has no valid reach_channels
[exit 1]

\### D. presence is the switch: an empty config/pinnace still turns the channel on; a directory of that name does not
empty flag file -> reach_channels: pinnace
directory named pinnace -> reach_channels: none

\### E. a hand-written version 1 record with none (the older schema) still validates and reads back
$ fm-afk-contract.sh validate
[exit 0]
$ fm-afk-contract.sh readback
Away posture (recorded):
  entered: 2026-09-20T10:00:00Z
  expected return: not given
  spend cap: 4 concurrent workers
  reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.
  your words (verbatim):
    merge it when green
[exit 0]

all lab homes removed
Evidence: P2 driver script (reproducible)

Source: P2 driver script (reproducible)

#!/usr/bin/env bash
# P2 live driver: the pinnace as the recorded reach channel, driven through the
# real bin/fm-afk-launch.sh enter (the skill's entry point) and
# bin/fm-afk-contract.sh against disposable lab homes (FM_HOME only).
set -u
ROOT=$(pwd)
say() { printf '\n### %s\n' "$*"; }
newlab() { local d; d=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX"); bin/fm-lab-home.sh create "$d" >/dev/null || exit 1; printf '%s' "$d"; }
FM() { local home=$1; shift; env -u NO_MISTAKES_GATE -u FM_ROOT_OVERRIDE -u FM_STATE_OVERRIDE -u FM_DATA_OVERRIDE -u FM_CONFIG_OVERRIDE FM_HOME="$home" "$ROOT/bin/$1" "${@:2}"; }
run() { local home=$1; shift; printf '$ %s\n' "$*"; FM "$home" "$@"; printf '[exit %s]\n' "$?"; }

say "A. config/pinnace present: /afk entry records the pinnace and announces it"
A=$(newlab)
printf 'captain@example.com\n' > "$A/config/pinnace"
run "$A" fm-afk-launch.sh enter --words 'merge the windows fix when green' --expected-return 2026-09-28T08:00:00Z --spend 2
say "A1. the record on disk"
cat "$A/state/.afk-contract"
say "A2. field, validate, readback"
run "$A" fm-afk-contract.sh field reach_channels
run "$A" fm-afk-contract.sh validate
run "$A" fm-afk-contract.sh readback
say "A3. a bare /afk refresh on the pinnace home announces the recorded channel with no 'waits for your return' tail"
run "$A" fm-afk-launch.sh enter
say "A4. the return archives a pinnace record cleanly"
run "$A" fm-afk-launch.sh stop
ls "$A/state/afk-contracts/"
printf 'archived reach_channels: '; sed -n 's/^reach_channels: //p' "$A"/state/afk-contracts/*.afk-contract
rm -rf "$A"

say "B. no flag: entry is exactly the hold-for-return record and announcement as before"
B=$(newlab)
run "$B" fm-afk-launch.sh enter --words 'merge the windows fix when green'
run "$B" fm-afk-contract.sh field reach_channels
printf '$ grep -c pinnace state/.afk-contract\n'; grep -c pinnace "$B/state/.afk-contract"; printf '[exit %s]\n' "$?"
say "B1. a bare /afk with no words and no flag"
rm -f "$B/state/.afk-contract"
run "$B" fm-afk-launch.sh enter
rm -rf "$B"

say "C. a standing version-2 none record survives the flag appearing; refresh keeps it; new words re-record; flag removal keeps a pinnace record valid; unknown channel refused"
C=$(newlab)
run "$C" fm-afk-contract.sh enter --words 'merge it when green' >/dev/null
printf 'version: %s, reach_channels: %s\n' "$(FM "$C" fm-afk-contract.sh field version)" "$(FM "$C" fm-afk-contract.sh field reach_channels)"
printf 'captain@example.com\n' > "$C/config/pinnace"
run "$C" fm-afk-contract.sh validate
run "$C" fm-afk-launch.sh enter
printf 'after refresh with flag present, reach_channels: %s\n' "$(FM "$C" fm-afk-contract.sh field reach_channels)"
run "$C" fm-afk-launch.sh enter --words 'now the phone is on'
printf 'after new words, reach_channels: %s\n' "$(FM "$C" fm-afk-contract.sh field reach_channels)"
ls "$C/state/afk-contracts/"
rm -f "$C/config/pinnace"
run "$C" fm-afk-contract.sh validate
run "$C" fm-afk-contract.sh readback
sed -i.bak 's/^reach_channels: pinnace$/reach_channels: pager/' "$C/state/.afk-contract" && rm -f "$C/state/.afk-contract.bak"
run "$C" fm-afk-contract.sh validate
run "$C" fm-afk-launch.sh enter
rm -rf "$C"

say "D. presence is the switch: an empty config/pinnace still turns the channel on; a directory of that name does not"
D=$(newlab)
: > "$D/config/pinnace"
FM "$D" fm-afk-contract.sh enter >/dev/null 2>&1; printf 'empty flag file -> reach_channels: %s\n' "$(FM "$D" fm-afk-contract.sh field reach_channels)"
rm -rf "$D"
D=$(newlab)
mkdir "$D/config/pinnace"
FM "$D" fm-afk-contract.sh enter >/dev/null 2>&1; printf 'directory named pinnace -> reach_channels: %s\n' "$(FM "$D" fm-afk-contract.sh field reach_channels)"
rm -rf "$D"

say "E. a hand-written version 1 record with none (the older schema) still validates and reads back"
E=$(newlab)
cat > "$E/state/.afk-contract" <<'REC'
version: 1
entered: 2026-09-20T10:00:00Z
entered_epoch: 1789898400
expected_return: -
reach_channels: none
reach_announced: No phone channel is configured; anything that needs you waits for your return.
spend_max_concurrent_workers: 4
confirmed: 2026-09-20T10:00:00Z
confirmed_epoch: 1789898400
words: |-
  merge it when green
clauses:
refused:
merge_grants:
REC
run "$E" fm-afk-contract.sh validate
run "$E" fm-afk-contract.sh readback
rm -rf "$E"
printf '\nall lab homes removed\n'
Evidence: P2 pinnace entry announcement as printed by fm-afk-launch.sh enter
Away posture recorded at 2026-09-27T06:15:09Z: the pinnace is the reach channel. Orders from your phone reach me while you are away, and anything that needs you is answered there. Your away instructions are recorded verbatim; the away session will carry them out where it can. Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: 2026-09-28T08:00:00Z. Spend cap: 2 concurrent workers.
Evidence: P2 session-start AFK subsection on a pinnace lab home

Source: P2 session-start AFK subsection on a pinnace lab home

present - away posture recorded at 2026-09-27T06:17:21Z (bin/fm-afk-contract.sh readback for the mandate and the reach channel); no daemon runs, the ordinary supervision session continues.


Orphan status logs (state/*.status without matching .meta)
--------------------------------------------------------------------------------
(none)

AFK
--------------------------------------------------------------------------------
present - away posture recorded at 2026-09-27T06:17:21Z (bin/fm-afk-contract.sh readback for the mandate and the reach channel); no daemon runs, the ordinary supervision session continues.

================================================================================
NETWORK CHECKS
Evidence: P3 lab Claude primary pane: /afk entry and the relayed pinnace announcement

Source: P3 lab Claude primary pane: /afk entry and the relayed pinnace announcement

     See the migration guide: https://hindsight.vectorize.io/sdks/integrations/coding-agents#migrating-from-the-per-agent-plugins
     Your Hindsight server and token carry over automatically; past conversations are re-imported from
     local transcripts. Silence this with "upgradeNotice": false in ~/.hindsight/claude-code.json
❯ /afk answer phone orders while I am out; hold anything you are unsure of
⏺ Captain, aye. Writing the away record first, then running the session start.
  Ran 2 shell commands
⏺ Record is written. Now the one-shot session start, and reading the daemon's header in parallel.
  Ran 2 shell commands
⏺ Session start is clean: no work under way, empty queue, and the away record is on file. This is a Claude home without the supervision host, so I prepare the native daemon
  lifecycle next.
  Ran 1 shell command
⏺ Switched to Opus 5.5 for this session · Fable 5.1 requires usage credits · /model to change
⏺ Lifecycle is prepared. Launching the away daemon through the native background job.
  Ran 2 shell commands
⏺ Captain, away mode is on and your instructions are recorded word for word. Your phone is the channel for reaching you. Orders you send from it reach me while you are out, and
  anything that needs you gets answered there.
  What I read your words as asking:
  - I treat each order from your phone as if you had typed it here. I act on it and send you a short reply on the phone.
  - Anything I'm unsure about waits. I tell you about it on the phone rather than guessing.
  Limits that still apply:
  - Nothing in your words is outside what I can do while you're away.
  - Destructive, irreversible or security-sensitive actions can't be approved in advance, even from the phone. If an order needs one, I'll ask you to confirm with a second phone
    message before doing it.
  - A merge with failing checks never goes through while you're away. Landing work that exists only as a local branch waits for your return.
  You gave no expected return time. I will run at most four workers at once.
  There is no work under way right now, and no projects or queued tasks are registered here. The startup checks passed, including GitHub sign-in. Monitoring is running and will
  pick up your phone orders as they arrive. To change these instructions, send the away command again with new words.
✻ Sautéed for 1m 23s · done 8:21 AM · 1 shell still running
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
❯ 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ⏵⏵ bypass permissions on · 1 shell · ← for agents · ↓ to manage
Evidence: P3 reply the first mate sent to the phone for the destructive order
Captain, I have not deleted anything. Deleting the whole data directory cannot be undone, so I need a confirming phone note before I do it. Right now that directory is already empty, so there is nothing to clear. Send a note saying confirm delete data directory if you still want it removed.
Evidence: P3 queued pinnace notes (JSON returned by fm-inbox.sh note)

Source: P3 queued pinnace notes (JSON returned by fm-inbox.sh note)

{"schema":"fm-inbox-note.v1","outcome":"created","id":"1790490114-XV2ztx","request_id":"pinnace:44fec563-c0c9-46e0-9d7d-57057aeae15a","saved":true,"announced":true,"acknowledged":false,"path":"/var/folders/3c/4w5d0nf515l_hw4zg0pcvq880000gn/T//fm-lab.r6MwOp/state/inbox/1790490114-XV2ztx.note"}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 1 info
  • ℹ️ bin/fm-inbox.sh:248 - fm-inbox.sh note --source --json nope queues a note with source=--json (exit 0, human output) instead of a usage error, because a leading dash satisfies the intent's token rule [A-Za-z0-9._-]+. This matches the existing --request-id convention and is not a scenario failure against the stated intent, but a source token can never legitimately begin with --, so the author may want valid_source to refuse a leading dash. Observed in p1-inbox-provenance-transcript.txt section 5.
  • Live validation: ✅ go - 18 of 19 scenarios driven live against the product
Scenario Result Live Evidence
P1: the phone queues a note with --source pinnace and --meta node/login; the record carries those headers above the body, receipts return source=pinnace with the body unchanged, and exactly one inbox… ✅ pass live p1-inbox-provenance-transcript.txt sections 1, 1a, 1b, 1d
P1: a retry with the same pinnace request id replays the original note (outcome replay, same id, one note on disk) with its source intact ✅ pass live p1-inbox-provenance-transcript.txt section 1c
P1: a pinnace note with its body on stdin (the server's form) records the provenance headers ✅ pass live p1-inbox-provenance-transcript.txt section 2
P1: a plain terminal note keeps the default source=text and no extra headers ✅ pass live p1-inbox-provenance-transcript.txt section 3
P1: human list and drain output still print only the note id and body, never the source header ✅ pass live p1-inbox-provenance-transcript.txt section 4
P1 adversarial: source tokens with a space, a path, an empty value, a newline, or a semicolon, and meta lines reusing a fixed header (source, id, at, announce_marker, request_id), lacking '=', with an… ✅ pass live p1-inbox-provenance-transcript.txt section 5 (note count 3 before and 4 after, the one addition being the flag-as-token case reported as an info finding)
P1: note --help and the script header document --source and --meta ✅ pass live p1-inbox-provenance-transcript.txt section 7
P1: the first mate's reply and acknowledgement of a pinnace note both appear in receipts ✅ pass live p1-inbox-provenance-transcript.txt section 8
P1: fm-inbox.sh say still records source=voice ⏸️ untested no say transcribes audio through AWS Bedrock and requires config/inbox-region, config/inbox-stt-model, and AWS credentials, none of which exist in this environment; provide a configured lab home with Bed…
P2: with config/pinnace present, /afk entry through fm-afk-launch.sh writes reach_channels: pinnace, announces 'the pinnace is the reach channel. Orders from your phone reach me...' with no 'waits for… ✅ pass live p2-afk-reach-channel-transcript.txt sections A, A1, A2, A3
P2: the real afk skill in a Claude primary on a pinnace home writes the pinnace record and relays the recorded reach sentence with no hold-for-return contradiction ✅ pass live p3-lab-pane-1-afk-entry.txt and p3-lab-primary-commands.txt (Bash #2)
P2: without the flag, entry with and without words writes reach_channels: none and announces 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' exac… ✅ pass live p2-afk-reach-channel-transcript.txt sections B and B1
P2 adversarial: a standing version-2 none record still validates after the flag appears, a refresh keeps none and announces the recorded channel rather than the flag, new words re-record pinnace, remo… ✅ pass live p2-afk-reach-channel-transcript.txt section C
P2: presence is the switch: an empty config/pinnace turns the channel on; a directory of that name does not ✅ pass live p2-afk-reach-channel-transcript.txt section D
P2: a hand-written version 1 record with none still validates and reads back ✅ pass live p2-afk-reach-channel-transcript.txt section E
P2: the return archives a pinnace record cleanly, both through fm-afk-launch.sh stop and through the real skill's return in the Claude lab ✅ pass live p2-afk-reach-channel-transcript.txt section A4 and p3-lab-pane-3-return.txt
P2: the session-start AFK digest no longer hardcodes 'hold-for-return only' and points to readback for the mandate and the reach channel ✅ pass live p2-session-start-afk-subsection.txt
P3: a real Claude first mate in the away posture receives a pinnace note via the away daemon's record-backed doorbell, verifies it, reads the source from fm-inbox.sh receipts, replies with fm-inbox.sh… ✅ pass live p3-lab-pane-2-pinnace-note-handled.txt, p3-pinnace-note-1-durable-state.txt, p3-lab-primary-commands.txt (Bash #8 to #10)
P3 adversarial: a destructive phone order ('delete the whole data directory') is held: nothing is deleted, the reply asks for a confirming note, the posture stays away, and the note is acknowledged ✅ pass live p3-pinnace-note-2-never-set-hold.txt, p3-lab-primary-commands.txt (Bash #11 to #13)
  • bin/fm-test-run.sh tests/fm-inbox.test.sh tests/fm-afk-contract.test.sh (baseline, both pass; log in baseline-targeted-tests.log)
  • bash p1-inbox-provenance-driver.sh against a lab home: fm-inbox.sh note --source pinnace --meta node=... --meta login=... --request-id pinnace:&lt;id&gt; --json, stdin body variant, plain note, list, drain, receipts, reply, drain --ack, and 18 malformed --source/--meta inputs
  • bash p2-afk-reach-channel-driver.sh against lab homes: fm-afk-launch.sh enter with and without config/pinnace, refresh, replacement, stop, fm-afk-contract.sh field/validate/readback, flag removal, a corrupted reach_channels value, an empty flag file, a directory named pinnace, and a hand-written version 1 record
  • FM_HOME=&lt;pinnace lab&gt; bin/fm-session-start.sh and inspection of its AFK subsection
  • Lab Claude primary on tmux -L fm-lab with FM_HOME=&lt;marked lab home&gt;: /afk answer phone orders while I am out; hold anything you are unsure of, then two pinnace notes queued from outside with fm-inbox.sh note --source pinnace ..., delivered by the real away daemon as record-backed doorbells, then the unmarked return message
  • Verification of the lab's durable state after each note: fm-inbox.sh receipts, the reply records under state/inbox/.replies, the handled note, the operational-inbox escalation record, the away record, and the archived record after return
  • Extraction of the lab primary's Bash commands from its own Claude Code session transcript to confirm it ran fm-inbox.sh receipts, reply &lt;id&gt;, and drain --ack &lt;id&gt; in that order
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

Chris added 7 commits September 27, 2026 08:03
The pinnace, the crowsnest phone channel, queues its orders through
`fm-inbox.sh note` like the voice handover does, but nothing in the record
said who spoke. `note --source <token>` now sets the `source` header that
`receipts` already returns (default `text`, `say` keeps `voice`, the phone
passes `pinnace`), and each repeatable `--meta <key>=<value>` adds one
provenance header through the header slot `say` already fills, so a phone
order carries its tailnet node and login. Tokens are validated and a meta
key may not reuse a fixed header name, so no caller can forge the id,
time, or source of a note.
…innace exists

The away-posture record always said `reach_channels: none` and the entry
announcement always denied a phone channel, because none existed. The
pinnace, the crowsnest phone channel, now exists and rides the captain-note
plane, so a home that opts in with `config/pinnace` (first line the captain's
tailnet login) records `reach_channels: pinnace` and announces that orders
from the phone reach the first mate while away. Validation accepts
`none|pinnace`, a standing record keeps the channel it was written with,
and a version 2 record carrying `none` still validates, so a live away
window is unaffected by the upgrade. The reach lines in the afk skill,
the architecture doc, the launch header, and the AGENTS.md away stub now
say the recorded channel instead of denying a phone.
A note the pinnace queues carries `source=pinnace`, but nothing told the
first mate how to treat it: while away, a note arrives as an operational
digest, and an inbox note is otherwise answered only "when it needs a
durable answer". The afk skill's "While away" section and the AGENTS.md
inbox-note rule now say a pinnace note is the captain speaking from the
phone: act on it with ordinary chat authority, keep the away posture,
confirm first through a further note for anything destructive,
irreversible, or security-sensitive, and always answer it with
`fm-inbox.sh reply` in one short section 9 outcome message before
acknowledging it. Prose only; the crowsnest smoke script and a manual
end-to-end after install are the executable proof.
@cbcotton cbcotton closed this Sep 27, 2026
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