diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts
index d4a6c75b055..1fb9cf12c48 100644
--- a/.pi/extensions/fm-calm.ts
+++ b/.pi/extensions/fm-calm.ts
@@ -42,6 +42,7 @@ import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts";
import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts";
import {
CALM_WORKING_SHIP_WIDGET_KEY,
+ createCalmWorkingShipAnimation,
createCalmWorkingShipWidget,
} from "./lib/fm-calm-working-ship.ts";
import {
@@ -105,6 +106,10 @@ export default function (pi: ExtensionAPI) {
// continuations, retries, or compaction that stay inside the same run.
let agentRunActive = false;
let workingShipShown = false;
+ // One animation instance per extension lifetime. Hiding the working widget freezes
+ // this state; the next working period resumes it. session_start resets it so a fresh
+ // Pi session starts at the normal initial position. Never module-global.
+ const workingShipAnimation = createCalmWorkingShipAnimation();
// Single owner of Calm's working-row presentation choice. The widget is only created
// or removed on a real transition, so repeated starts cannot duplicate its timer.
@@ -117,7 +122,9 @@ export default function (pi: ExtensionAPI) {
workingShipShown = showShip;
ui.setWidget(
CALM_WORKING_SHIP_WIDGET_KEY,
- showShip ? createCalmWorkingShipWidget : undefined,
+ showShip
+ ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation)
+ : undefined,
);
ui.setWorkingVisible(!showShip);
} else if (forceStockVisibility && !showShip) {
@@ -274,6 +281,8 @@ export default function (pi: ExtensionAPI) {
publishPresentationState();
agentRunActive = false;
workingShipShown = false;
+ // A genuine new session lifetime starts the boat at the normal initial position.
+ workingShipAnimation.reset();
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined);
ctx.ui.setStatus("firstmate-calm", undefined);
diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts
index 1efff27007e..390e28baebf 100644
--- a/.pi/extensions/lib/fm-calm-working-ship.ts
+++ b/.pi/extensions/lib/fm-calm-working-ship.ts
@@ -2,10 +2,10 @@
//
// Calm replaces Pi's stock working row with a tiny SSHHIP-derived boat while one
// logical agent run is active. This module owns only the sprite geometry, the bounce
-// track, the two animation cadences, and the temporary TUI widget;
-// `.pi/extensions/fm-calm.ts` owns when the presentation is installed and removed, and
-// stays the sole caller of setWorkingVisible(). docs/calm.md owns the captain-facing
-// contract.
+// track, the two animation cadences, the session-scoped freeze/resume state, and the
+// temporary TUI widget; `.pi/extensions/fm-calm.ts` owns when the presentation is
+// installed and removed, and stays the sole caller of setWorkingVisible().
+// docs/calm.md owns the captain-facing contract.
//
// Cadence: one scheduler drives two logically independent clocks. Every tick advances
// the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the
@@ -13,11 +13,19 @@
// itself reads as calm. Both clocks stop together when the widget is disposed. Ticks,
// not wall-clock timestamps, drive every state change, so tests can seek time exactly.
//
+// Continuity: one extension-owned animation instance survives hide/show within the same
+// Pi process and Calm extension lifetime. Disposing the widget freezes column,
+// direction, water phase, and tick cadence without advancing them for hidden wall
+// time. The next working period resumes from that exact logical state. A fresh session
+// or new extension lifetime calls reset() and starts at the normal initial position.
+// State is never a module-level or process-global singleton.
+//
// Verified against Pi 0.81.1 declarations and the Pi 0.82.0 CLI, which expose
// ExtensionUIContext.setWidget() with a component factory, per-widget dispose(), and
// TUI.requestRender(). Pi renders a widget through Component.render(width), so this
// module recomputes its track from that width on every frame instead of caching a
-// terminal size that a resize would invalidate.
+// terminal size that a resize would invalidate. A resize while the boat is hidden is
+// applied on the first resumed frame through the same clamp path.
import type { Component, TUI } from "@earendil-works/pi-tui";
// The hull is symmetric and replaces waves on its row rather than adding a third row.
@@ -51,6 +59,14 @@ export type CalmWorkingShipAnimation = {
render(width: number): string[];
/** Advance one scheduler tick: water every tick, boat on its slower cadence. */
tick(): void;
+ restoreLastRendered(): void;
+ /** Restore the normal initial column, direction, water phase, and cadence. */
+ reset(): void;
+ /**
+ * Clamp the frozen column and direction to `width` without advancing time.
+ * Used when a terminal resize lands while the working presentation is hidden.
+ */
+ clampToWidth(width: number): void;
/** Current hull column, exposed for deterministic motion assertions. */
position(): number;
/** Current travel direction: 1 travelling right, -1 travelling left. */
@@ -72,6 +88,11 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
let span = 0;
let phase = 0;
let ticks = 0;
+ let renderedPosition = position;
+ let renderedDirection = direction;
+ let renderedSpan = span;
+ let renderedPhase = phase;
+ let renderedTicks = ticks;
// Reversing the moment the boat lands on an endpoint means the endpoint frame itself
// already shows the new heading, so no frame at or after a bounce shows the old sail.
@@ -81,6 +102,33 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
else if (position <= 0) direction = 1;
};
+ const applyWidth = (width: number): void => {
+ if (width <= 0) {
+ span = 0;
+ position = 0;
+ return;
+ }
+ span = trackSpan(width);
+ position = Math.min(position, span);
+ settleDirectionAtEdges();
+ };
+
+ const commitRenderedState = (): void => {
+ renderedPosition = position;
+ renderedDirection = direction;
+ renderedSpan = span;
+ renderedPhase = phase;
+ renderedTicks = ticks;
+ };
+
+ const restoreLastRenderedState = (): void => {
+ position = renderedPosition;
+ direction = renderedDirection;
+ span = renderedSpan;
+ phase = renderedPhase;
+ ticks = renderedTicks;
+ };
+
/** One colored run of water covering absolute columns [from, from + count). */
const water = (from: number, count: number): string => {
if (count <= 0) return "";
@@ -98,6 +146,21 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
direction: () => direction,
waterPhase: () => phase,
+ restoreLastRendered: restoreLastRenderedState,
+
+ reset(): void {
+ position = 0;
+ direction = 1;
+ span = 0;
+ phase = 0;
+ ticks = 0;
+ commitRenderedState();
+ },
+
+ clampToWidth(width: number): void {
+ applyWidth(width);
+ },
+
tick(): void {
ticks += 1;
phase = (phase + 1) % WAVE_CYCLE.length;
@@ -115,44 +178,51 @@ export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation {
// A resize lands here before the next frame, so recompute and clamp the track
// immediately rather than trusting a position measured against the old width.
- span = trackSpan(width);
- position = Math.min(position, span);
- settleDirectionAtEdges();
+ applyWidth(width);
const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT;
+ let frame: string[];
if (width < SAIL_WIDTH) {
// Too narrow for even the sail: a deterministic single row of water.
- return [water(0, width)];
- }
-
- if (width < HULL_WIDTH) {
+ frame = [water(0, width)];
+ } else if (width < HULL_WIDTH) {
// Too narrow for the hull: the sail alone rides the water row.
- return [
+ frame = [
water(0, position) +
boat(sail) +
water(position + SAIL_WIDTH, width - position - SAIL_WIDTH),
];
+ } else {
+ frame = [
+ " ".repeat(position + SAIL_OFFSET) + boat(sail),
+ water(0, position) +
+ boat(HULL) +
+ water(position + HULL_WIDTH, width - position - HULL_WIDTH),
+ ];
}
- return [
- " ".repeat(position + SAIL_OFFSET) + boat(sail),
- water(0, position) +
- boat(HULL) +
- water(position + HULL_WIDTH, width - position - HULL_WIDTH),
- ];
+ commitRenderedState();
+ return frame;
},
};
}
/**
- * Build the temporary Calm working widget. Pi disposes the previous component before
- * installing a replacement under the same key and when it clears extension widgets, so
- * the single scheduler driving both cadences cannot outlive the widget or duplicate.
+ * Build the temporary Calm working widget bound to one caller-owned animation.
+ * Pi disposes the previous component before installing a replacement under the same
+ * key and when it clears extension widgets, so the single scheduler driving both
+ * cadences cannot outlive the widget or duplicate. Disposing freezes the shared
+ * animation in place; the next widget bound to the same animation resumes without
+ * applying hidden wall time.
*/
-export function createCalmWorkingShipWidget(tui: TUI): Component & { dispose(): void } {
- const animation = createCalmWorkingShipAnimation();
+export function createCalmWorkingShipWidget(
+ tui: TUI,
+ animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(),
+): Component & { dispose(): void } {
+ let disposed = false;
const timer = setInterval(() => {
+ if (disposed) return;
animation.tick();
tui.requestRender();
}, CALM_WORKING_SHIP_TICK_MS);
@@ -160,9 +230,14 @@ export function createCalmWorkingShipWidget(tui: TUI): Component & { dispose():
timer.unref?.();
return {
- render: (width) => animation.render(width),
+ render: (width) => (disposed ? [] : animation.render(width)),
// Every frame is rebuilt from fixed standard ANSI codes, so there is no cache.
invalidate: () => {},
- dispose: () => clearInterval(timer),
+ dispose: () => {
+ if (disposed) return;
+ disposed = true;
+ clearInterval(timer);
+ animation.restoreLastRendered();
+ },
};
}
diff --git a/bin/fm-board.sh b/bin/fm-board.sh
new file mode 100755
index 00000000000..feb01f4e2c7
--- /dev/null
+++ b/bin/fm-board.sh
@@ -0,0 +1,488 @@
+#!/usr/bin/env bash
+# fm-board.sh - captain-facing kanban board renderer over fm-fleet-snapshot.sh.
+#
+# Renders the live fleet as one self-contained HTML board for visual review in
+# Lavish Editor (lavish-axi). Like fm-fleet-view.sh and fm-bearings-snapshot.sh
+# it intentionally does not parse fleet state itself: backlog rows (all states
+# including captain holds), live task rows (recorded harness/model/effort and
+# current stage), and recorded PR links all come from the canonical
+# `fm-fleet-snapshot.sh --json` contract. The only extra input is the
+# hand-maintained effort maps under data/maps/*.md, which no snapshot surface
+# owns; each map contributes a top-band card (destination, decided and open
+# decision counts, fog, out of scope).
+#
+# Columns are the ordered pipeline steps Decide / Queued / Building / Review /
+# Landed:
+# Decide - captain-held backlog rows plus live tasks with open keyed
+# decisions; each card carries an approval panel (radio options
+# plus a free-text override) that queues exactly one Lavish prompt
+# per submit.
+# Queued - queued backlog rows without a captain hold.
+# Building - in-flight work with no recorded PR yet.
+# Review - work whose PR is recorded in task meta or backlog (the same
+# recording fm-pr-check.sh makes when it arms the merge poll), so
+# a Review card always links the PR.
+# Landed - Done backlog rows.
+#
+# The board never mutates fleet state: approval submits and card drags queue
+# Lavish prompts that come back to firstmate as exact orders, and the captain
+# releases them with Send to Agent. Approval panels are rendered as siblings
+# AFTER each card's todo
, never inside it - nesting inside the
+# list is the known clipping bug shape.
+#
+# Output: $FM_HOME/.lavish/fleet-board.html by default; --out overrides.
+# The file is a generated view - regenerate rather than edit it.
+#
+# usage: fm-board.sh [--out ]
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
+FM_HOME="${FM_HOME:-$FM_ROOT}"
+DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
+OUT="$FM_HOME/.lavish/fleet-board.html"
+
+usage() {
+ cat <<'EOF'
+usage: fm-board.sh [--out ]
+
+Generate the captain-facing kanban board (Decide / Queued / Building /
+Review / Landed) from the live fleet snapshot plus data/maps/*.md.
+Writes $FM_HOME/.lavish/fleet-board.html unless --out overrides it.
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ --out)
+ [ $# -ge 2 ] || { usage >&2; exit 2; }
+ OUT=$2; shift 2 ;;
+ *) usage >&2; exit 2 ;;
+ esac
+done
+
+command -v python3 >/dev/null 2>&1 || { echo "fm-board: python3 not found" >&2; exit 1; }
+
+SNAP_FILE=$(mktemp "${TMPDIR:-/tmp}/fm-board.XXXXXX") || exit 1
+trap 'rm -f "$SNAP_FILE"' EXIT
+
+"$SCRIPT_DIR/fm-fleet-snapshot.sh" --json > "$SNAP_FILE" \
+ || { echo "fm-board: fleet snapshot failed" >&2; exit 1; }
+
+mkdir -p "$(dirname "$OUT")" || exit 1
+
+python3 - "$SNAP_FILE" "$DATA/maps" "$OUT" <<'PY'
+import html
+import json
+import os
+import re
+import sys
+
+snap_path, maps_dir, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
+with open(snap_path, encoding="utf-8") as f:
+ snap = json.load(f)
+
+records = snap.get("backlog", {}).get("records", [])
+tasks = {t.get("id"): t for t in snap.get("tasks", [])}
+
+
+def esc(value):
+ return html.escape(str(value or ""), quote=True)
+
+
+def slugify(value):
+ slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", str(value or "")).strip("-")
+ return slug[:80] or "decision"
+
+
+def clip(text, limit):
+ text = str(text or "").strip()
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
+
+
+def decision_key(record, task):
+ for line in (record or {}).get("body_lines", []) or []:
+ match = re.match(r"\s*Decision key:\s*(\S+)", line)
+ if match:
+ return slugify(match.group(1))
+ for dec in ((task or {}).get("hints", {}) or {}).get("open_decisions", []) or []:
+ if dec.get("key"):
+ return slugify(dec["key"])
+ return slugify((record or {}).get("id") or (task or {}).get("id"))
+
+
+def pr_url(record, task):
+ url = ((task or {}).get("pr", {}) or {}).get("url")
+ return url or (record or {}).get("pr_url")
+
+
+# --- classify every backlog row and live task into one pipeline column ------
+decide, queued, building, review, landed = [], [], [], [], []
+seen_task_ids = set()
+
+for record in records:
+ task = tasks.get(record.get("id")) if record.get("structured") else None
+ if task:
+ seen_task_ids.add(record["id"])
+ state = record.get("state")
+ if not record.get("structured"):
+ target = landed if state == "done" else (queued if state == "queued" else building)
+ target.append((record, None))
+ continue
+ hints = (task or {}).get("hints", {}) or {}
+ if state == "done":
+ landed.append((record, task))
+ elif record.get("hold_kind") == "captain" or hints.get("open_decisions"):
+ decide.append((record, task))
+ elif state == "queued":
+ queued.append((record, task))
+ elif pr_url(record, task):
+ review.append((record, task))
+ else:
+ building.append((record, task))
+
+for task_id in sorted(tasks):
+ task = tasks[task_id]
+ if task_id in seen_task_ids or task.get("kind") == "secondmate":
+ continue
+ if (task.get("hints", {}) or {}).get("open_decisions"):
+ decide.append((None, task))
+ elif pr_url(None, task):
+ review.append((None, task))
+ else:
+ building.append((None, task))
+
+
+# --- effort maps under data/maps/ -------------------------------------------
+def parse_map(path):
+ info = {
+ "title": os.path.basename(path)[:-3],
+ "destination": "",
+ "decided": 0,
+ "open": 0,
+ "fog": [],
+ "out_of_scope": [],
+ }
+ section = None
+ with open(path, encoding="utf-8") as f:
+ for line in f:
+ line = line.rstrip()
+ if line.startswith("# "):
+ match = re.match(r"#\s*(?:Effort map:\s*)?(.+)", line)
+ if match:
+ info["title"] = match.group(1).strip()
+ elif line.startswith("## "):
+ heading = line[3:].strip().lower()
+ if heading.startswith("destination"):
+ section = "destination"
+ elif heading.startswith("decisions"):
+ section = "decided"
+ elif heading.startswith("open"):
+ section = "open"
+ elif heading.startswith("not yet"):
+ section = "fog"
+ elif heading.startswith("out of scope"):
+ section = "out_of_scope"
+ else:
+ section = None
+ elif section == "destination" and line.strip():
+ joiner = " " if info["destination"] else ""
+ info["destination"] += joiner + line.strip()
+ elif line.lstrip().startswith("- "):
+ item = line.lstrip()[2:].strip()
+ if section == "decided":
+ info["decided"] += 1
+ elif section == "open":
+ info["open"] += 1
+ elif section == "fog":
+ info["fog"].append(item)
+ elif section == "out_of_scope":
+ info["out_of_scope"].append(item)
+ return info
+
+
+maps = []
+if os.path.isdir(maps_dir):
+ for name in sorted(os.listdir(maps_dir)):
+ if name.endswith(".md"):
+ try:
+ maps.append(parse_map(os.path.join(maps_dir, name)))
+ except OSError:
+ continue
+
+
+# --- card rendering ----------------------------------------------------------
+def stage_of(record, task):
+ if task:
+ current = task.get("current_state", {}) or {}
+ state = current.get("state") or "unknown"
+ detail = current.get("detail") or ""
+ return f"{state} - {detail}" if detail else state
+ if record and record.get("hold_kind") == "captain":
+ return "awaiting decision"
+ if record and record.get("blocked_by"):
+ return f"blocked by {record['blocked_by']}"
+ if record and record.get("state") == "done":
+ verb = (record.get("completion", {}) or {}).get("verb") or "done"
+ date = (record.get("completion", {}) or {}).get("date") or ""
+ return f"{verb} {date}".strip()
+ return "no live worker yet"
+
+
+def crew_badge(task):
+ if not task:
+ return ""
+ parts = [task.get("harness") or "?"]
+ if task.get("model") and task["model"] != "default":
+ parts.append(task["model"])
+ if task.get("effort"):
+ parts.append(f"{task['effort']} effort")
+ return " · ".join(parts)
+
+
+def todo_lines(record):
+ lines = []
+ for line in (record or {}).get("body_lines", []) or []:
+ line = line.strip()
+ if line:
+ lines.append(clip(line, 160))
+ if len(lines) == 4:
+ break
+ return lines
+
+
+def approval_panel(record, task):
+ key = decision_key(record, task)
+ reason = (record or {}).get("hold_reason") or ""
+ if not reason:
+ for dec in ((task or {}).get("hints", {}) or {}).get("open_decisions", []) or []:
+ if dec.get("summary"):
+ reason = dec["summary"]
+ break
+ parts = [f'']
+ parts.append("⚑ YOUR APPROVAL - click for options")
+ if reason:
+ parts.append(f'
{esc(clip(reason, 320))}
')
+ parts.append(f'")
+ return "".join(parts)
+
+
+TICKET_CLASS = {
+ "decide": "t-captain",
+ "queued": "t-queued",
+ "building": "t-flight",
+ "review": "t-upstream",
+ "landed": "t-done",
+}
+
+
+def card(record, task, column):
+ title = (record or {}).get("title") or (record or {}).get("raw") or (task or {}).get("id") or "untitled"
+ url = pr_url(record, task)
+ heading = f'{esc(clip(title, 140))}' if url else esc(clip(title, 140))
+ badges = []
+ repo = (record or {}).get("repo")
+ if repo:
+ badges.append(f'{esc(repo)}')
+ kind = (record or {}).get("kind") or (task or {}).get("kind")
+ if kind:
+ badges.append(f'{esc(kind)}')
+ crew = crew_badge(task)
+ if crew:
+ badges.append(f'crew: {esc(crew)}')
+ badges.append(f'stage: {esc(clip(stage_of(record, task), 90))}')
+ blocked_by = (record or {}).get("blocked_by")
+ if blocked_by:
+ badges.append(f'blocked-by: {esc(blocked_by)}')
+
+ parts = [
+ '
',
+ '
',
+ f'
{heading}
',
+ f'
{"".join(badges)}
',
+ ]
+ lines = todo_lines(record)
+ if not lines and task:
+ note = ((task.get("hints", {}) or {}).get("last_event_text")) or ""
+ if note:
+ lines = [clip(note, 160)]
+ if lines:
+ items = "".join(f"
{esc(line)}
" for line in lines)
+ parts.append(f'
{items}
')
+ # The approval panel is a SIBLING of the todo list on purpose: a
+ # inside the
is the prototype's clipping bug.
+ if column == "decide":
+ parts.append(approval_panel(record, task))
+ parts.append("
")
+ return "".join(parts)
+
+
+def column_html(key, number, emoji, title, accent, entries):
+ cards = "".join(card(record, task, key) for record, task in entries)
+ if not cards:
+ cards = '
nothing here
'
+ return (
+ ''
+ f'
{number} · {emoji} {title}
'
+ f'
{cards}
'
+ ""
+ )
+
+
+def map_card(info):
+ badges = [
+ f'{info["decided"]} decided',
+ f'{info["open"]} open',
+ ]
+ fog = clip("; ".join(info["fog"]), 120) if info["fog"] else "none - remaining work is sharp"
+ badges.append(f'fog: {esc(fog)}')
+ if info["out_of_scope"]:
+ badges.append(
+ f'out of scope: {esc(clip("; ".join(info["out_of_scope"]), 120))}'
+ )
+ return (
+ '