@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,69 @@
|
|
|
1
|
+
// src/mcp/client.ts
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
|
+
|
|
7
|
+
// src/version.ts
|
|
8
|
+
var VERSION = "0.0.0";
|
|
9
|
+
|
|
10
|
+
// src/mcp/client.ts
|
|
11
|
+
function renderContent(content) {
|
|
12
|
+
if (!Array.isArray(content)) return typeof content === "string" ? content : JSON.stringify(content);
|
|
13
|
+
return content.map((c) => {
|
|
14
|
+
const block = c;
|
|
15
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
16
|
+
return `[${block.type ?? "content"}]`;
|
|
17
|
+
}).join("\n");
|
|
18
|
+
}
|
|
19
|
+
function clientFor() {
|
|
20
|
+
return new Client({ name: "horse-code", version: VERSION }, { capabilities: {} });
|
|
21
|
+
}
|
|
22
|
+
async function connectRemote(url, headers) {
|
|
23
|
+
const target = new URL(url);
|
|
24
|
+
const init = headers ? { requestInit: { headers } } : void 0;
|
|
25
|
+
try {
|
|
26
|
+
const client = clientFor();
|
|
27
|
+
await client.connect(new StreamableHTTPClientTransport(target, init));
|
|
28
|
+
return client;
|
|
29
|
+
} catch {
|
|
30
|
+
const client = clientFor();
|
|
31
|
+
await client.connect(new SSEClientTransport(target, init));
|
|
32
|
+
return client;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function connectMcpServer(name, spec) {
|
|
36
|
+
const client = "command" in spec ? await (async () => {
|
|
37
|
+
const c = clientFor();
|
|
38
|
+
await c.connect(new StdioClientTransport({
|
|
39
|
+
command: spec.command[0],
|
|
40
|
+
args: spec.command.slice(1),
|
|
41
|
+
env: { ...process.env, ...spec.env ?? {} }
|
|
42
|
+
}));
|
|
43
|
+
return c;
|
|
44
|
+
})() : await connectRemote(spec.url, spec.headers);
|
|
45
|
+
const listed = await client.listTools();
|
|
46
|
+
const declaredReadOnly = spec.readOnly === true;
|
|
47
|
+
const tools = listed.tools.map((t) => ({
|
|
48
|
+
name: t.name,
|
|
49
|
+
description: t.description,
|
|
50
|
+
inputSchema: t.inputSchema,
|
|
51
|
+
readOnly: declaredReadOnly || t.annotations?.readOnlyHint === true
|
|
52
|
+
}));
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
tools,
|
|
56
|
+
async callTool(tool, args) {
|
|
57
|
+
const res = await client.callTool({ name: tool, arguments: args });
|
|
58
|
+
return { content: renderContent(res.content), isError: res.isError === true };
|
|
59
|
+
},
|
|
60
|
+
async close() {
|
|
61
|
+
await client.close().catch(() => {
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
connectMcpServer
|
|
69
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contextTools,
|
|
3
|
+
globTool,
|
|
4
|
+
grepTool,
|
|
5
|
+
memoryHints,
|
|
6
|
+
readFileTool,
|
|
7
|
+
reinforceUsed
|
|
8
|
+
} from "./chunk-TOPZL5SU.js";
|
|
9
|
+
import {
|
|
10
|
+
ToolRegistry,
|
|
11
|
+
runStructuredRole
|
|
12
|
+
} from "./chunk-YILDXPSI.js";
|
|
13
|
+
|
|
14
|
+
// src/engine/triage.ts
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
var SIZE_MAX_TURNS = 12;
|
|
17
|
+
var SIZE_TOTAL_MS = 18e4;
|
|
18
|
+
var SIZE_ATTEMPT_MS = 9e4;
|
|
19
|
+
var TriageSchema = z.object({
|
|
20
|
+
depth: z.enum(["task", "brainstorm", "full"]).describe(
|
|
21
|
+
"How much process this finding deserves, and therefore what it costs. `task`: the fix is obvious once seen \u2014 go straight to it. `brainstorm`: something has to be DECIDED before anyone writes code. `full`: the finding reaches beyond itself and needs a specification. Prefer the smallest depth the evidence supports: buying a spec for a label is waste."
|
|
22
|
+
),
|
|
23
|
+
reason: z.string().describe("One sentence: what about this finding puts it at that depth.")
|
|
24
|
+
});
|
|
25
|
+
var PROMPT = "You size a defect found during manual testing, to decide how much process it deserves. You are not fixing it and you are not designing the fix.\n\nRead the code before you answer \u2014 the same words describe a one-line label fix and a rework of how a screen gets its data, and only the code says which this is.\n\n`task`: the change is known and contained. A wrong label, a missing field on a screen that already has the data, a formatting bug, a validation that is checking the wrong thing. Most findings are this.\n\n`brainstorm`: the fix is not obvious, or there is more than one reasonable way and they differ in a way the user would care about. Decide WITH them rather than guessing.\n\n`full`: this is not a fix. It is a piece of work that testing happened to discover \u2014 a capability that was never built, a design that does not hold, something that touches several parts of the system.\n\nPrefer the smallest depth the evidence supports. Buying a spec for a label is waste; but a `task` that turns out to be a redesign is worse, because it is written and reviewed as if it were understood.";
|
|
26
|
+
async function triageFinding(deps, workdir, f) {
|
|
27
|
+
const tools = new ToolRegistry();
|
|
28
|
+
tools.register(readFileTool);
|
|
29
|
+
tools.register(grepTool);
|
|
30
|
+
tools.register(globTool);
|
|
31
|
+
for (const t of contextTools(deps)) tools.register(t);
|
|
32
|
+
const { role: agentRole, model, fallbacks, onExhausted, onFallback } = deps.roleRegistry.fallbackOpts("analyst");
|
|
33
|
+
const message = `A defect was found while manually testing existing work.
|
|
34
|
+
|
|
35
|
+
Title: ${f.title}
|
|
36
|
+
|
|
37
|
+
What was seen:
|
|
38
|
+
${f.detail}
|
|
39
|
+
` + (f.files.length ? `
|
|
40
|
+
Files it appears to involve:
|
|
41
|
+
${f.files.map((x) => `- ${x}`).join("\n")}
|
|
42
|
+
` : "") + (f.acceptance.length ? `
|
|
43
|
+
What must be true for it to be settled:
|
|
44
|
+
${f.acceptance.map((x) => `- ${x}`).join("\n")}
|
|
45
|
+
` : "") + `
|
|
46
|
+
Size it.`;
|
|
47
|
+
const hints = memoryHints(deps, `${f.title} ${f.detail} ${f.files.join(" ")}`, { role: "analyst" });
|
|
48
|
+
const opts = {
|
|
49
|
+
provider: deps.provider,
|
|
50
|
+
role: agentRole,
|
|
51
|
+
model,
|
|
52
|
+
fallbacks,
|
|
53
|
+
onExhausted,
|
|
54
|
+
onFallback,
|
|
55
|
+
// A role that DECIDES has to know the user's standing rules — see test/agent/rules-reach-roles.test.ts.
|
|
56
|
+
systemPrompt: PROMPT + deps.roleRegistry.ruleSuffix(),
|
|
57
|
+
tools,
|
|
58
|
+
maxTurns: SIZE_MAX_TURNS,
|
|
59
|
+
perAttemptMs: SIZE_ATTEMPT_MS,
|
|
60
|
+
totalMs: SIZE_TOTAL_MS,
|
|
61
|
+
messages: hints.message ? [{ role: "user", content: hints.message }, { role: "user", content: message }] : [{ role: "user", content: message }],
|
|
62
|
+
permission: deps.permission,
|
|
63
|
+
approve: deps.approve,
|
|
64
|
+
cwd: workdir,
|
|
65
|
+
signal: deps.signal
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const t = await runStructuredRole(opts, TriageSchema);
|
|
69
|
+
reinforceUsed(deps, hints.ids, t.reason, "analyst");
|
|
70
|
+
return t;
|
|
71
|
+
} catch {
|
|
72
|
+
return { depth: "task", reason: "the triage could not run; treated as a contained change" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function describeEscalation(f, t) {
|
|
76
|
+
const what = t.depth === "brainstorm" ? "decide the approach with you first, then implement it" : "write a spec and a plan for it, then break it into tasks";
|
|
77
|
+
return `**${f.title}**
|
|
78
|
+
|
|
79
|
+
This is bigger than a single fix \u2014 ${t.reason}
|
|
80
|
+
|
|
81
|
+
I would ${what}.`;
|
|
82
|
+
}
|
|
83
|
+
var RequestSizeSchema = z.object({
|
|
84
|
+
verdict: z.enum(["small", "large", "unsure"]).describe(
|
|
85
|
+
"How big the change is, which decides whether the whole spec/plan pipeline runs. `small`: contained to a file or two with nothing to DECIDE once you have read the code. `large`: it needs designing, or it spreads across the system. `unsure`: the code did not tell you \u2014 say so rather than guessing, because a wrong `small` skips every gate and a wrong `large` buys a specification for a typo."
|
|
86
|
+
),
|
|
87
|
+
reason: z.string().describe("One sentence: what makes it that size, or what you are unsure about."),
|
|
88
|
+
acceptance: z.array(z.string()).default([]).describe("If small or unsure: what must be true when it is done. One checkable statement each."),
|
|
89
|
+
files: z.array(z.string()).default([]).describe("If small or unsure: the repo-relative files it touches.")
|
|
90
|
+
});
|
|
91
|
+
var SIZE_PROMPT = 'You size a change request, to decide how much process it deserves. You are not doing the work and you are not designing it.\n\nRead the code first. The same sentence describes a one-line style change and a rework of a component, and only the code says which this is. You are sizing, not implementing: stop as soon as you can tell which side of the line it falls on.\n\n`small`: the change is contained to a file or two and there is nothing to DECIDE \u2014 what to do follows from the request once you have seen the code. Centre an icon. Change a colour. Fix a typo. Rename a label. Correct a format string. Add a missing field to a screen that already has the data. Clamp a line count. Most requests a person types in one sentence are this.\n\nIt is still small when it needs a test, a small refactor to make room, or touching a second file that the first one obviously depends on. Small means CONTAINED and DECIDED, not trivial.\n\n`large`: a new capability, a design that has to be settled with the user, or a change reaching across several parts of the system. Those get the full pipeline, which exists to work out WHAT to build.\n\n`unsure`: you cannot tell from the code which of those it is. Say so. The developer is sitting there and one question costs them a sentence.\n\nDo NOT resolve doubt by choosing `large`. A mistake on the small path is cheap \u2014 the change is written, reviewed by a code reviewer, and checked against the acceptance criteria you give below, all in front of someone watching. A mistake the other way costs an hour and a half of spec, plan, board and review panels. Measured: one request called `large` produced 507 model calls over 94 minutes for a fix the developer described as a simple UI change.\n\nFor `small` or `unsure`, give the acceptance criteria and the files. Those are what the change will be judged against, so they must be checkable by looking at the result \u2014 not "the icon looks better".';
|
|
92
|
+
function describeSizeDoubt(prompt, reason) {
|
|
93
|
+
return `**${prompt}**
|
|
94
|
+
|
|
95
|
+
I cannot tell how big this is from the code \u2014 ${reason}
|
|
96
|
+
|
|
97
|
+
As a small change it goes straight to an implementer, is reviewed, and is checked against acceptance criteria: no branch, no spec, no plan. As a full piece of work it gets a spec, a plan and a task board first, which is right when the approach has to be worked out and expensive when it does not.`;
|
|
98
|
+
}
|
|
99
|
+
async function sizeRequest(deps, workdir, prompt) {
|
|
100
|
+
const tools = new ToolRegistry();
|
|
101
|
+
tools.register(readFileTool);
|
|
102
|
+
tools.register(grepTool);
|
|
103
|
+
tools.register(globTool);
|
|
104
|
+
for (const t of contextTools(deps)) tools.register(t);
|
|
105
|
+
const { role: agentRole, model, fallbacks, onExhausted, onFallback } = deps.roleRegistry.fallbackOpts("analyst");
|
|
106
|
+
const opts = {
|
|
107
|
+
provider: deps.provider,
|
|
108
|
+
role: agentRole,
|
|
109
|
+
model,
|
|
110
|
+
fallbacks,
|
|
111
|
+
onExhausted,
|
|
112
|
+
onFallback,
|
|
113
|
+
systemPrompt: SIZE_PROMPT + deps.roleRegistry.ruleSuffix(),
|
|
114
|
+
tools,
|
|
115
|
+
maxTurns: SIZE_MAX_TURNS,
|
|
116
|
+
perAttemptMs: SIZE_ATTEMPT_MS,
|
|
117
|
+
totalMs: SIZE_TOTAL_MS,
|
|
118
|
+
messages: [{ role: "user", content: `Request: "${prompt}"
|
|
119
|
+
|
|
120
|
+
Size it.` }],
|
|
121
|
+
permission: deps.permission,
|
|
122
|
+
approve: deps.approve,
|
|
123
|
+
cwd: workdir,
|
|
124
|
+
signal: deps.signal
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
const r = await runStructuredRole(opts, RequestSizeSchema);
|
|
128
|
+
if (r.verdict === "small" && !r.acceptance.length) {
|
|
129
|
+
return { ...r, verdict: "unsure", reason: `${r.reason} \u2014 but no checkable criteria came out of it` };
|
|
130
|
+
}
|
|
131
|
+
return r;
|
|
132
|
+
} catch {
|
|
133
|
+
return { verdict: "unsure", reason: "I could not work out its size from the code in the time allowed", acceptance: [], files: [] };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
SIZE_MAX_TURNS,
|
|
139
|
+
SIZE_TOTAL_MS,
|
|
140
|
+
SIZE_ATTEMPT_MS,
|
|
141
|
+
TriageSchema,
|
|
142
|
+
triageFinding,
|
|
143
|
+
describeEscalation,
|
|
144
|
+
RequestSizeSchema,
|
|
145
|
+
describeSizeDoubt,
|
|
146
|
+
sizeRequest
|
|
147
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/session/atomic.ts
|
|
2
|
+
import { rename, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { renameSync, rmSync, writeFileSync } from "fs";
|
|
4
|
+
var seq = 0;
|
|
5
|
+
var tmpName = (path) => `${path}.${process.pid}.${seq++}.tmp`;
|
|
6
|
+
async function writeAtomic(path, data) {
|
|
7
|
+
const tmp = tmpName(path);
|
|
8
|
+
try {
|
|
9
|
+
await writeFile(tmp, data, "utf8");
|
|
10
|
+
await rename(tmp, path);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
await rm(tmp, { force: true }).catch(() => {
|
|
13
|
+
});
|
|
14
|
+
throw e;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function writeAtomicSync(path, data) {
|
|
18
|
+
const tmp = tmpName(path);
|
|
19
|
+
try {
|
|
20
|
+
writeFileSync(tmp, data, "utf8");
|
|
21
|
+
renameSync(tmp, path);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
try {
|
|
24
|
+
rmSync(tmp, { force: true });
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
writeAtomic,
|
|
33
|
+
writeAtomicSync
|
|
34
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/skills/frontmatter.ts
|
|
2
|
+
function parseFrontmatter(raw) {
|
|
3
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
4
|
+
if (!m) return { body: raw };
|
|
5
|
+
const [, fm, body] = m;
|
|
6
|
+
const read = (key) => {
|
|
7
|
+
const line = fm.split(/\r?\n/).find((l) => l.trimStart().startsWith(`${key}:`));
|
|
8
|
+
if (!line) return void 0;
|
|
9
|
+
let v = line.slice(line.indexOf(":") + 1).trim();
|
|
10
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
|
|
11
|
+
v = v.slice(1, -1);
|
|
12
|
+
}
|
|
13
|
+
return v;
|
|
14
|
+
};
|
|
15
|
+
return { name: read("name"), description: read("description"), body };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
parseFrontmatter
|
|
20
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
arrayField,
|
|
3
|
+
objectField,
|
|
4
|
+
patchConfig
|
|
5
|
+
} from "./chunk-H2FDGPVW.js";
|
|
6
|
+
|
|
7
|
+
// src/config/save-skills.ts
|
|
8
|
+
async function saveSkillSource(home, src) {
|
|
9
|
+
return patchConfig(home, (current) => {
|
|
10
|
+
const sources = arrayField(current, "skillSources").filter(
|
|
11
|
+
(s) => !(typeof s === "object" && s !== null && s.name === src.name)
|
|
12
|
+
);
|
|
13
|
+
return { ...current, skillSources: [...sources, src] };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function removeSkillSource(home, name) {
|
|
17
|
+
let found = false;
|
|
18
|
+
const ok = await patchConfig(home, (current) => {
|
|
19
|
+
const sources = arrayField(current, "skillSources");
|
|
20
|
+
const kept = sources.filter((s) => {
|
|
21
|
+
const match = typeof s === "object" && s !== null && s.name === name;
|
|
22
|
+
if (match) found = true;
|
|
23
|
+
return !match;
|
|
24
|
+
});
|
|
25
|
+
return found ? { ...current, skillSources: kept } : void 0;
|
|
26
|
+
});
|
|
27
|
+
return ok && found;
|
|
28
|
+
}
|
|
29
|
+
async function saveRoleSkills(home, assignments) {
|
|
30
|
+
const names = Object.keys(assignments);
|
|
31
|
+
if (!names.length) return 0;
|
|
32
|
+
const ok = await patchConfig(home, (current) => {
|
|
33
|
+
const roles = objectField(current, "roles");
|
|
34
|
+
for (const role of names) {
|
|
35
|
+
const prev = typeof roles[role] === "object" && roles[role] !== null ? roles[role] : {};
|
|
36
|
+
roles[role] = { ...prev, skills: [...assignments[role]] };
|
|
37
|
+
}
|
|
38
|
+
return { ...current, roles };
|
|
39
|
+
});
|
|
40
|
+
return ok ? names.length : 0;
|
|
41
|
+
}
|
|
42
|
+
async function saveMcpServer(home, name, spec) {
|
|
43
|
+
return patchConfig(home, (current) => ({
|
|
44
|
+
...current,
|
|
45
|
+
mcp: { ...objectField(current, "mcp"), [name]: spec }
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
saveSkillSource,
|
|
51
|
+
removeSkillSource,
|
|
52
|
+
saveRoleSkills,
|
|
53
|
+
saveMcpServer
|
|
54
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hashContent,
|
|
3
|
+
traceDir
|
|
4
|
+
} from "./chunk-FFYBY2NA.js";
|
|
5
|
+
|
|
6
|
+
// src/engine/project-brief.ts
|
|
7
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
var BRIEF_FILE = "PROJECT.md";
|
|
11
|
+
var BRIEF_META = "PROJECT.json";
|
|
12
|
+
var DOC_PATTERNS = [
|
|
13
|
+
/^readme(\.md)?$/i,
|
|
14
|
+
/**
|
|
15
|
+
* A project's own index, before anything else under `docs/`.
|
|
16
|
+
*
|
|
17
|
+
* Selection was pattern order then file order, so `docs/` was read alphabetically: on a real repository
|
|
18
|
+
* that meant the brief would have been built from BACKUP_STRATEGY, DEPLOYMENT_GUIDE and an e-invoicing
|
|
19
|
+
* appendix, while `docs/architecture/00-INDEX.md` — the document that actually says what the system is —
|
|
20
|
+
* never reached the budget. An index is the one document written to be read first.
|
|
21
|
+
*/
|
|
22
|
+
/(^|\/)(00-)?index\.md$/i,
|
|
23
|
+
/(^|\/)(architecture|overview)\.md$/i,
|
|
24
|
+
/^(docs?|specs?)\/.*\.mde?$/i,
|
|
25
|
+
/^\.specify\/memory\/constitution\.md$/i,
|
|
26
|
+
/^specs\/[^/]+\/(spec|brainstorm|plan)\.md$/i,
|
|
27
|
+
/^(contributing|architecture|design|domain|glossary)\.md$/i,
|
|
28
|
+
/^docs?\/.*\.(md|txt)$/i
|
|
29
|
+
];
|
|
30
|
+
var MAX_BRIEF_INPUT_CHARS = 12e4;
|
|
31
|
+
var MAX_DOC_CHARS = 2e4;
|
|
32
|
+
function briefPath(cwd) {
|
|
33
|
+
return join(traceDir(cwd), BRIEF_FILE);
|
|
34
|
+
}
|
|
35
|
+
function selectDocs(files, excluded = /* @__PURE__ */ new Set()) {
|
|
36
|
+
const seen = /* @__PURE__ */ new Set();
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const pattern of DOC_PATTERNS) {
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
if (seen.has(f) || !pattern.test(f)) continue;
|
|
41
|
+
if (/(^|\/)(node_modules|dist|build|vendor|\.horsecode|graphify-out|CHANGELOG)/i.test(f)) continue;
|
|
42
|
+
if (excluded.has(f)) continue;
|
|
43
|
+
seen.add(f);
|
|
44
|
+
out.push(f);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
async function gatherBriefInput(cwd, files) {
|
|
50
|
+
const picked = selectDocs(files, await ourOwnOutput(cwd));
|
|
51
|
+
const sources = [];
|
|
52
|
+
let chars = 0;
|
|
53
|
+
for (const file of picked) {
|
|
54
|
+
if (chars >= MAX_BRIEF_INPUT_CHARS) break;
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(join(cwd, file), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!text.trim()) continue;
|
|
62
|
+
const clipped = text.length > MAX_DOC_CHARS ? `${text.slice(0, MAX_DOC_CHARS)}
|
|
63
|
+
\u2026[truncated]` : text;
|
|
64
|
+
sources.push({ file, text: clipped });
|
|
65
|
+
chars += clipped.length;
|
|
66
|
+
}
|
|
67
|
+
if (!sources.length) return void 0;
|
|
68
|
+
return { sources, hash: hashContent(sources.map((s) => `${s.file}:${s.text}`).join("\n")), chars };
|
|
69
|
+
}
|
|
70
|
+
async function ourOwnOutput(cwd) {
|
|
71
|
+
const out = /* @__PURE__ */ new Set();
|
|
72
|
+
try {
|
|
73
|
+
const { loadTraceIndex, traceRootRel } = await import("./trace-ZMB7LT7W.js");
|
|
74
|
+
const root = traceRootRel().replace(/\\/g, "/");
|
|
75
|
+
out.add(`${root}/PROJECT.md`);
|
|
76
|
+
out.add(`${root}/PROJECT.json`);
|
|
77
|
+
const index = await loadTraceIndex(cwd);
|
|
78
|
+
for (const [file, rec] of Object.entries(index.traces)) if (!rec.doc) out.add(`${root}/${file}.md`);
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function briefPrompt(input) {
|
|
84
|
+
const body = input.sources.map((s) => `### ${s.file}
|
|
85
|
+
${s.text}`).join("\n\n");
|
|
86
|
+
return `Read this project's own documentation and write the briefing an engineer would want on their first day, before touching any code.
|
|
87
|
+
|
|
88
|
+
At most 400 words, under these exact headings:
|
|
89
|
+
**What it is** \u2014 the product, in one or two sentences: what it does and for whom.
|
|
90
|
+
**Domain concepts** \u2014 the nouns this project's people use and what each one means here. Only terms the documents actually define or use consistently; this is a glossary, not a summary.
|
|
91
|
+
**Rules that matter** \u2014 business rules, constraints and invariants stated in the documents that code must not violate. Quote the constraint, not your paraphrase of its importance.
|
|
92
|
+
**Deliberate choices** \u2014 decisions the documents record along with their reasons, so they are not undone by someone who never saw the reason.
|
|
93
|
+
|
|
94
|
+
Rules:
|
|
95
|
+
- Use ONLY what these documents state. If they do not say who the users are, do not invent them.
|
|
96
|
+
- Omit a heading entirely rather than filling it with something plausible.
|
|
97
|
+
- No preamble and no closing summary.
|
|
98
|
+
|
|
99
|
+
Documents:
|
|
100
|
+
|
|
101
|
+
${body}`;
|
|
102
|
+
}
|
|
103
|
+
async function saveBrief(cwd, body, meta) {
|
|
104
|
+
await mkdir(traceDir(cwd), { recursive: true });
|
|
105
|
+
await writeFile(briefPath(cwd), `# Project brief
|
|
106
|
+
|
|
107
|
+
${body.trim()}
|
|
108
|
+
`, "utf8");
|
|
109
|
+
await writeFile(join(traceDir(cwd), BRIEF_META), `${JSON.stringify(meta, null, 2)}
|
|
110
|
+
`, "utf8");
|
|
111
|
+
}
|
|
112
|
+
async function loadBriefMeta(cwd) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(await readFile(join(traceDir(cwd), BRIEF_META), "utf8"));
|
|
115
|
+
} catch {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function readBriefSync(cwd) {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(briefPath(cwd), "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
var MAX_BRIEF_IN_PROMPT = 1200;
|
|
127
|
+
function briefForPrompt(cwd) {
|
|
128
|
+
const b = readBriefSync(cwd);
|
|
129
|
+
if (!b) return void 0;
|
|
130
|
+
const body = b.replace(/^# Project brief\s*/, "").trim();
|
|
131
|
+
return body.length > MAX_BRIEF_IN_PROMPT ? `${body.slice(0, MAX_BRIEF_IN_PROMPT)}\u2026` : body;
|
|
132
|
+
}
|
|
133
|
+
async function briefStatus(cwd, files) {
|
|
134
|
+
const meta = await loadBriefMeta(cwd);
|
|
135
|
+
if (!meta || !readBriefSync(cwd)) return { built: false, stale: false, sources: [], changed: [] };
|
|
136
|
+
const now = await gatherBriefInput(cwd, files);
|
|
137
|
+
if (!now) return { built: true, stale: false, sources: meta.sources, ...meta.writtenAt ? { builtAt: meta.writtenAt } : {}, changed: [] };
|
|
138
|
+
const stale = now.hash !== meta.hash;
|
|
139
|
+
const before = new Set(meta.sources);
|
|
140
|
+
const changed = stale ? [
|
|
141
|
+
...now.sources.filter((s) => !before.has(s.file)).map((s) => s.file),
|
|
142
|
+
...meta.sources.filter((f) => !now.sources.some((s) => s.file === f)).map((f) => `${f} (gone)`)
|
|
143
|
+
] : [];
|
|
144
|
+
return {
|
|
145
|
+
built: true,
|
|
146
|
+
stale,
|
|
147
|
+
sources: meta.sources,
|
|
148
|
+
...meta.writtenAt ? { builtAt: meta.writtenAt } : {},
|
|
149
|
+
// An edit inside an unchanged set of files changes the hash without changing the list; say so rather
|
|
150
|
+
// than reporting an empty reason.
|
|
151
|
+
changed: stale && !changed.length ? ["the documents were edited"] : changed
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
gatherBriefInput,
|
|
157
|
+
briefPrompt,
|
|
158
|
+
saveBrief,
|
|
159
|
+
readBriefSync,
|
|
160
|
+
briefForPrompt,
|
|
161
|
+
briefStatus
|
|
162
|
+
};
|