@afokapu/atdd-flow 0.3.75 → 0.3.77

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.
@@ -217,7 +217,11 @@ export function createInboxTransport({ root, seat, deliver, batchSize = defaultB
217
217
  };
218
218
  const start = async () => {
219
219
  await reconcile();
220
- try { watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); }); }
220
+ try {
221
+ // Watch a path that exists before the first queued mail, so a newly created queue wakes promptly.
222
+ await mkdir(pendingDirectory(root, seat), { recursive: true });
223
+ watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); });
224
+ }
221
225
  catch { /* Periodic reconciliation recovers unavailable watchers. */ }
222
226
  interval = setInterval(() => { void reconcile(); }, intervalMs);
223
227
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.75",
3
+ "version": "0.3.77",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/core.ts CHANGED
@@ -75,6 +75,8 @@ export type Runtime = {
75
75
  pi_session_path?: string;
76
76
  /** Immutable, non-secret receipt for that selected launch. */
77
77
  launch_receipt?: string;
78
+ /** Operator-recorded binding mode for an adopted runtime whose receipt is not launcher-generated. */
79
+ binding_mode?: string;
78
80
  };
79
81
 
80
82
  export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
@@ -10,7 +10,7 @@ import { basename, join, resolve } from "node:path";
10
10
  import { type Seat, paths, readYaml, yaml } from "./core";
11
11
  import type { HandoverReplacementRequest } from "./handover";
12
12
  import { readRuntimeState } from "./runtime-state";
13
- import { archivedReceiptMatches, recoveryEvidenceMatches } from "./stale-main-recovery";
13
+ import { archivedReceiptMatches, backupIsTranscriptPrefix, recoveryEvidenceMatches } from "./stale-main-recovery";
14
14
  import type { Task } from "./tasks";
15
15
 
16
16
  type ReplacementLaunchReceipt = {
@@ -188,12 +188,8 @@ function exactAdoptionReceiptMatches(receipt: ReplacementLaunchReceipt, activeBy
188
188
  }
189
189
 
190
190
  /** Prove the immutable recovery copy is the prefix of the adopted source JSONL. */
191
- async function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
192
- const relocation = receipt.relocation!;
193
- const [backup, source] = await Promise.all([readFile(relocation.backup!), readFile(request.source.pi_session_path)]);
194
- return digest(backup) === relocation.backup_sha256
195
- && source.length >= backup.length
196
- && source.subarray(0, backup.length).equals(backup);
191
+ function exactBackupMatches(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest) {
192
+ return backupIsTranscriptPrefix({ path: receipt.relocation!.backup, sha256: receipt.relocation!.backup_sha256 }, request.source.pi_session_path);
197
193
  }
198
194
 
199
195
  /** A live same-identity adoption is fresh only after its receipt and start. */
package/src/handover.ts CHANGED
@@ -14,7 +14,7 @@ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizati
14
14
  import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
15
15
  import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
16
16
  import { readRuntimeState } from "./runtime-state";
17
- import { type StaleMainRecovery, buildStaleMainRecovery, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
17
+ import { type StaleMainRecovery, buildStaleMainRecovery, listedPiAgents, relocationFor, replaceActiveRequest, staleRecoveryMatches } from "./stale-main-recovery";
18
18
 
19
19
  export type Handover = {
20
20
  schema: "atdd-workflow/handover/v1";
@@ -314,20 +314,9 @@ function assertSinglePiWriter(raw: string) {
314
314
  type PiSource = Pick<HandoverReplacementRequest["source"], "pi_session" | "pi_session_path">;
315
315
 
316
316
  function assertCanonicalSourceNotLive(raw: string, canonical: PiSource) {
317
- const value = JSON.parse(raw), agents = result(value)?.agents;
318
- if (!Array.isArray(agents)) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
319
- for (const entry of agents) {
320
- const agent = object(entry);
321
- if (!agent) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
322
- if (text(agent.agent) !== "pi") continue;
323
- const session = object(agent.agent_session);
324
- const sessionPath = session?.kind === "path" ? text(session.value) : undefined;
325
- if (!sessionPath) throw new Error("Consumed Path-A replacement requires complete global Herdr agent-list evidence.");
326
- 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];
327
- if (resolve(sessionPath) === resolve(canonical.pi_session_path) || sessionId === canonical.pi_session) {
328
- throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
329
- }
330
- }
317
+ const live = listedPiAgents(raw, "Consumed Path-A replacement").some(({ path }) => path === resolve(canonical.pi_session_path)
318
+ || basename(path).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)?.[1] === canonical.pi_session);
319
+ if (live) throw new Error("Consumed Path-A replacement prior canonical source is still a live Pi agent.");
331
320
  }
332
321
 
333
322
  async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies, canonical?: PiSource) {
@@ -387,7 +376,7 @@ function replacementTarget(args: string[], record: Seat, source: HandoverReplace
387
376
  const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
388
377
  if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
389
378
  const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
390
- if (target.herdr_session !== source.herdr_session || target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
379
+ if (target.herdr_session !== source.herdr_session) throw new Error("Path-A replacement target must be the exact released source pane.");
391
380
  return target;
392
381
  }
393
382
 
@@ -402,9 +391,12 @@ async function prepareReplacement(root: string, resolved: string, args: string[]
402
391
  const handover = await currentReplacementHandover(root, resolved, args);
403
392
  const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
404
393
  const { record } = await exactMainTakeoverTask(root, resolved, task);
405
- const source = replacementSource(args), target = replacementTarget(args, record, source);
394
+ const source = replacementSource(args), file = handoverReplacementPath(root, resolved);
395
+ const target = replacementTarget(args, record, source);
396
+ const relocation = recover ? await relocationFor(record, source, target, file) : undefined;
397
+ if (!relocation && target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
406
398
  if (!supersede && !recover) assertReplacementSource(record, source, legacy);
407
- return { legacy, supersede, recover, handover, task, record, source, target };
399
+ return { legacy, supersede, recover, handover, task, record, source, target, relocation };
408
400
  }
409
401
 
410
402
  function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
@@ -745,7 +737,9 @@ export async function requestHandoverReplacement(root: string, address: string,
745
737
  source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
746
738
  });
747
739
  const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
748
- const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical, once) => verifyLegacySource(intent.source, intent.target, authorization.hash, once(dependencies), canonical)) : undefined;
740
+ const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) },
741
+ (canonical, once) => verifyLegacySource(intent.relocation ? { ...intent.source, ...intent.target } : intent.source, intent.target, authorization.hash, once(dependencies), canonical),
742
+ () => (dependencies.command ?? runOutput)(herdr(intent.target.herdr_session, "agent", "list"))) : undefined;
749
743
  const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
750
744
  await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
751
745
  console.log(`Recorded Path-A replacement request for ${resolved}.`);
@@ -4,12 +4,13 @@
4
4
  // - test:atdd-flow:stale-main-recovery-resumed
5
5
  // - test:atdd-flow:stale-main-source-exited-path-a
6
6
  // - test:atdd-flow:stale-main-s1-exited-recovery
7
+ // - test:atdd-flow:relocated-receipt-stale-main-recovery
7
8
  // Runtime: bun
8
9
  // Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
9
10
 
10
11
  import { createHash } from "node:crypto";
11
12
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
- import { basename, dirname, join } from "node:path";
13
+ import { basename, dirname, join, resolve } from "node:path";
13
14
  import { type Runtime, type Seat, runOutput, yaml } from "./core";
14
15
  import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
15
16
 
@@ -24,15 +25,28 @@ export type StaleMainRecovery = {
24
25
  /** A superseded active request, preserved by hash; `live_source_exited` proves an active recovery request's own live source Pi was absent from every live Pi agent when a newer source replaced it. */
25
26
  prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
26
27
  live_source: LiveSourceProof;
28
+ /** Present only for a relocated main: the recorded receipt is an operator manual rebinding receipt for a retired placement. */
29
+ relocation?: Relocation;
27
30
  };
28
31
 
32
+ export const MANUAL_REBINDING_SCHEMA = "atdd-flow/manual-pi-runtime-rebinding-receipt/v1";
33
+ export const MANUAL_REBINDING_MODE = "operator-authorized-manual-in-place-adoption";
34
+
35
+ /** The retired placement named by the manual rebinding receipt; lineage only, never a live source. */
36
+ export type Relocation = { mode: string; retired: { herdr_session: string; pane: string } };
37
+
29
38
  /** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
30
39
  export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
31
40
 
32
41
  type Run = (argv: string[]) => Promise<string>;
33
42
  type Source = HandoverReplacementRequest["source"];
34
43
  type Target = HandoverReplacementRequest["target"];
35
- type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
44
+ type PriorReceipt = {
45
+ schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string;
46
+ mode?: string; authorized_by?: string; authorization?: string;
47
+ target?: { cwd?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
48
+ backup?: { path?: string; bytes?: number; sha256?: string };
49
+ };
36
50
 
37
51
  const sha256 = (content: string | Uint8Array) => createHash("sha256").update(content).digest("hex");
38
52
 
@@ -59,12 +73,22 @@ export function staleCanonicalSource(record: Seat, source: Source, target: Targe
59
73
  }
60
74
 
61
75
  /** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
62
- export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
76
+ export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
77
+ return relocation ? manualReceiptProvesCanonical(receipt, seat, canonical, relocation) : launchReceiptProvesCanonical(receipt, seat, canonical);
78
+ }
79
+
80
+ function launchReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
63
81
  return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
64
82
  && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
65
83
  && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
66
84
  }
67
85
 
86
+ function manualReceiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
87
+ return receipt.schema === MANUAL_REBINDING_SCHEMA && receipt.seat === seat && receipt.mode === relocation.mode
88
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
89
+ && receipt.herdr_session === relocation.retired.herdr_session && receipt.pane === relocation.retired.pane;
90
+ }
91
+
68
92
  const archivePath = (root: string, folder: string, seat: string, hash: string) => join(root, ".atdd-flow", folder, encodeURIComponent(seat), `${hash}.yaml`);
69
93
 
70
94
  async function publishOrVerify(file: string, bytes: string) {
@@ -75,11 +99,11 @@ async function publishOrVerify(file: string, bytes: string) {
75
99
  }
76
100
 
77
101
  /** Preserve the stale receipt by content hash, create-only; an existing copy must be identical. */
78
- export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"]): Promise<StaleMainRecovery["prior_receipt"]> {
102
+ export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation): Promise<StaleMainRecovery["prior_receipt"]> {
79
103
  let bytes: string;
80
104
  try { bytes = await readFile(canonical.launch_receipt, "utf8"); }
81
105
  catch { throw new Error("Stale main recovery requires its immutable recorded launch receipt."); }
82
- if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical)) {
106
+ if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical, relocation)) {
83
107
  throw new Error("Stale main recovery recorded launch receipt does not prove the recorded canonical runtime.");
84
108
  }
85
109
  return { path: canonical.launch_receipt, ...await archiveBytes(root, "stale-main-receipt-archive", seat, bytes) };
@@ -153,7 +177,7 @@ export async function archivedReceiptMatches(request: HandoverReplacementRequest
153
177
  try {
154
178
  const bytes = await readFile(recovery.prior_receipt.archive, "utf8");
155
179
  const priorRequest = !recovery.prior_request || await archiveIntact(recovery.prior_request);
156
- return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source);
180
+ return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source, recovery.relocation);
157
181
  } catch { return false; }
158
182
  }
159
183
 
@@ -169,15 +193,26 @@ function sessionMatches(runtime: Runtime | undefined, recovery: StaleMainRecover
169
193
  && runtime.launch_receipt === canonical.launch_receipt && recovery.prior_receipt.path === runtime.launch_receipt;
170
194
  }
171
195
 
172
- function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"]) {
196
+ function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation?: Relocation) {
197
+ if (relocation) return relocatedTargetMatches(request, canonical, relocation);
173
198
  return canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session
174
199
  && canonical.pane === request.target.pane && canonical.pi_session !== request.source.pi_session;
175
200
  }
176
201
 
177
- function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof) {
202
+ /** A relocated request names the exact recorded Pi at the retired pane, and moves to the current pane that Herdr observed it on. */
203
+ function relocatedTargetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"], relocation: Relocation) {
204
+ return relocation.mode === MANUAL_REBINDING_MODE
205
+ && canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session && canonical.pane === request.target.pane
206
+ && request.source.pi_session === canonical.pi_session && request.source.pi_session_path === canonical.pi_session_path
207
+ && request.source.herdr_session === relocation.retired.herdr_session && request.source.pane === relocation.retired.pane
208
+ && (relocation.retired.herdr_session !== canonical.herdr_session || relocation.retired.pane !== canonical.pane);
209
+ }
210
+
211
+ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof, relocation?: Relocation) {
212
+ const placement = relocation ? request.target : request.source;
178
213
  return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
179
- && live.pi_session_path === request.source.pi_session_path && live.herdr_session === request.source.herdr_session
180
- && live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
214
+ && live.pi_session_path === request.source.pi_session_path && live.herdr_session === placement.herdr_session
215
+ && live.pane === placement.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
181
216
  }
182
217
 
183
218
  function exitMatches(recovery: StaleMainRecovery) {
@@ -192,7 +227,7 @@ function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
192
227
 
193
228
  /** Request, canonical source and live-source snapshot must describe one exact target tuple. */
194
229
  export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
195
- return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
230
+ return targetMatches(request, recovery.canonical_source, recovery.relocation) && snapshotMatches(request, recovery.live_source, recovery.relocation) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
196
231
  }
197
232
 
198
233
  /** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
@@ -201,9 +236,137 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
201
236
  }
202
237
 
203
238
  /** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
204
- export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
239
+ export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target; relocation?: Relocation }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>, agentList: () => Promise<string>): Promise<StaleMainRecovery> {
240
+ if (intent.relocation) return buildRelocatedStaleMainRecovery(root, seat, intent, intent.relocation, active, { verify: () => verify(intent.source, agentListOnce()), agentList });
205
241
  const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
206
242
  const once = agentListOnce(), live_source = await verify(canonical, once);
207
243
  const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async (source) => { await verify(source, once); } });
208
244
  return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
209
245
  }
246
+
247
+
248
+ const refuse = (reason: string) => new Error(`Stale main relocation receipt recovery refused: ${reason}.`);
249
+
250
+ /** True when the seat records a manual binding_mode or its launch receipt is a manual rebinding receipt, rather than a launcher receipt. */
251
+ export async function isManualRebindingReceipt(record: Seat) {
252
+ const path = record.runtime?.launch_receipt;
253
+ if (!path) return false;
254
+ if (record.runtime?.binding_mode !== undefined) return true;
255
+ try { return yaml.parse<PriorReceipt>(await readFile(path, "utf8")).schema === MANUAL_REBINDING_SCHEMA; } catch { return false; }
256
+ }
257
+
258
+ /** A backup file must hash to its recorded digest (and size, when recorded) and be the exact byte prefix of the adopted session transcript. */
259
+ export async function backupIsTranscriptPrefix(backup: { path?: string; sha256?: string; bytes?: number }, transcriptPath: string) {
260
+ try {
261
+ const [saved, transcript] = await Promise.all([readFile(backup.path!), readFile(transcriptPath)]);
262
+ return sha256(saved) === backup.sha256 && (backup.bytes === undefined || backup.bytes === saved.length)
263
+ && transcript.length >= saved.length && transcript.subarray(0, saved.length).equals(saved);
264
+ } catch { return false; }
265
+ }
266
+
267
+ /** An existing stale active request must retain exactly the retired placement of the recorded Pi. */
268
+ async function priorRequestIsRetiredLineage(file: string, receipt: PriorReceipt) {
269
+ let bytes: string;
270
+ try { bytes = await readFile(file, "utf8"); } catch { return true; }
271
+ try {
272
+ const { source, target } = yaml.parse<HandoverReplacementRequest>(bytes);
273
+ return source.pi_session === receipt.pi_session && source.pi_session_path === receipt.pi_session_path
274
+ && [source, target].every((placement) => placement.herdr_session === receipt.herdr_session && placement.pane === receipt.pane);
275
+ } catch { return false; }
276
+ }
277
+
278
+ type Check = [boolean, string];
279
+ const firstFailure = (checks: Check[]) => checks.find(([ok]) => !ok)?.[1];
280
+
281
+ /** The recorded receipt is a complete operator-authorized manual rebinding receipt whose mode equals the seat binding_mode. */
282
+ function authorityFailure(receipt: PriorReceipt, record: Seat) {
283
+ return firstFailure([
284
+ [receipt.schema === MANUAL_REBINDING_SCHEMA, "the recorded receipt is not a manual rebinding receipt"],
285
+ [receipt.seat === record.address, "the receipt names another seat"],
286
+ [receipt.mode === MANUAL_REBINDING_MODE && record.runtime?.binding_mode === receipt.mode, "the receipt mode must equal the seat binding_mode"],
287
+ [receipt.authorized_by === "operator@desk" && Boolean(receipt.authorization), "the receipt lacks operator@desk authorization"],
288
+ ]);
289
+ }
290
+
291
+ /** The receipt describes the exact recorded Pi, the canonical worktree and one retired placement. */
292
+ function identityFailure(receipt: PriorReceipt, record: Seat, session: string, sessionPath: string) {
293
+ const placement = receipt.target;
294
+ return firstFailure([
295
+ [receipt.pi_session === session && receipt.pi_session_path === sessionPath, "the receipt does not name the exact recorded Pi session"],
296
+ [placement?.pi_session === session && placement.pi_session_path === sessionPath, "the receipt target does not name the exact recorded Pi session"],
297
+ [Boolean(placement?.cwd) && resolve(placement!.cwd!) === resolve(record.worktree), "the receipt cwd is not the canonical main worktree"],
298
+ [Boolean(receipt.pane) && placement?.herdr_session === receipt.herdr_session && placement.pane === receipt.pane, "the receipt placement is inconsistent"],
299
+ ]);
300
+ }
301
+
302
+ /** The request moves the exact recorded Pi from the retired placement to the seat's current locator. */
303
+ function requestFailure(receipt: PriorReceipt, source: Source, target: Target, recorded: { session: string; sessionPath: string; locator: { session: string; pane: string } }) {
304
+ return firstFailure([
305
+ [source.pi_session === recorded.session && source.pi_session_path === recorded.sessionPath, "the request source is not the exact recorded Pi"],
306
+ [source.herdr_session === receipt.herdr_session && source.pane === receipt.pane, "the request source must be the receipt's retired placement"],
307
+ [target.herdr_session === recorded.locator.session && target.pane === recorded.locator.pane, "the request target must be the seat's current locator"],
308
+ ]);
309
+ }
310
+
311
+ /**
312
+ * Validate a relocated main before any authorization or Herdr evidence is consulted: the recorded receipt must be a
313
+ * complete operator manual rebinding receipt whose mode equals the seat binding_mode, for the exact recorded Pi, whose
314
+ * backup is a prefix of the adopted transcript, and whose retired placement is the request source and the stale request's
315
+ * placement while the request target is the seat's current locator. Returns the lineage to record.
316
+ */
317
+ export async function assertRelocatedBinding(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation> {
318
+ const { locator, session, sessionPath, receipt: path } = completeRuntime(record.runtime);
319
+ let receipt: PriorReceipt;
320
+ try { receipt = yaml.parse<PriorReceipt>(await readFile(path, "utf8")); } catch { throw refuse("the recorded relocation receipt is unreadable"); }
321
+ const failure = authorityFailure(receipt, record) ?? identityFailure(receipt, record, session, sessionPath)
322
+ ?? requestFailure(receipt, source, target, { session, sessionPath, locator })
323
+ ?? firstFailure([
324
+ [await backupIsTranscriptPrefix(receipt.backup ?? {}, receipt.pi_session_path!), "the receipt backup is missing, mismatched, or not a prefix of the adopted transcript"],
325
+ [await priorRequestIsRetiredLineage(activeFile, receipt), "the stale active request does not retain the receipt's retired placement"],
326
+ ]);
327
+ if (failure) throw refuse(failure);
328
+ return { mode: receipt.mode!, retired: { herdr_session: receipt.herdr_session!, pane: receipt.pane! } };
329
+ }
330
+
331
+ /** Build the relocated recovery record: the live source (injected) is the exact recorded Pi at the current locator. */
332
+ export async function buildRelocatedStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, relocation: Relocation, active: { file: string }, observe: { verify: () => Promise<LiveSourceProof>; agentList: () => Promise<string> }): Promise<StaleMainRecovery> {
333
+ const { locator, session, sessionPath, receipt } = completeRuntime(intent.record.runtime);
334
+ const canonical = { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: intent.target.cwd, launch_receipt: receipt };
335
+ let live_source: LiveSourceProof;
336
+ try {
337
+ live_source = await observe.verify();
338
+ assertRelocatedAgentList(await observe.agentList(), intent.source, intent.target, relocation.retired);
339
+ } catch (error) { throw refuse((error as Error).message.replace(/\.$/, "")); }
340
+ const prior_request = await archivePriorRequest(root, seat, active.file, () => false, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async () => {} });
341
+ const prior_receipt = await archiveStaleReceipt(root, seat, canonical, relocation);
342
+ return { canonical_source: canonical, prior_receipt, ...(prior_request ? { prior_request } : {}), live_source, relocation };
343
+ }
344
+
345
+ /** Pi agents from one complete global Herdr agent list, each with its pane and exact session path; `who` names the refusing operation. */
346
+ export function listedPiAgents(raw: string, who: string) {
347
+ const incomplete = () => new Error(`${who} requires complete global Herdr agent-list evidence.`);
348
+ const parsed = JSON.parse(raw) as { result?: { agents?: unknown }; agents?: unknown };
349
+ const agents = (parsed.result ?? parsed).agents;
350
+ if (!Array.isArray(agents)) throw incomplete();
351
+ return agents.filter((entry) => {
352
+ if (!entry || typeof entry !== "object") throw incomplete();
353
+ return (entry as { agent?: unknown }).agent === "pi";
354
+ }).map((entry) => {
355
+ const { pane_id, agent_session } = entry as { pane_id?: string; agent_session?: { kind?: string; value?: string } };
356
+ if (agent_session?.kind !== "path" || !agent_session.value) throw incomplete();
357
+ return { pane: pane_id, path: resolve(agent_session.value) };
358
+ });
359
+ }
360
+
361
+ /** A relocated recovery requires the exact recorded Pi once, at the current pane, and no Pi at the retired pane. */
362
+ export function assertRelocatedAgentList(raw: string, source: Pick<Source, "pi_session_path">, current: { pane: string }, retired: { pane: string }) {
363
+ const agents = listedPiAgents(raw, "Relocated recovery"), recorded = agents.filter((agent) => agent.path === resolve(source.pi_session_path));
364
+ if (agents.some((agent) => agent.pane === retired.pane)) throw new Error("A Pi is live at the retired relocation pane.");
365
+ if (recorded.some((agent) => agent.pane !== current.pane)) throw new Error("The recorded Pi is also live outside the current pane.");
366
+ if (recorded.length !== 1) throw new Error("The recorded Pi must be listed exactly once at the current pane.");
367
+ }
368
+
369
+ /** A target pane that differs from the source pane is a relocation only when the seat records a manual binding; otherwise undefined. */
370
+ export async function relocationFor(record: Seat, source: Source, target: Target, activeFile: string): Promise<Relocation | undefined> {
371
+ return target.pane !== source.pane && await isManualRebindingReceipt(record) ? assertRelocatedBinding(record, source, target, activeFile) : undefined;
372
+ }