@afokapu/atdd-flow 0.3.50 → 0.3.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ content:
33
33
  receipts inbound communication from any seat, but sends substantive direction, results, and
34
34
  wakes only to main@project. Only main@project executes that project repository merge; the
35
35
  operator may authorize, relay, or inspect but never merges.
36
+ Post decisions and open questions as messages when they happen.
36
37
  projection: >-
37
38
  Keep this full policy only in this convention. Managed AGENTS.md and CLAUDE.md blocks
38
39
  contain only its canonical pointer and preserve all user-authored content outside the
@@ -6,7 +6,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
7
  import { paths, readYaml, type Seat } from "../../src/core";
8
8
  import { directiveNotice, nextDirective } from "../../src/tasks";
9
- import { runtimeStartupInstruction } from "../../src/collaboration-policy";
9
+ import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
10
+ import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
10
11
  import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
11
12
 
12
13
  type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
@@ -352,6 +353,9 @@ async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
352
353
  } catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
353
354
  }
354
355
 
356
+ /** The recovery bundle is advisory; any failure falls back to the plain prompt. */
357
+ const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
358
+
355
359
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
356
360
  export default function (pi: ExtensionAPI) {
357
361
  const root = process.env.ATDD_WORKFLOW_ROOT;
@@ -369,9 +373,10 @@ export default function (pi: ExtensionAPI) {
369
373
  const threshold = await contextHandoverThreshold(root);
370
374
  if (percent < threshold) return;
371
375
  handoverPrompted = true;
376
+ const bundle = await recovery(root, seat, "threshold", process.env.ATDD_FLOW_PI_SESSION);
372
377
  pi.sendMessage({
373
378
  customType: "atdd-flow-context-handover-replacement",
374
- content: `SYSTEM: Context ${percent}% reached the Flow handover threshold (${threshold}%). Finish or undo the atomic step, record a validated handover, then stop new work; do not compact or release a session.`,
379
+ content: handoverThresholdInstruction(percent, threshold, bundle),
375
380
  display: true, details: { seat, threshold, percent, authority: "handover" },
376
381
  }, { triggerTurn: true, deliverAs: "followUp" });
377
382
  } catch { /* Desk policy is re-read on the next safe boundary. */ }
@@ -412,7 +417,11 @@ export default function (pi: ExtensionAPI) {
412
417
  const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
413
418
  if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
414
419
  hostActivatedAt = Date.now();
415
- if (await runtime.activate()) pi.sendMessage({ customType: "atdd-flow-start", content: `Native mail active for ${seat} (host-attached). Notices created before this session were not replayed; run atdd-flow status seat ${seat} for unanswered mail.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
420
+ if (await runtime.activate()) {
421
+ const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
422
+ const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
423
+ pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
424
+ }
416
425
  }
417
426
  return;
418
427
  }
@@ -422,17 +431,17 @@ export default function (pi: ExtensionAPI) {
422
431
  root, seat, piSession, herdrSession, pane,
423
432
  activate: async () => {
424
433
  if (!await current.activate()) return;
425
- pi.sendMessage({ customType: "atdd-flow-start", content: runtimeStartupInstruction(seat), display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
426
- try {
427
- const request = await readHandoverReplacement(root, seat);
428
- if (request.source.pi_session !== piSession) {
429
- pi.sendMessage({
430
- customType: "atdd-flow-context-handover-replacement",
431
- content: `SYSTEM: Path-A handover ${request.handover.id} is injected for ${seat}. Read atdd-flow handover show ${seat}, reconcile the live Desk, then report RESUMED with verified/total counts and discrepancies.`,
432
- display: true, details: { seat, handover: request.handover.id, authority: "handover-replacement" },
433
- }, { triggerTurn: true, deliverAs: "followUp" });
434
- }
435
- } catch { /* This is an ordinary launch, not a Path-A successor. */ }
434
+ const request = await readHandoverReplacement(root, seat).catch(() => undefined);
435
+ const successor = request && request.source.pi_session !== piSession ? request : undefined;
436
+ const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
437
+ pi.sendMessage({ customType: "atdd-flow-start", content: runtimeStartupInstruction(seat, successor ? undefined : bundle), display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
438
+ if (successor) {
439
+ pi.sendMessage({
440
+ customType: "atdd-flow-context-handover-replacement",
441
+ content: successorInstruction(seat, successor.handover.id, bundle),
442
+ display: true, details: { seat, handover: successor.handover.id, authority: "handover-replacement" },
443
+ }, { triggerTurn: true, deliverAs: "followUp" });
444
+ }
436
445
  await sendTaskDirective(pi, root, seat);
437
446
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
438
447
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { status } from "./overview";
13
13
  import { multiplexer } from "./multiplexer";
14
14
  import * as cleanup from "./ephemeral-resources";
15
15
  import * as ownerAlerts from "./owner-escalations";
16
+ import { recoveryFor } from "./recovery-bundle";
16
17
  import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
17
18
 
18
19
  const usage = `atdd-flow — filesystem-first agent seats and tasks
@@ -75,6 +76,7 @@ Usage:
75
76
  atdd-flow behavioral-review record <project> <task-id> --by <reviewer-address> --file <result-yaml>
76
77
  atdd-flow behavioral-review open <project> <task-id>
77
78
  atdd-flow open <address>
79
+ atdd-flow recover <address>
78
80
  atdd-flow address-book
79
81
 
80
82
  Global:
@@ -207,6 +209,7 @@ async function main() {
207
209
  throw new Error("Use `atdd-flow behavioral-review launch|record|open <project> <task-id>`.");
208
210
  },
209
211
  open: () => openSeat(root, required(rest[0], "address")),
212
+ recover: async () => console.log(await recoveryFor(root, required(rest[0], "address"), "start", process.env.ATDD_FLOW_PI_SESSION)),
210
213
  "address-book": () => addressBook(root),
211
214
  };
212
215
  const action = commands[command];
@@ -70,7 +70,19 @@ export async function projectCollaborationPolicy(root: string) {
70
70
  }));
71
71
  }
72
72
 
73
+ const withBundle = (text: string, bundle?: string) => bundle ? `${text}\n${bundle}` : text;
74
+
73
75
  /** The one startup handoff for supported Flow-managed runtimes, resolved from the canonical convention. */
74
- export function runtimeStartupInstruction(seat: string) {
75
- return `SYSTEM: you are ${seat}. Collaboration policy: ${canonicalCollaborationPolicy()} Canonical convention: ${collaborationConventionPath}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
76
+ export function runtimeStartupInstruction(seat: string, bundle?: string) {
77
+ return withBundle(`SYSTEM: you are ${seat}. Collaboration policy: ${canonicalCollaborationPolicy()} Canonical convention: ${collaborationConventionPath}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, bundle);
78
+ }
79
+
80
+ /** The 80% handover prompt; the computed bundle gives the handover record exact current state. */
81
+ export function handoverThresholdInstruction(percent: number, threshold: number, bundle?: string) {
82
+ return withBundle(`SYSTEM: Context ${percent}% reached the Flow handover threshold (${threshold}%). Finish or undo the atomic step, record a validated handover, then stop new work; do not compact or release a session.`, bundle);
83
+ }
84
+
85
+ /** The Path-A successor prompt; the bundle points at the predecessor session file. */
86
+ export function successorInstruction(seat: string, handoverId: string, bundle?: string) {
87
+ return withBundle(`SYSTEM: Path-A handover ${handoverId} is injected for ${seat}. Read atdd-flow handover show ${seat}, reconcile the live Desk, then report RESUMED with verified/total counts and discrepancies.`, bundle);
76
88
  }
@@ -0,0 +1,308 @@
1
+ // URN: component:flow:e004:recovery-bundle:backend:application
2
+ // Tested-By:
3
+ // - test:atdd-flow:seat-recovery-bundle
4
+ // - test:atdd-flow:seat-recovery-injection
5
+ // Runtime: bun
6
+ // Purpose: Compute one capped, read-only recovery bundle for a seat.
7
+
8
+ import { readdir, stat } from "node:fs/promises";
9
+ import { dirname, join } from "node:path";
10
+ import { type Checkpoint, type Seat, canonicalAddress, exists, paths, readYaml, runOutput, runtimeAddress, seat as readSeat } from "./core";
11
+ import { readHandoverReplacement } from "./handover";
12
+ import { type Task, nextDirective, seatTasks, terminal } from "./tasks";
13
+ import { messages, pendingResponders, threadsForSeat } from "./threads";
14
+
15
+ /**
16
+ * The bundle only reorganizes durable records (git, Desk, previous Pi session
17
+ * file). It never writes, never calls a model, and renders every unreadable
18
+ * source as an explicit `unavailable` line instead of failing.
19
+ */
20
+ export type RecoveryMode = "start" | "threshold" | "successor";
21
+ type Options = { sessionPath?: string; now?: Date };
22
+ type Entry = { id: string; from: string; to: string; at: string; subject: string };
23
+
24
+ // Hard caps keep the bundle a pointer-style summary, never a transcript.
25
+ const maxLines = 50;
26
+ const maxBytes = 5120;
27
+ const maxTasks = 12;
28
+ const maxPending = 4;
29
+ const maxRecent = 5;
30
+ const maxChanged = 8;
31
+ const tailBytes = 256 * 1024;
32
+ const commandTimeoutMs = 8000;
33
+ const gatePattern = /\b(bun (run )?(test|check)|atdd-bun|tsc|lint)\b/;
34
+
35
+ /** Collapses whitespace and shortens one value so a single field cannot dominate the cap. */
36
+ function clip(text: string, length: number) {
37
+ const flat = text.replace(/\s+/g, " ").trim();
38
+ return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
39
+ }
40
+
41
+ function age(from: string, now: Date) {
42
+ const minutes = Math.floor((now.getTime() - Date.parse(from)) / 60_000);
43
+ if (Number.isNaN(minutes)) return "unknown";
44
+ if (minutes < 60) return `${Math.max(0, minutes)}m`;
45
+ return minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
46
+ }
47
+
48
+ /** Runs one field builder; a failure becomes `<label>: unavailable (<reason>)`. */
49
+ async function part(label: string, build: () => Promise<string[] | string>): Promise<string[]> {
50
+ try {
51
+ const built = await build();
52
+ return Array.isArray(built) ? built : [built];
53
+ } catch (error) {
54
+ return [`${label}: unavailable (${clip((error as Error).message, 80)})`];
55
+ }
56
+ }
57
+
58
+ /** `--no-optional-locks` keeps `git status` from refreshing (writing) the index. */
59
+ const git = (cwd: string, ...args: string[]) => runOutput(["git", "--no-optional-locks", ...args], cwd);
60
+
61
+ /** Shows the first few changed paths and counts the rest. */
62
+ function listing(entries: string[]) {
63
+ const shown: string[] = [];
64
+ let used = 0;
65
+ for (const entry of entries) {
66
+ if (shown.length >= maxChanged || used + entry.length > 140) break;
67
+ shown.push(entry);
68
+ used += entry.length + 2;
69
+ }
70
+ const rest = entries.length - shown.length;
71
+ return `${shown.join(", ")}${rest ? ` +${rest} more` : ""}`;
72
+ }
73
+
74
+ /** Each git field is its own part so one failing command only blanks that field. */
75
+ function gitParts(record: Seat, now: Date) {
76
+ const cwd = record.worktree;
77
+ return [
78
+ part("branch", async () => `branch: ${await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")}`)
79
+ .then((lines) => lines[0]!.includes("unavailable") ? [`branch: ${record.branch} (recorded; git unavailable)`] : lines),
80
+ part("head", async () => `head: ${await git(cwd, "rev-parse", "--short", "HEAD")}`),
81
+ part("upstream", async () => {
82
+ const [behind, ahead] = (await git(cwd, "rev-list", "--left-right", "--count", "@{u}...HEAD")).split(/\s+/);
83
+ return `upstream: ahead ${ahead}, behind ${behind}`;
84
+ }),
85
+ part("changed", async () => {
86
+ const entries = (await git(cwd, "status", "--porcelain=v1", "-uall")).split("\n").filter(Boolean)
87
+ .map((line) => line.replace(/^\s*(\S+)\s+/, "$1 "));
88
+ return `changed: ${entries.length ? listing(entries) : "none"}`;
89
+ }),
90
+ part("last commit", async () => `last commit: ${age(await git(cwd, "log", "-1", "--format=%cI"), now)} ago`),
91
+ ];
92
+ }
93
+
94
+ async function checkpointLine(root: string, address: string, now: Date) {
95
+ const file = paths(root).checkpointFile(address);
96
+ if (!await exists(file)) throw new Error("no checkpoint recorded");
97
+ const entry = await readYaml<Checkpoint>(file);
98
+ return `checkpoint: ${entry.status} — ${clip(entry.summary, 100)} | next: ${clip(entry.next_action, 60)} (${age(entry.updated_at, now)} ago)`;
99
+ }
100
+
101
+ /** Tasks that need this seat now (0), other work in flight (1), then todo (2). */
102
+ function taskGroup(task: Task, address: string) {
103
+ if (task.status === "todo") return 2;
104
+ const handoff = task.handoff?.state;
105
+ const coordinating = task.coordinator === address && (task.status === "review" || Boolean(task.blocker) || handoff === "awaiting_coordinator");
106
+ const assigned = task.assignee === address && task.status === "in_progress" && (handoff === "awaiting_assignee" || handoff === "executing");
107
+ return coordinating || assigned ? 0 : 1;
108
+ }
109
+
110
+ function taskLine(id: string, task: Task) {
111
+ const proven = `${task.done_when.filter((entry) => entry.proof).length}/${task.done_when.length}`;
112
+ const parts = [`task ${id}`, task.status, task.phase, task.handoff && `${task.handoff.state} ${task.handoff.evidence}`, `proof ${proven}`];
113
+ if (task.blocker) parts.push(`blocked: ${task.blocker}`);
114
+ if (task.depends_on?.length) parts.push(`deps: ${task.depends_on.join(",")}`);
115
+ return clip(parts.filter(Boolean).join(" "), 150);
116
+ }
117
+
118
+ const moreLine = (hidden: number, address: string) => ` +${hidden} more, run atdd-flow status seat ${address}`;
119
+
120
+ async function taskLines(root: string, address: string) {
121
+ const record = await readSeat(root, address);
122
+ const rows = (await seatTasks(root, record.project, address)).filter((entry) => !terminal(entry.task))
123
+ .map((entry) => ({ ...entry, group: taskGroup(entry.task, address) }))
124
+ .sort((left, right) => left.group - right.group || left.id.localeCompare(right.id));
125
+ const count = (group: number) => rows.filter((row) => row.group === group).length;
126
+ const lines = [`tasks: ${rows.length} open (${count(0)} need you, ${count(1)} in progress, ${count(2)} todo)`];
127
+ lines.push(...rows.slice(0, maxTasks).map((row) => taskLine(row.id, row.task)));
128
+ if (rows.length > maxTasks) lines.push(moreLine(rows.length - maxTasks, address));
129
+ return lines;
130
+ }
131
+
132
+ async function nextLine(root: string, address: string) {
133
+ const directive = await nextDirective(root, address);
134
+ if (!directive) return "next: none (no actionable task state)";
135
+ return clip(`next: ${directive.project}/${directive.task} [${directive.lifecycle}] ${directive.action}${directive.command ? ` ${directive.command}` : ""}`, 200);
136
+ }
137
+
138
+ const byAge = (left: Entry, right: Entry) => left.at.localeCompare(right.at) || left.id.localeCompare(right.id);
139
+
140
+ /** Unanswered expects_result messages in both directions plus the seat's own recent posts. */
141
+ async function conversation(root: string, address: string) {
142
+ const inbound: Entry[] = [];
143
+ const outbound: Entry[] = [];
144
+ const own: Entry[] = [];
145
+ for (const { threadId, record } of await threadsForSeat(root, address)) {
146
+ const all = await messages(root, threadId);
147
+ for (const message of all) {
148
+ const entry = { id: message.id, from: message.from, to: message.to === "all" ? "all" : message.to.join(","), at: message.created_at, subject: record.subject };
149
+ if (message.from === address) own.push(entry);
150
+ if (!message.expects_result || record.state === "closed") continue;
151
+ const waiting = pendingResponders(record.participants, all, message);
152
+ if (waiting.includes(address)) inbound.push(entry);
153
+ if (message.from === address && waiting.length) outbound.push({ ...entry, to: waiting.join(",") });
154
+ }
155
+ }
156
+ // Recent unanswered evidence wins the cap; totals still preserve the full count.
157
+ return { inbound: inbound.sort(byAge).reverse(), outbound: outbound.sort(byAge).reverse(), own: own.sort(byAge).reverse() };
158
+ }
159
+
160
+ async function conversationLines(root: string, address: string, now: Date) {
161
+ const { inbound, outbound, own } = await conversation(root, address);
162
+ const render = (kind: string, entries: Entry[]) => {
163
+ const shown = entries.slice(0, maxPending).map((entry) => clip(`${kind} ${entry.id} ${entry.from} -> ${entry.to} ${age(entry.at, now)}`, 170));
164
+ return entries.length > maxPending ? [...shown, moreLine(entries.length - maxPending, address)] : shown;
165
+ };
166
+ return {
167
+ unanswered: [`unanswered: ${inbound.length} inbound, ${outbound.length} outbound`, ...render("inbound", inbound), ...render("outbound", outbound)],
168
+ recent: own.slice(0, maxRecent).map((entry) => clip(`recent ${entry.id} ${entry.subject}`, 100)),
169
+ };
170
+ }
171
+
172
+ /** PR/CI state for the worktree branch; `gh` is read-only here and bounded by a timeout. */
173
+ async function prLine(cwd: string) {
174
+ const child = Bun.spawn(["gh", "pr", "view", "--json", "number,state,url,statusCheckRollup"], {
175
+ cwd, env: process.env, stdout: "pipe", stderr: "pipe", timeout: commandTimeoutMs,
176
+ });
177
+ const [out, err, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
178
+ if (code) throw new Error(err.trim().split("\n")[0] || `gh exited ${code}`);
179
+ const pr = JSON.parse(out) as { number: number; state: string; url: string; statusCheckRollup?: { conclusion?: string; state?: string }[] };
180
+ const tally = { pass: 0, fail: 0, pending: 0 };
181
+ for (const check of pr.statusCheckRollup ?? []) {
182
+ const result = (check.conclusion || check.state || "").toUpperCase();
183
+ if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(result)) tally.pass += 1;
184
+ else if (["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(result)) tally.fail += 1;
185
+ else tally.pending += 1;
186
+ }
187
+ return `pr: PR #${pr.number} ${pr.state} ${pr.url}; checks ${tally.pass} pass / ${tally.fail} fail / ${tally.pending} pending`;
188
+ }
189
+
190
+ type Tool = { commands: Map<string, string>; order: string[]; results: Map<string, { failed: boolean; text: string }> };
191
+ type Item = { type?: string; id?: string; name?: string; text?: string; arguments?: { command?: unknown } };
192
+ type Record = { role?: string; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean };
193
+
194
+ function textOf(content: unknown) {
195
+ if (typeof content === "string") return content;
196
+ return Array.isArray(content) ? (content as Item[]).map((item) => item.text ?? "").join("\n") : "";
197
+ }
198
+
199
+ function recordResult(tool: Tool, message: Record) {
200
+ if (message.role !== "toolResult" || message.toolName !== "bash" || !message.toolCallId) return;
201
+ tool.results.set(message.toolCallId, { failed: Boolean(message.isError), text: textOf(message.content) });
202
+ }
203
+
204
+ function recordCalls(tool: Tool, message: Record) {
205
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return;
206
+ for (const item of message.content as Item[]) {
207
+ const command = item.arguments?.command;
208
+ if (item.type !== "toolCall" || item.name !== "bash" || !item.id || typeof command !== "string") continue;
209
+ tool.commands.set(item.id, command);
210
+ tool.order.push(item.id);
211
+ }
212
+ }
213
+
214
+ /** Reads bash tool calls and their paired results from Pi session JSONL records. */
215
+ function toolRecords(text: string): Tool {
216
+ const tool: Tool = { commands: new Map(), order: [], results: new Map() };
217
+ for (const line of text.split("\n")) {
218
+ try { const message = JSON.parse(line).message ?? {}; recordResult(tool, message); recordCalls(tool, message); } catch { /* A partial or foreign line carries no tool record. */ }
219
+ }
220
+ return tool;
221
+ }
222
+
223
+ /** Last test/gate command and its recorded outcome, from the final 256KB of the session file only. */
224
+ async function gateLine(path: string) {
225
+ const size = (await stat(path)).size;
226
+ const start = Math.max(0, size - tailBytes);
227
+ const tail = await Bun.file(path).slice(start, size).text();
228
+ const tool = toolRecords(start > 0 ? tail.slice(tail.indexOf("\n") + 1) : tail);
229
+ const id = tool.order.findLast((candidate) => gatePattern.test(tool.commands.get(candidate)!));
230
+ if (!id) throw new Error("no test/gate command in session tail");
231
+ const result = tool.results.get(id);
232
+ const code = result?.text.match(/exited with code (\d+)/)?.[1];
233
+ const outcome = !result ? "result unrecorded" : result.failed ? `failed${code ? `, code ${code}` : ""}` : "ok";
234
+ return `gate: ${clip(tool.commands.get(id)!, 80)} — ${outcome}`;
235
+ }
236
+
237
+ function sessionOf(options: Options) {
238
+ if (!options.sessionPath) throw new Error("no previous session");
239
+ return options.sessionPath;
240
+ }
241
+
242
+ function runtimeLine(record: Seat) {
243
+ const runtime = record.runtime;
244
+ const locator = runtime?.addresses[runtime.application];
245
+ if (!runtime || locator === undefined) throw new Error("no runtime recorded");
246
+ const { address, session } = runtimeAddress(runtime.application, locator);
247
+ return `runtime: ${runtime.application} ${session ?? ""} ${address} | pi session ${runtime.pi_session ?? "unrecorded"}`.replace(/\s+/g, " ");
248
+ }
249
+
250
+ /** Enforces the 50-line / 5KB contract by dropping trailing (least actionable) lines. */
251
+ function cap(lines: string[]) {
252
+ const kept = lines.slice(0, maxLines);
253
+ while (kept.length > 1 && new TextEncoder().encode(kept.join("\n")).length > maxBytes) kept.pop();
254
+ return kept.join("\n");
255
+ }
256
+
257
+ /** Computes the recovery bundle for `address`; unreadable sources render as `unavailable`. */
258
+ export async function recoveryBundle(root: string, address: string, options: Options = {}) {
259
+ const now = options.now ?? new Date();
260
+ let resolved = address;
261
+ let record: Seat;
262
+ try {
263
+ resolved = await canonicalAddress(root, address);
264
+ record = await readSeat(root, resolved);
265
+ } catch (error) {
266
+ return cap([`RECOVERY BUNDLE ${resolved} | ${now.toISOString()}`, `seat: unavailable (${clip((error as Error).message, 80)})`]);
267
+ }
268
+ const talk = conversationLines(root, resolved, now).catch((error) => {
269
+ const reason = `unavailable (${clip((error as Error).message, 80)})`;
270
+ return { unanswered: [`unanswered: ${reason}`], recent: [`recent: ${reason}`] };
271
+ });
272
+ const sections = await Promise.all([
273
+ [`role: ${record.role} | project: ${record.project}`, `worktree: ${record.worktree}`],
274
+ ...gitParts(record, now),
275
+ part("checkpoint", () => checkpointLine(root, resolved, now)),
276
+ part("tasks", () => taskLines(root, resolved)),
277
+ part("next", () => nextLine(root, resolved)),
278
+ talk.then((lines) => lines.unanswered),
279
+ part("pr", () => prLine(record.worktree)),
280
+ part("gate", async () => gateLine(sessionOf(options))),
281
+ part("session", async () => `session: ${sessionOf(options)}`),
282
+ part("runtime", async () => runtimeLine(record)),
283
+ talk.then((lines) => lines.recent),
284
+ ]);
285
+ return cap([`RECOVERY BUNDLE ${resolved} | ${now.toISOString()}`, ...sections.flat()]);
286
+ }
287
+
288
+ /**
289
+ * The previous Pi session to point at: the predecessor's file for a Path-A
290
+ * successor, the seat's own file at the handover threshold, or the newest
291
+ * sibling file (excluding the running session) at an ordinary start.
292
+ */
293
+ export async function previousSessionPath(root: string, address: string, mode: RecoveryMode, current?: string) {
294
+ const resolved = await canonicalAddress(root, address);
295
+ if (mode === "successor") return (await readHandoverReplacement(root, resolved)).source.pi_session_path;
296
+ const runtime = (await readSeat(root, resolved)).runtime;
297
+ const own = runtime?.pi_session_path;
298
+ if (!own || mode === "threshold") return own;
299
+ const running = current ?? runtime?.pi_session;
300
+ const names = (await readdir(dirname(own))).filter((name) => name.endsWith(".jsonl") && !(running && name.includes(running))).sort();
301
+ return names.length ? join(dirname(own), names[names.length - 1]!) : undefined;
302
+ }
303
+
304
+ /** The bundle for a prompt or CLI call; an unresolvable previous session only renders as unavailable. */
305
+ export async function recoveryFor(root: string, address: string, mode: RecoveryMode, current?: string) {
306
+ const sessionPath = await previousSessionPath(root, address, mode, current).catch(() => undefined);
307
+ return recoveryBundle(root, address, { sessionPath });
308
+ }
package/src/threads.ts CHANGED
@@ -36,7 +36,7 @@ async function thread(root: string, threadId: string) {
36
36
  return readYaml<Thread>(paths(root).threadFile(threadId));
37
37
  }
38
38
 
39
- async function messages(root: string, threadId: string) {
39
+ export async function messages(root: string, threadId: string) {
40
40
  const folder = paths(root).thread(threadId);
41
41
  const files = (await readdir(folder)).filter((file) => file.startsWith("M-") && file.endsWith(".yaml")).sort();
42
42
  return Promise.all(files.map((file) => readYaml<Message>(`${folder}/${file}`)));
@@ -366,16 +366,19 @@ export async function result(root: string, threadId: string, messageId: string,
366
366
  return post(root, threadId, args, { from, to: [target.from], kind: "result", in_reply_to: messageId, body: required(words(args, "--body"), "--body") });
367
367
  }
368
368
 
369
+ /** Recipients that have not yet answered an expects_result message with a result. */
370
+ export function pendingResponders(participants: string[], all: Message[], message: Message) {
371
+ const responders = message.to === "all" ? participants.filter((address) => address !== message.from) : message.to;
372
+ return responders.filter((address) => !all.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address));
373
+ }
374
+
369
375
  export async function status(root: string) {
370
376
  const threadIds = (await readdir(paths(root).threads)).sort();
371
377
  for (const threadId of threadIds) {
372
378
  const record = await thread(root, threadId);
373
379
  const all = await messages(root, threadId);
374
- const pending = all.flatMap((message) => {
375
- if (!message.expects_result) return [];
376
- const responders = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
377
- return responders.filter((address) => !all.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address)).map((address) => `${message.id}@${address}`);
378
- });
380
+ const pending = all.flatMap((message) => message.expects_result
381
+ ? pendingResponders(record.participants, all, message).map((address) => `${message.id}@${address}`) : []);
379
382
  console.log(`${threadId} ${record.state} ${record.subject}${pending.length ? ` waiting:${pending.join(",")}` : ""}`);
380
383
  }
381
384
  }