@afokapu/atdd-flow 0.3.76 → 0.3.78

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.76",
3
+ "version": "0.3.78",
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
@@ -67,6 +67,7 @@ Usage:
67
67
  atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
68
68
  atdd-flow task block <project> <task-id> --by <address> --reason <text>
69
69
  atdd-flow task supersede|reject <project> <task-id> --by <coordinator-address> --reason <text>
70
+ atdd-flow task supersede <project> <task-id> --by main@<project> --reason <text> --authorization <operator-message-id>
70
71
  atdd-flow task defer <project> <task-id> --by <coordinator-address> --reason <text> --owner <address> --trigger <text> --review-at <ISO-8601>
71
72
  atdd-flow task unblock <project> <task-id> --by <coordinator-address>
72
73
  atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
@@ -83,7 +84,7 @@ Usage:
83
84
  atdd-flow thread add <thread-id> <address>
84
85
  atdd-flow thread open <thread-id>
85
86
  atdd-flow message read <message-id>
86
- atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>]
87
+ atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>] [--task-supersede-authorization <json>]
87
88
  atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
88
89
  atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
89
90
  atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
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}.`);
@@ -99,7 +99,7 @@ async function isExactNamedCoordinator(root: string, record: Seat, address: stri
99
99
  return Boolean(worktree && await hasUniqueCoordinatorPlacement(root, record, worktree));
100
100
  }
101
101
 
102
- async function launchEligibleTasks(root: string, record: Seat, address: string) {
102
+ export async function launchEligibleTasks(root: string, record: Seat, address: string) {
103
103
  const coordinator = await isExactNamedCoordinator(root, record, address);
104
104
  const operator = await isExactOperatorSeat(root, record, address);
105
105
  const tasks = await seatTasks(root, record.project, record.address);
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // URN: component:flow:e022:task-supersede-authorization:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:coordinator-stale-task-supersede-authorization
4
+ // Runtime: bun
5
+ // Purpose: Owner-authorized supersession of a stale coordinator task.
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { atomicYaml, paths, readYaml, seat, yaml } from "./core";
11
+
12
+ /** The one exact tuple an operator@desk message may carry to authorize `task supersede` by main. */
13
+ export type SupersedeAuthorization = {
14
+ schema: "atdd-workflow/task-supersede-authorization/v1";
15
+ project: string;
16
+ task: string;
17
+ coordinator: string;
18
+ action: "supersede";
19
+ reason: string;
20
+ task_sha256: string;
21
+ };
22
+
23
+ const sha256 = (content: string) => createHash("sha256").update(content).digest("hex");
24
+
25
+ /** Locate the one operator@desk message `reference`, addressed to main in a thread main joined, and return its typed payload. */
26
+ export async function operatorAuthorizationPayload(root: string, reference: string, main: string): Promise<unknown> {
27
+ type Found = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: unknown };
28
+ const folders = await readdir(paths(root).threads, { withFileTypes: true });
29
+ const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
30
+ .map(async (entry) => {
31
+ try {
32
+ const [thread, message] = await Promise.all([
33
+ readYaml<{ schema?: string; participants?: string[] }>(paths(root).threadFile(entry.name)),
34
+ readYaml<Found>(paths(root).message(entry.name, reference)),
35
+ ]);
36
+ return { thread, message };
37
+ } catch { return undefined; }
38
+ }))).filter((entry): entry is NonNullable<typeof entry> =>
39
+ entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
40
+ && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
41
+ if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
42
+ throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
43
+ }
44
+ const message = matches[0].message;
45
+ if (message.to !== "all" && !message.to?.includes(main)) throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
46
+ return message.authorization;
47
+ }
48
+
49
+ /** Create-only evidence: an existing file must hold exactly the same bytes. */
50
+ async function publishOrVerify(file: string, bytes: string) {
51
+ try { await writeFile(file, bytes, { encoding: "utf8", flag: "wx" }); }
52
+ catch (error) { if ((error as { code?: string }).code !== "EEXIST") throw error; }
53
+ if (await readFile(file, "utf8") !== bytes) throw new Error("Task supersede archive collides with different immutable evidence.");
54
+ }
55
+
56
+ /** Archive the pre-mutation task bytes and the authorization by task hash, create-only. */
57
+ async function archiveEvidence(root: string, projectName: string, id: string, hash: string, taskBytes: string, evidence: Record<string, unknown>) {
58
+ const folder = join(root, ".atdd-flow", "task-supersede-archive", projectName, id, hash);
59
+ await mkdir(folder, { recursive: true });
60
+ await publishOrVerify(join(folder, "task.yaml"), taskBytes);
61
+ await publishOrVerify(join(folder, "authorization.yaml"), yaml.print(evidence));
62
+ }
63
+
64
+ /** Exact tuple equality: the same keys with the same values, and nothing more. */
65
+ function sameTuple(actual: unknown, expected: SupersedeAuthorization) {
66
+ const record = actual && typeof actual === "object" ? actual as Record<string, unknown> : undefined;
67
+ const keys = Object.keys(expected) as (keyof SupersedeAuthorization)[];
68
+ return Boolean(record) && Object.keys(record!).length === keys.length && keys.every((key) => record![key] === expected[key]);
69
+ }
70
+
71
+ type Subject = { project: string; id: string; coordinator: string; reason: string; main: string; reference: string };
72
+
73
+ async function assertStaleCoordinatorTask(root: string, subject: Subject) {
74
+ const owner = await seat(root, subject.coordinator).catch(() => undefined);
75
+ if (owner?.role !== "coordinator" || owner.project !== subject.project) {
76
+ throw new Error(`Task ${subject.id} is not owned by a named coordinator of ${subject.project}; the owner authorization applies only to coordinator tasks.`);
77
+ }
78
+ const { launchEligibleTasks } = await import("./pi-runtime-launch");
79
+ if ((await launchEligibleTasks(root, owner, subject.coordinator)).length) {
80
+ throw new Error(`${subject.coordinator} has a launch-eligible task and can supersede task ${subject.id} itself.`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate every exact owner-authorization and staleness condition, then archive the evidence. Nothing is written until
86
+ * all checks pass; the caller records the terminal disposition afterwards. Returns the authorization reference.
87
+ */
88
+ export async function authorizeSupersede(root: string, subject: Subject): Promise<string> {
89
+ const taskBytes = await readFile(paths(root).taskFile(subject.project, subject.id), "utf8");
90
+ const expected: SupersedeAuthorization = {
91
+ schema: "atdd-workflow/task-supersede-authorization/v1", project: subject.project, task: subject.id,
92
+ coordinator: subject.coordinator, action: "supersede", reason: subject.reason, task_sha256: sha256(taskBytes),
93
+ };
94
+ const authorization = await operatorAuthorizationPayload(root, subject.reference, subject.main);
95
+ if (!sameTuple(authorization, expected)) throw new Error(`Authorization ${subject.reference} is not an exact supersede authorization.`);
96
+ await assertStaleCoordinatorTask(root, subject);
97
+ await archiveEvidence(root, subject.project, subject.id, expected.task_sha256, taskBytes, { id: subject.reference, from: "operator@desk", authorization: expected });
98
+ return subject.reference;
99
+ }
100
+
101
+ /** Parse `post --task-supersede-authorization`: operator@desk only, one exact task-supersede-authorization/v1 tuple. */
102
+ export function parseSupersedeAuthorization(encoded: string | undefined, from: string): SupersedeAuthorization | undefined {
103
+ if (!encoded) return undefined;
104
+ if (from !== "operator@desk") throw new Error("Only operator@desk may create a task supersede authorization.");
105
+ let value: Partial<SupersedeAuthorization> | undefined;
106
+ try { value = JSON.parse(encoded); } catch { throw new Error("--task-supersede-authorization must be valid JSON."); }
107
+ const fields = [value?.project, value?.task, value?.coordinator, value?.reason, value?.task_sha256];
108
+ if (value?.schema !== "atdd-workflow/task-supersede-authorization/v1" || value.action !== "supersede"
109
+ || !fields.every((entry) => typeof entry === "string" && entry.length) || !/^[a-f0-9]{64}$/i.test(value.task_sha256!)) {
110
+ throw new Error("--task-supersede-authorization must contain one exact task-supersede-authorization/v1 tuple.");
111
+ }
112
+ return value as SupersedeAuthorization;
113
+ }
package/src/tasks.ts CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
+ import { operatorAuthorizationPayload } from "./task-supersede-authorization";
6
7
  import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
7
8
 
8
9
  export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "superseded" | "rejected";
@@ -27,7 +28,7 @@ export type CoordinatorTransfer = {
27
28
  lineage_ref?: LineageRef;
28
29
  effective_at: string;
29
30
  };
30
- export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
31
+ export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string; authorization?: string };
31
32
  export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
32
33
  export type DispatchAlert = { key: string; message: string; observed_at: string };
33
34
  /** Persisted evidence for an immediate assignment; each field is written only by its own typed command. */
@@ -463,28 +464,7 @@ type TransferAuthorization = {
463
464
  };
464
465
 
465
466
  async function operatorAuthorization(root: string, reference: string, main: string, expected: TransferAuthorization) {
466
- type AuthorizationMessage = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: TransferAuthorization };
467
- type AuthorizationThread = { schema?: string; participants?: string[] };
468
- const folders = await readdir(paths(root).threads, { withFileTypes: true });
469
- const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
470
- .map(async (entry) => {
471
- try {
472
- const [thread, message] = await Promise.all([
473
- readYaml<AuthorizationThread>(paths(root).threadFile(entry.name)),
474
- readYaml<AuthorizationMessage>(paths(root).message(entry.name, reference)),
475
- ]);
476
- return { thread, message };
477
- } catch { return undefined; }
478
- }))).filter((entry): entry is NonNullable<typeof entry> =>
479
- entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
480
- && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
481
- if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
482
- throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
483
- }
484
- if (matches[0].message.to !== "all" && !matches[0].message.to?.includes(main)) {
485
- throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
486
- }
487
- const authorization = matches[0].message.authorization;
467
+ const authorization = await operatorAuthorizationPayload(root, reference, main) as TransferAuthorization | undefined;
488
468
  if (!authorization || authorization.schema !== expected.schema
489
469
  || authorization.project !== expected.project || authorization.task !== expected.task
490
470
  || authorization.from !== expected.from || authorization.to !== expected.to
@@ -721,10 +701,14 @@ export async function block(root: string, projectName: string, id: string, args:
721
701
  export async function disposition(root: string, projectName: string, id: string, kind: TerminalDisposition["kind"], args: string[]) {
722
702
  const task = await readTask(root, projectName, taskId(id));
723
703
  const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
724
- if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
704
+ const owner = kind === "superseded" && actor === `main@${projectName}` && actor !== task.coordinator && Boolean(words(args, "--authorization"));
705
+ if (actor !== task.coordinator && !owner) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
725
706
  if (task.disposition || task.status === "done") throw new Error(`Task ${id} already has terminal history and cannot be ${kind}.`);
707
+ const reason = required(words(args, "--reason"), "--reason");
708
+ const reference = owner ? words(args, "--authorization") : undefined;
709
+ const authorization = reference ? await (await import("./task-supersede-authorization")).authorizeSupersede(root, { project: projectName, id, coordinator: task.coordinator, reason, main: actor, reference }) : undefined;
726
710
  task.status = kind;
727
- task.disposition = { kind, by: actor, at: now(), reason: required(words(args, "--reason"), "--reason") };
711
+ task.disposition = { kind, by: actor, at: now(), reason, ...(authorization ? { authorization } : {}) };
728
712
  delete task.blocker;
729
713
  await writeTask(root, projectName, taskId(id), task);
730
714
  console.log(`${id} ${kind}`);
package/src/threads.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { notify } from "./adapters";
4
4
  import { seatTasks, terminal } from "./tasks";
5
+ import { type SupersedeAuthorization, parseSupersedeAuthorization } from "./task-supersede-authorization";
5
6
  import {
6
7
  atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
7
8
  required, runtimeAddress, seat, words,
@@ -29,7 +30,7 @@ export type Message = {
29
30
  created_at: string;
30
31
  body: string;
31
32
  /** Immutable owner authorization for one exact coordinator-transfer tuple. */
32
- authorization?: TransferAuthorization;
33
+ authorization?: TransferAuthorization | SupersedeAuthorization;
33
34
  };
34
35
 
35
36
  async function thread(root: string, threadId: string) {
@@ -280,6 +281,11 @@ function transferAuthorization(args: string[], from: string) {
280
281
  return value as TransferAuthorization;
281
282
  }
282
283
 
284
+ /** The one typed owner authorization a post may carry: a transfer tuple or a supersede tuple. */
285
+ function typedAuthorization(args: string[], from: string) {
286
+ return transferAuthorization(args, from) ?? parseSupersedeAuthorization(words(args, "--task-supersede-authorization"), from);
287
+ }
288
+
283
289
  async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
284
290
  const record = await thread(root, threadId);
285
291
  const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
@@ -289,7 +295,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
289
295
  if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
290
296
  await assertRoute(root, from, recipients.filter((address) => address !== from));
291
297
  const kind = overrides.kind ?? "message";
292
- const authorization = overrides.authorization ?? transferAuthorization(args, from);
298
+ const authorization = overrides.authorization ?? typedAuthorization(args, from);
293
299
  // Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
294
300
  const label = words(args, "--label") ?? kind;
295
301
  const message: Message = {