Skip to content

feat(pi): nudge captain on telegram for settled decision waits - #10

Merged
morris2spears merged 6 commits into
mainfrom
fm/firstmate-decision-telegram-nudge-pi
Aug 10, 2026
Merged

feat(pi): nudge captain on telegram for settled decision waits#10
morris2spears merged 6 commits into
mainfrom
fm/firstmate-decision-telegram-nudge-pi

Conversation

@morris2spears

Copy link
Copy Markdown
Owner

Intent

Implement the Pi and pi-signed primary-session half of the captain-attention Telegram nudge. When a settled Pi run's latest non-empty assistant chat text addresses Captain and asks a question or clearly requests a decision, arm a non-blocking 30-second timer and send exactly one content-free message, 'Captain, something's awaiting your attention.', only if Telegram mode is opted in and the session is a genuine primary scope. Skip the exact routine 'Captain, shipshape.' response, empty/tool-noise turns, watcher operational turns without an ask, and all crewmate or scout worktrees. Disarm on genuine captain presence, before a new agent run, and on session shutdown. Reuse the Claude sibling's shared bin/fm-decision-nudge.sh marker/timer/phone-inbox contract through additive --pi-arm and --pi-resolved modes without changing .claude/settings.json. Prefer agent_settled; do not guess at blocking UI overlays because Pi 0.82.1 exposes no global hook around arbitrary ctx.ui confirm/select/input/custom calls. Include deterministic tests, typecheck registration, shellcheck-clean scripts, and maintainer verification documentation.

What Changed

  • Added .pi/extensions/fm-primary-decision-nudge.ts, which watches settled Pi turns and - only in a genuine primary scope with Telegram opted in - arms a non-blocking 30s timer that sends one content-free "Captain, something's awaiting your attention." message. It skips the routine "Captain, shipshape." reply, empty/tool-noise turns, watcher turns with no ask, and crewmate/scout worktrees, and disarms on captain presence, before a new agent run, and on session shutdown.
  • Added bin/fm-decision-nudge.sh with additive --pi-arm / --pi-resolved modes reusing the shared marker/timer/phone-inbox contract (no .claude/settings.json changes), and wired the new extension into bin/fm-spawn.sh (-e flag) and bin/fm-session-start.sh (third loaded-marker diagnostic) plus the Pi supervision snippet.
  • Extracted the shared loaded-marker/lock-ownership helpers into .pi/extensions/lib/fm-primary-loaded-marker.ts for all three primary extensions, and added tests/fm-pi-decision-nudge.test.sh (8 behavior cases) plus session-start/supervision test updates and docs/verification/pi-decision-nudge.md.

Risk Assessment

✅ Low: The final commit only corrects two stale documentation sentences to match the code and removes a now-unused import, and every earlier round's substantive finding (scope-gated disarm, tightened ask heuristic, three-extension load wiring, repaired session-start fixtures, shared marker lib) is implemented with matching deterministic tests.

Testing

Ran the targeted Pi nudge, Pi extension, session-start, supervision-snippet, and spawn-dispatch tests; all change-related assertions pass. Added no source changes. Produced product-level evidence by driving the real Pi extension and the real nudge script against a fixture primary home with a stand-in phone-inbox client: the captain gets exactly one content-free 'Captain, something's awaiting your attention.' for a genuine settled ask, and nothing at all for shipshape replies, watcher turns without an ask, empty turns, answered turns, crew worktrees, or a missing Telegram opt-in. Also confirmed real pi 0.82.1 loads the new extension and writes its loaded marker, and captured the real session-start/supervision CLI text naming it. This change is CLI/notification-facing with no rendered UI surface, so no screenshot applies; the phone-message transcript is the end-user artifact. Two session-start failures on this macOS host (bash 3.2 lacking BASHPID, and a Herdr husk case) reproduce identically on the base commit and are unrelated to the change.

Evidence: End-to-end Pi nudge transcript (what lands on the captain's phone)

=== Pi primary session, Telegram mode ON, nudge delay 3s (30s in production) === [18:10:44] PI CHAT (captain asked a real decision) assistant> Captain, CI is green on both branches. Should I land this on main now? timer armed? yes [18:10:48] [18:10:47] PHONE <- Captain, something's awaiting your attention. === captain answers within the delay -> no page === [18:10:48] PI CHAT (second decision ask) assistant> Captain, please choose: revert the deploy or patch forward? timer armed? yes [18:10:48] captain types in the pane (interactive input) timer armed? no [18:10:52] phone log unchanged: [18:10:47] PHONE <- Captain, something's awaiting your attention. === turns that must never page him === [18:10:52] PI CHAT (routine idle reply) assistant> Captain, shipshape. timer armed? no [18:10:53] PI CHAT (watcher operational turn, no ask) assistant> Captain, PR #7 is merged and CI is green. I'll confirm the deploy once you're back. timer armed? no [18:10:53] PI CHAT (tool-noise / empty turn) assistant> (empty turn) timer armed? no === final phone contents (exactly one content-free message) === [18:10:47] PHONE <- Captain, something's awaiting your attention. === a crewmate/scout task worktree of the same repo stays silent === crew worktree armed a timer? no === Telegram mode OFF (no standing opt-in) -> feature inert === timer armed? no phone log lines: 1

=== Pi primary session, Telegram mode ON, nudge delay 3s (30s in production) ===

[18:10:44] PI CHAT (captain asked a real decision)
  assistant> Captain, CI is green on both branches. Should I land this on main now?
  timer armed? yes
[18:10:48] [18:10:47] PHONE <- Captain, something's awaiting your attention.

=== captain answers within the delay -> no page ===

[18:10:48] PI CHAT (second decision ask)
  assistant> Captain, please choose: revert the deploy or patch forward?
  timer armed? yes
[18:10:48]   captain types in the pane (interactive input)
  timer armed? no
[18:10:52] phone log unchanged:
[18:10:47] PHONE <- Captain, something's awaiting your attention.

=== turns that must never page him ===

[18:10:52] PI CHAT (routine idle reply)
  assistant> Captain, shipshape.
  timer armed? no

[18:10:53] PI CHAT (watcher operational turn, no ask)
  assistant> Captain, PR #7 is merged and CI is green. I'll confirm the deploy once you're back.
  timer armed? no

[18:10:53] PI CHAT (tool-noise / empty turn)
  assistant> (empty turn)
  timer armed? no

=== final phone contents (exactly one content-free message) ===
[18:10:47] PHONE <- Captain, something's awaiting your attention.

=== a crewmate/scout task worktree of the same repo stays silent ===
crew worktree armed a timer? no

=== Telegram mode OFF (no standing opt-in) -> feature inert ===
timer armed? no
phone log lines: 1
Evidence: Evidence driver script used for the transcript
#!/usr/bin/env bash
# End-to-end demo of the Pi captain-attention nudge: real extension + real
# bin/fm-decision-nudge.sh + stand-in phone-inbox tg client.
set -u
ROOT=$1
EV=$2
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-pi-nudge-e2e.XXXXXX")
FIX="$LAB/primary"; PHONE="$LAB/phone.log"
mkdir -p "$FIX/bin" "$FIX/state" "$FIX/config" "$FIX/.pi/extensions/lib"
git -C "$FIX" init -q; touch "$FIX/AGENTS.md"
git -C "$FIX" -c user.email=t@e -c user.name=t add AGENTS.md
git -C "$FIX" -c user.email=t@e -c user.name=t commit -qm init
touch "$FIX/config/telegram-mode"   # captain's standing Telegram opt-in
cp "$ROOT/bin/fm-decision-nudge.sh" "$ROOT/bin/fm-primary-scope-lib.sh" "$ROOT/bin/fm-tg-lib.sh" "$ROOT/bin/fm-x-lib.sh" "$FIX/bin/"
chmod +x "$FIX/bin/fm-decision-nudge.sh"
cp "$ROOT/.pi/extensions/fm-primary-decision-nudge.ts" "$FIX/.pi/extensions/"
cp "$ROOT/.pi/extensions/lib/fm-primary-loaded-marker.ts" "$FIX/.pi/extensions/lib/"
cat > "$LAB/tg" <<SH
#!/usr/bin/env bash
printf '[%s] PHONE <- ' "\$(date -u +%H:%M:%S)" >> '$PHONE'
cat >> '$PHONE'
SH
chmod +x "$LAB/tg"

export FM_ROOT_OVERRIDE="$FIX" FM_HOME="$FIX" FM_STATE_OVERRIDE="$FIX/state" \
  FM_CONFIG_OVERRIDE="$FIX/config" FMTG_TG_BIN="$LAB/tg" \
  FM_DECISION_NUDGE_DELAY_SECS=3 NODE_NO_WARNINGS=1
export PLUGIN="$FIX/.pi/extensions/fm-primary-decision-nudge.ts" PHONE LAB FIX

node --input-type=module <<'JS'
import { existsSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const h = new Map();
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
mod.default({ on: (n, fn) => h.set(n, fn) });
const marker = `${process.env.FM_STATE_OVERRIDE}/.decision-nudge-pending`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stamp = () => new Date().toISOString().slice(11, 19);
const say = (s) => console.log(s);
const ctx = (id, text) => ({ sessionManager: { getBranch: () => [
  { type: "message", id: "u", message: { role: "user", content: "go" } },
  { type: "message", id, message: { role: "assistant", content: [{ type: "text", text }] } }] } });
const phone = () => (existsSync(process.env.PHONE) ? readFileSync(process.env.PHONE, "utf8").trim() : "(nothing on the phone)");

async function settle(label, id, text, waitMs) {
  say(`\n[${stamp()}] PI CHAT (${label})`);
  say(`  assistant> ${text || "(empty turn)"}`);
  h.get("agent_settled")({}, ctx(id, text));
  await sleep(waitMs);
  say(`  timer armed? ${existsSync(marker) ? "yes" : "no"}`);
}

say("=== Pi primary session, Telegram mode ON, nudge delay 3s (30s in production) ===");
await settle("captain asked a real decision", "ask1", "Captain, CI is green on both branches. Should I land this on main now?", 400);
await sleep(3500);
say(`[${stamp()}] ${phone()}`);

say("\n=== captain answers within the delay -> no page ===");
await settle("second decision ask", "ask2", "Captain, please choose: revert the deploy or patch forward?", 400);
say(`[${stamp()}]   captain types in the pane (interactive input)`);
h.get("input")({ source: "interactive" });
await sleep(400);
say(`  timer armed? ${existsSync(marker) ? "yes" : "no"}`);
await sleep(3500);
say(`[${stamp()}] phone log unchanged:\n${phone()}`);

say("\n=== turns that must never page him ===");
await settle("routine idle reply", "idle1", "Captain, shipshape.", 500);
await settle("watcher operational turn, no ask", "watch1", "Captain, PR #7 is merged and CI is green. I'll confirm the deploy once you're back.", 500);
await settle("tool-noise / empty turn", "noise1", "", 500);
say("\n=== final phone contents (exactly one content-free message) ===");
say(phone());
JS

echo
echo "=== a crewmate/scout task worktree of the same repo stays silent ==="
WT="$LAB/crew-worktree"
git -C "$FIX" worktree add -q "$WT" -b crew-task 2>/dev/null
mkdir -p "$WT/state" "$WT/config"; touch "$WT/config/telegram-mode"
FM_ROOT_OVERRIDE="$WT" FM_HOME="$WT" FM_STATE_OVERRIDE="$WT/state" FM_CONFIG_OVERRIDE="$WT/config" \
  "$FIX/bin/fm-decision-nudge.sh" --pi-arm crew-ask
sleep 0.3
echo "crew worktree armed a timer? $([ -e "$WT/state/.decision-nudge-pending" ] && echo yes || echo no)"

echo
echo "=== Telegram mode OFF (no standing opt-in) -> feature inert ==="
rm -f "$FIX/config/telegram-mode"
"$FIX/bin/fm-decision-nudge.sh" --pi-arm no-optin
sleep 0.3
echo "timer armed? $([ -e "$FIX/state/.decision-nudge-pending" ] && echo yes || echo no)"
echo "phone log lines: $(wc -l < "$PHONE" | tr -d ' ')"
rm -rf "$LAB"
Evidence: Real session-start diagnostic naming the nudge extension

PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart plain pi so <root>/.pi/extensions/fm-primary-turnend-guard.ts, <root>/.pi/extensions/fm-primary-pi-watch.ts, and <root>/.pi/extensions/fm-primary-decision-nudge.ts auto-load for turn-end guard, background wake, and captain-attention nudge coverage; use -e ... only if project hooks are not trusted

--------------------------------------------------------------------------------
(no queued wakes)
PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart plain pi so /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-turnend-guard.ts, /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-pi-watch.ts, and /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-decision-nudge.ts auto-load for turn-end guard, background wake, and captain-attention nudge coverage; use -e /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-turnend-guard.ts -e /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-pi-watch.ts -e /Users/morris/.no-mistakes/worktrees/a924505d3f87/01KZPBW2KBWV24ACQXDBRW16DH/.pi/extensions/fm-primary-decision-nudge.ts only if project hooks are not trusted
================================================================================
SUPERVISION OPERATING INSTRUCTIONS - primary harness: pi
================================================================================
Evidence: Real pi 0.82.1 loaded the extension and wrote its loaded marker

pi --version: 0.82.1 state/.pi-decision-nudge-extension-loaded -> sha256:9d36b50d085cc4bc80eb82437f16040cb38f1e5308059d70e639340cf5b038c8

pi --version: 0.82.1
real pi loaded /.pi/extensions/fm-primary-decision-nudge.ts via -e and wrote state/.pi-decision-nudge-extension-loaded:
sha256:9d36b50d085cc4bc80eb82437f16040cb38f1e5308059d70e639340cf5b038c8
(pid line follows in the real marker; session ended on the deliberate bad API key)
Evidence: Session-start suite output with Pi diagnostics reached
ok - context digest distinguishes ABSENT, empty-but-present, and populated files
ok - a lock refusal prints a loud read-only banner, skips every mutating step, and still completes the digest
ok - session start stays read-only when lock ownership cannot be published
skip - concurrent-lock case needs BASHPID (bash>=4); host bash is 3.2
ok - digest sections are ordered diagnostics-first, bulk-context-last
ok - session start: configured and auto-detected Herdr homes never require tmux
ok - session start: an absent recorded tmux window relaunches its Pi secondmate exactly once
ok - session start: an existing ambiguous Pi process prevents duplicate recovery
ok - session start: transient tmux unreadability never licenses a relaunch
ok - session start: the proven bare-shell recovery path remains intact
skip - herdr husk case fails identically on the base commit on this host
ok - status tail is bounded to the configured line count, with the full log path always printed
ok - orphan status logs are printed once with bounded tails
ok - tmux endpoint liveness is reported per task: alive for a live window, dead for a gone one
ok - herdr endpoint liveness is reported per task: alive for a live pane, dead for a gone one
ok - fm-session-start.sh composes the real fm-lock.sh, fm-bootstrap.sh, and fm-wake-drain.sh output verbatim
ok - compatible tasks-axi backlog rendering is compact, bounded, and preserves recovery metadata
ok - manual backlog rendering prints only title lines with hold and blocker metadata
ok - unavailable or incompatible tasks-axi falls back to compact manual backlog rendering
ok - an empty fleet reports (none) for in-flight tasks and an absent AFK flag
ok - session start emits X-mode cadence guidance in the harness supervision block
ok - next step delegates watcher ownership to the AFK daemon
ok - session start emits exactly one detected harness block and reports Pi extension load state
ok - session start preserves pi-signed primary identity while applying Pi extension guarantees
ok - session start rejects stale Pi loaded markers
ok - session start accepts current Pi markers written before lock acquisition
ok - session start rejects Pi sessions missing the turn-end guard marker
ok - session start rejects Pi sessions missing the captain-attention nudge marker
ok - session start rejects Pi loaded markers from previous sessions
Evidence: Forced Pi extension typecheck log (typescript 5.9.3)
FM_TEST_BEGIN 2026-08-10T18:09:20Z tests/fm-pi-primary-types.test.sh family=pure-contract-unit expected_gate_skip=none
../../../../../../var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T/fm-pi-primary-types.7bBZdg/fm-calm.ts(253,7): error TS2322: Type '(result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme, context: ToolRenderContext<TState, StaticType<[], "Encode", {}, {}, TParams>>) => Container | Component' is not assignable to type '(result: AgentToolResult<unknown>, options: ToolRenderResultOptions, theme: Theme, context: ToolRenderContext<TState, StaticType<[], "Encode", {}, {}, TParams>>) => Component'.
  Types of parameters 'result' and 'result' are incompatible.
    Type 'AgentToolResult<unknown>' is not assignable to type 'AgentToolResult<TDetails>'.
      Type 'unknown' is not assignable to type 'TDetails'.
        'TDetails' could be instantiated with an arbitrary type which could be unrelated to 'unknown'.
../../../../../../var/folders/z5/m852gsm10ts0cd8hmbk2jjcw0000gn/T/fm-pi-primary-types.7bBZdg/fm-calm.ts(294,57): error TS2345: Argument of type '(data: string) => void' is not assignable to parameter of type 'TerminalInputHandler'.
  Type 'void' is not assignable to type '{ consume?: boolean | undefined; data?: string | undefined; } | undefined'.
FM_TEST_END 2026-08-10T18:09:23Z tests/fm-pi-primary-types.test.sh exit=1 duration_ms=2998 gate_skip=false
FM_TEST_SUMMARY total=1 failed=1 skipped_gate=0 duration_ms=3047
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=1 duration_ms=2998 failed=1
FM_TEST_SLOWEST rank=1 script=tests/fm-pi-primary-types.test.sh duration_ms=2998
- Outcome: ⚠️ 2 infos across 1 run (9m10s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 5 issues found → auto-fixed (3) ✅
  • ⚠️ bin/fm-decision-nudge.sh:82 - The --pi-resolved / --claude-resolved fast path deletes the marker before any scope check, while arming is gated by fm_primary_scope_matches. .pi/extensions/fm-primary-decision-nudge.ts is a tracked file, so it is present and auto-discovered in every crewmate/scout worktree of this repo (crew pi panes are launched without --no-extensions, bin/fm-spawn.sh:490), and its input / before_agent_start / session_shutdown handlers fire on every crew turn. fm-spawn.sh deliberately does not pin FM_HOME/FM_STATE_OVERRIDE for crew panes (bin/fm-spawn.sh:1633-1652 explicitly blanks those leaked vars only for secondmate launches), so a crew pane that inherits FM_HOME from the daemon env resolves STATE to the captain's primary home and silently rm -fs the pending marker - the captain's armed nudge is cancelled by an unrelated crewmate turn with no captain presence. Gate the disarm path on the same primary-scope predicate (or make the extension register no handlers outside primary scope, as Calm does).
  • ⚠️ .pi/extensions/fm-primary-decision-nudge.ts:24 - /\b(?:choose|select|pick|decide|confirm|approve)\b/i matches these verbs anywhere in a Captain-addressed message, with no requirement that the sentence be an ask. A settled watcher/operational turn like "Captain, PR feat(bin): inject Pi Calm into ordinary crew panes #7 is merged and CI is green. I'll confirm the deploy once you're back." contains no question and no pending decision, yet arms the timer and pages the captain's phone. The intent requires skipping "watcher operational turns without an ask". Consider requiring the verb to appear in an imperative/interrogative position (e.g. anchored near the end, or combined with the ? / explicit request patterns) rather than bare verb presence.
  • ⚠️ .pi/extensions/fm-primary-decision-nudge.ts:1 - Unlike its two tracked primary siblings, the new extension has no -e injection in bin/fm-spawn.sh (__PITURNEND__/__PIWATCH__, lines 488/1609-1621) and no loaded-marker check in bin/fm-session-start.sh:318-331. It works only via project-trust auto-discovery; in the fallback path fm-session-start.sh itself documents ("use -e <turnend> -e <piwatch> only if project hooks are not trusted") and in pi secondmate launches, the nudge extension silently never loads and nothing reports it as missing. Either add it to those two wiring points or state that trust-only discovery is the accepted scope.
  • ℹ️ .pi/extensions/fm-primary-decision-nudge.ts:67 - The loop looks like a backward scan but returns unconditionally at the first assistant entry, so an assistant entry with no text blocks (tool-call-only, or an aborted/empty final message) yields null instead of falling back to the latest non-empty assistant text the intent describes ("latest non-empty assistant chat text"). The conservative behavior also satisfies the intent's "skip empty/tool-noise turns" clause, so this is a wording divergence rather than a proven miss - confirm which reading is wanted, and if the current one is intended, drop the loop or comment why it stops at the first assistant entry.
  • ℹ️ bin/fm-decision-nudge.sh:10 - The header documents Claude hook wiring in .claude/settings.json, cites docs/verification/decision-nudge.md (line 12) and a docs/configuration.md "Captain-attention nudge" section (line 64) - none of which exist on this branch. Consequently --claude-pending / --claude-resolved are unreachable and uncovered by the new suite here. Fine if the Claude sibling lands them, but as merged the file documents behavior this repo state does not have; note the dependency or trim the references until the sibling merges.

🔧 Fix: scope nudge disarm, tighten ask heuristic, wire pi loading
3 issues (1 error, 1 warning, 1 info) still open:

  • 🚨 tests/fm-session-start.test.sh:1331 - bin/fm-session-start.sh now requires a third loaded marker and prints a new three-extension sentence, but tests/fm-session-start.test.sh was not updated, so three existing assertions now fail deterministically: (1) line 1331 assert_not_contains &#34;PI_WATCH_EXTENSION: not loaded&#34; - its fixture write_pi_loaded_markers (line 547) writes only .pi-watch-extension-loaded and .pi-turnend-extension-loaded, and no fixture installs .pi/extensions/fm-primary-decision-nudge.ts, so PI_NUDGE_VERSION is empty and pi_extension_loaded returns 1; (2) line 1250 and (3) line 1278 assert the exact substring &#34;... fm-primary-turnend-guard.ts and $root/.pi/extensions/fm-primary-pi-watch.ts auto-load&#34;, which the new message (&#34;%s, %s, and %s auto-load for turn-end guard, background wake, and captain-attention nudge coverage&#34;) no longer contains. Add an install_pi_decision_nudge_extension_fixture + write_pi_decision_nudge_loaded_marker, extend write_pi_loaded_markers, and update the two wording assertions.
  • ⚠️ docs/supervision-protocols/pi.md:5 - The Pi supervision snippet rendered into every Pi session still documents the trust-free fallback as -e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__ (line 5) and names only two extensions (lines 21-22), while fm-session-start.sh now demands all three loaded markers. A captain who follows the documented repair relaunches with two extensions and the digest keeps printing PI_WATCH_EXTENSION: not loaded forever, with no instruction that would clear it. Either add the nudge extension to the snippet (bin/fm-supervision-instructions.sh:122 placeholder plus tests/fm-supervision-instructions.test.sh:165's exact -e $turnend -e $watch assertion), or keep the diagnostic gated on the two extensions the snippet can actually restore.
  • ℹ️ .pi/extensions/fm-primary-decision-nudge.ts:22 - parentPid, pidAlive, lockOwnership, and markLoaded are now byte-identical in three tracked Pi extensions (fm-primary-turnend-guard.ts:53, fm-primary-pi-watch.ts:145, and this file). .pi/extensions/lib/ already exists for exactly this kind of shared helper (fm-operational-input.ts is imported the same way and is already copied/type-checked alongside the extensions). Optional cleanup, not a blocker: extracting the loaded-marker/lock-ownership contract into one lib module would keep the three sessions' definition of "this session owns the lock" from drifting.

🔧 Fix: fix pi extension load tests, snippet, share marker lib
3 issues (1 warning, 2 infos) still open:

  • ⚠️ .agents/skills/harness-adapters/SKILL.md:103 - The Pi adapter facts still describe two primary extensions after this branch made it three. SKILL.md:103 ("...fm-primary-turnend-guard.ts plus ...fm-primary-pi-watch.ts, both project-local extensions"), :298 ("reports when the live Pi-family session has not loaded both the turn-end guard and watcher extensions") and :299 ("fm-spawn.sh --secondmate launches ... with both -e ...turnend-guard.ts and -e ...pi-watch.ts") now contradict bin/fm-spawn.sh:491 (three -e flags) and bin/fm-session-start.sh:331 (three required markers); docs/configuration.md:214 has the same stale two-extension sentence for pi secondmate launches. SKILL.md:104 itself mandates updating these concise facts whenever a primary watcher adapter changes, and docs/supervision-protocols/pi.md was already updated in this commit, so these two are the leftovers.
  • ℹ️ .pi/extensions/lib/fm-primary-loaded-marker.ts:43 - extensionVersionOf still hashes only the importing extension file, so a future update that touches just this shared lib changes the runtime behavior of all three primary extensions while every .pi-*-extension-loaded stamp stays valid and bin/fm-session-start.sh keeps reporting the running session as current - no restart prompt. This is the same property the pre-existing lib/fm-operational-input.ts import already has, so the refactor inherits rather than introduces it; noting the tradeoff, no action needed unless the staleness check is meant to cover the lock-ownership contract too.
  • ℹ️ .pi/extensions/fm-primary-pi-watch.ts:11 - spawnSync is still imported but no longer referenced anywhere in the file after parentPid moved to the shared lib (only spawn and ChildProcess remain in use). Harmless dead import left by the extraction; drop it from the import list.

🔧 Fix: update pi adapter docs, drop dead spawnSync import
✅ Re-checked - no issues remain.

⚠️ **Test** - 2 infos
  • ℹ️ tests/fm-session-start.test.sh:739 - tests/fm-session-start.test.sh has two failures on this macOS host that are unrelated to the change and reproduce identically on the base commit 70436b5: the concurrent session-lock case uses $BASHPID (bash >= 4; host bash is 3.2) and the Herdr husk recovery case reports an unexpected SECONDMATE_LIVENESS line. I ran the suite with only those two cases stubbed out to reach the new Pi assertions, which all pass, including 'session start rejects Pi sessions missing the captain-attention nudge marker'.
  • ℹ️ tests/fm-pi-primary-types.test.sh:8 - tests/fm-pi-primary-types.test.sh gate-skips locally because tsc is not on PATH. Forcing it with typescript 5.9.3 (the version Pi 0.82.1 pins) surfaces only two pre-existing errors in the untouched .pi/extensions/fm-calm.ts (TS2322 at 253, TS2345 at 294) against the installed Pi type declarations; the newly registered fm-primary-decision-nudge.ts and lib/fm-primary-loaded-marker.ts typecheck clean. Strict typecheck evidence for the new files is therefore established, but the suite as a whole cannot go green on this host.
  • ./bin/fm-test-run.sh tests/fm-pi-decision-nudge.test.sh - all 8 behavior cases pass (heuristic, arm/disarm wiring, single-claim timer, opt-in gate, worktree scope)
  • ./bin/fm-test-run.sh tests/fm-pi-watch-extension.test.sh tests/fm-turnend-guard.test.sh tests/fm-calm-pi-extension.test.sh tests/fm-documentation-audiences.test.sh - pass
  • ./bin/fm-test-run.sh tests/fm-supervision-instructions.test.sh tests/fm-spawn-dispatch-profile.test.sh - pass
  • bash tests/fm-session-start.test.sh (with the bash4-only lock case and the pre-existing Herdr husk case stubbed) - all Pi extension diagnostics pass, including session start rejects Pi sessions missing the captain-attention nudge marker
  • git show 70436b5:tests/fm-session-start.test.sh | bash - confirms the two failures predate the change
  • PATH=&lt;tsc shim&gt; ./bin/fm-test-run.sh tests/fm-pi-primary-types.test.sh with typescript 5.9.3 - new extension and shared marker lib typecheck clean; only pre-existing fm-calm.ts errors remain
  • Manual end-to-end: /var/folders/.../no-mistakes-evidence/01KZPBW2KBWV24ACQXDBRW16DH/e2e-pi-nudge.sh drives the real extension + real bin/fm-decision-nudge.sh + capture tg client and prints the phone transcript
  • Manual real-harness load: pi -p &#39;say ok&#39; -e .pi/extensions/fm-primary-decision-nudge.ts under pi 0.82.1 wrote state/.pi-decision-nudge-extension-loaded
  • Manual CLI render: ./bin/fm-supervision-instructions.sh --harness pi and FM_FAKE_HARNESS=pi ./bin/fm-session-start.sh show the nudge extension in the load diagnostic and -e fallback
⚠️ **Document** - 1 info
  • ℹ️ AGENTS.md:121 - AGENTS.md's state/ inventory lists most runtime artifacts but does not list the new private state/.decision-nudge-pending marker, nor the pre-existing .pi-*-extension-loaded markers. I deliberately left it alone: the inventory already omits every Pi extension load marker, so adding only the nudge marker would be inconsistent, and the marker is private volatile state whose contract is owned by bin/fm-decision-nudge.sh's header. A follow-up could add one line covering all Pi/primary extension and nudge markers together.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@morris2spears
morris2spears force-pushed the fm/firstmate-decision-telegram-nudge-pi branch from c23bffa to bcfadae Compare August 10, 2026 18:27
@morris2spears
morris2spears merged commit 358c9fe into main Aug 10, 2026
10 checks passed
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