From 620486a471edd240810d754100271ffd6de43bf1 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 23 Aug 2026 22:49:17 -0700 Subject: [PATCH 1/2] style(pi): restyle supervision merge notes with a sailboat and matching pad Secondary-session notes were flush against the TUI edge and fully tinted. Use the sailboat prefix, Pi's default outputPad, boat-only color, and dim remainder so they sit like real messages. --- .pi/extensions/fm-branch-supervision.ts | 14 +++++++-- docs/supervision-protocols/pi.md | 2 +- tests/fm-pi-branch-extension.test.sh | 39 +++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index c8473035b5f..2eea7cde654 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -89,7 +89,7 @@ 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 = "⛵"; type MirrorItem = { tag: "captain" | "main"; text: string }; type MirrorCursor = { file: string; index: number }; type Verdict = "routine" | "captain"; @@ -355,7 +355,7 @@ export default function (pi: ExtensionAPI) { summary: string, ): boolean { if (!actingAsOwner(expectedGeneration)) return false; - const note = `⎇ branch merged [${verdict}] ${task}: ${summary}`; + const note = `${MERGE_NOTE_BOAT} branch merged [${verdict}] ${task}: ${summary}`; const message = { customType: "fm-branch-merge", content: note, display: true }; if (verdict === "captain") { pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); @@ -726,6 +726,14 @@ ${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 boat = note.startsWith(MERGE_NOTE_BOAT) ? MERGE_NOTE_BOAT : ""; + const rest = boat ? note.slice(MERGE_NOTE_BOAT.length) : note; + const outputPad = 1; + return new Text( + `${boat ? theme.fg("customMessageText", boat) : ""}${theme.fg("dim", rest)}`, + outputPad, + 0, + ); }); } diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 1cd1f0ae25b..13fa26a97df 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -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 "⛵ branch merged [...]" notes, 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 ` 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. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 9aae0bd6b6b..3b0bcf8d741 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -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 @@ -354,8 +356,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 mergeNote = sentToMain[0].message.content; +if (typeof mergeNote !== "string" || !mergeNote.startsWith("⛵")) { + throw new Error(`merge note missing sailboat prefix: ${mergeNote}`); +} +const fgCalls = []; +const rendered = renderers.get("fm-branch-merge")( + { content: mergeNote }, + { expanded: false }, + { + fg(color, text) { + fgCalls.push({ color, text }); + return text; + }, + }, +); +if (!String(rendered.text).includes("⛵")) throw new Error("renderer dropped the sailboat prefix"); +if (!String(rendered.text).includes("branch merged")) throw new Error("renderer dropped the note body"); +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 boatCalls = fgCalls.filter((call) => call.text === "⛵"); +if (boatCalls.length !== 1 || boatCalls[0].color === "dim") { + throw new Error(`boat glyph must carry color, not dim: ${JSON.stringify(fgCalls)}`); +} +const restCalls = fgCalls.filter((call) => call.text !== "⛵"); +if (restCalls.length === 0 || restCalls.some((call) => call.color !== "dim")) { + throw new Error(`note remainder must be dim: ${JSON.stringify(fgCalls)}`); +} process.exit(0); EOF status=$? From b65b27147ed399a20cd8c104397b49c8943e312e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 23 Aug 2026 23:24:47 -0700 Subject: [PATCH 2/2] style(pi): distinguish routine and captain merge notes by icon only Visible notes now lead with a sailboat or anchor, then only the dim outcome. Drop the branch-merged wording and verdict brackets so the icon is the only kind signal. --- .pi/extensions/fm-branch-supervision.ts | 14 +++-- docs/supervision-protocols/pi.md | 2 +- tests/fm-pi-branch-extension.test.sh | 78 ++++++++++++++----------- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 2eea7cde654..502a5462254 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -90,6 +90,7 @@ const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest(" 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"; @@ -355,7 +356,8 @@ export default function (pi: ExtensionAPI) { summary: string, ): boolean { if (!actingAsOwner(expectedGeneration)) return false; - const note = `${MERGE_NOTE_BOAT} 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" }); @@ -727,11 +729,15 @@ ${context.command} pi.registerMessageRenderer?.("fm-branch-merge", (message, _options, theme) => { const note = textOfContent(message.content); - const boat = note.startsWith(MERGE_NOTE_BOAT) ? MERGE_NOTE_BOAT : ""; - const rest = boat ? note.slice(MERGE_NOTE_BOAT.length) : note; + 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( - `${boat ? theme.fg("customMessageText", boat) : ""}${theme.fg("dim", rest)}`, + `${glyph ? theme.fg("customMessageText", glyph) : ""}${theme.fg("dim", rest)}`, outputPad, 0, ); diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 13fa26a97df..cd29023d79d 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -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 ` 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. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 3b0bcf8d741..fb2b251f095 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -334,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, @@ -356,39 +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 mergeNote = sentToMain[0].message.content; -if (typeof mergeNote !== "string" || !mergeNote.startsWith("⛵")) { - throw new Error(`merge note missing sailboat prefix: ${mergeNote}`); -} -const fgCalls = []; -const rendered = renderers.get("fm-branch-merge")( - { content: mergeNote }, - { expanded: false }, - { - fg(color, text) { - fgCalls.push({ color, text }); - return text; +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("⛵")) throw new Error("renderer dropped the sailboat prefix"); -if (!String(rendered.text).includes("branch merged")) throw new Error("renderer dropped the note body"); -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 boatCalls = fgCalls.filter((call) => call.text === "⛵"); -if (boatCalls.length !== 1 || boatCalls[0].color === "dim") { - throw new Error(`boat glyph must carry color, not dim: ${JSON.stringify(fgCalls)}`); -} -const restCalls = fgCalls.filter((call) => call.text !== "⛵"); -if (restCalls.length === 0 || restCalls.some((call) => call.color !== "dim")) { - throw new Error(`note remainder must be dim: ${JSON.stringify(fgCalls)}`); -} + 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=$?