@hizliemre/horse-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/app-SB2L34JW.js +6217 -0
  4. package/dist/chunk-2DGO2BUB.js +4490 -0
  5. package/dist/chunk-2SVAHH5N.js +60 -0
  6. package/dist/chunk-3XVZXTB6.js +4469 -0
  7. package/dist/chunk-5UWA2UBM.js +69 -0
  8. package/dist/chunk-7TBYMFMG.js +147 -0
  9. package/dist/chunk-B67BK5GQ.js +34 -0
  10. package/dist/chunk-BY4DP7IE.js +20 -0
  11. package/dist/chunk-DKVIN43T.js +54 -0
  12. package/dist/chunk-DTWKSZXY.js +162 -0
  13. package/dist/chunk-F2IALVBU.js +212 -0
  14. package/dist/chunk-FFYBY2NA.js +392 -0
  15. package/dist/chunk-FGVJFMK5.js +123 -0
  16. package/dist/chunk-H2FDGPVW.js +42 -0
  17. package/dist/chunk-HBSC2HT2.js +85 -0
  18. package/dist/chunk-IW2KBAVZ.js +21 -0
  19. package/dist/chunk-JWAEW7AJ.js +121 -0
  20. package/dist/chunk-NNTIACT4.js +163 -0
  21. package/dist/chunk-O74BDQKS.js +28 -0
  22. package/dist/chunk-PGOYDOI4.js +426 -0
  23. package/dist/chunk-QF4MP6BS.js +69 -0
  24. package/dist/chunk-SSDLHWSF.js +35 -0
  25. package/dist/chunk-TOPZL5SU.js +1052 -0
  26. package/dist/chunk-YBWTCXUS.js +153 -0
  27. package/dist/chunk-YILDXPSI.js +1363 -0
  28. package/dist/clean-YOQATBMZ.js +18 -0
  29. package/dist/cli.js +1495 -0
  30. package/dist/discover-5URG7C4J.js +52 -0
  31. package/dist/fix-HBBOTUWM.js +34 -0
  32. package/dist/frontmatter-UNIPNLLO.js +6 -0
  33. package/dist/git-VTSZALSR.js +6 -0
  34. package/dist/install-O34KMWJB.js +113 -0
  35. package/dist/main-branch-KGWUINYQ.js +19 -0
  36. package/dist/ongoing-OV5XROTU.js +70 -0
  37. package/dist/project-graph-IOPCSZUA.js +56 -0
  38. package/dist/run-LQOZ5I7Z.js +610 -0
  39. package/dist/save-skills-OHYGVTQ4.js +13 -0
  40. package/dist/source-cache-XEK5WN7I.js +29 -0
  41. package/dist/trace-ZMB7LT7W.js +66 -0
  42. package/dist/trace-adopt-C6TUWFJL.js +79 -0
  43. package/dist/trace-run-F23MFTY4.js +24 -0
  44. package/dist/triage-2J3T5PVQ.js +30 -0
  45. package/dist/verify-WQ3GHION.js +479 -0
  46. package/dist/worktree-F7TWLWLN.js +87 -0
  47. package/package.json +64 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1495 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ MAX_PARALLEL_TASKS,
4
+ MemoryStore,
5
+ OmniRouteProvider,
6
+ Timings,
7
+ autonomousAskHuman,
8
+ defaultCmdRunner,
9
+ detectPlatform,
10
+ makeApprove,
11
+ makeAskUser,
12
+ makePRAdapter,
13
+ nodeLineReader,
14
+ restoreTerminal,
15
+ runJob,
16
+ sttySane
17
+ } from "./chunk-2DGO2BUB.js";
18
+ import {
19
+ listOmniRouteModels
20
+ } from "./chunk-O74BDQKS.js";
21
+ import "./chunk-F2IALVBU.js";
22
+ import {
23
+ CODE_TEAM,
24
+ DEFAULT_COUNCIL,
25
+ DEFAULT_PROMPTS,
26
+ DEFAULT_ROLE_SKILLS,
27
+ PLAN_TEAM,
28
+ REQUIRED_ROLES,
29
+ RoleFitness,
30
+ RoleRegistry,
31
+ SPEC_TEAM,
32
+ WorktreeManager,
33
+ buildCouncilRegistry,
34
+ buildTeamRegistry,
35
+ mainWorktreeRoot,
36
+ toSlug
37
+ } from "./chunk-3XVZXTB6.js";
38
+ import "./chunk-QF4MP6BS.js";
39
+ import "./chunk-HBSC2HT2.js";
40
+ import "./chunk-FGVJFMK5.js";
41
+ import {
42
+ parseFrontmatter
43
+ } from "./chunk-BY4DP7IE.js";
44
+ import {
45
+ saveSkillSource
46
+ } from "./chunk-DKVIN43T.js";
47
+ import "./chunk-H2FDGPVW.js";
48
+ import {
49
+ buildBrief,
50
+ describePlan,
51
+ planFor,
52
+ runTraces,
53
+ traceableFiles
54
+ } from "./chunk-NNTIACT4.js";
55
+ import {
56
+ defaultGitRunner
57
+ } from "./chunk-IW2KBAVZ.js";
58
+ import {
59
+ InjectionLog,
60
+ memoryNote
61
+ } from "./chunk-TOPZL5SU.js";
62
+ import "./chunk-2SVAHH5N.js";
63
+ import {
64
+ Telemetry,
65
+ clearPerfMarks,
66
+ sampleMemory,
67
+ setTelemetry,
68
+ stripThinking,
69
+ telemetry,
70
+ writeHeapSnapshot
71
+ } from "./chunk-YILDXPSI.js";
72
+ import {
73
+ briefStatus
74
+ } from "./chunk-DTWKSZXY.js";
75
+ import {
76
+ discoverTraceRoot,
77
+ setTraceRoot
78
+ } from "./chunk-FFYBY2NA.js";
79
+ import {
80
+ buildProjectGraph,
81
+ graphStatus,
82
+ graphifyPython
83
+ } from "./chunk-PGOYDOI4.js";
84
+ import {
85
+ sessionBase
86
+ } from "./chunk-SSDLHWSF.js";
87
+ import "./chunk-B67BK5GQ.js";
88
+
89
+ // src/cli.ts
90
+ import { execFileSync } from "child_process";
91
+ import { readFileSync as readFileSync3, existsSync as existsSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, realpathSync } from "fs";
92
+ import { join as join8, dirname as dirname4 } from "path";
93
+ import { pathToFileURL } from "url";
94
+
95
+ // src/config/config.ts
96
+ import { z } from "zod";
97
+ var UNSET_MODEL = "default";
98
+ var DEFAULT_CONFIG = {
99
+ baseUrl: "http://localhost:20128",
100
+ model: UNSET_MODEL,
101
+ // acceptEdits: auto-approve file writes/edits (the pipeline builds in an isolated worktree → reviewed as a
102
+ // PR), still prompt for shell/exec. Keeps the automated build flowing without an approval per file.
103
+ mode: "acceptEdits",
104
+ allowlist: [],
105
+ roles: {},
106
+ specKit: { version: "v0.13.2" },
107
+ mcp: {},
108
+ modelSources: [],
109
+ traceDir: "",
110
+ skillSources: [],
111
+ maxParallel: 8,
112
+ telemetry: true
113
+ };
114
+ var reviewerSchema = z.object({ name: z.string(), perspective: z.string(), models: z.array(z.string()) });
115
+ var fileSchema = z.object({
116
+ apiKey: z.string().optional(),
117
+ baseUrl: z.string().optional(),
118
+ model: z.string().optional(),
119
+ mode: z.enum(["ask", "acceptEdits", "auto"]).optional(),
120
+ allowlist: z.array(z.string()).optional(),
121
+ roles: z.record(
122
+ z.string(),
123
+ z.object({
124
+ models: z.array(z.string()),
125
+ systemPrompt: z.string().optional(),
126
+ skills: z.array(z.string()).optional(),
127
+ // Anthropic models only — see RoleConfig.effort. Unknown keys are stripped, so without this line a
128
+ // level written into the config would be silently discarded on the way in.
129
+ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional()
130
+ })
131
+ ).optional(),
132
+ // The review team's finder lenses, one set per stage (any omitted set falls back to the built-in default).
133
+ team: z.object({
134
+ spec: z.array(reviewerSchema).optional(),
135
+ plan: z.array(reviewerSchema).optional(),
136
+ code: z.array(reviewerSchema).optional()
137
+ }).optional(),
138
+ council: z.object({ members: z.array(reviewerSchema) }).optional(),
139
+ specKit: z.object({ version: z.string() }).optional(),
140
+ modelSources: z.array(z.string()).optional(),
141
+ traceDir: z.string().optional(),
142
+ // where /graph trace writes; empty = .horsecode/traces
143
+ mainBranch: z.string().optional(),
144
+ // the branch a resumed session syncs from; asked once, then remembered
145
+ // Bounded: below 1 nothing runs; above 32 the git merge lock, not the models, becomes the limit.
146
+ maxParallel: z.number().int().min(1).max(32).optional(),
147
+ telemetry: z.boolean().optional(),
148
+ skillSources: z.array(z.object({
149
+ name: z.string(),
150
+ repo: z.string(),
151
+ path: z.string().optional(),
152
+ ref: z.string().optional()
153
+ })).optional(),
154
+ mcp: z.record(
155
+ z.string(),
156
+ z.union([
157
+ z.object({ command: z.array(z.string()).min(1), env: z.record(z.string(), z.string()).optional(), readOnly: z.boolean().optional() }),
158
+ z.object({ url: z.string(), headers: z.record(z.string(), z.string()).optional(), readOnly: z.boolean().optional() })
159
+ ])
160
+ ).optional()
161
+ }).partial();
162
+ function parseFile(raw) {
163
+ if (!raw) return {};
164
+ try {
165
+ const parsed = fileSchema.safeParse(JSON.parse(raw));
166
+ return parsed.success ? parsed.data : {};
167
+ } catch {
168
+ return {};
169
+ }
170
+ }
171
+ function loadConfig(opts) {
172
+ const global = parseFile(opts.readFile(`${opts.home}/.horsecode/config.json`));
173
+ const project = parseFile(opts.readFile(`${opts.cwd}/.horsecode/config.json`));
174
+ const { apiKey: _leak, ...projectSafe } = project;
175
+ const merged = {
176
+ ...DEFAULT_CONFIG,
177
+ ...global,
178
+ ...projectSafe
179
+ };
180
+ merged.allowlist = projectSafe.allowlist ?? global.allowlist ?? [];
181
+ merged.roles = { ...global.roles ?? {}, ...projectSafe.roles ?? {} };
182
+ merged.mcp = { ...global.mcp ?? {}, ...projectSafe.mcp ?? {} };
183
+ merged.modelSources = projectSafe.modelSources ?? global.modelSources ?? [];
184
+ merged.maxParallel = projectSafe.maxParallel ?? global.maxParallel ?? DEFAULT_CONFIG.maxParallel;
185
+ merged.telemetry = projectSafe.telemetry ?? global.telemetry ?? DEFAULT_CONFIG.telemetry;
186
+ const byName = new Map((global.skillSources ?? []).map((s) => [s.name, s]));
187
+ for (const s of projectSafe.skillSources ?? []) byName.set(s.name, s);
188
+ merged.skillSources = [...byName.values()];
189
+ merged.specKit = projectSafe.specKit ?? global.specKit ?? DEFAULT_CONFIG.specKit;
190
+ const team = {
191
+ spec: projectSafe.team?.spec ?? global.team?.spec,
192
+ plan: projectSafe.team?.plan ?? global.team?.plan,
193
+ code: projectSafe.team?.code ?? global.team?.code
194
+ };
195
+ merged.team = team.spec || team.plan || team.code ? team : void 0;
196
+ const councilMembers = projectSafe.council?.members ?? global.council?.members;
197
+ merged.council = councilMembers ? { members: councilMembers } : void 0;
198
+ if (opts.env.OMNIROUTE_API_KEY) merged.apiKey = opts.env.OMNIROUTE_API_KEY;
199
+ if (opts.env.OMNIROUTE_BASE_URL) merged.baseUrl = opts.env.OMNIROUTE_BASE_URL;
200
+ return merged;
201
+ }
202
+
203
+ // src/skills/registry.ts
204
+ import { readdir, readFile } from "fs/promises";
205
+ import { join } from "path";
206
+ var SkillRegistry = class {
207
+ skills = /* @__PURE__ */ new Map();
208
+ register(skill) {
209
+ this.skills.set(skill.name, skill);
210
+ }
211
+ get(name) {
212
+ return this.skills.get(name);
213
+ }
214
+ list() {
215
+ return [...this.skills.values()].map((s) => ({ name: s.name, description: s.description }));
216
+ }
217
+ async loadFromDir(dir) {
218
+ let entries;
219
+ try {
220
+ entries = await readdir(dir, { withFileTypes: true });
221
+ } catch {
222
+ return;
223
+ }
224
+ for (const e of entries) {
225
+ if (!e.isDirectory()) continue;
226
+ let raw;
227
+ try {
228
+ raw = await readFile(join(dir, e.name, "SKILL.md"), "utf8");
229
+ } catch {
230
+ continue;
231
+ }
232
+ const { name, description, body } = parseFrontmatter(raw);
233
+ if (!name || !description) {
234
+ throw new Error(`skill ${e.name}: missing frontmatter (name/description)`);
235
+ }
236
+ this.register({ name, description, content: body, dir: join(dir, e.name) });
237
+ }
238
+ }
239
+ };
240
+
241
+ // src/skills/builtin.ts
242
+ import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
243
+ import { dirname, join as join2, resolve } from "path";
244
+ import { fileURLToPath } from "url";
245
+ function candidates() {
246
+ const here = dirname(fileURLToPath(import.meta.url));
247
+ return [
248
+ resolve(here, "..", "skills"),
249
+ // bundled: dist/… → <repo>/skills
250
+ resolve(here, "..", "..", "skills"),
251
+ // source: src/skills/… → <repo>/skills
252
+ resolve(here, "..", "..", "..", "skills")
253
+ ];
254
+ }
255
+ async function registerBuiltinSkills(registry, dirs = candidates()) {
256
+ for (const dir of dirs) {
257
+ try {
258
+ const entries = await readdir2(dir, { withFileTypes: true });
259
+ const found = [];
260
+ for (const e of entries) {
261
+ if (!e.isDirectory()) continue;
262
+ let raw;
263
+ try {
264
+ raw = await readFile2(join2(dir, e.name, "SKILL.md"), "utf8");
265
+ } catch {
266
+ continue;
267
+ }
268
+ const { parseFrontmatter: parseFrontmatter2 } = await import("./frontmatter-UNIPNLLO.js");
269
+ const { name, description, body } = parseFrontmatter2(raw);
270
+ if (!name || !description) continue;
271
+ found.push({ name, description, content: body, dir: join2(dir, e.name) });
272
+ }
273
+ if (!found.length) continue;
274
+ for (const s of found) registry.register(s);
275
+ return found.length;
276
+ } catch {
277
+ continue;
278
+ }
279
+ }
280
+ return 0;
281
+ }
282
+
283
+ // src/skills/external.ts
284
+ import { mkdir, rm, readFile as readFile3, writeFile, readdir as readdir3, cp } from "fs/promises";
285
+ import { existsSync } from "fs";
286
+ import { join as join3 } from "path";
287
+ import { spawn } from "child_process";
288
+ import { tmpdir } from "os";
289
+ var PROVENANCE = ".horsecode-source.json";
290
+ function externalSkillsDir(home) {
291
+ return join3(home, ".horsecode", "skills");
292
+ }
293
+ function validName(name) {
294
+ return /^[A-Za-z0-9._-]+$/.test(name) && !name.includes("..");
295
+ }
296
+ function validRepo(repo) {
297
+ return /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo);
298
+ }
299
+ function parseSkillUrl(url) {
300
+ const m = url.trim().replace(/\/+$/, "").match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.*))?)?$/i);
301
+ if (!m) return void 0;
302
+ const [, owner, repoName, ref, rawPath] = m;
303
+ const repo = `${owner}/${repoName.replace(/\.git$/, "")}`;
304
+ const path = (rawPath ?? "").replace(/\/?SKILL\.md$/i, "");
305
+ const name = path ? path.split("/").filter(Boolean).pop() : repoName.replace(/\.git$/, "");
306
+ return {
307
+ name,
308
+ repo,
309
+ ...path ? { path } : {},
310
+ // A branch name in a tree URL is where the user was browsing, not necessarily a pin they want. Only keep
311
+ // it when it is not the obvious default, so "update" keeps following the branch.
312
+ ...ref && ref !== "main" && ref !== "master" && ref !== "HEAD" ? { ref } : {}
313
+ };
314
+ }
315
+ function run(cmd, args, cwd) {
316
+ return new Promise((resolve2) => {
317
+ const child = spawn(cmd, args, { ...cwd ? { cwd } : {}, stdio: ["ignore", "ignore", "pipe"] });
318
+ let err = "";
319
+ child.stderr?.on("data", (d) => err += d.toString());
320
+ child.on("error", (e) => resolve2({ code: 1, err: e.message }));
321
+ child.on("close", (code) => resolve2({ code: code ?? 1, err }));
322
+ });
323
+ }
324
+ async function resolveSha(src, deps) {
325
+ const f = deps.fetch ?? globalThis.fetch;
326
+ const ref = src.ref ?? "HEAD";
327
+ const res = await f(`https://api.github.com/repos/${src.repo}/commits/${ref}`, {
328
+ headers: { Accept: "application/vnd.github.sha" }
329
+ });
330
+ if (!res.ok) throw new Error(`cannot resolve ${src.repo}@${ref} (HTTP ${res.status})`);
331
+ return (await res.text()).trim();
332
+ }
333
+ async function installedSource(home, name) {
334
+ try {
335
+ return JSON.parse(await readFile3(join3(externalSkillsDir(home), name, PROVENANCE), "utf8"));
336
+ } catch {
337
+ return void 0;
338
+ }
339
+ }
340
+ async function installSkillSource(home, src, deps = {}) {
341
+ if (!validName(src.name)) throw new Error(`skill source name is not a valid directory name: ${src.name}`);
342
+ if (!validRepo(src.repo)) throw new Error(`skill source repo must be "owner/repo": ${src.repo}`);
343
+ if (src.path?.includes("..")) throw new Error(`skill source path may not contain "..": ${src.path}`);
344
+ const sha = await resolveSha(src, deps);
345
+ const dest = join3(externalSkillsDir(home), src.name);
346
+ const current = await installedSource(home, src.name);
347
+ if (current?.sha === sha && existsSync(join3(dest, "SKILL.md"))) {
348
+ return { name: src.name, sha, changed: false };
349
+ }
350
+ const staging = join3(tmpdir(), `hc-skill-${src.name}-${sha.slice(0, 8)}`);
351
+ await rm(staging, { recursive: true, force: true });
352
+ await mkdir(staging, { recursive: true });
353
+ try {
354
+ const url = `https://codeload.github.com/${src.repo}/tar.gz/${sha}`;
355
+ const dl = await run("sh", ["-c", `curl -fsSL --max-time 120 ${JSON.stringify(url)} | tar xz -C ${JSON.stringify(staging)} --strip-components=1`]);
356
+ if (dl.code !== 0) throw new Error(`download failed for ${src.repo}@${sha.slice(0, 8)}: ${dl.err.trim().slice(0, 200)}`);
357
+ const from = src.path ? join3(staging, src.path) : staging;
358
+ if (!existsSync(join3(from, "SKILL.md"))) {
359
+ throw new Error(`${src.repo}${src.path ? `/${src.path}` : ""} has no SKILL.md`);
360
+ }
361
+ await rm(dest, { recursive: true, force: true });
362
+ await mkdir(dest, { recursive: true });
363
+ await cp(from, dest, { recursive: true });
364
+ const record = { ...src, sha, installedAt: (deps.now ?? Date.now)() };
365
+ await writeFile(join3(dest, PROVENANCE), `${JSON.stringify(record, null, 2)}
366
+ `, "utf8");
367
+ return { name: src.name, sha, changed: true };
368
+ } finally {
369
+ await rm(staging, { recursive: true, force: true });
370
+ }
371
+ }
372
+ async function syncSkillSources(home, sources, deps = {}) {
373
+ const ok = [];
374
+ const failed = [];
375
+ for (const src of sources) {
376
+ try {
377
+ ok.push(await installSkillSource(home, src, deps));
378
+ } catch (e) {
379
+ failed.push({ name: src.name, error: e instanceof Error ? e.message : String(e) });
380
+ }
381
+ }
382
+ return { ok, failed };
383
+ }
384
+
385
+ // src/permission/rules.ts
386
+ import picomatch from "picomatch";
387
+ var DANGEROUS_PATTERNS = [
388
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b.*\s\/(\*|\s|$)/,
389
+ // rm -rf / or /*
390
+ /\brm\s+-rf\s+\/(\*|$)/,
391
+ /:\(\)\s*\{.*\|.*&.*\}\s*;/,
392
+ // fork bomb :(){ :|:& };:
393
+ /\bmkfs\b/,
394
+ /\bdd\s+.*of=\/dev\/(sd|hd|nvme)/,
395
+ /\b(sudo\s+)?chmod\s+-R\s+000\s+\//,
396
+ /\s>\s*\/dev\/sd[a-z]/
397
+ ];
398
+ function isDangerous(command) {
399
+ return DANGEROUS_PATTERNS.some((re) => re.test(command));
400
+ }
401
+ var READ_ONLY_BINS = /* @__PURE__ */ new Set([
402
+ "pwd",
403
+ "ls",
404
+ "cat",
405
+ "head",
406
+ "tail",
407
+ "wc",
408
+ "file",
409
+ "stat",
410
+ "du",
411
+ "df",
412
+ "tree",
413
+ "find",
414
+ "grep",
415
+ "egrep",
416
+ "fgrep",
417
+ "rg",
418
+ "ag",
419
+ "echo",
420
+ "printf",
421
+ "sort",
422
+ "uniq",
423
+ "cut",
424
+ "tr",
425
+ "diff",
426
+ "cmp",
427
+ "basename",
428
+ "dirname",
429
+ "realpath",
430
+ "readlink",
431
+ "date",
432
+ "whoami",
433
+ "hostname",
434
+ "uname",
435
+ "which",
436
+ "type",
437
+ "env",
438
+ "printenv",
439
+ "true",
440
+ "false",
441
+ "seq",
442
+ "yes",
443
+ "column",
444
+ "jq",
445
+ "xxd",
446
+ "od",
447
+ "md5",
448
+ "shasum",
449
+ "sha256sum",
450
+ "nl",
451
+ "expr",
452
+ "test"
453
+ ]);
454
+ var READ_ONLY_GIT = /* @__PURE__ */ new Set([
455
+ "status",
456
+ "log",
457
+ "diff",
458
+ "show",
459
+ "branch",
460
+ "ls-tree",
461
+ "ls-files",
462
+ "rev-parse",
463
+ "rev-list",
464
+ "describe",
465
+ "blame",
466
+ "cat-file",
467
+ "shortlog",
468
+ "reflog",
469
+ "tag",
470
+ "remote",
471
+ "worktree",
472
+ "grep",
473
+ "whatchanged",
474
+ "count-objects"
475
+ ]);
476
+ var READ_ONLY_PAIRS = /* @__PURE__ */ new Set([
477
+ "npm ls",
478
+ "npm list",
479
+ "npm view",
480
+ "npm outdated",
481
+ "pnpm ls",
482
+ "pnpm list",
483
+ "yarn list",
484
+ "node --version",
485
+ "npm --version",
486
+ "python --version",
487
+ "python3 --version",
488
+ "go version",
489
+ "cargo --version",
490
+ "tsc --version"
491
+ ]);
492
+ var WRITE_FLAGS = /(^|\s)(-i|--in-place|-i\.\w+|--output|-o)(\s|=|$)/;
493
+ var REDIRECT = /(^|[^0-9<>])>{1,2}[^&]|(^|\s)(tee|dd|sudo|doas)(\s|$)/;
494
+ function maskQuoted(cmd) {
495
+ let out = "";
496
+ let quote;
497
+ for (const ch of cmd) {
498
+ if (quote) {
499
+ out += ch === quote ? ch : "x";
500
+ if (ch === quote) quote = void 0;
501
+ continue;
502
+ }
503
+ if (ch === "'" || ch === '"') {
504
+ quote = ch;
505
+ out += ch;
506
+ continue;
507
+ }
508
+ out += ch;
509
+ }
510
+ return quote ? void 0 : out;
511
+ }
512
+ function splitSegments(cmd, masked) {
513
+ const cuts = [];
514
+ for (let i = 0; i < masked.length; i++) {
515
+ const two = masked.slice(i, i + 2);
516
+ if (two === "&&" || two === "||") {
517
+ cuts.push({ at: i, len: 2 });
518
+ i++;
519
+ continue;
520
+ }
521
+ if (masked[i] === "|" || masked[i] === ";") cuts.push({ at: i, len: 1 });
522
+ }
523
+ const out = [];
524
+ let from = 0;
525
+ for (const c of cuts) {
526
+ out.push(cmd.slice(from, c.at));
527
+ from = c.at + c.len;
528
+ }
529
+ out.push(cmd.slice(from));
530
+ return out;
531
+ }
532
+ function segmentIsReadOnly(seg) {
533
+ const s = seg.trim();
534
+ if (!s) return false;
535
+ if (WRITE_FLAGS.test(s)) return false;
536
+ const words = s.split(/\s+/);
537
+ const bin = words[0].replace(/^.*\//, "");
538
+ if (bin === "git") {
539
+ const sub = words.slice(1).find((w) => !w.startsWith("-"));
540
+ if (sub === "worktree") return words.includes("list");
541
+ if (sub === "remote") return words.length === 2 || words.includes("-v") || words.includes("show");
542
+ if (sub === "tag") return words.includes("-l") || words.includes("--list") || words.length === 2;
543
+ return sub !== void 0 && READ_ONLY_GIT.has(sub);
544
+ }
545
+ if (bin === "sed") return words.includes("-n");
546
+ if (bin === "awk") return true;
547
+ if (READ_ONLY_PAIRS.has(`${bin} ${words[1] ?? ""}`)) return true;
548
+ return READ_ONLY_BINS.has(bin);
549
+ }
550
+ function isReadOnly(command) {
551
+ const c = command.trim();
552
+ if (!c) return false;
553
+ const masked = maskQuoted(c);
554
+ if (masked === void 0) return false;
555
+ if (/\$\(|`|\$\{|<\(/.test(masked)) return false;
556
+ if (REDIRECT.test(masked)) return false;
557
+ const segments = splitSegments(c, masked);
558
+ return segments.length > 0 && segments.every(segmentIsReadOnly);
559
+ }
560
+ var SHELL_METACHARACTERS = /[;&|`\n<>${}()]/;
561
+ function matchesAllowlist(allowKey, rules, kind) {
562
+ if (kind === "prefix" && SHELL_METACHARACTERS.test(allowKey)) return false;
563
+ for (const rule of rules) {
564
+ if (kind === "glob") {
565
+ if (picomatch(rule)(allowKey)) return true;
566
+ } else {
567
+ if (allowKey === rule || allowKey.startsWith(rule + " ")) return true;
568
+ }
569
+ }
570
+ return false;
571
+ }
572
+
573
+ // src/permission/engine.ts
574
+ var PermissionEngine = class {
575
+ _mode;
576
+ allowlist;
577
+ constructor(opts) {
578
+ this._mode = opts.mode;
579
+ this.allowlist = [...opts.allowlist];
580
+ }
581
+ get mode() {
582
+ return this._mode;
583
+ }
584
+ setMode(m) {
585
+ this._mode = m;
586
+ }
587
+ addAllow(rule) {
588
+ if (!this.allowlist.includes(rule)) this.allowlist.push(rule);
589
+ }
590
+ check(req) {
591
+ if (req.level === "safe") return "allow";
592
+ const isExec = req.level === "exec";
593
+ const dangerous = isExec && isDangerous(req.allowKey);
594
+ if (isExec && !dangerous && isReadOnly(req.allowKey)) return "allow";
595
+ const kind = req.level === "write" ? "glob" : "prefix";
596
+ if (!dangerous && matchesAllowlist(req.allowKey, this.allowlist, kind)) {
597
+ return "allow";
598
+ }
599
+ switch (this._mode) {
600
+ case "ask":
601
+ return "ask";
602
+ case "acceptEdits":
603
+ return req.level === "write" ? "allow" : "ask";
604
+ case "auto":
605
+ return dangerous ? "ask" : "allow";
606
+ }
607
+ }
608
+ };
609
+
610
+ // src/engine/memory-proposals.ts
611
+ var MAX_PROPOSALS = 60;
612
+ var MAX_PROPOSAL_CHARS = 400;
613
+ function key(text) {
614
+ return text.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim();
615
+ }
616
+ var ProposalQueue = class {
617
+ items = [];
618
+ seen = /* @__PURE__ */ new Set();
619
+ dropped = 0;
620
+ /** Returns false when the proposal was rejected outright (empty, duplicate, or past the cap). */
621
+ add(text, kind, proposedBy) {
622
+ const t = text.trim().slice(0, MAX_PROPOSAL_CHARS);
623
+ if (!t) return false;
624
+ const k = key(t);
625
+ if (this.seen.has(k)) return false;
626
+ if (this.items.length >= MAX_PROPOSALS) {
627
+ this.dropped++;
628
+ return false;
629
+ }
630
+ this.seen.add(k);
631
+ this.items.push({ text: t, kind, proposedBy });
632
+ return true;
633
+ }
634
+ list() {
635
+ return [...this.items];
636
+ }
637
+ /** How many proposals were refused because the queue was already full (reported, never silent). */
638
+ overflow() {
639
+ return this.dropped;
640
+ }
641
+ size() {
642
+ return this.items.length;
643
+ }
644
+ /** Empties the queue — called once the curator has ruled on its contents. */
645
+ drain() {
646
+ const out = this.list();
647
+ this.items.length = 0;
648
+ this.seen.clear();
649
+ this.dropped = 0;
650
+ return out;
651
+ }
652
+ };
653
+
654
+ // src/speckit/templates.ts
655
+ import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
656
+ import { join as join4, dirname as dirname2 } from "path";
657
+ var TEMPLATE_FILES = {
658
+ spec: "spec-template.md",
659
+ plan: "plan-template.md",
660
+ tasks: "tasks-template.md",
661
+ constitution: "constitution-template.md",
662
+ checklist: "checklist-template.md"
663
+ };
664
+ var COMMAND_FILES = {
665
+ constitution: "commands/constitution.md",
666
+ specify: "commands/specify.md",
667
+ clarify: "commands/clarify.md",
668
+ plan: "commands/plan.md",
669
+ tasks: "commands/tasks.md"
670
+ };
671
+ var RAW_BASE = "https://raw.githubusercontent.com/github/spec-kit";
672
+ async function loadSpecKit(opts) {
673
+ if (!/^[A-Za-z0-9._-]+$/.test(opts.version) || opts.version.includes("..")) {
674
+ throw new Error(
675
+ `spec-kit version "${opts.version}" is not a valid spec-kit release tag (expected only letters, digits, dots, underscores, and hyphens, with no ".." segment).`
676
+ );
677
+ }
678
+ const fetchFn = opts.fetch ?? globalThis.fetch;
679
+ const cacheDir = join4(opts.home, ".horsecode", "spec-kit", opts.version, "templates");
680
+ const get = async (relPath) => {
681
+ const cachePath = join4(cacheDir, relPath);
682
+ if (existsSync2(cachePath)) return readFileSync(cachePath, "utf8");
683
+ const url = `${RAW_BASE}/${opts.version}/templates/${relPath}`;
684
+ const res = await fetchFn(url);
685
+ if (!res.ok) {
686
+ throw new Error(
687
+ `spec-kit template fetch failed (${res.status}): ${url}
688
+ Check your network or set specKit.version to a valid tag.`
689
+ );
690
+ }
691
+ const text = await res.text();
692
+ mkdirSync(dirname2(cachePath), { recursive: true });
693
+ writeFileSync(cachePath, text, "utf8");
694
+ return text;
695
+ };
696
+ const templates = {};
697
+ for (const [name, file] of Object.entries(TEMPLATE_FILES)) templates[name] = await get(file);
698
+ const commands = {};
699
+ for (const [name, file] of Object.entries(COMMAND_FILES)) commands[name] = await get(file);
700
+ return {
701
+ version: opts.version,
702
+ template: (name) => templates[name],
703
+ command: (name) => commands[name]
704
+ };
705
+ }
706
+
707
+ // src/wiring.ts
708
+ import { homedir } from "os";
709
+ import { join as join5 } from "path";
710
+ async function buildJobDeps(opts) {
711
+ const { config } = opts;
712
+ const roles = {};
713
+ const sessionChain = config.model === UNSET_MODEL ? [] : [config.model];
714
+ for (const name of REQUIRED_ROLES) {
715
+ const cfg = config.roles[name];
716
+ const declared = cfg?.skills;
717
+ const skills = (declared ?? DEFAULT_ROLE_SKILLS[name] ?? []).filter((s) => opts.skillRegistry.get(s));
718
+ roles[name] = { ...cfg ?? { models: sessionChain }, ...skills.length ? { skills } : { skills: [] } };
719
+ }
720
+ const roleRegistry = new RoleRegistry(roles, DEFAULT_PROMPTS, opts.skillRegistry);
721
+ const fillModels = (r) => ({
722
+ ...r,
723
+ models: r.models.length > 0 ? r.models : config.roles[r.name]?.models ?? sessionChain
724
+ });
725
+ const teams = {
726
+ spec: (config.team?.spec ?? SPEC_TEAM).map(fillModels),
727
+ plan: (config.team?.plan ?? PLAN_TEAM).map(fillModels),
728
+ code: (config.team?.code ?? CODE_TEAM).map(fillModels)
729
+ };
730
+ const teamRegistries = {
731
+ spec: buildTeamRegistry("spec", teams.spec),
732
+ plan: buildTeamRegistry("plan", teams.plan),
733
+ code: buildTeamRegistry("code", teams.code)
734
+ };
735
+ const council = (config.council?.members ?? DEFAULT_COUNCIL).map(fillModels);
736
+ const councilRegistry = buildCouncilRegistry(council);
737
+ if (opts.rules) {
738
+ roleRegistry.setRules(opts.rules);
739
+ for (const s of ["spec", "plan", "code"]) teamRegistries[s].setRules(opts.rules);
740
+ councilRegistry.setRules(opts.rules);
741
+ }
742
+ const permission = new PermissionEngine({ mode: config.mode, allowlist: config.allowlist });
743
+ const queue = new ProposalQueue();
744
+ let kitPromise;
745
+ const specKit = () => kitPromise ??= loadSpecKit({ version: config.specKit.version, home: opts.home, fetch: opts.fetch });
746
+ const fitness = new RoleFitness(join5(opts.home ?? homedir(), ".horsecode", "model-fitness.json"));
747
+ roleRegistry.setFitness(fitness);
748
+ return {
749
+ provider: opts.provider,
750
+ roleRegistry,
751
+ fitness,
752
+ skillRegistry: opts.skillRegistry,
753
+ permission,
754
+ approve: opts.approve,
755
+ signal: opts.signal,
756
+ home: opts.home,
757
+ specKit,
758
+ // One injection log per session: shared by the coach and every role so a memory shown to one agent is
759
+ // not immediately re-shown to the next.
760
+ injectionLog: new InjectionLog(),
761
+ // One proposal queue per session: review agents propose into it, the curator drains it when a job ends.
762
+ proposals: queue,
763
+ proposeMemory: (text, kind, role) => queue.add(text, kind, role),
764
+ teams,
765
+ teamRegistries,
766
+ councilRegistry,
767
+ council,
768
+ manager: opts.manager,
769
+ prAdapter: opts.prAdapter,
770
+ // One per job: every stage records into it, and the run reports the breakdown when the waves finish.
771
+ timings: new Timings(),
772
+ rounds: 3,
773
+ maxParallel: opts.config.maxParallel ?? MAX_PARALLEL_TASKS,
774
+ askHuman: opts.askHuman
775
+ };
776
+ }
777
+
778
+ // src/init.ts
779
+ var DEFAULT_BASE_URL = "http://localhost:20128";
780
+ var DEFAULT_MODEL = "auto/best-coding";
781
+ function parseExisting(raw) {
782
+ if (!raw) return {};
783
+ try {
784
+ const v = JSON.parse(raw);
785
+ return v && typeof v === "object" ? v : {};
786
+ } catch {
787
+ return {};
788
+ }
789
+ }
790
+ async function runInit(io) {
791
+ const path = `${io.home}/.horsecode/config.json`;
792
+ const existing = parseExisting(io.readFile(path));
793
+ const baseUrl = (await io.read(`omniroute baseUrl [${DEFAULT_BASE_URL}]: `)).trim() || DEFAULT_BASE_URL;
794
+ const apiKey = (await io.read("omniroute apiKey (empty=none): ")).trim();
795
+ const config = {
796
+ ...existing,
797
+ baseUrl,
798
+ model: existing.model ?? DEFAULT_MODEL
799
+ };
800
+ if (apiKey) config.apiKey = apiKey;
801
+ else delete config.apiKey;
802
+ io.writeFile(path, JSON.stringify(config, null, 2) + "\n");
803
+ io.log(`config written: ${path} (apiKey: ${apiKey ? "set" : "none"})`);
804
+ }
805
+
806
+ // src/providers/telemetry.ts
807
+ function telemetryProvider(inner, tel) {
808
+ return {
809
+ chat(req, signal) {
810
+ return stream(inner, tel, req, signal);
811
+ }
812
+ };
813
+ }
814
+ async function* stream(inner, tel, req, signal) {
815
+ const started = Date.now();
816
+ const callId = `${started.toString(36)}-${Math.round(Math.random() * 1e6).toString(36)}`;
817
+ tel.event("gen_ai.chat.start", {
818
+ "gen_ai.request.model": req.model,
819
+ "hc.call_id": callId,
820
+ "hc.messages": req.messages.length,
821
+ "hc.prompt_chars": req.messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0)
822
+ });
823
+ let promptTokens;
824
+ let completionTokens;
825
+ let cachedTokens;
826
+ let finish;
827
+ let failure;
828
+ let toolCalls = 0;
829
+ let textChars = 0;
830
+ try {
831
+ for await (const ev of inner.chat(req, signal)) {
832
+ if (ev.type === "usage") {
833
+ promptTokens = ev.promptTokens;
834
+ completionTokens = ev.completionTokens;
835
+ cachedTokens = ev.cachedTokens;
836
+ } else if (ev.type === "tool-call") toolCalls++;
837
+ else if (ev.type === "text-delta") textChars += ev.text.length;
838
+ else if (ev.type === "done") finish = ev.finishReason;
839
+ else if (ev.type === "error") failure = ev.message;
840
+ yield ev;
841
+ }
842
+ } catch (e) {
843
+ failure = e instanceof Error ? e.message : String(e);
844
+ throw e;
845
+ } finally {
846
+ tel.event("gen_ai.chat", {
847
+ "gen_ai.system": "omniroute",
848
+ "hc.call_id": callId,
849
+ "gen_ai.operation.name": "chat",
850
+ "gen_ai.request.model": req.model,
851
+ "gen_ai.usage.input_tokens": promptTokens,
852
+ "gen_ai.usage.output_tokens": completionTokens,
853
+ "gen_ai.usage.cached_tokens": cachedTokens,
854
+ "gen_ai.response.finish_reason": finish,
855
+ "hc.duration_ms": Date.now() - started,
856
+ "hc.tools_requested": toolCalls,
857
+ "hc.text_chars": textChars,
858
+ "hc.tools_offered": req.tools?.length ?? 0,
859
+ "hc.messages": req.messages.length,
860
+ ...failure ? { "hc.error": failure.slice(0, 300) } : {},
861
+ "hc.status": failure ? "error" : "ok"
862
+ });
863
+ }
864
+ }
865
+
866
+ // src/obs/redact.ts
867
+ var MASK = "\xABredacted\xBB";
868
+ var NAMED = /\b([A-Za-z0-9_]*(?:PASSWORD|PASSWD|PASS|SECRET|TOKEN|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)[A-Za-z0-9_]*)\s*[=:]\s*(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))/gi;
869
+ var FLAGGED = /(--(?:password|token|secret|api-key|apikey|access-key)(?:[=\s]+))(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))/gi;
870
+ var IN_URL = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):([^\s@/]+)@/gi;
871
+ var BEARER = /\b(Bearer|Basic)\s+([A-Za-z0-9._~+/=-]{8,})/g;
872
+ var VENDOR = /\b(sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,})\b/g;
873
+ function redact(text) {
874
+ return text.replace(NAMED, (_m, name) => `${name}=${MASK}`).replace(FLAGGED, (_m, flag) => `${flag}${MASK}`).replace(IN_URL, (_m, head) => `${head}:${MASK}@`).replace(BEARER, (_m, scheme) => `${scheme} ${MASK}`).replace(VENDOR, MASK);
875
+ }
876
+ function redactRecord(record) {
877
+ if (typeof record === "string") return redact(record);
878
+ if (Array.isArray(record)) return record.map((v) => redactRecord(v));
879
+ if (record && typeof record === "object") {
880
+ const out = {};
881
+ for (const [k, v] of Object.entries(record)) out[k] = redactRecord(v);
882
+ return out;
883
+ }
884
+ return record;
885
+ }
886
+
887
+ // src/obs/sink.ts
888
+ import { createWriteStream, mkdirSync as mkdirSync2 } from "fs";
889
+ import { join as join6 } from "path";
890
+ function telemetryDir(home) {
891
+ return join6(home, ".horsecode", "telemetry");
892
+ }
893
+ var MAX_QUEUE = 1e4;
894
+ var FileSink = class {
895
+ stream;
896
+ queued = 0;
897
+ dropped = 0;
898
+ path;
899
+ constructor(home, runId) {
900
+ this.path = join6(telemetryDir(home), `${runId}.jsonl`);
901
+ try {
902
+ mkdirSync2(telemetryDir(home), { recursive: true });
903
+ this.stream = createWriteStream(this.path, { flags: "a" });
904
+ this.stream.on("error", () => {
905
+ this.stream = void 0;
906
+ });
907
+ } catch {
908
+ this.stream = void 0;
909
+ }
910
+ }
911
+ /** Redacted here: one sink, and dozens of places that write to it. See src/obs/redact.ts. */
912
+ write(record) {
913
+ record = redactRecord(record);
914
+ if (!this.stream) return;
915
+ if (this.queued >= MAX_QUEUE) {
916
+ this.dropped++;
917
+ return;
918
+ }
919
+ this.queued++;
920
+ try {
921
+ this.stream.write(`${JSON.stringify(record)}
922
+ `, () => {
923
+ this.queued--;
924
+ });
925
+ } catch {
926
+ this.queued--;
927
+ }
928
+ }
929
+ /** How many records were dropped because the queue was full — reported rather than hidden. */
930
+ get lost() {
931
+ return this.dropped;
932
+ }
933
+ /**
934
+ * A stream that failed to open never calls `end`'s callback, and this awaited it forever.
935
+ *
936
+ * `createWriteStream` does not throw when the path cannot be opened — it emits `error` on a later tick. So
937
+ * the constructor's `catch` does not run, `stream` is set, and whether the error handler has cleared it by
938
+ * the time anything flushes is a race with the event loop. Lose that race and `end(cb)` is called on a
939
+ * broken stream, the callback is never invoked, and the promise has nothing left to resolve it.
940
+ *
941
+ * The observer then outlives what it observes: `flush()` is awaited as a run finishes, so a machine that
942
+ * cannot write the log cannot finish the run either. The `error` listener is the second exit, and either
943
+ * one is enough.
944
+ */
945
+ async flush() {
946
+ const s = this.stream;
947
+ if (!s) return;
948
+ this.stream = void 0;
949
+ await new Promise((resolve2) => {
950
+ s.once("error", () => resolve2());
951
+ s.end(() => resolve2());
952
+ });
953
+ }
954
+ };
955
+
956
+ // src/cli-info.ts
957
+ import { readFileSync as readFileSync2 } from "fs";
958
+ import { dirname as dirname3, join as join7 } from "path";
959
+ import { fileURLToPath as fileURLToPath2 } from "url";
960
+ function hcodeVersion() {
961
+ try {
962
+ const here = dirname3(fileURLToPath2(import.meta.url));
963
+ const pkg = JSON.parse(readFileSync2(join7(here, "..", "package.json"), "utf8"));
964
+ return pkg.version ?? "unknown";
965
+ } catch {
966
+ return "unknown";
967
+ }
968
+ }
969
+ var FLAGS = [
970
+ "--branch",
971
+ "-b",
972
+ "--job",
973
+ "-j",
974
+ "--rounds",
975
+ "--revision-rounds",
976
+ "--no-tui",
977
+ "--help",
978
+ "-h",
979
+ "--version",
980
+ "-v"
981
+ ];
982
+ function usage() {
983
+ return [
984
+ `horse-code ${hcodeVersion()} \u2014 a terminal coding agent that works on its own branch.`,
985
+ "",
986
+ "USAGE",
987
+ " hcode start the REPL",
988
+ ' hcode "<request>" run one request to completion, then report where the work landed',
989
+ " hcode init write .horsecode/ for this project and ask for the essentials",
990
+ "",
991
+ "OPTIONS",
992
+ " -b, --branch <name> branch the session's worktree from this ref (default: current branch)",
993
+ " -j, --job <name> name the session, and the branch it creates",
994
+ " --rounds <n> review rounds per document stage",
995
+ " --revision-rounds <n> revision rounds per implementation task",
996
+ " --no-tui plain output, no terminal UI \u2014 for pipes, CI and logs",
997
+ " -h, --help this",
998
+ " -v, --version print the version and exit",
999
+ "",
1000
+ "Configuration is ~/.horsecode/config.json, overlaid by .horsecode/config.json in the project.",
1001
+ "Run `hcode init` if you have neither."
1002
+ ].join("\n");
1003
+ }
1004
+ function nearestFlag(flag) {
1005
+ const score = (a) => {
1006
+ const b = flag;
1007
+ let i = 0;
1008
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
1009
+ return i;
1010
+ };
1011
+ const best = [...FLAGS].filter((f) => f.startsWith("--") === flag.startsWith("--")).sort((a, b) => score(b) - score(a))[0];
1012
+ return best !== void 0 && score(best) > 2 ? best : void 0;
1013
+ }
1014
+
1015
+ // src/cli.ts
1016
+ var HEAP_MB = 12288;
1017
+ function parseArgs(argv) {
1018
+ let fromBranch;
1019
+ let jobName;
1020
+ let rounds;
1021
+ let revisionRounds;
1022
+ let noTui;
1023
+ let help;
1024
+ let version;
1025
+ let unknown;
1026
+ const rest = [];
1027
+ for (let i = 0; i < argv.length; i++) {
1028
+ const a = argv[i];
1029
+ if (a === "--branch" || a === "-b") fromBranch = argv[++i];
1030
+ else if (a === "--job" || a === "-j") jobName = argv[++i];
1031
+ else if (a === "--rounds") rounds = Number(argv[++i]);
1032
+ else if (a === "--revision-rounds") revisionRounds = Number(argv[++i]);
1033
+ else if (a === "--no-tui") noTui = true;
1034
+ else if (a === "--help" || a === "-h") help = true;
1035
+ else if (a === "--version" || a === "-v") version = true;
1036
+ else if (unknown === void 0 && a !== void 0 && /^-/.test(a)) unknown = a;
1037
+ else rest.push(a);
1038
+ }
1039
+ return {
1040
+ prompt: rest.join(" "),
1041
+ ...fromBranch !== void 0 && { fromBranch },
1042
+ ...jobName !== void 0 && { jobName },
1043
+ ...rounds !== void 0 && { rounds },
1044
+ ...revisionRounds !== void 0 && { revisionRounds },
1045
+ ...noTui !== void 0 && { noTui },
1046
+ ...help !== void 0 && { help },
1047
+ ...version !== void 0 && { version },
1048
+ ...unknown !== void 0 && { unknown }
1049
+ };
1050
+ }
1051
+ function whereItLanded(path) {
1052
+ const base = sessionBase(dirname4(path));
1053
+ if (base === void 0) {
1054
+ return "Written directly in your working tree: no branch, no worktree, nothing to merge. Review it and commit it when you are happy with it.";
1055
+ }
1056
+ let branch = "";
1057
+ try {
1058
+ branch = execFileSync(
1059
+ "git",
1060
+ ["rev-parse", "--abbrev-ref", "HEAD"],
1061
+ { cwd: base, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
1062
+ ).trim();
1063
+ } catch {
1064
+ }
1065
+ return `Written on ${branch ? `branch \`${branch}\`` : "its own branch"}, in the worktree at \`${base}\`. Review it there, then merge it in.`;
1066
+ }
1067
+ function describeDelivery(d) {
1068
+ if (d.mergedInto) {
1069
+ return `Merged into \`${d.mergedInto}\` \u2014 the files are in your working copy.`;
1070
+ }
1071
+ return [
1072
+ `**The work is on branch \`${d.branch}\`** \u2014 not in your working copy yet.`,
1073
+ d.notMerged ? `Not merged: ${d.notMerged}.` : "",
1074
+ "",
1075
+ "To bring it in:",
1076
+ "```",
1077
+ `git merge --no-ff ${d.branch}`,
1078
+ "```",
1079
+ `Or inspect it first: \`git diff HEAD...${d.branch}\` \xB7 worktree: \`${d.worktree}\``
1080
+ ].filter((l) => l !== void 0).join("\n");
1081
+ }
1082
+ function renderResult(res) {
1083
+ if (res.kind === "chat") return stripThinking(res.response);
1084
+ if (res.kind === "rejected") return `Not approved (stopped at the ${res.stage} stage).`;
1085
+ if (res.kind === "undone") return res.report;
1086
+ if (res.kind === "verified") return res.report;
1087
+ if (res.kind === "tweaked") return res.report;
1088
+ if (res.kind === "governed") {
1089
+ return res.written ? `**Constitution written** \u2014 \`${res.path}\`
1090
+
1091
+ _${whereItLanded(res.path)}_` : `The constitution phase finished without writing \`${res.path}\` \u2014 nothing was changed.`;
1092
+ }
1093
+ const outcome = res.wave.status === "completed" ? res.wave.pr ? `PR: ${res.wave.pr.url}` : "all tasks completed" : `Partial: ${res.wave.failed.length} failed, ${res.wave.skipped.length} skipped`;
1094
+ const rev = res.revision ? `
1095
+ revision: ${res.revision.status}` : "";
1096
+ return `${stripThinking(res.report)}
1097
+
1098
+ Status: ${res.wave.status} \u2014 ${outcome}${rev}
1099
+
1100
+ ${describeDelivery(res.wave.delivery)}`;
1101
+ }
1102
+ function shouldUseTui(stdinTTY, stdoutTTY, noTui) {
1103
+ return stdinTTY && stdoutTTY && !noTui;
1104
+ }
1105
+ async function trackedFiles(cwd) {
1106
+ try {
1107
+ const r = await defaultGitRunner(["ls-files"], cwd);
1108
+ return r.code === 0 ? r.stdout.split("\n").filter(Boolean) : [];
1109
+ } catch {
1110
+ return [];
1111
+ }
1112
+ }
1113
+ async function currentBranch(cwd) {
1114
+ try {
1115
+ const sym = await defaultGitRunner(["symbolic-ref", "--short", "HEAD"], cwd);
1116
+ if (sym.code === 0 && sym.stdout.trim()) return sym.stdout.trim();
1117
+ const r = await defaultGitRunner(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
1118
+ const b = r.stdout.trim();
1119
+ return b && b !== "HEAD" ? b : "main";
1120
+ } catch {
1121
+ return "main";
1122
+ }
1123
+ }
1124
+ async function main(argv) {
1125
+ const args = parseArgs(argv);
1126
+ if (args.version) {
1127
+ console.log(hcodeVersion());
1128
+ return;
1129
+ }
1130
+ if (args.help) {
1131
+ console.log(usage());
1132
+ return;
1133
+ }
1134
+ if (args.unknown !== void 0) {
1135
+ const near = nearestFlag(args.unknown);
1136
+ console.error(`unknown option: ${args.unknown}${near !== void 0 ? ` \u2014 did you mean ${near}?` : ""}`);
1137
+ console.error("`hcode --help` lists every option. To pass text starting with a dash, quote the whole request.");
1138
+ process.exitCode = 1;
1139
+ return;
1140
+ }
1141
+ if (argv[0] === "init") {
1142
+ const { read: read2, close: close2 } = nodeLineReader();
1143
+ try {
1144
+ await runInit({
1145
+ read: read2,
1146
+ readFile: (p) => {
1147
+ try {
1148
+ return readFileSync3(p, "utf8");
1149
+ } catch {
1150
+ return void 0;
1151
+ }
1152
+ },
1153
+ writeFile: (p, c) => {
1154
+ mkdirSync3(dirname4(p), { recursive: true });
1155
+ writeFileSync2(p, c);
1156
+ },
1157
+ home: process.env.HOME ?? "",
1158
+ log: (s) => console.log(s)
1159
+ });
1160
+ } finally {
1161
+ close2();
1162
+ }
1163
+ return;
1164
+ }
1165
+ const cwd = process.cwd();
1166
+ const config = loadConfig({
1167
+ cwd,
1168
+ home: process.env.HOME ?? "",
1169
+ env: process.env,
1170
+ readFile: (p) => {
1171
+ try {
1172
+ return readFileSync3(p, "utf8");
1173
+ } catch {
1174
+ return void 0;
1175
+ }
1176
+ }
1177
+ });
1178
+ const home = process.env.HOME ?? "";
1179
+ if (!process.env.HC_HEAP_SET && !process.env.NODE_OPTIONS) {
1180
+ const { spawnSync } = await import("child_process");
1181
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
1182
+ const ignore = () => {
1183
+ };
1184
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
1185
+ for (const sig of SIGNALS) process.on(sig, ignore);
1186
+ const r = spawnSync(process.execPath, [`--max-old-space-size=${HEAP_MB}`, ...process.argv.slice(1)], {
1187
+ stdio: "inherit",
1188
+ /**
1189
+ * `NODE_ENV=production` is set here because it must be set BEFORE React is imported.
1190
+ *
1191
+ * Without it, react-reconciler loads its development build, which calls `performance.measure()` on
1192
+ * every render. Node keeps every entry forever: a heap snapshot of a live session found 1,381,896
1193
+ * `PerformanceMeasure` objects, with React's own labels — `Components ⚛`, `Changed Props`,
1194
+ * `+ children` — among the largest classes on the heap. That is the ~700 MB/hour the floor was
1195
+ * climbing by, in a TUI that re-renders several times a second for hours.
1196
+ *
1197
+ * A user who has chosen their own NODE_ENV keeps it.
1198
+ */
1199
+ env: { ...process.env, HC_HEAP_SET: "1", NODE_ENV: process.env.NODE_ENV ?? "production" }
1200
+ });
1201
+ restoreTerminal({ stdin: process.stdin, write: (x) => process.stdout.write(x), sane: sttySane });
1202
+ for (const sig of SIGNALS) process.removeListener(sig, ignore);
1203
+ if (r.signal) process.kill(process.pid, r.signal);
1204
+ process.exit(r.status ?? 0);
1205
+ }
1206
+ const runId = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${process.pid}`;
1207
+ const sink = config.telemetry ? new FileSink(home, runId) : void 0;
1208
+ if (sink) {
1209
+ setTelemetry(new Telemetry(sink));
1210
+ sampleMemory(telemetry());
1211
+ clearPerfMarks();
1212
+ process.on("SIGUSR2", () => {
1213
+ void writeHeapSnapshot(telemetryDir(home), telemetry());
1214
+ });
1215
+ const flush = () => {
1216
+ void sink.flush();
1217
+ };
1218
+ process.once("exit", flush);
1219
+ process.once("SIGTERM", flush);
1220
+ }
1221
+ const telemetryNote = sink ? `\u{1F4C8} Telemetry \u2192 \`${sink.path}\` (one JSON object per line)` : void 0;
1222
+ const raw = new OmniRouteProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1223
+ const provider = config.telemetry ? telemetryProvider(raw, telemetry()) : raw;
1224
+ const skillRegistry = new SkillRegistry();
1225
+ await registerBuiltinSkills(skillRegistry);
1226
+ await skillRegistry.loadFromDir(externalSkillsDir(home));
1227
+ const skillsDir = join8(cwd, ".horsecode", "skills");
1228
+ if (existsSync3(skillsDir)) await skillRegistry.loadFromDir(skillsDir);
1229
+ const worktreeHome = await mainWorktreeRoot(defaultGitRunner, cwd);
1230
+ if (worktreeHome !== cwd) {
1231
+ console.log(`\u2139\uFE0F Sessions will be opened in ${worktreeHome} \u2014 the repository's main checkout.`);
1232
+ }
1233
+ const manager = new WorktreeManager({ repoRoot: cwd, worktreeHome });
1234
+ const remoteUrl = (await defaultGitRunner(["remote", "get-url", "origin"], cwd)).stdout.trim();
1235
+ const prAdapter = makePRAdapter({ platform: detectPlatform(remoteUrl), run: defaultCmdRunner, cwd, log: (s) => console.log(s) });
1236
+ const fromBranch = args.fromBranch ?? await currentBranch(cwd);
1237
+ setTraceRoot(config.traceDir || discoverTraceRoot(cwd, await trackedFiles(cwd)));
1238
+ const memStore = new MemoryStore({ home, cwd });
1239
+ await memStore.load();
1240
+ await memStore.pruneExpired();
1241
+ const rules = () => memStore.all().filter((m) => m.kind === "rule").map((m) => m.text);
1242
+ const buildDeps = (read2) => buildJobDeps({
1243
+ config,
1244
+ provider,
1245
+ skillRegistry,
1246
+ manager,
1247
+ prAdapter,
1248
+ rules,
1249
+ // Autonomous by default: a task that exhausts the escalation ladder is auto-retried (then abandoned) so
1250
+ // an unattended run finishes on its own instead of blocking on an interactive per-failure human prompt.
1251
+ askHuman: autonomousAskHuman(),
1252
+ approve: makeApprove(read2),
1253
+ signal: new AbortController().signal,
1254
+ home
1255
+ });
1256
+ const useTui = shouldUseTui(!!process.stdin.isTTY, !!process.stdout.isTTY, !!args.noTui);
1257
+ if (!args.prompt) {
1258
+ if (useTui) {
1259
+ const { runTuiRepl } = await import("./app-SB2L34JW.js");
1260
+ const { fetchCatalog, makeProbe, discoverSources } = await import("./discover-5URG7C4J.js");
1261
+ const { loadSourceCache, saveSourceCache } = await import("./source-cache-XEK5WN7I.js");
1262
+ const manualSources = config.modelSources.length > 0;
1263
+ const sourcesRef = { current: manualSources ? config.modelSources : loadSourceCache(home, config.baseUrl) ?? [] };
1264
+ const configuredModels = () => [...new Set(Object.values(config.roles).flatMap((r) => r.models ?? []))].filter((m) => m && m !== "default");
1265
+ const listModels = async () => {
1266
+ const catalog = await listOmniRouteModels({ baseUrl: config.baseUrl, apiKey: config.apiKey, sources: sourcesRef.current });
1267
+ const known = new Set(catalog);
1268
+ return [...catalog, ...configuredModels().filter((m) => !known.has(m))];
1269
+ };
1270
+ const refreshSources = async () => {
1271
+ const catalog = await fetchCatalog({ baseUrl: config.baseUrl, apiKey: config.apiKey });
1272
+ const found = await discoverSources({ catalog, probe: makeProbe({ baseUrl: config.baseUrl, apiKey: config.apiKey }) });
1273
+ sourcesRef.current = found;
1274
+ saveSourceCache(home, config.baseUrl, found);
1275
+ return found;
1276
+ };
1277
+ const sourcesInfo = () => ({ sources: sourcesRef.current, manual: manualSources, needsDiscovery: !manualSources && sourcesRef.current.length === 0 });
1278
+ const probeModel = async (model) => {
1279
+ try {
1280
+ const res = await fetch(`${config.baseUrl.replace(/\/$/, "")}/api/v1/chat/completions`, {
1281
+ method: "POST",
1282
+ headers: { "Content-Type": "application/json", ...config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {} },
1283
+ body: JSON.stringify({ model, stream: false, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
1284
+ signal: AbortSignal.timeout(2e4)
1285
+ });
1286
+ return res.status === 200;
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ };
1291
+ const effectiveRoleSkills = (skill) => Object.keys(DEFAULT_ROLE_SKILLS).concat(Object.keys(config.roles)).filter((r, i, a) => a.indexOf(r) === i).filter((r) => (config.roles[r]?.skills ?? DEFAULT_ROLE_SKILLS[r] ?? []).includes(skill));
1292
+ const listSkills = () => skillRegistry.list().map((s) => ({
1293
+ ...s,
1294
+ roles: effectiveRoleSkills(s.name)
1295
+ }));
1296
+ const addSkill = async (url) => {
1297
+ const src = parseSkillUrl(url);
1298
+ if (!src) return `Not a GitHub URL: \`${url}\`
1299
+
1300
+ _Expected something like \`https://github.com/owner/repo\` or a link to the directory holding SKILL.md._`;
1301
+ const r = await installSkillSource(home, src);
1302
+ await saveSkillSource(home, src);
1303
+ await skillRegistry.loadFromDir(externalSkillsDir(home));
1304
+ const s = skillRegistry.get(src.name);
1305
+ const desc = s ? `
1306
+
1307
+ > ${s.description}` : "";
1308
+ return `Installed **${src.name}** from \`${src.repo}\`${src.path ? `/${src.path}` : ""} at \`${r.sha.slice(0, 8)}\`.${desc}
1309
+
1310
+ _Discoverable by every agent. \`/roles adjust\` assigns it to the roles it fits; \`/skills update\` re-installs it from upstream._`;
1311
+ };
1312
+ const reloadProjectSkills = async () => {
1313
+ const dir = join8(cwd, ".horsecode", "skills");
1314
+ if (existsSync3(dir)) await skillRegistry.loadFromDir(dir);
1315
+ };
1316
+ const updateSkills = async () => {
1317
+ if (!config.skillSources.length) {
1318
+ return "No external skill sources configured. Add them under `skillSources` in your config.";
1319
+ }
1320
+ const { ok, failed } = await syncSkillSources(home, config.skillSources);
1321
+ await skillRegistry.loadFromDir(externalSkillsDir(home));
1322
+ const changed = ok.filter((r) => r.changed);
1323
+ const lines = [
1324
+ ...changed.map((r) => `- \u2B06\uFE0F **${r.name}** \u2192 \`${r.sha.slice(0, 8)}\``),
1325
+ ...ok.filter((r) => !r.changed).map((r) => `- \u2713 ${r.name} already up to date`),
1326
+ ...failed.map((f) => `- \u274C **${f.name}** \u2014 ${f.error}`)
1327
+ ];
1328
+ const tail = changed.length ? "\n\n_Restart to pick up a changed skill in already-built prompts._" : "";
1329
+ return `**Skill sources:**
1330
+ ${lines.join("\n")}${tail}`;
1331
+ };
1332
+ const graphStatusText = async () => {
1333
+ const st = await graphStatus(cwd);
1334
+ if (!st.built) {
1335
+ const py = await graphifyPython();
1336
+ return py ? "No code graph yet. `/graph build` \u2014 AST parsing only, no tokens, a few seconds.\n\n_Without it every agent works blind: it can grep for a name but cannot tell what calls it._" : "No code graph, and graphify is not installed.\n\n`uv tool install graphifyy` (or `pipx install graphifyy`), then `/graph build`.\n\n_MIT, pure tree-sitter AST parsing \u2014 no API key, no tokens._";
1337
+ }
1338
+ const age = st.builtAt ? `${Math.round((Date.now() - st.builtAt) / 6e4)} min ago` : "unknown";
1339
+ const fresh = st.stale ? `
1340
+
1341
+ \u26A0\uFE0F **Stale** \u2014 changed since it was built: ${st.staleBecause.map((f) => `\`${f}\``).join(", ")}. Run \`/graph build\`.` : "\n\n\u2713 Up to date with the working tree.";
1342
+ const bs = await briefStatus(cwd, await traceableDocs());
1343
+ const briefLine = !bs.built ? "\n\n**Project brief:** not written \u2014 `/graph trace` writes it (this is the part that costs tokens)." : bs.stale ? `
1344
+
1345
+ **Project brief:** \u26A0\uFE0F stale \u2014 ${bs.changed.slice(0, 3).map((c) => `\`${c}\``).join(", ")}. \`/graph trace\` rewrites it.` : `
1346
+
1347
+ **Project brief:** \u2713 current, from ${bs.sources.length} document(s).`;
1348
+ return `**Project graph** \u2014 ${st.nodes} symbols, ${st.edges} relationships, built ${age}.${fresh}${briefLine}
1349
+
1350
+ _Every agent can query it: \`graph_impact\` (blast radius), \`graph_trace\`, \`graph_find\`, \`graph_context\`, \`graph_overview\`._`;
1351
+ };
1352
+ const buildGraphText = async () => (await buildProjectGraph(cwd)).message;
1353
+ const cleanWorktreesText = async (apply, branch) => {
1354
+ const { surveySessions, cleanSessions, describeSurvey, describeClean } = await import("./clean-YOQATBMZ.js");
1355
+ const target = branch?.trim() || fromBranch;
1356
+ const known = await defaultGitRunner(["rev-parse", "--verify", "--quiet", target], cwd);
1357
+ if (known.code !== 0) return `There is no branch \`${target}\` in this repository.`;
1358
+ if (!apply) return describeSurvey(await surveySessions(defaultGitRunner, cwd, target), target);
1359
+ return describeClean(await cleanSessions(defaultGitRunner, cwd, target), target);
1360
+ };
1361
+ const gitFiles = async () => (await defaultGitRunner(["ls-files", "--cached", "--others", "--exclude-standard"], cwd)).stdout.split("\n").filter(Boolean);
1362
+ const traceableDocs = async () => traceableFiles(cwd, { code: false });
1363
+ const traceableFiles2 = async () => traceableFiles(cwd);
1364
+ const tracerModel = () => {
1365
+ for (const role of ["tracer", "architect", "senior-coder", "judge"]) {
1366
+ const m = config.roles[role]?.models[0];
1367
+ if (m && m !== "default") return m;
1368
+ }
1369
+ return config.model;
1370
+ };
1371
+ const planTracesFn = async () => {
1372
+ const plan = await planFor(cwd, await traceableFiles2());
1373
+ return { summary: describePlan(plan, tracerModel()), jobs: plan.jobs.length };
1374
+ };
1375
+ const runTracesFn = async (onProgress, metered) => {
1376
+ const files = await traceableFiles2();
1377
+ const brief = await buildBrief({ cwd, provider: metered ?? provider, model: tracerModel(), files: await traceableDocs() });
1378
+ const plan = await planFor(cwd, files);
1379
+ const res = await runTraces({
1380
+ cwd,
1381
+ provider: metered ?? provider,
1382
+ model: tracerModel(),
1383
+ plan,
1384
+ liveFiles: new Set(files),
1385
+ ...onProgress ? { onProgress } : {}
1386
+ });
1387
+ const bits = [`${brief.message}
1388
+
1389
+ **Traces written: ${res.written}**`];
1390
+ if (res.upToDate) bits.push(`${res.upToDate} already current`);
1391
+ if (res.pruned.length) bits.push(`${res.pruned.length} removed for deleted files`);
1392
+ if (res.wroteGitignore) bits.push("\n\n_Added .gitignore rules: traces are committed, the AST cache is not._");
1393
+ if (res.failed.length) {
1394
+ bits.push(`
1395
+
1396
+ \u26A0\uFE0F ${res.failed.length} failed:
1397
+ ${res.failed.slice(0, 5).map((f) => `- \`${f.file}\` \u2014 ${f.error}`).join("\n")}`);
1398
+ }
1399
+ return `${bits.join(" \xB7 ")}
1400
+
1401
+ _Committed with the repo, so every clone starts with them. Agents read one with \`graph_trace\`._`;
1402
+ };
1403
+ await runTuiRepl({
1404
+ buildDeps,
1405
+ memStore,
1406
+ listSkills,
1407
+ updateSkills,
1408
+ addSkill,
1409
+ reloadProjectSkills,
1410
+ graphStatus: graphStatusText,
1411
+ buildGraph: buildGraphText,
1412
+ cleanWorktrees: cleanWorktreesText,
1413
+ planTraces: planTracesFn,
1414
+ runTraces: runTracesFn,
1415
+ jobBase: { fromBranch, maxRounds: args.rounds ?? 3, ...args.revisionRounds !== void 0 && { revisionRounds: args.revisionRounds } },
1416
+ formatResult: renderResult,
1417
+ model: config.model,
1418
+ listModels,
1419
+ mcp: config.mcp,
1420
+ maxParallel: config.maxParallel,
1421
+ ...telemetryNote ? { startupNote: telemetryNote } : {},
1422
+ ...sink ? { telemetryPath: sink.path } : {},
1423
+ refreshSources,
1424
+ sourcesInfo,
1425
+ probeModel
1426
+ });
1427
+ return;
1428
+ }
1429
+ console.error('usage: hcode "<prompt>" [--branch b] [--job j] [--rounds n] [--revision-rounds n] [--no-tui] | hcode (interactive TUI REPL) | hcode init');
1430
+ process.exitCode = 1;
1431
+ return;
1432
+ }
1433
+ const jobName = args.jobName ?? (toSlug(args.prompt) || "hcode-job");
1434
+ const job = {
1435
+ prompt: args.prompt,
1436
+ fromBranch,
1437
+ jobName,
1438
+ maxRounds: args.rounds ?? 3,
1439
+ ...args.revisionRounds !== void 0 && { revisionRounds: args.revisionRounds }
1440
+ };
1441
+ if (useTui) {
1442
+ const { runTui } = await import("./app-SB2L34JW.js");
1443
+ const res = await runTui({ buildDeps, job });
1444
+ console.log(renderResult(res));
1445
+ return;
1446
+ }
1447
+ const { read, close } = nodeLineReader();
1448
+ try {
1449
+ const deps = await buildDeps(read);
1450
+ deps.memory = () => memStore.all();
1451
+ deps.reinforceMemory = (id) => {
1452
+ void memStore.reinforce(id);
1453
+ };
1454
+ deps.rememberFact = (fact) => {
1455
+ void memStore.add(fact);
1456
+ };
1457
+ deps.recordInjection = (ids) => {
1458
+ void memStore.recordInjection(ids);
1459
+ };
1460
+ deps.learnMemory = async (text, kind, o) => (await memStore.add(text, kind, o)).ok;
1461
+ deps.onMemory = (ev) => {
1462
+ const t = memoryNote(ev);
1463
+ if (t) console.log(t);
1464
+ };
1465
+ await memStore.runHygiene().catch(() => {
1466
+ });
1467
+ const res = await runJob(deps, { ...job, askUser: makeAskUser(read) });
1468
+ console.log(renderResult(res));
1469
+ } finally {
1470
+ close();
1471
+ }
1472
+ }
1473
+ function isMainModule() {
1474
+ const entry = process.argv[1];
1475
+ if (!entry) return false;
1476
+ try {
1477
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
1478
+ } catch {
1479
+ return false;
1480
+ }
1481
+ }
1482
+ if (isMainModule()) {
1483
+ main(process.argv.slice(2)).catch((e) => {
1484
+ console.error("error:", e instanceof Error ? e.message : e);
1485
+ process.exitCode = 1;
1486
+ });
1487
+ }
1488
+ export {
1489
+ describeDelivery,
1490
+ main,
1491
+ parseArgs,
1492
+ renderResult,
1493
+ shouldUseTui,
1494
+ whereItLanded
1495
+ };