Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
20e7db5
feat(supervision): surface open captain decisions and waits
ItsFlow Jul 28, 2026
3ac611f
docs: route the captain-attention contract from architecture and deci…
ItsFlow Jul 28, 2026
dd3180f
fix(attention): treat a captain-gated work item as a decision
ItsFlow Jul 28, 2026
3e4a04c
no-mistakes(review): Fix captain attention surfacing gaps
ItsFlow Jul 28, 2026
d72c143
no-mistakes(review): Harden captain attention surfacing
ItsFlow Jul 28, 2026
4e89852
no-mistakes(review): Reset unknown attention marker after recovery
ItsFlow Jul 28, 2026
ba0092b
no-mistakes(document): Refresh captain attention docs
ItsFlow Jul 28, 2026
2844dd2
no-mistakes(lint): Fix attention lint warnings
ItsFlow Jul 28, 2026
5fe8369
fix(test): give the old-bin conformance fixture its attention dependency
ItsFlow Jul 28, 2026
a9615de
no-mistakes(review): Captain, all fixes are applied; I’m running the …
ItsFlow Jul 29, 2026
efcd098
no-mistakes(review): Harden captain attention receipt and wait lifecycle
ItsFlow Jul 29, 2026
95118a1
no-mistakes(review): Captain, harden attention identity and lifecycle…
ItsFlow Jul 29, 2026
1e2c61e
no-mistakes(review): Captain, require explicit attention associations…
ItsFlow Jul 29, 2026
89f39a5
no-mistakes(review): Captain, follow linked state and reject dangling…
ItsFlow Jul 29, 2026
5313774
no-mistakes(document): Align captain-attention docs with final receip…
ItsFlow Jul 29, 2026
45c29fd
no-mistakes(review): Bound evidence-less captain-decision stops; port…
ItsFlow Aug 4, 2026
5c1fc00
no-mistakes(document): Align inventories with captain-attention marke…
ItsFlow Aug 4, 2026
5085634
no-mistakes(lint): fm-attention: resolve shellcheck SC2034/SC2016 fin…
ItsFlow Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/bearings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas
If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh <id>`; never infer current state from a raw `tail` of `state/<id>.status`, which is append-only wake-event history whose last line goes stale.
For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state.
Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them.
For each one, take the plain-language decision text from `bin/fm-attention.sh`, which is the single chat renderer for open decisions and meaningful waits and already renders the choice, the stakes, the options, and the recommendation; a decision with no recorded briefing renders as not yet written, and Captain's Call must say that rather than dressing up its raw note.
A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason.
A declared external delay belongs in Charted Next with what it is waiting for and when it is next checked, both of which `bin/fm-attention.sh` renders; [`docs/captain-attention.md`](../../../docs/captain-attention.md) owns the explicit transition that can move it to Captain's Call.
The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call.

2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.**
Expand Down
5 changes: 4 additions & 1 deletion .agents/skills/decision-hold-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ This skill is the single policy owner for unresolved captain decisions discovere
Every unresolved decision that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must become a structured captain-held work item in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete.
The agent performs the semantic inventory because scripts must not infer decisions from report prose, visual-review artifacts, terminal output, or chat.
Give each distinct unresolved decision a stable privacy-safe key, register it through `bin/fm-decision-hold.sh hold`, and use the same key on retry so registration is idempotent while different decisions retain different durable identities.
Register it with its captain briefing in the same call: the concrete choice, why it matters now, what waiting costs, at least one real option, and your recommendation.
Give that briefing a stable privacy-safe semantic revision, reuse the revision for wording-only paraphrases, and change it whenever the choice, stakes, waiting cost, options, or recommendation changes substantively.
A title and a one-line reason cannot carry a decision, so the script refuses a new hold without that briefing; the briefing is what makes the decision answerable without further investigation.
After inventorying the whole report and review surface, run `bin/fm-decision-hold.sh complete` with every unresolved key, or with `--none` only when the reviewed surface contains no unresolved captain decision.
A completed investigation and an ended visual review use this same owner and completion command; a visual tool, including Lavish, never owns a parallel completion policy.
Run the command in the originating work's authoritative `FM_HOME`; main-home work creates main-home holds, and secondmate-owned work creates holds in that secondmate home's backlog rather than copying them into the main backlog.
Expand All @@ -29,7 +32,7 @@ Bearings reads the resulting structured state and must never compensate by scrap

1. Read the complete investigation result and complete the visual review before declaring either complete.
2. Inventory only genuine unresolved choices that require the captain.
3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, and repository.
3. For each choice, choose a stable key and use the script's `hold` command with a concise title, reason, repository, and the captain briefing fields.
4. Run the script's `complete` command with the full unresolved-key inventory for that review pass.
5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat.
6. After the captain decides, record dependent work with normal tasks-axi commands and block it by the hold identity.
Expand Down
82 changes: 71 additions & 11 deletions .opencode/plugins/fm-primary-turnend-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,37 @@ import { realpathSync } from "node:fs";
import { resolve } from "node:path";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js";

// The shared guard has two independent stops and says which one fired in its own
// banner (bin/fm-turnend-guard.sh owns both headlines). A captain decision that
// has never been shown to the captain is not a supervision lapse, so the passive
// follow-up must not claim the watcher is down.
const CAPTAIN_CALL_HEADLINE = "TURN WOULD END WITHOUT TELLING THE CAPTAIN";
const UNKNOWN_HEADLINE = "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS";

function turnEndPrefix(stderr) {
if (typeof stderr === "string" && stderr.includes(CAPTAIN_CALL_HEADLINE)) {
return (
"TURN WOULD END WITHOUT TELLING THE CAPTAIN. " +
"A decision is waiting on him that he has never been shown. Relay it in plain language before ending the turn.\n\n"
);
}
if (typeof stderr === "string" && stderr.includes(UNKNOWN_HEADLINE)) {
return (
"TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS. " +
"The open decision and wait list is unknown. Restore that list before reporting an all-clear.\n\n"
);
}
return (
"TURN WOULD END BLIND - supervision is off. " +
"The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n"
);
}


const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm";

let skipNextIdle = false;
const assistantMessages = new Map();

function runProcess(command, args, input = "") {
return new Promise((resolve) => {
Expand Down Expand Up @@ -42,45 +70,77 @@ function resolvePath(anchor) {
}
}

function runGuard(root) {
function runGuard(root, lastAssistantMessage) {
if (!root) return Promise.resolve({ code: 0, stderr: "" });
return runProcess(`${root}/bin/fm-turnend-guard.sh`, [], '{"stop_hook_active":false}');
return runProcess(
`${root}/bin/fm-turnend-guard.sh`,
[],
JSON.stringify({ stop_hook_active: false, last_assistant_message: lastAssistantMessage }),
);
}

async function letWatchArmRun(sessionID, client) {
const coordinator = globalThis[COORDINATOR_KEY];
if (!coordinator?.ensureArmed) return false;
const status = await coordinator.ensureArmed(sessionID, client);
return status === "armed" || status === "wake" || status === "failed";
if (!coordinator?.ensureArmed) return;
try {
await coordinator.ensureArmed(sessionID, client);
} catch {
}
}

function observeAssistantMessage(event) {
if (event.type === "message.updated") {
const info = event.properties?.info;
if (info?.role === "assistant" && info.sessionID && info.id) {
const current = assistantMessages.get(info.sessionID);
if (current?.messageID !== info.id) {
assistantMessages.set(info.sessionID, { messageID: info.id, parts: new Map() });
}
}
return;
}
if (event.type !== "message.part.updated") return;
const part = event.properties?.part;
if (part?.type !== "text" || part.synthetic || part.ignored) return;
const current = assistantMessages.get(part.sessionID);
if (!current || current.messageID !== part.messageID) return;
current.parts.set(part.id, String(part.text ?? ""));
}

function lastAssistantMessage(sessionID) {
const current = assistantMessages.get(sessionID);
return current ? [...current.parts.values()].join("\n") : "";
}

export const FmPrimaryTurnendGuard = async ({ client, directory, worktree }) => {
const root = worktree ? resolvePath(worktree) : await resolveRoot(directory);

return {
event: async ({ event }) => {
observeAssistantMessage(event);
if (event.type !== "session.idle") return;

const suppressRoutineFollowup = skipNextIdle;
if (skipNextIdle) {
skipNextIdle = false;
return;
}

const sessionID = event.properties?.sessionID;
if (!sessionID) return;

if (await letWatchArmRun(sessionID, client)) return;
await letWatchArmRun(sessionID, client);

const result = await runGuard(root);
const result = await runGuard(root, lastAssistantMessage(sessionID));
if (result.code !== 2) return;
const attentionStop =
result.stderr.includes(CAPTAIN_CALL_HEADLINE) || result.stderr.includes(UNKNOWN_HEADLINE);
if (suppressRoutineFollowup && !attentionStop) return;

try {
const text = await encodeFirstmateOperationalInput(
root,
"turn-end-guard",
"TURN WOULD END BLIND - supervision is off. " +
"The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" +
result.stderr,
turnEndPrefix(result.stderr) + result.stderr,
);
await client.session.promptAsync({
path: { id: sessionID },
Expand Down
67 changes: 60 additions & 7 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";

let guardFollowupActive = false;
let lastAssistantMessage = "";

type LockOwnership = "owned" | "missing" | "other";

Expand Down Expand Up @@ -61,7 +62,33 @@ function runSessionstartNudge(): string {
return result.stdout.trim();
}

function runGuard(): Promise<{ code: number; stderr: string }> {
// The shared guard has two independent stops and says which one fired in its own
// banner (bin/fm-turnend-guard.sh owns both headlines). A captain decision that
// has never been shown to the captain is not a supervision lapse, so the passive
// follow-up must not claim the watcher is down.
const CAPTAIN_CALL_HEADLINE = "TURN WOULD END WITHOUT TELLING THE CAPTAIN";
const UNKNOWN_HEADLINE = "TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS";

function turnEndPrefix(stderr: string): string {
if (stderr.includes(CAPTAIN_CALL_HEADLINE)) {
return (
"TURN WOULD END WITHOUT TELLING THE CAPTAIN. " +
"A decision is waiting on him that he has never been shown. Relay it in plain language before ending the turn.\n\n"
);
}
if (stderr.includes(UNKNOWN_HEADLINE)) {
return (
"TURN WOULD END WITHOUT KNOWING WHAT THE CAPTAIN NEEDS. " +
"The open decision and wait list is unknown. Restore that list before reporting an all-clear.\n\n"
);
}
return (
"TURN WOULD END BLIND - supervision is off. " +
"The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n"
);
}

function runGuard(message: string): Promise<{ code: number; stderr: string }> {
return new Promise((resolveResult) => {
const child = spawn(`${root}/bin/fm-turnend-guard.sh`, {
stdio: ["pipe", "ignore", "pipe"],
Expand All @@ -72,10 +99,24 @@ function runGuard(): Promise<{ code: number; stderr: string }> {
});
child.on("error", () => resolveResult({ code: 0, stderr: "" }));
child.on("close", (code) => resolveResult({ code: code ?? 0, stderr }));
child.stdin.end('{"stop_hook_active":false}');
child.stdin.end(JSON.stringify({ stop_hook_active: false, last_assistant_message: message }));
});
}

function assistantText(message: unknown): string {
if (!message || typeof message !== "object") return "";
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part): part is { type: string; text: string } =>
Boolean(part && typeof part === "object" &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string"))
.map((part) => part.text)
.join("\n");
}

// PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md;
// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same
// extension file rather than separate ones so no extra Pi -e flag is needed at
Expand Down Expand Up @@ -135,22 +176,34 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" };
});

pi.on("agent_end", (event) => {
const messages = (event as { messages?: unknown[] }).messages;
if (!Array.isArray(messages)) return;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i] as { role?: unknown };
if (message?.role !== "assistant") continue;
lastAssistantMessage = assistantText(message);
return;
}
});

pi.on("agent_settled", async () => {
const suppressRoutineFollowup = guardFollowupActive;
if (guardFollowupActive) {
guardFollowupActive = false;
return;
}

const result = await runGuard();
const result = await runGuard(lastAssistantMessage);
if (result.code !== 2) return;
const attentionStop =
result.stderr.includes(CAPTAIN_CALL_HEADLINE) || result.stderr.includes(UNKNOWN_HEADLINE);
if (suppressRoutineFollowup && !attentionStop) return;

guardFollowupActive = true;
try {
const content = encodeFirstmateOperationalInput(
"turn-end-guard",
"TURN WOULD END BLIND - supervision is off. " +
"The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" +
result.stderr,
turnEndPrefix(result.stderr) + result.stderr,
);
await pi.sendUserMessage(content, { deliverAs: "followUp" });
} catch {
Expand Down
Loading
Loading