@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Next-Gen Collective
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # @next-generation-collective/factory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **It checks the work before it says done, and it remembers every mistake.**
4
+
5
+ A command that works with the AI coding tool you already use: Claude Code, Codex, Gemini
6
+ CLI or OpenCode. It plans each change with you, has your AI build it, runs your tests, uses
7
+ the app like a person, has a separate AI session review it, and reports only what it proved.
8
+
9
+ A [Next-Gen Collective](https://github.com/next-generation-collective) tool. Node 20 or newer.
10
+
11
+ ## Try it
12
+
13
+ ```bash
14
+ cd your-project
15
+ npx @next-generation-collective/factory
16
+ ```
17
+
18
+ In about a second it shows what your project can prove: credentials in the code, tests
19
+ that check nothing, no tests at all, and more. No account, no questions, nothing written
20
+ into your project. If it finds a problem and you have an AI tool installed, it offers to
21
+ fix it: you approve a short plan first.
22
+
23
+ Give it a job:
24
+
25
+ ```bash
26
+ npx @next-generation-collective/factory "add a booking form that survives a reload"
27
+ ```
28
+
29
+ Or install it:
30
+
31
+ ```bash
32
+ npm install -g @next-generation-collective/factory
33
+ factory
34
+ ```
35
+
36
+ ## A job, step by step
37
+
38
+ 1. **Spec from your code.** Every screen state and how each check will be proven. Guesses
39
+ are marked `Assumed:`. It asks nothing, except two questions for login, money or
40
+ personal data.
41
+ 2. **Your approval**, recorded with a fingerprint. Editing the spec later makes it stale.
42
+ 3. **Plan, tasks, build.** Your AI may edit files in the project, not run commands.
43
+ 4. **The factory's checks**: your tests and build, secrets, tests that prove nothing, and
44
+ more. Failures go back to the builder.
45
+ 5. **Using the app** in Chrome or Firefox (or Playwright, or Maestro), a screenshot per check.
46
+ 6. **An independent review** by a separate session, a verdict for every acceptance check.
47
+ 7. **The report**: what passed, what a person must look at, what was not checked.
48
+
49
+ Nothing is committed for you.
50
+
51
+ ## Commands
52
+
53
+ | Command | What it does |
54
+ |---|---|
55
+ | `factory` | What the project can prove; then offers to fix the top problem |
56
+ | `factory "..."` | A job, from spec to review |
57
+ | `factory approve [name]` | Read what matters in a spec, then approve it |
58
+ | `factory build` | Plan, build, check, use the app, review (resumes where it stopped) |
59
+ | `factory verify [--deep] [--app] [--json]` | The checks alone |
60
+ | `factory review [--base=ref]` | The independent review alone |
61
+ | `factory report` | A one-page handover |
62
+ | `factory status`, `factory doctor` | The state of things; what was detected |
63
+ | `factory lesson new` | Turn a real failure into a dated lesson |
64
+
65
+ Exit codes: `0` nothing proven wrong; `1` a proven failure, or a command could not do its
66
+ job; `2` no agent found (the prompt is saved in `.factory/prompts/`).
67
+
68
+ ## Honest limits
69
+
70
+ It cannot prove your software is correct, and it never says "bug-free", "secure" or
71
+ "compliant". It does not set up a phone simulator. Screenshots show what the AI saw on
72
+ your machine. The reviewer is an AI too; what it cannot prove is marked for a person.
73
+
74
+ Full guide, limits and security notes:
75
+ <https://github.com/next-generation-collective/factory>
76
+
77
+ ## Licence
78
+
79
+ MIT.
package/bin/factory.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../lib/cli.js";
3
+
4
+ main(process.argv.slice(2))
5
+ .then((code) => process.exit(code))
6
+ .catch((error) => {
7
+ console.error(error?.stack ?? error);
8
+ process.exit(1);
9
+ });
package/lib/agent.js ADDED
@@ -0,0 +1,121 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { AGENT_ORDER, PRESETS } from "./agents.js";
5
+ import { ensure } from "./config.js";
6
+
7
+ const WINDOWS = process.platform === "win32";
8
+ const SAFE_COMMAND = /^[A-Za-z0-9._:/\\ ()-]+$/;
9
+ const INLINE_LIMIT = 30000;
10
+ const probes = new Map();
11
+
12
+ // factory.config.json is trusted like a package.json script, but a command name with
13
+ // shell syntax in it is refused, because Windows runs agents through the shell.
14
+ export function safeCommand(command) {
15
+ return typeof command === "string" && SAFE_COMMAND.test(command);
16
+ }
17
+
18
+ function quoteForWindows(arg) {
19
+ if (/^[A-Za-z0-9._:/\\=@+-]+$/.test(arg)) return arg;
20
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
21
+ }
22
+
23
+ // Windows: an .exe starts directly; a .cmd or .bat (how npm installs claude, codex and
24
+ // gemini) needs the shell, so its path is quoted. Elsewhere, start it directly.
25
+ function launch(command) {
26
+ if (!WINDOWS) return { file: command, shell: false };
27
+ let full = command;
28
+ if (!/[\\/]/.test(command)) {
29
+ const exts = /\.[a-z]+$/i.test(command) ? [""] : (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";");
30
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
31
+ const hit = exts.map((ext) => join(dir, command + ext)).find((candidate) => existsSync(candidate));
32
+ if (hit) {
33
+ full = hit;
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ if (/\.(?:cmd|bat)$/i.test(full)) return { file: quoteForWindows(full), shell: true };
39
+ return { file: full, shell: false };
40
+ }
41
+
42
+ function probe(command) {
43
+ if (!probes.has(command)) {
44
+ const { file, shell } = launch(command);
45
+ const run = spawnSync(file, ["--version"], {
46
+ encoding: "utf8",
47
+ timeout: 10000,
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ shell,
50
+ });
51
+ const first = String(run.stdout ?? "").trim().split("\n")[0];
52
+ probes.set(command, run.status === 0 ? first || "found" : null);
53
+ }
54
+ return probes.get(command);
55
+ }
56
+
57
+ export function agentVersion(config) {
58
+ const command = config?.agent?.command;
59
+ return safeCommand(command) ? probe(command) : null;
60
+ }
61
+
62
+ export function agentAvailable(config) {
63
+ return agentVersion(config) != null;
64
+ }
65
+
66
+ // The first coding agent installed on this machine, with its preset, or null.
67
+ export function detectAgent() {
68
+ for (const name of AGENT_ORDER) {
69
+ if (probe(name) != null) return { ...PRESETS[name], args: [...PRESETS[name].args] };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function agentInvocation(args, prompt, promptFile) {
75
+ const list = (args ?? []).map(String);
76
+ const inline = list.some((arg) => arg.includes("{prompt}"));
77
+ const usesFile = list.some((arg) => arg.includes("{promptFile}"));
78
+ return {
79
+ args: list.map((arg) => arg.replaceAll("{promptFile}", promptFile ?? "").replaceAll("{prompt}", prompt)),
80
+ stdin: inline || usesFile ? null : prompt,
81
+ inline,
82
+ };
83
+ }
84
+
85
+ export function runAgent(root, prompt, config, promptFile) {
86
+ const command = config.agent.command;
87
+ if (!safeCommand(command)) {
88
+ console.error(`Refusing to run agent.command "${command}": use the program's name or path only.`);
89
+ return false;
90
+ }
91
+
92
+ const call = agentInvocation(config.agent.args, prompt, promptFile);
93
+ if (call.inline && WINDOWS) {
94
+ console.error("{prompt} in agent.args does not work on Windows. Remove it and the prompt goes on stdin.");
95
+ return false;
96
+ }
97
+ if (call.inline && prompt.length > INLINE_LIMIT) {
98
+ console.error(
99
+ `Warning: the prompt is ${prompt.length} characters on the command line. Remove {prompt} from agent.args to send it on stdin.`,
100
+ );
101
+ }
102
+
103
+ const { file, shell } = launch(command);
104
+ const options = { cwd: root, shell, maxBuffer: 64 * 1024 * 1024 };
105
+ if (call.stdin != null) {
106
+ options.input = call.stdin;
107
+ options.stdio = ["pipe", "inherit", "inherit"];
108
+ } else {
109
+ options.stdio = "inherit";
110
+ }
111
+ const args = shell ? call.args.map(quoteForWindows) : call.args;
112
+ const run = spawnSync(file, args, options);
113
+ return run.status === 0;
114
+ }
115
+
116
+ export function savePrompt(root, name, prompt) {
117
+ const p = ensure(root);
118
+ const file = join(p.prompts, `${name}.md`);
119
+ writeFileSync(file, prompt);
120
+ return file;
121
+ }
package/lib/agents.js ADDED
@@ -0,0 +1,42 @@
1
+ // How each coding agent takes a prompt. No placeholder in args means the prompt goes on
2
+ // stdin; {promptFile} is the saved prompt's path; {prompt} puts the text itself in an
3
+ // argument (kept for custom configs, but Windows cuts command lines off at 32 KB).
4
+ // Each preset may edit files inside the project and nothing more; it cannot run shell
5
+ // commands on its own (the factory runs the checks). To let an agent run commands, add
6
+ // its own flags to agent.args in factory.config.json.
7
+ export const PRESETS = {
8
+ // claude -p reads the prompt from stdin; acceptEdits allows edits, not commands.
9
+ claude: { command: "claude", args: ["-p", "--permission-mode", "acceptEdits"] },
10
+ // codex exec reads stdin when no prompt is given; workspace-write keeps writes in the project.
11
+ codex: { command: "codex", args: ["exec", "-s", "workspace-write"] },
12
+ // gemini -p appends its text to what arrives on stdin; auto_edit allows edits only.
13
+ gemini: { command: "gemini", args: ["--approval-mode", "auto_edit", "-p", "Follow the instructions above exactly."] },
14
+ opencode: {
15
+ command: "opencode",
16
+ args: ["run", "--file", "{promptFile}", "Read the attached file and follow it exactly."],
17
+ },
18
+ };
19
+
20
+ export const AGENT_ORDER = ["claude", "codex", "gemini", "opencode"];
21
+
22
+ const LEGACY_CLAUDE = [JSON.stringify(["-p", "{prompt}"]), JSON.stringify(["-p"])];
23
+
24
+ export function presetName(command) {
25
+ const name = String(command ?? "")
26
+ .split(/[\\/]/)
27
+ .pop()
28
+ .toLowerCase()
29
+ .replace(/\.(?:cmd|exe|bat)$/, "");
30
+ return PRESETS[name] ? name : null;
31
+ }
32
+
33
+ // Fills in args for a known agent when the config names only the command, and moves
34
+ // configs written by 0.2.0 (claude -p {prompt}) and early 0.3.0 (claude -p) to the preset.
35
+ export function normaliseAgent(agent) {
36
+ const command = agent?.command ?? PRESETS.claude.command;
37
+ const preset = presetName(command);
38
+ let args = agent?.args;
39
+ if (!Array.isArray(args)) args = preset ? PRESETS[preset].args : [];
40
+ if (preset === "claude" && LEGACY_CLAUDE.includes(JSON.stringify(args))) args = PRESETS.claude.args;
41
+ return { ...agent, command, args: [...args] };
42
+ }
package/lib/analyse.js ADDED
@@ -0,0 +1,252 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { detectStack } from "./detect.js";
4
+ import { browserServers } from "./journeys.js";
5
+ import { listSpecs } from "./spec.js";
6
+ import { isTestFile, plural, rel, walk } from "./util.js";
7
+
8
+ const NOTABLE = [
9
+ "expo", "react-native", "next", "nuxt", "astro", "svelte", "vue", "@angular/core", "react",
10
+ "@supabase/supabase-js", "firebase", "drizzle-orm", "prisma", "@prisma/client", "mongoose",
11
+ "express", "hono", "fastify", "@nestjs/core", "tailwindcss", "stripe",
12
+ ];
13
+ const PY_NOTABLE = ["django", "fastapi", "flask", "sqlalchemy", "pydantic", "pytest"];
14
+ const TEMPLATE_README = /turborepo starter|create react app|create-next-app|expo app|vite \+ react|next\.js app|bootstrapped with/i;
15
+
16
+ function readJson(file) {
17
+ try {
18
+ return JSON.parse(readFileSync(file, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ const count = plural;
25
+
26
+ function underPrefix(prefix) {
27
+ return (path) => !prefix || path === prefix || path.startsWith(`${prefix}/`);
28
+ }
29
+
30
+ // Screens a person can open: Next.js pages, Expo Router files, React Native screens.
31
+ function countScreens(relFiles, stack) {
32
+ const inPrimary = underPrefix(stack.primary);
33
+ const code = relFiles.filter((path) => /\.[cm]?[jt]sx?$/.test(path) && !isTestFile(path) && inPrimary(path));
34
+ if (stack.kind === "web") {
35
+ return code.filter(
36
+ (path) =>
37
+ /(?:^|\/)app\/(?:.+\/)?page\.[cm]?[jt]sx?$/.test(path) ||
38
+ (/(?:^|\/)pages\/.+/.test(path) && !/(?:^|\/)pages\/(?:_app|_document|_error|api\/)/.test(path)),
39
+ ).length;
40
+ }
41
+ if (stack.kind === "mobile") {
42
+ return code.filter(
43
+ (path) =>
44
+ /(?:^|\/)screens\/[^/]+\.[jt]sx?$/.test(path) ||
45
+ /(?:^|\/)screens\/[^/]+\/index\.[jt]sx?$/.test(path) ||
46
+ /(?:^|\/)app\/(?:[^/]+\/)*[^_+/][^/]*\.[jt]sx?$/.test(path),
47
+ ).length;
48
+ }
49
+ return 0;
50
+ }
51
+
52
+ function migrationInfo(root, stack) {
53
+ const bases = ["", ...stack.workspaces.map((w) => `${w.path}/`)];
54
+ const found = [];
55
+ for (const base of bases) {
56
+ for (const name of ["supabase/migrations", "migrations", "db/migrations", "prisma/migrations", "drizzle"]) {
57
+ const dir = `${base}${name}`;
58
+ if (!existsSync(join(root, dir))) continue;
59
+ try {
60
+ const files = readdirSync(join(root, dir)).filter((file) => /\.(?:sql|ts|js|prisma)$/.test(file));
61
+ if (files.length) found.push(`${dir} (${files.length})`);
62
+ } catch {}
63
+ }
64
+ if (existsSync(join(root, `${base}prisma`, "schema.prisma"))) found.push(`${base}prisma/schema.prisma`);
65
+ }
66
+ return found;
67
+ }
68
+
69
+ function envKeys(root, stack) {
70
+ const bases = ["", ...stack.workspaces.map((w) => `${w.path}/`)];
71
+ const keys = new Set();
72
+ for (const base of bases) {
73
+ for (const name of [".env.example", ".env.sample", ".env.template"]) {
74
+ const file = join(root, `${base}${name}`);
75
+ if (!existsSync(file)) continue;
76
+ for (const match of readFileSync(file, "utf8").matchAll(/^([A-Z0-9_]+)\s*=/gm)) keys.add(match[1]);
77
+ }
78
+ }
79
+ return [...keys];
80
+ }
81
+
82
+ function authFiles(relFiles) {
83
+ return relFiles
84
+ .filter((file) => /(?:^|[\\/_-])(?:auth|login|session|supabase)[^\\/]*\.[cm]?[jt]sx?$/i.test(file) && !isTestFile(file))
85
+ .slice(0, 6);
86
+ }
87
+
88
+ function packageDrivers(deps) {
89
+ const known = [
90
+ ["@playwright/test", "playwright"],
91
+ ["playwright", "playwright"],
92
+ ["puppeteer", "puppeteer"],
93
+ ["cypress", "cypress"],
94
+ ["webdriverio", "webdriverio"],
95
+ ["selenium-webdriver", "selenium"],
96
+ ];
97
+ const found = [];
98
+ for (const [dep, label] of known) {
99
+ if (deps.includes(dep) && !found.includes(label)) found.push(label);
100
+ }
101
+ return found;
102
+ }
103
+
104
+ function mcpBrowsers(root) {
105
+ const found = [];
106
+ for (const name of [".mcp.json", join(".claude", "settings.json")]) {
107
+ const file = join(root, name);
108
+ if (!existsSync(file)) continue;
109
+ let text = readFileSync(file, "utf8");
110
+ try {
111
+ text = JSON.stringify(JSON.parse(text));
112
+ } catch {}
113
+ if (/(?:chrome|chromium)/i.test(text)) found.push("chrome-devtools-mcp");
114
+ if (/firefox/i.test(text)) found.push("firefox-devtools-mcp");
115
+ if (/(?:safari|webkit)/i.test(text)) found.push("safari");
116
+ }
117
+ return found;
118
+ }
119
+
120
+ function drivers(root, deps, stack) {
121
+ const found = [];
122
+ const bases = ["", ...stack.workspaces.map((w) => `${w.path}/`)];
123
+ for (const base of bases) {
124
+ if (["playwright.config.ts", "playwright.config.js"].some((name) => existsSync(join(root, `${base}${name}`)))) {
125
+ found.push("playwright");
126
+ }
127
+ if (["cypress.config.ts", "cypress.config.js"].some((name) => existsSync(join(root, `${base}${name}`)))) {
128
+ found.push("cypress");
129
+ }
130
+ }
131
+ found.push(...packageDrivers(deps), ...mcpBrowsers(root));
132
+ if (bases.some((base) => [".maestro", "maestro"].some((name) => existsSync(join(root, `${base}${name}`))))) {
133
+ found.push("maestro");
134
+ }
135
+ return [...new Set(found)];
136
+ }
137
+
138
+ function readmeLine(root, stack) {
139
+ for (const base of ["", stack.primary ? `${stack.primary}/` : null].filter((b) => b !== null)) {
140
+ const file = ["README.md", "readme.md", "README"].map((name) => join(root, `${base}${name}`)).find((f) => existsSync(f));
141
+ if (!file) continue;
142
+ const line = readFileSync(file, "utf8")
143
+ .split("\n")
144
+ .map((item) => item.trim())
145
+ .find((item) => item && !item.startsWith("#") && !item.startsWith("<") && !item.startsWith("!["));
146
+ if (line && !TEMPLATE_README.test(line)) return line.slice(0, 160);
147
+ }
148
+ return "";
149
+ }
150
+
151
+ function describeStack(stack) {
152
+ const name = (w) => (w.kind === "api" && w.framework ? `${w.framework} api` : w.framework ?? w.kind);
153
+ if (stack.workspaces.length) {
154
+ const ordered = [...stack.workspaces].sort((a, b) => (b.path === stack.primary) - (a.path === stack.primary));
155
+ const kinds = [...new Set(ordered.filter((w) => w.kind !== "script").map(name))];
156
+ const paths = stack.workspaces.map((w) => w.path);
157
+ const shown = paths.length > 4 ? `${paths.slice(0, 4).join(", ")}, and ${paths.length - 4} more` : paths.join(", ");
158
+ return `${kinds.join(" + ") || "javascript"} (monorepo: ${shown}${stack.packageManager ? `, ${stack.packageManager}` : ""})`;
159
+ }
160
+ const what = stack.framework ?? stack.kind;
161
+ const language = stack.language && stack.language !== stack.framework ? ` / ${stack.language}` : "";
162
+ return `${what}${language}${stack.packageManager ? `, ${stack.packageManager}` : ""}`;
163
+ }
164
+
165
+ function notableLibraries(root, stack, pkg) {
166
+ const primaryPkg = stack.primary ? readJson(join(root, stack.primary, "package.json")) : null;
167
+ const deps = new Set(
168
+ [pkg, primaryPkg].flatMap((p) => Object.keys({ ...(p?.dependencies ?? {}), ...(p?.devDependencies ?? {}) })),
169
+ );
170
+ const found = NOTABLE.filter((name) => deps.has(name));
171
+ if (stack.language === "python") {
172
+ const text = ["pyproject.toml", "requirements.txt"]
173
+ .map((name) => join(root, name))
174
+ .filter((file) => existsSync(file))
175
+ .map((file) => readFileSync(file, "utf8").toLowerCase())
176
+ .join("\n");
177
+ found.push(...PY_NOTABLE.filter((name) => new RegExp(`\\b${name}\\b`).test(text)));
178
+ }
179
+ return found.length ? found : [...deps].slice(0, 8);
180
+ }
181
+
182
+ export function analyseProject(root) {
183
+ const stack = detectStack(root);
184
+ const pkg = existsSync(join(root, "package.json")) ? readJson(join(root, "package.json")) : null;
185
+ const files = walk(root);
186
+ const relFiles = files.map((file) => rel(root, file).split("\\").join("/"));
187
+ const screens = countScreens(relFiles, stack);
188
+ const tests = relFiles.filter(isTestFile);
189
+ const specs = listSpecs(root);
190
+ const migrations = migrationInfo(root, stack);
191
+ const env = envKeys(root, stack);
192
+ const auth = authFiles(relFiles);
193
+ const allDeps = [pkg, ...stack.workspaces.map((w) => readJson(join(root, w.path, "package.json")))].flatMap((p) =>
194
+ Object.keys({ ...(p?.dependencies ?? {}), ...(p?.devDependencies ?? {}) }),
195
+ );
196
+ const availableDrivers = drivers(root, allDeps, stack);
197
+ const readme = readmeLine(root, stack);
198
+ const libraries = notableLibraries(root, stack, pkg);
199
+
200
+ const scripts = pkg?.scripts ?? {};
201
+ const primaryScripts = stack.primary ? readJson(join(root, stack.primary, "package.json"))?.scripts ?? {} : {};
202
+ const startCommand =
203
+ scripts.dev ??
204
+ scripts.start ??
205
+ primaryScripts.dev ??
206
+ primaryScripts.start ??
207
+ (stack.framework === "expo" ? "npx expo start" : null);
208
+
209
+ const label = describeStack(stack);
210
+ const counts = [count(files.length, "file")];
211
+ if (stack.kind === "web" || stack.kind === "mobile") counts.push(stack.kind === "web" ? count(screens, "page") : count(screens, "screen"));
212
+ counts.push(count(tests.length, "test file"));
213
+ if (specs.length) counts.push(count(specs.length, "spec"));
214
+
215
+ const run = [
216
+ startCommand && `Start: ${startCommand}`,
217
+ stack.testCommand && `Tests: ${stack.testCommand}${stack.commandCwd ? ` (in ${stack.commandCwd})` : ""}`,
218
+ stack.buildCommand && `Build: ${stack.buildCommand}`,
219
+ ].filter(Boolean);
220
+
221
+ // Short form, for the first run.
222
+ const summary = [label, counts.join(", ")];
223
+ if (run.length) summary.push(run.join(" "));
224
+ const data = [migrations.length && `Data: ${migrations.join(", ")}`, env.length && `Env keys: ${env.length} (names only)`].filter(Boolean);
225
+ if (data.length) summary.push(data.join(" "));
226
+ const usable = [...new Set([...availableDrivers, ...browserServers(root).map((server) => `${server.browser}-devtools-mcp`)])];
227
+ if (usable.length && (stack.kind === "web" || stack.kind === "mobile")) summary.push(`Can use the app with: ${usable.join(", ")}`);
228
+
229
+ // Long form, for the agent's brief.
230
+ const lines = [label, counts.join(", "), ...run];
231
+ if (libraries.length) lines.push(`Libraries: ${libraries.slice(0, 12).join(", ")}`);
232
+ if (migrations.length) lines.push(`Data: ${migrations.join(", ")}`);
233
+ if (auth.length) lines.push(`Auth: ${auth.join(", ")}`);
234
+ if (env.length) lines.push(`Env keys: ${env.slice(0, 15).join(", ")} (names only)`);
235
+ if (availableDrivers.length) lines.push(`Drivers: ${availableDrivers.join(", ")}`);
236
+ if (readme) lines.push(`README: ${readme}`);
237
+
238
+ return {
239
+ stack,
240
+ label,
241
+ summary,
242
+ lines,
243
+ brief: lines.join("\n"),
244
+ empty: files.length === 0,
245
+ screens,
246
+ tests,
247
+ specs,
248
+ env,
249
+ drivers: availableDrivers,
250
+ startCommand,
251
+ };
252
+ }
@@ -0,0 +1,72 @@
1
+ // What a person needs to read before approving a spec: the decisions left to them, every
2
+ // guess the AI made, which states were covered, and the checks that will prove it.
3
+
4
+ export function section(text, heading) {
5
+ const lines = String(text).split("\n");
6
+ const start = lines.findIndex((line) => new RegExp(`^##\\s+${heading}\\b`, "i").test(line.trim()));
7
+ if (start === -1) return "";
8
+ const body = [];
9
+ for (const line of lines.slice(start + 1)) {
10
+ if (/^##\s/.test(line)) break;
11
+ body.push(line);
12
+ }
13
+ return body.join("\n").trim();
14
+ }
15
+
16
+ export function assumedLines(text) {
17
+ return String(text)
18
+ .split("\n")
19
+ .map((line) => line.trim().replace(/^[-*]\s+/, ""))
20
+ .filter((line) => /^Assumed:/i.test(line) && !/\[anything decided/i.test(line));
21
+ }
22
+
23
+ export function coveredLine(text) {
24
+ return (
25
+ String(text)
26
+ .split("\n")
27
+ .map((line) => line.trim())
28
+ .find((line) => /^Covered:/i.test(line) && !line.includes("[the states")) ?? ""
29
+ );
30
+ }
31
+
32
+ const NONE = /^(?:none\.?|n\/a\.?|-|\[.*\])$/i;
33
+
34
+ export function approvalView(text) {
35
+ const open = section(text, "Open questions");
36
+ const checks = section(text, "Acceptance checks");
37
+ const assumed = assumedLines(text);
38
+ const covered = coveredLine(text);
39
+ const out = [];
40
+
41
+ if (open && !NONE.test(open)) out.push("Decide these first", indent(open), "");
42
+ if (assumed.length) out.push("The AI assumed", ...assumed.map((line) => ` ${line}`), "");
43
+ if (covered) out.push(` ${covered}`, "");
44
+ if (checks) out.push("How it will be proven", indent(checks), "");
45
+
46
+ if (!out.length) {
47
+ const lines = String(text).split("\n");
48
+ out.push(...lines.slice(0, 32));
49
+ if (lines.length > 32) out.push(`... ${lines.length - 32} more lines`);
50
+ }
51
+ return out.join("\n").trimEnd();
52
+ }
53
+
54
+ function indent(block) {
55
+ return block
56
+ .split("\n")
57
+ .map((line) => ` ${line}`)
58
+ .join("\n");
59
+ }
60
+
61
+ // Jobs where the code cannot know who else is affected or what must never break.
62
+ const GUARDED =
63
+ /\b(?:log ?in|sign[ -]?in|sign[ -]?up|signup|password|auth\w*|payments?|pay|card|money|price|billing|checkout|personal|privacy|location|phone|e-?mail|other users?|block\w*|delete account|permissions?)\b/i;
64
+
65
+ export function guardedTopics(line) {
66
+ return GUARDED.test(String(line));
67
+ }
68
+
69
+ export const GUARDED_QUESTIONS = [
70
+ { key: "people", q: "Who else sees or depends on this?", hint: "a second account, an admin, a client" },
71
+ { key: "never", q: "What must never break while we change this?", hint: "the thing you would be called about" },
72
+ ];