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
30 changes: 23 additions & 7 deletions .pi/extensions/fm-branch-supervision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,9 @@ const AWAY_POSTURE_TAIL =
const PROCESSING_INSTRUCTION =
"This is a supervision processing request delivered automatically by the supervision branch. " +
"It was not typed by the captain. " +
"The outcomes below are already stored durably and already shown to the captain as anchor entries in this transcript; each fleet event is already handled, so do not re-drain, re-run, or acknowledge the wake. " +
"The outcomes below are already stored durably and already shown to the captain as anchor entries in this transcript; the originating wake is already handled, so do not re-drain, re-run, or acknowledge that wake. This does not complete a pending workflow continuation. " +
"Process each outcome now as firstmate: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed. " +
"For a continuation handoff, reconcile current task/stage state and existing authority, then start the authorized spawn, steer, or handoff before acknowledging; if already started, do not duplicate it. Record the actual blocker, decision, or evidence that no further action is needed when you cannot advance. Waiting for firstmate to act is not an external pause. " +
"When every outcome below is processed, call fm_branch_processed with through={N} exactly once. " +
"Until that call the outcomes stay open and are presented again; an answer that does not make that call never counts as processing.";
type MirrorItem = { tag: "captain" | "main"; text: string };
Expand All @@ -211,6 +212,7 @@ type OutcomeRow = {
verdict: Verdict;
summary: string;
silent: boolean;
continuation?: string;
};
type VisibleOutcomeRecord = OutcomeRow & { version: 1 };
type ProviderRecovery = {
Expand Down Expand Up @@ -492,7 +494,11 @@ function parseOutcomeRow(value: unknown): OutcomeRow | null {
if (row.silent !== undefined && typeof row.silent !== "boolean") return null;
const silent = row.silent === true;
if (silent && (row.task !== "fleet" || row.verdict !== "routine")) return null;
return { seq: row.seq, task: row.task, verdict: row.verdict, summary: row.summary, silent };
if (row.continuation !== undefined && (typeof row.continuation !== "string" || !row.continuation.trim() || row.verdict !== "captain" || silent)) return null;
return {
seq: row.seq, task: row.task, verdict: row.verdict, summary: row.summary, silent,
...(row.continuation !== undefined ? { continuation: row.continuation as string } : {}),
};
}

function parseVisibleOutcomeRecord(value: unknown): VisibleOutcomeRecord | null {
Expand All @@ -506,7 +512,8 @@ function sameOutcome(left: OutcomeRow, right: OutcomeRow): boolean {
left.task === right.task &&
left.verdict === right.verdict &&
left.summary === right.summary &&
left.silent === right.silent;
left.silent === right.silent &&
left.continuation === right.continuation;
}

// Volatile mirror-collection state. Instance-scoped and cleared at the
Expand Down Expand Up @@ -1031,7 +1038,7 @@ export default function (pi: ExtensionAPI) {
// beats an outcome that is never processed.
async function processingRequestInput(rows: OutcomeRow[]): Promise<string> {
const through = rows[rows.length - 1].seq;
const listed = rows.map((row) => `[seq ${row.seq}] ${row.task}: ${row.summary}`).join("\n");
const listed = rows.map((row) => `[seq ${row.seq}] ${row.task}: ${row.summary}${row.continuation ? `\nMAIN continuation handoff: ${row.continuation}` : ""}`).join("\n");
const body = `${PROCESSING_INSTRUCTION.replace("{N}", String(through))}\n\n${listed}`;
try {
return await encodeFirstmateOperationalInputWith(runCommandAsync, "branch-outcome", body);
Expand Down Expand Up @@ -1186,6 +1193,9 @@ export default function (pi: ExtensionAPI) {
description:
"One or two sentences in captain outcome language; include the full https:// PR URL when a PR is involved",
}),
continuation: Type.Optional(Type.String({
description: "Pending MAIN action: completed stage/artifact, next spawn/steer/handoff, and existing authorization from the brief or accepted plan. Forces captain routing; omit when no MAIN action remains. Never infer authority from waiting prose.",
})),
wake: Type.Optional(Type.String({ description: "The wake reason line this outcome answers" })),
silent: Type.Optional(Type.Boolean({
description: "True only when a fleet-wide heartbeat review found literally nothing worth reporting; omit or use false whenever any action was taken or any routine result is worth a note",
Expand All @@ -1196,20 +1206,26 @@ export default function (pi: ExtensionAPI) {
const verdictRaw = String((params as { verdict: unknown }).verdict || "");
const summary = String((params as { summary: unknown }).summary || "").trim();
const wake = String((params as { wake?: unknown }).wake ?? "").trim();
const continuationRaw = (params as { continuation?: unknown }).continuation;
if (continuationRaw !== undefined && (typeof continuationRaw !== "string" || !continuationRaw.trim())) {
return { content: [{ type: "text", text: "invalid continuation: a nonempty handoff is required" }], details: undefined, isError: true };
}
const continuation = typeof continuationRaw === "string" ? continuationRaw.trim() : undefined;
const silent = (params as { silent?: unknown }).silent === true;
if (!task || !summary || (verdictRaw !== "routine" && verdictRaw !== "captain") || (silent && (task !== "fleet" || verdictRaw !== "routine"))) {
if (!task || !summary || (verdictRaw !== "routine" && verdictRaw !== "captain") || (silent && (task !== "fleet" || verdictRaw !== "routine" || continuation !== undefined))) {
return {
content: [{ type: "text", text: "invalid report: task, verdict (routine|captain), and summary are required" }],
details: undefined,
isError: true,
};
}
const verdict = verdictRaw as Verdict;
const verdict: Verdict = continuation ? "captain" : verdictRaw as Verdict;
const scopeRefusal = wakeScopeRefusal(task);
if (scopeRefusal) {
return { content: [{ type: "text", text: scopeRefusal }], details: undefined, isError: true };
}
const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent)];
if (continuation) appendArgs.push("--continuation", continuation);
if (wake) appendArgs.push("--wake", wake);
// Ownership, the durable append, and the delivery it authorizes are
// ONE unit of the delivery queue: store-before-visible-delivery and
Expand Down Expand Up @@ -2222,7 +2238,7 @@ ${context.command}
name: "fm_branch_processed",
label: "Acknowledge processed supervision outcomes",
description:
"Acknowledge that every captain-facing supervision outcome up to a sequence number has been processed by this conversation. Call it exactly once after handling a supervision processing request, with through set to the highest sequence that request listed; an outcome that is not acknowledged is presented again.",
"Acknowledge that every captain-facing supervision outcome up to a sequence number has been processed by this conversation. Call it exactly once after handling a supervision processing request, with through set to the highest sequence that request listed, after performing any authorized continuation or recording its actual blocker/decision/no-action evidence; an outcome that is not acknowledged is presented again.",
promptSnippet: "Acknowledge processed captain-facing supervision outcomes by sequence.",
parameters: Type.Object({
through: Type.Number({ description: "The highest outcome sequence number this conversation has processed" }),
Expand Down
32 changes: 24 additions & 8 deletions bin/fm-branch-outcome.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
# - Store: $STATE/branch-outcomes.jsonl, strictly APPEND-ONLY. One JSON
# object per line: {"seq":N,"epoch":N,"task":"...","wake":"...",
# "verdict":"routine"|"captain","summary":"...","silent":true|false,
# "statusEndpoint":N,"statusIdent":"..."}. Legacy rows without `silent`
# or status provenance remain valid and are treated as visible.
# "statusEndpoint":N,"statusIdent":"...","continuation":"..."}.
# Optional continuation records the completed stage, next action, and its
# existing authority for MAIN. It forces verdict captain and cannot be
# silent; it is a durable handoff, never executable prose or new authority.
# Legacy rows without `silent`, continuation, or status provenance remain valid and are treated as visible.
# Every read and append validates the complete log as a gap-free sequence;
# malformed, duplicate, or reordered rows fail closed.
# Existing lines are never rewritten, reordered, or deleted by any
Expand Down Expand Up @@ -59,7 +62,7 @@
#
# Usage:
# fm-branch-outcome.sh append --task <id> --verdict routine|captain \
# --summary <text> [--wake <text>] [--silent true|false]
# --summary <text> [--wake <text>] [--silent true|false] [--continuation <text>]
# Append one outcome record; prints the assigned seq.
# fm-branch-outcome.sh unread
# Print every unread record (raw JSONL). Exit 0 with no output when none.
Expand Down Expand Up @@ -107,7 +110,7 @@ OUTCOME_INDEX_MAX_BYTES=512
OUTCOME_INDEX_READY="$STATE/.branch-outcome-index-ready"

usage() {
echo "usage: fm-branch-outcome.sh append --task <id> --verdict routine|captain --summary <text> [--wake <text>] [--silent true|false] | unread | mark-read --through <seq> | unprocessed | mark-processed --through <seq> | processed-init [--held-lock] | list [--recent <n>] | startup-replay" >&2
echo "usage: fm-branch-outcome.sh append --task <id> --verdict routine|captain --summary <text> [--wake <text>] [--silent true|false] [--continuation <text>] | unread | mark-read --through <seq> | unprocessed | mark-processed --through <seq> | processed-init [--held-lock] | list [--recent <n>] | startup-replay" >&2
exit 2
}

Expand Down Expand Up @@ -179,7 +182,10 @@ last_seq() {
jq -Rse '
def valid:
type == "object"
and (
and (if has("continuation") then
((.continuation | type) == "string" and (.continuation | test("\\S")) and .verdict == "captain" and .silent != true)
else true end)
and (del(.continuation) | (
keys == ["epoch", "seq", "summary", "task", "verdict", "wake"]
or (keys == ["epoch", "seq", "silent", "summary", "task", "verdict", "wake"] and (.silent | type) == "boolean")
or (
Expand All @@ -188,7 +194,7 @@ last_seq() {
and ((.statusEndpoint | type) == "number" and .statusEndpoint >= 0 and .statusEndpoint <= 9007199254740991 and .statusEndpoint == (.statusEndpoint | floor))
and ((.statusIdent | type) == "string" and (.statusIdent | test("[\\t\\n]") | not))
)
)
))
and ((.seq | type) == "number" and .seq >= 1 and .seq <= 9007199254740991 and .seq == (.seq | floor))
and ((.epoch | type) == "number" and .epoch >= 0 and .epoch == (.epoch | floor))
and ((.task | type) == "string" and (.wake | type) == "string")
Expand Down Expand Up @@ -427,10 +433,12 @@ case "$CMD" in
SUMMARY=''
WAKE=''
SILENT=false
CONTINUATION=''
while [ "$#" -gt 0 ]; do
case "$1" in
--task) TASK=${2:-}; shift 2 || usage ;;
--verdict) VERDICT=${2:-}; shift 2 || usage ;;
--continuation) CONTINUATION=${2:-}; [ -n "$CONTINUATION" ] || usage; shift 2 || usage ;;
--summary) SUMMARY=${2:-}; shift 2 || usage ;;
--wake) WAKE=${2:-}; shift 2 || usage ;;
--silent) SILENT=${2:-}; shift 2 || usage ;;
Expand All @@ -442,6 +450,10 @@ case "$CMD" in
[ -n "$SUMMARY" ] || usage
case "$VERDICT" in routine|captain) ;; *) usage ;; esac
case "$SILENT" in true|false) ;; *) usage ;; esac
if [ -n "$CONTINUATION" ]; then
[[ "$CONTINUATION" =~ [^[:space:]] ]] || usage
VERDICT=captain
fi
if [ "$SILENT" = true ] && { [ "$TASK" != fleet ] || [ "$VERDICT" != routine ]; }; then
echo "error: silent outcomes must be routine fleet outcomes" >&2
exit 2
Expand All @@ -460,10 +472,14 @@ case "$CMD" in
SEQ=$(( LAST_SEQ + 1 ))
capture_status_position "$TASK"
rm -f -- "$OUTCOME_INDEX_READY" || { fm_lock_release "$LOCK"; exit 1; }
printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s,"statusEndpoint":%s,"statusIdent":"%s"}\n' \
CONTINUATION_FIELD=''
if [ -n "$CONTINUATION" ]; then
CONTINUATION_FIELD=",\"continuation\":\"$(json_escape "$CONTINUATION")\""
fi
printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s,"statusEndpoint":%s,"statusIdent":"%s"%s}\n' \
"$SEQ" "$(date +%s)" "$(json_escape "$TASK")" "$(json_escape "$WAKE")" \
"$VERDICT" "$(json_escape "$SUMMARY")" "$SILENT" "$CAPTURED_STATUS_ENDPOINT" \
"$(json_escape "$CAPTURED_STATUS_IDENT")" >> "$STORE"
"$(json_escape "$CAPTURED_STATUS_IDENT")" "$CONTINUATION_FIELD" >> "$STORE"
# A task with neither a live meta nor a status log is retired: the branch
# reports the teardown it just performed, and writing the index here would
# recreate the footprint teardown removed. The outcome itself is still
Expand Down
5 changes: 5 additions & 0 deletions bin/fm-branch-prompt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ For anything it tells you to escalate, or any failure that survives the playbook

Report verdict captain for the finished result of work the captain requested, even when that result is healthy.
A start or still-working update on requested work that brings no new artifact, finding, or decision is verdict routine.
A completed stage awaiting firstmate's next authorized action is an active continuation, even if the worker calls it paused or waiting.
Reconcile the brief, accepted plan, and current task state: perform an authorized action within your role, or set fm_branch_report.continuation to hand MAIN the completed stage/artifact, the next spawn/steer/handoff, and the existing authority for it.
The tool forces that handoff to verdict captain; an attended branch must use it when continuation needs a new worker because spawning remains MAIN-only.
Before handing off a repeated wake, check whether that stage already advanced or has an unprocessed handoff in bin/fm-branch-outcome.sh unprocessed; do not request the same continuation again.
Never derive authorization from arbitrary waiting prose; uncertain authority is a captain decision, and an actual external wait remains routine.
Also report verdict captain for:
- work ready for review - include the PR's full https:// URL when the task's ready status or `pr=` metadata holds one, otherwise only the identifier you actually have;
- a decision only the captain can make, including every ask-user finding from a validation gate;
Expand Down
5 changes: 4 additions & 1 deletion docs/pi-supervision-branch.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The branch prompt frames mirrored text as context for judgment, never as instruc

Stage one is unchanged: the bash watcher absorbs everything provably fine at zero token cost.
Stage two is the branch's verdict on each handled event, reported through its `fm_branch_report` tool: `routine` keeps the existing custom-message path without a follow-up turn, while `captain` appends a versioned `fm-branch-visible-outcome` custom session entry.
The captain entry contains the store sequence, task, verdict, exact summary, and silent flag, and its renderer presents the exact task and summary with an anchor prefix.
The captain entry follows `VisibleOutcomeRecord` in [the branch extension](../.pi/extensions/fm-branch-supervision.ts); its renderer presents the exact task and summary with an anchor prefix.
Pi custom session entries persist in the transcript but do not enter model context, so a stale compaction summary, an unrelated assistant response, prompt caching, or model instruction noncompliance cannot acknowledge or rewrite the outcome.
The store sequence is the idempotency key: reload after entry persistence but before cursor advancement finds the matching entry, avoids a duplicate, and advances the cursor; conflicting content for one sequence fails closed.
Reconciliation runs at session start when that generation already owns the fleet lock and at the first post-lock `turn_end`, so a cold start that acquires the lock through the startup digest still delivers stored captain outcomes without waiting for another wake.
Expand All @@ -122,6 +122,9 @@ A presentation already pending its run boundary is not resent or widened; once t
The first two presentations of a given sequence set open a turn of their own; after that the request rides the captain's next prompt so an ignored request cannot become an unbounded loop of empty turns, while changed sequence membership and a session replacement each start that budget over.
Routine outcomes never enter this path and stay turn-free.
A home upgraded with outcomes already delivered treats those rows as processed once, at the first reconciliation that finds no processed marker, so its history is not re-presented.
A typed continuation handoff from `fm_branch_report` is stored with the outcome and forces captain routing even if the caller supplies routine; it survives session replacement as part of the same sequence-bound processing request.
The branch prompt owns when to hand off a completed stage, and the [Pi supervision protocol](supervision-protocols/pi.md) owns MAIN's continuation duty; the store header owns the optional field format.
This does not interpret waiting prose, launch a worker automatically, grant branch spawn authority, or replay handled history.
The generated [Pi supervision protocol](supervision-protocols/pi.md) owns event ownership for merged outcomes and main's acknowledgement duty, while deterministic entry delivery owns captain visibility.
A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is also delivered silently with no rendered note, while every other `routine` outcome stays rendered with its sailboat prefix.
The branch prompt's "Verdict: routine or captain" section owns the verdict criteria, including how requested work's finished results and its mere progress updates are classified; unsolicited routine outcomes remain routine sailboat notes, unchanged fleet reviews remain silent, and doubt escalates.
Expand Down
Loading
Loading