@hizliemre/horse-code 0.3.0 → 0.4.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/dist/{app-5FXHE7GX.js → app-KR7TAHUD.js} +82 -40
- package/dist/{chunk-LNW557IO.js → chunk-372X5HHU.js} +2 -2
- package/dist/{chunk-XEGQT5EN.js → chunk-4M6LXNG2.js} +1 -1
- package/dist/{chunk-6OSEQOYY.js → chunk-6S4WWQMN.js} +2 -2
- package/dist/chunk-ACTVFJRW.js +989 -0
- package/dist/{chunk-LLL7QWXB.js → chunk-BFIZMM4G.js} +6 -6
- package/dist/chunk-CYLPQWIF.js +214 -0
- package/dist/{chunk-AE36LLL2.js → chunk-JLWQCA7B.js} +2 -209
- package/dist/{chunk-XYZVZPAY.js → chunk-KXBYRU4W.js} +66 -31
- package/dist/chunk-MZM24M5M.js +251 -0
- package/dist/chunk-NBTH2VVI.js +1945 -0
- package/dist/chunk-PIG54WFU.js +2873 -0
- package/dist/{run-P6ZYL5JL.js → chunk-QJYVZPLG.js} +133 -389
- package/dist/{chunk-YPZP7LYL.js → chunk-UANNVVIU.js} +1 -1
- package/dist/{chunk-UGESK765.js → chunk-UTHLEW5V.js} +1 -1
- package/dist/{chunk-KAGKX2YT.js → chunk-ZPJP2VH5.js} +10 -1
- package/dist/cli.js +262 -69
- package/dist/{fix-ONLA45HD.js → fix-SSDUVV4T.js} +11 -8
- package/dist/{ongoing-WHYXPW24.js → ongoing-6NUSPSCV.js} +3 -2
- package/dist/{project-graph-5HNPRFQG.js → project-graph-OGIM2B33.js} +1 -1
- package/dist/research-MHBNZ6SA.js +111 -0
- package/dist/run-V5ZLZ3LS.js +274 -0
- package/dist/{save-skills-ZW5GY6KV.js → save-skills-NPKTYNAF.js} +2 -2
- package/dist/{trace-X6TU3AG6.js → trace-UVMZZRA5.js} +1 -1
- package/dist/{trace-adopt-URECQWJV.js → trace-adopt-7HWELJFE.js} +1 -1
- package/dist/{trace-run-7U4WJZ3V.js → trace-run-OBOD552Q.js} +9 -4
- package/dist/{triage-FCYHD2AQ.js → triage-IFCVL5MA.js} +7 -6
- package/dist/{verify-LC57A6H2.js → verify-2DTBG6RG.js} +26 -20
- package/package.json +1 -1
- package/dist/chunk-MRZVA5JB.js +0 -163
- package/dist/chunk-UEWVVN5L.js +0 -5691
- package/dist/{chunk-EAF22QIG.js → chunk-JR2JLRE3.js} +3 -3
|
@@ -0,0 +1,2873 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODE_REVIEW_MAX_TURNS,
|
|
3
|
+
CODE_REVIEW_TIMEOUT_MS,
|
|
4
|
+
buildRememberTool,
|
|
5
|
+
constitutionNote,
|
|
6
|
+
createWebFetchTool,
|
|
7
|
+
describeDiff,
|
|
8
|
+
diffSince,
|
|
9
|
+
gitTool,
|
|
10
|
+
partitionByConfidence,
|
|
11
|
+
readOnlyRegistry,
|
|
12
|
+
routeSkills,
|
|
13
|
+
taskDiff,
|
|
14
|
+
workingTreeDiff
|
|
15
|
+
} from "./chunk-NBTH2VVI.js";
|
|
16
|
+
import {
|
|
17
|
+
RoleRegistry,
|
|
18
|
+
cliFor,
|
|
19
|
+
cliInvocation,
|
|
20
|
+
grokEffort,
|
|
21
|
+
planFor,
|
|
22
|
+
runTraces
|
|
23
|
+
} from "./chunk-ACTVFJRW.js";
|
|
24
|
+
import {
|
|
25
|
+
applySkills,
|
|
26
|
+
buildSkillTool,
|
|
27
|
+
placedSkills
|
|
28
|
+
} from "./chunk-MZM24M5M.js";
|
|
29
|
+
import {
|
|
30
|
+
SYNTHETIC,
|
|
31
|
+
runCliAgent
|
|
32
|
+
} from "./chunk-G45RWL7S.js";
|
|
33
|
+
import {
|
|
34
|
+
defaultGitRunner
|
|
35
|
+
} from "./chunk-LPQU436C.js";
|
|
36
|
+
import {
|
|
37
|
+
BATCH_TOOLS_NOTE,
|
|
38
|
+
contextTools,
|
|
39
|
+
emitBatchInjection,
|
|
40
|
+
globTool,
|
|
41
|
+
grepTool,
|
|
42
|
+
memoryHints,
|
|
43
|
+
projectToolsNote,
|
|
44
|
+
readFileTool,
|
|
45
|
+
reinforceTouched,
|
|
46
|
+
reinforceUsed
|
|
47
|
+
} from "./chunk-BFIZMM4G.js";
|
|
48
|
+
import {
|
|
49
|
+
ToolRegistry,
|
|
50
|
+
runStructuredRole
|
|
51
|
+
} from "./chunk-CYLPQWIF.js";
|
|
52
|
+
import {
|
|
53
|
+
handedOver,
|
|
54
|
+
runToCompletion,
|
|
55
|
+
telemetry
|
|
56
|
+
} from "./chunk-JLWQCA7B.js";
|
|
57
|
+
import {
|
|
58
|
+
loadTraceIndex,
|
|
59
|
+
pruneTraces,
|
|
60
|
+
saveTraceIndex,
|
|
61
|
+
sharedDerived,
|
|
62
|
+
traceRootRel,
|
|
63
|
+
traceable
|
|
64
|
+
} from "./chunk-ZPJP2VH5.js";
|
|
65
|
+
import {
|
|
66
|
+
buildProjectGraph,
|
|
67
|
+
loadGraphSync,
|
|
68
|
+
pruneAreaNames
|
|
69
|
+
} from "./chunk-4M6LXNG2.js";
|
|
70
|
+
import {
|
|
71
|
+
writableStateRoot
|
|
72
|
+
} from "./chunk-6W4UH2BQ.js";
|
|
73
|
+
|
|
74
|
+
// src/agent/deadline.ts
|
|
75
|
+
function withDeadline(work, signal, message) {
|
|
76
|
+
work.catch(() => {
|
|
77
|
+
});
|
|
78
|
+
return Promise.race([work, expired(signal, message)]);
|
|
79
|
+
}
|
|
80
|
+
function expired(signal, message) {
|
|
81
|
+
return new Promise((_, reject) => {
|
|
82
|
+
if (signal.aborted) {
|
|
83
|
+
reject(new Error(message));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
signal.addEventListener("abort", () => reject(new Error(message)), { once: true });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
var SHORT_CALL_MS = 5 * 60 * 1e3;
|
|
90
|
+
var LONG_CALL_MS = 15 * 60 * 1e3;
|
|
91
|
+
function isCallerAbort(signal) {
|
|
92
|
+
return signal.aborted && signal.reason?.name !== "TimeoutError";
|
|
93
|
+
}
|
|
94
|
+
function isDeadline(signal) {
|
|
95
|
+
return signal.aborted && signal.reason?.name === "TimeoutError";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/agents/cli-provider.ts
|
|
99
|
+
function promptFor(req) {
|
|
100
|
+
const parts = [];
|
|
101
|
+
for (const m of req.messages) {
|
|
102
|
+
if (m.role === "system") {
|
|
103
|
+
parts.push(m.content);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!m.content.trim()) continue;
|
|
107
|
+
parts.push(m.role === "assistant" ? `[your previous reply]
|
|
108
|
+
${m.content}` : m.content);
|
|
109
|
+
}
|
|
110
|
+
const submit = req.tools?.find((t) => t.name === "submit");
|
|
111
|
+
if (submit) {
|
|
112
|
+
parts.push(
|
|
113
|
+
"Reply with ONE JSON object and nothing else \u2014 no prose before or after it, no code fence. It must satisfy this schema:\n" + JSON.stringify(submit.parameters ?? {}, null, 2)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return parts.join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
function isLoggedOut(text) {
|
|
119
|
+
return /not logged in|not signed in|please run \/login/i.test(text);
|
|
120
|
+
}
|
|
121
|
+
async function* streamWhileRunning(start) {
|
|
122
|
+
const queue = [];
|
|
123
|
+
let finished = false;
|
|
124
|
+
let wake = () => {
|
|
125
|
+
};
|
|
126
|
+
let waiter = new Promise((r) => {
|
|
127
|
+
wake = r;
|
|
128
|
+
});
|
|
129
|
+
const bump = () => {
|
|
130
|
+
const w = wake;
|
|
131
|
+
waiter = new Promise((r) => {
|
|
132
|
+
wake = r;
|
|
133
|
+
});
|
|
134
|
+
w();
|
|
135
|
+
};
|
|
136
|
+
const done = start((ev) => {
|
|
137
|
+
queue.push(ev);
|
|
138
|
+
bump();
|
|
139
|
+
});
|
|
140
|
+
let failure;
|
|
141
|
+
void done.then(() => {
|
|
142
|
+
finished = true;
|
|
143
|
+
bump();
|
|
144
|
+
}, (e) => {
|
|
145
|
+
failure = e;
|
|
146
|
+
finished = true;
|
|
147
|
+
bump();
|
|
148
|
+
});
|
|
149
|
+
for (; ; ) {
|
|
150
|
+
while (queue.length) yield queue.shift();
|
|
151
|
+
if (finished) break;
|
|
152
|
+
await waiter;
|
|
153
|
+
}
|
|
154
|
+
if (failure) throw failure;
|
|
155
|
+
}
|
|
156
|
+
var CliProvider = class {
|
|
157
|
+
fixed;
|
|
158
|
+
readOnly;
|
|
159
|
+
cwd;
|
|
160
|
+
accounts;
|
|
161
|
+
constructor(opts = {}) {
|
|
162
|
+
this.fixed = opts.kind;
|
|
163
|
+
this.readOnly = opts.readOnly ?? true;
|
|
164
|
+
this.cwd = opts.cwd;
|
|
165
|
+
this.accounts = opts.accounts;
|
|
166
|
+
}
|
|
167
|
+
async *chat(req, signal) {
|
|
168
|
+
const kind = this.fixed ?? cliFor(req.model);
|
|
169
|
+
if (!kind) {
|
|
170
|
+
yield { type: "error", message: `no CLI serves ${req.model} \u2014 it is not available in this catalog`, retryable: true };
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const args = [];
|
|
174
|
+
const { model, effort: named } = cliInvocation(req.model);
|
|
175
|
+
if (model) args.push("--model", model);
|
|
176
|
+
const effort = req.effort ?? named;
|
|
177
|
+
if (effort && (kind === "claude" || kind === "zai")) args.push("--effort", effort);
|
|
178
|
+
if (effort && kind === "grok") {
|
|
179
|
+
const level = grokEffort(effort);
|
|
180
|
+
if (level) args.push("--reasoning-effort", level);
|
|
181
|
+
}
|
|
182
|
+
if (this.readOnly && (kind === "claude" || kind === "zai")) args.push("--disallowed-tools", "Write", "Edit", "NotebookEdit");
|
|
183
|
+
if (this.readOnly && kind === "codex") args.push("--sandbox", "read-only");
|
|
184
|
+
if (this.readOnly && kind === "grok") args.push("--disallowed-tools", "write,search_replace");
|
|
185
|
+
if (!this.readOnly && (kind === "claude" || kind === "zai")) args.push("--permission-mode", "acceptEdits");
|
|
186
|
+
if (!this.readOnly && kind === "codex") args.push("--sandbox", "workspace-write");
|
|
187
|
+
if (!this.readOnly && kind === "grok") args.push("--permission-mode", "acceptEdits");
|
|
188
|
+
const account = this.accounts?.pick(kind);
|
|
189
|
+
let res;
|
|
190
|
+
yield* streamWhileRunning((push) => runCliAgent({
|
|
191
|
+
kind,
|
|
192
|
+
cwd: this.cwd ?? process.cwd(),
|
|
193
|
+
prompt: promptFor(req),
|
|
194
|
+
signal,
|
|
195
|
+
args,
|
|
196
|
+
...account ? { configDir: account.configDir } : {},
|
|
197
|
+
onEvent: (ev) => {
|
|
198
|
+
if (ev.tool) push({ type: "activity", tool: ev.tool.name, ...ev.tool.target ? { target: ev.tool.target } : {}, ...ev.tool.ok === false ? { ok: false } : {} });
|
|
199
|
+
if (ev.text) push({ type: "text-delta", text: ev.text });
|
|
200
|
+
if (ev.quota && account) this.accounts?.record(kind, account.name, ev.quota.windows);
|
|
201
|
+
}
|
|
202
|
+
}).then((r) => {
|
|
203
|
+
res = r;
|
|
204
|
+
}));
|
|
205
|
+
if (res.rateLimited) {
|
|
206
|
+
yield { type: "error", message: `${kind} CLI: ${res.rateLimited}`, retryable: true };
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (res.served === SYNTHETIC) {
|
|
210
|
+
const loggedOut = isLoggedOut(res.text);
|
|
211
|
+
yield {
|
|
212
|
+
type: "error",
|
|
213
|
+
retryable: true,
|
|
214
|
+
message: loggedOut ? `${kind} CLI is not logged in${account ? ` under profile "${account.name}"` : ""} \u2014 run \`hcode add-provider ${kind}\` to sign it in again` : `${kind} CLI did not recognise ${req.model} and answered without a model`
|
|
215
|
+
};
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (isCallerAbort(signal)) {
|
|
219
|
+
yield { type: "error", message: "cancelled", retryable: false };
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (isDeadline(signal)) {
|
|
223
|
+
yield { type: "error", message: `${kind} CLI: deadline expired`, retryable: true };
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (res.error && !res.text.trim()) {
|
|
227
|
+
if (isLoggedOut(res.error)) {
|
|
228
|
+
yield {
|
|
229
|
+
type: "error",
|
|
230
|
+
retryable: true,
|
|
231
|
+
message: `${kind} CLI is not logged in${account ? ` under profile "${account.name}"` : ""} \u2014 run \`hcode add-provider ${kind}\` to sign it in again`
|
|
232
|
+
};
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
yield { type: "error", message: `${kind} CLI: ${res.error}`, retryable: res.exitCode !== 0 };
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (res.usage) yield usageEvent(res.usage);
|
|
239
|
+
yield { type: "done", finishReason: "stop" };
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function usageEvent(u) {
|
|
243
|
+
return {
|
|
244
|
+
type: "usage",
|
|
245
|
+
promptTokens: u.freshTokens,
|
|
246
|
+
completionTokens: u.outputTokens,
|
|
247
|
+
cachedTokens: u.cachedTokens,
|
|
248
|
+
cacheWriteTokens: u.cacheWriteTokens
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/tools/write.ts
|
|
253
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
254
|
+
import { existsSync } from "fs";
|
|
255
|
+
import { dirname, resolve, sep } from "path";
|
|
256
|
+
import { z } from "zod";
|
|
257
|
+
var params = z.object({ path: z.string(), content: z.string() });
|
|
258
|
+
var writeFileTool = {
|
|
259
|
+
name: "write_file",
|
|
260
|
+
description: "Writes content to a file (creates parent directories). Creating a NEW file is always allowed; to OVERWRITE an existing file you must read_file it first in this run \u2014 otherwise the write is refused.",
|
|
261
|
+
permissionLevel: "write",
|
|
262
|
+
parameters: params,
|
|
263
|
+
describe(rawArgs) {
|
|
264
|
+
const a = params.parse(rawArgs);
|
|
265
|
+
return { allowKey: a.path, preview: `write ${a.path} (${Buffer.byteLength(a.content)} bytes)` };
|
|
266
|
+
},
|
|
267
|
+
async run(rawArgs, ctx) {
|
|
268
|
+
const parsed = params.safeParse(rawArgs);
|
|
269
|
+
if (!parsed.success) {
|
|
270
|
+
return {
|
|
271
|
+
content: `write_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
|
|
272
|
+
isError: true
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const a = parsed.data;
|
|
276
|
+
const target = resolve(ctx.cwd, a.path);
|
|
277
|
+
const cwdResolved = resolve(ctx.cwd);
|
|
278
|
+
if (target !== cwdResolved && !target.startsWith(cwdResolved + sep)) {
|
|
279
|
+
return { content: `write_file: path is outside cwd: ${a.path}`, isError: true };
|
|
280
|
+
}
|
|
281
|
+
if (ctx.readFiles && existsSync(target) && !ctx.readFiles.has(target)) {
|
|
282
|
+
return {
|
|
283
|
+
content: `write_file: refusing to overwrite ${a.path} \u2014 read_file it first so you know what you are replacing (or use edit_file for a targeted change).`,
|
|
284
|
+
isError: true
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await mkdir(dirname(target), { recursive: true });
|
|
289
|
+
await writeFile(target, a.content, "utf8");
|
|
290
|
+
{
|
|
291
|
+
const ls = a.content ? a.content.split("\n") : [];
|
|
292
|
+
ctx.onActivity?.({ tool: "write", target: a.path, lines: ls.length, preview: ls.slice(0, 12), startLine: 1 });
|
|
293
|
+
}
|
|
294
|
+
ctx.readFiles?.add(target);
|
|
295
|
+
return { content: `Written: ${a.path}`, isError: false };
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return {
|
|
298
|
+
content: `write_file error: ${e instanceof Error ? e.message : String(e)}`,
|
|
299
|
+
isError: true
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// src/tools/edit.ts
|
|
306
|
+
import { readFile, writeFile as writeFile2 } from "fs/promises";
|
|
307
|
+
import { resolve as resolve2, sep as sep2 } from "path";
|
|
308
|
+
import { z as z2 } from "zod";
|
|
309
|
+
var params2 = z2.object({
|
|
310
|
+
path: z2.string(),
|
|
311
|
+
oldString: z2.string(),
|
|
312
|
+
newString: z2.string(),
|
|
313
|
+
replaceAll: z2.boolean().optional()
|
|
314
|
+
});
|
|
315
|
+
var NEAR_MISS_CHARS = 600;
|
|
316
|
+
var MAX_MATCH_LINES = 5;
|
|
317
|
+
var norm = (t) => t.replace(/[ \t]+/g, " ").replace(/[ \t]+$/gm, "").trim();
|
|
318
|
+
function shortPath(path, cwd) {
|
|
319
|
+
const abs = resolve2(cwd, path);
|
|
320
|
+
return abs === cwd ? "." : abs.startsWith(cwd + sep2) ? abs.slice(cwd.length + 1) : path;
|
|
321
|
+
}
|
|
322
|
+
function whyNotFound(content, oldString) {
|
|
323
|
+
if (/^\s*\d+\t/m.test(oldString)) {
|
|
324
|
+
return " \u2014 your oldString still carries read_file's display prefixes (`123\\t\u2026`). Strip the number and the tab from every line and send the file's real bytes.";
|
|
325
|
+
}
|
|
326
|
+
const lines = content.split("\n");
|
|
327
|
+
const wanted = oldString.split("\n");
|
|
328
|
+
const firstReal = wanted.find((l) => l.trim().length > 0)?.trim();
|
|
329
|
+
if (firstReal === void 0) return " \u2014 your oldString is empty or only whitespace.";
|
|
330
|
+
if (norm(content).includes(norm(oldString))) {
|
|
331
|
+
const at2 = lines.findIndex((l) => norm(l) === norm(wanted.find((w) => w.trim())) || norm(l).includes(firstReal));
|
|
332
|
+
const block = at2 >= 0 ? lines.slice(at2, at2 + wanted.length).join("\n") : "";
|
|
333
|
+
return ` \u2014 the text IS in the file, but its whitespace differs from what you sent (tabs vs spaces, or trailing space). Here it is exactly as the file has it, from line ${at2 + 1}:
|
|
334
|
+
` + block.slice(0, NEAR_MISS_CHARS);
|
|
335
|
+
}
|
|
336
|
+
const at = lines.findIndex((l) => l.includes(firstReal));
|
|
337
|
+
if (at >= 0) {
|
|
338
|
+
return ` \u2014 the file has "${firstReal.slice(0, 60)}" at line ${at + 1}, but what follows it is not what you sent. This is what is there now:
|
|
339
|
+
${lines.slice(at, at + wanted.length + 2).join("\n").slice(0, NEAR_MISS_CHARS)}`;
|
|
340
|
+
}
|
|
341
|
+
return " \u2014 no line of your oldString is in the file. Read it again before editing: it has changed since you last saw it, or this is not the file you meant.";
|
|
342
|
+
}
|
|
343
|
+
var editFileTool = {
|
|
344
|
+
name: "edit_file",
|
|
345
|
+
description: "Performs an exact string replacement in a file. oldString must match the file's REAL bytes \u2014 strip the `<number>\\t` prefix that read_file adds for display, or nothing will match. oldString must be unique (otherwise replaceAll is required); a miss is reported as an error, never a silent no-op.",
|
|
346
|
+
permissionLevel: "write",
|
|
347
|
+
parameters: params2,
|
|
348
|
+
describe(rawArgs) {
|
|
349
|
+
const a = params2.parse(rawArgs);
|
|
350
|
+
return { allowKey: a.path, preview: `edit ${a.path}` };
|
|
351
|
+
},
|
|
352
|
+
async run(rawArgs, ctx) {
|
|
353
|
+
const parsed = params2.safeParse(rawArgs);
|
|
354
|
+
if (!parsed.success) {
|
|
355
|
+
return {
|
|
356
|
+
content: `edit_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
|
|
357
|
+
isError: true
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const a = parsed.data;
|
|
361
|
+
const target = resolve2(ctx.cwd, a.path);
|
|
362
|
+
const cwdResolved = resolve2(ctx.cwd);
|
|
363
|
+
if (target !== cwdResolved && !target.startsWith(cwdResolved + sep2)) {
|
|
364
|
+
return { content: `edit_file: path is outside cwd: ${a.path}`, isError: true };
|
|
365
|
+
}
|
|
366
|
+
let content;
|
|
367
|
+
try {
|
|
368
|
+
content = await readFile(target, "utf8");
|
|
369
|
+
} catch (e) {
|
|
370
|
+
return {
|
|
371
|
+
content: `edit_file error: ${e instanceof Error ? e.message : String(e)}`,
|
|
372
|
+
isError: true
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const count = content.split(a.oldString).length - 1;
|
|
376
|
+
if (count === 0) {
|
|
377
|
+
return {
|
|
378
|
+
content: `edit_file: oldString not found (${shortPath(a.path, cwdResolved)})${whyNotFound(content, a.oldString)}`,
|
|
379
|
+
isError: true
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (count > 1 && !a.replaceAll) {
|
|
383
|
+
const lines = content.split("\n");
|
|
384
|
+
const at = [];
|
|
385
|
+
for (let i = 0; i < lines.length && at.length <= MAX_MATCH_LINES; i++) {
|
|
386
|
+
if (lines[i]?.includes(a.oldString.split("\n")[0] ?? "")) at.push(i + 1);
|
|
387
|
+
}
|
|
388
|
+
const where = at.length ? ` First seen at line${at.length > 1 ? "s" : ""} ${at.slice(0, MAX_MATCH_LINES).join(", ")}${at.length > MAX_MATCH_LINES ? ", \u2026" : ""}.` : "";
|
|
389
|
+
return {
|
|
390
|
+
content: `edit_file: oldString matches ${count} places in ${shortPath(a.path, cwdResolved)}.${where} Add surrounding lines to oldString so it names the one you mean \u2014 or pass replaceAll: true if every occurrence should change.`,
|
|
391
|
+
isError: true
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const next = a.replaceAll ? content.split(a.oldString).join(a.newString) : content.replace(a.oldString, a.newString);
|
|
395
|
+
try {
|
|
396
|
+
await writeFile2(target, next, "utf8");
|
|
397
|
+
{
|
|
398
|
+
const added = a.newString ? a.newString.split("\n") : [];
|
|
399
|
+
const removed = a.oldString ? a.oldString.split("\n") : [];
|
|
400
|
+
const at = content.indexOf(a.oldString);
|
|
401
|
+
const startLine = at < 0 ? 1 : content.slice(0, at).split("\n").length;
|
|
402
|
+
ctx.onActivity?.({ tool: "edit", target: a.path, lines: added.length, preview: added.slice(0, 12), removed: removed.slice(0, 12), startLine });
|
|
403
|
+
}
|
|
404
|
+
return { content: `Edited: ${a.path}`, isError: false };
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return {
|
|
407
|
+
content: `edit_file error: ${e instanceof Error ? e.message : String(e)}`,
|
|
408
|
+
isError: true
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// src/tools/shell.ts
|
|
415
|
+
import { spawn } from "child_process";
|
|
416
|
+
import { resolve as resolve3, sep as sep3 } from "path";
|
|
417
|
+
import { z as z3 } from "zod";
|
|
418
|
+
var params3 = z3.object({
|
|
419
|
+
command: z3.string(),
|
|
420
|
+
/** Milliseconds before the command is killed. Defaults to DEFAULT_TIMEOUT_MS, capped at MAX_TIMEOUT_MS. */
|
|
421
|
+
timeout: z3.number().int().positive().optional()
|
|
422
|
+
});
|
|
423
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
424
|
+
var MAX_TIMEOUT_MS = 6e5;
|
|
425
|
+
var KILL_GRACE_MS = 2e3;
|
|
426
|
+
var MAX_SHELL_CHARS = 32e3;
|
|
427
|
+
var TAIL_SHARE = 0.6;
|
|
428
|
+
function clampOutput(body, max = MAX_SHELL_CHARS) {
|
|
429
|
+
if (body.length <= max) return body;
|
|
430
|
+
const tail = Math.floor(max * TAIL_SHARE);
|
|
431
|
+
const head = max - tail;
|
|
432
|
+
const dropped = body.length - max;
|
|
433
|
+
const lines = body.slice(head, body.length - tail).split("\n").length;
|
|
434
|
+
return `${body.slice(0, head)}
|
|
435
|
+
|
|
436
|
+
\u2026 [${dropped.toLocaleString("en-US")} chars / ~${lines} lines trimmed from the middle] \u2026
|
|
437
|
+
|
|
438
|
+
${body.slice(-tail)}`;
|
|
439
|
+
}
|
|
440
|
+
var REWRITES = [
|
|
441
|
+
/\bsed\s+(?:-[a-zA-Z]*\s+)*-i\b/,
|
|
442
|
+
// sed -i
|
|
443
|
+
/\bperl\s+(?:-\w+\s+)*-i\b/,
|
|
444
|
+
// perl -i
|
|
445
|
+
/\bwrite_text\s*\(/,
|
|
446
|
+
// python: Path(...).write_text(...)
|
|
447
|
+
/\bwriteFileSync\s*\(/,
|
|
448
|
+
// node: fs.writeFileSync(...)
|
|
449
|
+
/\bopen\s*\([^)]*['"][wa]\+?['"]\s*\)/,
|
|
450
|
+
// python: open(path, "w")
|
|
451
|
+
/\btee\s+(?!-a\b)/
|
|
452
|
+
// tee <file>
|
|
453
|
+
];
|
|
454
|
+
var REDIRECT = /(?:^|[^0-9<>&])>>?\s*(?!\/dev\/|\/tmp\/|&)([A-Za-z0-9_./-]*\.[A-Za-z0-9]+)/;
|
|
455
|
+
function leavesWorkdir(command, cwd) {
|
|
456
|
+
const base = resolve3(cwd);
|
|
457
|
+
let at = base;
|
|
458
|
+
for (const seg of command.split(/&&|\|\||;|\|/)) {
|
|
459
|
+
const m = /^\s*(?:cd|pushd)(?:\s+(.*))?$/.exec(seg.trim());
|
|
460
|
+
if (!m) continue;
|
|
461
|
+
const raw = (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
462
|
+
if (!raw || raw === "~" || raw === "$HOME" || raw.startsWith("~/")) return raw || "~";
|
|
463
|
+
if (raw === "-") return "-";
|
|
464
|
+
at = resolve3(at, raw);
|
|
465
|
+
if (at !== base && !at.startsWith(base + sep3)) return raw;
|
|
466
|
+
}
|
|
467
|
+
return void 0;
|
|
468
|
+
}
|
|
469
|
+
var DESTROYS_WORK = [
|
|
470
|
+
{ re: /^reset\b[^]*\s--hard\b/, what: "reset --hard" },
|
|
471
|
+
{ re: /^clean\b[^]*\s-\S*[fd]/, what: "clean -f/-d" },
|
|
472
|
+
{ re: /^checkout\b[^]*\s--\s+(?:\.|-A|:\/)\s*$/, what: "checkout -- ." },
|
|
473
|
+
{ re: /^restore\b[^]*\s(?:\.|-A|:\/)\s*$/, what: "restore ." }
|
|
474
|
+
];
|
|
475
|
+
function destroysWork(command) {
|
|
476
|
+
for (const seg of command.split(/&&|\|\||;|\|/)) {
|
|
477
|
+
const m = /^\s*git\s+(.*)$/.exec(seg.trim());
|
|
478
|
+
if (!m) continue;
|
|
479
|
+
const rest = (m[1] ?? "").replace(/^(?:(?:-[cC]|--(?:git-dir|work-tree|namespace|exec-path|config-env))\s+\S+|-\S+)\s+/g, "").trim();
|
|
480
|
+
const hit = DESTROYS_WORK.find((d) => d.re.test(rest));
|
|
481
|
+
if (hit) return hit.what;
|
|
482
|
+
}
|
|
483
|
+
return void 0;
|
|
484
|
+
}
|
|
485
|
+
function rewritesAFile(command) {
|
|
486
|
+
for (const re of REWRITES) if (re.test(command)) return re.source;
|
|
487
|
+
const m = REDIRECT.exec(command);
|
|
488
|
+
return m ? `redirect into ${m[1]}` : void 0;
|
|
489
|
+
}
|
|
490
|
+
var shellTool = {
|
|
491
|
+
name: "shell",
|
|
492
|
+
description: "Runs a shell command (in the cwd context). Returns stdout+stderr and the exit code. Runs NON-INTERACTIVELY (stdin is closed) \u2014 pass non-interactive flags (e.g. --yes, --no-input) or the command will fail rather than wait for input. Killed after `timeout` ms (default 120000, max 600000); do not start long-running watchers or dev servers. To CHANGE a file use `edit_file` or `write_file` \u2014 rewriting one from here (sed -i, a python heredoc, a redirect) is refused: those tools report what changed, and this one cannot.",
|
|
493
|
+
permissionLevel: "exec",
|
|
494
|
+
parameters: params3,
|
|
495
|
+
describe(rawArgs) {
|
|
496
|
+
const a = params3.parse(rawArgs);
|
|
497
|
+
return { allowKey: a.command, preview: a.command };
|
|
498
|
+
},
|
|
499
|
+
run(rawArgs, ctx) {
|
|
500
|
+
const parsed = params3.safeParse(rawArgs);
|
|
501
|
+
const why = parsed.success ? rewritesAFile(parsed.data.command) : void 0;
|
|
502
|
+
if (why !== void 0) {
|
|
503
|
+
return Promise.resolve({
|
|
504
|
+
content: `shell: this command rewrites a file (${why}). Use \`edit_file\` to change part of one, or \`write_file\` to replace it \u2014 they report exactly what changed, so you do not have to read the file back afterwards to find out. Reading, searching and running things are still fine here.`,
|
|
505
|
+
isError: true
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (!parsed.success) {
|
|
509
|
+
return Promise.resolve({
|
|
510
|
+
content: `shell: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
|
|
511
|
+
isError: true
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const wipes = parsed.success ? destroysWork(parsed.data.command) : void 0;
|
|
515
|
+
if (wipes !== void 0) {
|
|
516
|
+
return Promise.resolve({
|
|
517
|
+
content: `shell: \`git ${wipes}\` throws away uncommitted work wholesale, and the \`git\` tool refuses it for that reason \u2014 running it here goes around the same rule. Every file you write is already committed as a \`wip(\u2026)\` checkpoint, so undo by going back to one of those, or revert a NAMED path (\`git checkout -- path/to/file\`), which says in the transcript what was undone.`,
|
|
518
|
+
isError: true
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
const away = leavesWorkdir(parsed.data.command, ctx.cwd);
|
|
522
|
+
if (away !== void 0) {
|
|
523
|
+
return Promise.resolve({
|
|
524
|
+
content: `shell: \`cd ${away}\` leaves this session's working directory. You are in \`${ctx.cwd}\` and everything this task does belongs here \u2014 another checkout of the same repository is someone else's working copy, and anything written there is invisible to the review of THIS change and left behind as clutter. Run the command from here (a \`cd\` into a subdirectory is fine), or point the tool at a path under this directory.`,
|
|
525
|
+
isError: true
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const a = parsed.data;
|
|
529
|
+
return new Promise((resolvePromise) => {
|
|
530
|
+
let child;
|
|
531
|
+
try {
|
|
532
|
+
child = spawn(a.command, {
|
|
533
|
+
cwd: ctx.cwd,
|
|
534
|
+
shell: true,
|
|
535
|
+
signal: ctx.signal,
|
|
536
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
537
|
+
detached: true
|
|
538
|
+
});
|
|
539
|
+
} catch (e) {
|
|
540
|
+
resolvePromise({
|
|
541
|
+
content: `shell error: ${e instanceof Error ? e.message : String(e)}`,
|
|
542
|
+
isError: true
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const budget = Math.min(a.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
547
|
+
let out = "";
|
|
548
|
+
let err = "";
|
|
549
|
+
let timedOut = false;
|
|
550
|
+
let killer;
|
|
551
|
+
const killTree = (sig) => {
|
|
552
|
+
try {
|
|
553
|
+
if (child.pid) process.kill(-child.pid, sig);
|
|
554
|
+
else child.kill(sig);
|
|
555
|
+
} catch {
|
|
556
|
+
try {
|
|
557
|
+
child.kill(sig);
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
const onAbort = () => killTree("SIGKILL");
|
|
563
|
+
ctx.signal?.addEventListener("abort", onAbort, { once: true });
|
|
564
|
+
const timer = setTimeout(() => {
|
|
565
|
+
timedOut = true;
|
|
566
|
+
killTree("SIGTERM");
|
|
567
|
+
killer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
|
|
568
|
+
killer.unref?.();
|
|
569
|
+
}, budget);
|
|
570
|
+
timer.unref?.();
|
|
571
|
+
const done = () => {
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
if (killer) clearTimeout(killer);
|
|
574
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
575
|
+
};
|
|
576
|
+
child.stdout?.on("data", (d) => out += d.toString());
|
|
577
|
+
child.stderr?.on("data", (d) => err += d.toString());
|
|
578
|
+
child.on("error", (e) => {
|
|
579
|
+
done();
|
|
580
|
+
resolvePromise({ content: `shell error: ${e.message}`, isError: true });
|
|
581
|
+
});
|
|
582
|
+
child.on("close", (code) => {
|
|
583
|
+
done();
|
|
584
|
+
const body = clampOutput([out, err].filter((s) => s.length).join("\n").trimEnd());
|
|
585
|
+
const tail = timedOut ? `
|
|
586
|
+
(killed after ${Math.round(budget / 1e3)}s \u2014 it was still running. Use a non-interactive, terminating command; do not start watchers or dev servers.)` : `
|
|
587
|
+
(exit ${code ?? "null"})`;
|
|
588
|
+
resolvePromise({ content: `$ ${a.command}
|
|
589
|
+
${body}${tail}`, isError: timedOut || code !== 0 });
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// src/engine/trace-refresh.ts
|
|
596
|
+
import { existsSync as existsSync2 } from "fs";
|
|
597
|
+
import { join } from "path";
|
|
598
|
+
var IRRELEVANT = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/;
|
|
599
|
+
async function changedByMerge(git, cwd, before, after = "HEAD") {
|
|
600
|
+
if (!before) return [];
|
|
601
|
+
const r = await git(["diff", "--name-only", `${before}..${after}`], cwd);
|
|
602
|
+
if (r.code !== 0) return [];
|
|
603
|
+
return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l && !IRRELEVANT.test(l));
|
|
604
|
+
}
|
|
605
|
+
async function refreshAfterChange(opts) {
|
|
606
|
+
if (!opts.files.length) return;
|
|
607
|
+
try {
|
|
608
|
+
const r = await refreshTraces({
|
|
609
|
+
cwd: opts.cwd,
|
|
610
|
+
files: opts.files,
|
|
611
|
+
provider: opts.provider,
|
|
612
|
+
models: opts.models,
|
|
613
|
+
...opts.signal ? { signal: opts.signal } : {},
|
|
614
|
+
...opts.note ? { note: opts.note } : {}
|
|
615
|
+
});
|
|
616
|
+
const line = describeRefresh(r);
|
|
617
|
+
if (line) opts.note?.(line);
|
|
618
|
+
await commitRefreshed(opts.git ?? defaultGitRunner, opts.cwd, traceRootRel());
|
|
619
|
+
} catch {
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function refreshTraces(opts) {
|
|
623
|
+
const out = { traced: 0, failed: 0, removed: 0, skipped: 0 };
|
|
624
|
+
const candidates = traceable(opts.files);
|
|
625
|
+
out.skipped = opts.files.length - candidates.length;
|
|
626
|
+
const gone = candidates.filter((f) => !existsSync2(join(opts.cwd, f)));
|
|
627
|
+
if (gone.length) {
|
|
628
|
+
try {
|
|
629
|
+
const index = await loadTraceIndex(opts.cwd);
|
|
630
|
+
const kept = new Set(Object.keys(index.traces).filter((f) => !gone.includes(f)));
|
|
631
|
+
out.removed = (await pruneTraces(opts.cwd, kept, index)).length;
|
|
632
|
+
if (out.removed) await saveTraceIndex(opts.cwd, index);
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const targets = candidates.filter((f) => !gone.includes(f));
|
|
637
|
+
if (!targets.length) return out;
|
|
638
|
+
const chain = opts.models.filter(Boolean);
|
|
639
|
+
if (!chain.length) return out;
|
|
640
|
+
try {
|
|
641
|
+
const g = await buildProjectGraph(opts.cwd);
|
|
642
|
+
out.graph = g.message;
|
|
643
|
+
out.staleNames = await pruneAreaNames(opts.cwd);
|
|
644
|
+
const plan = await planFor(opts.cwd, targets);
|
|
645
|
+
if (!plan.jobs.length) {
|
|
646
|
+
out.skipped += targets.length;
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
const res = await runTraces({
|
|
650
|
+
cwd: opts.cwd,
|
|
651
|
+
provider: opts.provider,
|
|
652
|
+
models: chain,
|
|
653
|
+
plan,
|
|
654
|
+
// No liveFiles: this run knows only the files one task changed, and a pruner given that list would
|
|
655
|
+
// read every OTHER trace in the project as orphaned and delete it.
|
|
656
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
657
|
+
});
|
|
658
|
+
out.traced = res.written;
|
|
659
|
+
out.failed = res.failed.length;
|
|
660
|
+
out.skipped += res.upToDate;
|
|
661
|
+
} catch (e) {
|
|
662
|
+
opts.note?.(`Trace refresh failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
function describeRefresh(r) {
|
|
667
|
+
if (!r.traced && !r.failed && !r.removed && !r.staleNames) return void 0;
|
|
668
|
+
const bits = [`\u{1F4DD} ${r.traced} trace(s) refreshed`];
|
|
669
|
+
if (r.removed) bits.push(`${r.removed} removed for deleted file(s)`);
|
|
670
|
+
if (r.staleNames) bits.push(`${r.staleNames} area name(s) dropped \u2014 the rebuild left them pointing at nothing`);
|
|
671
|
+
if (r.failed) bits.push(`${r.failed} failed`);
|
|
672
|
+
return `${bits.join(" \xB7 ")} \u2014 the changed files now describe themselves.`;
|
|
673
|
+
}
|
|
674
|
+
async function commitRefreshed(git, baseWorktree, traceRootRel2) {
|
|
675
|
+
const paths = [traceRootRel2, ...sharedDerived()].filter((p) => existsSync2(join(baseWorktree, p)));
|
|
676
|
+
if (!paths.length) return false;
|
|
677
|
+
const add = await git(["add", "--", ...paths], baseWorktree);
|
|
678
|
+
if (add.code !== 0) return false;
|
|
679
|
+
const staged = await git(["diff", "--cached", "--quiet", "--", ...paths], baseWorktree);
|
|
680
|
+
if (staged.code === 0) return false;
|
|
681
|
+
const r = await git(["commit", "-m", "docs(traces): refresh for the files this task changed", "--", ...paths], baseWorktree);
|
|
682
|
+
return r.code === 0;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/engine/writer-registry.ts
|
|
686
|
+
import { z as z5 } from "zod";
|
|
687
|
+
|
|
688
|
+
// src/engine/normalize-question.ts
|
|
689
|
+
import { z as z4 } from "zod";
|
|
690
|
+
var NormalizedQuestionSchema = z4.object({
|
|
691
|
+
question: z4.string().describe("The core question, concise, WITHOUT the embedded options table/list."),
|
|
692
|
+
options: z4.array(z4.string()).describe("Each selectable choice as a SHORT label; the recommended one first, suffixed ' (recommended)'. Empty when the question is genuinely open-ended."),
|
|
693
|
+
multiSelect: z4.boolean().describe("true only if the user may pick more than one.")
|
|
694
|
+
});
|
|
695
|
+
var PROMPT = "You reformat an agent's question for a terminal UI that renders selectable options (arrow keys + Enter). Given the raw question text \u2014 which may embed choices as a markdown table, an A/B/C/D list, or a 'recommended' suggestion \u2014 extract exactly:\n- `question`: the core question, concise, WITHOUT the embedded options table/list.\n- `options`: each selectable choice as a SHORT label. If one choice is recommended, list it FIRST and append ' (recommended)'. Do NOT add an 'other' / free-text / 'answer in your own words' option \u2014 the UI already provides that.\n- `multiSelect`: true only if the user may pick several.\nIf the text is genuinely open-ended (no discrete choices), return options: []. Preserve the user's language. Return the result via submit.";
|
|
696
|
+
function looksLikeChoices(text) {
|
|
697
|
+
return /\|[^\n]*\|[^\n]*\|/.test(text) || /(^|\n)\s*[A-Ea-e][).\-:]\s/.test(text) || /(^|\n)\s*[-*]\s+\S.*(\n\s*[-*]\s+\S.*){1,}/.test(text) || /\b(option|seçenek|choice|önerilen|recommended)\b/i.test(text);
|
|
698
|
+
}
|
|
699
|
+
async function normalizeQuestion(deps, raw) {
|
|
700
|
+
const { role: agentRole, model, fallbacks, onExhausted, onFallback } = deps.roleRegistry.fallbackOpts("refiner");
|
|
701
|
+
return runStructuredRole({
|
|
702
|
+
provider: deps.provider,
|
|
703
|
+
role: agentRole,
|
|
704
|
+
model,
|
|
705
|
+
fallbacks,
|
|
706
|
+
onExhausted,
|
|
707
|
+
onFallback,
|
|
708
|
+
// This shapes the question the USER reads; a rule like "always ask in Turkish" belongs here.
|
|
709
|
+
systemPrompt: PROMPT + deps.roleRegistry.ruleSuffix(),
|
|
710
|
+
tools: new ToolRegistry(),
|
|
711
|
+
messages: [{ role: "user", content: raw }],
|
|
712
|
+
permission: deps.permission,
|
|
713
|
+
approve: deps.approve,
|
|
714
|
+
cwd: ".",
|
|
715
|
+
signal: deps.signal
|
|
716
|
+
}, NormalizedQuestionSchema);
|
|
717
|
+
}
|
|
718
|
+
function extractChoicesFrom(text) {
|
|
719
|
+
const lines = text.split("\n");
|
|
720
|
+
const rows = lines.map((l) => l.trim()).filter((l) => l.startsWith("|") && l.endsWith("|") && !/^\|[\s|:-]+\|$/.test(l)).map((l) => l.slice(1, -1).split("|").map((c) => c.trim()));
|
|
721
|
+
const body = rows.filter((cells) => cells.length >= 2 && cells[0] && cells[1]).filter((cells, i) => !(i === 0 && /^(option|seçenek|choice|alternatif)$/i.test(cells[0])));
|
|
722
|
+
if (body.length >= 2) {
|
|
723
|
+
const question = lines.filter((l) => !/^\s*\|.*\|\s*$/.test(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
724
|
+
return { choices: body.map((cells) => ({ label: cells[0], description: cells.slice(1).join(" \u2014 ") })), question };
|
|
725
|
+
}
|
|
726
|
+
const isLettered = (l) => /^\s*([A-Ea-e])[).\-:]\s+(\S.*)$/.exec(l.trim());
|
|
727
|
+
const lettered = lines.map(isLettered).filter((m) => !!m).map((m) => ({ label: `${m[1].toUpperCase()} \u2014 ${m[2]}` }));
|
|
728
|
+
if (lettered.length >= 2) {
|
|
729
|
+
const question = lines.filter((l) => !isLettered(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
730
|
+
return { choices: lettered, question };
|
|
731
|
+
}
|
|
732
|
+
const MARKER = /\(([A-Ea-e])\)\s*/g;
|
|
733
|
+
const marks = [...text.matchAll(MARKER)];
|
|
734
|
+
const inOrder = marks.length >= 2 && marks.every((m, i) => m[1].toUpperCase() === String.fromCharCode(65 + i));
|
|
735
|
+
if (inOrder) {
|
|
736
|
+
const first = marks[0].index ?? 0;
|
|
737
|
+
const parts = marks.map((m, i) => {
|
|
738
|
+
const start = (m.index ?? 0) + m[0].length;
|
|
739
|
+
const end = i + 1 < marks.length ? marks[i + 1].index ?? text.length : text.length;
|
|
740
|
+
return { letter: m[1].toUpperCase(), body: text.slice(start, end).trim().replace(/\s+/g, " ") };
|
|
741
|
+
}).filter((p) => p.body);
|
|
742
|
+
if (parts.length >= 2) {
|
|
743
|
+
const question = text.slice(0, first).replace(/\s*(Se\u00e7enekler|Options|Choices)\s*:?\s*$/i, "").trim();
|
|
744
|
+
return {
|
|
745
|
+
choices: parts.map((p) => ({ label: `${p.letter} \u2014 ${clipLabel(p.body)}`, description: p.body })),
|
|
746
|
+
question: question || text
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return { choices: [], question: text };
|
|
751
|
+
}
|
|
752
|
+
var clipLabel = (body) => {
|
|
753
|
+
const cut = body.replace(/^\[[^\]]*\]\s*/, "");
|
|
754
|
+
const stop = cut.search(/[—–.;:]\s/);
|
|
755
|
+
const head = stop > 12 ? cut.slice(0, stop) : cut;
|
|
756
|
+
return head.length > 72 ? `${head.slice(0, 71)}\u2026` : head;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// src/engine/writer-registry.ts
|
|
760
|
+
var askUserParams = z5.object({
|
|
761
|
+
question: z5.string(),
|
|
762
|
+
// For a multiple-choice question, list the choices here → the UI shows a selectable checkbox/radio list
|
|
763
|
+
// (arrow keys + Enter) instead of a free-text box. Omit for an open-ended question.
|
|
764
|
+
//
|
|
765
|
+
// A choice may be a plain string, or an object carrying what the label alone cannot say: a one-line
|
|
766
|
+
// `description`, and a `preview` rendered in a panel beside the list while that option is focused. Use the
|
|
767
|
+
// rich form when the decision turns on the trade-offs rather than the name (e.g. "which approach?").
|
|
768
|
+
options: z5.array(z5.union([
|
|
769
|
+
z5.string(),
|
|
770
|
+
z5.object({ label: z5.string(), description: z5.string().optional(), preview: z5.string().optional() })
|
|
771
|
+
])).optional().describe(
|
|
772
|
+
"The choices, when the question has discrete answers \u2014 the UI renders a selectable list instead of a free-text box. Omit for an open-ended question. A choice may be a plain string, or an object with a one-line `description` and a `preview` shown beside the list; use the rich form when the decision turns on trade-offs rather than on the name."
|
|
773
|
+
),
|
|
774
|
+
multiSelect: z5.boolean().optional().describe(
|
|
775
|
+
"True when the user may pick more than one (checkboxes); omitted means pick exactly one (radio)."
|
|
776
|
+
),
|
|
777
|
+
/**
|
|
778
|
+
* What the user has to DO before they can answer — one action per entry.
|
|
779
|
+
*
|
|
780
|
+
* Present ⇒ this is a hand-off, not a question: the run has stopped because only a person can carry the
|
|
781
|
+
* next step, and the UI says so rather than showing a bare "? Question".
|
|
782
|
+
*/
|
|
783
|
+
steps: z5.array(z5.string()).optional().describe(
|
|
784
|
+
'What the user has to DO before they can answer \u2014 one action per entry. Supplying this makes it a HAND-OFF rather than a question: the run has stopped because only a person can carry the next step, and the UI says so instead of showing a bare "? Question". Use it whenever you are asking someone to go and perform something and report back; leave it out when you only want an answer.'
|
|
785
|
+
)
|
|
786
|
+
});
|
|
787
|
+
var POINTS_ELSEWHERE = /\b(above|below|earlier|previously|as listed|as described)\b|yukarı|aşağı|altında|önceki|birazdan|listelenen/i;
|
|
788
|
+
var ITEM_REFERENCE = /(?:^|[^\w])(?:q|soru|question|madde|item)\s*\.?\s*(\d{1,2})\b/gi;
|
|
789
|
+
var ITEM_STATED = /^[\s>*\-–—#]*(?:q|soru|question|madde|item)\s*\.?\s*\d{1,2}\s*[:).]/gim;
|
|
790
|
+
function danglingItems(question) {
|
|
791
|
+
const referenced = new Set([...question.matchAll(ITEM_REFERENCE)].map((m) => Number(m[1])));
|
|
792
|
+
if (referenced.size < 2) return [];
|
|
793
|
+
const stated = question.match(ITEM_STATED)?.length ?? 0;
|
|
794
|
+
return stated >= referenced.size ? [] : [...referenced].sort((a, b) => a - b);
|
|
795
|
+
}
|
|
796
|
+
var ENUMERATOR = /(?:^|[^\w])\(?(\d{1,2})[).:]/g;
|
|
797
|
+
function packedQuestions(question) {
|
|
798
|
+
if ((question.match(/\?/g)?.length ?? 0) < 2) return 0;
|
|
799
|
+
const numbered = new Set([...question.matchAll(ENUMERATOR)].map((m) => Number(m[1])));
|
|
800
|
+
return numbered.size;
|
|
801
|
+
}
|
|
802
|
+
var ONE_AT_A_TIME = "This tool asks ONE question and takes ONE answer. Ask the first one on its own, with its own `options`, and call this tool again for the next once you have the answer \u2014 the answer to one of these usually changes what the next one should be.";
|
|
803
|
+
function buildAskUserTool(askUser, normalize) {
|
|
804
|
+
return {
|
|
805
|
+
name: "ask_user",
|
|
806
|
+
description: 'Ask the user ONE question and get their answer. Several decisions are several calls: ask the first, read the answer, then ask the next \u2014 the user has one answer field, so four questions in one box means four decisions they must hold in their head and answer in prose. For a multiple-choice question, pass `options` (the choices) \u2014 the UI shows a selectable list the user checks off; set `multiSelect: true` when they may pick several. Omit `options` for an open-ended (free-text) question. An option may be a plain string, or {label, description, preview} when the decision turns on trade-offs the label cannot carry \u2014 the preview is shown beside the list as the user moves the cursor. A `label` is a SHORT single line (a few words, no line breaks): it is a name for the choice, not the argument for it. Put the reasoning in `description` (one sentence) and the detail in `preview`. If you have findings to report, WRITE THEM as your message before calling this \u2014 a question that says "the evaluation is above" when you never wrote one leaves the user choosing between options whose basis they cannot see. The user may attach a free-text note to their choice, which arrives appended to the answer.\n\nWhen you need the user to DO something first \u2014 click through a screen, run a scenario, look at a network response \u2014 put each action in `steps`, one per entry, and ask in `question` for what they should report back. The user reads THIS BOX and the chat; a file you wrote is not on their screen, so "the steps above" points at nothing they can see. With `steps` the UI shows a hand-off \u2014 the numbered actions and then the question \u2014 instead of a bare question.',
|
|
807
|
+
permissionLevel: "safe",
|
|
808
|
+
parameters: askUserParams,
|
|
809
|
+
run: async (rawArgs, ctx) => {
|
|
810
|
+
const parsed = askUserParams.safeParse(rawArgs);
|
|
811
|
+
if (!parsed.success) {
|
|
812
|
+
return { content: `ask_user: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
|
|
813
|
+
}
|
|
814
|
+
const { question, options, multiSelect, steps } = parsed.data;
|
|
815
|
+
const asker = ctx.role || ctx.model ? { asker: { ...ctx.role ? { role: ctx.role } : {}, ...ctx.model ? { model: ctx.model } : {} } } : {};
|
|
816
|
+
if (POINTS_ELSEWHERE.test(question) && !steps?.length && !ctx.said?.trim()) {
|
|
817
|
+
return {
|
|
818
|
+
content: "ask_user: this question refers to something the user cannot see. You wrote no message this turn, so there is nothing above it \u2014 and a file you wrote is not on their screen. Put what they must do in `steps` (one action per entry), or write it out in `question`, and ask again.",
|
|
819
|
+
isError: true
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const dangling = danglingItems(question);
|
|
823
|
+
if (dangling.length && !steps?.length && !ctx.said?.trim()) {
|
|
824
|
+
return {
|
|
825
|
+
content: `ask_user: this asks the user to answer ${dangling.map((n) => `Q${n}`).join(", ")}, and none of them is on their screen \u2014 you did not write them this turn, and a file you wrote is not something they are looking at. ${ONE_AT_A_TIME}`,
|
|
826
|
+
isError: true
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const packed = packedQuestions(question);
|
|
830
|
+
if (packed >= 2 && !steps?.length && !options?.length) {
|
|
831
|
+
return {
|
|
832
|
+
content: `ask_user: this is ${packed} questions in one box, and the user has one answer field for all of them. ${ONE_AT_A_TIME}`,
|
|
833
|
+
isError: true
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
if ((!options || options.length === 0) && !steps?.length && looksLikeChoices(question)) {
|
|
837
|
+
const found = extractChoicesFrom(question);
|
|
838
|
+
if (found.choices.length >= 2) {
|
|
839
|
+
return { content: await askUser(found.question, { options: found.choices, ...asker }), isError: false };
|
|
840
|
+
}
|
|
841
|
+
if (normalize) {
|
|
842
|
+
try {
|
|
843
|
+
const n = await normalize(question);
|
|
844
|
+
if (n.options.length > 0) {
|
|
845
|
+
return { content: await askUser(n.question, { options: n.options, multiSelect: n.multiSelect, ...asker }), isError: false };
|
|
846
|
+
}
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { content: await askUser(question, { options, multiSelect, steps, ...asker }), isError: false };
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function writerRegistry(skillRegistry, extra = []) {
|
|
856
|
+
const r = new ToolRegistry();
|
|
857
|
+
r.register(readFileTool);
|
|
858
|
+
r.register(writeFileTool);
|
|
859
|
+
r.register(editFileTool);
|
|
860
|
+
r.register(grepTool);
|
|
861
|
+
r.register(globTool);
|
|
862
|
+
r.register(buildSkillTool(skillRegistry));
|
|
863
|
+
for (const t of extra) r.register(t);
|
|
864
|
+
return r;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/engine/role-fitness.ts
|
|
868
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
869
|
+
import { dirname as dirname2 } from "path";
|
|
870
|
+
var UNFIT_AFTER = 2;
|
|
871
|
+
var UNFIT_RATE = 0.5;
|
|
872
|
+
var key = (role, model) => `${role}\0${model}`;
|
|
873
|
+
var RoleFitness = class {
|
|
874
|
+
constructor(path, now = () => Date.now()) {
|
|
875
|
+
this.path = path;
|
|
876
|
+
this.now = now;
|
|
877
|
+
if (path) this.load();
|
|
878
|
+
}
|
|
879
|
+
path;
|
|
880
|
+
now;
|
|
881
|
+
records = /* @__PURE__ */ new Map();
|
|
882
|
+
/**
|
|
883
|
+
* Records that a model failed to do a role's job — not that a call failed.
|
|
884
|
+
*
|
|
885
|
+
* The distinction is the whole point: a rate limit, a timeout or a network error says nothing about
|
|
886
|
+
* fitness and must never be recorded here. What belongs here is a model that answered without doing the
|
|
887
|
+
* work — prose instead of an implementation, prose instead of the structured result the role demands.
|
|
888
|
+
*/
|
|
889
|
+
record(role, model, reason) {
|
|
890
|
+
if (!role || !model) return 0;
|
|
891
|
+
const k = key(role, model);
|
|
892
|
+
const prev = this.records.get(k);
|
|
893
|
+
const rec = {
|
|
894
|
+
role,
|
|
895
|
+
model,
|
|
896
|
+
strikes: (prev?.strikes ?? 0) + 1,
|
|
897
|
+
attempts: (prev?.attempts ?? 0) + 1,
|
|
898
|
+
reason: reason.slice(0, 120),
|
|
899
|
+
at: this.now()
|
|
900
|
+
};
|
|
901
|
+
this.records.set(k, rec);
|
|
902
|
+
this.save();
|
|
903
|
+
return rec.strikes;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Records that the model DID do the role's job.
|
|
907
|
+
*
|
|
908
|
+
* Without this the record only ever accumulates evidence against a model, and a model used a hundred times
|
|
909
|
+
* looks worse than one used twice. Successes are what make the failures mean something.
|
|
910
|
+
*/
|
|
911
|
+
ok(role, model) {
|
|
912
|
+
if (!role || !model) return;
|
|
913
|
+
const k = key(role, model);
|
|
914
|
+
const prev = this.records.get(k);
|
|
915
|
+
this.records.set(k, {
|
|
916
|
+
role,
|
|
917
|
+
model,
|
|
918
|
+
strikes: prev?.strikes ?? 0,
|
|
919
|
+
attempts: (prev?.attempts ?? 0) + 1,
|
|
920
|
+
reason: prev?.reason ?? "",
|
|
921
|
+
at: this.now()
|
|
922
|
+
});
|
|
923
|
+
this.save();
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* True when this model has failed this role often enough, and often ENOUGH OF THE TIME, that assigning it
|
|
927
|
+
* again is a known mistake. Both tests must pass: a pattern, and a pattern that dominates.
|
|
928
|
+
*/
|
|
929
|
+
unfit(role, model) {
|
|
930
|
+
const r = this.records.get(key(role, model));
|
|
931
|
+
if (!r || r.strikes < UNFIT_AFTER) return false;
|
|
932
|
+
return r.strikes / Math.max(r.attempts, 1) >= UNFIT_RATE;
|
|
933
|
+
}
|
|
934
|
+
/** Why a model is unfit for a role, for the line that tells the user. */
|
|
935
|
+
reasonFor(role, model) {
|
|
936
|
+
return this.unfit(role, model) ? this.records.get(key(role, model))?.reason : void 0;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Drops the models a role has already proven it cannot use — but never returns an empty list.
|
|
940
|
+
*
|
|
941
|
+
* A role with no model at all is a worse failure than a role with a bad one: the first stops the run, the
|
|
942
|
+
* second wastes an attempt and rotates. When everything available is unfit, the original order is returned
|
|
943
|
+
* unchanged and the caller carries on.
|
|
944
|
+
*/
|
|
945
|
+
fitFor(role, models) {
|
|
946
|
+
const fit = models.filter((m) => !this.unfit(role, m));
|
|
947
|
+
return fit.length ? fit : models;
|
|
948
|
+
}
|
|
949
|
+
/** Everything recorded, worst first — for `/roles` and for explaining an assignment. */
|
|
950
|
+
list() {
|
|
951
|
+
return [...this.records.values()].sort((a, b) => b.strikes - a.strikes);
|
|
952
|
+
}
|
|
953
|
+
/** Forgets one pairing, or all of them — a model that was broken in March may be fine in June. */
|
|
954
|
+
clear(role, model) {
|
|
955
|
+
if (role && model) this.records.delete(key(role, model));
|
|
956
|
+
else this.records.clear();
|
|
957
|
+
this.save();
|
|
958
|
+
}
|
|
959
|
+
load() {
|
|
960
|
+
try {
|
|
961
|
+
const raw = JSON.parse(readFileSync(this.path, "utf8"));
|
|
962
|
+
if (!Array.isArray(raw)) return;
|
|
963
|
+
for (const r of raw) {
|
|
964
|
+
if (r && typeof r.role === "string" && typeof r.model === "string" && typeof r.strikes === "number") {
|
|
965
|
+
this.records.set(key(r.role, r.model), { ...r, attempts: r.attempts ?? r.strikes });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
} catch {
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
save() {
|
|
972
|
+
if (!this.path) return;
|
|
973
|
+
try {
|
|
974
|
+
mkdirSync(dirname2(this.path), { recursive: true });
|
|
975
|
+
writeFileSync(this.path, JSON.stringify(this.list(), null, 2), "utf8");
|
|
976
|
+
} catch {
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// src/engine/routing.ts
|
|
982
|
+
import { z as z6 } from "zod";
|
|
983
|
+
|
|
984
|
+
// src/engine/route-role.ts
|
|
985
|
+
var STYLE_EXT = [".css", ".scss", ".sass", ".less", ".styl"];
|
|
986
|
+
var MARKUP_EXT = [".html", ".htm", ".svg", ".vue", ".svelte"];
|
|
987
|
+
var COMPONENT_EXT = [".tsx", ".jsx"];
|
|
988
|
+
var BACKEND_EXT = [".sql", ".prisma", ".proto", ".go", ".py", ".rb", ".php", ".java", ".rs", ".kt"];
|
|
989
|
+
var BACKEND_DIR = ["server", "api", "backend", "db", "database", "migrations", "migration", "infra", "worker", "jobs"];
|
|
990
|
+
var UI_DIR = ["ui", "components", "component", "views", "view", "pages", "page", "screens", "styles", "theme", "themes", "layouts", "widgets"];
|
|
991
|
+
var DESIGN_WORDS = [
|
|
992
|
+
"style",
|
|
993
|
+
"styles",
|
|
994
|
+
"styling",
|
|
995
|
+
"theme",
|
|
996
|
+
"theming",
|
|
997
|
+
"layout",
|
|
998
|
+
"design",
|
|
999
|
+
"visual",
|
|
1000
|
+
"visuals",
|
|
1001
|
+
"animation",
|
|
1002
|
+
"animate",
|
|
1003
|
+
"transition",
|
|
1004
|
+
"responsive",
|
|
1005
|
+
"accessibility",
|
|
1006
|
+
"a11y",
|
|
1007
|
+
"color",
|
|
1008
|
+
"colors",
|
|
1009
|
+
"colour",
|
|
1010
|
+
"typography",
|
|
1011
|
+
"spacing",
|
|
1012
|
+
"icon",
|
|
1013
|
+
"icons",
|
|
1014
|
+
"ui",
|
|
1015
|
+
"ux",
|
|
1016
|
+
"polish",
|
|
1017
|
+
"palette",
|
|
1018
|
+
"css"
|
|
1019
|
+
];
|
|
1020
|
+
var ext = (p) => {
|
|
1021
|
+
const base = p.slice(p.lastIndexOf("/") + 1);
|
|
1022
|
+
const dot = base.lastIndexOf(".");
|
|
1023
|
+
return dot > 0 ? base.slice(dot).toLowerCase() : "";
|
|
1024
|
+
};
|
|
1025
|
+
var segments = (p) => p.toLowerCase().split(/[/\\]/).slice(0, -1);
|
|
1026
|
+
var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
1027
|
+
function routeByEvidence(card) {
|
|
1028
|
+
const files = card.files.map((f) => f.trim()).filter(Boolean);
|
|
1029
|
+
const exts = files.map(ext);
|
|
1030
|
+
const dirs = files.flatMap(segments);
|
|
1031
|
+
const title = words(card.title);
|
|
1032
|
+
const hasStyle = exts.some((e) => STYLE_EXT.includes(e));
|
|
1033
|
+
const hasMarkup = exts.some((e) => MARKUP_EXT.includes(e));
|
|
1034
|
+
const hasComponent = exts.some((e) => COMPONENT_EXT.includes(e));
|
|
1035
|
+
const hasUiDir = dirs.some((d) => UI_DIR.includes(d));
|
|
1036
|
+
const designWord = title.find((w) => DESIGN_WORDS.includes(w));
|
|
1037
|
+
const backend = exts.some((e) => BACKEND_EXT.includes(e)) || dirs.some((d) => BACKEND_DIR.includes(d));
|
|
1038
|
+
if (!hasStyle && !hasMarkup && !hasComponent && !hasUiDir && !designWord) {
|
|
1039
|
+
return { role: "coder", why: files.length ? "no interface work in its files or title" : "nothing about it is interface work" };
|
|
1040
|
+
}
|
|
1041
|
+
if ((hasStyle || hasMarkup || designWord) && !backend) {
|
|
1042
|
+
const reason = hasStyle || hasMarkup ? `writes ${files.find((f) => STYLE_EXT.includes(ext(f)) || MARKUP_EXT.includes(ext(f)))}` : `"${designWord}" is the work`;
|
|
1043
|
+
return { role: "designer", why: reason };
|
|
1044
|
+
}
|
|
1045
|
+
return { why: hasComponent && !hasStyle && !hasMarkup ? "component files could be either" : "the evidence points both ways" };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/engine/routing.ts
|
|
1049
|
+
var RouteSchema = z6.object({
|
|
1050
|
+
role: z6.enum(["coder", "designer"]).describe(
|
|
1051
|
+
"Who should implement this. `designer` when the work IS how the thing looks or behaves to a person \u2014 layout, spacing, colour, copy, interaction. `coder` for everything else. Judge by what the work is, not by the file type: a component file holding a data hook is code work; a component file whose whole job is appearance is design work."
|
|
1052
|
+
)
|
|
1053
|
+
});
|
|
1054
|
+
async function routeTask(deps, task) {
|
|
1055
|
+
const evidence = routeByEvidence(task);
|
|
1056
|
+
telemetry().event("decision.route", {
|
|
1057
|
+
"hc.decision": "route",
|
|
1058
|
+
"hc.task.id": task.id,
|
|
1059
|
+
"hc.role": evidence.role,
|
|
1060
|
+
"hc.route.why": evidence.why,
|
|
1061
|
+
"hc.route.by": evidence.role ? "evidence" : "model"
|
|
1062
|
+
});
|
|
1063
|
+
if (evidence.role) return evidence.role;
|
|
1064
|
+
try {
|
|
1065
|
+
const resolved = deps.roleRegistry.resolve("router");
|
|
1066
|
+
const tools = new ToolRegistry();
|
|
1067
|
+
tools.register(buildSkillTool(deps.skillRegistry));
|
|
1068
|
+
const opts = {
|
|
1069
|
+
provider: deps.provider,
|
|
1070
|
+
...resolved,
|
|
1071
|
+
tools,
|
|
1072
|
+
messages: [
|
|
1073
|
+
{ role: "user", content: `Task: "${task.title}"
|
|
1074
|
+
` + (task.files.length ? `Files it writes: ${task.files.join(", ")}
|
|
1075
|
+
` : "") + (task.acceptance.length ? `Done when: ${task.acceptance.join("; ")}
|
|
1076
|
+
` : "") + `
|
|
1077
|
+
Is this UI/UX work (designer) or code work (coder)? Judge by what the work IS, not by the file types: a component file holding a data hook is code work, and a component file whose whole job is how the thing looks is design work.` }
|
|
1078
|
+
],
|
|
1079
|
+
permission: deps.permission,
|
|
1080
|
+
approve: deps.approve,
|
|
1081
|
+
cwd: "/",
|
|
1082
|
+
signal: deps.signal,
|
|
1083
|
+
perAttemptMs: SHORT_CALL_MS,
|
|
1084
|
+
// each model in the chain gets its own clock — see RoleAgentOptions
|
|
1085
|
+
// One question, one answer, from the text in front of it. Nothing here is worth a fifty-turn budget —
|
|
1086
|
+
// and an unbounded structured role walks its entire fallback chain when a model will not submit.
|
|
1087
|
+
maxTurns: 3
|
|
1088
|
+
};
|
|
1089
|
+
const { role } = await runStructuredRole(opts, RouteSchema);
|
|
1090
|
+
return role;
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
if (deps.signal.aborted) throw e;
|
|
1093
|
+
return "coder";
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// src/tools/index.ts
|
|
1098
|
+
function createDefaultRegistry() {
|
|
1099
|
+
const reg = new ToolRegistry();
|
|
1100
|
+
reg.register(readFileTool);
|
|
1101
|
+
reg.register(writeFileTool);
|
|
1102
|
+
reg.register(editFileTool);
|
|
1103
|
+
reg.register(grepTool);
|
|
1104
|
+
reg.register(globTool);
|
|
1105
|
+
reg.register(gitTool);
|
|
1106
|
+
reg.register(shellTool);
|
|
1107
|
+
reg.register(createWebFetchTool());
|
|
1108
|
+
return reg;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/engine/operational.ts
|
|
1112
|
+
import { z as z7 } from "zod";
|
|
1113
|
+
var CommitSchema = z7.object({
|
|
1114
|
+
message: z7.string().describe("A Conventional Commits message: `type(scope): subject`, English, imperative.")
|
|
1115
|
+
});
|
|
1116
|
+
var MAX_DIFF = 12e3;
|
|
1117
|
+
var OPERATIONAL_MAX_TURNS = 3;
|
|
1118
|
+
async function runOperational(deps, diff, context) {
|
|
1119
|
+
const clipped = diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}
|
|
1120
|
+
\u2026 (diff truncated)` : diff;
|
|
1121
|
+
const resolved = deps.roleRegistry.resolve("operational");
|
|
1122
|
+
const out = await runStructuredRole({
|
|
1123
|
+
provider: deps.provider,
|
|
1124
|
+
...resolved,
|
|
1125
|
+
tools: new ToolRegistry(),
|
|
1126
|
+
messages: [{ role: "user", content: `Context: ${context}
|
|
1127
|
+
|
|
1128
|
+
Git diff of the work just completed:
|
|
1129
|
+
|
|
1130
|
+
${clipped}
|
|
1131
|
+
|
|
1132
|
+
Write the commit message.` }],
|
|
1133
|
+
permission: deps.permission,
|
|
1134
|
+
approve: deps.approve,
|
|
1135
|
+
cwd: ".",
|
|
1136
|
+
signal: deps.signal,
|
|
1137
|
+
perAttemptMs: SHORT_CALL_MS,
|
|
1138
|
+
// each model in the chain gets its own clock — see RoleAgentOptions
|
|
1139
|
+
totalMs: SHORT_CALL_MS * 2,
|
|
1140
|
+
// …and the whole chain cannot outlast the sentence it is writing
|
|
1141
|
+
// One sentence from a diff it was handed. Uncapped, a model that would not call `submit` walked its whole
|
|
1142
|
+
// fallback chain at fifty turns an attempt — to phrase a commit message.
|
|
1143
|
+
maxTurns: OPERATIONAL_MAX_TURNS
|
|
1144
|
+
}, CommitSchema);
|
|
1145
|
+
return out.message.trim();
|
|
1146
|
+
}
|
|
1147
|
+
function fileCommitMessage(path) {
|
|
1148
|
+
const p = path.replace(/\\/g, "/");
|
|
1149
|
+
const type = /(^|\/)(test|tests|spec|__tests__)\//.test(p) || /\.(spec|test)\.[a-z]+$/.test(p) ? "test" : /\.(md|mdx|txt|rst)$/i.test(p) ? "docs" : /(^|\/)(package\.json|tsconfig[^/]*\.json|angular\.json|vite\.config|.*\.config\.[a-z]+)$/i.test(p) ? "build" : "chore";
|
|
1150
|
+
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")).split("/").filter((x) => x !== "src").pop() : "";
|
|
1151
|
+
return `wip(${type}${dir ? `/${dir}` : ""}): ${p.slice(p.lastIndexOf("/") + 1)}`;
|
|
1152
|
+
}
|
|
1153
|
+
var SCRATCH_RE = /(^|\/|\.)(tmp|scratch|repro|sandbox)\.|[-_.](tmp|scratch|repro)\.[a-z]+$/i;
|
|
1154
|
+
function isScratch(path) {
|
|
1155
|
+
return SCRATCH_RE.test(path.split("/").pop() ?? path);
|
|
1156
|
+
}
|
|
1157
|
+
async function commitFile(deps, workdir, path, git = defaultGitRunner) {
|
|
1158
|
+
if (writableStateRoot(workdir) === void 0) return void 0;
|
|
1159
|
+
if (isScratch(path)) return void 0;
|
|
1160
|
+
await git(["add", "--", path], workdir);
|
|
1161
|
+
const staged = await git(["diff", "--cached", "--quiet", "--", path], workdir);
|
|
1162
|
+
if (staged.code === 0) return void 0;
|
|
1163
|
+
const message = fileCommitMessage(path);
|
|
1164
|
+
const res = await git(["commit", "-m", message, "--", path], workdir);
|
|
1165
|
+
if (res.code !== 0) return void 0;
|
|
1166
|
+
return message;
|
|
1167
|
+
}
|
|
1168
|
+
async function commitStep(deps, workdir, context, git = defaultGitRunner) {
|
|
1169
|
+
await git(["add", "-A"], workdir);
|
|
1170
|
+
const dirty = await git(["diff", "--cached", "--name-only"], workdir);
|
|
1171
|
+
const scratch = dirty.stdout.split("\n").map((l) => l.trim()).filter((l) => l && isScratch(l));
|
|
1172
|
+
if (scratch.length) await git(["reset", "--quiet", "--", ...scratch], workdir);
|
|
1173
|
+
const staged = await git(["diff", "--cached", "--quiet"], workdir);
|
|
1174
|
+
if (staged.code === 0) return void 0;
|
|
1175
|
+
const diff = await git(["diff", "--cached"], workdir);
|
|
1176
|
+
let message;
|
|
1177
|
+
try {
|
|
1178
|
+
message = await runOperational(deps, diff.stdout, context);
|
|
1179
|
+
} catch {
|
|
1180
|
+
message = `chore: ${context}`;
|
|
1181
|
+
}
|
|
1182
|
+
const res = await git(["commit", "-m", message], workdir);
|
|
1183
|
+
if (res.code !== 0) return void 0;
|
|
1184
|
+
deps.note?.(`\u{1F516} ${message}`);
|
|
1185
|
+
return message;
|
|
1186
|
+
}
|
|
1187
|
+
async function squashTask(deps, worktree, baseRef, title, git = defaultGitRunner) {
|
|
1188
|
+
const fork = await git(["merge-base", "HEAD", baseRef], worktree);
|
|
1189
|
+
if (fork.code !== 0) return void 0;
|
|
1190
|
+
const at = fork.stdout.trim();
|
|
1191
|
+
if (!at) return void 0;
|
|
1192
|
+
const aheadOut = await git(["rev-list", "--count", `${at}..HEAD`], worktree);
|
|
1193
|
+
const ahead = Number(aheadOut.stdout.trim() || "0");
|
|
1194
|
+
if (ahead < 1) return void 0;
|
|
1195
|
+
const diff = await git(["diff", `${at}..HEAD`], worktree);
|
|
1196
|
+
if (!diff.stdout.trim()) return void 0;
|
|
1197
|
+
let message;
|
|
1198
|
+
try {
|
|
1199
|
+
message = await runOperational(deps, diff.stdout, `completed the task: ${title}`);
|
|
1200
|
+
} catch {
|
|
1201
|
+
message = `chore: ${title}`;
|
|
1202
|
+
}
|
|
1203
|
+
const reset = await git(["reset", "--soft", at], worktree);
|
|
1204
|
+
if (reset.code !== 0) return void 0;
|
|
1205
|
+
const res = await git(["commit", "-m", message], worktree);
|
|
1206
|
+
if (res.code !== 0) return void 0;
|
|
1207
|
+
deps.note?.(`\u{1F4E6} **${message}**${ahead ? ` \u2014 ${ahead} checkpoint(s) squashed` : ""}`);
|
|
1208
|
+
return message;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/engine/touched.ts
|
|
1212
|
+
async function changedPaths(cwd, git = defaultGitRunner) {
|
|
1213
|
+
const res = await git(["status", "--porcelain", "-uall"], cwd);
|
|
1214
|
+
if (res.code !== 0) return [];
|
|
1215
|
+
const out = [];
|
|
1216
|
+
for (const line of res.stdout.split("\n")) {
|
|
1217
|
+
if (!line.trim()) continue;
|
|
1218
|
+
const path = line.slice(3).trim();
|
|
1219
|
+
const arrow = path.lastIndexOf(" -> ");
|
|
1220
|
+
const p = arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
1221
|
+
out.push(p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p);
|
|
1222
|
+
}
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
async function reconcileTouched(deps, cwd, touched, git = defaultGitRunner) {
|
|
1226
|
+
let changed;
|
|
1227
|
+
try {
|
|
1228
|
+
changed = await changedPaths(cwd, git);
|
|
1229
|
+
} catch {
|
|
1230
|
+
return [];
|
|
1231
|
+
}
|
|
1232
|
+
const known = new Set(touched);
|
|
1233
|
+
const extra = changed.filter((p) => !known.has(p));
|
|
1234
|
+
for (const p of extra) {
|
|
1235
|
+
touched.push(p);
|
|
1236
|
+
try {
|
|
1237
|
+
await commitFile(deps, cwd, p, git);
|
|
1238
|
+
} catch {
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return extra;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// src/skills/adjudicate.ts
|
|
1245
|
+
async function adjudicateSkills(opts) {
|
|
1246
|
+
const { confident, borderline } = partitionByConfidence(opts.matches, opts.bar, opts.margin);
|
|
1247
|
+
if (!borderline.length) return { keep: confident, asked: false };
|
|
1248
|
+
const described = borderline.map((m) => `- ${m.name}: ${opts.registry.get(m.name)?.description ?? "(no description)"}`).join("\n");
|
|
1249
|
+
const systemPrompt = `Decide which of these skills, if any, genuinely apply to one development task.
|
|
1250
|
+
|
|
1251
|
+
A skill is a document inlined into the agent's instructions: whatever it says, the agent will do. So a skill on the wrong task is worse than no skill \u2014 the agent will follow it.
|
|
1252
|
+
|
|
1253
|
+
Judge by whether the task IS the kind of work the skill is for, not by whether they share words. "Implement store CRUD methods" mentions components and state, but it is data-layer work, not interface design. Rejecting all of them is a normal and frequent answer.
|
|
1254
|
+
|
|
1255
|
+
Task: ${opts.task}
|
|
1256
|
+
|
|
1257
|
+
Candidates:
|
|
1258
|
+
${described}
|
|
1259
|
+
|
|
1260
|
+
Answer with a fenced \`\`\`json block: {"keep":["<skill name>", \u2026]} \u2014 an empty list if none apply. One short sentence of reasoning before it.`;
|
|
1261
|
+
try {
|
|
1262
|
+
const req = {
|
|
1263
|
+
model: opts.model,
|
|
1264
|
+
messages: [
|
|
1265
|
+
{ role: "system", content: "You decide whether a skill applies to a task. You reject far more often than you accept." },
|
|
1266
|
+
{ role: "user", content: systemPrompt }
|
|
1267
|
+
],
|
|
1268
|
+
tools: []
|
|
1269
|
+
};
|
|
1270
|
+
let full = "";
|
|
1271
|
+
for await (const ev of opts.provider.chat(req, opts.signal ?? new AbortController().signal)) {
|
|
1272
|
+
if (ev.type === "text-delta") full += ev.text;
|
|
1273
|
+
else if (ev.type === "error") throw new Error(ev.message);
|
|
1274
|
+
}
|
|
1275
|
+
const keep = parseKeep(full);
|
|
1276
|
+
if (!keep) throw new Error("unparseable verdict");
|
|
1277
|
+
const kept = borderline.filter((m) => keep.includes(m.name));
|
|
1278
|
+
return {
|
|
1279
|
+
keep: [...confident, ...kept],
|
|
1280
|
+
asked: true,
|
|
1281
|
+
reasoning: full.split("```")[0].replace(/<\/?think>/gi, "").trim().slice(0, 200)
|
|
1282
|
+
};
|
|
1283
|
+
} catch {
|
|
1284
|
+
return { keep: opts.matches, asked: false };
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function parseKeep(text) {
|
|
1288
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
1289
|
+
const raw = fence ? fence[1] : text.slice(text.indexOf("{"));
|
|
1290
|
+
try {
|
|
1291
|
+
const parsed = JSON.parse(raw);
|
|
1292
|
+
return Array.isArray(parsed.keep) ? parsed.keep.filter((k) => typeof k === "string") : void 0;
|
|
1293
|
+
} catch {
|
|
1294
|
+
return void 0;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/engine/group-notes.ts
|
|
1299
|
+
function subjectOf(note) {
|
|
1300
|
+
const path = /\b((?:[\w.-]+\/)+[\w.-]+\.(?:cs|ts|tsx|json|sql|md))\b/.exec(note)?.[1];
|
|
1301
|
+
if (path) return path.split("/").pop().replace(/\.\w+$/, "");
|
|
1302
|
+
return /\b([A-Z][a-z]+(?:[A-Z][a-z0-9]+){1,})\b/.exec(note)?.[1];
|
|
1303
|
+
}
|
|
1304
|
+
function groupNotes(notes) {
|
|
1305
|
+
const order = [];
|
|
1306
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
1307
|
+
const loose = [];
|
|
1308
|
+
for (const note of notes) {
|
|
1309
|
+
const s = subjectOf(note);
|
|
1310
|
+
if (!s) {
|
|
1311
|
+
loose.push(note);
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (!bySubject.has(s)) {
|
|
1315
|
+
bySubject.set(s, []);
|
|
1316
|
+
order.push(s);
|
|
1317
|
+
}
|
|
1318
|
+
bySubject.get(s).push(note);
|
|
1319
|
+
}
|
|
1320
|
+
const blocks = [];
|
|
1321
|
+
for (const s of order) {
|
|
1322
|
+
const group = bySubject.get(s);
|
|
1323
|
+
if (group.length === 1) {
|
|
1324
|
+
loose.push(group[0]);
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
blocks.push(`${s} \u2014 ${group.length} notes, one subject:
|
|
1328
|
+
${group.map((n) => ` - ${n}`).join("\n")}`);
|
|
1329
|
+
}
|
|
1330
|
+
if (loose.length) blocks.push(loose.map((n) => `- ${n}`).join("\n"));
|
|
1331
|
+
return blocks.join("\n\n");
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/engine/implementer.ts
|
|
1335
|
+
var IMPLEMENTER_MAX_TURNS = 200;
|
|
1336
|
+
var WHAT_IT_COST = "Before you stop: did anything here cost you more than one attempt \u2014 a command that had to be invoked a particular way, a file that was not where it should have been, a trap you fell into? If you have not already recorded it with `remember_fact`, do that now. The next agent pays for it again otherwise.";
|
|
1337
|
+
var IMPLEMENTER_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
1338
|
+
var MAX_BUDGET_EXTENSIONS = 2;
|
|
1339
|
+
function attemptBudget(task, baseMs) {
|
|
1340
|
+
let deaths = 0;
|
|
1341
|
+
for (let i = task.stageHistory.length - 1; i >= 0 && deaths < MAX_BUDGET_EXTENSIONS; i--) {
|
|
1342
|
+
const e = task.stageHistory[i];
|
|
1343
|
+
if (e.action === "attempt-error") {
|
|
1344
|
+
if (!/budget/i.test(e.note ?? "")) break;
|
|
1345
|
+
deaths += 1;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
if (e.action === "reviewed:fail" || e.action === "acceptance:failed" || e.action === "no-changes") break;
|
|
1349
|
+
}
|
|
1350
|
+
return baseMs * (1 + deaths);
|
|
1351
|
+
}
|
|
1352
|
+
var DEADLINE_WARNING_AT = 0.75;
|
|
1353
|
+
function deadlineWarning(elapsedMs, budgetMs) {
|
|
1354
|
+
if (elapsedMs < budgetMs * DEADLINE_WARNING_AT) return void 0;
|
|
1355
|
+
const left = Math.max(1, Math.round((budgetMs - elapsedMs) / 6e4));
|
|
1356
|
+
return `You have about ${left} minute(s) of budget left for this attempt, and it will be stopped when they are gone. Finish and WRITE what you have now \u2014 a partial implementation that is on disk is kept and continued from; work still in your head is lost. Stop exploring.`;
|
|
1357
|
+
}
|
|
1358
|
+
var MAX_WRITTEN_CHARS = 6e4;
|
|
1359
|
+
async function writtenText(cwd, touched) {
|
|
1360
|
+
const { readFile: readFile3 } = await import("fs/promises");
|
|
1361
|
+
const { join: join4 } = await import("path");
|
|
1362
|
+
const parts = [];
|
|
1363
|
+
let used = 0;
|
|
1364
|
+
for (const p of [...new Set(touched)]) {
|
|
1365
|
+
if (used >= MAX_WRITTEN_CHARS) break;
|
|
1366
|
+
try {
|
|
1367
|
+
const t = await readFile3(join4(cwd, p), "utf8");
|
|
1368
|
+
parts.push(t.slice(0, MAX_WRITTEN_CHARS - used));
|
|
1369
|
+
used += t.length;
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return parts.join("\n");
|
|
1374
|
+
}
|
|
1375
|
+
async function runImplementer(deps, role, task, cwd, slot = 0) {
|
|
1376
|
+
const resolved = deps.roleRegistry.resolve(role);
|
|
1377
|
+
const chain = deps.roleRegistry.chainFor(role, slot);
|
|
1378
|
+
const tok = { promptTokens: 0, completionTokens: 0 };
|
|
1379
|
+
let serving = chain[0] ?? "";
|
|
1380
|
+
const tools = createDefaultRegistry();
|
|
1381
|
+
tools.register(buildSkillTool(deps.skillRegistry));
|
|
1382
|
+
for (const t of contextTools(deps)) tools.register(t);
|
|
1383
|
+
tools.register(buildRememberTool(deps.rememberFact));
|
|
1384
|
+
if (deps.askUser) tools.register(buildAskUserTool(deps.askUser));
|
|
1385
|
+
const returning = task.reviewNotes.length > 0;
|
|
1386
|
+
const brief = [
|
|
1387
|
+
task.acceptance.length ? `It is done when ALL of these are true \u2014 they are exactly what the review will check:
|
|
1388
|
+
` + task.acceptance.map((a) => `- ${a}`).join("\n") : "",
|
|
1389
|
+
task.files.length ? `The plan expects this task to create or change these files:
|
|
1390
|
+
${task.files.map((f) => `- ${f}`).join("\n")}
|
|
1391
|
+
Start there. Touch anything else the work genuinely needs \u2014 this is where the plan expected the change to live, not a limit on it.` : ""
|
|
1392
|
+
].filter(Boolean).join("\n\n");
|
|
1393
|
+
const hygiene = `Your whole diff is what the review judges. Before you finish: undo anything you changed for your OWN convenience \u2014 a test config narrowed to one spec, a widened timeout, a disabled lint rule \u2014 and delete any scratch, repro or debug file you made. Leaving them in fails the review even when the work is right.`;
|
|
1394
|
+
const handOver = `When you stop you are HANDING the change over \u2014 to a review and an acceptance check that have not run yet. So close with what you changed and what you ran, and leave the verdict to them: do not write that the task is complete, done or finished. Whether it is, is not yours to say, and saying it is how a card reads "complete" above a review that rejects it.
|
|
1395
|
+
|
|
1396
|
+
${WHAT_IT_COST}
|
|
1397
|
+
|
|
1398
|
+
Staging, committing and branches are this tool's business, never the developer's: do not end by asking them to \`git add\`, commit, or tidy the tree. Ask them only for what nobody else can do \u2014 look at a screen, start an environment, decide something. If a file you wrote is not in git yet, that is a fault to report, not an errand to hand over.`;
|
|
1399
|
+
const content = (returning ? `This is a RETURNING task: "${task.title}". Address the reviewer notes:
|
|
1400
|
+
${groupNotes(task.reviewNotes)}` : `This is a NEW task: "${task.title}". Implement it.`) + (brief ? `
|
|
1401
|
+
|
|
1402
|
+
${brief}` : "") + `
|
|
1403
|
+
|
|
1404
|
+
${hygiene}
|
|
1405
|
+
|
|
1406
|
+
${handOver}`;
|
|
1407
|
+
const hints = memoryHints(deps, `${task.title} ${task.reviewNotes.join(" ")}`, { role, operations: true });
|
|
1408
|
+
const touched = [];
|
|
1409
|
+
const attached = deps.roleRegistry.skillsFor(role);
|
|
1410
|
+
const subject = `${task.title} ${task.acceptance.join(" ")}`;
|
|
1411
|
+
const routed = routeSkills(subject, deps.skillRegistry, attached, {
|
|
1412
|
+
role,
|
|
1413
|
+
implementing: true,
|
|
1414
|
+
/**
|
|
1415
|
+
* The files the PLAN named, when it named any.
|
|
1416
|
+
*
|
|
1417
|
+
* Inference is a fallback, not the first answer: resolving a task's words against the whole graph returns
|
|
1418
|
+
* whatever happens to contain those words, and those paths are then treated as evidence about what kind
|
|
1419
|
+
* of work this is. Measured live — "fix product description rendering" resolved to `ShopifyService.cs`,
|
|
1420
|
+
* `n11/OrderMapper.cs` and `ExcelTableRenderer.cs`, because marketplace integrators are full of symbols
|
|
1421
|
+
* called product and description, and the brainstormer was handed `azure-kubernetes` for it.
|
|
1422
|
+
*
|
|
1423
|
+
* A card's own file list came from a plan a person approved. It outranks a guess.
|
|
1424
|
+
*/
|
|
1425
|
+
/**
|
|
1426
|
+
* Only files someone NAMED, never a guess.
|
|
1427
|
+
*
|
|
1428
|
+
* Resolving a task's words against the graph was measured over real commit history: on the integration
|
|
1429
|
+
* project this runs against, 131 samples, an answer produced for 130 of them, and 9% of those files
|
|
1430
|
+
* correct. The domain is the reason: "product", "description", "order" name symbols in every marketplace
|
|
1431
|
+
* integrator there is, so the words that describe a task resolve to all of them.
|
|
1432
|
+
*
|
|
1433
|
+
* Confident and wrong is the worst kind of evidence, because it reads as knowledge. Without it the router
|
|
1434
|
+
* falls back to the task's own text, which is honest about knowing less.
|
|
1435
|
+
*/
|
|
1436
|
+
files: task.files,
|
|
1437
|
+
placed: placedSkills()
|
|
1438
|
+
});
|
|
1439
|
+
const verdict = routed.length ? await adjudicateSkills({
|
|
1440
|
+
provider: deps.provider,
|
|
1441
|
+
model: chain[0] ?? "",
|
|
1442
|
+
task: subject,
|
|
1443
|
+
matches: routed,
|
|
1444
|
+
registry: deps.skillRegistry,
|
|
1445
|
+
signal: deps.signal
|
|
1446
|
+
}) : { keep: routed, asked: false, reasoning: void 0 };
|
|
1447
|
+
const kept = verdict.keep;
|
|
1448
|
+
if (kept.length) {
|
|
1449
|
+
deps.note?.(`\u{1F4CE} \`${role}\` \xB7 ${kept.map((m) => `**${m.name}**`).join(", ")}${verdict.asked ? " _(adjudicated)_" : ""}`);
|
|
1450
|
+
} else if (routed.length && verdict.asked) {
|
|
1451
|
+
deps.note?.(`\u{1F4CE} \`${role}\` \xB7 **no skill applied** \u2014 considered ${routed.map((m) => m.name).join(", ")}: ${verdict.reasoning ?? "none of them fit this task"}`);
|
|
1452
|
+
}
|
|
1453
|
+
const withSkills = kept.length ? applySkills(resolved.systemPrompt, kept.map((m) => m.name), deps.skillRegistry) : resolved.systemPrompt;
|
|
1454
|
+
const law = deps.home ? await constitutionNote(
|
|
1455
|
+
{ ...deps, home: deps.home, note: deps.note },
|
|
1456
|
+
cwd,
|
|
1457
|
+
{ role, files: task.files, title: task.title }
|
|
1458
|
+
) : "";
|
|
1459
|
+
const delegate = deps.delegateTo ? cliFor(chain[0] ?? "") ?? deps.delegateTo : void 0;
|
|
1460
|
+
const systemPrompt = withSkills + law + (delegate ? "" : projectToolsNote(tools.list(), !!loadGraphSync(cwd))) + BATCH_TOOLS_NOTE;
|
|
1461
|
+
const budgetMs = attemptBudget(task, deps.implementerTimeoutMs ?? IMPLEMENTER_TIMEOUT_MS);
|
|
1462
|
+
const budget = AbortSignal.timeout(budgetMs);
|
|
1463
|
+
let warned = false;
|
|
1464
|
+
const startedAt = Date.now();
|
|
1465
|
+
const deadlineNote = () => {
|
|
1466
|
+
if (warned) return void 0;
|
|
1467
|
+
const note = deadlineWarning(Date.now() - startedAt, budgetMs);
|
|
1468
|
+
if (note) warned = true;
|
|
1469
|
+
return note;
|
|
1470
|
+
};
|
|
1471
|
+
const opts = {
|
|
1472
|
+
/**
|
|
1473
|
+
* A delegated implementer gets a provider bound to its worktree and allowed to write; every other role
|
|
1474
|
+
* keeps the one the composition root built. Per call rather than shared, because the worktree differs
|
|
1475
|
+
* per task and a provider shared across parallel implementers could only hold one of them.
|
|
1476
|
+
*/
|
|
1477
|
+
provider: delegate ? new CliProvider({ kind: delegate, readOnly: false, cwd, ...deps.accounts ? { accounts: deps.accounts } : {} }) : deps.provider,
|
|
1478
|
+
...resolved,
|
|
1479
|
+
systemPrompt,
|
|
1480
|
+
...chain.length ? { model: chain[0], fallbacks: chain.slice(1) } : {},
|
|
1481
|
+
tools,
|
|
1482
|
+
maxTurns: IMPLEMENTER_MAX_TURNS,
|
|
1483
|
+
/**
|
|
1484
|
+
* A screenshot named in the task comes with it.
|
|
1485
|
+
*
|
|
1486
|
+
* A card built from a small request or from a finding carries the user's own words, and those words can
|
|
1487
|
+
* name the picture that shows the problem. Without this the implementer gets a path and the only thing it
|
|
1488
|
+
* can do with one is `read_file`, which cannot read a PNG.
|
|
1489
|
+
*/
|
|
1490
|
+
messages: (hints.message ? [{ role: "user", content: hints.message }] : []).concat([
|
|
1491
|
+
{ role: "user", ...handedOver(content, cwd) }
|
|
1492
|
+
]),
|
|
1493
|
+
onUsage: (u) => {
|
|
1494
|
+
tok.promptTokens += u.promptTokens;
|
|
1495
|
+
tok.completionTokens += u.completionTokens;
|
|
1496
|
+
if (u.model && u.model !== serving) {
|
|
1497
|
+
serving = u.model;
|
|
1498
|
+
deps.onProgress?.({ kind: "agent-model", id: task.id, model: serving });
|
|
1499
|
+
}
|
|
1500
|
+
deps.onProgress?.({ kind: "agent-usage", id: task.id, ...tok });
|
|
1501
|
+
},
|
|
1502
|
+
permission: deps.permission,
|
|
1503
|
+
approve: deps.approve,
|
|
1504
|
+
cwd,
|
|
1505
|
+
// The agent's own inbox first (a by-the-way note), then the deadline warning when it is due.
|
|
1506
|
+
inbox: () => deps.inbox?.() ?? deadlineNote(),
|
|
1507
|
+
signal: AbortSignal.any([deps.signal, budget]),
|
|
1508
|
+
// Stamped with the card id: the agent panel is keyed by it, and unattributed activity goes to the chat.
|
|
1509
|
+
onActivity: deps.onActivity ? (a) => deps.onActivity?.({ ...a, agent: task.id }) : void 0,
|
|
1510
|
+
/**
|
|
1511
|
+
* Attributed by ID, not by title.
|
|
1512
|
+
*
|
|
1513
|
+
* A wave runs several implementers at once, so unattributed prose interleaves into something no one can
|
|
1514
|
+
* read — that much was right. Using the task's TITLE for it was not: one agent narrating fifteen times
|
|
1515
|
+
* printed the same forty-word title fifteen times, and the user reported it as the tool "saying the same
|
|
1516
|
+
* thing over and over". They were reading it correctly; two thirds of every line WAS the same.
|
|
1517
|
+
*
|
|
1518
|
+
* The id is unique, it is what the board and the agent panel already show, and it leaves the line to the
|
|
1519
|
+
* sentence that differs.
|
|
1520
|
+
*/
|
|
1521
|
+
...deps.note ? { onSay: (t) => deps.note?.(` \u21B3 **${role}** \`${task.id}\` ${t}`) } : {},
|
|
1522
|
+
onLiveActivity: deps.onLiveActivity,
|
|
1523
|
+
onWrite: (path) => {
|
|
1524
|
+
touched.push(path);
|
|
1525
|
+
return commitFile(deps, cwd, path).then(() => {
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
const mins = Math.round(budgetMs / 6e4);
|
|
1530
|
+
const baseMins = Math.round((deps.implementerTimeoutMs ?? IMPLEMENTER_TIMEOUT_MS) / 6e4);
|
|
1531
|
+
if (mins > baseMins) {
|
|
1532
|
+
deps.note?.(`\u23F3 **${task.title}** ran out of time, not out of ideas \u2014 this attempt gets ${mins} minutes.`);
|
|
1533
|
+
}
|
|
1534
|
+
const overran = `the implementer ran past its ${mins}-minute budget for a single attempt and was stopped. Whatever it wrote is committed and kept \u2014 continue from there rather than starting over.`;
|
|
1535
|
+
try {
|
|
1536
|
+
const attempt = () => withDeadline(runToCompletion(opts), budget, overran);
|
|
1537
|
+
const timed = () => deps.timings ? deps.timings.time("implementation", attempt) : attempt();
|
|
1538
|
+
await telemetry().span("stage.implementation", {
|
|
1539
|
+
"hc.stage": "implementation",
|
|
1540
|
+
"hc.role": role,
|
|
1541
|
+
"hc.task.id": task.id,
|
|
1542
|
+
"hc.task.title": task.title.slice(0, 120),
|
|
1543
|
+
"hc.model": chain[0],
|
|
1544
|
+
"hc.attempt": task.attempts
|
|
1545
|
+
}, timed);
|
|
1546
|
+
} catch (e) {
|
|
1547
|
+
if (deps.signal.aborted || !budget.aborted) throw e;
|
|
1548
|
+
throw new Error(overran);
|
|
1549
|
+
} finally {
|
|
1550
|
+
const alsoChanged = await reconcileTouched(deps, cwd, touched);
|
|
1551
|
+
if (alsoChanged.length) {
|
|
1552
|
+
deps.note?.(`\u{1F4DD} ${alsoChanged.length} file(s) changed outside a write tool \u2014 checkpointed and credited.`);
|
|
1553
|
+
}
|
|
1554
|
+
reinforceTouched(deps, hints.ids, touched, role);
|
|
1555
|
+
reinforceUsed(deps, hints.ids, await writtenText(cwd, touched), role);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/engine/review.ts
|
|
1560
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1561
|
+
import { isAbsolute, join as join2 } from "path";
|
|
1562
|
+
import { z as z8 } from "zod";
|
|
1563
|
+
function asChoice(o) {
|
|
1564
|
+
return typeof o === "string" ? { label: o } : o;
|
|
1565
|
+
}
|
|
1566
|
+
var AssessmentSchema = z8.object({
|
|
1567
|
+
findings: z8.array(z8.object({
|
|
1568
|
+
severity: z8.enum(["critical", "medium", "low"]).describe(
|
|
1569
|
+
"`critical`: shipping it this way causes real harm \u2014 wrong behaviour, data loss, a security hole. `medium`: it should be fixed but nothing breaks if it ships. `low`: a preference or a tidy-up."
|
|
1570
|
+
),
|
|
1571
|
+
note: z8.string()
|
|
1572
|
+
})).default([]),
|
|
1573
|
+
recommendation: z8.enum(["approve", "revise"]).describe(
|
|
1574
|
+
"`revise` only if at least one finding must be addressed before this can ship; otherwise `approve` and leave the findings as notes. Findings you would not block on do not make it a revise."
|
|
1575
|
+
)
|
|
1576
|
+
});
|
|
1577
|
+
function severityCounts(a) {
|
|
1578
|
+
const c = { critical: 0, medium: 0, low: 0 };
|
|
1579
|
+
for (const f of a.findings) c[f.severity]++;
|
|
1580
|
+
return c;
|
|
1581
|
+
}
|
|
1582
|
+
function memberStatus(a) {
|
|
1583
|
+
const c = severityCounts(a);
|
|
1584
|
+
return `${a.recommendation === "approve" ? "APPROVE" : "REJECT"} \xB7 C:${c.critical} M:${c.medium} L:${c.low}`;
|
|
1585
|
+
}
|
|
1586
|
+
function findingNotes(assessments, stage, severities) {
|
|
1587
|
+
return assessments.flatMap((a) => a.findings.filter((f) => severities.includes(f.severity)).map((f) => `[${stage}][${f.severity}] ${a.name}: ${f.note}`));
|
|
1588
|
+
}
|
|
1589
|
+
function nonBlockingNotes(assessments, stage) {
|
|
1590
|
+
return findingNotes(assessments, stage, ["medium", "low"]);
|
|
1591
|
+
}
|
|
1592
|
+
function reviseBrief(findings, reasons) {
|
|
1593
|
+
return [...findings, ...reasons.map((r) => `[decision] ${r}`)];
|
|
1594
|
+
}
|
|
1595
|
+
function voteReasons(votes) {
|
|
1596
|
+
return votes.filter((v) => v.vote === "revise").map((v) => `${v.name}: ${v.rationale}`);
|
|
1597
|
+
}
|
|
1598
|
+
function blockingSignatures(assessments) {
|
|
1599
|
+
const out = /* @__PURE__ */ new Set();
|
|
1600
|
+
for (const a of assessments) for (const f of a.findings) {
|
|
1601
|
+
if (f.severity === "critical") out.add(`${a.name}::${f.note.trim().toLowerCase().replace(/\s+/g, " ")}`);
|
|
1602
|
+
}
|
|
1603
|
+
return out;
|
|
1604
|
+
}
|
|
1605
|
+
function severityTotal(assessments, sev) {
|
|
1606
|
+
return assessments.filter((a) => !a.unverified).reduce((n, a) => n + a.findings.filter((f) => f.severity === sev).length, 0);
|
|
1607
|
+
}
|
|
1608
|
+
var TEAM_MIN_COVERAGE = 0.6;
|
|
1609
|
+
function coverage(assessments) {
|
|
1610
|
+
const unverified = assessments.filter((a) => a.unverified).length;
|
|
1611
|
+
const verified = assessments.length - unverified;
|
|
1612
|
+
return { verified, unverified, enough: !assessments.length || verified / assessments.length >= TEAM_MIN_COVERAGE };
|
|
1613
|
+
}
|
|
1614
|
+
var CouncilVoteSchema = z8.object({
|
|
1615
|
+
vote: z8.enum(["pass", "revise"]).describe(
|
|
1616
|
+
"`revise` only if something must change before this can ship. A concern you would not block on is a `pass` with the concern in the rationale."
|
|
1617
|
+
),
|
|
1618
|
+
rationale: z8.string()
|
|
1619
|
+
});
|
|
1620
|
+
var JudgeSchema = z8.object({
|
|
1621
|
+
decision: z8.enum(["pass", "revise", "ask-human"]).describe(
|
|
1622
|
+
"`pass`: it can ship. `revise`: it can be fixed from the feedback below, without anyone being asked. `ask-human` ONLY when the decision is genuinely not yours \u2014 the reviewers disagree on something a person owns, or the answer depends on intent nobody wrote down. It stops the run and costs someone their attention; do not use it for a call you can make."
|
|
1623
|
+
),
|
|
1624
|
+
feedback: z8.array(z8.string()),
|
|
1625
|
+
question: z8.string()
|
|
1626
|
+
});
|
|
1627
|
+
var STAGE_FRAMING = {
|
|
1628
|
+
spec: `You are reviewing a SPECIFICATION: it states WHAT the product must do and WHY, written for business stakeholders. By design it MUST NOT contain implementation detail (languages, frameworks, APIs, storage mechanics, code structure) \u2014 those decisions belong to the LATER plan stage.
|
|
1629
|
+
OUT OF SCOPE here: implementation questions (which storage engine, how concurrency is handled, API shapes, libraries, performance tactics). Do NOT ask the spec to answer them and do NOT treat their absence as a defect \u2014 that is the plan's job. (The one exception is the abstraction-leak lens, which flags implementation detail that HAS leaked into the spec.)
|
|
1630
|
+
SEVERITY: "critical" = the spec contradicts itself, or a capability the user explicitly requested is missing or impossible as written. "medium" = a real ambiguity or gap that would likely cause the wrong thing to be built. "low" = wording/polish. "The spec does not specify <technical mechanism>" is NOT a finding.`,
|
|
1631
|
+
plan: 'You are reviewing an IMPLEMENTATION PLAN: it states HOW the already-approved spec will be built (technical context, architecture, data model, contracts, project structure). This is the right place for technology and mechanism decisions.\nOUT OF SCOPE here: re-litigating WHAT the product should do (the spec is approved), and reviewing code that does not exist yet.\nSEVERITY: "critical" = the plan cannot deliver a specified requirement, or has a design flaw that would have to be undone later. "medium" = a design weakness worth fixing now. "low" = preference/polish.',
|
|
1632
|
+
code: `You are reviewing CODE that implements one approved task.
|
|
1633
|
+
OUT OF SCOPE here: re-litigating the approved spec or plan, and demanding refactors beyond this task's scope.
|
|
1634
|
+
SEVERITY: "critical" = breaks correctness, security or data integrity, or the task's requirement is not actually implemented. "medium" = a real defect or risk worth fixing now. "low" = style/polish.`
|
|
1635
|
+
};
|
|
1636
|
+
var REVIEW_MAX_TURNS = 15;
|
|
1637
|
+
var REVIEW_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
1638
|
+
function isDeadlineFailure(e) {
|
|
1639
|
+
return /within its deadline|timed? ?out/i.test(e instanceof Error ? e.message : String(e));
|
|
1640
|
+
}
|
|
1641
|
+
function reviewerDeadlineMs(deps) {
|
|
1642
|
+
return deps.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS;
|
|
1643
|
+
}
|
|
1644
|
+
var SCOPE_RULE = "Scale your expectations to the REQUESTED scope: do not hold a small, simple product to enterprise-grade standards it never asked for. Demanding unrequested capability is itself a defect (scope creep), not a finding.";
|
|
1645
|
+
function teamPrompt(stage, perspective) {
|
|
1646
|
+
return `You are a review TEAM member for the ${stage.toUpperCase()} stage. Your lens: ${perspective}.
|
|
1647
|
+
|
|
1648
|
+
${STAGE_FRAMING[stage]}
|
|
1649
|
+
|
|
1650
|
+
${SCOPE_RULE}
|
|
1651
|
+
|
|
1652
|
+
Produce a list of findings \u2014 each with a severity ("critical"/"medium"/"low") and a concise note \u2014 plus a recommendation: "approve" (nothing blocking from your lens) or "revise". Report only genuine issues: an empty findings list with "approve" is the correct answer for work that is good enough for THIS stage. Write findings in ENGLISH \u2014 they are a technical review artifact, not a conversation with the user, so they stay English regardless of any conversational-language rule.`;
|
|
1653
|
+
}
|
|
1654
|
+
function councilPrompt(perspective) {
|
|
1655
|
+
return `You are a member of the review COUNCIL \u2014 a small, senior decision panel. Your judgment lens: ${perspective}.
|
|
1656
|
+
|
|
1657
|
+
You are given the work under review AND the review team's findings. Weigh them and cast a single vote: "pass" (ship it as-is for this stage) or "revise" (it needs changes first), with a concise rationale. Judge it against what was ASKED FOR and against what THIS stage is responsible for \u2014 a spec is not expected to answer implementation questions, and a plan is not expected to re-state requirements. Do not nitpick: vote "revise" only for issues that genuinely warrant another pass. ${SCOPE_RULE} Write the rationale in ENGLISH regardless of any conversational-language rule.`;
|
|
1658
|
+
}
|
|
1659
|
+
function buildReviewerRegistry(reviewers, prompt) {
|
|
1660
|
+
const roles = {};
|
|
1661
|
+
for (const r of reviewers) roles[r.name] = { models: r.models, systemPrompt: prompt(r.perspective) };
|
|
1662
|
+
return new RoleRegistry(roles);
|
|
1663
|
+
}
|
|
1664
|
+
function buildTeamRegistry(stage, team) {
|
|
1665
|
+
return buildReviewerRegistry(team, (p) => teamPrompt(stage, p));
|
|
1666
|
+
}
|
|
1667
|
+
function buildCouncilRegistry(council) {
|
|
1668
|
+
return buildReviewerRegistry(council, councilPrompt);
|
|
1669
|
+
}
|
|
1670
|
+
function errText(e) {
|
|
1671
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1672
|
+
return m.length > 160 ? `${m.slice(0, 159)}\u2026` : m;
|
|
1673
|
+
}
|
|
1674
|
+
async function runWithHealing(deps, role, id, opts, schema, signal, emit) {
|
|
1675
|
+
try {
|
|
1676
|
+
return await runStructuredRole(opts, schema);
|
|
1677
|
+
} catch (e) {
|
|
1678
|
+
if (deps.signal.aborted || isDeadlineFailure(e) || !deps.rechainRole) throw e;
|
|
1679
|
+
const chain = await deps.rechainRole(role, errText(e));
|
|
1680
|
+
if (!chain?.length) throw e;
|
|
1681
|
+
emit({ kind: "note", text: `\u{1F501} \`${role}\` lost its whole model chain \u2014 retrying on \`${chain[0]}\`.` });
|
|
1682
|
+
emit({ kind: "agent-model", id, model: chain[0] });
|
|
1683
|
+
return runStructuredRole({ ...opts, model: chain[0], fallbacks: chain.slice(1) }, schema);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
async function runTeam(deps, stage, workdir, target, request, emit = () => {
|
|
1687
|
+
}, carried = []) {
|
|
1688
|
+
const registry = deps.teamRegistries[stage];
|
|
1689
|
+
const carriedByName = new Map(carried.map((a) => [a.name, a]));
|
|
1690
|
+
const team = deps.teams[stage].filter((c) => !carriedByName.has(c.name));
|
|
1691
|
+
if (carriedByName.size) {
|
|
1692
|
+
emit({ kind: "note", text: `\u21A9\uFE0E ${carriedByName.size} lens(es) approved last round \u2014 carrying their verdict; re-reviewing ${team.length}.` });
|
|
1693
|
+
}
|
|
1694
|
+
const scope = request ? `
|
|
1695
|
+
|
|
1696
|
+
The user's original request (the scope you must judge against):
|
|
1697
|
+
"""
|
|
1698
|
+
${request}
|
|
1699
|
+
"""` : "";
|
|
1700
|
+
const what = stage === "code" ? `Review the code for ONE task: ${target}.
|
|
1701
|
+
The subject is THIS task's change and nothing else. Other tasks in the plan \u2014 including ones this change references, depends on, or leaves for later \u2014 are NOT your subject: each has its own card and its own review, and work that has not started yet is not a defect in this change. Judge whether what is here does THIS task correctly, and say nothing about what other tasks have not done.` : `Review the "${target}" document.`;
|
|
1702
|
+
const diff = stage === "code" ? await changeUnderReview(deps, workdir) : "";
|
|
1703
|
+
const evidence = stage === "code" ? `
|
|
1704
|
+
|
|
1705
|
+
${describeDiff(diff)}` : "";
|
|
1706
|
+
const query = `${stage} ${target} ${request ?? ""}`;
|
|
1707
|
+
const hintsByLens = new Map(team.map((c) => [c.name, memoryHints(deps, query, { role: c.name, silent: true })]));
|
|
1708
|
+
emit({ kind: "agents", agents: team.map((c) => ({ id: `team:${c.name}`, title: `team: ${c.name}`, model: registry.peekModel(c.name) })) });
|
|
1709
|
+
emitBatchInjection(deps, `team:${stage}`, [...hintsByLens.values()]);
|
|
1710
|
+
try {
|
|
1711
|
+
const fresh = await Promise.all(
|
|
1712
|
+
team.map(async (c) => {
|
|
1713
|
+
const tok = { promptTokens: 0, completionTokens: 0 };
|
|
1714
|
+
let resolved;
|
|
1715
|
+
try {
|
|
1716
|
+
resolved = registry.resolve(c.name);
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
emit({ kind: "agent-result", id: `team:${c.name}`, status: "\u26A0 UNVERIFIED (no model)" });
|
|
1719
|
+
return { name: c.name, recommendation: "revise", findings: [{
|
|
1720
|
+
severity: "critical",
|
|
1721
|
+
note: `The "${c.name}" lens has no model assigned (${errText(e)}) \u2014 this dimension is UNVERIFIED. Run \`/roles adjust\` to give every review lens a model.`
|
|
1722
|
+
}] };
|
|
1723
|
+
}
|
|
1724
|
+
const hints = hintsByLens.get(c.name);
|
|
1725
|
+
const id = `team:${c.name}`;
|
|
1726
|
+
let serving = registry.peekModel(c.name);
|
|
1727
|
+
const ask = { role: "user", content: `${what} Evaluate it through your lens.${scope}${evidence}` };
|
|
1728
|
+
const signal = deps.signal;
|
|
1729
|
+
const opts = {
|
|
1730
|
+
provider: deps.provider,
|
|
1731
|
+
...resolved,
|
|
1732
|
+
// Fifteen lenses reading the same change, one file per turn each, is the same waste multiplied.
|
|
1733
|
+
systemPrompt: resolved.systemPrompt + BATCH_TOOLS_NOTE,
|
|
1734
|
+
tools: readOnlyRegistry(deps, { propose: true }),
|
|
1735
|
+
// A slide down the chain is a visible event: rename the row, then let the registry's own note run.
|
|
1736
|
+
onFallback: (from, to, why) => {
|
|
1737
|
+
serving = to;
|
|
1738
|
+
emit({ kind: "agent-model", id, model: to });
|
|
1739
|
+
resolved.onFallback?.(from, to, why);
|
|
1740
|
+
},
|
|
1741
|
+
proposeMemory: (t, k) => deps.proposeMemory?.(t, k, c.name) ?? false,
|
|
1742
|
+
messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
|
|
1743
|
+
permission: deps.permission,
|
|
1744
|
+
approve: deps.approve,
|
|
1745
|
+
cwd: workdir,
|
|
1746
|
+
signal,
|
|
1747
|
+
// Per MODEL, not once for the chain — see reviewerDeadlineMs.
|
|
1748
|
+
perAttemptMs: reviewerDeadlineMs(deps),
|
|
1749
|
+
maxTurns: REVIEW_MAX_TURNS,
|
|
1750
|
+
// Stream the running total onto this member's row as each call lands — a row that shows only a
|
|
1751
|
+
// ticking clock for minutes says nothing about what it is costing while it is still costing it.
|
|
1752
|
+
onUsage: (u) => {
|
|
1753
|
+
tok.promptTokens += u.promptTokens;
|
|
1754
|
+
tok.completionTokens += u.completionTokens;
|
|
1755
|
+
if (u.model && u.model !== serving) {
|
|
1756
|
+
serving = u.model;
|
|
1757
|
+
emit({ kind: "agent-model", id, model: serving });
|
|
1758
|
+
}
|
|
1759
|
+
emit({ kind: "agent-usage", id, ...tok });
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
try {
|
|
1763
|
+
const r = await runWithHealing(deps, c.name, id, opts, AssessmentSchema, signal, emit);
|
|
1764
|
+
const a = { name: c.name, findings: r.findings, recommendation: r.recommendation };
|
|
1765
|
+
reinforceUsed(deps, hints.ids, r.findings.map((f) => f.note).join(" "), c.name);
|
|
1766
|
+
emit({ kind: "agent-result", id: `team:${c.name}`, status: memberStatus(a), ...tok });
|
|
1767
|
+
return a;
|
|
1768
|
+
} catch (e) {
|
|
1769
|
+
if (deps.signal.aborted) throw e;
|
|
1770
|
+
const timedOut = isDeadlineFailure(e);
|
|
1771
|
+
const why = timedOut ? `did not finish within its ${Math.round((deps.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS) / 1e3)}s budget` : `every model in its chain failed \u2014 ${errText(e)}`;
|
|
1772
|
+
emit({ kind: "agent-result", id: `team:${c.name}`, status: timedOut ? "\u26A0 UNVERIFIED (timed out)" : "\u26A0 UNVERIFIED (no response)", ...tok });
|
|
1773
|
+
return { name: c.name, recommendation: "revise", unverified: true, findings: [{ severity: "critical", note: `The "${c.name}" lens could not complete its review (${why}) \u2014 this dimension is UNVERIFIED and must be re-checked.` }] };
|
|
1774
|
+
}
|
|
1775
|
+
})
|
|
1776
|
+
);
|
|
1777
|
+
return [...carried, ...fresh];
|
|
1778
|
+
} finally {
|
|
1779
|
+
emit({ kind: "agents", agents: [] });
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
function findingsDigest(assessments) {
|
|
1783
|
+
return assessments.map((a) => {
|
|
1784
|
+
const list = a.findings.map((f) => `[${f.severity}] ${f.note}`).join("; ") || "no findings";
|
|
1785
|
+
return `- ${a.name} (${a.recommendation}): ${list}`;
|
|
1786
|
+
}).join("\n");
|
|
1787
|
+
}
|
|
1788
|
+
function teamSummaryNote(assessments, label) {
|
|
1789
|
+
const approve = assessments.filter((a) => a.recommendation === "approve").length;
|
|
1790
|
+
const lines = assessments.map((a) => {
|
|
1791
|
+
const c = severityCounts(a);
|
|
1792
|
+
const counts = c.critical || c.medium || c.low ? ` \u2014 C:${c.critical} M:${c.medium} L:${c.low}` : "";
|
|
1793
|
+
return `- \`${a.name}\` ${a.recommendation === "approve" ? "\u2713 APPROVE" : "\u2717 REJECT"}${counts}`;
|
|
1794
|
+
}).join("\n");
|
|
1795
|
+
return `**Team review of the ${label}** \u2014 ${approve}/${assessments.length} approve:
|
|
1796
|
+
${lines}`;
|
|
1797
|
+
}
|
|
1798
|
+
async function runCouncil(deps, stage, workdir, target, assessments, request, emit = () => {
|
|
1799
|
+
}, question = "blocking") {
|
|
1800
|
+
const digest = findingsDigest(assessments);
|
|
1801
|
+
const scope = request ? `
|
|
1802
|
+
|
|
1803
|
+
The user's original request:
|
|
1804
|
+
"""
|
|
1805
|
+
${request}
|
|
1806
|
+
"""` : "";
|
|
1807
|
+
const subject = stage === "code" ? `the code for: ${target}` : `the "${target}" ${stage}`;
|
|
1808
|
+
const councilDiff = stage === "code" ? await changeUnderReview(deps, workdir) : "";
|
|
1809
|
+
const councilEvidence = stage === "code" ? `
|
|
1810
|
+
|
|
1811
|
+
${describeDiff(councilDiff)}` : "";
|
|
1812
|
+
const ask = question === "deferral" ? `
|
|
1813
|
+
|
|
1814
|
+
NOTE: this work has ALREADY been revised once and NO critical findings remain \u2014 only medium/low ones. Decide: vote "pass" to hand it to the next stage and DEFER those findings (they are recorded and carried forward, not dropped), or vote "revise" ONLY if one of them would genuinely cause the wrong thing to be built or shipped despite its label. Wanting it clearer, tighter or more complete is NOT a reason to revise.` : "";
|
|
1815
|
+
const hintsByMember = new Map(deps.council.map((c) => [c.name, memoryHints(deps, `${stage} ${target} ${request ?? ""}`, { role: c.name, silent: true })]));
|
|
1816
|
+
emit({ kind: "agents", agents: deps.council.map((c) => ({ id: `council:${c.name}`, title: `council: ${c.name}`, model: deps.councilRegistry.peekModel(c.name) })) });
|
|
1817
|
+
emitBatchInjection(deps, "council", [...hintsByMember.values()]);
|
|
1818
|
+
try {
|
|
1819
|
+
const results = await Promise.all(
|
|
1820
|
+
deps.council.map(async (c) => {
|
|
1821
|
+
const tok = { promptTokens: 0, completionTokens: 0 };
|
|
1822
|
+
let resolved;
|
|
1823
|
+
try {
|
|
1824
|
+
resolved = deps.councilRegistry.resolve(c.name);
|
|
1825
|
+
} catch (e) {
|
|
1826
|
+
emit({ kind: "agent-result", id: `council:${c.name}`, status: "\u26A0 UNVERIFIED (no model)" });
|
|
1827
|
+
return {
|
|
1828
|
+
name: c.name,
|
|
1829
|
+
vote: "revise",
|
|
1830
|
+
rationale: `The "${c.name}" decider has no model assigned (${errText(e)}) \u2014 counted as revise to be safe.`
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
const hints = hintsByMember.get(c.name);
|
|
1834
|
+
const id = `council:${c.name}`;
|
|
1835
|
+
let serving = deps.councilRegistry.peekModel(c.name);
|
|
1836
|
+
const vote = { role: "user", content: `You are reviewing ${subject} (the ${stage} stage), plus the team's findings:
|
|
1837
|
+
${digest}${scope}${ask}${councilEvidence}
|
|
1838
|
+
|
|
1839
|
+
Cast your vote (pass/revise) with a rationale.` };
|
|
1840
|
+
const signal = deps.signal;
|
|
1841
|
+
const opts = {
|
|
1842
|
+
provider: deps.provider,
|
|
1843
|
+
...resolved,
|
|
1844
|
+
// Fifteen lenses reading the same change, one file per turn each, is the same waste multiplied.
|
|
1845
|
+
systemPrompt: resolved.systemPrompt + BATCH_TOOLS_NOTE,
|
|
1846
|
+
tools: readOnlyRegistry(deps, { propose: true }),
|
|
1847
|
+
// A slide down the chain is a visible event: rename the row, then let the registry's own note run.
|
|
1848
|
+
onFallback: (from, to, why) => {
|
|
1849
|
+
serving = to;
|
|
1850
|
+
emit({ kind: "agent-model", id, model: to });
|
|
1851
|
+
resolved.onFallback?.(from, to, why);
|
|
1852
|
+
},
|
|
1853
|
+
proposeMemory: (t, k) => deps.proposeMemory?.(t, k, c.name) ?? false,
|
|
1854
|
+
messages: hints.message ? [{ role: "user", content: hints.message }, vote] : [vote],
|
|
1855
|
+
permission: deps.permission,
|
|
1856
|
+
approve: deps.approve,
|
|
1857
|
+
cwd: workdir,
|
|
1858
|
+
signal,
|
|
1859
|
+
// Per MODEL, not once for the chain — see reviewerDeadlineMs.
|
|
1860
|
+
perAttemptMs: reviewerDeadlineMs(deps),
|
|
1861
|
+
maxTurns: REVIEW_MAX_TURNS,
|
|
1862
|
+
// Stream the running total onto this member's row as each call lands — a row that shows only a
|
|
1863
|
+
// ticking clock for minutes says nothing about what it is costing while it is still costing it.
|
|
1864
|
+
onUsage: (u) => {
|
|
1865
|
+
tok.promptTokens += u.promptTokens;
|
|
1866
|
+
tok.completionTokens += u.completionTokens;
|
|
1867
|
+
if (u.model && u.model !== serving) {
|
|
1868
|
+
serving = u.model;
|
|
1869
|
+
emit({ kind: "agent-model", id, model: serving });
|
|
1870
|
+
}
|
|
1871
|
+
emit({ kind: "agent-usage", id, ...tok });
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
try {
|
|
1875
|
+
const r = await runWithHealing(deps, c.name, id, opts, CouncilVoteSchema, signal, emit);
|
|
1876
|
+
reinforceUsed(deps, hints.ids, r.rationale, c.name);
|
|
1877
|
+
emit({ kind: "agent-result", id: `council:${c.name}`, status: r.vote === "pass" ? "PASS" : "REVISE", ...tok });
|
|
1878
|
+
return { name: c.name, vote: r.vote, rationale: r.rationale };
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
if (deps.signal.aborted) throw e;
|
|
1881
|
+
const timedOut = isDeadlineFailure(e);
|
|
1882
|
+
emit({ kind: "agent-result", id: `council:${c.name}`, status: timedOut ? "\u26A0 UNVERIFIED (timed out)" : "\u26A0 UNVERIFIED (no response)", ...tok });
|
|
1883
|
+
return { name: c.name, vote: "revise", rationale: `The "${c.name}" decider could not vote (${timedOut ? "timed out" : `chain failed \u2014 ${errText(e)}`}) \u2014 counted as revise to be safe.` };
|
|
1884
|
+
}
|
|
1885
|
+
})
|
|
1886
|
+
);
|
|
1887
|
+
return results;
|
|
1888
|
+
} finally {
|
|
1889
|
+
emit({ kind: "agents", agents: [] });
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
async function runJudge(deps, stage, workdir, target, assessments, votes, request, emit = () => {
|
|
1893
|
+
}, question = "contested", rounds = 0) {
|
|
1894
|
+
const resolved = deps.roleRegistry.resolve("judge");
|
|
1895
|
+
const findings = findingsDigest(assessments);
|
|
1896
|
+
const council = votes.map((v) => `- ${v.name}: ${v.vote} \u2014 ${v.rationale}`).join("\n");
|
|
1897
|
+
const subject = stage === "code" ? `The code for "${target}"` : `The "${target}" ${stage}`;
|
|
1898
|
+
const ask = question === "final" ? `This review is STUCK: ${rounds} revision round(s) have run and the same blocking findings keep surviving, or the round budget is spent. You are the LAST authority before the user is involved.
|
|
1899
|
+
Rule decisively:
|
|
1900
|
+
- "pass" \u2014 the work is good enough for THIS stage; remaining findings are not real blockers (preferred if true).
|
|
1901
|
+
- "revise" \u2014 one more TARGETED attempt is genuinely worth it; say exactly what must change.
|
|
1902
|
+
- "ask-human" \u2014 ONLY if the blocker is a product/scope decision that you cannot make on the user's behalf. Difficulty, reviewer disagreement or a desire for more polish are NOT reasons to ask the user.` : `You are the final decider on this contested round. Judge it against what was asked for and against what THIS stage is responsible for (a spec answers WHAT/WHY, a plan answers HOW, code is the implementation). Decide (pass/revise/ask-human).`;
|
|
1903
|
+
const hints = memoryHints(deps, `${stage} ${target} ${request ?? ""}`, { role: "judge" });
|
|
1904
|
+
const brief = { role: "user", content: `${subject} is contested (the ${stage} review stage).${request ? `
|
|
1905
|
+
|
|
1906
|
+
The user's original request:
|
|
1907
|
+
"""
|
|
1908
|
+
${request}
|
|
1909
|
+
"""` : ""}
|
|
1910
|
+
|
|
1911
|
+
The review team's findings:
|
|
1912
|
+
${findings}
|
|
1913
|
+
|
|
1914
|
+
The council's votes:
|
|
1915
|
+
${council}
|
|
1916
|
+
|
|
1917
|
+
${ask}` };
|
|
1918
|
+
const opts = {
|
|
1919
|
+
provider: deps.provider,
|
|
1920
|
+
...resolved,
|
|
1921
|
+
tools: readOnlyRegistry(deps, { propose: true }),
|
|
1922
|
+
proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "judge") ?? false,
|
|
1923
|
+
messages: hints.message ? [{ role: "user", content: hints.message }, brief] : [brief],
|
|
1924
|
+
permission: deps.permission,
|
|
1925
|
+
approve: deps.approve,
|
|
1926
|
+
cwd: workdir,
|
|
1927
|
+
signal: deps.signal,
|
|
1928
|
+
perAttemptMs: reviewerDeadlineMs(deps),
|
|
1929
|
+
maxTurns: REVIEW_MAX_TURNS
|
|
1930
|
+
};
|
|
1931
|
+
let d;
|
|
1932
|
+
try {
|
|
1933
|
+
d = await runStructuredRole(opts, JudgeSchema);
|
|
1934
|
+
reinforceUsed(deps, hints.ids, d.feedback.join(" "), "judge");
|
|
1935
|
+
} catch (e) {
|
|
1936
|
+
if (deps.signal.aborted) throw e;
|
|
1937
|
+
emit({ kind: "note", text: `\u{1F528} **Judge** couldn't produce a ruling \u2014 defaulting to revise (re-reviewing).` });
|
|
1938
|
+
return { decision: "revise", feedback: ["The judge could not reach a structured decision; revising and re-reviewing to be safe."], question: "" };
|
|
1939
|
+
}
|
|
1940
|
+
emit({ kind: "note", text: d.decision === "pass" ? `\u{1F528} **Judge** ruled: approve.` : d.decision === "revise" ? `\u{1F528} **Judge** ruled: revise \u2192 sending it back for changes.` : `\u{1F528} **Judge** needs your input to break the tie.` });
|
|
1941
|
+
return d;
|
|
1942
|
+
}
|
|
1943
|
+
var TEAM_CONSENSUS = 0.7;
|
|
1944
|
+
var CORROBORATION_FLOOR = 5;
|
|
1945
|
+
function corroboratedCriticals(assessments) {
|
|
1946
|
+
const criticals = assessments.flatMap((a) => a.findings.filter((f) => f.severity === "critical").map((f) => ({ lens: a.name, note: f.note })));
|
|
1947
|
+
if (assessments.length < CORROBORATION_FLOOR) return criticals.length;
|
|
1948
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
1949
|
+
for (const c of criticals) {
|
|
1950
|
+
const key2 = subjectOf(c.note) ?? `${c.lens}:${c.note.slice(0, 40)}`;
|
|
1951
|
+
(bySubject.get(key2) ?? bySubject.set(key2, /* @__PURE__ */ new Set()).get(key2)).add(c.lens);
|
|
1952
|
+
}
|
|
1953
|
+
return [...bySubject.values()].filter((lenses) => lenses.size > 1).length;
|
|
1954
|
+
}
|
|
1955
|
+
var REVIEW_SIGNATURES_LOGGED = 8;
|
|
1956
|
+
var SIGNATURE_CHARS = 120;
|
|
1957
|
+
var COUNCIL_SUPERMAJORITY = 0.8;
|
|
1958
|
+
function tallyCouncil(votes) {
|
|
1959
|
+
if (votes.length === 0) return "split";
|
|
1960
|
+
const needed = Math.ceil(votes.length * COUNCIL_SUPERMAJORITY);
|
|
1961
|
+
const pass = votes.filter((v) => v.vote === "pass").length;
|
|
1962
|
+
if (pass >= needed) return "pass";
|
|
1963
|
+
if (votes.length - pass >= needed) return "revise";
|
|
1964
|
+
return "split";
|
|
1965
|
+
}
|
|
1966
|
+
async function runReviewLoop(deps, o) {
|
|
1967
|
+
const { stage, workdir, target, request, revise, askUser, maxRounds } = o;
|
|
1968
|
+
const emit = o.emit ?? (() => {
|
|
1969
|
+
});
|
|
1970
|
+
const language = o.language;
|
|
1971
|
+
const label = stage;
|
|
1972
|
+
let round = 0;
|
|
1973
|
+
let prevSignatures = /* @__PURE__ */ new Set();
|
|
1974
|
+
let deferralVetoUsed = false;
|
|
1975
|
+
let batches = 1;
|
|
1976
|
+
const MAX_BATCHES = 2;
|
|
1977
|
+
let lastAssessments;
|
|
1978
|
+
let lastVotes = [];
|
|
1979
|
+
for (; ; ) {
|
|
1980
|
+
for (let i = 0; i < maxRounds; i++, round++) {
|
|
1981
|
+
if (stage !== "code" && !existsSync3(isAbsolute(target) ? target : join2(workdir, target))) {
|
|
1982
|
+
emit({ kind: "note", text: `\u26A0\uFE0F **${label} not found** at \`${target}\` \u2014 nothing to review. The authoring phase produced no file.` });
|
|
1983
|
+
return { approved: false };
|
|
1984
|
+
}
|
|
1985
|
+
const approvedLast = (lastAssessments ?? []).filter((a) => a.recommendation === "approve");
|
|
1986
|
+
const carry = round > 0 && approvedLast.length < (lastAssessments?.length ?? 0) ? approvedLast : [];
|
|
1987
|
+
emit({ kind: "note", text: `\u{1F50D} **Reviewing the ${label}** (round ${round + 1}) \u2014 the team (${deps.teams[stage].length - carry.length}) is discussing it\u2026` });
|
|
1988
|
+
const assessments = await runTeam(deps, stage, workdir, target, request, emit, carry);
|
|
1989
|
+
lastAssessments = assessments;
|
|
1990
|
+
if (assessments.length) emit({ kind: "note", text: teamSummaryNote(assessments, label) });
|
|
1991
|
+
const verdicts = assessments.filter((a) => !a.unverified);
|
|
1992
|
+
const approve = verdicts.filter((a) => a.recommendation === "approve").length;
|
|
1993
|
+
const crit = severityTotal(assessments, "critical");
|
|
1994
|
+
const med = severityTotal(assessments, "medium");
|
|
1995
|
+
const cover = coverage(assessments);
|
|
1996
|
+
if (cover.unverified) {
|
|
1997
|
+
emit({ kind: "note", text: `\u26A0\uFE0F ${cover.unverified} of ${assessments.length} lens(es) never returned a verdict \u2014 those dimensions are UNVERIFIED. ${cover.enough ? "The decision rests on the ones that did." : "Too few ran to decide by rule."}` });
|
|
1998
|
+
}
|
|
1999
|
+
if (round === 0) {
|
|
2000
|
+
const clean = crit === 0 && med === 0 && cover.enough;
|
|
2001
|
+
if (verdicts.length && clean && approve / verdicts.length >= TEAM_CONSENSUS) {
|
|
2002
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 clean (no critical/medium findings), ${approve}/${verdicts.length} approve \u2192 the ${label} is approved.` });
|
|
2003
|
+
return { approved: true };
|
|
2004
|
+
}
|
|
2005
|
+
} else if (crit === 0 && cover.enough) {
|
|
2006
|
+
const deferred = nonBlockingNotes(assessments, stage);
|
|
2007
|
+
if (!deferred.length) {
|
|
2008
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 nothing left to fix \u2192 the ${label} is approved.` });
|
|
2009
|
+
return { approved: true };
|
|
2010
|
+
}
|
|
2011
|
+
if (deferralVetoUsed) {
|
|
2012
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 only medium/low findings remain \u2192 the ${label} is approved; ${deferred.length} note(s) carried forward.` });
|
|
2013
|
+
return { approved: true, deferred };
|
|
2014
|
+
}
|
|
2015
|
+
emit({ kind: "note", text: `\u{1F465} **Team** \u2014 no criticals, ${deferred.length} medium/low finding(s) \u2192 asking the **council** whether to defer them or fix one now.` });
|
|
2016
|
+
const dVotes = await runCouncil(deps, stage, workdir, target, assessments, request, emit, "deferral");
|
|
2017
|
+
const dTally = tallyCouncil(dVotes);
|
|
2018
|
+
const dPass = dVotes.filter((v) => v.vote === "pass").length;
|
|
2019
|
+
if (dTally === "pass") {
|
|
2020
|
+
emit({ kind: "note", text: `\u2705 **Council** voted to defer (${dPass}/${dVotes.length} pass) \u2192 the ${label} is approved; ${deferred.length} note(s) carried forward.` });
|
|
2021
|
+
return { approved: true, deferred };
|
|
2022
|
+
}
|
|
2023
|
+
const dJudged = dTally === "revise" ? { decision: "revise", feedback: voteReasons(dVotes), question: "" } : await runJudge(deps, stage, workdir, target, assessments, dVotes, request, emit);
|
|
2024
|
+
if (dJudged.decision === "pass") {
|
|
2025
|
+
emit({ kind: "note", text: `\u2705 **Judge** approved the ${label}; ${deferred.length} note(s) carried forward.` });
|
|
2026
|
+
return { approved: true, deferred };
|
|
2027
|
+
}
|
|
2028
|
+
emit({ kind: "note", text: `\u{1F504} **Council** found a non-critical finding worth fixing \u2192 one more revision of the ${label}.` });
|
|
2029
|
+
deferralVetoUsed = true;
|
|
2030
|
+
await revise(reviseBrief(deferred, dJudged.feedback));
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
const sig = blockingSignatures(assessments);
|
|
2034
|
+
const stuck = round > 0 && sig.size > 0 && [...sig].every((x) => prevSignatures.has(x));
|
|
2035
|
+
telemetry().event("decision.review_round", {
|
|
2036
|
+
"hc.stage": stage,
|
|
2037
|
+
"hc.review.round": round,
|
|
2038
|
+
"hc.review.blocking": sig.size,
|
|
2039
|
+
"hc.review.repeated": [...sig].filter((x) => prevSignatures.has(x)).length,
|
|
2040
|
+
"hc.review.stuck": stuck,
|
|
2041
|
+
"hc.review.signatures": [...sig].slice(0, REVIEW_SIGNATURES_LOGGED).map((s) => s.length > SIGNATURE_CHARS ? `${s.slice(0, SIGNATURE_CHARS)}\u2026` : s).join(" \xB7 ")
|
|
2042
|
+
});
|
|
2043
|
+
prevSignatures = sig;
|
|
2044
|
+
const blockingFindings = findingNotes(assessments, stage, round === 0 ? ["critical", "medium"] : ["critical"]);
|
|
2045
|
+
const reason = crit || med ? `surfaced ${crit} critical / ${med} medium finding(s)` : `is split (${approve}/${assessments.length} approve)`;
|
|
2046
|
+
emit({ kind: "note", text: `\u{1F465} **Team** ${reason} \u2192 handed the decision to the **council** (${deps.council.length} members vote).` });
|
|
2047
|
+
const votes = await runCouncil(deps, stage, workdir, target, assessments, request, emit);
|
|
2048
|
+
const tally = tallyCouncil(votes);
|
|
2049
|
+
const passVotes = votes.filter((v) => v.vote === "pass").length;
|
|
2050
|
+
let decision;
|
|
2051
|
+
if (tally === "pass") {
|
|
2052
|
+
const deferred = nonBlockingNotes(assessments, stage);
|
|
2053
|
+
emit({ kind: "note", text: `\u2705 **Council** voted to approve (${passVotes}/${votes.length} pass) \u2192 the ${label} is approved.${deferred.length ? ` ${deferred.length} note(s) carried forward.` : ""}` });
|
|
2054
|
+
return { approved: true, deferred };
|
|
2055
|
+
} else if (tally === "revise") {
|
|
2056
|
+
emit({ kind: "note", text: `\u{1F504} **Council** voted to revise (${votes.length - passVotes}/${votes.length}) \u2192 sending the ${label} back for changes.` });
|
|
2057
|
+
decision = { decision: "revise", feedback: voteReasons(votes), question: "" };
|
|
2058
|
+
} else {
|
|
2059
|
+
emit({ kind: "note", text: `\u{1F528} **Council** was split (${passVotes}/${votes.length} pass) \u2192 deferred the final decision to the **judge**.` });
|
|
2060
|
+
decision = await runJudge(deps, stage, workdir, target, assessments, votes, request, emit);
|
|
2061
|
+
if (decision.decision === "pass") {
|
|
2062
|
+
const deferred = nonBlockingNotes(assessments, stage);
|
|
2063
|
+
emit({ kind: "note", text: `\u2705 **Judge** approved the ${label}.${deferred.length ? ` ${deferred.length} note(s) carried forward.` : ""}` });
|
|
2064
|
+
return { approved: true, deferred };
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
lastVotes = votes;
|
|
2068
|
+
let feedback = decision.feedback;
|
|
2069
|
+
if (decision.decision === "ask-human") {
|
|
2070
|
+
emit({ kind: "note", text: `\u2753 Judge needs your input: ${decision.question}` });
|
|
2071
|
+
const answer2 = await askUser(decision.question);
|
|
2072
|
+
feedback = [...feedback, `Human answer: ${answer2}`];
|
|
2073
|
+
}
|
|
2074
|
+
if (stuck) {
|
|
2075
|
+
emit({ kind: "note", text: `\u26A0\uFE0F **Not converging** \u2014 the same blocking findings survived the last revision. Handing it to the **judge** for a final ruling.` });
|
|
2076
|
+
break;
|
|
2077
|
+
}
|
|
2078
|
+
emit({ kind: "note", text: `\u{1F504} Revising the ${label} with ${blockingFindings.length} finding(s) + the deciders' reasons\u2026` });
|
|
2079
|
+
await revise(reviseBrief(blockingFindings, feedback));
|
|
2080
|
+
}
|
|
2081
|
+
if (lastAssessments) {
|
|
2082
|
+
const finalRuling = await runJudge(deps, stage, workdir, target, lastAssessments, lastVotes, request, emit, "final", round);
|
|
2083
|
+
if (finalRuling.decision === "pass") {
|
|
2084
|
+
const deferred = nonBlockingNotes(lastAssessments, stage);
|
|
2085
|
+
emit({ kind: "note", text: `\u2705 **Judge** ruled the ${label} good enough for this stage.${deferred.length ? ` ${deferred.length} note(s) carried forward.` : ""}` });
|
|
2086
|
+
return { approved: true, deferred };
|
|
2087
|
+
}
|
|
2088
|
+
if (finalRuling.decision === "revise" && batches < MAX_BATCHES) {
|
|
2089
|
+
batches++;
|
|
2090
|
+
emit({ kind: "note", text: `\u{1F504} **Judge** ruled one more targeted attempt is worth it \u2192 another ${maxRounds} round(s).` });
|
|
2091
|
+
await revise(reviseBrief(findingNotes(lastAssessments, stage, ["critical"]), finalRuling.feedback));
|
|
2092
|
+
prevSignatures = /* @__PURE__ */ new Set();
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
if (finalRuling.decision === "ask-human" && finalRuling.question) {
|
|
2096
|
+
emit({ kind: "note", text: `\u2753 **Judge** needs a decision only you can make: ${finalRuling.question}` });
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
const [q, approveLabel, continueLabel, stopLabel] = language === "Turkish" ? [`${round} revizyon turunda onaylanmad\u0131. Ne yapmak istersin?`, "Mevcut haliyle onayla", `Review'a devam et (${maxRounds} tur daha)`, "Durdur"] : [`Not approved after ${round} revision rounds. What now?`, "Approve as-is", `Keep reviewing (${maxRounds} more rounds)`, "Stop"];
|
|
2100
|
+
const answer = (await askUser(q, { options: [approveLabel, continueLabel, stopLabel] })).trim();
|
|
2101
|
+
if (answer === continueLabel || /^\s*(review|more|daha|başka|tur|round)/i.test(answer)) continue;
|
|
2102
|
+
if (answer === stopLabel || /^\s*(stop|durdur|iptal|cancel|hay[ıi]r|no)\s*$/i.test(answer)) return { approved: false };
|
|
2103
|
+
if (answer === approveLabel || /^\s*(approve|yes|onayla|onay|evet|kabul|tamam|ok)\s*$/i.test(answer)) return { approved: true };
|
|
2104
|
+
emit({ kind: "note", text: language === "Turkish" ? "\u{1F504} Anla\u015F\u0131lamad\u0131 \u2014 review'a devam ediliyor." : "\u{1F504} Unclear answer \u2014 continuing the review." });
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
var CORE_CODE_LENSES = [
|
|
2108
|
+
"code-correctness",
|
|
2109
|
+
"code-plan-conformance",
|
|
2110
|
+
"code-tests",
|
|
2111
|
+
"code-security"
|
|
2112
|
+
];
|
|
2113
|
+
var SMALL_CHANGE_LINES = 40;
|
|
2114
|
+
function changedLines(diff) {
|
|
2115
|
+
let n = 0;
|
|
2116
|
+
for (const line of diff.split("\n")) {
|
|
2117
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
2118
|
+
if (line.startsWith("+") || line.startsWith("-")) n++;
|
|
2119
|
+
}
|
|
2120
|
+
return n;
|
|
2121
|
+
}
|
|
2122
|
+
function lensesFor(team, diff) {
|
|
2123
|
+
if (!diff.trim() || changedLines(diff) > SMALL_CHANGE_LINES) return team;
|
|
2124
|
+
const core = team.filter((c) => CORE_CODE_LENSES.includes(c.name));
|
|
2125
|
+
return core.length ? core : team;
|
|
2126
|
+
}
|
|
2127
|
+
async function changeUnderReview(deps, workdir) {
|
|
2128
|
+
if (deps.baseRef) return taskDiff(workdir, deps.baseRef);
|
|
2129
|
+
if (deps.inPlaceBase) return diffSince(workdir, deps.inPlaceBase);
|
|
2130
|
+
return workingTreeDiff(workdir);
|
|
2131
|
+
}
|
|
2132
|
+
async function runCodeReview(deps, workdir, taskTitle, request, emit = () => {
|
|
2133
|
+
}, attempt = 0, cleared = []) {
|
|
2134
|
+
const diff = await changeUnderReview(deps, workdir);
|
|
2135
|
+
const scoped = lensesFor(deps.teams.code, diff);
|
|
2136
|
+
const remaining = cleared.length ? scoped.filter((c) => !cleared.includes(c.name)) : scoped;
|
|
2137
|
+
const named = scoped.filter((c) => CORE_CODE_LENSES.includes(c.name));
|
|
2138
|
+
const core = named.length ? named : scoped.slice(0, CORE_CODE_LENSES.length);
|
|
2139
|
+
const team = remaining.length ? remaining : core;
|
|
2140
|
+
const scaled = team.length < deps.teams.code.length;
|
|
2141
|
+
if (!remaining.length) {
|
|
2142
|
+
emit({ kind: "note", text: `\u{1F465} **Team** \u2014 every lens had approved; re-running the ${team.length} core lens(es) on the new code.` });
|
|
2143
|
+
}
|
|
2144
|
+
if (cleared.length) {
|
|
2145
|
+
emit({ kind: "note", text: `\u{1F465} **Team** \u2014 ${team.length} lens(es) to re-run; ${cleared.length} approved on an earlier attempt.` });
|
|
2146
|
+
}
|
|
2147
|
+
telemetry().event("decision.review_scale", {
|
|
2148
|
+
"hc.decision": "review_scale",
|
|
2149
|
+
"hc.changed_lines": changedLines(diff),
|
|
2150
|
+
"hc.lenses": team.length,
|
|
2151
|
+
"hc.lenses.full": deps.teams.code.length,
|
|
2152
|
+
"hc.scaled": scaled
|
|
2153
|
+
});
|
|
2154
|
+
emit({ kind: "note", text: `\u{1F50D} **Reviewing the code** for "${taskTitle}" \u2014 ${team.length} lens(es)${scaled ? ` (${changedLines(diff)} changed lines \u2014 the core set)` : ""} discussing it\u2026` });
|
|
2155
|
+
const assessments = await runTeam({ ...deps, teams: { ...deps.teams, code: team } }, "code", workdir, taskTitle, request, emit);
|
|
2156
|
+
if (assessments.length) emit({ kind: "note", text: teamSummaryNote(assessments, "code") });
|
|
2157
|
+
const approve = assessments.filter((a) => a.recommendation === "approve").length;
|
|
2158
|
+
const crit = severityTotal(assessments, "critical");
|
|
2159
|
+
const med = severityTotal(assessments, "medium");
|
|
2160
|
+
const approvedLenses = [
|
|
2161
|
+
...cleared,
|
|
2162
|
+
...assessments.filter((a) => !a.unverified && !a.findings.some((f) => f.severity === "critical")).map((a) => a.name)
|
|
2163
|
+
];
|
|
2164
|
+
const cover = coverage(assessments);
|
|
2165
|
+
if (!cover.enough) {
|
|
2166
|
+
const why = `${cover.unverified} of ${assessments.length} lens(es) never returned a verdict \u2014 too little of the review ran to judge this change. Re-run it; if it keeps happening the lens's model chain is the fault.`;
|
|
2167
|
+
emit({ kind: "note", text: `\u26A0\uFE0F **Code review** \u2014 ${why}` });
|
|
2168
|
+
return { verdict: "fail", notes: [why] };
|
|
2169
|
+
}
|
|
2170
|
+
const corroborated = corroboratedCriticals(assessments);
|
|
2171
|
+
if (crit > 0 && corroborated === 0) {
|
|
2172
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 ${crit} critical finding(s), none corroborated by a second lens \u2192 deferred to the revision pass.` });
|
|
2173
|
+
}
|
|
2174
|
+
if (corroborated === 0) {
|
|
2175
|
+
const deferred = [...findingNotes(assessments, "code", ["critical"]), ...nonBlockingNotes(assessments, "code")];
|
|
2176
|
+
if (!deferred.length) {
|
|
2177
|
+
if (attempt > 0 || !assessments.length || approve / assessments.length >= TEAM_CONSENSUS) {
|
|
2178
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 nothing to fix \u2192 the code passed.` });
|
|
2179
|
+
return { verdict: "pass", notes: [], approvedLenses };
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
emit({ kind: "note", text: `\u2705 **Team** \u2014 nothing corroborated; ${deferred.length} note(s) deferred to the revision pass.` });
|
|
2183
|
+
return { verdict: "pass", notes: [], deferred, approvedLenses };
|
|
2184
|
+
}
|
|
2185
|
+
const reason = crit || med ? `surfaced ${crit} critical / ${med} medium finding(s)` : `is split (${approve}/${assessments.length} approve)`;
|
|
2186
|
+
emit({ kind: "note", text: `\u{1F465} **Team** ${reason} \u2192 handed the decision to the **council** (${deps.council.length} members vote).` });
|
|
2187
|
+
const votes = await runCouncil(deps, "code", workdir, taskTitle, assessments, request, emit);
|
|
2188
|
+
const tally = tallyCouncil(votes);
|
|
2189
|
+
const passVotes = votes.filter((v) => v.vote === "pass").length;
|
|
2190
|
+
const blocking = assessments.flatMap((a) => a.findings.filter((f) => f.severity !== "low").map((f) => `[${f.severity}] ${a.name}: ${f.note}`));
|
|
2191
|
+
if (tally === "pass") {
|
|
2192
|
+
emit({ kind: "note", text: `\u2705 **Council** voted to approve (${passVotes}/${votes.length} pass) \u2192 the code passed.` });
|
|
2193
|
+
return { verdict: "pass", notes: [], approvedLenses };
|
|
2194
|
+
}
|
|
2195
|
+
if (tally === "revise") {
|
|
2196
|
+
emit({ kind: "note", text: `\u{1F504} **Council** voted to revise (${votes.length - passVotes}/${votes.length}) \u2192 sending the code back.` });
|
|
2197
|
+
return {
|
|
2198
|
+
verdict: "fail",
|
|
2199
|
+
approvedLenses,
|
|
2200
|
+
notes: blocking.length ? blocking : votes.filter((v) => v.vote === "revise").map((v) => v.rationale)
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
emit({ kind: "note", text: `\u{1F528} **Council** was split (${passVotes}/${votes.length} pass) \u2192 deferred the final decision to the **judge**.` });
|
|
2204
|
+
const d = await runJudge(deps, "code", workdir, taskTitle, assessments, votes, request, emit);
|
|
2205
|
+
if (d.decision === "pass") return { verdict: "pass", notes: [], approvedLenses };
|
|
2206
|
+
return { verdict: "fail", approvedLenses, notes: d.feedback.length ? d.feedback : blocking };
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/engine/acceptance.ts
|
|
2210
|
+
import { z as z9 } from "zod";
|
|
2211
|
+
|
|
2212
|
+
// src/engine/criterion-commands.ts
|
|
2213
|
+
import { spawn as spawn2 } from "child_process";
|
|
2214
|
+
var RUNNABLE_COMMANDS = [
|
|
2215
|
+
"dotnet",
|
|
2216
|
+
"npm",
|
|
2217
|
+
"npx",
|
|
2218
|
+
"pnpm",
|
|
2219
|
+
"yarn",
|
|
2220
|
+
"nx",
|
|
2221
|
+
"prettier",
|
|
2222
|
+
"eslint",
|
|
2223
|
+
"tsc",
|
|
2224
|
+
"cargo",
|
|
2225
|
+
"go",
|
|
2226
|
+
"make",
|
|
2227
|
+
"mvn",
|
|
2228
|
+
"gradle"
|
|
2229
|
+
];
|
|
2230
|
+
var SHELL_SYNTAX = /[;&|><$(){}\n]|`/;
|
|
2231
|
+
var CRITERION_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2232
|
+
var MAX_OUTPUT = 2e3;
|
|
2233
|
+
function commandsIn(criterion) {
|
|
2234
|
+
const out = [];
|
|
2235
|
+
for (const m of criterion.matchAll(/`([^`\n]+)`/g)) {
|
|
2236
|
+
const span = m[1].trim();
|
|
2237
|
+
if (!span || SHELL_SYNTAX.test(span)) continue;
|
|
2238
|
+
const argv = span.split(/\s+/);
|
|
2239
|
+
if (!RUNNABLE_COMMANDS.includes(argv[0])) continue;
|
|
2240
|
+
out.push(argv);
|
|
2241
|
+
}
|
|
2242
|
+
return out;
|
|
2243
|
+
}
|
|
2244
|
+
async function runCommand(cwd, argv, timeoutMs = CRITERION_TIMEOUT_MS) {
|
|
2245
|
+
const [bin, ...args] = argv;
|
|
2246
|
+
return new Promise((resolve4) => {
|
|
2247
|
+
let child;
|
|
2248
|
+
try {
|
|
2249
|
+
child = spawn2(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1" } });
|
|
2250
|
+
} catch (e) {
|
|
2251
|
+
resolve4({ argv, passed: false, exitCode: null, timedOut: false, output: e instanceof Error ? e.message : String(e) });
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
let out = "";
|
|
2255
|
+
const take = (d) => {
|
|
2256
|
+
out += d.toString();
|
|
2257
|
+
if (out.length > MAX_OUTPUT * 4) out = out.slice(-MAX_OUTPUT * 2);
|
|
2258
|
+
};
|
|
2259
|
+
child.stdout.on("data", take);
|
|
2260
|
+
child.stderr.on("data", take);
|
|
2261
|
+
let timedOut = false;
|
|
2262
|
+
const timer = setTimeout(() => {
|
|
2263
|
+
timedOut = true;
|
|
2264
|
+
child.kill("SIGKILL");
|
|
2265
|
+
}, timeoutMs);
|
|
2266
|
+
child.on("error", (e) => {
|
|
2267
|
+
clearTimeout(timer);
|
|
2268
|
+
resolve4({ argv, passed: false, exitCode: null, timedOut: false, output: e.message });
|
|
2269
|
+
});
|
|
2270
|
+
child.on("close", (code) => {
|
|
2271
|
+
clearTimeout(timer);
|
|
2272
|
+
resolve4({ argv, passed: !timedOut && code === 0, exitCode: code, timedOut, output: out.slice(-MAX_OUTPUT) });
|
|
2273
|
+
});
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
async function runCriterionCommands(cwd, criteria, timeoutMs = CRITERION_TIMEOUT_MS) {
|
|
2277
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2278
|
+
const argvs = [];
|
|
2279
|
+
for (const c of criteria) {
|
|
2280
|
+
for (const argv of commandsIn(c)) {
|
|
2281
|
+
const key2 = argv.join(" ");
|
|
2282
|
+
if (seen.has(key2)) continue;
|
|
2283
|
+
seen.add(key2);
|
|
2284
|
+
argvs.push(argv);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
const runs = [];
|
|
2288
|
+
for (const argv of argvs) runs.push(await runCommand(cwd, argv, timeoutMs));
|
|
2289
|
+
return runs;
|
|
2290
|
+
}
|
|
2291
|
+
function describeCommandRuns(runs) {
|
|
2292
|
+
if (!runs.length) return "";
|
|
2293
|
+
const lines = runs.map((r) => {
|
|
2294
|
+
const cmd = `\`${r.argv.join(" ")}\``;
|
|
2295
|
+
if (r.timedOut) return `- ${cmd} \u2014 TIMED OUT after ${Math.round(CRITERION_TIMEOUT_MS / 6e4)} minutes.`;
|
|
2296
|
+
if (r.passed) return `- ${cmd} \u2014 SUCCEEDED (exit 0).`;
|
|
2297
|
+
return `- ${cmd} \u2014 FAILED (exit ${r.exitCode ?? "none"}):
|
|
2298
|
+
\`\`\`
|
|
2299
|
+
${r.output.slice(-800)}
|
|
2300
|
+
\`\`\``;
|
|
2301
|
+
});
|
|
2302
|
+
return "The harness has already RUN the commands these criteria name, to completion, and this is what happened. Use this as the evidence for any criterion about one of them \u2014 do not run it again, and do not report a criterion unmet because you could not wait for it:\n" + lines.join("\n");
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// src/engine/test-runner.ts
|
|
2306
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2307
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2308
|
+
import { spawn as spawn3 } from "child_process";
|
|
2309
|
+
import { join as join3 } from "path";
|
|
2310
|
+
var TEST_TIMEOUT_MS = 6e5;
|
|
2311
|
+
var MAX_TEST_OUTPUT = 12e3;
|
|
2312
|
+
var PLACEHOLDER = /no test specified/i;
|
|
2313
|
+
async function detectTestCommand(cwd) {
|
|
2314
|
+
const pkgPath = join3(cwd, "package.json");
|
|
2315
|
+
if (existsSync4(pkgPath)) {
|
|
2316
|
+
try {
|
|
2317
|
+
const pkg = JSON.parse(await readFile2(pkgPath, "utf8"));
|
|
2318
|
+
const script = pkg.scripts?.test;
|
|
2319
|
+
if (script && !PLACEHOLDER.test(script)) {
|
|
2320
|
+
const runner = existsSync4(join3(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync4(join3(cwd, "yarn.lock")) ? "yarn" : existsSync4(join3(cwd, "bun.lockb")) ? "bun" : "npm";
|
|
2321
|
+
const ci = /\bng test\b/.test(script) && !/--watch|--no-watch/.test(script) ? ["--", "--watch=false", "--browsers=ChromeHeadless"] : [];
|
|
2322
|
+
return { argv: [runner, "test", ...ci], why: `package.json scripts.test: ${script}` };
|
|
2323
|
+
}
|
|
2324
|
+
} catch {
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
if (existsSync4(join3(cwd, "pytest.ini")) || existsSync4(join3(cwd, "pyproject.toml")) || existsSync4(join3(cwd, "tox.ini"))) {
|
|
2328
|
+
return { argv: ["python3", "-m", "pytest", "-q"], why: "a pytest configuration is present" };
|
|
2329
|
+
}
|
|
2330
|
+
if (existsSync4(join3(cwd, "go.mod"))) return { argv: ["go", "test", "./..."], why: "go.mod is present" };
|
|
2331
|
+
if (existsSync4(join3(cwd, "Cargo.toml"))) return { argv: ["cargo", "test"], why: "Cargo.toml is present" };
|
|
2332
|
+
if (existsSync4(join3(cwd, "Gemfile"))) return { argv: ["bundle", "exec", "rspec"], why: "a Gemfile is present" };
|
|
2333
|
+
return void 0;
|
|
2334
|
+
}
|
|
2335
|
+
async function runProjectTests(cwd, cmd) {
|
|
2336
|
+
const command = cmd ?? await detectTestCommand(cwd);
|
|
2337
|
+
if (!command) return { skipped: true, passed: true, output: "", timedOut: false };
|
|
2338
|
+
const [bin, ...args] = command.argv;
|
|
2339
|
+
return new Promise((resolve4) => {
|
|
2340
|
+
const child = spawn3(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1" } });
|
|
2341
|
+
let out = "";
|
|
2342
|
+
const take = (d) => {
|
|
2343
|
+
out += d.toString();
|
|
2344
|
+
if (out.length > MAX_TEST_OUTPUT * 4) out = out.slice(-MAX_TEST_OUTPUT * 2);
|
|
2345
|
+
};
|
|
2346
|
+
child.stdout.on("data", take);
|
|
2347
|
+
child.stderr.on("data", take);
|
|
2348
|
+
let timedOut = false;
|
|
2349
|
+
const timer = setTimeout(() => {
|
|
2350
|
+
timedOut = true;
|
|
2351
|
+
child.kill("SIGKILL");
|
|
2352
|
+
}, TEST_TIMEOUT_MS);
|
|
2353
|
+
const done = (code, extra = "") => {
|
|
2354
|
+
clearTimeout(timer);
|
|
2355
|
+
resolve4({
|
|
2356
|
+
skipped: false,
|
|
2357
|
+
passed: code === 0 && !timedOut,
|
|
2358
|
+
command: command.argv.join(" "),
|
|
2359
|
+
why: command.why,
|
|
2360
|
+
output: clampOutput(`${extra}${out}`.trimEnd(), MAX_TEST_OUTPUT),
|
|
2361
|
+
timedOut
|
|
2362
|
+
});
|
|
2363
|
+
};
|
|
2364
|
+
child.on("error", (e) => {
|
|
2365
|
+
clearTimeout(timer);
|
|
2366
|
+
resolve4({
|
|
2367
|
+
skipped: true,
|
|
2368
|
+
passed: true,
|
|
2369
|
+
command: command.argv.join(" "),
|
|
2370
|
+
why: command.why,
|
|
2371
|
+
output: `could not run the suite: ${e.message}`,
|
|
2372
|
+
timedOut: false
|
|
2373
|
+
});
|
|
2374
|
+
});
|
|
2375
|
+
child.on("close", (code) => done(code ?? 1));
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
function describeTestRun(run) {
|
|
2379
|
+
if (run.skipped) {
|
|
2380
|
+
return run.output ? `The project's test suite could not be run (${run.output}). Judge the criteria by reading the code.` : "This project has no test suite. Judge the criteria by reading the code.";
|
|
2381
|
+
}
|
|
2382
|
+
if (run.timedOut) {
|
|
2383
|
+
return `The test suite (\`${run.command}\`) was killed after ${TEST_TIMEOUT_MS / 6e4} minutes without finishing. Treat this as a FAILURE unless the criteria are unrelated to it.
|
|
2384
|
+
|
|
2385
|
+
${run.output}`;
|
|
2386
|
+
}
|
|
2387
|
+
if (run.passed) return `The project's test suite passed (\`${run.command}\`).`;
|
|
2388
|
+
return `The project's test suite FAILED (\`${run.command}\`). This is evidence, not opinion \u2014 a criterion about behaviour cannot be met while the suite is red. If the failures are clearly unrelated to this task's criteria, say so explicitly in the evidence rather than ignoring them.
|
|
2389
|
+
|
|
2390
|
+
${run.output}`;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// src/engine/acceptance.ts
|
|
2394
|
+
function normalizeCriterion(s) {
|
|
2395
|
+
return s.toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().replace(/[.,;:!?…]+$/, "");
|
|
2396
|
+
}
|
|
2397
|
+
var AcceptanceSchema = z9.object({
|
|
2398
|
+
checks: z9.array(z9.object({
|
|
2399
|
+
criterion: z9.string().describe(
|
|
2400
|
+
"Copy the criterion VERBATIM from the numbered list you were given, including any backticks and punctuation. Do not paraphrase, renumber or reformat it \u2014 it is matched back to the task by text."
|
|
2401
|
+
),
|
|
2402
|
+
met: z9.boolean(),
|
|
2403
|
+
evidence: z9.string().describe(
|
|
2404
|
+
'Where you SAW it: a file path and what it contains, a symbol, a test name. "It looks fine" is not evidence.'
|
|
2405
|
+
)
|
|
2406
|
+
}))
|
|
2407
|
+
});
|
|
2408
|
+
var PROMPT2 = `You are the acceptance gate for one implementation task. You are given the task's acceptance criteria and the worktree. For EACH criterion decide whether it is ACTUALLY satisfied by the code that is present, and cite the concrete evidence you saw (file path, symbol, test name, config key).
|
|
2409
|
+
Rules:
|
|
2410
|
+
- Verify by LOOKING. If you did not open the file, the criterion is not met.
|
|
2411
|
+
- A criterion about a COMMAND is settled by the run reported to you above, not by looking and not by running it yourself. If that report says the command succeeded, the criterion is met; if it failed, quote the error. Never mark such a criterion unmet for want of waiting.
|
|
2412
|
+
- "met" means observably true right now \u2014 not "planned", "close enough" or "the implementer says so".
|
|
2413
|
+
- Judge ONLY the listed criteria. Code quality, style and scope opinions belong to the code review, not here.
|
|
2414
|
+
- Report EVERY criterion you were given \u2014 one check each, none dropped.
|
|
2415
|
+
- Copy each criterion into the "criterion" field VERBATIM from the numbered list, including any backticks and punctuation. It is matched back to the task by text; a paraphrase loses the pairing.
|
|
2416
|
+
- Write the evidence in ENGLISH (it is a technical record).`;
|
|
2417
|
+
async function verifyAcceptance(deps, card, cwd, emit = () => {
|
|
2418
|
+
}, runCommands = runCriterionCommands) {
|
|
2419
|
+
const suite = () => deps.timings ? deps.timings.time("test suite", () => runProjectTests(cwd)) : runProjectTests(cwd);
|
|
2420
|
+
const tests = await telemetry().span("stage.test_suite", { "hc.stage": "test suite" }, suite);
|
|
2421
|
+
telemetry().event("tests.run", {
|
|
2422
|
+
"hc.tests.ran": !tests.skipped,
|
|
2423
|
+
"hc.tests.passed": tests.passed,
|
|
2424
|
+
"hc.tests.timed_out": tests.timedOut === true,
|
|
2425
|
+
"hc.tests.command": tests.command
|
|
2426
|
+
});
|
|
2427
|
+
if (!tests.skipped) {
|
|
2428
|
+
emit({ kind: "note", text: tests.passed ? `\u2705 **Tests passed** for "${card.title}" \u2014 \`${tests.command}\`` : `\u274C **Tests FAILED** for "${card.title}" \u2014 \`${tests.command}\`${tests.timedOut ? " (timed out)" : ""}` });
|
|
2429
|
+
if (!tests.passed) {
|
|
2430
|
+
return {
|
|
2431
|
+
passed: false,
|
|
2432
|
+
unmet: [
|
|
2433
|
+
`The project's test suite fails (\`${tests.command}\`)${tests.timedOut ? " \u2014 it timed out" : ""}. Nothing may enter DONE while it is red.
|
|
2434
|
+
${tests.output.slice(-4e3)}`,
|
|
2435
|
+
...card.acceptance.map((c) => `${c} (not assessed \u2014 the suite is red)`)
|
|
2436
|
+
],
|
|
2437
|
+
tests: { ran: true, passed: false, ...tests.command ? { command: tests.command } : {} }
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
const testEvidence = { ran: !tests.skipped, passed: tests.passed, ...tests.command ? { command: tests.command } : {} };
|
|
2442
|
+
if (!card.acceptance.length) return { passed: true, unmet: [], tests: testEvidence };
|
|
2443
|
+
const diff = deps.baseRef ? await taskDiff(cwd, deps.baseRef) : deps.inPlaceBase ? await diffSince(cwd, deps.inPlaceBase) : "";
|
|
2444
|
+
const resolved = deps.roleRegistry.resolve("code-reviewer");
|
|
2445
|
+
const commandRuns = await telemetry().span(
|
|
2446
|
+
"stage.criterion_commands",
|
|
2447
|
+
{ "hc.stage": "criterion commands" },
|
|
2448
|
+
() => runCommands(cwd, card.acceptance)
|
|
2449
|
+
);
|
|
2450
|
+
for (const r of commandRuns) {
|
|
2451
|
+
emit({ kind: "note", text: r.passed ? `\u2705 \`${r.argv.join(" ")}\` \u2014 exit 0` : `\u274C \`${r.argv.join(" ")}\` \u2014 ${r.timedOut ? "timed out" : `exit ${r.exitCode ?? "none"}`}` });
|
|
2452
|
+
}
|
|
2453
|
+
const hints = memoryHints(deps, `${card.title} ${card.acceptance.join(" ")}`, { role: "code-reviewer" });
|
|
2454
|
+
const ask = { role: "user", content: `Task: "${card.title}".
|
|
2455
|
+
|
|
2456
|
+
Acceptance criteria:
|
|
2457
|
+
${card.acceptance.map((c, i) => `${i + 1}. ${c}`).join("\n")}
|
|
2458
|
+
|
|
2459
|
+
Check each one against the worktree and report met/unmet with the evidence you saw.
|
|
2460
|
+
|
|
2461
|
+
${describeTestRun(tests)}
|
|
2462
|
+
|
|
2463
|
+
${describeCommandRuns(commandRuns)}
|
|
2464
|
+
|
|
2465
|
+
${describeDiff(diff)}` };
|
|
2466
|
+
const opts = {
|
|
2467
|
+
provider: deps.provider,
|
|
2468
|
+
...resolved,
|
|
2469
|
+
systemPrompt: `${PROMPT2}${deps.roleRegistry.ruleSuffix()}`,
|
|
2470
|
+
tools: readOnlyRegistry(deps),
|
|
2471
|
+
messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
|
|
2472
|
+
permission: deps.permission,
|
|
2473
|
+
approve: deps.approve,
|
|
2474
|
+
cwd,
|
|
2475
|
+
signal: AbortSignal.any([deps.signal, AbortSignal.timeout(CODE_REVIEW_TIMEOUT_MS)]),
|
|
2476
|
+
maxTurns: CODE_REVIEW_MAX_TURNS
|
|
2477
|
+
};
|
|
2478
|
+
let checks;
|
|
2479
|
+
try {
|
|
2480
|
+
({ checks } = await runStructuredRole(opts, AcceptanceSchema));
|
|
2481
|
+
reinforceUsed(deps, hints.ids, checks.map((c) => c.evidence).join(" "), "code-reviewer");
|
|
2482
|
+
} catch (e) {
|
|
2483
|
+
if (deps.signal.aborted) throw e;
|
|
2484
|
+
emit({ kind: "note", text: `\u26A0\uFE0F **Acceptance gate** could not run for "${card.title}" \u2014 treating the criteria as unmet.` });
|
|
2485
|
+
return {
|
|
2486
|
+
passed: false,
|
|
2487
|
+
unmet: card.acceptance.map((c) => `${c} (not verified: the gate did not run)`),
|
|
2488
|
+
tests: testEvidence
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
const byCriterion = new Map(checks.map((c) => [normalizeCriterion(c.criterion), c]));
|
|
2492
|
+
const paired = /* @__PURE__ */ new Map();
|
|
2493
|
+
const usedChecks = /* @__PURE__ */ new Set();
|
|
2494
|
+
const unpairedCriteria = [];
|
|
2495
|
+
for (const c of card.acceptance) {
|
|
2496
|
+
const key2 = normalizeCriterion(c);
|
|
2497
|
+
const hit = byCriterion.get(key2) ?? checks.find((x) => normalizeCriterion(x.criterion).includes(key2.slice(0, 40)));
|
|
2498
|
+
if (hit) {
|
|
2499
|
+
paired.set(c, hit);
|
|
2500
|
+
usedChecks.add(hit);
|
|
2501
|
+
} else unpairedCriteria.push(c);
|
|
2502
|
+
}
|
|
2503
|
+
const unusedChecks = checks.filter((x) => !usedChecks.has(x));
|
|
2504
|
+
if (unpairedCriteria.length === 1 && unusedChecks.length === 1) {
|
|
2505
|
+
paired.set(unpairedCriteria[0], unusedChecks[0]);
|
|
2506
|
+
}
|
|
2507
|
+
const unmet = [];
|
|
2508
|
+
for (const c of card.acceptance) {
|
|
2509
|
+
const hit = paired.get(c);
|
|
2510
|
+
if (!hit) unmet.push(`${c} (not reported by the acceptance gate)`);
|
|
2511
|
+
else if (!hit.met) unmet.push(`${c} \u2014 ${hit.evidence}`);
|
|
2512
|
+
}
|
|
2513
|
+
const passed = unmet.length === 0;
|
|
2514
|
+
const result = { passed, unmet, tests: testEvidence };
|
|
2515
|
+
emit({ kind: "note", text: passed ? `\u2705 **Acceptance gate** \u2014 all ${card.acceptance.length} criteria verified for "${card.title}".` : `\u26D4 **Acceptance gate** \u2014 ${unmet.length}/${card.acceptance.length} criteria NOT met for "${card.title}".` });
|
|
2516
|
+
return result;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// src/engine/worktree-state.ts
|
|
2520
|
+
async function worktreeState(git, cwd) {
|
|
2521
|
+
const head = await git(["rev-parse", "HEAD"], cwd);
|
|
2522
|
+
if (head.code !== 0) return void 0;
|
|
2523
|
+
const status = await git(["status", "--porcelain"], cwd);
|
|
2524
|
+
return `${head.stdout.trim()}|${status.stdout.trim()}`;
|
|
2525
|
+
}
|
|
2526
|
+
async function hasWorkAgainst(git, cwd, baseRef) {
|
|
2527
|
+
const merge = await git(["merge-base", "HEAD", baseRef], cwd);
|
|
2528
|
+
const at = merge.stdout.trim();
|
|
2529
|
+
if (merge.code !== 0 || !at) return false;
|
|
2530
|
+
const diff = await git(["diff", "--quiet", `${at}..HEAD`], cwd);
|
|
2531
|
+
if (diff.code !== 0) return true;
|
|
2532
|
+
const dirty = await git(["status", "--porcelain"], cwd);
|
|
2533
|
+
return dirty.stdout.trim().length > 0;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// src/engine/task-cycle.ts
|
|
2537
|
+
async function runCycleWithRole(deps, board, taskId, cwd, role, git = defaultGitRunner, slot = 0) {
|
|
2538
|
+
board.move(taskId, "IN-PROGRESS", role);
|
|
2539
|
+
const rotation = slot + board.get(taskId).attempts;
|
|
2540
|
+
board.setWorker(taskId, role, deps.roleRegistry.chainFor(role, rotation)[0] ?? "");
|
|
2541
|
+
const before = await worktreeState(git, cwd);
|
|
2542
|
+
const startedAt = before?.split("|")[0];
|
|
2543
|
+
const rdeps = deps.baseRef || !startedAt ? deps : { ...deps, inPlaceBase: startedAt };
|
|
2544
|
+
await runImplementer(deps, role, board.get(taskId), cwd, rotation);
|
|
2545
|
+
const after = await worktreeState(git, cwd);
|
|
2546
|
+
const idle = before !== void 0 && after !== void 0 && before === after && !(deps.baseRef && await hasWorkAgainst(git, cwd, deps.baseRef));
|
|
2547
|
+
if (idle) {
|
|
2548
|
+
const note = "The previous attempt produced NO file changes. You must actually write the code with write_file/edit_file \u2014 describing it is not enough.";
|
|
2549
|
+
const servedBy = deps.roleRegistry.chainFor(role, rotation)[0] ?? "";
|
|
2550
|
+
telemetry().event("implementer.no_changes", {
|
|
2551
|
+
"hc.task.id": taskId,
|
|
2552
|
+
"hc.role": role,
|
|
2553
|
+
"hc.model": servedBy,
|
|
2554
|
+
"hc.attempt": board.get(taskId).attempts
|
|
2555
|
+
});
|
|
2556
|
+
if (servedBy) {
|
|
2557
|
+
const strikes = deps.fitness?.record(role, servedBy, "answered in prose instead of implementing") ?? 0;
|
|
2558
|
+
if (strikes === UNFIT_AFTER) {
|
|
2559
|
+
deps.note?.(`\u{1F6AB} \`${servedBy}\` will no longer be assigned to \`${role}\` \u2014 ${strikes} attempts that wrote nothing. It stays available to every other role.`);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
board.appendStage(taskId, { role, action: "no-changes", note: servedBy ? `model: ${servedBy}` : void 0 });
|
|
2563
|
+
board.clearReviewNotes(taskId);
|
|
2564
|
+
board.addReviewNote(taskId, note);
|
|
2565
|
+
board.move(taskId, "TODO", role);
|
|
2566
|
+
deps.note?.(`\u26A0\uFE0F **${board.get(taskId).title}** \u2014 \`${role}\`${servedBy ? ` on \`${servedBy}\`` : ""} wrote nothing; trying the next model.`);
|
|
2567
|
+
return { verdict: "fail", notes: [note], noProgress: true };
|
|
2568
|
+
}
|
|
2569
|
+
deps.fitness?.ok(role, deps.roleRegistry.chainFor(role, rotation)[0] ?? "");
|
|
2570
|
+
board.move(taskId, "REVIEW", role);
|
|
2571
|
+
const card = board.get(taskId);
|
|
2572
|
+
const review = () => runCodeReview(
|
|
2573
|
+
rdeps,
|
|
2574
|
+
cwd,
|
|
2575
|
+
card.title,
|
|
2576
|
+
void 0,
|
|
2577
|
+
(ev) => {
|
|
2578
|
+
if (ev.kind === "note") deps.note?.(ev.text);
|
|
2579
|
+
},
|
|
2580
|
+
card.attempts,
|
|
2581
|
+
// Only what objected last time is asked again — see `runCodeReview`'s `cleared`.
|
|
2582
|
+
card.clearedLenses ?? []
|
|
2583
|
+
);
|
|
2584
|
+
let v;
|
|
2585
|
+
try {
|
|
2586
|
+
v = await telemetry().span(
|
|
2587
|
+
"stage.code_review",
|
|
2588
|
+
{ "hc.stage": "code review", "hc.task.id": taskId },
|
|
2589
|
+
() => deps.timings ? deps.timings.time("code review", review) : review()
|
|
2590
|
+
);
|
|
2591
|
+
} catch (e) {
|
|
2592
|
+
if (!deps.signal.aborted) throw e;
|
|
2593
|
+
const note = "Review was cancelled. Add a human note before retrying this task.";
|
|
2594
|
+
board.clearReviewNotes(taskId);
|
|
2595
|
+
board.addReviewNote(taskId, note);
|
|
2596
|
+
board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:cancelled", note });
|
|
2597
|
+
return { verdict: "fail", notes: [note] };
|
|
2598
|
+
}
|
|
2599
|
+
if (deps.signal.aborted) {
|
|
2600
|
+
const note = "Review was cancelled. Add a human note before retrying this task.";
|
|
2601
|
+
board.clearReviewNotes(taskId);
|
|
2602
|
+
board.addReviewNote(taskId, note);
|
|
2603
|
+
board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:cancelled", note });
|
|
2604
|
+
return { verdict: "fail", notes: [note] };
|
|
2605
|
+
}
|
|
2606
|
+
const approved = v.approvedLenses ?? [];
|
|
2607
|
+
if (approved.length) board.markLensesCleared(taskId, approved);
|
|
2608
|
+
if (v.verdict === "pass") {
|
|
2609
|
+
const check = () => verifyAcceptance(rdeps, board.get(taskId), cwd, (ev) => {
|
|
2610
|
+
if (ev.kind === "note") deps.note?.(ev.text);
|
|
2611
|
+
});
|
|
2612
|
+
const gate = await telemetry().span(
|
|
2613
|
+
"stage.acceptance_gate",
|
|
2614
|
+
{ "hc.stage": "acceptance gate", "hc.task.id": taskId },
|
|
2615
|
+
() => deps.timings ? deps.timings.time("acceptance gate", check) : check()
|
|
2616
|
+
);
|
|
2617
|
+
if (!gate.passed) {
|
|
2618
|
+
board.appendStage(taskId, { role: "code-reviewer", action: "acceptance:failed", note: gate.unmet.join("; ") });
|
|
2619
|
+
board.clearReviewNotes(taskId);
|
|
2620
|
+
for (const n of gate.unmet) board.addReviewNote(taskId, `Acceptance criterion not met: ${n}`);
|
|
2621
|
+
board.move(taskId, "TODO", "code-reviewer");
|
|
2622
|
+
return { verdict: "fail", notes: gate.unmet };
|
|
2623
|
+
}
|
|
2624
|
+
board.appendStage(taskId, { role: "code-reviewer", action: "acceptance:passed" });
|
|
2625
|
+
board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:pass" });
|
|
2626
|
+
for (const d of v.deferred ?? []) board.appendStage(taskId, { role: "code-reviewer", action: "deferred", note: d });
|
|
2627
|
+
board.clearReviewNotes(taskId);
|
|
2628
|
+
board.move(taskId, "DONE", "code-reviewer");
|
|
2629
|
+
} else {
|
|
2630
|
+
const notes = v.notes.length > 0 ? v.notes : ["review failed (no notes given)"];
|
|
2631
|
+
board.appendStage(taskId, {
|
|
2632
|
+
role: "code-reviewer",
|
|
2633
|
+
action: "reviewed:fail",
|
|
2634
|
+
note: notes.join("; ")
|
|
2635
|
+
});
|
|
2636
|
+
board.clearReviewNotes(taskId);
|
|
2637
|
+
for (const n of notes) board.addReviewNote(taskId, n);
|
|
2638
|
+
board.move(taskId, "TODO", "code-reviewer");
|
|
2639
|
+
}
|
|
2640
|
+
return v;
|
|
2641
|
+
}
|
|
2642
|
+
async function runTaskCycle(deps, board, taskId, worktreePath, slot = 0) {
|
|
2643
|
+
const task = board.get(taskId);
|
|
2644
|
+
if (!task) throw new Error(`runTaskCycle: unknown task: ${taskId}`);
|
|
2645
|
+
const role = await routeTask(deps, task);
|
|
2646
|
+
board.setWorktree(taskId, worktreePath);
|
|
2647
|
+
return runCycleWithRole(deps, board, taskId, worktreePath, role, defaultGitRunner, slot);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// src/board/board.ts
|
|
2651
|
+
import { z as z10 } from "zod";
|
|
2652
|
+
var MAX_STAGE_EVENTS = 200;
|
|
2653
|
+
var stageEventSchema = z10.object({
|
|
2654
|
+
role: z10.string(),
|
|
2655
|
+
action: z10.string(),
|
|
2656
|
+
note: z10.string().optional()
|
|
2657
|
+
});
|
|
2658
|
+
var cardSchema = z10.object({
|
|
2659
|
+
id: z10.string(),
|
|
2660
|
+
title: z10.string(),
|
|
2661
|
+
column: z10.enum(["TODO", "IN-PROGRESS", "REVIEW", "DONE", "MERGED", "PARKED", "ABANDONED"]),
|
|
2662
|
+
worktree: z10.string().optional(),
|
|
2663
|
+
deps: z10.array(z10.string()),
|
|
2664
|
+
acceptance: z10.array(z10.string()).default([]),
|
|
2665
|
+
// default: boards persisted before the gate existed still load
|
|
2666
|
+
files: z10.array(z10.string()).default([]),
|
|
2667
|
+
// ditto — a board written before file lists existed still loads
|
|
2668
|
+
reviewNotes: z10.array(z10.string()),
|
|
2669
|
+
// Optional rather than defaulted: a board written before this existed must round-trip unchanged, and an
|
|
2670
|
+
// empty list is the same statement as no list at all.
|
|
2671
|
+
clearedLenses: z10.array(z10.string()).optional(),
|
|
2672
|
+
attempts: z10.number(),
|
|
2673
|
+
stageHistory: z10.array(stageEventSchema)
|
|
2674
|
+
});
|
|
2675
|
+
var boardDataSchema = z10.object({ version: z10.literal(1), cards: z10.array(cardSchema) });
|
|
2676
|
+
function migrateDelivered(c) {
|
|
2677
|
+
if (c.column !== "DONE") return c;
|
|
2678
|
+
return c.stageHistory.some((e) => e.action === "merged") ? { ...c, column: "MERGED" } : c;
|
|
2679
|
+
}
|
|
2680
|
+
function cloneCard(c) {
|
|
2681
|
+
return {
|
|
2682
|
+
...c,
|
|
2683
|
+
deps: [...c.deps],
|
|
2684
|
+
acceptance: [...c.acceptance],
|
|
2685
|
+
files: [...c.files],
|
|
2686
|
+
reviewNotes: [...c.reviewNotes],
|
|
2687
|
+
...c.clearedLenses?.length ? { clearedLenses: [...c.clearedLenses] } : {},
|
|
2688
|
+
stageHistory: c.stageHistory.map((e) => ({ ...e }))
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
var Board = class _Board {
|
|
2692
|
+
onChange;
|
|
2693
|
+
// called after every mutation (if set; H3a progress events)
|
|
2694
|
+
onMove;
|
|
2695
|
+
// called on a real column transition → action notes
|
|
2696
|
+
cards = /* @__PURE__ */ new Map();
|
|
2697
|
+
constructor(cards = []) {
|
|
2698
|
+
for (const c of cards) this.cards.set(c.id, cloneCard(c));
|
|
2699
|
+
}
|
|
2700
|
+
addCard(input) {
|
|
2701
|
+
if (this.cards.has(input.id)) throw new Error(`card already exists: ${input.id}`);
|
|
2702
|
+
const card = {
|
|
2703
|
+
id: input.id,
|
|
2704
|
+
title: input.title,
|
|
2705
|
+
column: "TODO",
|
|
2706
|
+
deps: input.deps ? [...input.deps] : [],
|
|
2707
|
+
acceptance: input.acceptance ? [...input.acceptance] : [],
|
|
2708
|
+
files: input.files ? [...input.files] : [],
|
|
2709
|
+
reviewNotes: [],
|
|
2710
|
+
attempts: 0,
|
|
2711
|
+
stageHistory: []
|
|
2712
|
+
};
|
|
2713
|
+
this.cards.set(card.id, card);
|
|
2714
|
+
this.onChange?.();
|
|
2715
|
+
return cloneCard(card);
|
|
2716
|
+
}
|
|
2717
|
+
get(id) {
|
|
2718
|
+
const c = this.cards.get(id);
|
|
2719
|
+
return c ? cloneCard(c) : void 0;
|
|
2720
|
+
}
|
|
2721
|
+
list() {
|
|
2722
|
+
return [...this.cards.values()].map(cloneCard);
|
|
2723
|
+
}
|
|
2724
|
+
byColumn(column) {
|
|
2725
|
+
return this.list().filter((c) => c.column === column);
|
|
2726
|
+
}
|
|
2727
|
+
require(id) {
|
|
2728
|
+
const c = this.cards.get(id);
|
|
2729
|
+
if (!c) throw new Error(`unknown card: ${id}`);
|
|
2730
|
+
return c;
|
|
2731
|
+
}
|
|
2732
|
+
move(id, column, actor) {
|
|
2733
|
+
const c = this.require(id);
|
|
2734
|
+
const from = c.column;
|
|
2735
|
+
c.column = column;
|
|
2736
|
+
if (actor) {
|
|
2737
|
+
c.stageHistory.push({ role: actor, action: `\u2192${column}` });
|
|
2738
|
+
if (c.stageHistory.length > MAX_STAGE_EVENTS) {
|
|
2739
|
+
c.stageHistory.splice(0, c.stageHistory.length - MAX_STAGE_EVENTS);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (from !== column) this.onMove?.(c, from, column, actor);
|
|
2743
|
+
this.onChange?.();
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* Records a dependency the breakdown missed.
|
|
2747
|
+
*
|
|
2748
|
+
* Returns false — rather than throwing — for anything that is not a real new edge (unknown id, self, or
|
|
2749
|
+
* already present), because the caller is an audit whose input is a model's suggestion: a nonsense entry
|
|
2750
|
+
* is an expected outcome there, not an exceptional one.
|
|
2751
|
+
*/
|
|
2752
|
+
addDep(id, dependsOn) {
|
|
2753
|
+
const c = this.cards.get(id);
|
|
2754
|
+
if (!c || id === dependsOn || !this.cards.has(dependsOn) || c.deps.includes(dependsOn)) return false;
|
|
2755
|
+
c.deps.push(dependsOn);
|
|
2756
|
+
this.onChange?.();
|
|
2757
|
+
return true;
|
|
2758
|
+
}
|
|
2759
|
+
/** Undoes `addDep` — used when the added edge turns out to close a cycle. */
|
|
2760
|
+
removeDep(id, dependsOn) {
|
|
2761
|
+
const c = this.cards.get(id);
|
|
2762
|
+
if (!c) return;
|
|
2763
|
+
c.deps = c.deps.filter((d) => d !== dependsOn);
|
|
2764
|
+
this.onChange?.();
|
|
2765
|
+
}
|
|
2766
|
+
appendStage(id, event) {
|
|
2767
|
+
this.require(id).stageHistory.push({ ...event });
|
|
2768
|
+
this.onChange?.();
|
|
2769
|
+
}
|
|
2770
|
+
/** Records the model of the implementer now working this card (surfaced in the live-agents UI). */
|
|
2771
|
+
setModel(id, model) {
|
|
2772
|
+
this.require(id).model = model;
|
|
2773
|
+
this.onChange?.();
|
|
2774
|
+
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Returns an interrupted card to TODO and forgets who was working it.
|
|
2777
|
+
*
|
|
2778
|
+
* Deliberately silent — no stage event, no move note: nothing HAPPENED to this task, a process died. The
|
|
2779
|
+
* chat should not report a transition the user did not cause and no agent performed.
|
|
2780
|
+
*/
|
|
2781
|
+
reopen(id) {
|
|
2782
|
+
const c = this.require(id);
|
|
2783
|
+
c.column = "TODO";
|
|
2784
|
+
c.role = void 0;
|
|
2785
|
+
c.model = void 0;
|
|
2786
|
+
this.onChange?.();
|
|
2787
|
+
}
|
|
2788
|
+
/** Records WHO is working this card: the role and the model it will actually use. */
|
|
2789
|
+
setWorker(id, role, model) {
|
|
2790
|
+
const c = this.require(id);
|
|
2791
|
+
c.role = role;
|
|
2792
|
+
c.model = model;
|
|
2793
|
+
this.onChange?.();
|
|
2794
|
+
}
|
|
2795
|
+
addReviewNote(id, note) {
|
|
2796
|
+
this.require(id).reviewNotes.push(note);
|
|
2797
|
+
this.onChange?.();
|
|
2798
|
+
}
|
|
2799
|
+
clearReviewNotes(id) {
|
|
2800
|
+
this.require(id).reviewNotes = [];
|
|
2801
|
+
this.onChange?.();
|
|
2802
|
+
}
|
|
2803
|
+
/** Remember which lenses approved, so the next attempt does not ask them again. */
|
|
2804
|
+
markLensesCleared(id, lenses) {
|
|
2805
|
+
const c = this.require(id);
|
|
2806
|
+
c.clearedLenses = [.../* @__PURE__ */ new Set([...c.clearedLenses ?? [], ...lenses])];
|
|
2807
|
+
this.onChange?.();
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Starts the escalation ladder over for one card, keeping its history.
|
|
2811
|
+
*
|
|
2812
|
+
* The tier is derived from `attempts`, so a task carrying a large count from earlier runs begins at the
|
|
2813
|
+
* council — the most expensive tier, and the one that had already failed it. Everything that actually
|
|
2814
|
+
* happened stays in `stageHistory`; only the counter that picks the tier goes back to zero.
|
|
2815
|
+
*/
|
|
2816
|
+
resetAttempts(id) {
|
|
2817
|
+
const c = this.require(id);
|
|
2818
|
+
if (c.attempts === 0) return;
|
|
2819
|
+
c.stageHistory.push({ role: "team-lead", action: "reset", note: `new run \u2014 ladder restarted (was ${c.attempts})` });
|
|
2820
|
+
c.attempts = 0;
|
|
2821
|
+
this.onChange?.();
|
|
2822
|
+
}
|
|
2823
|
+
incrementAttempts(id) {
|
|
2824
|
+
const c = this.require(id);
|
|
2825
|
+
c.attempts += 1;
|
|
2826
|
+
this.onChange?.();
|
|
2827
|
+
return c.attempts;
|
|
2828
|
+
}
|
|
2829
|
+
setWorktree(id, path) {
|
|
2830
|
+
this.require(id).worktree = path;
|
|
2831
|
+
this.onChange?.();
|
|
2832
|
+
}
|
|
2833
|
+
toJSON() {
|
|
2834
|
+
return { version: 1, cards: this.list() };
|
|
2835
|
+
}
|
|
2836
|
+
static fromJSON(data) {
|
|
2837
|
+
const parsed = boardDataSchema.parse(data);
|
|
2838
|
+
return new _Board(parsed.cards.map(migrateDelivered));
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
|
|
2842
|
+
export {
|
|
2843
|
+
SHORT_CALL_MS,
|
|
2844
|
+
LONG_CALL_MS,
|
|
2845
|
+
CliProvider,
|
|
2846
|
+
subjectOf,
|
|
2847
|
+
asChoice,
|
|
2848
|
+
buildTeamRegistry,
|
|
2849
|
+
buildCouncilRegistry,
|
|
2850
|
+
runReviewLoop,
|
|
2851
|
+
writeFileTool,
|
|
2852
|
+
editFileTool,
|
|
2853
|
+
shellTool,
|
|
2854
|
+
createDefaultRegistry,
|
|
2855
|
+
changedByMerge,
|
|
2856
|
+
refreshAfterChange,
|
|
2857
|
+
routeTask,
|
|
2858
|
+
normalizeQuestion,
|
|
2859
|
+
extractChoicesFrom,
|
|
2860
|
+
buildAskUserTool,
|
|
2861
|
+
writerRegistry,
|
|
2862
|
+
commitFile,
|
|
2863
|
+
commitStep,
|
|
2864
|
+
squashTask,
|
|
2865
|
+
WHAT_IT_COST,
|
|
2866
|
+
runImplementer,
|
|
2867
|
+
RoleFitness,
|
|
2868
|
+
worktreeState,
|
|
2869
|
+
hasWorkAgainst,
|
|
2870
|
+
runCycleWithRole,
|
|
2871
|
+
runTaskCycle,
|
|
2872
|
+
Board
|
|
2873
|
+
};
|