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.
package/src/brain.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // src/brain.mjs — the BRAIN authors (CONTRACT §a: the slots with "no tool").
2
+ //
3
+ // These are the steps the e2e run proved cannot be deterministic: concept (Station 2), content
4
+ // (Station 3), the Station-4 image briefs + diagram ASCII, and the for-humans primer. Each author
5
+ // gathers a compact, GROUNDED brief from the real KB the deterministic build-kb station produced
6
+ // (summary + dep-graph + entrypoints + symbols + a passage sample + README), hands it to Claude, and
7
+ // returns a typed object the orchestrator merges into the owning slot. Grounding is the guardrail:
8
+ // the prompt forbids invented facts/benchmarks (CONTRACT INV-06 — every claim traceable to the KB).
9
+ //
10
+ // STATUS: concept / content / visualBrief / primer authors are WORKING (real prompts, schema-checked
11
+ // output). authorKbTarget is the one GATED author — it emits a kb.config.mjs target entry but, by
12
+ // default, the orchestrator does NOT mutate the shared registry with it (see src/orchestrator.mjs).
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { callClaudeJSON, callClaude } from './claude.mjs';
17
+
18
+ // ── grounding: assemble a bounded text brief from the real KB artifacts ────────────────────────
19
+ function readJsonRel(repoRoot, rel) {
20
+ if (!rel) return null;
21
+ const p = path.isAbsolute(rel) ? rel : path.resolve(repoRoot, rel);
22
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
23
+ }
24
+ function readPassageSample(repoRoot, rel, maxChars = 12_000) {
25
+ if (!rel) return '';
26
+ const p = path.isAbsolute(rel) ? rel : path.resolve(repoRoot, rel);
27
+ let text;
28
+ try { text = fs.readFileSync(p, 'utf8'); } catch { return ''; }
29
+ const out = [];
30
+ let used = 0;
31
+ for (const line of text.split('\n')) {
32
+ if (!line.trim()) continue;
33
+ let rec; try { rec = JSON.parse(line); } catch { continue; }
34
+ const body = String(rec.text || rec.passage || rec.body || '').trim();
35
+ if (!body) continue;
36
+ const snippet = body.slice(0, 600);
37
+ if (used + snippet.length > maxChars) break;
38
+ out.push(`• [${rec.id || rec.path || '?'}] ${snippet}`);
39
+ used += snippet.length;
40
+ }
41
+ return out.join('\n');
42
+ }
43
+
44
+ export function gatherBrief(ctx, repoRoot) {
45
+ const kb = ctx.kb || {};
46
+ const dep = readJsonRel(repoRoot, kb.depGraphPath) || {};
47
+ const ent = readJsonRel(repoRoot, kb.entrypointsPath) || {};
48
+ const sym = readJsonRel(repoRoot, kb.symbolsPath) || {};
49
+ const passages = readPassageSample(repoRoot, kb.passagesPath);
50
+ const components = Array.isArray(dep.nodes) ? dep.nodes.map((n) => n.name || n).slice(0, 40) : [];
51
+ const edges = Array.isArray(dep.internalEdges) ? dep.internalEdges.map((e) => `${e.from}->${e.to}`).slice(0, 60) : [];
52
+ const install = Array.isArray(ent.install) ? ent.install : [];
53
+ const commands = Array.isArray(ent.commands) ? ent.commands.map((c) => `${c.category}: ${c.cmd}`) : [];
54
+ const quickstart = Array.isArray(ent.quickstart) ? ent.quickstart : [];
55
+ return [
56
+ `REPO: ${ctx.repo?.owner || '?'}/${ctx.repo?.name || ctx.repo?.slug || '?'} (${ctx.repo?.url || ''})`,
57
+ `SUMMARY: ${ctx.understanding?.summary || '(none)'}`,
58
+ `ECOSYSTEMS: ${(dep.ecosystems || []).join(', ') || 'unknown'}`,
59
+ `COMPONENTS (${dep.componentCount ?? components.length}): ${components.join(', ') || '(none)'}`,
60
+ `INTERNAL DEPS: ${edges.join(', ') || '(none)'}`,
61
+ `EXTERNAL DEPS: ${(dep.externalDepNames || []).slice(0, 30).join(', ') || '(none)'}`,
62
+ `PUBLIC SYMBOLS: ${sym.count ?? (Array.isArray(sym.symbols) ? sym.symbols.length : 0)}`,
63
+ `INSTALL: ${install.join(' ; ') || '(none)'}`,
64
+ `COMMANDS: ${commands.join(' ; ') || '(none)'}`,
65
+ `QUICKSTART: ${quickstart.join(' ; ') || '(none)'}`,
66
+ '',
67
+ 'KB PASSAGE SAMPLE (ground every claim in these — do NOT invent facts, numbers, or benchmarks):',
68
+ passages || '(no passages available)',
69
+ ].join('\n');
70
+ }
71
+
72
+ const GROUNDING_RULE = 'Ground every claim in the brief below. Never invent facts, metrics, benchmarks, or version numbers. If something is unknown, omit it rather than guessing.';
73
+
74
+ // A refine pass hands the brain the harsh critic's per-criterion findings so the rewrite fixes the
75
+ // NAMED weaknesses instead of re-rolling blindly. Empty/absent feedback → no block (first authoring).
76
+ function revisionBlock(feedback) {
77
+ if (!Array.isArray(feedback) || !feedback.length) return '';
78
+ const lines = feedback.slice(0, 14).map((f) => {
79
+ const what = String(f.saw || f.note || '').replace(/\s+/g, ' ').trim().slice(0, 280);
80
+ return `- ${f.criterion}${f.score != null ? ` (scored ${f.score})` : ''}: ${what}`;
81
+ });
82
+ return `REVISION PASS — a demanding critic graded your previous version and flagged these SPECIFIC weaknesses. `
83
+ + `Fix EACH one concretely in this rewrite; keep what already works. Do not regress strong sections.\n${lines.join('\n')}\n\n`;
84
+ }
85
+
86
+ // ── Station 2: concept (the art-direction brief) ───────────────────────────────────────────────
87
+ // Palette knobs MUST be from assemble-page's allow-list; values must avoid ; { } < > url( javascript:
88
+ const PALETTE_KNOBS = 'bg, bg-2, surface, surface-2, ridge, ink, ink-2, muted, faint, on-accent, accent (REQUIRED), accent-2, accent-3, spectrum, ok, warn, bad, radius, radius-s, ease, hero-grad-angle';
89
+
90
+ export async function authorConcept(ctx, { apiKey, model }) {
91
+ const brief = gatherBrief(ctx, ctx._repoRoot);
92
+ const system = `You are an award-winning art director + brand designer. You invent a SPECIFIC visual metaphor for ONE software repo and express it as design tokens. ${GROUNDING_RULE}`;
93
+ const user = `${brief}
94
+
95
+ Author the "concept" slot for this repo's explainer page. Return JSON:
96
+ {
97
+ "metaphor": "a concrete visual metaphor SPECIFIC to this repo (e.g. prism, dossier, orb, lattice) — not generic",
98
+ "heroConcept": "one sentence describing the emotional opening image",
99
+ "copyVoice": "the tone/register for all authored text",
100
+ "tagline": "ONE punchy line (<= 70 chars) for the social card + og:description",
101
+ "palette": { /* keys from this allow-list only: ${PALETTE_KNOBS}. Values are CSS colors/sizes WITHOUT ; { } < > url() . MUST include "accent". Choose colors that carry the metaphor and read well. */ },
102
+ "typePersonality": { "display": "a Google font family name", "sans": "a Google font family name", "mono": "a mono font family name", "fontHref": "https://fonts.googleapis.com/css2?... for the chosen families" },
103
+ "layoutRhythm": ["hero", "problem", "whatItIs", "insight", "howItWorks", "useCases", "getStarted", "pack"]
104
+ }`;
105
+ const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 2000, temperature: 0.8 });
106
+ if (!out || typeof out !== 'object') throw new Error('authorConcept: model did not return an object');
107
+ if (!out.palette || typeof out.palette !== 'object') throw new Error('authorConcept: missing palette');
108
+ if (!('accent' in out.palette) && !('--accent' in out.palette)) throw new Error("authorConcept: palette must define 'accent'");
109
+ if (!out.tagline) throw new Error('authorConcept: missing tagline');
110
+ return out;
111
+ }
112
+
113
+ // ── Station 3: content (the eight authored sections) ───────────────────────────────────────────
114
+ export async function authorContent(ctx, { apiKey, model, feedback }) {
115
+ const brief = gatherBrief(ctx, ctx._repoRoot);
116
+ const voice = ctx.concept?.copyVoice || 'clear, confident, technical-but-human';
117
+ const system = `You are a senior technical writer + narrative designer. You write the copy for a repo explainer page in this voice: ${voice}. ${GROUNDING_RULE}`;
118
+ const user = `${brief}
119
+
120
+ ${revisionBlock(feedback)}Author the "content" slot. The page renders these sections from typed fields — match the shapes EXACTLY. Return JSON:
121
+ {
122
+ "arc": [ { "question": "What world am I in?", "section": "hero", "altitude": "high" } ],
123
+ "sections": {
124
+ "hero": { "eyebrow": "short kicker", "headline": "the big headline", "lede": "one strong paragraph", "sub": "optional supporting line", "ctas": [ { "label": "Get started", "href": "#get-started" }, { "label": "View on GitHub", "href": "${ctx.repo?.url || '#'}", "ghost": true } ], "meta": [ { "label": "Language", "value": "…" } ] },
125
+ "problem": { "title": "…", "lead": "…", "paragraphs": ["…","…"] },
126
+ "whatItIs": { "title": "…", "lead": "…", "paragraphs": ["…"], "table": { "caption": "optional", "head": ["Aspect","Detail"], "rows": [["…","…"]] } },
127
+ "insight": { "title": "…", "lead": "…", "paragraphs": ["…"], "oh": "the one-line aha" },
128
+ "howItWorks": { "title": "…", "lead": "…", "paragraphs": ["…"] },
129
+ "useCases": { "title": "…", "intro": "…", "cases": [ { "title": "…", "tag": "…", "paragraphs": ["…"] } ] },
130
+ "getStarted": { "title": "…", "intro": "…", "install": "the real install command from the brief", "steps": [ "step one", { "strong": "step", "text": "detail" } ] },
131
+ "pack": { "title": "…", "intro": "…", "downloadLabel": "Download the knowledge pack" }
132
+ },
133
+ "citations": [ { "claim": "a claim you made", "passageId": "the [id] from the brief that supports it" } ]
134
+ }
135
+ Rules: 2-4 paragraphs max per section; useCases has 2-3 cases; getStarted.install + steps must come from the brief's INSTALL/COMMANDS/QUICKSTART; cite real passage ids.
136
+ GET-STARTED must give real IMPLEMENTATION CONFIDENCE (this is the most-failed axis): the steps must include (a) any PREREQUISITES (toolchain/version), (b) the EXACT command(s) to run, copyable and grounded in the brief, (c) WHAT THE READER WILL SEE when it succeeds (the concrete result/output), (d) what they HAVE at the end, and (e) the NEXT step. Prefer { "strong": "...", "text": "..." } steps so each has a bolded action + concrete detail. If the repo genuinely has no install command or CLI (a pure library), SAY so honestly, then give the real clone → build → test commands and what each produces — never a vague "just explore the code".`;
137
+ const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 4000, temperature: 0.6 });
138
+ const need = ['hero', 'problem', 'whatItIs', 'insight', 'howItWorks', 'useCases', 'getStarted', 'pack'];
139
+ if (!out?.sections) throw new Error('authorContent: missing sections');
140
+ for (const s of need) if (!out.sections[s]) throw new Error(`authorContent: missing section "${s}"`);
141
+ return out;
142
+ }
143
+
144
+ // ── Station 4 brief: image prompts + diagram ASCII (the brain half of VISUALIZE) ───────────────
145
+ // Returns the brain content; the orchestrator stamps fixed, valid px sizes + engine ids so we never
146
+ // emit an invalid image size. arch/flow ASCII is grounded by make-diagrams from the real kb graph —
147
+ // here we only author their altText + the two judgment diagrams (big-idea, insight).
148
+ export async function authorVisualBrief(ctx, { apiKey, model }) {
149
+ const brief = gatherBrief(ctx, ctx._repoRoot);
150
+ const metaphor = ctx.concept?.metaphor || 'a clean technical metaphor';
151
+ const palette = ctx.concept?.palette ? JSON.stringify(ctx.concept.palette) : '(none)';
152
+ const system = `You are an award-winning art director (think Stripe, Linear, Vercel brand work) writing image-generation prompts + ASCII concept diagrams for a repo explainer. The page's visual metaphor is: ${metaphor}. Palette: ${palette}. ${GROUNDING_RULE}
153
+
154
+ ART DIRECTION — non-negotiable, this is what separates a memorable hero from generic AI slop:
155
+ - BAN these clichés outright: glowing neural-network trees, generic floating DNA helixes, holographic "cyber" grids, neon circuit boards, abstract particle clouds, faceless hooded hackers, glowing brains, "matrix" rain. If the metaphor is the obvious one (e.g. DNA for genomics), find a FRESH, specific, unexpected angle on it — never the postcard version.
156
+ - Anchor every image to a CONCRETE, specific scene or object grounded in what THIS repo actually does — a real moment, material, or mechanism — not a vague mood. Specificity is what reads as "designed," not "generated."
157
+ - Direct it like a real photo/render: name the exact subject, the camera angle, the lighting (e.g. raking low light, soft studio, single hard key), the material/texture, depth of field, and one surprising compositional choice. Editorial photography or refined cinematic 3D — not "digital art."
158
+ - It must feel bespoke to this repo: someone who knows the project should think "yes, that's exactly it," and a stranger should think "that's striking."`;
159
+ const user = `${brief}
160
+
161
+ Author the visual brief. Return JSON:
162
+ {
163
+ "hero": { "prompt": "a rich, SPECIFIC, art-directed text-to-image prompt for the HERO image — a concrete subject + camera angle + lighting + material + one unexpected compositional idea, embodying the metaphor WITHOUT the banned clichés; editorial/cinematic quality; no text/words/letters baked into the image" },
164
+ "sections": [
165
+ { "id": "problem", "role": "problem illustration", "prompt": "a specific, art-directed image prompt for the PROBLEM this repo solves — a concrete before-state scene, not an abstract mood; no baked-in text" },
166
+ { "id": "useCase", "role": "scenario", "prompt": "a specific, art-directed image prompt for a concrete real-world use-case scenario — a real moment of someone/something using this; no baked-in text" }
167
+ ],
168
+ "diagrams": {
169
+ "bigIdea": {
170
+ "title": "a 3-5 word heading for the big-idea diagram (e.g. 'How it all fits together')",
171
+ "rows": [ { "items": ["3 to 6 SHORT concept-card labels (<=42 chars each), in order", "..."], "connect": true } ],
172
+ "altText": "one-line takeaway describing the big idea"
173
+ },
174
+ "insight": {
175
+ "title": "a 3-5 word heading for the insight diagram (e.g. 'The clever move')",
176
+ "rows": [ { "items": ["2 to 4 SHORT concept-card labels (<=42 chars each)"], "connect": true } ],
177
+ "altText": "one-line takeaway describing the key insight"
178
+ },
179
+ "architecture":{ "altText": "one-line description of the architecture diagram" },
180
+ "flow": { "altText": "one-line description of the runtime/process flow diagram" }
181
+ }
182
+ }
183
+ DIAGRAM RULES (bigIdea + insight are DRAWN as real glassmorphic concept-cards joined by glowing arrows — NEVER ASCII):
184
+ - Each "items" entry is ONE short card label: a concrete noun-phrase grounded in the brief (a real component, artifact, or step), <= 42 characters. NO ASCII art, NO box-drawing or pipe characters, NO arrows inside a label.
185
+ - Use ONE row with "connect": true for a SEQUENCE (cards joined top-to-bottom by arrows). Use MULTIPLE rows (each "connect": false) for parallel/grouped ideas drawn without an arrow between groups.
186
+ - bigIdea = the central mechanism in 3-6 cards (how the pieces combine to do the one big thing). insight = the single clever move in 2-4 cards. Keep BOTH distinct from the architecture diagram — do not just relist every module.`;
187
+ const out = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 2000, temperature: 0.7 });
188
+ if (!out?.hero?.prompt) throw new Error('authorVisualBrief: missing hero.prompt');
189
+ const okRows = (d) => d && Array.isArray(d.rows) && d.rows.length
190
+ && d.rows.every((r) => r && Array.isArray(r.items) && r.items.length
191
+ && r.items.every((s) => (typeof s === 'string' ? s.trim() : (s && String(s.label || '').trim()))));
192
+ if (!okRows(out?.diagrams?.bigIdea) || !okRows(out?.diagrams?.insight)) {
193
+ throw new Error('authorVisualBrief: bigIdea/insight must each provide non-empty rows[].items (short concept-card labels, not ASCII)');
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // Shape the visuals slot from the brain brief + FIXED deterministic px/engine (CONTRACT §a visuals).
199
+ export function visualsSlotFromBrief(brief) {
200
+ // big-idea / insight are now STRUCTURED concept models (rows of short card labels) that make-diagrams
201
+ // DRAWS with the glass-card renderer — never typeset ASCII. Normalise each row to { items:[string], connect }.
202
+ const conceptModel = (d, fallbackTitle) => ({
203
+ rows: (Array.isArray(d?.rows) ? d.rows : []).map((r) => ({
204
+ items: (Array.isArray(r.items) ? r.items : [])
205
+ .map((s) => (typeof s === 'string' ? s : (s && s.label) || '')).map((s) => String(s).trim()).filter(Boolean),
206
+ connect: r && r.connect !== false,
207
+ })).filter((r) => r.items.length),
208
+ title: (d && typeof d.title === 'string' && d.title.trim()) ? d.title.trim() : fallbackTitle,
209
+ altText: (d && typeof d.altText === 'string') ? d.altText : '',
210
+ });
211
+ return {
212
+ hero: { role: 'metaphor', prompt: brief.hero.prompt, px: '1536x1024', engine: 'gpt-image-2' },
213
+ sections: (brief.sections || []).slice(0, 3).map((s) => ({
214
+ id: s.id, role: s.role || 'illustration', prompt: s.prompt, px: '1024x1024', engine: 'gpt-image-2',
215
+ })),
216
+ bigIdeaDiagram: conceptModel(brief.diagrams.bigIdea, 'How it all fits together'),
217
+ insightDiagram: conceptModel(brief.diagrams.insight, 'The clever move'),
218
+ architectureDiagram: { altText: brief.diagrams.architecture?.altText || '' },
219
+ flowDiagram: { altText: brief.diagrams.flow?.altText || '' },
220
+ };
221
+ }
222
+
223
+ // ── Station 1 brain deliverable: the for-humans primer markdown (make-pack/make-dropin require it) ──
224
+ export async function authorPrimer(ctx, { apiKey, model, repoRoot }) {
225
+ const brief = gatherBrief(ctx, repoRoot);
226
+ const name = ctx.understanding?.repoName || ctx.repo?.name || ctx.repo?.slug;
227
+ const system = `You write a top-down, for-humans primer for a code repo — the human half of an AI knowledge pack. Plain markdown, ## section headers, honest and specific. ${GROUNDING_RULE}`;
228
+ const user = `${brief}
229
+
230
+ Write a primer for "${name}" as markdown. Use these ## sections, in order:
231
+ ## 1. What is ${name}
232
+ ## 2. What can it do for you
233
+ ## 3. What is it made of (the components)
234
+ ## 4. How it works
235
+ ## 5. How do I install and use it
236
+ ## 6. Honest scope and limits
237
+ Keep it tight and real; ground every statement in the brief above.`;
238
+ const md = await callClaude({ apiKey, model, system, user, maxTokens: 3000, temperature: 0.4 });
239
+ const primerRel = ctx.kb?.primerPath;
240
+ if (!primerRel) throw new Error('authorPrimer: build.json has no kb.primerPath (run build-kb first)');
241
+ const primerAbs = path.isAbsolute(primerRel) ? primerRel : path.resolve(repoRoot, primerRel);
242
+ fs.mkdirSync(path.dirname(primerAbs), { recursive: true });
243
+ fs.writeFileSync(primerAbs, md.trim() + '\n');
244
+ return primerAbs;
245
+ }
246
+
247
+ // ── GATED: author a kb.config.mjs target entry for an unregistered repo ─────────────────────────
248
+ // build-kb wraps `kb/build-kb.mjs --target <slug>`, which requires the slug registered in
249
+ // kb/kb.config.mjs (embed block + repoDir -> the clone + corpus rules). The embed block is fixed
250
+ // (the project's standard 384-dim bge-small); the corpus rules are judgment, so the brain authors
251
+ // them. We return the entry as a plain object — the orchestrator decides whether to write it
252
+ // (default: NO, it stops loud; --register-kb: yes). This keeps the shared registry under explicit
253
+ // operator control rather than silently edited by a build.
254
+ export async function authorKbTarget(ctx, { apiKey, model }) {
255
+ const brief = gatherBrief(ctx, ctx._repoRoot);
256
+ const slug = ctx.repo?.slug;
257
+ const clonePath = ctx.repo?.clonePath;
258
+ const system = `You configure a code-indexing corpus for one repo. Decide which roots/extensions to walk and which files matter most. ${GROUNDING_RULE}`;
259
+ const user = `${brief}
260
+
261
+ Return JSON for this repo's corpus config (only these keys):
262
+ {
263
+ "metaName": "display name",
264
+ "productNames": ["key public names / API verbs from the brief"],
265
+ "componentRoots": ["top-level source dirs, e.g. src, bin, examples, crates"],
266
+ "codeExt": [".ts",".js",".mjs"],
267
+ "fullTextExt": [".md",".mdx",".txt"],
268
+ "scopeExclude": ["node_modules","dist","target",".git","coverage","pkg",".next"],
269
+ "include": [
270
+ { "rule": "mdSweepFullText", "roots": ["."] },
271
+ { "rule": "literalFiles", "files": ["README.md","package.json"] },
272
+ { "rule": "sourceBodies", "roots": ["src"], "ext": [".ts",".js",".mjs"] }
273
+ ]
274
+ }`;
275
+ const rules = await callClaudeJSON({ apiKey, model, system, user, maxTokens: 1500, temperature: 0.3 });
276
+ return {
277
+ slug,
278
+ metaName: rules.metaName || ctx.understanding?.repoName || slug,
279
+ embed: {
280
+ model: 'Xenova/bge-small-en-v1.5', dim: 384, pooling: 'mean',
281
+ queryPrefix: 'Represent this sentence for searching relevant passages: ',
282
+ rankScale: 0.6, rvfSuffix: '.rvf',
283
+ },
284
+ productNames: rules.productNames || [],
285
+ repoDir: clonePath,
286
+ scopeExclude: rules.scopeExclude || ['node_modules', 'dist', 'target', '.git', 'coverage', 'pkg', '.next'],
287
+ codeExt: rules.codeExt || ['.ts', '.tsx', '.js', '.mjs', '.cjs'],
288
+ fullTextExt: rules.fullTextExt || ['.md', '.mdx', '.txt'],
289
+ templateExt: [],
290
+ componentRoots: rules.componentRoots || ['src'],
291
+ componentWord: ['crate', 'package', 'module', 'component'],
292
+ include: rules.include || [
293
+ { rule: 'mdSweepFullText', roots: ['.'] },
294
+ { rule: 'literalFiles', files: ['README.md', 'package.json'] },
295
+ { rule: 'sourceBodies', roots: ['src'], ext: ['.ts', '.js', '.mjs'] },
296
+ ],
297
+ };
298
+ }
@@ -0,0 +1,66 @@
1
+ // src/build-context.mjs — the BuildContext (build.json) lifecycle for the orchestrator.
2
+ //
3
+ // CONTRACT §a: there is exactly ONE data contract per build — build.json inside the build dir. The
4
+ // brain (this CLI) owns it; each station fills its own slot. This module gives the brain the same
5
+ // disciplined read-modify-write-one-slot access the tools use, so the orchestrator never clobbers a
6
+ // tool's slot. The build dir layout is CONTRACT §b:
7
+ //
8
+ // <build-dir>/
9
+ // build.json the BuildContext (the ONLY cross-tool channel)
10
+ // repo/ the cloned working tree (clone-repo writes this)
11
+ // assets/ generated images / SVGs / favicons / social card
12
+ // site/ the assembled page + the knowledge-pack zip
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export function buildJsonPath(buildDir) { return path.join(path.resolve(buildDir), 'build.json'); }
18
+
19
+ // Create the build dir + a seed build.json carrying only repo.url (clone-repo, Station 0–1, sets
20
+ // buildId + the rest of the repo slot). Idempotent: an existing build.json is preserved and its
21
+ // repo.url refreshed, so re-running a build resumes rather than wiping prior station output.
22
+ export function initBuildDir(buildDir, repoUrl) {
23
+ const dir = path.resolve(buildDir);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
26
+ const p = buildJsonPath(dir);
27
+ let ctx = {};
28
+ if (fs.existsSync(p)) {
29
+ try { ctx = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { ctx = {}; }
30
+ }
31
+ ctx.repo = { ...(ctx.repo || {}), url: repoUrl };
32
+ fs.writeFileSync(p, JSON.stringify(ctx, null, 2) + '\n');
33
+ return dir;
34
+ }
35
+
36
+ export function readContext(buildDir) {
37
+ const p = buildJsonPath(buildDir);
38
+ let raw;
39
+ try { raw = fs.readFileSync(p, 'utf8'); }
40
+ catch { throw new Error(`build.json not found at ${p} (run earlier stations first)`); }
41
+ try { return JSON.parse(raw); }
42
+ catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
43
+ }
44
+
45
+ // Read-modify-write a SINGLE top-level slot, exactly like the tools' mergeSlot. For object slots we
46
+ // shallow-merge so a partial author preserves sibling keys; non-object values replace outright.
47
+ export function mergeSlot(buildDir, slot, value) {
48
+ const p = buildJsonPath(buildDir);
49
+ const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
50
+ const isPlainObject = value && typeof value === 'object' && !Array.isArray(value);
51
+ obj[slot] = isPlainObject ? { ...(obj[slot] || {}), ...value } : value;
52
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
53
+ return obj[slot];
54
+ }
55
+
56
+ // True iff a slot is already populated (used by --from/--to resume + "skip if done" logic).
57
+ export function hasSlot(buildDir, slot) {
58
+ try {
59
+ const ctx = readContext(buildDir);
60
+ const v = ctx[slot];
61
+ if (v == null) return false;
62
+ if (Array.isArray(v)) return v.length > 0;
63
+ if (typeof v === 'object') return Object.keys(v).length > 0;
64
+ return true;
65
+ } catch { return false; }
66
+ }
package/src/claude.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // src/claude.mjs — the BRAIN-CALL interface.
2
+ //
3
+ // CONTRACT §a slot-ownership table: `concept` and `content` (and the diagram ASCII / image briefs
4
+ // that seed Station 4) are filled by the BRAIN directly — "there is intentionally no tool for them."
5
+ // The e2e finding was that these slots are author-judgment, so the pipeline cannot run fully
6
+ // deterministically: the CLI itself must call Claude to author them between the deterministic
7
+ // stations. This module is that single, narrow seam to Claude.
8
+ //
9
+ // It talks to the Anthropic Messages API over plain `fetch` (Node 18+ global) — ZERO npm deps, so
10
+ // the package installs and `node --test` stays green without an SDK. The key comes from the merged
11
+ // env (ANTHROPIC_API_KEY, back-filled from CLAUDE_API_KEY by src/env.mjs); it is never logged.
12
+ //
13
+ // Interface (stable):
14
+ // callClaude({ apiKey, model?, system, user, maxTokens?, temperature?, timeoutMs? }) -> string
15
+ // callClaudeJSON({ …same… }) -> parsed JSON (asks for JSON-only, strips fences, retries once)
16
+
17
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
18
+ const ANTHROPIC_VERSION = '2023-06-01';
19
+
20
+ // Verified live via GET /v1/models on 2026-06-29 (claude-sonnet-4-6 present). Sonnet is the
21
+ // authoring default: strong judgment for concept/content at a sane cost. Override with --model or
22
+ // EXPLAINMYREPO_MODEL / ANTHROPIC_MODEL.
23
+ export const DEFAULT_MODEL = 'claude-sonnet-4-6';
24
+
25
+ export function resolveModel(env = {}, override) {
26
+ return override || env.EXPLAINMYREPO_MODEL || env.ANTHROPIC_MODEL || DEFAULT_MODEL;
27
+ }
28
+
29
+ export async function callClaude({
30
+ apiKey, model = DEFAULT_MODEL, system, user,
31
+ maxTokens = 4096, temperature = 0.7, timeoutMs = 120_000,
32
+ }) {
33
+ if (!apiKey) {
34
+ throw new Error('no Anthropic API key — set ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in .env');
35
+ }
36
+ const ctrl = new AbortController();
37
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
38
+ let resp;
39
+ try {
40
+ resp = await fetch(ANTHROPIC_URL, {
41
+ method: 'POST',
42
+ signal: ctrl.signal,
43
+ headers: {
44
+ 'x-api-key': apiKey,
45
+ 'anthropic-version': ANTHROPIC_VERSION,
46
+ 'content-type': 'application/json',
47
+ },
48
+ body: JSON.stringify({
49
+ model, max_tokens: maxTokens, temperature,
50
+ system,
51
+ messages: [{ role: 'user', content: user }],
52
+ }),
53
+ });
54
+ } catch (e) {
55
+ clearTimeout(timer);
56
+ throw new Error(e.name === 'AbortError' ? `Anthropic request timed out after ${timeoutMs}ms` : `Anthropic request failed: ${e.message}`);
57
+ }
58
+ clearTimeout(timer);
59
+ if (!resp.ok) {
60
+ const body = await resp.text().catch(() => '');
61
+ throw new Error(`Anthropic API ${resp.status} (${model}): ${body.slice(0, 400)}`);
62
+ }
63
+ const j = await resp.json();
64
+ const text = (j.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
65
+ if (!text.trim()) throw new Error(`Anthropic returned no text (stop_reason=${j.stop_reason || 'unknown'})`);
66
+ return text;
67
+ }
68
+
69
+ // Strip ```fences``` then parse. If the model wrapped JSON in prose, slice from the first bracket to
70
+ // its matching last bracket and parse that.
71
+ function extractJSON(text) {
72
+ let t = String(text).trim();
73
+ t = t.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
74
+ try { return JSON.parse(t); } catch { /* fall through to bracket slice */ }
75
+ const firstObj = t.indexOf('{');
76
+ const firstArr = t.indexOf('[');
77
+ let start = -1, open = '{', close = '}';
78
+ if (firstObj === -1 && firstArr === -1) throw new Error('no JSON found in model reply');
79
+ if (firstArr !== -1 && (firstObj === -1 || firstArr < firstObj)) { start = firstArr; open = '['; close = ']'; }
80
+ else start = firstObj;
81
+ const end = t.lastIndexOf(close);
82
+ if (end <= start) throw new Error('unbalanced JSON in model reply');
83
+ return JSON.parse(t.slice(start, end + 1));
84
+ }
85
+
86
+ export async function callClaudeJSON(opts) {
87
+ const jsonSystem = `${opts.system || ''}\n\nOUTPUT FORMAT: respond with ONE valid JSON value and NOTHING else — no markdown fences, no commentary, no leading or trailing prose.`;
88
+ let text = await callClaude({ ...opts, system: jsonSystem });
89
+ try {
90
+ return extractJSON(text);
91
+ } catch (firstErr) {
92
+ // One stricter retry — most JSON failures are a stray sentence the model can drop on request.
93
+ const retryUser = `${opts.user}\n\n(Your previous reply was not parseable as JSON: ${firstErr.message}. Reply again with ONLY the JSON value.)`;
94
+ text = await callClaude({ ...opts, system: jsonSystem, user: retryUser, temperature: 0.2 });
95
+ return extractJSON(text);
96
+ }
97
+ }
package/src/env.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // src/env.mjs — environment + secret loading for the explainmyrepo CLI.
2
+ //
3
+ // The deterministic tools/ each read their own credentials from the environment (CONTRACT §d
4
+ // "Secrets"). Several of them (generate-image, quality-grade) ALSO self-parse the repo-root .env,
5
+ // but others (deploy, publish-repo, notify) read process.env only. So the orchestrator loads .env
6
+ // ONCE here, merges it under process.env (process.env always wins), adds the canonical aliases the
7
+ // tools expect, and hands that merged env to every child spawn AND to its own Claude brain calls.
8
+ //
9
+ // Secrets are NEVER printed, NEVER written to build.json, NEVER committed (CONTRACT §d). This module
10
+ // only resolves them in-memory; the redact() helper exists so any diagnostics stay safe.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ // Parse a dotenv file into a plain object. Never throws (a missing .env is normal). Supports
16
+ // `KEY=value`, `export KEY=value`, and single/double-quoted values. Ignores comments + blanks.
17
+ export function parseDotenv(file) {
18
+ const out = {};
19
+ let text;
20
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
21
+ for (const raw of text.split(/\r?\n/)) {
22
+ let line = raw.trim();
23
+ if (!line || line.startsWith('#')) continue;
24
+ if (line.startsWith('export ')) line = line.slice(7).trim();
25
+ const eq = line.indexOf('=');
26
+ if (eq === -1) continue;
27
+ const k = line.slice(0, eq).trim();
28
+ if (!k) continue;
29
+ let v = line.slice(eq + 1).trim();
30
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
31
+ out[k] = v;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // Canonical aliases: the tools/ + brain code read the canonical names, but this repo's .env uses
37
+ // vendor-specific ones. Fill the canonical key from the first present alias WITHOUT overwriting a
38
+ // value that is already set.
39
+ const ALIASES = {
40
+ ANTHROPIC_API_KEY: ['CLAUDE_API_KEY'],
41
+ OPENAI_API_KEY: ['OPEN_AI_KEY'],
42
+ };
43
+
44
+ // Load the merged environment for a build run. The project .env is the SOURCE OF TRUTH and OVERRIDES
45
+ // the ambient process.env for any key it defines; the ambient env only fills in keys the file omits.
46
+ // (We deliberately reversed the usual "env wins" precedence after a STALE NETLIFY_AUTH_TOKEN left over
47
+ // in the shell session silently overrode a freshly-updated .env and caused a 401 deploy — 2026-06-30.)
48
+ // Canonical aliases are then back-filled. Returns a NEW object (does not mutate process.env).
49
+ export function loadEnv(repoRoot) {
50
+ const fromFile = parseDotenv(path.join(repoRoot, '.env'));
51
+ const merged = { ...process.env, ...fromFile };
52
+ for (const [canon, alts] of Object.entries(ALIASES)) {
53
+ if (merged[canon] && String(merged[canon]).trim()) continue;
54
+ for (const a of alts) {
55
+ if (merged[a] && String(merged[a]).trim()) { merged[canon] = merged[a]; break; }
56
+ }
57
+ }
58
+ return merged;
59
+ }
60
+
61
+ // First present, non-empty value among `names`, else null. Used to resolve a credential without
62
+ // caring which alias the operator set.
63
+ export function getSecret(env, names) {
64
+ for (const n of names) {
65
+ const v = env[n];
66
+ if (v && String(v).trim()) return String(v).trim();
67
+ }
68
+ return null;
69
+ }
70
+
71
+ // Mask a secret for safe logging — only the first/last 3 chars survive.
72
+ export function redact(s) {
73
+ if (!s) return '(unset)';
74
+ const t = String(s);
75
+ if (t.length <= 8) return '***';
76
+ return `${t.slice(0, 3)}…${t.slice(-3)} (len ${t.length})`;
77
+ }