@hizliemre/horse-code 0.3.1 → 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.
@@ -1,27 +1,37 @@
1
1
  import {
2
- checkpointKey,
3
- checkpointMtime,
4
- isContinuePrompt,
5
- readCheckpoint
6
- } from "./chunk-ZSQ24YDJ.js";
2
+ CODE_REVIEW_MAX_TURNS,
3
+ CODE_REVIEW_TIMEOUT_MS,
4
+ buildRememberTool,
5
+ constitutionNote,
6
+ createWebFetchTool,
7
+ describeDiff,
8
+ diffSince,
9
+ gitTool,
10
+ partitionByConfidence,
11
+ readOnlyRegistry,
12
+ routeSkills,
13
+ taskDiff,
14
+ workingTreeDiff
15
+ } from "./chunk-NBTH2VVI.js";
7
16
  import {
8
17
  RoleRegistry,
9
- applySkills,
10
- buildSkillTool,
11
18
  cliFor,
12
19
  cliInvocation,
13
20
  grokEffort,
14
- placedSkills,
15
21
  planFor,
16
22
  runTraces
17
- } from "./chunk-YULQ4URQ.js";
23
+ } from "./chunk-ACTVFJRW.js";
24
+ import {
25
+ applySkills,
26
+ buildSkillTool,
27
+ placedSkills
28
+ } from "./chunk-MZM24M5M.js";
18
29
  import {
19
30
  SYNTHETIC,
20
31
  runCliAgent
21
32
  } from "./chunk-G45RWL7S.js";
22
33
  import {
23
- defaultGitRunner,
24
- gitVerb
34
+ defaultGitRunner
25
35
  } from "./chunk-LPQU436C.js";
26
36
  import {
27
37
  BATCH_TOOLS_NOTE,
@@ -42,8 +52,7 @@ import {
42
52
  import {
43
53
  handedOver,
44
54
  runToCompletion,
45
- telemetry,
46
- truncateSafe
55
+ telemetry
47
56
  } from "./chunk-JLWQCA7B.js";
48
57
  import {
49
58
  loadTraceIndex,
@@ -240,1865 +249,23 @@ function usageEvent(u) {
240
249
  };
241
250
  }
242
251
 
243
- // src/worktree/slug.ts
244
- var MAX_SLUG = 60;
245
- var MAX_WORDS = 5;
246
- var LEADING_VERBS = /* @__PURE__ */ new Set([
247
- "add",
248
- "build",
249
- "create",
250
- "implement",
251
- "make",
252
- "write",
253
- "develop",
254
- "design",
255
- "generate",
256
- "introduce",
257
- "fix",
258
- "update",
259
- "change",
260
- "modify",
261
- "refactor",
262
- "rewrite",
263
- "improve",
264
- "enhance",
265
- "optimize",
266
- "clean",
267
- "setup",
268
- "configure",
269
- "install",
270
- "remove",
271
- "delete",
272
- "drop",
273
- "rename",
274
- "migrate",
275
- "move",
276
- "port",
277
- "support",
278
- "enable",
279
- "disable",
280
- "finalize",
281
- "complete",
282
- "finish",
283
- "expand",
284
- "extend",
285
- "set",
286
- "apply"
287
- ]);
288
- var FILLERS = /* @__PURE__ */ new Set(["a", "an", "the"]);
289
- function dropLeadingAction(words3) {
290
- let i = 0;
291
- if (words3.length && LEADING_VERBS.has(words3[0])) {
292
- i = 1;
293
- while (i < words3.length && FILLERS.has(words3[i])) i++;
294
- } else {
295
- while (i < words3.length && FILLERS.has(words3[i])) i++;
296
- }
297
- return i < words3.length ? words3.slice(i) : words3;
298
- }
299
- function toSlug(name) {
300
- const words3 = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
301
- const named = dropLeadingAction(words3).slice(0, MAX_WORDS);
302
- const s = named.join("-").slice(0, MAX_SLUG).replace(/-+$/g, "");
303
- return s || "job";
304
- }
305
- function uniqueSlug(base, taken) {
306
- if (!taken(base)) return base;
307
- let n = 2;
308
- while (taken(`${base}-${n}`)) n++;
309
- return `${base}-${n}`;
310
- }
311
-
312
- // src/worktree/manager.ts
313
- import { mkdir as mkdir2, writeFile, rm as rm2 } from "fs/promises";
314
- import { existsSync as existsSync2, readdirSync, realpathSync } from "fs";
315
- import { join as join2, resolve, dirname as dirname2, basename } from "path";
316
-
317
- // src/worktree/inherit.ts
318
- import { cp, mkdir, rm, stat } from "fs/promises";
252
+ // src/tools/write.ts
253
+ import { mkdir, writeFile } from "fs/promises";
319
254
  import { existsSync } from "fs";
320
- import { dirname, join, sep } from "path";
321
- var INHERITED_ASSETS = [
322
- join("graphify-out", "graph.json"),
323
- // The community names beside it: without them a session's graph tools fall back to numbers, which is the
324
- // difference between "this touches Wallet Member & Balance" and "this touches community 47".
325
- join("graphify-out", ".graphify_labels.json"),
326
- join(".horsecode", "memory.jsonl"),
327
- join(".horsecode", "skills"),
328
- join(".specify", "memory", "constitution.md"),
329
- join(".horsecode", "migrated.json")
330
- ];
331
- var NEVER = [join(".horsecode", "worktrees")];
332
- var excluded = (rel) => NEVER.some((n) => rel === n || rel.startsWith(n + sep) || rel.startsWith(n + "/"));
333
- function nestedCheckout(repoRoot, rel, cache) {
334
- const parts = rel.split(/[\\/]/).slice(0, -1);
335
- let acc = "";
336
- for (const part of parts) {
337
- acc = acc ? `${acc}/${part}` : part;
338
- let hit = cache.get(acc);
339
- if (hit === void 0) {
340
- hit = existsSync(join(repoRoot, acc, ".git"));
341
- cache.set(acc, hit);
342
- }
343
- if (hit) return true;
344
- }
345
- return false;
346
- }
347
- var MAX_UNTRACKED = 5e3;
348
- async function copyPath(from, to) {
349
- await mkdir(dirname(to), { recursive: true });
350
- await cp(from, to, { recursive: true, dereference: true, force: true });
351
- }
352
- async function inheritFromRoot(git, repoRoot, baseWorktree) {
353
- const out = { modified: [], untracked: [], assets: [], deleted: [], skipped: 0 };
354
- if (repoRoot === baseWorktree) return out;
355
- const changed = await git(["diff", "--name-status", "HEAD"], repoRoot);
356
- if (changed.code === 0) {
357
- for (const line of changed.stdout.split("\n")) {
358
- const [status, ...rest] = line.trim().split(/\t/);
359
- const rel = rest.join(" ");
360
- if (!status || !rel || excluded(rel)) continue;
361
- try {
362
- if (status.startsWith("D")) {
363
- await rm(join(baseWorktree, rel), { force: true });
364
- out.deleted.push(rel);
365
- } else {
366
- await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
367
- out.modified.push(rel);
368
- }
369
- } catch {
370
- }
371
- }
372
- }
373
- const others = await git(["ls-files", "--others", "--exclude-standard"], repoRoot);
374
- if (others.code === 0) {
375
- const nested = /* @__PURE__ */ new Map();
376
- for (const rel of others.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
377
- if (excluded(rel) || nestedCheckout(repoRoot, rel, nested)) {
378
- out.skipped++;
379
- continue;
380
- }
381
- if (out.untracked.length >= MAX_UNTRACKED) {
382
- out.skipped++;
383
- continue;
384
- }
385
- try {
386
- await copyPath(join(repoRoot, rel), join(baseWorktree, rel));
387
- out.untracked.push(rel);
388
- } catch {
389
- }
390
- }
391
- }
392
- for (const rel of INHERITED_ASSETS) {
393
- const from = assetSource(repoRoot, rel);
394
- if (!from) continue;
395
- try {
396
- await stat(from);
397
- await copyPath(from, join(baseWorktree, rel));
398
- out.assets.push(rel);
399
- } catch {
400
- }
401
- }
402
- return out;
403
- }
404
- function assetSource(repoRoot, rel) {
405
- const atRoot = join(repoRoot, rel);
406
- if (existsSync(atRoot)) return atRoot;
407
- const standing = join(repoRoot, ".horsecode", "worktrees", "traces", "base", rel);
408
- return existsSync(standing) ? standing : void 0;
409
- }
410
- function describeInherited(i) {
411
- const parts = [];
412
- const n = i.modified.length + i.deleted.length;
413
- if (n) parts.push(`${n} uncommitted change(s)`);
414
- if (i.untracked.length) parts.push(`${i.untracked.length} untracked file(s)`);
415
- if (i.skipped) parts.push(`${i.skipped} left behind (another checkout, or past the ${MAX_UNTRACKED} bound)`);
416
- if (i.assets.length) parts.push(i.assets.map((a) => `\`${a}\``).join(", "));
417
- return parts.length ? `\u{1F4E5} Carried into this session: ${parts.join(" \xB7 ")}.` : void 0;
418
- }
419
- async function topUpInherited(repoRoot, baseWorktree) {
420
- const added = [];
421
- if (repoRoot === baseWorktree) return added;
422
- for (const rel of INHERITED_ASSETS) {
423
- const from = join(repoRoot, rel);
424
- const to = join(baseWorktree, rel);
425
- if (!existsSync(from) || existsSync(to)) continue;
426
- try {
427
- await copyPath(from, to);
428
- added.push(rel);
429
- } catch {
430
- }
431
- }
432
- return added;
433
- }
434
- function describeTopUp(added) {
435
- if (!added.length) return void 0;
436
- return `\u{1F4E5} This session was opened before ${added.map((a) => `\`${a}\``).join(", ")} existed \u2014 carried in now.`;
437
- }
438
-
439
- // src/worktree/manager.ts
440
- var MAX_DIFF_CHARS = 12e4;
441
- var DOC_SPECS = ["*.md", "*.txt"];
442
- function excludeOwnState() {
443
- const roots = [".horsecode", "graphify-out", traceRootRel()].filter(Boolean);
444
- return [...new Set(roots)].map((r) => `:(exclude)${r}/**`);
445
- }
446
- async function mainWorktreeRoot(git, cwd) {
447
- const abs = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
448
- const r = abs.code === 0 ? abs : await git(["rev-parse", "--git-common-dir"], cwd);
449
- if (r.code !== 0 || !r.stdout.trim()) return cwd;
450
- const common = resolve(cwd, r.stdout.trim());
451
- return basename(common) === ".git" ? dirname2(common) : cwd;
452
- }
453
- var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
454
- var DAYS = ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"];
455
- function sessionName(now, taken) {
456
- const day = `${String(now.getDate()).padStart(2, "0")}-${MONTHS[now.getMonth()]}-${now.getFullYear()}-${DAYS[now.getDay()]}`;
457
- for (let n = 1; n < 1e3; n++) {
458
- const name = `${day}_${String(n).padStart(2, "0")}`;
459
- if (!taken(name)) return name;
460
- }
461
- return `${day}_${Date.now()}`;
462
- }
463
- var FORBIDDEN_AT_ROOT = /* @__PURE__ */ new Set([
464
- "merge",
465
- "rebase",
466
- "cherry-pick",
467
- "revert",
468
- "reset",
469
- "checkout",
470
- "switch",
471
- "restore",
472
- "commit",
473
- "am",
474
- "apply",
475
- "stash",
476
- "pull",
477
- "clean"
478
- ]);
479
- function guardRoot(run, repoRoot) {
480
- return async (args, cwd) => {
481
- const verb = gitVerb(args);
482
- if (cwd === repoRoot && verb !== void 0 && FORBIDDEN_AT_ROOT.has(verb)) {
483
- throw new Error(
484
- `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.`
485
- );
486
- }
487
- return run(args, cwd);
488
- };
489
- }
490
- var WorktreeManager = class {
491
- repoRoot;
492
- /** The project checkout this manager was built for — where per-project settings and the remote live. */
493
- get projectRoot() {
494
- return this.repoRoot;
495
- }
496
- /**
497
- * Where sessions are kept, which is the REPOSITORY's business and not the caller's checkout.
498
- *
499
- * Measured from a live run: started inside another tool's worktree, horse-code opened its session at
500
- * `…/.claude/worktrees/product-create-wizard/.horsecode/worktrees/…/base` — its own worktree nested inside
501
- * someone else's, inside the repository. That works and is a place nobody will look: `/clean-worktrees` at
502
- * the repository root cannot see it, and removing the outer checkout takes it with it.
503
- *
504
- * Distinct from `repoRoot` on purpose. What a session INHERITS — the code graph, the memory, the project
505
- * config — is whatever the user is standing in, and that is frequently not the main checkout.
506
- */
507
- worktreeHome;
508
- git;
509
- /**
510
- * The unguarded runner, for the one case that legitimately needs a forbidden verb: turning a directory
511
- * that is not a repository into one.
512
- *
513
- * `git worktree add` needs a commit to branch from, and an empty repository has none — so a first commit
514
- * is not delivery, it is the precondition for ever leaving the root alone again. It runs only when there
515
- * is no HEAD, so there is no branch to disturb and no work to overwrite. Named rather than flagged, so
516
- * grepping for it finds every use.
517
- */
518
- rawGit;
519
- /** Injectable clock: a session's NAME is the day it opened, so a test has to be able to say which day. */
520
- now;
521
- constructor(deps) {
522
- this.repoRoot = deps.repoRoot;
523
- this.worktreeHome = deps.worktreeHome ?? deps.repoRoot;
524
- this.rawGit = deps.runGit ?? defaultGitRunner;
525
- this.git = guardRoot(this.rawGit, deps.repoRoot);
526
- this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
527
- }
528
- /** Runs git; nonzero exit → throws a clear error. Returns output (stdout). */
529
- async run(args, cwd) {
530
- const r = await this.git(args, cwd);
531
- if (r.code !== 0) {
532
- throw new Error(`git ${args.join(" ")} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
533
- }
534
- return r.stdout;
535
- }
536
- /** `git init` the repo if the directory isn't one yet (the user may not have run git init) + ensure an
537
- * identity so the first commit doesn't fail on a machine with no global git config. */
538
- async ensureRepo() {
539
- const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
540
- if (inside.code === 0 && inside.stdout.trim() === "true") return;
541
- await this.run(["init", "-b", "main"], this.repoRoot);
542
- const email = await this.git(["config", "user.email"], this.repoRoot);
543
- if (email.code !== 0 || !email.stdout.trim()) {
544
- await this.git(["config", "user.email", "horse-code@local"], this.repoRoot);
545
- await this.git(["config", "user.name", "horse-code"], this.repoRoot);
546
- }
547
- }
548
- /**
549
- * A worktree must branch off a commit. First ensure the directory IS a git repo (auto `git init` if not).
550
- * A freshly `git init`-ed repo has an unborn HEAD (no commits), so `git worktree add … <branch>` fails with
551
- * "invalid reference". Bootstrap one empty commit so horse-code works in a brand-new / non-git directory.
552
- */
553
- async ensureBaseCommit() {
554
- await this.ensureRepo();
555
- const head = await this.git(["rev-parse", "--verify", "--quiet", "HEAD"], this.repoRoot);
556
- if (head.code === 0) return;
557
- const r = await this.rawGit(["commit", "--allow-empty", "-m", "hc: initial commit"], this.repoRoot);
558
- if (r.code !== 0) throw new Error(`git commit --allow-empty failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
559
- }
560
- /**
561
- * The ref to base the session's worktree on. Uses `fromBranch` when it resolves; otherwise falls back to
562
- * HEAD. This covers the common fresh-repo mismatch: horse-code guesses "main" but the repo's actual
563
- * (default/unborn) branch is "master", so "main" never resolves even after the bootstrap commit.
564
- */
565
- async resolveBase(fromBranch) {
566
- const ok = await this.git(["rev-parse", "--verify", "--quiet", fromBranch], this.repoRoot);
567
- return ok.code === 0 ? fromBranch : "HEAD";
568
- }
569
- async openSession(fromBranch, jobName) {
570
- await this.ensureBaseCommit();
571
- const base = await this.resolveBase(fromBranch);
572
- const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
573
- await mkdir2(worktreesDir, { recursive: true });
574
- await writeFile(join2(worktreesDir, ".gitignore"), "*\n", "utf8");
575
- const listed = await this.git(["for-each-ref", "--format=%(refname:short)", "refs/heads/hc/"], this.repoRoot);
576
- const branches = new Set(listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean));
577
- const jobSlug = sessionName(this.now(), (s) => existsSync2(join2(worktreesDir, s)) || branches.has(`hc/${s}/base`));
578
- const root = join2(worktreesDir, jobSlug);
579
- const baseWorktree = join2(root, "base");
580
- const baseBranch = `hc/${jobSlug}/base`;
581
- await mkdir2(join2(root, "tasks"), { recursive: true });
582
- await this.run(["worktree", "add", "-b", baseBranch, baseWorktree, base], this.repoRoot);
583
- const inherited = await inheritFromRoot((args, cwd) => this.git(args, cwd), this.repoRoot, baseWorktree);
584
- return { jobSlug, root, baseWorktree, baseBranch, inherited };
585
- }
586
- /**
587
- * Opens — or re-enters — a worktree with a FIXED name, for the standing work that is not one job.
588
- *
589
- * `openSession` mints a fresh dated slug every call, which is right for a job: two runs of "add login" are
590
- * two pieces of work and must not share a branch. Tracing is the opposite. It is one long-lived artefact
591
- * the project keeps, its index is checkpointed so an interrupted run resumes, and a new worktree per
592
- * invocation would both lose that resumption and pile up full checkouts — measured on the project this was
593
- * written for, a checkout is not small.
594
- *
595
- * So the slug is the caller's, and running it twice re-enters the same place. Re-entry is decided by git
596
- * rather than by the directory existing: a leftover directory git no longer tracks is not a worktree, and
597
- * treating one as resumable is how a run ends up writing into a checkout that no longer has a branch.
598
- */
599
- async openFixed(fromBranch, slug) {
600
- await this.ensureBaseCommit();
601
- const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
602
- await mkdir2(worktreesDir, { recursive: true });
603
- await writeFile(join2(worktreesDir, ".gitignore"), "*\n", "utf8");
604
- const root = join2(worktreesDir, slug);
605
- const baseWorktree = join2(root, "base");
606
- const baseBranch = `hc/${slug}/base`;
607
- let real;
608
- try {
609
- real = realpathSync(baseWorktree);
610
- } catch {
611
- }
612
- if (real && (await this.registeredWorktrees()).has(real)) {
613
- return { jobSlug: slug, root, baseWorktree, baseBranch, resumed: true };
614
- }
615
- const base = await this.resolveBase(fromBranch);
616
- await mkdir2(join2(root, "tasks"), { recursive: true });
617
- const listed = await this.git(["for-each-ref", "--format=%(refname:short)", `refs/heads/${baseBranch}`], this.repoRoot);
618
- const exists = listed.stdout.trim() === baseBranch;
619
- await this.run(exists ? ["worktree", "add", baseWorktree, baseBranch] : ["worktree", "add", "-b", baseBranch, baseWorktree, base], this.repoRoot);
620
- const inherited = await inheritFromRoot((args, cwd) => this.git(args, cwd), this.repoRoot, baseWorktree);
621
- return { jobSlug: slug, root, baseWorktree, baseBranch, inherited };
622
- }
623
- /** Absolute paths of the worktrees git currently tracks (from `git worktree list --porcelain`). */
624
- async registeredWorktrees() {
625
- const r = await this.git(["worktree", "list", "--porcelain"], this.repoRoot);
626
- const paths = /* @__PURE__ */ new Set();
627
- for (const line of r.stdout.split("\n")) {
628
- if (line.startsWith("worktree ")) {
629
- const p = line.slice("worktree ".length).trim();
630
- try {
631
- paths.add(realpathSync(p));
632
- } catch {
633
- paths.add(p);
634
- }
635
- }
636
- }
637
- return paths;
638
- }
639
- /**
640
- * Resume support: find a preserved worktree from an earlier interrupted run. Scans every
641
- * `.horsecode/worktrees/<slug>/checkpoint.json` and only considers a session whose `base` worktree is still
642
- * live in git (a pruned/stale dir can't be safely reused). A bare "continue" request (`isContinuePrompt`)
643
- * matches any preserved work — the user needn't retype the original request — and among those, the one with
644
- * ACTUAL PROGRESS wins over the merely most recent. Otherwise the prompt must match a checkpoint's stored
645
- * `rawPrompt` (case/space-tolerant). Returns null when there is nothing to resume.
646
- */
647
- async findResumable(rawPrompt) {
648
- const worktreesDir = join2(this.worktreeHome, ".horsecode", "worktrees");
649
- if (!existsSync2(worktreesDir)) return null;
650
- const inside = await this.git(["rev-parse", "--is-inside-work-tree"], this.repoRoot);
651
- if (inside.code !== 0) return null;
652
- const anyContinue = isContinuePrompt(rawPrompt);
653
- const key2 = checkpointKey(rawPrompt);
654
- const registered = await this.registeredWorktrees();
655
- const candidates = [];
656
- for (const slug of readdirSync(worktreesDir)) {
657
- const root = join2(worktreesDir, slug);
658
- const cp2 = readCheckpoint(root);
659
- if (!cp2) continue;
660
- if (!anyContinue && checkpointKey(cp2.rawPrompt) !== key2) continue;
661
- const baseWorktree = join2(root, "base");
662
- let real;
663
- try {
664
- real = realpathSync(baseWorktree);
665
- } catch {
666
- continue;
667
- }
668
- if (!registered.has(real)) continue;
669
- candidates.push({
670
- session: { jobSlug: slug, root, baseWorktree, baseBranch: `hc/${slug}/base`, resumed: true },
671
- mtime: checkpointMtime(root),
672
- progress: cp2.done.length
673
- });
674
- }
675
- if (candidates.length === 0) return null;
676
- candidates.sort((a, b) => (b.progress > 0 ? 1 : 0) - (a.progress > 0 ? 1 : 0) || b.mtime - a.mtime);
677
- const picked = candidates[0].session;
678
- const added = await topUpInherited(this.repoRoot, picked.baseWorktree);
679
- return added.length ? { ...picked, toppedUp: added } : picked;
680
- }
681
- /**
682
- * The worktree for a task — REUSED when the task already has one.
683
- *
684
- * It used to mint a fresh slug every time, so a task got `…-1`, `…-2`, `…-9` and each run began from base
685
- * with the previous run's work stranded in a directory nobody would open again. Measured live: 321
686
- * worktrees on disk, TEN of them for one task, and the newest empty while `…-9` held 8 commits and 7.6 KB
687
- * of finished work.
688
- *
689
- * It also made the pipeline lie. The deadline warning tells the implementer "whatever it wrote is committed
690
- * and kept — continue from there rather than starting over", and across runs that was simply false: a task
691
- * needing more than one run's worth of work could never accumulate any.
692
- *
693
- * A fresh slug is still minted when the existing directory is not a usable worktree for this task's branch,
694
- * because a broken one must not stop the task.
695
- */
696
- async deriveTask(session, taskName) {
697
- const tasksDir = join2(session.root, "tasks");
698
- const slug = toSlug(taskName);
699
- const branch = `hc/${session.jobSlug}/t/${slug}`;
700
- const existing = join2(tasksDir, slug);
701
- if (existsSync2(existing)) {
702
- const ok = await this.git(["rev-parse", "--abbrev-ref", "HEAD"], existing);
703
- if (ok.code === 0 && ok.stdout.trim() === branch) return { taskSlug: slug, worktree: existing, branch };
704
- }
705
- const taskSlug = uniqueSlug(slug, (s) => existsSync2(join2(tasksDir, s)));
706
- const wt = join2(tasksDir, taskSlug);
707
- const br = `hc/${session.jobSlug}/t/${taskSlug}`;
708
- await this.run(["worktree", "add", "-b", br, wt, session.baseBranch], this.repoRoot);
709
- return { taskSlug, worktree: wt, branch: br };
710
- }
711
- /**
712
- * Retires a task's worktree and branch so the next derive starts from the CURRENT base.
713
- *
714
- * Reusing a task's worktree between attempts stopped the "start from scratch every run" waste, but it also
715
- * FROZE the branch's root. Measured on a real board: the export/import task's branch was rooted two and a
716
- * half days back and the base had moved 68 commits past it, while the throwaway worktrees it replaced had
717
- * been rooted 29-30 commits back. Its merge then had to reconcile a drift that wide across seven files, and
718
- * the resolver ran out of turns every time — twice on a review that had already PASSED.
719
- *
720
- * Past a few of those, re-implementing on today's base is cheaper than reconciling the drift, and it is the
721
- * only move that actually removes the cause.
722
- *
723
- * The old branch is RENAMED, not deleted. It holds work that passed review; throwing it away to save a
724
- * branch name would destroy the only copy of it.
725
- */
726
- async restartTask(session, task) {
727
- let retired = `${task.branch}-stale`;
728
- for (let n = 2; (await this.git(["rev-parse", "--verify", "--quiet", retired], this.repoRoot)).code === 0; n++) {
729
- retired = `${task.branch}-stale-${n}`;
730
- }
731
- await this.run(["worktree", "remove", "--force", task.worktree], this.repoRoot);
732
- await this.run(["branch", "-m", task.branch, retired], this.repoRoot);
733
- return retired;
734
- }
735
- async mergeTask(session, task) {
736
- return this.mergeRef(session, task.branch);
737
- }
738
- /**
739
- * Merges any ref into the session base — a task branch coming home, or the project's main branch coming in.
740
- *
741
- * A resumed session picks up a branch that was cut days ago, and everything the team merged in the meantime
742
- * is missing from it: it continues against code that no longer exists. Bringing main IN is the same
743
- * operation as bringing a task in, which is why it is the same method and the same conflict path.
744
- */
745
- async mergeRef(session, ref) {
746
- const r = await this.git(["merge", ref], session.baseWorktree);
747
- if (r.code === 0) return { status: "merged" };
748
- const diff = await this.git(
749
- ["diff", "--name-only", "--diff-filter=U"],
750
- session.baseWorktree
751
- );
752
- const files = diff.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
753
- if (files.length > 0) return { status: "conflict", files };
754
- throw new Error(`git merge ${ref} failed (${r.code}): ${(r.stderr || r.stdout).trim()}`);
755
- }
756
- /**
757
- * Brings one branch up to date from the remote. Best-effort: no remote, no network, no such branch — the
758
- * merge then runs against the local copy, which is still better than not syncing at all.
759
- *
760
- * Returns the ref to merge: `origin/<branch>` when the fetch landed, the plain branch name otherwise.
761
- */
762
- async fetchBranch(branch) {
763
- const r = await this.git(["fetch", "origin", branch], this.repoRoot);
764
- if (r.code !== 0) return branch;
765
- const remote = await this.git(["rev-parse", "--verify", `origin/${branch}`], this.repoRoot);
766
- return remote.code === 0 ? `origin/${branch}` : branch;
767
- }
768
- /** Whether the session base already contains `ref` — nothing to merge, and nothing to say about it. */
769
- async containsRef(session, ref) {
770
- const r = await this.git(["merge-base", "--is-ancestor", ref, "HEAD"], session.baseWorktree);
771
- return r.code === 0;
772
- }
773
- /** How many commits `ref` has that the session base does not — what a sync is about to bring in. */
774
- async commitsBehind(session, ref) {
775
- const r = await this.git(["rev-list", "--count", `HEAD..${ref}`], session.baseWorktree);
776
- return r.code === 0 ? Number(r.stdout.trim()) || 0 : 0;
777
- }
778
- /** Files git marks as unmerged (conflicted) in the base worktree. */
779
- async unmergedFiles(session) {
780
- const r = await this.git(["diff", "--name-only", "--diff-filter=U"], session.baseWorktree);
781
- return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
782
- }
783
- /**
784
- * Resolves one conflicted path by taking the BASE's copy, for files that are regenerated rather than merged.
785
- *
786
- * `--ours` during a merge means the branch being merged INTO — the session base, where the other tasks'
787
- * work has already landed. For a lockfile that is the right side: it already carries every dependency the
788
- * merged tasks installed, and the incoming branch's own addition is re-derived by running the package
789
- * manager, not by choosing lines from a machine-written file.
790
- */
791
- async resolveWithBase(session, file) {
792
- await this.git(["checkout", "--ours", "--", file], session.baseWorktree);
793
- await this.git(["add", "--", file], session.baseWorktree);
794
- }
795
- /**
796
- * One side of a conflicted file, as git holds it: stage 2 is the base's, stage 3 is the incoming branch's.
797
- *
798
- * Needed by the resolutions that COMBINE the two sides rather than choosing one — reading the working-tree
799
- * copy would only give the version with the markers in it.
800
- */
801
- async conflictSide(session, file, side) {
802
- const r = await this.git(["show", `:${side === "ours" ? 2 : 3}:${file}`], session.baseWorktree);
803
- return r.code === 0 ? r.stdout : void 0;
804
- }
805
- /** Stages content the caller merged itself, settling that file's conflict. */
806
- async resolveWith(session, file, content) {
807
- await writeFile(join2(session.baseWorktree, file), content, "utf8");
808
- await this.git(["add", "--", file], session.baseWorktree);
809
- }
810
- /** Unified diff of changes in the base worktree against the base branch (the PR diff). */
811
- /**
812
- * The code first, then what was written about it.
813
- *
814
- * Git orders a diff by path, so on a run whose work lives under `toucan/` every specification, plan,
815
- * checklist and brainstorm sorts ahead of the source. Measured on PR #765 with horse-code's own state
816
- * already excluded: the first 60,000 characters held nine files, all of them markdown, and not one line of
817
- * the code the review existed to read. Ordering is the fix — a budget spent on prose is a budget the
818
- * source never sees, however large it is.
819
- */
820
- async diff(session, base) {
821
- const range = `${base}...${session.baseBranch}`;
822
- const notDocs = DOC_SPECS.map((d) => `:(exclude)${d}`);
823
- const code = await this.git(["diff", range, "--", ".", ...excludeOwnState(), ...notDocs], session.baseWorktree);
824
- const docs = await this.git(["diff", range, "--", ...DOC_SPECS, ...excludeOwnState()], session.baseWorktree);
825
- const out = code.stdout + docs.stdout;
826
- if (out.length <= MAX_DIFF_CHARS) return out;
827
- return `\u2026 (diff truncated to the first ${MAX_DIFF_CHARS} characters of ${out.length}; use the read tools to inspect anything not shown here)
828
- ${out.slice(0, MAX_DIFF_CHARS)}`;
829
- }
830
- /**
831
- * Rejection path: commit whatever draft the worktree holds to its branch (so the work is NOT lost) and
832
- * KEEP both the worktree directory and the branch, so the user can inspect the produced files directly
833
- * under .horsecode/worktrees/<slug>/base. Returns the worktree path. (closeSession, by contrast, deletes
834
- * the worktree + branch — but nothing currently calls it on the happy path; worktrees are kept for inspection.)
835
- */
836
- async preserveSession(session, message) {
837
- await this.commitMerge(session, message);
838
- return session.baseWorktree;
839
- }
840
- async commitMerge(session, message) {
841
- await this.run(["add", "-A"], session.baseWorktree);
842
- const staged = await this.git(["diff", "--cached", "--quiet"], session.baseWorktree);
843
- if (staged.code === 0) return;
844
- await this.run(message ? ["commit", "-m", message] : ["commit", "--no-edit"], session.baseWorktree);
845
- }
846
- /** Commits all changes in the task worktree to the task branch; no-op if there are no changes. */
847
- async commitTask(task, message) {
848
- await this.run(["add", "-A"], task.worktree);
849
- const staged = await this.git(["diff", "--cached", "--quiet"], task.worktree);
850
- if (staged.code === 0) return;
851
- await this.run(["commit", "-m", message], task.worktree);
852
- }
853
- async abortMerge(session) {
854
- await this.run(["merge", "--abort"], session.baseWorktree);
855
- }
856
- async removeTask(session, task) {
857
- await this.git(["worktree", "remove", "--force", task.worktree], this.repoRoot);
858
- await this.git(["branch", "-D", task.branch], this.repoRoot);
859
- }
860
- async closeSession(session) {
861
- await rm2(session.root, { recursive: true, force: true });
862
- await this.git(["worktree", "prune"], this.repoRoot);
863
- const prefix = `hc/${session.jobSlug}/`;
864
- const list = await this.git(["branch", "--list"], this.repoRoot);
865
- const branches = list.stdout.split("\n").map((s) => s.replace(/^[*+ ]+/, "").trim()).filter((b) => b.startsWith(prefix));
866
- for (const b of branches) {
867
- await this.git(["branch", "-D", b], this.repoRoot);
868
- }
869
- }
870
- /** Whether a remote exists — the difference between a pull request being delivery and being impossible. */
871
- async hasRemote(session, remote = "origin") {
872
- return (await this.git(["remote", "get-url", remote], session.baseWorktree)).code === 0;
873
- }
874
- async push(session, remote = "origin") {
875
- const check = await this.git(["remote", "get-url", remote], session.baseWorktree);
876
- if (check.code !== 0) return;
877
- await this.run(["push", remote, session.baseBranch], session.baseWorktree);
878
- }
879
- async openPR(session, adapter, input) {
880
- const res = await adapter.createPR({
881
- branch: session.baseBranch,
882
- base: input.base,
883
- title: input.title,
884
- body: input.body
885
- });
886
- return { url: res.url };
887
- }
888
- };
889
-
890
- // src/engine/unfinished.ts
891
- import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync, statSync } from "fs";
892
- import { join as join3 } from "path";
893
- function boardCounts(dir) {
894
- try {
895
- const raw = JSON.parse(readFileSync(join3(dir, "board.json"), "utf8"));
896
- const cards = Array.isArray(raw.cards) ? raw.cards : Object.values(raw.cards ?? {});
897
- const list = cards;
898
- return { total: list.length, done: list.filter((c) => c.column === "MERGED" || c.column === "DONE").length };
899
- } catch {
900
- return { total: 0, done: 0 };
901
- }
902
- }
903
- function unfinishedSessions(cwd, commitCount = () => 0) {
904
- const root = join3(cwd, ".horsecode", "worktrees");
905
- if (!existsSync3(root)) return [];
906
- const out = [];
907
- for (const id of readdirSync2(root)) {
908
- const dir = join3(root, id);
909
- try {
910
- if (!statSync(dir).isDirectory()) continue;
911
- } catch {
912
- continue;
913
- }
914
- if (!existsSync3(join3(dir, "base"))) continue;
915
- const checkpoint = readCheckpoint(dir);
916
- if (!checkpoint) continue;
917
- let updatedAt = 0;
918
- try {
919
- updatedAt = statSync(join3(dir, "checkpoint.json")).mtimeMs;
920
- } catch {
921
- }
922
- out.push({
923
- id,
924
- checkpoint,
925
- cards: boardCounts(dir),
926
- commits: commitCount(`hc/${id}/base`),
927
- updatedAt
928
- });
929
- }
930
- return out.sort((a, b) => b.updatedAt - a.updatedAt);
931
- }
932
- function describeUnfinished(s) {
933
- const asked = s.checkpoint.rawPrompt.trim() || s.checkpoint.title;
934
- const bits = [
935
- s.checkpoint.done.length ? `${s.checkpoint.done.join(" \u2192 ")} done` : "nothing finished yet",
936
- s.cards.total ? `${s.cards.done}/${s.cards.total} tasks` : "",
937
- s.commits ? `${s.commits} commit${s.commits === 1 ? "" : "s"}` : ""
938
- ].filter(Boolean);
939
- return `\u201C${asked.length > 70 ? `${asked.slice(0, 69)}\u2026` : asked}\u201D \u2014 ${bits.join(" \xB7 ")} (\`${s.id}\`; say **continue** to pick it up)`;
940
- }
941
-
942
- // src/tools/git.ts
943
- import { execFile } from "child_process";
255
+ import { dirname, resolve, sep } from "path";
944
256
  import { z } from "zod";
945
- var READ_ONLY = /* @__PURE__ */ new Set([
946
- "status",
947
- "log",
948
- "show",
949
- "diff",
950
- "blame",
951
- "shortlog",
952
- "whatchanged",
953
- "rev-parse",
954
- "rev-list",
955
- "merge-base",
956
- "name-rev",
957
- "describe",
958
- "symbolic-ref",
959
- "ls-files",
960
- "ls-tree",
961
- "cat-file",
962
- "count-objects",
963
- "show-ref",
964
- "for-each-ref",
965
- "ls-remote",
966
- /**
967
- * `git grep` searches tracked content and has no writing form at all — the same standing as `log`.
968
- *
969
- * Left out, it was refused twice in one 36-minute run while agents fell back to `find | xargs grep`
970
- * through the shell, which is slower on a repository this size and searches build output and
971
- * `node_modules` unless every caller remembers to prune them. git already knows what is tracked.
972
- */
973
- "grep",
974
- /**
975
- * `check-ignore` asks whether a path is ignored — it reads `.gitignore` and answers, and changes nothing.
976
- *
977
- * Left out, it was the one read-only verb agents had to reach for `shell` to run: four calls in one run,
978
- * each landing outside the tool that knows this repository's rules. And through `shell` its exit code is
979
- * raw, so "no, that path is not ignored" — which git says with 1 — came back as a failed command.
980
- */
981
- "check-ignore"
982
- ]);
983
- var READ_ONLY_PAIRS = /* @__PURE__ */ new Set([
984
- "worktree list",
985
- "tag --list",
986
- "tag -l",
987
- "stash list",
988
- "remote -v",
989
- "remote show",
990
- "config --get",
991
- "config --list",
992
- /**
993
- * The long forms of what is already allowed, and the queries that only ask.
994
- *
995
- * `branch -a` was allowed and `branch --all` was not — the same command spelled the way git's own
996
- * documentation spells it. Measured in one run: an agent asked for `branch --all` and then `branch
997
- * --show-current`, and paid a refused turn for each while `-a` sat in this list. A short flag admitted and
998
- * its long twin refused is not a security boundary, it is a typo in one.
999
- *
1000
- * These are the closure of what this set already permits, not new ground: every one of them prints
1001
- * information about branches or tags and none of them can create, move or delete a ref. The forms that
1002
- * write — `-d`, `-D`, `-m`, `-M`, `-c`, `-C`, `--delete`, `--move`, `--copy`, `--set-upstream-to`,
1003
- * `--edit-description` — are still absent, and a first argument that is not a flag never reaches here.
1004
- */
1005
- "tag --contains",
1006
- "tag --no-contains",
1007
- "tag --merged",
1008
- "tag --points-at",
1009
- "tag -n",
1010
- "remote --verbose",
1011
- "remote get-url",
1012
- "stash show"
1013
- ]);
1014
- var REFUSED_ARG = /^(--output|-c$|--config-env|--exec-path|-C$|--git-dir|--work-tree|--upload-pack|--receive-pack)/;
1015
- var params = z.object({
1016
- args: z.array(z.string()).min(1).describe(
1017
- 'Git arguments as a list, without the leading "git" \u2014 e.g. ["status","--porcelain"] or ["log","-5","--oneline"].'
1018
- )
1019
- });
1020
- var MAX_GIT_OUTPUT = 6e4;
1021
- var GIT_TIMEOUT_MS = 3e4;
1022
- var GIT_PUSH_TIMEOUT_MS = 12e4;
1023
- var ANSWERS_WITH_ONE = /* @__PURE__ */ new Set([
1024
- "diff",
1025
- "diff-index",
1026
- "diff-tree",
1027
- "diff-files",
1028
- "merge-base",
1029
- "check-ignore",
1030
- /**
1031
- * `grep` says "no match" with exit 1, exactly as the others say their own no.
1032
- *
1033
- * Admitted to the read-only set earlier tonight and left out of this one, so a search that found nothing
1034
- * came back as `git failed with no output.` — a fault where there was an answer. Measured live within
1035
- * minutes: `git grep -n -i ExportReportService.cs` twice, both reported as failures, for a file that
1036
- * simply is not in the repository.
1037
- */
1038
- "grep"
1039
- ]);
1040
- function answeredWithOne(args, code) {
1041
- return code === 1 && ANSWERS_WITH_ONE.has(args[0] ?? "");
1042
- }
1043
- function howToNarrow(args) {
1044
- const verb = gitVerb(args) ?? "";
1045
- if (verb === "diff" || verb === "show") {
1046
- return "narrow the range, or put `--stat` directly after the subcommand (git " + verb + " --stat <rest>), which git requires";
1047
- }
1048
- if (verb === "log") return "ask for fewer commits (-n 20) or just their subjects (--oneline)";
1049
- if (verb === "ls-files" || verb === "ls-tree") return "narrow the pathspec to one directory at a time";
1050
- if (verb === "blame") return "limit it to a range of lines (-L 40,120)";
1051
- return "ask for a narrower part of it";
1052
- }
1053
- function answerOfOne(args) {
1054
- const verb = args[0] ?? "";
1055
- if (verb === "check-ignore") {
1056
- return "No \u2014 that path is not ignored by this repository's rules. (git exit code 1, which is the answer here.)";
1057
- }
1058
- if (verb === "grep") {
1059
- return "No match \u2014 nothing in the tracked files matches that pattern. (git exit code 1, which is the answer here.)";
1060
- }
1061
- if (verb === "merge-base") {
1062
- 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.)";
1063
- }
1064
- 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.";
1065
- }
1066
- function packedArgument(a) {
1067
- if (!/\s/.test(a)) return void 0;
1068
- if (/^--?[\w-]+[=:]/.test(a)) return void 0;
1069
- const parts = a.trim().split(/\s+/).filter(Boolean);
1070
- if (parts.length < 2) return void 0;
1071
- if (parts[0] === "--") return parts;
1072
- return parts.slice(1).some((p) => p === "--" || p.startsWith("-") || p.includes("/")) ? parts : void 0;
1073
- }
1074
- var GLUED_PATHSPEC = /^--[^\s=]*\/[^\s=]*$/;
1075
- var BRANCH_TAKES_VALUE = /* @__PURE__ */ new Set([
1076
- "--contains",
1077
- "--no-contains",
1078
- "--merged",
1079
- "--no-merged",
1080
- "--points-at",
1081
- "--format",
1082
- "--sort",
1083
- "--color",
1084
- "--abbrev",
1085
- "-u",
1086
- "--set-upstream-to",
1087
- "-t",
1088
- "--track"
1089
- ]);
1090
- var BRANCH_WRITERS = /* @__PURE__ */ new Set([
1091
- "-d",
1092
- "-D",
1093
- "--delete",
1094
- "-m",
1095
- "-M",
1096
- "--move",
1097
- "-c",
1098
- "-C",
1099
- "--copy",
1100
- "--edit-description",
1101
- "--set-upstream",
1102
- "--set-upstream-to",
1103
- "--unset-upstream",
1104
- "-u",
1105
- "-t",
1106
- "--track",
1107
- "--no-track",
1108
- "-f",
1109
- "--force"
1110
- ]);
1111
- function branchWrites(rest) {
1112
- const listing = rest.some((a) => a === "--list" || a === "-l");
1113
- for (let i = 0; i < rest.length; i++) {
1114
- const a = rest[i];
1115
- if (a === void 0) continue;
1116
- if (a === "--") return "`git branch` with a pathspec is not a thing this tool needs to run.";
1117
- if (a.startsWith("--")) {
1118
- const name = a.split("=")[0];
1119
- if (BRANCH_WRITERS.has(name)) return `\`git branch ${name}\` changes a branch. Only listing is allowed.`;
1120
- if (BRANCH_TAKES_VALUE.has(name) && !a.includes("=")) i++;
1121
- continue;
1122
- }
1123
- if (a.startsWith("-")) {
1124
- const bad = [...a.slice(1)].find((c) => BRANCH_WRITERS.has(`-${c}`));
1125
- if (bad) return `\`git branch -${bad}\` changes a branch. Only listing is allowed.`;
1126
- if (BRANCH_TAKES_VALUE.has(a)) i++;
1127
- continue;
1128
- }
1129
- if (listing) continue;
1130
- if (/[*?\[]/.test(a)) {
1131
- 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}\`.`;
1132
- }
1133
- return `\`git branch ${a}\` creates a branch. Only listing is allowed \u2014 git_write owns the rest.`;
1134
- }
1135
- return void 0;
1136
- }
1137
- function refuse(args) {
1138
- const packed = args.find((a) => packedArgument(a) !== void 0 || GLUED_PATHSPEC.test(a));
1139
- if (packed !== void 0) {
1140
- const split = packedArgument(packed) ?? ["--", packed.slice(2)];
1141
- if (split[0] !== "--") {
1142
- 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.`;
1143
- }
1144
- const parts = split.slice(1);
1145
- 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).`;
1146
- }
1147
- const bad = args.find((a) => REFUSED_ARG.test(a));
1148
- if (bad) {
1149
- return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
1150
- }
1151
- const [sub, second] = args;
1152
- if (!sub || sub.startsWith("-")) return "The first argument must be a git subcommand, e.g. `status`.";
1153
- if (READ_ONLY.has(sub)) return void 0;
1154
- if (sub === "branch") return branchWrites(args.slice(1));
1155
- if (sub === "reflog") {
1156
- return second === "expire" || second === "delete" ? `\`git reflog ${second}\` rewrites the reflog. Only reading it is allowed.` : void 0;
1157
- }
1158
- if (sub === "config") {
1159
- if (second !== void 0 && !second.startsWith("-") && args.length === 2) return void 0;
1160
- if (args.length > 2 && !args.some((a) => a.startsWith("--get") || a === "--list")) {
1161
- return "`git config <key> <value>` writes configuration. Read one with `git config <key>`.";
1162
- }
1163
- }
1164
- if (second && READ_ONLY_PAIRS.has(`${sub} ${second}`)) return void 0;
1165
- 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(", ")}.`;
1166
- }
1167
- var gitTool = {
1168
- name: "git",
1169
- 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.',
1170
- permissionLevel: "safe",
1171
- parameters: params,
1172
- describe: (args) => {
1173
- const list = args.args;
1174
- const text = Array.isArray(list) ? list.join(" ") : "";
1175
- return { allowKey: "git:read", preview: `git ${text}`.slice(0, 120) };
1176
- },
1177
- async run(rawArgs, ctx) {
1178
- const parsed = params.safeParse(rawArgs);
1179
- if (!parsed.success) {
1180
- return { content: `git: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1181
- }
1182
- const args = parsed.data.args;
1183
- const why = refuse(args);
1184
- if (why) return { content: why, isError: true, settled: true };
1185
- const out = await new Promise((resolve5) => {
1186
- const child = execFile("git", args, {
1187
- cwd: ctx.cwd,
1188
- timeout: GIT_TIMEOUT_MS,
1189
- maxBuffer: MAX_GIT_OUTPUT * 4,
1190
- // `--no-pager` would still be needed for some subcommands; killing the pager entirely is simpler and
1191
- // leaves nothing waiting for a terminal that does not exist.
1192
- env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
1193
- }, (err, stdout, stderr) => {
1194
- const text = `${stdout}${stderr}`.trim();
1195
- resolve5({ code: err?.code ?? (err ? 1 : 0), text });
1196
- });
1197
- ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
1198
- });
1199
- const failed = out.code !== 0 && !answeredWithOne(args, out.code);
1200
- if (!out.text) {
1201
- if (!failed && out.code === 1) return { content: answerOfOne(args), isError: false };
1202
- return { content: out.code === 0 ? "(no output)" : "git failed with no output.", isError: failed };
1203
- }
1204
- const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
1205
- \u2026[truncated \u2014 ${howToNarrow(args)}]` : out.text;
1206
- return { content: clipped, isError: failed };
1207
- }
1208
- };
1209
- var WRITE = /* @__PURE__ */ new Set(["add", "commit", "push", "fetch"]);
1210
- var REFSPEC = /:/;
1211
- var REFUSED_PUSH = /^(-f|--force|--force-with-lease|--delete|--mirror|--prune)/;
1212
- function refuseWrite(args) {
1213
- const bad = args.find((a) => REFUSED_ARG.test(a));
1214
- if (bad) {
1215
- return `\`${bad}\` is not allowed: it can write a file, run a program through git's configuration, or point git at another repository.`;
1216
- }
1217
- const [sub] = args;
1218
- if (!sub || !WRITE.has(sub)) {
1219
- return `\`git ${sub ?? ""}\` is not available here \u2014 this tool records work (${[...WRITE].join(", ")}). Use the \`git\` tool to read history and state.`;
1220
- }
1221
- if (sub === "fetch") {
1222
- const spec = args.slice(1).find((a) => REFSPEC.test(a) && !a.startsWith("-"));
1223
- if (spec) {
1224
- 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.`;
1225
- }
1226
- const pruned = args.find((a) => /^(--prune|-p)$/.test(a));
1227
- if (pruned) {
1228
- return `\`${pruned}\` is not allowed: it deletes remote-tracking refs, and something else may be relying on one. Fetch without it.`;
1229
- }
1230
- }
1231
- const forced = sub === "push" && args.find((a) => REFUSED_PUSH.test(a));
1232
- if (forced) {
1233
- 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.`;
1234
- }
1235
- return void 0;
1236
- }
1237
- var gitWriteTool = {
1238
- name: "git_write",
1239
- 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.',
1240
- permissionLevel: "exec",
1241
- parameters: params,
1242
- describe: (args) => {
1243
- const list = args.args;
1244
- const text = Array.isArray(list) ? list.join(" ") : "";
1245
- const sub = Array.isArray(list) && typeof list[0] === "string" ? list[0] : "";
1246
- return { allowKey: `git ${sub}`, preview: `git ${text}`.slice(0, 200) };
1247
- },
1248
- async run(rawArgs, ctx) {
1249
- const parsed = params.safeParse(rawArgs);
1250
- if (!parsed.success) {
1251
- return { content: `git_write: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1252
- }
1253
- const args = parsed.data.args;
1254
- const why = refuseWrite(args);
1255
- if (why) return { content: why, isError: true, settled: true };
1256
- const out = await new Promise((resolve5) => {
1257
- const child = execFile("git", args, {
1258
- cwd: ctx.cwd,
1259
- // A push talks to a server: the read tool's 30s is a reasonable ceiling for a local query and a
1260
- // pessimistic one for a repository with anything in it.
1261
- timeout: args[0] === "push" ? GIT_PUSH_TIMEOUT_MS : GIT_TIMEOUT_MS,
1262
- maxBuffer: MAX_GIT_OUTPUT * 4,
1263
- // GIT_TERMINAL_PROMPT=0: a push that needs credentials fails with a message instead of blocking on a
1264
- // prompt no one can see — the TUI owns the terminal, so the agent would simply hang.
1265
- env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" }
1266
- }, (err, stdout, stderr) => {
1267
- resolve5({ code: err ? 1 : 0, text: `${stdout}${stderr}`.trim() });
1268
- });
1269
- ctx.signal?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
1270
- });
1271
- if (!out.text) return { content: out.code === 0 ? "(done)" : "git failed with no output.", isError: out.code !== 0 };
1272
- const clipped = out.text.length > MAX_GIT_OUTPUT ? `${truncateSafe(out.text, MAX_GIT_OUTPUT)}
1273
- \u2026[truncated]` : out.text;
1274
- return { content: clipped, isError: out.code !== 0 };
1275
- }
1276
- };
1277
-
1278
- // src/tools/remember.ts
1279
- import { z as z2 } from "zod";
1280
- var params2 = z2.object({
1281
- fact: z2.string().describe(
1282
- "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."
1283
- )
1284
- });
1285
- function buildRememberTool(sink) {
1286
- return {
1287
- name: "remember_fact",
1288
- 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.",
1289
- permissionLevel: "safe",
1290
- parameters: params2,
1291
- async run(rawArgs, ctx) {
1292
- const parsed = params2.safeParse(rawArgs);
1293
- if (!parsed.success) return { content: "remember_fact: invalid args (expected { fact })", isError: true };
1294
- const fact = parsed.data.fact.trim();
1295
- if (!fact) return { content: "remember_fact: empty fact", isError: true };
1296
- const write = sink ?? ctx.remember;
1297
- if (!write) return { content: "remember_fact: memory is not available in this context", isError: true };
1298
- write(fact);
1299
- return { content: `Remembered: ${fact}`, isError: false };
1300
- }
1301
- };
1302
- }
1303
- var rememberFactTool = buildRememberTool();
1304
-
1305
- // src/speckit/layout.ts
1306
- import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync3 } from "fs";
1307
- import { join as join4 } from "path";
1308
- function specsDir(workdir) {
1309
- return join4(workdir, "specs");
1310
- }
1311
- function constitutionPath(workdir) {
1312
- return join4(workdir, ".specify", "memory", "constitution.md");
1313
- }
1314
- function featurePaths(workdir, slug) {
1315
- const dir = join4(specsDir(workdir), slug);
1316
- return { dir, brainstorm: join4(dir, "brainstorm.md"), spec: join4(dir, "spec.md"), plan: join4(dir, "plan.md"), tasks: join4(dir, "tasks.md") };
1317
- }
1318
- function verifyPaths(workdir, slug) {
1319
- const dir = join4(specsDir(workdir), slug);
1320
- return { dir, plan: join4(dir, "test-plan.md"), report: join4(dir, "test-report.md") };
1321
- }
1322
- var ACTIVITY_WORDS = /* @__PURE__ */ new Set([
1323
- "test",
1324
- "tests",
1325
- "testing",
1326
- "smoke",
1327
- "e2e",
1328
- "verify",
1329
- "verification",
1330
- "verifying",
1331
- "check",
1332
- "checking",
1333
- "run",
1334
- "running",
1335
- "continue",
1336
- "continuing",
1337
- "report",
1338
- "session",
1339
- "the",
1340
- "for",
1341
- "of",
1342
- "a",
1343
- "an",
1344
- "and"
1345
- ]);
1346
- var subjectWords = (slug) => new Set(slug.split("-").filter((w) => w.length > 2 && !ACTIVITY_WORDS.has(w)));
1347
- function featureSlugFor(workdir, title) {
1348
- const want = toSlug(title);
1349
- const dir = specsDir(workdir);
1350
- if (existsSync4(dir)) {
1351
- const names = readdirSync3(dir);
1352
- for (const name of names) {
1353
- if (name.replace(/^\d+-/, "") === want) return name;
1354
- }
1355
- const wanted = subjectWords(want);
1356
- let best;
1357
- for (const name of names) {
1358
- const shared = [...subjectWords(name.replace(/^\d+-/, ""))].filter((w) => wanted.has(w)).length;
1359
- if (shared >= 2 && (!best || shared > best.shared)) best = { name, shared };
1360
- }
1361
- if (best) return best.name;
1362
- }
1363
- return nextFeatureSlug(workdir, title);
1364
- }
1365
- function nextFeatureSlug(workdir, title) {
1366
- const dir = specsDir(workdir);
1367
- let max = 0;
1368
- if (existsSync4(dir)) {
1369
- for (const name of readdirSync3(dir)) {
1370
- const m = name.match(/^(\d+)-/);
1371
- if (m) max = Math.max(max, Number(m[1]));
1372
- }
1373
- }
1374
- return `${String(max + 1).padStart(3, "0")}-${toSlug(title)}`;
1375
- }
1376
- function scaffoldFeature(workdir, slug) {
1377
- const paths = featurePaths(workdir, slug);
1378
- mkdirSync(paths.dir, { recursive: true });
1379
- mkdirSync(join4(workdir, ".specify", "memory"), { recursive: true });
1380
- return paths;
1381
- }
1382
-
1383
- // src/engine/constitution-store.ts
1384
- import { createHash } from "crypto";
1385
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
1386
- import { join as join5 } from "path";
1387
- import { z as z3 } from "zod";
1388
-
1389
- // src/engine/constitution.ts
1390
- var SCOPES = ["always", "backend", "frontend", "data", "infra", "docs", "review", "spec", "test", "govern"];
1391
- var RATIONALE = /^\s*\*(?:Gerekçe|Rationale):\*/i;
1392
- function sectionOf(heading) {
1393
- const m = /^([IVXLC]+)\.\s/.exec(heading);
1394
- return m ? m[1] : heading;
1395
- }
1396
- function parseConstitution(text) {
1397
- const out = [];
1398
- let heading = "";
1399
- let buf = [];
1400
- const flush = () => {
1401
- const block = buf.join("\n").trim();
1402
- buf = [];
1403
- if (!block || !heading || RATIONALE.test(block)) return;
1404
- out.push({ section: sectionOf(heading), heading, text: block });
1405
- };
1406
- for (const line of text.split("\n")) {
1407
- const h = /^#{2,3}\s+(.*)$/.exec(line);
1408
- if (h) {
1409
- flush();
1410
- heading = /^core principles$/i.test(h[1].trim()) ? "" : h[1].trim();
1411
- continue;
1412
- }
1413
- if (!line.trim()) {
1414
- flush();
1415
- continue;
1416
- }
1417
- buf.push(line);
1418
- }
1419
- flush();
1420
- return out;
1421
- }
1422
- var BY_EXT = [
1423
- [/\.(cs|java|kt|go|py|rb|php|scala|rs)$/i, "backend"],
1424
- [/\.(ts|tsx|js|jsx|html|css|scss|sass|less|vue|svelte)$/i, "frontend"],
1425
- [/\.(sql)$|(^|\/)migrations?\//i, "data"],
1426
- [/\.(tf|tfvars)$|(^|\/)(k8s|helm|charts|deploy|infra)\//i, "infra"],
1427
- [/\.(md|mdx|adoc)$/i, "docs"]
1428
- ];
1429
- var BY_ROLE = {
1430
- "code-reviewer": ["review"],
1431
- "principal-coder": ["review"],
1432
- "senior-coder": ["review"],
1433
- analyst: ["spec"],
1434
- planner: ["spec"],
1435
- brainstormer: ["spec"],
1436
- tester: ["test"]
1437
- };
1438
- function scopesForWork(opts) {
1439
- const out = /* @__PURE__ */ new Set(["always"]);
1440
- for (const s of BY_ROLE[opts.role ?? ""] ?? []) out.add(s);
1441
- for (const f of opts.files ?? []) for (const [re, scope] of BY_EXT) if (re.test(f)) out.add(scope);
1442
- return out;
1443
- }
1444
- var MAX_CONSTITUTION_CHARS = 2e4;
1445
- function selectRules(rules, scopes, max = MAX_CONSTITUTION_CHARS) {
1446
- const applies = (r) => r.scopes.some((s) => scopes.has(s));
1447
- const always = rules.filter((r) => r.scopes.includes("always"));
1448
- const scoped = rules.filter((r) => !r.scopes.includes("always") && applies(r));
1449
- const used = [...always];
1450
- const dropped = [];
1451
- let size = used.reduce((n, r) => n + r.text.length, 0);
1452
- for (const r of scoped) {
1453
- if (size + r.text.length > max) {
1454
- dropped.push(r);
1455
- continue;
1456
- }
1457
- used.push(r);
1458
- size += r.text.length;
1459
- }
1460
- return { text: render(used, dropped), used, dropped };
1461
- }
1462
- function render(used, dropped) {
1463
- if (!used.length) return "";
1464
- const byHeading = /* @__PURE__ */ new Map();
1465
- for (const r of used) byHeading.set(r.heading, [...byHeading.get(r.heading) ?? [], r.text]);
1466
- const body = [...byHeading].map(([h, texts]) => `## ${h}
1467
- ${texts.join("\n\n")}`).join("\n\n");
1468
- const cut = dropped.length ? `
1469
-
1470
- (${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.)` : "";
1471
- return `
1472
-
1473
- # Project constitution \u2014 the rules that bind THIS work
1474
-
1475
- 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\`.
1476
-
1477
- ${body}${cut}`;
1478
- }
1479
- 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.
1480
-
1481
- For each rule, answer with the scopes it is ABOUT, from exactly this list: ${SCOPES.join(", ")}.
1482
-
1483
- - \`backend\`, \`frontend\`, \`data\`, \`infra\`: it names code, files or tooling of that kind \u2014 a language, a framework, a database, a deployment target.
1484
- - \`spec\`: it constrains what a specification or plan may say (the stack, the boundaries, the vocabulary).
1485
- - \`review\`: it is a gate a reviewer applies \u2014 what blocks a merge, how findings are reported.
1486
- - \`test\`: it is about verification and evidence.
1487
- - \`govern\`: it is about amending the constitution itself, and binds nobody else.
1488
- - \`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.
1489
-
1490
- 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.
1491
-
1492
- Answer for EVERY index you are given, and for no others. Do not translate, summarise or rewrite anything \u2014 you are only labelling.
1493
-
1494
- Return {labels: [{index, scopes}]} via submit, one entry per rule, in the order given.`;
1495
- var CLASSIFY_BATCH = 20;
1496
- var CLASSIFY_RETRIES = 2;
1497
- function classifyMessage(rules, offset = 0) {
1498
- return rules.map((r, i) => `--- ${i + offset} --- (${r.heading})
1499
- ${r.text}`).join("\n\n");
1500
- }
1501
- function applyLabels(rules, labels) {
1502
- const known = new Set(SCOPES);
1503
- const byIndex = new Map(labels.map((l) => [l.index, l.scopes.filter((s) => known.has(s))]));
1504
- const unlabelled = [];
1505
- const scoped = rules.map((r, i) => {
1506
- const scopes = byIndex.get(i) ?? [];
1507
- if (!scopes.length) unlabelled.push(i);
1508
- return { ...r, scopes: scopes.length ? scopes : ["always"] };
1509
- });
1510
- return { scoped, unlabelled };
1511
- }
1512
- var MAX_ALWAYS_SHARE = 0.35;
1513
- function labellingLooksWrong(scoped) {
1514
- if (!scoped.length) return void 0;
1515
- const always = scoped.filter((r) => r.scopes.includes("always")).length;
1516
- if (always > scoped.length * MAX_ALWAYS_SHARE) {
1517
- return `${always} of ${scoped.length} rules came back as \`always\``;
1518
- }
1519
- return void 0;
1520
- }
1521
-
1522
- // src/engine/constitution-store.ts
1523
- var LabelsSchema = z3.object({
1524
- labels: z3.array(z3.object({
1525
- index: z3.number().int().describe("The rule's number, exactly as given to you."),
1526
- scopes: z3.array(z3.enum(SCOPES)).describe(
1527
- `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.`
1528
- )
1529
- }))
1530
- });
1531
- var CLASSIFY_MAX_TURNS = 3;
1532
- function cachePath(home, text) {
1533
- const hash = createHash("sha256").update(text).digest("hex").slice(0, 16);
1534
- return join5(home, ".horsecode", "constitution", `${hash}.json`);
1535
- }
1536
- var memo = /* @__PURE__ */ new Map();
1537
- async function scopedConstitution(deps, cwd) {
1538
- const path = constitutionPath(cwd);
1539
- if (!existsSync5(path)) return [];
1540
- let text;
1541
- try {
1542
- text = readFileSync2(path, "utf8");
1543
- } catch {
1544
- return [];
1545
- }
1546
- const cache = cachePath(deps.home, text);
1547
- const hit = memo.get(cache);
1548
- if (hit) return hit;
1549
- if (existsSync5(cache)) {
1550
- try {
1551
- const saved = JSON.parse(readFileSync2(cache, "utf8"));
1552
- memo.set(cache, saved);
1553
- return saved;
1554
- } catch {
1555
- }
1556
- }
1557
- const rules = parseConstitution(text);
1558
- if (!rules.length) return [];
1559
- deps.note?.(`\u{1F4DC} Reading the project constitution \u2014 ${rules.length} rules, labelled once so each reaches the work it binds.`);
1560
- const labels = [];
1561
- const resolved = deps.roleRegistry.resolve("judge");
1562
- let failed = 0;
1563
- for (let start = 0; start < rules.length; start += CLASSIFY_BATCH) {
1564
- const batch = rules.slice(start, start + CLASSIFY_BATCH);
1565
- for (let attempt = 0; attempt <= CLASSIFY_RETRIES; attempt++) {
1566
- try {
1567
- const out = await runStructuredRole({
1568
- provider: deps.provider,
1569
- ...resolved,
1570
- systemPrompt: CLASSIFY_PROMPT,
1571
- tools: new ToolRegistry(),
1572
- messages: [{ role: "user", content: classifyMessage(batch, start) }],
1573
- permission: deps.permission,
1574
- approve: deps.approve,
1575
- cwd,
1576
- signal: deps.signal,
1577
- maxTurns: CLASSIFY_MAX_TURNS
1578
- }, LabelsSchema);
1579
- labels.push(...out.labels);
1580
- break;
1581
- } catch {
1582
- if (attempt === CLASSIFY_RETRIES) failed++;
1583
- }
1584
- }
1585
- }
1586
- const { scoped, unlabelled } = applyLabels(rules, labels);
1587
- if (unlabelled.length) {
1588
- 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.`);
1589
- }
1590
- const wrong = labellingLooksWrong(scoped);
1591
- if (failed || wrong) {
1592
- 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.`);
1593
- } else {
1594
- try {
1595
- mkdirSync2(join5(deps.home, ".horsecode", "constitution"), { recursive: true });
1596
- writeFileSync(cache, JSON.stringify(scoped), "utf8");
1597
- } catch {
1598
- }
1599
- }
1600
- memo.set(cache, scoped);
1601
- return scoped;
1602
- }
1603
- async function constitutionNote(deps, cwd, work) {
1604
- const scoped = await scopedConstitution(deps, cwd);
1605
- if (!scoped.length) return "";
1606
- const sel = selectRules(scoped, scopesForWork(work));
1607
- if (sel.dropped.length) {
1608
- 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(", ")}.`);
1609
- }
1610
- return sel.text;
1611
- }
1612
-
1613
- // src/engine/reviewer.ts
1614
- import { z as z7 } from "zod";
1615
-
1616
- // src/tools/find-tool.ts
1617
- import { z as z4 } from "zod";
1618
- var params3 = z4.object({
1619
- query: z4.string().describe(
1620
- '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.'
1621
- )
1622
- });
1623
- var MAX_FOUND = 5;
1624
- var NOISE = /* @__PURE__ */ new Set([
1625
- "the",
1626
- "a",
1627
- "an",
1628
- "of",
1629
- "for",
1630
- "in",
1631
- "on",
1632
- "to",
1633
- "and",
1634
- "or",
1635
- "with",
1636
- "by",
1637
- "from",
1638
- "get",
1639
- "list",
1640
- "tool",
1641
- "project",
1642
- "use",
1643
- "this",
1644
- "that",
1645
- "it",
1646
- "is",
1647
- "are",
1648
- "be",
1649
- "mcp"
1650
- ]);
1651
- var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !NOISE.has(w));
1652
- function scoreTool(t, query) {
1653
- const q = words(query);
1654
- if (!q.length) return 0;
1655
- const name = t.name.toLowerCase();
1656
- const desc = t.description.toLowerCase();
1657
- let score = 0;
1658
- for (const w of q) {
1659
- if (name.includes(w)) score += 3;
1660
- else if (desc.includes(w)) score += 1;
1661
- }
1662
- if (name === query.toLowerCase().trim()) score += 100;
1663
- return score;
1664
- }
1665
- function buildFindToolTool(registry) {
1666
- return {
1667
- name: "find_tool",
1668
- 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.',
1669
- permissionLevel: "safe",
1670
- parameters: params3,
1671
- run: async (rawArgs) => {
1672
- const parsed = params3.safeParse(rawArgs);
1673
- if (!parsed.success) {
1674
- return { content: `find_tool: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
1675
- }
1676
- const { query } = parsed.data;
1677
- const pool = registry.deferredTools().filter((t) => t.broken === void 0);
1678
- if (!pool.length) {
1679
- return {
1680
- content: "Every tool this project connects is already loaded \u2014 there is nothing further to fetch. Use the ones you have.",
1681
- isError: false
1682
- };
1683
- }
1684
- const scored = pool.map((t) => ({ t, s: scoreTool(t, query) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
1685
- const exact = scored.find((x) => x.t.name.toLowerCase() === query.toLowerCase().trim());
1686
- const hits = exact ? [exact] : scored.slice(0, MAX_FOUND);
1687
- if (!hits.length) {
1688
- return {
1689
- content: `No project tool matches "${query}". Available to fetch: ${pool.map((t) => t.name).join(", ")}.`,
1690
- isError: false
1691
- };
1692
- }
1693
- registry.surface(hits.map((x) => x.t.name));
1694
- const rows = hits.map((x) => `- \`${x.t.name}\` \u2014 ${x.t.description.replace(/^\[MCP:[^\]]*\]\s*/, "").split(/\n/)[0].trim()}`);
1695
- return {
1696
- content: `Loaded ${hits.length} tool(s) \u2014 you can call them from your next message:
1697
- ${rows.join("\n")}`,
1698
- isError: false
1699
- };
1700
- }
1701
- };
1702
- }
1703
-
1704
- // src/tools/unfinished-tool.ts
1705
- import { execFileSync } from "child_process";
1706
- import { join as join6 } from "path";
1707
- import { z as z5 } from "zod";
1708
- var params4 = z5.object({});
1709
- function commitsAhead(cwd, branch) {
1710
- try {
1711
- const out = execFileSync(
1712
- "git",
1713
- ["rev-list", "--count", `HEAD..${branch}`],
1714
- { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1715
- );
1716
- return Number(out.trim()) || 0;
1717
- } catch {
1718
- return 0;
1719
- }
1720
- }
1721
- var findUnfinishedTool = {
1722
- name: "find_unfinished",
1723
- 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.`,
1724
- permissionLevel: "safe",
1725
- parameters: params4,
1726
- describe: () => ({ allowKey: "find_unfinished", preview: "find unfinished work" }),
1727
- run: async (_args, ctx) => {
1728
- const found = unfinishedSessions(ctx.cwd, (b) => commitsAhead(ctx.cwd, b));
1729
- if (!found.length) {
1730
- return {
1731
- 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.",
1732
- isError: false
1733
- };
1734
- }
1735
- const rows = found.map((s) => {
1736
- const c = s.checkpoint;
1737
- return [
1738
- `## ${s.id}`,
1739
- `- The user asked: "${c.rawPrompt.trim() || c.title}"`,
1740
- `- Understood as: ${c.refinedPrompt}`,
1741
- `- Phases finished: ${c.done.length ? c.done.join(" \u2192 ") : "none"}${c.lane ? ` (lane: ${c.lane})` : ""}`,
1742
- s.cards.total ? `- Tasks: ${s.cards.done} of ${s.cards.total} finished` : "- No task board yet",
1743
- `- Branch \`hc/${s.id}/base\` has ${s.commits} commit(s) the base does not`,
1744
- `- Worktree: ${join6(ctx.cwd, ".horsecode", "worktrees", s.id, "base")}`
1745
- ].join("\n");
1746
- });
1747
- return {
1748
- content: `${rows.join("\n\n")}
1749
-
1750
- 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.`,
1751
- isError: false
1752
- };
1753
- }
1754
- };
1755
-
1756
- // src/tools/propose-memory.ts
1757
- import { z as z6 } from "zod";
1758
- var params5 = z6.object({
1759
- text: z6.string(),
1760
- kind: z6.enum(["fact", "lesson"]).optional().describe(
1761
- "`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."
1762
- )
1763
- });
1764
- var proposeMemoryTool = {
1765
- name: "propose_memory",
1766
- 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.",
1767
- permissionLevel: "safe",
1768
- parameters: params5,
1769
- async run(rawArgs, ctx) {
1770
- const parsed = params5.safeParse(rawArgs);
1771
- if (!parsed.success) return { content: "propose_memory: invalid args (expected { text, kind? })", isError: true };
1772
- const text = parsed.data.text.trim();
1773
- if (!text) return { content: "propose_memory: empty proposal", isError: true };
1774
- if (!ctx.proposeMemory) return { content: "propose_memory: memory is not available in this context", isError: true };
1775
- const accepted = ctx.proposeMemory(text, parsed.data.kind ?? "fact");
1776
- return {
1777
- 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.",
1778
- isError: false
1779
- };
1780
- }
1781
- };
1782
-
1783
- // src/skills/route.ts
1784
- var STOP = /* @__PURE__ */ new Set([
1785
- "use",
1786
- "when",
1787
- "the",
1788
- "user",
1789
- "wants",
1790
- "and",
1791
- "for",
1792
- "with",
1793
- "that",
1794
- "this",
1795
- "from",
1796
- "into",
1797
- "also",
1798
- "not",
1799
- "other",
1800
- "otherwise",
1801
- "improve",
1802
- "covers",
1803
- "handles",
1804
- "should",
1805
- "become",
1806
- "than",
1807
- "over",
1808
- "onto",
1809
- "your",
1810
- "you",
1811
- "are",
1812
- "any",
1813
- "all",
1814
- "its",
1815
- "their",
1816
- "them",
1817
- "they",
1818
- "have",
1819
- "has",
1820
- "was",
1821
- "were",
1822
- "will",
1823
- "would",
1824
- "can",
1825
- "may",
1826
- "must",
1827
- "such",
1828
- "more",
1829
- "most",
1830
- "less",
1831
- "very",
1832
- "just",
1833
- "only",
1834
- "some",
1835
- "each",
1836
- "every",
1837
- "make",
1838
- "made",
1839
- "making",
1840
- "need",
1841
- "needs",
1842
- "needed",
1843
- "want",
1844
- "wanted",
1845
- "work",
1846
- "works",
1847
- "working",
1848
- "task",
1849
- "tasks",
1850
- "using",
1851
- "used",
1852
- "uses",
1853
- "via",
1854
- "per",
1855
- "out",
1856
- "off",
1857
- "about",
1858
- "after",
1859
- "before",
1860
- "then",
1861
- "there",
1862
- "where",
1863
- "which",
1864
- "while",
1865
- "what",
1866
- "who",
1867
- "how",
1868
- "why",
1869
- "does",
1870
- "did",
1871
- "done",
1872
- "get",
1873
- "got",
1874
- "let",
1875
- "lets"
1876
- ]);
1877
- var MIN_TERM = 3;
1878
- var SHORT_TERMS = /* @__PURE__ */ new Set(["ui", "ux", "db", "js", "ts", "qa"]);
1879
- function splitIdentifiers(text) {
1880
- return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/[_./-]+/g, " ");
1881
- }
1882
- function terms(text) {
1883
- return (splitIdentifiers(text).toLowerCase().match(/[a-z][a-z0-9]+/g) ?? []).filter((t) => (t.length >= MIN_TERM || SHORT_TERMS.has(t)) && !STOP.has(t));
1884
- }
1885
- var MIN_SHARED = 4;
1886
- function sameWord(a, b) {
1887
- if (a === b) return true;
1888
- const fold = (w) => w.endsWith("e") ? w.slice(0, -1) : w;
1889
- const [x, y] = [fold(a), fold(b)];
1890
- const [short, long] = x.length <= y.length ? [x, y] : [y, x];
1891
- return short.length >= MIN_SHARED && long.startsWith(short);
1892
- }
1893
- function exclusions(description) {
1894
- const m = /\bnot\s+for\s+([^.]+)/i.exec(description);
1895
- return m ? terms(m[1]) : [];
1896
- }
1897
- function isExplicitOnly(description) {
1898
- 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);
1899
- }
1900
- function isNonImplementing(description) {
1901
- return /\bread[- ]only\b/i.test(description) || /\bdoes\s+not\s+(implement|apply|execute|write)\b/i.test(description);
1902
- }
1903
- var MATCH_BAR = 3;
1904
- var MIN_DENSITY = 0.1;
1905
- var MAX_ROUTED = 3;
1906
- var MAX_ROUTED_CHARS = 24e3;
1907
- function scoreSkill(task, description) {
1908
- const taskTerms = new Set(terms(task));
1909
- if (!taskTerms.size) return { score: 0, hits: [], density: 0 };
1910
- const task_ = [...taskTerms];
1911
- const excluded2 = exclusions(description);
1912
- if (excluded2.some((e) => task_.some((t) => sameWord(t, e)))) return { score: 0, hits: [], density: 0 };
1913
- const body = description.replace(/\bnot\s+for\s+[^.]+/i, "");
1914
- const vocab = [...new Set(terms(body))];
1915
- const hits = vocab.filter((d) => task_.some((t) => sameWord(t, d)));
1916
- return { score: hits.length, hits, density: vocab.length ? hits.length / vocab.length : 0 };
1917
- }
1918
- function routeSkills(task, registry, already = [], opts = {}) {
1919
- const bar = opts.bar ?? MATCH_BAR;
1920
- let spent = 0;
1921
- const have = new Set(already);
1922
- const placed = new Set(opts.placed ?? []);
1923
- const paths = (opts.files ?? []).join(" ");
1924
- const base = [opts.role ?? "", routingSubject(task), paths].filter(Boolean).join(" ");
1925
- const subject = [base, expandExtensions(opts.files ?? []), expandAbbreviations(base)].filter(Boolean).join(" ");
1926
- 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) => {
1927
- const len = registry.get(m.name)?.content.length ?? 0;
1928
- if (spent + len > MAX_ROUTED_CHARS) return false;
1929
- spent += len;
1930
- return true;
1931
- });
1932
- }
1933
- function routingSubject(task) {
1934
- return task.replace(/\S*[\\/]\.horsecode[\\/]pastes[\\/]\S+/g, " ").replace(/\s{2,}/g, " ").trim();
1935
- }
1936
- var EXT_WORDS = {
1937
- tsx: "frontend interface component web",
1938
- jsx: "frontend interface component web",
1939
- vue: "frontend interface component web",
1940
- svelte: "frontend interface component web",
1941
- css: "frontend interface styling web",
1942
- scss: "frontend interface styling web",
1943
- html: "frontend interface web",
1944
- sql: "database migration",
1945
- proto: "protocol schema"
1946
- };
1947
- var ABBREVIATIONS = {
1948
- a11y: "accessibility",
1949
- i18n: "internationalization localization",
1950
- l10n: "localization",
1951
- auth: "authentication",
1952
- ui: "interface",
1953
- ux: "interface experience"
1954
- };
1955
- function expandAbbreviations(text) {
1956
- const out = [];
1957
- for (const t of terms(text)) if (ABBREVIATIONS[t]) out.push(ABBREVIATIONS[t]);
1958
- return out.join(" ");
1959
- }
1960
- function expandExtensions(files) {
1961
- const words3 = /* @__PURE__ */ new Set();
1962
- for (const f of files) {
1963
- const ext2 = f.split(".").pop()?.toLowerCase();
1964
- if (ext2 && EXT_WORDS[ext2]) for (const w of EXT_WORDS[ext2].split(" ")) words3.add(w);
1965
- }
1966
- return [...words3].join(" ");
1967
- }
1968
- var CONFIDENT_MARGIN = 4;
1969
- function partitionByConfidence(matches, bar = MATCH_BAR, margin = CONFIDENT_MARGIN) {
1970
- const confident = [];
1971
- const borderline = [];
1972
- for (const m of matches) (m.score >= bar + margin ? confident : borderline).push(m);
1973
- return { confident, borderline };
1974
- }
1975
-
1976
- // src/engine/task-diff.ts
1977
- var MAX_DIFF_CHARS2 = 6e4;
1978
- async function taskDiff(cwd, baseRef, git = defaultGitRunner) {
1979
- const out = await git(["diff", `${baseRef}...HEAD`, "--", ".", ...excludeOwnState()], cwd);
1980
- if (out.code !== 0) return "";
1981
- const diff = out.stdout;
1982
- if (diff.length <= MAX_DIFF_CHARS2) return diff;
1983
- return `${diff.slice(0, MAX_DIFF_CHARS2)}
1984
- \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
1985
- }
1986
- function describeDiff(diff) {
1987
- if (!diff.trim()) {
1988
- return "The diff for this task could not be produced. Inspect the worktree with read_file/grep instead.";
1989
- }
1990
- 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.
1991
-
1992
- \`\`\`diff
1993
- ${diff}
1994
- \`\`\``;
1995
- }
1996
- async function workingTreeDiff(cwd, git = defaultGitRunner) {
1997
- const out = await git(["diff", "HEAD", "--", ".", ...excludeOwnState()], cwd);
1998
- if (out.code !== 0) return "";
1999
- const diff = out.stdout;
2000
- if (diff.length <= MAX_DIFF_CHARS2) return diff;
2001
- return `${diff.slice(0, MAX_DIFF_CHARS2)}
2002
- \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
2003
- }
2004
- async function diffSince(cwd, sinceRef, git = defaultGitRunner) {
2005
- const out = await git(["diff", sinceRef, "--", ".", ...excludeOwnState()], cwd);
2006
- if (out.code !== 0) return "";
2007
- const diff = out.stdout;
2008
- if (diff.length <= MAX_DIFF_CHARS2) return diff;
2009
- return `${diff.slice(0, MAX_DIFF_CHARS2)}
2010
- \u2026diff truncated at ${MAX_DIFF_CHARS2} characters \u2014 read the remaining files directly.`;
2011
- }
2012
-
2013
- // src/engine/reviewer.ts
2014
- var CODE_REVIEW_MAX_TURNS = 25;
2015
- var CODE_REVIEW_TIMEOUT_MS = 10 * 60 * 1e3;
2016
- var VerdictSchema = z7.object({
2017
- verdict: z7.enum(["pass", "fail"]).describe(
2018
- "`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."
2019
- ),
2020
- notes: z7.array(z7.string())
2021
- });
2022
- function readOnlyRegistry(deps, opts = {}) {
2023
- const r = new ToolRegistry();
2024
- r.register(readFileTool);
2025
- r.register(grepTool);
2026
- r.register(globTool);
2027
- r.register(gitTool);
2028
- r.register(buildSkillTool(deps.skillRegistry));
2029
- for (const t of contextTools(deps)) r.register(t);
2030
- if (opts.remember) r.register(buildRememberTool(deps.rememberFact));
2031
- if (opts.gitWrite) {
2032
- r.register(gitWriteTool);
2033
- r.register(findUnfinishedTool);
2034
- }
2035
- if (opts.propose) r.register(proposeMemoryTool);
2036
- if (opts.mcp) deferMcp(r, deps.mcpTools?.() ?? []);
2037
- return r;
2038
- }
2039
- function deferMcp(r, tools) {
2040
- for (const t of tools) r.registerDeferred(t);
2041
- if (tools.length) r.register(buildFindToolTool(r));
2042
- }
2043
- async function runReviewer(deps, task, cwd) {
2044
- const resolved = deps.roleRegistry.resolve("code-reviewer");
2045
- const hints = memoryHints(deps, task.title, { role: "code-reviewer" });
2046
- const reviewerTools = readOnlyRegistry(deps, { propose: true });
2047
- const routed = routeSkills(task.title, deps.skillRegistry, deps.roleRegistry.skillsFor("code-reviewer"), {
2048
- // The card's own files, never a guess: see the implementer for the measurement that settled it.
2049
- role: "code-reviewer",
2050
- files: task.files,
2051
- placed: placedSkills()
2052
- });
2053
- if (routed.length) deps.note?.(`\u{1F4CE} \`code-reviewer\` \xB7 ${routed.map((m) => `**${m.name}**`).join(", ")}`);
2054
- const diff = deps.baseRef ? await taskDiff(cwd, deps.baseRef) : "";
2055
- const ask = { role: "user", content: `Review the CODE that implements task "${task.title}" \u2014 correctness, tests, and implementation quality.
2056
- 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.
2057
- Give a verdict (pass/fail + notes).
2058
-
2059
- ${describeDiff(diff)}` };
2060
- const law = deps.home ? await constitutionNote(
2061
- { ...deps, home: deps.home, note: deps.note },
2062
- cwd,
2063
- { role: "code-reviewer", files: task.files, title: task.title }
2064
- ) : "";
2065
- const opts = {
2066
- provider: deps.provider,
2067
- ...resolved,
2068
- // The reviewer gets the SAME rules the implementer was given: a gate that does not know what was
2069
- // required cannot tell whether it was met, and that is where a constitution stops being one.
2070
- systemPrompt: (routed.length ? applySkills(resolved.systemPrompt, routed.map((m) => m.name), deps.skillRegistry) : resolved.systemPrompt) + law + projectToolsNote(reviewerTools.list(), !!loadGraphSync(cwd)) + BATCH_TOOLS_NOTE,
2071
- tools: reviewerTools,
2072
- proposeMemory: (t, k) => deps.proposeMemory?.(t, k, "code-reviewer") ?? false,
2073
- messages: hints.message ? [{ role: "user", content: hints.message }, ask] : [ask],
2074
- permission: deps.permission,
2075
- approve: deps.approve,
2076
- cwd,
2077
- signal: AbortSignal.any([deps.signal, AbortSignal.timeout(CODE_REVIEW_TIMEOUT_MS)]),
2078
- maxTurns: CODE_REVIEW_MAX_TURNS
2079
- };
2080
- const verdict = await runStructuredRole(opts, VerdictSchema);
2081
- reinforceUsed(deps, hints.ids, verdict.notes.join(" "), "code-reviewer");
2082
- return verdict;
2083
- }
2084
-
2085
- // src/tools/write.ts
2086
- import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
2087
- import { existsSync as existsSync6 } from "fs";
2088
- import { dirname as dirname3, resolve as resolve2, sep as sep3 } from "path";
2089
- import { z as z8 } from "zod";
2090
- var params6 = z8.object({ path: z8.string(), content: z8.string() });
257
+ var params = z.object({ path: z.string(), content: z.string() });
2091
258
  var writeFileTool = {
2092
259
  name: "write_file",
2093
260
  description: "Writes content to a file (creates parent directories). Creating a NEW file is always allowed; to OVERWRITE an existing file you must read_file it first in this run \u2014 otherwise the write is refused.",
2094
261
  permissionLevel: "write",
2095
- parameters: params6,
262
+ parameters: params,
2096
263
  describe(rawArgs) {
2097
- const a = params6.parse(rawArgs);
264
+ const a = params.parse(rawArgs);
2098
265
  return { allowKey: a.path, preview: `write ${a.path} (${Buffer.byteLength(a.content)} bytes)` };
2099
266
  },
2100
267
  async run(rawArgs, ctx) {
2101
- const parsed = params6.safeParse(rawArgs);
268
+ const parsed = params.safeParse(rawArgs);
2102
269
  if (!parsed.success) {
2103
270
  return {
2104
271
  content: `write_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
@@ -2106,20 +273,20 @@ var writeFileTool = {
2106
273
  };
2107
274
  }
2108
275
  const a = parsed.data;
2109
- const target = resolve2(ctx.cwd, a.path);
2110
- const cwdResolved = resolve2(ctx.cwd);
2111
- if (target !== cwdResolved && !target.startsWith(cwdResolved + sep3)) {
276
+ const target = resolve(ctx.cwd, a.path);
277
+ const cwdResolved = resolve(ctx.cwd);
278
+ if (target !== cwdResolved && !target.startsWith(cwdResolved + sep)) {
2112
279
  return { content: `write_file: path is outside cwd: ${a.path}`, isError: true };
2113
280
  }
2114
- if (ctx.readFiles && existsSync6(target) && !ctx.readFiles.has(target)) {
281
+ if (ctx.readFiles && existsSync(target) && !ctx.readFiles.has(target)) {
2115
282
  return {
2116
283
  content: `write_file: refusing to overwrite ${a.path} \u2014 read_file it first so you know what you are replacing (or use edit_file for a targeted change).`,
2117
284
  isError: true
2118
285
  };
2119
286
  }
2120
287
  try {
2121
- await mkdir3(dirname3(target), { recursive: true });
2122
- await writeFile2(target, a.content, "utf8");
288
+ await mkdir(dirname(target), { recursive: true });
289
+ await writeFile(target, a.content, "utf8");
2123
290
  {
2124
291
  const ls = a.content ? a.content.split("\n") : [];
2125
292
  ctx.onActivity?.({ tool: "write", target: a.path, lines: ls.length, preview: ls.slice(0, 12), startLine: 1 });
@@ -2136,21 +303,21 @@ var writeFileTool = {
2136
303
  };
2137
304
 
2138
305
  // src/tools/edit.ts
2139
- import { readFile, writeFile as writeFile3 } from "fs/promises";
2140
- import { resolve as resolve3, sep as sep4 } from "path";
2141
- import { z as z9 } from "zod";
2142
- var params7 = z9.object({
2143
- path: z9.string(),
2144
- oldString: z9.string(),
2145
- newString: z9.string(),
2146
- replaceAll: z9.boolean().optional()
306
+ import { readFile, writeFile as writeFile2 } from "fs/promises";
307
+ import { resolve as resolve2, sep as sep2 } from "path";
308
+ import { z as z2 } from "zod";
309
+ var params2 = z2.object({
310
+ path: z2.string(),
311
+ oldString: z2.string(),
312
+ newString: z2.string(),
313
+ replaceAll: z2.boolean().optional()
2147
314
  });
2148
315
  var NEAR_MISS_CHARS = 600;
2149
316
  var MAX_MATCH_LINES = 5;
2150
317
  var norm = (t) => t.replace(/[ \t]+/g, " ").replace(/[ \t]+$/gm, "").trim();
2151
318
  function shortPath(path, cwd) {
2152
- const abs = resolve3(cwd, path);
2153
- return abs === cwd ? "." : abs.startsWith(cwd + sep4) ? abs.slice(cwd.length + 1) : path;
319
+ const abs = resolve2(cwd, path);
320
+ return abs === cwd ? "." : abs.startsWith(cwd + sep2) ? abs.slice(cwd.length + 1) : path;
2154
321
  }
2155
322
  function whyNotFound(content, oldString) {
2156
323
  if (/^\s*\d+\t/m.test(oldString)) {
@@ -2177,13 +344,13 @@ var editFileTool = {
2177
344
  name: "edit_file",
2178
345
  description: "Performs an exact string replacement in a file. oldString must match the file's REAL bytes \u2014 strip the `<number>\\t` prefix that read_file adds for display, or nothing will match. oldString must be unique (otherwise replaceAll is required); a miss is reported as an error, never a silent no-op.",
2179
346
  permissionLevel: "write",
2180
- parameters: params7,
347
+ parameters: params2,
2181
348
  describe(rawArgs) {
2182
- const a = params7.parse(rawArgs);
349
+ const a = params2.parse(rawArgs);
2183
350
  return { allowKey: a.path, preview: `edit ${a.path}` };
2184
351
  },
2185
352
  async run(rawArgs, ctx) {
2186
- const parsed = params7.safeParse(rawArgs);
353
+ const parsed = params2.safeParse(rawArgs);
2187
354
  if (!parsed.success) {
2188
355
  return {
2189
356
  content: `edit_file: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
@@ -2191,9 +358,9 @@ var editFileTool = {
2191
358
  };
2192
359
  }
2193
360
  const a = parsed.data;
2194
- const target = resolve3(ctx.cwd, a.path);
2195
- const cwdResolved = resolve3(ctx.cwd);
2196
- if (target !== cwdResolved && !target.startsWith(cwdResolved + sep4)) {
361
+ const target = resolve2(ctx.cwd, a.path);
362
+ const cwdResolved = resolve2(ctx.cwd);
363
+ if (target !== cwdResolved && !target.startsWith(cwdResolved + sep2)) {
2197
364
  return { content: `edit_file: path is outside cwd: ${a.path}`, isError: true };
2198
365
  }
2199
366
  let content;
@@ -2226,7 +393,7 @@ var editFileTool = {
2226
393
  }
2227
394
  const next = a.replaceAll ? content.split(a.oldString).join(a.newString) : content.replace(a.oldString, a.newString);
2228
395
  try {
2229
- await writeFile3(target, next, "utf8");
396
+ await writeFile2(target, next, "utf8");
2230
397
  {
2231
398
  const added = a.newString ? a.newString.split("\n") : [];
2232
399
  const removed = a.oldString ? a.oldString.split("\n") : [];
@@ -2246,12 +413,12 @@ var editFileTool = {
2246
413
 
2247
414
  // src/tools/shell.ts
2248
415
  import { spawn } from "child_process";
2249
- import { resolve as resolve4, sep as sep5 } from "path";
2250
- import { z as z10 } from "zod";
2251
- var params8 = z10.object({
2252
- command: z10.string(),
416
+ import { resolve as resolve3, sep as sep3 } from "path";
417
+ import { z as z3 } from "zod";
418
+ var params3 = z3.object({
419
+ command: z3.string(),
2253
420
  /** Milliseconds before the command is killed. Defaults to DEFAULT_TIMEOUT_MS, capped at MAX_TIMEOUT_MS. */
2254
- timeout: z10.number().int().positive().optional()
421
+ timeout: z3.number().int().positive().optional()
2255
422
  });
2256
423
  var DEFAULT_TIMEOUT_MS = 12e4;
2257
424
  var MAX_TIMEOUT_MS = 6e5;
@@ -2286,7 +453,7 @@ var REWRITES = [
2286
453
  ];
2287
454
  var REDIRECT = /(?:^|[^0-9<>&])>>?\s*(?!\/dev\/|\/tmp\/|&)([A-Za-z0-9_./-]*\.[A-Za-z0-9]+)/;
2288
455
  function leavesWorkdir(command, cwd) {
2289
- const base = resolve4(cwd);
456
+ const base = resolve3(cwd);
2290
457
  let at = base;
2291
458
  for (const seg of command.split(/&&|\|\||;|\|/)) {
2292
459
  const m = /^\s*(?:cd|pushd)(?:\s+(.*))?$/.exec(seg.trim());
@@ -2294,8 +461,8 @@ function leavesWorkdir(command, cwd) {
2294
461
  const raw = (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
2295
462
  if (!raw || raw === "~" || raw === "$HOME" || raw.startsWith("~/")) return raw || "~";
2296
463
  if (raw === "-") return "-";
2297
- at = resolve4(at, raw);
2298
- if (at !== base && !at.startsWith(base + sep5)) return raw;
464
+ at = resolve3(at, raw);
465
+ if (at !== base && !at.startsWith(base + sep3)) return raw;
2299
466
  }
2300
467
  return void 0;
2301
468
  }
@@ -2324,13 +491,13 @@ var shellTool = {
2324
491
  name: "shell",
2325
492
  description: "Runs a shell command (in the cwd context). Returns stdout+stderr and the exit code. Runs NON-INTERACTIVELY (stdin is closed) \u2014 pass non-interactive flags (e.g. --yes, --no-input) or the command will fail rather than wait for input. Killed after `timeout` ms (default 120000, max 600000); do not start long-running watchers or dev servers. To CHANGE a file use `edit_file` or `write_file` \u2014 rewriting one from here (sed -i, a python heredoc, a redirect) is refused: those tools report what changed, and this one cannot.",
2326
493
  permissionLevel: "exec",
2327
- parameters: params8,
494
+ parameters: params3,
2328
495
  describe(rawArgs) {
2329
- const a = params8.parse(rawArgs);
496
+ const a = params3.parse(rawArgs);
2330
497
  return { allowKey: a.command, preview: a.command };
2331
498
  },
2332
499
  run(rawArgs, ctx) {
2333
- const parsed = params8.safeParse(rawArgs);
500
+ const parsed = params3.safeParse(rawArgs);
2334
501
  const why = parsed.success ? rewritesAFile(parsed.data.command) : void 0;
2335
502
  if (why !== void 0) {
2336
503
  return Promise.resolve({
@@ -2425,43 +592,9 @@ ${body}${tail}`, isError: timedOut || code !== 0 });
2425
592
  }
2426
593
  };
2427
594
 
2428
- // src/tools/web.ts
2429
- import { z as z11 } from "zod";
2430
- var params9 = z11.object({ url: z11.string().url() });
2431
- var MAX_CHARS = 1e5;
2432
- function createWebFetchTool(fetchFn = globalThis.fetch) {
2433
- return {
2434
- name: "web_fetch",
2435
- description: "Fetches the content (text) of a URL.",
2436
- permissionLevel: "safe",
2437
- parameters: params9,
2438
- async run(rawArgs, ctx) {
2439
- const parsed = params9.safeParse(rawArgs);
2440
- if (!parsed.success) {
2441
- return {
2442
- content: `web_fetch: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
2443
- isError: true
2444
- };
2445
- }
2446
- const a = parsed.data;
2447
- try {
2448
- const res = await fetchFn(a.url, { signal: ctx.signal });
2449
- const text = await res.text();
2450
- const capped = text.length > MAX_CHARS ? text.slice(0, MAX_CHARS) + "\n\u2026 (truncated)" : text;
2451
- return { content: capped, isError: !res.ok };
2452
- } catch (e) {
2453
- return {
2454
- content: `web_fetch error: ${e instanceof Error ? e.message : String(e)}`,
2455
- isError: true
2456
- };
2457
- }
2458
- }
2459
- };
2460
- }
2461
-
2462
595
  // src/engine/trace-refresh.ts
2463
- import { existsSync as existsSync7 } from "fs";
2464
- import { join as join7 } from "path";
596
+ import { existsSync as existsSync2 } from "fs";
597
+ import { join } from "path";
2465
598
  var IRRELEVANT = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/;
2466
599
  async function changedByMerge(git, cwd, before, after = "HEAD") {
2467
600
  if (!before) return [];
@@ -2490,7 +623,7 @@ async function refreshTraces(opts) {
2490
623
  const out = { traced: 0, failed: 0, removed: 0, skipped: 0 };
2491
624
  const candidates = traceable(opts.files);
2492
625
  out.skipped = opts.files.length - candidates.length;
2493
- const gone = candidates.filter((f) => !existsSync7(join7(opts.cwd, f)));
626
+ const gone = candidates.filter((f) => !existsSync2(join(opts.cwd, f)));
2494
627
  if (gone.length) {
2495
628
  try {
2496
629
  const index = await loadTraceIndex(opts.cwd);
@@ -2539,7 +672,7 @@ function describeRefresh(r) {
2539
672
  return `${bits.join(" \xB7 ")} \u2014 the changed files now describe themselves.`;
2540
673
  }
2541
674
  async function commitRefreshed(git, baseWorktree, traceRootRel2) {
2542
- const paths = [traceRootRel2, ...sharedDerived()].filter((p) => existsSync7(join7(baseWorktree, p)));
675
+ const paths = [traceRootRel2, ...sharedDerived()].filter((p) => existsSync2(join(baseWorktree, p)));
2543
676
  if (!paths.length) return false;
2544
677
  const add = await git(["add", "--", ...paths], baseWorktree);
2545
678
  if (add.code !== 0) return false;
@@ -2550,14 +683,14 @@ async function commitRefreshed(git, baseWorktree, traceRootRel2) {
2550
683
  }
2551
684
 
2552
685
  // src/engine/writer-registry.ts
2553
- import { z as z13 } from "zod";
686
+ import { z as z5 } from "zod";
2554
687
 
2555
688
  // src/engine/normalize-question.ts
2556
- import { z as z12 } from "zod";
2557
- var NormalizedQuestionSchema = z12.object({
2558
- question: z12.string().describe("The core question, concise, WITHOUT the embedded options table/list."),
2559
- options: z12.array(z12.string()).describe("Each selectable choice as a SHORT label; the recommended one first, suffixed ' (recommended)'. Empty when the question is genuinely open-ended."),
2560
- multiSelect: z12.boolean().describe("true only if the user may pick more than one.")
689
+ import { z as z4 } from "zod";
690
+ var NormalizedQuestionSchema = z4.object({
691
+ question: z4.string().describe("The core question, concise, WITHOUT the embedded options table/list."),
692
+ options: z4.array(z4.string()).describe("Each selectable choice as a SHORT label; the recommended one first, suffixed ' (recommended)'. Empty when the question is genuinely open-ended."),
693
+ multiSelect: z4.boolean().describe("true only if the user may pick more than one.")
2561
694
  });
2562
695
  var PROMPT = "You reformat an agent's question for a terminal UI that renders selectable options (arrow keys + Enter). Given the raw question text \u2014 which may embed choices as a markdown table, an A/B/C/D list, or a 'recommended' suggestion \u2014 extract exactly:\n- `question`: the core question, concise, WITHOUT the embedded options table/list.\n- `options`: each selectable choice as a SHORT label. If one choice is recommended, list it FIRST and append ' (recommended)'. Do NOT add an 'other' / free-text / 'answer in your own words' option \u2014 the UI already provides that.\n- `multiSelect`: true only if the user may pick several.\nIf the text is genuinely open-ended (no discrete choices), return options: []. Preserve the user's language. Return the result via submit.";
2563
696
  function looksLikeChoices(text) {
@@ -2624,21 +757,21 @@ var clipLabel = (body) => {
2624
757
  };
2625
758
 
2626
759
  // src/engine/writer-registry.ts
2627
- var askUserParams = z13.object({
2628
- question: z13.string(),
760
+ var askUserParams = z5.object({
761
+ question: z5.string(),
2629
762
  // For a multiple-choice question, list the choices here → the UI shows a selectable checkbox/radio list
2630
763
  // (arrow keys + Enter) instead of a free-text box. Omit for an open-ended question.
2631
764
  //
2632
765
  // A choice may be a plain string, or an object carrying what the label alone cannot say: a one-line
2633
766
  // `description`, and a `preview` rendered in a panel beside the list while that option is focused. Use the
2634
767
  // rich form when the decision turns on the trade-offs rather than the name (e.g. "which approach?").
2635
- options: z13.array(z13.union([
2636
- z13.string(),
2637
- z13.object({ label: z13.string(), description: z13.string().optional(), preview: z13.string().optional() })
768
+ options: z5.array(z5.union([
769
+ z5.string(),
770
+ z5.object({ label: z5.string(), description: z5.string().optional(), preview: z5.string().optional() })
2638
771
  ])).optional().describe(
2639
772
  "The choices, when the question has discrete answers \u2014 the UI renders a selectable list instead of a free-text box. Omit for an open-ended question. A choice may be a plain string, or an object with a one-line `description` and a `preview` shown beside the list; use the rich form when the decision turns on trade-offs rather than on the name."
2640
773
  ),
2641
- multiSelect: z13.boolean().optional().describe(
774
+ multiSelect: z5.boolean().optional().describe(
2642
775
  "True when the user may pick more than one (checkboxes); omitted means pick exactly one (radio)."
2643
776
  ),
2644
777
  /**
@@ -2647,7 +780,7 @@ var askUserParams = z13.object({
2647
780
  * Present ⇒ this is a hand-off, not a question: the run has stopped because only a person can carry the
2648
781
  * next step, and the UI says so rather than showing a bare "? Question".
2649
782
  */
2650
- steps: z13.array(z13.string()).optional().describe(
783
+ steps: z5.array(z5.string()).optional().describe(
2651
784
  'What the user has to DO before they can answer \u2014 one action per entry. Supplying this makes it a HAND-OFF rather than a question: the run has stopped because only a person can carry the next step, and the UI says so instead of showing a bare "? Question". Use it whenever you are asking someone to go and perform something and report back; leave it out when you only want an answer.'
2652
785
  )
2653
786
  });
@@ -2732,8 +865,8 @@ function writerRegistry(skillRegistry, extra = []) {
2732
865
  }
2733
866
 
2734
867
  // src/engine/role-fitness.ts
2735
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
2736
- import { dirname as dirname4 } from "path";
868
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
869
+ import { dirname as dirname2 } from "path";
2737
870
  var UNFIT_AFTER = 2;
2738
871
  var UNFIT_RATE = 0.5;
2739
872
  var key = (role, model) => `${role}\0${model}`;
@@ -2825,7 +958,7 @@ var RoleFitness = class {
2825
958
  }
2826
959
  load() {
2827
960
  try {
2828
- const raw = JSON.parse(readFileSync3(this.path, "utf8"));
961
+ const raw = JSON.parse(readFileSync(this.path, "utf8"));
2829
962
  if (!Array.isArray(raw)) return;
2830
963
  for (const r of raw) {
2831
964
  if (r && typeof r.role === "string" && typeof r.model === "string" && typeof r.strikes === "number") {
@@ -2838,15 +971,15 @@ var RoleFitness = class {
2838
971
  save() {
2839
972
  if (!this.path) return;
2840
973
  try {
2841
- mkdirSync3(dirname4(this.path), { recursive: true });
2842
- writeFileSync2(this.path, JSON.stringify(this.list(), null, 2), "utf8");
974
+ mkdirSync(dirname2(this.path), { recursive: true });
975
+ writeFileSync(this.path, JSON.stringify(this.list(), null, 2), "utf8");
2843
976
  } catch {
2844
977
  }
2845
978
  }
2846
979
  };
2847
980
 
2848
981
  // src/engine/routing.ts
2849
- import { z as z14 } from "zod";
982
+ import { z as z6 } from "zod";
2850
983
 
2851
984
  // src/engine/route-role.ts
2852
985
  var STYLE_EXT = [".css", ".scss", ".sass", ".less", ".styl"];
@@ -2890,12 +1023,12 @@ var ext = (p) => {
2890
1023
  return dot > 0 ? base.slice(dot).toLowerCase() : "";
2891
1024
  };
2892
1025
  var segments = (p) => p.toLowerCase().split(/[/\\]/).slice(0, -1);
2893
- var words2 = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
1026
+ var words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
2894
1027
  function routeByEvidence(card) {
2895
1028
  const files = card.files.map((f) => f.trim()).filter(Boolean);
2896
1029
  const exts = files.map(ext);
2897
1030
  const dirs = files.flatMap(segments);
2898
- const title = words2(card.title);
1031
+ const title = words(card.title);
2899
1032
  const hasStyle = exts.some((e) => STYLE_EXT.includes(e));
2900
1033
  const hasMarkup = exts.some((e) => MARKUP_EXT.includes(e));
2901
1034
  const hasComponent = exts.some((e) => COMPONENT_EXT.includes(e));
@@ -2913,8 +1046,8 @@ function routeByEvidence(card) {
2913
1046
  }
2914
1047
 
2915
1048
  // src/engine/routing.ts
2916
- var RouteSchema = z14.object({
2917
- role: z14.enum(["coder", "designer"]).describe(
1049
+ var RouteSchema = z6.object({
1050
+ role: z6.enum(["coder", "designer"]).describe(
2918
1051
  "Who should implement this. `designer` when the work IS how the thing looks or behaves to a person \u2014 layout, spacing, colour, copy, interaction. `coder` for everything else. Judge by what the work is, not by the file type: a component file holding a data hook is code work; a component file whose whole job is appearance is design work."
2919
1052
  )
2920
1053
  });
@@ -2976,9 +1109,9 @@ function createDefaultRegistry() {
2976
1109
  }
2977
1110
 
2978
1111
  // src/engine/operational.ts
2979
- import { z as z15 } from "zod";
2980
- var CommitSchema = z15.object({
2981
- message: z15.string().describe("A Conventional Commits message: `type(scope): subject`, English, imperative.")
1112
+ import { z as z7 } from "zod";
1113
+ var CommitSchema = z7.object({
1114
+ message: z7.string().describe("A Conventional Commits message: `type(scope): subject`, English, imperative.")
2982
1115
  });
2983
1116
  var MAX_DIFF = 12e3;
2984
1117
  var OPERATIONAL_MAX_TURNS = 3;
@@ -3225,13 +1358,13 @@ function deadlineWarning(elapsedMs, budgetMs) {
3225
1358
  var MAX_WRITTEN_CHARS = 6e4;
3226
1359
  async function writtenText(cwd, touched) {
3227
1360
  const { readFile: readFile3 } = await import("fs/promises");
3228
- const { join: join10 } = await import("path");
1361
+ const { join: join4 } = await import("path");
3229
1362
  const parts = [];
3230
1363
  let used = 0;
3231
1364
  for (const p of [...new Set(touched)]) {
3232
1365
  if (used >= MAX_WRITTEN_CHARS) break;
3233
1366
  try {
3234
- const t = await readFile3(join10(cwd, p), "utf8");
1367
+ const t = await readFile3(join4(cwd, p), "utf8");
3235
1368
  parts.push(t.slice(0, MAX_WRITTEN_CHARS - used));
3236
1369
  used += t.length;
3237
1370
  } catch {
@@ -3424,20 +1557,20 @@ ${handOver}`;
3424
1557
  }
3425
1558
 
3426
1559
  // src/engine/review.ts
3427
- import { existsSync as existsSync8 } from "fs";
3428
- import { isAbsolute, join as join8 } from "path";
3429
- import { z as z16 } from "zod";
1560
+ import { existsSync as existsSync3 } from "fs";
1561
+ import { isAbsolute, join as join2 } from "path";
1562
+ import { z as z8 } from "zod";
3430
1563
  function asChoice(o) {
3431
1564
  return typeof o === "string" ? { label: o } : o;
3432
1565
  }
3433
- var AssessmentSchema = z16.object({
3434
- findings: z16.array(z16.object({
3435
- severity: z16.enum(["critical", "medium", "low"]).describe(
1566
+ var AssessmentSchema = z8.object({
1567
+ findings: z8.array(z8.object({
1568
+ severity: z8.enum(["critical", "medium", "low"]).describe(
3436
1569
  "`critical`: shipping it this way causes real harm \u2014 wrong behaviour, data loss, a security hole. `medium`: it should be fixed but nothing breaks if it ships. `low`: a preference or a tidy-up."
3437
1570
  ),
3438
- note: z16.string()
1571
+ note: z8.string()
3439
1572
  })).default([]),
3440
- recommendation: z16.enum(["approve", "revise"]).describe(
1573
+ recommendation: z8.enum(["approve", "revise"]).describe(
3441
1574
  "`revise` only if at least one finding must be addressed before this can ship; otherwise `approve` and leave the findings as notes. Findings you would not block on do not make it a revise."
3442
1575
  )
3443
1576
  });
@@ -3478,18 +1611,18 @@ function coverage(assessments) {
3478
1611
  const verified = assessments.length - unverified;
3479
1612
  return { verified, unverified, enough: !assessments.length || verified / assessments.length >= TEAM_MIN_COVERAGE };
3480
1613
  }
3481
- var CouncilVoteSchema = z16.object({
3482
- vote: z16.enum(["pass", "revise"]).describe(
1614
+ var CouncilVoteSchema = z8.object({
1615
+ vote: z8.enum(["pass", "revise"]).describe(
3483
1616
  "`revise` only if something must change before this can ship. A concern you would not block on is a `pass` with the concern in the rationale."
3484
1617
  ),
3485
- rationale: z16.string()
1618
+ rationale: z8.string()
3486
1619
  });
3487
- var JudgeSchema = z16.object({
3488
- decision: z16.enum(["pass", "revise", "ask-human"]).describe(
1620
+ var JudgeSchema = z8.object({
1621
+ decision: z8.enum(["pass", "revise", "ask-human"]).describe(
3489
1622
  "`pass`: it can ship. `revise`: it can be fixed from the feedback below, without anyone being asked. `ask-human` ONLY when the decision is genuinely not yours \u2014 the reviewers disagree on something a person owns, or the answer depends on intent nobody wrote down. It stops the run and costs someone their attention; do not use it for a call you can make."
3490
1623
  ),
3491
- feedback: z16.array(z16.string()),
3492
- question: z16.string()
1624
+ feedback: z8.array(z8.string()),
1625
+ question: z8.string()
3493
1626
  });
3494
1627
  var STAGE_FRAMING = {
3495
1628
  spec: `You are reviewing a SPECIFICATION: it states WHAT the product must do and WHY, written for business stakeholders. By design it MUST NOT contain implementation detail (languages, frameworks, APIs, storage mechanics, code structure) \u2014 those decisions belong to the LATER plan stage.
@@ -3845,7 +1978,7 @@ async function runReviewLoop(deps, o) {
3845
1978
  let lastVotes = [];
3846
1979
  for (; ; ) {
3847
1980
  for (let i = 0; i < maxRounds; i++, round++) {
3848
- if (stage !== "code" && !existsSync8(isAbsolute(target) ? target : join8(workdir, target))) {
1981
+ if (stage !== "code" && !existsSync3(isAbsolute(target) ? target : join2(workdir, target))) {
3849
1982
  emit({ kind: "note", text: `\u26A0\uFE0F **${label} not found** at \`${target}\` \u2014 nothing to review. The authoring phase produced no file.` });
3850
1983
  return { approved: false };
3851
1984
  }
@@ -4074,7 +2207,7 @@ async function runCodeReview(deps, workdir, taskTitle, request, emit = () => {
4074
2207
  }
4075
2208
 
4076
2209
  // src/engine/acceptance.ts
4077
- import { z as z17 } from "zod";
2210
+ import { z as z9 } from "zod";
4078
2211
 
4079
2212
  // src/engine/criterion-commands.ts
4080
2213
  import { spawn as spawn2 } from "child_process";
@@ -4110,12 +2243,12 @@ function commandsIn(criterion) {
4110
2243
  }
4111
2244
  async function runCommand(cwd, argv, timeoutMs = CRITERION_TIMEOUT_MS) {
4112
2245
  const [bin, ...args] = argv;
4113
- return new Promise((resolve5) => {
2246
+ return new Promise((resolve4) => {
4114
2247
  let child;
4115
2248
  try {
4116
2249
  child = spawn2(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1" } });
4117
2250
  } catch (e) {
4118
- resolve5({ argv, passed: false, exitCode: null, timedOut: false, output: e instanceof Error ? e.message : String(e) });
2251
+ resolve4({ argv, passed: false, exitCode: null, timedOut: false, output: e instanceof Error ? e.message : String(e) });
4119
2252
  return;
4120
2253
  }
4121
2254
  let out = "";
@@ -4132,11 +2265,11 @@ async function runCommand(cwd, argv, timeoutMs = CRITERION_TIMEOUT_MS) {
4132
2265
  }, timeoutMs);
4133
2266
  child.on("error", (e) => {
4134
2267
  clearTimeout(timer);
4135
- resolve5({ argv, passed: false, exitCode: null, timedOut: false, output: e.message });
2268
+ resolve4({ argv, passed: false, exitCode: null, timedOut: false, output: e.message });
4136
2269
  });
4137
2270
  child.on("close", (code) => {
4138
2271
  clearTimeout(timer);
4139
- resolve5({ argv, passed: !timedOut && code === 0, exitCode: code, timedOut, output: out.slice(-MAX_OUTPUT) });
2272
+ resolve4({ argv, passed: !timedOut && code === 0, exitCode: code, timedOut, output: out.slice(-MAX_OUTPUT) });
4140
2273
  });
4141
2274
  });
4142
2275
  }
@@ -4171,39 +2304,39 @@ ${r.output.slice(-800)}
4171
2304
 
4172
2305
  // src/engine/test-runner.ts
4173
2306
  import { readFile as readFile2 } from "fs/promises";
4174
- import { existsSync as existsSync9 } from "fs";
2307
+ import { existsSync as existsSync4 } from "fs";
4175
2308
  import { spawn as spawn3 } from "child_process";
4176
- import { join as join9 } from "path";
2309
+ import { join as join3 } from "path";
4177
2310
  var TEST_TIMEOUT_MS = 6e5;
4178
2311
  var MAX_TEST_OUTPUT = 12e3;
4179
2312
  var PLACEHOLDER = /no test specified/i;
4180
2313
  async function detectTestCommand(cwd) {
4181
- const pkgPath = join9(cwd, "package.json");
4182
- if (existsSync9(pkgPath)) {
2314
+ const pkgPath = join3(cwd, "package.json");
2315
+ if (existsSync4(pkgPath)) {
4183
2316
  try {
4184
2317
  const pkg = JSON.parse(await readFile2(pkgPath, "utf8"));
4185
2318
  const script = pkg.scripts?.test;
4186
2319
  if (script && !PLACEHOLDER.test(script)) {
4187
- const runner = existsSync9(join9(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync9(join9(cwd, "yarn.lock")) ? "yarn" : existsSync9(join9(cwd, "bun.lockb")) ? "bun" : "npm";
2320
+ const runner = existsSync4(join3(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync4(join3(cwd, "yarn.lock")) ? "yarn" : existsSync4(join3(cwd, "bun.lockb")) ? "bun" : "npm";
4188
2321
  const ci = /\bng test\b/.test(script) && !/--watch|--no-watch/.test(script) ? ["--", "--watch=false", "--browsers=ChromeHeadless"] : [];
4189
2322
  return { argv: [runner, "test", ...ci], why: `package.json scripts.test: ${script}` };
4190
2323
  }
4191
2324
  } catch {
4192
2325
  }
4193
2326
  }
4194
- if (existsSync9(join9(cwd, "pytest.ini")) || existsSync9(join9(cwd, "pyproject.toml")) || existsSync9(join9(cwd, "tox.ini"))) {
2327
+ if (existsSync4(join3(cwd, "pytest.ini")) || existsSync4(join3(cwd, "pyproject.toml")) || existsSync4(join3(cwd, "tox.ini"))) {
4195
2328
  return { argv: ["python3", "-m", "pytest", "-q"], why: "a pytest configuration is present" };
4196
2329
  }
4197
- if (existsSync9(join9(cwd, "go.mod"))) return { argv: ["go", "test", "./..."], why: "go.mod is present" };
4198
- if (existsSync9(join9(cwd, "Cargo.toml"))) return { argv: ["cargo", "test"], why: "Cargo.toml is present" };
4199
- if (existsSync9(join9(cwd, "Gemfile"))) return { argv: ["bundle", "exec", "rspec"], why: "a Gemfile is present" };
2330
+ if (existsSync4(join3(cwd, "go.mod"))) return { argv: ["go", "test", "./..."], why: "go.mod is present" };
2331
+ if (existsSync4(join3(cwd, "Cargo.toml"))) return { argv: ["cargo", "test"], why: "Cargo.toml is present" };
2332
+ if (existsSync4(join3(cwd, "Gemfile"))) return { argv: ["bundle", "exec", "rspec"], why: "a Gemfile is present" };
4200
2333
  return void 0;
4201
2334
  }
4202
2335
  async function runProjectTests(cwd, cmd) {
4203
2336
  const command = cmd ?? await detectTestCommand(cwd);
4204
2337
  if (!command) return { skipped: true, passed: true, output: "", timedOut: false };
4205
2338
  const [bin, ...args] = command.argv;
4206
- return new Promise((resolve5) => {
2339
+ return new Promise((resolve4) => {
4207
2340
  const child = spawn3(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CI: "1" } });
4208
2341
  let out = "";
4209
2342
  const take = (d) => {
@@ -4219,7 +2352,7 @@ async function runProjectTests(cwd, cmd) {
4219
2352
  }, TEST_TIMEOUT_MS);
4220
2353
  const done = (code, extra = "") => {
4221
2354
  clearTimeout(timer);
4222
- resolve5({
2355
+ resolve4({
4223
2356
  skipped: false,
4224
2357
  passed: code === 0 && !timedOut,
4225
2358
  command: command.argv.join(" "),
@@ -4230,7 +2363,7 @@ async function runProjectTests(cwd, cmd) {
4230
2363
  };
4231
2364
  child.on("error", (e) => {
4232
2365
  clearTimeout(timer);
4233
- resolve5({
2366
+ resolve4({
4234
2367
  skipped: true,
4235
2368
  passed: true,
4236
2369
  command: command.argv.join(" "),
@@ -4261,13 +2394,13 @@ ${run.output}`;
4261
2394
  function normalizeCriterion(s) {
4262
2395
  return s.toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().replace(/[.,;:!?…]+$/, "");
4263
2396
  }
4264
- var AcceptanceSchema = z17.object({
4265
- checks: z17.array(z17.object({
4266
- criterion: z17.string().describe(
2397
+ var AcceptanceSchema = z9.object({
2398
+ checks: z9.array(z9.object({
2399
+ criterion: z9.string().describe(
4267
2400
  "Copy the criterion VERBATIM from the numbered list you were given, including any backticks and punctuation. Do not paraphrase, renumber or reformat it \u2014 it is matched back to the task by text."
4268
2401
  ),
4269
- met: z17.boolean(),
4270
- evidence: z17.string().describe(
2402
+ met: z9.boolean(),
2403
+ evidence: z9.string().describe(
4271
2404
  'Where you SAW it: a file path and what it contains, a symbol, a test name. "It looks fine" is not evidence.'
4272
2405
  )
4273
2406
  }))
@@ -4282,7 +2415,7 @@ Rules:
4282
2415
  - Copy each criterion into the "criterion" field VERBATIM from the numbered list, including any backticks and punctuation. It is matched back to the task by text; a paraphrase loses the pairing.
4283
2416
  - Write the evidence in ENGLISH (it is a technical record).`;
4284
2417
  async function verifyAcceptance(deps, card, cwd, emit = () => {
4285
- }) {
2418
+ }, runCommands = runCriterionCommands) {
4286
2419
  const suite = () => deps.timings ? deps.timings.time("test suite", () => runProjectTests(cwd)) : runProjectTests(cwd);
4287
2420
  const tests = await telemetry().span("stage.test_suite", { "hc.stage": "test suite" }, suite);
4288
2421
  telemetry().event("tests.run", {
@@ -4312,7 +2445,7 @@ ${tests.output.slice(-4e3)}`,
4312
2445
  const commandRuns = await telemetry().span(
4313
2446
  "stage.criterion_commands",
4314
2447
  { "hc.stage": "criterion commands" },
4315
- () => runCriterionCommands(cwd, card.acceptance)
2448
+ () => runCommands(cwd, card.acceptance)
4316
2449
  );
4317
2450
  for (const r of commandRuns) {
4318
2451
  emit({ kind: "note", text: r.passed ? `\u2705 \`${r.argv.join(" ")}\` \u2014 exit 0` : `\u274C \`${r.argv.join(" ")}\` \u2014 ${r.timedOut ? "timed out" : `exit ${r.exitCode ?? "none"}`}` });
@@ -4515,31 +2648,31 @@ async function runTaskCycle(deps, board, taskId, worktreePath, slot = 0) {
4515
2648
  }
4516
2649
 
4517
2650
  // src/board/board.ts
4518
- import { z as z18 } from "zod";
2651
+ import { z as z10 } from "zod";
4519
2652
  var MAX_STAGE_EVENTS = 200;
4520
- var stageEventSchema = z18.object({
4521
- role: z18.string(),
4522
- action: z18.string(),
4523
- note: z18.string().optional()
2653
+ var stageEventSchema = z10.object({
2654
+ role: z10.string(),
2655
+ action: z10.string(),
2656
+ note: z10.string().optional()
4524
2657
  });
4525
- var cardSchema = z18.object({
4526
- id: z18.string(),
4527
- title: z18.string(),
4528
- column: z18.enum(["TODO", "IN-PROGRESS", "REVIEW", "DONE", "MERGED", "PARKED", "ABANDONED"]),
4529
- worktree: z18.string().optional(),
4530
- deps: z18.array(z18.string()),
4531
- acceptance: z18.array(z18.string()).default([]),
2658
+ var cardSchema = z10.object({
2659
+ id: z10.string(),
2660
+ title: z10.string(),
2661
+ column: z10.enum(["TODO", "IN-PROGRESS", "REVIEW", "DONE", "MERGED", "PARKED", "ABANDONED"]),
2662
+ worktree: z10.string().optional(),
2663
+ deps: z10.array(z10.string()),
2664
+ acceptance: z10.array(z10.string()).default([]),
4532
2665
  // default: boards persisted before the gate existed still load
4533
- files: z18.array(z18.string()).default([]),
2666
+ files: z10.array(z10.string()).default([]),
4534
2667
  // ditto — a board written before file lists existed still loads
4535
- reviewNotes: z18.array(z18.string()),
2668
+ reviewNotes: z10.array(z10.string()),
4536
2669
  // Optional rather than defaulted: a board written before this existed must round-trip unchanged, and an
4537
2670
  // empty list is the same statement as no list at all.
4538
- clearedLenses: z18.array(z18.string()).optional(),
4539
- attempts: z18.number(),
4540
- stageHistory: z18.array(stageEventSchema)
2671
+ clearedLenses: z10.array(z10.string()).optional(),
2672
+ attempts: z10.number(),
2673
+ stageHistory: z10.array(stageEventSchema)
4541
2674
  });
4542
- var boardDataSchema = z18.object({ version: z18.literal(1), cards: z18.array(cardSchema) });
2675
+ var boardDataSchema = z10.object({ version: z10.literal(1), cards: z10.array(cardSchema) });
4543
2676
  function migrateDelivered(c) {
4544
2677
  if (c.column !== "DONE") return c;
4545
2678
  return c.stageHistory.some((e) => e.action === "merged") ? { ...c, column: "MERGED" } : c;
@@ -4710,26 +2843,6 @@ export {
4710
2843
  SHORT_CALL_MS,
4711
2844
  LONG_CALL_MS,
4712
2845
  CliProvider,
4713
- describeInherited,
4714
- describeTopUp,
4715
- toSlug,
4716
- mainWorktreeRoot,
4717
- WorktreeManager,
4718
- unfinishedSessions,
4719
- describeUnfinished,
4720
- gitTool,
4721
- buildRememberTool,
4722
- routeSkills,
4723
- specsDir,
4724
- constitutionPath,
4725
- verifyPaths,
4726
- featureSlugFor,
4727
- nextFeatureSlug,
4728
- scaffoldFeature,
4729
- constitutionNote,
4730
- readOnlyRegistry,
4731
- deferMcp,
4732
- runReviewer,
4733
2846
  subjectOf,
4734
2847
  asChoice,
4735
2848
  buildTeamRegistry,
@@ -4738,7 +2851,6 @@ export {
4738
2851
  writeFileTool,
4739
2852
  editFileTool,
4740
2853
  shellTool,
4741
- createWebFetchTool,
4742
2854
  createDefaultRegistry,
4743
2855
  changedByMerge,
4744
2856
  refreshAfterChange,