@next-generation-collective/factory 0.0.0-stage → 0.3.1
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 +78 -2
- package/bin/factory.js +9 -0
- package/lib/agent.js +121 -0
- package/lib/agents.js +42 -0
- package/lib/analyse.js +252 -0
- package/lib/approval.js +72 -0
- package/lib/checks.js +454 -0
- package/lib/cli.js +182 -0
- package/lib/commands.js +1229 -0
- package/lib/config.js +97 -0
- package/lib/detect.js +184 -0
- package/lib/fixes.js +90 -0
- package/lib/interview.js +64 -0
- package/lib/journeys.js +222 -0
- package/lib/prompts.js +343 -0
- package/lib/review.js +151 -0
- package/lib/spec.js +136 -0
- package/lib/util.js +109 -0
- package/lib/verify.js +149 -0
- package/package.json +39 -4
- package/template/constitution.md +204 -0
- package/template/lesson.md +20 -0
- package/template/plan.md +52 -0
- package/template/review-rules.md +88 -0
- package/template/roles/builder.md +12 -0
- package/template/roles/planner.md +15 -0
- package/template/roles/reviewer.md +15 -0
- package/template/roles/shipper.md +12 -0
- package/template/roles/tester.md +13 -0
- package/template/spec.md +80 -0
- package/template/tasks.md +19 -0
package/lib/prompts.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { section } from "./approval.js";
|
|
5
|
+
import { paths } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEMPLATES = join(HERE, "..", "template");
|
|
9
|
+
|
|
10
|
+
export const ROLES = ["planner", "builder", "tester", "reviewer", "shipper"];
|
|
11
|
+
|
|
12
|
+
export function readRole(root, name) {
|
|
13
|
+
const local = join(paths(root).roles, `${name}.md`);
|
|
14
|
+
const fallback = join(TEMPLATES, "roles", `${name}.md`);
|
|
15
|
+
const file = existsSync(local) ? local : fallback;
|
|
16
|
+
return existsSync(file) ? readFileSync(file, "utf8").replace(/\r\n/g, "\n").trim() : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readTemplate(root, name) {
|
|
20
|
+
const local = join(paths(root).templates, name);
|
|
21
|
+
const fallback = join(TEMPLATES, name);
|
|
22
|
+
const file = existsSync(local) ? local : fallback;
|
|
23
|
+
return existsSync(file) ? readFileSync(file, "utf8").replace(/\r\n/g, "\n").trim() : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function roleBlock(root) {
|
|
27
|
+
return ROLES.map((name) => {
|
|
28
|
+
const body = readRole(root, name);
|
|
29
|
+
return body ? `## ${name[0].toUpperCase()}${name.slice(1)}\n\n${body}` : "";
|
|
30
|
+
})
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ANSWER_LABELS = {
|
|
36
|
+
problem: "Problem",
|
|
37
|
+
who: "Who feels it",
|
|
38
|
+
outcome: "What working means",
|
|
39
|
+
never: "Must never break",
|
|
40
|
+
people: "Others affected",
|
|
41
|
+
states: "Worries",
|
|
42
|
+
out: "Out of scope",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function personBlock(answers) {
|
|
46
|
+
const lines = Object.entries(answers ?? {})
|
|
47
|
+
.filter(([, value]) => String(value ?? "").trim())
|
|
48
|
+
.map(([key, value]) => `- ${ANSWER_LABELS[key] ?? key}: ${String(value).trim()}`);
|
|
49
|
+
return lines.length
|
|
50
|
+
? lines.join("\n")
|
|
51
|
+
: "The person skipped the questions. Decide, and record every assumption you make.";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const QUICK_LANE = `This is a small fix. Keep the spec short: Problem, What must never break, Acceptance
|
|
55
|
+
checks (each with its Proof:), Assumptions, Out of scope. Answer States and Expected baseline
|
|
56
|
+
only if the fix changes what a person sees; otherwise write one line saying so.`;
|
|
57
|
+
|
|
58
|
+
export function composeSpecPrompt(root, config, title, target, context = {}) {
|
|
59
|
+
const template = readTemplate(root, "spec.md");
|
|
60
|
+
const brief = context.analysis?.brief?.trim() || "No project files were found.";
|
|
61
|
+
return `You are the Planner for "${config.name}".
|
|
62
|
+
|
|
63
|
+
Read these before you write anything:
|
|
64
|
+
- .factory/constitution.md (the rules; they win over this prompt)
|
|
65
|
+
- factory.config.json (what this project is, who depends on it, what must never break)
|
|
66
|
+
- the code this job touches
|
|
67
|
+
|
|
68
|
+
## What the repo already shows
|
|
69
|
+
|
|
70
|
+
${brief}
|
|
71
|
+
|
|
72
|
+
## What the person said
|
|
73
|
+
|
|
74
|
+
${personBlock(context.answers)}
|
|
75
|
+
|
|
76
|
+
## The job
|
|
77
|
+
|
|
78
|
+
${title}
|
|
79
|
+
|
|
80
|
+
## Write the spec here
|
|
81
|
+
|
|
82
|
+
\`${target}\`
|
|
83
|
+
|
|
84
|
+
Fill every section of this template from the repo and the job line. Replace every
|
|
85
|
+
placeholder.
|
|
86
|
+
${context.lane === "quick" ? `\n${QUICK_LANE}\n` : ""}
|
|
87
|
+
${template}
|
|
88
|
+
|
|
89
|
+
## Rules
|
|
90
|
+
|
|
91
|
+
- Do not ask the person anything now. Decide from the code and the job line.
|
|
92
|
+
- Where you had to guess, start the line with \`Assumed:\`. The person reads every one
|
|
93
|
+
before approving.
|
|
94
|
+
- Put only the decisions the person must make under Open questions, at most five.
|
|
95
|
+
- Every acceptance check must be provable by a machine from a known state, and names its
|
|
96
|
+
proof. "Sending..." on screen is not proof; the result on the other side is.
|
|
97
|
+
- Prove acceptance checks with the project's tests (run by the factory) or with something a
|
|
98
|
+
reviewer can see by reading the code. The factory runs the tests, build and checks and
|
|
99
|
+
records the evidence itself. Do not make an acceptance check out of git chores (staging,
|
|
100
|
+
committing, tags) or out of evidence files someone must produce by running commands:
|
|
101
|
+
committing is the person's job after the review.
|
|
102
|
+
- If the repo already does part of this, say so and reuse it.
|
|
103
|
+
- No code, no plan, no task list.
|
|
104
|
+
- Plain language. British/South African spelling. Dates as YYYY-MM-DD.
|
|
105
|
+
- Return one line when the file is written, and nothing else.
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function composePlanPrompt(root, config, name, specText, target) {
|
|
110
|
+
const template = readTemplate(root, "plan.md");
|
|
111
|
+
return `You are the Planner for "${config.name}".
|
|
112
|
+
|
|
113
|
+
Read \`.factory/constitution.md\` and \`factory.config.json\` first.
|
|
114
|
+
|
|
115
|
+
## The approved spec
|
|
116
|
+
|
|
117
|
+
\`specs/${name}/spec.md\`
|
|
118
|
+
|
|
119
|
+
${specText}
|
|
120
|
+
|
|
121
|
+
## Write the plan here
|
|
122
|
+
|
|
123
|
+
\`${target}\`
|
|
124
|
+
|
|
125
|
+
Fill in this template. Replace every placeholder.
|
|
126
|
+
|
|
127
|
+
${template}
|
|
128
|
+
|
|
129
|
+
## Additional rules
|
|
130
|
+
|
|
131
|
+
- Walk the reuse ladder in the constitution. Say what already exists and why it was or was
|
|
132
|
+
not used. If you propose a substantial new component, say why rungs 1 to 6 failed.
|
|
133
|
+
- State the schema, every index and what it is for, and what would be painful to change
|
|
134
|
+
once real people are in it.
|
|
135
|
+
- State who can read or write each thing, where secrets live, and what personal data is
|
|
136
|
+
stored, why, and for how long.
|
|
137
|
+
- The plan must not add a state the spec did not answer. If the spec is missing something,
|
|
138
|
+
stop and say what is missing.
|
|
139
|
+
- Every acceptance check in the spec gets a row in "One check per acceptance check".
|
|
140
|
+
- Plain language. Dates as YYYY-MM-DD.
|
|
141
|
+
- Return one line when the file is written, and nothing else.
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function composeTasksPrompt(root, config, name, specText, planText, target) {
|
|
146
|
+
const template = readTemplate(root, "tasks.md");
|
|
147
|
+
return `You are the Planner for "${config.name}".
|
|
148
|
+
|
|
149
|
+
Read \`.factory/constitution.md\` first.
|
|
150
|
+
|
|
151
|
+
## The approved spec
|
|
152
|
+
|
|
153
|
+
${specText}
|
|
154
|
+
|
|
155
|
+
## The plan
|
|
156
|
+
|
|
157
|
+
${planText}
|
|
158
|
+
|
|
159
|
+
## Write the task list here
|
|
160
|
+
|
|
161
|
+
\`${target}\`
|
|
162
|
+
|
|
163
|
+
Fill in this template. Replace every placeholder.
|
|
164
|
+
|
|
165
|
+
${template}
|
|
166
|
+
|
|
167
|
+
## Additional rules
|
|
168
|
+
|
|
169
|
+
- Every task is a reviewable change with an exact file path.
|
|
170
|
+
- Every task says what proves it: \`Proves: <the check that shows the outcome>\`.
|
|
171
|
+
A task whose proof is "it looks right" is not a task.
|
|
172
|
+
- Proves: lines name tests or the factory's checks. Do not ask for evidence files made by
|
|
173
|
+
running commands, and do not make staging or committing a task; the factory records the
|
|
174
|
+
evidence and the person commits after the review.
|
|
175
|
+
- The last task is: the factory's checks pass.
|
|
176
|
+
- Order by risk: the uncertain part first, not last.
|
|
177
|
+
- Plain language. Dates as YYYY-MM-DD.
|
|
178
|
+
- Return one line when the file is written, and nothing else.
|
|
179
|
+
`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function composeBuildPrompt(root, config, idea, failures, artifacts) {
|
|
183
|
+
const failureBlock =
|
|
184
|
+
failures && failures.length
|
|
185
|
+
? `\n## What failed last time\n\nThe factory checked your work and these did not pass. Fix them, then continue.\n\n${failures
|
|
186
|
+
.map((r) => `- ${r.title}: ${r.summary}${r.details?.length ? `\n${r.details.map((d) => ` - ${d}`).join("\n")}` : ""}`)
|
|
187
|
+
.join("\n")}\n`
|
|
188
|
+
: "";
|
|
189
|
+
|
|
190
|
+
const specBlock = artifacts?.spec
|
|
191
|
+
? `\n## The approved spec\n\n\`specs/${artifacts.name}/spec.md\`\n\n${artifacts.spec}\n`
|
|
192
|
+
: "";
|
|
193
|
+
const planBlock = artifacts?.plan
|
|
194
|
+
? `\n## The plan\n\n\`specs/${artifacts.name}/plan.md\`\n\n${artifacts.plan}\n`
|
|
195
|
+
: "";
|
|
196
|
+
const tasksBlock = artifacts?.tasks
|
|
197
|
+
? `\n## The tasks\n\n\`specs/${artifacts.name}/tasks.md\`\n\nTick each box only when its proof passes.\n\n${artifacts.tasks}\n`
|
|
198
|
+
: "";
|
|
199
|
+
|
|
200
|
+
return `You are the engineering team for "${config.name}".
|
|
201
|
+
|
|
202
|
+
Read these before you start:
|
|
203
|
+
- .factory/constitution.md (the rules)
|
|
204
|
+
- factory.config.json (what this project is and who depends on it)
|
|
205
|
+
- the code already in this project
|
|
206
|
+
${specBlock}${planBlock}${tasksBlock}
|
|
207
|
+
${roleBlock(root)}
|
|
208
|
+
|
|
209
|
+
## The job
|
|
210
|
+
|
|
211
|
+
${idea}
|
|
212
|
+
${failureBlock}
|
|
213
|
+
## How to work
|
|
214
|
+
|
|
215
|
+
- Work through the tasks in order. Tick a box only when its proof passes.
|
|
216
|
+
- Small changes. Walk the reuse ladder before writing anything new.
|
|
217
|
+
- Run the project's own tests and build as you go.
|
|
218
|
+
- Never leave a secret, key or password in a file.
|
|
219
|
+
- If the job touches personal data, say what data, why, and where it is stored.
|
|
220
|
+
- Do not add a dependency if the project already has a way to do it.
|
|
221
|
+
- If something is unclear, ask one question and stop. Do not guess.
|
|
222
|
+
|
|
223
|
+
## When you believe it is done
|
|
224
|
+
|
|
225
|
+
Stop. Do not announce success. As soon as you finish, the factory runs the project's tests,
|
|
226
|
+
build and checks itself (\`factory verify --deep\`) and decides. If you are not allowed to
|
|
227
|
+
run commands here, that is expected: do not ask the person to run them, and do not list
|
|
228
|
+
commands for them. Say in a few lines what you changed, what you ran, and anything only the
|
|
229
|
+
person can do (for example, changing a leaked key).
|
|
230
|
+
|
|
231
|
+
If a check fails, you will be given the failure and asked to fix it.
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function constitutionText(root) {
|
|
236
|
+
const local = join(paths(root).dir, "constitution.md");
|
|
237
|
+
const file = existsSync(local) ? local : join(TEMPLATES, "constitution.md");
|
|
238
|
+
return existsSync(file) ? readFileSync(file, "utf8").replace(/\r\n/g, "\n") : "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function checkLines(run) {
|
|
242
|
+
if (!run?.results) return "The factory has not run its checks yet.";
|
|
243
|
+
return run.results.map((result) => `- ${result.title}: ${result.status.toUpperCase()}. ${result.summary}`).join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The reviewer is a separate agent run. It sees the change, the approved spec and the
|
|
247
|
+
// rules, never the builder's prompt or reasoning.
|
|
248
|
+
export function composeReviewPrompt(root, config, { artifacts, change, reviewFile, run }) {
|
|
249
|
+
const rules = readTemplate(root, "review-rules.md").replaceAll("{{reviewFile}}", reviewFile);
|
|
250
|
+
const traps = section(constitutionText(root), "Known traps");
|
|
251
|
+
const changeBlock = change.text
|
|
252
|
+
? `\`\`\`diff\n${change.text}\n\`\`\``
|
|
253
|
+
: `The change is too large to paste. Read these files yourself:\n\n${change.files.map((file) => `- ${file}`).join("\n")}`;
|
|
254
|
+
|
|
255
|
+
return `You are the Reviewer for "${config.name}". You did not write this change.
|
|
256
|
+
|
|
257
|
+
## Your role
|
|
258
|
+
|
|
259
|
+
${readRole(root, "reviewer")}
|
|
260
|
+
|
|
261
|
+
${rules}
|
|
262
|
+
|
|
263
|
+
## Known traps
|
|
264
|
+
|
|
265
|
+
${traps || "None recorded."}
|
|
266
|
+
|
|
267
|
+
## The approved spec
|
|
268
|
+
|
|
269
|
+
${artifacts?.spec ? `\`specs/${artifacts.name}/spec.md\`\n\n${artifacts.spec}` : "There is no approved spec (a hotfix). Review the change against the rules only, and write \"No spec\" under Acceptance checks."}
|
|
270
|
+
${artifacts?.tasks ? `\n## The task list\n\n${artifacts.tasks}\n` : ""}
|
|
271
|
+
## What the factory's own checks found
|
|
272
|
+
|
|
273
|
+
These ran after the build, outside the builder's control. You may cite them as evidence.
|
|
274
|
+
|
|
275
|
+
${checkLines(run)}
|
|
276
|
+
|
|
277
|
+
## The change
|
|
278
|
+
|
|
279
|
+
${change.stat ? `\`\`\`\n${change.stat}\`\`\`\n\n` : ""}${changeBlock}
|
|
280
|
+
|
|
281
|
+
Write the review file, then return one line saying it is written, and nothing else.
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const DRIVER_STEPS = {
|
|
286
|
+
mcp: (info, url, dir) => `The app is running at ${url}. Open it with your ${info.driver} browser tools (server "${info.server}").
|
|
287
|
+
For each check: do the steps a person would, then check the result where it lands (reload
|
|
288
|
+
the page, read the record back, look as the other account when it matters). Save one
|
|
289
|
+
screenshot of the result with the screenshot tool's save option, to
|
|
290
|
+
\`${dir}/<check>-<a-few-words>.png\` (for example \`${dir}/AC1-booking-saved.png\`).`,
|
|
291
|
+
playwright: (info, url, dir) => `The app is running at ${url}. Run the project's Playwright tests that cover these checks
|
|
292
|
+
with \`npx playwright test <file>\`. If a check has no test, add one next to the existing
|
|
293
|
+
browser tests. In each test, check the result where it lands, not only the screen, and
|
|
294
|
+
save a screenshot of the result with \`page.screenshot({ path: "${dir}/<check>-<a-few-words>.png" })\`.`,
|
|
295
|
+
maestro: (info, url, dir) => `A simulator must already be running with the app installed; if it is not, write that in
|
|
296
|
+
journeys.md and stop. Run the flows that cover these checks with \`maestro test <flow>\`
|
|
297
|
+
(or \`./scripts/e2e.sh <flow>\` when the project has it), and add a \`takeScreenshot\` step
|
|
298
|
+
saving to \`${dir}\` where a flow has none. Save each flow's output to \`${dir}/<check>.txt\`
|
|
299
|
+
and use that file, or the screenshot, in the Screenshot column.`,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// The tester: a separate run after the checks pass, that uses the app the way a person
|
|
303
|
+
// does and leaves a screenshot for every claim.
|
|
304
|
+
export function composeJourneyPrompt(root, config, { info, url, artifacts, dir }) {
|
|
305
|
+
const steps = (info.driver.endsWith("-mcp") ? DRIVER_STEPS.mcp : DRIVER_STEPS[info.driver])(info, url, dir);
|
|
306
|
+
return `You are the Tester for "${config.name}". The code is written and the factory's checks
|
|
307
|
+
passed. Now use the app the way a person would, and prove each acceptance check a person can
|
|
308
|
+
see.
|
|
309
|
+
|
|
310
|
+
## Your role
|
|
311
|
+
|
|
312
|
+
${readRole(root, "tester")}
|
|
313
|
+
|
|
314
|
+
## The approved spec
|
|
315
|
+
|
|
316
|
+
\`specs/${artifacts.name}/spec.md\`
|
|
317
|
+
|
|
318
|
+
${artifacts.spec}
|
|
319
|
+
|
|
320
|
+
## How to use the app
|
|
321
|
+
|
|
322
|
+
${steps}
|
|
323
|
+
|
|
324
|
+
## Write this file
|
|
325
|
+
|
|
326
|
+
\`${dir}/journeys.md\`
|
|
327
|
+
|
|
328
|
+
\`\`\`
|
|
329
|
+
| Check | Steps | What you saw | Result | Screenshot |
|
|
330
|
+
|---|---|---|---|---|
|
|
331
|
+
| AC1 | opened /book, picked 10:00, pressed Book, reloaded | the booking is listed after reload | PASS | ${dir}/AC1-booking-saved.png |
|
|
332
|
+
\`\`\`
|
|
333
|
+
|
|
334
|
+
- Result is PASS or FAIL. PASS needs the screenshot file to exist.
|
|
335
|
+
- A check a person cannot see (a log line, a header, a database rule) is not a journey;
|
|
336
|
+
leave it out. If nothing in the spec is visible to a person, write one row:
|
|
337
|
+
\`| none | | nothing a person sees changed | SKIP | |\`
|
|
338
|
+
- Do not change the app's code in this step. If something is broken, mark it FAIL and say
|
|
339
|
+
what you saw.
|
|
340
|
+
|
|
341
|
+
Return one line when journeys.md is written, and nothing else.
|
|
342
|
+
`;
|
|
343
|
+
}
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DIFF_LIMIT = 200 * 1024;
|
|
7
|
+
// The reviewer's own output and the factory's records are not part of the change.
|
|
8
|
+
const IGNORE = [":(exclude).factory", ":(exclude,glob)specs/*/evidence/**"];
|
|
9
|
+
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
const run = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
12
|
+
return run.status === 0 ? run.stdout : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isGitRepo(root) {
|
|
16
|
+
return git(root, ["rev-parse", "--is-inside-work-tree"])?.trim() === "true";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Where the change starts: --base, then the commit the build started on, then where this
|
|
20
|
+
// branch left main.
|
|
21
|
+
export function reviewBase(root, { base, state } = {}) {
|
|
22
|
+
if (base) return git(root, ["rev-parse", "--verify", `${base}^{commit}`])?.trim() ?? null;
|
|
23
|
+
if (state?.buildBase && git(root, ["cat-file", "-e", `${state.buildBase}^{commit}`]) !== null) return state.buildBase;
|
|
24
|
+
for (const main of ["origin/main", "main", "origin/master", "master"]) {
|
|
25
|
+
const found = git(root, ["merge-base", "HEAD", main])?.trim();
|
|
26
|
+
if (found) return found;
|
|
27
|
+
}
|
|
28
|
+
return git(root, ["rev-parse", "HEAD"])?.trim() ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function untracked(root) {
|
|
32
|
+
return (git(root, ["ls-files", "--others", "--exclude-standard", "--", ".", ...IGNORE]) ?? "")
|
|
33
|
+
.split("\n")
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The change as the reviewer sees it: tracked changes since base, plus new files. Too big
|
|
38
|
+
// to paste, and the reviewer gets the list of files to read itself.
|
|
39
|
+
export function changeSince(root, base) {
|
|
40
|
+
const stat = git(root, ["diff", "--stat", base, "--", ".", ...IGNORE]) ?? "";
|
|
41
|
+
const diff = git(root, ["diff", base, "--", ".", ...IGNORE]) ?? "";
|
|
42
|
+
const added = untracked(root);
|
|
43
|
+
let text = diff;
|
|
44
|
+
for (const file of added) {
|
|
45
|
+
let body = "";
|
|
46
|
+
try {
|
|
47
|
+
body = readFileSync(join(root, file), "utf8");
|
|
48
|
+
} catch {}
|
|
49
|
+
if (body.includes("\u0000")) continue;
|
|
50
|
+
text += `\n--- /dev/null\n+++ b/${file} (new file)\n${body}`;
|
|
51
|
+
}
|
|
52
|
+
const files = [
|
|
53
|
+
...(git(root, ["diff", "--name-only", base, "--", ".", ...IGNORE]) ?? "").split("\n").filter(Boolean),
|
|
54
|
+
...added,
|
|
55
|
+
];
|
|
56
|
+
if (text.length > DIFF_LIMIT) {
|
|
57
|
+
return { stat, files, text: null, truncated: true };
|
|
58
|
+
}
|
|
59
|
+
return { stat, files, text, truncated: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A fingerprint of every changed and new file, so the factory can tell whether the
|
|
63
|
+
// reviewer touched anything beyond its own review file.
|
|
64
|
+
export function workingTreeFingerprint(root) {
|
|
65
|
+
const hash = createHash("sha256");
|
|
66
|
+
const changed = (git(root, ["status", "--porcelain", "--untracked-files=all", "--", ".", ...IGNORE]) ?? "")
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.sort();
|
|
70
|
+
for (const line of changed) {
|
|
71
|
+
const file = line.slice(3).split(" -> ").pop();
|
|
72
|
+
hash.update(line);
|
|
73
|
+
try {
|
|
74
|
+
hash.update(readFileSync(join(root, file)));
|
|
75
|
+
} catch {
|
|
76
|
+
hash.update("<gone>");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return hash.digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tableRows(block) {
|
|
83
|
+
return block
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter((line) => line.startsWith("|") && !/^\|[\s|:-]+\|$/.test(line))
|
|
87
|
+
.map((line) =>
|
|
88
|
+
line
|
|
89
|
+
.slice(1, -1)
|
|
90
|
+
.split("|")
|
|
91
|
+
.map((cell) => cell.trim()),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reads review.md. Anything unexpected counts against the change: no verdict line means
|
|
96
|
+
// no review.
|
|
97
|
+
export function parseReview(text) {
|
|
98
|
+
const body = String(text ?? "");
|
|
99
|
+
const verdictLine = body.match(/^\s*VERDICT:\s*(approve|send back)\s*$/im);
|
|
100
|
+
const [findingsPart, checksPart = ""] = body.split(/^##\s+Acceptance checks\s*$/im);
|
|
101
|
+
|
|
102
|
+
const findings = tableRows(findingsPart)
|
|
103
|
+
.filter((cells) => cells.length >= 5 && !/^#$/.test(cells[0]))
|
|
104
|
+
.map(([, severity, where, rule, what, how = ""]) => ({
|
|
105
|
+
severity: severity.toLowerCase(),
|
|
106
|
+
where,
|
|
107
|
+
rule,
|
|
108
|
+
what,
|
|
109
|
+
how,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const checks = tableRows(checksPart)
|
|
113
|
+
.filter((cells) => cells.length >= 3 && !/^check$/i.test(cells[0]))
|
|
114
|
+
.map(([check, verdict, evidence]) => ({ check, verdict: verdict.toUpperCase(), evidence }));
|
|
115
|
+
|
|
116
|
+
const listed = (name) => {
|
|
117
|
+
const match = checksPart.match(new RegExp(`^\\s*${name}:\\s*(.+)$`, "im"));
|
|
118
|
+
const value = match?.[1]?.trim() ?? "";
|
|
119
|
+
return !value || /^none\.?$/i.test(value) ? [] : [value];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
verdict: verdictLine ? verdictLine[1].toLowerCase() : null,
|
|
124
|
+
findings,
|
|
125
|
+
mustFix: findings.filter((finding) => finding.severity === "must fix"),
|
|
126
|
+
shouldFix: findings.filter((finding) => finding.severity === "should fix"),
|
|
127
|
+
checks,
|
|
128
|
+
failed: checks.filter((check) => check.verdict === "FAIL"),
|
|
129
|
+
unproven: checks.filter((check) => check.verdict === "UNPROVEN"),
|
|
130
|
+
proven: checks.filter((check) => check.verdict === "PASS"),
|
|
131
|
+
missing: listed("Missing"),
|
|
132
|
+
extra: listed("Extra"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The tool decides, not the reviewer's last line alone: any must-fix, FAIL or missing
|
|
137
|
+
// check sends it back even if the verdict says approve.
|
|
138
|
+
export function reviewPassed(review) {
|
|
139
|
+
return (
|
|
140
|
+
review.verdict === "approve" && !review.mustFix.length && !review.failed.length && !review.missing.length
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function reviewLine(review) {
|
|
145
|
+
const parts = [`${review.mustFix.length} must fix`, `${review.shouldFix.length} should fix`];
|
|
146
|
+
if (review.checks.length) {
|
|
147
|
+
parts.push(`acceptance checks ${review.proven.length} of ${review.checks.length} proven`);
|
|
148
|
+
if (review.unproven.length) parts.push(`${review.unproven.length} for a person to check`);
|
|
149
|
+
}
|
|
150
|
+
return `${reviewPassed(review) ? "approved" : "sent back"} by a separate session: ${parts.join(", ")}`;
|
|
151
|
+
}
|
package/lib/spec.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readConfig, writeConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export const SPECS_DIR = "specs";
|
|
9
|
+
|
|
10
|
+
export function slugify(text) {
|
|
11
|
+
return (
|
|
12
|
+
String(text)
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.normalize("NFKD")
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/^-+|-+$/g, "")
|
|
17
|
+
.slice(0, 60) || "spec"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function listSpecs(root) {
|
|
22
|
+
const dir = join(root, SPECS_DIR);
|
|
23
|
+
if (!existsSync(dir)) return [];
|
|
24
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
25
|
+
.filter((entry) => entry.isDirectory() && /^\d{3}-/.test(entry.name))
|
|
26
|
+
.map((entry) => entry.name)
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function nextSpecName(root, title) {
|
|
31
|
+
const numbers = listSpecs(root)
|
|
32
|
+
.map((name) => Number.parseInt(name.slice(0, 3), 10))
|
|
33
|
+
.filter((number) => Number.isFinite(number));
|
|
34
|
+
const next = String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(3, "0");
|
|
35
|
+
return `${next}-${slugify(title)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function specDir(root, name) {
|
|
39
|
+
return join(root, SPECS_DIR, name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function specPaths(root, name) {
|
|
43
|
+
const dir = specDir(root, name);
|
|
44
|
+
return {
|
|
45
|
+
dir,
|
|
46
|
+
spec: join(dir, "spec.md"),
|
|
47
|
+
plan: join(dir, "plan.md"),
|
|
48
|
+
tasks: join(dir, "tasks.md"),
|
|
49
|
+
approval: join(dir, "approval.json"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hashText(text) {
|
|
54
|
+
return createHash("sha256").update(text).digest("hex");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readApproval(root, name) {
|
|
58
|
+
const file = specPaths(root, name).approval;
|
|
59
|
+
if (!existsSync(file)) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readArtifacts(root, name) {
|
|
68
|
+
const files = specPaths(root, name);
|
|
69
|
+
const read = (file) => (existsSync(file) ? readFileSync(file, "utf8") : "");
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
...files,
|
|
73
|
+
spec: read(files.spec),
|
|
74
|
+
plan: read(files.plan),
|
|
75
|
+
tasks: read(files.tasks),
|
|
76
|
+
approval: readApproval(root, name),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function approvalState(root, name) {
|
|
81
|
+
const artifacts = readArtifacts(root, name);
|
|
82
|
+
if (!artifacts.spec.trim()) return { state: "missing", artifacts };
|
|
83
|
+
if (!artifacts.approval) return { state: "draft", artifacts };
|
|
84
|
+
if (artifacts.approval.hash !== hashText(artifacts.spec)) {
|
|
85
|
+
return { state: "stale", artifacts };
|
|
86
|
+
}
|
|
87
|
+
return { state: "approved", artifacts };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function activeSpec(root) {
|
|
91
|
+
const config = readConfig(root);
|
|
92
|
+
const name = config.spec?.active;
|
|
93
|
+
if (!name) return null;
|
|
94
|
+
if (!existsSync(specPaths(root, name).spec)) return null;
|
|
95
|
+
return { name, ...approvalState(root, name) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function findSpec(root, query) {
|
|
99
|
+
const names = listSpecs(root);
|
|
100
|
+
if (!query) return null;
|
|
101
|
+
return (
|
|
102
|
+
names.find((name) => name === query) ||
|
|
103
|
+
names.find((name) => name.startsWith(query)) ||
|
|
104
|
+
names.find((name) => name.includes(query)) ||
|
|
105
|
+
null
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function setActive(root, name) {
|
|
110
|
+
const config = readConfig(root);
|
|
111
|
+
config.spec = { ...config.spec, active: name };
|
|
112
|
+
writeConfig(root, config);
|
|
113
|
+
return config;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function approve(root, name, by) {
|
|
117
|
+
const artifacts = readArtifacts(root, name);
|
|
118
|
+
if (!artifacts.spec.trim()) return null;
|
|
119
|
+
const approval = {
|
|
120
|
+
spec: name,
|
|
121
|
+
hash: hashText(artifacts.spec),
|
|
122
|
+
approvedAt: new Date().toISOString(),
|
|
123
|
+
approvedBy: String(by || "").trim() || gitUser(root) || userInfo().username || "unknown",
|
|
124
|
+
};
|
|
125
|
+
mkdirSync(specPaths(root, name).dir, { recursive: true });
|
|
126
|
+
writeFileSync(specPaths(root, name).approval, `${JSON.stringify(approval, null, 2)}\n`);
|
|
127
|
+
return approval;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function gitUser(root) {
|
|
131
|
+
const result = spawnSync("git", ["config", "user.name"], {
|
|
132
|
+
cwd: root,
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
});
|
|
135
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
136
|
+
}
|