@afokapu/atdd-flow 0.3.48 → 0.3.49

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.48",
3
+ "version": "0.3.49",
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
@@ -38,7 +38,7 @@ Usage:
38
38
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
39
39
  atdd-flow handover <address> --file <path> [--check]
40
40
  atdd-flow handover show <address>
41
- atdd-flow handover replace <address> --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
41
+ atdd-flow handover replace <address> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
42
42
  atdd-flow handover resumed <address>
43
43
  atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
44
44
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
package/src/handover.ts CHANGED
@@ -1,7 +1,7 @@
1
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 { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, seat, words, yaml } from "./core";
2
+ import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
5
5
  import { type Task, terminal } from "./tasks";
6
6
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
7
7
  import { readRuntimeState } from "./runtime-state";
@@ -102,9 +102,25 @@ export type HandoverReplacementRequest = {
102
102
  authorization: { id: string; hash: string };
103
103
  source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
104
104
  target: { herdr_session: string; pane: string; cwd: string };
105
+ legacy_source?: {
106
+ authorization_hash: string;
107
+ pi_session: string;
108
+ pi_session_path: string;
109
+ herdr_session: string;
110
+ pane: string;
111
+ cwd: string;
112
+ agent: string;
113
+ verified_at: string;
114
+ };
105
115
  created_at: string;
106
116
  };
107
117
 
118
+ export type HandoverReplacementDependencies = {
119
+ command?: (command: string[]) => Promise<string>;
120
+ at?: () => string;
121
+ publish?: (file: string, content: string) => Promise<void>;
122
+ };
123
+
108
124
  export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
109
125
  const nonEmpty = (value: string | undefined, flag: string) => required(value, flag).trim();
110
126
 
@@ -121,10 +137,27 @@ export async function isExactMainSeat(root: string, record: Seat, address: strin
121
137
  return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
122
138
  }
123
139
 
124
- export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
140
+ function unboundLegacyRuntime(runtime: Runtime | undefined, pane: string) {
125
141
  const locator = runtime?.addresses.herdr;
126
- return typeof locator !== "string" && locator?.session === request.source.herdr_session && locator.pane === request.source.pane
127
- && runtime?.pi_session === request.source.pi_session && runtime.pi_session_path === request.source.pi_session_path;
142
+ return runtime?.application === "herdr" && typeof locator === "string" && locator === pane
143
+ && !runtime.pi_session && !runtime.pi_session_path && !runtime.launch_receipt;
144
+ }
145
+
146
+ function snapshotMatchesRequest(snapshot: NonNullable<HandoverReplacementRequest["legacy_source"]>, request: HandoverReplacementRequest) {
147
+ return snapshot.authorization_hash === request.authorization.hash && snapshot.pi_session === request.source.pi_session
148
+ && snapshot.pi_session_path === request.source.pi_session_path && snapshot.herdr_session === request.source.herdr_session
149
+ && snapshot.pane === request.source.pane && snapshot.cwd === request.target.cwd && Boolean(snapshot.agent) && Boolean(snapshot.verified_at);
150
+ }
151
+
152
+ function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: HandoverReplacementRequest["source"]) {
153
+ const locator = runtime?.addresses.herdr;
154
+ return typeof locator !== "string" && locator?.session === source.herdr_session && locator.pane === source.pane
155
+ && runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
156
+ }
157
+
158
+ export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
159
+ if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
160
+ return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
128
161
  }
129
162
 
130
163
  export async function mainLaunchEligible(root: string, record: Seat, address: string, task: Task) {
@@ -172,48 +205,136 @@ async function exactMainTakeoverTask(root: string, address: string, id: string)
172
205
  return { record };
173
206
  }
174
207
 
175
- /**
176
- * Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
177
- * It deliberately does not stop, bind, attach, project, or launch any process.
178
- */
179
- export async function requestHandoverReplacement(root: string, address: string, args: string[]) {
180
- const resolved = await canonicalAddress(root, address);
181
- const allowed = ["--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
182
- if (args.some((arg) => arg.startsWith("--") && !allowed.includes(arg))) throw new Error("Use `handover replace <main-seat> --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
183
- const handover = await recorded(root, resolved);
184
- const handoverId = nonEmpty(words(args, "--handover"), "--handover");
208
+ type LegacyPaneObservation = { pane: string; agent: string; sessionPath: string; cwd: string };
209
+ const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
210
+ const text = (value: unknown) => typeof value === "string" ? value : undefined;
211
+ function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
212
+ function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
213
+
214
+ function legacyPaneObservation(raw: string): LegacyPaneObservation {
215
+ const value = JSON.parse(raw), pane = object(result(value)?.pane) ?? result(value), session = object(pane?.agent_session);
216
+ const observation = { pane: text(pane?.pane_id), agent: text(pane?.agent), sessionPath: session?.kind === "path" ? text(session.value) : undefined, cwd: text(pane?.cwd) };
217
+ if (!observation.pane || !observation.agent || !observation.sessionPath || !observation.cwd) throw new Error("Legacy source bootstrap requires complete live Herdr pane/session/path/cwd evidence.");
218
+ return observation as LegacyPaneObservation;
219
+ }
220
+
221
+ function assertLegacyPane(observed: LegacyPaneObservation, source: HandoverReplacementRequest["source"], cwd: string) {
222
+ const match = basename(observed.sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
223
+ if (observed.pane !== source.pane || resolve(observed.sessionPath) !== source.pi_session_path || match?.[1] !== source.pi_session || resolve(observed.cwd) !== cwd) {
224
+ throw new Error("Legacy source bootstrap live pane/session/path/cwd does not match the authorized source tuple.");
225
+ }
226
+ }
227
+
228
+ function legacyAgentName(raw: string, source: HandoverReplacementRequest["source"], cwd: string) {
229
+ const value = JSON.parse(raw), agent = object(result(value)?.agent) ?? result(value), session = object(agent?.agent_session);
230
+ const name = text(agent?.name), sessionPath = session?.kind === "path" ? text(session.value) : undefined, agentCwd = text(agent?.cwd);
231
+ if (!name || text(agent?.pane_id) !== source.pane || text(agent?.agent) !== "pi" || !sessionPath || resolve(sessionPath) !== source.pi_session_path
232
+ || !agentCwd || resolve(agentCwd) !== cwd) throw new Error("Legacy source bootstrap Pi agent does not match the authorized source pane/session/path/cwd.");
233
+ return name;
234
+ }
235
+
236
+ function assertSinglePiWriter(raw: string) {
237
+ const value = JSON.parse(raw), report = object(result(value)?.process_info) ?? result(value);
238
+ const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.map(object).filter(Boolean) as Record<string, unknown>[] : [];
239
+ const pi = processes.filter((process) => text(process.name) === "pi" || text(process.argv0)?.endsWith("/pi") || text(process.argv0) === "pi" || /(^|\/)pi(?:\s|$)/.test(text(process.cmdline) ?? ""));
240
+ if (pi.length !== 1) throw new Error("Legacy source bootstrap requires exactly one live Pi writer on the authorized source pane.");
241
+ }
242
+
243
+ async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies) {
244
+ const command = dependencies.command ?? runOutput;
245
+ const pane = legacyPaneObservation(await command(herdr(source.herdr_session, "pane", "get", source.pane)));
246
+ assertLegacyPane(pane, source, target.cwd);
247
+ const [agentRaw, processRaw] = await Promise.all([
248
+ command(herdr(source.herdr_session, "agent", "get", source.pane)),
249
+ command(herdr(source.herdr_session, "pane", "process-info", "--pane", source.pane)),
250
+ ]);
251
+ const agent = legacyAgentName(agentRaw, source, target.cwd);
252
+ assertSinglePiWriter(processRaw);
253
+ return { authorization_hash: authorizationHash, ...source, cwd: target.cwd, agent, verified_at: dependencies.at?.() ?? now() };
254
+ }
255
+
256
+ function assertLegacySeat(record: Seat, source: HandoverReplacementRequest["source"]) {
257
+ const runtime = record.runtime, locator = runtime?.addresses.herdr;
258
+ if (!runtime || runtime.application !== "herdr" || typeof locator !== "string" || locator !== source.pane
259
+ || runtime.pi_session || runtime.pi_session_path || runtime.launch_receipt) {
260
+ throw new Error("Legacy source bootstrap requires the unchanged scalar Herdr placement without canonical runtime binding.");
261
+ }
262
+ }
263
+
264
+ async function publishCreateOnly(file: string, content: string) {
265
+ await mkdir(dirname(file), { recursive: true });
266
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
267
+ try {
268
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
269
+ await link(temporary, file);
270
+ } finally { await rm(temporary, { force: true }); }
271
+ }
272
+
273
+ const replacementFlags = ["--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
274
+ function validateReplacementArgs(args: string[]) {
275
+ if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
276
+ }
277
+
278
+ async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
279
+ const handover = await recorded(root, resolved), handoverId = nonEmpty(words(args, "--handover"), "--handover");
185
280
  if (!handover || handover.handover_id !== handoverId) throw new Error(`Path-A replacement requires the current validated handover ${handoverId} for ${resolved}.`);
186
- const { record } = await exactMainTakeoverTask(root, resolved, nonEmpty(words(args, "--takeover-task"), "--takeover-task"));
187
- const targetCwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
188
- if (targetCwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
189
- const source = {
281
+ return handover;
282
+ }
283
+
284
+ function replacementSource(args: string[]) {
285
+ return {
190
286
  pi_session: nonEmpty(words(args, "--source-pi-session"), "--source-pi-session"), pi_session_path: resolve(nonEmpty(words(args, "--source-pi-session-path"), "--source-pi-session-path")),
191
287
  herdr_session: nonEmpty(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: nonEmpty(words(args, "--source-pane"), "--source-pane"),
192
288
  };
193
- const locator = record.runtime?.addresses.herdr;
194
- if (!record.runtime || typeof locator === "string" || locator?.session !== source.herdr_session || locator.pane !== source.pane
195
- || record.runtime.pi_session !== source.pi_session || record.runtime.pi_session_path !== source.pi_session_path) {
196
- throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
197
- }
198
- const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd: targetCwd };
289
+ }
290
+
291
+ function replacementTarget(args: string[], record: Seat, source: HandoverReplacementRequest["source"]) {
292
+ const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
293
+ if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
294
+ const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
199
295
  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.");
200
- const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
201
- seat: resolved, pi_session: source.pi_session, pi_session_path: source.pi_session_path,
202
- source: { herdr_session: source.herdr_session, pane: source.pane }, target,
203
- });
204
- const request: HandoverReplacementRequest = {
205
- schema: "atdd-flow/context-handover-replacement-request/v1",
206
- seat: resolved,
207
- task: nonEmpty(words(args, "--takeover-task"), "--takeover-task"),
208
- handover: { id: handover.handover_id, sha256: createHash("sha256").update(handover.text).digest("hex") },
209
- authorization,
210
- source,
211
- target,
212
- created_at: now(),
296
+ return target;
297
+ }
298
+
299
+ function assertReplacementSource(record: Seat, source: HandoverReplacementRequest["source"], legacy: boolean) {
300
+ if (legacy) return assertLegacySeat(record, source);
301
+ if (!canonicalSourceMatchesRuntime(record.runtime, source)) throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
302
+ }
303
+
304
+ async function prepareReplacement(root: string, resolved: string, args: string[]) {
305
+ validateReplacementArgs(args);
306
+ const legacy = args.includes("--legacy-source-bootstrap"), handover = await currentReplacementHandover(root, resolved, args);
307
+ const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
308
+ const { record } = await exactMainTakeoverTask(root, resolved, task);
309
+ const source = replacementSource(args), target = replacementTarget(args, record, source);
310
+ assertReplacementSource(record, source, legacy);
311
+ return { legacy, handover, task, source, target };
312
+ }
313
+
314
+ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
315
+ return {
316
+ schema: "atdd-flow/context-handover-replacement-request/v1", seat: resolved, task: intent.task,
317
+ handover: { id: intent.handover.handover_id, sha256: createHash("sha256").update(intent.handover.text).digest("hex") },
318
+ authorization, source: intent.source, target: intent.target,
319
+ ...(legacySource ? { legacy_source: legacySource } : {}), created_at: dependencies.at?.() ?? now(),
213
320
  };
321
+ }
322
+
323
+ /**
324
+ * Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
325
+ * It deliberately does not stop, bind, attach, project, or launch any process.
326
+ */
327
+ export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
328
+ const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
214
329
  const file = handoverReplacementPath(root, resolved);
215
- await mkdir(dirname(file), { recursive: true });
216
- await writeFile(file, yaml.print(request), { encoding: "utf8", flag: "wx" });
330
+ if (intent.legacy && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
331
+ const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
332
+ seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
333
+ source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
334
+ });
335
+ const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
336
+ const request = replacementRequest(resolved, intent, authorization, legacySource, dependencies);
337
+ await (dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
217
338
  console.log(`Recorded Path-A replacement request for ${resolved}.`);
218
339
  }
219
340