@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 +1 -1
- package/src/cli.ts +1 -1
- package/src/handover.ts +162 -41
package/package.json
CHANGED
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
|
-
|
|
140
|
+
function unboundLegacyRuntime(runtime: Runtime | undefined, pane: string) {
|
|
125
141
|
const locator = runtime?.addresses.herdr;
|
|
126
|
-
return
|
|
127
|
-
&& runtime
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
216
|
-
await
|
|
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
|
|