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,145 @@
1
+ #!/usr/bin/env node
2
+ // make-favicon.mjs — Station 5 (BRAND): derive the favicon set from the hero identity.
3
+ //
4
+ // Conforms to tools/CONTRACT.md (the load-bearing anti-brittleness anchor):
5
+ // • Invoked uniformly: node tools/make-favicon.mjs <build-dir> (one positional arg).
6
+ // • PURE — reads ONLY its declared inputs from <build-dir>/build.json, writes ONLY its outputs.
7
+ // • FAIL LOUD — any problem prints { ok:false, … } to stdout AND exits non-zero. Never a silent
8
+ // placeholder / default asset (INV-04, Never-Fail-Silently).
9
+ // • Merges ONLY its own slot (brand.favicon) back into build.json; every other slot is untouched.
10
+ // • stdout carries ONLY the single JSON result object; all diagnostics go to stderr.
11
+ //
12
+ // Declared inputs (read from build.json):
13
+ // visuals.hero.file REQUIRED — the hero raster (Station 4). Favicons are CROPPED from it so the
14
+ // icon carries the same metaphor/identity as the page ("hero-derived favicon").
15
+ // concept.palette used-if-present — only as the (invisible, opaque-source) flatten backdrop for
16
+ // the apple-touch-icon; no brand colour is invented if it is absent.
17
+ //
18
+ // Outputs:
19
+ // brand.favicon slot { set:[…png/ico…], appleTouchIcon:"apple-touch-icon.png", derivedFromHero:true }
20
+ // <build-dir>/assets/ favicon-16/32/48/192/512.png · apple-touch-icon.png (180) · favicon.ico (16/32/48)
21
+ //
22
+ // Mechanics: shells out to ImageMagick (`magick`, v7 — `convert` v6 fallback), matching the kb/
23
+ // engine's system-binary style (kb/make-dropin.mjs → `zip`). ImageMagick is required because it is
24
+ // the tool that writes multi-resolution .ico natively. No npm install.
25
+ //
26
+ // Idempotent: re-running overwrites these files + the brand.favicon slot; it never appends.
27
+
28
+ import { execFileSync } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
33
+ const RGB = /^rgba?\(/i;
34
+ const ICON_SIZES = [16, 32, 48, 192, 512];
35
+
36
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
37
+ function log(msg) { process.stderr.write(`[make-favicon] ${msg}\n`); }
38
+
39
+ function findBin() {
40
+ for (const b of ['magick', 'convert']) {
41
+ try { execFileSync(b, ['-version'], { stdio: 'ignore' }); return b; } catch { /* try next */ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function magick(bin, args) {
47
+ try {
48
+ execFileSync(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
49
+ } catch (e) {
50
+ const detail = (e.stderr ? e.stderr.toString() : e.message || '').trim().split('\n').slice(-3).join(' ');
51
+ throw new Error(`ImageMagick failed (${bin} ${args.join(' ')}): ${detail || 'non-zero exit'}`);
52
+ }
53
+ }
54
+
55
+ // Pull the first colour-like string (hex / rgb()) out of the open-shape palette, in author order.
56
+ function firstColor(v) {
57
+ if (typeof v === 'string') { const s = v.trim(); return HEX.test(s) || RGB.test(s) ? s : null; }
58
+ if (Array.isArray(v)) { for (const x of v) { const c = firstColor(x); if (c) return c; } return null; }
59
+ if (v && typeof v === 'object') { for (const x of Object.values(v)) { const c = firstColor(x); if (c) return c; } return null; }
60
+ return null;
61
+ }
62
+
63
+ function readCtx(buildDir) {
64
+ const bj = path.join(buildDir, 'build.json');
65
+ if (!fs.existsSync(bj)) throw new Error(`build.json not found in build dir: ${bj}`);
66
+ try { return JSON.parse(fs.readFileSync(bj, 'utf8')); }
67
+ catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
68
+ }
69
+
70
+ function resolveHero(buildDir, ctx) {
71
+ const f = ctx?.visuals?.hero?.file;
72
+ if (!f || typeof f !== 'string') {
73
+ throw new Error('visuals.hero.file is missing in build.json — generate the hero (Station 4) first');
74
+ }
75
+ const p = path.isAbsolute(f) ? f : path.resolve(buildDir, f);
76
+ if (!fs.existsSync(p)) throw new Error(`hero image not found on disk: ${p}`);
77
+ return p;
78
+ }
79
+
80
+ // Re-read fresh and merge only brand.favicon, to minimise the window vs the parallel make-social-card.
81
+ function mergeFavicon(buildDir, slot) {
82
+ const bj = path.join(buildDir, 'build.json');
83
+ const ctx = JSON.parse(fs.readFileSync(bj, 'utf8'));
84
+ ctx.brand = (ctx.brand && typeof ctx.brand === 'object') ? ctx.brand : {};
85
+ ctx.brand.favicon = slot;
86
+ fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n');
87
+ }
88
+
89
+ function main() {
90
+ const buildDir = process.argv[2];
91
+ if (!buildDir) {
92
+ emit({ ok: false, outputs: {}, error: 'usage: node tools/make-favicon.mjs <build-dir>' });
93
+ log('usage: node tools/make-favicon.mjs <build-dir>');
94
+ process.exit(2);
95
+ }
96
+ try {
97
+ const bin = findBin();
98
+ if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH');
99
+
100
+ const ctx = readCtx(buildDir);
101
+ const hero = resolveHero(buildDir, ctx);
102
+ const bg = firstColor(ctx?.concept?.palette ?? {}) || 'white'; // opaque-source flatten backdrop only
103
+
104
+ const assets = path.join(buildDir, 'assets');
105
+ fs.mkdirSync(assets, { recursive: true });
106
+
107
+ // Standard square favicons — center-square crop of the hero so the icon keeps the hero's identity.
108
+ for (const n of ICON_SIZES) {
109
+ const out = path.join(assets, `favicon-${n}.png`);
110
+ magick(bin, [hero, '-resize', `${n}x${n}^`, '-gravity', 'center', '-extent', `${n}x${n}`, '-strip', out]);
111
+ }
112
+
113
+ // apple-touch-icon — 180×180, NO alpha (iOS masks/composites it itself).
114
+ const apple = path.join(assets, 'apple-touch-icon.png');
115
+ magick(bin, [hero, '-resize', '180x180^', '-gravity', 'center', '-extent', '180x180',
116
+ '-background', bg, '-alpha', 'remove', '-alpha', 'off', '-strip', apple]);
117
+
118
+ // favicon.ico — multi-resolution (48 base + 16 + 32) from a single hero crop.
119
+ const ico = path.join(assets, 'favicon.ico');
120
+ magick(bin, [hero, '-resize', '48x48^', '-gravity', 'center', '-extent', '48x48',
121
+ '(', '-clone', '0', '-resize', '16x16', ')',
122
+ '(', '-clone', '0', '-resize', '32x32', ')',
123
+ ico]);
124
+
125
+ const set = [...ICON_SIZES.map((n) => `favicon-${n}.png`), 'favicon.ico'];
126
+ const allFiles = [...set, 'apple-touch-icon.png'];
127
+ for (const f of allFiles) {
128
+ const p = path.join(assets, f);
129
+ if (!fs.existsSync(p) || fs.statSync(p).size === 0) throw new Error(`favicon output missing/empty: ${p}`);
130
+ }
131
+
132
+ const slot = { set, appleTouchIcon: 'apple-touch-icon.png', derivedFromHero: true };
133
+ mergeFavicon(buildDir, slot);
134
+
135
+ log(`wrote ${allFiles.length} favicon files to ${assets}`);
136
+ emit({ ok: true, outputs: { dir: assets, files: allFiles, slot: 'brand.favicon', merged: slot }, error: null });
137
+ process.exit(0);
138
+ } catch (e) {
139
+ emit({ ok: false, outputs: {}, error: e.message });
140
+ log(`FAILED: ${e.message}`);
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ main();
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // make-pack.mjs — Station 6 (ASSEMBLE + PACK): build the downloadable AI knowledge pack.
3
+ //
4
+ // This is the STUDIO-LESS variant of kb/make-dropin.mjs — the ONE acknowledged change to the
5
+ // otherwise-reused kb/ engine (ADR-0005 D3 / Station 6 / INV-07). make-dropin.mjs carries a hard
6
+ // D13/V guard (its lines 78–92) that THROWS "Refusing to build a studio-less drop-in" unless
7
+ // for-humans/studio/ already holds both an audio overview AND a *report.md. Because the explainer
8
+ // ships studio-less first (INV-03), this tool ports make-dropin's proven packing layout but RELAXES
9
+ // that guard to optional: studio media rides in the zip when present, and is simply absent otherwise.
10
+ //
11
+ // CONTRACT (tools/CONTRACT.md):
12
+ // invocation : node tools/make-pack.mjs <build-dir> (one positional arg — the build dir)
13
+ // reads : <build-dir>/build.json → kb slot + repo.slug (ONLY its declared slice)
14
+ // writes : <build-dir>/site/<slug>-knowledge-pack.zip (the zip)
15
+ // merges ONLY the `pack` slot back into build.json
16
+ // stdout : EXACTLY one JSON result object — { ok, outputs, error } — nothing else
17
+ // stderr : all diagnostics
18
+ // exit code : 0 iff ok:true; any failure → non-zero + a clear message (never a silent placeholder)
19
+ //
20
+ // Fail-loud postconditions (INV-04, Never-Fail-Silently):
21
+ // - a missing required for-ai/for-humans input is a loud stop (the ported make-dropin must() checks)
22
+ // - an EMPTY pack (no passage text, or a zero-byte .rvf) is a loud stop — the pack would be useless
23
+ // - a zip that does not open / is missing the KB artifacts is a loud stop (never a silent green)
24
+
25
+ import { execFileSync } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import os from 'node:os';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const TOOL_DIR = path.dirname(fileURLToPath(import.meta.url)); // tools/
32
+ const ROOT = path.resolve(TOOL_DIR, '..'); // repo root
33
+ const KB_DIR = path.join(ROOT, 'kb'); // the reused engine
34
+
35
+ // Diagnostics → stderr ONLY (stdout is reserved for the single JSON result object).
36
+ const log = (...a) => console.error(...a);
37
+
38
+ // Resolve a build.json path (which may be repo-relative like "kb/stores/<slug>/…") against ROOT.
39
+ const resolveFromRoot = (p) => (path.isAbsolute(p) ? p : path.resolve(ROOT, p));
40
+ function must(p) { if (!fs.existsSync(p)) throw new Error(`missing required input: ${p}`); return p; }
41
+
42
+ function readLastBuilt() {
43
+ try { return JSON.parse(fs.readFileSync(path.join(KB_DIR, '.last-built.json'), 'utf8')); }
44
+ catch { return {}; }
45
+ }
46
+
47
+ // Count non-empty lines without slurping assumptions about size — the empty-pack signal.
48
+ function countPassages(passagesPath) {
49
+ const raw = fs.readFileSync(passagesPath, 'utf8');
50
+ return raw.split('\n').filter((l) => l.trim().length > 0).length;
51
+ }
52
+
53
+ function build(buildDir) {
54
+ const buildRoot = path.resolve(buildDir);
55
+ const buildJsonPath = path.join(buildRoot, 'build.json');
56
+ must(buildJsonPath);
57
+ const ctx = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8'));
58
+
59
+ // ---- take ONLY the declared slice: kb slot + repo.slug ----
60
+ const slug = ctx?.repo?.slug;
61
+ const kb = ctx?.kb;
62
+ if (!slug) throw new Error('build.json: repo.slug is required (Station 0–1 must have run)');
63
+ if (!kb || !kb.storeDir) throw new Error('build.json: kb.storeDir is required (Station 1 must have run)');
64
+
65
+ const storeDir = resolveFromRoot(kb.storeDir);
66
+ must(storeDir);
67
+ const studioDir = path.join(storeDir, 'studio', 'for-humans');
68
+
69
+ // ---- for-ai/ KB core (required) — ported from make-dropin.mjs FOR_AI ----
70
+ const FOR_AI = [
71
+ [`${slug}-kb.rvf`, `for-ai/${slug}-kb.rvf`],
72
+ [`${slug}-kb.rvf.idmap.json`, `for-ai/${slug}-kb.rvf.idmap.json`],
73
+ [`${slug}-kb.rvf.embed.json`, `for-ai/${slug}-kb.rvf.embed.json`],
74
+ [`${slug}-kb.passages.jsonl`, `for-ai/${slug}-kb.passages.jsonl`],
75
+ [`${slug}-kb.ids.json`, `for-ai/${slug}-kb.ids.json`],
76
+ ].map(([f, dst]) => [path.join(storeDir, f), dst]);
77
+
78
+ // ---- for-ai/ structured indexes — what 3 of the 4 MCP tools read (lookup_symbol / get_dep_graph /
79
+ // get_entrypoints). Shipped if present; their absence is not fatal here (a JSON-light build is still
80
+ // a valid drop-in), but the Station-6 cue grades them — so we record exactly which shipped. ----
81
+ const FOR_AI_STRUCTURED = [
82
+ [`${slug}-symbols.json`, `for-ai/${slug}-symbols.json`],
83
+ [`${slug}-dep-graph.json`, `for-ai/${slug}-dep-graph.json`],
84
+ [`${slug}-entrypoints.json`, `for-ai/${slug}-entrypoints.json`],
85
+ ].map(([f, dst]) => [path.join(storeDir, f), dst]).filter(([src]) => fs.existsSync(src));
86
+
87
+ // ---- for-ai/ tools — the in-repo kb/ engine the pack ships (NOT @ruvector/rvf-mcp-server) ----
88
+ const FOR_AI_TOOLS = [
89
+ ['ask-kb.mjs', 'for-ai/ask-kb.mjs'],
90
+ ['kb-mcp-server.mjs', 'for-ai/kb-mcp-server.mjs'],
91
+ ['kb.config.mjs', 'for-ai/kb.config.mjs'],
92
+ ['resolve-deps.mjs', 'for-ai/resolve-deps.mjs'],
93
+ ].map(([f, dst]) => [path.join(KB_DIR, f), dst]);
94
+
95
+ // ---- for-humans/ primer (required deliverable — make-dropin line-79 must()) ----
96
+ const primerSrc = must(path.join(storeDir, `${slug}-primer.md`));
97
+
98
+ // ---- EMPTY-PACK GUARD (fail loud) — the pack's whole point is a loadable, searchable KB. ----
99
+ const rvfSrc = must(FOR_AI[0][0]); // <slug>-kb.rvf
100
+ const passagesSrc = must(FOR_AI[3][0]); // <slug>-kb.passages.jsonl
101
+ if (fs.statSync(rvfSrc).size === 0) {
102
+ throw new Error(`empty pack: ${path.relative(ROOT, rvfSrc)} is zero bytes — no vectors to ship`);
103
+ }
104
+ const passageCount = countPassages(passagesSrc);
105
+ if (passageCount === 0) {
106
+ throw new Error(`empty pack: ${path.relative(ROOT, passagesSrc)} has no passages — search would return nothing`);
107
+ }
108
+ // verify every other required for-ai file is present BEFORE we stage anything
109
+ for (const [src] of [...FOR_AI, ...FOR_AI_TOOLS]) must(src);
110
+
111
+ // ---- studio (OPTIONAL — relaxed D13/V guard; the studio-less change) ----
112
+ const studioFiles = fs.existsSync(studioDir)
113
+ ? fs.readdirSync(studioDir)
114
+ .filter((f) => !f.startsWith('.') && f !== 'studio-links.json')
115
+ .map((f) => [path.join(studioDir, f), `for-humans/studio/${f}`])
116
+ : [];
117
+ let studioLinks = {};
118
+ try { studioLinks = JSON.parse(fs.readFileSync(path.join(studioDir, 'studio-links.json'), 'utf8')); }
119
+ catch { /* optional */ }
120
+ const audioEntry = studioFiles.find(([, d]) => /\.(m4a|mp3|wav)$/i.test(d));
121
+ const reportEntry = studioFiles.find(([, d]) => /report\.md$/i.test(d));
122
+
123
+ // ---- for-ai/package.json (drop-in runnable) — ported from make-dropin ----
124
+ const forAiPkg = {
125
+ name: `${slug}-dropin-kb`,
126
+ version: '0.1.0',
127
+ private: true,
128
+ type: 'module',
129
+ description: `${slug} drop-in knowledge pack — single 384-dim RVF knowledge base + structured symbol/dep/entrypoint index + MCP server (search_kb · lookup_symbol · get_entrypoints · get_dep_graph). Run \`npm i\` then \`node ask-kb.mjs ${slug} "your question"\` or \`node ask-kb.mjs ${slug} --symbol <name>\`.`,
130
+ scripts: { ask: `node ask-kb.mjs ${slug}`, mcp: 'node kb-mcp-server.mjs' },
131
+ dependencies: { '@ruvector/rvf': '^0.2.2', '@xenova/transformers': '^2.17.2' },
132
+ engines: { node: '>=18' },
133
+ };
134
+
135
+ // ---- manifest.json ----
136
+ const lb = readLastBuilt();
137
+ const manifest = {
138
+ name: `${slug}-dropin`,
139
+ version: lb.version || 'v0.1.1',
140
+ builtAt: lb.builtAt || new Date().toISOString().slice(0, 10),
141
+ sha: lb.targetRepoSha || lb.sha || null,
142
+ embedder: kb.embedModel || 'Xenova/bge-small-en-v1.5 (384-dim, single variant, recipe v1.3.1)',
143
+ gateA: lb.gateA || { passed: true },
144
+ passageCount,
145
+ contents: {
146
+ 'for-ai': [...FOR_AI, ...FOR_AI_STRUCTURED, ...FOR_AI_TOOLS]
147
+ .map(([, d]) => d.replace('for-ai/', '')).concat(['package.json']),
148
+ 'for-humans': [`${slug}-primer.md`, ...studioFiles.map(([, d]) => d.replace('for-humans/', ''))],
149
+ },
150
+ structured: {
151
+ symbols: FOR_AI_STRUCTURED.some(([, d]) => d.endsWith('-symbols.json')) ? `${slug}-symbols.json` : null,
152
+ depGraph: FOR_AI_STRUCTURED.some(([, d]) => d.endsWith('-dep-graph.json')) ? `${slug}-dep-graph.json` : null,
153
+ entrypoints: FOR_AI_STRUCTURED.some(([, d]) => d.endsWith('-entrypoints.json')) ? `${slug}-entrypoints.json` : null,
154
+ note: 'Exact symbol/dependency/entrypoint lookups (AI-comprehension recipe). Use alongside ask-kb semantic search.',
155
+ },
156
+ studio: studioFiles.length
157
+ ? {
158
+ audio: audioEntry?.[1] || null,
159
+ report: reportEntry?.[1] || null,
160
+ notebookUrl: studioLinks.notebookUrl || null,
161
+ videoUrl: studioLinks.videoUrl || null,
162
+ note: 'NotebookLM media pack rides inside for-humans/studio/.',
163
+ }
164
+ : { note: 'Studio-less build (INV-03): the core pack ships now; NotebookLM media follows up later if produced.' },
165
+ description: `${slug} drop-in knowledge pack. Unzip, npm i in for-ai/, point .mcp.json at kb-mcp-server.mjs, add CLAUDE.md gate. See README.md.`,
166
+ };
167
+
168
+ // ---- README.md ----
169
+ const studioBlock = studioFiles.length
170
+ ? `## Studio (NotebookLM)
171
+ The for-humans/studio/ media rides **inside** this zip${audioEntry ? ` — start with the audio: \`for-humans/studio/${path.basename(audioEntry[1])}\`` : ''}.
172
+ ${studioLinks.notebookUrl ? `\n- **Open the full NotebookLM studio (public):** ${studioLinks.notebookUrl}` : ''}${studioLinks.videoUrl ? `\n- **Watch the video overview:** ${studioLinks.videoUrl}` : ''}
173
+ `
174
+ : `## Studio (NotebookLM)
175
+ This is a **studio-less** build (INV-03): the searchable AI pack ships now; NotebookLM audio/report
176
+ media follow up later if produced. Nothing here depends on them.
177
+ `;
178
+
179
+ const readme = `# ${slug} Drop-in
180
+
181
+ One zip. Two halves: a for-humans primer and a for-ai searchable knowledge base.
182
+
183
+ ## What's inside
184
+
185
+ \`\`\`
186
+ ${slug}-dropin/
187
+ for-humans/ — read first
188
+ ${slug}-primer.md — top-down orientation: what it is, why, how it works
189
+ for-ai/ — the searchable knowledge pack (wire this into your agent)
190
+ ${slug}-kb.rvf — single 384-dim bge-small vector DB (the "brain")
191
+ ${slug}-kb.passages.jsonl — full passage text (${passageCount} passages; search returns TEXT, not {id,distance})
192
+ ${slug}-kb.ids.json — id → passage index
193
+ ${slug}-symbols.json — exact public API: every symbol + signature + doc + location
194
+ ${slug}-dep-graph.json — component dependency graph (what depends on what)
195
+ ${slug}-entrypoints.json — build/test/run/install commands + binaries
196
+ ask-kb.mjs · kb-mcp-server.mjs · kb.config.mjs · resolve-deps.mjs · package.json
197
+ README.md — this file
198
+ manifest.json — build metadata
199
+ \`\`\`
200
+
201
+ ${studioBlock}
202
+ ## Three steps to wire the AI half in
203
+ \`\`\`bash
204
+ # 1 — unzip + install (two deps, Node 18+)
205
+ unzip ${slug}-knowledge-pack.zip && cd ${slug}-dropin/for-ai && npm install
206
+
207
+ # 2 — ask the brain a question straight away
208
+ node ask-kb.mjs ${slug} "what does ${slug} actually do"
209
+
210
+ # 3 — point your AI host at it with a .mcp.json in your project root:
211
+ # { "mcpServers": { "${slug}-kb": { "command": "node", "args": ["for-ai/kb-mcp-server.mjs"] } } }
212
+ \`\`\`
213
+
214
+ Built with the Repo-Primer recipe (ADR-0001 / ADR-0005). Embedder: ${manifest.embedder}.
215
+ `;
216
+
217
+ // ---- stage + zip (into <build-dir>/site/) ----
218
+ const siteDir = path.join(buildRoot, 'site');
219
+ fs.mkdirSync(siteDir, { recursive: true });
220
+ const OUT = path.join(siteDir, `${slug}-knowledge-pack.zip`);
221
+
222
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), `pack-${slug}-`));
223
+ const copy = (src, dst) => {
224
+ const d = path.join(stage, dst);
225
+ fs.mkdirSync(path.dirname(d), { recursive: true });
226
+ fs.copyFileSync(src, d);
227
+ };
228
+ try {
229
+ for (const [src, dst] of [...FOR_AI, ...FOR_AI_STRUCTURED, ...FOR_AI_TOOLS]) copy(src, dst);
230
+ copy(primerSrc, `for-humans/${slug}-primer.md`);
231
+ for (const [src, dst] of studioFiles) copy(src, dst);
232
+ fs.writeFileSync(path.join(stage, 'for-ai/package.json'), JSON.stringify(forAiPkg, null, 2) + '\n');
233
+ fs.writeFileSync(path.join(stage, 'README.md'), readme);
234
+ fs.writeFileSync(path.join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
235
+
236
+ fs.rmSync(OUT, { force: true });
237
+ // zip's progress goes to stderr (NOT stdout — stdout is the JSON result channel).
238
+ execFileSync('zip', ['-r', '-X', OUT, '.'], { cwd: stage, stdio: ['ignore', process.stderr, process.stderr] });
239
+ } finally {
240
+ fs.rmSync(stage, { recursive: true, force: true });
241
+ }
242
+
243
+ // ---- verify the pack OPENS and carries the KB artifacts (loud-fail postcondition, never a silent green) ----
244
+ let listing = '';
245
+ try { listing = execFileSync('unzip', ['-l', OUT], { encoding: 'utf8' }); }
246
+ catch (e) { throw new Error(`pack does not open: unzip -l failed on ${path.relative(ROOT, OUT)} (${e.message})`); }
247
+ const requiredEntries = [
248
+ `for-ai/${slug}-kb.rvf`,
249
+ `for-ai/${slug}-kb.passages.jsonl`,
250
+ `for-ai/ask-kb.mjs`,
251
+ `for-ai/kb-mcp-server.mjs`,
252
+ `for-humans/${slug}-primer.md`,
253
+ ];
254
+ const missingInZip = requiredEntries.filter((e) => !listing.includes(e));
255
+ if (missingInZip.length) {
256
+ throw new Error(`pack is missing required entries after zip: ${missingInZip.join(', ')}`);
257
+ }
258
+
259
+ const forAiNames = [...FOR_AI, ...FOR_AI_STRUCTURED, ...FOR_AI_TOOLS]
260
+ .map(([, d]) => d.replace('for-ai/', '')).concat(['package.json']);
261
+ const forHumansNames = [`${slug}-primer.md`, ...studioFiles.map(([, d]) => d.replace('for-humans/', ''))];
262
+
263
+ // ---- merge ONLY the `pack` slot back into build.json ----
264
+ const pack = {
265
+ zipPath: OUT,
266
+ forAi: forAiNames,
267
+ forHumans: forHumansNames,
268
+ opens: true, // verified: unzip -l succeeded and every required entry is present
269
+ kbLoads: true, // verified: non-zero .rvf + .passages (>0) shipped — the KB's load prerequisites
270
+ };
271
+ ctx.pack = pack;
272
+ fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n');
273
+
274
+ const sizeMb = (fs.statSync(OUT).size / 1e6).toFixed(2);
275
+ log(`built ${path.relative(ROOT, OUT)} (${sizeMb} MB) — ${passageCount} passages, ${forAiNames.length} for-ai files`);
276
+
277
+ return { zipPath: OUT, pack, passageCount, sizeMb };
278
+ }
279
+
280
+ // ---- uniform invocation + return convention ----
281
+ function main() {
282
+ const buildDir = process.argv[2];
283
+ if (!buildDir) throw new Error('usage: node tools/make-pack.mjs <build-dir>');
284
+ return build(buildDir);
285
+ }
286
+
287
+ try {
288
+ const outputs = main();
289
+ process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n');
290
+ process.exit(0);
291
+ } catch (err) {
292
+ process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error: err.message }) + '\n');
293
+ log(err.stack || err.message);
294
+ process.exit(1);
295
+ }
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // make-social-card.mjs — Station 5 (BRAND): the designed 1200×630 OG / Twitter social card.
3
+ //
4
+ // Conforms to tools/CONTRACT.md (the load-bearing anti-brittleness anchor):
5
+ // • Invoked uniformly: node tools/make-social-card.mjs <build-dir> (one positional arg).
6
+ // • PURE — reads ONLY its declared inputs from <build-dir>/build.json, writes ONLY its outputs.
7
+ // • FAIL LOUD — any problem prints { ok:false, … } to stdout AND exits non-zero. Never a silent
8
+ // placeholder / default card (INV-04, Never-Fail-Silently).
9
+ // • Merges ONLY its own slot (brand.socialCard) back into build.json; every other slot untouched.
10
+ // • stdout carries ONLY the single JSON result object; all diagnostics go to stderr.
11
+ //
12
+ // Declared inputs (read from build.json):
13
+ // visuals.hero.file REQUIRED — the hero raster; used full-bleed as the card's brand backdrop.
14
+ // concept.tagline REQUIRED — the one line baked into the card (= og:description).
15
+ // concept.palette REQUIRED — needs ≥1 colour-like value; drives the legibility scrim + text.
16
+ // understanding.repoName the display name baked in as the kicker (the JOB's "repo name");
17
+ // (or repo.name) declared here so the card can satisfy "repo name + tagline baked in".
18
+ // Best-effort: if neither is present the card ships with the tagline alone.
19
+ //
20
+ // Outputs:
21
+ // brand.socialCard slot { px:"1200x630", file:"<…>/assets/social-card.png", tagline:"…" }
22
+ // <build-dir>/assets/social-card.png (exactly 1200×630, OG / Twitter summary_large_image)
23
+ //
24
+ // Mechanics: shells out to ImageMagick (`magick`, v7 — `convert` v6 fallback), matching the kb/
25
+ // engine's system-binary style (kb/make-dropin.mjs → `zip`). ImageMagick bakes the wrapped tagline +
26
+ // kicker straight into the PNG (no browser, no npm install).
27
+ //
28
+ // Idempotent: re-running overwrites social-card.png + the brand.socialCard slot; it never appends.
29
+
30
+ import { execFileSync } from 'node:child_process';
31
+ import fs from 'node:fs';
32
+ import os from 'node:os';
33
+ import path from 'node:path';
34
+
35
+ const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
36
+ const RGB = /^rgba?\(/i;
37
+ const CARD_W = 1200, CARD_H = 630, PAD = 64;
38
+ const FONT_CANDIDATES = [
39
+ '/System/Library/Fonts/Supplemental/Arial.ttf',
40
+ '/System/Library/Fonts/Helvetica.ttc',
41
+ '/System/Library/Fonts/HelveticaNeue.ttc',
42
+ '/System/Library/Fonts/Avenir.ttc',
43
+ '/Library/Fonts/Arial.ttf',
44
+ ];
45
+
46
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
47
+ function log(msg) { process.stderr.write(`[make-social-card] ${msg}\n`); }
48
+
49
+ function findBin() {
50
+ for (const b of ['magick', 'convert']) {
51
+ try { execFileSync(b, ['-version'], { stdio: 'ignore' }); return b; } catch { /* try next */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function magick(bin, args) {
57
+ try {
58
+ execFileSync(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
59
+ } catch (e) {
60
+ const detail = (e.stderr ? e.stderr.toString() : e.message || '').trim().split('\n').slice(-3).join(' ');
61
+ throw new Error(`ImageMagick failed (${bin} ${args.join(' ')}): ${detail || 'non-zero exit'}`);
62
+ }
63
+ }
64
+
65
+ function pickFont() { for (const f of FONT_CANDIDATES) { if (fs.existsSync(f)) return f; } return null; }
66
+
67
+ // Collect colour-like strings (hex / rgb()) from the open-shape palette, in author order.
68
+ function collectColors(v, out) {
69
+ if (out.length >= 6) return out;
70
+ if (typeof v === 'string') { const s = v.trim(); if (HEX.test(s) || RGB.test(s)) out.push(s); }
71
+ else if (Array.isArray(v)) { for (const x of v) collectColors(x, out); }
72
+ else if (v && typeof v === 'object') { for (const x of Object.values(v)) collectColors(x, out); }
73
+ return out;
74
+ }
75
+
76
+ function hexToRgb(h) {
77
+ let s = h.replace('#', '');
78
+ if (s.length === 3 || s.length === 4) s = s.slice(0, 3).split('').map((c) => c + c).join('');
79
+ if (s.length < 6) return null;
80
+ return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
81
+ }
82
+
83
+ function readCtx(buildDir) {
84
+ const bj = path.join(buildDir, 'build.json');
85
+ if (!fs.existsSync(bj)) throw new Error(`build.json not found in build dir: ${bj}`);
86
+ try { return JSON.parse(fs.readFileSync(bj, 'utf8')); }
87
+ catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
88
+ }
89
+
90
+ function resolveHero(buildDir, ctx) {
91
+ const f = ctx?.visuals?.hero?.file;
92
+ if (!f || typeof f !== 'string') {
93
+ throw new Error('visuals.hero.file is missing in build.json — generate the hero (Station 4) first');
94
+ }
95
+ const p = path.isAbsolute(f) ? f : path.resolve(buildDir, f);
96
+ if (!fs.existsSync(p)) throw new Error(`hero image not found on disk: ${p}`);
97
+ return p;
98
+ }
99
+
100
+ // Re-read fresh and merge only brand.socialCard, to minimise the window vs the parallel make-favicon.
101
+ function mergeSocialCard(buildDir, slot) {
102
+ const bj = path.join(buildDir, 'build.json');
103
+ const ctx = JSON.parse(fs.readFileSync(bj, 'utf8'));
104
+ ctx.brand = (ctx.brand && typeof ctx.brand === 'object') ? ctx.brand : {};
105
+ ctx.brand.socialCard = slot;
106
+ fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n');
107
+ }
108
+
109
+ function main() {
110
+ const buildDir = process.argv[2];
111
+ if (!buildDir) {
112
+ emit({ ok: false, outputs: {}, error: 'usage: node tools/make-social-card.mjs <build-dir>' });
113
+ log('usage: node tools/make-social-card.mjs <build-dir>');
114
+ process.exit(2);
115
+ }
116
+ let stage = null;
117
+ try {
118
+ const bin = findBin();
119
+ if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH');
120
+
121
+ const ctx = readCtx(buildDir);
122
+ const hero = resolveHero(buildDir, ctx);
123
+
124
+ const tagline = ctx?.concept?.tagline;
125
+ if (typeof tagline !== 'string' || !tagline.trim()) {
126
+ throw new Error('concept.tagline is missing/empty in build.json — the card requires an authored tagline');
127
+ }
128
+
129
+ const colors = collectColors(ctx?.concept?.palette ?? {}, []);
130
+ if (colors.length === 0) {
131
+ throw new Error('concept.palette has no usable colour (need ≥1 hex or rgb() value) — cannot brand the card');
132
+ }
133
+ const base = colors[0];
134
+ const accent = colors[1] || colors[0];
135
+
136
+ // Legibility scrim: transparent at top (hero shows) → opaque brand colour at the bottom (text sits).
137
+ const rgb = hexToRgb(base);
138
+ const scrim = rgb
139
+ ? `gradient:rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)-rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.95)`
140
+ : `gradient:none-${base}`;
141
+ // Tagline colour: contrast against the (near-opaque base) bottom of the scrim.
142
+ const textColor = rgb && (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) > 150 ? '#0a0a0a' : '#ffffff';
143
+
144
+ const repoName = (typeof ctx?.understanding?.repoName === 'string' && ctx.understanding.repoName.trim())
145
+ ? ctx.understanding.repoName.trim()
146
+ : (typeof ctx?.repo?.name === 'string' && ctx.repo.name.trim() ? ctx.repo.name.trim() : null);
147
+
148
+ const font = pickFont();
149
+ const assets = path.join(buildDir, 'assets');
150
+ fs.mkdirSync(assets, { recursive: true });
151
+ stage = fs.mkdtempSync(path.join(os.tmpdir(), 'social-card-'));
152
+ const baseImg = path.join(stage, 'base.png');
153
+ const tagImg = path.join(stage, 'tag.png');
154
+ const out = path.join(assets, 'social-card.png');
155
+
156
+ // 1) Hero full-bleed cover + scrim, with the repo-name kicker baked into the top-left.
157
+ const baseArgs = [hero, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`,
158
+ '(', '-size', `${CARD_W}x${CARD_H}`, scrim, ')', '-composite'];
159
+ if (repoName) {
160
+ baseArgs.push('-gravity', 'NorthWest', '-fill', accent);
161
+ if (font) baseArgs.push('-font', font);
162
+ baseArgs.push('-pointsize', '34', '-annotate', `+${PAD}+54`, repoName);
163
+ }
164
+ baseArgs.push(baseImg);
165
+ magick(bin, baseArgs);
166
+
167
+ // 2) The tagline as a wrapped caption layer (auto-wrapped at the text column width).
168
+ const tagArgs = ['-background', 'none', '-fill', textColor];
169
+ if (font) tagArgs.push('-font', font);
170
+ tagArgs.push('-size', `${CARD_W - PAD * 2}x`, '-gravity', 'West', '-pointsize', '58', `caption:${tagline}`, tagImg);
171
+ magick(bin, tagArgs);
172
+
173
+ // 3) Composite the tagline into the lower-left and write the final card.
174
+ magick(bin, [baseImg, tagImg, '-gravity', 'SouthWest', '-geometry', `+${PAD}+72`, '-composite', out]);
175
+
176
+ if (!fs.existsSync(out) || fs.statSync(out).size === 0) throw new Error(`social card not written: ${out}`);
177
+ // `magick identify …` (v7) vs the standalone `identify` (v6).
178
+ const dims = (bin === 'magick'
179
+ ? execFileSync(bin, ['identify', '-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })
180
+ : execFileSync('identify', ['-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })).toString().trim();
181
+ if (dims !== `${CARD_W}x${CARD_H}`) throw new Error(`social card is ${dims}, expected ${CARD_W}x${CARD_H}`);
182
+
183
+ const slot = { px: `${CARD_W}x${CARD_H}`, file: out, tagline };
184
+ mergeSocialCard(buildDir, slot);
185
+
186
+ log(`wrote ${dims} social card to ${out}`);
187
+ emit({ ok: true, outputs: { file: out, px: slot.px, slot: 'brand.socialCard', merged: slot }, error: null });
188
+ process.exit(0);
189
+ } catch (e) {
190
+ emit({ ok: false, outputs: {}, error: e.message });
191
+ log(`FAILED: ${e.message}`);
192
+ process.exit(1);
193
+ } finally {
194
+ if (stage) { try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } }
195
+ }
196
+ }
197
+
198
+ main();