@afokapu/atdd-flow 0.3.10 → 0.3.12
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 +10 -3
- package/src/core.ts +13 -1
- package/src/seats.ts +151 -11
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
|
|
4
|
+
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
5
5
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
6
6
|
import * as tasks from "./tasks";
|
|
7
|
-
import { required, values, words } from "./core";
|
|
7
|
+
import { required, values, words, yaml } from "./core";
|
|
8
8
|
import { addressBook } from "./address-book";
|
|
9
9
|
import * as judgment from "./judgment";
|
|
10
10
|
import * as reviews from "./reviews";
|
|
@@ -25,6 +25,7 @@ Usage:
|
|
|
25
25
|
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
26
26
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
27
27
|
atdd-flow pi extension-path
|
|
28
|
+
atdd-flow pi runtime launch <address> --pane <existing-herdr-pane> --herdr-session <session> [--resume] [--dry-run]
|
|
28
29
|
atdd-flow application use <address> <application>
|
|
29
30
|
atdd-flow multiplexer status|apply herdr [--session <name>]
|
|
30
31
|
atdd-flow describe <address> --purpose <one-line responsibility>
|
|
@@ -85,7 +86,13 @@ async function main() {
|
|
|
85
86
|
attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
|
|
86
87
|
pi: async () => {
|
|
87
88
|
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
88
|
-
|
|
89
|
+
if (rest[0] === "runtime" && rest[1] === "launch") {
|
|
90
|
+
const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
|
|
91
|
+
if (plan.dryRun) console.log(yaml.print(plan));
|
|
92
|
+
else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("Use `pi extension-path` or `pi runtime launch <address> --pane <pane> --herdr-session <session> [--resume] [--dry-run]`.");
|
|
89
96
|
},
|
|
90
97
|
application: async () => {
|
|
91
98
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
package/src/core.ts
CHANGED
|
@@ -51,7 +51,19 @@ export type Project = {
|
|
|
51
51
|
export type HerdrLocator = { session: string; pane: string };
|
|
52
52
|
/** Strings are legacy native addresses; new Herdr bindings retain their session. */
|
|
53
53
|
export type RuntimeAddress = string | HerdrLocator;
|
|
54
|
-
export type Runtime = {
|
|
54
|
+
export type Runtime = {
|
|
55
|
+
application: string;
|
|
56
|
+
addresses: Record<string, RuntimeAddress>;
|
|
57
|
+
attached_at?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
wake?: "host" | "native";
|
|
60
|
+
/** Exact Pi session used by the narrow Pi/Herdr launch command. */
|
|
61
|
+
pi_session?: string;
|
|
62
|
+
/** Exact durable JSONL path reported by Herdr for that Pi session. */
|
|
63
|
+
pi_session_path?: string;
|
|
64
|
+
/** Immutable, non-secret receipt for that selected launch. */
|
|
65
|
+
launch_receipt?: string;
|
|
66
|
+
};
|
|
55
67
|
|
|
56
68
|
export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
|
|
57
69
|
if (application !== "herdr" || typeof value === "string") return { address: value, ...(application === "herdr" && legacyHerdrSession ? { session: legacyHerdrSession } : {}) };
|
package/src/seats.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
5
5
|
import {
|
|
6
6
|
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
7
7
|
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
|
-
required, run, runtimeAddress, seat, words, yaml,
|
|
8
|
+
required, run, runOutput, runtimeAddress, seat, words, yaml,
|
|
9
9
|
} from "./core";
|
|
10
|
-
import { selectModel } from "./judgment";
|
|
10
|
+
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
11
11
|
import { seatTasks } from "./tasks";
|
|
12
12
|
|
|
13
13
|
const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
|
|
@@ -92,7 +92,7 @@ export async function spawn(root: string, projectName: string, roleName: string,
|
|
|
92
92
|
console.log(address);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"]) {
|
|
95
|
+
export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) {
|
|
96
96
|
const resolved = await canonicalAddress(root, address);
|
|
97
97
|
const record = await seat(root, resolved);
|
|
98
98
|
const config = await desk(root);
|
|
@@ -114,6 +114,9 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
114
114
|
attached_at: now(),
|
|
115
115
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
116
116
|
...(selectedWake ? { wake: selectedWake } : {}),
|
|
117
|
+
...(piSession ? { pi_session: piSession } : {}),
|
|
118
|
+
...(piSessionPath ? { pi_session_path: piSessionPath } : {}),
|
|
119
|
+
...(launchReceipt ? { launch_receipt: launchReceipt } : {}),
|
|
117
120
|
};
|
|
118
121
|
// Legacy Desks use this field as the executable chosen by a later `launch`.
|
|
119
122
|
// Keep it aligned when an existing seat is deliberately re-homed to Pi.
|
|
@@ -159,11 +162,26 @@ export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
|
159
162
|
return { agent: resolveExecutable(config, candidate.executable), args: candidate.args ?? [] };
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
async function
|
|
165
|
+
async function launchEligibleTasks(root: string, record: Seat) {
|
|
166
|
+
const tasks = await seatTasks(root, record.project, record.address);
|
|
167
|
+
const eligible = await Promise.all(tasks.map(async (entry) => {
|
|
168
|
+
if (entry.task.assignee !== record.address || entry.task.blocker) return undefined;
|
|
169
|
+
if (entry.task.status === "in_progress") return entry;
|
|
170
|
+
if (entry.task.status !== "todo") return undefined;
|
|
171
|
+
const dependencies = await Promise.all((entry.task.depends_on ?? []).map(async (id) => {
|
|
172
|
+
try { return (await readYaml<{ status?: string }>(paths(root).taskFile(record.project, id))).status === "done"; }
|
|
173
|
+
catch { return false; }
|
|
174
|
+
}));
|
|
175
|
+
return dependencies.every(Boolean) ? entry : undefined;
|
|
176
|
+
}));
|
|
177
|
+
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
163
181
|
const candidates = availableModelCandidates(config, portfolio);
|
|
164
182
|
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
165
183
|
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => entry.task.status !== "done");
|
|
166
|
-
const selection = await
|
|
184
|
+
const selection = await select({
|
|
167
185
|
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
168
186
|
tasks: work.map((entry) => ({
|
|
169
187
|
id: entry.id,
|
|
@@ -175,16 +193,138 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
175
193
|
})),
|
|
176
194
|
candidates,
|
|
177
195
|
});
|
|
178
|
-
if (!selection.available) {
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
if (!selection.available || selection.confidence < 0.75 || !candidates.some((entry) => entry.id === selection.selected_model)) {
|
|
197
|
+
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
198
|
+
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
199
|
+
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
200
|
+
return { candidate: candidates[0]!, selection, fallback: reason };
|
|
181
201
|
}
|
|
182
|
-
return required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`);
|
|
202
|
+
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
186
206
|
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
187
207
|
|
|
208
|
+
type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
|
|
209
|
+
type Command = (command: string[]) => Promise<string>;
|
|
210
|
+
export type PiRuntimeLaunchDependencies = {
|
|
211
|
+
select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
|
|
212
|
+
command?: Command;
|
|
213
|
+
sessionId?: () => string;
|
|
214
|
+
at?: () => string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
218
|
+
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
219
|
+
|
|
220
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
221
|
+
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
222
|
+
const number = (value: unknown) => typeof value === "number" ? value : undefined;
|
|
223
|
+
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
224
|
+
function paneReport(value: unknown): PaneReport {
|
|
225
|
+
const pane = object(result(value)?.pane) ?? result(value);
|
|
226
|
+
const session = object(pane?.agent_session);
|
|
227
|
+
const id = text(pane?.pane_id);
|
|
228
|
+
if (!id) throw new Error("Herdr pane get did not report a pane id.");
|
|
229
|
+
return {
|
|
230
|
+
pane: id,
|
|
231
|
+
...(text(pane?.agent) ? { agent: text(pane?.agent) } : {}),
|
|
232
|
+
...(text(pane?.agent_status) ? { status: text(pane?.agent_status) } : {}),
|
|
233
|
+
...(session?.kind === "path" && text(session.value) ? { sessionPath: text(session.value) } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function processReport(value: unknown): ProcessReport {
|
|
237
|
+
const report = object(result(value)?.process_info) ?? result(value);
|
|
238
|
+
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
239
|
+
const process = object(entry);
|
|
240
|
+
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
241
|
+
}) : [];
|
|
242
|
+
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
243
|
+
}
|
|
244
|
+
function agentReport(value: unknown) {
|
|
245
|
+
const agent = object(result(value)?.agent) ?? result(value);
|
|
246
|
+
const name = text(agent?.name);
|
|
247
|
+
const pane = text(agent?.pane_id);
|
|
248
|
+
if (!name || !pane) throw new Error("Herdr agent get did not report agent name and pane id.");
|
|
249
|
+
return { name, pane, status: text(agent?.status) ?? text(agent?.state) };
|
|
250
|
+
}
|
|
251
|
+
function piProcess(processes: ProcessReport["processes"]) {
|
|
252
|
+
return processes.find((entry) => entry.name === "pi" || entry.argv0?.endsWith("/pi") || entry.argv0 === "pi" || /(^|\/)pi(?:\s|$)/.test(entry.cmdline ?? ""));
|
|
253
|
+
}
|
|
254
|
+
/** Pi's durable JSONL session filenames end in `_<session-id>.jsonl`. */
|
|
255
|
+
function sessionIdFromPiJsonlPath(path: string) {
|
|
256
|
+
const match = 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);
|
|
257
|
+
if (!match) throw new Error("Herdr did not report a Pi JSONL path with a documented session ID.");
|
|
258
|
+
return match[1]!;
|
|
259
|
+
}
|
|
260
|
+
function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
261
|
+
function agentName(address: string) { return `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32); }
|
|
262
|
+
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
263
|
+
function startCommand(request: HerdrRequest) {
|
|
264
|
+
const session = request.resume ? ["--session", required(request.sessionPath, "a previously verified Pi session path")] : ["--session-id", request.piSession];
|
|
265
|
+
return herdr(request.session, "agent", "start", agentName(request.seat), "--kind", "pi", "--pane", request.pane, "--", ...session, ...request.args);
|
|
266
|
+
}
|
|
267
|
+
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
268
|
+
|
|
269
|
+
/** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
|
|
270
|
+
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
271
|
+
const resolved = await canonicalAddress(root, address);
|
|
272
|
+
const record = await seat(root, resolved);
|
|
273
|
+
const config = await desk(root);
|
|
274
|
+
const pane = required(words(args, "--pane"), "--pane");
|
|
275
|
+
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
276
|
+
const resume = args.includes("--resume");
|
|
277
|
+
const dryRun = args.includes("--dry-run");
|
|
278
|
+
if (args.some((argument) => !["--pane", pane, "--herdr-session", herdrSession, "--resume", "--dry-run"].includes(argument))) throw new Error("Use `pi runtime launch <seat> --pane <existing-pane> --herdr-session <session> [--resume] [--dry-run]`.");
|
|
279
|
+
const active = await launchEligibleTasks(root, record);
|
|
280
|
+
if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
281
|
+
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
282
|
+
const model = resolveModelCommand(config, selection.candidate);
|
|
283
|
+
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
284
|
+
const command = dependencies.command ?? runOutput;
|
|
285
|
+
const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
286
|
+
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
287
|
+
const currentProcess = processReport(JSON.parse(processRaw));
|
|
288
|
+
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
289
|
+
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.");
|
|
290
|
+
const piSession = resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
291
|
+
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
292
|
+
if (resume) {
|
|
293
|
+
const prior = record.runtime?.launch_receipt;
|
|
294
|
+
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
295
|
+
try {
|
|
296
|
+
const receipt = await readYaml<{ seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string }>(prior);
|
|
297
|
+
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");
|
|
298
|
+
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
299
|
+
}
|
|
300
|
+
const priorSessionPath = resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
|
|
301
|
+
const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume, args: [...model.args, "--extension", piExtensionPath()] };
|
|
302
|
+
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
|
|
303
|
+
if (dryRun) return plan;
|
|
304
|
+
await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)}`));
|
|
305
|
+
await command(startCommand(request));
|
|
306
|
+
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))]);
|
|
307
|
+
const agent = agentReport(JSON.parse(agentRaw));
|
|
308
|
+
const verifiedPane = paneReport(JSON.parse(verifiedPaneRaw));
|
|
309
|
+
const verifiedProcess = piProcess(processReport(JSON.parse(verifiedProcessRaw)).processes);
|
|
310
|
+
const sessionPath = verifiedPane.sessionPath;
|
|
311
|
+
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
312
|
+
const reportedId = sessionIdFromPiJsonlPath(sessionPath);
|
|
313
|
+
if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
314
|
+
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.");
|
|
315
|
+
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.");
|
|
316
|
+
const receipt = plan.receipt;
|
|
317
|
+
const receiptRecord = {
|
|
318
|
+
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
319
|
+
selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
|
|
320
|
+
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane, created_at: dependencies.at?.() ?? now(),
|
|
321
|
+
};
|
|
322
|
+
await mkdir(dirname(receipt), { recursive: true });
|
|
323
|
+
await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
|
324
|
+
await bind(root, resolved, ["--application", "herdr", "--address", pane, "--session", herdrSession], selection.candidate.id, "native", piSession, sessionPath, receipt);
|
|
325
|
+
return plan;
|
|
326
|
+
}
|
|
327
|
+
|
|
188
328
|
export async function describe(root: string, address: string, args: string[]) {
|
|
189
329
|
const resolved = await canonicalAddress(root, address);
|
|
190
330
|
const record = await seat(root, resolved);
|