@hizliemre/horse-code 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/{app-5FXHE7GX.js → app-KR7TAHUD.js} +82 -40
  2. package/dist/{chunk-LNW557IO.js → chunk-372X5HHU.js} +2 -2
  3. package/dist/{chunk-XEGQT5EN.js → chunk-4M6LXNG2.js} +1 -1
  4. package/dist/{chunk-6OSEQOYY.js → chunk-6S4WWQMN.js} +2 -2
  5. package/dist/chunk-ACTVFJRW.js +989 -0
  6. package/dist/{chunk-LLL7QWXB.js → chunk-BFIZMM4G.js} +6 -6
  7. package/dist/chunk-CYLPQWIF.js +214 -0
  8. package/dist/{chunk-AE36LLL2.js → chunk-JLWQCA7B.js} +2 -209
  9. package/dist/{chunk-XYZVZPAY.js → chunk-KXBYRU4W.js} +66 -31
  10. package/dist/chunk-MZM24M5M.js +251 -0
  11. package/dist/chunk-NBTH2VVI.js +1945 -0
  12. package/dist/chunk-PIG54WFU.js +2873 -0
  13. package/dist/{run-P6ZYL5JL.js → chunk-QJYVZPLG.js} +133 -389
  14. package/dist/{chunk-YPZP7LYL.js → chunk-UANNVVIU.js} +1 -1
  15. package/dist/{chunk-UGESK765.js → chunk-UTHLEW5V.js} +1 -1
  16. package/dist/{chunk-KAGKX2YT.js → chunk-ZPJP2VH5.js} +10 -1
  17. package/dist/cli.js +262 -69
  18. package/dist/{fix-ONLA45HD.js → fix-SSDUVV4T.js} +11 -8
  19. package/dist/{ongoing-WHYXPW24.js → ongoing-6NUSPSCV.js} +3 -2
  20. package/dist/{project-graph-5HNPRFQG.js → project-graph-OGIM2B33.js} +1 -1
  21. package/dist/research-MHBNZ6SA.js +111 -0
  22. package/dist/run-V5ZLZ3LS.js +274 -0
  23. package/dist/{save-skills-ZW5GY6KV.js → save-skills-NPKTYNAF.js} +2 -2
  24. package/dist/{trace-X6TU3AG6.js → trace-UVMZZRA5.js} +1 -1
  25. package/dist/{trace-adopt-URECQWJV.js → trace-adopt-7HWELJFE.js} +1 -1
  26. package/dist/{trace-run-7U4WJZ3V.js → trace-run-OBOD552Q.js} +9 -4
  27. package/dist/{triage-FCYHD2AQ.js → triage-IFCVL5MA.js} +7 -6
  28. package/dist/{verify-LC57A6H2.js → verify-2DTBG6RG.js} +26 -20
  29. package/package.json +1 -1
  30. package/dist/chunk-MRZVA5JB.js +0 -163
  31. package/dist/chunk-UEWVVN5L.js +0 -5691
  32. package/dist/{chunk-EAF22QIG.js → chunk-JR2JLRE3.js} +3 -3
@@ -0,0 +1,1945 @@
1
+ import {
2
+ checkpointKey,
3
+ checkpointMtime,
4
+ isContinuePrompt,
5
+ readCheckpoint
6
+ } from "./chunk-ZSQ24YDJ.js";
7
+ import {
8
+ applySkills,
9
+ buildSkillTool,
10
+ placedSkills
11
+ } from "./chunk-MZM24M5M.js";
12
+ import {
13
+ defaultGitRunner,
14
+ gitVerb
15
+ } from "./chunk-LPQU436C.js";
16
+ import {
17
+ BATCH_TOOLS_NOTE,
18
+ contextTools,
19
+ globTool,
20
+ grepTool,
21
+ memoryHints,
22
+ projectToolsNote,
23
+ readFileTool,
24
+ reinforceUsed
25
+ } from "./chunk-BFIZMM4G.js";
26
+ import {
27
+ ToolRegistry,
28
+ runStructuredRole
29
+ } from "./chunk-CYLPQWIF.js";
30
+ import {
31
+ truncateSafe
32
+ } from "./chunk-JLWQCA7B.js";
33
+ import {
34
+ traceRootRel
35
+ } from "./chunk-ZPJP2VH5.js";
36
+ import {
37
+ loadGraphSync
38
+ } from "./chunk-4M6LXNG2.js";
39
+
40
+ // src/worktree/slug.ts
41
+ var MAX_SLUG = 60;
42
+ var MAX_WORDS = 5;
43
+ var LEADING_VERBS = /* @__PURE__ */ new Set([
44
+ "add",
45
+ "build",
46
+ "create",
47
+ "implement",
48
+ "make",
49
+ "write",
50
+ "develop",
51
+ "design",
52
+ "generate",
53
+ "introduce",
54
+ "fix",
55
+ "update",
56
+ "change",
57
+ "modify",
58
+ "refactor",
59
+ "rewrite",
60
+ "improve",
61
+ "enhance",
62
+ "optimize",
63
+ "clean",
64
+ "setup",
65
+ "configure",
66
+ "install",
67
+ "remove",
68
+ "delete",
69
+ "drop",
70
+ "rename",
71
+ "migrate",
72
+ "move",
73
+ "port",
74
+ "support",
75
+ "enable",
76
+ "disable",
77
+ "finalize",
78
+ "complete",
79
+ "finish",
80
+ "expand",
81
+ "extend",
82
+ "set",
83
+ "apply"
84
+ ]);
85
+ var FILLERS = /* @__PURE__ */ new Set(["a", "an", "the"]);
86
+ function dropLeadingAction(words2) {
87
+ let i = 0;
88
+ if (words2.length && LEADING_VERBS.has(words2[0])) {
89
+ i = 1;
90
+ while (i < words2.length && FILLERS.has(words2[i])) i++;
91
+ } else {
92
+ while (i < words2.length && FILLERS.has(words2[i])) i++;
93
+ }
94
+ return i < words2.length ? words2.slice(i) : words2;
95
+ }
96
+ function toSlug(name) {
97
+ const words2 = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
98
+ const named = dropLeadingAction(words2).slice(0, MAX_WORDS);
99
+ const s = named.join("-").slice(0, MAX_SLUG).replace(/-+$/g, "");
100
+ return s || "job";
101
+ }
102
+ function uniqueSlug(base, taken) {
103
+ if (!taken(base)) return base;
104
+ let n = 2;
105
+ while (taken(`${base}-${n}`)) n++;
106
+ return `${base}-${n}`;
107
+ }
108
+
109
+ // src/worktree/manager.ts
110
+ import { mkdir as mkdir2, writeFile, rm as rm2 } from "fs/promises";
111
+ import { existsSync as existsSync2, readdirSync, realpathSync } from "fs";
112
+ import { join as join2, resolve, dirname as dirname2, basename } from "path";
113
+
114
+ // src/worktree/inherit.ts
115
+ import { cp, mkdir, rm, stat } from "fs/promises";
116
+ import { existsSync } from "fs";
117
+ import { dirname, join, sep } from "path";
118
+ var INHERITED_ASSETS = [
119
+ join("graphify-out", "graph.json"),
120
+ // The community names beside it: without them a session's graph tools fall back to numbers, which is the
121
+ // difference between "this touches Wallet Member & Balance" and "this touches community 47".
122
+ join("graphify-out", ".graphify_labels.json"),
123
+ join(".horsecode", "memory.jsonl"),
124
+ join(".horsecode", "skills"),
125
+ join(".specify", "memory", "constitution.md"),
126
+ join(".horsecode", "migrated.json")
127
+ ];
128
+ var NEVER = [join(".horsecode", "worktrees")];
129
+ var excluded = (rel) => NEVER.some((n) => rel === n || rel.startsWith(n + sep) || rel.startsWith(n + "/"));
130
+ function nestedCheckout(repoRoot, rel, cache) {
131
+ const parts = rel.split(/[\\/]/).slice(0, -1);
132
+ let acc = "";
133
+ for (const part of parts) {
134
+ acc = acc ? `${acc}/${part}` : part;
135
+ let hit = cache.get(acc);
136
+ if (hit === void 0) {
137
+ hit = existsSync(join(repoRoot, acc, ".git"));
138
+ cache.set(acc, hit);
139
+ }
140
+ if (hit) return true;
141
+ }
142
+ return false;
143
+ }
144
+ var MAX_UNTRACKED = 5e3;
145
+ async function copyPath(from, to) {
146
+ await mkdir(dirname(to), { recursive: true });
147
+ await cp(from, to, { recursive: true, dereference: true, force: true });
148
+ }
149
+ async function inheritFromRoot(git, repoRoot, baseWorktree) {
150
+ const out = { modified: [], untracked: [], assets: [], deleted: [], skipped: 0 };
151
+ if (repoRoot === baseWorktree) return out;
152
+ const changed = await git(["diff", "--name-status", "HEAD"], repoRoot);
153
+ if (changed.code === 0) {
154
+ for (const line of changed.stdout.split("\n")) {
155
+ const [status, ...rest] = line.trim().split(/\t/);
156
+ const rel = rest.join(" ");
157
+ if (!status || !rel || excluded(rel)) continue;
158
+ try {
159
+ if (status.startsWith("D")) {
160
+ await rm(join(baseWorktree, rel), { force: true });
161
+ out.deleted.push(rel);
162
+ } else {
163
+ await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
164
+ out.modified.push(rel);
165
+ }
166
+ } catch {
167
+ }
168
+ }
169
+ }
170
+ const others = await git(["ls-files", "--others", "--exclude-standard"], repoRoot);
171
+ if (others.code === 0) {
172
+ const nested = /* @__PURE__ */ new Map();
173
+ for (const rel of others.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
174
+ if (excluded(rel) || nestedCheckout(repoRoot, rel, nested)) {
175
+ out.skipped++;
176
+ continue;
177
+ }
178
+ if (out.untracked.length >= MAX_UNTRACKED) {
179
+ out.skipped++;
180
+ continue;
181
+ }
182
+ try {
183
+ await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
184
+ out.untracked.push(rel);
185
+ } catch {
186
+ }
187
+ }
188
+ }
189
+ for (const rel of INHERITED_ASSETS) {
190
+ const from = assetSource(repoRoot, rel);
191
+ if (!from) continue;
192
+ try {
193
+ await stat(from);
194
+ await copyPath(from, join(baseWorktree, rel));
195
+ out.assets.push(rel);
196
+ } catch {
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ function assetSource(repoRoot, rel) {
202
+ const atRoot = join(repoRoot, rel);
203
+ if (existsSync(atRoot)) return atRoot;
204
+ const standing = join(repoRoot, ".horsecode", "worktrees", "traces", "base", rel);
205
+ return existsSync(standing) ? standing : void 0;
206
+ }
207
+ function describeInherited(i) {
208
+ const parts = [];
209
+ const n = i.modified.length + i.deleted.length;
210
+ if (n) parts.push(`${n} uncommitted change(s)`);
211
+ if (i.untracked.length) parts.push(`${i.untracked.length} untracked file(s)`);
212
+ if (i.skipped) parts.push(`${i.skipped} left behind (another checkout, or past the ${MAX_UNTRACKED} bound)`);
213
+ if (i.assets.length) parts.push(i.assets.map((a) => `\`${a}\``).join(", "));
214
+ return parts.length ? `\u{1F4E5} Carried into this session: ${parts.join(" \xB7 ")}.` : void 0;
215
+ }
216
+ async function topUpInherited(repoRoot, baseWorktree) {
217
+ const added = [];
218
+ if (repoRoot === baseWorktree) return added;
219
+ for (const rel of INHERITED_ASSETS) {
220
+ const from = join(repoRoot, rel);
221
+ const to = join(baseWorktree, rel);
222
+ if (!existsSync(from) || existsSync(to)) continue;
223
+ try {
224
+ await copyPath(from, to);
225
+ added.push(rel);
226
+ } catch {
227
+ }
228
+ }
229
+ return added;
230
+ }
231
+ function describeTopUp(added) {
232
+ if (!added.length) return void 0;
233
+ return `\u{1F4E5} This session was opened before ${added.map((a) => `\`${a}\``).join(", ")} existed \u2014 carried in now.`;
234
+ }
235
+
236
+ // src/worktree/manager.ts
237
+ var MAX_DIFF_CHARS = 12e4;
238
+ var DOC_SPECS = ["*.md", "*.txt"];
239
+ function excludeOwnState() {
240
+ const roots = [".horsecode", "graphify-out", traceRootRel()].filter(Boolean);
241
+ return [...new Set(roots)].map((r) => `:(exclude)${r}/**`);
242
+ }
243
+ async function mainWorktreeRoot(git, cwd) {
244
+ const abs = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
245
+ const r = abs.code === 0 ? abs : await git(["rev-parse", "--git-common-dir"], cwd);
246
+ if (r.code !== 0 || !r.stdout.trim()) return cwd;
247
+ const common = resolve(cwd, r.stdout.trim());
248
+ return basename(common) === ".git" ? dirname2(common) : cwd;
249
+ }
250
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
251
+ var DAYS = ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"];
252
+ function sessionName(now, taken) {
253
+ const day = `${String(now.getDate()).padStart(2, "0")}-${MONTHS[now.getMonth()]}-${now.getFullYear()}-${DAYS[now.getDay()]}`;
254
+ for (let n = 1; n < 1e3; n++) {
255
+ const name = `${day}_${String(n).padStart(2, "0")}`;
256
+ if (!taken(name)) return name;
257
+ }
258
+ return `${day}_${Date.now()}`;
259
+ }
260
+ var FORBIDDEN_AT_ROOT = /* @__PURE__ */ new Set([
261
+ "merge",
262
+ "rebase",
263
+ "cherry-pick",
264
+ "revert",
265
+ "reset",
266
+ "checkout",
267
+ "switch",
268
+ "restore",
269
+ "commit",
270
+ "am",
271
+ "apply",
272
+ "stash",
273
+ "pull",
274
+ "clean"
275
+ ]);
276
+ function guardRoot(run, repoRoot) {
277
+ return async (args, cwd) => {
278
+ const verb = gitVerb(args);
279
+ if (cwd === repoRoot && verb !== void 0 && FORBIDDEN_AT_ROOT.has(verb)) {
280
+ throw new Error(
281
+ `refusing to run \`git ${verb}\` in the project checkout (${repoRoot}). A session's work stays on its own branch and in its own worktree; bringing it in is the user's decision, taken in their own time.`
282
+ );
283
+ }
284
+ return run(args, cwd);
285
+ };
286
+ }
287
+ var WorktreeManager = class {
288
+ repoRoot;
289
+ /** The project checkout this manager was built for — where per-project settings and the remote live. */
290
+ get projectRoot() {
291
+ return this.repoRoot;
292
+ }
293
+ /**
294
+ * Where sessions are kept, which is the REPOSITORY's business and not the caller's checkout.
295
+ *
296
+ * Measured from a live run: started inside another tool's worktree, horse-code opened its session at
297
+ * `…/.claude/worktrees/product-create-wizard/.horsecode/worktrees/…/base` — its own worktree nested inside
298
+ * someone else's, inside the repository. That works and is a place nobody will look: `/clean-worktrees` at
299
+ * the repository root cannot see it, and removing the outer checkout takes it with it.
300
+ *
301
+ * Distinct from `repoRoot` on purpose. What a session INHERITS — the code graph, the memory, the project
302
+ * config — is whatever the user is standing in, and that is frequently not the main checkout.
303
+ */
304
+ worktreeHome;
305
+ git;
306
+ /**
307
+ * The unguarded runner, for the one case that legitimately needs a forbidden verb: turning a directory
308
+ * that is not a repository into one.
309
+ *
310
+ * `git worktree add` needs a commit to branch from, and an empty repository has none — so a first commit
311
+ * is not delivery, it is the precondition for ever leaving the root alone again. It runs only when there
312
+ * is no HEAD, so there is no branch to disturb and no work to overwrite. Named rather than flagged, so
313
+ * grepping for it finds every use.
314
+ */
315
+ rawGit;
316
+ /** Injectable clock: a session's NAME is the day it opened, so a test has to be able to say which day. */
317
+ now;
318
+ constructor(deps) {
319
+ this.repoRoot = deps.repoRoot;
320
+ this.worktreeHome = deps.worktreeHome ?? deps.repoRoot;
321
+ this.rawGit = deps.runGit ?? defaultGitRunner;
322
+ this.git = guardRoot(this.rawGit, deps.repoRoot);
323
+ this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
324
+ }
325
+ /** Runs git; nonzero exit → throws a clear error. Returns output (stdout). */
326
+ async run(args, cwd) {
327
+ const r = await this.git(args, cwd);
328
+ if (r.code !== 0) {
329
+ throw new Error(`git ${args.join(" ")} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
330
+ }
331
+ return r.stdout;
332
+ }
333
+ /** `git init` the repo if the directory isn't one yet (the user may not have run git init) + ensure an
334
+ * identity so the first commit doesn't fail on a machine with no global git config. */
335
+ async ensureRepo() {
336
+ const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
337
+ if (inside.code === 0 && inside.stdout.trim() === "true") return;
338
+ await this.run(["init", "-b", "main"], this.repoRoot);
339
+ const email = await this.git(["config", "user.email"], this.repoRoot);
340
+ if (email.code !== 0 || !email.stdout.trim()) {
341
+ await this.git(["config", "user.email", "horse-code@local"], this.repoRoot);
342
+ await this.git(["config", "user.name", "horse-code"], this.repoRoot);
343
+ }
344
+ }
345
+ /**
346
+ * A worktree must branch off a commit. First ensure the directory IS a git repo (auto `git init` if not).
347
+ * A freshly `git init`-ed repo has an unborn HEAD (no commits), so `git worktree add … <branch>` fails with
348
+ * "invalid reference". Bootstrap one empty commit so horse-code works in a brand-new / non-git directory.
349
+ */
350
+ async ensureBaseCommit() {
351
+ await this.ensureRepo();
352
+ const head = await this.git(["rev-parse", "--verify", "--quiet", "HEAD"], this.repoRoot);
353
+ if (head.code === 0) return;
354
+ const r = await this.rawGit(["commit", "--allow-empty", "-m", "hc: initial commit"], this.repoRoot);
355
+ if (r.code !== 0) throw new Error(`git commit --allow-empty failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
356
+ }
357
+ /**
358
+ * The ref to base the session's worktree on. Uses `fromBranch` when it resolves; otherwise falls back to
359
+ * HEAD. This covers the common fresh-repo mismatch: horse-code guesses "main" but the repo's actual
360
+ * (default/unborn) branch is "master", so "main" never resolves even after the bootstrap commit.
361
+ */
362
+ async resolveBase(fromBranch) {
363
+ const ok = await this.git(["rev-parse", "--verify", "--quiet", fromBranch], this.repoRoot);
364
+ return ok.code === 0 ? fromBranch : "HEAD";
365
+ }
366
+ async openSession(fromBranch, jobName) {
367
+ await this.ensureBaseCommit();
368
+ const base = await this.resolveBase(fromBranch);
369
+ const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
370
+ await mkdir2(worktreesDir, { recursive: true });
371
+ await writeFile(join2(worktreesDir, ".gitignore"), "*\n", "utf8");
372
+ const listed = await this.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/hc/"], this.repoRoot);
373
+ const branches = new Set(listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean));
374
+ const jobSlug = sessionName(this.now(), (s) => existsSync2(join2(worktreesDir, s)) || branches.has(`hc/${s}/base`));
375
+ const root = join2(worktreesDir, jobSlug);
376
+ const baseWorktree = join2(root, "base");
377
+ const baseBranch = `hc/${jobSlug}/base`;
378
+ await mkdir2(join2(root, "tasks"), { recursive: true });
379
+ await this.run(["worktree", "add", "-b", baseBranch, baseWorktree, base], this.repoRoot);
380
+ const inherited = await inheritFromRoot((args, cwd) => this.git(args, cwd), this.repoRoot, baseWorktree);
381
+ return { jobSlug, root, baseWorktree, baseBranch, inherited };
382
+ }
383
+ /**
384
+ * Opens — or re-enters — a worktree with a FIXED name, for the standing work that is not one job.
385
+ *
386
+ * `openSession` mints a fresh dated slug every call, which is right for a job: two runs of "add login" are
387
+ * two pieces of work and must not share a branch. Tracing is the opposite. It is one long-lived artefact
388
+ * the project keeps, its index is checkpointed so an interrupted run resumes, and a new worktree per
389
+ * invocation would both lose that resumption and pile up full checkouts — measured on the project this was
390
+ * written for, a checkout is not small.
391
+ *
392
+ * So the slug is the caller's, and running it twice re-enters the same place. Re-entry is decided by git
393
+ * rather than by the directory existing: a leftover directory git no longer tracks is not a worktree, and
394
+ * treating one as resumable is how a run ends up writing into a checkout that no longer has a branch.
395
+ */
396
+ async openFixed(fromBranch, slug) {
397
+ await this.ensureBaseCommit();
398
+ const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
399
+ await mkdir2(worktreesDir, { recursive: true });
400
+ await writeFile(join2(worktreesDir, ".gitignore"), "*\n", "utf8");
401
+ const root = join2(worktreesDir, slug);
402
+ const baseWorktree = join2(root, "base");
403
+ const baseBranch = `hc/${slug}/base`;
404
+ let real;
405
+ try {
406
+ real = realpathSync(baseWorktree);
407
+ } catch {
408
+ }
409
+ if (real && (await this.registeredWorktrees()).has(real)) {
410
+ return { jobSlug: slug, root, baseWorktree, baseBranch, resumed: true };
411
+ }
412
+ const base = await this.resolveBase(fromBranch);
413
+ await mkdir2(join2(root, "tasks"), { recursive: true });
414
+ const listed = await this.git(["for-each-ref", "--format=%(refname:short)", `refs/heads/${baseBranch}`], this.repoRoot);
415
+ const exists = listed.stdout.trim() === baseBranch;
416
+ await this.run(exists ? ["worktree", "add", baseWorktree, baseBranch] : ["worktree", "add", "-b", baseBranch, baseWorktree, base], this.repoRoot);
417
+ const inherited = await inheritFromRoot((args, cwd) => this.git(args, cwd), this.repoRoot, baseWorktree);
418
+ return { jobSlug: slug, root, baseWorktree, baseBranch, inherited };
419
+ }
420
+ /** Absolute paths of the worktrees git currently tracks (from `git worktree list --porcelain`). */
421
+ async registeredWorktrees() {
422
+ const r = await this.git(["worktree", "list", "--porcelain"], this.repoRoot);
423
+ const paths = /* @__PURE__ */ new Set();
424
+ for (const line of r.stdout.split("\n")) {
425
+ if (line.startsWith("worktree ")) {
426
+ const p = line.slice("worktree ".length).trim();
427
+ try {
428
+ paths.add(realpathSync(p));
429
+ } catch {
430
+ paths.add(p);
431
+ }
432
+ }
433
+ }
434
+ return paths;
435
+ }
436
+ /**
437
+ * Resume support: find a preserved worktree from an earlier interrupted run. Scans every
438
+ * `.horsecode/worktrees/<slug>/checkpoint.json` and only considers a session whose `base` worktree is still
439
+ * live in git (a pruned/stale dir can't be safely reused). A bare "continue" request (`isContinuePrompt`)
440
+ * matches any preserved work — the user needn't retype the original request — and among those, the one with
441
+ * ACTUAL PROGRESS wins over the merely most recent. Otherwise the prompt must match a checkpoint's stored
442
+ * `rawPrompt` (case/space-tolerant). Returns null when there is nothing to resume.
443
+ */
444
+ async findResumable(rawPrompt) {
445
+ const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
446
+ if (!existsSync2(worktreesDir)) return null;
447
+ const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
448
+ if (inside.code !== 0) return null;
449
+ const anyContinue = isContinuePrompt(rawPrompt);
450
+ const key = checkpointKey(rawPrompt);
451
+ const registered = await this.registeredWorktrees();
452
+ const candidates = [];
453
+ for (const slug of readdirSync(worktreesDir)) {
454
+ const root = join2(worktreesDir, slug);
455
+ const cp2 = readCheckpoint(root);
456
+ if (!cp2) continue;
457
+ if (!anyContinue && checkpointKey(cp2.rawPrompt) !== key) continue;
458
+ const baseWorktree = join2(root, "base");
459
+ let real;
460
+ try {
461
+ real = realpathSync(baseWorktree);
462
+ } catch {
463
+ continue;
464
+ }
465
+ if (!registered.has(real)) continue;
466
+ candidates.push({
467
+ session: { jobSlug: slug, root, baseWorktree, baseBranch: `hc/${slug}/base`, resumed: true },
468
+ mtime: checkpointMtime(root),
469
+ progress: cp2.done.length
470
+ });
471
+ }
472
+ if (candidates.length === 0) return null;
473
+ candidates.sort((a, b) => (b.progress > 0 ? 1 : 0) - (a.progress > 0 ? 1 : 0) || b.mtime - a.mtime);
474
+ const picked = candidates[0].session;
475
+ const added = await topUpInherited(this.repoRoot, picked.baseWorktree);
476
+ return added.length ? { ...picked, toppedUp: added } : picked;
477
+ }
478
+ /**
479
+ * The worktree for a task — REUSED when the task already has one.
480
+ *
481
+ * It used to mint a fresh slug every time, so a task got `…-1`, `…-2`, `…-9` and each run began from base
482
+ * with the previous run's work stranded in a directory nobody would open again. Measured live: 321
483
+ * worktrees on disk, TEN of them for one task, and the newest empty while `…-9` held 8 commits and 7.6 KB
484
+ * of finished work.
485
+ *
486
+ * It also made the pipeline lie. The deadline warning tells the implementer "whatever it wrote is committed
487
+ * and kept — continue from there rather than starting over", and across runs that was simply false: a task
488
+ * needing more than one run's worth of work could never accumulate any.
489
+ *
490
+ * A fresh slug is still minted when the existing directory is not a usable worktree for this task's branch,
491
+ * because a broken one must not stop the task.
492
+ */
493
+ async deriveTask(session, taskName) {
494
+ const tasksDir = join2(session.root, "tasks");
495
+ const slug = toSlug(taskName);
496
+ const branch = `hc/${session.jobSlug}/t/${slug}`;
497
+ const existing = join2(tasksDir, slug);
498
+ if (existsSync2(existing)) {
499
+ const ok = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], existing);
500
+ if (ok.code === 0 && ok.stdout.trim() === branch) return { taskSlug: slug, worktree: existing, branch };
501
+ }
502
+ const taskSlug = uniqueSlug(slug, (s) => existsSync2(join2(tasksDir, s)));
503
+ const wt = join2(tasksDir, taskSlug);
504
+ const br = `hc/${session.jobSlug}/t/${taskSlug}`;
505
+ await this.run(["worktree", "add", "-b", br, wt, session.baseBranch], this.repoRoot);
506
+ return { taskSlug, worktree: wt, branch: br };
507
+ }
508
+ /**
509
+ * Retires a task's worktree and branch so the next derive starts from the CURRENT base.
510
+ *
511
+ * Reusing a task's worktree between attempts stopped the "start from scratch every run" waste, but it also
512
+ * FROZE the branch's root. Measured on a real board: the export/import task's branch was rooted two and a
513
+ * half days back and the base had moved 68 commits past it, while the throwaway worktrees it replaced had
514
+ * been rooted 29-30 commits back. Its merge then had to reconcile a drift that wide across seven files, and
515
+ * the resolver ran out of turns every time — twice on a review that had already PASSED.
516
+ *
517
+ * Past a few of those, re-implementing on today's base is cheaper than reconciling the drift, and it is the
518
+ * only move that actually removes the cause.
519
+ *
520
+ * The old branch is RENAMED, not deleted. It holds work that passed review; throwing it away to save a
521
+ * branch name would destroy the only copy of it.
522
+ */
523
+ async restartTask(session, task) {
524
+ let retired = `${task.branch}-stale`;
525
+ for (let n = 2; (await this.git(["rev-parse", "--verify", "--quiet", retired], this.repoRoot)).code === 0; n++) {
526
+ retired = `${task.branch}-stale-${n}`;
527
+ }
528
+ await this.run(["worktree", "remove", "--force", task.worktree], this.repoRoot);
529
+ await this.run(["branch", "-m", task.branch, retired], this.repoRoot);
530
+ return retired;
531
+ }
532
+ async mergeTask(session, task) {
533
+ return this.mergeRef(session, task.branch);
534
+ }
535
+ /**
536
+ * Merges any ref into the session base — a task branch coming home, or the project's main branch coming in.
537
+ *
538
+ * A resumed session picks up a branch that was cut days ago, and everything the team merged in the meantime
539
+ * is missing from it: it continues against code that no longer exists. Bringing main IN is the same
540
+ * operation as bringing a task in, which is why it is the same method and the same conflict path.
541
+ */
542
+ async mergeRef(session, ref) {
543
+ const r = await this.git(["merge", ref], session.baseWorktree);
544
+ if (r.code === 0) return { status: "merged" };
545
+ const diff = await this.git(
546
+ ["diff", "--name-only", "--diff-filter=U"],
547
+ session.baseWorktree
548
+ );
549
+ const files = diff.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
550
+ if (files.length > 0) return { status: "conflict", files };
551
+ throw new Error(`git merge ${ref} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
552
+ }
553
+ /**
554
+ * Brings one branch up to date from the remote. Best-effort: no remote, no network, no such branch — the
555
+ * merge then runs against the local copy, which is still better than not syncing at all.
556
+ *
557
+ * Returns the ref to merge: `origin/<branch>` when the fetch landed, the plain branch name otherwise.
558
+ */
559
+ async fetchBranch(branch) {
560
+ const r = await this.git(["fetch", "origin", branch], this.repoRoot);
561
+ if (r.code !== 0) return branch;
562
+ const remote = await this.git(["rev-parse", "--verify", `origin/${branch}`], this.repoRoot);
563
+ return remote.code === 0 ? `origin/${branch}` : branch;
564
+ }
565
+ /** Whether the session base already contains `ref` — nothing to merge, and nothing to say about it. */
566
+ async containsRef(session, ref) {
567
+ const r = await this.git(["merge-base", "--is-ancestor", ref, "HEAD"], session.baseWorktree);
568
+ return r.code === 0;
569
+ }
570
+ /** How many commits `ref` has that the session base does not — what a sync is about to bring in. */
571
+ async commitsBehind(session, ref) {
572
+ const r = await this.git(["rev-list", "--count", `HEAD..${ref}`], session.baseWorktree);
573
+ return r.code === 0 ? Number(r.stdout.trim()) || 0 : 0;
574
+ }
575
+ /** Files git marks as unmerged (conflicted) in the base worktree. */
576
+ async unmergedFiles(session) {
577
+ const r = await this.git(["diff", "--name-only", "--diff-filter=U"], session.baseWorktree);
578
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
579
+ }
580
+ /**
581
+ * Resolves one conflicted path by taking the BASE's copy, for files that are regenerated rather than merged.
582
+ *
583
+ * `--ours` during a merge means the branch being merged INTO — the session base, where the other tasks'
584
+ * work has already landed. For a lockfile that is the right side: it already carries every dependency the
585
+ * merged tasks installed, and the incoming branch's own addition is re-derived by running the package
586
+ * manager, not by choosing lines from a machine-written file.
587
+ */
588
+ async resolveWithBase(session, file) {
589
+ await this.git(["checkout", "--ours", "--", file], session.baseWorktree);
590
+ await this.git(["add", "--", file], session.baseWorktree);
591
+ }
592
+ /**
593
+ * One side of a conflicted file, as git holds it: stage 2 is the base's, stage 3 is the incoming branch's.
594
+ *
595
+ * Needed by the resolutions that COMBINE the two sides rather than choosing one — reading the working-tree
596
+ * copy would only give the version with the markers in it.
597
+ */
598
+ async conflictSide(session, file, side) {
599
+ const r = await this.git(["show", `:${side === "ours" ? 2 : 3}:${file}`], session.baseWorktree);
600
+ return r.code === 0 ? r.stdout : void 0;
601
+ }
602
+ /** Stages content the caller merged itself, settling that file's conflict. */
603
+ async resolveWith(session, file, content) {
604
+ await writeFile(join2(session.baseWorktree, file), content, "utf8");
605
+ await this.git(["add", "--", file], session.baseWorktree);
606
+ }
607
+ /** Unified diff of changes in the base worktree against the base branch (the PR diff). */
608
+ /**
609
+ * The code first, then what was written about it.
610
+ *
611
+ * Git orders a diff by path, so on a run whose work lives under `toucan/` every specification, plan,
612
+ * checklist and brainstorm sorts ahead of the source. Measured on PR #765 with horse-code's own state
613
+ * already excluded: the first 60,000 characters held nine files, all of them markdown, and not one line of
614
+ * the code the review existed to read. Ordering is the fix — a budget spent on prose is a budget the
615
+ * source never sees, however large it is.
616
+ */
617
+ async diff(session, base) {
618
+ const range = `${base}...${session.baseBranch}`;
619
+ const notDocs = DOC_SPECS.map((d) => `:(exclude)${d}`);
620
+ const code = await this.git(["diff", range, "--", ".", ...excludeOwnState(), ...notDocs], session.baseWorktree);
621
+ const docs = await this.git(["diff", range, "--", ...DOC_SPECS, ...excludeOwnState()], session.baseWorktree);
622
+ const out = code.stdout + docs.stdout;
623
+ if (out.length <= MAX_DIFF_CHARS) return out;
624
+ return `\u2026 (diff truncated to the first ${MAX_DIFF_CHARS} characters of ${out.length}; use the read tools to inspect anything not shown here)
625
+ ${out.slice(0, MAX_DIFF_CHARS)}`;
626
+ }
627
+ /**
628
+ * Rejection path: commit whatever draft the worktree holds to its branch (so the work is NOT lost) and
629
+ * KEEP both the worktree directory and the branch, so the user can inspect the produced files directly
630
+ * under .horsecode/worktrees/<slug>/base. Returns the worktree path. (closeSession, by contrast, deletes
631
+ * the worktree + branch — but nothing currently calls it on the happy path; worktrees are kept for inspection.)
632
+ */
633
+ async preserveSession(session, message) {
634
+ await this.commitMerge(session, message);
635
+ return session.baseWorktree;
636
+ }
637
+ async commitMerge(session, message) {
638
+ await this.run(["add", "-A"], session.baseWorktree);
639
+ const staged = await this.git(["diff", "--cached", "--quiet"], session.baseWorktree);
640
+ if (staged.code === 0) return;
641
+ await this.run(message ? ["commit", "-m", message] : ["commit", "--no-edit"], session.baseWorktree);
642
+ }
643
+ /** Commits all changes in the task worktree to the task branch; no-op if there are no changes. */
644
+ async commitTask(task, message) {
645
+ await this.run(["add", "-A"], task.worktree);
646
+ const staged = await this.git(["diff", "--cached", "--quiet"], task.worktree);
647
+ if (staged.code === 0) return;
648
+ await this.run(["commit", "-m", message], task.worktree);
649
+ }
650
+ async abortMerge(session) {
651
+ await this.run(["merge", "--abort"], session.baseWorktree);
652
+ }
653
+ async removeTask(session, task) {
654
+ await this.git(["worktree", "remove", "--force", task.worktree], this.repoRoot);
655
+ await this.git(["branch", "-D", task.branch], this.repoRoot);
656
+ }
657
+ async closeSession(session) {
658
+ await rm2(session.root, { recursive: true, force: true });
659
+ await this.git(["worktree", "prune"], this.repoRoot);
660
+ const prefix = `hc/${session.jobSlug}/`;
661
+ const list = await this.git(["branch", "--list"], this.repoRoot);
662
+ const branches = list.stdout.split("\n").map((s) => s.replace(/^[*+ ]+/, "").trim()).filter((b) => b.startsWith(prefix));
663
+ for (const b of branches) {
664
+ await this.git(["branch", "-D", b], this.repoRoot);
665
+ }
666
+ }
667
+ /** Whether a remote exists — the difference between a pull request being delivery and being impossible. */
668
+ async hasRemote(session, remote = "origin") {
669
+ return (await this.git(["remote", "get-url", remote], session.baseWorktree)).code === 0;
670
+ }
671
+ async push(session, remote = "origin") {
672
+ const check = await this.git(["remote", "get-url", remote], session.baseWorktree);
673
+ if (check.code !== 0) return;
674
+ await this.run(["push", remote, session.baseBranch], session.baseWorktree);
675
+ }
676
+ async openPR(session, adapter, input) {
677
+ const res = await adapter.createPR({
678
+ branch: session.baseBranch,
679
+ base: input.base,
680
+ title: input.title,
681
+ body: input.body
682
+ });
683
+ return { url: res.url };
684
+ }
685
+ };
686
+
687
+ // src/engine/unfinished.ts
688
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync, statSync } from "fs";
689
+ import { join as join3 } from "path";
690
+ function boardCounts(dir) {
691
+ try {
692
+ const raw = JSON.parse(readFileSync(join3(dir, "board.json"), "utf8"));
693
+ const cards = Array.isArray(raw.cards) ? raw.cards : Object.values(raw.cards ?? {});
694
+ const list = cards;
695
+ return { total: list.length, done: list.filter((c) => c.column === "MERGED" || c.column === "DONE").length };
696
+ } catch {
697
+ return { total: 0, done: 0 };
698
+ }
699
+ }
700
+ function unfinishedSessions(cwd, commitCount = () => 0) {
701
+ const root = join3(cwd, ".horsecode", "worktrees");
702
+ if (!existsSync3(root)) return [];
703
+ const out = [];
704
+ for (const id of readdirSync2(root)) {
705
+ const dir = join3(root, id);
706
+ try {
707
+ if (!statSync(dir).isDirectory()) continue;
708
+ } catch {
709
+ continue;
710
+ }
711
+ if (!existsSync3(join3(dir, "base"))) continue;
712
+ const checkpoint = readCheckpoint(dir);
713
+ if (!checkpoint) continue;
714
+ let updatedAt = 0;
715
+ try {
716
+ updatedAt = statSync(join3(dir, "checkpoint.json")).mtimeMs;
717
+ } catch {
718
+ }
719
+ out.push({
720
+ id,
721
+ checkpoint,
722
+ cards: boardCounts(dir),
723
+ commits: commitCount(`hc/${id}/base`),
724
+ updatedAt
725
+ });
726
+ }
727
+ return out.sort((a, b) => b.updatedAt - a.updatedAt);
728
+ }
729
+ function describeUnfinished(s) {
730
+ const asked = s.checkpoint.rawPrompt.trim() || s.checkpoint.title;
731
+ const bits = [
732
+ s.checkpoint.done.length ? `${s.checkpoint.done.join(" \u2192 ")} done` : "nothing finished yet",
733
+ s.cards.total ? `${s.cards.done}/${s.cards.total} tasks` : "",
734
+ s.commits ? `${s.commits} commit${s.commits === 1 ? "" : "s"}` : ""
735
+ ].filter(Boolean);
736
+ return `\u201C${asked.length > 70 ? `${asked.slice(0, 69)}\u2026` : asked}\u201D \u2014 ${bits.join(" \xB7 ")} (\`${s.id}\`; say **continue** to pick it up)`;
737
+ }
738
+
739
+ // src/tools/git.ts
740
+ import { execFile } from "child_process";
741
+ import { z } from "zod";
742
+ var READ_ONLY = /* @__PURE__ */ new Set([
743
+ "status",
744
+ "log",
745
+ "show",
746
+ "diff",
747
+ "blame",
748
+ "shortlog",
749
+ "whatchanged",
750
+ "rev-parse",
751
+ "rev-list",
752
+ "merge-base",
753
+ "name-rev",
754
+ "describe",
755
+ "symbolic-ref",
756
+ "ls-files",
757
+ "ls-tree",
758
+ "cat-file",
759
+ "count-objects",
760
+ "show-ref",
761
+ "for-each-ref",
762
+ "ls-remote",
763
+ /**
764
+ * `git grep` searches tracked content and has no writing form at all — the same standing as `log`.
765
+ *
766
+ * Left out, it was refused twice in one 36-minute run while agents fell back to `find | xargs grep`
767
+ * through the shell, which is slower on a repository this size and searches build output and
768
+ * `node_modules` unless every caller remembers to prune them. git already knows what is tracked.
769
+ */
770
+ "grep",
771
+ /**
772
+ * `check-ignore` asks whether a path is ignored — it reads `.gitignore` and answers, and changes nothing.
773
+ *
774
+ * Left out, it was the one read-only verb agents had to reach for `shell` to run: four calls in one run,
775
+ * each landing outside the tool that knows this repository's rules. And through `shell` its exit code is
776
+ * raw, so "no, that path is not ignored" — which git says with 1 — came back as a failed command.
777
+ */
778
+ "check-ignore"
779
+ ]);
780
+ var READ_ONLY_PAIRS = /* @__PURE__ */ new Set([
781
+ "worktree list",
782
+ "tag --list",
783
+ "tag -l",
784
+ "stash list",
785
+ "remote -v",
786
+ "remote show",
787
+ "config --get",
788
+ "config --list",
789
+ /**
790
+ * The long forms of what is already allowed, and the queries that only ask.
791
+ *
792
+ * `branch -a` was allowed and `branch --all` was not — the same command spelled the way git's own
793
+ * documentation spells it. Measured in one run: an agent asked for `branch --all` and then `branch
794
+ * --show-current`, and paid a refused turn for each while `-a` sat in this list. A short flag admitted and
795
+ * its long twin refused is not a security boundary, it is a typo in one.
796
+ *
797
+ * These are the closure of what this set already permits, not new ground: every one of them prints
798
+ * information about branches or tags and none of them can create, move or delete a ref. The forms that
799
+ * write — `-d`, `-D`, `-m`, `-M`, `-c`, `-C`, `--delete`, `--move`, `--copy`, `--set-upstream-to`,
800
+ * `--edit-description` — are still absent, and a first argument that is not a flag never reaches here.
801
+ */
802
+ "tag --contains",
803
+ "tag --no-contains",
804
+ "tag --merged",
805
+ "tag --points-at",
806
+ "tag -n",
807
+ "remote --verbose",
808
+ "remote get-url",
809
+ "stash show"
810
+ ]);
811
+ var REFUSED_ARG = /^(--output|-c$|--config-env|--exec-path|-C$|--git-dir|--work-tree|--upload-pack|--receive-pack)/;
812
+ var params = z.object({
813
+ args: z.array(z.string()).min(1).describe(
814
+ 'Git arguments as a list, without the leading "git" \u2014 e.g. ["status","--porcelain"] or ["log","-5","--oneline"].'
815
+ )
816
+ });
817
+ var MAX_GIT_OUTPUT = 6e4;
818
+ var GIT_TIMEOUT_MS = 3e4;
819
+ var GIT_PUSH_TIMEOUT_MS = 12e4;
820
+ var ANSWERS_WITH_ONE = /* @__PURE__ */ new Set([
821
+ "diff",
822
+ "diff-index",
823
+ "diff-tree",
824
+ "diff-files",
825
+ "merge-base",
826
+ "check-ignore",
827
+ /**
828
+ * `grep` says "no match" with exit 1, exactly as the others say their own no.
829
+ *
830
+ * Admitted to the read-only set earlier tonight and left out of this one, so a search that found nothing
831
+ * came back as `git failed with no output.` — a fault where there was an answer. Measured live within
832
+ * minutes: `git grep -n -i ExportReportService.cs` twice, both reported as failures, for a file that
833
+ * simply is not in the repository.
834
+ */
835
+ "grep"
836
+ ]);
837
+ function answeredWithOne(args, code) {
838
+ return code === 1 && ANSWERS_WITH_ONE.has(args[0] ?? "");
839
+ }
840
+ function howToNarrow(args) {
841
+ const verb = gitVerb(args) ?? "";
842
+ if (verb === "diff" || verb === "show") {
843
+ return "narrow the range, or put `--stat` directly after the subcommand (git " + verb + " --stat <rest>), which git requires";
844
+ }
845
+ if (verb === "log") return "ask for fewer commits (-n 20) or just their subjects (--oneline)";
846
+ if (verb === "ls-files" || verb === "ls-tree") return "narrow the pathspec to one directory at a time";
847
+ if (verb === "blame") return "limit it to a range of lines (-L 40,120)";
848
+ return "ask for a narrower part of it";
849
+ }
850
+ function answerOfOne(args) {
851
+ const verb = args[0] ?? "";
852
+ if (verb === "check-ignore") {
853
+ return "No \u2014 that path is not ignored by this repository's rules. (git exit code 1, which is the answer here.)";
854
+ }
855
+ if (verb === "grep") {
856
+ return "No match \u2014 nothing in the tracked files matches that pattern. (git exit code 1, which is the answer here.)";
857
+ }
858
+ if (verb === "merge-base") {
859
+ 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.)";
860
+ }
861
+ 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.";
862
+ }
863
+ function packedArgument(a) {
864
+ if (!/\s/.test(a)) return void 0;
865
+ if (/^--?[\w-]+[=:]/.test(a)) return void 0;
866
+ const parts = a.trim().split(/\s+/).filter(Boolean);
867
+ if (parts.length < 2) return void 0;
868
+ if (parts[0] === "--") return parts;
869
+ return parts.slice(1).some((p) => p === "--" || p.startsWith("-") || p.includes("/")) ? parts : void 0;
870
+ }
871
+ var GLUED_PATHSPEC = /^--[^\s=]*\/[^\s=]*$/;
872
+ var BRANCH_TAKES_VALUE = /* @__PURE__ */ new Set([
873
+ "--contains",
874
+ "--no-contains",
875
+ "--merged",
876
+ "--no-merged",
877
+ "--points-at",
878
+ "--format",
879
+ "--sort",
880
+ "--color",
881
+ "--abbrev",
882
+ "-u",
883
+ "--set-upstream-to",
884
+ "-t",
885
+ "--track"
886
+ ]);
887
+ var BRANCH_WRITERS = /* @__PURE__ */ new Set([
888
+ "-d",
889
+ "-D",
890
+ "--delete",
891
+ "-m",
892
+ "-M",
893
+ "--move",
894
+ "-c",
895
+ "-C",
896
+ "--copy",
897
+ "--edit-description",
898
+ "--set-upstream",
899
+ "--set-upstream-to",
900
+ "--unset-upstream",
901
+ "-u",
902
+ "-t",
903
+ "--track",
904
+ "--no-track",
905
+ "-f",
906
+ "--force"
907
+ ]);
908
+ function branchWrites(rest) {
909
+ const listing = rest.some((a) => a === "--list" || a === "-l");
910
+ for (let i = 0; i < rest.length; i++) {
911
+ const a = rest[i];
912
+ if (a === void 0) continue;
913
+ if (a === "--") return "`git branch` with a pathspec is not a thing this tool needs to run.";
914
+ if (a.startsWith("--")) {
915
+ const name = a.split("=")[0];
916
+ if (BRANCH_WRITERS.has(name)) return `\`git branch ${name}\` changes a branch. Only listing is allowed.`;
917
+ if (BRANCH_TAKES_VALUE.has(name) && !a.includes("=")) i++;
918
+ continue;
919
+ }
920
+ if (a.startsWith("-")) {
921
+ const bad = [...a.slice(1)].find((c) => BRANCH_WRITERS.has(`-${c}`));
922
+ if (bad) return `\`git branch -${bad}\` changes a branch. Only listing is allowed.`;
923
+ if (BRANCH_TAKES_VALUE.has(a)) i++;
924
+ continue;
925
+ }
926
+ if (listing) continue;
927
+ if (/[*?\[]/.test(a)) {
928
+ return `\`git branch ${a}\` would create a branch with that literal name. To search for branches, put the pattern after --list: \`git branch --list ${a}\`.`;
929
+ }
930
+ return `\`git branch ${a}\` creates a branch. Only listing is allowed \u2014 git_write owns the rest.`;
931
+ }
932
+ return void 0;
933
+ }
934
+ function refuse(args) {
935
+ const packed = args.find((a) => packedArgument(a) !== void 0 || GLUED_PATHSPEC.test(a));
936
+ if (packed !== void 0) {
937
+ const split = packedArgument(packed) ?? ["--", packed.slice(2)];
938
+ if (split[0] !== "--") {
939
+ return `each item in the list is ONE argument \u2014 this one holds several: ${JSON.stringify([packed]).slice(0, 90)}. Send ${JSON.stringify(split).slice(0, 130)} instead.`;
940
+ }
941
+ const parts = split.slice(1);
942
+ return `\`--\` is the separator and must be its own element of the list \u2014 it is never part of a path. You sent ${JSON.stringify([packed]).slice(0, 90)}; send ${JSON.stringify(["--", ...parts]).slice(0, 130)} instead (however many paths follow, they are separate elements too).`;
943
+ }
944
+ const bad = args.find((a) => REFUSED_ARG.test(a));
945
+ if (bad) {
946
+ return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
947
+ }
948
+ const [sub, second] = args;
949
+ if (!sub || sub.startsWith("-")) return "The first argument must be a git subcommand, e.g. `status`.";
950
+ if (READ_ONLY.has(sub)) return void 0;
951
+ if (sub === "branch") return branchWrites(args.slice(1));
952
+ if (sub === "reflog") {
953
+ return second === "expire" || second === "delete" ? `\`git reflog ${second}\` rewrites the reflog. Only reading it is allowed.` : void 0;
954
+ }
955
+ if (sub === "config") {
956
+ if (second !== void 0 && !second.startsWith("-") && args.length === 2) return void 0;
957
+ if (args.length > 2 && !args.some((a) => a.startsWith("--get") || a === "--list")) {
958
+ return "`git config <key> <value>` writes configuration. Read one with `git config <key>`.";
959
+ }
960
+ }
961
+ if (second && READ_ONLY_PAIRS.has(`${sub} ${second}`)) return void 0;
962
+ 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(", ")}.`;
963
+ }
964
+ var gitTool = {
965
+ name: "git",
966
+ 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.',
967
+ permissionLevel: "safe",
968
+ parameters: params,
969
+ describe: (args) => {
970
+ const list = args.args;
971
+ const text = Array.isArray(list) ? list.join(" ") : "";
972
+ return { allowKey: "git:read", preview: `git ${text}`.slice(0, 120) };
973
+ },
974
+ async run(rawArgs, ctx) {
975
+ const parsed = params.safeParse(rawArgs);
976
+ if (!parsed.success) {
977
+ return { content: `git: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
978
+ }
979
+ const args = parsed.data.args;
980
+ const why = refuse(args);
981
+ if (why) return { content: why, isError: true, settled: true };
982
+ const out = await new Promise((resolve2) => {
983
+ const child = execFile("git", args, {
984
+ cwd: ctx.cwd,
985
+ timeout: GIT_TIMEOUT_MS,
986
+ maxBuffer: MAX_GIT_OUTPUT * 4,
987
+ // `--no-pager` would still be needed for some subcommands; killing the pager entirely is simpler and
988
+ // leaves nothing waiting for a terminal that does not exist.
989
+ env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
990
+ }, (err, stdout, stderr) => {
991
+ const text = `${stdout}${stderr}`.trim();
992
+ resolve2({ code: err?.code ?? (err ? 1 : 0), text });
993
+ });
994
+ ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
995
+ });
996
+ const failed = out.code !== 0 && !answeredWithOne(args, out.code);
997
+ if (!out.text) {
998
+ if (!failed && out.code === 1) return { content: answerOfOne(args), isError: false };
999
+ return { content: out.code === 0 ? "(no output)" : "git failed with no output.", isError: failed };
1000
+ }
1001
+ const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
1002
+ \u2026[truncated \u2014 ${howToNarrow(args)}]` : out.text;
1003
+ return { content: clipped, isError: failed };
1004
+ }
1005
+ };
1006
+ var WRITE = /* @__PURE__ */ new Set(["add", "commit", "push", "fetch"]);
1007
+ var REFSPEC = /:/;
1008
+ var REFUSED_PUSH = /^(-f|--force|--force-with-lease|--delete|--mirror|--prune)/;
1009
+ function refuseWrite(args) {
1010
+ const bad = args.find((a) => REFUSED_ARG.test(a));
1011
+ if (bad) {
1012
+ return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
1013
+ }
1014
+ const [sub] = args;
1015
+ if (!sub || !WRITE.has(sub)) {
1016
+ return `\`git ${sub ?? ""}\` is not available here \u2014 this tool records work (${[...WRITE].join(", ")}). Use the \`git\` tool to read history and state.`;
1017
+ }
1018
+ if (sub === "fetch") {
1019
+ const spec = args.slice(1).find((a) => REFSPEC.test(a) && !a.startsWith("-"));
1020
+ if (spec) {
1021
+ 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.`;
1022
+ }
1023
+ const pruned = args.find((a) => /^(--prune|-p)$/.test(a));
1024
+ if (pruned) {
1025
+ return `\`${pruned}\` is not allowed: it deletes remote-tracking refs, and something else may be relying on one. Fetch without it.`;
1026
+ }
1027
+ }
1028
+ const forced = sub === "push" && args.find((a) => REFUSED_PUSH.test(a));
1029
+ if (forced) {
1030
+ 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.`;
1031
+ }
1032
+ return void 0;
1033
+ }
1034
+ var gitWriteTool = {
1035
+ name: "git_write",
1036
+ 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.',
1037
+ permissionLevel: "exec",
1038
+ parameters: params,
1039
+ describe: (args) => {
1040
+ const list = args.args;
1041
+ const text = Array.isArray(list) ? list.join(" ") : "";
1042
+ const sub = Array.isArray(list) && typeof list[0] === "string" ? list[0] : "";
1043
+ return { allowKey: `git ${sub}`, preview: `git ${text}`.slice(0, 200) };
1044
+ },
1045
+ async run(rawArgs, ctx) {
1046
+ const parsed = params.safeParse(rawArgs);
1047
+ if (!parsed.success) {
1048
+ return { content: `git_write: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1049
+ }
1050
+ const args = parsed.data.args;
1051
+ const why = refuseWrite(args);
1052
+ if (why) return { content: why, isError: true, settled: true };
1053
+ const out = await new Promise((resolve2) => {
1054
+ const child = execFile("git", args, {
1055
+ cwd: ctx.cwd,
1056
+ // A push talks to a server: the read tool's 30s is a reasonable ceiling for a local query and a
1057
+ // pessimistic one for a repository with anything in it.
1058
+ timeout: args[0] === "push" ? GIT_PUSH_TIMEOUT_MS : GIT_TIMEOUT_MS,
1059
+ maxBuffer: MAX_GIT_OUTPUT * 4,
1060
+ // GIT_TERMINAL_PROMPT=0: a push that needs credentials fails with a message instead of blocking on a
1061
+ // prompt no one can see — the TUI owns the terminal, so the agent would simply hang.
1062
+ env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
1063
+ }, (err, stdout, stderr) => {
1064
+ resolve2({ code: err ? 1 : 0, text: `${stdout}${stderr}`.trim() });
1065
+ });
1066
+ ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
1067
+ });
1068
+ if (!out.text) return { content: out.code === 0 ? "(done)" : "git failed with no output.", isError: out.code !== 0 };
1069
+ const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
1070
+ \u2026[truncated]` : out.text;
1071
+ return { content: clipped, isError: out.code !== 0 };
1072
+ }
1073
+ };
1074
+
1075
+ // src/tools/remember.ts
1076
+ import { z as z2 } from "zod";
1077
+ var params2 = z2.object({
1078
+ fact: z2.string().describe(
1079
+ "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."
1080
+ )
1081
+ });
1082
+ function buildRememberTool(sink) {
1083
+ return {
1084
+ name: "remember_fact",
1085
+ 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.",
1086
+ permissionLevel: "safe",
1087
+ parameters: params2,
1088
+ async run(rawArgs, ctx) {
1089
+ const parsed = params2.safeParse(rawArgs);
1090
+ if (!parsed.success) return { content: "remember_fact: invalid args (expected { fact })", isError: true };
1091
+ const fact = parsed.data.fact.trim();
1092
+ if (!fact) return { content: "remember_fact: empty fact", isError: true };
1093
+ const write = sink ?? ctx.remember;
1094
+ if (!write) return { content: "remember_fact: memory is not available in this context", isError: true };
1095
+ write(fact);
1096
+ return { content: `Remembered: ${fact}`, isError: false };
1097
+ }
1098
+ };
1099
+ }
1100
+ var rememberFactTool = buildRememberTool();
1101
+
1102
+ // src/speckit/layout.ts
1103
+ import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync3 } from "fs";
1104
+ import { join as join4 } from "path";
1105
+ function specsDir(workdir) {
1106
+ return join4(workdir, "specs");
1107
+ }
1108
+ function constitutionPath(workdir) {
1109
+ return join4(workdir, ".specify", "memory", "constitution.md");
1110
+ }
1111
+ function featurePaths(workdir, slug) {
1112
+ const dir = join4(specsDir(workdir), slug);
1113
+ return { dir, brainstorm: join4(dir, "brainstorm.md"), spec: join4(dir, "spec.md"), plan: join4(dir, "plan.md"), tasks: join4(dir, "tasks.md") };
1114
+ }
1115
+ function verifyPaths(workdir, slug) {
1116
+ const dir = join4(specsDir(workdir), slug);
1117
+ return { dir, plan: join4(dir, "test-plan.md"), report: join4(dir, "test-report.md") };
1118
+ }
1119
+ var ACTIVITY_WORDS = /* @__PURE__ */ new Set([
1120
+ "test",
1121
+ "tests",
1122
+ "testing",
1123
+ "smoke",
1124
+ "e2e",
1125
+ "verify",
1126
+ "verification",
1127
+ "verifying",
1128
+ "check",
1129
+ "checking",
1130
+ "run",
1131
+ "running",
1132
+ "continue",
1133
+ "continuing",
1134
+ "report",
1135
+ "session",
1136
+ "the",
1137
+ "for",
1138
+ "of",
1139
+ "a",
1140
+ "an",
1141
+ "and"
1142
+ ]);
1143
+ var subjectWords = (slug) => new Set(slug.split("-").filter((w) => w.length > 2 && !ACTIVITY_WORDS.has(w)));
1144
+ function featureSlugFor(workdir, title) {
1145
+ const want = toSlug(title);
1146
+ const dir = specsDir(workdir);
1147
+ if (existsSync4(dir)) {
1148
+ const names = readdirSync3(dir);
1149
+ for (const name of names) {
1150
+ if (name.replace(/^\d+-/, "") === want) return name;
1151
+ }
1152
+ const wanted = subjectWords(want);
1153
+ let best;
1154
+ for (const name of names) {
1155
+ const shared = [...subjectWords(name.replace(/^\d+-/, ""))].filter((w) => wanted.has(w)).length;
1156
+ if (shared >= 2 && (!best || shared > best.shared)) best = { name, shared };
1157
+ }
1158
+ if (best) return best.name;
1159
+ }
1160
+ return nextFeatureSlug(workdir, title);
1161
+ }
1162
+ function nextFeatureSlug(workdir, title) {
1163
+ const dir = specsDir(workdir);
1164
+ let max = 0;
1165
+ if (existsSync4(dir)) {
1166
+ for (const name of readdirSync3(dir)) {
1167
+ const m = name.match(/^(\d+)-/);
1168
+ if (m) max = Math.max(max, Number(m[1]));
1169
+ }
1170
+ }
1171
+ return `${String(max + 1).padStart(3, "0")}-${toSlug(title)}`;
1172
+ }
1173
+ function scaffoldFeature(workdir, slug) {
1174
+ const paths = featurePaths(workdir, slug);
1175
+ mkdirSync(paths.dir, { recursive: true });
1176
+ mkdirSync(join4(workdir, ".specify", "memory"), { recursive: true });
1177
+ return paths;
1178
+ }
1179
+
1180
+ // src/engine/constitution-store.ts
1181
+ import { createHash } from "crypto";
1182
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
1183
+ import { join as join5 } from "path";
1184
+ import { z as z3 } from "zod";
1185
+
1186
+ // src/engine/constitution.ts
1187
+ var SCOPES = ["always", "backend", "frontend", "data", "infra", "docs", "review", "spec", "test", "govern"];
1188
+ var RATIONALE = /^\s*\*(?:Gerekçe|Rationale):\*/i;
1189
+ function sectionOf(heading) {
1190
+ const m = /^([IVXLC]+)\.\s/.exec(heading);
1191
+ return m ? m[1] : heading;
1192
+ }
1193
+ function parseConstitution(text) {
1194
+ const out = [];
1195
+ let heading = "";
1196
+ let buf = [];
1197
+ const flush = () => {
1198
+ const block = buf.join("\n").trim();
1199
+ buf = [];
1200
+ if (!block || !heading || RATIONALE.test(block)) return;
1201
+ out.push({ section: sectionOf(heading), heading, text: block });
1202
+ };
1203
+ for (const line of text.split("\n")) {
1204
+ const h = /^#{2,3}\s+(.*)$/.exec(line);
1205
+ if (h) {
1206
+ flush();
1207
+ heading = /^core principles$/i.test(h[1].trim()) ? "" : h[1].trim();
1208
+ continue;
1209
+ }
1210
+ if (!line.trim()) {
1211
+ flush();
1212
+ continue;
1213
+ }
1214
+ buf.push(line);
1215
+ }
1216
+ flush();
1217
+ return out;
1218
+ }
1219
+ var BY_EXT = [
1220
+ [/\.(cs|java|kt|go|py|rb|php|scala|rs)$/i, "backend"],
1221
+ [/\.(ts|tsx|js|jsx|html|css|scss|sass|less|vue|svelte)$/i, "frontend"],
1222
+ [/\.(sql)$|(^|\/)migrations?\//i, "data"],
1223
+ [/\.(tf|tfvars)$|(^|\/)(k8s|helm|charts|deploy|infra)\//i, "infra"],
1224
+ [/\.(md|mdx|adoc)$/i, "docs"]
1225
+ ];
1226
+ var BY_ROLE = {
1227
+ "code-reviewer": ["review"],
1228
+ "principal-coder": ["review"],
1229
+ "senior-coder": ["review"],
1230
+ analyst: ["spec"],
1231
+ planner: ["spec"],
1232
+ brainstormer: ["spec"],
1233
+ tester: ["test"]
1234
+ };
1235
+ function scopesForWork(opts) {
1236
+ const out = /* @__PURE__ */ new Set(["always"]);
1237
+ for (const s of BY_ROLE[opts.role ?? ""] ?? []) out.add(s);
1238
+ for (const f of opts.files ?? []) for (const [re, scope] of BY_EXT) if (re.test(f)) out.add(scope);
1239
+ return out;
1240
+ }
1241
+ var MAX_CONSTITUTION_CHARS = 2e4;
1242
+ function selectRules(rules, scopes, max = MAX_CONSTITUTION_CHARS) {
1243
+ const applies = (r) => r.scopes.some((s) => scopes.has(s));
1244
+ const always = rules.filter((r) => r.scopes.includes("always"));
1245
+ const scoped = rules.filter((r) => !r.scopes.includes("always") && applies(r));
1246
+ const used = [...always];
1247
+ const dropped = [];
1248
+ let size = used.reduce((n, r) => n + r.text.length, 0);
1249
+ for (const r of scoped) {
1250
+ if (size + r.text.length > max) {
1251
+ dropped.push(r);
1252
+ continue;
1253
+ }
1254
+ used.push(r);
1255
+ size += r.text.length;
1256
+ }
1257
+ return { text: render(used, dropped), used, dropped };
1258
+ }
1259
+ function render(used, dropped) {
1260
+ if (!used.length) return "";
1261
+ const byHeading = /* @__PURE__ */ new Map();
1262
+ for (const r of used) byHeading.set(r.heading, [...byHeading.get(r.heading) ?? [], r.text]);
1263
+ const body = [...byHeading].map(([h, texts]) => `## ${h}
1264
+ ${texts.join("\n\n")}`).join("\n\n");
1265
+ const cut = dropped.length ? `
1266
+
1267
+ (${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.)` : "";
1268
+ return `
1269
+
1270
+ # Project constitution \u2014 the rules that bind THIS work
1271
+
1272
+ 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\`.
1273
+
1274
+ ${body}${cut}`;
1275
+ }
1276
+ 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.
1277
+
1278
+ For each rule, answer with the scopes it is ABOUT, from exactly this list: ${SCOPES.join(", ")}.
1279
+
1280
+ - \`backend\`, \`frontend\`, \`data\`, \`infra\`: it names code, files or tooling of that kind \u2014 a language, a framework, a database, a deployment target.
1281
+ - \`spec\`: it constrains what a specification or plan may say (the stack, the boundaries, the vocabulary).
1282
+ - \`review\`: it is a gate a reviewer applies \u2014 what blocks a merge, how findings are reported.
1283
+ - \`test\`: it is about verification and evidence.
1284
+ - \`govern\`: it is about amending the constitution itself, and binds nobody else.
1285
+ - \`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.
1286
+
1287
+ 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.
1288
+
1289
+ Answer for EVERY index you are given, and for no others. Do not translate, summarise or rewrite anything \u2014 you are only labelling.
1290
+
1291
+ Return {labels: [{index, scopes}]} via submit, one entry per rule, in the order given.`;
1292
+ var CLASSIFY_BATCH = 20;
1293
+ var CLASSIFY_RETRIES = 2;
1294
+ function classifyMessage(rules, offset = 0) {
1295
+ return rules.map((r, i) => `--- ${i + offset} --- (${r.heading})
1296
+ ${r.text}`).join("\n\n");
1297
+ }
1298
+ function applyLabels(rules, labels) {
1299
+ const known = new Set(SCOPES);
1300
+ const byIndex = new Map(labels.map((l) => [l.index, l.scopes.filter((s) => known.has(s))]));
1301
+ const unlabelled = [];
1302
+ const scoped = rules.map((r, i) => {
1303
+ const scopes = byIndex.get(i) ?? [];
1304
+ if (!scopes.length) unlabelled.push(i);
1305
+ return { ...r, scopes: scopes.length ? scopes : ["always"] };
1306
+ });
1307
+ return { scoped, unlabelled };
1308
+ }
1309
+ var MAX_ALWAYS_SHARE = 0.35;
1310
+ function labellingLooksWrong(scoped) {
1311
+ if (!scoped.length) return void 0;
1312
+ const always = scoped.filter((r) => r.scopes.includes("always")).length;
1313
+ if (always > scoped.length * MAX_ALWAYS_SHARE) {
1314
+ return `${always} of ${scoped.length} rules came back as \`always\``;
1315
+ }
1316
+ return void 0;
1317
+ }
1318
+
1319
+ // src/engine/constitution-store.ts
1320
+ var LabelsSchema = z3.object({
1321
+ labels: z3.array(z3.object({
1322
+ index: z3.number().int().describe("The rule's number, exactly as given to you."),
1323
+ scopes: z3.array(z3.enum(SCOPES)).describe(
1324
+ `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.`
1325
+ )
1326
+ }))
1327
+ });
1328
+ var CLASSIFY_MAX_TURNS = 3;
1329
+ function cachePath(home, text) {
1330
+ const hash = createHash("sha256").update(text).digest("hex").slice(0, 16);
1331
+ return join5(home, ".horsecode", "constitution", `${hash}.json`);
1332
+ }
1333
+ var memo = /* @__PURE__ */ new Map();
1334
+ async function scopedConstitution(deps, cwd) {
1335
+ const path = constitutionPath(cwd);
1336
+ if (!existsSync5(path)) return [];
1337
+ let text;
1338
+ try {
1339
+ text = readFileSync2(path, "utf8");
1340
+ } catch {
1341
+ return [];
1342
+ }
1343
+ const cache = cachePath(deps.home, text);
1344
+ const hit = memo.get(cache);
1345
+ if (hit) return hit;
1346
+ if (existsSync5(cache)) {
1347
+ try {
1348
+ const saved = JSON.parse(readFileSync2(cache, "utf8"));
1349
+ memo.set(cache, saved);
1350
+ return saved;
1351
+ } catch {
1352
+ }
1353
+ }
1354
+ const rules = parseConstitution(text);
1355
+ if (!rules.length) return [];
1356
+ deps.note?.(`\u{1F4DC} Reading the project constitution \u2014 ${rules.length} rules, labelled once so each reaches the work it binds.`);
1357
+ const labels = [];
1358
+ const resolved = deps.roleRegistry.resolve("judge");
1359
+ let failed = 0;
1360
+ for (let start = 0; start < rules.length; start += CLASSIFY_BATCH) {
1361
+ const batch = rules.slice(start, start + CLASSIFY_BATCH);
1362
+ for (let attempt = 0; attempt <= CLASSIFY_RETRIES; attempt++) {
1363
+ try {
1364
+ const out = await runStructuredRole({
1365
+ provider: deps.provider,
1366
+ ...resolved,
1367
+ systemPrompt: CLASSIFY_PROMPT,
1368
+ tools: new ToolRegistry(),
1369
+ messages: [{ role: "user", content: classifyMessage(batch, start) }],
1370
+ permission: deps.permission,
1371
+ approve: deps.approve,
1372
+ cwd,
1373
+ signal: deps.signal,
1374
+ maxTurns: CLASSIFY_MAX_TURNS
1375
+ }, LabelsSchema);
1376
+ labels.push(...out.labels);
1377
+ break;
1378
+ } catch {
1379
+ if (attempt === CLASSIFY_RETRIES) failed++;
1380
+ }
1381
+ }
1382
+ }
1383
+ const { scoped, unlabelled } = applyLabels(rules, labels);
1384
+ if (unlabelled.length) {
1385
+ 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.`);
1386
+ }
1387
+ const wrong = labellingLooksWrong(scoped);
1388
+ if (failed || wrong) {
1389
+ 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.`);
1390
+ } else {
1391
+ try {
1392
+ mkdirSync2(join5(deps.home, ".horsecode", "constitution"), { recursive: true });
1393
+ writeFileSync(cache, JSON.stringify(scoped), "utf8");
1394
+ } catch {
1395
+ }
1396
+ }
1397
+ memo.set(cache, scoped);
1398
+ return scoped;
1399
+ }
1400
+ async function constitutionNote(deps, cwd, work) {
1401
+ const scoped = await scopedConstitution(deps, cwd);
1402
+ if (!scoped.length) return "";
1403
+ const sel = selectRules(scoped, scopesForWork(work));
1404
+ if (sel.dropped.length) {
1405
+ 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(", ")}.`);
1406
+ }
1407
+ return sel.text;
1408
+ }
1409
+
1410
+ // src/engine/reviewer.ts
1411
+ import { z as z7 } from "zod";
1412
+
1413
+ // src/tools/find-tool.ts
1414
+ import { z as z4 } from "zod";
1415
+ var params3 = z4.object({
1416
+ query: z4.string().describe(
1417
+ '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.'
1418
+ )
1419
+ });
1420
+ var MAX_FOUND = 5;
1421
+ var NOISE = /* @__PURE__ */ new Set([
1422
+ "the",
1423
+ "a",
1424
+ "an",
1425
+ "of",
1426
+ "for",
1427
+ "in",
1428
+ "on",
1429
+ "to",
1430
+ "and",
1431
+ "or",
1432
+ "with",
1433
+ "by",
1434
+ "from",
1435
+ "get",
1436
+ "list",
1437
+ "tool",
1438
+ "project",
1439
+ "use",
1440
+ "this",
1441
+ "that",
1442
+ "it",
1443
+ "is",
1444
+ "are",
1445
+ "be",
1446
+ "mcp"
1447
+ ]);
1448
+ var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !NOISE.has(w));
1449
+ function scoreTool(t, query) {
1450
+ const q = words(query);
1451
+ if (!q.length) return 0;
1452
+ const name = t.name.toLowerCase();
1453
+ const desc = t.description.toLowerCase();
1454
+ let score = 0;
1455
+ for (const w of q) {
1456
+ if (name.includes(w)) score += 3;
1457
+ else if (desc.includes(w)) score += 1;
1458
+ }
1459
+ if (name === query.toLowerCase().trim()) score += 100;
1460
+ return score;
1461
+ }
1462
+ function buildFindToolTool(registry) {
1463
+ return {
1464
+ name: "find_tool",
1465
+ 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.',
1466
+ permissionLevel: "safe",
1467
+ parameters: params3,
1468
+ run: async (rawArgs) => {
1469
+ const parsed = params3.safeParse(rawArgs);
1470
+ if (!parsed.success) {
1471
+ return { content: `find_tool: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1472
+ }
1473
+ const { query } = parsed.data;
1474
+ const pool = registry.deferredTools().filter((t) => t.broken === void 0);
1475
+ if (!pool.length) {
1476
+ return {
1477
+ content: "Every tool this project connects is already loaded \u2014 there is nothing further to fetch. Use the ones you have.",
1478
+ isError: false
1479
+ };
1480
+ }
1481
+ const scored = pool.map((t) => ({ t, s: scoreTool(t, query) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
1482
+ const exact = scored.find((x) => x.t.name.toLowerCase() === query.toLowerCase().trim());
1483
+ const hits = exact ? [exact] : scored.slice(0, MAX_FOUND);
1484
+ if (!hits.length) {
1485
+ return {
1486
+ content: `No project tool matches "${query}". Available to fetch: ${pool.map((t) => t.name).join(", ")}.`,
1487
+ isError: false
1488
+ };
1489
+ }
1490
+ registry.surface(hits.map((x) => x.t.name));
1491
+ const rows = hits.map((x) => `- \`${x.t.name}\` \u2014 ${x.t.description.replace(/^\[MCP:[^\]]*\]\s*/, "").split(/\n/)[0].trim()}`);
1492
+ return {
1493
+ content: `Loaded ${hits.length} tool(s) \u2014 you can call them from your next message:
1494
+ ${rows.join("\n")}`,
1495
+ isError: false
1496
+ };
1497
+ }
1498
+ };
1499
+ }
1500
+
1501
+ // src/tools/unfinished-tool.ts
1502
+ import { execFileSync } from "child_process";
1503
+ import { join as join6 } from "path";
1504
+ import { z as z5 } from "zod";
1505
+ var params4 = z5.object({});
1506
+ function commitsAhead(cwd, branch) {
1507
+ try {
1508
+ const out = execFileSync(
1509
+ "git",
1510
+ ["rev-list", "--count", `HEAD..${branch}`],
1511
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1512
+ );
1513
+ return Number(out.trim()) || 0;
1514
+ } catch {
1515
+ return 0;
1516
+ }
1517
+ }
1518
+ var findUnfinishedTool = {
1519
+ name: "find_unfinished",
1520
+ 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.`,
1521
+ permissionLevel: "safe",
1522
+ parameters: params4,
1523
+ describe: () => ({ allowKey: "find_unfinished", preview: "find unfinished work" }),
1524
+ run: async (_args, ctx) => {
1525
+ const found = unfinishedSessions(ctx.cwd, (b) => commitsAhead(ctx.cwd, b));
1526
+ if (!found.length) {
1527
+ return {
1528
+ 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.",
1529
+ isError: false
1530
+ };
1531
+ }
1532
+ const rows = found.map((s) => {
1533
+ const c = s.checkpoint;
1534
+ return [
1535
+ `## ${s.id}`,
1536
+ `- The user asked: "${c.rawPrompt.trim() || c.title}"`,
1537
+ `- Understood as: ${c.refinedPrompt}`,
1538
+ `- Phases finished: ${c.done.length ? c.done.join(" \u2192 ") : "none"}${c.lane ? ` (lane: ${c.lane})` : ""}`,
1539
+ s.cards.total ? `- Tasks: ${s.cards.done} of ${s.cards.total} finished` : "- No task board yet",
1540
+ `- Branch \`hc/${s.id}/base\` has ${s.commits} commit(s) the base does not`,
1541
+ `- Worktree: ${join6(ctx.cwd, ".horsecode", "worktrees", s.id, "base")}`
1542
+ ].join("\n");
1543
+ });
1544
+ return {
1545
+ content: `${rows.join("\n\n")}
1546
+
1547
+ 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.`,
1548
+ isError: false
1549
+ };
1550
+ }
1551
+ };
1552
+
1553
+ // src/tools/propose-memory.ts
1554
+ import { z as z6 } from "zod";
1555
+ var params5 = z6.object({
1556
+ text: z6.string(),
1557
+ kind: z6.enum(["fact", "lesson"]).optional().describe(
1558
+ "`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."
1559
+ )
1560
+ });
1561
+ var proposeMemoryTool = {
1562
+ name: "propose_memory",
1563
+ 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.",
1564
+ permissionLevel: "safe",
1565
+ parameters: params5,
1566
+ async run(rawArgs, ctx) {
1567
+ const parsed = params5.safeParse(rawArgs);
1568
+ if (!parsed.success) return { content: "propose_memory: invalid args (expected { text, kind? })", isError: true };
1569
+ const text = parsed.data.text.trim();
1570
+ if (!text) return { content: "propose_memory: empty proposal", isError: true };
1571
+ if (!ctx.proposeMemory) return { content: "propose_memory: memory is not available in this context", isError: true };
1572
+ const accepted = ctx.proposeMemory(text, parsed.data.kind ?? "fact");
1573
+ return {
1574
+ 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.",
1575
+ isError: false
1576
+ };
1577
+ }
1578
+ };
1579
+
1580
+ // src/skills/route.ts
1581
+ var STOP = /* @__PURE__ */ new Set([
1582
+ "use",
1583
+ "when",
1584
+ "the",
1585
+ "user",
1586
+ "wants",
1587
+ "and",
1588
+ "for",
1589
+ "with",
1590
+ "that",
1591
+ "this",
1592
+ "from",
1593
+ "into",
1594
+ "also",
1595
+ "not",
1596
+ "other",
1597
+ "otherwise",
1598
+ "improve",
1599
+ "covers",
1600
+ "handles",
1601
+ "should",
1602
+ "become",
1603
+ "than",
1604
+ "over",
1605
+ "onto",
1606
+ "your",
1607
+ "you",
1608
+ "are",
1609
+ "any",
1610
+ "all",
1611
+ "its",
1612
+ "their",
1613
+ "them",
1614
+ "they",
1615
+ "have",
1616
+ "has",
1617
+ "was",
1618
+ "were",
1619
+ "will",
1620
+ "would",
1621
+ "can",
1622
+ "may",
1623
+ "must",
1624
+ "such",
1625
+ "more",
1626
+ "most",
1627
+ "less",
1628
+ "very",
1629
+ "just",
1630
+ "only",
1631
+ "some",
1632
+ "each",
1633
+ "every",
1634
+ "make",
1635
+ "made",
1636
+ "making",
1637
+ "need",
1638
+ "needs",
1639
+ "needed",
1640
+ "want",
1641
+ "wanted",
1642
+ "work",
1643
+ "works",
1644
+ "working",
1645
+ "task",
1646
+ "tasks",
1647
+ "using",
1648
+ "used",
1649
+ "uses",
1650
+ "via",
1651
+ "per",
1652
+ "out",
1653
+ "off",
1654
+ "about",
1655
+ "after",
1656
+ "before",
1657
+ "then",
1658
+ "there",
1659
+ "where",
1660
+ "which",
1661
+ "while",
1662
+ "what",
1663
+ "who",
1664
+ "how",
1665
+ "why",
1666
+ "does",
1667
+ "did",
1668
+ "done",
1669
+ "get",
1670
+ "got",
1671
+ "let",
1672
+ "lets"
1673
+ ]);
1674
+ var MIN_TERM = 3;
1675
+ var SHORT_TERMS = /* @__PURE__ */ new Set(["ui", "ux", "db", "js", "ts", "qa"]);
1676
+ function splitIdentifiers(text) {
1677
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[_./-]+/g, " ");
1678
+ }
1679
+ function terms(text) {
1680
+ return (splitIdentifiers(text).toLowerCase().match(/[a-z][a-z0-9]+/g) ?? []).filter((t) => (t.length >= MIN_TERM || SHORT_TERMS.has(t)) && !STOP.has(t));
1681
+ }
1682
+ var MIN_SHARED = 4;
1683
+ function sameWord(a, b) {
1684
+ if (a === b) return true;
1685
+ const fold = (w) => w.endsWith("e") ? w.slice(0, -1) : w;
1686
+ const [x, y] = [fold(a), fold(b)];
1687
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x];
1688
+ return short.length >= MIN_SHARED && long.startsWith(short);
1689
+ }
1690
+ function exclusions(description) {
1691
+ const m = /\bnot\s+for\s+([^.]+)/i.exec(description);
1692
+ return m ? terms(m[1]) : [];
1693
+ }
1694
+ function isExplicitOnly(description) {
1695
+ 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);
1696
+ }
1697
+ function isNonImplementing(description) {
1698
+ return /\bread[- ]only\b/i.test(description) || /\bdoes\s+not\s+(implement|apply|execute|write)\b/i.test(description);
1699
+ }
1700
+ var MATCH_BAR = 3;
1701
+ var MIN_DENSITY = 0.1;
1702
+ var MAX_ROUTED = 3;
1703
+ var MAX_ROUTED_CHARS = 24e3;
1704
+ function scoreSkill(task, description) {
1705
+ const taskTerms = new Set(terms(task));
1706
+ if (!taskTerms.size) return { score: 0, hits: [], density: 0 };
1707
+ const task_ = [...taskTerms];
1708
+ const excluded2 = exclusions(description);
1709
+ if (excluded2.some((e) => task_.some((t) => sameWord(t, e)))) return { score: 0, hits: [], density: 0 };
1710
+ const body = description.replace(/\bnot\s+for\s+[^.]+/i, "");
1711
+ const vocab = [...new Set(terms(body))];
1712
+ const hits = vocab.filter((d) => task_.some((t) => sameWord(t, d)));
1713
+ return { score: hits.length, hits, density: vocab.length ? hits.length / vocab.length : 0 };
1714
+ }
1715
+ function routeSkills(task, registry, already = [], opts = {}) {
1716
+ const bar = opts.bar ?? MATCH_BAR;
1717
+ let spent = 0;
1718
+ const have = new Set(already);
1719
+ const placed = new Set(opts.placed ?? []);
1720
+ const paths = (opts.files ?? []).join(" ");
1721
+ const base = [opts.role ?? "", routingSubject(task), paths].filter(Boolean).join(" ");
1722
+ const subject = [base, expandExtensions(opts.files ?? []), expandAbbreviations(base)].filter(Boolean).join(" ");
1723
+ 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)).filter((m, i) => i === 0 || m.density >= MIN_DENSITY).slice(0, opts.max ?? MAX_ROUTED).filter((m) => {
1724
+ const len = registry.get(m.name)?.content.length ?? 0;
1725
+ if (spent + len > MAX_ROUTED_CHARS) return false;
1726
+ spent += len;
1727
+ return true;
1728
+ });
1729
+ }
1730
+ function routingSubject(task) {
1731
+ return task.replace(/\S*[\\/]\.horsecode[\\/]pastes[\\/]\S+/g, " ").replace(/\s{2,}/g, " ").trim();
1732
+ }
1733
+ var EXT_WORDS = {
1734
+ tsx: "frontend interface component web",
1735
+ jsx: "frontend interface component web",
1736
+ vue: "frontend interface component web",
1737
+ svelte: "frontend interface component web",
1738
+ css: "frontend interface styling web",
1739
+ scss: "frontend interface styling web",
1740
+ html: "frontend interface web",
1741
+ sql: "database migration",
1742
+ proto: "protocol schema"
1743
+ };
1744
+ var ABBREVIATIONS = {
1745
+ a11y: "accessibility",
1746
+ i18n: "internationalization localization",
1747
+ l10n: "localization",
1748
+ auth: "authentication",
1749
+ ui: "interface",
1750
+ ux: "interface experience"
1751
+ };
1752
+ function expandAbbreviations(text) {
1753
+ const out = [];
1754
+ for (const t of terms(text)) if (ABBREVIATIONS[t]) out.push(ABBREVIATIONS[t]);
1755
+ return out.join(" ");
1756
+ }
1757
+ function expandExtensions(files) {
1758
+ const words2 = /* @__PURE__ */ new Set();
1759
+ for (const f of files) {
1760
+ const ext = f.split(".").pop()?.toLowerCase();
1761
+ if (ext && EXT_WORDS[ext]) for (const w of EXT_WORDS[ext].split(" ")) words2.add(w);
1762
+ }
1763
+ return [...words2].join(" ");
1764
+ }
1765
+ var CONFIDENT_MARGIN = 4;
1766
+ function partitionByConfidence(matches, bar = MATCH_BAR, margin = CONFIDENT_MARGIN) {
1767
+ const confident = [];
1768
+ const borderline = [];
1769
+ for (const m of matches) (m.score >= bar + margin ? confident : borderline).push(m);
1770
+ return { confident, borderline };
1771
+ }
1772
+
1773
+ // src/engine/task-diff.ts
1774
+ var MAX_DIFF_CHARS2 = 6e4;
1775
+ async function taskDiff(cwd, baseRef, git = defaultGitRunner) {
1776
+ const out = await git(["diff", `${baseRef}...HEAD`, "--", ".", ...excludeOwnState()], cwd);
1777
+ if (out.code !== 0) return "";
1778
+ const diff = out.stdout;
1779
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1780
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1781
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1782
+ }
1783
+ function describeDiff(diff) {
1784
+ if (!diff.trim()) {
1785
+ return "The diff for this task could not be produced. Inspect the worktree with read_file/grep instead.";
1786
+ }
1787
+ 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.
1788
+
1789
+ \`\`\`diff
1790
+ ${diff}
1791
+ \`\`\``;
1792
+ }
1793
+ async function workingTreeDiff(cwd, git = defaultGitRunner) {
1794
+ const out = await git(["diff", "HEAD", "--", ".", ...excludeOwnState()], cwd);
1795
+ if (out.code !== 0) return "";
1796
+ const diff = out.stdout;
1797
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1798
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1799
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1800
+ }
1801
+ async function diffSince(cwd, sinceRef, git = defaultGitRunner) {
1802
+ const out = await git(["diff", sinceRef, "--", ".", ...excludeOwnState()], cwd);
1803
+ if (out.code !== 0) return "";
1804
+ const diff = out.stdout;
1805
+ if (diff.length <= MAX_DIFF_CHARS2) return diff;
1806
+ return `${diff.slice(0, MAX_DIFF_CHARS2)}
1807
+ \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1808
+ }
1809
+
1810
+ // src/engine/reviewer.ts
1811
+ var CODE_REVIEW_MAX_TURNS = 25;
1812
+ var CODE_REVIEW_TIMEOUT_MS = 10 * 60 * 1e3;
1813
+ var VerdictSchema = z7.object({
1814
+ verdict: z7.enum(["pass", "fail"]).describe(
1815
+ "`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."
1816
+ ),
1817
+ notes: z7.array(z7.string())
1818
+ });
1819
+ function readOnlyRegistry(deps, opts = {}) {
1820
+ const r = new ToolRegistry();
1821
+ r.register(readFileTool);
1822
+ r.register(grepTool);
1823
+ r.register(globTool);
1824
+ r.register(gitTool);
1825
+ r.register(buildSkillTool(deps.skillRegistry));
1826
+ for (const t of contextTools(deps)) r.register(t);
1827
+ if (opts.remember) r.register(buildRememberTool(deps.rememberFact));
1828
+ if (opts.gitWrite) {
1829
+ r.register(gitWriteTool);
1830
+ r.register(findUnfinishedTool);
1831
+ }
1832
+ if (opts.propose) r.register(proposeMemoryTool);
1833
+ if (opts.mcp) deferMcp(r, deps.mcpTools?.() ?? []);
1834
+ return r;
1835
+ }
1836
+ function deferMcp(r, tools) {
1837
+ for (const t of tools) r.registerDeferred(t);
1838
+ if (tools.length) r.register(buildFindToolTool(r));
1839
+ }
1840
+ async function runReviewer(deps, task, cwd) {
1841
+ const resolved = deps.roleRegistry.resolve("code-reviewer");
1842
+ const hints = memoryHints(deps, task.title, { role: "code-reviewer" });
1843
+ const reviewerTools = readOnlyRegistry(deps, { propose: true });
1844
+ const routed = routeSkills(task.title, deps.skillRegistry, deps.roleRegistry.skillsFor("code-reviewer"), {
1845
+ // The card's own files, never a guess: see the implementer for the measurement that settled it.
1846
+ role: "code-reviewer",
1847
+ files: task.files,
1848
+ placed: placedSkills()
1849
+ });
1850
+ if (routed.length) deps.note?.(`\u{1F4CE} \`code-reviewer\` \xB7 ${routed.map((m) => `**${m.name}**`).join(", ")}`);
1851
+ const diff = deps.baseRef ? await taskDiff(cwd, deps.baseRef) : "";
1852
+ const ask = { role: "user", content: `Review the CODE that implements task "${task.title}" \u2014 correctness, tests, and implementation quality.
1853
+ 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.
1854
+ Give a verdict (pass/fail + notes).
1855
+
1856
+ ${describeDiff(diff)}` };
1857
+ const law = deps.home ? await constitutionNote(
1858
+ { ...deps, home: deps.home, note: deps.note },
1859
+ cwd,
1860
+ { role: "code-reviewer", files: task.files, title: task.title }
1861
+ ) : "";
1862
+ const opts = {
1863
+ provider: deps.provider,
1864
+ ...resolved,
1865
+ // The reviewer gets the SAME rules the implementer was given: a gate that does not know what was
1866
+ // required cannot tell whether it was met, and that is where a constitution stops being one.
1867
+ systemPrompt: (routed.length ? applySkills(resolved.systemPrompt, routed.map((m) => m.name), deps.skillRegistry) : resolved.systemPrompt) + law + projectToolsNote(reviewerTools.list(), !!loadGraphSync(cwd)) + BATCH_TOOLS_NOTE,
1868
+ tools: reviewerTools,
1869
+ proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "code-reviewer") ?? false,
1870
+ messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
1871
+ permission: deps.permission,
1872
+ approve: deps.approve,
1873
+ cwd,
1874
+ signal: AbortSignal.any([deps.signal, AbortSignal.timeout(CODE_REVIEW_TIMEOUT_MS)]),
1875
+ maxTurns: CODE_REVIEW_MAX_TURNS
1876
+ };
1877
+ const verdict = await runStructuredRole(opts, VerdictSchema);
1878
+ reinforceUsed(deps, hints.ids, verdict.notes.join(" "), "code-reviewer");
1879
+ return verdict;
1880
+ }
1881
+
1882
+ // src/tools/web.ts
1883
+ import { z as z8 } from "zod";
1884
+ var params6 = z8.object({ url: z8.string().url() });
1885
+ var MAX_CHARS = 1e5;
1886
+ function createWebFetchTool(fetchFn = globalThis.fetch) {
1887
+ return {
1888
+ name: "web_fetch",
1889
+ description: "Fetches the content (text) of a URL.",
1890
+ permissionLevel: "safe",
1891
+ parameters: params6,
1892
+ async run(rawArgs, ctx) {
1893
+ const parsed = params6.safeParse(rawArgs);
1894
+ if (!parsed.success) {
1895
+ return {
1896
+ content: `web_fetch: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
1897
+ isError: true
1898
+ };
1899
+ }
1900
+ const a = parsed.data;
1901
+ try {
1902
+ const res = await fetchFn(a.url, { signal: ctx.signal });
1903
+ const text = await res.text();
1904
+ const capped = text.length > MAX_CHARS ? text.slice(0, MAX_CHARS) + "\n\u2026 (truncated)" : text;
1905
+ return { content: capped, isError: !res.ok };
1906
+ } catch (e) {
1907
+ return {
1908
+ content: `web_fetch error: ${e instanceof Error ? e.message : String(e)}`,
1909
+ isError: true
1910
+ };
1911
+ }
1912
+ }
1913
+ };
1914
+ }
1915
+
1916
+ export {
1917
+ describeInherited,
1918
+ describeTopUp,
1919
+ toSlug,
1920
+ mainWorktreeRoot,
1921
+ WorktreeManager,
1922
+ unfinishedSessions,
1923
+ describeUnfinished,
1924
+ gitTool,
1925
+ buildRememberTool,
1926
+ routeSkills,
1927
+ partitionByConfidence,
1928
+ specsDir,
1929
+ constitutionPath,
1930
+ verifyPaths,
1931
+ featureSlugFor,
1932
+ nextFeatureSlug,
1933
+ scaffoldFeature,
1934
+ constitutionNote,
1935
+ taskDiff,
1936
+ describeDiff,
1937
+ workingTreeDiff,
1938
+ diffSince,
1939
+ CODE_REVIEW_MAX_TURNS,
1940
+ CODE_REVIEW_TIMEOUT_MS,
1941
+ readOnlyRegistry,
1942
+ deferMcp,
1943
+ runReviewer,
1944
+ createWebFetchTool
1945
+ };