@afokapu/atdd-flow 0.2.0
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/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/atdd-flow +3 -0
- package/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +183 -0
- package/package.json +47 -0
- package/src/adapters.ts +88 -0
- package/src/cli.ts +134 -0
- package/src/core.ts +226 -0
- package/src/judgment.ts +333 -0
- package/src/overview.ts +403 -0
- package/src/reviews.ts +448 -0
- package/src/seats.ts +233 -0
- package/src/tasks.ts +243 -0
- package/src/threads.ts +153 -0
package/src/reviews.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { launchedAddress, launchCommand, notify } from "./adapters";
|
|
4
|
+
import {
|
|
5
|
+
type Seat, atomicYaml, canonicalAddress, desk, exists, id, modelPortfolio, now, paths, project,
|
|
6
|
+
readYaml, required, runOutput, seat, values, words, yaml,
|
|
7
|
+
} from "./core";
|
|
8
|
+
import {
|
|
9
|
+
type JudgmentOptions, type ReviewRoutingResponse, routeBehavioralReview, selectModel,
|
|
10
|
+
} from "./judgment";
|
|
11
|
+
import { availableModelCandidates, bind, resolveExecutable, resolveModelCommand } from "./seats";
|
|
12
|
+
import { type Task } from "./tasks";
|
|
13
|
+
|
|
14
|
+
const conventionRule = "atdd-bun.review.behavioral-reconciliation";
|
|
15
|
+
const conventionRelativePath = "conventions/atdd-bun.review/atdd-bun.review.behavioral-reconciliation.convention.yaml";
|
|
16
|
+
|
|
17
|
+
export type BehavioralReviewDecision = "APPROVE" | "RETURN" | "ESCALATE";
|
|
18
|
+
export type BehavioralReviewResult = {
|
|
19
|
+
intent: { sources: string[]; expected_behavior: string };
|
|
20
|
+
exercise: { boundary: string; evidence: string[] };
|
|
21
|
+
observed: { behavior: string };
|
|
22
|
+
reconciliation: {
|
|
23
|
+
intent_matches_runtime: boolean;
|
|
24
|
+
proof_matches_intent: boolean;
|
|
25
|
+
missing_behavior: string[];
|
|
26
|
+
unexpected_behavior: string[];
|
|
27
|
+
drift: string[];
|
|
28
|
+
};
|
|
29
|
+
decision: BehavioralReviewDecision;
|
|
30
|
+
rationale: string;
|
|
31
|
+
confidence: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type BehavioralReviewAttempt = {
|
|
35
|
+
id: string;
|
|
36
|
+
status: "pending" | "complete";
|
|
37
|
+
created_at: string;
|
|
38
|
+
completed_at?: string;
|
|
39
|
+
delivery_head: string;
|
|
40
|
+
routing: {
|
|
41
|
+
classification: "LOCAL" | "ASSEMBLED" | "JOURNEY" | "SYSTEM";
|
|
42
|
+
confidence: number;
|
|
43
|
+
signals: {
|
|
44
|
+
behaviorEffect: "BEHAVIOR_AFFECTING" | "BEHAVIOR_PRESERVING";
|
|
45
|
+
proofBoundary: "LOCAL_ACCEPTANCE" | "ASSEMBLED_API_RUNTIME" | "INTERLOCKING_ROUTE" | "TRAIN" | "USER_JOURNEY";
|
|
46
|
+
crossesMultiplePaths: boolean;
|
|
47
|
+
consequence: "ORDINARY" | "HIGH_CONSEQUENCE";
|
|
48
|
+
reconciliation: "SINGLE_PATH" | "MULTI_PATH_SYSTEM";
|
|
49
|
+
runtimeObservation: "AVAILABLE" | "NOT_AVAILABLE";
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
reviewer: { address: string; model: string };
|
|
53
|
+
convention: { rule_id: string; path: string; package_version: string };
|
|
54
|
+
input: {
|
|
55
|
+
task: { title: string; body?: string; source?: string; done_when: Array<{ text: string; proof?: string }> };
|
|
56
|
+
plan_artifacts: string[];
|
|
57
|
+
deterministic_gates: string[];
|
|
58
|
+
executable_proof: string[];
|
|
59
|
+
runtime: { worktree: string; branch: string; head: string };
|
|
60
|
+
implementation: { changed_files: string[]; diff_command: string };
|
|
61
|
+
};
|
|
62
|
+
result?: BehavioralReviewResult;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type BehavioralReviewHistory = {
|
|
66
|
+
schema: "atdd-workflow/behavioral-reviews/v1";
|
|
67
|
+
task: string;
|
|
68
|
+
attempts: BehavioralReviewAttempt[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
async function readTask(root: string, projectName: string, taskId: string) {
|
|
72
|
+
const task = await readYaml<Task>(paths(root).taskFile(projectName, taskId));
|
|
73
|
+
if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${taskId}`);
|
|
74
|
+
return task;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readBehavioralReviews(root: string, projectName: string, taskId: string): Promise<BehavioralReviewHistory | undefined> {
|
|
78
|
+
const file = paths(root).behavioralReviewFile(projectName, taskId);
|
|
79
|
+
if (!await exists(file)) return undefined;
|
|
80
|
+
const history = await readYaml<BehavioralReviewHistory>(file);
|
|
81
|
+
if (history.schema !== "atdd-workflow/behavioral-reviews/v1") throw new Error(`Unsupported behavioral review schema: ${taskId}`);
|
|
82
|
+
return history;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function writeBehavioralReviews(root: string, projectName: string, taskId: string, history: BehavioralReviewHistory) {
|
|
86
|
+
await atomicYaml(paths(root).behavioralReviewFile(projectName, taskId), history);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function ownerForTask(root: string, task: Task) {
|
|
90
|
+
return seat(root, required(task.assignee, "a task assignee for behavioral review"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function explicitWorkflowProfile(worktree: string) {
|
|
94
|
+
const file = join(worktree, "atdd-bun.yaml");
|
|
95
|
+
if (!await exists(file)) return false;
|
|
96
|
+
const config = await readYaml<{ profiles?: string[] }>(file);
|
|
97
|
+
return Array.isArray(config.profiles) && config.profiles.includes("workflow");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function behavioralReviewRequired(root: string, task: Task) {
|
|
101
|
+
if (!task.assignee) return false;
|
|
102
|
+
const owner = await ownerForTask(root, task);
|
|
103
|
+
const config = await project(root, owner.project);
|
|
104
|
+
const policyRoot = config.repository ?? (await seat(root, task.coordinator)).worktree;
|
|
105
|
+
return explicitWorkflowProfile(policyRoot);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function currentHead(worktree: string) {
|
|
109
|
+
return runOutput(["git", "rev-parse", "HEAD"], worktree);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function assertCleanDelivery(worktree: string) {
|
|
113
|
+
const dirty = await runOutput(["git", "status", "--porcelain"], worktree);
|
|
114
|
+
if (dirty) throw new Error("Final behavioral review requires a clean committed delivery worktree.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function changedFiles(worktree: string, base: string) {
|
|
118
|
+
const outputs = await Promise.allSettled([
|
|
119
|
+
runOutput(["git", "diff", "--name-only", `${base}...HEAD`], worktree),
|
|
120
|
+
runOutput(["git", "diff", "--name-only"], worktree),
|
|
121
|
+
runOutput(["git", "diff", "--name-only", "--cached"], worktree),
|
|
122
|
+
runOutput(["git", "ls-files", "--others", "--exclude-standard"], worktree),
|
|
123
|
+
]);
|
|
124
|
+
return [...new Set(outputs.flatMap((output) => output.status === "fulfilled"
|
|
125
|
+
? output.value.split("\n").map((entry) => entry.trim()).filter(Boolean)
|
|
126
|
+
: []
|
|
127
|
+
))].slice(0, 200);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function planArtifacts(worktree: string) {
|
|
131
|
+
try {
|
|
132
|
+
const output = await runOutput(["git", "ls-files", "plan"], worktree);
|
|
133
|
+
return output.split("\n").map((entry) => entry.trim()).filter((entry) => entry.endsWith(".yaml")).slice(0, 400);
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function convention(worktree: string) {
|
|
140
|
+
const packageRoot = join(worktree, "node_modules", "@afokapu", "atdd-bun");
|
|
141
|
+
const file = join(packageRoot, conventionRelativePath);
|
|
142
|
+
if (!await exists(file)) {
|
|
143
|
+
throw new Error(`Installed ATDD Bun does not provide ${conventionRule}; upgrade @afokapu/atdd-bun before final behavioral review.`);
|
|
144
|
+
}
|
|
145
|
+
const packageJson = await readFile(join(packageRoot, "package.json"), "utf8");
|
|
146
|
+
const packageVersion = String(JSON.parse(packageJson).version ?? "unknown");
|
|
147
|
+
return { path: conventionRelativePath, absolutePath: file, packageVersion, text: await readFile(file, "utf8") };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function conservativeRouting(): Extract<ReviewRoutingResponse, { available: true }> {
|
|
151
|
+
return {
|
|
152
|
+
available: true,
|
|
153
|
+
classification: "SYSTEM",
|
|
154
|
+
confidence: 0,
|
|
155
|
+
signals: {
|
|
156
|
+
behaviorEffect: "BEHAVIOR_AFFECTING",
|
|
157
|
+
proofBoundary: "USER_JOURNEY",
|
|
158
|
+
crossesMultiplePaths: true,
|
|
159
|
+
consequence: "HIGH_CONSEQUENCE",
|
|
160
|
+
reconciliation: "MULTI_PATH_SYSTEM",
|
|
161
|
+
runtimeObservation: "NOT_AVAILABLE",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function route(
|
|
167
|
+
root: string,
|
|
168
|
+
projectName: string,
|
|
169
|
+
taskId: string,
|
|
170
|
+
task: Task,
|
|
171
|
+
owner: Seat,
|
|
172
|
+
options: JudgmentOptions = {},
|
|
173
|
+
) {
|
|
174
|
+
const config = await project(root, projectName);
|
|
175
|
+
const role = config.roles[owner.role];
|
|
176
|
+
const base = role?.base ?? config.roles.coordinator?.branch ?? "main";
|
|
177
|
+
const [files, plans] = await Promise.all([changedFiles(owner.worktree, base), planArtifacts(owner.worktree)]);
|
|
178
|
+
const judgment = await routeBehavioralReview({
|
|
179
|
+
title: task.title,
|
|
180
|
+
...(task.body ? { body: task.body } : {}),
|
|
181
|
+
...(task.source ? { source: task.source } : {}),
|
|
182
|
+
doneWhen: task.done_when,
|
|
183
|
+
changedFiles: files,
|
|
184
|
+
planArtifacts: plans,
|
|
185
|
+
}, options);
|
|
186
|
+
return {
|
|
187
|
+
routing: judgment.available ? judgment : conservativeRouting(),
|
|
188
|
+
files,
|
|
189
|
+
plans,
|
|
190
|
+
base,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function reviewAddress(projectName: string, taskId: string) {
|
|
195
|
+
return `reviewer.${taskId}@${projectName}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function writeReviewerSeat(root: string, projectName: string, taskId: string, owner: Seat) {
|
|
199
|
+
const address = reviewAddress(projectName, taskId);
|
|
200
|
+
const file = paths(root).seatFile(address);
|
|
201
|
+
const previous = await exists(file) ? await readYaml<Seat>(file) : undefined;
|
|
202
|
+
const record: Seat = {
|
|
203
|
+
schema: "atdd-workflow/seat/v2",
|
|
204
|
+
address,
|
|
205
|
+
role: "reviewer",
|
|
206
|
+
project: projectName,
|
|
207
|
+
worktree: owner.worktree,
|
|
208
|
+
branch: owner.branch,
|
|
209
|
+
purpose: `Perform final behavioral reconciliation for ${projectName}/${taskId}; never mutate task state.`,
|
|
210
|
+
...(previous?.runtime ? { runtime: previous.runtime } : {}),
|
|
211
|
+
};
|
|
212
|
+
await atomicYaml(file, record);
|
|
213
|
+
return record;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function reviewerNotice(projectName: string, taskId: string, attempt: BehavioralReviewAttempt, conventionText: string) {
|
|
217
|
+
const resultPath = `/tmp/atdd-workflow-${taskId}-behavioral-review.yaml`;
|
|
218
|
+
return [
|
|
219
|
+
`SYSTEM: you are ${attempt.reviewer.address}. You are the independent final behavioral reviewer for ${projectName}/${taskId}.`,
|
|
220
|
+
"Do not mutate task state, acceptance intent, or implementation. Perform only the final semantic reconciliation defined below.",
|
|
221
|
+
"",
|
|
222
|
+
"AUTHORITATIVE METHOD (ATDD Bun):",
|
|
223
|
+
conventionText,
|
|
224
|
+
"",
|
|
225
|
+
"READ INPUTS IN THIS ORDER:",
|
|
226
|
+
`1. Task criteria/source: atdd-flow task open ${projectName} ${taskId}`,
|
|
227
|
+
`2. Plan artifacts: ${attempt.input.plan_artifacts.join(", ") || "(none discovered)"}`,
|
|
228
|
+
`3. Deterministic gates: ${attempt.input.deterministic_gates.join("; ")}`,
|
|
229
|
+
`4. Executable proof: ${attempt.input.executable_proof.join("; ") || "(none)"}`,
|
|
230
|
+
`5. Runtime/worktree: ${attempt.input.runtime.worktree} @ ${attempt.input.runtime.head}`,
|
|
231
|
+
`6. Implementation only after reconstructing the behavioral oracle: ${attempt.input.implementation.diff_command}`,
|
|
232
|
+
"",
|
|
233
|
+
`Write the structured review result to ${resultPath} with intent, exercise, observed, reconciliation, decision, rationale, and confidence, then persist it with:`,
|
|
234
|
+
`atdd-flow behavioral-review record ${projectName} ${taskId} --by ${attempt.reviewer.address} --file ${resultPath}`,
|
|
235
|
+
"Allowed decisions: APPROVE, RETURN, ESCALATE. Do not directly return, block, or complete the task.",
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function launchBehavioralReview(
|
|
240
|
+
root: string,
|
|
241
|
+
projectName: string,
|
|
242
|
+
taskId: string,
|
|
243
|
+
args: string[],
|
|
244
|
+
options: JudgmentOptions = {},
|
|
245
|
+
) {
|
|
246
|
+
const task = await readTask(root, projectName, taskId);
|
|
247
|
+
if (task.status !== "review") throw new Error(`Task ${taskId} must be in review before behavioral review.`);
|
|
248
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
249
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may launch final behavioral review for ${taskId}.`);
|
|
250
|
+
if (!task.done_when.every((entry) => Boolean(entry.proof))) throw new Error(`Task ${taskId} is missing delivery proof.`);
|
|
251
|
+
|
|
252
|
+
const owner = await ownerForTask(root, task);
|
|
253
|
+
await assertCleanDelivery(owner.worktree);
|
|
254
|
+
|
|
255
|
+
let gateOutput: string;
|
|
256
|
+
try {
|
|
257
|
+
gateOutput = await runOutput(["atdd-bun"], owner.worktree);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw new Error(`Deterministic ATDD Bun gates are red; do not invoke the semantic reviewer: ${(error as Error).message}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const conventionSource = await convention(owner.worktree);
|
|
263
|
+
const routed = await route(root, projectName, taskId, task, owner, options);
|
|
264
|
+
const config = await desk(root);
|
|
265
|
+
const portfolio = await modelPortfolio(root);
|
|
266
|
+
let modelId: string;
|
|
267
|
+
let agent: string;
|
|
268
|
+
let modelArgs: string[] = [];
|
|
269
|
+
if (portfolio) {
|
|
270
|
+
const candidates = availableModelCandidates(config, portfolio);
|
|
271
|
+
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable for behavioral review.");
|
|
272
|
+
const selection = routed.routing.confidence < 0.75 ? undefined : await selectModel({
|
|
273
|
+
seat: { address: reviewAddress(projectName, taskId), role: "reviewer", purpose: "Final behavioral reconciliation" },
|
|
274
|
+
tasks: [{ id: taskId, title: task.title, status: task.status, ...(task.body ? { body: task.body } : {}), doneWhen: task.done_when.map((entry) => entry.text) }],
|
|
275
|
+
candidates,
|
|
276
|
+
reviewRouting: {
|
|
277
|
+
classification: routed.routing.classification,
|
|
278
|
+
confidence: routed.routing.confidence,
|
|
279
|
+
signals: routed.routing.signals,
|
|
280
|
+
},
|
|
281
|
+
}, options);
|
|
282
|
+
const selected = selection?.available
|
|
283
|
+
? required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`)
|
|
284
|
+
: candidates[0]!;
|
|
285
|
+
const command = resolveModelCommand(config, selected);
|
|
286
|
+
modelId = selected.id;
|
|
287
|
+
agent = command.agent;
|
|
288
|
+
modelArgs = command.args;
|
|
289
|
+
} else {
|
|
290
|
+
const coordinator = await seat(root, task.coordinator);
|
|
291
|
+
const legacyAgent = required(coordinator.agent, "models.yaml or a legacy coordinator agent for behavioral review");
|
|
292
|
+
modelId = legacyAgent;
|
|
293
|
+
agent = resolveExecutable(config, legacyAgent);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const reviewer = await writeReviewerSeat(root, projectName, taskId, owner);
|
|
297
|
+
const head = await currentHead(owner.worktree);
|
|
298
|
+
const history = await readBehavioralReviews(root, projectName, taskId) ?? {
|
|
299
|
+
schema: "atdd-workflow/behavioral-reviews/v1" as const,
|
|
300
|
+
task: `${projectName}/${taskId}`,
|
|
301
|
+
attempts: [],
|
|
302
|
+
};
|
|
303
|
+
if (history.attempts.at(-1)?.status === "pending") throw new Error(`Task ${taskId} already has a pending behavioral review.`);
|
|
304
|
+
|
|
305
|
+
const attempt: BehavioralReviewAttempt = {
|
|
306
|
+
id: id("R"),
|
|
307
|
+
status: "pending",
|
|
308
|
+
created_at: now(),
|
|
309
|
+
delivery_head: head,
|
|
310
|
+
routing: {
|
|
311
|
+
classification: routed.routing.classification,
|
|
312
|
+
confidence: routed.routing.confidence,
|
|
313
|
+
signals: routed.routing.signals,
|
|
314
|
+
},
|
|
315
|
+
reviewer: { address: reviewer.address, model: modelId },
|
|
316
|
+
convention: { rule_id: conventionRule, path: conventionSource.path, package_version: conventionSource.packageVersion },
|
|
317
|
+
input: {
|
|
318
|
+
task: {
|
|
319
|
+
title: task.title,
|
|
320
|
+
...(task.body ? { body: task.body } : {}),
|
|
321
|
+
...(task.source ? { source: task.source } : {}),
|
|
322
|
+
done_when: task.done_when,
|
|
323
|
+
},
|
|
324
|
+
plan_artifacts: routed.plans,
|
|
325
|
+
deterministic_gates: [`atdd-bun: PASS${gateOutput ? ` — ${gateOutput.slice(0, 500)}` : ""}`, ...values(args, "--gate")],
|
|
326
|
+
executable_proof: task.done_when.flatMap((entry) => entry.proof ? [entry.proof] : []),
|
|
327
|
+
runtime: { worktree: owner.worktree, branch: owner.branch, head },
|
|
328
|
+
implementation: {
|
|
329
|
+
changed_files: routed.files,
|
|
330
|
+
diff_command: `git diff ${routed.base}...HEAD`,
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
history.attempts.push(attempt);
|
|
335
|
+
await writeBehavioralReviews(root, projectName, taskId, history);
|
|
336
|
+
|
|
337
|
+
const application = required(words(args, "--application"), "--application");
|
|
338
|
+
const placement = required(words(args, "--placement"), "--placement");
|
|
339
|
+
let output: string;
|
|
340
|
+
try {
|
|
341
|
+
output = await runOutput(launchCommand({
|
|
342
|
+
application,
|
|
343
|
+
placement,
|
|
344
|
+
name: reviewer.address,
|
|
345
|
+
worktree: reviewer.worktree,
|
|
346
|
+
agent,
|
|
347
|
+
args: modelArgs,
|
|
348
|
+
root,
|
|
349
|
+
seat: reviewer.address,
|
|
350
|
+
environment: {
|
|
351
|
+
ATDD_WORKFLOW_REVIEW_TASK: `${projectName}/${taskId}`,
|
|
352
|
+
ATDD_WORKFLOW_REVIEW_ID: attempt.id,
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
history.attempts.pop();
|
|
357
|
+
await writeBehavioralReviews(root, projectName, taskId, history);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const nativeAddress = launchedAddress(application, placement, output);
|
|
361
|
+
await bind(root, reviewer.address, ["--application", application, "--address", nativeAddress], modelId);
|
|
362
|
+
try {
|
|
363
|
+
await notify(application, nativeAddress, reviewerNotice(projectName, taskId, attempt, conventionSource.text));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.warn(`Behavioral review notification for ${reviewer.address} was not delivered: ${(error as Error).message}`);
|
|
366
|
+
}
|
|
367
|
+
console.log(`${attempt.id} ${routed.routing.classification} ${modelId}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function stringArray(value: unknown, label: string) {
|
|
371
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) throw new Error(`${label} must be a string array.`);
|
|
372
|
+
return value as string[];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function stringValue(value: unknown, label: string) {
|
|
376
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`);
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function booleanValue(value: unknown, label: string) {
|
|
381
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be boolean.`);
|
|
382
|
+
return value;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function validateResult(value: unknown): BehavioralReviewResult {
|
|
386
|
+
if (!value || typeof value !== "object") throw new Error("Behavioral review result must be a mapping.");
|
|
387
|
+
const result = value as Record<string, any>;
|
|
388
|
+
const decision = result.decision;
|
|
389
|
+
if (!["APPROVE", "RETURN", "ESCALATE"].includes(decision)) throw new Error("decision must be APPROVE, RETURN, or ESCALATE.");
|
|
390
|
+
const confidence = Number(result.confidence);
|
|
391
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw new Error("confidence must be between 0 and 1.");
|
|
392
|
+
return {
|
|
393
|
+
intent: {
|
|
394
|
+
sources: stringArray(result.intent?.sources, "intent.sources"),
|
|
395
|
+
expected_behavior: stringValue(result.intent?.expected_behavior, "intent.expected_behavior"),
|
|
396
|
+
},
|
|
397
|
+
exercise: {
|
|
398
|
+
boundary: stringValue(result.exercise?.boundary, "exercise.boundary"),
|
|
399
|
+
evidence: stringArray(result.exercise?.evidence, "exercise.evidence"),
|
|
400
|
+
},
|
|
401
|
+
observed: { behavior: stringValue(result.observed?.behavior, "observed.behavior") },
|
|
402
|
+
reconciliation: {
|
|
403
|
+
intent_matches_runtime: booleanValue(result.reconciliation?.intent_matches_runtime, "reconciliation.intent_matches_runtime"),
|
|
404
|
+
proof_matches_intent: booleanValue(result.reconciliation?.proof_matches_intent, "reconciliation.proof_matches_intent"),
|
|
405
|
+
missing_behavior: stringArray(result.reconciliation?.missing_behavior, "reconciliation.missing_behavior"),
|
|
406
|
+
unexpected_behavior: stringArray(result.reconciliation?.unexpected_behavior, "reconciliation.unexpected_behavior"),
|
|
407
|
+
drift: stringArray(result.reconciliation?.drift, "reconciliation.drift"),
|
|
408
|
+
},
|
|
409
|
+
decision,
|
|
410
|
+
rationale: stringValue(result.rationale, "rationale"),
|
|
411
|
+
confidence,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function recordBehavioralReview(root: string, projectName: string, taskId: string, args: string[]) {
|
|
416
|
+
const task = await readTask(root, projectName, taskId);
|
|
417
|
+
if (task.status !== "review") throw new Error(`Task ${taskId} must still be in review when the behavioral result is recorded.`);
|
|
418
|
+
const history = required(await readBehavioralReviews(root, projectName, taskId), `a pending behavioral review for ${taskId}`);
|
|
419
|
+
const attempt = required([...history.attempts].reverse().find((entry) => entry.status === "pending"), `a pending behavioral review for ${taskId}`);
|
|
420
|
+
const actor = required(words(args, "--by"), "--by");
|
|
421
|
+
if (actor !== attempt.reviewer.address) throw new Error(`Only ${attempt.reviewer.address} may record review ${attempt.id}.`);
|
|
422
|
+
const owner = await ownerForTask(root, task);
|
|
423
|
+
await assertCleanDelivery(owner.worktree);
|
|
424
|
+
if (await currentHead(owner.worktree) !== attempt.delivery_head) throw new Error("Delivery HEAD changed during behavioral review; launch a new review against the current delivery.");
|
|
425
|
+
const file = required(words(args, "--file"), "--file");
|
|
426
|
+
const result = validateResult(yaml.parse(await readFile(file, "utf8")));
|
|
427
|
+
attempt.status = "complete";
|
|
428
|
+
attempt.completed_at = now();
|
|
429
|
+
attempt.result = result;
|
|
430
|
+
await writeBehavioralReviews(root, projectName, taskId, history);
|
|
431
|
+
console.log(`${attempt.id} ${result.decision}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function openBehavioralReview(root: string, projectName: string, taskId: string) {
|
|
435
|
+
const history = required(await readBehavioralReviews(root, projectName, taskId), `behavioral review history for ${taskId}`);
|
|
436
|
+
console.log(yaml.print(history));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function assertAcceptedBehavioralReview(root: string, projectName: string, taskId: string, task: Task) {
|
|
440
|
+
if (!await behavioralReviewRequired(root, task)) return;
|
|
441
|
+
const history = required(await readBehavioralReviews(root, projectName, taskId), `final behavioral review for ${taskId}`);
|
|
442
|
+
const latest = required(history.attempts.at(-1), `final behavioral review attempt for ${taskId}`);
|
|
443
|
+
if (latest.status !== "complete" || !latest.result) throw new Error(`Task ${taskId} has no completed final behavioral review.`);
|
|
444
|
+
if (latest.result.decision !== "APPROVE") throw new Error(`Task ${taskId} final behavioral review is ${latest.result.decision}, not APPROVE.`);
|
|
445
|
+
const owner = await ownerForTask(root, task);
|
|
446
|
+
await assertCleanDelivery(owner.worktree);
|
|
447
|
+
if (await currentHead(owner.worktree) !== latest.delivery_head) throw new Error(`Task ${taskId} changed after final behavioral review; review the current delivery before completion.`);
|
|
448
|
+
}
|
package/src/seats.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { discoverAddress, launchedAddress, launchCommand, notify } from "./adapters";
|
|
5
|
+
import {
|
|
6
|
+
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Seat,
|
|
7
|
+
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
|
+
required, run, runOutput, seat, words, yaml,
|
|
9
|
+
} from "./core";
|
|
10
|
+
import { selectModel } from "./judgment";
|
|
11
|
+
import { seatTasks } from "./tasks";
|
|
12
|
+
|
|
13
|
+
const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
|
|
14
|
+
coordinator: {
|
|
15
|
+
address: "coordinator@{project}", branch: "main", worktree: "{repository}",
|
|
16
|
+
...(dynamicModels ? {} : { agent: "claude" }),
|
|
17
|
+
},
|
|
18
|
+
driver: {
|
|
19
|
+
address: "driver.{name}@{project}", branch: "delivery/{name}", base: "main", worktree: "{worktree_root}/{name}",
|
|
20
|
+
...(dynamicModels ? {} : { agent: "codex" }),
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const defaultExecutables = () => ({ claude: "claude", codex: "codex", pi: "pi", kimi: "kimi", glm: "glm" });
|
|
25
|
+
const defaultModels = (): ModelPortfolio => ({
|
|
26
|
+
schema: "atdd-workflow/models/v1",
|
|
27
|
+
models: [
|
|
28
|
+
{ id: "claude", executable: "claude", description: "Default high-capability candidate; replace or refine this portfolio for the local environment." },
|
|
29
|
+
{ id: "codex", executable: "codex", description: "Default lower-cost candidate." },
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Resolve the seat's named agent through the Desk-wide executable registry. */
|
|
34
|
+
export function resolveExecutable(config: Desk, agent: string) {
|
|
35
|
+
return config.executables?.[agent] ?? agent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function init(root: string, name: string, args: string[]) {
|
|
39
|
+
const config = { schema: "atdd-workflow/desk/v1" as const, desk: name, application: "tuios", executables: defaultExecutables() };
|
|
40
|
+
await Promise.all([mkdir(paths(root).work, { recursive: true }), mkdir(paths(root).threads, { recursive: true })]);
|
|
41
|
+
await Promise.all([atomicYaml(paths(root).desk, config), atomicYaml(paths(root).models, defaultModels())]);
|
|
42
|
+
if (args.includes("--git") && !await exists(join(root, ".git"))) await run(["git", "init", "--initial-branch=main", root]);
|
|
43
|
+
console.log(`Initialized Desk ${root}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function migrate(root: string) {
|
|
47
|
+
if (await migrateDesk(root)) console.log("Migrated legacy coordination registry to desk.yaml");
|
|
48
|
+
else console.log("Desk registry already exists");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function initProject(root: string, name: string) {
|
|
52
|
+
await desk(root);
|
|
53
|
+
const config: Project = { schema: "atdd-workflow/project/v1", project: name, roles: defaultRoles(Boolean(await modelPortfolio(root))) };
|
|
54
|
+
await mkdir(paths(root).seats(name), { recursive: true });
|
|
55
|
+
await atomicYaml(paths(root).projectFile(name), config);
|
|
56
|
+
console.log(`Initialized project ${name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ensureWorktree(config: Project, role: Role, worktree: string, branch: string) {
|
|
60
|
+
if (!config.repository || worktree === resolve(config.repository) || await exists(worktree)) return;
|
|
61
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
62
|
+
const ref = `refs/heads/${branch}`;
|
|
63
|
+
const probe = Bun.spawn(["git", "-C", config.repository, "show-ref", "--verify", "--quiet", ref]);
|
|
64
|
+
const branchExists = await probe.exited === 0;
|
|
65
|
+
const command = branchExists
|
|
66
|
+
? ["git", "-C", config.repository, "worktree", "add", worktree, branch]
|
|
67
|
+
: ["git", "-C", config.repository, "worktree", "add", "-b", branch, worktree, role.base ?? "HEAD"];
|
|
68
|
+
await run(command, true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
|
|
72
|
+
const config = await project(root, projectName);
|
|
73
|
+
const role = required(config.roles[roleName], `role ${roleName}`);
|
|
74
|
+
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
75
|
+
const address = fill(role.address, entries);
|
|
76
|
+
const configuredPath = role.worktree ? fill(role.worktree, { ...entries, repository: config.repository ?? "" }) : undefined;
|
|
77
|
+
const worktree = resolve(required(words(args, "--worktree") ?? configuredPath, "--worktree or role worktree template"));
|
|
78
|
+
const branch = words(args, "--branch") ?? fill(role.branch, { project: config.project, name });
|
|
79
|
+
await ensureWorktree(config, role, worktree, branch);
|
|
80
|
+
const purpose = words(args, "--purpose") ?? (role.purpose ? fill(role.purpose, entries) : undefined);
|
|
81
|
+
const portfolio = await modelPortfolio(root);
|
|
82
|
+
const requestedAgent = words(args, "--agent");
|
|
83
|
+
if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
|
|
84
|
+
const legacyAgent = portfolio ? undefined : requestedAgent ?? role.agent;
|
|
85
|
+
const record: Seat = {
|
|
86
|
+
schema: "atdd-workflow/seat/v2", address, role: roleName, project: config.project, worktree, branch,
|
|
87
|
+
...(legacyAgent ? { agent: legacyAgent } : {}), ...(purpose ? { purpose } : {}),
|
|
88
|
+
};
|
|
89
|
+
await atomicYaml(paths(root).seatFile(address), record);
|
|
90
|
+
console.log(address);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function bind(root: string, address: string, args: string[], selectedModel?: string) {
|
|
94
|
+
const resolved = await canonicalAddress(root, address);
|
|
95
|
+
const record = await seat(root, resolved);
|
|
96
|
+
const config = await desk(root);
|
|
97
|
+
const application = words(args, "--application") ?? config.application;
|
|
98
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(application)) throw new Error(`Application must use lowercase letters, numbers, underscores, or hyphens: ${application}`);
|
|
99
|
+
const nativeAddress = required(words(args, "--address"), "--address");
|
|
100
|
+
const addresses = record.runtime?.addresses ?? {};
|
|
101
|
+
record.runtime = {
|
|
102
|
+
...record.runtime,
|
|
103
|
+
application,
|
|
104
|
+
addresses: { ...addresses, [application]: nativeAddress },
|
|
105
|
+
attached_at: now(),
|
|
106
|
+
...(selectedModel ? { model: selectedModel } : {}),
|
|
107
|
+
};
|
|
108
|
+
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
109
|
+
console.log(`Bound ${resolved} to ${application}:${nativeAddress}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function useApplication(root: string, address: string, application: string) {
|
|
113
|
+
const resolved = await canonicalAddress(root, address);
|
|
114
|
+
const record = await seat(root, resolved);
|
|
115
|
+
const runtime = required(record.runtime, `a runtime binding for ${resolved}`);
|
|
116
|
+
if (!runtime.addresses[application]) throw new Error(`${resolved} has no ${application} address. Bind it first.`);
|
|
117
|
+
record.runtime = { ...runtime, application, attached_at: now() };
|
|
118
|
+
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
119
|
+
console.log(`Using ${application}:${record.runtime.addresses[application]} for ${resolved}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function attach(root: string, address: string, args: string[]) {
|
|
123
|
+
const application = words(args, "--application") ?? (await desk(root)).application;
|
|
124
|
+
const nativeAddress = discoverAddress(application);
|
|
125
|
+
await bind(root, address, ["--application", application, "--address", nativeAddress]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
|
|
129
|
+
return portfolio.models.filter((candidate) => {
|
|
130
|
+
if (candidate.enabled === false) return false;
|
|
131
|
+
const executable = resolveExecutable(config, candidate.executable);
|
|
132
|
+
return executable.includes("/") ? existsSync(executable) : Boolean(Bun.which(executable));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
137
|
+
return { agent: resolveExecutable(config, candidate.executable), args: candidate.args ?? [] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio) {
|
|
141
|
+
const candidates = availableModelCandidates(config, portfolio);
|
|
142
|
+
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
143
|
+
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => entry.task.status !== "done");
|
|
144
|
+
const selection = await selectModel({
|
|
145
|
+
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
146
|
+
tasks: work.map((entry) => ({
|
|
147
|
+
id: entry.id,
|
|
148
|
+
title: entry.task.title,
|
|
149
|
+
status: entry.task.status,
|
|
150
|
+
...(entry.task.body ? { body: entry.task.body } : {}),
|
|
151
|
+
doneWhen: entry.task.done_when.map((item) => item.text),
|
|
152
|
+
...(entry.task.blocker ? { blocker: entry.task.blocker } : {}),
|
|
153
|
+
})),
|
|
154
|
+
candidates,
|
|
155
|
+
});
|
|
156
|
+
if (!selection.available) {
|
|
157
|
+
console.warn(`Model selection for ${record.address} unavailable: ${selection.reason} Falling back to strongest available model.`);
|
|
158
|
+
return candidates[0]!;
|
|
159
|
+
}
|
|
160
|
+
return required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const launchNotice = (address: string) =>
|
|
164
|
+
`SYSTEM: you are ${address}. Read your durable seat and assigned task with: atdd-flow open ${address}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
|
|
165
|
+
|
|
166
|
+
export async function launch(root: string, address: string, args: string[]) {
|
|
167
|
+
const resolved = await canonicalAddress(root, address);
|
|
168
|
+
const record = await seat(root, resolved);
|
|
169
|
+
const config = await desk(root);
|
|
170
|
+
const application = required(words(args, "--application"), "--application");
|
|
171
|
+
const placement = required(words(args, "--placement"), "--placement");
|
|
172
|
+
const portfolio = await modelPortfolio(root);
|
|
173
|
+
let agent: string;
|
|
174
|
+
let modelArgs: string[] = [];
|
|
175
|
+
let selectedModel: string;
|
|
176
|
+
if (portfolio) {
|
|
177
|
+
const selected = await chooseLaunchModel(root, config, record, portfolio);
|
|
178
|
+
const command = resolveModelCommand(config, selected);
|
|
179
|
+
agent = command.agent;
|
|
180
|
+
modelArgs = command.args;
|
|
181
|
+
selectedModel = selected.id;
|
|
182
|
+
} else {
|
|
183
|
+
const legacyAgent = required(record.agent, "models.yaml or a legacy seat agent");
|
|
184
|
+
agent = resolveExecutable(config, legacyAgent);
|
|
185
|
+
selectedModel = legacyAgent;
|
|
186
|
+
}
|
|
187
|
+
const output = await runOutput(launchCommand({
|
|
188
|
+
application, placement, name: resolved, worktree: record.worktree,
|
|
189
|
+
agent, args: modelArgs, root, seat: resolved,
|
|
190
|
+
}));
|
|
191
|
+
const nativeAddress = launchedAddress(application, placement, output);
|
|
192
|
+
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel);
|
|
193
|
+
const notice = launchNotice(resolved);
|
|
194
|
+
try { await notify(application, nativeAddress, notice); }
|
|
195
|
+
catch (error) { console.warn(`Launch notification for ${resolved} was not delivered: ${(error as Error).message}`); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function describe(root: string, address: string, args: string[]) {
|
|
199
|
+
const resolved = await canonicalAddress(root, address);
|
|
200
|
+
const record = await seat(root, resolved);
|
|
201
|
+
record.purpose = required(words(args, "--purpose"), "--purpose");
|
|
202
|
+
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
203
|
+
console.log(`Described ${resolved}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function checkpoint(root: string, address: string, args: string[]) {
|
|
207
|
+
const resolved = await canonicalAddress(root, address);
|
|
208
|
+
await seat(root, resolved);
|
|
209
|
+
const references = words(args, "--references")?.split(",").filter(Boolean);
|
|
210
|
+
const record: Checkpoint = {
|
|
211
|
+
schema: "atdd-workflow/checkpoint/v1", seat: resolved,
|
|
212
|
+
status: (words(args, "--status") ?? "active") as Checkpoint["status"], updated_at: now(),
|
|
213
|
+
summary: required(words(args, "--summary"), "--summary"), next_action: required(words(args, "--next"), "--next"),
|
|
214
|
+
...(references?.length ? { references } : {}),
|
|
215
|
+
};
|
|
216
|
+
await atomicYaml(paths(root).checkpointFile(resolved), record);
|
|
217
|
+
console.log(`Checkpointed ${resolved}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function openSeat(root: string, address: string) {
|
|
221
|
+
const resolved = await canonicalAddress(root, address);
|
|
222
|
+
const record = await seat(root, resolved);
|
|
223
|
+
console.log(yaml.print(record));
|
|
224
|
+
const checkpointFile = paths(root).checkpointFile(resolved);
|
|
225
|
+
if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
|
|
226
|
+
const threadIds = await readdir(paths(root).threads);
|
|
227
|
+
for (const threadId of threadIds) {
|
|
228
|
+
const file = paths(root).threadFile(threadId);
|
|
229
|
+
if (!await exists(file)) continue;
|
|
230
|
+
const entry = await readYaml<{ participants: string[]; state: string; subject: string }>(file);
|
|
231
|
+
if (entry.participants.includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
|
|
232
|
+
}
|
|
233
|
+
}
|