@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/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
+ }