@afokapu/atdd-flow 0.3.51 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.51",
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";
@@ -39,7 +44,7 @@ Usage:
39
44
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
40
45
  atdd-flow handover <address> --file <path> [--check]
41
46
  atdd-flow handover show <address>
42
- 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>
43
48
  atdd-flow handover resumed <address>
44
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> ...]
45
50
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
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,