@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/app-SB2L34JW.js +6217 -0
- package/dist/chunk-2DGO2BUB.js +4490 -0
- package/dist/chunk-2SVAHH5N.js +60 -0
- package/dist/chunk-3XVZXTB6.js +4469 -0
- package/dist/chunk-5UWA2UBM.js +69 -0
- package/dist/chunk-7TBYMFMG.js +147 -0
- package/dist/chunk-B67BK5GQ.js +34 -0
- package/dist/chunk-BY4DP7IE.js +20 -0
- package/dist/chunk-DKVIN43T.js +54 -0
- package/dist/chunk-DTWKSZXY.js +162 -0
- package/dist/chunk-F2IALVBU.js +212 -0
- package/dist/chunk-FFYBY2NA.js +392 -0
- package/dist/chunk-FGVJFMK5.js +123 -0
- package/dist/chunk-H2FDGPVW.js +42 -0
- package/dist/chunk-HBSC2HT2.js +85 -0
- package/dist/chunk-IW2KBAVZ.js +21 -0
- package/dist/chunk-JWAEW7AJ.js +121 -0
- package/dist/chunk-NNTIACT4.js +163 -0
- package/dist/chunk-O74BDQKS.js +28 -0
- package/dist/chunk-PGOYDOI4.js +426 -0
- package/dist/chunk-QF4MP6BS.js +69 -0
- package/dist/chunk-SSDLHWSF.js +35 -0
- package/dist/chunk-TOPZL5SU.js +1052 -0
- package/dist/chunk-YBWTCXUS.js +153 -0
- package/dist/chunk-YILDXPSI.js +1363 -0
- package/dist/clean-YOQATBMZ.js +18 -0
- package/dist/cli.js +1495 -0
- package/dist/discover-5URG7C4J.js +52 -0
- package/dist/fix-HBBOTUWM.js +34 -0
- package/dist/frontmatter-UNIPNLLO.js +6 -0
- package/dist/git-VTSZALSR.js +6 -0
- package/dist/install-O34KMWJB.js +113 -0
- package/dist/main-branch-KGWUINYQ.js +19 -0
- package/dist/ongoing-OV5XROTU.js +70 -0
- package/dist/project-graph-IOPCSZUA.js +56 -0
- package/dist/run-LQOZ5I7Z.js +610 -0
- package/dist/save-skills-OHYGVTQ4.js +13 -0
- package/dist/source-cache-XEK5WN7I.js +29 -0
- package/dist/trace-ZMB7LT7W.js +66 -0
- package/dist/trace-adopt-C6TUWFJL.js +79 -0
- package/dist/trace-run-F23MFTY4.js +24 -0
- package/dist/triage-2J3T5PVQ.js +30 -0
- package/dist/verify-WQ3GHION.js +479 -0
- package/dist/worktree-F7TWLWLN.js +87 -0
- package/package.json +64 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSkillTool,
|
|
3
|
+
editFileTool,
|
|
4
|
+
writeFileTool
|
|
5
|
+
} from "./chunk-3XVZXTB6.js";
|
|
6
|
+
import {
|
|
7
|
+
globTool,
|
|
8
|
+
grepTool,
|
|
9
|
+
readFileTool
|
|
10
|
+
} from "./chunk-TOPZL5SU.js";
|
|
11
|
+
import {
|
|
12
|
+
ToolRegistry,
|
|
13
|
+
runStructuredRole
|
|
14
|
+
} from "./chunk-YILDXPSI.js";
|
|
15
|
+
|
|
16
|
+
// src/engine/language.ts
|
|
17
|
+
function respondIn(language) {
|
|
18
|
+
if (!language || /^english$/i.test(language)) return "";
|
|
19
|
+
return `
|
|
20
|
+
|
|
21
|
+
Respond to the user in ${language}: everything you say to them, and every question you ask, is in ${language}. Code, identifiers, logs and commit messages keep whatever language the project uses.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/engine/writer-registry.ts
|
|
25
|
+
import { z as z2 } from "zod";
|
|
26
|
+
|
|
27
|
+
// src/engine/normalize-question.ts
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
var NormalizedQuestionSchema = z.object({
|
|
30
|
+
question: z.string().describe("The core question, concise, WITHOUT the embedded options table/list."),
|
|
31
|
+
options: z.array(z.string()).describe("Each selectable choice as a SHORT label; the recommended one first, suffixed ' (recommended)'. Empty when the question is genuinely open-ended."),
|
|
32
|
+
multiSelect: z.boolean().describe("true only if the user may pick more than one.")
|
|
33
|
+
});
|
|
34
|
+
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.";
|
|
35
|
+
function looksLikeChoices(text) {
|
|
36
|
+
return /\|[^\n]*\|[^\n]*\|/.test(text) || /(^|\n)\s*[A-Ea-e][).\-:]\s/.test(text) || /(^|\n)\s*[-*]\s+\S.*(\n\s*[-*]\s+\S.*){1,}/.test(text) || /\b(option|seçenek|choice|önerilen|recommended)\b/i.test(text);
|
|
37
|
+
}
|
|
38
|
+
async function normalizeQuestion(deps, raw) {
|
|
39
|
+
const { role: agentRole, model, fallbacks, onExhausted, onFallback } = deps.roleRegistry.fallbackOpts("refiner");
|
|
40
|
+
return runStructuredRole({
|
|
41
|
+
provider: deps.provider,
|
|
42
|
+
role: agentRole,
|
|
43
|
+
model,
|
|
44
|
+
fallbacks,
|
|
45
|
+
onExhausted,
|
|
46
|
+
onFallback,
|
|
47
|
+
// This shapes the question the USER reads; a rule like "always ask in Turkish" belongs here.
|
|
48
|
+
systemPrompt: PROMPT + deps.roleRegistry.ruleSuffix(),
|
|
49
|
+
tools: new ToolRegistry(),
|
|
50
|
+
messages: [{ role: "user", content: raw }],
|
|
51
|
+
permission: deps.permission,
|
|
52
|
+
approve: deps.approve,
|
|
53
|
+
cwd: ".",
|
|
54
|
+
signal: deps.signal
|
|
55
|
+
}, NormalizedQuestionSchema);
|
|
56
|
+
}
|
|
57
|
+
function extractChoicesFrom(text) {
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
const rows = lines.map((l) => l.trim()).filter((l) => l.startsWith("|") && l.endsWith("|") && !/^\|[\s|:-]+\|$/.test(l)).map((l) => l.slice(1, -1).split("|").map((c) => c.trim()));
|
|
60
|
+
const body = rows.filter((cells) => cells.length >= 2 && cells[0] && cells[1]).filter((cells, i) => !(i === 0 && /^(option|seçenek|choice|alternatif)$/i.test(cells[0])));
|
|
61
|
+
if (body.length >= 2) {
|
|
62
|
+
const question = lines.filter((l) => !/^\s*\|.*\|\s*$/.test(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
63
|
+
return { choices: body.map((cells) => ({ label: cells[0], description: cells.slice(1).join(" \u2014 ") })), question };
|
|
64
|
+
}
|
|
65
|
+
const isLettered = (l) => /^\s*([A-Ea-e])[).\-:]\s+(\S.*)$/.exec(l.trim());
|
|
66
|
+
const lettered = lines.map(isLettered).filter((m) => !!m).map((m) => ({ label: `${m[1].toUpperCase()} \u2014 ${m[2]}` }));
|
|
67
|
+
if (lettered.length >= 2) {
|
|
68
|
+
const question = lines.filter((l) => !isLettered(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
69
|
+
return { choices: lettered, question };
|
|
70
|
+
}
|
|
71
|
+
const MARKER = /\(([A-Ea-e])\)\s*/g;
|
|
72
|
+
const marks = [...text.matchAll(MARKER)];
|
|
73
|
+
const inOrder = marks.length >= 2 && marks.every((m, i) => m[1].toUpperCase() === String.fromCharCode(65 + i));
|
|
74
|
+
if (inOrder) {
|
|
75
|
+
const first = marks[0].index ?? 0;
|
|
76
|
+
const parts = marks.map((m, i) => {
|
|
77
|
+
const start = (m.index ?? 0) + m[0].length;
|
|
78
|
+
const end = i + 1 < marks.length ? marks[i + 1].index ?? text.length : text.length;
|
|
79
|
+
return { letter: m[1].toUpperCase(), body: text.slice(start, end).trim().replace(/\s+/g, " ") };
|
|
80
|
+
}).filter((p) => p.body);
|
|
81
|
+
if (parts.length >= 2) {
|
|
82
|
+
const question = text.slice(0, first).replace(/\s*(Se\u00e7enekler|Options|Choices)\s*:?\s*$/i, "").trim();
|
|
83
|
+
return {
|
|
84
|
+
choices: parts.map((p) => ({ label: `${p.letter} \u2014 ${clipLabel(p.body)}`, description: p.body })),
|
|
85
|
+
question: question || text
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { choices: [], question: text };
|
|
90
|
+
}
|
|
91
|
+
var clipLabel = (body) => {
|
|
92
|
+
const cut = body.replace(/^\[[^\]]*\]\s*/, "");
|
|
93
|
+
const stop = cut.search(/[—–.;:]\s/);
|
|
94
|
+
const head = stop > 12 ? cut.slice(0, stop) : cut;
|
|
95
|
+
return head.length > 72 ? `${head.slice(0, 71)}\u2026` : head;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/engine/writer-registry.ts
|
|
99
|
+
var askUserParams = z2.object({
|
|
100
|
+
question: z2.string(),
|
|
101
|
+
// For a multiple-choice question, list the choices here → the UI shows a selectable checkbox/radio list
|
|
102
|
+
// (arrow keys + Enter) instead of a free-text box. Omit for an open-ended question.
|
|
103
|
+
//
|
|
104
|
+
// A choice may be a plain string, or an object carrying what the label alone cannot say: a one-line
|
|
105
|
+
// `description`, and a `preview` rendered in a panel beside the list while that option is focused. Use the
|
|
106
|
+
// rich form when the decision turns on the trade-offs rather than the name (e.g. "which approach?").
|
|
107
|
+
options: z2.array(z2.union([
|
|
108
|
+
z2.string(),
|
|
109
|
+
z2.object({ label: z2.string(), description: z2.string().optional(), preview: z2.string().optional() })
|
|
110
|
+
])).optional().describe(
|
|
111
|
+
"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."
|
|
112
|
+
),
|
|
113
|
+
multiSelect: z2.boolean().optional().describe(
|
|
114
|
+
"True when the user may pick more than one (checkboxes); omitted means pick exactly one (radio)."
|
|
115
|
+
),
|
|
116
|
+
/**
|
|
117
|
+
* What the user has to DO before they can answer — one action per entry.
|
|
118
|
+
*
|
|
119
|
+
* Present ⇒ this is a hand-off, not a question: the run has stopped because only a person can carry the
|
|
120
|
+
* next step, and the UI says so rather than showing a bare "? Question".
|
|
121
|
+
*/
|
|
122
|
+
steps: z2.array(z2.string()).optional().describe(
|
|
123
|
+
'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.'
|
|
124
|
+
)
|
|
125
|
+
});
|
|
126
|
+
var POINTS_ELSEWHERE = /\b(above|below|earlier|previously|as listed|as described)\b|yukarı|aşağı|altında|önceki|birazdan|listelenen/i;
|
|
127
|
+
var ITEM_REFERENCE = /(?:^|[^\w])(?:q|soru|question|madde|item)\s*\.?\s*(\d{1,2})\b/gi;
|
|
128
|
+
var ITEM_STATED = /^[\s>*\-–—#]*(?:q|soru|question|madde|item)\s*\.?\s*\d{1,2}\s*[:).]/gim;
|
|
129
|
+
function danglingItems(question) {
|
|
130
|
+
const referenced = new Set([...question.matchAll(ITEM_REFERENCE)].map((m) => Number(m[1])));
|
|
131
|
+
if (referenced.size < 2) return [];
|
|
132
|
+
const stated = question.match(ITEM_STATED)?.length ?? 0;
|
|
133
|
+
return stated >= referenced.size ? [] : [...referenced].sort((a, b) => a - b);
|
|
134
|
+
}
|
|
135
|
+
var ENUMERATOR = /(?:^|[^\w])\(?(\d{1,2})[).:]/g;
|
|
136
|
+
function packedQuestions(question) {
|
|
137
|
+
if ((question.match(/\?/g)?.length ?? 0) < 2) return 0;
|
|
138
|
+
const numbered = new Set([...question.matchAll(ENUMERATOR)].map((m) => Number(m[1])));
|
|
139
|
+
return numbered.size;
|
|
140
|
+
}
|
|
141
|
+
var ONE_AT_A_TIME = "This tool asks ONE question and takes ONE answer. Ask the first one on its own, with its own `options`, and call this tool again for the next once you have the answer \u2014 the answer to one of these usually changes what the next one should be.";
|
|
142
|
+
function buildAskUserTool(askUser, normalize) {
|
|
143
|
+
return {
|
|
144
|
+
name: "ask_user",
|
|
145
|
+
description: 'Ask the user ONE question and get their answer. Several decisions are several calls: ask the first, read the answer, then ask the next \u2014 the user has one answer field, so four questions in one box means four decisions they must hold in their head and answer in prose. For a multiple-choice question, pass `options` (the choices) \u2014 the UI shows a selectable list the user checks off; set `multiSelect: true` when they may pick several. Omit `options` for an open-ended (free-text) question. An option may be a plain string, or {label, description, preview} when the decision turns on trade-offs the label cannot carry \u2014 the preview is shown beside the list as the user moves the cursor. A `label` is a SHORT single line (a few words, no line breaks): it is a name for the choice, not the argument for it. Put the reasoning in `description` (one sentence) and the detail in `preview`. If you have findings to report, WRITE THEM as your message before calling this \u2014 a question that says "the evaluation is above" when you never wrote one leaves the user choosing between options whose basis they cannot see. The user may attach a free-text note to their choice, which arrives appended to the answer.\n\nWhen you need the user to DO something first \u2014 click through a screen, run a scenario, look at a network response \u2014 put each action in `steps`, one per entry, and ask in `question` for what they should report back. The user reads THIS BOX and the chat; a file you wrote is not on their screen, so "the steps above" points at nothing they can see. With `steps` the UI shows a hand-off \u2014 the numbered actions and then the question \u2014 instead of a bare question.',
|
|
146
|
+
permissionLevel: "safe",
|
|
147
|
+
parameters: askUserParams,
|
|
148
|
+
run: async (rawArgs, ctx) => {
|
|
149
|
+
const parsed = askUserParams.safeParse(rawArgs);
|
|
150
|
+
if (!parsed.success) {
|
|
151
|
+
return { content: `ask_user: invalid args: ${parsed.error.issues.map((i) => i.message).join("; ")}`, isError: true };
|
|
152
|
+
}
|
|
153
|
+
const { question, options, multiSelect, steps } = parsed.data;
|
|
154
|
+
const asker = ctx.role || ctx.model ? { asker: { ...ctx.role ? { role: ctx.role } : {}, ...ctx.model ? { model: ctx.model } : {} } } : {};
|
|
155
|
+
if (POINTS_ELSEWHERE.test(question) && !steps?.length && !ctx.said?.trim()) {
|
|
156
|
+
return {
|
|
157
|
+
content: "ask_user: this question refers to something the user cannot see. You wrote no message this turn, so there is nothing above it \u2014 and a file you wrote is not on their screen. Put what they must do in `steps` (one action per entry), or write it out in `question`, and ask again.",
|
|
158
|
+
isError: true
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const dangling = danglingItems(question);
|
|
162
|
+
if (dangling.length && !steps?.length && !ctx.said?.trim()) {
|
|
163
|
+
return {
|
|
164
|
+
content: `ask_user: this asks the user to answer ${dangling.map((n) => `Q${n}`).join(", ")}, and none of them is on their screen \u2014 you did not write them this turn, and a file you wrote is not something they are looking at. ${ONE_AT_A_TIME}`,
|
|
165
|
+
isError: true
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const packed = packedQuestions(question);
|
|
169
|
+
if (packed >= 2 && !steps?.length && !options?.length) {
|
|
170
|
+
return {
|
|
171
|
+
content: `ask_user: this is ${packed} questions in one box, and the user has one answer field for all of them. ${ONE_AT_A_TIME}`,
|
|
172
|
+
isError: true
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if ((!options || options.length === 0) && !steps?.length && looksLikeChoices(question)) {
|
|
176
|
+
const found = extractChoicesFrom(question);
|
|
177
|
+
if (found.choices.length >= 2) {
|
|
178
|
+
return { content: await askUser(found.question, { options: found.choices, ...asker }), isError: false };
|
|
179
|
+
}
|
|
180
|
+
if (normalize) {
|
|
181
|
+
try {
|
|
182
|
+
const n = await normalize(question);
|
|
183
|
+
if (n.options.length > 0) {
|
|
184
|
+
return { content: await askUser(n.question, { options: n.options, multiSelect: n.multiSelect, ...asker }), isError: false };
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { content: await askUser(question, { options, multiSelect, steps, ...asker }), isError: false };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function writerRegistry(skillRegistry, extra = []) {
|
|
195
|
+
const r = new ToolRegistry();
|
|
196
|
+
r.register(readFileTool);
|
|
197
|
+
r.register(writeFileTool);
|
|
198
|
+
r.register(editFileTool);
|
|
199
|
+
r.register(grepTool);
|
|
200
|
+
r.register(globTool);
|
|
201
|
+
r.register(buildSkillTool(skillRegistry));
|
|
202
|
+
for (const t of extra) r.register(t);
|
|
203
|
+
return r;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export {
|
|
207
|
+
respondIn,
|
|
208
|
+
normalizeQuestion,
|
|
209
|
+
extractChoicesFrom,
|
|
210
|
+
buildAskUserTool,
|
|
211
|
+
writerRegistry
|
|
212
|
+
};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stateRoot
|
|
3
|
+
} from "./chunk-SSDLHWSF.js";
|
|
4
|
+
import {
|
|
5
|
+
writeAtomic
|
|
6
|
+
} from "./chunk-B67BK5GQ.js";
|
|
7
|
+
|
|
8
|
+
// src/engine/trace.ts
|
|
9
|
+
import { readFile, writeFile, mkdir, rm } from "fs/promises";
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import { createHash } from "crypto";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
var TRACE_DIR = join(".horsecode", "traces");
|
|
14
|
+
var traceRoot = TRACE_DIR;
|
|
15
|
+
function setTraceRoot(rel) {
|
|
16
|
+
if (rel && rel.trim()) traceRoot = rel.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
17
|
+
}
|
|
18
|
+
function traceRootRel() {
|
|
19
|
+
return traceRoot;
|
|
20
|
+
}
|
|
21
|
+
var MAX_DISCOVERY_DEPTH = 4;
|
|
22
|
+
function discoverTraceRoot(cwd, trackedFiles) {
|
|
23
|
+
const candidates = trackedFiles.map((f) => f.replace(/\\/g, "/")).filter((f) => f.endsWith(`/${TRACE_INDEX}`) && f.split("/").length <= MAX_DISCOVERY_DEPTH).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
24
|
+
for (const rel of candidates) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(join(cwd, rel), "utf8");
|
|
27
|
+
const doc = JSON.parse(raw);
|
|
28
|
+
if (doc?.version !== 1 || typeof doc.traces !== "object" || doc.traces === null) continue;
|
|
29
|
+
if (!Object.keys(doc.traces).length) continue;
|
|
30
|
+
return rel.slice(0, -`/${TRACE_INDEX}`.length);
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
var TRACE_INDEX = "index.json";
|
|
37
|
+
var TRACEABLE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|c|h|cc|cpp|hpp|cs|php|swift|kt|scala)$/;
|
|
38
|
+
function everTraceable(file) {
|
|
39
|
+
return TRACEABLE_EXT.test(file);
|
|
40
|
+
}
|
|
41
|
+
var NOT_SOURCE = /(^|\/)(dist|build|out|node_modules|vendor|coverage|graphify-out|\.[^/]+)\//;
|
|
42
|
+
var GENERATED = /(\.Designer\.cs|ModelSnapshot\.cs|\.(g|generated)\.(cs|ts|js)|\.d\.ts|_pb2(_grpc)?\.py|\.pb\.go)$/;
|
|
43
|
+
function traceable(files, opts) {
|
|
44
|
+
return files.filter((f) => !NOT_SOURCE.test(f) && !GENERATED.test(f) && (opts?.code === false || TRACEABLE_EXT.test(f)));
|
|
45
|
+
}
|
|
46
|
+
function traceDir(cwd) {
|
|
47
|
+
return join(stateRoot(cwd), traceRoot);
|
|
48
|
+
}
|
|
49
|
+
function tracePath(cwd, file) {
|
|
50
|
+
return join(traceDir(cwd), `${file}.md`);
|
|
51
|
+
}
|
|
52
|
+
function hashContent(text) {
|
|
53
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
async function loadTraceIndex(cwd) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(await readFile(join(traceDir(cwd), TRACE_INDEX), "utf8"));
|
|
58
|
+
if (raw?.version === 1 && typeof raw.traces === "object" && raw.traces !== null) return raw;
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
return { version: 1, traces: {} };
|
|
62
|
+
}
|
|
63
|
+
function mergeTraceIndexes(ours, theirs) {
|
|
64
|
+
const traces = { ...ours.traces };
|
|
65
|
+
for (const [file, rec] of Object.entries(theirs.traces)) {
|
|
66
|
+
const mine = traces[file];
|
|
67
|
+
if (!mine || (rec.writtenAt ?? 0) > (mine.writtenAt ?? 0)) traces[file] = rec;
|
|
68
|
+
}
|
|
69
|
+
return { version: 1, traces };
|
|
70
|
+
}
|
|
71
|
+
function parseTraceIndex(text) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(text);
|
|
74
|
+
if (raw?.version === 1 && typeof raw.traces === "object" && raw.traces !== null) return raw;
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
return void 0;
|
|
78
|
+
}
|
|
79
|
+
function serializeTraceIndex(index) {
|
|
80
|
+
return `${JSON.stringify(index, null, 2)}
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
async function saveTraceIndex(cwd, index) {
|
|
84
|
+
await mkdir(traceDir(cwd), { recursive: true });
|
|
85
|
+
await writeAtomic(join(traceDir(cwd), TRACE_INDEX), `${JSON.stringify(index, null, 2)}
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
async function readTrace(cwd, file) {
|
|
89
|
+
try {
|
|
90
|
+
return await readFile(tracePath(cwd, file), "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
return void 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function indexSync(cwd) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(readFileSync(join(traceDir(cwd), TRACE_INDEX), "utf8"));
|
|
98
|
+
if (raw?.version === 1 && typeof raw.traces === "object" && raw.traces !== null) return raw;
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
return { version: 1, traces: {} };
|
|
102
|
+
}
|
|
103
|
+
function traceIsStale(cwd, file, rec) {
|
|
104
|
+
if (!rec?.hash) return false;
|
|
105
|
+
try {
|
|
106
|
+
return hashContent(readFileSync(join(stateRoot(cwd), file), "utf8")) !== rec.hash;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
var STALE_BANNER = "> \u26A0\uFE0F **This file has changed since this note was written.** Treat everything below as the previous version: the purpose is probably still right, the specifics may not be. Read the file itself before relying on any detail here.\n\n";
|
|
112
|
+
function readTraceSync(cwd, file) {
|
|
113
|
+
const rec = indexSync(cwd).traces[file];
|
|
114
|
+
const mark = (body) => traceIsStale(cwd, file, rec) ? STALE_BANNER + body : body;
|
|
115
|
+
if (rec?.doc) {
|
|
116
|
+
try {
|
|
117
|
+
return mark(readFileSync(join(stateRoot(cwd), rec.doc), "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!file || file.includes("..") || file.startsWith("/")) return void 0;
|
|
122
|
+
try {
|
|
123
|
+
return mark(readFileSync(tracePath(cwd, file), "utf8"));
|
|
124
|
+
} catch {
|
|
125
|
+
return void 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
var MAX_TRACE_FILE_CHARS = 6e4;
|
|
129
|
+
var CHARS_PER_TOKEN = 3.13;
|
|
130
|
+
var TRACE_OUTPUT_TOKENS = 350;
|
|
131
|
+
function traceState(cwd, file, content, index, hash = hashContent(content)) {
|
|
132
|
+
if (content.length > MAX_TRACE_FILE_CHARS) return "too-large";
|
|
133
|
+
if (!content.trim()) return "empty";
|
|
134
|
+
const rec = index.traces[file];
|
|
135
|
+
const backing = rec?.doc ? join(cwd, rec.doc) : tracePath(cwd, file);
|
|
136
|
+
if (!rec || !existsSync(backing)) return "missing";
|
|
137
|
+
return rec.hash === hash ? "current" : "stale";
|
|
138
|
+
}
|
|
139
|
+
async function traceCoverage(cwd, files, index) {
|
|
140
|
+
const out = { traceable: 0, current: 0, missing: 0, stale: 0 };
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
let content;
|
|
143
|
+
try {
|
|
144
|
+
content = await readFile(join(cwd, file), "utf8");
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const state = traceState(cwd, file, content, index);
|
|
149
|
+
if (state === "too-large" || state === "empty") continue;
|
|
150
|
+
out.traceable++;
|
|
151
|
+
out[state]++;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
async function planTraces(cwd, files, graph, index) {
|
|
156
|
+
const jobs = [];
|
|
157
|
+
const skipped = [];
|
|
158
|
+
let upToDate = 0;
|
|
159
|
+
const symbolsOf = /* @__PURE__ */ new Map();
|
|
160
|
+
const fileOfNode = /* @__PURE__ */ new Map();
|
|
161
|
+
if (graph) {
|
|
162
|
+
for (const n of graph.nodes) {
|
|
163
|
+
if (!n.source_file) continue;
|
|
164
|
+
fileOfNode.set(n.id, n.source_file);
|
|
165
|
+
const list = symbolsOf.get(n.source_file);
|
|
166
|
+
if (list) list.push(n.label);
|
|
167
|
+
else symbolsOf.set(n.source_file, [n.label]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const usedByOf = /* @__PURE__ */ new Map();
|
|
171
|
+
const usesOf = /* @__PURE__ */ new Map();
|
|
172
|
+
if (graph) {
|
|
173
|
+
for (const e of graph.edges) {
|
|
174
|
+
const sf = fileOfNode.get(e.source);
|
|
175
|
+
const tf = fileOfNode.get(e.target);
|
|
176
|
+
if (!sf || !tf || sf === tf) continue;
|
|
177
|
+
let a = usedByOf.get(tf);
|
|
178
|
+
if (!a) {
|
|
179
|
+
a = /* @__PURE__ */ new Set();
|
|
180
|
+
usedByOf.set(tf, a);
|
|
181
|
+
}
|
|
182
|
+
a.add(sf);
|
|
183
|
+
let b = usesOf.get(sf);
|
|
184
|
+
if (!b) {
|
|
185
|
+
b = /* @__PURE__ */ new Set();
|
|
186
|
+
usesOf.set(sf, b);
|
|
187
|
+
}
|
|
188
|
+
b.add(tf);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const relatedOf = (file) => ({
|
|
192
|
+
usedBy: [...usedByOf.get(file) ?? []].slice(0, 12),
|
|
193
|
+
uses: [...usesOf.get(file) ?? []].slice(0, 12)
|
|
194
|
+
});
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
let content;
|
|
197
|
+
try {
|
|
198
|
+
content = await readFile(join(cwd, file), "utf8");
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const hash = hashContent(content);
|
|
203
|
+
const state = traceState(cwd, file, content, index, hash);
|
|
204
|
+
if (state === "too-large") {
|
|
205
|
+
skipped.push({ file, why: `${Math.round(content.length / 1e3)} KB \u2014 too large to trace economically` });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (state === "empty") continue;
|
|
209
|
+
if (state === "current") {
|
|
210
|
+
upToDate++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const rel = relatedOf(file);
|
|
214
|
+
jobs.push({ file, hash, content, symbols: (symbolsOf.get(file) ?? []).slice(0, 40), ...rel });
|
|
215
|
+
}
|
|
216
|
+
const estimatedInputTokens = Math.round(jobs.reduce((n, j) => n + j.content.length / CHARS_PER_TOKEN, 0) + jobs.length * 200);
|
|
217
|
+
return { jobs, upToDate, estimatedInputTokens, estimatedOutputTokens: jobs.length * TRACE_OUTPUT_TOKENS, skipped };
|
|
218
|
+
}
|
|
219
|
+
function tracePrompt(job, brief) {
|
|
220
|
+
const rel = [
|
|
221
|
+
job.symbols.length ? `Defines: ${job.symbols.join(", ")}` : "",
|
|
222
|
+
job.usedBy.length ? `Used by: ${job.usedBy.join(", ")}` : "Used by: nothing else in this repo",
|
|
223
|
+
job.uses.length ? `Uses: ${job.uses.join(", ")}` : ""
|
|
224
|
+
].filter(Boolean).join("\n");
|
|
225
|
+
const context = brief ? `What this project IS, from its own documentation \u2014 use this vocabulary and these rules; do not contradict them and do not repeat them back:
|
|
226
|
+
${brief}
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
` : "";
|
|
231
|
+
return `${context}Write a short reference note about ONE source file, for an engineer who has never seen this codebase and is about to change it.
|
|
232
|
+
|
|
233
|
+
Answer, in at most 150 words total, under these exact headings:
|
|
234
|
+
**Purpose** \u2014 what this file is responsible for, in one or two sentences. Say what it is FOR in the product's terms, not what its syntax does. "Decides which model a role gets when its first choice is rate-limited" is useful; "exports a function that returns an array" is not.
|
|
235
|
+
**Key decisions** \u2014 any non-obvious choice a reader would otherwise undo by accident. Omit the heading entirely if there are none; do not invent one.
|
|
236
|
+
**Careful** \u2014 what breaks elsewhere if this changes, based on the relationships given below. Omit if nothing does.
|
|
237
|
+
|
|
238
|
+
Rules:
|
|
239
|
+
- State only what the code and the relationships below actually show. If the business purpose is not evident, say what the file does technically and do NOT speculate about why.
|
|
240
|
+
- No preamble, no restating the filename, no summary of what you were asked.
|
|
241
|
+
|
|
242
|
+
File: ${job.file}
|
|
243
|
+
${rel}
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
${job.content}`;
|
|
247
|
+
}
|
|
248
|
+
async function saveTrace(cwd, job, body, model) {
|
|
249
|
+
const path = tracePath(cwd, job.file);
|
|
250
|
+
await mkdir(dirname(path), { recursive: true });
|
|
251
|
+
await writeFile(path, `# ${job.file}
|
|
252
|
+
|
|
253
|
+
${body.trim()}
|
|
254
|
+
`, "utf8");
|
|
255
|
+
return { hash: job.hash, file: job.file, writtenAt: Date.now(), ...model ? { model } : {} };
|
|
256
|
+
}
|
|
257
|
+
async function pruneTraces(cwd, liveFiles, index) {
|
|
258
|
+
const gone = Object.keys(index.traces).filter((f) => !liveFiles.has(f));
|
|
259
|
+
for (const f of gone) {
|
|
260
|
+
delete index.traces[f];
|
|
261
|
+
await rm(tracePath(cwd, f), { force: true });
|
|
262
|
+
}
|
|
263
|
+
return gone;
|
|
264
|
+
}
|
|
265
|
+
var GITIGNORE_MARKER = "# horse-code project knowledge";
|
|
266
|
+
var SHARED_DERIVED = [
|
|
267
|
+
/**
|
|
268
|
+
* The community names, and NOT the graph they name.
|
|
269
|
+
*
|
|
270
|
+
* The graph used to be here too, and it is the one thing in this list that cannot survive a merge. It is a
|
|
271
|
+
* single line of JSON — 33.7 MB of it on a real project — and git merges by line, so two branches that
|
|
272
|
+
* both rebuilt it have nothing to reconcile. Measured on PR 677: of twelve files touched by the merge,
|
|
273
|
+
* every one auto-merged except `graphify-out/graph.json` and the labels beside it, and those two blocked
|
|
274
|
+
* the pull request outright. Rebuilding it costs CPU and nothing else, `graphify update` is incremental,
|
|
275
|
+
* and a session gets its copy through INHERITED_ASSETS without git being involved at all.
|
|
276
|
+
*
|
|
277
|
+
* The names are the opposite on every count. graphify's clustering finds the communities on its own, but
|
|
278
|
+
* naming them is step 5 of its runbook and the step an LLM performs: "look at its node labels and write a
|
|
279
|
+
* 2-5 word plain-language name". `graph.json` stores each node's community NUMBER and nothing else —
|
|
280
|
+
* measured on a real project, not one of its 6,283 names appears anywhere in the graph file. So a clone
|
|
281
|
+
* that has the graph still reads "community 47" where this file says "Wallet Member & Balance", and
|
|
282
|
+
* getting that back means paying an LLM for the naming pass again.
|
|
283
|
+
*
|
|
284
|
+
* It merges, too: `pruneAreaNames` writes one key per line in numeric order precisely so that a change to
|
|
285
|
+
* one name is a change to one line. Two branches adding different names merge cleanly; only two branches
|
|
286
|
+
* naming the SAME community differently conflict, in 136 KB rather than 33.7 MB.
|
|
287
|
+
*/
|
|
288
|
+
{ path: "graphify-out/.graphify_labels.json", why: "# Shared \u2014 the community names an LLM wrote; the graph stores only their numbers." }
|
|
289
|
+
];
|
|
290
|
+
function localOnly() {
|
|
291
|
+
return [...LOCAL_ONLY];
|
|
292
|
+
}
|
|
293
|
+
function sharedDerived() {
|
|
294
|
+
return SHARED_DERIVED.map((s) => s.path);
|
|
295
|
+
}
|
|
296
|
+
var LOCAL_ONLY = [
|
|
297
|
+
"graphify-out/manifest.json",
|
|
298
|
+
"graphify-out/graph.html",
|
|
299
|
+
"graphify-out/.graphify_root",
|
|
300
|
+
// The graph itself: rebuilt per checkout, inherited by copy, and unmergeable by construction — see above.
|
|
301
|
+
"graphify-out/graph.json",
|
|
302
|
+
// …and the commit it was built at, which describes that local copy and travels with it.
|
|
303
|
+
"graphify-out/.graph-commit.json"
|
|
304
|
+
];
|
|
305
|
+
var NESTED_CHECKOUTS = [".claude/worktrees/", ".horsecode/worktrees/"];
|
|
306
|
+
function openDirectory(text, dir) {
|
|
307
|
+
return text.split("\n").map((l) => l.trim() === `${dir}/` ? l.replace(`${dir}/`, `${dir}/*`) : l).join("\n");
|
|
308
|
+
}
|
|
309
|
+
function planGitignore(current, root0 = ".") {
|
|
310
|
+
let text = current;
|
|
311
|
+
const rules = [];
|
|
312
|
+
const lines = () => text.split("\n").map((l) => l.trim());
|
|
313
|
+
const has = (rule) => lines().includes(rule);
|
|
314
|
+
const keep = (path, isDir, why) => {
|
|
315
|
+
const dir = path.split("/")[0];
|
|
316
|
+
const target = isDir ? `${path}/` : path;
|
|
317
|
+
const blocked = lines().some((l) => l === `${dir}/` || l === `${dir}/*` || l === dir || l === path);
|
|
318
|
+
if (!blocked || has(`!${target}`)) return;
|
|
319
|
+
text = openDirectory(text, dir);
|
|
320
|
+
rules.push(why, `!${target}`);
|
|
321
|
+
};
|
|
322
|
+
keep(
|
|
323
|
+
traceRootRel().replace(/\\/g, "/"),
|
|
324
|
+
true,
|
|
325
|
+
"# Shared \u2014 the traces describe the code, so every clone starts understanding the project instead of re-buying it."
|
|
326
|
+
);
|
|
327
|
+
for (const s of SHARED_DERIVED) keep(s.path, false, s.why);
|
|
328
|
+
const note = (comment) => {
|
|
329
|
+
if (!rules.includes(comment)) rules.push(comment);
|
|
330
|
+
};
|
|
331
|
+
const nested = NESTED_CHECKOUTS.filter((d) => existsSync(join(root0, d)) && !has(d) && !has(d.replace(/\/$/, "")));
|
|
332
|
+
if (nested.length) {
|
|
333
|
+
note("# Nested checkouts of this repository \u2014 committing one commits the repository into itself.");
|
|
334
|
+
rules.push(...nested);
|
|
335
|
+
}
|
|
336
|
+
const local = LOCAL_ONLY.filter((r) => !has(r) && !has("graphify-out/") && !has("graphify-out/*"));
|
|
337
|
+
if (local.length) {
|
|
338
|
+
note("# Machine-local or derived: an AST cache keyed by local mtimes, and a viewer regenerated on every build.");
|
|
339
|
+
rules.push(...local);
|
|
340
|
+
}
|
|
341
|
+
return { text, rules };
|
|
342
|
+
}
|
|
343
|
+
async function ensureGitignore(cwd) {
|
|
344
|
+
const path = join(cwd, ".gitignore");
|
|
345
|
+
let current = "";
|
|
346
|
+
try {
|
|
347
|
+
current = await readFile(path, "utf8");
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
const plan = planGitignore(current, cwd);
|
|
351
|
+
if (!plan.rules.length && plan.text === current) return false;
|
|
352
|
+
const heading = plan.text.includes(GITIGNORE_MARKER) ? "" : `${GITIGNORE_MARKER}
|
|
353
|
+
`;
|
|
354
|
+
const block = plan.rules.length ? `${heading}${plan.rules.join("\n")}
|
|
355
|
+
` : "";
|
|
356
|
+
const body = `${plan.text.trimEnd()}${plan.text.trim() && block ? "\n\n" : ""}${block}`;
|
|
357
|
+
await writeFile(path, body, "utf8");
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export {
|
|
362
|
+
TRACE_DIR,
|
|
363
|
+
setTraceRoot,
|
|
364
|
+
traceRootRel,
|
|
365
|
+
discoverTraceRoot,
|
|
366
|
+
TRACE_INDEX,
|
|
367
|
+
TRACEABLE_EXT,
|
|
368
|
+
everTraceable,
|
|
369
|
+
traceable,
|
|
370
|
+
traceDir,
|
|
371
|
+
tracePath,
|
|
372
|
+
hashContent,
|
|
373
|
+
loadTraceIndex,
|
|
374
|
+
mergeTraceIndexes,
|
|
375
|
+
parseTraceIndex,
|
|
376
|
+
serializeTraceIndex,
|
|
377
|
+
saveTraceIndex,
|
|
378
|
+
readTrace,
|
|
379
|
+
readTraceSync,
|
|
380
|
+
MAX_TRACE_FILE_CHARS,
|
|
381
|
+
TRACE_OUTPUT_TOKENS,
|
|
382
|
+
traceState,
|
|
383
|
+
traceCoverage,
|
|
384
|
+
planTraces,
|
|
385
|
+
tracePrompt,
|
|
386
|
+
saveTrace,
|
|
387
|
+
pruneTraces,
|
|
388
|
+
GITIGNORE_MARKER,
|
|
389
|
+
localOnly,
|
|
390
|
+
sharedDerived,
|
|
391
|
+
ensureGitignore
|
|
392
|
+
};
|