@afokapu/atdd-flow 0.3.49 → 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
|
package/extensions/pi/index.ts
CHANGED
|
@@ -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 };
|
|
@@ -318,6 +319,28 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
|
|
|
318
319
|
throw new Error("Timed out waiting for verified Flow launch receipt and binding.");
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Host-attached Pi has no launcher variables; it may activate only when Herdr's injected pane identity exactly
|
|
324
|
+
* matches the Desk seat's native scoped binding. This reads the seat and never writes or creates a binding.
|
|
325
|
+
*/
|
|
326
|
+
export async function hostAttachedActivation({ root, seat, herdrSession, pane, piSession, piSessionPath }: { root: string; seat: string; herdrSession?: string; pane?: string; piSession?: string; piSessionPath?: string }) {
|
|
327
|
+
if (!herdrSession || !pane) return false;
|
|
328
|
+
try {
|
|
329
|
+
const runtime = (await readYaml<Seat>(paths(root).seatFile(seat))).runtime;
|
|
330
|
+
return nativeScopedTo(runtime, herdrSession, pane) && sessionUnsetOrEqual(runtime, piSession, piSessionPath);
|
|
331
|
+
} catch { return false; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function nativeScopedTo(runtime: Seat["runtime"], herdrSession: string, pane: string) {
|
|
335
|
+
const locator = runtime?.addresses.herdr;
|
|
336
|
+
return runtime?.application === "herdr" && runtime.wake === "native" && !runtime.launch_receipt
|
|
337
|
+
&& typeof locator === "object" && locator.session === herdrSession && locator.pane === pane;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const unsetOrEqual = (recorded: string | undefined, live: string | undefined) => !recorded || recorded === live;
|
|
341
|
+
const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
|
|
342
|
+
unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
|
|
343
|
+
|
|
321
344
|
/** Injects the current task-derived action; failure to derive never blocks mail or startup. */
|
|
322
345
|
async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
|
|
323
346
|
try {
|
|
@@ -330,6 +353,9 @@ async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
|
|
|
330
353
|
} catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
|
|
331
354
|
}
|
|
332
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
|
+
|
|
333
359
|
/** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
|
|
334
360
|
export default function (pi: ExtensionAPI) {
|
|
335
361
|
const root = process.env.ATDD_WORKFLOW_ROOT;
|
|
@@ -337,6 +363,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
337
363
|
if (!root || !seat) return;
|
|
338
364
|
let runtime: ReturnType<typeof createPiRuntime> | undefined;
|
|
339
365
|
let handoverPrompted = false;
|
|
366
|
+
// Host-attach only: mail older than this activation is consumed without a prompt, so a long backlog cannot trigger one turn per message.
|
|
367
|
+
let hostActivatedAt: number | undefined;
|
|
340
368
|
pi.on("turn_end", async (_event, ctx) => {
|
|
341
369
|
if (handoverPrompted) return;
|
|
342
370
|
const percent = ctx.getContextUsage()?.percent;
|
|
@@ -345,9 +373,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
345
373
|
const threshold = await contextHandoverThreshold(root);
|
|
346
374
|
if (percent < threshold) return;
|
|
347
375
|
handoverPrompted = true;
|
|
376
|
+
const bundle = await recovery(root, seat, "threshold", process.env.ATDD_FLOW_PI_SESSION);
|
|
348
377
|
pi.sendMessage({
|
|
349
378
|
customType: "atdd-flow-context-handover-replacement",
|
|
350
|
-
content:
|
|
379
|
+
content: handoverThresholdInstruction(percent, threshold, bundle),
|
|
351
380
|
display: true, details: { seat, threshold, percent, authority: "handover" },
|
|
352
381
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
353
382
|
} catch { /* Desk policy is re-read on the next safe boundary. */ }
|
|
@@ -369,6 +398,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
369
398
|
pi.on("session_start", async (_event, ctx) => {
|
|
370
399
|
runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
371
400
|
if (!sendIfOwned) return false;
|
|
401
|
+
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
372
402
|
return sendIfOwned(async () => {
|
|
373
403
|
// Task state is re-read at every wake, so queued mail cannot carry a stale action.
|
|
374
404
|
await sendTaskDirective(pi, root, seat);
|
|
@@ -383,23 +413,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
383
413
|
const piSession = process.env.ATDD_FLOW_PI_SESSION;
|
|
384
414
|
const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
|
|
385
415
|
const pane = process.env.ATDD_FLOW_HERDR_PANE;
|
|
416
|
+
if (!piSession && !herdrSession && !pane) {
|
|
417
|
+
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
418
|
+
if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
419
|
+
hostActivatedAt = Date.now();
|
|
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
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
386
428
|
if (!piSession || !herdrSession || !pane) return;
|
|
387
429
|
const current = runtime;
|
|
388
430
|
void awaitLaunchActivation({
|
|
389
431
|
root, seat, piSession, herdrSession, pane,
|
|
390
432
|
activate: async () => {
|
|
391
433
|
if (!await current.activate()) return;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
}
|
|
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
|
+
}
|
|
403
445
|
await sendTaskDirective(pi, root, seat);
|
|
404
446
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
405
447
|
},
|
package/package.json
CHANGED
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
|
-
|
|
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
|
}
|