@afokapu/atdd-flow 0.3.50 → 0.3.52

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.
package/README.md CHANGED
@@ -134,6 +134,12 @@ Proof is a compact PR, CI run, report, commit range, deployment, or thread refer
134
134
 
135
135
  At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
136
136
 
137
+ A project main seat can perform a second Path-A replacement without deleting or editing the consumed active request. Use the normal `handover replace` arguments with the explicit `--supersede-consumed` flag, a fresh exact-session adoption authorization, the current validated handover, and exactly one ready self-assigned takeover task. Without the flag, replacement remains create-only and refuses an existing active request.
138
+
139
+ Before observing Herdr, supersession requires the prior request's takeover task to be terminal and the canonical runtime receipt to embed that exact request and successor. It also requires unchanged main topology, canonical pane and worktree placement, and a matching current handover lineage. Read-only Herdr checks then prove the freshly authorized live Pi session, JSONL path, pane, cwd, agent, and exactly one Pi writer. The command never stops, launches, binds, or changes a runtime.
140
+
141
+ Supersession serializes publication per seat. It stages a byte-identical copy of the prior request and launch receipt under `.atdd-flow/handover-replacement-archive/<encoded-seat>/<prior-request-sha256>/`, publishes that complete immutable archive first, then atomically replaces the active request. Validation, staging, archive, swap, and concurrent-attempt failures preserve the original active request, receipt, seat, and runtime. After the source extension observes the new request and exits, use the unchanged `pi runtime launch` adoption path; do not hand-edit Desk records or rebind the seat.
142
+
137
143
  For topology normalization, transfer an active task through the narrow durable command, never by editing its YAML: `atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <current-coordinator>`; `main@project` must additionally pass `--authorization <operator-message-id>`. That message must be an immutable `task-transfer-authorization/v1` exact tuple for the project, task, current/target coordinator, reason, and target branch/head; only `operator@desk` creates it through `atdd-flow post --task-transfer-authorization <json>`. The command appends immutable source/target/reason/exact-head/effective-time provenance, keeps earlier phase/review/message evidence intact, derives the new governed base from the accepted target head, and never moves a seat, branch, runtime, or worktree. Same-target, duplicate, nested, mismatched, unauthorized, and manually rewritten transitions fail closed.
138
144
 
139
145
  ## Communicate and hand over
@@ -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.52",
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
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env bun
2
+ // URN: component:flow:e005:cli:backend:presentation
3
+ // Tested-By:
4
+ // - test:atdd-flow:repeat-handover-replacement-supersession
5
+ // Runtime: bun
6
+ // Purpose: Route supported Desk commands and publish their exact CLI grammar.
2
7
 
3
8
  import { basename, resolve } from "node:path";
4
9
  import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
@@ -13,6 +18,7 @@ import { status } from "./overview";
13
18
  import { multiplexer } from "./multiplexer";
14
19
  import * as cleanup from "./ephemeral-resources";
15
20
  import * as ownerAlerts from "./owner-escalations";
21
+ import { recoveryFor } from "./recovery-bundle";
16
22
  import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
17
23
 
18
24
  const usage = `atdd-flow — filesystem-first agent seats and tasks
@@ -38,7 +44,7 @@ Usage:
38
44
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
39
45
  atdd-flow handover <address> --file <path> [--check]
40
46
  atdd-flow handover show <address>
41
- atdd-flow handover replace <address> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
47
+ atdd-flow handover replace <address> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
42
48
  atdd-flow handover resumed <address>
43
49
  atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
44
50
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
@@ -75,6 +81,7 @@ Usage:
75
81
  atdd-flow behavioral-review record <project> <task-id> --by <reviewer-address> --file <result-yaml>
76
82
  atdd-flow behavioral-review open <project> <task-id>
77
83
  atdd-flow open <address>
84
+ atdd-flow recover <address>
78
85
  atdd-flow address-book
79
86
 
80
87
  Global:
@@ -207,6 +214,7 @@ async function main() {
207
214
  throw new Error("Use `atdd-flow behavioral-review launch|record|open <project> <task-id>`.");
208
215
  },
209
216
  open: () => openSeat(root, required(rest[0], "address")),
217
+ recover: async () => console.log(await recoveryFor(root, required(rest[0], "address"), "start", process.env.ATDD_FLOW_PI_SESSION)),
210
218
  "address-book": () => addressBook(root),
211
219
  };
212
220
  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
  }
package/src/handover.ts CHANGED
@@ -1,5 +1,11 @@
1
+ // URN: component:flow:e005:handover:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:repeat-handover-replacement-supersession
4
+ // Runtime: bun
5
+ // Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
6
+
1
7
  import { createHash } from "node:crypto";
2
- import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
8
+ import { link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
9
  import { basename, dirname, join, resolve } from "node:path";
4
10
  import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
5
11
  import { type Task, terminal } from "./tasks";
@@ -102,23 +108,33 @@ export type HandoverReplacementRequest = {
102
108
  authorization: { id: string; hash: string };
103
109
  source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
104
110
  target: { herdr_session: string; pane: string; cwd: string };
105
- legacy_source?: {
106
- authorization_hash: string;
107
- pi_session: string;
108
- pi_session_path: string;
109
- herdr_session: string;
110
- pane: string;
111
- cwd: string;
112
- agent: string;
113
- verified_at: string;
111
+ legacy_source?: LiveSourceProof;
112
+ supersession?: {
113
+ prior_request: { path: string; sha256: string; task: string; handover: string; handover_sha256: string; archive: string };
114
+ prior_receipt: { path: string; sha256: string; successor_session: string };
115
+ canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
116
+ live_source: LiveSourceProof;
114
117
  };
115
118
  created_at: string;
116
119
  };
117
120
 
121
+ type LiveSourceProof = {
122
+ authorization_hash: string;
123
+ pi_session: string;
124
+ pi_session_path: string;
125
+ herdr_session: string;
126
+ pane: string;
127
+ cwd: string;
128
+ agent: string;
129
+ verified_at: string;
130
+ };
131
+
118
132
  export type HandoverReplacementDependencies = {
119
133
  command?: (command: string[]) => Promise<string>;
120
134
  at?: () => string;
121
135
  publish?: (file: string, content: string) => Promise<void>;
136
+ publishArchive?: (staging: string, destination: string) => Promise<void>;
137
+ replaceActive?: (staged: string, active: string) => Promise<void>;
122
138
  };
123
139
 
124
140
  export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
@@ -155,7 +171,37 @@ function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: Han
155
171
  && runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
156
172
  }
157
173
 
174
+ type SupersessionProof = NonNullable<HandoverReplacementRequest["supersession"]>;
175
+
176
+ function supersessionPlacementMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
177
+ const locator = runtime?.addresses.herdr;
178
+ return runtime?.application === "herdr" && typeof locator !== "string"
179
+ && locator?.session === proof.canonical_source.herdr_session && locator.pane === proof.canonical_source.pane;
180
+ }
181
+
182
+ function supersessionSessionMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
183
+ return runtime?.pi_session === proof.canonical_source.pi_session
184
+ && runtime.pi_session_path === proof.canonical_source.pi_session_path
185
+ && runtime.launch_receipt === proof.canonical_source.launch_receipt
186
+ && proof.prior_receipt.path === runtime.launch_receipt
187
+ && proof.prior_receipt.successor_session === runtime.pi_session;
188
+ }
189
+
190
+ function supersessionEvidenceMatches(request: HandoverReplacementRequest, proof: SupersessionProof) {
191
+ return proof.canonical_source.cwd === request.target.cwd
192
+ && proof.canonical_source.herdr_session === request.target.herdr_session
193
+ && proof.canonical_source.pane === request.target.pane
194
+ && snapshotMatchesRequest(proof.live_source, request)
195
+ && /^[a-f0-9]{64}$/i.test(proof.prior_request.sha256)
196
+ && /^[a-f0-9]{64}$/i.test(proof.prior_request.handover_sha256)
197
+ && /^[a-f0-9]{64}$/i.test(proof.prior_receipt.sha256)
198
+ && basename(proof.prior_request.archive) === proof.prior_request.sha256
199
+ && Boolean(proof.prior_request.task && proof.prior_request.handover);
200
+ }
201
+
158
202
  export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
203
+ const proof = request.supersession;
204
+ if (proof) return supersessionPlacementMatches(runtime, proof) && supersessionSessionMatches(runtime, proof) && supersessionEvidenceMatches(request, proof);
159
205
  if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
160
206
  return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
161
207
  }
@@ -270,9 +316,10 @@ async function publishCreateOnly(file: string, content: string) {
270
316
  } finally { await rm(temporary, { force: true }); }
271
317
  }
272
318
 
273
- const replacementFlags = ["--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
319
+ const replacementFlags = ["--supersede-consumed", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
274
320
  function validateReplacementArgs(args: string[]) {
275
- if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
321
+ if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
322
+ if (has(args, "--supersede-consumed") && has(args, "--legacy-source-bootstrap")) throw new Error("Consumed replacement supersession cannot use legacy source bootstrap.");
276
323
  }
277
324
 
278
325
  async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
@@ -303,12 +350,13 @@ function assertReplacementSource(record: Seat, source: HandoverReplacementReques
303
350
 
304
351
  async function prepareReplacement(root: string, resolved: string, args: string[]) {
305
352
  validateReplacementArgs(args);
306
- const legacy = args.includes("--legacy-source-bootstrap"), handover = await currentReplacementHandover(root, resolved, args);
353
+ const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed");
354
+ const handover = await currentReplacementHandover(root, resolved, args);
307
355
  const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
308
356
  const { record } = await exactMainTakeoverTask(root, resolved, task);
309
357
  const source = replacementSource(args), target = replacementTarget(args, record, source);
310
- assertReplacementSource(record, source, legacy);
311
- return { legacy, handover, task, source, target };
358
+ if (!supersede) assertReplacementSource(record, source, legacy);
359
+ return { legacy, supersede, handover, task, record, source, target };
312
360
  }
313
361
 
314
362
  function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
@@ -320,6 +368,322 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
320
368
  };
321
369
  }
322
370
 
371
+ type ReplacementLaunchReceipt = {
372
+ schema?: string;
373
+ seat?: string;
374
+ tasks?: string[];
375
+ pi_session?: string;
376
+ pi_session_path?: string;
377
+ herdr_session?: string;
378
+ pane?: string;
379
+ handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
380
+ };
381
+
382
+ type ConsumedReplacement = {
383
+ request: HandoverReplacementRequest;
384
+ requestBytes: string;
385
+ requestHash: string;
386
+ receiptBytes: string;
387
+ receiptHash: string;
388
+ receiptPath: string;
389
+ canonical: NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
390
+ archive: string;
391
+ };
392
+
393
+ function validConsumedRequest(request: HandoverReplacementRequest, resolved: string) {
394
+ return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved
395
+ && Boolean(request.task && request.handover?.id && request.handover.sha256);
396
+ }
397
+
398
+ async function readConsumedRequest(file: string, resolved: string) {
399
+ let bytes: string;
400
+ try { bytes = await readFile(file, "utf8"); }
401
+ catch { throw new Error(`Consumed Path-A replacement request does not exist for ${resolved}.`); }
402
+ let request: HandoverReplacementRequest;
403
+ try { request = yaml.parse<HandoverReplacementRequest>(bytes); }
404
+ catch { throw new Error(`Consumed Path-A replacement request is malformed for ${resolved}.`); }
405
+ if (!validConsumedRequest(request, resolved)) throw new Error(`Consumed Path-A replacement request is invalid for ${resolved}.`);
406
+ return { bytes, request };
407
+ }
408
+
409
+ function completeCanonicalRuntime(intent: Awaited<ReturnType<typeof prepareReplacement>>) {
410
+ const runtime = intent.record.runtime, locator = runtime?.addresses.herdr;
411
+ if (runtime?.application !== "herdr" || typeof locator === "string" || !locator
412
+ || !runtime.pi_session || !runtime.pi_session_path || !runtime.launch_receipt) {
413
+ throw new Error("Consumed Path-A replacement requires a complete canonical main runtime receipt.");
414
+ }
415
+ return { runtime, locator };
416
+ }
417
+
418
+ function assertCanonicalPlacement(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest, locator: { session: string; pane: string }) {
419
+ if (locator.session !== intent.target.herdr_session || locator.pane !== intent.target.pane
420
+ || request.target?.herdr_session !== locator.session || request.target.pane !== locator.pane
421
+ || resolve(request.target.cwd) !== resolve(intent.record.worktree)) {
422
+ throw new Error("Consumed Path-A replacement does not match the unchanged canonical main runtime and placement.");
423
+ }
424
+ if (intent.handover.supersedes !== request.handover.id) throw new Error("Consumed Path-A replacement does not match the current handover lineage.");
425
+ }
426
+
427
+ function canonicalRuntimeForSupersession(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest) {
428
+ const { runtime, locator } = completeCanonicalRuntime(intent);
429
+ assertCanonicalPlacement(intent, request, locator);
430
+ return {
431
+ pi_session: runtime.pi_session!, pi_session_path: runtime.pi_session_path!,
432
+ herdr_session: locator.session, pane: locator.pane, cwd: resolve(intent.record.worktree), launch_receipt: runtime.launch_receipt!,
433
+ };
434
+ }
435
+
436
+ async function assertTerminalPriorTask(root: string, projectName: string, taskId: string) {
437
+ const priorTask = await readYaml<Task>(paths(root).taskFile(projectName, taskId));
438
+ if (priorTask.schema !== "atdd-workflow/task/v1" || !terminal(priorTask)) {
439
+ throw new Error("Consumed Path-A replacement requires its prior takeover task to be terminal.");
440
+ }
441
+ }
442
+
443
+ async function readReplacementReceipt(path: string) {
444
+ try {
445
+ const bytes = await readFile(path, "utf8");
446
+ return { bytes, receipt: yaml.parse<ReplacementLaunchReceipt>(bytes) };
447
+ } catch { throw new Error("Consumed Path-A replacement requires its immutable prior launch receipt."); }
448
+ }
449
+
450
+ function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, resolved: string, canonical: ConsumedReplacement["canonical"]) {
451
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === resolved
452
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
453
+ && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
454
+ }
455
+
456
+ function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: ConsumedReplacement["canonical"]) {
457
+ return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
458
+ && receipt.handover_replacement?.successor_session === canonical.pi_session
459
+ && JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
460
+ }
461
+
462
+ async function consumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>) {
463
+ const { bytes: requestBytes, request } = await readConsumedRequest(file, resolved);
464
+ const canonical = canonicalRuntimeForSupersession(intent, request);
465
+ await assertTerminalPriorTask(root, intent.record.project, request.task);
466
+ const { bytes: receiptBytes, receipt } = await readReplacementReceipt(canonical.launch_receipt);
467
+ if (!receiptMatchesCanonical(receipt, resolved, canonical) || !receiptProvesRequest(receipt, request, canonical)) {
468
+ throw new Error("Consumed Path-A replacement prior receipt does not prove the exact request, task, and canonical successor.");
469
+ }
470
+ const requestHash = createHash("sha256").update(requestBytes).digest("hex");
471
+ const receiptHash = createHash("sha256").update(receiptBytes).digest("hex");
472
+ return {
473
+ request, requestBytes, requestHash, receiptBytes, receiptHash, canonical,
474
+ receiptPath: canonical.launch_receipt,
475
+ archive: join(root, ".atdd-flow", "handover-replacement-archive", encodeURIComponent(resolved), requestHash),
476
+ } satisfies ConsumedReplacement;
477
+ }
478
+
479
+ function supersessionManifest(resolved: string, file: string, consumed: ConsumedReplacement) {
480
+ return yaml.print({
481
+ schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: resolved,
482
+ prior_request: { path: file, sha256: consumed.requestHash, task: consumed.request.task, handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256 },
483
+ prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
484
+ });
485
+ }
486
+
487
+ async function archiveMatches(destination: string, requestBytes: string, receiptBytes: string, manifest: string) {
488
+ try {
489
+ const [files, request, receipt, proof] = await Promise.all([
490
+ readdir(destination), readFile(join(destination, "request.yaml"), "utf8"),
491
+ readFile(join(destination, "launch-receipt.yaml"), "utf8"), readFile(join(destination, "supersession.yaml"), "utf8"),
492
+ ]);
493
+ return files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n")
494
+ && request === requestBytes && receipt === receiptBytes && proof === manifest;
495
+ } catch { return false; }
496
+ }
497
+
498
+ async function restoreActive(file: string, priorBytes: string) {
499
+ let current: string | undefined;
500
+ try { current = await readFile(file, "utf8"); } catch { /* Restore an absent active request below. */ }
501
+ if (current === priorBytes) return;
502
+ const rollback = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.rollback.tmp`);
503
+ try {
504
+ await writeFile(rollback, priorBytes, { encoding: "utf8", flag: "wx" });
505
+ await rename(rollback, file);
506
+ } finally { await rm(rollback, { force: true }); }
507
+ }
508
+
509
+ function supersedingRequest(resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, consumed: ConsumedReplacement, liveSource: LiveSourceProof, dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
510
+ return {
511
+ ...replacementRequest(resolved, intent, authorization, undefined, dependencies),
512
+ supersession: {
513
+ prior_request: {
514
+ path: file, sha256: consumed.requestHash, task: consumed.request.task,
515
+ handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256, archive: consumed.archive,
516
+ },
517
+ prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
518
+ canonical_source: consumed.canonical, live_source: liveSource,
519
+ },
520
+ };
521
+ }
522
+
523
+ type SupersessionTransaction = {
524
+ consumed: ConsumedReplacement;
525
+ archiveStage: string;
526
+ successorStage: string;
527
+ archiveExisted: boolean;
528
+ activeSwapAttempted: boolean;
529
+ };
530
+
531
+ async function stageSupersession(file: string, request: HandoverReplacementRequest, consumed: ConsumedReplacement, manifest: string, dependencies: HandoverReplacementDependencies) {
532
+ const archiveParent = dirname(consumed.archive);
533
+ const archiveStage = join(archiveParent, `.${basename(consumed.archive)}.${crypto.randomUUID()}.tmp`);
534
+ const successorStage = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.tmp`);
535
+ await mkdir(archiveParent, { recursive: true });
536
+ try {
537
+ await mkdir(archiveStage);
538
+ const successorWrite = dependencies.publish
539
+ ? dependencies.publish(successorStage, yaml.print(request))
540
+ : writeFile(successorStage, yaml.print(request), { encoding: "utf8", flag: "wx" });
541
+ await Promise.all([
542
+ writeFile(join(archiveStage, "request.yaml"), consumed.requestBytes, { encoding: "utf8", flag: "wx" }),
543
+ writeFile(join(archiveStage, "launch-receipt.yaml"), consumed.receiptBytes, { encoding: "utf8", flag: "wx" }),
544
+ writeFile(join(archiveStage, "supersession.yaml"), manifest, { encoding: "utf8", flag: "wx" }),
545
+ successorWrite,
546
+ ]);
547
+ return { archiveStage, successorStage };
548
+ } catch (error) {
549
+ await rm(archiveStage, { recursive: true, force: true });
550
+ await rm(successorStage, { force: true });
551
+ throw error;
552
+ }
553
+ }
554
+
555
+ async function publishArchiveEvidence(transaction: SupersessionTransaction, manifest: string, dependencies: HandoverReplacementDependencies) {
556
+ const { consumed, archiveStage, archiveExisted } = transaction;
557
+ if (archiveExisted) {
558
+ if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
559
+ throw new Error("Consumed Path-A replacement archive collides with different immutable evidence.");
560
+ }
561
+ return;
562
+ }
563
+ await (dependencies.publishArchive ?? rename)(archiveStage, consumed.archive);
564
+ if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
565
+ throw new Error("Consumed Path-A replacement archive publication was incomplete.");
566
+ }
567
+ }
568
+
569
+ async function rollbackSupersession(file: string, transaction: SupersessionTransaction) {
570
+ if (transaction.activeSwapAttempted) await restoreActive(file, transaction.consumed.requestBytes);
571
+ if (!transaction.archiveExisted) await rm(transaction.consumed.archive, { recursive: true, force: true });
572
+ }
573
+
574
+ async function cleanupSupersession(transaction: SupersessionTransaction | undefined) {
575
+ if (!transaction) return;
576
+ await rm(transaction.archiveStage, { recursive: true, force: true });
577
+ await rm(transaction.successorStage, { force: true });
578
+ }
579
+
580
+ const supersessionLockSchema = "atdd-flow/handover-replacement-supersession-lock/v1" as const;
581
+ type SupersessionLock = { schema: typeof supersessionLockSchema; seat: string; active_request_sha256: string; pid: number; token: string };
582
+ const errorCode = (error: unknown) => typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
583
+
584
+ function validSupersessionLock(value: unknown, resolved: string, activeHash: string): value is SupersessionLock {
585
+ const record = object(value);
586
+ return record?.schema === supersessionLockSchema && record.seat === resolved && record.active_request_sha256 === activeHash
587
+ && Number.isInteger(record.pid) && Number(record.pid) > 0 && typeof record.token === "string" && Boolean(record.token);
588
+ }
589
+
590
+ function liveProcess(pid: number) {
591
+ try { process.kill(pid, 0); return true; }
592
+ catch (error) {
593
+ if (errorCode(error) === "ESRCH") return false;
594
+ if (errorCode(error) === "EPERM") return true;
595
+ throw error;
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Recovery keeps the lock fail-closed without requiring a manual Desk edit.
601
+ * The active-request hash binds a dead owner to the immutable request it saw.
602
+ * A live PID, EPERM result, malformed record, or changed identity always refuses.
603
+ * A hard-link recovery marker is create-only, so one contender owns cleanup.
604
+ * New contenders observe that marker before attempting ordinary acquisition.
605
+ * A contender already between its marker check and `wx` may win the lock, but
606
+ * the recovery owner then loses its own `wx`; in either ordering only one wins.
607
+ * The marker also closes the remove/reacquire gap for later contenders.
608
+ */
609
+ async function createSupersessionLock(lock: string, content: string) {
610
+ try { await writeFile(lock, content, { encoding: "utf8", flag: "wx" }); return true; }
611
+ catch (error) {
612
+ if (errorCode(error) === "EEXIST") return false;
613
+ throw error;
614
+ }
615
+ }
616
+
617
+ async function staleSupersessionLock(lock: string, resolved: string, activeHash: string) {
618
+ const observed = await readFile(lock, "utf8");
619
+ let prior: unknown;
620
+ try { prior = yaml.parse<unknown>(observed); }
621
+ catch { throw new Error(`Path-A replacement supersession lock is malformed for ${resolved}.`); }
622
+ if (!validSupersessionLock(prior, resolved, activeHash)) throw new Error(`Path-A replacement supersession lock identity changed for ${resolved}.`);
623
+ if (liveProcess(prior.pid)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
624
+ return observed;
625
+ }
626
+
627
+ async function recoverSupersessionLock(file: string, lock: string, recovery: string, resolved: string, activeHash: string, observed: string, content: string) {
628
+ try { await link(lock, recovery); }
629
+ catch { throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`); }
630
+ try {
631
+ const [currentLock, currentActive] = await Promise.all([readFile(lock, "utf8"), readFile(file, "utf8")]);
632
+ if (currentLock !== observed || createHash("sha256").update(currentActive).digest("hex") !== activeHash) {
633
+ throw new Error(`Path-A replacement supersession lock changed during recovery for ${resolved}.`);
634
+ }
635
+ await rm(lock);
636
+ if (!await createSupersessionLock(lock, content)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
637
+ } finally { await rm(recovery, { force: true }); }
638
+ }
639
+
640
+ async function acquireSupersessionLock(file: string, resolved: string) {
641
+ const lock = `${file}.lock`, recovery = `${lock}.recovery`;
642
+ const activeHash = createHash("sha256").update(await readFile(file, "utf8")).digest("hex");
643
+ const record: SupersessionLock = { schema: supersessionLockSchema, seat: resolved, active_request_sha256: activeHash, pid: process.pid, token: crypto.randomUUID() };
644
+ const content = yaml.print(record);
645
+ if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
646
+ if (await createSupersessionLock(lock, content)) return { lock, content };
647
+ if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
648
+ const observed = await staleSupersessionLock(lock, resolved, activeHash);
649
+ await recoverSupersessionLock(file, lock, recovery, resolved, activeHash, observed, content);
650
+ return { lock, content };
651
+ }
652
+
653
+ async function releaseSupersessionLock(lock: string, content: string) {
654
+ let current: string;
655
+ try { current = await readFile(lock, "utf8"); } catch { return; }
656
+ if (current === content) await rm(lock);
657
+ }
658
+
659
+ async function supersedeConsumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, args: string[], dependencies: HandoverReplacementDependencies) {
660
+ await mkdir(dirname(file), { recursive: true });
661
+ const ownedLock = await acquireSupersessionLock(file, resolved);
662
+ let transaction: SupersessionTransaction | undefined;
663
+ try {
664
+ const consumed = await consumedReplacement(root, resolved, file, intent);
665
+ const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
666
+ seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
667
+ source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
668
+ });
669
+ const liveSource = await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies);
670
+ const request = supersedingRequest(resolved, file, intent, authorization, consumed, liveSource, dependencies);
671
+ const manifest = supersessionManifest(resolved, file, consumed);
672
+ const staging = await stageSupersession(file, request, consumed, manifest, dependencies);
673
+ transaction = { consumed, ...staging, archiveExisted: await exists(consumed.archive), activeSwapAttempted: false };
674
+ await publishArchiveEvidence(transaction, manifest, dependencies);
675
+ transaction.activeSwapAttempted = true;
676
+ await (dependencies.replaceActive ?? rename)(transaction.successorStage, file);
677
+ console.log(`Recorded superseding Path-A replacement request for ${resolved}.`);
678
+ } catch (error) {
679
+ if (transaction) await rollbackSupersession(file, transaction);
680
+ throw error;
681
+ } finally {
682
+ await cleanupSupersession(transaction);
683
+ await releaseSupersessionLock(ownedLock.lock, ownedLock.content);
684
+ }
685
+ }
686
+
323
687
  /**
324
688
  * Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
325
689
  * It deliberately does not stop, bind, attach, project, or launch any process.
@@ -327,7 +691,8 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
327
691
  export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
328
692
  const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
329
693
  const file = handoverReplacementPath(root, resolved);
330
- if (intent.legacy && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
694
+ if (intent.supersede) return supersedeConsumedReplacement(root, resolved, file, intent, args, dependencies);
695
+ if (await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
331
696
  const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
332
697
  seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
333
698
  source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
@@ -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
  }