@golden-frijoles/kit 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/one-pagers.mjs +466 -0
  2. package/package.json +1 -1
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env node
2
+ // one-pagers.mjs — three sheets a maker can consult in seconds, derived from the strategy files (coaches-v2 D10).
3
+ //
4
+ // node scripts/one-pagers.mjs render into Roadmap/00-strategy/one-pagers/ (.html and .md each)
5
+ // node scripts/one-pagers.mjs --out <dir> …somewhere else
6
+ //
7
+ // The sheets: a business model canvas, a value proposition sheet and a persona poster. The Strategy gate's Approve
8
+ // renders them, each coach renders them after its last write, and this command renders them on demand.
9
+ //
10
+ // ── Derived, never typed ──────────────────────────────────────────────────────────────────────────────────────────
11
+ // Every line on a sheet comes from a strategy file; nothing here holds strategy. A sheet typed by hand drifts from the
12
+ // narrative the day after it is printed, and then two documents disagree about the product (the 2026-10-05 reference
13
+ // sheets were transcribed, and needed a revision the next morning). A field the files don't fill says "Not written
14
+ // yet" rather than being guessed.
15
+ //
16
+ // ── Honest labels ─────────────────────────────────────────────────────────────────────────────────────────────────
17
+ // Each claim shows `true today`, `aspirational`, `agreed`, `sourced` or `hypothesis`, taken from the `(…)` label the
18
+ // coach wrote at the end of the line. A persona line with NO label renders as `hypothesis`: an unlabelled line is an
19
+ // unchecked one, and the poster must never present an anecdote as a researched frustration (dogfood F34). A sheet
20
+ // whose source file is still `status: draft` is watermarked "Draft".
21
+ //
22
+ // ── Licences ──────────────────────────────────────────────────────────────────────────────────────────────────────
23
+ // The Business Model Canvas is Strategyzer AG's, licensed CC BY-SA 3.0: usable with "Strategyzer.com" visible under
24
+ // it, which the canvas carries. The Value Proposition Canvas is NOT licensed for software, so the value proposition
25
+ // sheet is our own layout (the customer's outcome, motivation and gaps against the product's promise) and never uses
26
+ // its name or shape.
27
+ //
28
+ // Exit codes: 0 rendered, or nothing to render yet (said in one line) · 2 usage or could not write.
29
+ // Zero deps — Node 18+.
30
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { join, resolve } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { projectRoot } from './lib/project-root.mjs';
34
+ import { ONE_PAGERS_DIR, STRATEGY_DIR, parseFrontmatter, section } from './lib/strategy-files.mjs';
35
+
36
+ /** The persona block's fields, in the order the narrative template lists them under `### Persona`. */
37
+ export const PERSONA_FIELDS = [
38
+ 'Role',
39
+ 'Stage',
40
+ 'Background',
41
+ 'Goals',
42
+ 'Frustrations',
43
+ 'Drivers',
44
+ 'Moments they reach for it',
45
+ 'Where to find them',
46
+ 'Words to use',
47
+ 'Words to avoid',
48
+ 'Not this persona',
49
+ ];
50
+
51
+ export const LABELS = ['true today', 'aspirational', 'agreed', 'sourced', 'hypothesis'];
52
+ export const NOT_WRITTEN = 'Not written yet';
53
+ export const BMC_CREDIT = 'The Business Model Canvas by Strategyzer.com, licensed CC BY-SA 3.0.';
54
+
55
+ /** Split a trailing `(label)` or `(sourced: <where>)` off a claim. An unknown or missing label is `null`. */
56
+ export function splitLabel(raw) {
57
+ const text = String(raw ?? '').trim();
58
+ const m = text.match(
59
+ // The source may hold a markdown link or a URL with its own parentheses; a trailing full stop is allowed.
60
+ /\s*\(\s*(true today|aspirational|agreed|hypothesis|sourced)(?:\s*:\s*((?:[^()]|\([^()]*\))*))?\s*\)\s*\.?\s*$/i
61
+ );
62
+ if (!m) return { text, label: null, source: null };
63
+ return { text: text.slice(0, m.index).trim(), label: m[1].toLowerCase(), source: m[2]?.trim() || null };
64
+ }
65
+
66
+ /** A value the coach has not written: missing, or still the template's `<…>` placeholder. */
67
+ const unwritten = (v) => !v || /^<[^>]*>(?:\s*\([^)]*\))?$/.test(v.trim()); // a placeholder, maybe with its label hint
68
+ /** A value with any `<…>` placeholder fragment removed: half-filled template text never reaches a sheet. */
69
+ const fill = (v) =>
70
+ String(v ?? '')
71
+ .replace(/<[^>]*>/g, '')
72
+ .replace(/\s{2,}/g, ' ')
73
+ .trim();
74
+
75
+ /** `**Label:** value` lines inside `body`, by label. */
76
+ export function labelledLines(body) {
77
+ const out = {};
78
+ const lines = String(body ?? '').split('\n');
79
+ const LABEL = /^\s*(?:[-*]\s+)?\*\*([^*]+?):\*\*\s*(.*)$/;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const m = lines[i].match(LABEL);
82
+ if (!m) continue;
83
+ const parts = [m[2].trim()];
84
+ const items = [];
85
+ // A value may wrap onto following lines, or be a list of sub-bullets under an empty label: both are written
86
+ // content, and dropping them would print "Not written yet" over something the maker wrote (review of #316).
87
+ for (let k = i + 1; k < lines.length; k++) {
88
+ const next = lines[k];
89
+ if (!next.trim() || LABEL.test(next) || /^#{1,6} /.test(next)) break;
90
+ const bullet = next.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/);
91
+ if (bullet && (!parts[0] || /^\s/.test(next))) items.push(bullet[1].trim());
92
+ else if (!bullet && !items.length) parts.push(next.trim());
93
+ else break;
94
+ }
95
+ const raw = [parts.join(' ').trim(), ...items.filter((x) => !unwritten(x))].filter(
96
+ (x) => x && !unwritten(x)
97
+ );
98
+ const value = fill(raw.join(' · '));
99
+ if (!unwritten(value)) out[m[1].trim()] = value;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** The `- ` bullets directly under a `**Label:**` line (Gaps:), or every top-level bullet when no label is given. */
105
+ export function bullets(body, after = null) {
106
+ const lines = String(body ?? '').split('\n');
107
+ let start = 0;
108
+ if (after) {
109
+ start = lines.findIndex((l) => new RegExp(`^\\s*\\*\\*${after}:\\*\\*`).test(l));
110
+ if (start === -1) return [];
111
+ start += 1;
112
+ }
113
+ const out = [];
114
+ for (const line of lines.slice(start)) {
115
+ if (after && /^\s*\*\*[^*]+:\*\*/.test(line)) break;
116
+ const m = line.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/);
117
+ // `- **Fast:** drafts next week` is a benefit too: keep it, as "Fast: drafts next week".
118
+ // Placeholder check on the RAW text, before fill(): `<benefit> (true today | aspirational)` must not survive as
119
+ // a benefit called "(true today | aspirational)" (review of #316, round 2).
120
+ if (!m || unwritten(m[1])) continue;
121
+ const item = fill(m[1].replace(/^\*\*([^*]+?):\*\*\s*/, '$1: '));
122
+ if (item && !unwritten(item)) out.push(item);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** The body of `### <heading>` inside an h2 section's text. */
128
+ function subsection(body, heading) {
129
+ const lines = String(body ?? '').split('\n');
130
+ const start = lines.findIndex((l) => l.trim() === `### ${heading}`);
131
+ if (start === -1) return null;
132
+ const end = lines.findIndex((l, i) => i > start && /^#{2,3} /.test(l));
133
+ return lines.slice(start + 1, end === -1 ? undefined : end).join('\n');
134
+ }
135
+
136
+ /** A claim with its label; a persona line without one is a hypothesis (F34). */
137
+ function claim(raw, { defaultLabel = null } = {}) {
138
+ const c = splitLabel(raw);
139
+ return { ...c, label: c.label ?? defaultLabel };
140
+ }
141
+
142
+ /** Pure — everything the three sheets show, read from the three files' text (any may be null). */
143
+ export function readStrategy({ narrative = null, northStar = null, risk = null } = {}) {
144
+ const n = narrative ?? '';
145
+ const fm = parseFrontmatter(n);
146
+ const problem = section(n, 'Problem to solve') ?? '';
147
+ const audience = section(n, 'Target audience') ?? '';
148
+ const vp = section(n, 'Value proposition') ?? '';
149
+ const adv = section(n, 'Competitive advantage') ?? '';
150
+ const growth = section(n, 'Growth strategy') ?? '';
151
+ const bm = section(n, 'Business model') ?? '';
152
+ const personaBody = subsection(audience, 'Persona');
153
+ const personaLines = labelledLines(personaBody);
154
+ const p = labelledLines(problem);
155
+ const a = labelledLines(audience);
156
+ const v = labelledLines(vp);
157
+ const c = labelledLines(adv);
158
+ const g = labelledLines(growth);
159
+ const b = labelledLines(bm);
160
+
161
+ const ns = northStar ?? '';
162
+ const metricLine = (section(ns, 'North Star metric') ?? '')
163
+ .split('\n')
164
+ .find((l) => l.trim() && !l.startsWith('_'));
165
+ const metric = metricLine?.match(/^\*\*([^*]+)\*\*\s*:?\s*(.*)$/);
166
+
167
+ const r = risk ?? '';
168
+ const domino = labelledLines(section(r, 'Highest domino'));
169
+
170
+ // A claim the coach left unlabelled still shows what it is (review of #316): the maker approved it at the Strategy
171
+ // gate when the file is agreed; until then it is unchecked. Persona lines stay hypothesis either way (F34).
172
+ const N = { defaultLabel: fm.status === 'agreed' ? 'agreed' : 'hypothesis' };
173
+ const benefits = bullets(vp).map((x) => claim(x, N));
174
+ return {
175
+ product: (n.match(/^# .*?—\s*(.+)$/m)?.[1] ?? '').replace(/^<.*>$/, '').trim() || null,
176
+ status: {
177
+ narrative: narrative === null ? null : fm.status || 'draft',
178
+ northStar: northStar === null ? null : parseFrontmatter(ns).status || 'draft',
179
+ risk: risk === null ? null : parseFrontmatter(r).status || 'draft',
180
+ },
181
+ persona:
182
+ personaBody === null
183
+ ? null
184
+ : PERSONA_FIELDS.map((f) => ({
185
+ field: f,
186
+ ...(personaLines[f]
187
+ ? claim(personaLines[f], { defaultLabel: 'hypothesis' })
188
+ : { text: null, label: null }),
189
+ })),
190
+ outcome: p.Outcome ? claim(p.Outcome, N) : null,
191
+ motivation: p.Motivation ? claim(p.Motivation, N) : null,
192
+ gaps: bullets(problem, 'Gaps').map((x) => claim(x, N)),
193
+ segmentsNow: a.Now ? claim(a.Now, N) : null,
194
+ segmentsFuture: a.Future ? claim(a.Future, N) : null,
195
+ tagline: v.Tagline ? claim(v.Tagline, N) : null,
196
+ benefits,
197
+ shortTerm: c['Short term'] ? claim(c['Short term'], N) : null,
198
+ longTerm: c['Long term'] ? claim(c['Long term'], N) : null,
199
+ channelsNow: g.Now ? claim(g.Now, N) : null,
200
+ channelsLater: g.Later ? claim(g.Later, N) : null,
201
+ revenue: b.Revenue ? claim(b.Revenue, N) : null,
202
+ pricing: b.Pricing ? claim(b.Pricing, N) : null,
203
+ costs: b.Costs ? claim(b.Costs, N) : null,
204
+ partners: b['Key partners'] ? claim(b['Key partners'], N) : null,
205
+ activities: b['Key activities'] ? claim(b['Key activities'], N) : null,
206
+ resources: b['Key resources'] ? claim(b['Key resources'], N) : null,
207
+ relationships: b['Customer relationships'] ? claim(b['Customer relationships'], N) : null,
208
+ northStar:
209
+ metric && !unwritten(metric[1])
210
+ ? { name: metric[1].replace(/:\s*$/, '').trim(), definition: metric[2].trim() }
211
+ : null,
212
+ notClaimedYet: [
213
+ ...(domino.Hypothesis
214
+ ? [
215
+ {
216
+ text: domino.Hypothesis,
217
+ label: 'hypothesis',
218
+ source: null,
219
+ why: `riskiest assumption${domino.Dimension ? ` (${domino.Dimension})` : ''}`,
220
+ },
221
+ ]
222
+ : []),
223
+ ...benefits
224
+ .filter((x) => x.label === 'aspirational')
225
+ .map((x) => ({ ...x, why: 'aspirational benefit' })),
226
+ ],
227
+ };
228
+ }
229
+
230
+ // ── Rendering ─────────────────────────────────────────────────────────────────────────────────────────────────────
231
+
232
+ const esc = (s) =>
233
+ String(s).replace(
234
+ /[&<>"']/g,
235
+ (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]
236
+ );
237
+
238
+ function chipHtml(c) {
239
+ if (!c?.label) return '';
240
+ const cls = c.label === 'hypothesis' || c.label === 'aspirational' ? 'hyp' : 'ok';
241
+ const title = c.source ? ` title="${esc(c.source)}"` : '';
242
+ return ` <span class="chip ${cls}"${title}>${esc(c.label)}</span>`;
243
+ }
244
+ const chipMd = (c) => (c?.label ? ` _(${c.label}${c.source ? `: ${c.source}` : ''})_` : '');
245
+
246
+ const lineHtml = (c) =>
247
+ c?.text ? `${esc(c.text)}${chipHtml(c)}` : `<span class="muted">${NOT_WRITTEN}</span>`;
248
+ const lineMd = (c) => (c?.text ? `${c.text}${chipMd(c)}` : `_${NOT_WRITTEN}_`);
249
+ const listHtml = (cs) =>
250
+ cs.length
251
+ ? `<ul>${cs.map((c) => `<li>${lineHtml(c)}</li>`).join('')}</ul>`
252
+ : `<p class="muted">${NOT_WRITTEN}</p>`;
253
+ const listMd = (cs) => (cs.length ? cs.map((c) => `- ${lineMd(c)}`).join('\n') : `_${NOT_WRITTEN}_`);
254
+
255
+ const CSS = `:root{--bg:#f3f3ef;--paper:#fff;--ink:#1a1916;--muted:#66625a;--line:#d8d5cc;--gold:#9c7000;--gold-bg:#fbf1d3;
256
+ --hyp:#7a4a00;--hyp-bg:#fff6e0;--ok:#2b6b3a;--ok-bg:#e3f2e6;color-scheme:light}
257
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#11100e;--paper:#1a1916;--ink:#ecebe5;--muted:#a29e94;
258
+ --line:#2f2d29;--gold:#e5b53d;--gold-bg:rgba(229,181,61,.14);--hyp:#f0b35a;--hyp-bg:rgba(240,179,90,.12);--ok:#86d196;--ok-bg:rgba(134,209,150,.12);color-scheme:dark}}
259
+ :root[data-theme="dark"]{--bg:#11100e;--paper:#1a1916;--ink:#ecebe5;--muted:#a29e94;--line:#2f2d29;--gold:#e5b53d;
260
+ --gold-bg:rgba(229,181,61,.14);--hyp:#f0b35a;--hyp-bg:rgba(240,179,90,.12);--ok:#86d196;--ok-bg:rgba(134,209,150,.12);color-scheme:dark}
261
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px 16px}
262
+ .sheet{position:relative;max-width:1240px;margin:0 auto;background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:24px 26px;overflow:hidden}
263
+ header{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 24px;border-bottom:2px solid var(--ink);padding-bottom:10px;margin-bottom:16px}
264
+ h1{font:800 28px/1.1 system-ui,sans-serif;margin:0}.kicker{display:block;font:600 11px system-ui;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
265
+ h2{font:700 12px system-ui;text-transform:uppercase;letter-spacing:.1em;margin:0 0 8px}
266
+ ul{margin:0;padding-left:16px}li{margin:0 0 5px}p{margin:0 0 6px}.muted{color:var(--muted)}
267
+ .chip{display:inline-block;font:600 10.5px system-ui;letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:99px;vertical-align:1px}
268
+ .chip.ok{background:var(--ok-bg);color:var(--ok)}.chip.hyp{background:var(--hyp-bg);color:var(--hyp);border:1px dashed var(--hyp)}
269
+ .grid{display:grid;gap:12px}.box{border:1px solid var(--line);border-radius:6px;padding:12px 14px}
270
+ .bmc{grid-template-columns:repeat(5,1fr);grid-template-rows:auto auto auto}
271
+ .bmc .kp{grid-row:1/3}.bmc .vp{grid-row:1/3;grid-column:3}.bmc .cs{grid-row:1/3;grid-column:5}
272
+ .bmc .ka{grid-column:2}.bmc .kr{grid-column:2;grid-row:2}.bmc .cr{grid-column:4}.bmc .ch{grid-column:4;grid-row:2}
273
+ .bmc .co{grid-column:1/3;grid-row:3}.bmc .rs{grid-column:3/6;grid-row:3}
274
+ .two{grid-template-columns:1fr 1fr}.persona{grid-template-columns:repeat(3,1fr)}
275
+ .lead{font-size:18px;margin:0 0 12px}.ns{border:2px solid var(--gold);background:var(--gold-bg)}.ns b{color:var(--gold);font-size:18px;display:block}
276
+ footer{margin-top:16px;padding-top:8px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted);display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 18px}
277
+ .draft{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;font:900 140px system-ui;color:var(--hyp);opacity:.09;transform:rotate(-18deg)}
278
+ @media screen and (max-width:820px){.bmc,.two,.persona{grid-template-columns:1fr}.bmc>*{grid-column:auto!important;grid-row:auto!important}}
279
+ @media print{body{background:#fff;padding:0}.sheet{border:0;max-width:none;padding:8mm}@page{size:A4 landscape;margin:8mm}}`;
280
+
281
+ function page({ title, kicker, body, draft, sources, footer = '' }) {
282
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
283
+ <title>${esc(title)}</title><style>${CSS}</style></head><body><main class="sheet">${draft ? '<div class="draft" aria-hidden="true">Draft</div>' : ''}
284
+ <header><div><span class="kicker">${esc(kicker)}${draft ? ' · draft' : ''}</span><h1>${esc(title)}</h1></div>
285
+ <div class="muted">From ${esc(sources)}.</div></header>
286
+ ${body}
287
+ <footer>${footer}<span>Rendered from the strategy files by one-pagers.mjs. Never edit this sheet; edit the file and render again.</span></footer></main></body></html>
288
+ `;
289
+ }
290
+
291
+ const isDraft = (...statuses) => statuses.some((s) => s && s !== 'agreed');
292
+ const named = (s, kind) => `${s.product ? `${s.product}: ` : ''}${kind}`;
293
+
294
+ /** Pure — the business model canvas, as { html, md }. */
295
+ export function renderCanvas(s) {
296
+ const box = (cls, title, inner) => `<section class="box ${cls}"><h2>${title}</h2>${inner}</section>`;
297
+ const one = (c) => `<p>${lineHtml(c)}</p>`;
298
+ const segments = [
299
+ s.segmentsNow && { ...s.segmentsNow, text: `Now: ${s.segmentsNow.text}` },
300
+ s.segmentsFuture && { ...s.segmentsFuture, text: `Later: ${s.segmentsFuture.text}` },
301
+ ].filter(Boolean);
302
+ const channels = [
303
+ s.channelsNow && { ...s.channelsNow, text: `Now: ${s.channelsNow.text}` },
304
+ s.channelsLater && { ...s.channelsLater, text: `Later: ${s.channelsLater.text}` },
305
+ ].filter(Boolean);
306
+ const value = [s.tagline, ...s.benefits].filter(Boolean);
307
+ const revenue = [s.revenue, s.pricing].filter(Boolean);
308
+ const draft = isDraft(s.status.narrative);
309
+ const html = page({
310
+ title: named(s, 'Business model canvas'),
311
+ kicker: 'Strategy one-pager',
312
+ draft,
313
+ sources: 'pmf-narrative.md',
314
+ body: `<div class="grid bmc">${box('kp', 'Key partners', one(s.partners))}${box('ka', 'Key activities', one(s.activities))}${box('kr', 'Key resources', one(s.resources))}${box('vp', 'Value propositions', listHtml(value))}${box('cr', 'Customer relationships', one(s.relationships))}${box('ch', 'Channels', listHtml(channels))}${box('cs', 'Customer segments', listHtml(segments))}${box('co', 'Cost structure', one(s.costs))}${box('rs', 'Revenue streams', listHtml(revenue))}</div>`,
315
+ footer: `<span>${esc(BMC_CREDIT)}</span>`,
316
+ });
317
+ const md = [
318
+ `# ${named(s, 'Business model canvas')}${draft ? ' (draft)' : ''}`,
319
+ '',
320
+ ...[
321
+ ['Key partners', lineMd(s.partners)],
322
+ ['Key activities', lineMd(s.activities)],
323
+ ['Key resources', lineMd(s.resources)],
324
+ ['Value propositions', listMd(value)],
325
+ ['Customer relationships', lineMd(s.relationships)],
326
+ ['Channels', listMd(channels)],
327
+ ['Customer segments', listMd(segments)],
328
+ ['Cost structure', lineMd(s.costs)],
329
+ ['Revenue streams', listMd(revenue)],
330
+ ].flatMap(([h, v]) => [`## ${h}`, '', v, '']),
331
+ `_${BMC_CREDIT}_`,
332
+ '',
333
+ ].join('\n');
334
+ return { html, md };
335
+ }
336
+
337
+ /** Pure — the value proposition sheet (our own layout, never the reserved canvas), as { html, md }. */
338
+ export function renderValueSheet(s) {
339
+ const draft = isDraft(s.status.narrative, s.status.northStar, s.status.risk);
340
+ const ns = s.northStar
341
+ ? `<section class="box ns"><h2>North Star</h2><b>${esc(s.northStar.name)}</b><p>${esc(s.northStar.definition)}</p></section>`
342
+ : `<section class="box"><h2>North Star</h2><p class="muted">${NOT_WRITTEN}</p></section>`;
343
+ const html = page({
344
+ title: named(s, 'Value proposition sheet'),
345
+ kicker: 'Strategy one-pager',
346
+ draft,
347
+ sources: 'pmf-narrative.md · north-star.md · risk-validation.md',
348
+ body: `<p class="lead"><strong>${lineHtml(s.tagline)}</strong></p>
349
+ <div class="grid two"><section class="box"><h2>What the customer is after</h2><p><strong>Outcome:</strong> ${lineHtml(s.outcome)}</p><p><strong>Why:</strong> ${lineHtml(s.motivation)}</p><h2>What stands in the way</h2>${listHtml(s.gaps)}</section>
350
+ <section class="box"><h2>What the product promises</h2>${listHtml(s.benefits)}<h2>Why it wins</h2><p><strong>Today:</strong> ${lineHtml(s.shortTerm)}</p><p><strong>Over time:</strong> ${lineHtml(s.longTerm)}</p></section></div>
351
+ <div class="grid two" style="margin-top:12px">${ns}<section class="box"><h2>Not claimed yet</h2>${s.notClaimedYet.length ? `<ul>${s.notClaimedYet.map((c) => `<li>${esc(c.text)}${chipHtml(c)} <span class="muted">(${esc(c.why)})</span></li>`).join('')}</ul>` : '<p class="muted">Nothing marked as untested.</p>'}</section></div>`,
352
+ });
353
+ const md = [
354
+ `# ${named(s, 'Value proposition sheet')}${draft ? ' (draft)' : ''}`,
355
+ '',
356
+ `**${lineMd(s.tagline)}**`,
357
+ '',
358
+ '## What the customer is after',
359
+ '',
360
+ `- **Outcome:** ${lineMd(s.outcome)}`,
361
+ `- **Why:** ${lineMd(s.motivation)}`,
362
+ '',
363
+ '## What stands in the way',
364
+ '',
365
+ listMd(s.gaps),
366
+ '',
367
+ '## What the product promises',
368
+ '',
369
+ listMd(s.benefits),
370
+ '',
371
+ '## Why it wins',
372
+ '',
373
+ `- **Today:** ${lineMd(s.shortTerm)}`,
374
+ `- **Over time:** ${lineMd(s.longTerm)}`,
375
+ '',
376
+ '## North Star',
377
+ '',
378
+ s.northStar ? `**${s.northStar.name}**: ${s.northStar.definition}` : `_${NOT_WRITTEN}_`,
379
+ '',
380
+ '## Not claimed yet',
381
+ '',
382
+ s.notClaimedYet.length
383
+ ? s.notClaimedYet.map((c) => `- ${c.text}${chipMd(c)} (${c.why})`).join('\n')
384
+ : '_Nothing marked as untested._',
385
+ '',
386
+ ].join('\n');
387
+ return { html, md };
388
+ }
389
+
390
+ /** Pure — the persona poster, as { html, md }. Every line carries a label; an unlabelled one reads hypothesis. */
391
+ export function renderPersona(s) {
392
+ const draft = isDraft(s.status.narrative);
393
+ const rows = s.persona ?? PERSONA_FIELDS.map((field) => ({ field, text: null, label: null }));
394
+ const role = rows.find((r) => r.field === 'Role');
395
+ const html = page({
396
+ title: named(s, `Persona${role?.text ? `: ${role.text}` : ''}`),
397
+ kicker: 'Strategy one-pager',
398
+ draft,
399
+ sources: 'pmf-narrative.md (Target audience → Persona)',
400
+ body: `<div class="grid persona">${rows
401
+ .map((r) => `<section class="box"><h2>${esc(r.field)}</h2><p>${lineHtml(r)}</p></section>`)
402
+ .join('')}</div>`,
403
+ footer:
404
+ '<span><span class="chip ok">sourced</span> / <span class="chip ok">agreed</span> = checked or confirmed · <span class="chip hyp">hypothesis</span> = not checked yet</span>',
405
+ });
406
+ const md = [
407
+ `# ${named(s, `Persona${role?.text ? `: ${role.text}` : ''}`)}${draft ? ' (draft)' : ''}`,
408
+ '',
409
+ ...rows.flatMap((r) => [`## ${r.field}`, '', lineMd(r), '']),
410
+ ].join('\n');
411
+ return { html, md };
412
+ }
413
+
414
+ export const SHEETS = {
415
+ 'business-model-canvas': renderCanvas,
416
+ 'value-proposition-sheet': renderValueSheet,
417
+ 'persona-poster': renderPersona,
418
+ };
419
+
420
+ function readOrNull(path) {
421
+ try {
422
+ return readFileSync(path, 'utf8');
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function main(argv) {
429
+ const root = projectRoot();
430
+ const i = argv.indexOf('--out');
431
+ if (i !== -1 && !argv[i + 1]) {
432
+ process.stderr.write('usage: one-pagers.mjs [--out <dir>]\n');
433
+ return 2;
434
+ }
435
+ const out = i !== -1 ? resolve(argv[i + 1]) : join(root, ONE_PAGERS_DIR);
436
+ const file = (k) => readOrNull(join(root, STRATEGY_DIR, `${k}.md`));
437
+ const narrative = file('pmf-narrative');
438
+ if (narrative === null) {
439
+ process.stdout.write(
440
+ `one-pagers: nothing to render yet — the sheets come from ${STRATEGY_DIR}/pmf-narrative.md, which doesn't exist.\n`
441
+ );
442
+ return 0;
443
+ }
444
+ const s = readStrategy({ narrative, northStar: file('north-star'), risk: file('risk-validation') });
445
+ try {
446
+ mkdirSync(out, { recursive: true });
447
+ const written = [];
448
+ for (const [name, render] of Object.entries(SHEETS)) {
449
+ const { html, md } = render(s);
450
+ writeFileSync(join(out, `${name}.html`), html);
451
+ writeFileSync(join(out, `${name}.md`), md);
452
+ written.push(`${name}.html`);
453
+ }
454
+ const draft = isDraft(s.status.narrative, s.status.northStar, s.status.risk);
455
+ process.stdout.write(
456
+ `one-pagers: wrote ${written.join(', ')} (+ .md) to ${out}${draft ? ' — marked draft until the Strategy gate approves the files' : ''}.\n`
457
+ );
458
+ return 0;
459
+ } catch (e) {
460
+ process.stderr.write(`one-pagers: could not write to ${out}: ${e.message}\n`);
461
+ return 2;
462
+ }
463
+ }
464
+
465
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
466
+ process.exitCode = main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {