@next-generation-collective/factory 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent.js CHANGED
@@ -102,11 +102,14 @@ export function runAgent(root, prompt, config, promptFile) {
102
102
 
103
103
  const { file, shell } = launch(command);
104
104
  const options = { cwd: root, shell, maxBuffer: 64 * 1024 * 1024 };
105
+ // Inside an AI tool, our stdout is the conversation with that tool. The agent's own
106
+ // output goes to stderr there, so it can never corrupt it.
107
+ const out = process.env.FACTORY_MCP ? 2 : "inherit";
105
108
  if (call.stdin != null) {
106
109
  options.input = call.stdin;
107
- options.stdio = ["pipe", "inherit", "inherit"];
110
+ options.stdio = ["pipe", out, "inherit"];
108
111
  } else {
109
- options.stdio = "inherit";
112
+ options.stdio = ["inherit", out, "inherit"];
110
113
  }
111
114
  const args = shell ? call.args.map(quoteForWindows) : call.args;
112
115
  const run = spawnSync(file, args, options);
package/lib/banner.js ADDED
@@ -0,0 +1,89 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { colors } from "./util.js";
6
+
7
+ // The Next-Gen mark in the terminal: an N whose diagonal runs into a G, in the brand gold.
8
+ const MARK = [
9
+ "█▄ █ ▄▀▀▀▀",
10
+ "█ ▀▄█ █ ▄▄",
11
+ "█ █ ▀▄▄▄▀",
12
+ ];
13
+
14
+ const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
15
+ const gold = (t) => colors.paint(truecolor ? "38;2;245;166;35" : "38;5;214", t);
16
+ const cream = (t) => colors.paint(truecolor ? "38;2;253;251;247" : "97", t);
17
+
18
+ const width = (text) => [...text.replace(/\u001b\[[0-9;]*m/g, "")].length;
19
+
20
+ // The builder's first name, from their git identity or, on a Mac, their account's full
21
+ // name. Nothing is sent anywhere.
22
+ export function fullName() {
23
+ const ask = (cmd, args) => {
24
+ try {
25
+ return execFileSync(cmd, args, { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
26
+ } catch {
27
+ return "";
28
+ }
29
+ };
30
+ return ask("git", ["config", "user.name"]) || (process.platform === "darwin" ? ask("id", ["-F"]) : "") || null;
31
+ }
32
+
33
+ export function firstName() {
34
+ return fullName()?.split(/\s+/)[0] || null;
35
+ }
36
+
37
+ // One small file in the builder's own settings folder, so the first welcome happens once.
38
+ function welcomeFile() {
39
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
40
+ return join(base, "next-gen", "welcomed");
41
+ }
42
+
43
+ export function isFirstVisit() {
44
+ try {
45
+ return !existsSync(welcomeFile());
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ export function rememberVisit() {
52
+ try {
53
+ const file = welcomeFile();
54
+ mkdirSync(join(file, ".."), { recursive: true });
55
+ writeFileSync(file, `${new Date().toISOString().slice(0, 10)}\n`);
56
+ } catch {
57
+ // A read-only home folder only means the long welcome shows again.
58
+ }
59
+ }
60
+
61
+ export function welcomeLines({ name, first, version }) {
62
+ const you = name ? `, ${name}` : "";
63
+ return first
64
+ ? [
65
+ colors.bold(cream(`Welcome to Next-Gen Factory${you}.`)),
66
+ cream("You are one of the builders of the AI economy."),
67
+ `${gold("Africa builds.")} ${colors.dim(`v${version}`)}`,
68
+ ]
69
+ : [
70
+ colors.bold(cream(`Welcome back${you}.`)),
71
+ cream("Every build you prove makes every builder stronger."),
72
+ `${gold("Africa builds.")} ${colors.dim(`v${version}`)}`,
73
+ ];
74
+ }
75
+
76
+ export function banner(version, root, { name = firstName(), first = isFirstVisit() } = {}) {
77
+ const lines = welcomeLines({ name, first, version });
78
+ const rows = lines.map((line, i) => ` ${gold(MARK[i] ?? " ".repeat(width(MARK[0])))} ${line}`);
79
+ const inner = Math.max(...rows.map(width)) + 2;
80
+ const edge = (l, r) => colors.dim(`${l}${"─".repeat(inner)}${r}`);
81
+ if (first) rememberVisit();
82
+ return [
83
+ edge("╭", "╮"),
84
+ ...rows.map((row) => `${colors.dim("│")}${row}${" ".repeat(inner - width(row))}${colors.dim("│")}`),
85
+ edge("╰", "╯"),
86
+ colors.dim(` Reading ${basename(root) || root}...`),
87
+ "",
88
+ ].join("\n");
89
+ }
package/lib/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { homedir } from "node:os";
2
+ import { banner } from "./banner.js";
3
+ import { serve } from "./mcp.js";
2
4
  import { parse as parse_, resolve } from "node:path";
3
5
  import { createRequire } from "node:module";
4
6
  import {
@@ -21,7 +23,7 @@ const VERSION = createRequire(import.meta.url)("../package.json").version;
21
23
 
22
24
  const COMMANDS = new Set([
23
25
  "init", "spec", "approve", "plan", "tasks", "verify", "build", "review", "report",
24
- "lesson", "status", "doctor", "help", "version",
26
+ "lesson", "status", "doctor", "help", "version", "mcp",
25
27
  ]);
26
28
 
27
29
  const VALUE_FLAGS = new Set([
@@ -81,6 +83,7 @@ Steps, if you want them one at a time
81
83
  factory doctor what was detected, which agent, which checks apply
82
84
  factory lesson new turn a real failure into a dated lesson
83
85
  factory init answer five questions to fill factory.config.json
86
+ factory mcp run inside Claude Code, Cursor, Codex, Gemini or VS Code
84
87
 
85
88
  Options
86
89
  --ask ask all seven questions before writing the spec
@@ -132,7 +135,7 @@ export async function main(argv) {
132
135
  console.error(refusal);
133
136
  return 1;
134
137
  }
135
- if (process.stderr.isTTY) process.stderr.write("Reading your project...\n\n");
138
+ if (process.stdout.isTTY) console.log(banner(VERSION, root));
136
139
  }
137
140
 
138
141
  if (!command) return start(root, flags);
@@ -146,6 +149,8 @@ export async function main(argv) {
146
149
  }
147
150
 
148
151
  switch (command) {
152
+ case "mcp":
153
+ return serve(VERSION, root);
149
154
  case "init":
150
155
  return init(root, flags);
151
156
  case "spec":
package/lib/commands.js CHANGED
@@ -703,7 +703,7 @@ function reviewerConfig(config) {
703
703
  return config.agent.review ? { ...config, agent: normaliseAgent(config.agent.review) } : config;
704
704
  }
705
705
 
706
- function runReview(root, config, { artifacts, base, run }) {
706
+ export function runReview(root, config, { artifacts, base, run }) {
707
707
  const reviewer = reviewerConfig(config);
708
708
  const stop = (problem) => {
709
709
  writeState(root, { lastReview: { at: new Date().toISOString(), passed: false, summary: problem, file: null, unproven: [] } });
package/lib/mcp.js ADDED
@@ -0,0 +1,316 @@
1
+ // The factory inside an AI tool. Claude Code, Cursor, Codex, Gemini CLI and VS Code all
2
+ // speak the Model Context Protocol: JSON-RPC messages, one per line, over stdin and stdout.
3
+ // The person talks to their AI as usual; the AI uses these tools, and the factory keeps the
4
+ // same rules as on the command line: a person approves, the checks decide, proof is kept.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import { analyseProject } from "./analyse.js";
10
+ import { approvalView } from "./approval.js";
11
+ import { firstName, fullName, isFirstVisit, rememberVisit } from "./banner.js";
12
+ import { ensureConfig, report, runReview } from "./commands.js";
13
+ import { paths, readConfig, readState, writeState } from "./config.js";
14
+ import { composeBuildPrompt, composeSpecPrompt } from "./prompts.js";
15
+ import { isGitRepo, reviewBase } from "./review.js";
16
+ import { activeSpec, approve, findSpec, nextSpecName, setActive } from "./spec.js";
17
+ import { formatResults, verify } from "./verify.js";
18
+
19
+ const PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
20
+
21
+ const INSTRUCTIONS = `Next-Gen Factory checks the work you build with AI and proves it works.
22
+
23
+ When the person asks you to build, add, fix or change something in this project:
24
+ 1. Call factory_start with their request, then write the spec exactly where it says.
25
+ 2. Call factory_approve. The person approves the spec, not you. If the tool asks you to,
26
+ show the person the summary and wait for their own words before calling it again.
27
+ 3. Build what the approved spec says.
28
+ 4. Call factory_prove. If it says NOT DONE, fix what failed and call it again.
29
+ 5. Tell the person the result in plain words, with the proof file.
30
+
31
+ Never say the work is done, bug-free, secure or compliant unless factory_prove says DONE,
32
+ and even then only report what was proven. Call factory_check when the person asks what
33
+ the project can prove, or before you start on a project for the first time.`;
34
+
35
+ const TOOLS = [
36
+ {
37
+ name: "factory_check",
38
+ description:
39
+ "What this project can prove right now: credentials in code, tests that check nothing, missing tests, personal data in logs. Fast, and it changes nothing. Use it first on a new project, or when the person asks how healthy the project is.",
40
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
41
+ },
42
+ {
43
+ name: "factory_start",
44
+ description:
45
+ "Start any build, feature, fix or change the person asks for. Returns the instructions for writing the spec and where to save it. Always call this before changing code.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: { request: { type: "string", description: "What the person asked for, in their words." } },
49
+ required: ["request"],
50
+ additionalProperties: false,
51
+ },
52
+ },
53
+ {
54
+ name: "factory_approve",
55
+ description:
56
+ "Get the person's approval of the spec before building. The person approves, never the AI. When the AI tool cannot show the person an approval box, this returns the summary to show them; call it again with person_said set to their exact words only after they approve.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ spec: { type: "string", description: "Spec name. Leave out for the active spec." },
61
+ person_said: { type: "string", description: "The person's own words approving the spec, quoted exactly." },
62
+ },
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ {
67
+ name: "factory_prove",
68
+ description:
69
+ "After building, prove the work: runs the project's tests and build and the factory's checks. Says DONE or NOT DONE with what failed. Fix and call again until DONE, then call factory_review.",
70
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
71
+ },
72
+ {
73
+ name: "factory_review",
74
+ description:
75
+ "The independent review: a separate AI session that never saw how you worked checks the change against the approved spec. Run it after factory_prove says DONE. It can take a few minutes.",
76
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
77
+ },
78
+ ];
79
+
80
+ // Every console line from the commands is collected for the reply, never written to stdout.
81
+ function captured(fn) {
82
+ const lines = [];
83
+ const keep = (...args) => lines.push(args.map(String).join(" "));
84
+ const saved = { log: console.log, error: console.error, warn: console.warn };
85
+ console.log = keep;
86
+ console.error = keep;
87
+ console.warn = keep;
88
+ try {
89
+ const value = fn();
90
+ return { value, text: lines.join("\n") };
91
+ } finally {
92
+ Object.assign(console, saved);
93
+ }
94
+ }
95
+
96
+ function welcome() {
97
+ const name = firstName();
98
+ const you = name ? `, ${name}` : "";
99
+ if (!isFirstVisit()) return null;
100
+ rememberVisit();
101
+ return `Welcome to Next-Gen Factory${you}. You are one of the builders of the AI economy. Africa builds.`;
102
+ }
103
+
104
+ const text = (body, isError = false) => ({ content: [{ type: "text", text: body }], ...(isError ? { isError: true } : {}) });
105
+
106
+ export function createServer({ root = process.cwd(), send }) {
107
+ let clientCanAsk = false;
108
+ let nextId = 1;
109
+ const waiting = new Map();
110
+
111
+ // A question to the person through the AI tool's own approval box, which the AI cannot answer.
112
+ const askPerson = (message, requestedSchema) =>
113
+ new Promise((resolveAnswer) => {
114
+ const id = `factory-${nextId++}`;
115
+ waiting.set(id, resolveAnswer);
116
+ send({ jsonrpc: "2.0", id, method: "elicitation/create", params: { message, requestedSchema } });
117
+ });
118
+
119
+ const tools = {
120
+ factory_check() {
121
+ const analysis = analyseProject(root);
122
+ if (analysis.empty) return text("No project found here. Open a project folder first.", true);
123
+ const config = readConfig(root);
124
+ const { value: run } = captured(() => verify(root, { deep: false, config, save: false }));
125
+ const greeting = welcome();
126
+ return text(
127
+ [
128
+ greeting ? `${greeting}\n(Show this welcome to the person.)\n` : "",
129
+ `${config.name} (${analysis.label})`,
130
+ ...analysis.summary.slice(1),
131
+ "",
132
+ "What this project can prove right now",
133
+ formatResults(root, run, { header: false }),
134
+ "",
135
+ "Checked by Next-Gen Factory.",
136
+ ].join("\n"),
137
+ );
138
+ },
139
+
140
+ factory_start({ request }) {
141
+ const job = String(request ?? "").trim();
142
+ if (!job) return text("Say what the person asked for in request.", true);
143
+ const config = captured(() => ensureConfig(root)).value;
144
+ const name = nextSpecName(root, job);
145
+ const target = `specs/${name}/spec.md`;
146
+ mkdirSync(join(root, "specs", name), { recursive: true });
147
+ setActive(root, name);
148
+ const prompt = composeSpecPrompt(root, config, job, target, { analysis: analyseProject(root), answers: {} });
149
+ const greeting = welcome();
150
+ return text(
151
+ [
152
+ greeting ? `${greeting}\n(Show this welcome to the person.)\n` : "",
153
+ `Next-Gen Factory job: ${name}`,
154
+ `Write the spec to ${target}, following the instructions below. Then call factory_approve.`,
155
+ "",
156
+ prompt,
157
+ ].join("\n"),
158
+ );
159
+ },
160
+
161
+ async factory_approve({ spec, person_said: personSaid }) {
162
+ const config = readConfig(root);
163
+ const name = findSpec(root, String(spec ?? config.spec?.active ?? "").trim());
164
+ if (!name) return text("No spec yet. Call factory_start first.", true);
165
+ const current = activeSpec(root);
166
+ const specText = current?.name === name ? current.artifacts?.spec : null;
167
+ const view = approvalView(specText ?? "");
168
+ if (!String(specText ?? "").trim()) return text(`The spec for ${name} is empty. Write it first.`, true);
169
+
170
+ let how = null;
171
+ const said = String(personSaid ?? "").trim();
172
+ const answer = clientCanAsk && !said
173
+ ? await askPerson(
174
+ `Next-Gen Factory: approve this plan before anything is built?\n\n${view}`,
175
+ { type: "object", properties: { approve: { type: "boolean", title: "Approve and build" } }, required: ["approve"] },
176
+ )
177
+ : null;
178
+ if (answer?.action === "accept" && answer.content?.approve === true) {
179
+ how = { via: "approval box in the AI tool" };
180
+ } else if (answer?.action === "decline" || (answer?.action === "accept" && answer.content?.approve === false)) {
181
+ return text("The person did not approve. Ask what to change, update the spec, then call factory_approve again.");
182
+ } else if (said) {
183
+ how = { via: "chat", personSaid: String(personSaid).trim().slice(0, 500) };
184
+ } else {
185
+ return text(
186
+ [
187
+ "Show the person this summary of the spec and ask them to approve it.",
188
+ "Only after they say yes, call factory_approve again with person_said set to their exact words.",
189
+ "",
190
+ view,
191
+ ].join("\n"),
192
+ );
193
+ }
194
+
195
+ const approval = approve(root, name, fullName(), how);
196
+ setActive(root, name);
197
+ const head = isGitRepo(root) ? spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout?.trim() : null;
198
+ writeState(root, { buildBase: head || null, buildStartedAt: new Date().toISOString() });
199
+ const artifacts = activeSpec(root)?.artifacts;
200
+ return text(
201
+ [
202
+ `Approved by ${approval.approvedBy} (${how.via}). Editing the spec now cancels the approval.`,
203
+ "Build it now, following these rules. Then call factory_prove.",
204
+ "",
205
+ composeBuildPrompt(root, config, "", [], artifacts),
206
+ ].join("\n"),
207
+ );
208
+ },
209
+
210
+ factory_prove() {
211
+ const current = activeSpec(root);
212
+ if (!current) return text("No job yet. Call factory_start first.", true);
213
+ if (current.state !== "approved") return text(`The spec ${current.name} is ${current.state}. Get the person's approval with factory_approve.`, true);
214
+ const config = readConfig(root);
215
+ const { value: run } = captured(() => verify(root, { deep: true, config, save: true }));
216
+ const results = formatResults(root, run, { header: false });
217
+ if (!run.ok) {
218
+ return text(["NOT DONE. Fix what failed, then call factory_prove again.", "", results].join("\n"));
219
+ }
220
+ return text(["DONE: the checks passed.", "", results, "", "Now call factory_review for the independent review."].join("\n"));
221
+ },
222
+
223
+ factory_review() {
224
+ if (!isGitRepo(root)) return text("The independent review needs git, to know what changed.", true);
225
+ const current = activeSpec(root);
226
+ const config = readConfig(root);
227
+ const base = reviewBase(root, { state: readState(root) });
228
+ const p = paths(root);
229
+ const lastRun = existsSync(p.lastVerify) ? JSON.parse(readFileSync(p.lastVerify, "utf8")) : null;
230
+ const { value: result } = captured(() =>
231
+ runReview(root, config, { artifacts: current?.state === "approved" ? current.artifacts : null, base, run: lastRun }),
232
+ );
233
+ const { text: reportText } = captured(() => report(root, {}));
234
+ if (result.problem?.startsWith("No reviewer agent")) {
235
+ return text(
236
+ `The independent review did not run: no second AI is installed on this computer (it uses ${config.agent?.command ?? "claude"}). Tell the person the change passed the checks but has no independent review. ${reportText}`,
237
+ );
238
+ }
239
+ const verdict = result.passed ? "Proven by Next-Gen: the checks passed and a separate session approved the change." : `NOT DONE: ${result.summary}`;
240
+ return text([verdict, result.file ? `Review: ${result.file}` : "", reportText].filter(Boolean).join("\n"));
241
+ },
242
+ };
243
+
244
+ async function handle(message) {
245
+ const { id, method, params } = message;
246
+
247
+ if (method === undefined && id !== undefined && waiting.has(id)) {
248
+ const resolveAnswer = waiting.get(id);
249
+ waiting.delete(id);
250
+ resolveAnswer(message.result ?? { action: "unavailable" });
251
+ return;
252
+ }
253
+
254
+ const reply = (result) => id !== undefined && send({ jsonrpc: "2.0", id, result });
255
+ const fail = (code, msg) => id !== undefined && send({ jsonrpc: "2.0", id, error: { code, message: msg } });
256
+
257
+ switch (method) {
258
+ case "initialize": {
259
+ clientCanAsk = Boolean(params?.capabilities?.elicitation);
260
+ const asked = params?.protocolVersion;
261
+ reply({
262
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : "2025-06-18",
263
+ capabilities: { tools: {} },
264
+ serverInfo: { name: "next-gen-factory", title: "Next-Gen Factory", version: VERSION },
265
+ instructions: INSTRUCTIONS,
266
+ });
267
+ return;
268
+ }
269
+ case "notifications/initialized":
270
+ case "notifications/cancelled":
271
+ return;
272
+ case "ping":
273
+ reply({});
274
+ return;
275
+ case "tools/list":
276
+ reply({ tools: TOOLS });
277
+ return;
278
+ case "tools/call": {
279
+ const tool = tools[params?.name];
280
+ if (!tool) return fail(-32602, `Unknown tool: ${params?.name}`);
281
+ try {
282
+ reply(await tool(params?.arguments ?? {}));
283
+ } catch (error) {
284
+ reply(text(`The factory hit an error: ${error?.message ?? error}`, true));
285
+ }
286
+ return;
287
+ }
288
+ default:
289
+ if (id !== undefined) fail(-32601, `Method not found: ${method}`);
290
+ }
291
+ }
292
+
293
+ return { handle };
294
+ }
295
+
296
+ let VERSION = "0.0.0";
297
+
298
+ export async function serve(version, root = process.cwd()) {
299
+ VERSION = version;
300
+ process.env.FACTORY_MCP = "1";
301
+ const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
302
+ const server = createServer({ root: resolve(root), send });
303
+ const lines = createInterface({ input: process.stdin });
304
+ for await (const line of lines) {
305
+ if (!line.trim()) continue;
306
+ let message;
307
+ try {
308
+ message = JSON.parse(line);
309
+ } catch {
310
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
311
+ continue;
312
+ }
313
+ server.handle(message);
314
+ }
315
+ return 0;
316
+ }
package/lib/spec.js CHANGED
@@ -113,7 +113,7 @@ export function setActive(root, name) {
113
113
  return config;
114
114
  }
115
115
 
116
- export function approve(root, name, by) {
116
+ export function approve(root, name, by, how = null) {
117
117
  const artifacts = readArtifacts(root, name);
118
118
  if (!artifacts.spec.trim()) return null;
119
119
  const approval = {
@@ -121,6 +121,7 @@ export function approve(root, name, by) {
121
121
  hash: hashText(artifacts.spec),
122
122
  approvedAt: new Date().toISOString(),
123
123
  approvedBy: String(by || "").trim() || gitUser(root) || userInfo().username || "unknown",
124
+ ...(how ? { how } : {}),
124
125
  };
125
126
  mkdirSync(specPaths(root, name).dir, { recursive: true });
126
127
  writeFileSync(specPaths(root, name).approval, `${JSON.stringify(approval, null, 2)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next-generation-collective/factory",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Checks the work you build with AI, proves it works, and learns from every builder. Built in the open, in Africa.",
5
5
  "type": "module",
6
6
  "bin": {