@afokapu/atdd-flow 0.3.21 → 0.3.23

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.21",
3
+ "version": "0.3.23",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { now, required, words, yaml } from "./core";
5
+
6
+ export const authorizationSchema = "atdd-flow/exact-session-adoption-authorization/v1" as const;
7
+ export type ExactSessionAdoptionAuthorization = {
8
+ schema: typeof authorizationSchema;
9
+ id: string;
10
+ issued_by: "operator@desk";
11
+ created_at: string;
12
+ seat: string;
13
+ pi_session: string;
14
+ pi_session_path: string;
15
+ source: { herdr_session: string; pane: string };
16
+ target: { herdr_session: string; pane: string; cwd: string };
17
+ };
18
+
19
+ const validId = (value: string) => /^A-[A-Za-z0-9_-]+$/.test(value);
20
+ const nonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
21
+ const path = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.yaml`);
22
+ const hashPath = (root: string, id: string) => join(root, ".atdd-flow", "exact-session-adoption-authorizations", `${id}.sha256`);
23
+ const digest = (content: string) => createHash("sha256").update(content).digest("hex");
24
+
25
+ function tuple(record: ExactSessionAdoptionAuthorization) {
26
+ return record.schema === authorizationSchema && validId(record.id) && record.issued_by === "operator@desk" && nonEmpty(record.created_at)
27
+ && nonEmpty(record.seat) && nonEmpty(record.pi_session) && nonEmpty(record.pi_session_path)
28
+ && nonEmpty(record.source?.herdr_session) && nonEmpty(record.source?.pane)
29
+ && nonEmpty(record.target?.herdr_session) && nonEmpty(record.target?.pane) && nonEmpty(record.target?.cwd);
30
+ }
31
+
32
+ export async function createExactSessionAdoptionAuthorization(root: string, id: string, args: string[]) {
33
+ if (!validId(id)) throw new Error("Exact-session adoption authorization id must use A- followed by letters, numbers, underscores, or hyphens.");
34
+ if (words(args, "--by") !== "operator@desk") throw new Error("Only operator@desk may issue an exact-session adoption authorization.");
35
+ const record: ExactSessionAdoptionAuthorization = {
36
+ schema: authorizationSchema, id, issued_by: "operator@desk", created_at: now(),
37
+ seat: required(words(args, "--seat"), "--seat"), pi_session: required(words(args, "--pi-session"), "--pi-session"),
38
+ pi_session_path: resolve(required(words(args, "--pi-session-path"), "--pi-session-path")),
39
+ source: { herdr_session: required(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: required(words(args, "--source-pane"), "--source-pane") },
40
+ target: { herdr_session: required(words(args, "--target-herdr-session"), "--target-herdr-session"), pane: required(words(args, "--target-pane"), "--target-pane"), cwd: resolve(required(words(args, "--target-cwd"), "--target-cwd")) },
41
+ };
42
+ const file = path(root, id);
43
+ const content = yaml.print(record);
44
+ await mkdir(dirname(file), { recursive: true });
45
+ await writeFile(file, content, { encoding: "utf8", flag: "wx" });
46
+ await writeFile(hashPath(root, id), `${digest(content)}\n`, { encoding: "utf8", flag: "wx" });
47
+ console.log(id);
48
+ }
49
+
50
+ function same(record: ExactSessionAdoptionAuthorization, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
51
+ return record.seat === expected.seat && record.pi_session === expected.pi_session && record.pi_session_path === expected.pi_session_path
52
+ && record.source.herdr_session === expected.source.herdr_session && record.source.pane === expected.source.pane
53
+ && record.target.herdr_session === expected.target.herdr_session && record.target.pane === expected.target.pane && record.target.cwd === expected.target.cwd;
54
+ }
55
+
56
+ async function used(root: string, id: string) {
57
+ const folder = join(root, ".atdd-flow", "runtime-launch");
58
+ try {
59
+ const files = await readdir(folder);
60
+ for (const file of files.filter((entry) => entry.endsWith(".yaml"))) {
61
+ try {
62
+ const value = yaml.parse<Record<string, unknown>>(await readFile(join(folder, file), "utf8"));
63
+ const authorization = value.relocation && typeof value.relocation === "object" ? (value.relocation as Record<string, unknown>).authorization : undefined;
64
+ if (authorization && typeof authorization === "object" && (authorization as Record<string, unknown>).id === id) return true;
65
+ } catch { /* Malformed receipts never authorize reuse. */ }
66
+ }
67
+ } catch { /* No previous receipt means unused. */ }
68
+ return false;
69
+ }
70
+
71
+ /** Resolve only a create-only operator record whose complete tuple matches before runtime projection. */
72
+ export async function resolveExactSessionAdoptionAuthorization(root: string, id: string, expected: Omit<ExactSessionAdoptionAuthorization, "schema" | "id" | "issued_by" | "created_at">) {
73
+ if (!validId(id)) throw new Error("Exact-session adoption requires an immutable operator authorization record id.");
74
+ let raw: string;
75
+ try { raw = await readFile(path(root, id), "utf8"); }
76
+ catch { throw new Error("Exact-session adoption requires an existing immutable operator authorization record."); }
77
+ let record: ExactSessionAdoptionAuthorization;
78
+ try { record = yaml.parse<ExactSessionAdoptionAuthorization>(raw); }
79
+ catch { throw new Error("Exact-session adoption requires a well-formed immutable operator authorization record."); }
80
+ let storedHash: string;
81
+ try { storedHash = (await readFile(hashPath(root, id), "utf8")).trim(); }
82
+ catch { throw new Error("Exact-session adoption requires a create-only immutable operator authorization record."); }
83
+ if (!/^[a-f0-9]{64}$/i.test(storedHash) || storedHash !== digest(raw)) throw new Error("Exact-session adoption requires an immutable operator authorization record.");
84
+ if (!tuple(record) || record.id !== id || !same(record, expected)) throw new Error("Exact-session adoption requires an immutable operator authorization record bound to the exact adoption tuple.");
85
+ if (await used(root, id)) throw new Error("Exact-session adoption authorization record was already used.");
86
+ return { id, hash: storedHash };
87
+ }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { basename, resolve } from "node:path";
4
4
  import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
5
+ import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
5
6
  import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
6
7
  import * as tasks from "./tasks";
7
8
  import { required, values, words, yaml } from "./core";
@@ -26,7 +27,8 @@ Usage:
26
27
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
27
28
  atdd-flow attach <address> [--application <application>] [--wake host|native]
28
29
  atdd-flow pi extension-path
29
- atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run]
30
+ atdd-flow pi runtime authorize-adoption <id> --by operator@desk --seat <seat> --pi-session <id> --pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --target-herdr-session <session> --target-pane <pane> --target-cwd <path>
31
+ atdd-flow pi runtime launch <address> [--pane <asserted-herdr-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]
30
32
  atdd-flow application use <address> <application>
31
33
  atdd-flow multiplexer status|apply herdr [--session <name>]
32
34
  atdd-flow describe <address> --purpose <one-line responsibility>
@@ -92,13 +94,14 @@ async function main() {
92
94
  attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
93
95
  pi: async () => {
94
96
  if (rest[0] === "extension-path") return console.log(piExtensionPath());
97
+ if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
95
98
  if (rest[0] === "runtime" && rest[1] === "launch") {
96
99
  const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
97
100
  if (plan.dryRun) console.log(yaml.print(plan));
98
101
  else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
99
102
  return;
100
103
  }
101
- throw new Error("Use `pi extension-path` or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run]`.");
104
+ throw new Error("Use `pi extension-path`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
102
105
  },
103
106
  application: async () => {
104
107
  if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
package/src/seats.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readdir, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
5
6
  import {
@@ -11,6 +12,7 @@ import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } fr
11
12
  import { seatTasks } from "./tasks";
12
13
  import { projectHerdrSeat } from "./multiplexer";
13
14
  import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
15
+ import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
14
16
 
15
17
  const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
16
18
 
@@ -291,6 +293,15 @@ function startCommand(request: HerdrRequest) {
291
293
  }
292
294
  function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
293
295
 
296
+ async function immutableSessionBackup(root: string, sessionPath: string, piSession: string) {
297
+ const content = await readFile(sessionPath);
298
+ const sha256 = createHash("sha256").update(content).digest("hex");
299
+ const backup = join(root, ".atdd-flow", "runtime-launch", "backups", `${encodeURIComponent(piSession)}-${crypto.randomUUID()}.jsonl`);
300
+ await mkdir(dirname(backup), { recursive: true });
301
+ await writeFile(backup, content, { flag: "wx" });
302
+ return { backup, sha256 };
303
+ }
304
+
294
305
  /** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
295
306
  export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
296
307
  const resolved = await canonicalAddress(root, address);
@@ -300,15 +311,41 @@ export async function launchPiRuntime(root: string, address: string, args: strin
300
311
  const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
301
312
  const resume = args.includes("--resume");
302
313
  const dryRun = args.includes("--dry-run");
303
- const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : [])];
304
- if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run]`.");
314
+ const adoptedSessionPath = words(args, "--adopt-session");
315
+ const sourceHerdrSession = words(args, "--source-herdr-session");
316
+ const sourcePane = words(args, "--source-pane");
317
+ const authorization = words(args, "--authorization");
318
+ const adoptionFields = [adoptedSessionPath, sourceHerdrSession, sourcePane, authorization];
319
+ const adopting = adoptionFields.some(Boolean);
320
+ const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : []),
321
+ ...(adoptedSessionPath ? ["--adopt-session", adoptedSessionPath] : []), ...(sourceHerdrSession ? ["--source-herdr-session", sourceHerdrSession] : []),
322
+ ...(sourcePane ? ["--source-pane", sourcePane] : []), ...(authorization ? ["--authorization", authorization] : [])];
323
+ if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <owner-reference>]`.");
324
+ if (adopting && (resume || !adoptionFields.every(Boolean))) throw new Error("Exact-session adoption requires --adopt-session, --source-herdr-session, --source-pane, and --authorization without --resume.");
325
+ const existingHerdr = record.runtime?.addresses.herdr;
326
+ const existingPane = typeof existingHerdr === "object" ? existingHerdr.pane : undefined;
327
+ const existingSession = typeof existingHerdr === "object" ? existingHerdr.session : undefined;
328
+ if (resume && ((requestedPane && requestedPane !== existingPane) || herdrSession !== existingSession)) {
329
+ throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
330
+ }
305
331
  const active = await launchEligibleTasks(root, record);
306
332
  if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
307
333
  const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
308
334
  const model = resolveModelCommand(config, selection.candidate);
309
335
  if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
310
336
  const command = dependencies.command ?? runOutput;
337
+ const piSession = adopting ? sessionIdFromPiJsonlPath(required(adoptedSessionPath, "an exact adopted Pi session path")) : resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
338
+ if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
339
+ const canonicalAdoptedSessionPath = adopting ? resolve(required(adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
340
+ const authorizationRecord = adopting
341
+ ? await resolveExactSessionAdoptionAuthorization(root, required(authorization, "an immutable operator authorization record"), {
342
+ seat: resolved, pi_session: piSession, pi_session_path: required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"),
343
+ source: { herdr_session: required(sourceHerdrSession, "a source Herdr session"), pane: required(sourcePane, "a source pane") },
344
+ target: { herdr_session: herdrSession, pane: required(requestedPane, "--pane for exact-session adoption"), cwd: resolve(record.worktree) },
345
+ })
346
+ : undefined;
311
347
  const projection = await projectHerdrSeat(root, resolved, herdrSession, command);
348
+ if (resume && projection.pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
312
349
  if (requestedPane && requestedPane !== projection.pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
313
350
  const pane = projection.pane;
314
351
  const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
@@ -316,8 +353,6 @@ export async function launchPiRuntime(root: string, address: string, args: strin
316
353
  const currentProcess = processReport(JSON.parse(processRaw));
317
354
  if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
318
355
  if (currentPane.agent || piProcess(currentProcess.processes)) throw new Error(resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
319
- const piSession = resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
320
- if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
321
356
  if (resume) {
322
357
  const prior = record.runtime?.launch_receipt;
323
358
  if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
@@ -326,10 +361,24 @@ export async function launchPiRuntime(root: string, address: string, args: strin
326
361
  if (receipt.seat !== resolved || receipt.pi_session !== piSession || receipt.pi_session_path !== record.runtime?.pi_session_path || receipt.herdr_session !== herdrSession || receipt.pane !== pane) throw new Error("receipt does not match seat/session/path/pane");
327
362
  } catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
328
363
  }
329
- const priorSessionPath = resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
330
- const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume, args: [...model.args, "--extension", piExtensionPath()] };
364
+ const priorSessionPath = adopting ? required(canonicalAdoptedSessionPath, "an exact adopted Pi session path") : resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
365
+ if (adopting) {
366
+ const [sourcePaneRaw, sourceProcessRaw] = await Promise.all([
367
+ command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "get", required(sourcePane, "a source pane"))),
368
+ command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "process-info", "--pane", required(sourcePane, "a source pane"))),
369
+ ]);
370
+ const sourceReport = paneReport(JSON.parse(sourcePaneRaw));
371
+ const sourceProcess = processReport(JSON.parse(sourceProcessRaw));
372
+ if (sourceReport.pane !== sourcePane || sourceReport.agent || piProcess(sourceProcess.processes)) {
373
+ throw new Error("Exact-session adoption requires proof that the source Pi has exited and been released.");
374
+ }
375
+ }
376
+ const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume: resume || adopting, args: [...model.args, "--extension", piExtensionPath()] };
331
377
  const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
332
378
  if (dryRun) return plan;
379
+ // The source snapshot is a pre-migration prerequisite: never start a target
380
+ // Pi when its immutable recovery copy cannot be created.
381
+ const relocation = adopting ? await immutableSessionBackup(root, required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"), piSession) : undefined;
333
382
  await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)} ATDD_FLOW_PI_SESSION=${shellQuote(piSession)} ATDD_FLOW_HERDR_SESSION=${shellQuote(herdrSession)} ATDD_FLOW_HERDR_PANE=${shellQuote(pane)}`));
334
383
  await command(startCommand(request));
335
384
  const [agentRaw, verifiedPaneRaw, verifiedProcessRaw] = await Promise.all([command(herdr(herdrSession, "agent", "get", agentName(resolved))), command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
@@ -340,13 +389,15 @@ export async function launchPiRuntime(root: string, address: string, args: strin
340
389
  if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
341
390
  const reportedId = sessionIdFromPiJsonlPath(sessionPath);
342
391
  if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
343
- if (resume && sessionPath !== record.runtime?.pi_session_path) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
392
+ if ((resume || adopting) && sessionPath !== priorSessionPath) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
344
393
  if (agent.name !== agentName(resolved) || agent.pane !== pane || ![agent.status, verifiedPane.status].every((status) => status === "idle" || status === "done") || verifiedPane.pane !== pane || !verifiedProcess) throw new Error("Herdr did not verify the replacement Pi process; seat binding was not changed.");
345
394
  const receipt = plan.receipt;
346
395
  const receiptRecord = {
347
396
  schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
348
397
  selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
349
- pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane, created_at: dependencies.at?.() ?? now(),
398
+ pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane,
399
+ ...(relocation ? { relocation: { source_herdr_session: sourceHerdrSession, source_pane: sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
400
+ created_at: dependencies.at?.() ?? now(),
350
401
  };
351
402
  await mkdir(dirname(receipt), { recursive: true });
352
403
  await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });