explainmyrepo 0.1.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.
@@ -0,0 +1,419 @@
1
+ // src/orchestrator.mjs — run the explainer pipeline in CONTRACT order, with Claude in the loop.
2
+ //
3
+ // The deterministic stations are the EXISTING tools/*.mjs, invoked as child processes (never
4
+ // re-implemented — src/run-tool.mjs). Between them, the brain (src/brain.mjs → Claude) authors the
5
+ // judgment slots the e2e run proved cannot be deterministic: the primer, concept, content, and the
6
+ // Station-4 image briefs + diagram ASCII. The whole run is one BuildContext / build.json (CONTRACT §a).
7
+ //
8
+ // Station map (CONTRACT §d roster + §a "brain — no tool" rows):
9
+ // 0 clone-repo .............. tool (Station 0–1)
10
+ // 1 kb:register ............. brain GATED — only if the repo isn't in kb/kb.config.mjs
11
+ // 2 build-kb ................ tool (Station 1)
12
+ // 3 primer .................. brain (Station 1 brain deliverable; make-pack needs it)
13
+ // 4 concept ................. brain (Station 2)
14
+ // 5 content ................. brain (Station 3)
15
+ // 6 visual-brief ........... brain (Station 4 brain half: image prompts + diagram ASCII)
16
+ // 7 generate-image ......... tool (Station 4 raster)
17
+ // 8 make-favicon ........... tool (Station 5 — reads the hero)
18
+ // 9 make-social-card ....... tool (Station 5)
19
+ // 10 make-diagrams .......... tool (Station 4 structural SVGs)
20
+ // 11 assemble-page .......... tool (Station 6 — the single render)
21
+ // 12 make-pack .............. tool (Station 6)
22
+ // 13 quality-grade .......... tool (Station 7 — the completion gate)
23
+ // 14 deploy ................. tool (Station 8 — skip with --no-deploy)
24
+ // 15 publish-repo ........... tool (Station 8 — skip with --no-publish)
25
+ // 16 repo-seo .............. tool (Station 8 — skip with --no-publish)
26
+ // 17 readme-enhance ......... tool (Station 8b — optional, env-gated, NON-BLOCKING)
27
+ // 18 notify ................. tool (Station 9 — NON-BLOCKING)
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import readline from 'node:readline';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
+ import { loadEnv, getSecret, redact } from './env.mjs';
34
+ import { initBuildDir, readContext, mergeSlot } from './build-context.mjs';
35
+ import { runTool } from './run-tool.mjs';
36
+ import { resolveModel } from './claude.mjs';
37
+ import {
38
+ authorConcept, authorContent, authorVisualBrief, visualsSlotFromBrief,
39
+ authorPrimer, authorKbTarget,
40
+ } from './brain.mjs';
41
+
42
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ const C = { dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', bold: '\x1b[1m', reset: '\x1b[0m' };
44
+ const log = (s = '') => process.stderr.write(s + '\n');
45
+ const step = (n, total, label, kind) => log(`\n${C.bold}${C.cyan}[${n}/${total}] ${label}${C.reset} ${C.dim}(${kind})${C.reset}`);
46
+
47
+ // Reuse clone-repo's URL grammar (https | git@host:owner/name | bare host/owner/name) just enough to
48
+ // derive a default out-dir name before clone-repo runs.
49
+ export function parseRepoUrl(raw) {
50
+ let s = String(raw || '').trim();
51
+ if (!s) return null;
52
+ const scp = s.match(/^git@([^:]+):(.+)$/);
53
+ if (scp) s = `https://${scp[1]}/${scp[2]}`;
54
+ if (!/^[a-z]+:\/\//i.test(s)) {
55
+ // bare "owner/name" (no host) → assume github.com; "host/owner/name" keeps its host
56
+ s = /^[^/]+\.[^/]+\//.test(s) ? `https://${s}` : `https://github.com/${s}`;
57
+ }
58
+ s = s.replace(/\/+$/, '');
59
+ let u; try { u = new URL(s); } catch { return null; }
60
+ const parts = u.pathname.replace(/^\/+/, '').split('/').filter(Boolean);
61
+ if (parts.length < 2) return null;
62
+ const owner = parts[0];
63
+ const name = parts[1].replace(/\.git$/i, '');
64
+ return { owner, name, url: `https://${u.host}/${owner}/${name}` };
65
+ }
66
+
67
+ const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
68
+
69
+ // ── the gated KB-registration station ──────────────────────────────────────────────────────────
70
+ async function kbRegisterStation({ buildDir, env, model, apiKey, opts }) {
71
+ const ctx = readContext(buildDir);
72
+ ctx._repoRoot = REPO_ROOT;
73
+ const slug = ctx.repo?.slug;
74
+ if (!slug) throw new Error('kb:register — no repo.slug yet (clone-repo must run first)');
75
+
76
+ const cfgPath = path.join(REPO_ROOT, 'kb', 'kb.config.mjs');
77
+ const importCfg = async () => import(pathToFileURL(cfgPath).href + `?t=${Date.now()}`);
78
+ let registered = false;
79
+ try { const m = await importCfg(); m.getTarget(slug); registered = true; } catch { registered = false; }
80
+ if (registered) { log(`${C.dim}kb target "${slug}" already registered in kb/kb.config.mjs — using it.${C.reset}`); return { ok: true, skipped: 'already-registered' }; }
81
+
82
+ log(`${C.yellow}kb target "${slug}" is NOT registered in kb/kb.config.mjs.${C.reset}`);
83
+ const entry = await authorKbTarget(ctx, { apiKey, model });
84
+ const genPath = path.join(buildDir, `kb-target.${slug}.generated.mjs`);
85
+ fs.writeFileSync(genPath, `// GENERATED kb.config target entry for "${slug}" — paste into kb/kb.config.mjs targets{}.\nexport default ${JSON.stringify({ [slug]: entry }, null, 2)};\n`);
86
+ log(`${C.dim}wrote generated entry → ${path.relative(process.cwd(), genPath)}${C.reset}`);
87
+
88
+ if (!opts.registerKb) {
89
+ throw new Error(
90
+ `kb:register — "${slug}" must be a registered kb.config target before build-kb can index it.\n` +
91
+ ` • A generated entry was written to ${genPath}.\n` +
92
+ ` • Re-run with --register-kb to inject it into kb/kb.config.mjs automatically (the one step that edits the shared registry), or paste it in by hand.`,
93
+ );
94
+ }
95
+ // --register-kb (opt-in, CLEARLY the single step that mutates kb/kb.config.mjs) ----------------
96
+ let src = fs.readFileSync(cfgPath, 'utf8');
97
+ const anchor = 'export const targets = {';
98
+ const at = src.indexOf(anchor);
99
+ if (at === -1) throw new Error('kb:register — could not find "export const targets = {" in kb/kb.config.mjs to inject into');
100
+ const inject = `\n ${JSON.stringify(slug)}: ${JSON.stringify(entry, null, 2).replace(/\n/g, '\n ')},`;
101
+ src = src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length);
102
+ fs.writeFileSync(cfgPath, src);
103
+ log(`${C.yellow}injected "${slug}" into kb/kb.config.mjs (--register-kb).${C.reset}`);
104
+ const m2 = await importCfg();
105
+ try { m2.getTarget(slug); } catch (e) { throw new Error(`kb:register — injection did not take: ${e.message}`); }
106
+ return { ok: true, registered: true };
107
+ }
108
+
109
+ // ── brain station runners (each reads fresh ctx, authors, merges its slot) ──────────────────────
110
+ function brainRun(fn) {
111
+ return async ({ buildDir, apiKey, model }) => {
112
+ const ctx = readContext(buildDir);
113
+ ctx._repoRoot = REPO_ROOT;
114
+ await fn(ctx, { buildDir, apiKey, model });
115
+ return { ok: true };
116
+ };
117
+ }
118
+
119
+ // ── the station table ───────────────────────────────────────────────────────────────────────────
120
+ function stations(opts) {
121
+ const tool = (id, name, { fatal = true } = {}) => ({ id, name, kind: 'tool', fatal, run: ({ buildDir, env }) => runTool(name, buildDir, { repoRoot: REPO_ROOT, env }) });
122
+ const brain = (id, fn) => ({ id, kind: 'brain', fatal: true, run: brainRun(fn) });
123
+
124
+ const all = [
125
+ tool('clone-repo', 'clone-repo'),
126
+ { id: 'kb:register', kind: 'brain', fatal: true, run: kbRegisterStation },
127
+ tool('build-kb', 'build-kb'),
128
+ brain('primer', async (ctx, { apiKey, model }) => { await authorPrimer(ctx, { apiKey, model, repoRoot: REPO_ROOT }); }),
129
+ brain('concept', async (ctx, { buildDir, apiKey, model }) => { mergeSlot(buildDir, 'concept', await authorConcept(ctx, { apiKey, model })); }),
130
+ brain('content', async (ctx, { buildDir, apiKey, model }) => { mergeSlot(buildDir, 'content', await authorContent(ctx, { apiKey, model })); }),
131
+ brain('visual-brief', async (ctx, { buildDir, apiKey, model }) => { mergeSlot(buildDir, 'visuals', visualsSlotFromBrief(await authorVisualBrief(ctx, { apiKey, model }))); }),
132
+ tool('generate-image', 'generate-image'),
133
+ tool('make-favicon', 'make-favicon'),
134
+ tool('make-social-card', 'make-social-card'),
135
+ tool('make-diagrams', 'make-diagrams'),
136
+ tool('assemble-page', 'assemble-page'),
137
+ tool('make-pack', 'make-pack'),
138
+ tool('quality-grade', 'quality-grade'),
139
+ tool('deploy', 'deploy'),
140
+ tool('publish-repo', 'publish-repo'),
141
+ tool('repo-seo', 'repo-seo'),
142
+ tool('readme-enhance', 'readme-enhance', { fatal: false }), // Station 8b — non-blocking
143
+ tool('notify', 'notify', { fatal: false }), // Station 9 — non-blocking
144
+ ];
145
+
146
+ // apply skip flags
147
+ return all.filter((s) => {
148
+ if (opts.noQuality && s.id === 'quality-grade') return false;
149
+ if (opts.noDeploy && s.id === 'deploy') return false;
150
+ if (opts.noPublish && (s.id === 'publish-repo' || s.id === 'repo-seo')) return false;
151
+ if (opts.noNotify && s.id === 'notify') return false;
152
+ return true;
153
+ });
154
+ }
155
+
156
+ // ── preflight: fail fast with ACTIONABLE guidance when a station in the slice lacks its credential ──
157
+ // "If something's not there, tell them I need this key, and how to set it — don't work around it and
158
+ // ship a degraded result." Required keys (brain, OpenAI) are FATAL; ship-step keys are heads-up + a skip flag.
159
+ async function preflight(list, env, repoRoot) {
160
+ const has = (names) => names.some((n) => env[n] && String(env[n]).trim());
161
+ const ids = new Set(list.map((s) => s.id));
162
+ const brainIds = ['kb:register', 'primer', 'concept', 'content', 'visual-brief'];
163
+ const problems = [];
164
+ if (brainIds.some((id) => ids.has(id)) && !has(['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'])) {
165
+ problems.push({ fatal: true, need: 'ANTHROPIC_API_KEY (or CLAUDE_API_KEY)', why: 'the brain stations (concept/content/visual-brief/primer) author the page',
166
+ how: 'add ANTHROPIC_API_KEY=sk-ant-… to .env — create one at https://console.anthropic.com/settings/keys',
167
+ envKey: 'ANTHROPIC_API_KEY', aliases: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'] });
168
+ }
169
+ if ((ids.has('generate-image') || ids.has('quality-grade')) && !has(['OPENAI_API_KEY', 'OPEN_AI_KEY'])) {
170
+ problems.push({ fatal: true, need: 'OPENAI_API_KEY (or OPEN_AI_KEY)', why: 'the atmospheric images (gpt-image-2) and the visual quality grade (gpt-5.5) need it',
171
+ how: 'add OPENAI_API_KEY=sk-… to .env — create one at https://platform.openai.com/api-keys',
172
+ envKey: 'OPENAI_API_KEY', aliases: ['OPENAI_API_KEY', 'OPEN_AI_KEY'] });
173
+ }
174
+ if (ids.has('deploy')) {
175
+ // Deploy goes to NETLIFY ONLY now — each explainer to its own {slug}-explainer.netlify.app site.
176
+ // (Vercel was removed after it overwrote a live site; the legacy Vercel explainers are untouched and
177
+ // not managed by this pipeline.) A missing Netlify token is FATAL: we refuse to start a deploy run
178
+ // rather than guess a target or fall back to another account.
179
+ if (!has(['NETLIFY_AUTH_TOKEN'])) {
180
+ problems.push({ fatal: true, need: 'NETLIFY_AUTH_TOKEN', why: 'the deploy station publishes each explainer to its own {slug}-explainer.netlify.app site',
181
+ how: 'create a token at https://app.netlify.com/user/applications#personal-access-tokens and add NETLIFY_AUTH_TOKEN=… to .env, or re-run with --no-deploy to build locally only',
182
+ envKey: 'NETLIFY_AUTH_TOKEN', aliases: ['NETLIFY_AUTH_TOKEN'] });
183
+ }
184
+ }
185
+ if ((ids.has('publish-repo') || ids.has('repo-seo')) && !has(['GITHUB_TOKEN', 'GH_TOKEN'])) {
186
+ problems.push({ fatal: false, need: 'GITHUB_TOKEN / GH_TOKEN (or `gh auth login`)', why: 'publish-repo creates the editable explainer repo on GitHub',
187
+ how: 'run `gh auth login`, or add GITHUB_TOKEN=ghp_… to .env, or re-run with --no-publish' });
188
+ }
189
+ if (!problems.length) return;
190
+ log(`\n${C.bold}Preflight — credentials${C.reset}`);
191
+ for (const p of problems) {
192
+ const tag = p.fatal ? `${C.red}MISSING${C.reset}` : `${C.yellow}heads-up${C.reset}`;
193
+ log(` ${tag} ${C.bold}${p.need}${C.reset} ${C.dim}— ${p.why}${C.reset}\n → ${p.how}`);
194
+ }
195
+ const fatal = problems.filter((p) => p.fatal);
196
+ if (fatal.length) {
197
+ // DROP-IN UX: on an interactive terminal, offer to paste the missing key(s) right now and save
198
+ // them to .env — so a developer using their own keys doesn't have to stop, edit a file, and re-run.
199
+ // Off a TTY (CI / hosted runner / piped), we keep the strict fail-loud behavior below.
200
+ await promptForMissingKeys(fatal, env, repoRoot);
201
+ const stillMissing = fatal.filter((p) => !(p.aliases || []).some((n) => env[n] && String(env[n]).trim()));
202
+ if (stillMissing.length) {
203
+ throw new Error(`preflight: ${stillMissing.length} required credential(s) missing (see above). Set them in .env and re-run — nothing was built; refusing to produce a degraded result silently.`);
204
+ }
205
+ log(`${C.green}✓ all required credentials present — continuing.${C.reset}`);
206
+ }
207
+ log(`${C.dim}(non-fatal — continuing; the affected ship steps will be skipped or fail loudly if reached)${C.reset}`);
208
+ }
209
+
210
+ // ── interactive key capture (drop-in mode) ──────────────────────────────────────────────────────
211
+ // Prompt for each still-missing FATAL key, but ONLY on a real interactive terminal. A provided value
212
+ // is set for this run AND persisted to .env (mode 0600). Values are NEVER printed or logged.
213
+ function promptSecret(question) {
214
+ return new Promise((resolve) => {
215
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
216
+ rl.question(question, (ans) => { rl.close(); resolve((ans || '').trim()); });
217
+ });
218
+ }
219
+ export function upsertEnvFile(envPath, key, value) {
220
+ let text = '';
221
+ try { text = fs.readFileSync(envPath, 'utf8'); } catch { /* new .env */ }
222
+ const line = `${key}=${value}`;
223
+ const re = new RegExp(`^\\s*(?:export\\s+)?${key}=.*$`, 'm');
224
+ text = re.test(text) ? text.replace(re, line) : text + (text && !text.endsWith('\n') ? '\n' : '') + line + '\n';
225
+ fs.writeFileSync(envPath, text, { mode: 0o600 });
226
+ }
227
+ export async function promptForMissingKeys(fatal, env, repoRoot) {
228
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.EXPLAINER_NONINTERACTIVE;
229
+ if (!interactive) return; // CI / hosted / piped → leave gaps; the caller throws with guidance
230
+ const envPath = path.join(repoRoot, '.env');
231
+ log(`\n${C.bold}Paste the missing key(s) to continue${C.reset} ${C.dim}— saved to .env (chmod 600), never printed, never committed. Press Enter to skip any.${C.reset}`);
232
+ for (const p of fatal) {
233
+ if (!p.envKey) continue;
234
+ if ((p.aliases || []).some((n) => env[n] && String(env[n]).trim())) continue; // already satisfied
235
+ const val = await promptSecret(` ${p.envKey}: `);
236
+ if (!val) continue;
237
+ env[p.envKey] = val; // in-memory for this run
238
+ try { upsertEnvFile(envPath, p.envKey, val); log(` ${C.green}✓ saved ${p.envKey} → ${envPath}${C.reset}`); }
239
+ catch (e) { log(` ${C.yellow}kept ${p.envKey} for this run, but couldn't write .env: ${e.message}${C.reset}`); }
240
+ }
241
+ }
242
+
243
+ // run a sub-list of stations linearly; stop on the first FATAL failure (non-fatal ones warn + continue).
244
+ async function runStations(subList, baseArgs, total, startIdx) {
245
+ const results = [];
246
+ let i = startIdx;
247
+ for (const s of subList) {
248
+ i++;
249
+ step(i, total, s.id, s.kind);
250
+ let r;
251
+ try { r = await s.run(baseArgs); }
252
+ catch (e) { r = { ok: false, error: e.message }; }
253
+ if (r.ok) {
254
+ log(`${C.green}✓ ${s.id}${C.reset}${r.skipped ? ` ${C.dim}(${r.skipped})${C.reset}` : ''}`);
255
+ results.push({ id: s.id, ok: true });
256
+ } else {
257
+ results.push({ id: s.id, ok: false, error: r.error });
258
+ if (s.fatal) { log(`${C.red}✗ ${s.id} FAILED:${C.reset} ${r.error}`); return { ok: false, failedAt: s.id, error: r.error, results }; }
259
+ log(`${C.yellow}⚠ ${s.id} failed (non-blocking): ${r.error}${C.reset}`);
260
+ }
261
+ }
262
+ return { ok: true, results };
263
+ }
264
+
265
+ const sumMean = (q) => (Array.isArray(q?.scorecard) ? q.scorecard.reduce((s, c) => s + (c.meanScore || 0), 0) : 0);
266
+ function readQuality(buildDir) { try { return readContext(buildDir).quality; } catch { return null; } }
267
+
268
+ // The self-correcting loop: while the page is below the bar, hand the harsh critic's CONTENT-actionable
269
+ // findings (A* substance axes + operator questions) back to the brain, re-author the copy, re-assemble,
270
+ // re-grade — keeping the BEST iteration. Craft (B*) and diagram (INV-18) notes are not content-fixable so
271
+ // they're left for the design system / make-diagrams, not looped on here.
272
+ async function refineLoop({ buildDir, env, model, apiKey, opts }) {
273
+ const MAX = opts.maxRefine != null ? Math.max(0, parseInt(opts.maxRefine, 10) || 0) : 2;
274
+ let q = readQuality(buildDir);
275
+ let best = { mean: sumMean(q), content: (() => { try { return readContext(buildDir).content; } catch { return null; } })() };
276
+ let pass = 0;
277
+ while (q && !q.passed && pass < MAX) {
278
+ pass++;
279
+ const fb = (q.refineNotes || []).filter((n) => /^A\d|^operator:|^MEAN$/.test(n.criterion));
280
+ const axes = [...new Set(fb.map((f) => f.criterion))].join(', ') || 'the weak axes';
281
+ log(`\n${C.bold}${C.cyan}Refine pass ${pass}/${MAX}${C.reset} ${C.dim}(re-authoring content to lift ${axes})${C.reset}`);
282
+ const ctx = readContext(buildDir); ctx._repoRoot = REPO_ROOT;
283
+ try { mergeSlot(buildDir, 'content', await authorContent(ctx, { apiKey, model, feedback: fb })); }
284
+ catch (e) { log(`${C.yellow}refine: content re-author failed (${e.message}) — stopping refine${C.reset}`); break; }
285
+ const a = runTool('assemble-page', buildDir, { repoRoot: REPO_ROOT, env });
286
+ if (!a.ok) { log(`${C.yellow}refine: assemble-page failed (${a.error}) — stopping refine${C.reset}`); break; }
287
+ const g = runTool('quality-grade', buildDir, { repoRoot: REPO_ROOT, env });
288
+ if (!g.ok) { log(`${C.yellow}refine: quality-grade failed (${g.error}) — stopping refine${C.reset}`); break; }
289
+ q = readQuality(buildDir);
290
+ const m = sumMean(q);
291
+ log(`${C.dim}refine pass ${pass}: mean(sum of devices)=${m} passed=${q?.passed}${C.reset}`);
292
+ if (m > best.mean) best = { mean: m, content: readContext(buildDir).content };
293
+ }
294
+ // not passing → restore the BEST iteration so the local build is the strongest we reached
295
+ if (q && !q.passed && best.content) {
296
+ const cur = readContext(buildDir);
297
+ if (JSON.stringify(cur.content) !== JSON.stringify(best.content)) {
298
+ log(`${C.dim}restoring the best-scoring iteration (mean ${best.mean})${C.reset}`);
299
+ mergeSlot(buildDir, 'content', best.content);
300
+ runTool('assemble-page', buildDir, { repoRoot: REPO_ROOT, env });
301
+ runTool('quality-grade', buildDir, { repoRoot: REPO_ROOT, env });
302
+ q = readQuality(buildDir);
303
+ }
304
+ }
305
+ return q;
306
+ }
307
+
308
+ function finalSummary(outDir) {
309
+ log(`\n${C.green}${C.bold}Done.${C.reset} build dir: ${outDir}`);
310
+ try {
311
+ const ctx = readContext(outDir);
312
+ if (ctx.publish?.liveUrl) log(`${C.green}live: ${ctx.publish.liveUrl}${C.reset}`);
313
+ if (ctx.publish?.explainerRepoUrl) log(`${C.dim}repo: ${ctx.publish.explainerRepoUrl}${C.reset}`);
314
+ if (ctx.quality?.passed != null) log(`${C.dim}quality passed: ${ctx.quality.passed}${C.reset}`);
315
+ } catch { /* best-effort summary */ }
316
+ }
317
+
318
+ function reportQualityGap(quality) {
319
+ if (!quality || !Array.isArray(quality.scorecard)) { log(`${C.red}quality gate: no scorecard available${C.reset}`); return; }
320
+ log(`\n${C.bold}Quality scorecard — HELD below the SHIP bar (need, on both devices: mean ≥ 82, worst axis ≥ 70, real legible architecture+flow diagrams, and the comprehension operators YES). World-class target is mean ≥ 90 / worst ≥ 85 / all 5 operators.${C.reset}`);
321
+ for (const c of quality.scorecard) {
322
+ log(` ${C.bold}${c.device}${C.reset}: mean ${c.meanScore}, worst axis ${c.headlineScore}, diagrams ${c.inv18?.passed ? 'ok' : 'FAIL'}, passed ${c.passed ? C.green + 'yes' + C.reset : C.red + 'no' + C.reset}`);
323
+ }
324
+ const seen = new Set();
325
+ for (const n of (quality.refineNotes || [])) {
326
+ const k = n.device + n.criterion;
327
+ if (seen.has(k)) continue; seen.add(k);
328
+ log(` ${C.yellow}• [${n.device}] ${n.criterion} (${n.score})${C.reset} ${C.dim}${String(n.saw || '').replace(/\s+/g, ' ').slice(0, 150)}${C.reset}`);
329
+ }
330
+ }
331
+
332
+ // ── the run ───────────────────────────────────────────────────────────────────────────────────
333
+ export async function run(repoUrl, opts = {}) {
334
+ const parsed = parseRepoUrl(repoUrl);
335
+ if (!parsed) throw new Error(`not a parseable GitHub repo URL: "${repoUrl}"`);
336
+ const slug = slugify(parsed.name);
337
+
338
+ const env = loadEnv(REPO_ROOT);
339
+ let apiKey = getSecret(env, ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY']);
340
+ const model = resolveModel(env, opts.model);
341
+
342
+ const outDir = path.resolve(opts.out || path.join(process.cwd(), 'explainer-builds', slug));
343
+ let list = stations(opts);
344
+
345
+ // --from / --to / --only slicing (resume a long build)
346
+ if (opts.only) list = list.filter((s) => s.id === opts.only);
347
+ else {
348
+ if (opts.from) { const i = list.findIndex((s) => s.id === opts.from); if (i === -1) throw new Error(`--from: unknown station "${opts.from}"`); list = list.slice(i); }
349
+ if (opts.to) { const i = list.findIndex((s) => s.id === opts.to); if (i === -1) throw new Error(`--to: unknown station "${opts.to}"`); list = list.slice(0, i + 1); }
350
+ }
351
+
352
+ log(`${C.bold}explainmyrepo${C.reset} — ${C.cyan}${parsed.owner}/${parsed.name}${C.reset}`);
353
+ log(`${C.dim}build dir : ${outDir}${C.reset}`);
354
+ log(`${C.dim}model : ${model} anthropic key: ${redact(apiKey)}${C.reset}`);
355
+ log(`${C.dim}stations : ${list.map((s) => s.id).join(' → ')}${C.reset}`);
356
+
357
+ if (opts.dryRun) { log(`\n${C.yellow}--dry-run: plan only, nothing executed.${C.reset}`); return { ok: true, dryRun: true, outDir, stations: list.map((s) => s.id) }; }
358
+
359
+ // Preflight: name any missing credential for the stations in this slice + how to set it. On a TTY it
360
+ // offers to capture the key(s) now and save them to .env (drop-in mode); off a TTY it refuses to start
361
+ // on a fatal gap rather than build a degraded result. (The brain key is among the fatal checks.)
362
+ await preflight(list, env, REPO_ROOT);
363
+ apiKey = getSecret(env, ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY']); // may have just been entered + saved
364
+
365
+ // Seed the build dir + build.json (idempotent; resumes if it already exists). If the slice starts
366
+ // at clone-repo we seed; if it starts later (a resume) we require an existing build.
367
+ const startsAtCloneRepo = list.length > 0 && list[0].id === 'clone-repo';
368
+ if (startsAtCloneRepo) {
369
+ initBuildDir(outDir, parsed.url || repoUrl);
370
+ } else if (!fs.existsSync(path.join(outDir, 'build.json'))) {
371
+ throw new Error(`--from/--only "${list[0]?.id}" needs an existing build at ${outDir} — start with clone-repo first`);
372
+ } else {
373
+ const c = readContext(outDir);
374
+ c.repo = { ...(c.repo || {}), url: c.repo?.url || parsed.url || repoUrl };
375
+ fs.writeFileSync(path.join(outDir, 'build.json'), JSON.stringify(c, null, 2) + '\n');
376
+ }
377
+
378
+ const total = list.length;
379
+ const baseArgs = { buildDir: outDir, env, model, apiKey, opts };
380
+ const qIdx = list.findIndex((s) => s.id === 'quality-grade');
381
+
382
+ // No quality gate in this slice (--no-quality, or a partial --only/--from run) → run linearly.
383
+ if (qIdx === -1) {
384
+ const r = await runStations(list, baseArgs, total, 0);
385
+ if (!r.ok) { log(`\n${C.red}${C.bold}Build stopped at "${r.failedAt}".${C.reset} Resume: ${C.cyan}--from ${r.failedAt} --out ${outDir}${C.reset}`); return { ok: false, failedAt: r.failedAt, error: r.error, outDir, results: r.results }; }
386
+ finalSummary(outDir);
387
+ return { ok: true, outDir, results: r.results };
388
+ }
389
+
390
+ // Gated run: build through quality-grade, refine-until-pass, and ONLY ship (deploy/publish/…) on pass.
391
+ const pre = list.slice(0, qIdx + 1);
392
+ const post = list.slice(qIdx + 1);
393
+ const preR = await runStations(pre, baseArgs, total, 0);
394
+ if (!preR.ok) { log(`\n${C.red}${C.bold}Build stopped at "${preR.failedAt}".${C.reset} Resume: ${C.cyan}--from ${preR.failedAt} --out ${outDir}${C.reset}`); return { ok: false, failedAt: preR.failedAt, error: preR.error, outDir, results: preR.results }; }
395
+
396
+ let quality = readQuality(outDir);
397
+ if (quality && !quality.passed && !opts.noRefine) {
398
+ quality = await refineLoop({ buildDir: outDir, env, model, apiKey, opts });
399
+ }
400
+
401
+ if (!(quality && quality.passed)) {
402
+ // NEVER ship a below-bar page silently — the whole point. Stop before deploy/publish, report the gap.
403
+ reportQualityGap(quality);
404
+ log(`\n${C.yellow}${C.bold}Held at the quality gate — did NOT deploy or publish a below-bar page.${C.reset}`);
405
+ log(`${C.dim}Best local build: ${outDir}/site . Lift the gaps above (or re-run with a higher --max-refine), then ship the remaining steps with: ${C.cyan}--from ${post[0] ? post[0].id : 'deploy'} --out ${outDir}${C.reset}`);
406
+ return { ok: false, gated: true, quality, outDir, results: preR.results };
407
+ }
408
+
409
+ const meanPair = quality.scorecard.map((c) => `${String(c.device).replace(/\(.*/, '')} ${c.meanScore}`).join(' / ');
410
+ if (quality.exemplary) {
411
+ log(`\n${C.green}${C.bold}Quality gate PASSED — world-class (mean ${meanPair}).${C.reset} Shipping.`);
412
+ } else {
413
+ log(`\n${C.green}${C.bold}Quality gate PASSED — ship-worthy (mean ${meanPair}).${C.reset} Shipping.`);
414
+ log(`${C.dim}Genuinely good + no slop + real legible diagrams (INV-18). The world-class target (mean ≥ 90 / worst axis ≥ 85 / all 5 operators) is not fully reached — the per-axis gap is recorded in build.json refineNotes and travels with the scorecard.${C.reset}`);
415
+ }
416
+ const postR = post.length ? await runStations(post, baseArgs, total, qIdx + 1) : { ok: true, results: [] };
417
+ finalSummary(outDir);
418
+ return { ok: postR.ok !== false, outDir, results: [...preR.results, ...postR.results] };
419
+ }
@@ -0,0 +1,49 @@
1
+ // src/run-tool.mjs — spawn an EXISTING tools/<name>.mjs as a child process (CONTRACT §b).
2
+ //
3
+ // The orchestrator never re-implements a tool. It invokes each one the uniform way —
4
+ // `node tools/<name>.mjs <build-dir>` — and obeys the uniform return convention:
5
+ // • stdout carries EXACTLY one JSON result object → we capture + JSON.parse it.
6
+ // • stderr is diagnostics → we INHERIT it so the operator watches the tool work live.
7
+ // • the exit code is the source of truth → ok iff code 0 (CONTRACT §b·6).
8
+ //
9
+ // We pass the merged env (src/env.mjs) so every tool finds its own credentials in process.env.
10
+
11
+ import path from 'node:path';
12
+ import fs from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ // Pull the LAST non-empty line of stdout and JSON.parse it. Tools route diagnostics to stderr, so
16
+ // stdout is normally a single JSON line; the last-line rule is just belt-and-suspenders.
17
+ function parseResult(stdout) {
18
+ const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
19
+ for (let i = lines.length - 1; i >= 0; i--) {
20
+ try { return JSON.parse(lines[i]); } catch { /* keep scanning upward */ }
21
+ }
22
+ return null;
23
+ }
24
+
25
+ // Run one tool. Returns { ok, code, result, error }. Never throws — the orchestrator decides whether
26
+ // a non-ok station is fatal or a non-blocking warning (notify / readme-enhance degrade to warnings).
27
+ export function runTool(name, buildDir, { repoRoot, env, timeoutMs = 1_800_000 } = {}) {
28
+ const toolPath = path.join(repoRoot, 'tools', `${name}.mjs`);
29
+ if (!fs.existsSync(toolPath)) {
30
+ return { ok: false, code: 127, result: null, error: `tool not found: tools/${name}.mjs` };
31
+ }
32
+ const res = spawnSync(process.execPath, [toolPath, path.resolve(buildDir)], {
33
+ cwd: repoRoot,
34
+ env,
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'inherit'], // capture stdout JSON; stream stderr live to operator
37
+ timeout: timeoutMs,
38
+ maxBuffer: 64 * 1024 * 1024,
39
+ });
40
+ if (res.error) {
41
+ const why = res.error.code === 'ETIMEDOUT' ? `timed out after ${timeoutMs}ms` : res.error.message;
42
+ return { ok: false, code: res.status ?? 1, result: null, error: `tools/${name}.mjs: ${why}` };
43
+ }
44
+ const result = parseResult(res.stdout);
45
+ const code = res.status;
46
+ const ok = code === 0 && (!result || result.ok !== false);
47
+ const error = ok ? null : (result?.error || `tools/${name}.mjs exited ${code} (no JSON result on stdout)`);
48
+ return { ok, code, result, error };
49
+ }