@next-generation-collective/factory 0.0.0-stage → 0.3.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.
@@ -0,0 +1,1229 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { analyseProject } from "./analyse.js";
7
+ import { approvalView, GUARDED_QUESTIONS, guardedTopics } from "./approval.js";
8
+ import { agentAvailable, agentVersion, detectAgent, runAgent, savePrompt } from "./agent.js";
9
+ import { normaliseAgent } from "./agents.js";
10
+ import { defaultConfig, ensure, paths, readConfig, readState, writeConfig, writeState } from "./config.js";
11
+ import { detectStack } from "./detect.js";
12
+ import { fixSummary, pickFix } from "./fixes.js";
13
+ import { askList, QUESTIONS } from "./interview.js";
14
+ import {
15
+ composeBuildPrompt,
16
+ composeJourneyPrompt,
17
+ composePlanPrompt,
18
+ composeReviewPrompt,
19
+ composeSpecPrompt,
20
+ composeTasksPrompt,
21
+ } from "./prompts.js";
22
+ import {
23
+ appUrlAllowed,
24
+ checkJourneys,
25
+ journeyAgent,
26
+ journeysResultNa,
27
+ journeysResultNotRun,
28
+ pickDriver,
29
+ startApp,
30
+ startCommand,
31
+ } from "./journeys.js";
32
+ import { changeSince, isGitRepo, parseReview, reviewBase, reviewLine, reviewPassed, workingTreeFingerprint } from "./review.js";
33
+ import {
34
+ activeSpec,
35
+ approvalState,
36
+ approve,
37
+ findSpec,
38
+ gitUser,
39
+ listSpecs,
40
+ nextSpecName,
41
+ setActive,
42
+ specPaths,
43
+ } from "./spec.js";
44
+ import { formatResults, label, specEvidenceDir, verify } from "./verify.js";
45
+ import { colors, isTestFile, rel, walk } from "./util.js";
46
+
47
+ const HERE = dirname(fileURLToPath(import.meta.url));
48
+ const TEMPLATES = join(HERE, "..", "template");
49
+ const KINDS = ["web", "mobile", "api", "data", "cli", "library", "other"];
50
+
51
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, "-");
52
+
53
+ function copyIfMissing(from, to) {
54
+ if (!existsSync(from) || existsSync(to)) return;
55
+ mkdirSync(dirname(to), { recursive: true });
56
+ cpSync(from, to);
57
+ }
58
+
59
+ export function scaffold(root) {
60
+ const p = ensure(root);
61
+ copyIfMissing(join(TEMPLATES, "constitution.md"), join(p.dir, "constitution.md"));
62
+
63
+ const rolesDir = join(TEMPLATES, "roles");
64
+ if (existsSync(rolesDir)) {
65
+ for (const file of readdirSync(rolesDir)) {
66
+ copyIfMissing(join(rolesDir, file), join(p.roles, file));
67
+ }
68
+ }
69
+
70
+ for (const file of ["spec.md", "plan.md", "tasks.md", "review-rules.md"]) {
71
+ copyIfMissing(join(TEMPLATES, file), join(p.templates, file));
72
+ }
73
+ }
74
+
75
+ export function ensureConfig(root) {
76
+ if (!existsSync(paths(root).config)) {
77
+ const stack = detectStack(root);
78
+ const config = defaultConfig(root);
79
+ if (stack.kind !== "other") config.kind = stack.kind;
80
+ const agent = detectAgent();
81
+ if (agent) config.agent = agent;
82
+ writeConfig(root, config);
83
+ }
84
+ scaffold(root);
85
+ return readConfig(root);
86
+ }
87
+
88
+ function ignoreFactory(root) {
89
+ if (!existsSync(join(root, ".git"))) return;
90
+ const file = join(root, ".gitignore");
91
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
92
+ if (/^\.factory\/?$/m.test(text)) return;
93
+ const prefix = text.endsWith("\n") || text === "" ? "" : "\n";
94
+ writeFileSync(file, `${text}${prefix}\n# Software Factory\n.factory/\n`);
95
+ }
96
+
97
+ function normalizeKind(value, fallback) {
98
+ const kind = String(value ?? "").trim().toLowerCase();
99
+ return KINDS.includes(kind) ? kind : fallback;
100
+ }
101
+
102
+ function normalizeData(value) {
103
+ const text = String(value ?? "").trim().toLowerCase();
104
+ if (["y", "yes", "true"].includes(text)) return "yes";
105
+ if (["n", "no", "false"].includes(text)) return "no";
106
+ return "unknown";
107
+ }
108
+
109
+ async function ask(rl, question, fallback) {
110
+ const answer = (await rl.question(`${question}${fallback ? ` [${fallback}]` : ""}: `)).trim();
111
+ return answer || fallback || "";
112
+ }
113
+
114
+ export async function init(root, opts = {}) {
115
+ const stack = detectStack(root);
116
+ const existing = readConfig(root);
117
+
118
+ const detectedKind = stack.kind !== "other" ? stack.kind : "other";
119
+ let name = opts.name ?? existing.name ?? basename(root);
120
+ let kind = normalizeKind(
121
+ opts.kind ?? (existing.kind !== "other" ? existing.kind : detectedKind),
122
+ detectedKind,
123
+ );
124
+ let who = opts.who ?? (existing.whoDepends?.join(", ") || "nobody yet");
125
+ let neverBreak = opts["never-break"] ?? existing.neverBreak ?? "";
126
+ let data = normalizeData(opts.data ?? existing.personalData ?? "unknown");
127
+ const configured = existsSync(paths(root).config);
128
+ let agent = opts.agent ?? (configured ? existing.agent?.command : detectAgent()?.command) ?? "claude";
129
+
130
+ if (!opts.yes && process.stdin.isTTY) {
131
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
132
+ try {
133
+ console.log(colors.dim("Five questions. Press enter to keep the default.\n"));
134
+ name = await ask(rl, "1. What is this project called?", name);
135
+ const detected = stack.kind !== "other" ? `, looks like ${stack.kind}` : "";
136
+ kind = normalizeKind(
137
+ await ask(rl, `2. What kind of project is it? (${KINDS.join(", ")}${detected})`, kind),
138
+ stack.kind,
139
+ );
140
+ who = await ask(rl, "3. Who depends on it? (client, funder, public, your team, nobody yet)", who);
141
+ neverBreak = await ask(rl, "4. What must never break?", neverBreak);
142
+ data = normalizeData(await ask(rl, "5. Does it handle personal data? (yes, no, not sure)", data));
143
+ agent = await ask(rl, "Which agent command do you use? (claude, codex, gemini, other)", agent);
144
+ } finally {
145
+ rl.close();
146
+ }
147
+ }
148
+
149
+ const config = {
150
+ ...existing,
151
+ name: name.trim() || basename(root),
152
+ kind,
153
+ whoDepends: String(who)
154
+ .split(",")
155
+ .map((item) => item.trim())
156
+ .filter(Boolean),
157
+ neverBreak: String(neverBreak).trim(),
158
+ personalData: data,
159
+ agent:
160
+ configured && existing.agent?.command === (agent.trim() || "claude")
161
+ ? existing.agent
162
+ : normaliseAgent({ command: agent.trim() || "claude" }),
163
+ updatedAt: new Date().toISOString(),
164
+ };
165
+
166
+ writeConfig(root, config);
167
+ scaffold(root);
168
+ ignoreFactory(root);
169
+
170
+ console.log(colors.green(`Created ${rel(root, paths(root).config)}`));
171
+ console.log(colors.dim(`Detected: ${stack.framework ?? "unknown"} / ${stack.language ?? "unknown"}`));
172
+ console.log("");
173
+ console.log("Next:");
174
+ console.log(' factory spec "what to build" write the spec first');
175
+ console.log(" factory spec --approve record your approval");
176
+ console.log(" factory verify fast check, under a minute");
177
+ return 0;
178
+ }
179
+
180
+ function written(file) {
181
+ return existsSync(file) && readFileSync(file, "utf8").trim().length >= 40;
182
+ }
183
+
184
+ function noAgent(root, config, file) {
185
+ console.log(colors.yellow(`No agent found: ${config.agent.command}`));
186
+ console.log(`The prompt is saved at ${rel(root, file)}`);
187
+ console.log("Run it with your agent, then continue. Nothing was called done.");
188
+ return 2;
189
+ }
190
+
191
+ function printSpecList(root, config) {
192
+ const names = listSpecs(root);
193
+ if (!names.length) {
194
+ console.log("No specs yet. Start one:");
195
+ console.log(' factory spec "what you want to build"');
196
+ return 0;
197
+ }
198
+
199
+ console.log(colors.bold("Specs"));
200
+ for (const name of names) {
201
+ const { state } = approvalState(root, name);
202
+ const mark = config.spec?.active === name ? "*" : " ";
203
+ console.log(`${mark} ${name.padEnd(42)} ${state}`);
204
+ }
205
+ console.log("");
206
+ console.log('Start one: factory spec "what you want to build"');
207
+ console.log("Approve: factory spec --approve [name]");
208
+ return 0;
209
+ }
210
+
211
+ // Who approves: --by, then the name saved in the config, then git's user.name, then (in a
212
+ // terminal) ask once and remember it. approve() itself never asks.
213
+ async function approverName(root, config, opts) {
214
+ const known = String(opts.by ?? "").trim() || config.approvedBy || gitUser(root);
215
+ if (known) return known;
216
+ if (opts.yes || !process.stdin.isTTY) return "";
217
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
218
+ try {
219
+ const name = (await rl.question("Your name, for the approval record: ")).trim();
220
+ if (name) writeConfig(root, { ...readConfig(root), approvedBy: name });
221
+ return name;
222
+ } finally {
223
+ rl.close();
224
+ }
225
+ }
226
+
227
+ export async function specCommand(root, opts = {}, args = []) {
228
+ if (opts.approve) {
229
+ const config = ensureConfig(root);
230
+ const query = String(args[0] ?? config.spec?.active ?? "").trim();
231
+ const name = findSpec(root, query);
232
+ if (!name) {
233
+ console.error("No spec to approve.");
234
+ console.error('Start one with: factory "what you want to change"');
235
+ return 1;
236
+ }
237
+
238
+ const approval = approve(root, name, await approverName(root, config, opts));
239
+ if (!approval) {
240
+ console.error(`Spec ${name} is empty. Nothing to approve.`);
241
+ return 1;
242
+ }
243
+
244
+ setActive(root, name);
245
+ console.log(colors.green(`Approved: ${name}`));
246
+ console.log(`By ${approval.approvedBy}, ${approval.approvedAt.slice(0, 10)}.`);
247
+ console.log(colors.dim(`Fingerprint ${approval.hash.slice(0, 12)}. Editing the spec invalidates it.`));
248
+ if (!opts.quiet) console.log("Next: factory build");
249
+ return 0;
250
+ }
251
+
252
+ const title = args.join(" ").trim();
253
+ if (!title) return printSpecList(root, readConfig(root));
254
+
255
+ const job = String(opts.job ?? title);
256
+ const name = nextSpecName(root, title);
257
+ const target = `specs/${name}/spec.md`;
258
+ const abs = join(root, target);
259
+ const analysis = opts.analysis ?? analyseProject(root);
260
+ const context = { analysis, answers: opts.answers ?? {}, lane: opts.lane };
261
+
262
+ if (opts.print) {
263
+ console.log(composeSpecPrompt(root, readConfig(root), job, target, context));
264
+ return 0;
265
+ }
266
+
267
+ const config = ensureConfig(root);
268
+ const prompt = composeSpecPrompt(root, config, job, target, context);
269
+ const file = savePrompt(root, `spec-${stamp()}`, prompt);
270
+
271
+ mkdirSync(join(root, "specs", name), { recursive: true });
272
+ if (!agentAvailable(config)) return noAgent(root, config, file);
273
+
274
+ console.log(colors.bold(`Writing the spec: ${name}`));
275
+ runAgent(root, prompt, config, file);
276
+
277
+ if (!written(abs)) {
278
+ console.error(colors.red("The agent did not write a spec."));
279
+ console.error(`The prompt is at ${rel(root, file)}. Run it yourself, then: factory approve ${name}`);
280
+ return 1;
281
+ }
282
+
283
+ setActive(root, name);
284
+ console.log(colors.green(`Spec written to ${target}`));
285
+ if (!opts.quiet) {
286
+ console.log("");
287
+ console.log("Read it. If it is right:");
288
+ console.log(` factory approve ${name}`);
289
+ console.log("If not, edit it, then approve. Editing after approval invalidates it.");
290
+ }
291
+ return 0;
292
+ }
293
+
294
+ function approvedSpecOrExplain(root) {
295
+ const spec = activeSpec(root);
296
+ if (!spec) {
297
+ console.error("No active spec.");
298
+ console.error('Start one with: factory "what you want to change"');
299
+ return null;
300
+ }
301
+ if (spec.state !== "approved") {
302
+ console.error(`Spec ${spec.name} is ${spec.state}. Read it, then: factory approve ${spec.name}`);
303
+ return null;
304
+ }
305
+ return spec;
306
+ }
307
+
308
+ export function planCommand(root, opts = {}) {
309
+ const spec = approvedSpecOrExplain(root);
310
+ if (!spec) return 1;
311
+
312
+ const files = specPaths(root, spec.name);
313
+ if (existsSync(files.plan) && !opts.force) {
314
+ console.error(`${rel(root, files.plan)} already exists. Use --force to rewrite it.`);
315
+ return 1;
316
+ }
317
+
318
+ const target = `specs/${spec.name}/plan.md`;
319
+ if (opts.print) {
320
+ console.log(composePlanPrompt(root, readConfig(root), spec.name, spec.artifacts.spec, target));
321
+ return 0;
322
+ }
323
+
324
+ const config = ensureConfig(root);
325
+ const prompt = composePlanPrompt(root, config, spec.name, spec.artifacts.spec, target);
326
+ const file = savePrompt(root, `plan-${stamp()}`, prompt);
327
+ if (!agentAvailable(config)) return noAgent(root, config, file);
328
+
329
+ console.log(colors.bold(`Writing the plan: ${spec.name}`));
330
+ runAgent(root, prompt, config, file);
331
+
332
+ if (!written(files.plan)) {
333
+ console.error(colors.red("The agent did not write a plan."));
334
+ console.error(`The prompt is at ${rel(root, file)}. Run it yourself, then: factory tasks`);
335
+ return 1;
336
+ }
337
+
338
+ console.log(colors.green(`Plan written to ${target}`));
339
+ if (!opts.quiet) console.log("Next: factory tasks");
340
+ return 0;
341
+ }
342
+
343
+ export function tasksCommand(root, opts = {}) {
344
+ const spec = approvedSpecOrExplain(root);
345
+ if (!spec) return 1;
346
+
347
+ const files = specPaths(root, spec.name);
348
+ if (!existsSync(files.plan)) {
349
+ console.error("No plan yet. Run: factory plan");
350
+ return 1;
351
+ }
352
+ if (existsSync(files.tasks) && !opts.force) {
353
+ console.error(`${rel(root, files.tasks)} already exists. Use --force to rewrite it.`);
354
+ return 1;
355
+ }
356
+
357
+ const planText = readFileSync(files.plan, "utf8");
358
+ const target = `specs/${spec.name}/tasks.md`;
359
+ if (opts.print) {
360
+ console.log(composeTasksPrompt(root, readConfig(root), spec.name, spec.artifacts.spec, planText, target));
361
+ return 0;
362
+ }
363
+
364
+ const config = ensureConfig(root);
365
+ const prompt = composeTasksPrompt(root, config, spec.name, spec.artifacts.spec, planText, target);
366
+ const file = savePrompt(root, `tasks-${stamp()}`, prompt);
367
+ if (!agentAvailable(config)) return noAgent(root, config, file);
368
+
369
+ console.log(colors.bold(`Writing the tasks: ${spec.name}`));
370
+ runAgent(root, prompt, config, file);
371
+
372
+ if (!written(files.tasks)) {
373
+ console.error(colors.red("The agent did not write a task list."));
374
+ console.error(`The prompt is at ${rel(root, file)}. Run it yourself, then: factory build`);
375
+ return 1;
376
+ }
377
+
378
+ console.log(colors.green(`Tasks written to ${target}`));
379
+ if (!opts.quiet) console.log("Next: factory build");
380
+ return 0;
381
+ }
382
+
383
+ function specTitle(text, fallback) {
384
+ const line = String(text)
385
+ .split("\n")
386
+ .find((item) => /^#\s+/.test(item));
387
+ if (!line) return fallback;
388
+ return line.replace(/^#\s+(?:Feature Specification:\s*)?/i, "").trim() || fallback;
389
+ }
390
+
391
+ async function confirm(question) {
392
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
393
+ try {
394
+ const answer = (await rl.question(question)).trim().toLowerCase();
395
+ return ["y", "yes"].includes(answer);
396
+ } finally {
397
+ rl.close();
398
+ }
399
+ }
400
+
401
+ function showApproval(root, name, text) {
402
+ console.log("");
403
+ console.log(`${colors.bold(`Spec ${name}`)} ${colors.dim(rel(root, specPaths(root, name).spec))}`);
404
+ console.log("");
405
+ console.log(approvalView(text));
406
+ console.log("");
407
+ }
408
+
409
+ async function reviewAndApprove(root, spec) {
410
+ showApproval(root, spec.name, spec.artifacts.spec);
411
+ if (!(await confirm("Approve it, and I'll plan, build and check it? (y/n) "))) {
412
+ console.log(`\nNot approved. Edit ${rel(root, spec.artifacts.spec)} if you want, then: factory approve`);
413
+ return false;
414
+ }
415
+ await specCommand(root, { approve: true, quiet: true }, [spec.name]);
416
+ return true;
417
+ }
418
+
419
+ // factory approve [name]: read what matters, then approve. Resumes a spec written earlier.
420
+ export async function approveCommand(root, opts = {}, args = []) {
421
+ const config = readConfig(root);
422
+ const name = findSpec(root, String(args[0] ?? config.spec?.active ?? "").trim());
423
+ if (!name) {
424
+ console.error("No spec to approve.");
425
+ console.error('Start one with: factory "what you want to change"');
426
+ return 1;
427
+ }
428
+
429
+ const { state, artifacts } = approvalState(root, name);
430
+ if (state === "missing") {
431
+ console.error(`Spec ${name} is empty. Nothing to approve.`);
432
+ return 1;
433
+ }
434
+ if (state === "approved") {
435
+ console.log(`Spec ${name} is already approved.`);
436
+ console.log("Next: factory build");
437
+ return 0;
438
+ }
439
+
440
+ if (!opts.yes) {
441
+ if (!process.stdin.isTTY) {
442
+ console.error("Approving needs a terminal, so you can read what you approve.");
443
+ console.error(`Or record it without reading: factory approve ${name} --yes`);
444
+ return 1;
445
+ }
446
+ showApproval(root, name, artifacts.spec);
447
+ if (!(await confirm("Approve? (y/n) "))) {
448
+ console.log(`Not approved. Edit ${rel(root, specPaths(root, name).spec)} if you want, then run factory approve again.`);
449
+ return 0;
450
+ }
451
+ }
452
+
453
+ return specCommand(root, { approve: true, by: opts.by, yes: opts.yes }, [name]);
454
+ }
455
+
456
+ function nextSteps(root, configured) {
457
+ const lines = [];
458
+ const spec = configured ? activeSpec(root) : null;
459
+ if (spec && spec.state !== "approved") {
460
+ lines.push(["factory approve", `spec ${spec.name} is waiting for your approval`]);
461
+ } else if (spec && !readState(root).lastBuild) {
462
+ lines.push(["factory build", `spec ${spec.name} is approved; plan, build and check it`]);
463
+ }
464
+ lines.push(['factory "what you want to change"', "spec, your approval, then it builds and checks"]);
465
+ lines.push(["factory verify --deep", "also runs your tests and build"]);
466
+ const width = Math.max(...lines.map(([command]) => command.length));
467
+ return lines.map(([command, what]) => ` ${command.padEnd(width)} ${colors.dim(what)}`);
468
+ }
469
+
470
+ function createConfig(root) {
471
+ const config = ensureConfig(root);
472
+ ignoreFactory(root);
473
+ console.log(colors.dim(`Created factory.config.json (edit it any time). Agent: ${config.agent.command}`));
474
+ return config;
475
+ }
476
+
477
+ // The first run: what the project is and what it can prove, with no questions, no
478
+ // account and no agent. Without factory.config.json it writes nothing. In a terminal,
479
+ // with an agent installed, it then offers to fix the top problem it found.
480
+ export async function firstRun(root) {
481
+ const configured = existsSync(paths(root).config);
482
+ const analysis = analyseProject(root);
483
+ if (analysis.empty) {
484
+ console.log("No project found here. Run factory inside a project.");
485
+ return 0;
486
+ }
487
+
488
+ const config = readConfig(root);
489
+ const run = verify(root, { deep: false, config, save: configured });
490
+
491
+ console.log(`${colors.bold(config.name)} ${colors.dim(`(${analysis.label})`)}`);
492
+ console.log("");
493
+ console.log(colors.bold("What I see"));
494
+ for (const line of analysis.summary.slice(1)) console.log(` ${line}`);
495
+ console.log("");
496
+ console.log(colors.bold("What this project can prove right now"));
497
+ console.log(formatResults(root, run, { header: false }));
498
+
499
+ const fix = pickFix(run.results);
500
+ let noAgentForFix = false;
501
+ if (fix && process.stdin.isTTY && process.stdout.isTTY) {
502
+ const agent = configured ? (agentAvailable(config) ? config.agent : null) : detectAgent();
503
+ if (agent) {
504
+ console.log("");
505
+ console.log(colors.bold("Top problem"));
506
+ console.log(` ${fix.problem}`);
507
+ console.log("");
508
+ if (await confirm("Want me to fix this one now? You approve a short plan first. (y/n) ")) {
509
+ return fixNow(root, fix, analysis);
510
+ }
511
+ } else {
512
+ noAgentForFix = true;
513
+ }
514
+ }
515
+
516
+ console.log("");
517
+ console.log(colors.bold("Next"));
518
+ for (const line of nextSteps(root, configured)) console.log(line);
519
+ if (noAgentForFix) {
520
+ console.log("");
521
+ console.log(colors.dim("Install Claude Code, Codex, Gemini CLI or OpenCode and I can fix the top problem for you."));
522
+ }
523
+ return run.ok ? 0 : 1;
524
+ }
525
+
526
+ async function fixNow(root, fix, analysis) {
527
+ if (!existsSync(paths(root).config)) createConfig(root);
528
+ return runJob(root, { job: fix.job, title: fix.title, analysis, lane: "quick", fix });
529
+ }
530
+
531
+ async function runJob(root, { job, title, analysis, answers = {}, lane, fix, hotfix }) {
532
+ const code = await specCommand(root, { quiet: true, analysis, answers, lane, job }, [title]);
533
+ if (code !== 0) return code;
534
+ const active = activeSpec(root);
535
+ if (!active) return 1;
536
+ if (!(await reviewAndApprove(root, active))) return 0;
537
+
538
+ const built = build(root, { idea: job, hotfix, requires: fix?.requires });
539
+ if (built !== 0) return built;
540
+ report(root, {});
541
+ if (fix) shareSummary(root, fix);
542
+ return 0;
543
+ }
544
+
545
+ function shareSummary(root, fix) {
546
+ const config = readConfig(root);
547
+ const p = paths(root);
548
+ if (!existsSync(p.lastVerify)) return;
549
+ const run = JSON.parse(readFileSync(p.lastVerify, "utf8"));
550
+ const spec = activeSpec(root);
551
+ const text = fixSummary({
552
+ project: config.name,
553
+ fix,
554
+ run,
555
+ approval: spec?.artifacts.approval,
556
+ diffStat: readState(root).lastDiffStat,
557
+ review: readState(root).lastReview,
558
+ });
559
+ const dir = specEvidenceDir(root, config) ?? p.evidence;
560
+ mkdirSync(dir, { recursive: true });
561
+ writeFileSync(join(dir, "summary.txt"), text);
562
+ console.log("");
563
+ console.log(colors.bold("Done. Here's the proof, ready to paste to your team:"));
564
+ console.log("");
565
+ console.log(text);
566
+ console.log(colors.dim(`Saved to ${rel(root, join(dir, "summary.txt"))}`));
567
+ }
568
+
569
+ export async function start(root, opts = {}) {
570
+ const title = String(opts.idea ?? "").trim();
571
+ if (!title) return firstRun(root);
572
+
573
+ if (!process.stdin.isTTY) {
574
+ console.error("A job needs a terminal, so you can approve the spec.");
575
+ console.error('Or run the steps one by one: factory spec "...", factory approve --yes, factory build');
576
+ return 1;
577
+ }
578
+
579
+ if (!existsSync(paths(root).config)) createConfig(root);
580
+ else ensureConfig(root);
581
+ const analysis = analyseProject(root);
582
+
583
+ let answers = {};
584
+ const questions = opts.ask ? QUESTIONS : guardedTopics(title) ? GUARDED_QUESTIONS : [];
585
+ if (questions.length) {
586
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
587
+ try {
588
+ console.log(opts.ask ? "A few questions, so the spec is yours." : "Two questions the code cannot answer.");
589
+ answers = await askList(rl, questions, { log: console.log });
590
+ } finally {
591
+ rl.close();
592
+ }
593
+ }
594
+
595
+ return runJob(root, { job: title, title, analysis, answers, hotfix: Boolean(opts.hotfix) });
596
+ }
597
+
598
+ function gitText(root, args) {
599
+ const run = spawnSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
600
+ return run.status === 0 ? run.stdout.trim() : null;
601
+ }
602
+
603
+ function countTests(root) {
604
+ return walk(root).filter(isTestFile).length;
605
+ }
606
+
607
+ // What the build changed, from the commit it started on. Kept as evidence, and the
608
+ // future reviewer reads the same range.
609
+ function saveChanges(root, config, base) {
610
+ if (!base) return null;
611
+ const stat = gitText(root, ["diff", "--stat", base]) ?? "";
612
+ const untracked = (gitText(root, ["status", "--porcelain"]) ?? "")
613
+ .split("\n")
614
+ .filter((line) => line.startsWith("??"))
615
+ .map((line) => `new: ${line.slice(3)}`);
616
+ const dir = specEvidenceDir(root, config) ?? paths(root).evidence;
617
+ mkdirSync(dir, { recursive: true });
618
+ writeFileSync(join(dir, "changes.txt"), [`Changes since ${base.slice(0, 12)}`, "", stat, ...untracked, ""].join("\n"));
619
+ const last = stat.split("\n").pop()?.trim() ?? "";
620
+ const summary = [last, untracked.length ? `${untracked.length} new ${untracked.length === 1 ? "file" : "files"}` : ""].filter(Boolean).join(", ");
621
+ writeState(root, { lastDiffStat: summary || "no changes" });
622
+ return summary;
623
+ }
624
+
625
+ function printBuild(root, idea) {
626
+ const config = readConfig(root);
627
+ const spec = activeSpec(root);
628
+ const artifacts = spec?.state === "approved" ? spec.artifacts : null;
629
+ console.log(composeBuildPrompt(root, config, idea || (artifacts ? specTitle(artifacts.spec, spec.name) : ""), [], artifacts));
630
+ return 0;
631
+ }
632
+
633
+ // Using the app: a separate agent run, after the checks pass and before the review, that
634
+ // works the app like a person and leaves a screenshot for every claim. Websites are started
635
+ // here; phone apps need a simulator already running.
636
+ function runJourneys(root, config, { artifacts }) {
637
+ const analysis = analyseProject(root);
638
+ const info = pickDriver(root, analysis, config);
639
+ const finish = (row, dir = null) => {
640
+ writeState(root, { lastJourneys: { at: new Date().toISOString(), status: row.status, summary: row.summary, dir } });
641
+ return { result: row, dir };
642
+ };
643
+
644
+ if (!info.driver) return finish(journeysResultNa(info.reason));
645
+ if (!artifacts) return finish(journeysResultNa("No approved spec, so there is nothing to walk through"));
646
+ const tester = journeyAgent(config, info);
647
+ if (!tester) return finish(journeysResultNa("Using the app runs with Claude Code; for another agent, set agent.journeys"));
648
+ if (!agentAvailable(tester)) return finish(journeysResultNotRun(`No agent found for the journeys: ${tester.agent.command}`));
649
+
650
+ const base = specEvidenceDir(root, config) ?? ensure(root).evidence;
651
+ const dir = join(base, "journeys");
652
+ rmSync(dir, { recursive: true, force: true });
653
+ mkdirSync(dir, { recursive: true });
654
+
655
+ let app = null;
656
+ let url = null;
657
+ if (analysis.stack.kind === "web") {
658
+ const start = startCommand(root, analysis.stack);
659
+ if (!start) return finish(journeysResultNotRun("No dev or start script found, so the app could not be started"), rel(root, dir));
660
+ url = config.checks?.appUrl ?? null;
661
+ if (url && !appUrlAllowed(url, config)) {
662
+ return finish(journeysResultNotRun("checks.appUrl is not on this machine; set checks.allowRemoteApp to true to allow it"), rel(root, dir));
663
+ }
664
+ const logFile = join(dir, "app.log");
665
+ console.log(colors.dim(`Starting the app: ${start.command}${start.cwd ? ` (in ${start.cwd})` : ""}`));
666
+ app = startApp(root, start, { logFile, url, timeoutSeconds: config.checks?.appStartSeconds ?? 60 });
667
+ if (app.error) {
668
+ return finish(journeysResultNotRun(`The app could not be used: ${app.error}`, [], [rel(root, logFile)]), rel(root, dir));
669
+ }
670
+ url = app.url;
671
+ }
672
+
673
+ const prompt = composeJourneyPrompt(root, config, { info, url, artifacts, dir: rel(root, dir) });
674
+ const promptFile = savePrompt(root, `journeys-${stamp()}`, prompt);
675
+ const guarded = info.driver !== "playwright" && isGitRepo(root);
676
+ const before = guarded ? workingTreeFingerprint(root) : null;
677
+ console.log(colors.bold(`Using the app: ${info.driver}${url ? ` at ${url}` : ""}`));
678
+ try {
679
+ runAgent(root, prompt, tester, promptFile);
680
+ } finally {
681
+ app?.stop();
682
+ }
683
+
684
+ if (guarded && workingTreeFingerprint(root) !== before) {
685
+ return finish(
686
+ { ...journeysResultNotRun(""), status: "fail", summary: "The tester changed the app's code, so its journeys do not count" },
687
+ rel(root, dir),
688
+ );
689
+ }
690
+ return finish(checkJourneys(root, dir), rel(root, dir));
691
+ }
692
+
693
+ function printJourneys(row) {
694
+ const paint = row.status === "pass" ? colors.green : row.status === "fail" ? colors.red : colors.dim;
695
+ console.log(paint(`${label(row).padEnd(8)} Used the app: ${row.summary}`));
696
+ if (row.status === "fail") for (const detail of row.details.slice(0, 5)) console.log(colors.dim(` ${detail}`));
697
+ }
698
+
699
+ // The independent review: a separate agent run that sees the change, the approved spec,
700
+ // the rules and the factory's check results, never the builder's prompt. It may write
701
+ // only its review file; if anything else changes, the review does not count.
702
+ function reviewerConfig(config) {
703
+ return config.agent.review ? { ...config, agent: normaliseAgent(config.agent.review) } : config;
704
+ }
705
+
706
+ function runReview(root, config, { artifacts, base, run }) {
707
+ const reviewer = reviewerConfig(config);
708
+ const stop = (problem) => {
709
+ writeState(root, { lastReview: { at: new Date().toISOString(), passed: false, summary: problem, file: null, unproven: [] } });
710
+ return { passed: false, problem, summary: problem };
711
+ };
712
+ if (!agentAvailable(reviewer)) return stop(`No reviewer agent found: ${reviewer.agent.command}`);
713
+ const change = changeSince(root, base);
714
+ if (!change.files.length) return stop(`Nothing changed since ${base.slice(0, 12)}; nothing to review`);
715
+
716
+ const dir = specEvidenceDir(root, config) ?? ensure(root).evidence;
717
+ mkdirSync(dir, { recursive: true });
718
+ const file = join(dir, "review.md");
719
+ rmSync(file, { force: true });
720
+
721
+ const prompt = composeReviewPrompt(root, config, { artifacts, change, reviewFile: rel(root, file), run });
722
+ const promptFile = savePrompt(root, `review-${stamp()}`, prompt);
723
+ const before = workingTreeFingerprint(root);
724
+ console.log(colors.bold(`Independent review: ${reviewer.agent.command}, a separate session`));
725
+ runAgent(root, prompt, reviewer, promptFile);
726
+
727
+ let problem = null;
728
+ if (workingTreeFingerprint(root) !== before) problem = "The reviewer changed files besides its review, so the review does not count";
729
+ else if (!existsSync(file)) problem = "The reviewer wrote no review";
730
+ const review = problem ? null : parseReview(readFileSync(file, "utf8"));
731
+ if (review && !review.verdict) problem = "The review has no VERDICT line, so it does not count";
732
+
733
+ const passed = !problem && reviewPassed(review);
734
+ const summary = problem ?? reviewLine(review);
735
+ writeState(root, {
736
+ lastReview: {
737
+ at: new Date().toISOString(),
738
+ passed,
739
+ summary,
740
+ file: rel(root, file),
741
+ mustFix: review?.mustFix.length ?? null,
742
+ unproven: review?.unproven.map((check) => `${check.check}: ${check.evidence}`) ?? [],
743
+ },
744
+ });
745
+ return { passed, problem, review, file, summary };
746
+ }
747
+
748
+ function reviewFailure(result) {
749
+ const details = [];
750
+ if (result.review) {
751
+ for (const finding of result.review.mustFix) details.push(`${finding.where} ${finding.rule}: ${finding.what}`);
752
+ for (const check of result.review.failed) details.push(`${check.check} FAIL: ${check.evidence}`);
753
+ for (const item of result.review.missing) details.push(`Missing: ${item}`);
754
+ }
755
+ return {
756
+ id: "review",
757
+ title: "Independent review",
758
+ kind: "proven",
759
+ status: "fail",
760
+ summary: result.summary,
761
+ details: [...details, `Full review: ${rel(process.cwd(), result.file ?? "")}`].filter((line) => !line.endsWith(": ")),
762
+ evidence: result.file ? [result.file] : [],
763
+ };
764
+ }
765
+
766
+ function printReview(root, result) {
767
+ const paint = result.passed ? colors.green : colors.red;
768
+ console.log(paint(`Review: ${result.summary}`));
769
+ if (result.review) {
770
+ for (const finding of result.review.mustFix) console.log(` must fix ${finding.where} ${finding.rule}: ${finding.what}`);
771
+ for (const check of result.review.failed) console.log(` FAIL ${check.check}: ${check.evidence}`);
772
+ for (const check of result.review.unproven) console.log(colors.yellow(` a person ${check.check}: ${check.evidence}`));
773
+ }
774
+ if (result.file && existsSync(result.file)) console.log(colors.dim(` ${rel(root, result.file)}`));
775
+ }
776
+
777
+ export function reviewCommand(root, opts = {}) {
778
+ if (!isGitRepo(root)) {
779
+ console.error("factory review needs git, to know what changed.");
780
+ return 1;
781
+ }
782
+ const config = ensureConfig(root);
783
+ const spec = activeSpec(root);
784
+ const artifacts = spec?.state === "approved" ? spec.artifacts : null;
785
+ const base = reviewBase(root, { base: opts.base, state: readState(root) });
786
+ if (!base) {
787
+ console.error("No commit to compare against. Pass one: factory review --base <commit or branch>");
788
+ return 1;
789
+ }
790
+ const p = paths(root);
791
+ const run = existsSync(p.lastVerify) ? JSON.parse(readFileSync(p.lastVerify, "utf8")) : null;
792
+ const result = runReview(root, config, { artifacts, base, run });
793
+ printReview(root, result);
794
+ if (result.problem?.startsWith("No reviewer agent")) return 2;
795
+ return result.passed ? 0 : 1;
796
+ }
797
+
798
+ export function build(root, opts = {}) {
799
+ let idea = String(opts.idea ?? "").trim();
800
+ if (opts.print) return printBuild(root, idea);
801
+
802
+ const config = ensureConfig(root);
803
+ let spec = activeSpec(root);
804
+ const specRequired = config.spec?.required !== false;
805
+
806
+ if (specRequired && !opts.hotfix) {
807
+ if (!spec) {
808
+ console.error("No active spec. The factory builds against an approved spec.");
809
+ console.error('Start one: factory "what you want to change"');
810
+ console.error("For a genuine quick fix: add --hotfix (it is recorded).");
811
+ return 1;
812
+ }
813
+ if (spec.state !== "approved") {
814
+ console.error(`Spec ${spec.name} is ${spec.state}. Read it, then: factory approve ${spec.name}`);
815
+ console.error("Editing a spec after approval makes it stale; approve it again.");
816
+ return 1;
817
+ }
818
+ }
819
+
820
+ if (!idea && spec?.state === "approved") idea = specTitle(spec.artifacts.spec, spec.name);
821
+ if (!idea) {
822
+ console.error('Say what to build. Example: factory build "add a booking form" --hotfix');
823
+ return 1;
824
+ }
825
+
826
+ // Plan and tasks come before code; `factory build` resumes from wherever it stopped.
827
+ if (spec?.state === "approved" && !opts.hotfix) {
828
+ const files = specPaths(root, spec.name);
829
+ if (!existsSync(files.plan)) {
830
+ const code = planCommand(root, { quiet: true });
831
+ if (code !== 0) return code;
832
+ }
833
+ if (!existsSync(files.tasks)) {
834
+ const code = tasksCommand(root, { quiet: true });
835
+ if (code !== 0) return code;
836
+ }
837
+ spec = activeSpec(root);
838
+ }
839
+
840
+ const p = ensure(root);
841
+ writeFileSync(p.idea, `${idea}\n`);
842
+ if (opts.hotfix) {
843
+ console.log(colors.yellow("HOTFIX: no approved spec. This is recorded in the evidence."));
844
+ writeState(root, { lastHotfix: new Date().toISOString() });
845
+ }
846
+
847
+ const base = gitText(root, ["rev-parse", "HEAD"]);
848
+ writeState(root, { buildBase: base, buildStartedAt: new Date().toISOString() });
849
+ const testsBefore = countTests(root);
850
+
851
+ const artifacts = spec?.state === "approved" ? spec.artifacts : null;
852
+ const attempts = Math.max(1, Number(opts.attempts ?? 2) || 1);
853
+ let failures = [];
854
+
855
+ for (let attempt = 1; attempt <= attempts; attempt++) {
856
+ const prompt = composeBuildPrompt(root, config, idea, failures, artifacts);
857
+ const file = savePrompt(root, `build-${stamp()}-${attempt}`, prompt);
858
+
859
+ if (!agentAvailable(config)) {
860
+ console.log(colors.yellow(`No agent found: ${config.agent.command}`));
861
+ console.log(`The prompt is saved at ${rel(root, file)}`);
862
+ console.log("Run it with your agent, then run: factory verify --deep");
863
+ return 2;
864
+ }
865
+
866
+ console.log(colors.bold(`Attempt ${attempt}/${attempts}: ${config.agent.command}`));
867
+ runAgent(root, prompt, config, file);
868
+
869
+ const run = verify(root, { deep: true, config });
870
+ const testsAfter = countTests(root);
871
+ const removed =
872
+ testsAfter < testsBefore
873
+ ? {
874
+ id: "tests-removed",
875
+ title: "Tests removed",
876
+ kind: "proven",
877
+ status: "fail",
878
+ summary: `${testsBefore} test files before the build, ${testsAfter} after. Put them back or say why in the spec.`,
879
+ details: [],
880
+ evidence: [],
881
+ }
882
+ : null;
883
+ // A fix can name checks that must pass, not merely not fail: "add the first test"
884
+ // is not done while the tests cannot run.
885
+ const unmet = (opts.requires ?? [])
886
+ .map((id) => run.results.find((result) => result.id === id))
887
+ .filter((result) => result && result.status !== "pass")
888
+ .map((result) => ({
889
+ ...result,
890
+ id: `required-${result.id}`,
891
+ kind: "proven",
892
+ status: "fail",
893
+ summary: `This job needs "${result.title}" to pass; it is ${label(result)}: ${result.summary}`,
894
+ }));
895
+ saveChanges(root, config, base);
896
+ console.log(`\n${formatResults(root, run)}`);
897
+ if (removed) console.log(colors.red(`FAIL ${removed.title}: ${removed.summary}`));
898
+ for (const item of unmet) console.log(colors.red(`FAIL ${item.summary}`));
899
+ console.log("");
900
+
901
+ let reviewed = null;
902
+ if (run.ok && !removed && !unmet.length) {
903
+ if (artifacts) {
904
+ const after = activeSpec(root);
905
+ if (!after || after.state !== "approved") {
906
+ console.error(colors.red("NOT DONE: the spec changed during the build. Approve it again."));
907
+ return 1;
908
+ }
909
+ }
910
+
911
+ const used = runJourneys(root, config, { artifacts });
912
+ printJourneys(used.result);
913
+ if (used.result.status === "fail") {
914
+ failures = [used.result];
915
+ if (attempt < attempts) console.log(colors.red("NOT DONE: sending what failed in the app back to the team.\n"));
916
+ continue;
917
+ }
918
+ const evidenceRun = { ...run, results: [...run.results, used.result] };
919
+
920
+ if (config.review?.enabled !== false && base) {
921
+ reviewed = runReview(root, config, { artifacts, base, run: evidenceRun });
922
+ printReview(root, reviewed);
923
+ console.log("");
924
+ } else {
925
+ writeState(root, { lastReview: null });
926
+ console.log(colors.yellow(base ? "No independent review: review.enabled is false." : "No independent review: this is not a git repository."));
927
+ }
928
+
929
+ if (!reviewed || reviewed.passed) {
930
+ writeState(root, { lastBuild: new Date().toISOString() });
931
+ console.log(colors.green(`DONE: the checks passed${reviewed ? " and a separate session approved the change" : ""}. Proof: ${rel(root, run.mdFile)}`));
932
+ return 0;
933
+ }
934
+ }
935
+
936
+ failures = run.results.filter((result) => result.kind === "proven" && result.status === "fail");
937
+ if (removed) failures.push(removed);
938
+ failures.push(...unmet);
939
+ if (reviewed && !reviewed.passed) failures.push(reviewFailure(reviewed));
940
+ if (failures.length && attempt < attempts) {
941
+ console.log(colors.red(`NOT DONE: sending ${failures.length} ${failures.length === 1 ? "failure" : "failures"} back to the team.\n`));
942
+ }
943
+ }
944
+
945
+ console.log(colors.red("NOT DONE: the checks did not pass. Nothing was called done."));
946
+ return 1;
947
+ }
948
+
949
+ export function verifyCommand(root, opts = {}) {
950
+ if (opts.app) {
951
+ const config = ensureConfig(root);
952
+ const spec = activeSpec(root);
953
+ const used = runJourneys(root, config, { artifacts: spec?.state === "approved" ? spec.artifacts : null });
954
+ printJourneys(used.result);
955
+ return used.result.status === "fail" ? 1 : 0;
956
+ }
957
+ const save = existsSync(paths(root).config) || Boolean(opts.save);
958
+ const run = verify(root, { deep: Boolean(opts.deep), save });
959
+ if (opts.json) process.stdout.write(`${JSON.stringify(run, null, 2)}\n`);
960
+ else console.log(formatResults(root, run));
961
+ return run.ok ? 0 : 1;
962
+ }
963
+
964
+ function renderReport(run, config, spec, review, journeys) {
965
+ const date = new Date(run.at).toLocaleString();
966
+ const skipped = run.results.filter((result) => result.status === "na" || result.status === "not-run");
967
+
968
+ const lines = [
969
+ `# ${config.name}: what was checked`,
970
+ "",
971
+ `Prepared ${date}. This is a record of checks that ran. It is not a guarantee.`,
972
+ "",
973
+ config.whoDepends?.length
974
+ ? `Who depends on this: ${config.whoDepends.join(", ")}.`
975
+ : "Who depends on this: not recorded.",
976
+ "",
977
+ spec
978
+ ? `Spec: ${spec.name} (${spec.state}${
979
+ spec.artifacts.approval
980
+ ? `, approved by ${spec.artifacts.approval.approvedBy} on ${String(spec.artifacts.approval.approvedAt).slice(0, 10)}`
981
+ : ""
982
+ }).`
983
+ : "Spec: none.",
984
+ "",
985
+ "## Results",
986
+ "",
987
+ "| Check | Result | What it found |",
988
+ "|---|---|---|",
989
+ ...run.results.map((result) => `| ${result.title} | ${label(result)} | ${result.summary} |`),
990
+ "",
991
+ "## Used the app",
992
+ "",
993
+ journeys ? `${journeys.summary}.${journeys.dir ? ` Screenshots and notes: ${journeys.dir}` : ""}` : "The app was not used for this result.",
994
+ "",
995
+ "## Independent review",
996
+ "",
997
+ review
998
+ ? `${review.summary}.${review.file ? ` Review: ${review.file}` : ""}`
999
+ : "No independent review ran for this result.",
1000
+ ...(review?.unproven?.length ? ["", "For a person to check:", ...review.unproven.map((item) => `- ${item}`)] : []),
1001
+ "",
1002
+ "## What was not checked",
1003
+ "",
1004
+ ];
1005
+
1006
+ if (skipped.length) {
1007
+ for (const result of skipped) {
1008
+ lines.push(`- ${result.title}: ${result.summary}`);
1009
+ }
1010
+ } else {
1011
+ lines.push("- Every check in this run applied.");
1012
+ }
1013
+
1014
+ lines.push(
1015
+ "",
1016
+ "## What this does not prove",
1017
+ "",
1018
+ "- It does not prove the software is correct or free of bugs.",
1019
+ "- It does not prove legal compliance, including POPIA.",
1020
+ "- It does not test real phones, real networks or real people. Screenshots show what the AI saw, on this machine.",
1021
+ "- It does not judge whether the design is good or the words are clear.",
1022
+ "",
1023
+ "## Evidence",
1024
+ "",
1025
+ `- Full result: ${run.jsonFile ?? ".factory/last-verify.json"}`,
1026
+ );
1027
+
1028
+ for (const result of run.results) {
1029
+ for (const file of result.evidence) {
1030
+ lines.push(`- ${result.title}: ${file}`);
1031
+ }
1032
+ }
1033
+
1034
+ lines.push(
1035
+ "",
1036
+ "## If you are receiving this",
1037
+ "",
1038
+ "Ask the builder for the evidence file, and run the same checks yourself:",
1039
+ "",
1040
+ "```bash",
1041
+ "factory verify --deep",
1042
+ "```",
1043
+ "",
1044
+ );
1045
+
1046
+ return lines.join("\n");
1047
+ }
1048
+
1049
+ export function report(root, opts = {}) {
1050
+ const p = paths(root);
1051
+ if (!existsSync(p.lastVerify)) {
1052
+ console.error("No verification yet. Run: factory verify --deep");
1053
+ return 1;
1054
+ }
1055
+
1056
+ const run = JSON.parse(readFileSync(p.lastVerify, "utf8"));
1057
+ const config = readConfig(root);
1058
+ const state = readState(root);
1059
+ const text = renderReport(run, config, activeSpec(root), state.lastReview, state.lastJourneys);
1060
+ const out = opts.out ? resolve(root, String(opts.out)) : p.report;
1061
+
1062
+ mkdirSync(dirname(out), { recursive: true });
1063
+ writeFileSync(out, text);
1064
+ console.log(colors.green(`Report written to ${out}`));
1065
+ return 0;
1066
+ }
1067
+
1068
+ function slugify(text) {
1069
+ return String(text)
1070
+ .toLowerCase()
1071
+ .normalize("NFKD")
1072
+ .replace(/[^a-z0-9]+/g, "-")
1073
+ .replace(/^-+|-+$/g, "")
1074
+ .slice(0, 60) || "lesson";
1075
+ }
1076
+
1077
+ export async function lessonNew(root, opts = {}) {
1078
+ const answers = {
1079
+ title: opts.title ?? "",
1080
+ what: opts.what ?? "",
1081
+ spot: opts.spot ?? "",
1082
+ check: opts.check ?? "",
1083
+ example: opts.example ?? "",
1084
+ sa: ["y", "yes", "true"].includes(String(opts.sa ?? opts["south-africa"] ?? "").toLowerCase()),
1085
+ };
1086
+
1087
+ if (!answers.title || !answers.what) {
1088
+ if (opts.yes || !process.stdin.isTTY) {
1089
+ console.error('A lesson needs --title and --what. Example: factory lesson new --title "..." --what "..."');
1090
+ return 1;
1091
+ }
1092
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1093
+ try {
1094
+ answers.title = await ask(rl, "Title (one line)", answers.title);
1095
+ answers.what = await ask(rl, "What went wrong?", answers.what);
1096
+ answers.spot = await ask(rl, "How can someone spot it?", answers.spot);
1097
+ answers.check = await ask(rl, "What check catches it? (or describe it in plain words)", answers.check);
1098
+ answers.example = await ask(rl, "A real example (project, what happened)", answers.example);
1099
+ answers.sa = normalizeData(await ask(rl, "Is it South Africa specific? (y/N)", "no")) === "yes";
1100
+ } finally {
1101
+ rl.close();
1102
+ }
1103
+ }
1104
+
1105
+ if (!String(answers.title).trim() || !String(answers.what).trim()) {
1106
+ console.error("A lesson needs a title and what went wrong.");
1107
+ return 1;
1108
+ }
1109
+
1110
+ const date = new Date().toISOString().slice(0, 10);
1111
+ const file = join(root, "lessons", `${date}-${slugify(answers.title)}.md`);
1112
+
1113
+ if (existsSync(file)) {
1114
+ console.error(`That lesson already exists: ${rel(root, file)}`);
1115
+ return 1;
1116
+ }
1117
+
1118
+ mkdirSync(join(root, "lessons"), { recursive: true });
1119
+ copyIfMissing(join(TEMPLATES, "lesson.md"), join(root, "lessons", "_template.md"));
1120
+
1121
+ const lines = [
1122
+ `# ${String(answers.title).trim()}`,
1123
+ "",
1124
+ `- Date: ${date}`,
1125
+ `- South Africa specific: ${answers.sa ? "yes" : "no"}`,
1126
+ "",
1127
+ "## What went wrong",
1128
+ "",
1129
+ String(answers.what).trim() || "_Not written yet._",
1130
+ "",
1131
+ "## How to spot it",
1132
+ "",
1133
+ String(answers.spot).trim() || "_Not written yet._",
1134
+ "",
1135
+ "## The check",
1136
+ "",
1137
+ String(answers.check).trim() || "_No check yet. Describe it in plain words._",
1138
+ "",
1139
+ "## Real example",
1140
+ "",
1141
+ String(answers.example).trim() || "_Not written yet._",
1142
+ "",
1143
+ ];
1144
+
1145
+ if (answers.sa) {
1146
+ lines.push(
1147
+ "## Not legal advice",
1148
+ "",
1149
+ "This lesson is a prompt to look, not legal advice. A person who knows the law has to decide.",
1150
+ "",
1151
+ );
1152
+ }
1153
+
1154
+ writeFileSync(file, lines.join("\n"));
1155
+ console.log(colors.green(`Lesson written to ${rel(root, file)}`));
1156
+ return 0;
1157
+ }
1158
+
1159
+ export function status(root) {
1160
+ const config = readConfig(root);
1161
+ const state = readState(root);
1162
+ const p = paths(root);
1163
+
1164
+ console.log(colors.bold(`${config.name}`) + colors.dim(` ${config.kind}`));
1165
+ console.log(`Config: ${existsSync(p.config) ? rel(root, p.config) : "not created (run factory init)"}`);
1166
+ const spec = activeSpec(root);
1167
+ console.log(`Spec: ${spec ? `${spec.name} (${spec.state})` : "none"}`);
1168
+
1169
+ if (!state.lastVerify || !existsSync(p.lastVerify)) {
1170
+ console.log("Checked: not yet. Run: factory verify");
1171
+ return 0;
1172
+ }
1173
+
1174
+ const run = JSON.parse(readFileSync(p.lastVerify, "utf8"));
1175
+ const failed = run.results.filter((result) => result.kind === "proven" && result.status === "fail");
1176
+ const look = run.results.filter((result) => result.kind === "flagged" && result.status === "fail");
1177
+ const notRun = run.results.filter((result) => result.status === "not-run");
1178
+
1179
+ console.log(`Checked: ${new Date(run.at).toLocaleString()} (${run.deep ? "deep" : "fast"} pass)`);
1180
+ console.log(failed.length ? colors.red(`Failed: ${failed.map((item) => item.title).join(", ")}`) : colors.green("Failed: none"));
1181
+ if (look.length) console.log(colors.yellow(`To look at: ${look.map((item) => item.title).join(", ")}`));
1182
+ if (notRun.length) console.log(colors.dim(`Not run: ${notRun.map((item) => item.title).join(", ")}`));
1183
+ if (state.lastReview) {
1184
+ const paint = state.lastReview.passed ? colors.green : colors.red;
1185
+ console.log(paint(`Review: ${state.lastReview.summary}`));
1186
+ }
1187
+ console.log(colors.dim(`Evidence: ${rel(root, p.lastVerify)}`));
1188
+ return 0;
1189
+ }
1190
+
1191
+ export function doctor(root) {
1192
+ const detected = detectStack(root);
1193
+ const config = readConfig(root);
1194
+ const stack = { ...detected };
1195
+ if (stack.kind === "other" && config.kind !== "other") stack.kind = config.kind;
1196
+ const p = paths(root);
1197
+ const configured = existsSync(p.config);
1198
+ const version = agentVersion(config);
1199
+ const found = configured || version ? null : detectAgent();
1200
+
1201
+ console.log(colors.bold("factory doctor"));
1202
+ console.log(`Project: ${config.name} (${stack.kind})`);
1203
+ console.log(`Detected: ${stack.framework ?? "unknown"} / ${stack.language ?? "unknown"}${stack.workspaces.length ? `, monorepo (${stack.workspaces.length} packages, main: ${stack.primary})` : ""}`);
1204
+ console.log(`Config: ${configured ? rel(root, p.config) : "none yet (factory writes nothing until you start a job)"}`);
1205
+ const spec = configured ? activeSpec(root) : null;
1206
+ console.log(`Spec: ${spec ? `${spec.name} (${spec.state})` : "none"}`);
1207
+ console.log(`Tests: ${stack.testCommand ? `${stack.testCommand}${stack.commandCwd ? ` (in ${stack.commandCwd})` : ""}` : "none found"}`);
1208
+ console.log(`Build: ${stack.buildCommand ?? "none found"}`);
1209
+ if (version) {
1210
+ console.log(`Agent: ${config.agent.command} ${colors.green(`(found: ${version})`)}`);
1211
+ } else if (found) {
1212
+ console.log(`Agent: ${found.command} ${colors.green("(found; it will be used when you start a job)")}`);
1213
+ } else {
1214
+ console.log(`Agent: ${colors.yellow("none found")}`);
1215
+ console.log(" Install Claude Code, Codex, Gemini CLI or OpenCode, or set agent.command in");
1216
+ console.log(" factory.config.json. Everything except spec, plan, tasks and build works without one.");
1217
+ }
1218
+ console.log("");
1219
+ const web = stack.kind === "web";
1220
+ console.log("Checks:");
1221
+ console.log(" secrets always (real credentials, and possible test passwords)");
1222
+ console.log(" test proof always (empty, screen-only or skipped tests; no tests at all)");
1223
+ console.log(` tests ${stack.testCommand ? "with --deep" : "not applicable (no test command)"}`);
1224
+ console.log(` build ${stack.buildCommand ? "with --deep" : "not applicable (no build command)"}`);
1225
+ console.log(` weight ${web ? "after a build" : "not applicable (websites only)"}`);
1226
+ console.log(" personal data always");
1227
+ console.log(` offline ${web ? "when the site has a web app manifest or checks.offline is true" : "not applicable (websites only)"}`);
1228
+ return 0;
1229
+ }