@afokapu/atdd-flow 0.3.53 → 0.3.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.53",
3
+ "version": "0.3.55",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ // URN: component:flow:e006:exact-adoption-recovery:backend:integration
2
+ // Tested-By:
3
+ // - test:flow:exact-adoption-resumed-proof-and-relaunch
4
+ // Runtime: bun
5
+ // Purpose: Classify the immutable evidence for one exact Path-A adoption and recovery.
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { readFile, readdir } from "node:fs/promises";
9
+ import { basename, join, resolve } from "node:path";
10
+ import { type Seat, paths, readYaml, yaml } from "./core";
11
+ import type { HandoverReplacementRequest } from "./handover";
12
+ import { readRuntimeState } from "./runtime-state";
13
+ import type { Task } from "./tasks";
14
+
15
+ type ReplacementLaunchReceipt = {
16
+ schema?: string;
17
+ seat?: string;
18
+ tasks?: string[];
19
+ pi_session?: string;
20
+ pi_session_path?: string;
21
+ herdr_session?: string;
22
+ pane?: string;
23
+ handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
24
+ relocation?: {
25
+ source_herdr_session?: string;
26
+ source_pane?: string;
27
+ authorization?: { id?: string; hash?: string };
28
+ backup?: string;
29
+ backup_sha256?: string;
30
+ };
31
+ created_at?: string;
32
+ };
33
+
34
+ type CanonicalSource = NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
35
+ type TaskEligibility = (task: Task) => boolean | Promise<boolean>;
36
+
37
+ /** Hash bytes exactly as the immutable records were published. */
38
+ function digest(content: string | Uint8Array) {
39
+ return createHash("sha256").update(content).digest("hex");
40
+ }
41
+
42
+ /** Match both the authorization content and its separately stored digest. */
43
+ async function exactAuthorizationMatches(root: string, request: HandoverReplacementRequest) {
44
+ const id = request.authorization.id;
45
+ const folder = join(root, ".atdd-flow", "exact-session-adoption-authorizations");
46
+ try {
47
+ const raw = await readFile(join(folder, `${id}.yaml`), "utf8");
48
+ const storedHash = (await readFile(join(folder, `${id}.sha256`), "utf8")).trim();
49
+ const record = object(yaml.parse<unknown>(raw));
50
+ const source = object(record?.source), target = object(record?.target);
51
+ const observed = {
52
+ storedHash, contentHash: digest(raw), schema: record?.schema, id: record?.id, issuedBy: record?.issued_by,
53
+ seat: record?.seat, session: record?.pi_session, sessionPath: record?.pi_session_path,
54
+ sourceSession: source?.herdr_session, sourcePane: source?.pane,
55
+ targetSession: target?.herdr_session, targetPane: target?.pane, targetCwd: target?.cwd,
56
+ };
57
+ const expected = {
58
+ storedHash: request.authorization.hash, contentHash: request.authorization.hash,
59
+ schema: "atdd-flow/exact-session-adoption-authorization/v1", id, issuedBy: "operator@desk",
60
+ seat: request.seat, session: request.source.pi_session, sessionPath: request.source.pi_session_path,
61
+ sourceSession: request.source.herdr_session, sourcePane: request.source.pane,
62
+ targetSession: request.target.herdr_session, targetPane: request.target.pane, targetCwd: request.target.cwd,
63
+ };
64
+ return JSON.stringify(observed) === JSON.stringify(expected);
65
+ } catch { return false; }
66
+ }
67
+
68
+ function object(value: unknown): Record<string, unknown> | undefined {
69
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
70
+ }
71
+
72
+ /** Validate the prior canonical receipt without trusting the active adopted receipt. */
73
+ function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, seat: string, canonical: CanonicalSource) {
74
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
75
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
76
+ && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
77
+ }
78
+
79
+ /** Bind the archived prior receipt to its archived request and successor. */
80
+ function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: CanonicalSource) {
81
+ return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
82
+ && receipt.handover_replacement?.successor_session === canonical.pi_session
83
+ && JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
84
+ }
85
+
86
+ function validConsumedRequest(request: HandoverReplacementRequest, seat: string) {
87
+ return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === seat
88
+ && Boolean(request.task && request.handover?.id && request.handover.sha256);
89
+ }
90
+
91
+ /** Rebuild and compare every archived supersession byte and identity. */
92
+ async function exactArchiveMatches(request: HandoverReplacementRequest) {
93
+ const proof = request.supersession;
94
+ if (!proof) return false;
95
+ try {
96
+ const requestPath = join(proof.prior_request.archive, "request.yaml");
97
+ const receiptPath = join(proof.prior_request.archive, "launch-receipt.yaml");
98
+ const manifestPath = join(proof.prior_request.archive, "supersession.yaml");
99
+ const [files, requestBytes, receiptBytes, manifestBytes] = await Promise.all([
100
+ readdir(proof.prior_request.archive), readFile(requestPath, "utf8"),
101
+ readFile(receiptPath, "utf8"), readFile(manifestPath, "utf8"),
102
+ ]);
103
+ const archivedRequest = yaml.parse<HandoverReplacementRequest>(requestBytes);
104
+ const archivedReceipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
105
+ const manifest = yaml.print({
106
+ schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: request.seat,
107
+ prior_request: {
108
+ path: proof.prior_request.path, sha256: proof.prior_request.sha256, task: proof.prior_request.task,
109
+ handover: proof.prior_request.handover, handover_sha256: proof.prior_request.handover_sha256,
110
+ },
111
+ prior_receipt: proof.prior_receipt,
112
+ });
113
+ return [
114
+ files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n"),
115
+ basename(proof.prior_request.archive) === proof.prior_request.sha256,
116
+ digest(requestBytes) === proof.prior_request.sha256,
117
+ digest(receiptBytes) === proof.prior_receipt.sha256,
118
+ manifestBytes === manifest,
119
+ validConsumedRequest(archivedRequest, request.seat),
120
+ archivedRequest.task === proof.prior_request.task,
121
+ archivedRequest.handover.id === proof.prior_request.handover,
122
+ archivedRequest.handover.sha256 === proof.prior_request.handover_sha256,
123
+ receiptMatchesCanonical(archivedReceipt, request.seat, proof.canonical_source),
124
+ receiptProvesRequest(archivedReceipt, archivedRequest, proof.canonical_source),
125
+ ].every(Boolean);
126
+ } catch { return false; }
127
+ }
128
+
129
+ /** A single authorization may identify exactly one adoption receipt. */
130
+ async function uniqueAdoptionReceipt(root: string, authorizationId: string, expectedPath: string) {
131
+ try {
132
+ const folder = join(root, ".atdd-flow", "runtime-launch");
133
+ const candidates: string[] = [];
134
+ for (const file of await readdir(folder)) {
135
+ if (!file.endsWith(".yaml")) continue;
136
+ const path = join(folder, file);
137
+ try {
138
+ const receipt = yaml.parse<ReplacementLaunchReceipt>(await readFile(path, "utf8"));
139
+ if (receipt.relocation?.authorization?.id === authorizationId) candidates.push(path);
140
+ } catch { /* Malformed receipts cannot prove exact adoption. */ }
141
+ }
142
+ return candidates.length === 1 && resolve(candidates[0]!) === resolve(expectedPath);
143
+ } catch { return false; }
144
+ }
145
+
146
+ /** The failed adopted runtime retains source identity at the exact target pane. */
147
+ function exactAdoptionRuntimeMatches(record: Seat, request: HandoverReplacementRequest) {
148
+ const runtime = record.runtime, locator = runtime?.addresses.herdr;
149
+ const observed = {
150
+ supersession: Boolean(request.supersession), application: runtime?.application, wake: runtime?.wake,
151
+ session: runtime?.pi_session, sessionPath: runtime?.pi_session_path,
152
+ herdrSession: typeof locator === "string" ? undefined : locator?.session,
153
+ pane: typeof locator === "string" ? undefined : locator?.pane,
154
+ };
155
+ const expected = {
156
+ supersession: true, application: "herdr", wake: "native",
157
+ session: request.source.pi_session, sessionPath: request.source.pi_session_path,
158
+ herdrSession: request.target.herdr_session, pane: request.target.pane,
159
+ };
160
+ return JSON.stringify(observed) === JSON.stringify(expected);
161
+ }
162
+
163
+ /** Require the active request and receipt to describe one exact relocation. */
164
+ function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBytes: string, address: string, request: HandoverReplacementRequest) {
165
+ const relocation = receipt.relocation;
166
+ return [
167
+ activeBytes === yaml.print(request),
168
+ yaml.print(receipt.handover_replacement?.request) === activeBytes,
169
+ receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1",
170
+ receipt.seat === address,
171
+ receipt.tasks?.length === 1,
172
+ receipt.tasks?.[0] === request.task,
173
+ receipt.pi_session === request.source.pi_session,
174
+ receipt.pi_session_path === request.source.pi_session_path,
175
+ receipt.herdr_session === request.target.herdr_session,
176
+ receipt.pane === request.target.pane,
177
+ receipt.handover_replacement?.successor_session === request.source.pi_session,
178
+ relocation?.source_herdr_session === request.source.herdr_session,
179
+ relocation?.source_pane === request.source.pane,
180
+ relocation?.authorization?.id === request.authorization.id,
181
+ relocation?.authorization?.hash === request.authorization.hash,
182
+ Boolean(relocation?.backup), Boolean(relocation?.backup_sha256), Boolean(receipt.created_at),
183
+ ].every(Boolean);
184
+ }
185
+
186
+ /** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
187
+ async function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
188
+ const relocation = receipt.relocation!;
189
+ const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
190
+ return digest(backup) === relocation.backup_sha256
191
+ && source.length >= backup.length
192
+ && source.subarray(0, backup.length).equals(backup);
193
+ }
194
+
195
+ /** A live same-identity adoption is fresh only after its receipt and start. */
196
+ function freshAdoptionHeartbeat(runtimeState: Awaited<ReturnType<typeof readRuntimeState>>, receipt: ReplacementLaunchReceipt, address: string, cwd: string) {
197
+ if (!runtimeState) return false;
198
+ return [
199
+ runtimeState.seat === address,
200
+ resolve(runtimeState.cwd) === resolve(cwd),
201
+ Date.parse(runtimeState.started_at) >= Date.parse(receipt.created_at!),
202
+ Date.parse(runtimeState.heartbeat_at) >= Date.parse(runtimeState.started_at),
203
+ ].every(Boolean);
204
+ }
205
+
206
+ /**
207
+ * Classify only the complete immutable evidence emitted by one exact Path-A
208
+ * adoption. The caller supplies the narrow task lifecycle accepted at its
209
+ * boundary; every receipt, archive, authorization, backup, and runtime check
210
+ * remains shared by adopted RESUMED proof and failed-adoption relaunch.
211
+ * Missing or contradictory evidence always returns no classification.
212
+ */
213
+ export async function exactFailedAdoptionProof(root: string, record: Seat, address: string, request: HandoverReplacementRequest, taskEligible: TaskEligibility) {
214
+ const receiptPath = record.runtime?.launch_receipt;
215
+ if (!receiptPath || !exactAdoptionRuntimeMatches(record, request)) return undefined;
216
+ try {
217
+ const activePath = join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
218
+ const [activeBytes, receiptBytes, runtimeState, task] = await Promise.all([
219
+ readFile(activePath, "utf8"), readFile(receiptPath, "utf8"), readRuntimeState(root, address),
220
+ readYaml<Task>(paths(root).taskFile(record.project, request.task)),
221
+ ]);
222
+ const receipt = yaml.parse<ReplacementLaunchReceipt>(receiptBytes);
223
+ const evidence = [
224
+ exactAdoptionReceiptMatches(receipt, activeBytes, address, request),
225
+ await taskEligible(task),
226
+ await exactAuthorizationMatches(root, request),
227
+ await exactArchiveMatches(request),
228
+ await uniqueAdoptionReceipt(root, request.authorization.id, receiptPath),
229
+ await exactBackupMatches(receipt, request),
230
+ ];
231
+ if (!evidence.every(Boolean)) return undefined;
232
+ return { receipt, runtimeState, heartbeat: freshAdoptionHeartbeat(runtimeState, receipt, address, request.target.cwd) };
233
+ } catch { return undefined; }
234
+ }
package/src/handover.ts CHANGED
@@ -10,6 +10,7 @@ import { basename, dirname, join, resolve } from "node:path";
10
10
  import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
11
11
  import { type Task, terminal } from "./tasks";
12
12
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
13
+ import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
13
14
  import { readRuntimeState } from "./runtime-state";
14
15
 
15
16
  export type Handover = {
@@ -211,13 +212,29 @@ export async function mainLaunchEligible(root: string, record: Seat, address: st
211
212
  && await isExactMainSeat(root, record, address);
212
213
  }
213
214
 
215
+ function evidenceEmptyRecoveryTask(task: Task) {
216
+ return task.status === "in_progress" && !task.blocker && !task.handoff
217
+ && task.done_when.every((criterion) => !criterion.proof);
218
+ }
219
+
220
+ async function failedAdoptionTaskEligible(root: string, record: Seat, address: string, task: Task) {
221
+ return (task.status === "todo" || evidenceEmptyRecoveryTask(task))
222
+ && task.coordinator === address && task.assignee === address
223
+ && await isExactMainSeat(root, record, address);
224
+ }
225
+
214
226
  export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
215
227
  if (!await isExactMainSeat(root, record, address)) return undefined;
216
228
  const request = await readHandoverReplacement(root, address).catch(() => {
217
229
  throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
218
230
  });
219
231
  const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
220
- if (!replacementMatchesRuntime(record.runtime, request) || !await mainLaunchEligible(root, record, address, task)) {
232
+ const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
233
+ (task) => failedAdoptionTaskEligible(root, record, address, task));
234
+ const canonicalLaunch = replacementMatchesRuntime(record.runtime, request)
235
+ && await mainLaunchEligible(root, record, address, task);
236
+ const failedAdoptionRecovery = Boolean(failedAdoption && !failedAdoption.runtimeState);
237
+ if (!canonicalLaunch && !failedAdoptionRecovery) {
221
238
  throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
222
239
  }
223
240
  return { request, task };
@@ -286,7 +303,26 @@ function assertSinglePiWriter(raw: string) {
286
303
  if (pi.length !== 1) throw new Error("Legacy source bootstrap requires exactly one live Pi writer on the authorized source pane.");
287
304
  }
288
305
 
289
- async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies) {
306
+ type PiSource = Pick<HandoverReplacementRequest["source"], "pi_session" | "pi_session_path">;
307
+
308
+ function assertCanonicalSourceNotLive(raw: string, canonical: PiSource) {
309
+ const value = JSON.parse(raw), agents = result(value)?.agents;
310
+ if (!Array.isArray(agents)) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
311
+ for (const entry of agents) {
312
+ const agent = object(entry);
313
+ if (!agent) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
314
+ if (text(agent.agent) !== "pi") continue;
315
+ const session = object(agent.agent_session);
316
+ const sessionPath = session?.kind === "path" ? text(session.value) : undefined;
317
+ if (!sessionPath) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
318
+ const sessionId = basename(sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)?.[1];
319
+ if (resolve(sessionPath) === resolve(canonical.pi_session_path) || sessionId === canonical.pi_session) {
320
+ throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
321
+ }
322
+ }
323
+ }
324
+
325
+ async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies, canonical?: PiSource) {
290
326
  const command = dependencies.command ?? runOutput;
291
327
  const pane = legacyPaneObservation(await command(herdr(source.herdr_session, "pane", "get", source.pane)));
292
328
  assertLegacyPane(pane, source, target.cwd);
@@ -296,6 +332,9 @@ async function verifyLegacySource(source: HandoverReplacementRequest["source"],
296
332
  ]);
297
333
  const agent = legacyAgentName(agentRaw, source, target.cwd);
298
334
  assertSinglePiWriter(processRaw);
335
+ if (canonical && (canonical.pi_session !== source.pi_session || resolve(canonical.pi_session_path) !== resolve(source.pi_session_path))) {
336
+ assertCanonicalSourceNotLive(await command(herdr(source.herdr_session, "agent", "list")), canonical);
337
+ }
299
338
  return { authorization_hash: authorizationHash, ...source, cwd: target.cwd, agent, verified_at: dependencies.at?.() ?? now() };
300
339
  }
301
340
 
@@ -421,9 +460,6 @@ function assertCanonicalPlacement(intent: Awaited<ReturnType<typeof prepareRepla
421
460
  || resolve(request.target.cwd) !== resolve(intent.record.worktree)) {
422
461
  throw new Error("Consumed Path-A replacement does not match the unchanged canonical main runtime and placement.");
423
462
  }
424
- if (intent.handover.supersedes !== request.handover.id && !canonicalSourceMatchesRuntime(intent.record.runtime, intent.source)) {
425
- throw new Error("Consumed Path-A replacement does not match the current handover lineage.");
426
- }
427
463
  }
428
464
 
429
465
  function canonicalRuntimeForSupersession(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest) {
@@ -668,7 +704,7 @@ async function supersedeConsumedReplacement(root: string, resolved: string, file
668
704
  seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
669
705
  source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
670
706
  });
671
- const liveSource = await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies);
707
+ const liveSource = await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies, consumed.canonical);
672
708
  const request = supersedingRequest(resolved, file, intent, authorization, consumed, liveSource, dependencies);
673
709
  const manifest = supersessionManifest(resolved, file, consumed);
674
710
  const staging = await stageSupersession(file, request, consumed, manifest, dependencies);
@@ -720,10 +756,12 @@ export async function resumedHandoverReplacement(root: string, address: string)
720
756
  const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
721
757
  const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
722
758
  const runtimeState = await readRuntimeState(root, resolved);
759
+ const failedAdoption = await exactFailedAdoptionProof(root, record, resolved, request,
760
+ (task) => failedAdoptionTaskEligible(root, record, resolved, task));
723
761
  const verified = [
724
762
  request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
725
763
  runtime?.application === "herdr" && runtime.wake === "native",
726
- runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
764
+ runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path) || Boolean(failedAdoption?.heartbeat),
727
765
  typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
728
766
  replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
729
767
  Boolean(runtimeState && runtimeState.seat === resolved),