@hizliemre/horse-code 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/app-SB2L34JW.js +6217 -0
  4. package/dist/chunk-2DGO2BUB.js +4490 -0
  5. package/dist/chunk-2SVAHH5N.js +60 -0
  6. package/dist/chunk-3XVZXTB6.js +4469 -0
  7. package/dist/chunk-5UWA2UBM.js +69 -0
  8. package/dist/chunk-7TBYMFMG.js +147 -0
  9. package/dist/chunk-B67BK5GQ.js +34 -0
  10. package/dist/chunk-BY4DP7IE.js +20 -0
  11. package/dist/chunk-DKVIN43T.js +54 -0
  12. package/dist/chunk-DTWKSZXY.js +162 -0
  13. package/dist/chunk-F2IALVBU.js +212 -0
  14. package/dist/chunk-FFYBY2NA.js +392 -0
  15. package/dist/chunk-FGVJFMK5.js +123 -0
  16. package/dist/chunk-H2FDGPVW.js +42 -0
  17. package/dist/chunk-HBSC2HT2.js +85 -0
  18. package/dist/chunk-IW2KBAVZ.js +21 -0
  19. package/dist/chunk-JWAEW7AJ.js +121 -0
  20. package/dist/chunk-NNTIACT4.js +163 -0
  21. package/dist/chunk-O74BDQKS.js +28 -0
  22. package/dist/chunk-PGOYDOI4.js +426 -0
  23. package/dist/chunk-QF4MP6BS.js +69 -0
  24. package/dist/chunk-SSDLHWSF.js +35 -0
  25. package/dist/chunk-TOPZL5SU.js +1052 -0
  26. package/dist/chunk-YBWTCXUS.js +153 -0
  27. package/dist/chunk-YILDXPSI.js +1363 -0
  28. package/dist/clean-YOQATBMZ.js +18 -0
  29. package/dist/cli.js +1495 -0
  30. package/dist/discover-5URG7C4J.js +52 -0
  31. package/dist/fix-HBBOTUWM.js +34 -0
  32. package/dist/frontmatter-UNIPNLLO.js +6 -0
  33. package/dist/git-VTSZALSR.js +6 -0
  34. package/dist/install-O34KMWJB.js +113 -0
  35. package/dist/main-branch-KGWUINYQ.js +19 -0
  36. package/dist/ongoing-OV5XROTU.js +70 -0
  37. package/dist/project-graph-IOPCSZUA.js +56 -0
  38. package/dist/run-LQOZ5I7Z.js +610 -0
  39. package/dist/save-skills-OHYGVTQ4.js +13 -0
  40. package/dist/source-cache-XEK5WN7I.js +29 -0
  41. package/dist/trace-ZMB7LT7W.js +66 -0
  42. package/dist/trace-adopt-C6TUWFJL.js +79 -0
  43. package/dist/trace-run-F23MFTY4.js +24 -0
  44. package/dist/triage-2J3T5PVQ.js +30 -0
  45. package/dist/verify-WQ3GHION.js +479 -0
  46. package/dist/worktree-F7TWLWLN.js +87 -0
  47. package/package.json +64 -0
@@ -0,0 +1,4469 @@
1
+ import {
2
+ checkpointKey,
3
+ checkpointMtime,
4
+ isContinuePrompt,
5
+ readCheckpoint
6
+ } from "./chunk-FGVJFMK5.js";
7
+ import {
8
+ planFor,
9
+ runTraces
10
+ } from "./chunk-NNTIACT4.js";
11
+ import {
12
+ defaultGitRunner
13
+ } from "./chunk-IW2KBAVZ.js";
14
+ import {
15
+ BATCH_TOOLS_NOTE,
16
+ contextTools,
17
+ emitBatchInjection,
18
+ globTool,
19
+ grepTool,
20
+ memoryHints,
21
+ projectToolsNote,
22
+ readFileTool,
23
+ reinforceTouched,
24
+ reinforceUsed
25
+ } from "./chunk-TOPZL5SU.js";
26
+ import {
27
+ ToolRegistry,
28
+ handedOver,
29
+ runStructuredRole,
30
+ runToCompletion,
31
+ telemetry,
32
+ truncateSafe
33
+ } from "./chunk-YILDXPSI.js";
34
+ import {
35
+ loadTraceIndex,
36
+ pruneTraces,
37
+ saveTraceIndex,
38
+ sharedDerived,
39
+ traceRootRel,
40
+ traceable
41
+ } from "./chunk-FFYBY2NA.js";
42
+ import {
43
+ buildProjectGraph,
44
+ loadGraphSync,
45
+ pruneAreaNames
46
+ } from "./chunk-PGOYDOI4.js";
47
+ import {
48
+ writableStateRoot
49
+ } from "./chunk-SSDLHWSF.js";
50
+
51
+ // src/worktree/slug.ts
52
+ var MAX_SLUG = 60;
53
+ var MAX_WORDS = 5;
54
+ var LEADING_VERBS = /* @__PURE__ */ new Set([
55
+ "add",
56
+ "build",
57
+ "create",
58
+ "implement",
59
+ "make",
60
+ "write",
61
+ "develop",
62
+ "design",
63
+ "generate",
64
+ "introduce",
65
+ "fix",
66
+ "update",
67
+ "change",
68
+ "modify",
69
+ "refactor",
70
+ "rewrite",
71
+ "improve",
72
+ "enhance",
73
+ "optimize",
74
+ "clean",
75
+ "setup",
76
+ "configure",
77
+ "install",
78
+ "remove",
79
+ "delete",
80
+ "drop",
81
+ "rename",
82
+ "migrate",
83
+ "move",
84
+ "port",
85
+ "support",
86
+ "enable",
87
+ "disable",
88
+ "finalize",
89
+ "complete",
90
+ "finish",
91
+ "expand",
92
+ "extend",
93
+ "set",
94
+ "apply"
95
+ ]);
96
+ var FILLERS = /* @__PURE__ */ new Set(["a", "an", "the"]);
97
+ function dropLeadingAction(words3) {
98
+ let i = 0;
99
+ if (words3.length && LEADING_VERBS.has(words3[0])) {
100
+ i = 1;
101
+ while (i < words3.length && FILLERS.has(words3[i])) i++;
102
+ } else {
103
+ while (i < words3.length && FILLERS.has(words3[i])) i++;
104
+ }
105
+ return i < words3.length ? words3.slice(i) : words3;
106
+ }
107
+ function toSlug(name) {
108
+ const words3 = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
109
+ const named = dropLeadingAction(words3).slice(0, MAX_WORDS);
110
+ const s = named.join("-").slice(0, MAX_SLUG).replace(/-+$/g, "");
111
+ return s || "job";
112
+ }
113
+ function uniqueSlug(base, taken) {
114
+ if (!taken(base)) return base;
115
+ let n = 2;
116
+ while (taken(`${base}-${n}`)) n++;
117
+ return `${base}-${n}`;
118
+ }
119
+
120
+ // src/worktree/manager.ts
121
+ import { mkdir as mkdir2, writeFile, rm as rm2 } from "fs/promises";
122
+ import { existsSync as existsSync2, readdirSync, realpathSync } from "fs";
123
+ import { join as join2, resolve, dirname as dirname2, basename } from "path";
124
+
125
+ // src/worktree/inherit.ts
126
+ import { cp, mkdir, rm, stat } from "fs/promises";
127
+ import { existsSync } from "fs";
128
+ import { dirname, join, sep } from "path";
129
+ var INHERITED_ASSETS = [
130
+ join("graphify-out", "graph.json"),
131
+ // The community names beside it: without them a session's graph tools fall back to numbers, which is the
132
+ // difference between "this touches Wallet Member & Balance" and "this touches community 47".
133
+ join("graphify-out", ".graphify_labels.json"),
134
+ join(".horsecode", "memory.jsonl"),
135
+ join(".horsecode", "skills"),
136
+ join(".specify", "memory", "constitution.md"),
137
+ join(".horsecode", "migrated.json")
138
+ ];
139
+ var NEVER = [join(".horsecode", "worktrees")];
140
+ var excluded = (rel) => NEVER.some((n) => rel === n || rel.startsWith(n + sep) || rel.startsWith(n + "/"));
141
+ function nestedCheckout(repoRoot, rel, cache) {
142
+ const parts = rel.split(/[\\/]/).slice(0, -1);
143
+ let acc = "";
144
+ for (const part of parts) {
145
+ acc = acc ? `${acc}/${part}` : part;
146
+ let hit = cache.get(acc);
147
+ if (hit === void 0) {
148
+ hit = existsSync(join(repoRoot, acc, ".git"));
149
+ cache.set(acc, hit);
150
+ }
151
+ if (hit) return true;
152
+ }
153
+ return false;
154
+ }
155
+ var MAX_UNTRACKED = 5e3;
156
+ async function copyPath(from, to) {
157
+ await mkdir(dirname(to), { recursive: true });
158
+ await cp(from, to, { recursive: true, dereference: true, force: true });
159
+ }
160
+ async function inheritFromRoot(git, repoRoot, baseWorktree) {
161
+ const out = { modified: [], untracked: [], assets: [], deleted: [], skipped: 0 };
162
+ if (repoRoot === baseWorktree) return out;
163
+ const changed = await git(["diff", "--name-status", "HEAD"], repoRoot);
164
+ if (changed.code === 0) {
165
+ for (const line of changed.stdout.split("\n")) {
166
+ const [status, ...rest] = line.trim().split(/\t/);
167
+ const rel = rest.join(" ");
168
+ if (!status || !rel || excluded(rel)) continue;
169
+ try {
170
+ if (status.startsWith("D")) {
171
+ await rm(join(baseWorktree, rel), { force: true });
172
+ out.deleted.push(rel);
173
+ } else {
174
+ await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
175
+ out.modified.push(rel);
176
+ }
177
+ } catch {
178
+ }
179
+ }
180
+ }
181
+ const others = await git(["ls-files", "--others", "--exclude-standard"], repoRoot);
182
+ if (others.code === 0) {
183
+ const nested = /* @__PURE__ */ new Map();
184
+ for (const rel of others.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
185
+ if (excluded(rel) || nestedCheckout(repoRoot, rel, nested)) {
186
+ out.skipped++;
187
+ continue;
188
+ }
189
+ if (out.untracked.length >= MAX_UNTRACKED) {
190
+ out.skipped++;
191
+ continue;
192
+ }
193
+ try {
194
+ await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
195
+ out.untracked.push(rel);
196
+ } catch {
197
+ }
198
+ }
199
+ }
200
+ for (const rel of INHERITED_ASSETS) {
201
+ const from = join(repoRoot, rel);
202
+ if (!existsSync(from)) continue;
203
+ try {
204
+ await stat(from);
205
+ await copyPath(from, join(baseWorktree, rel));
206
+ out.assets.push(rel);
207
+ } catch {
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ function describeInherited(i) {
213
+ const parts = [];
214
+ const n = i.modified.length + i.deleted.length;
215
+ if (n) parts.push(`${n} uncommitted change(s)`);
216
+ if (i.untracked.length) parts.push(`${i.untracked.length} untracked file(s)`);
217
+ if (i.skipped) parts.push(`${i.skipped} left behind (another checkout, or past the ${MAX_UNTRACKED} bound)`);
218
+ if (i.assets.length) parts.push(i.assets.map((a) => `\`${a}\``).join(", "));
219
+ return parts.length ? `\u{1F4E5} Carried into this session: ${parts.join(" \xB7 ")}.` : void 0;
220
+ }
221
+ async function topUpInherited(repoRoot, baseWorktree) {
222
+ const added = [];
223
+ if (repoRoot === baseWorktree) return added;
224
+ for (const rel of INHERITED_ASSETS) {
225
+ const from = join(repoRoot, rel);
226
+ const to = join(baseWorktree, rel);
227
+ if (!existsSync(from) || existsSync(to)) continue;
228
+ try {
229
+ await copyPath(from, to);
230
+ added.push(rel);
231
+ } catch {
232
+ }
233
+ }
234
+ return added;
235
+ }
236
+ function describeTopUp(added) {
237
+ if (!added.length) return void 0;
238
+ return `\u{1F4E5} This session was opened before ${added.map((a) => `\`${a}\``).join(", ")} existed \u2014 carried in now.`;
239
+ }
240
+
241
+ // src/worktree/manager.ts
242
+ var MAX_DIFF_CHARS = 12e4;
243
+ var DOC_SPECS = ["*.md", "*.txt"];
244
+ function excludeOwnState() {
245
+ const roots = [".horsecode", "graphify-out", traceRootRel()].filter(Boolean);
246
+ return [...new Set(roots)].map((r) => `:(exclude)${r}/**`);
247
+ }
248
+ async function mainWorktreeRoot(git, cwd) {
249
+ const abs = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
250
+ const r = abs.code === 0 ? abs : await git(["rev-parse", "--git-common-dir"], cwd);
251
+ if (r.code !== 0 || !r.stdout.trim()) return cwd;
252
+ const common = resolve(cwd, r.stdout.trim());
253
+ return basename(common) === ".git" ? dirname2(common) : cwd;
254
+ }
255
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
256
+ var DAYS = ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"];
257
+ function sessionName(now, taken) {
258
+ const day = `${String(now.getDate()).padStart(2, "0")}-${MONTHS[now.getMonth()]}-${now.getFullYear()}-${DAYS[now.getDay()]}`;
259
+ for (let n = 1; n < 1e3; n++) {
260
+ const name = `${day}_${String(n).padStart(2, "0")}`;
261
+ if (!taken(name)) return name;
262
+ }
263
+ return `${day}_${Date.now()}`;
264
+ }
265
+ var WorktreeManager = class {
266
+ repoRoot;
267
+ /** The project checkout this manager was built for — where per-project settings and the remote live. */
268
+ get projectRoot() {
269
+ return this.repoRoot;
270
+ }
271
+ /**
272
+ * Where sessions are kept, which is the REPOSITORY's business and not the caller's checkout.
273
+ *
274
+ * Measured from a live run: started inside another tool's worktree, horse-code opened its session at
275
+ * `…/.claude/worktrees/product-create-wizard/.horsecode/worktrees/…/base` — its own worktree nested inside
276
+ * someone else's, inside the repository. That works and is a place nobody will look: `/clean-worktrees` at
277
+ * the repository root cannot see it, and removing the outer checkout takes it with it.
278
+ *
279
+ * Distinct from `repoRoot` on purpose. What a session INHERITS — the code graph, the memory, the project
280
+ * config — is whatever the user is standing in, and that is frequently not the main checkout.
281
+ */
282
+ worktreeHome;
283
+ git;
284
+ /** Injectable clock: a session's NAME is the day it opened, so a test has to be able to say which day. */
285
+ now;
286
+ constructor(deps) {
287
+ this.repoRoot = deps.repoRoot;
288
+ this.worktreeHome = deps.worktreeHome ?? deps.repoRoot;
289
+ this.git = deps.runGit ?? defaultGitRunner;
290
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
291
+ }
292
+ /** Runs git; nonzero exit → throws a clear error. Returns output (stdout). */
293
+ async run(args, cwd) {
294
+ const r = await this.git(args, cwd);
295
+ if (r.code !== 0) {
296
+ throw new Error(`git ${args.join(" ")} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
297
+ }
298
+ return r.stdout;
299
+ }
300
+ /** `git init` the repo if the directory isn't one yet (the user may not have run git init) + ensure an
301
+ * identity so the first commit doesn't fail on a machine with no global git config. */
302
+ async ensureRepo() {
303
+ const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
304
+ if (inside.code === 0 && inside.stdout.trim() === "true") return;
305
+ await this.run(["init", "-b", "main"], this.repoRoot);
306
+ const email = await this.git(["config", "user.email"], this.repoRoot);
307
+ if (email.code !== 0 || !email.stdout.trim()) {
308
+ await this.git(["config", "user.email", "horse-code@local"], this.repoRoot);
309
+ await this.git(["config", "user.name", "horse-code"], this.repoRoot);
310
+ }
311
+ }
312
+ /**
313
+ * A worktree must branch off a commit. First ensure the directory IS a git repo (auto `git init` if not).
314
+ * A freshly `git init`-ed repo has an unborn HEAD (no commits), so `git worktree add … <branch>` fails with
315
+ * "invalid reference". Bootstrap one empty commit so horse-code works in a brand-new / non-git directory.
316
+ */
317
+ async ensureBaseCommit() {
318
+ await this.ensureRepo();
319
+ const head = await this.git(["rev-parse", "--verify", "--quiet", "HEAD"], this.repoRoot);
320
+ if (head.code === 0) return;
321
+ await this.run(["commit", "--allow-empty", "-m", "hc: initial commit"], this.repoRoot);
322
+ }
323
+ /**
324
+ * The ref to base the session's worktree on. Uses `fromBranch` when it resolves; otherwise falls back to
325
+ * HEAD. This covers the common fresh-repo mismatch: horse-code guesses "main" but the repo's actual
326
+ * (default/unborn) branch is "master", so "main" never resolves even after the bootstrap commit.
327
+ */
328
+ async resolveBase(fromBranch) {
329
+ const ok = await this.git(["rev-parse", "--verify", "--quiet", fromBranch], this.repoRoot);
330
+ return ok.code === 0 ? fromBranch : "HEAD";
331
+ }
332
+ async openSession(fromBranch, jobName) {
333
+ await this.ensureBaseCommit();
334
+ const base = await this.resolveBase(fromBranch);
335
+ const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
336
+ await mkdir2(worktreesDir, { recursive: true });
337
+ await writeFile(join2(worktreesDir, ".gitignore"), "*\n", "utf8");
338
+ const listed = await this.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/hc/"], this.repoRoot);
339
+ const branches = new Set(listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean));
340
+ const jobSlug = sessionName(this.now(), (s) => existsSync2(join2(worktreesDir, s)) || branches.has(`hc/${s}/base`));
341
+ const root = join2(worktreesDir, jobSlug);
342
+ const baseWorktree = join2(root, "base");
343
+ const baseBranch = `hc/${jobSlug}/base`;
344
+ await mkdir2(join2(root, "tasks"), { recursive: true });
345
+ await this.run(["worktree", "add", "-b", baseBranch, baseWorktree, base], this.repoRoot);
346
+ const inherited = await inheritFromRoot((args, cwd) => this.git(args, cwd), this.repoRoot, baseWorktree);
347
+ return { jobSlug, root, baseWorktree, baseBranch, inherited };
348
+ }
349
+ /** Absolute paths of the worktrees git currently tracks (from `git worktree list --porcelain`). */
350
+ async registeredWorktrees() {
351
+ const r = await this.git(["worktree", "list", "--porcelain"], this.repoRoot);
352
+ const paths = /* @__PURE__ */ new Set();
353
+ for (const line of r.stdout.split("\n")) {
354
+ if (line.startsWith("worktree ")) {
355
+ const p = line.slice("worktree ".length).trim();
356
+ try {
357
+ paths.add(realpathSync(p));
358
+ } catch {
359
+ paths.add(p);
360
+ }
361
+ }
362
+ }
363
+ return paths;
364
+ }
365
+ /**
366
+ * Resume support: find a preserved worktree from an earlier interrupted run. Scans every
367
+ * `.horsecode/worktrees/<slug>/checkpoint.json` and only considers a session whose `base` worktree is still
368
+ * live in git (a pruned/stale dir can't be safely reused). A bare "continue" request (`isContinuePrompt`)
369
+ * matches any preserved work — the user needn't retype the original request — and among those, the one with
370
+ * ACTUAL PROGRESS wins over the merely most recent. Otherwise the prompt must match a checkpoint's stored
371
+ * `rawPrompt` (case/space-tolerant). Returns null when there is nothing to resume.
372
+ */
373
+ async findResumable(rawPrompt) {
374
+ const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
375
+ if (!existsSync2(worktreesDir)) return null;
376
+ const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
377
+ if (inside.code !== 0) return null;
378
+ const anyContinue = isContinuePrompt(rawPrompt);
379
+ const key2 = checkpointKey(rawPrompt);
380
+ const registered = await this.registeredWorktrees();
381
+ const candidates = [];
382
+ for (const slug of readdirSync(worktreesDir)) {
383
+ const root = join2(worktreesDir, slug);
384
+ const cp2 = readCheckpoint(root);
385
+ if (!cp2) continue;
386
+ if (!anyContinue && checkpointKey(cp2.rawPrompt) !== key2) continue;
387
+ const baseWorktree = join2(root, "base");
388
+ let real;
389
+ try {
390
+ real = realpathSync(baseWorktree);
391
+ } catch {
392
+ continue;
393
+ }
394
+ if (!registered.has(real)) continue;
395
+ candidates.push({
396
+ session: { jobSlug: slug, root, baseWorktree, baseBranch: `hc/${slug}/base`, resumed: true },
397
+ mtime: checkpointMtime(root),
398
+ progress: cp2.done.length
399
+ });
400
+ }
401
+ if (candidates.length === 0) return null;
402
+ candidates.sort((a, b) => (b.progress > 0 ? 1 : 0) - (a.progress > 0 ? 1 : 0) || b.mtime - a.mtime);
403
+ const picked = candidates[0].session;
404
+ const added = await topUpInherited(this.repoRoot, picked.baseWorktree);
405
+ return added.length ? { ...picked, toppedUp: added } : picked;
406
+ }
407
+ /**
408
+ * The worktree for a task — REUSED when the task already has one.
409
+ *
410
+ * It used to mint a fresh slug every time, so a task got `…-1`, `…-2`, `…-9` and each run began from base
411
+ * with the previous run's work stranded in a directory nobody would open again. Measured live: 321
412
+ * worktrees on disk, TEN of them for one task, and the newest empty while `…-9` held 8 commits and 7.6 KB
413
+ * of finished work.
414
+ *
415
+ * It also made the pipeline lie. The deadline warning tells the implementer "whatever it wrote is committed
416
+ * and kept — continue from there rather than starting over", and across runs that was simply false: a task
417
+ * needing more than one run's worth of work could never accumulate any.
418
+ *
419
+ * A fresh slug is still minted when the existing directory is not a usable worktree for this task's branch,
420
+ * because a broken one must not stop the task.
421
+ */
422
+ async deriveTask(session, taskName) {
423
+ const tasksDir = join2(session.root, "tasks");
424
+ const slug = toSlug(taskName);
425
+ const branch = `hc/${session.jobSlug}/t/${slug}`;
426
+ const existing = join2(tasksDir, slug);
427
+ if (existsSync2(existing)) {
428
+ const ok = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], existing);
429
+ if (ok.code === 0 && ok.stdout.trim() === branch) return { taskSlug: slug, worktree: existing, branch };
430
+ }
431
+ const taskSlug = uniqueSlug(slug, (s) => existsSync2(join2(tasksDir, s)));
432
+ const wt = join2(tasksDir, taskSlug);
433
+ const br = `hc/${session.jobSlug}/t/${taskSlug}`;
434
+ await this.run(["worktree", "add", "-b", br, wt, session.baseBranch], this.repoRoot);
435
+ return { taskSlug, worktree: wt, branch: br };
436
+ }
437
+ /**
438
+ * Retires a task's worktree and branch so the next derive starts from the CURRENT base.
439
+ *
440
+ * Reusing a task's worktree between attempts stopped the "start from scratch every run" waste, but it also
441
+ * FROZE the branch's root. Measured on a real board: the export/import task's branch was rooted two and a
442
+ * half days back and the base had moved 68 commits past it, while the throwaway worktrees it replaced had
443
+ * been rooted 29-30 commits back. Its merge then had to reconcile a drift that wide across seven files, and
444
+ * the resolver ran out of turns every time — twice on a review that had already PASSED.
445
+ *
446
+ * Past a few of those, re-implementing on today's base is cheaper than reconciling the drift, and it is the
447
+ * only move that actually removes the cause.
448
+ *
449
+ * The old branch is RENAMED, not deleted. It holds work that passed review; throwing it away to save a
450
+ * branch name would destroy the only copy of it.
451
+ */
452
+ async restartTask(session, task) {
453
+ let retired = `${task.branch}-stale`;
454
+ for (let n = 2; (await this.git(["rev-parse", "--verify", "--quiet", retired], this.repoRoot)).code === 0; n++) {
455
+ retired = `${task.branch}-stale-${n}`;
456
+ }
457
+ await this.run(["worktree", "remove", "--force", task.worktree], this.repoRoot);
458
+ await this.run(["branch", "-m", task.branch, retired], this.repoRoot);
459
+ return retired;
460
+ }
461
+ async mergeTask(session, task) {
462
+ return this.mergeRef(session, task.branch);
463
+ }
464
+ /**
465
+ * Merges any ref into the session base — a task branch coming home, or the project's main branch coming in.
466
+ *
467
+ * A resumed session picks up a branch that was cut days ago, and everything the team merged in the meantime
468
+ * is missing from it: it continues against code that no longer exists. Bringing main IN is the same
469
+ * operation as bringing a task in, which is why it is the same method and the same conflict path.
470
+ */
471
+ async mergeRef(session, ref) {
472
+ const r = await this.git(["merge", ref], session.baseWorktree);
473
+ if (r.code === 0) return { status: "merged" };
474
+ const diff = await this.git(
475
+ ["diff", "--name-only", "--diff-filter=U"],
476
+ session.baseWorktree
477
+ );
478
+ const files = diff.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
479
+ if (files.length > 0) return { status: "conflict", files };
480
+ throw new Error(`git merge ${ref} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
481
+ }
482
+ /**
483
+ * Brings one branch up to date from the remote. Best-effort: no remote, no network, no such branch — the
484
+ * merge then runs against the local copy, which is still better than not syncing at all.
485
+ *
486
+ * Returns the ref to merge: `origin/<branch>` when the fetch landed, the plain branch name otherwise.
487
+ */
488
+ async fetchBranch(branch) {
489
+ const r = await this.git(["fetch", "origin", branch], this.repoRoot);
490
+ if (r.code !== 0) return branch;
491
+ const remote = await this.git(["rev-parse", "--verify", `origin/${branch}`], this.repoRoot);
492
+ return remote.code === 0 ? `origin/${branch}` : branch;
493
+ }
494
+ /** Whether the session base already contains `ref` — nothing to merge, and nothing to say about it. */
495
+ async containsRef(session, ref) {
496
+ const r = await this.git(["merge-base", "--is-ancestor", ref, "HEAD"], session.baseWorktree);
497
+ return r.code === 0;
498
+ }
499
+ /** How many commits `ref` has that the session base does not — what a sync is about to bring in. */
500
+ async commitsBehind(session, ref) {
501
+ const r = await this.git(["rev-list", "--count", `HEAD..${ref}`], session.baseWorktree);
502
+ return r.code === 0 ? Number(r.stdout.trim()) || 0 : 0;
503
+ }
504
+ /** Files git marks as unmerged (conflicted) in the base worktree. */
505
+ async unmergedFiles(session) {
506
+ const r = await this.git(["diff", "--name-only", "--diff-filter=U"], session.baseWorktree);
507
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
508
+ }
509
+ /**
510
+ * Resolves one conflicted path by taking the BASE's copy, for files that are regenerated rather than merged.
511
+ *
512
+ * `--ours` during a merge means the branch being merged INTO — the session base, where the other tasks'
513
+ * work has already landed. For a lockfile that is the right side: it already carries every dependency the
514
+ * merged tasks installed, and the incoming branch's own addition is re-derived by running the package
515
+ * manager, not by choosing lines from a machine-written file.
516
+ */
517
+ async resolveWithBase(session, file) {
518
+ await this.git(["checkout", "--ours", "--", file], session.baseWorktree);
519
+ await this.git(["add", "--", file], session.baseWorktree);
520
+ }
521
+ /**
522
+ * One side of a conflicted file, as git holds it: stage 2 is the base's, stage 3 is the incoming branch's.
523
+ *
524
+ * Needed by the resolutions that COMBINE the two sides rather than choosing one — reading the working-tree
525
+ * copy would only give the version with the markers in it.
526
+ */
527
+ async conflictSide(session, file, side) {
528
+ const r = await this.git(["show", `:${side === "ours" ? 2 : 3}:${file}`], session.baseWorktree);
529
+ return r.code === 0 ? r.stdout : void 0;
530
+ }
531
+ /** Stages content the caller merged itself, settling that file's conflict. */
532
+ async resolveWith(session, file, content) {
533
+ await writeFile(join2(session.baseWorktree, file), content, "utf8");
534
+ await this.git(["add", "--", file], session.baseWorktree);
535
+ }
536
+ /** Unified diff of changes in the base worktree against the base branch (the PR diff). */
537
+ /**
538
+ * The code first, then what was written about it.
539
+ *
540
+ * Git orders a diff by path, so on a run whose work lives under `toucan/` every specification, plan,
541
+ * checklist and brainstorm sorts ahead of the source. Measured on PR #765 with horse-code's own state
542
+ * already excluded: the first 60,000 characters held nine files, all of them markdown, and not one line of
543
+ * the code the review existed to read. Ordering is the fix — a budget spent on prose is a budget the
544
+ * source never sees, however large it is.
545
+ */
546
+ async diff(session, base) {
547
+ const range = `${base}...${session.baseBranch}`;
548
+ const notDocs = DOC_SPECS.map((d) => `:(exclude)${d}`);
549
+ const code = await this.git(["diff", range, "--", ".", ...excludeOwnState(), ...notDocs], session.baseWorktree);
550
+ const docs = await this.git(["diff", range, "--", ...DOC_SPECS, ...excludeOwnState()], session.baseWorktree);
551
+ const out = code.stdout + docs.stdout;
552
+ if (out.length <= MAX_DIFF_CHARS) return out;
553
+ return `\u2026 (diff truncated to the first ${MAX_DIFF_CHARS} characters of ${out.length}; use the read tools to inspect anything not shown here)
554
+ ${out.slice(0, MAX_DIFF_CHARS)}`;
555
+ }
556
+ /**
557
+ * Rejection path: commit whatever draft the worktree holds to its branch (so the work is NOT lost) and
558
+ * KEEP both the worktree directory and the branch, so the user can inspect the produced files directly
559
+ * under .horsecode/worktrees/<slug>/base. Returns the worktree path. (closeSession, by contrast, deletes
560
+ * the worktree + branch — but nothing currently calls it on the happy path; worktrees are kept for inspection.)
561
+ */
562
+ async preserveSession(session, message) {
563
+ await this.commitMerge(session, message);
564
+ return session.baseWorktree;
565
+ }
566
+ async commitMerge(session, message) {
567
+ await this.run(["add", "-A"], session.baseWorktree);
568
+ const staged = await this.git(["diff", "--cached", "--quiet"], session.baseWorktree);
569
+ if (staged.code === 0) return;
570
+ await this.run(message ? ["commit", "-m", message] : ["commit", "--no-edit"], session.baseWorktree);
571
+ }
572
+ /** Commits all changes in the task worktree to the task branch; no-op if there are no changes. */
573
+ async commitTask(task, message) {
574
+ await this.run(["add", "-A"], task.worktree);
575
+ const staged = await this.git(["diff", "--cached", "--quiet"], task.worktree);
576
+ if (staged.code === 0) return;
577
+ await this.run(["commit", "-m", message], task.worktree);
578
+ }
579
+ async abortMerge(session) {
580
+ await this.run(["merge", "--abort"], session.baseWorktree);
581
+ }
582
+ async removeTask(session, task) {
583
+ await this.git(["worktree", "remove", "--force", task.worktree], this.repoRoot);
584
+ await this.git(["branch", "-D", task.branch], this.repoRoot);
585
+ }
586
+ async closeSession(session) {
587
+ await rm2(session.root, { recursive: true, force: true });
588
+ await this.git(["worktree", "prune"], this.repoRoot);
589
+ const prefix = `hc/${session.jobSlug}/`;
590
+ const list = await this.git(["branch", "--list"], this.repoRoot);
591
+ const branches = list.stdout.split("\n").map((s) => s.replace(/^[*+ ]+/, "").trim()).filter((b) => b.startsWith(prefix));
592
+ for (const b of branches) {
593
+ await this.git(["branch", "-D", b], this.repoRoot);
594
+ }
595
+ }
596
+ /** Whether a remote exists — the difference between a pull request being delivery and being impossible. */
597
+ async hasRemote(session, remote = "origin") {
598
+ return (await this.git(["remote", "get-url", remote], session.baseWorktree)).code === 0;
599
+ }
600
+ async push(session, remote = "origin") {
601
+ const check = await this.git(["remote", "get-url", remote], session.baseWorktree);
602
+ if (check.code !== 0) return;
603
+ await this.run(["push", remote, session.baseBranch], session.baseWorktree);
604
+ }
605
+ /**
606
+ * Lands the finished work on the branch the job started from, in the main working copy.
607
+ *
608
+ * Without this, a project with no git remote gets nothing: `push` is a no-op and a pull request has
609
+ * nowhere to go, so every completed task sits on `hc/<job>/base` — invisible from the repository root.
610
+ * A user who watched thirty tasks succeed then finds an empty directory and cannot run the project.
611
+ *
612
+ * A pull request is delivery when there is a remote to open it against. When there is not, merging is.
613
+ *
614
+ * Refuses rather than forces. A dirty working copy or a checkout on some other branch means the user has
615
+ * something in progress, and overwriting that to deliver would be a worse failure than not delivering:
616
+ * the branch still exists and the caller reports how to merge it by hand.
617
+ */
618
+ async deliverLocally(session, targetBranch) {
619
+ const dirty = await this.git(["status", "--porcelain"], this.repoRoot);
620
+ if (dirty.code !== 0) return { ok: false, why: "the repository could not be read" };
621
+ if (dirty.stdout.split("\n").some((l) => l.trim() && !l.startsWith("??"))) {
622
+ return { ok: false, why: "the working copy has uncommitted changes" };
623
+ }
624
+ const head = await this.git(["symbolic-ref", "--short", "HEAD"], this.repoRoot);
625
+ const current = head.stdout.trim();
626
+ if (head.code !== 0 || !current) return { ok: false, why: "the repository is not on a branch" };
627
+ if (current !== targetBranch) return { ok: false, why: `the repository is on \`${current}\`, not \`${targetBranch}\`` };
628
+ const count = await this.git(["rev-list", "--count", `${targetBranch}..${session.baseBranch}`], this.repoRoot);
629
+ const commits = Number(count.stdout.trim()) || 0;
630
+ if (!commits) return { ok: true, commits: 0 };
631
+ const merged = await this.git(
632
+ ["merge", "--no-ff", "-m", `hc: ${session.jobSlug}`, session.baseBranch],
633
+ this.repoRoot
634
+ );
635
+ if (merged.code !== 0) return { ok: false, why: "the merge did not apply cleanly" };
636
+ return { ok: true, commits };
637
+ }
638
+ async openPR(session, adapter, input) {
639
+ const res = await adapter.createPR({
640
+ branch: session.baseBranch,
641
+ base: input.base,
642
+ title: input.title,
643
+ body: input.body
644
+ });
645
+ return { url: res.url };
646
+ }
647
+ };
648
+
649
+ // src/skills/apply.ts
650
+ import { readFile } from "fs/promises";
651
+ import { readdirSync as readdirSync2 } from "fs";
652
+ import { resolve as resolve2, sep as sep3 } from "path";
653
+ import { z } from "zod";
654
+ function applySkills(basePrompt, mandatory, registry) {
655
+ const parts = [basePrompt];
656
+ if (mandatory.length) {
657
+ const sections = mandatory.map((name) => {
658
+ const skill = registry.get(name);
659
+ if (!skill) throw new Error(`applySkills: undefined skill: ${name}`);
660
+ const where = skill.dir ? `
661
+ _Skill base directory: ${skill.dir}_
662
+ ` : "";
663
+ return `## ${skill.name}${where}
664
+ ${skill.content}`;
665
+ });
666
+ parts.push(`# Mandatory Skills
667
+ ${sections.join("\n\n")}`);
668
+ }
669
+ const mandatorySet = new Set(mandatory);
670
+ const available = registry.list().filter((s) => !mandatorySet.has(s.name));
671
+ if (available.length) {
672
+ const lines = available.map((s) => `- ${s.name}: ${s.description}`);
673
+ parts.push(`# Discoverable Skills (call the skill tool to fetch its content)
674
+ ${lines.join("\n")}`);
675
+ }
676
+ return parts.join("\n\n");
677
+ }
678
+ var skillParams = z.object({
679
+ name: z.string().describe("The skill's name, exactly as it is listed."),
680
+ /**
681
+ * A supporting document inside the skill's own directory, e.g. "reference/critique.md".
682
+ *
683
+ * Described, because an undescribed optional string gets filled in. Measured: four consecutive calls sent
684
+ * `file: ""` and every one of them failed — the skill was there, its content was one branch away, and an
685
+ * empty string took the other branch.
686
+ */
687
+ file: z.string().optional().describe('Optional. A supporting document inside the skill, e.g. "reference/critique.md". Omit it to read the skill itself \u2014 do not pass an empty string.')
688
+ });
689
+ var DOCS_SHOWN = 12;
690
+ function docsIn(dir) {
691
+ try {
692
+ return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.name !== "SKILL.md" && !e.name.startsWith(".")).map((e) => e.isDirectory() ? `${e.name}/` : e.name).sort().slice(0, DOCS_SHOWN);
693
+ } catch {
694
+ return [];
695
+ }
696
+ }
697
+ var MAX_SKILL_DOC_CHARS = 3e4;
698
+ function noSuchSkill(name, available) {
699
+ const shape = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
700
+ const same = available.filter((s) => shape(s) === shape(name));
701
+ if (same.length === 1) {
702
+ return `skill not found: ${name} \u2014 did you mean \`${same[0]}\`? Call it with that exact name.`;
703
+ }
704
+ return available.length ? `skill not found: ${name}. Available: ${available.join(", ")}. Use one of these exactly, or carry on without a skill \u2014 do not guess another name.` : `skill not found: ${name}. This project has no skills installed, so carry on without one.`;
705
+ }
706
+ function buildSkillTool(registry) {
707
+ return {
708
+ name: "skill",
709
+ description: 'Fetch a skill\'s content by name. Some skills are dispatchers whose SKILL.md points at supporting documents (e.g. "see reference/critique.md"); pass `file` with that relative path to read one. Fetch a document only when the skill actually sends you to it.',
710
+ permissionLevel: "safe",
711
+ parameters: skillParams,
712
+ run: async (rawArgs) => {
713
+ const parsed = skillParams.safeParse(rawArgs);
714
+ if (!parsed.success) {
715
+ return { content: `skill: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
716
+ }
717
+ const { name, file } = parsed.data;
718
+ const skill = registry.get(name);
719
+ if (!skill) return { content: noSuchSkill(name, registry.list().map((s) => s.name)), isError: true };
720
+ if (file === void 0 || !file.trim()) {
721
+ const where = skill.dir ? `_Skill base directory: ${skill.dir}_
722
+
723
+ ` : "";
724
+ return { content: `${where}${skill.content}`, isError: false };
725
+ }
726
+ if (!skill.dir) return { content: `skill ${name}: has no supporting documents`, isError: true };
727
+ const target = resolve2(skill.dir, file);
728
+ const root = resolve2(skill.dir);
729
+ if (target !== root && !target.startsWith(root + sep3)) {
730
+ return { content: `skill ${name}: ${file} is outside the skill directory`, isError: true };
731
+ }
732
+ let raw;
733
+ try {
734
+ raw = await readFile(target, "utf8");
735
+ } catch {
736
+ const has = docsIn(skill.dir);
737
+ return {
738
+ content: `skill ${name}: no such document: ${file}` + (has.length ? `. It has: ${has.join(", ")}` : `. It has no supporting documents.`),
739
+ isError: true
740
+ };
741
+ }
742
+ if (raw.length <= MAX_SKILL_DOC_CHARS) return { content: raw, isError: false };
743
+ return {
744
+ content: `${raw.slice(0, MAX_SKILL_DOC_CHARS)}
745
+
746
+ [skill ${name}/${file}: truncated at ${MAX_SKILL_DOC_CHARS} of ${raw.length} chars]`,
747
+ isError: false
748
+ };
749
+ }
750
+ };
751
+ }
752
+
753
+ // src/engine/unfinished.ts
754
+ import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync, statSync } from "fs";
755
+ import { join as join3 } from "path";
756
+ function boardCounts(dir) {
757
+ try {
758
+ const raw = JSON.parse(readFileSync(join3(dir, "board.json"), "utf8"));
759
+ const cards = Array.isArray(raw.cards) ? raw.cards : Object.values(raw.cards ?? {});
760
+ const list = cards;
761
+ return { total: list.length, done: list.filter((c) => c.column === "MERGED" || c.column === "DONE").length };
762
+ } catch {
763
+ return { total: 0, done: 0 };
764
+ }
765
+ }
766
+ function unfinishedSessions(cwd, commitCount = () => 0) {
767
+ const root = join3(cwd, ".horsecode", "worktrees");
768
+ if (!existsSync3(root)) return [];
769
+ const out = [];
770
+ for (const id of readdirSync3(root)) {
771
+ const dir = join3(root, id);
772
+ try {
773
+ if (!statSync(dir).isDirectory()) continue;
774
+ } catch {
775
+ continue;
776
+ }
777
+ if (!existsSync3(join3(dir, "base"))) continue;
778
+ const checkpoint = readCheckpoint(dir);
779
+ if (!checkpoint) continue;
780
+ let updatedAt = 0;
781
+ try {
782
+ updatedAt = statSync(join3(dir, "checkpoint.json")).mtimeMs;
783
+ } catch {
784
+ }
785
+ out.push({
786
+ id,
787
+ checkpoint,
788
+ cards: boardCounts(dir),
789
+ commits: commitCount(`hc/${id}/base`),
790
+ updatedAt
791
+ });
792
+ }
793
+ return out.sort((a, b) => b.updatedAt - a.updatedAt);
794
+ }
795
+ function describeUnfinished(s) {
796
+ const asked = s.checkpoint.rawPrompt.trim() || s.checkpoint.title;
797
+ const bits = [
798
+ s.checkpoint.done.length ? `${s.checkpoint.done.join(" \u2192 ")} done` : "nothing finished yet",
799
+ s.cards.total ? `${s.cards.done}/${s.cards.total} tasks` : "",
800
+ s.commits ? `${s.commits} commit${s.commits === 1 ? "" : "s"}` : ""
801
+ ].filter(Boolean);
802
+ return `\u201C${asked.length > 70 ? `${asked.slice(0, 69)}\u2026` : asked}\u201D \u2014 ${bits.join(" \xB7 ")} (\`${s.id}\`; say **continue** to pick it up)`;
803
+ }
804
+
805
+ // src/tools/git.ts
806
+ import { execFile } from "child_process";
807
+ import { z as z2 } from "zod";
808
+ var READ_ONLY = /* @__PURE__ */ new Set([
809
+ "status",
810
+ "log",
811
+ "show",
812
+ "diff",
813
+ "blame",
814
+ "shortlog",
815
+ "whatchanged",
816
+ "rev-parse",
817
+ "rev-list",
818
+ "merge-base",
819
+ "name-rev",
820
+ "describe",
821
+ "symbolic-ref",
822
+ "ls-files",
823
+ "ls-tree",
824
+ "cat-file",
825
+ "count-objects",
826
+ "show-ref",
827
+ "for-each-ref",
828
+ "ls-remote",
829
+ /**
830
+ * `check-ignore` asks whether a path is ignored — it reads `.gitignore` and answers, and changes nothing.
831
+ *
832
+ * Left out, it was the one read-only verb agents had to reach for `shell` to run: four calls in one run,
833
+ * each landing outside the tool that knows this repository's rules. And through `shell` its exit code is
834
+ * raw, so "no, that path is not ignored" — which git says with 1 — came back as a failed command.
835
+ */
836
+ "check-ignore"
837
+ ]);
838
+ var READ_ONLY_PAIRS = /* @__PURE__ */ new Set([
839
+ "worktree list",
840
+ "branch --list",
841
+ "branch -l",
842
+ "branch -a",
843
+ "branch -v",
844
+ "branch -r",
845
+ "tag --list",
846
+ "tag -l",
847
+ "stash list",
848
+ "remote -v",
849
+ "remote show",
850
+ "config --get",
851
+ "config --list"
852
+ ]);
853
+ var REFUSED_ARG = /^(--output|-c$|--config-env|--exec-path|-C$|--git-dir|--work-tree|--upload-pack|--receive-pack)/;
854
+ var params = z2.object({
855
+ args: z2.array(z2.string()).min(1).describe(
856
+ 'Git arguments as a list, without the leading "git" \u2014 e.g. ["status","--porcelain"] or ["log","-5","--oneline"].'
857
+ )
858
+ });
859
+ var MAX_GIT_OUTPUT = 6e4;
860
+ var GIT_TIMEOUT_MS = 3e4;
861
+ var GIT_PUSH_TIMEOUT_MS = 12e4;
862
+ var ANSWERS_WITH_ONE = /* @__PURE__ */ new Set(["diff", "diff-index", "diff-tree", "diff-files", "merge-base", "check-ignore"]);
863
+ function answeredWithOne(args, code) {
864
+ return code === 1 && ANSWERS_WITH_ONE.has(args[0] ?? "");
865
+ }
866
+ function answerOfOne(args) {
867
+ const verb = args[0] ?? "";
868
+ if (verb === "check-ignore") {
869
+ return "No \u2014 that path is not ignored by this repository's rules. (git exit code 1, which is the answer here.)";
870
+ }
871
+ if (verb === "merge-base") {
872
+ return args.includes("--is-ancestor") ? "No \u2014 the first commit is not an ancestor of the second. (git exit code 1, which is the answer here.)" : "No merge base: these commits share no common ancestor. (git exit code 1, which is the answer here.)";
873
+ }
874
+ return "There ARE differences \u2014 the comparison is not empty. Nothing failed; `--quiet`/`--exit-code` reports this as exit code 1. Re-run without it to see them.";
875
+ }
876
+ function refuse(args) {
877
+ const bad = args.find((a) => REFUSED_ARG.test(a));
878
+ if (bad) {
879
+ return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
880
+ }
881
+ const [sub, second] = args;
882
+ if (!sub || sub.startsWith("-")) return "The first argument must be a git subcommand, e.g. `status`.";
883
+ if (READ_ONLY.has(sub)) return void 0;
884
+ if (second && READ_ONLY_PAIRS.has(`${sub} ${second}`)) return void 0;
885
+ return `\`git ${sub}\` is not available here \u2014 this tool reads history and state, it never changes them. Available: ${[...READ_ONLY].sort().join(", ")}; also ${[...READ_ONLY_PAIRS].sort().join(", ")}.`;
886
+ }
887
+ var gitTool = {
888
+ name: "git",
889
+ description: 'Runs a READ-ONLY git command in the working directory and returns its output. Pass arguments as a list without the leading `git`: ["status","--porcelain"], ["log","-10","--oneline"], ["diff","--stat","main...HEAD"], ["show","abc123:path/to/file"]. Use it for what only git knows \u2014 what changed, when, by which commit, how a branch compares to another. Commands that change anything (checkout, commit, reset, clean, branch -D, stash, worktree add) are refused.',
890
+ permissionLevel: "safe",
891
+ parameters: params,
892
+ describe: (args) => {
893
+ const list = args.args;
894
+ const text = Array.isArray(list) ? list.join(" ") : "";
895
+ return { allowKey: "git:read", preview: `git ${text}`.slice(0, 120) };
896
+ },
897
+ async run(rawArgs, ctx) {
898
+ const parsed = params.safeParse(rawArgs);
899
+ if (!parsed.success) {
900
+ return { content: `git: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
901
+ }
902
+ const args = parsed.data.args;
903
+ const why = refuse(args);
904
+ if (why) return { content: why, isError: true, settled: true };
905
+ const out = await new Promise((resolve6) => {
906
+ const child = execFile("git", args, {
907
+ cwd: ctx.cwd,
908
+ timeout: GIT_TIMEOUT_MS,
909
+ maxBuffer: MAX_GIT_OUTPUT * 4,
910
+ // `--no-pager` would still be needed for some subcommands; killing the pager entirely is simpler and
911
+ // leaves nothing waiting for a terminal that does not exist.
912
+ env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
913
+ }, (err, stdout, stderr) => {
914
+ const text = `${stdout}${stderr}`.trim();
915
+ resolve6({ code: err?.code ?? (err ? 1 : 0), text });
916
+ });
917
+ ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
918
+ });
919
+ const failed = out.code !== 0 && !answeredWithOne(args, out.code);
920
+ if (!out.text) {
921
+ if (!failed && out.code === 1) return { content: answerOfOne(args), isError: false };
922
+ return { content: out.code === 0 ? "(no output)" : "git failed with no output.", isError: failed };
923
+ }
924
+ const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
925
+ \u2026[truncated \u2014 narrow the range, or put \`--stat\` directly after the subcommand (git diff --stat <rest>), which git requires]` : out.text;
926
+ return { content: clipped, isError: failed };
927
+ }
928
+ };
929
+ var WRITE = /* @__PURE__ */ new Set(["add", "commit", "push", "fetch"]);
930
+ var REFSPEC = /:/;
931
+ var REFUSED_PUSH = /^(-f|--force|--force-with-lease|--delete|--mirror|--prune)/;
932
+ function refuseWrite(args) {
933
+ const bad = args.find((a) => REFUSED_ARG.test(a));
934
+ if (bad) {
935
+ return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
936
+ }
937
+ const [sub] = args;
938
+ if (!sub || !WRITE.has(sub)) {
939
+ return `\`git ${sub ?? ""}\` is not available here \u2014 this tool records work (${[...WRITE].join(", ")}). Use the \`git\` tool to read history and state.`;
940
+ }
941
+ if (sub === "fetch") {
942
+ const spec = args.slice(1).find((a) => REFSPEC.test(a) && !a.startsWith("-"));
943
+ if (spec) {
944
+ return `\`${spec}\` is not allowed: a refspec can move LOCAL branches, including the one this session is standing on. Fetch without one \u2014 it updates the remote-tracking refs, which is what tells you whether the remote has moved.`;
945
+ }
946
+ const pruned = args.find((a) => /^(--prune|-p)$/.test(a));
947
+ if (pruned) {
948
+ return `\`${pruned}\` is not allowed: it deletes remote-tracking refs, and something else may be relying on one. Fetch without it.`;
949
+ }
950
+ }
951
+ const forced = sub === "push" && args.find((a) => REFUSED_PUSH.test(a));
952
+ if (forced) {
953
+ return `\`${forced}\` is not allowed: it rewrites or removes history on the remote, which no one can undo from here. Push the branch as it stands, or ask the user to do the rewrite themselves.`;
954
+ }
955
+ return void 0;
956
+ }
957
+ var gitWriteTool = {
958
+ name: "git_write",
959
+ description: 'Changes git state: `add`, `commit`, `push` and `fetch`, nothing else. `fetch` updates the remote-tracking refs so you can see whether the remote has moved \u2014 it touches no local branch and no file; refspecs and `--prune` are refused. To merge what you fetched, ask the user. Pass arguments as a list without the leading `git`: ["add","docs/architecture"], ["commit","-m","docs: refresh traces"], ["push"]. Use it ONLY when the user has asked for the work to be recorded \u2014 committing on your own initiative puts a change in their history that they did not ask for. Every call goes through the permission prompt, so do the job in as few calls as it takes, and say what you are about to commit BEFORE you call it. Read the state first with the `git` tool \u2014 which branch you are on, what is staged, what changed \u2014 and never commit what you have not looked at. Force pushes and history rewrites are refused.',
960
+ permissionLevel: "exec",
961
+ parameters: params,
962
+ describe: (args) => {
963
+ const list = args.args;
964
+ const text = Array.isArray(list) ? list.join(" ") : "";
965
+ const sub = Array.isArray(list) && typeof list[0] === "string" ? list[0] : "";
966
+ return { allowKey: `git ${sub}`, preview: `git ${text}`.slice(0, 200) };
967
+ },
968
+ async run(rawArgs, ctx) {
969
+ const parsed = params.safeParse(rawArgs);
970
+ if (!parsed.success) {
971
+ return { content: `git_write: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
972
+ }
973
+ const args = parsed.data.args;
974
+ const why = refuseWrite(args);
975
+ if (why) return { content: why, isError: true, settled: true };
976
+ const out = await new Promise((resolve6) => {
977
+ const child = execFile("git", args, {
978
+ cwd: ctx.cwd,
979
+ // A push talks to a server: the read tool's 30s is a reasonable ceiling for a local query and a
980
+ // pessimistic one for a repository with anything in it.
981
+ timeout: args[0] === "push" ? GIT_PUSH_TIMEOUT_MS : GIT_TIMEOUT_MS,
982
+ maxBuffer: MAX_GIT_OUTPUT * 4,
983
+ // GIT_TERMINAL_PROMPT=0: a push that needs credentials fails with a message instead of blocking on a
984
+ // prompt no one can see — the TUI owns the terminal, so the agent would simply hang.
985
+ env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
986
+ }, (err, stdout, stderr) => {
987
+ resolve6({ code: err ? 1 : 0, text: `${stdout}${stderr}`.trim() });
988
+ });
989
+ ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
990
+ });
991
+ if (!out.text) return { content: out.code === 0 ? "(done)" : "git failed with no output.", isError: out.code !== 0 };
992
+ const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
993
+ \u2026[truncated]` : out.text;
994
+ return { content: clipped, isError: out.code !== 0 };
995
+ }
996
+ };
997
+
998
+ // src/tools/remember.ts
999
+ import { z as z3 } from "zod";
1000
+ var params2 = z3.object({
1001
+ fact: z3.string().describe(
1002
+ "One short sentence, durable and project-specific: where something lives, which command builds it, a convention this codebase follows, a schema detail that cost you a search. Not what you did, not what is true of the language in general \u2014 something the next agent would otherwise have to rediscover."
1003
+ )
1004
+ });
1005
+ function buildRememberTool(sink) {
1006
+ return {
1007
+ name: "remember_fact",
1008
+ description: "Save a short, durable fact worth recalling in future sessions \u2014 a project convention, where something lives, a schema detail, a command that works. It is written straight away, so a session that stops early still leaves it behind. Use it the moment you learn something you would not want to work out twice; skip anything transient or specific to the task in hand.",
1009
+ permissionLevel: "safe",
1010
+ parameters: params2,
1011
+ async run(rawArgs, ctx) {
1012
+ const parsed = params2.safeParse(rawArgs);
1013
+ if (!parsed.success) return { content: "remember_fact: invalid args (expected { fact })", isError: true };
1014
+ const fact = parsed.data.fact.trim();
1015
+ if (!fact) return { content: "remember_fact: empty fact", isError: true };
1016
+ const write = sink ?? ctx.remember;
1017
+ if (!write) return { content: "remember_fact: memory is not available in this context", isError: true };
1018
+ write(fact);
1019
+ return { content: `Remembered: ${fact}`, isError: false };
1020
+ }
1021
+ };
1022
+ }
1023
+ var rememberFactTool = buildRememberTool();
1024
+
1025
+ // src/prompts.ts
1026
+ var REQUIRED_ROLES = [
1027
+ "refiner",
1028
+ "coach",
1029
+ "brainstormer",
1030
+ "analyst",
1031
+ "planner",
1032
+ "judge",
1033
+ "project-manager",
1034
+ "team-lead",
1035
+ "router",
1036
+ "coder",
1037
+ "designer",
1038
+ "senior-coder",
1039
+ "senior-designer",
1040
+ "architect",
1041
+ "code-reviewer",
1042
+ "task-auditor",
1043
+ "principal-coder",
1044
+ "operational",
1045
+ "memory-keeper",
1046
+ "tracer",
1047
+ "tester"
1048
+ ];
1049
+ var DEFAULT_ROLE_SKILLS = {
1050
+ brainstormer: ["brainstorming"],
1051
+ // The roles that WRITE code get the test discipline inlined, rather than having the code-tests lens reject
1052
+ // vacuous tests after the fact. Rejecting is more expensive than getting it right the first time.
1053
+ coder: ["test-driven-development"],
1054
+ "senior-coder": ["test-driven-development"],
1055
+ // The task list is where a plan becomes something an implementer can actually execute. spec-kit's template
1056
+ // supplies the SHAPE (phases, story grouping, [P] markers); it says almost nothing about what makes an
1057
+ // individual task executable. That is what this skill adds.
1058
+ "project-manager": ["writing-plans"],
1059
+ // The UI roles get design direction inlined for the same reason the coders get TDD: the code-accessibility
1060
+ // and code-maintainability lenses can reject a templated, default-looking interface, but they cannot teach
1061
+ // one. This skill is self-contained (no sibling reference files), which is what makes it safe to inline.
1062
+ designer: ["frontend-design"],
1063
+ "senior-designer": ["frontend-design"]
1064
+ // NB: systematic-debugging is shipped but attached to NO role — it is only needed when something is stuck,
1065
+ // so it stays in the discoverable listing every role already receives and is fetched with the `skill` tool.
1066
+ };
1067
+ var DEFAULT_PROMPTS = {
1068
+ tracer: "You write the reference note that every other agent reads before it touches a file it did not write. A wrong note is worse than none: an agent will act on it, so accuracy outranks fluency and admitting you cannot tell outranks a plausible guess. State only what the code and the given relationships show; if the business purpose is not evident from them, describe what the file does technically and say nothing about why. Never speculate about intent, history or requirements.",
1069
+ /**
1070
+ * The role that exercises work already built and writes down what actually happened.
1071
+ *
1072
+ * Every rule below is here because its absence produces the one output worse than no testing at all: a
1073
+ * report that says PASSED about something nobody ran. Such a report is not merely empty — it manufactures
1074
+ * confidence, and the next person spends it.
1075
+ */
1076
+ tester: "You verify software that already exists, by running its scenarios and recording what they actually did. You are not here to build, fix or improve anything: the code under test is finished, and changing it would mean the thing you verified is not the thing that shipped.\n\nEVIDENCE IS THE WHOLE JOB. A scenario's outcome is what you OBSERVED \u2014 a database row, a log line, an HTTP response, a screen the user confirmed. Record the evidence beside every result: the query you ran and what it returned, the log event id and its line, the response body. A result you cannot show is not a result.\n\nIF A STEP WRITES TO THE DATABASE, THE RESPONSE IS NOT THE EVIDENCE. A 201 or a 204 says the request was accepted; it does not say what was stored, and a screen showing the new state does not either \u2014 both can be right while the row is wrong. For every step that creates or changes a record: query the database for that row and put the query AND the rows it returned in the report, and query the logs for the event that step should have emitted and put the query AND the line it returned there too. Absence is evidence as well: when a step must NOT emit an event \u2014 a no-op, a rejected change \u2014 show the query returning nothing. Without both, the scenario is NOT EXECUTED, however convincing the response looked.\n\nNever mark a scenario PASSED that you did not execute and observe. If you could not run it \u2014 the data does not exist, the surface is unreachable, the case is destructive against a live system \u2014 label it NOT EXECUTED and say exactly why. FAILED means you ran it and the behaviour was wrong; say what you expected, what happened, and the evidence for both. Guessing from the source is not executing: where you reasoned from code alone, say so in those words.\n\nWrite each result into the report BEFORE moving on to the next scenario. The report is a living document, not something assembled at the end: a run that stops halfway must leave behind everything it learned up to that point.\n\n\u2026and say each verdict OUT LOUD as you reach it, in one or two sentences: which scenario, what it did, and the single piece of evidence that settled it \u2014 the row, the log line, the status code. The full evidence still goes in the report; this is so the person watching the run knows what you found without opening a file. Say the failures and the NOT EXECUTED ones the same way, and with the same brevity: a result nobody hears is one they have to go looking for.\n\nNever start or stop the development environment \u2014 application hosts, dev servers, containers, databases. Those are the developer's to run. When you need something up, say which command they should run and wait for them to confirm it is ready.\n\nYou do NOT write product code. When you find something wrong that is not the verdict of the scenario you are running \u2014 a missing label, prose rendered as raw markup, a wrong format, or something the developer points out in passing \u2014 call `report_finding`. Another role fixes it and you are told when it is done, so you can re-check what it affected. Do not fix it yourself: changing the product mid-verification means the thing you verified is not the thing that shipped. And do not fail a scenario over it \u2014 a scenario fails when the scenario itself does not pass, not because something else was noticed while running it.\n\nIf the project's own rules (its constitution) say more about how verification is done here, they govern over this description \u2014 read them and follow them.",
1077
+ refiner: "Your #1 rule: `refinedPrompt` MUST ALWAYS be in ENGLISH. If the user wrote in another language (Turkish, German, Spanish, \u2026), TRANSLATE their intent into English \u2014 never echo their language back. This is non-negotiable: a Turkish input like 'bir todo app geli\u015Ftir, \xF6nce backend' MUST come out as English 'Build a todo app; implement the backend first.'\n\nRewrite the user's message down to the raw core intent the AI needs to act on \u2014 clear, direct, and structured. Strip all politeness, emotional, and filler words (please, thanks, kindly, 'could you', 'would you', 'I'd like', etc.) and anything that carries no instruction. Do NOT add words, qualifiers, or scope the user did not state (e.g. do not add 'always'). Keep the user's own perspective and form \u2014 a question stays a question, an instruction stays an instruction; do NOT describe the user in the third person and do NOT answer the request. Example: a polite request like 'would you please answer me in language X?' becomes just 'respond in language X' (drop 'please'; do not add 'always' or any scope the user didn't state). Also classify the intent: 'chat' (conversation/question), 'feature' (new feature/work), 'bugfix' (bug fix), 'govern' (establish or amend the project's OWN standing rules and principles \u2014 writing or revising the constitution, the coding conventions, the project's rules; work whose entire output is a governing document, with no source code changed). Judge by what the request PRODUCES, not by what it mentions: 'write the project constitution from CLAUDE.md' is govern, and so is 'update our commit-message rules'; 'make the code follow the constitution' changes source and is feature. Also 'verify' \u2014 the user wants work that ALREADY EXISTS exercised and its behaviour confirmed with evidence: running a pull request's test scenarios, doing a smoke test of a feature that is already built, producing a test report. Judge by what it PRODUCES: a record of what the software DID is verify; changing what it does is feature or bugfix. 'Run the smoke tests for PR 677 and mark them passed' is verify, and so is 'check that the wizard works end to end'. 'The wizard is broken, fix it' is bugfix. Finally 'undo' \u2014 the user is asking you to REVERSE what the previous turn did, not to do anything new: 'undo that', 'revert your changes', 'go back to the previous version', 'that was wrong, put it back'. Classify by whether the request refers to work already done: undoing is never a rewrite, and asking for a different result ('rewrite it shorter') is not an undo. Also detect the natural language the user wrote in and return its English name as `language` (e.g. 'Turkish', 'English', 'German') \u2014 this is separate from refinedPrompt, which stays English. Also produce `title`: what the work is ABOUT, as a 2-5 word English kebab-case noun phrase suitable for a git branch name \u2014 the SUBJECT, not the action. 'build a luxury todo app' is 'luxury-todo-app'; 'add a login page' is 'login-page'; 'fix the null crash on retry' is 'null-crash-on-retry'. Do not open with a verb (build/add/fix/implement/update): the tool is already doing it, so the verb says nothing and crowds out the words that identify the work. Lowercase, dash-joined, no punctuation. Return the result via submit as {refinedPrompt, intent, language, title}. Remember: refinedPrompt in English, always.",
1078
+ brainstormer: "You run the BRAINSTORM stage: you turn a raw request into a decided design, before anything is specified.\n\nThe `brainstorming` skill above is the authority on HOW to do this \u2014 follow it. What follows is only how it binds to this pipeline, because the skill names conventions from a different habitat:\n\n- OUTPUT: write the design brief to the file named in your message (specs/NNN-slug/brainstorm.md). Ignore the skill's `docs/superpowers/specs/\u2026` path.\n- NEXT STAGE: the SPEC is written from your brief, by another agent, immediately after you. There is no `writing-plans` skill to invoke here \u2014 finishing the brief IS the terminal step.\n- QUESTIONS: ask through the `ask_user` tool. For a choice between approaches use its rich option form ({label, description, preview}) so the trade-offs sit beside the list; lead with your recommendation. The user may attach a note to their answer \u2014 treat it as binding.\n- NOT AVAILABLE: the visual companion (there is no browser) and the per-checklist task list. Skip both.\n\nWrite what was DECIDED, not a transcript: the chosen approach, why it beat the others, the rejected alternatives with their reason, the constraints the spec must honour, and what is out of scope. Keep it short \u2014 it is the brief the spec is written from, not the spec itself, and it carries no implementation detail beyond the architectural choice.\n\nScale to the request: a small, obvious change deserves a paragraph and no questions at all.",
1079
+ coach: "You are horse-code, a terminal-based AI coding agent. Your product identity is always horse-code \u2014 never claim to be Claude Code, Gemini CLI, Antigravity, or any other product, even though the underlying language model powering you may be Claude, Gemini, or another model. Answer the user's technical questions about their repository and code. If needed, inspect the repository with read_file/grep/glob.\n\nWork out loud while you do it. Before a batch of tool calls, say in ONE line what you are looking for and why; when something you read changes your mind, say that too. This is not a summary at the end \u2014 the user is watching an empty screen while you search, and a run that reads thirty files in silence is indistinguishable from one that is stuck, and impossible to redirect before the tokens are spent. Keep each line short: a sentence, not a paragraph.\n\nBe concise, direct, and helpful.",
1080
+ // analyst + planner are spec-kit-driven (their system prompt comes from the fetched spec-kit command
1081
+ // prompts — see src/speckit/phases.ts); they carry no default prompt here, only a model (peekModel).
1082
+ judge: "Synthesize the council evaluations and make a single decision: 'pass' (sufficient), 'revise' (fix it, with reasons), or 'ask-human' (a question to ask the user). Return {decision, feedback, question} via submit.",
1083
+ "project-manager": 'Read the given plan and break it into real, actionable tasks (id, short title, deps). Each task should be a single, clear piece of work. Return {tasks} via submit.\n\nThe `writing-plans` skill above governs WHAT MAKES A TASK EXECUTABLE \u2014 take that from it and nothing else. Two bindings, because the skill describes a different habitat:\n- STRUCTURE comes from the spec-kit tasks template you are given (phases, story grouping, [P] markers), NOT from the skill\'s own document layout. Ignore its `docs/superpowers/plans/\u2026` path, its required-sub-skill header, and its execution-handoff section: this pipeline already owns worktrees, dispatch and review.\n- What you DO take: exact file paths per task, a real test cycle rather than a vague "add tests" step, no placeholders (no TBD/TODO/"similar to task N"), and interfaces named explicitly so a task whose implementer never sees the others still knows the signatures it must produce and consume.\nRight-size the same way the skill does: a task is the smallest unit worth its own test cycle and its own review. Fold setup and scaffolding into the task whose deliverable needs them.',
1084
+ "task-auditor": "You are the last check on a task breakdown before any of it is built. Every hour of implementation after you is spent executing this list, and a bad list does not fail \u2014 the tasks pass their reviews and the wrong work is delivered correctly. Its structure has already been checked mechanically; you are here for the part only a reader can answer: does the breakdown deliver what the plan requires, and would a task's acceptance criteria still hold for an implementation that missed the point? Do not propose better work than the plan asked for \u2014 scope you invent here becomes hours someone spends. Flag any task whose only deliverable is an answer \u2014 verifying, inspecting, confirming \u2014 because an implementer reads the code as part of doing the work, and a task that ends with the repository unchanged has spent a review round on nothing. A clean breakdown is the normal case; say so. Return {missing, weak} via submit.",
1085
+ "team-lead": "You audit a task breakdown before any of it runs. The schedule itself is computed from the declared dependencies and is not yours to write; what nothing has checked is whether those dependencies are RIGHT. You are given the tasks with the files each one writes and what must be true when it is done, plus the groups that would run at the same time in separate worktrees. Find the task that cannot actually start yet because it needs a type, function, table or config key another task in its own group creates \u2014 and say which declared dependencies hold work back for no reason. Both answers are usually empty; say so rather than inventing an edge. Return {missing, spurious} via submit.",
1086
+ router: "Look at the task title and choose the implementer role: 'designer' for UI/UX work, 'coder' for other code work. Return {role} via submit.",
1087
+ coder: "Implement the given task in the worktree. If it is a new task, start from scratch; if it is a returning task, address the reviewer notes. Work with read/write/edit/grep/glob/shell and run the tests.\n\nThe `test-driven-development` skill above is how you write code here: the failing test comes first, and it must fail for the RIGHT reason before you make it pass. A test that asserts nothing is worse than no test \u2014 it reports success forever. Bindings for this pipeline: your worktree is already prepared (do not create one), every file you write is committed as you write it, and there is no separate agent to hand off to \u2014 you take the task to green yourself.",
1088
+ designer: "Implement the UI/UX task in the worktree. Focus on the user interface and experience; work with read/write/edit.\n\nThe `frontend-design` skill above governs the LOOK: aesthetic direction, typography, and choices that do not read as templated defaults. Follow the project's existing visual language where there is one \u2014 a distinctive design that fights the surrounding product is worse than a plain one that fits it.",
1089
+ "senior-coder": "Take over the task the coder got stuck on; implement it with a more careful approach. Take the reviewer notes and previous attempts into account.\n\nYou are here because a previous attempt failed, so start by understanding WHY rather than rewriting: the `systematic-debugging` skill is available (fetch it with the `skill` tool) and is the right tool when a test fails or behaviour is unexplained. The `test-driven-development` skill above still governs how you write the fix \u2014 reproduce the failure in a test first, then make it pass.",
1090
+ "senior-designer": "Take over the UI/UX task the designer got stuck on; implement it more carefully.\n\nA previous attempt already failed, so establish WHY before redesigning \u2014 the `systematic-debugging` skill is available via the `skill` tool when the failure is behavioural rather than visual. The `frontend-design` skill above still governs the look.",
1091
+ architect: "Analyze the root cause of a repeatedly failing task or a merge conflict, and produce a concrete solution plan. Return {rootCause, plan} via submit.\n\nFetch the `systematic-debugging` skill with the `skill` tool and follow it: your job is the ROOT CAUSE, and the failure mode to avoid is proposing a plausible fix for a cause you never established. Say what the evidence is, not what it might be.",
1092
+ "code-reviewer": "Review the worktree changes of the task in REVIEW (correctness, tests, quality). Return {verdict: pass|fail, notes} via submit \u2014 your decision is final.",
1093
+ "principal-coder": "Holistically review all changes in the PR (base worktree). If sufficient, approve; otherwise request-changes with concrete comments. In the final decision round, give accept or ask-human (a question to ask the user).",
1094
+ "memory-keeper": "You are the ONLY writer into this project's long-term memory. Everything else \u2014 every review lens, the council, the judge \u2014 can merely PROPOSE; you decide.\n\nTreat every proposal as an UNVERIFIED CLAIM from a narrow, single-angle agent that saw one slice of one job, not as text to store. Most proposals are wrong in a specific way: they generalize a one-off into a rule, they restate the finding the agent was reviewing, or they record general programming advice any model already knows. Discard all of those. When a claim does survive, REWRITE it in your own words \u2014 never store an agent's sentence verbatim. Merge proposals that say the same thing into one memory.\n\nA memory qualifies ONLY if it is (a) durable \u2014 still true next month, (b) project-specific, and (c) actionable \u2014 it would change what an agent does. Write conventions, constraints, gotchas and root causes. A `lesson` must state what went wrong AND what to do instead. Set `audience` only when the memory is genuinely useful to specific roles and useless to the rest; leave it out otherwise.\n\nNEVER write transient run detail (task ids, attempt counts, what happened today), never restate the request, never duplicate a memory that already exists, and never include credentials, tokens, keys, or anything resembling a secret. Each memory is one self-contained sentence that makes sense with no other context.\n\nReturn at most 5 memories via submit as {memories}. Returning NONE is the most common correct answer \u2014 prefer an empty list over a weak memory, because a bad memory is injected into every future run.",
1095
+ operational: "You handle version control for the project. Given a git diff of work just completed, write a single Conventional Commits message: `type(scope): subject`. Types: feat, fix, docs, refactor, test, chore, style, perf, build, ci. Choose the scope from the touched area (e.g. spec, plan, tasks, or a module name) or omit it. The subject is imperative, lowercase, \u226472 chars, no trailing period. Add a short body only if the change genuinely needs explanation. Commit messages are always in English. Return {message} via submit."
1096
+ };
1097
+ var SPEC_TEAM = [
1098
+ { name: "spec-completeness", perspective: "coverage of the REQUESTED scope: capabilities the user asked for that are missing, or behavior left unspecified", models: [] },
1099
+ { name: "spec-clarity", perspective: "ambiguity: requirements that can be read two ways, vague wording, unresolved NEEDS CLARIFICATION markers", models: [] },
1100
+ { name: "spec-consistency", perspective: "internal contradictions between requirements, acceptance scenarios, and success criteria", models: [] },
1101
+ { name: "spec-scope", perspective: "scope discipline: requirements the user never asked for, gold-plating, scope creep beyond the request", models: [] },
1102
+ { name: "spec-abstraction-leak", perspective: "implementation detail that has leaked into the spec (languages, frameworks, APIs, storage mechanics, code structure) \u2014 a spec must stay technology-agnostic", models: [] },
1103
+ { name: "spec-verifiability", perspective: "are success criteria measurable and technology-agnostic, and can each acceptance scenario be tested without knowing the implementation", models: [] },
1104
+ { name: "spec-user-value", perspective: "do the user stories deliver the value the user actually asked for, and is the priority ordering sensible", models: [] },
1105
+ { name: "spec-domain-model", perspective: "key entities, their attributes and relationships \u2014 coherent and complete at the domain level, with no implementation detail", models: [] },
1106
+ { name: "spec-privacy", perspective: "requirement-level data handling: what data is stored, who may see it, what must never leak or be retained", models: [] }
1107
+ ];
1108
+ var PLAN_TEAM = [
1109
+ { name: "plan-spec-conformance", perspective: "traceability to the approved spec: every requirement covered by the plan, and nothing planned that the spec never asked for", models: [] },
1110
+ { name: "plan-architecture", perspective: "layering, module boundaries, dependency direction, overall structural coherence", models: [] },
1111
+ { name: "plan-data-model", perspective: "schema and entity design, relationships, migrations, integrity constraints", models: [] },
1112
+ { name: "plan-api-contracts", perspective: "interface and contract design, naming, backward compatibility, ergonomics", models: [] },
1113
+ { name: "plan-security", perspective: "threat model, authentication/authorization design, input validation, secret handling, injection surfaces", models: [] },
1114
+ { name: "plan-concurrency", perspective: "race conditions, atomicity, ordering, multi-writer/multi-tab safety, shared-state design", models: [] },
1115
+ { name: "plan-resilience", perspective: "failure modes, error propagation, recovery, retries, partial-failure behavior", models: [] },
1116
+ { name: "plan-performance", perspective: "algorithmic complexity, hot paths, resource bounds, scalability of the chosen design", models: [] },
1117
+ { name: "plan-test-strategy", perspective: "how the design will be proven: seams, dependency injection, contract/integration test layers, what each test actually establishes", models: [] },
1118
+ { name: "plan-simplicity", perspective: "YAGNI: over-engineering, unnecessary abstraction, complexity the requested scope does not justify", models: [] },
1119
+ { name: "plan-dependencies", perspective: "third-party choices, supply-chain risk, versioning, licensing", models: [] },
1120
+ { name: "plan-observability", perspective: "logging, metrics, tracing, debuggability, actionable failure signals", models: [] },
1121
+ { name: "plan-structure", perspective: "project structure: directory/file layout, build setup, adherence to existing repo conventions", models: [] },
1122
+ { name: "plan-feasibility", perspective: "can this be built and maintained as described, in reasonable increments, with the effort the request warrants", models: [] }
1123
+ ];
1124
+ var CODE_TEAM = [
1125
+ { name: "code-plan-conformance", perspective: "does the code implement what the task required \u2014 nothing missing, and no extra scope beyond the task", models: [] },
1126
+ { name: "code-correctness", perspective: "logical correctness, edge cases, off-by-one and boundary conditions, invariants", models: [] },
1127
+ { name: "code-security", perspective: "injection, secret leakage, missing authorization checks, unsafe APIs, unvalidated input", models: [] },
1128
+ { name: "code-error-handling", perspective: "swallowed errors, propagation, cleanup on failure, partial-failure behavior", models: [] },
1129
+ { name: "code-concurrency", perspective: "race conditions, deadlocks, atomicity, shared mutable state", models: [] },
1130
+ { name: "code-tests", perspective: "is the new behavior covered, and do the tests actually assert something meaningful (no vacuous tests)", models: [] },
1131
+ { name: "code-data-integrity", perspective: "persistence correctness, transactions, validation at boundaries, migration safety", models: [] },
1132
+ { name: "code-performance", perspective: "hot paths, unnecessary allocation/work, N+1 patterns, obvious inefficiency", models: [] },
1133
+ { name: "code-maintainability", perspective: "naming, structure, complexity, readability, future tech-debt", models: [] },
1134
+ { name: "code-simplicity", perspective: "dead code, duplication, unnecessary abstraction, complexity the task does not justify", models: [] },
1135
+ { name: "code-api-surface", perspective: "public interface shape, backward compatibility, accidental API exposure", models: [] },
1136
+ { name: "code-accessibility", perspective: "accessibility of UI code: keyboard operation, ARIA/semantics, contrast, i18n readiness", models: [] },
1137
+ { name: "code-observability", perspective: "logging/metrics where a failure would otherwise be undiagnosable", models: [] },
1138
+ { name: "code-dependencies", perspective: "newly introduced dependencies: justified, correctly versioned, no supply-chain or licensing problem", models: [] },
1139
+ { name: "code-conventions", perspective: "consistency with the surrounding codebase's idioms, patterns, and style", models: [] }
1140
+ ];
1141
+ var DEFAULT_COUNCIL = [
1142
+ { name: "correctness-judge", perspective: "Is the work under review correct, coherent and internally consistent? Weigh the team's correctness/logic/data findings.", models: [] },
1143
+ { name: "risk-judge", perspective: "What is the real blast radius of shipping this as-is? Weigh security, failure modes, concurrency, and data-integrity findings against likelihood and severity.", models: [] },
1144
+ { name: "completeness-judge", perspective: "Is what was asked for fully and unambiguously covered? Weigh the team's completeness, gap, and contract findings.", models: [] },
1145
+ { name: "user-value-judge", perspective: "Does this deliver the user's actual intent well? Weigh usability, accessibility, and whether the scope serves the request without gold-plating.", models: [] },
1146
+ { name: "feasibility-judge", perspective: "Can this be built and maintained as described? Weigh architecture, simplicity, dependencies, and maintainability findings against effort.", models: [] }
1147
+ ];
1148
+ function placedSkills() {
1149
+ return [...new Set(Object.values(DEFAULT_ROLE_SKILLS).flat())];
1150
+ }
1151
+
1152
+ // src/speckit/layout.ts
1153
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync4 } from "fs";
1154
+ import { join as join4 } from "path";
1155
+ function specsDir(workdir) {
1156
+ return join4(workdir, "specs");
1157
+ }
1158
+ function constitutionPath(workdir) {
1159
+ return join4(workdir, ".specify", "memory", "constitution.md");
1160
+ }
1161
+ function featurePaths(workdir, slug) {
1162
+ const dir = join4(specsDir(workdir), slug);
1163
+ return { dir, brainstorm: join4(dir, "brainstorm.md"), spec: join4(dir, "spec.md"), plan: join4(dir, "plan.md"), tasks: join4(dir, "tasks.md") };
1164
+ }
1165
+ function verifyPaths(workdir, slug) {
1166
+ const dir = join4(specsDir(workdir), slug);
1167
+ return { dir, plan: join4(dir, "test-plan.md"), report: join4(dir, "test-report.md") };
1168
+ }
1169
+ var ACTIVITY_WORDS = /* @__PURE__ */ new Set([
1170
+ "test",
1171
+ "tests",
1172
+ "testing",
1173
+ "smoke",
1174
+ "e2e",
1175
+ "verify",
1176
+ "verification",
1177
+ "verifying",
1178
+ "check",
1179
+ "checking",
1180
+ "run",
1181
+ "running",
1182
+ "continue",
1183
+ "continuing",
1184
+ "report",
1185
+ "session",
1186
+ "the",
1187
+ "for",
1188
+ "of",
1189
+ "a",
1190
+ "an",
1191
+ "and"
1192
+ ]);
1193
+ var subjectWords = (slug) => new Set(slug.split("-").filter((w) => w.length > 2 && !ACTIVITY_WORDS.has(w)));
1194
+ function featureSlugFor(workdir, title) {
1195
+ const want = toSlug(title);
1196
+ const dir = specsDir(workdir);
1197
+ if (existsSync4(dir)) {
1198
+ const names = readdirSync4(dir);
1199
+ for (const name of names) {
1200
+ if (name.replace(/^\d+-/, "") === want) return name;
1201
+ }
1202
+ const wanted = subjectWords(want);
1203
+ let best;
1204
+ for (const name of names) {
1205
+ const shared = [...subjectWords(name.replace(/^\d+-/, ""))].filter((w) => wanted.has(w)).length;
1206
+ if (shared >= 2 && (!best || shared > best.shared)) best = { name, shared };
1207
+ }
1208
+ if (best) return best.name;
1209
+ }
1210
+ return nextFeatureSlug(workdir, title);
1211
+ }
1212
+ function nextFeatureSlug(workdir, title) {
1213
+ const dir = specsDir(workdir);
1214
+ let max = 0;
1215
+ if (existsSync4(dir)) {
1216
+ for (const name of readdirSync4(dir)) {
1217
+ const m = name.match(/^(\d+)-/);
1218
+ if (m) max = Math.max(max, Number(m[1]));
1219
+ }
1220
+ }
1221
+ return `${String(max + 1).padStart(3, "0")}-${toSlug(title)}`;
1222
+ }
1223
+ function scaffoldFeature(workdir, slug) {
1224
+ const paths = featurePaths(workdir, slug);
1225
+ mkdirSync(paths.dir, { recursive: true });
1226
+ mkdirSync(join4(workdir, ".specify", "memory"), { recursive: true });
1227
+ return paths;
1228
+ }
1229
+
1230
+ // src/engine/constitution-store.ts
1231
+ import { createHash } from "crypto";
1232
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
1233
+ import { join as join5 } from "path";
1234
+ import { z as z4 } from "zod";
1235
+
1236
+ // src/engine/constitution.ts
1237
+ var SCOPES = ["always", "backend", "frontend", "data", "infra", "docs", "review", "spec", "test", "govern"];
1238
+ var RATIONALE = /^\s*\*(?:Gerekçe|Rationale):\*/i;
1239
+ function sectionOf(heading) {
1240
+ const m = /^([IVXLC]+)\.\s/.exec(heading);
1241
+ return m ? m[1] : heading;
1242
+ }
1243
+ function parseConstitution(text) {
1244
+ const out = [];
1245
+ let heading = "";
1246
+ let buf = [];
1247
+ const flush = () => {
1248
+ const block = buf.join("\n").trim();
1249
+ buf = [];
1250
+ if (!block || !heading || RATIONALE.test(block)) return;
1251
+ out.push({ section: sectionOf(heading), heading, text: block });
1252
+ };
1253
+ for (const line of text.split("\n")) {
1254
+ const h = /^#{2,3}\s+(.*)$/.exec(line);
1255
+ if (h) {
1256
+ flush();
1257
+ heading = /^core principles$/i.test(h[1].trim()) ? "" : h[1].trim();
1258
+ continue;
1259
+ }
1260
+ if (!line.trim()) {
1261
+ flush();
1262
+ continue;
1263
+ }
1264
+ buf.push(line);
1265
+ }
1266
+ flush();
1267
+ return out;
1268
+ }
1269
+ var BY_EXT = [
1270
+ [/\.(cs|java|kt|go|py|rb|php|scala|rs)$/i, "backend"],
1271
+ [/\.(ts|tsx|js|jsx|html|css|scss|sass|less|vue|svelte)$/i, "frontend"],
1272
+ [/\.(sql)$|(^|\/)migrations?\//i, "data"],
1273
+ [/\.(tf|tfvars)$|(^|\/)(k8s|helm|charts|deploy|infra)\//i, "infra"],
1274
+ [/\.(md|mdx|adoc)$/i, "docs"]
1275
+ ];
1276
+ var BY_ROLE = {
1277
+ "code-reviewer": ["review"],
1278
+ "principal-coder": ["review"],
1279
+ "senior-coder": ["review"],
1280
+ analyst: ["spec"],
1281
+ planner: ["spec"],
1282
+ brainstormer: ["spec"],
1283
+ tester: ["test"]
1284
+ };
1285
+ function scopesForWork(opts) {
1286
+ const out = /* @__PURE__ */ new Set(["always"]);
1287
+ for (const s of BY_ROLE[opts.role ?? ""] ?? []) out.add(s);
1288
+ for (const f of opts.files ?? []) for (const [re, scope] of BY_EXT) if (re.test(f)) out.add(scope);
1289
+ return out;
1290
+ }
1291
+ var MAX_CONSTITUTION_CHARS = 2e4;
1292
+ function selectRules(rules, scopes, max = MAX_CONSTITUTION_CHARS) {
1293
+ const applies = (r) => r.scopes.some((s) => scopes.has(s));
1294
+ const always = rules.filter((r) => r.scopes.includes("always"));
1295
+ const scoped = rules.filter((r) => !r.scopes.includes("always") && applies(r));
1296
+ const used = [...always];
1297
+ const dropped = [];
1298
+ let size = used.reduce((n, r) => n + r.text.length, 0);
1299
+ for (const r of scoped) {
1300
+ if (size + r.text.length > max) {
1301
+ dropped.push(r);
1302
+ continue;
1303
+ }
1304
+ used.push(r);
1305
+ size += r.text.length;
1306
+ }
1307
+ return { text: render(used, dropped), used, dropped };
1308
+ }
1309
+ function render(used, dropped) {
1310
+ if (!used.length) return "";
1311
+ const byHeading = /* @__PURE__ */ new Map();
1312
+ for (const r of used) byHeading.set(r.heading, [...byHeading.get(r.heading) ?? [], r.text]);
1313
+ const body = [...byHeading].map(([h, texts]) => `## ${h}
1314
+ ${texts.join("\n\n")}`).join("\n\n");
1315
+ const cut = dropped.length ? `
1316
+
1317
+ (${dropped.length} further section(s) of the constitution apply to this work but did not fit: ${[...new Set(dropped.map((d) => d.section))].join(", ")}. Read them in the constitution if this change goes near them.)` : "";
1318
+ return `
1319
+
1320
+ # Project constitution \u2014 the rules that bind THIS work
1321
+
1322
+ These are binding, and they are the project's own words. Where they and anything else disagree, they win. The full document is at \`.specify/memory/constitution.md\`.
1323
+
1324
+ ${body}${cut}`;
1325
+ }
1326
+ var CLASSIFY_PROMPT = `You are labelling the rules of a software project's constitution so each one can be handed to the agents it actually binds.
1327
+
1328
+ For each rule, answer with the scopes it is ABOUT, from exactly this list: ${SCOPES.join(", ")}.
1329
+
1330
+ - \`backend\`, \`frontend\`, \`data\`, \`infra\`: it names code, files or tooling of that kind \u2014 a language, a framework, a database, a deployment target.
1331
+ - \`spec\`: it constrains what a specification or plan may say (the stack, the boundaries, the vocabulary).
1332
+ - \`review\`: it is a gate a reviewer applies \u2014 what blocks a merge, how findings are reported.
1333
+ - \`test\`: it is about verification and evidence.
1334
+ - \`govern\`: it is about amending the constitution itself, and binds nobody else.
1335
+ - \`always\`: it names NO particular kind of code. It is about how to work, how to talk to the user, or what may never be done \u2014 so it binds every role on every task.
1336
+
1337
+ A rule may carry several scopes. \`always\` is a real answer, not a safe one: use it when the rule genuinely mentions no kind of code, and NOT because you are unsure. If a rule is about backend code, say \`backend\` \u2014 labelling it \`always\` sends it to everyone writing CSS.
1338
+
1339
+ Answer for EVERY index you are given, and for no others. Do not translate, summarise or rewrite anything \u2014 you are only labelling.
1340
+
1341
+ Return {labels: [{index, scopes}]} via submit, one entry per rule, in the order given.`;
1342
+ var CLASSIFY_BATCH = 20;
1343
+ var CLASSIFY_RETRIES = 2;
1344
+ function classifyMessage(rules, offset = 0) {
1345
+ return rules.map((r, i) => `--- ${i + offset} --- (${r.heading})
1346
+ ${r.text}`).join("\n\n");
1347
+ }
1348
+ function applyLabels(rules, labels) {
1349
+ const known = new Set(SCOPES);
1350
+ const byIndex = new Map(labels.map((l) => [l.index, l.scopes.filter((s) => known.has(s))]));
1351
+ const unlabelled = [];
1352
+ const scoped = rules.map((r, i) => {
1353
+ const scopes = byIndex.get(i) ?? [];
1354
+ if (!scopes.length) unlabelled.push(i);
1355
+ return { ...r, scopes: scopes.length ? scopes : ["always"] };
1356
+ });
1357
+ return { scoped, unlabelled };
1358
+ }
1359
+ var MAX_ALWAYS_SHARE = 0.35;
1360
+ function labellingLooksWrong(scoped) {
1361
+ if (!scoped.length) return void 0;
1362
+ const always = scoped.filter((r) => r.scopes.includes("always")).length;
1363
+ if (always > scoped.length * MAX_ALWAYS_SHARE) {
1364
+ return `${always} of ${scoped.length} rules came back as \`always\``;
1365
+ }
1366
+ return void 0;
1367
+ }
1368
+
1369
+ // src/engine/constitution-store.ts
1370
+ var LabelsSchema = z4.object({
1371
+ labels: z4.array(z4.object({
1372
+ index: z4.number().int().describe("The rule's number, exactly as given to you."),
1373
+ scopes: z4.array(z4.enum(SCOPES)).describe(
1374
+ `Which kinds of work this rule actually binds. Only the ones it really governs: a rule that reaches everyone is carried into every agent's prompt, so a scope added "to be safe" is paid for on every call that will never use it.`
1375
+ )
1376
+ }))
1377
+ });
1378
+ var CLASSIFY_MAX_TURNS = 3;
1379
+ function cachePath(home, text) {
1380
+ const hash = createHash("sha256").update(text).digest("hex").slice(0, 16);
1381
+ return join5(home, ".horsecode", "constitution", `${hash}.json`);
1382
+ }
1383
+ var memo = /* @__PURE__ */ new Map();
1384
+ async function scopedConstitution(deps, cwd) {
1385
+ const path = constitutionPath(cwd);
1386
+ if (!existsSync5(path)) return [];
1387
+ let text;
1388
+ try {
1389
+ text = readFileSync2(path, "utf8");
1390
+ } catch {
1391
+ return [];
1392
+ }
1393
+ const cache = cachePath(deps.home, text);
1394
+ const hit = memo.get(cache);
1395
+ if (hit) return hit;
1396
+ if (existsSync5(cache)) {
1397
+ try {
1398
+ const saved = JSON.parse(readFileSync2(cache, "utf8"));
1399
+ memo.set(cache, saved);
1400
+ return saved;
1401
+ } catch {
1402
+ }
1403
+ }
1404
+ const rules = parseConstitution(text);
1405
+ if (!rules.length) return [];
1406
+ deps.note?.(`\u{1F4DC} Reading the project constitution \u2014 ${rules.length} rules, labelled once so each reaches the work it binds.`);
1407
+ const labels = [];
1408
+ const resolved = deps.roleRegistry.resolve("judge");
1409
+ let failed = 0;
1410
+ for (let start = 0; start < rules.length; start += CLASSIFY_BATCH) {
1411
+ const batch = rules.slice(start, start + CLASSIFY_BATCH);
1412
+ for (let attempt = 0; attempt <= CLASSIFY_RETRIES; attempt++) {
1413
+ try {
1414
+ const out = await runStructuredRole({
1415
+ provider: deps.provider,
1416
+ ...resolved,
1417
+ systemPrompt: CLASSIFY_PROMPT,
1418
+ tools: new ToolRegistry(),
1419
+ messages: [{ role: "user", content: classifyMessage(batch, start) }],
1420
+ permission: deps.permission,
1421
+ approve: deps.approve,
1422
+ cwd,
1423
+ signal: deps.signal,
1424
+ maxTurns: CLASSIFY_MAX_TURNS
1425
+ }, LabelsSchema);
1426
+ labels.push(...out.labels);
1427
+ break;
1428
+ } catch {
1429
+ if (attempt === CLASSIFY_RETRIES) failed++;
1430
+ }
1431
+ }
1432
+ }
1433
+ const { scoped, unlabelled } = applyLabels(rules, labels);
1434
+ if (unlabelled.length) {
1435
+ deps.note?.(`\u26A0\uFE0F ${unlabelled.length} of ${rules.length} constitution rules could not be labelled \u2014 those go to every role. A rule sent too widely is noise; one sent nowhere is not a rule.`);
1436
+ }
1437
+ const wrong = labellingLooksWrong(scoped);
1438
+ if (failed || wrong) {
1439
+ deps.note?.(`\u26A0\uFE0F The constitution labelling is not trustworthy \u2014 ${failed ? `${failed} batch(es) failed` : wrong}. Not caching it, so the next session tries again rather than inheriting it.`);
1440
+ } else {
1441
+ try {
1442
+ mkdirSync2(join5(deps.home, ".horsecode", "constitution"), { recursive: true });
1443
+ writeFileSync(cache, JSON.stringify(scoped), "utf8");
1444
+ } catch {
1445
+ }
1446
+ }
1447
+ memo.set(cache, scoped);
1448
+ return scoped;
1449
+ }
1450
+ async function constitutionNote(deps, cwd, work) {
1451
+ const scoped = await scopedConstitution(deps, cwd);
1452
+ if (!scoped.length) return "";
1453
+ const sel = selectRules(scoped, scopesForWork(work));
1454
+ if (sel.dropped.length) {
1455
+ deps.note?.(`\u{1F4DC} ${sel.dropped.length} constitution section(s) that apply here did not fit the prompt: ${[...new Set(sel.dropped.map((d) => d.section))].join(", ")}.`);
1456
+ }
1457
+ return sel.text;
1458
+ }
1459
+
1460
+ // src/engine/reviewer.ts
1461
+ import { z as z8 } from "zod";
1462
+
1463
+ // src/tools/find-tool.ts
1464
+ import { z as z5 } from "zod";
1465
+ var params3 = z5.object({
1466
+ query: z5.string().describe(
1467
+ 'What you need a tool for, in a few words \u2014 e.g. "pull request comments", "list angular projects". Or an exact tool name to fetch just that one.'
1468
+ )
1469
+ });
1470
+ var MAX_FOUND = 5;
1471
+ var NOISE = /* @__PURE__ */ new Set([
1472
+ "the",
1473
+ "a",
1474
+ "an",
1475
+ "of",
1476
+ "for",
1477
+ "in",
1478
+ "on",
1479
+ "to",
1480
+ "and",
1481
+ "or",
1482
+ "with",
1483
+ "by",
1484
+ "from",
1485
+ "get",
1486
+ "list",
1487
+ "tool",
1488
+ "project",
1489
+ "use",
1490
+ "this",
1491
+ "that",
1492
+ "it",
1493
+ "is",
1494
+ "are",
1495
+ "be",
1496
+ "mcp"
1497
+ ]);
1498
+ var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !NOISE.has(w));
1499
+ function scoreTool(t, query) {
1500
+ const q = words(query);
1501
+ if (!q.length) return 0;
1502
+ const name = t.name.toLowerCase();
1503
+ const desc = t.description.toLowerCase();
1504
+ let score = 0;
1505
+ for (const w of q) {
1506
+ if (name.includes(w)) score += 3;
1507
+ else if (desc.includes(w)) score += 1;
1508
+ }
1509
+ if (name === query.toLowerCase().trim()) score += 100;
1510
+ return score;
1511
+ }
1512
+ function buildFindToolTool(registry) {
1513
+ return {
1514
+ name: "find_tool",
1515
+ description: 'Fetches the full definition of a project tool so you can call it. The system prompt lists the tools this project connects, by name and one line each; their parameters are not loaded until you ask. Pass what you need \u2014 "pull request threads", "run a build pipeline" \u2014 or an exact tool name. The matches become callable from your NEXT message, so call this first, then call the tool itself. If a search returns nothing useful, do the job with the tools you already have rather than searching again with different words.',
1516
+ permissionLevel: "safe",
1517
+ parameters: params3,
1518
+ run: async (rawArgs) => {
1519
+ const parsed = params3.safeParse(rawArgs);
1520
+ if (!parsed.success) {
1521
+ return { content: `find_tool: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1522
+ }
1523
+ const { query } = parsed.data;
1524
+ const pool = registry.deferredTools().filter((t) => t.broken === void 0);
1525
+ if (!pool.length) {
1526
+ return {
1527
+ content: "Every tool this project connects is already loaded \u2014 there is nothing further to fetch. Use the ones you have.",
1528
+ isError: false
1529
+ };
1530
+ }
1531
+ const scored = pool.map((t) => ({ t, s: scoreTool(t, query) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
1532
+ const exact = scored.find((x) => x.t.name.toLowerCase() === query.toLowerCase().trim());
1533
+ const hits = exact ? [exact] : scored.slice(0, MAX_FOUND);
1534
+ if (!hits.length) {
1535
+ return {
1536
+ content: `No project tool matches "${query}". Available to fetch: ${pool.map((t) => t.name).join(", ")}.`,
1537
+ isError: false
1538
+ };
1539
+ }
1540
+ registry.surface(hits.map((x) => x.t.name));
1541
+ const rows = hits.map((x) => `- \`${x.t.name}\` \u2014 ${x.t.description.replace(/^\[MCP:[^\]]*\]\s*/, "").split(/\n/)[0].trim()}`);
1542
+ return {
1543
+ content: `Loaded ${hits.length} tool(s) \u2014 you can call them from your next message:
1544
+ ${rows.join("\n")}`,
1545
+ isError: false
1546
+ };
1547
+ }
1548
+ };
1549
+ }
1550
+
1551
+ // src/tools/unfinished-tool.ts
1552
+ import { execFileSync } from "child_process";
1553
+ import { join as join6 } from "path";
1554
+ import { z as z6 } from "zod";
1555
+ var params4 = z6.object({});
1556
+ function commitsAhead(cwd, branch) {
1557
+ try {
1558
+ const out = execFileSync(
1559
+ "git",
1560
+ ["rev-list", "--count", `HEAD..${branch}`],
1561
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1562
+ );
1563
+ return Number(out.trim()) || 0;
1564
+ } catch {
1565
+ return 0;
1566
+ }
1567
+ }
1568
+ var findUnfinishedTool = {
1569
+ name: "find_unfinished",
1570
+ description: `Lists work a previous run left behind in this project, newest first: what the user originally asked, which pipeline phases finished, how many board tasks are done, how many commits sit on the session's branch, and the absolute path of its worktree. Call it whenever the user refers to earlier work \u2014 "continue", "where were we", "what was I doing" \u2014 before answering from the repository, because a session's work is NOT in the checkout you are standing in: it is on its own branch in its own worktree. Read files under the worktree path to see the spec, plan or board it produced.`,
1571
+ permissionLevel: "safe",
1572
+ parameters: params4,
1573
+ describe: () => ({ allowKey: "find_unfinished", preview: "find unfinished work" }),
1574
+ run: async (_args, ctx) => {
1575
+ const found = unfinishedSessions(ctx.cwd, (b) => commitsAhead(ctx.cwd, b));
1576
+ if (!found.length) {
1577
+ return {
1578
+ content: "No unfinished session in this project: every worktree has either been cleaned up or never recorded a checkpoint. Anything earlier is in the repository's own history.",
1579
+ isError: false
1580
+ };
1581
+ }
1582
+ const rows = found.map((s) => {
1583
+ const c = s.checkpoint;
1584
+ return [
1585
+ `## ${s.id}`,
1586
+ `- The user asked: "${c.rawPrompt.trim() || c.title}"`,
1587
+ `- Understood as: ${c.refinedPrompt}`,
1588
+ `- Phases finished: ${c.done.length ? c.done.join(" \u2192 ") : "none"}${c.lane ? ` (lane: ${c.lane})` : ""}`,
1589
+ s.cards.total ? `- Tasks: ${s.cards.done} of ${s.cards.total} finished` : "- No task board yet",
1590
+ `- Branch \`hc/${s.id}/base\` has ${s.commits} commit(s) the base does not`,
1591
+ `- Worktree: ${join6(ctx.cwd, ".horsecode", "worktrees", s.id, "base")}`
1592
+ ].join("\n");
1593
+ });
1594
+ return {
1595
+ content: `${rows.join("\n\n")}
1596
+
1597
+ To continue one of these, the user says **continue** \u2014 that reopens the session and its lane. You can read anything under the worktree path above to answer questions about it now.`,
1598
+ isError: false
1599
+ };
1600
+ }
1601
+ };
1602
+
1603
+ // src/tools/propose-memory.ts
1604
+ import { z as z7 } from "zod";
1605
+ var params5 = z7.object({
1606
+ text: z7.string(),
1607
+ kind: z7.enum(["fact", "lesson"]).optional().describe(
1608
+ "`fact`: something true about this project that a later run would otherwise have to rediscover (where something lives, which command builds it). `lesson`: something learned the hard way \u2014 an approach that failed and what to do instead."
1609
+ )
1610
+ });
1611
+ var proposeMemoryTool = {
1612
+ name: "propose_memory",
1613
+ description: "Propose something you learned about THIS PROJECT for long-term memory. It is NOT stored directly \u2014 a curator reviews, rewrites and may discard it. Propose ONLY durable, project-specific knowledge that would still be true and useful months from now in an unrelated task: a convention, a constraint, a non-obvious gotcha, the root cause of a recurring problem. NEVER propose your findings about the work you are reviewing right now, anything about this specific task or run, or general programming advice. Most reviews should propose nothing at all. Use it at most once, and only when you are sure.",
1614
+ permissionLevel: "safe",
1615
+ parameters: params5,
1616
+ async run(rawArgs, ctx) {
1617
+ const parsed = params5.safeParse(rawArgs);
1618
+ if (!parsed.success) return { content: "propose_memory: invalid args (expected { text, kind? })", isError: true };
1619
+ const text = parsed.data.text.trim();
1620
+ if (!text) return { content: "propose_memory: empty proposal", isError: true };
1621
+ if (!ctx.proposeMemory) return { content: "propose_memory: memory is not available in this context", isError: true };
1622
+ const accepted = ctx.proposeMemory(text, parsed.data.kind ?? "fact");
1623
+ return {
1624
+ content: accepted ? "Proposal queued for the memory curator. It may be rewritten or discarded; do not propose it again." : "Already proposed (or the queue is full) \u2014 no action needed.",
1625
+ isError: false
1626
+ };
1627
+ }
1628
+ };
1629
+
1630
+ // src/skills/route.ts
1631
+ var STOP = /* @__PURE__ */ new Set([
1632
+ "use",
1633
+ "when",
1634
+ "the",
1635
+ "user",
1636
+ "wants",
1637
+ "and",
1638
+ "for",
1639
+ "with",
1640
+ "that",
1641
+ "this",
1642
+ "from",
1643
+ "into",
1644
+ "also",
1645
+ "not",
1646
+ "other",
1647
+ "otherwise",
1648
+ "improve",
1649
+ "covers",
1650
+ "handles",
1651
+ "should",
1652
+ "become",
1653
+ "than",
1654
+ "over",
1655
+ "onto",
1656
+ "your",
1657
+ "you",
1658
+ "are",
1659
+ "any",
1660
+ "all",
1661
+ "its",
1662
+ "their",
1663
+ "them",
1664
+ "they",
1665
+ "have",
1666
+ "has",
1667
+ "was",
1668
+ "were",
1669
+ "will",
1670
+ "would",
1671
+ "can",
1672
+ "may",
1673
+ "must",
1674
+ "such",
1675
+ "more",
1676
+ "most",
1677
+ "less",
1678
+ "very",
1679
+ "just",
1680
+ "only",
1681
+ "some",
1682
+ "each",
1683
+ "every",
1684
+ "make",
1685
+ "made",
1686
+ "making",
1687
+ "need",
1688
+ "needs",
1689
+ "needed",
1690
+ "want",
1691
+ "wanted",
1692
+ "work",
1693
+ "works",
1694
+ "working",
1695
+ "task",
1696
+ "tasks",
1697
+ "using",
1698
+ "used",
1699
+ "uses",
1700
+ "via",
1701
+ "per",
1702
+ "out",
1703
+ "off",
1704
+ "about",
1705
+ "after",
1706
+ "before",
1707
+ "then",
1708
+ "there",
1709
+ "where",
1710
+ "which",
1711
+ "while",
1712
+ "what",
1713
+ "who",
1714
+ "how",
1715
+ "why",
1716
+ "does",
1717
+ "did",
1718
+ "done",
1719
+ "get",
1720
+ "got",
1721
+ "let",
1722
+ "lets"
1723
+ ]);
1724
+ var MIN_TERM = 3;
1725
+ var SHORT_TERMS = /* @__PURE__ */ new Set(["ui", "ux", "db", "js", "ts", "qa"]);
1726
+ function splitIdentifiers(text) {
1727
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[_./-]+/g, " ");
1728
+ }
1729
+ function terms(text) {
1730
+ return (splitIdentifiers(text).toLowerCase().match(/[a-z][a-z0-9]+/g) ?? []).filter((t) => (t.length >= MIN_TERM || SHORT_TERMS.has(t)) && !STOP.has(t));
1731
+ }
1732
+ var MIN_SHARED = 4;
1733
+ function sameWord(a, b) {
1734
+ if (a === b) return true;
1735
+ const fold = (w) => w.endsWith("e") ? w.slice(0, -1) : w;
1736
+ const [x, y] = [fold(a), fold(b)];
1737
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x];
1738
+ return short.length >= MIN_SHARED && long.startsWith(short);
1739
+ }
1740
+ function exclusions(description) {
1741
+ const m = /\bnot\s+for\s+([^.]+)/i.exec(description);
1742
+ return m ? terms(m[1]) : [];
1743
+ }
1744
+ function isExplicitOnly(description) {
1745
+ return /\bonly\s+(runs?|use[sd]?|invoke[sd]?)\b[^.]*\bexplicit/i.test(description) || /\bdoes\s+not\s+trigger\s+on\s+its\s+own\b/i.test(description) || /\bonly\s+when\s+explicitly\s+(invoked|asked|requested)\b/i.test(description);
1746
+ }
1747
+ function isNonImplementing(description) {
1748
+ return /\bread[- ]only\b/i.test(description) || /\bdoes\s+not\s+(implement|apply|execute|write)\b/i.test(description);
1749
+ }
1750
+ var MATCH_BAR = 3;
1751
+ var MAX_ROUTED = 3;
1752
+ var MAX_ROUTED_CHARS = 24e3;
1753
+ function scoreSkill(task, description) {
1754
+ const taskTerms = new Set(terms(task));
1755
+ if (!taskTerms.size) return { score: 0, hits: [], density: 0 };
1756
+ const task_ = [...taskTerms];
1757
+ const excluded2 = exclusions(description);
1758
+ if (excluded2.some((e) => task_.some((t) => sameWord(t, e)))) return { score: 0, hits: [], density: 0 };
1759
+ const body = description.replace(/\bnot\s+for\s+[^.]+/i, "");
1760
+ const vocab = [...new Set(terms(body))];
1761
+ const hits = vocab.filter((d) => task_.some((t) => sameWord(t, d)));
1762
+ return { score: hits.length, hits, density: vocab.length ? hits.length / vocab.length : 0 };
1763
+ }
1764
+ function routeSkills(task, registry, already = [], opts = {}) {
1765
+ const bar = opts.bar ?? MATCH_BAR;
1766
+ let spent = 0;
1767
+ const have = new Set(already);
1768
+ const placed = new Set(opts.placed ?? []);
1769
+ const paths = (opts.files ?? []).join(" ");
1770
+ const base = [opts.role ?? "", routingSubject(task), paths].filter(Boolean).join(" ");
1771
+ const subject = [base, expandExtensions(opts.files ?? []), expandAbbreviations(base)].filter(Boolean).join(" ");
1772
+ return registry.list().filter((s) => !have.has(s.name)).filter((s) => !placed.has(s.name)).filter((s) => !isExplicitOnly(s.description)).filter((s) => !(opts.implementing && isNonImplementing(s.description))).map((s) => ({ name: s.name, ...scoreSkill(subject, s.description) })).filter((m) => m.score >= bar).sort((a, b) => b.score - a.score || b.density - a.density || a.name.localeCompare(b.name)).slice(0, opts.max ?? MAX_ROUTED).filter((m) => {
1773
+ const len = registry.get(m.name)?.content.length ?? 0;
1774
+ if (spent + len > MAX_ROUTED_CHARS) return false;
1775
+ spent += len;
1776
+ return true;
1777
+ });
1778
+ }
1779
+ function routingSubject(task) {
1780
+ return task.replace(/\S*[\\/]\.horsecode[\\/]pastes[\\/]\S+/g, " ").replace(/\s{2,}/g, " ").trim();
1781
+ }
1782
+ var EXT_WORDS = {
1783
+ tsx: "frontend interface component web",
1784
+ jsx: "frontend interface component web",
1785
+ vue: "frontend interface component web",
1786
+ svelte: "frontend interface component web",
1787
+ css: "frontend interface styling web",
1788
+ scss: "frontend interface styling web",
1789
+ html: "frontend interface web",
1790
+ sql: "database migration",
1791
+ proto: "protocol schema"
1792
+ };
1793
+ var ABBREVIATIONS = {
1794
+ a11y: "accessibility",
1795
+ i18n: "internationalization localization",
1796
+ l10n: "localization",
1797
+ auth: "authentication",
1798
+ ui: "interface",
1799
+ ux: "interface experience"
1800
+ };
1801
+ function expandAbbreviations(text) {
1802
+ const out = [];
1803
+ for (const t of terms(text)) if (ABBREVIATIONS[t]) out.push(ABBREVIATIONS[t]);
1804
+ return out.join(" ");
1805
+ }
1806
+ function expandExtensions(files) {
1807
+ const words3 = /* @__PURE__ */ new Set();
1808
+ for (const f of files) {
1809
+ const ext2 = f.split(".").pop()?.toLowerCase();
1810
+ if (ext2 && EXT_WORDS[ext2]) for (const w of EXT_WORDS[ext2].split(" ")) words3.add(w);
1811
+ }
1812
+ return [...words3].join(" ");
1813
+ }
1814
+ var CONFIDENT_MARGIN = 4;
1815
+ function partitionByConfidence(matches, bar = MATCH_BAR, margin = CONFIDENT_MARGIN) {
1816
+ const confident = [];
1817
+ const borderline = [];
1818
+ for (const m of matches) (m.score >= bar + margin ? confident : borderline).push(m);
1819
+ return { confident, borderline };
1820
+ }
1821
+
1822
+ // src/engine/task-diff.ts
1823
+ var MAX_DIFF_CHARS2 = 6e4;
1824
+ async function taskDiff(cwd, baseRef, git = defaultGitRunner) {
1825
+ const out = await git(["diff", `${baseRef}...HEAD`, "--", ".", ...excludeOwnState()], cwd);
1826
+ if (out.code !== 0) return "";
1827
+ const diff = out.stdout;
1828
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1829
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1830
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1831
+ }
1832
+ function describeDiff(diff) {
1833
+ if (!diff.trim()) {
1834
+ return "The diff for this task could not be produced. Inspect the worktree with read_file/grep instead.";
1835
+ }
1836
+ return `The complete diff of this task's changes follows. It is the subject of the review \u2014 read it first, and open a file only when the diff alone cannot answer a question.
1837
+
1838
+ \`\`\`diff
1839
+ ${diff}
1840
+ \`\`\``;
1841
+ }
1842
+ async function workingTreeDiff(cwd, git = defaultGitRunner) {
1843
+ const out = await git(["diff", "HEAD", "--", ".", ...excludeOwnState()], cwd);
1844
+ if (out.code !== 0) return "";
1845
+ const diff = out.stdout;
1846
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1847
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1848
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1849
+ }
1850
+ async function diffSince(cwd, sinceRef, git = defaultGitRunner) {
1851
+ const out = await git(["diff", sinceRef, "--", ".", ...excludeOwnState()], cwd);
1852
+ if (out.code !== 0) return "";
1853
+ const diff = out.stdout;
1854
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1855
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1856
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1857
+ }
1858
+
1859
+ // src/engine/reviewer.ts
1860
+ var CODE_REVIEW_MAX_TURNS = 25;
1861
+ var CODE_REVIEW_TIMEOUT_MS = 5 * 60 * 1e3;
1862
+ var VerdictSchema = z8.object({
1863
+ verdict: z8.enum(["pass", "fail"]).describe(
1864
+ "`fail` only if the code does not do what the task required, or does it wrongly. Style you would have written differently is a note on a `pass` \u2014 a fail sends the task back around the whole cycle."
1865
+ ),
1866
+ notes: z8.array(z8.string())
1867
+ });
1868
+ function readOnlyRegistry(deps, opts = {}) {
1869
+ const r = new ToolRegistry();
1870
+ r.register(readFileTool);
1871
+ r.register(grepTool);
1872
+ r.register(globTool);
1873
+ r.register(gitTool);
1874
+ r.register(buildSkillTool(deps.skillRegistry));
1875
+ for (const t of contextTools(deps)) r.register(t);
1876
+ if (opts.remember) r.register(buildRememberTool(deps.rememberFact));
1877
+ if (opts.gitWrite) {
1878
+ r.register(gitWriteTool);
1879
+ r.register(findUnfinishedTool);
1880
+ }
1881
+ if (opts.propose) r.register(proposeMemoryTool);
1882
+ if (opts.mcp) deferMcp(r, deps.mcpTools?.() ?? []);
1883
+ return r;
1884
+ }
1885
+ function deferMcp(r, tools) {
1886
+ for (const t of tools) r.registerDeferred(t);
1887
+ if (tools.length) r.register(buildFindToolTool(r));
1888
+ }
1889
+ async function runReviewer(deps, task, cwd) {
1890
+ const resolved = deps.roleRegistry.resolve("code-reviewer");
1891
+ const hints = memoryHints(deps, task.title, { role: "code-reviewer" });
1892
+ const reviewerTools = readOnlyRegistry(deps, { propose: true });
1893
+ const routed = routeSkills(task.title, deps.skillRegistry, deps.roleRegistry.skillsFor("code-reviewer"), {
1894
+ // The card's own files, never a guess: see the implementer for the measurement that settled it.
1895
+ role: "code-reviewer",
1896
+ files: task.files,
1897
+ placed: placedSkills()
1898
+ });
1899
+ if (routed.length) deps.note?.(`\u{1F4CE} \`code-reviewer\` \xB7 ${routed.map((m) => `**${m.name}**`).join(", ")}`);
1900
+ const diff = deps.baseRef ? await taskDiff(cwd, deps.baseRef) : "";
1901
+ const ask = { role: "user", content: `Review the CODE that implements task "${task.title}" \u2014 correctness, tests, and implementation quality.
1902
+ The subject of this review is ALWAYS the code. Do NOT review, re-open, or comment on the upstream planning documents (specs/**, .specify/**, plan.md, tasks.md) \u2014 they were already reviewed and approved before coding began; treat them as fixed context, not as something to critique.
1903
+ Give a verdict (pass/fail + notes).
1904
+
1905
+ ${describeDiff(diff)}` };
1906
+ const law = deps.home ? await constitutionNote(
1907
+ { ...deps, home: deps.home, note: deps.note },
1908
+ cwd,
1909
+ { role: "code-reviewer", files: task.files, title: task.title }
1910
+ ) : "";
1911
+ const opts = {
1912
+ provider: deps.provider,
1913
+ ...resolved,
1914
+ // The reviewer gets the SAME rules the implementer was given: a gate that does not know what was
1915
+ // required cannot tell whether it was met, and that is where a constitution stops being one.
1916
+ systemPrompt: (routed.length ? applySkills(resolved.systemPrompt, routed.map((m) => m.name), deps.skillRegistry) : resolved.systemPrompt) + law + projectToolsNote(reviewerTools.list(), !!loadGraphSync(cwd)) + BATCH_TOOLS_NOTE,
1917
+ tools: reviewerTools,
1918
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "code-reviewer") ?? false,
1919
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
1920
+ permission: deps.permission,
1921
+ approve: deps.approve,
1922
+ cwd,
1923
+ signal: AbortSignal.any([deps.signal, AbortSignal.timeout(CODE_REVIEW_TIMEOUT_MS)]),
1924
+ maxTurns: CODE_REVIEW_MAX_TURNS
1925
+ };
1926
+ const verdict = await runStructuredRole(opts, VerdictSchema);
1927
+ reinforceUsed(deps, hints.ids, verdict.notes.join(" "), "code-reviewer");
1928
+ return verdict;
1929
+ }
1930
+
1931
+ // src/tools/write.ts
1932
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1933
+ import { existsSync as existsSync6 } from "fs";
1934
+ import { dirname as dirname3, resolve as resolve3, sep as sep4 } from "path";
1935
+ import { z as z9 } from "zod";
1936
+ var params6 = z9.object({ path: z9.string(), content: z9.string() });
1937
+ var writeFileTool = {
1938
+ name: "write_file",
1939
+ 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.",
1940
+ permissionLevel: "write",
1941
+ parameters: params6,
1942
+ describe(rawArgs) {
1943
+ const a = params6.parse(rawArgs);
1944
+ return { allowKey: a.path, preview: `write ${a.path} (${Buffer.byteLength(a.content)} bytes)` };
1945
+ },
1946
+ async run(rawArgs, ctx) {
1947
+ const parsed = params6.safeParse(rawArgs);
1948
+ if (!parsed.success) {
1949
+ return {
1950
+ content: `write_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
1951
+ isError: true
1952
+ };
1953
+ }
1954
+ const a = parsed.data;
1955
+ const target = resolve3(ctx.cwd, a.path);
1956
+ const cwdResolved = resolve3(ctx.cwd);
1957
+ if (target !== cwdResolved && !target.startsWith(cwdResolved + sep4)) {
1958
+ return { content: `write_file: path is outside cwd: ${a.path}`, isError: true };
1959
+ }
1960
+ if (ctx.readFiles && existsSync6(target) && !ctx.readFiles.has(target)) {
1961
+ return {
1962
+ 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).`,
1963
+ isError: true
1964
+ };
1965
+ }
1966
+ try {
1967
+ await mkdir3(dirname3(target), { recursive: true });
1968
+ await writeFile2(target, a.content, "utf8");
1969
+ {
1970
+ const ls = a.content ? a.content.split("\n") : [];
1971
+ ctx.onActivity?.({ tool: "write", target: a.path, lines: ls.length, preview: ls.slice(0, 12), startLine: 1 });
1972
+ }
1973
+ ctx.readFiles?.add(target);
1974
+ return { content: `Written: ${a.path}`, isError: false };
1975
+ } catch (e) {
1976
+ return {
1977
+ content: `write_file error: ${e instanceof Error ? e.message : String(e)}`,
1978
+ isError: true
1979
+ };
1980
+ }
1981
+ }
1982
+ };
1983
+
1984
+ // src/tools/edit.ts
1985
+ import { readFile as readFile2, writeFile as writeFile3 } from "fs/promises";
1986
+ import { resolve as resolve4, sep as sep5 } from "path";
1987
+ import { z as z10 } from "zod";
1988
+ var params7 = z10.object({
1989
+ path: z10.string(),
1990
+ oldString: z10.string(),
1991
+ newString: z10.string(),
1992
+ replaceAll: z10.boolean().optional()
1993
+ });
1994
+ var NEAR_MISS_CHARS = 600;
1995
+ var norm = (t) => t.replace(/[ \t]+/g, " ").replace(/[ \t]+$/gm, "").trim();
1996
+ function shortPath(path, cwd) {
1997
+ const abs = resolve4(cwd, path);
1998
+ return abs === cwd ? "." : abs.startsWith(cwd + sep5) ? abs.slice(cwd.length + 1) : path;
1999
+ }
2000
+ function whyNotFound(content, oldString) {
2001
+ if (/^\s*\d+\t/m.test(oldString)) {
2002
+ 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.";
2003
+ }
2004
+ const lines = content.split("\n");
2005
+ const wanted = oldString.split("\n");
2006
+ const firstReal = wanted.find((l) => l.trim().length > 0)?.trim();
2007
+ if (firstReal === void 0) return " \u2014 your oldString is empty or only whitespace.";
2008
+ if (norm(content).includes(norm(oldString))) {
2009
+ const at2 = lines.findIndex((l) => norm(l) === norm(wanted.find((w) => w.trim())) || norm(l).includes(firstReal));
2010
+ const block = at2 >= 0 ? lines.slice(at2, at2 + wanted.length).join("\n") : "";
2011
+ 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}:
2012
+ ` + block.slice(0, NEAR_MISS_CHARS);
2013
+ }
2014
+ const at = lines.findIndex((l) => l.includes(firstReal));
2015
+ if (at >= 0) {
2016
+ 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:
2017
+ ${lines.slice(at, at + wanted.length + 2).join("\n").slice(0, NEAR_MISS_CHARS)}`;
2018
+ }
2019
+ 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.";
2020
+ }
2021
+ var editFileTool = {
2022
+ name: "edit_file",
2023
+ 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.",
2024
+ permissionLevel: "write",
2025
+ parameters: params7,
2026
+ describe(rawArgs) {
2027
+ const a = params7.parse(rawArgs);
2028
+ return { allowKey: a.path, preview: `edit ${a.path}` };
2029
+ },
2030
+ async run(rawArgs, ctx) {
2031
+ const parsed = params7.safeParse(rawArgs);
2032
+ if (!parsed.success) {
2033
+ return {
2034
+ content: `edit_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
2035
+ isError: true
2036
+ };
2037
+ }
2038
+ const a = parsed.data;
2039
+ const target = resolve4(ctx.cwd, a.path);
2040
+ const cwdResolved = resolve4(ctx.cwd);
2041
+ if (target !== cwdResolved && !target.startsWith(cwdResolved + sep5)) {
2042
+ return { content: `edit_file: path is outside cwd: ${a.path}`, isError: true };
2043
+ }
2044
+ let content;
2045
+ try {
2046
+ content = await readFile2(target, "utf8");
2047
+ } catch (e) {
2048
+ return {
2049
+ content: `edit_file error: ${e instanceof Error ? e.message : String(e)}`,
2050
+ isError: true
2051
+ };
2052
+ }
2053
+ const count = content.split(a.oldString).length - 1;
2054
+ if (count === 0) {
2055
+ return {
2056
+ content: `edit_file: oldString not found (${shortPath(a.path, cwdResolved)})${whyNotFound(content, a.oldString)}`,
2057
+ isError: true
2058
+ };
2059
+ }
2060
+ if (count > 1 && !a.replaceAll) {
2061
+ return {
2062
+ content: `edit_file: oldString is not unique (${count} matches) \u2014 replaceAll required`,
2063
+ isError: true
2064
+ };
2065
+ }
2066
+ const next = a.replaceAll ? content.split(a.oldString).join(a.newString) : content.replace(a.oldString, a.newString);
2067
+ try {
2068
+ await writeFile3(target, next, "utf8");
2069
+ {
2070
+ const added = a.newString ? a.newString.split("\n") : [];
2071
+ const removed = a.oldString ? a.oldString.split("\n") : [];
2072
+ const at = content.indexOf(a.oldString);
2073
+ const startLine = at < 0 ? 1 : content.slice(0, at).split("\n").length;
2074
+ ctx.onActivity?.({ tool: "edit", target: a.path, lines: added.length, preview: added.slice(0, 12), removed: removed.slice(0, 12), startLine });
2075
+ }
2076
+ return { content: `Edited: ${a.path}`, isError: false };
2077
+ } catch (e) {
2078
+ return {
2079
+ content: `edit_file error: ${e instanceof Error ? e.message : String(e)}`,
2080
+ isError: true
2081
+ };
2082
+ }
2083
+ }
2084
+ };
2085
+
2086
+ // src/tools/shell.ts
2087
+ import { spawn } from "child_process";
2088
+ import { resolve as resolve5, sep as sep6 } from "path";
2089
+ import { z as z11 } from "zod";
2090
+ var params8 = z11.object({
2091
+ command: z11.string(),
2092
+ /** Milliseconds before the command is killed. Defaults to DEFAULT_TIMEOUT_MS, capped at MAX_TIMEOUT_MS. */
2093
+ timeout: z11.number().int().positive().optional()
2094
+ });
2095
+ var DEFAULT_TIMEOUT_MS = 12e4;
2096
+ var MAX_TIMEOUT_MS = 6e5;
2097
+ var KILL_GRACE_MS = 2e3;
2098
+ var MAX_SHELL_CHARS = 32e3;
2099
+ var TAIL_SHARE = 0.6;
2100
+ function clampOutput(body, max = MAX_SHELL_CHARS) {
2101
+ if (body.length <= max) return body;
2102
+ const tail = Math.floor(max * TAIL_SHARE);
2103
+ const head = max - tail;
2104
+ const dropped = body.length - max;
2105
+ const lines = body.slice(head, body.length - tail).split("\n").length;
2106
+ return `${body.slice(0, head)}
2107
+
2108
+ \u2026 [${dropped.toLocaleString("en-US")} chars / ~${lines} lines trimmed from the middle] \u2026
2109
+
2110
+ ${body.slice(-tail)}`;
2111
+ }
2112
+ var REWRITES = [
2113
+ /\bsed\s+(?:-[a-zA-Z]*\s+)*-i\b/,
2114
+ // sed -i
2115
+ /\bperl\s+(?:-\w+\s+)*-i\b/,
2116
+ // perl -i
2117
+ /\bwrite_text\s*\(/,
2118
+ // python: Path(...).write_text(...)
2119
+ /\bwriteFileSync\s*\(/,
2120
+ // node: fs.writeFileSync(...)
2121
+ /\bopen\s*\([^)]*['"][wa]\+?['"]\s*\)/,
2122
+ // python: open(path, "w")
2123
+ /\btee\s+(?!-a\b)/
2124
+ // tee <file>
2125
+ ];
2126
+ var REDIRECT = /(?:^|[^0-9<>&])>>?\s*(?!\/dev\/|\/tmp\/|&)([A-Za-z0-9_./-]*\.[A-Za-z0-9]+)/;
2127
+ function leavesWorkdir(command, cwd) {
2128
+ const base = resolve5(cwd);
2129
+ let at = base;
2130
+ for (const seg of command.split(/&&|\|\||;|\|/)) {
2131
+ const m = /^\s*(?:cd|pushd)(?:\s+(.*))?$/.exec(seg.trim());
2132
+ if (!m) continue;
2133
+ const raw = (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
2134
+ if (!raw || raw === "~" || raw === "$HOME" || raw.startsWith("~/")) return raw || "~";
2135
+ if (raw === "-") return "-";
2136
+ at = resolve5(at, raw);
2137
+ if (at !== base && !at.startsWith(base + sep6)) return raw;
2138
+ }
2139
+ return void 0;
2140
+ }
2141
+ var DESTROYS_WORK = [
2142
+ { re: /^reset\b[^]*\s--hard\b/, what: "reset --hard" },
2143
+ { re: /^clean\b[^]*\s-\S*[fd]/, what: "clean -f/-d" },
2144
+ { re: /^checkout\b[^]*\s--\s+(?:\.|-A|:\/)\s*$/, what: "checkout -- ." },
2145
+ { re: /^restore\b[^]*\s(?:\.|-A|:\/)\s*$/, what: "restore ." }
2146
+ ];
2147
+ function destroysWork(command) {
2148
+ for (const seg of command.split(/&&|\|\||;|\|/)) {
2149
+ const m = /^\s*git\s+(.*)$/.exec(seg.trim());
2150
+ if (!m) continue;
2151
+ const rest = (m[1] ?? "").replace(/^(?:-\S+\s+)*/, "").trim();
2152
+ const hit = DESTROYS_WORK.find((d) => d.re.test(rest));
2153
+ if (hit) return hit.what;
2154
+ }
2155
+ return void 0;
2156
+ }
2157
+ function rewritesAFile(command) {
2158
+ for (const re of REWRITES) if (re.test(command)) return re.source;
2159
+ const m = REDIRECT.exec(command);
2160
+ return m ? `redirect into ${m[1]}` : void 0;
2161
+ }
2162
+ var shellTool = {
2163
+ name: "shell",
2164
+ 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.",
2165
+ permissionLevel: "exec",
2166
+ parameters: params8,
2167
+ describe(rawArgs) {
2168
+ const a = params8.parse(rawArgs);
2169
+ return { allowKey: a.command, preview: a.command };
2170
+ },
2171
+ run(rawArgs, ctx) {
2172
+ const parsed = params8.safeParse(rawArgs);
2173
+ const why = parsed.success ? rewritesAFile(parsed.data.command) : void 0;
2174
+ if (why !== void 0) {
2175
+ return Promise.resolve({
2176
+ 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.`,
2177
+ isError: true
2178
+ });
2179
+ }
2180
+ if (!parsed.success) {
2181
+ return Promise.resolve({
2182
+ content: `shell: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
2183
+ isError: true
2184
+ });
2185
+ }
2186
+ const wipes = parsed.success ? destroysWork(parsed.data.command) : void 0;
2187
+ if (wipes !== void 0) {
2188
+ return Promise.resolve({
2189
+ 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.`,
2190
+ isError: true
2191
+ });
2192
+ }
2193
+ const away = leavesWorkdir(parsed.data.command, ctx.cwd);
2194
+ if (away !== void 0) {
2195
+ return Promise.resolve({
2196
+ 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.`,
2197
+ isError: true
2198
+ });
2199
+ }
2200
+ const a = parsed.data;
2201
+ return new Promise((resolvePromise) => {
2202
+ let child;
2203
+ try {
2204
+ child = spawn(a.command, {
2205
+ cwd: ctx.cwd,
2206
+ shell: true,
2207
+ signal: ctx.signal,
2208
+ stdio: ["ignore", "pipe", "pipe"],
2209
+ detached: true
2210
+ });
2211
+ } catch (e) {
2212
+ resolvePromise({
2213
+ content: `shell error: ${e instanceof Error ? e.message : String(e)}`,
2214
+ isError: true
2215
+ });
2216
+ return;
2217
+ }
2218
+ const budget = Math.min(a.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
2219
+ let out = "";
2220
+ let err = "";
2221
+ let timedOut = false;
2222
+ let killer;
2223
+ const killTree = (sig) => {
2224
+ try {
2225
+ if (child.pid) process.kill(-child.pid, sig);
2226
+ else child.kill(sig);
2227
+ } catch {
2228
+ try {
2229
+ child.kill(sig);
2230
+ } catch {
2231
+ }
2232
+ }
2233
+ };
2234
+ const onAbort = () => killTree("SIGKILL");
2235
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
2236
+ const timer = setTimeout(() => {
2237
+ timedOut = true;
2238
+ killTree("SIGTERM");
2239
+ killer = setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS);
2240
+ killer.unref?.();
2241
+ }, budget);
2242
+ timer.unref?.();
2243
+ const done = () => {
2244
+ clearTimeout(timer);
2245
+ if (killer) clearTimeout(killer);
2246
+ ctx.signal?.removeEventListener("abort", onAbort);
2247
+ };
2248
+ child.stdout?.on("data", (d) => out += d.toString());
2249
+ child.stderr?.on("data", (d) => err += d.toString());
2250
+ child.on("error", (e) => {
2251
+ done();
2252
+ resolvePromise({ content: `shell error: ${e.message}`, isError: true });
2253
+ });
2254
+ child.on("close", (code) => {
2255
+ done();
2256
+ const body = clampOutput([out, err].filter((s) => s.length).join("\n").trimEnd());
2257
+ const tail = timedOut ? `
2258
+ (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.)` : `
2259
+ (exit ${code ?? "null"})`;
2260
+ resolvePromise({ content: `$ ${a.command}
2261
+ ${body}${tail}`, isError: timedOut || code !== 0 });
2262
+ });
2263
+ });
2264
+ }
2265
+ };
2266
+
2267
+ // src/tools/web.ts
2268
+ import { z as z12 } from "zod";
2269
+ var params9 = z12.object({ url: z12.string().url() });
2270
+ var MAX_CHARS = 1e5;
2271
+ function createWebFetchTool(fetchFn = globalThis.fetch) {
2272
+ return {
2273
+ name: "web_fetch",
2274
+ description: "Fetches the content (text) of a URL.",
2275
+ permissionLevel: "safe",
2276
+ parameters: params9,
2277
+ async run(rawArgs, ctx) {
2278
+ const parsed = params9.safeParse(rawArgs);
2279
+ if (!parsed.success) {
2280
+ return {
2281
+ content: `web_fetch: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
2282
+ isError: true
2283
+ };
2284
+ }
2285
+ const a = parsed.data;
2286
+ try {
2287
+ const res = await fetchFn(a.url, { signal: ctx.signal });
2288
+ const text = await res.text();
2289
+ const capped = text.length > MAX_CHARS ? text.slice(0, MAX_CHARS) + "\n\u2026 (truncated)" : text;
2290
+ return { content: capped, isError: !res.ok };
2291
+ } catch (e) {
2292
+ return {
2293
+ content: `web_fetch error: ${e instanceof Error ? e.message : String(e)}`,
2294
+ isError: true
2295
+ };
2296
+ }
2297
+ }
2298
+ };
2299
+ }
2300
+
2301
+ // src/engine/trace-refresh.ts
2302
+ import { existsSync as existsSync7 } from "fs";
2303
+ import { join as join7 } from "path";
2304
+ var IRRELEVANT = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/;
2305
+ async function changedByMerge(git, cwd, before, after = "HEAD") {
2306
+ if (!before) return [];
2307
+ const r = await git(["diff", "--name-only", `${before}..${after}`], cwd);
2308
+ if (r.code !== 0) return [];
2309
+ return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l && !IRRELEVANT.test(l));
2310
+ }
2311
+ async function refreshAfterChange(opts) {
2312
+ if (!opts.files.length) return;
2313
+ try {
2314
+ const r = await refreshTraces({
2315
+ cwd: opts.cwd,
2316
+ files: opts.files,
2317
+ provider: opts.provider,
2318
+ models: opts.models,
2319
+ ...opts.signal ? { signal: opts.signal } : {},
2320
+ ...opts.note ? { note: opts.note } : {}
2321
+ });
2322
+ const line = describeRefresh(r);
2323
+ if (line) opts.note?.(line);
2324
+ await commitRefreshed(opts.git ?? defaultGitRunner, opts.cwd, traceRootRel());
2325
+ } catch {
2326
+ }
2327
+ }
2328
+ async function refreshTraces(opts) {
2329
+ const out = { traced: 0, failed: 0, removed: 0, skipped: 0 };
2330
+ const candidates = traceable(opts.files);
2331
+ out.skipped = opts.files.length - candidates.length;
2332
+ const gone = candidates.filter((f) => !existsSync7(join7(opts.cwd, f)));
2333
+ if (gone.length) {
2334
+ try {
2335
+ const index = await loadTraceIndex(opts.cwd);
2336
+ const kept = new Set(Object.keys(index.traces).filter((f) => !gone.includes(f)));
2337
+ out.removed = (await pruneTraces(opts.cwd, kept, index)).length;
2338
+ if (out.removed) await saveTraceIndex(opts.cwd, index);
2339
+ } catch {
2340
+ }
2341
+ }
2342
+ const targets = candidates.filter((f) => !gone.includes(f));
2343
+ if (!targets.length) return out;
2344
+ const model = opts.models.find(Boolean);
2345
+ if (!model) return out;
2346
+ try {
2347
+ const g = await buildProjectGraph(opts.cwd);
2348
+ out.graph = g.message;
2349
+ out.staleNames = await pruneAreaNames(opts.cwd);
2350
+ const plan = await planFor(opts.cwd, targets);
2351
+ if (!plan.jobs.length) {
2352
+ out.skipped += targets.length;
2353
+ return out;
2354
+ }
2355
+ const res = await runTraces({
2356
+ cwd: opts.cwd,
2357
+ provider: opts.provider,
2358
+ model,
2359
+ plan,
2360
+ // No liveFiles: this run knows only the files one task changed, and a pruner given that list would
2361
+ // read every OTHER trace in the project as orphaned and delete it.
2362
+ ...opts.signal ? { signal: opts.signal } : {}
2363
+ });
2364
+ out.traced = res.written;
2365
+ out.failed = res.failed.length;
2366
+ out.skipped += res.upToDate;
2367
+ } catch (e) {
2368
+ opts.note?.(`Trace refresh failed: ${e instanceof Error ? e.message : String(e)}`);
2369
+ }
2370
+ return out;
2371
+ }
2372
+ function describeRefresh(r) {
2373
+ if (!r.traced && !r.failed && !r.removed && !r.staleNames) return void 0;
2374
+ const bits = [`\u{1F4DD} ${r.traced} trace(s) refreshed`];
2375
+ if (r.removed) bits.push(`${r.removed} removed for deleted file(s)`);
2376
+ if (r.staleNames) bits.push(`${r.staleNames} area name(s) dropped \u2014 the rebuild left them pointing at nothing`);
2377
+ if (r.failed) bits.push(`${r.failed} failed`);
2378
+ return `${bits.join(" \xB7 ")} \u2014 the changed files now describe themselves.`;
2379
+ }
2380
+ async function commitRefreshed(git, baseWorktree, traceRootRel2) {
2381
+ const paths = [traceRootRel2, ...sharedDerived()].filter((p) => existsSync7(join7(baseWorktree, p)));
2382
+ if (!paths.length) return false;
2383
+ const add = await git(["add", "--", ...paths], baseWorktree);
2384
+ if (add.code !== 0) return false;
2385
+ const staged = await git(["diff", "--cached", "--quiet", "--", ...paths], baseWorktree);
2386
+ if (staged.code === 0) return false;
2387
+ const r = await git(["commit", "-m", "docs(traces): refresh for the files this task changed", "--", ...paths], baseWorktree);
2388
+ return r.code === 0;
2389
+ }
2390
+
2391
+ // src/engine/role-fitness.ts
2392
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
2393
+ import { dirname as dirname4 } from "path";
2394
+ var UNFIT_AFTER = 2;
2395
+ var UNFIT_RATE = 0.5;
2396
+ var key = (role, model) => `${role}\0${model}`;
2397
+ var RoleFitness = class {
2398
+ constructor(path, now = () => Date.now()) {
2399
+ this.path = path;
2400
+ this.now = now;
2401
+ if (path) this.load();
2402
+ }
2403
+ path;
2404
+ now;
2405
+ records = /* @__PURE__ */ new Map();
2406
+ /**
2407
+ * Records that a model failed to do a role's job — not that a call failed.
2408
+ *
2409
+ * The distinction is the whole point: a rate limit, a timeout or a network error says nothing about
2410
+ * fitness and must never be recorded here. What belongs here is a model that answered without doing the
2411
+ * work — prose instead of an implementation, prose instead of the structured result the role demands.
2412
+ */
2413
+ record(role, model, reason) {
2414
+ if (!role || !model) return 0;
2415
+ const k = key(role, model);
2416
+ const prev = this.records.get(k);
2417
+ const rec = {
2418
+ role,
2419
+ model,
2420
+ strikes: (prev?.strikes ?? 0) + 1,
2421
+ attempts: (prev?.attempts ?? 0) + 1,
2422
+ reason: reason.slice(0, 120),
2423
+ at: this.now()
2424
+ };
2425
+ this.records.set(k, rec);
2426
+ this.save();
2427
+ return rec.strikes;
2428
+ }
2429
+ /**
2430
+ * Records that the model DID do the role's job.
2431
+ *
2432
+ * Without this the record only ever accumulates evidence against a model, and a model used a hundred times
2433
+ * looks worse than one used twice. Successes are what make the failures mean something.
2434
+ */
2435
+ ok(role, model) {
2436
+ if (!role || !model) return;
2437
+ const k = key(role, model);
2438
+ const prev = this.records.get(k);
2439
+ this.records.set(k, {
2440
+ role,
2441
+ model,
2442
+ strikes: prev?.strikes ?? 0,
2443
+ attempts: (prev?.attempts ?? 0) + 1,
2444
+ reason: prev?.reason ?? "",
2445
+ at: this.now()
2446
+ });
2447
+ this.save();
2448
+ }
2449
+ /**
2450
+ * True when this model has failed this role often enough, and often ENOUGH OF THE TIME, that assigning it
2451
+ * again is a known mistake. Both tests must pass: a pattern, and a pattern that dominates.
2452
+ */
2453
+ unfit(role, model) {
2454
+ const r = this.records.get(key(role, model));
2455
+ if (!r || r.strikes < UNFIT_AFTER) return false;
2456
+ return r.strikes / Math.max(r.attempts, 1) >= UNFIT_RATE;
2457
+ }
2458
+ /** Why a model is unfit for a role, for the line that tells the user. */
2459
+ reasonFor(role, model) {
2460
+ return this.unfit(role, model) ? this.records.get(key(role, model))?.reason : void 0;
2461
+ }
2462
+ /**
2463
+ * Drops the models a role has already proven it cannot use — but never returns an empty list.
2464
+ *
2465
+ * A role with no model at all is a worse failure than a role with a bad one: the first stops the run, the
2466
+ * second wastes an attempt and rotates. When everything available is unfit, the original order is returned
2467
+ * unchanged and the caller carries on.
2468
+ */
2469
+ fitFor(role, models) {
2470
+ const fit = models.filter((m) => !this.unfit(role, m));
2471
+ return fit.length ? fit : models;
2472
+ }
2473
+ /** Everything recorded, worst first — for `/roles` and for explaining an assignment. */
2474
+ list() {
2475
+ return [...this.records.values()].sort((a, b) => b.strikes - a.strikes);
2476
+ }
2477
+ /** Forgets one pairing, or all of them — a model that was broken in March may be fine in June. */
2478
+ clear(role, model) {
2479
+ if (role && model) this.records.delete(key(role, model));
2480
+ else this.records.clear();
2481
+ this.save();
2482
+ }
2483
+ load() {
2484
+ try {
2485
+ const raw = JSON.parse(readFileSync3(this.path, "utf8"));
2486
+ if (!Array.isArray(raw)) return;
2487
+ for (const r of raw) {
2488
+ if (r && typeof r.role === "string" && typeof r.model === "string" && typeof r.strikes === "number") {
2489
+ this.records.set(key(r.role, r.model), { ...r, attempts: r.attempts ?? r.strikes });
2490
+ }
2491
+ }
2492
+ } catch {
2493
+ }
2494
+ }
2495
+ save() {
2496
+ if (!this.path) return;
2497
+ try {
2498
+ mkdirSync3(dirname4(this.path), { recursive: true });
2499
+ writeFileSync2(this.path, JSON.stringify(this.list(), null, 2), "utf8");
2500
+ } catch {
2501
+ }
2502
+ }
2503
+ };
2504
+
2505
+ // src/engine/routing.ts
2506
+ import { z as z13 } from "zod";
2507
+
2508
+ // src/engine/route-role.ts
2509
+ var STYLE_EXT = [".css", ".scss", ".sass", ".less", ".styl"];
2510
+ var MARKUP_EXT = [".html", ".htm", ".svg", ".vue", ".svelte"];
2511
+ var COMPONENT_EXT = [".tsx", ".jsx"];
2512
+ var BACKEND_EXT = [".sql", ".prisma", ".proto", ".go", ".py", ".rb", ".php", ".java", ".rs", ".kt"];
2513
+ var BACKEND_DIR = ["server", "api", "backend", "db", "database", "migrations", "migration", "infra", "worker", "jobs"];
2514
+ var UI_DIR = ["ui", "components", "component", "views", "view", "pages", "page", "screens", "styles", "theme", "themes", "layouts", "widgets"];
2515
+ var DESIGN_WORDS = [
2516
+ "style",
2517
+ "styles",
2518
+ "styling",
2519
+ "theme",
2520
+ "theming",
2521
+ "layout",
2522
+ "design",
2523
+ "visual",
2524
+ "visuals",
2525
+ "animation",
2526
+ "animate",
2527
+ "transition",
2528
+ "responsive",
2529
+ "accessibility",
2530
+ "a11y",
2531
+ "color",
2532
+ "colors",
2533
+ "colour",
2534
+ "typography",
2535
+ "spacing",
2536
+ "icon",
2537
+ "icons",
2538
+ "ui",
2539
+ "ux",
2540
+ "polish",
2541
+ "palette",
2542
+ "css"
2543
+ ];
2544
+ var ext = (p) => {
2545
+ const base = p.slice(p.lastIndexOf("/") + 1);
2546
+ const dot = base.lastIndexOf(".");
2547
+ return dot > 0 ? base.slice(dot).toLowerCase() : "";
2548
+ };
2549
+ var segments = (p) => p.toLowerCase().split(/[/\\]/).slice(0, -1);
2550
+ var words2 = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
2551
+ function routeByEvidence(card) {
2552
+ const files = card.files.map((f) => f.trim()).filter(Boolean);
2553
+ const exts = files.map(ext);
2554
+ const dirs = files.flatMap(segments);
2555
+ const title = words2(card.title);
2556
+ const hasStyle = exts.some((e) => STYLE_EXT.includes(e));
2557
+ const hasMarkup = exts.some((e) => MARKUP_EXT.includes(e));
2558
+ const hasComponent = exts.some((e) => COMPONENT_EXT.includes(e));
2559
+ const hasUiDir = dirs.some((d) => UI_DIR.includes(d));
2560
+ const designWord = title.find((w) => DESIGN_WORDS.includes(w));
2561
+ const backend = exts.some((e) => BACKEND_EXT.includes(e)) || dirs.some((d) => BACKEND_DIR.includes(d));
2562
+ if (!hasStyle && !hasMarkup && !hasComponent && !hasUiDir && !designWord) {
2563
+ return { role: "coder", why: files.length ? "no interface work in its files or title" : "nothing about it is interface work" };
2564
+ }
2565
+ if ((hasStyle || hasMarkup || designWord) && !backend) {
2566
+ const reason = hasStyle || hasMarkup ? `writes ${files.find((f) => STYLE_EXT.includes(ext(f)) || MARKUP_EXT.includes(ext(f)))}` : `"${designWord}" is the work`;
2567
+ return { role: "designer", why: reason };
2568
+ }
2569
+ return { why: hasComponent && !hasStyle && !hasMarkup ? "component files could be either" : "the evidence points both ways" };
2570
+ }
2571
+
2572
+ // src/agent/deadline.ts
2573
+ function withDeadline(work, signal, message) {
2574
+ work.catch(() => {
2575
+ });
2576
+ return Promise.race([work, expired(signal, message)]);
2577
+ }
2578
+ function expired(signal, message) {
2579
+ return new Promise((_, reject) => {
2580
+ if (signal.aborted) {
2581
+ reject(new Error(message));
2582
+ return;
2583
+ }
2584
+ signal.addEventListener("abort", () => reject(new Error(message)), { once: true });
2585
+ });
2586
+ }
2587
+ var SHORT_CALL_MS = 3 * 60 * 1e3;
2588
+ var LONG_CALL_MS = 15 * 60 * 1e3;
2589
+
2590
+ // src/engine/routing.ts
2591
+ var RouteSchema = z13.object({
2592
+ role: z13.enum(["coder", "designer"]).describe(
2593
+ "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."
2594
+ )
2595
+ });
2596
+ async function routeTask(deps, task) {
2597
+ const evidence = routeByEvidence(task);
2598
+ telemetry().event("decision.route", {
2599
+ "hc.decision": "route",
2600
+ "hc.task.id": task.id,
2601
+ "hc.role": evidence.role,
2602
+ "hc.route.why": evidence.why,
2603
+ "hc.route.by": evidence.role ? "evidence" : "model"
2604
+ });
2605
+ if (evidence.role) return evidence.role;
2606
+ try {
2607
+ const resolved = deps.roleRegistry.resolve("router");
2608
+ const tools = new ToolRegistry();
2609
+ tools.register(buildSkillTool(deps.skillRegistry));
2610
+ const opts = {
2611
+ provider: deps.provider,
2612
+ ...resolved,
2613
+ tools,
2614
+ messages: [
2615
+ { role: "user", content: `Task: "${task.title}"
2616
+ ` + (task.files.length ? `Files it writes: ${task.files.join(", ")}
2617
+ ` : "") + (task.acceptance.length ? `Done when: ${task.acceptance.join("; ")}
2618
+ ` : "") + `
2619
+ 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.` }
2620
+ ],
2621
+ permission: deps.permission,
2622
+ approve: deps.approve,
2623
+ cwd: "/",
2624
+ signal: deps.signal,
2625
+ perAttemptMs: SHORT_CALL_MS,
2626
+ // each model in the chain gets its own clock — see RoleAgentOptions
2627
+ // One question, one answer, from the text in front of it. Nothing here is worth a fifty-turn budget —
2628
+ // and an unbounded structured role walks its entire fallback chain when a model will not submit.
2629
+ maxTurns: 3
2630
+ };
2631
+ const { role } = await runStructuredRole(opts, RouteSchema);
2632
+ return role;
2633
+ } catch (e) {
2634
+ if (deps.signal.aborted) throw e;
2635
+ return "coder";
2636
+ }
2637
+ }
2638
+
2639
+ // src/tools/index.ts
2640
+ function createDefaultRegistry() {
2641
+ const reg = new ToolRegistry();
2642
+ reg.register(readFileTool);
2643
+ reg.register(writeFileTool);
2644
+ reg.register(editFileTool);
2645
+ reg.register(grepTool);
2646
+ reg.register(globTool);
2647
+ reg.register(gitTool);
2648
+ reg.register(shellTool);
2649
+ reg.register(createWebFetchTool());
2650
+ return reg;
2651
+ }
2652
+
2653
+ // src/engine/operational.ts
2654
+ import { z as z14 } from "zod";
2655
+ var CommitSchema = z14.object({
2656
+ message: z14.string().describe("A Conventional Commits message: `type(scope): subject`, English, imperative.")
2657
+ });
2658
+ var MAX_DIFF = 12e3;
2659
+ var OPERATIONAL_MAX_TURNS = 3;
2660
+ async function runOperational(deps, diff, context) {
2661
+ const clipped = diff.length > MAX_DIFF ? `${diff.slice(0, MAX_DIFF)}
2662
+ \u2026 (diff truncated)` : diff;
2663
+ const resolved = deps.roleRegistry.resolve("operational");
2664
+ const out = await runStructuredRole({
2665
+ provider: deps.provider,
2666
+ ...resolved,
2667
+ tools: new ToolRegistry(),
2668
+ messages: [{ role: "user", content: `Context: ${context}
2669
+
2670
+ Git diff of the work just completed:
2671
+
2672
+ ${clipped}
2673
+
2674
+ Write the commit message.` }],
2675
+ permission: deps.permission,
2676
+ approve: deps.approve,
2677
+ cwd: ".",
2678
+ signal: deps.signal,
2679
+ perAttemptMs: SHORT_CALL_MS,
2680
+ // each model in the chain gets its own clock — see RoleAgentOptions
2681
+ totalMs: SHORT_CALL_MS * 2,
2682
+ // …and the whole chain cannot outlast the sentence it is writing
2683
+ // One sentence from a diff it was handed. Uncapped, a model that would not call `submit` walked its whole
2684
+ // fallback chain at fifty turns an attempt — to phrase a commit message.
2685
+ maxTurns: OPERATIONAL_MAX_TURNS
2686
+ }, CommitSchema);
2687
+ return out.message.trim();
2688
+ }
2689
+ function fileCommitMessage(path) {
2690
+ const p = path.replace(/\\/g, "/");
2691
+ 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";
2692
+ const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")).split("/").filter((x) => x !== "src").pop() : "";
2693
+ return `wip(${type}${dir ? `/${dir}` : ""}): ${p.slice(p.lastIndexOf("/") + 1)}`;
2694
+ }
2695
+ var SCRATCH_RE = /(^|\/|\.)(tmp|scratch|repro|sandbox)\.|[-_.](tmp|scratch|repro)\.[a-z]+$/i;
2696
+ function isScratch(path) {
2697
+ return SCRATCH_RE.test(path.split("/").pop() ?? path);
2698
+ }
2699
+ async function commitFile(deps, workdir, path, git = defaultGitRunner) {
2700
+ if (writableStateRoot(workdir) === void 0) return void 0;
2701
+ if (isScratch(path)) return void 0;
2702
+ await git(["add", "--", path], workdir);
2703
+ const staged = await git(["diff", "--cached", "--quiet", "--", path], workdir);
2704
+ if (staged.code === 0) return void 0;
2705
+ const message = fileCommitMessage(path);
2706
+ const res = await git(["commit", "-m", message, "--", path], workdir);
2707
+ if (res.code !== 0) return void 0;
2708
+ return message;
2709
+ }
2710
+ async function commitStep(deps, workdir, context, git = defaultGitRunner) {
2711
+ await git(["add", "-A"], workdir);
2712
+ const dirty = await git(["diff", "--cached", "--name-only"], workdir);
2713
+ const scratch = dirty.stdout.split("\n").map((l) => l.trim()).filter((l) => l && isScratch(l));
2714
+ if (scratch.length) await git(["reset", "--quiet", "--", ...scratch], workdir);
2715
+ const staged = await git(["diff", "--cached", "--quiet"], workdir);
2716
+ if (staged.code === 0) return void 0;
2717
+ const diff = await git(["diff", "--cached"], workdir);
2718
+ let message;
2719
+ try {
2720
+ message = await runOperational(deps, diff.stdout, context);
2721
+ } catch {
2722
+ message = `chore: ${context}`;
2723
+ }
2724
+ const res = await git(["commit", "-m", message], workdir);
2725
+ if (res.code !== 0) return void 0;
2726
+ deps.note?.(`\u{1F516} ${message}`);
2727
+ return message;
2728
+ }
2729
+ async function squashTask(deps, worktree, baseRef, title, git = defaultGitRunner) {
2730
+ const fork = await git(["merge-base", "HEAD", baseRef], worktree);
2731
+ if (fork.code !== 0) return void 0;
2732
+ const at = fork.stdout.trim();
2733
+ if (!at) return void 0;
2734
+ const aheadOut = await git(["rev-list", "--count", `${at}..HEAD`], worktree);
2735
+ const ahead = Number(aheadOut.stdout.trim() || "0");
2736
+ if (ahead < 1) return void 0;
2737
+ const diff = await git(["diff", `${at}..HEAD`], worktree);
2738
+ if (!diff.stdout.trim()) return void 0;
2739
+ let message;
2740
+ try {
2741
+ message = await runOperational(deps, diff.stdout, `completed the task: ${title}`);
2742
+ } catch {
2743
+ message = `chore: ${title}`;
2744
+ }
2745
+ const reset = await git(["reset", "--soft", at], worktree);
2746
+ if (reset.code !== 0) return void 0;
2747
+ const res = await git(["commit", "-m", message], worktree);
2748
+ if (res.code !== 0) return void 0;
2749
+ deps.note?.(`\u{1F4E6} **${message}**${ahead ? ` \u2014 ${ahead} checkpoint(s) squashed` : ""}`);
2750
+ return message;
2751
+ }
2752
+
2753
+ // src/skills/adjudicate.ts
2754
+ async function adjudicateSkills(opts) {
2755
+ const { confident, borderline } = partitionByConfidence(opts.matches, opts.bar, opts.margin);
2756
+ if (!borderline.length) return { keep: confident, asked: false };
2757
+ const described = borderline.map((m) => `- ${m.name}: ${opts.registry.get(m.name)?.description ?? "(no description)"}`).join("\n");
2758
+ const systemPrompt = `Decide which of these skills, if any, genuinely apply to one development task.
2759
+
2760
+ 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.
2761
+
2762
+ 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.
2763
+
2764
+ Task: ${opts.task}
2765
+
2766
+ Candidates:
2767
+ ${described}
2768
+
2769
+ Answer with a fenced \`\`\`json block: {"keep":["<skill name>", \u2026]} \u2014 an empty list if none apply. One short sentence of reasoning before it.`;
2770
+ try {
2771
+ const req = {
2772
+ model: opts.model,
2773
+ messages: [
2774
+ { role: "system", content: "You decide whether a skill applies to a task. You reject far more often than you accept." },
2775
+ { role: "user", content: systemPrompt }
2776
+ ],
2777
+ tools: []
2778
+ };
2779
+ let full = "";
2780
+ for await (const ev of opts.provider.chat(req, opts.signal ?? new AbortController().signal)) {
2781
+ if (ev.type === "text-delta") full += ev.text;
2782
+ else if (ev.type === "error") throw new Error(ev.message);
2783
+ }
2784
+ const keep = parseKeep(full);
2785
+ if (!keep) throw new Error("unparseable verdict");
2786
+ const kept = borderline.filter((m) => keep.includes(m.name));
2787
+ return {
2788
+ keep: [...confident, ...kept],
2789
+ asked: true,
2790
+ reasoning: full.split("```")[0].replace(/<\/?think>/gi, "").trim().slice(0, 200)
2791
+ };
2792
+ } catch {
2793
+ return { keep: opts.matches, asked: false };
2794
+ }
2795
+ }
2796
+ function parseKeep(text) {
2797
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
2798
+ const raw = fence ? fence[1] : text.slice(text.indexOf("{"));
2799
+ try {
2800
+ const parsed = JSON.parse(raw);
2801
+ return Array.isArray(parsed.keep) ? parsed.keep.filter((k) => typeof k === "string") : void 0;
2802
+ } catch {
2803
+ return void 0;
2804
+ }
2805
+ }
2806
+
2807
+ // src/engine/implementer.ts
2808
+ var IMPLEMENTER_MAX_TURNS = 200;
2809
+ 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.";
2810
+ var IMPLEMENTER_TIMEOUT_MS = 20 * 60 * 1e3;
2811
+ var MAX_BUDGET_EXTENSIONS = 2;
2812
+ function attemptBudget(task, baseMs) {
2813
+ let deaths = 0;
2814
+ for (let i = task.stageHistory.length - 1; i >= 0 && deaths < MAX_BUDGET_EXTENSIONS; i--) {
2815
+ const e = task.stageHistory[i];
2816
+ if (e.action === "attempt-error") {
2817
+ if (!/budget/i.test(e.note ?? "")) break;
2818
+ deaths += 1;
2819
+ continue;
2820
+ }
2821
+ if (e.action === "reviewed:fail" || e.action === "acceptance:failed" || e.action === "no-changes") break;
2822
+ }
2823
+ return baseMs * (1 + deaths);
2824
+ }
2825
+ var DEADLINE_WARNING_AT = 0.75;
2826
+ function deadlineWarning(elapsedMs, budgetMs) {
2827
+ if (elapsedMs < budgetMs * DEADLINE_WARNING_AT) return void 0;
2828
+ const left = Math.max(1, Math.round((budgetMs - elapsedMs) / 6e4));
2829
+ 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.`;
2830
+ }
2831
+ var MAX_WRITTEN_CHARS = 6e4;
2832
+ async function writtenText(cwd, touched) {
2833
+ const { readFile: readFile4 } = await import("fs/promises");
2834
+ const { join: join10 } = await import("path");
2835
+ const parts = [];
2836
+ let used = 0;
2837
+ for (const p of [...new Set(touched)]) {
2838
+ if (used >= MAX_WRITTEN_CHARS) break;
2839
+ try {
2840
+ const t = await readFile4(join10(cwd, p), "utf8");
2841
+ parts.push(t.slice(0, MAX_WRITTEN_CHARS - used));
2842
+ used += t.length;
2843
+ } catch {
2844
+ }
2845
+ }
2846
+ return parts.join("\n");
2847
+ }
2848
+ async function runImplementer(deps, role, task, cwd, slot = 0) {
2849
+ const resolved = deps.roleRegistry.resolve(role);
2850
+ const chain = deps.roleRegistry.chainFor(role, slot);
2851
+ const tok = { promptTokens: 0, completionTokens: 0 };
2852
+ let serving = chain[0] ?? "";
2853
+ const tools = createDefaultRegistry();
2854
+ tools.register(buildSkillTool(deps.skillRegistry));
2855
+ for (const t of contextTools(deps)) tools.register(t);
2856
+ tools.register(buildRememberTool(deps.rememberFact));
2857
+ const returning = task.reviewNotes.length > 0;
2858
+ const brief = [
2859
+ task.acceptance.length ? `It is done when ALL of these are true \u2014 they are exactly what the review will check:
2860
+ ` + task.acceptance.map((a) => `- ${a}`).join("\n") : "",
2861
+ task.files.length ? `The plan expects this task to create or change these files:
2862
+ ${task.files.map((f) => `- ${f}`).join("\n")}
2863
+ 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.` : ""
2864
+ ].filter(Boolean).join("\n\n");
2865
+ 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.`;
2866
+ 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.
2867
+
2868
+ ${WHAT_IT_COST}
2869
+
2870
+ 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.`;
2871
+ const content = (returning ? `This is a RETURNING task: "${task.title}". Address the reviewer notes:
2872
+ ${task.reviewNotes.map((n) => `- ${n}`).join("\n")}` : `This is a NEW task: "${task.title}". Implement it.`) + (brief ? `
2873
+
2874
+ ${brief}` : "") + `
2875
+
2876
+ ${hygiene}
2877
+
2878
+ ${handOver}`;
2879
+ const hints = memoryHints(deps, `${task.title} ${task.reviewNotes.join(" ")}`, { role, operations: true });
2880
+ const touched = [];
2881
+ const attached = deps.roleRegistry.skillsFor(role);
2882
+ const subject = `${task.title} ${task.acceptance.join(" ")} ${task.reviewNotes.join(" ")}`;
2883
+ const routed = routeSkills(subject, deps.skillRegistry, attached, {
2884
+ role,
2885
+ implementing: true,
2886
+ /**
2887
+ * The files the PLAN named, when it named any.
2888
+ *
2889
+ * Inference is a fallback, not the first answer: resolving a task's words against the whole graph returns
2890
+ * whatever happens to contain those words, and those paths are then treated as evidence about what kind
2891
+ * of work this is. Measured live — "fix product description rendering" resolved to `ShopifyService.cs`,
2892
+ * `n11/OrderMapper.cs` and `ExcelTableRenderer.cs`, because marketplace integrators are full of symbols
2893
+ * called product and description, and the brainstormer was handed `azure-kubernetes` for it.
2894
+ *
2895
+ * A card's own file list came from a plan a person approved. It outranks a guess.
2896
+ */
2897
+ /**
2898
+ * Only files someone NAMED, never a guess.
2899
+ *
2900
+ * Resolving a task's words against the graph was measured over real commit history: on the integration
2901
+ * project this runs against, 131 samples, an answer produced for 130 of them, and 9% of those files
2902
+ * correct. The domain is the reason: "product", "description", "order" name symbols in every marketplace
2903
+ * integrator there is, so the words that describe a task resolve to all of them.
2904
+ *
2905
+ * Confident and wrong is the worst kind of evidence, because it reads as knowledge. Without it the router
2906
+ * falls back to the task's own text, which is honest about knowing less.
2907
+ */
2908
+ files: task.files,
2909
+ placed: placedSkills()
2910
+ });
2911
+ const verdict = routed.length ? await adjudicateSkills({
2912
+ provider: deps.provider,
2913
+ model: chain[0] ?? "",
2914
+ task: subject,
2915
+ matches: routed,
2916
+ registry: deps.skillRegistry,
2917
+ signal: deps.signal
2918
+ }) : { keep: routed, asked: false, reasoning: void 0 };
2919
+ const kept = verdict.keep;
2920
+ if (kept.length) {
2921
+ deps.note?.(`\u{1F4CE} \`${role}\` \xB7 ${kept.map((m) => `**${m.name}**`).join(", ")}${verdict.asked ? " _(adjudicated)_" : ""}`);
2922
+ } else if (routed.length && verdict.asked) {
2923
+ 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"}`);
2924
+ }
2925
+ const withSkills = kept.length ? applySkills(resolved.systemPrompt, kept.map((m) => m.name), deps.skillRegistry) : resolved.systemPrompt;
2926
+ const law = deps.home ? await constitutionNote(
2927
+ { ...deps, home: deps.home, note: deps.note },
2928
+ cwd,
2929
+ { role, files: task.files, title: task.title }
2930
+ ) : "";
2931
+ const systemPrompt = withSkills + law + projectToolsNote(tools.list(), !!loadGraphSync(cwd)) + BATCH_TOOLS_NOTE;
2932
+ const budgetMs = attemptBudget(task, deps.implementerTimeoutMs ?? IMPLEMENTER_TIMEOUT_MS);
2933
+ const budget = AbortSignal.timeout(budgetMs);
2934
+ let warned = false;
2935
+ const startedAt = Date.now();
2936
+ const deadlineNote = () => {
2937
+ if (warned) return void 0;
2938
+ const note = deadlineWarning(Date.now() - startedAt, budgetMs);
2939
+ if (note) warned = true;
2940
+ return note;
2941
+ };
2942
+ const opts = {
2943
+ provider: deps.provider,
2944
+ ...resolved,
2945
+ systemPrompt,
2946
+ ...chain.length ? { model: chain[0], fallbacks: chain.slice(1) } : {},
2947
+ tools,
2948
+ maxTurns: IMPLEMENTER_MAX_TURNS,
2949
+ /**
2950
+ * A screenshot named in the task comes with it.
2951
+ *
2952
+ * A card built from a small request or from a finding carries the user's own words, and those words can
2953
+ * name the picture that shows the problem. Without this the implementer gets a path and the only thing it
2954
+ * can do with one is `read_file`, which cannot read a PNG.
2955
+ */
2956
+ messages: (hints.message ? [{ role: "user", content: hints.message }] : []).concat([
2957
+ { role: "user", ...handedOver(content, cwd) }
2958
+ ]),
2959
+ onUsage: (u) => {
2960
+ tok.promptTokens += u.promptTokens;
2961
+ tok.completionTokens += u.completionTokens;
2962
+ if (u.model && u.model !== serving) {
2963
+ serving = u.model;
2964
+ deps.onProgress?.({ kind: "agent-model", id: task.id, model: serving });
2965
+ }
2966
+ deps.onProgress?.({ kind: "agent-usage", id: task.id, ...tok });
2967
+ },
2968
+ permission: deps.permission,
2969
+ approve: deps.approve,
2970
+ cwd,
2971
+ // The agent's own inbox first (a by-the-way note), then the deadline warning when it is due.
2972
+ inbox: () => deps.inbox?.() ?? deadlineNote(),
2973
+ signal: AbortSignal.any([deps.signal, budget]),
2974
+ // Stamped with the card id: the agent panel is keyed by it, and unattributed activity goes to the chat.
2975
+ onActivity: deps.onActivity ? (a) => deps.onActivity?.({ ...a, agent: task.id }) : void 0,
2976
+ /**
2977
+ * Attributed by ID, not by title.
2978
+ *
2979
+ * A wave runs several implementers at once, so unattributed prose interleaves into something no one can
2980
+ * read — that much was right. Using the task's TITLE for it was not: one agent narrating fifteen times
2981
+ * printed the same forty-word title fifteen times, and the user reported it as the tool "saying the same
2982
+ * thing over and over". They were reading it correctly; two thirds of every line WAS the same.
2983
+ *
2984
+ * The id is unique, it is what the board and the agent panel already show, and it leaves the line to the
2985
+ * sentence that differs.
2986
+ */
2987
+ ...deps.note ? { onSay: (t) => deps.note?.(` \u21B3 **${role}** \`${task.id}\` ${t}`) } : {},
2988
+ onLiveActivity: deps.onLiveActivity,
2989
+ onWrite: (path) => {
2990
+ touched.push(path);
2991
+ return commitFile(deps, cwd, path).then(() => {
2992
+ });
2993
+ }
2994
+ };
2995
+ const mins = Math.round(budgetMs / 6e4);
2996
+ const baseMins = Math.round((deps.implementerTimeoutMs ?? IMPLEMENTER_TIMEOUT_MS) / 6e4);
2997
+ if (mins > baseMins) {
2998
+ deps.note?.(`\u23F3 **${task.title}** ran out of time, not out of ideas \u2014 this attempt gets ${mins} minutes.`);
2999
+ }
3000
+ 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.`;
3001
+ try {
3002
+ const attempt = () => withDeadline(runToCompletion(opts), budget, overran);
3003
+ const timed = () => deps.timings ? deps.timings.time("implementation", attempt) : attempt();
3004
+ await telemetry().span("stage.implementation", {
3005
+ "hc.stage": "implementation",
3006
+ "hc.role": role,
3007
+ "hc.task.id": task.id,
3008
+ "hc.task.title": task.title.slice(0, 120),
3009
+ "hc.model": chain[0],
3010
+ "hc.attempt": task.attempts
3011
+ }, timed);
3012
+ } catch (e) {
3013
+ if (deps.signal.aborted || !budget.aborted) throw e;
3014
+ throw new Error(overran);
3015
+ } finally {
3016
+ reinforceTouched(deps, hints.ids, touched, role);
3017
+ reinforceUsed(deps, hints.ids, await writtenText(cwd, touched), role);
3018
+ }
3019
+ }
3020
+
3021
+ // src/engine/review.ts
3022
+ import { existsSync as existsSync8 } from "fs";
3023
+ import { isAbsolute, join as join8 } from "path";
3024
+ import { z as z15 } from "zod";
3025
+
3026
+ // src/agent/roles.ts
3027
+ function isTransientFailure(reason) {
3028
+ const r = reason.toLowerCase();
3029
+ if (/\b(429|rate.?limit|quota|exhaust|insufficient|billing|credit)\b/.test(r)) return false;
3030
+ return /overload|529|50[0234]|timeout|timed out|deadline|econnreset|epipe|socket hang up|stream ended|temporar|unavailable|try again/.test(r);
3031
+ }
3032
+ var RoleRegistry = class _RoleRegistry {
3033
+ // durable behavioral rules → appended to EVERY role's prompt
3034
+ constructor(roles, defaultPrompts = {}, skillRegistry) {
3035
+ this.roles = roles;
3036
+ this.defaultPrompts = defaultPrompts;
3037
+ this.skillRegistry = skillRegistry;
3038
+ }
3039
+ roles;
3040
+ defaultPrompts;
3041
+ skillRegistry;
3042
+ modelOverride;
3043
+ roleOverrides = /* @__PURE__ */ new Map();
3044
+ // per-role model CHAIN override (highest priority)
3045
+ effortOverrides = /* @__PURE__ */ new Map();
3046
+ // Models that failed retryably (429/5xx/quota) → skipped in every chain until released. Kept WITH the
3047
+ // reason and the time so a coordinator can report them and later re-probe whether the limit has reset.
3048
+ quarantine = /* @__PURE__ */ new Map();
3049
+ notify;
3050
+ // fallback UI note sink (wired once the controller exists)
3051
+ onQuarantine;
3052
+ /** What each model has actually managed to do in each ROLE — see setFitness. */
3053
+ fitness;
3054
+ // Models that answered in prose instead of calling the submit tool. Not a transport error, so nothing ever
3055
+ // benched them: the chain quietly slid to the fallback on EVERY call, forever, in every role that held them.
3056
+ strikes = /* @__PURE__ */ new Map();
3057
+ rulesProvider;
3058
+ /** Every configured role name — used to validate a role reference produced by a model (memory audiences). */
3059
+ names() {
3060
+ return [.../* @__PURE__ */ new Set([...Object.keys(this.roles), ...Object.keys(this.defaultPrompts)])];
3061
+ }
3062
+ /** Wire the fallback-note sink (called after the controller exists). */
3063
+ setNotify(fn) {
3064
+ this.notify = fn;
3065
+ }
3066
+ /** Wire the durable-rules source (memory). Rules are appended to every role's system prompt (always honored). */
3067
+ setRules(fn) {
3068
+ this.rulesProvider = fn;
3069
+ }
3070
+ /** The rule block to append to a role's prompt — empty when there are no rules. Public so prompt-supplying
3071
+ * callers (spec-kit phases build their own prompt) can append it too. */
3072
+ ruleSuffix() {
3073
+ const rules = this.rulesProvider?.() ?? [];
3074
+ return rules.length ? `
3075
+
3076
+ User rules (ALWAYS honor these):
3077
+ ${rules.map((r) => `- ${r}`).join("\n")}` : "";
3078
+ }
3079
+ /** Live-swap the model used by every role (session-only; clears on undefined/empty). */
3080
+ setModelOverride(model) {
3081
+ this.modelOverride = model && model.length > 0 ? model : void 0;
3082
+ }
3083
+ /** Live-swap the model CHAIN of ONE role (session-only; wins over the global override). Clears on empty. */
3084
+ setRoleModel(roleName, models) {
3085
+ const chain = (typeof models === "string" ? [models] : models ?? []).filter((m) => m.length > 0);
3086
+ if (chain.length) this.roleOverrides.set(roleName, chain);
3087
+ else this.roleOverrides.delete(roleName);
3088
+ }
3089
+ /**
3090
+ * How hard this role should work, set alongside its chain.
3091
+ *
3092
+ * An override on the live registry rather than a config re-read, for the same reason `setRoleModel` is one:
3093
+ * `/roles adjust` has to take effect in the session that ran it, not only in the next one.
3094
+ *
3095
+ * `undefined` REMOVES it — a role reassigned from a Claude model to one whose effort cannot be set must
3096
+ * stop carrying a level, or the config keeps a number that no longer applies to anything.
3097
+ */
3098
+ setRoleEffort(roleName, effort) {
3099
+ if (effort) this.effortOverrides.set(roleName, effort);
3100
+ else this.effortOverrides.delete(roleName);
3101
+ }
3102
+ /**
3103
+ * Wire the record of what each model has actually managed to do in each role.
3104
+ *
3105
+ * Without it a chain is only a list of names from a catalogue. With it, a model that has twice answered
3106
+ * this role in prose instead of doing its work stops being offered to this role — while staying available
3107
+ * to every other role, where it may be perfectly good.
3108
+ */
3109
+ setFitness(f) {
3110
+ this.fitness = f;
3111
+ }
3112
+ /** Wire the quarantine hook: whatever benches a model, every role still holding it must be re-assigned. */
3113
+ setOnQuarantine(fn) {
3114
+ this.onQuarantine = fn;
3115
+ }
3116
+ /** Mark a model spent — every chain skips it from now on, until it is released. */
3117
+ markExhausted(model, reason = "unavailable", now = Date.now(), until) {
3118
+ if (!model || this.isQuarantined(model)) return;
3119
+ const ends = until ?? (isTransientFailure(reason) ? now + _RoleRegistry.TRANSIENT_BENCH_MS : void 0);
3120
+ this.quarantine.set(model, { at: now, reason, ...ends !== void 0 && { until: ends } });
3121
+ this.onQuarantine?.(model, reason, ends);
3122
+ }
3123
+ /**
3124
+ * How long a BEHAVIOURAL bench lasts before the model is tried again.
3125
+ *
3126
+ * A model that is out of quota is out until the quota returns, and nothing here can shorten that. A model
3127
+ * that answered in prose is a different case entirely: the transport was fine, and the next prompt may not
3128
+ * be the one it stumbled on. Benching it for the rest of a multi-hour run costs every role that held it —
3129
+ * measured live, one such bench re-assigned SIXTEEN roles away from the best model available.
3130
+ */
3131
+ static STRUCTURAL_BENCH_MS = 10 * 6e4;
3132
+ /**
3133
+ * How long a TRANSPORT bench lasts — the busy server, not the spent subscription.
3134
+ *
3135
+ * The argument above, one door over. A model that answered in prose gets ten minutes because the transport
3136
+ * was fine; a model whose transport said "Overloaded" for one second is the same case in its purest form,
3137
+ * and it was the one getting benched for the whole session.
3138
+ *
3139
+ * Measured live, in the middle of a feature run: `cc/claude-opus-5` served five calls in the preceding two
3140
+ * minutes (23.8s, 2.9s, 3.1s, 25.3s, 38.7s, all ok), then one 529 in 1.7 seconds — and 18 roles were moved
3141
+ * off the best model in the fleet for the rest of the session. A 529 is the textbook transient condition;
3142
+ * the API's own guidance for it is to retry with backoff.
3143
+ *
3144
+ * Two minutes: long enough that a genuinely struggling gateway is not hammered, short enough that a
3145
+ * one-second blip costs a couple of turns rather than an afternoon.
3146
+ */
3147
+ static TRANSIENT_BENCH_MS = 2 * 6e4;
3148
+ /**
3149
+ * How many structured failures a model gets before it is benched. One miss can be a genuinely hard prompt;
3150
+ * a pattern is the model. Low, because every strike costs a full wasted pass in every role that holds it.
3151
+ */
3152
+ static STRUCTURAL_STRIKES = 2;
3153
+ /**
3154
+ * Records that a model finished a turn WITHOUT producing the structured result it was asked for (prose
3155
+ * instead of a tool call). This is not "unavailable" — the transport was fine — so it never reached the
3156
+ * retryable path that benches a model, and the chain slid to the fallback on every single call instead.
3157
+ * Returns the strike count; at the threshold the model is quarantined like any other spent one.
3158
+ */
3159
+ markStructuralFailure(model, reason = "no valid structured result", role) {
3160
+ if (!model) return 0;
3161
+ const key2 = role ? `${model}::${role}` : model;
3162
+ const n = (this.strikes.get(key2) ?? 0) + 1;
3163
+ this.strikes.set(key2, n);
3164
+ if (n < _RoleRegistry.STRUCTURAL_STRIKES) return n;
3165
+ if (role) {
3166
+ this.fitness?.record?.(role, model, reason);
3167
+ const rolesFailed = [...this.strikes.entries()].filter(([k, v]) => k.startsWith(`${model}::`) && v >= _RoleRegistry.STRUCTURAL_STRIKES).length;
3168
+ if (rolesFailed >= _RoleRegistry.STRUCTURAL_ROLES_BEFORE_BENCH) {
3169
+ this.markExhausted(
3170
+ model,
3171
+ `${reason} (in ${rolesFailed} roles)`,
3172
+ Date.now(),
3173
+ Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS
3174
+ );
3175
+ }
3176
+ return n;
3177
+ }
3178
+ this.markExhausted(model, reason, Date.now(), Date.now() + _RoleRegistry.STRUCTURAL_BENCH_MS);
3179
+ return n;
3180
+ }
3181
+ /**
3182
+ * How many DISTINCT roles must reject a model this way before it is benched outright.
3183
+ *
3184
+ * Two, because one role can have a prompt that a good model reads badly — and the fitness record already
3185
+ * takes it out of that role. A second, unrelated role failing the same way is the first evidence that the
3186
+ * model, not the prompt, is the problem.
3187
+ */
3188
+ static STRUCTURAL_ROLES_BEFORE_BENCH = 2;
3189
+ /** Models currently quarantined, with why and when — surfaced to the user and re-probed before an adjust. */
3190
+ quarantined() {
3191
+ return [...this.quarantine].map(([model, q]) => ({ model, ...q }));
3192
+ }
3193
+ isQuarantined(model, now = Date.now()) {
3194
+ const q = this.quarantine.get(model);
3195
+ if (!q) return false;
3196
+ if (q.until !== void 0 && now >= q.until) {
3197
+ this.quarantine.delete(model);
3198
+ this.strikes.clear();
3199
+ return false;
3200
+ }
3201
+ return true;
3202
+ }
3203
+ /** Put a model back in play (its quota reset, or the user forced it). */
3204
+ release(model) {
3205
+ this.strikes.delete(model);
3206
+ return this.quarantine.delete(model);
3207
+ }
3208
+ /**
3209
+ * Roles whose CURRENT chain still contains `model`. When a model is quarantined these are the roles that
3210
+ * would otherwise keep resolving to a dead chain, so they are exactly the ones to re-assign.
3211
+ */
3212
+ rolesUsing(model) {
3213
+ return this.names().filter((r) => this.rawChain(r).includes(model));
3214
+ }
3215
+ /**
3216
+ * The role's chain BEFORE quarantine filtering — what was actually assigned to it.
3217
+ *
3218
+ * The order is the documented one and nothing precedes it: per-role override, then the session model, then
3219
+ * the config. It used to bail on an empty CONFIG chain before either override was consulted, which made a
3220
+ * role the config had never heard of impossible to assign — the one case where assigning is the whole
3221
+ * point. Measured with `tester`, added in a version the user's config predated: `/roles adjust` set the
3222
+ * override, the override was skipped, and the role stayed broken for the rest of the session while the
3223
+ * error message recommended running `/roles adjust`.
3224
+ */
3225
+ rawChain(roleName) {
3226
+ const perRole = this.roleOverrides.get(roleName);
3227
+ if (perRole && perRole.length) return perRole;
3228
+ if (this.modelOverride && roleName !== "refiner") return [this.modelOverride];
3229
+ return this.roles[roleName]?.models ?? [];
3230
+ }
3231
+ /** True when every model assigned to this role is quarantined — the chain has collapsed and needs replacing. */
3232
+ chainCollapsed(roleName) {
3233
+ const raw = this.rawChain(roleName);
3234
+ return raw.length > 0 && raw.every((m) => this.isQuarantined(m));
3235
+ }
3236
+ /** The full model chain for a role by priority: per-role override → global override (non-refiner) → config. */
3237
+ chain(roleName) {
3238
+ const base = this.rawChain(roleName);
3239
+ if (!base.length) return [];
3240
+ const live = base.filter((m) => !this.isQuarantined(m));
3241
+ const usable = live.length ? live : base;
3242
+ const fit = this.fitness ? usable.filter((m) => !this.fitness.unfit(roleName, m)) : usable;
3243
+ return fit.length ? fit : usable;
3244
+ }
3245
+ /**
3246
+ * The role's chain ROTATED by `slot`. Parallel workers share one role — five implementers in a wave are all
3247
+ * `coder` — so every one of them resolved to the same chain head and hammered a single subscription until it
3248
+ * rate-limited. Rotating gives each worker a different lead model while keeping its FULL fallback set, so
3249
+ * spreading the load costs no resilience.
3250
+ */
3251
+ chainFor(roleName, slot = 0) {
3252
+ const c = this.chain(roleName);
3253
+ const k = c.length ? (slot % c.length + c.length) % c.length : 0;
3254
+ return k === 0 ? c : [...c.slice(k), ...c.slice(0, k)];
3255
+ }
3256
+ /** The model a role would use next (chain head), for UI display only. */
3257
+ peekModel(roleName) {
3258
+ return this.chain(roleName)[0] ?? "";
3259
+ }
3260
+ /**
3261
+ * The chain (primary + fallbacks) and session-fallback hooks for a role, WITHOUT its system prompt —
3262
+ * for callers that supply their own prompt (e.g. spec-kit phases). resolve() layers the prompt on top.
3263
+ */
3264
+ fallbackOpts(roleName) {
3265
+ const chain = this.chain(roleName);
3266
+ const notify = this.notify;
3267
+ const effort = this.effortOverrides.get(roleName) ?? this.roles[roleName]?.effort;
3268
+ return {
3269
+ // Travels with the chain, not with the prompt: the seven callers that take only the chain are exactly
3270
+ // the ones whose work is heaviest (the tester, the analyst, the spec-kit phases).
3271
+ ...effort ? { effort } : {},
3272
+ /**
3273
+ * The name belongs to the CHAIN, not to the prompt.
3274
+ *
3275
+ * It was put on `resolve()` alone, on the assumption that every caller spreads a resolved role. Seven
3276
+ * do not: they take the chain from here and supply their own prompt (the spec-kit phases, the tester,
3277
+ * the analyst, the question normalizer). Measured on the first run after the change — 19 tool calls,
3278
+ * one of them attributed. Everything the attribution was for happens in those seven.
3279
+ */
3280
+ role: roleName,
3281
+ model: chain[0] ?? "",
3282
+ fallbacks: chain.slice(1),
3283
+ onExhausted: (m, reason) => this.markExhausted(m, reason ?? "unavailable"),
3284
+ onStructuralFailure: (m, reason) => this.markStructuralFailure(m, reason, roleName),
3285
+ onFallback: notify ? (from, to, reason) => notify(`\u2935 \`${from}\` \u2192 \`${to}\` \u2014 ${reason}`) : void 0
3286
+ };
3287
+ }
3288
+ /** The skills already attached to a role — what task-level routing must not inline a second time. */
3289
+ skillsFor(roleName) {
3290
+ return this.roles[roleName]?.skills ?? [];
3291
+ }
3292
+ resolve(roleName) {
3293
+ const role = this.roles[roleName];
3294
+ if (!role) throw new Error(`undefined role: ${roleName}`);
3295
+ if (!this.rawChain(roleName).length) {
3296
+ throw new Error(
3297
+ `role '${roleName}' has no model defined \u2014 set one with \`/roles setmodel\`, run \`/roles adjust\`, or choose a session model with \`/model\`.`
3298
+ );
3299
+ }
3300
+ let systemPrompt = role.systemPrompt ?? this.defaultPrompts[roleName];
3301
+ if (systemPrompt === void 0) throw new Error(`role '${roleName}' has no systemPrompt`);
3302
+ if (this.skillRegistry) {
3303
+ try {
3304
+ systemPrompt = applySkills(systemPrompt, role.skills ?? [], this.skillRegistry);
3305
+ } catch (e) {
3306
+ throw new Error(`role '${roleName}' skill error: ${e instanceof Error ? e.message : String(e)}`);
3307
+ }
3308
+ }
3309
+ return { ...this.fallbackOpts(roleName), systemPrompt: systemPrompt + this.ruleSuffix() };
3310
+ }
3311
+ };
3312
+
3313
+ // src/engine/review.ts
3314
+ function asChoice(o) {
3315
+ return typeof o === "string" ? { label: o } : o;
3316
+ }
3317
+ var AssessmentSchema = z15.object({
3318
+ findings: z15.array(z15.object({
3319
+ severity: z15.enum(["critical", "medium", "low"]).describe(
3320
+ "`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."
3321
+ ),
3322
+ note: z15.string()
3323
+ })).default([]),
3324
+ recommendation: z15.enum(["approve", "revise"]).describe(
3325
+ "`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."
3326
+ )
3327
+ });
3328
+ function severityCounts(a) {
3329
+ const c = { critical: 0, medium: 0, low: 0 };
3330
+ for (const f of a.findings) c[f.severity]++;
3331
+ return c;
3332
+ }
3333
+ function memberStatus(a) {
3334
+ const c = severityCounts(a);
3335
+ return `${a.recommendation === "approve" ? "APPROVE" : "REJECT"} \xB7 C:${c.critical} M:${c.medium} L:${c.low}`;
3336
+ }
3337
+ function findingNotes(assessments, stage, severities) {
3338
+ return assessments.flatMap((a) => a.findings.filter((f) => severities.includes(f.severity)).map((f) => `[${stage}][${f.severity}] ${a.name}: ${f.note}`));
3339
+ }
3340
+ function nonBlockingNotes(assessments, stage) {
3341
+ return findingNotes(assessments, stage, ["medium", "low"]);
3342
+ }
3343
+ function reviseBrief(findings, reasons) {
3344
+ return [...findings, ...reasons.map((r) => `[decision] ${r}`)];
3345
+ }
3346
+ function voteReasons(votes) {
3347
+ return votes.filter((v) => v.vote === "revise").map((v) => `${v.name}: ${v.rationale}`);
3348
+ }
3349
+ function blockingSignatures(assessments) {
3350
+ const out = /* @__PURE__ */ new Set();
3351
+ for (const a of assessments) for (const f of a.findings) {
3352
+ if (f.severity === "critical") out.add(`${a.name}::${f.note.trim().toLowerCase().replace(/\s+/g, " ")}`);
3353
+ }
3354
+ return out;
3355
+ }
3356
+ function severityTotal(assessments, sev) {
3357
+ return assessments.filter((a) => !a.unverified).reduce((n, a) => n + a.findings.filter((f) => f.severity === sev).length, 0);
3358
+ }
3359
+ var TEAM_MIN_COVERAGE = 0.6;
3360
+ function coverage(assessments) {
3361
+ const unverified = assessments.filter((a) => a.unverified).length;
3362
+ const verified = assessments.length - unverified;
3363
+ return { verified, unverified, enough: !assessments.length || verified / assessments.length >= TEAM_MIN_COVERAGE };
3364
+ }
3365
+ var CouncilVoteSchema = z15.object({
3366
+ vote: z15.enum(["pass", "revise"]).describe(
3367
+ "`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."
3368
+ ),
3369
+ rationale: z15.string()
3370
+ });
3371
+ var JudgeSchema = z15.object({
3372
+ decision: z15.enum(["pass", "revise", "ask-human"]).describe(
3373
+ "`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."
3374
+ ),
3375
+ feedback: z15.array(z15.string()),
3376
+ question: z15.string()
3377
+ });
3378
+ var STAGE_FRAMING = {
3379
+ 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.
3380
+ 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.)
3381
+ 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.`,
3382
+ 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.',
3383
+ code: `You are reviewing CODE that implements one approved task.
3384
+ OUT OF SCOPE here: re-litigating the approved spec or plan, and demanding refactors beyond this task's scope.
3385
+ 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.`
3386
+ };
3387
+ var REVIEW_MAX_TURNS = 15;
3388
+ var REVIEW_TIMEOUT_MS = 3 * 60 * 1e3;
3389
+ function isDeadlineFailure(e) {
3390
+ return /within its deadline|timed? ?out/i.test(e instanceof Error ? e.message : String(e));
3391
+ }
3392
+ function reviewerDeadlineMs(deps) {
3393
+ return deps.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS;
3394
+ }
3395
+ 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.";
3396
+ function teamPrompt(stage, perspective) {
3397
+ return `You are a review TEAM member for the ${stage.toUpperCase()} stage. Your lens: ${perspective}.
3398
+
3399
+ ${STAGE_FRAMING[stage]}
3400
+
3401
+ ${SCOPE_RULE}
3402
+
3403
+ 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.`;
3404
+ }
3405
+ function councilPrompt(perspective) {
3406
+ return `You are a member of the review COUNCIL \u2014 a small, senior decision panel. Your judgment lens: ${perspective}.
3407
+
3408
+ 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.`;
3409
+ }
3410
+ function buildReviewerRegistry(reviewers, prompt) {
3411
+ const roles = {};
3412
+ for (const r of reviewers) roles[r.name] = { models: r.models, systemPrompt: prompt(r.perspective) };
3413
+ return new RoleRegistry(roles);
3414
+ }
3415
+ function buildTeamRegistry(stage, team) {
3416
+ return buildReviewerRegistry(team, (p) => teamPrompt(stage, p));
3417
+ }
3418
+ function buildCouncilRegistry(council) {
3419
+ return buildReviewerRegistry(council, councilPrompt);
3420
+ }
3421
+ function errText(e) {
3422
+ const m = e instanceof Error ? e.message : String(e);
3423
+ return m.length > 160 ? `${m.slice(0, 159)}\u2026` : m;
3424
+ }
3425
+ async function runWithHealing(deps, role, id, opts, schema, signal, emit) {
3426
+ try {
3427
+ return await runStructuredRole(opts, schema);
3428
+ } catch (e) {
3429
+ if (deps.signal.aborted || isDeadlineFailure(e) || !deps.rechainRole) throw e;
3430
+ const chain = await deps.rechainRole(role, errText(e));
3431
+ if (!chain?.length) throw e;
3432
+ emit({ kind: "note", text: `\u{1F501} \`${role}\` lost its whole model chain \u2014 retrying on \`${chain[0]}\`.` });
3433
+ emit({ kind: "agent-model", id, model: chain[0] });
3434
+ return runStructuredRole({ ...opts, model: chain[0], fallbacks: chain.slice(1) }, schema);
3435
+ }
3436
+ }
3437
+ async function runTeam(deps, stage, workdir, target, request, emit = () => {
3438
+ }, carried = []) {
3439
+ const registry = deps.teamRegistries[stage];
3440
+ const carriedByName = new Map(carried.map((a) => [a.name, a]));
3441
+ const team = deps.teams[stage].filter((c) => !carriedByName.has(c.name));
3442
+ if (carriedByName.size) {
3443
+ emit({ kind: "note", text: `\u21A9\uFE0E ${carriedByName.size} lens(es) approved last round \u2014 carrying their verdict; re-reviewing ${team.length}.` });
3444
+ }
3445
+ const scope = request ? `
3446
+
3447
+ The user's original request (the scope you must judge against):
3448
+ """
3449
+ ${request}
3450
+ """` : "";
3451
+ const what = stage === "code" ? `Review the code for ONE task: ${target}.
3452
+ 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.`;
3453
+ const diff = stage === "code" ? await changeUnderReview(deps, workdir) : "";
3454
+ const evidence = stage === "code" ? `
3455
+
3456
+ ${describeDiff(diff)}` : "";
3457
+ const query = `${stage} ${target} ${request ?? ""}`;
3458
+ const hintsByLens = new Map(team.map((c) => [c.name, memoryHints(deps, query, { role: c.name, silent: true })]));
3459
+ emit({ kind: "agents", agents: team.map((c) => ({ id: `team:${c.name}`, title: `team: ${c.name}`, model: registry.peekModel(c.name) })) });
3460
+ emitBatchInjection(deps, `team:${stage}`, [...hintsByLens.values()]);
3461
+ try {
3462
+ const fresh = await Promise.all(
3463
+ team.map(async (c) => {
3464
+ const tok = { promptTokens: 0, completionTokens: 0 };
3465
+ let resolved;
3466
+ try {
3467
+ resolved = registry.resolve(c.name);
3468
+ } catch (e) {
3469
+ emit({ kind: "agent-result", id: `team:${c.name}`, status: "\u26A0 UNVERIFIED (no model)" });
3470
+ return { name: c.name, recommendation: "revise", findings: [{
3471
+ severity: "critical",
3472
+ 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.`
3473
+ }] };
3474
+ }
3475
+ const hints = hintsByLens.get(c.name);
3476
+ const id = `team:${c.name}`;
3477
+ let serving = registry.peekModel(c.name);
3478
+ const ask = { role: "user", content: `${what} Evaluate it through your lens.${scope}${evidence}` };
3479
+ const signal = deps.signal;
3480
+ const opts = {
3481
+ provider: deps.provider,
3482
+ ...resolved,
3483
+ // Fifteen lenses reading the same change, one file per turn each, is the same waste multiplied.
3484
+ systemPrompt: resolved.systemPrompt + BATCH_TOOLS_NOTE,
3485
+ tools: readOnlyRegistry(deps, { propose: true }),
3486
+ // A slide down the chain is a visible event: rename the row, then let the registry's own note run.
3487
+ onFallback: (from, to, why) => {
3488
+ serving = to;
3489
+ emit({ kind: "agent-model", id, model: to });
3490
+ resolved.onFallback?.(from, to, why);
3491
+ },
3492
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, c.name) ?? false,
3493
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
3494
+ permission: deps.permission,
3495
+ approve: deps.approve,
3496
+ cwd: workdir,
3497
+ signal,
3498
+ // Per MODEL, not once for the chain — see reviewerDeadlineMs.
3499
+ perAttemptMs: reviewerDeadlineMs(deps),
3500
+ maxTurns: REVIEW_MAX_TURNS,
3501
+ // Stream the running total onto this member's row as each call lands — a row that shows only a
3502
+ // ticking clock for minutes says nothing about what it is costing while it is still costing it.
3503
+ onUsage: (u) => {
3504
+ tok.promptTokens += u.promptTokens;
3505
+ tok.completionTokens += u.completionTokens;
3506
+ if (u.model && u.model !== serving) {
3507
+ serving = u.model;
3508
+ emit({ kind: "agent-model", id, model: serving });
3509
+ }
3510
+ emit({ kind: "agent-usage", id, ...tok });
3511
+ }
3512
+ };
3513
+ try {
3514
+ const r = await runWithHealing(deps, c.name, id, opts, AssessmentSchema, signal, emit);
3515
+ const a = { name: c.name, findings: r.findings, recommendation: r.recommendation };
3516
+ reinforceUsed(deps, hints.ids, r.findings.map((f) => f.note).join(" "), c.name);
3517
+ emit({ kind: "agent-result", id: `team:${c.name}`, status: memberStatus(a), ...tok });
3518
+ return a;
3519
+ } catch (e) {
3520
+ if (deps.signal.aborted) throw e;
3521
+ const timedOut = isDeadlineFailure(e);
3522
+ 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)}`;
3523
+ emit({ kind: "agent-result", id: `team:${c.name}`, status: timedOut ? "\u26A0 UNVERIFIED (timed out)" : "\u26A0 UNVERIFIED (no response)", ...tok });
3524
+ 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.` }] };
3525
+ }
3526
+ })
3527
+ );
3528
+ return [...carried, ...fresh];
3529
+ } finally {
3530
+ emit({ kind: "agents", agents: [] });
3531
+ }
3532
+ }
3533
+ function findingsDigest(assessments) {
3534
+ return assessments.map((a) => {
3535
+ const list = a.findings.map((f) => `[${f.severity}] ${f.note}`).join("; ") || "no findings";
3536
+ return `- ${a.name} (${a.recommendation}): ${list}`;
3537
+ }).join("\n");
3538
+ }
3539
+ function teamSummaryNote(assessments, label) {
3540
+ const approve = assessments.filter((a) => a.recommendation === "approve").length;
3541
+ const lines = assessments.map((a) => {
3542
+ const c = severityCounts(a);
3543
+ const counts = c.critical || c.medium || c.low ? ` \u2014 C:${c.critical} M:${c.medium} L:${c.low}` : "";
3544
+ return `- \`${a.name}\` ${a.recommendation === "approve" ? "\u2713 APPROVE" : "\u2717 REJECT"}${counts}`;
3545
+ }).join("\n");
3546
+ return `**Team review of the ${label}** \u2014 ${approve}/${assessments.length} approve:
3547
+ ${lines}`;
3548
+ }
3549
+ async function runCouncil(deps, stage, workdir, target, assessments, request, emit = () => {
3550
+ }, question = "blocking") {
3551
+ const digest = findingsDigest(assessments);
3552
+ const scope = request ? `
3553
+
3554
+ The user's original request:
3555
+ """
3556
+ ${request}
3557
+ """` : "";
3558
+ const subject = stage === "code" ? `the code for: ${target}` : `the "${target}" ${stage}`;
3559
+ const councilDiff = stage === "code" ? await changeUnderReview(deps, workdir) : "";
3560
+ const councilEvidence = stage === "code" ? `
3561
+
3562
+ ${describeDiff(councilDiff)}` : "";
3563
+ const ask = question === "deferral" ? `
3564
+
3565
+ 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.` : "";
3566
+ const hintsByMember = new Map(deps.council.map((c) => [c.name, memoryHints(deps, `${stage} ${target} ${request ?? ""}`, { role: c.name, silent: true })]));
3567
+ emit({ kind: "agents", agents: deps.council.map((c) => ({ id: `council:${c.name}`, title: `council: ${c.name}`, model: deps.councilRegistry.peekModel(c.name) })) });
3568
+ emitBatchInjection(deps, "council", [...hintsByMember.values()]);
3569
+ try {
3570
+ const results = await Promise.all(
3571
+ deps.council.map(async (c) => {
3572
+ const tok = { promptTokens: 0, completionTokens: 0 };
3573
+ let resolved;
3574
+ try {
3575
+ resolved = deps.councilRegistry.resolve(c.name);
3576
+ } catch (e) {
3577
+ emit({ kind: "agent-result", id: `council:${c.name}`, status: "\u26A0 UNVERIFIED (no model)" });
3578
+ return {
3579
+ name: c.name,
3580
+ vote: "revise",
3581
+ rationale: `The "${c.name}" decider has no model assigned (${errText(e)}) \u2014 counted as revise to be safe.`
3582
+ };
3583
+ }
3584
+ const hints = hintsByMember.get(c.name);
3585
+ const id = `council:${c.name}`;
3586
+ let serving = deps.councilRegistry.peekModel(c.name);
3587
+ const vote = { role: "user", content: `You are reviewing ${subject} (the ${stage} stage), plus the team's findings:
3588
+ ${digest}${scope}${ask}${councilEvidence}
3589
+
3590
+ Cast your vote (pass/revise) with a rationale.` };
3591
+ const signal = deps.signal;
3592
+ const opts = {
3593
+ provider: deps.provider,
3594
+ ...resolved,
3595
+ // Fifteen lenses reading the same change, one file per turn each, is the same waste multiplied.
3596
+ systemPrompt: resolved.systemPrompt + BATCH_TOOLS_NOTE,
3597
+ tools: readOnlyRegistry(deps, { propose: true }),
3598
+ // A slide down the chain is a visible event: rename the row, then let the registry's own note run.
3599
+ onFallback: (from, to, why) => {
3600
+ serving = to;
3601
+ emit({ kind: "agent-model", id, model: to });
3602
+ resolved.onFallback?.(from, to, why);
3603
+ },
3604
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, c.name) ?? false,
3605
+ messages: hints.message ? [{ role: "user", content: hints.message }, vote] : [vote],
3606
+ permission: deps.permission,
3607
+ approve: deps.approve,
3608
+ cwd: workdir,
3609
+ signal,
3610
+ // Per MODEL, not once for the chain — see reviewerDeadlineMs.
3611
+ perAttemptMs: reviewerDeadlineMs(deps),
3612
+ maxTurns: REVIEW_MAX_TURNS,
3613
+ // Stream the running total onto this member's row as each call lands — a row that shows only a
3614
+ // ticking clock for minutes says nothing about what it is costing while it is still costing it.
3615
+ onUsage: (u) => {
3616
+ tok.promptTokens += u.promptTokens;
3617
+ tok.completionTokens += u.completionTokens;
3618
+ if (u.model && u.model !== serving) {
3619
+ serving = u.model;
3620
+ emit({ kind: "agent-model", id, model: serving });
3621
+ }
3622
+ emit({ kind: "agent-usage", id, ...tok });
3623
+ }
3624
+ };
3625
+ try {
3626
+ const r = await runWithHealing(deps, c.name, id, opts, CouncilVoteSchema, signal, emit);
3627
+ reinforceUsed(deps, hints.ids, r.rationale, c.name);
3628
+ emit({ kind: "agent-result", id: `council:${c.name}`, status: r.vote === "pass" ? "PASS" : "REVISE", ...tok });
3629
+ return { name: c.name, vote: r.vote, rationale: r.rationale };
3630
+ } catch (e) {
3631
+ if (deps.signal.aborted) throw e;
3632
+ const timedOut = isDeadlineFailure(e);
3633
+ emit({ kind: "agent-result", id: `council:${c.name}`, status: timedOut ? "\u26A0 UNVERIFIED (timed out)" : "\u26A0 UNVERIFIED (no response)", ...tok });
3634
+ 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.` };
3635
+ }
3636
+ })
3637
+ );
3638
+ return results;
3639
+ } finally {
3640
+ emit({ kind: "agents", agents: [] });
3641
+ }
3642
+ }
3643
+ async function runJudge(deps, stage, workdir, target, assessments, votes, request, emit = () => {
3644
+ }, question = "contested", rounds = 0) {
3645
+ const resolved = deps.roleRegistry.resolve("judge");
3646
+ const findings = findingsDigest(assessments);
3647
+ const council = votes.map((v) => `- ${v.name}: ${v.vote} \u2014 ${v.rationale}`).join("\n");
3648
+ const subject = stage === "code" ? `The code for "${target}"` : `The "${target}" ${stage}`;
3649
+ 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.
3650
+ Rule decisively:
3651
+ - "pass" \u2014 the work is good enough for THIS stage; remaining findings are not real blockers (preferred if true).
3652
+ - "revise" \u2014 one more TARGETED attempt is genuinely worth it; say exactly what must change.
3653
+ - "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).`;
3654
+ const hints = memoryHints(deps, `${stage} ${target} ${request ?? ""}`, { role: "judge" });
3655
+ const brief = { role: "user", content: `${subject} is contested (the ${stage} review stage).${request ? `
3656
+
3657
+ The user's original request:
3658
+ """
3659
+ ${request}
3660
+ """` : ""}
3661
+
3662
+ The review team's findings:
3663
+ ${findings}
3664
+
3665
+ The council's votes:
3666
+ ${council}
3667
+
3668
+ ${ask}` };
3669
+ const opts = {
3670
+ provider: deps.provider,
3671
+ ...resolved,
3672
+ tools: readOnlyRegistry(deps, { propose: true }),
3673
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "judge") ?? false,
3674
+ messages: hints.message ? [{ role: "user", content: hints.message }, brief] : [brief],
3675
+ permission: deps.permission,
3676
+ approve: deps.approve,
3677
+ cwd: workdir,
3678
+ signal: deps.signal,
3679
+ perAttemptMs: reviewerDeadlineMs(deps),
3680
+ maxTurns: REVIEW_MAX_TURNS
3681
+ };
3682
+ let d;
3683
+ try {
3684
+ d = await runStructuredRole(opts, JudgeSchema);
3685
+ reinforceUsed(deps, hints.ids, d.feedback.join(" "), "judge");
3686
+ } catch (e) {
3687
+ if (deps.signal.aborted) throw e;
3688
+ emit({ kind: "note", text: `\u{1F528} **Judge** couldn't produce a ruling \u2014 defaulting to revise (re-reviewing).` });
3689
+ return { decision: "revise", feedback: ["The judge could not reach a structured decision; revising and re-reviewing to be safe."], question: "" };
3690
+ }
3691
+ 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.` });
3692
+ return d;
3693
+ }
3694
+ var TEAM_CONSENSUS = 0.7;
3695
+ var REVIEW_SIGNATURES_LOGGED = 8;
3696
+ var SIGNATURE_CHARS = 120;
3697
+ var COUNCIL_SUPERMAJORITY = 0.8;
3698
+ function tallyCouncil(votes) {
3699
+ if (votes.length === 0) return "split";
3700
+ const needed = Math.ceil(votes.length * COUNCIL_SUPERMAJORITY);
3701
+ const pass = votes.filter((v) => v.vote === "pass").length;
3702
+ if (pass >= needed) return "pass";
3703
+ if (votes.length - pass >= needed) return "revise";
3704
+ return "split";
3705
+ }
3706
+ async function runReviewLoop(deps, o) {
3707
+ const { stage, workdir, target, request, revise, askUser, maxRounds } = o;
3708
+ const emit = o.emit ?? (() => {
3709
+ });
3710
+ const language = o.language;
3711
+ const label = stage;
3712
+ let round = 0;
3713
+ let prevSignatures = /* @__PURE__ */ new Set();
3714
+ let deferralVetoUsed = false;
3715
+ let batches = 1;
3716
+ const MAX_BATCHES = 2;
3717
+ let lastAssessments;
3718
+ let lastVotes = [];
3719
+ for (; ; ) {
3720
+ for (let i = 0; i < maxRounds; i++, round++) {
3721
+ if (stage !== "code" && !existsSync8(isAbsolute(target) ? target : join8(workdir, target))) {
3722
+ emit({ kind: "note", text: `\u26A0\uFE0F **${label} not found** at \`${target}\` \u2014 nothing to review. The authoring phase produced no file.` });
3723
+ return { approved: false };
3724
+ }
3725
+ const approvedLast = (lastAssessments ?? []).filter((a) => a.recommendation === "approve");
3726
+ const carry = round > 0 && approvedLast.length < (lastAssessments?.length ?? 0) ? approvedLast : [];
3727
+ 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` });
3728
+ const assessments = await runTeam(deps, stage, workdir, target, request, emit, carry);
3729
+ lastAssessments = assessments;
3730
+ if (assessments.length) emit({ kind: "note", text: teamSummaryNote(assessments, label) });
3731
+ const verdicts = assessments.filter((a) => !a.unverified);
3732
+ const approve = verdicts.filter((a) => a.recommendation === "approve").length;
3733
+ const crit = severityTotal(assessments, "critical");
3734
+ const med = severityTotal(assessments, "medium");
3735
+ const cover = coverage(assessments);
3736
+ if (cover.unverified) {
3737
+ 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."}` });
3738
+ }
3739
+ if (round === 0) {
3740
+ const clean = crit === 0 && med === 0 && cover.enough;
3741
+ if (verdicts.length && clean && approve / verdicts.length >= TEAM_CONSENSUS) {
3742
+ emit({ kind: "note", text: `\u2705 **Team** \u2014 clean (no critical/medium findings), ${approve}/${verdicts.length} approve \u2192 the ${label} is approved.` });
3743
+ return { approved: true };
3744
+ }
3745
+ } else if (crit === 0 && cover.enough) {
3746
+ const deferred = nonBlockingNotes(assessments, stage);
3747
+ if (!deferred.length) {
3748
+ emit({ kind: "note", text: `\u2705 **Team** \u2014 nothing left to fix \u2192 the ${label} is approved.` });
3749
+ return { approved: true };
3750
+ }
3751
+ if (deferralVetoUsed) {
3752
+ emit({ kind: "note", text: `\u2705 **Team** \u2014 only medium/low findings remain \u2192 the ${label} is approved; ${deferred.length} note(s) carried forward.` });
3753
+ return { approved: true, deferred };
3754
+ }
3755
+ 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.` });
3756
+ const dVotes = await runCouncil(deps, stage, workdir, target, assessments, request, emit, "deferral");
3757
+ const dTally = tallyCouncil(dVotes);
3758
+ const dPass = dVotes.filter((v) => v.vote === "pass").length;
3759
+ if (dTally === "pass") {
3760
+ emit({ kind: "note", text: `\u2705 **Council** voted to defer (${dPass}/${dVotes.length} pass) \u2192 the ${label} is approved; ${deferred.length} note(s) carried forward.` });
3761
+ return { approved: true, deferred };
3762
+ }
3763
+ const dJudged = dTally === "revise" ? { decision: "revise", feedback: voteReasons(dVotes), question: "" } : await runJudge(deps, stage, workdir, target, assessments, dVotes, request, emit);
3764
+ if (dJudged.decision === "pass") {
3765
+ emit({ kind: "note", text: `\u2705 **Judge** approved the ${label}; ${deferred.length} note(s) carried forward.` });
3766
+ return { approved: true, deferred };
3767
+ }
3768
+ emit({ kind: "note", text: `\u{1F504} **Council** found a non-critical finding worth fixing \u2192 one more revision of the ${label}.` });
3769
+ deferralVetoUsed = true;
3770
+ await revise(reviseBrief(deferred, dJudged.feedback));
3771
+ continue;
3772
+ }
3773
+ const sig = blockingSignatures(assessments);
3774
+ const stuck = round > 0 && sig.size > 0 && [...sig].every((x) => prevSignatures.has(x));
3775
+ telemetry().event("decision.review_round", {
3776
+ "hc.stage": stage,
3777
+ "hc.review.round": round,
3778
+ "hc.review.blocking": sig.size,
3779
+ "hc.review.repeated": [...sig].filter((x) => prevSignatures.has(x)).length,
3780
+ "hc.review.stuck": stuck,
3781
+ "hc.review.signatures": [...sig].slice(0, REVIEW_SIGNATURES_LOGGED).map((s) => s.length > SIGNATURE_CHARS ? `${s.slice(0, SIGNATURE_CHARS)}\u2026` : s).join(" \xB7 ")
3782
+ });
3783
+ prevSignatures = sig;
3784
+ const blockingFindings = findingNotes(assessments, stage, round === 0 ? ["critical", "medium"] : ["critical"]);
3785
+ const reason = crit || med ? `surfaced ${crit} critical / ${med} medium finding(s)` : `is split (${approve}/${assessments.length} approve)`;
3786
+ emit({ kind: "note", text: `\u{1F465} **Team** ${reason} \u2192 handed the decision to the **council** (${deps.council.length} members vote).` });
3787
+ const votes = await runCouncil(deps, stage, workdir, target, assessments, request, emit);
3788
+ const tally = tallyCouncil(votes);
3789
+ const passVotes = votes.filter((v) => v.vote === "pass").length;
3790
+ let decision;
3791
+ if (tally === "pass") {
3792
+ const deferred = nonBlockingNotes(assessments, stage);
3793
+ 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.` : ""}` });
3794
+ return { approved: true, deferred };
3795
+ } else if (tally === "revise") {
3796
+ emit({ kind: "note", text: `\u{1F504} **Council** voted to revise (${votes.length - passVotes}/${votes.length}) \u2192 sending the ${label} back for changes.` });
3797
+ decision = { decision: "revise", feedback: voteReasons(votes), question: "" };
3798
+ } else {
3799
+ emit({ kind: "note", text: `\u{1F528} **Council** was split (${passVotes}/${votes.length} pass) \u2192 deferred the final decision to the **judge**.` });
3800
+ decision = await runJudge(deps, stage, workdir, target, assessments, votes, request, emit);
3801
+ if (decision.decision === "pass") {
3802
+ const deferred = nonBlockingNotes(assessments, stage);
3803
+ emit({ kind: "note", text: `\u2705 **Judge** approved the ${label}.${deferred.length ? ` ${deferred.length} note(s) carried forward.` : ""}` });
3804
+ return { approved: true, deferred };
3805
+ }
3806
+ }
3807
+ lastVotes = votes;
3808
+ let feedback = decision.feedback;
3809
+ if (decision.decision === "ask-human") {
3810
+ emit({ kind: "note", text: `\u2753 Judge needs your input: ${decision.question}` });
3811
+ const answer2 = await askUser(decision.question);
3812
+ feedback = [...feedback, `Human answer: ${answer2}`];
3813
+ }
3814
+ if (stuck) {
3815
+ 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.` });
3816
+ break;
3817
+ }
3818
+ emit({ kind: "note", text: `\u{1F504} Revising the ${label} with ${blockingFindings.length} finding(s) + the deciders' reasons\u2026` });
3819
+ await revise(reviseBrief(blockingFindings, feedback));
3820
+ }
3821
+ if (lastAssessments) {
3822
+ const finalRuling = await runJudge(deps, stage, workdir, target, lastAssessments, lastVotes, request, emit, "final", round);
3823
+ if (finalRuling.decision === "pass") {
3824
+ const deferred = nonBlockingNotes(lastAssessments, stage);
3825
+ emit({ kind: "note", text: `\u2705 **Judge** ruled the ${label} good enough for this stage.${deferred.length ? ` ${deferred.length} note(s) carried forward.` : ""}` });
3826
+ return { approved: true, deferred };
3827
+ }
3828
+ if (finalRuling.decision === "revise" && batches < MAX_BATCHES) {
3829
+ batches++;
3830
+ emit({ kind: "note", text: `\u{1F504} **Judge** ruled one more targeted attempt is worth it \u2192 another ${maxRounds} round(s).` });
3831
+ await revise(reviseBrief(findingNotes(lastAssessments, stage, ["critical"]), finalRuling.feedback));
3832
+ prevSignatures = /* @__PURE__ */ new Set();
3833
+ continue;
3834
+ }
3835
+ if (finalRuling.decision === "ask-human" && finalRuling.question) {
3836
+ emit({ kind: "note", text: `\u2753 **Judge** needs a decision only you can make: ${finalRuling.question}` });
3837
+ }
3838
+ }
3839
+ 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"];
3840
+ const answer = (await askUser(q, { options: [approveLabel, continueLabel, stopLabel] })).trim();
3841
+ if (answer === continueLabel || /^\s*(review|more|daha|başka|tur|round)/i.test(answer)) continue;
3842
+ if (answer === stopLabel || /^\s*(stop|durdur|iptal|cancel|hay[ıi]r|no)\s*$/i.test(answer)) return { approved: false };
3843
+ if (answer === approveLabel || /^\s*(approve|yes|onayla|onay|evet|kabul|tamam|ok)\s*$/i.test(answer)) return { approved: true };
3844
+ 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." });
3845
+ }
3846
+ }
3847
+ var CORE_CODE_LENSES = [
3848
+ "code-correctness",
3849
+ "code-plan-conformance",
3850
+ "code-tests",
3851
+ "code-security"
3852
+ ];
3853
+ var SMALL_CHANGE_LINES = 40;
3854
+ function changedLines(diff) {
3855
+ let n = 0;
3856
+ for (const line of diff.split("\n")) {
3857
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
3858
+ if (line.startsWith("+") || line.startsWith("-")) n++;
3859
+ }
3860
+ return n;
3861
+ }
3862
+ function lensesFor(team, diff) {
3863
+ if (!diff.trim() || changedLines(diff) > SMALL_CHANGE_LINES) return team;
3864
+ const core = team.filter((c) => CORE_CODE_LENSES.includes(c.name));
3865
+ return core.length ? core : team;
3866
+ }
3867
+ async function changeUnderReview(deps, workdir) {
3868
+ if (deps.baseRef) return taskDiff(workdir, deps.baseRef);
3869
+ if (deps.inPlaceBase) return diffSince(workdir, deps.inPlaceBase);
3870
+ return workingTreeDiff(workdir);
3871
+ }
3872
+ async function runCodeReview(deps, workdir, taskTitle, request, emit = () => {
3873
+ }, attempt = 0) {
3874
+ const diff = await changeUnderReview(deps, workdir);
3875
+ const team = lensesFor(deps.teams.code, diff);
3876
+ const scaled = team.length < deps.teams.code.length;
3877
+ telemetry().event("decision.review_scale", {
3878
+ "hc.decision": "review_scale",
3879
+ "hc.changed_lines": changedLines(diff),
3880
+ "hc.lenses": team.length,
3881
+ "hc.lenses.full": deps.teams.code.length,
3882
+ "hc.scaled": scaled
3883
+ });
3884
+ 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` });
3885
+ const assessments = await runTeam({ ...deps, teams: { ...deps.teams, code: team } }, "code", workdir, taskTitle, request, emit);
3886
+ if (assessments.length) emit({ kind: "note", text: teamSummaryNote(assessments, "code") });
3887
+ const approve = assessments.filter((a) => a.recommendation === "approve").length;
3888
+ const crit = severityTotal(assessments, "critical");
3889
+ const med = severityTotal(assessments, "medium");
3890
+ const cover = coverage(assessments);
3891
+ if (!cover.enough) {
3892
+ 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.`;
3893
+ emit({ kind: "note", text: `\u26A0\uFE0F **Code review** \u2014 ${why}` });
3894
+ return { verdict: "fail", notes: [why] };
3895
+ }
3896
+ if (crit === 0) {
3897
+ const deferred = nonBlockingNotes(assessments, "code");
3898
+ if (!deferred.length) {
3899
+ if (attempt > 0 || !assessments.length || approve / assessments.length >= TEAM_CONSENSUS) {
3900
+ emit({ kind: "note", text: `\u2705 **Team** \u2014 nothing to fix \u2192 the code passed.` });
3901
+ return { verdict: "pass", notes: [] };
3902
+ }
3903
+ }
3904
+ emit({ kind: "note", text: `\u2705 **Team** \u2014 no critical findings; ${deferred.length} medium/low note(s) deferred to the revision pass.` });
3905
+ return { verdict: "pass", notes: [], deferred };
3906
+ }
3907
+ const reason = crit || med ? `surfaced ${crit} critical / ${med} medium finding(s)` : `is split (${approve}/${assessments.length} approve)`;
3908
+ emit({ kind: "note", text: `\u{1F465} **Team** ${reason} \u2192 handed the decision to the **council** (${deps.council.length} members vote).` });
3909
+ const votes = await runCouncil(deps, "code", workdir, taskTitle, assessments, request, emit);
3910
+ const tally = tallyCouncil(votes);
3911
+ const passVotes = votes.filter((v) => v.vote === "pass").length;
3912
+ const blocking = assessments.flatMap((a) => a.findings.filter((f) => f.severity !== "low").map((f) => `[${f.severity}] ${a.name}: ${f.note}`));
3913
+ if (tally === "pass") {
3914
+ emit({ kind: "note", text: `\u2705 **Council** voted to approve (${passVotes}/${votes.length} pass) \u2192 the code passed.` });
3915
+ return { verdict: "pass", notes: [] };
3916
+ }
3917
+ if (tally === "revise") {
3918
+ emit({ kind: "note", text: `\u{1F504} **Council** voted to revise (${votes.length - passVotes}/${votes.length}) \u2192 sending the code back.` });
3919
+ return { verdict: "fail", notes: blocking.length ? blocking : votes.filter((v) => v.vote === "revise").map((v) => v.rationale) };
3920
+ }
3921
+ emit({ kind: "note", text: `\u{1F528} **Council** was split (${passVotes}/${votes.length} pass) \u2192 deferred the final decision to the **judge**.` });
3922
+ const d = await runJudge(deps, "code", workdir, taskTitle, assessments, votes, request, emit);
3923
+ if (d.decision === "pass") return { verdict: "pass", notes: [] };
3924
+ return { verdict: "fail", notes: d.feedback.length ? d.feedback : blocking };
3925
+ }
3926
+
3927
+ // src/engine/acceptance.ts
3928
+ import { z as z16 } from "zod";
3929
+
3930
+ // src/engine/test-runner.ts
3931
+ import { readFile as readFile3 } from "fs/promises";
3932
+ import { existsSync as existsSync9 } from "fs";
3933
+ import { spawn as spawn2 } from "child_process";
3934
+ import { join as join9 } from "path";
3935
+ var TEST_TIMEOUT_MS = 6e5;
3936
+ var MAX_TEST_OUTPUT = 12e3;
3937
+ var PLACEHOLDER = /no test specified/i;
3938
+ async function detectTestCommand(cwd) {
3939
+ const pkgPath = join9(cwd, "package.json");
3940
+ if (existsSync9(pkgPath)) {
3941
+ try {
3942
+ const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
3943
+ const script = pkg.scripts?.test;
3944
+ if (script && !PLACEHOLDER.test(script)) {
3945
+ const runner = existsSync9(join9(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync9(join9(cwd, "yarn.lock")) ? "yarn" : existsSync9(join9(cwd, "bun.lockb")) ? "bun" : "npm";
3946
+ const ci = /\bng test\b/.test(script) && !/--watch|--no-watch/.test(script) ? ["--", "--watch=false", "--browsers=ChromeHeadless"] : [];
3947
+ return { argv: [runner, "test", ...ci], why: `package.json scripts.test: ${script}` };
3948
+ }
3949
+ } catch {
3950
+ }
3951
+ }
3952
+ if (existsSync9(join9(cwd, "pytest.ini")) || existsSync9(join9(cwd, "pyproject.toml")) || existsSync9(join9(cwd, "tox.ini"))) {
3953
+ return { argv: ["python3", "-m", "pytest", "-q"], why: "a pytest configuration is present" };
3954
+ }
3955
+ if (existsSync9(join9(cwd, "go.mod"))) return { argv: ["go", "test", "./..."], why: "go.mod is present" };
3956
+ if (existsSync9(join9(cwd, "Cargo.toml"))) return { argv: ["cargo", "test"], why: "Cargo.toml is present" };
3957
+ if (existsSync9(join9(cwd, "Gemfile"))) return { argv: ["bundle", "exec", "rspec"], why: "a Gemfile is present" };
3958
+ return void 0;
3959
+ }
3960
+ async function runProjectTests(cwd, cmd) {
3961
+ const command = cmd ?? await detectTestCommand(cwd);
3962
+ if (!command) return { skipped: true, passed: true, output: "", timedOut: false };
3963
+ const [bin, ...args] = command.argv;
3964
+ return new Promise((resolve6) => {
3965
+ const child = spawn2(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1" } });
3966
+ let out = "";
3967
+ const take = (d) => {
3968
+ out += d.toString();
3969
+ if (out.length > MAX_TEST_OUTPUT * 4) out = out.slice(-MAX_TEST_OUTPUT * 2);
3970
+ };
3971
+ child.stdout.on("data", take);
3972
+ child.stderr.on("data", take);
3973
+ let timedOut = false;
3974
+ const timer = setTimeout(() => {
3975
+ timedOut = true;
3976
+ child.kill("SIGKILL");
3977
+ }, TEST_TIMEOUT_MS);
3978
+ const done = (code, extra = "") => {
3979
+ clearTimeout(timer);
3980
+ resolve6({
3981
+ skipped: false,
3982
+ passed: code === 0 && !timedOut,
3983
+ command: command.argv.join(" "),
3984
+ why: command.why,
3985
+ output: clampOutput(`${extra}${out}`.trimEnd(), MAX_TEST_OUTPUT),
3986
+ timedOut
3987
+ });
3988
+ };
3989
+ child.on("error", (e) => {
3990
+ clearTimeout(timer);
3991
+ resolve6({
3992
+ skipped: true,
3993
+ passed: true,
3994
+ command: command.argv.join(" "),
3995
+ why: command.why,
3996
+ output: `could not run the suite: ${e.message}`,
3997
+ timedOut: false
3998
+ });
3999
+ });
4000
+ child.on("close", (code) => done(code ?? 1));
4001
+ });
4002
+ }
4003
+ function describeTestRun(run) {
4004
+ if (run.skipped) {
4005
+ 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.";
4006
+ }
4007
+ if (run.timedOut) {
4008
+ 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.
4009
+
4010
+ ${run.output}`;
4011
+ }
4012
+ if (run.passed) return `The project's test suite passed (\`${run.command}\`).`;
4013
+ 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.
4014
+
4015
+ ${run.output}`;
4016
+ }
4017
+
4018
+ // src/engine/acceptance.ts
4019
+ var AcceptanceSchema = z16.object({
4020
+ checks: z16.array(z16.object({
4021
+ criterion: z16.string(),
4022
+ met: z16.boolean(),
4023
+ evidence: z16.string().describe(
4024
+ 'Where you SAW it: a file path and what it contains, a symbol, a test name. "It looks fine" is not evidence.'
4025
+ )
4026
+ }))
4027
+ });
4028
+ var PROMPT = `You are the acceptance gate for one implementation task. You are given the task's acceptance criteria and read-only access to 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).
4029
+ Rules:
4030
+ - Verify by LOOKING. If you did not open the file, the criterion is not met.
4031
+ - "met" means observably true right now \u2014 not "planned", "close enough" or "the implementer says so".
4032
+ - Judge ONLY the listed criteria. Code quality, style and scope opinions belong to the code review, not here.
4033
+ - Write the evidence in ENGLISH (it is a technical record).`;
4034
+ async function verifyAcceptance(deps, card, cwd, emit = () => {
4035
+ }) {
4036
+ const suite = () => deps.timings ? deps.timings.time("test suite", () => runProjectTests(cwd)) : runProjectTests(cwd);
4037
+ const tests = await telemetry().span("stage.test_suite", { "hc.stage": "test suite" }, suite);
4038
+ telemetry().event("tests.run", {
4039
+ "hc.tests.ran": !tests.skipped,
4040
+ "hc.tests.passed": tests.passed,
4041
+ "hc.tests.timed_out": tests.timedOut === true,
4042
+ "hc.tests.command": tests.command
4043
+ });
4044
+ if (!tests.skipped) {
4045
+ 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)" : ""}` });
4046
+ if (!tests.passed) {
4047
+ return {
4048
+ passed: false,
4049
+ unmet: [
4050
+ `The project's test suite fails (\`${tests.command}\`)${tests.timedOut ? " \u2014 it timed out" : ""}. Nothing may enter DONE while it is red.
4051
+ ${tests.output.slice(-4e3)}`,
4052
+ ...card.acceptance.map((c) => `${c} (not assessed \u2014 the suite is red)`)
4053
+ ],
4054
+ tests: { ran: true, passed: false, ...tests.command ? { command: tests.command } : {} }
4055
+ };
4056
+ }
4057
+ }
4058
+ const testEvidence = { ran: !tests.skipped, passed: tests.passed, ...tests.command ? { command: tests.command } : {} };
4059
+ if (!card.acceptance.length) return { passed: true, unmet: [], tests: testEvidence };
4060
+ const diff = deps.baseRef ? await taskDiff(cwd, deps.baseRef) : deps.inPlaceBase ? await diffSince(cwd, deps.inPlaceBase) : "";
4061
+ const resolved = deps.roleRegistry.resolve("code-reviewer");
4062
+ const hints = memoryHints(deps, `${card.title} ${card.acceptance.join(" ")}`, { role: "code-reviewer" });
4063
+ const ask = { role: "user", content: `Task: "${card.title}".
4064
+
4065
+ Acceptance criteria:
4066
+ ${card.acceptance.map((c, i) => `${i + 1}. ${c}`).join("\n")}
4067
+
4068
+ Check each one against the worktree and report met/unmet with the evidence you saw.
4069
+
4070
+ ${describeTestRun(tests)}
4071
+
4072
+ ${describeDiff(diff)}` };
4073
+ const opts = {
4074
+ provider: deps.provider,
4075
+ ...resolved,
4076
+ systemPrompt: `${PROMPT}${deps.roleRegistry.ruleSuffix()}`,
4077
+ tools: readOnlyRegistry(deps),
4078
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
4079
+ permission: deps.permission,
4080
+ approve: deps.approve,
4081
+ cwd,
4082
+ signal: AbortSignal.any([deps.signal, AbortSignal.timeout(CODE_REVIEW_TIMEOUT_MS)]),
4083
+ maxTurns: CODE_REVIEW_MAX_TURNS
4084
+ };
4085
+ let checks;
4086
+ try {
4087
+ ({ checks } = await runStructuredRole(opts, AcceptanceSchema));
4088
+ reinforceUsed(deps, hints.ids, checks.map((c) => c.evidence).join(" "), "code-reviewer");
4089
+ } catch (e) {
4090
+ if (deps.signal.aborted) throw e;
4091
+ emit({ kind: "note", text: `\u26A0\uFE0F **Acceptance gate** could not run for "${card.title}" \u2014 treating the criteria as unmet.` });
4092
+ return {
4093
+ passed: false,
4094
+ unmet: card.acceptance.map((c) => `${c} (not verified: the gate did not run)`),
4095
+ tests: testEvidence
4096
+ };
4097
+ }
4098
+ const byCriterion = new Map(checks.map((c) => [c.criterion.trim().toLowerCase(), c]));
4099
+ const unmet = [];
4100
+ for (const c of card.acceptance) {
4101
+ const hit = byCriterion.get(c.trim().toLowerCase()) ?? checks.find((x) => x.criterion.toLowerCase().includes(c.trim().toLowerCase().slice(0, 40)));
4102
+ if (!hit) unmet.push(`${c} (not reported by the acceptance gate)`);
4103
+ else if (!hit.met) unmet.push(`${c} \u2014 ${hit.evidence}`);
4104
+ }
4105
+ const passed = unmet.length === 0;
4106
+ const result = { passed, unmet, tests: testEvidence };
4107
+ 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}".` });
4108
+ return result;
4109
+ }
4110
+
4111
+ // src/engine/worktree-state.ts
4112
+ async function worktreeState(git, cwd) {
4113
+ const head = await git(["rev-parse", "HEAD"], cwd);
4114
+ if (head.code !== 0) return void 0;
4115
+ const status = await git(["status", "--porcelain"], cwd);
4116
+ return `${head.stdout.trim()}|${status.stdout.trim()}`;
4117
+ }
4118
+ async function hasWorkAgainst(git, cwd, baseRef) {
4119
+ const merge = await git(["merge-base", "HEAD", baseRef], cwd);
4120
+ const at = merge.stdout.trim();
4121
+ if (merge.code !== 0 || !at) return false;
4122
+ const diff = await git(["diff", "--quiet", `${at}..HEAD`], cwd);
4123
+ if (diff.code !== 0) return true;
4124
+ const dirty = await git(["status", "--porcelain"], cwd);
4125
+ return dirty.stdout.trim().length > 0;
4126
+ }
4127
+
4128
+ // src/engine/task-cycle.ts
4129
+ async function runCycleWithRole(deps, board, taskId, cwd, role, git = defaultGitRunner, slot = 0) {
4130
+ board.move(taskId, "IN-PROGRESS", role);
4131
+ const rotation = slot + board.get(taskId).attempts;
4132
+ board.setWorker(taskId, role, deps.roleRegistry.chainFor(role, rotation)[0] ?? "");
4133
+ const before = await worktreeState(git, cwd);
4134
+ const startedAt = before?.split("|")[0];
4135
+ const rdeps = deps.baseRef || !startedAt ? deps : { ...deps, inPlaceBase: startedAt };
4136
+ await runImplementer(deps, role, board.get(taskId), cwd, rotation);
4137
+ const after = await worktreeState(git, cwd);
4138
+ const idle = before !== void 0 && after !== void 0 && before === after && !(deps.baseRef && await hasWorkAgainst(git, cwd, deps.baseRef));
4139
+ if (idle) {
4140
+ 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.";
4141
+ const servedBy = deps.roleRegistry.chainFor(role, rotation)[0] ?? "";
4142
+ telemetry().event("implementer.no_changes", {
4143
+ "hc.task.id": taskId,
4144
+ "hc.role": role,
4145
+ "hc.model": servedBy,
4146
+ "hc.attempt": board.get(taskId).attempts
4147
+ });
4148
+ if (servedBy) {
4149
+ const strikes = deps.fitness?.record(role, servedBy, "answered in prose instead of implementing") ?? 0;
4150
+ if (strikes === UNFIT_AFTER) {
4151
+ 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.`);
4152
+ }
4153
+ }
4154
+ board.appendStage(taskId, { role, action: "no-changes", note: servedBy ? `model: ${servedBy}` : void 0 });
4155
+ board.clearReviewNotes(taskId);
4156
+ board.addReviewNote(taskId, note);
4157
+ board.move(taskId, "TODO", role);
4158
+ deps.note?.(`\u26A0\uFE0F **${board.get(taskId).title}** \u2014 \`${role}\`${servedBy ? ` on \`${servedBy}\`` : ""} wrote nothing; trying the next model.`);
4159
+ return { verdict: "fail", notes: [note], noProgress: true };
4160
+ }
4161
+ deps.fitness?.ok(role, deps.roleRegistry.chainFor(role, rotation)[0] ?? "");
4162
+ board.move(taskId, "REVIEW", role);
4163
+ const card = board.get(taskId);
4164
+ const review = () => runCodeReview(rdeps, cwd, card.title, void 0, (ev) => {
4165
+ if (ev.kind === "note") deps.note?.(ev.text);
4166
+ }, card.attempts);
4167
+ let v;
4168
+ try {
4169
+ v = await telemetry().span(
4170
+ "stage.code_review",
4171
+ { "hc.stage": "code review", "hc.task.id": taskId },
4172
+ () => deps.timings ? deps.timings.time("code review", review) : review()
4173
+ );
4174
+ } catch (e) {
4175
+ if (!deps.signal.aborted) throw e;
4176
+ const note = "Review was cancelled. Add a human note before retrying this task.";
4177
+ board.clearReviewNotes(taskId);
4178
+ board.addReviewNote(taskId, note);
4179
+ board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:cancelled", note });
4180
+ return { verdict: "fail", notes: [note] };
4181
+ }
4182
+ if (deps.signal.aborted) {
4183
+ const note = "Review was cancelled. Add a human note before retrying this task.";
4184
+ board.clearReviewNotes(taskId);
4185
+ board.addReviewNote(taskId, note);
4186
+ board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:cancelled", note });
4187
+ return { verdict: "fail", notes: [note] };
4188
+ }
4189
+ if (v.verdict === "pass") {
4190
+ const check = () => verifyAcceptance(rdeps, board.get(taskId), cwd, (ev) => {
4191
+ if (ev.kind === "note") deps.note?.(ev.text);
4192
+ });
4193
+ const gate = await telemetry().span(
4194
+ "stage.acceptance_gate",
4195
+ { "hc.stage": "acceptance gate", "hc.task.id": taskId },
4196
+ () => deps.timings ? deps.timings.time("acceptance gate", check) : check()
4197
+ );
4198
+ if (!gate.passed) {
4199
+ board.appendStage(taskId, { role: "code-reviewer", action: "acceptance:failed", note: gate.unmet.join("; ") });
4200
+ board.clearReviewNotes(taskId);
4201
+ for (const n of gate.unmet) board.addReviewNote(taskId, `Acceptance criterion not met: ${n}`);
4202
+ board.move(taskId, "TODO", "code-reviewer");
4203
+ return { verdict: "fail", notes: gate.unmet };
4204
+ }
4205
+ board.appendStage(taskId, { role: "code-reviewer", action: "acceptance:passed" });
4206
+ board.appendStage(taskId, { role: "code-reviewer", action: "reviewed:pass" });
4207
+ for (const d of v.deferred ?? []) board.appendStage(taskId, { role: "code-reviewer", action: "deferred", note: d });
4208
+ board.clearReviewNotes(taskId);
4209
+ board.move(taskId, "DONE", "code-reviewer");
4210
+ } else {
4211
+ const notes = v.notes.length > 0 ? v.notes : ["review failed (no notes given)"];
4212
+ board.appendStage(taskId, {
4213
+ role: "code-reviewer",
4214
+ action: "reviewed:fail",
4215
+ note: notes.join("; ")
4216
+ });
4217
+ board.clearReviewNotes(taskId);
4218
+ for (const n of notes) board.addReviewNote(taskId, n);
4219
+ board.move(taskId, "TODO", "code-reviewer");
4220
+ }
4221
+ return v;
4222
+ }
4223
+ async function runTaskCycle(deps, board, taskId, worktreePath, slot = 0) {
4224
+ const task = board.get(taskId);
4225
+ if (!task) throw new Error(`runTaskCycle: unknown task: ${taskId}`);
4226
+ const role = await routeTask(deps, task);
4227
+ board.setWorktree(taskId, worktreePath);
4228
+ return runCycleWithRole(deps, board, taskId, worktreePath, role, defaultGitRunner, slot);
4229
+ }
4230
+
4231
+ // src/board/board.ts
4232
+ import { z as z17 } from "zod";
4233
+ var MAX_STAGE_EVENTS = 200;
4234
+ var stageEventSchema = z17.object({
4235
+ role: z17.string(),
4236
+ action: z17.string(),
4237
+ note: z17.string().optional()
4238
+ });
4239
+ var cardSchema = z17.object({
4240
+ id: z17.string(),
4241
+ title: z17.string(),
4242
+ column: z17.enum(["TODO", "IN-PROGRESS", "REVIEW", "DONE", "MERGED", "PARKED", "ABANDONED"]),
4243
+ worktree: z17.string().optional(),
4244
+ deps: z17.array(z17.string()),
4245
+ acceptance: z17.array(z17.string()).default([]),
4246
+ // default: boards persisted before the gate existed still load
4247
+ files: z17.array(z17.string()).default([]),
4248
+ // ditto — a board written before file lists existed still loads
4249
+ reviewNotes: z17.array(z17.string()),
4250
+ attempts: z17.number(),
4251
+ stageHistory: z17.array(stageEventSchema)
4252
+ });
4253
+ var boardDataSchema = z17.object({ version: z17.literal(1), cards: z17.array(cardSchema) });
4254
+ function migrateDelivered(c) {
4255
+ if (c.column !== "DONE") return c;
4256
+ return c.stageHistory.some((e) => e.action === "merged") ? { ...c, column: "MERGED" } : c;
4257
+ }
4258
+ function cloneCard(c) {
4259
+ return {
4260
+ ...c,
4261
+ deps: [...c.deps],
4262
+ acceptance: [...c.acceptance],
4263
+ files: [...c.files],
4264
+ reviewNotes: [...c.reviewNotes],
4265
+ stageHistory: c.stageHistory.map((e) => ({ ...e }))
4266
+ };
4267
+ }
4268
+ var Board = class _Board {
4269
+ onChange;
4270
+ // called after every mutation (if set; H3a progress events)
4271
+ onMove;
4272
+ // called on a real column transition → action notes
4273
+ cards = /* @__PURE__ */ new Map();
4274
+ constructor(cards = []) {
4275
+ for (const c of cards) this.cards.set(c.id, cloneCard(c));
4276
+ }
4277
+ addCard(input) {
4278
+ if (this.cards.has(input.id)) throw new Error(`card already exists: ${input.id}`);
4279
+ const card = {
4280
+ id: input.id,
4281
+ title: input.title,
4282
+ column: "TODO",
4283
+ deps: input.deps ? [...input.deps] : [],
4284
+ acceptance: input.acceptance ? [...input.acceptance] : [],
4285
+ files: input.files ? [...input.files] : [],
4286
+ reviewNotes: [],
4287
+ attempts: 0,
4288
+ stageHistory: []
4289
+ };
4290
+ this.cards.set(card.id, card);
4291
+ this.onChange?.();
4292
+ return cloneCard(card);
4293
+ }
4294
+ get(id) {
4295
+ const c = this.cards.get(id);
4296
+ return c ? cloneCard(c) : void 0;
4297
+ }
4298
+ list() {
4299
+ return [...this.cards.values()].map(cloneCard);
4300
+ }
4301
+ byColumn(column) {
4302
+ return this.list().filter((c) => c.column === column);
4303
+ }
4304
+ require(id) {
4305
+ const c = this.cards.get(id);
4306
+ if (!c) throw new Error(`unknown card: ${id}`);
4307
+ return c;
4308
+ }
4309
+ move(id, column, actor) {
4310
+ const c = this.require(id);
4311
+ const from = c.column;
4312
+ c.column = column;
4313
+ if (actor) {
4314
+ c.stageHistory.push({ role: actor, action: `\u2192${column}` });
4315
+ if (c.stageHistory.length > MAX_STAGE_EVENTS) {
4316
+ c.stageHistory.splice(0, c.stageHistory.length - MAX_STAGE_EVENTS);
4317
+ }
4318
+ }
4319
+ if (from !== column) this.onMove?.(c, from, column, actor);
4320
+ this.onChange?.();
4321
+ }
4322
+ /**
4323
+ * Records a dependency the breakdown missed.
4324
+ *
4325
+ * Returns false — rather than throwing — for anything that is not a real new edge (unknown id, self, or
4326
+ * already present), because the caller is an audit whose input is a model's suggestion: a nonsense entry
4327
+ * is an expected outcome there, not an exceptional one.
4328
+ */
4329
+ addDep(id, dependsOn) {
4330
+ const c = this.cards.get(id);
4331
+ if (!c || id === dependsOn || !this.cards.has(dependsOn) || c.deps.includes(dependsOn)) return false;
4332
+ c.deps.push(dependsOn);
4333
+ this.onChange?.();
4334
+ return true;
4335
+ }
4336
+ /** Undoes `addDep` — used when the added edge turns out to close a cycle. */
4337
+ removeDep(id, dependsOn) {
4338
+ const c = this.cards.get(id);
4339
+ if (!c) return;
4340
+ c.deps = c.deps.filter((d) => d !== dependsOn);
4341
+ this.onChange?.();
4342
+ }
4343
+ appendStage(id, event) {
4344
+ this.require(id).stageHistory.push({ ...event });
4345
+ this.onChange?.();
4346
+ }
4347
+ /** Records the model of the implementer now working this card (surfaced in the live-agents UI). */
4348
+ setModel(id, model) {
4349
+ this.require(id).model = model;
4350
+ this.onChange?.();
4351
+ }
4352
+ /**
4353
+ * Returns an interrupted card to TODO and forgets who was working it.
4354
+ *
4355
+ * Deliberately silent — no stage event, no move note: nothing HAPPENED to this task, a process died. The
4356
+ * chat should not report a transition the user did not cause and no agent performed.
4357
+ */
4358
+ reopen(id) {
4359
+ const c = this.require(id);
4360
+ c.column = "TODO";
4361
+ c.role = void 0;
4362
+ c.model = void 0;
4363
+ this.onChange?.();
4364
+ }
4365
+ /** Records WHO is working this card: the role and the model it will actually use. */
4366
+ setWorker(id, role, model) {
4367
+ const c = this.require(id);
4368
+ c.role = role;
4369
+ c.model = model;
4370
+ this.onChange?.();
4371
+ }
4372
+ addReviewNote(id, note) {
4373
+ this.require(id).reviewNotes.push(note);
4374
+ this.onChange?.();
4375
+ }
4376
+ clearReviewNotes(id) {
4377
+ this.require(id).reviewNotes = [];
4378
+ this.onChange?.();
4379
+ }
4380
+ /**
4381
+ * Starts the escalation ladder over for one card, keeping its history.
4382
+ *
4383
+ * The tier is derived from `attempts`, so a task carrying a large count from earlier runs begins at the
4384
+ * council — the most expensive tier, and the one that had already failed it. Everything that actually
4385
+ * happened stays in `stageHistory`; only the counter that picks the tier goes back to zero.
4386
+ */
4387
+ resetAttempts(id) {
4388
+ const c = this.require(id);
4389
+ if (c.attempts === 0) return;
4390
+ c.stageHistory.push({ role: "team-lead", action: "reset", note: `new run \u2014 ladder restarted (was ${c.attempts})` });
4391
+ c.attempts = 0;
4392
+ this.onChange?.();
4393
+ }
4394
+ incrementAttempts(id) {
4395
+ const c = this.require(id);
4396
+ c.attempts += 1;
4397
+ this.onChange?.();
4398
+ return c.attempts;
4399
+ }
4400
+ setWorktree(id, path) {
4401
+ this.require(id).worktree = path;
4402
+ this.onChange?.();
4403
+ }
4404
+ toJSON() {
4405
+ return { version: 1, cards: this.list() };
4406
+ }
4407
+ static fromJSON(data) {
4408
+ const parsed = boardDataSchema.parse(data);
4409
+ return new _Board(parsed.cards.map(migrateDelivered));
4410
+ }
4411
+ };
4412
+
4413
+ export {
4414
+ describeInherited,
4415
+ describeTopUp,
4416
+ toSlug,
4417
+ mainWorktreeRoot,
4418
+ WorktreeManager,
4419
+ applySkills,
4420
+ buildSkillTool,
4421
+ RoleRegistry,
4422
+ unfinishedSessions,
4423
+ describeUnfinished,
4424
+ gitTool,
4425
+ buildRememberTool,
4426
+ routeSkills,
4427
+ REQUIRED_ROLES,
4428
+ DEFAULT_ROLE_SKILLS,
4429
+ DEFAULT_PROMPTS,
4430
+ SPEC_TEAM,
4431
+ PLAN_TEAM,
4432
+ CODE_TEAM,
4433
+ DEFAULT_COUNCIL,
4434
+ placedSkills,
4435
+ specsDir,
4436
+ constitutionPath,
4437
+ verifyPaths,
4438
+ featureSlugFor,
4439
+ nextFeatureSlug,
4440
+ scaffoldFeature,
4441
+ constitutionNote,
4442
+ readOnlyRegistry,
4443
+ deferMcp,
4444
+ runReviewer,
4445
+ asChoice,
4446
+ buildTeamRegistry,
4447
+ buildCouncilRegistry,
4448
+ runReviewLoop,
4449
+ writeFileTool,
4450
+ editFileTool,
4451
+ shellTool,
4452
+ createWebFetchTool,
4453
+ createDefaultRegistry,
4454
+ changedByMerge,
4455
+ refreshAfterChange,
4456
+ LONG_CALL_MS,
4457
+ routeTask,
4458
+ commitFile,
4459
+ commitStep,
4460
+ squashTask,
4461
+ WHAT_IT_COST,
4462
+ runImplementer,
4463
+ RoleFitness,
4464
+ worktreeState,
4465
+ hasWorkAgainst,
4466
+ runCycleWithRole,
4467
+ runTaskCycle,
4468
+ Board
4469
+ };