Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 17 additions & 3 deletions .pi/extensions/fm-branch-supervision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ const BRANCH_TOOL_NAMES = ["read", "bash", "fm_branch_report"] as const;
const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest("hex").slice(0, 24)}`;

const MIRROR_MESSAGE_CAP = 4000;

const MERGE_NOTE_BOAT = "⛵";
const MERGE_NOTE_ANCHOR = "⚓";
type MirrorItem = { tag: "captain" | "main"; text: string };
type MirrorCursor = { file: string; index: number };
type Verdict = "routine" | "captain";
Expand Down Expand Up @@ -355,7 +356,8 @@ export default function (pi: ExtensionAPI) {
summary: string,
): boolean {
if (!actingAsOwner(expectedGeneration)) return false;
const note = `⎇ branch merged [${verdict}] ${task}: ${summary}`;
const glyph = verdict === "captain" ? MERGE_NOTE_ANCHOR : MERGE_NOTE_BOAT;
const note = `${glyph} ${task}: ${summary}`;
const message = { customType: "fm-branch-merge", content: note, display: true };
if (verdict === "captain") {
pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
Expand Down Expand Up @@ -726,6 +728,18 @@ ${context.command}
});

pi.registerMessageRenderer?.("fm-branch-merge", (message, _options, theme) => {
return new Text(theme.fg("customMessageText", textOfContent(message.content)), 0, 0);
const note = textOfContent(message.content);
const glyph = note.startsWith(MERGE_NOTE_ANCHOR)
? MERGE_NOTE_ANCHOR
: note.startsWith(MERGE_NOTE_BOAT)
? MERGE_NOTE_BOAT
: "";
const rest = glyph ? note.slice(glyph.length) : note;
const outputPad = 1;
return new Text(
`${glyph ? theme.fg("customMessageText", glyph) : ""}${theme.fg("dim", rest)}`,
outputPad,
0,
);
});
}
2 changes: 1 addition & 1 deletion docs/supervision-protocols/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ When this session owns supervision and away mode is not active:
11. Never use shell `&` for watcher supervision.
The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`).

When the supervision branch is explicitly enabled for every task in a wake (`config/pi-supervision-branch` lists their exact `project=` metadata values; docs/pi-supervision-branch.md), the watcher extension hands each wholly in-scope ordinary actionable wake to the persistent in-process supervision branch instead of this conversation, and branch outcomes return as appended "⎇ branch merged [...]" notes, of which only captain-relevant ones open a turn.
When the supervision branch is explicitly enabled for every task in a wake (`config/pi-supervision-branch` lists their exact `project=` metadata values; docs/pi-supervision-branch.md), the watcher extension hands each wholly in-scope ordinary actionable wake to the persistent in-process supervision branch instead of this conversation, and branch outcomes return as appended notes that lead with ⛵ for routine and ⚓ for captain-facing, then only the dim outcome text, of which only captain-relevant ones open a turn.
Before MAIN steers, controls lifecycle, or cleans up a task, claim its lease with `bin/fm-lease.sh claim <task>` and release it afterwards; a refused claim means the branch is acting on that task right now.
This conversation still receives every wake when the branch is disabled, unavailable, or away mode is active, and every watcher-failure alarm regardless, so the arm and repair contract above is unchanged.
Treat a merged note as already handled - do not re-drain or re-handle its event - and read the durable outcome store with the fm_branch_outcomes tool when the captain asks what happened.
Expand Down
55 changes: 50 additions & 5 deletions tests/fm-pi-branch-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ JS
JSON
cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
export class Text {
constructor(text) {
constructor(text, paddingX, paddingY) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
}
JS
Expand Down Expand Up @@ -332,8 +334,20 @@ await report.execute("call-3", { task: "task-9", verdict: "captain", summary: "P
if (sentToMain[2].options.triggerTurn !== true || sentToMain[2].options.deliverAs !== "followUp") {
throw new Error(`captain merge must trigger exactly one follow-up turn: ${JSON.stringify(sentToMain[2].options)}`);
}
if (!sentToMain[2].message.content.includes("[captain] task-9: PR https://example.com/pr/9")) {
throw new Error(`captain note lost its content: ${sentToMain[2].message.content}`);
if (typeof sentToMain[0].message.content !== "string" || !sentToMain[0].message.content.startsWith("⛵ ")) {
throw new Error(`routine note missing sailboat prefix: ${sentToMain[0].message.content}`);
}
if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[0].message.content)) {
throw new Error(`routine note still has boilerplate: ${sentToMain[0].message.content}`);
}
if (typeof sentToMain[2].message.content !== "string" || !sentToMain[2].message.content.startsWith("⚓ ")) {
throw new Error(`captain note missing anchor prefix: ${sentToMain[2].message.content}`);
}
if (!sentToMain[2].message.content.includes("task-9: PR https://example.com/pr/9")) {
throw new Error(`captain note lost its outcome: ${sentToMain[2].message.content}`);
}
if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[2].message.content)) {
throw new Error(`captain note still has boilerplate: ${sentToMain[2].message.content}`);
}

// The store (the owned durable contract) holds all three outcomes in order,
Expand All @@ -354,8 +368,39 @@ if (listedText.split("\n").length !== 2 || !listedText.includes("checks green"))
throw new Error(`fm_branch_outcomes did not read the store: ${listedText}`);
}
if (!renderers.has("fm-branch-merge")) throw new Error("merge-note renderer missing");
const rendered = renderers.get("fm-branch-merge")({ content: "note body" }, { expanded: false }, { fg: (_c, text) => text });
if (rendered.text !== "note body") throw new Error("merge-note renderer dropped the note");
const assertRenderedNote = (note, glyph) => {
const fgCalls = [];
const rendered = renderers.get("fm-branch-merge")(
{ content: note },
{ expanded: false },
{
fg(color, text) {
fgCalls.push({ color, text });
return text;
},
},
);
if (!String(rendered.text).includes(glyph)) throw new Error(`renderer dropped ${glyph}: ${rendered.text}`);
if (String(rendered.text).includes("branch merged")) throw new Error(`renderer kept boilerplate: ${rendered.text}`);
if (rendered.paddingX === 0 && rendered.paddingY === 0) {
throw new Error("renderer still pads with 0,0 instead of outputPad");
}
if (rendered.paddingX !== 1 || rendered.paddingY !== 0) {
throw new Error(
`renderer padding should match real Pi messages (outputPad, 0), got ${rendered.paddingX},${rendered.paddingY}`,
);
}
const glyphCalls = fgCalls.filter((call) => call.text === glyph);
if (glyphCalls.length !== 1 || glyphCalls[0].color === "dim") {
throw new Error(`icon ${glyph} must carry color, not dim: ${JSON.stringify(fgCalls)}`);
}
const restCalls = fgCalls.filter((call) => call.text !== glyph);
if (restCalls.length === 0 || restCalls.some((call) => call.color !== "dim")) {
throw new Error(`note remainder must be dim: ${JSON.stringify(fgCalls)}`);
}
};
assertRenderedNote(sentToMain[0].message.content, "⛵");
assertRenderedNote(sentToMain[2].message.content, "⚓");
process.exit(0);
EOF
status=$?
Expand Down
Loading