@golden-frijoles/kit 0.41.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.
- package/dist/one-pagers.mjs +466 -0
- package/dist/strategy-private.mjs +158 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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));
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// strategy-private.mjs — keep the maker's strategy out of a public repo unless they chose to commit it (coaches-v2 D9).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/strategy-private.mjs ensure decide, act at most once, print one line for the maker
|
|
5
|
+
// node scripts/strategy-private.mjs check the same decision, never writes
|
|
6
|
+
//
|
|
7
|
+
// ── Why ────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// The coaches write positioning, pricing and the riskiest assumption to `Roadmap/00-strategy/`. On a public repo the
|
|
9
|
+
// next `git add .` publishes them, and nothing asked (dogfood F2). So before a coach's first write this adds the
|
|
10
|
+
// folder to `.gitignore` when the repo is public, or when its visibility can't be read: "couldn't tell" must fail
|
|
11
|
+
// towards private, because the cost of a wrong guess is one-way.
|
|
12
|
+
//
|
|
13
|
+
// It never takes away a choice the maker already made:
|
|
14
|
+
// • a file under the folder is already committed → they commit strategy here; leave it
|
|
15
|
+
// • `.gitignore` says `!Roadmap/00-strategy/` → the explicit opt-in; leave it (a re-run must not undo it)
|
|
16
|
+
// • the folder is already ignored → nothing to do
|
|
17
|
+
//
|
|
18
|
+
// Exit codes: 0 decided (whether or not it wrote) · 2 usage.
|
|
19
|
+
// Zero deps — Node 18+.
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
25
|
+
import { STRATEGY_DIR } from './lib/strategy-files.mjs';
|
|
26
|
+
|
|
27
|
+
export const IGNORE_LINE = `${STRATEGY_DIR.split('\\').join('/')}/`;
|
|
28
|
+
export const OPT_IN_LINE = `!${IGNORE_LINE}`;
|
|
29
|
+
|
|
30
|
+
export const COMMENT_LINE = `# Strategy files are the maker's own. To commit them, change the next line to ${OPT_IN_LINE}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when `.gitignore` text carries an opt-in in any spelling git honours (`!Roadmap/00-strategy`, `!/…/`, `!…/**`),
|
|
34
|
+
* or when our own comment is there but the line under it was deleted: both are a maker saying "commit it", and a later
|
|
35
|
+
* coach must never undo either (review of #315). A negation in a NESTED .gitignore is caught by git itself in
|
|
36
|
+
* readFacts (`negatedBy`), not here.
|
|
37
|
+
*/
|
|
38
|
+
export function hasOptIn(gitignore) {
|
|
39
|
+
const lines = String(gitignore ?? '')
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((l) => l.trim());
|
|
42
|
+
const folder = IGNORE_LINE.replace(/\/$/, '');
|
|
43
|
+
const norm = (l) => l.replace(/^!\/?/, '').replace(/\/(\*\*?)?$/, '');
|
|
44
|
+
if (lines.some((l) => l.startsWith('!') && norm(l) === folder)) return true;
|
|
45
|
+
const at = lines.indexOf(COMMENT_LINE);
|
|
46
|
+
return at !== -1 && !lines.slice(at + 1).some((l) => !l.startsWith('!') && norm(l) === folder);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure — what to do. `visibility` is 'public' | 'private' | 'internal' | 'unknown'; `isRepo` false when there is no
|
|
51
|
+
* git repository at all. Returns { write: boolean, line: string }.
|
|
52
|
+
*/
|
|
53
|
+
export function decide({ isRepo, visibility, tracked, ignored, optedIn }) {
|
|
54
|
+
if (!isRepo)
|
|
55
|
+
return {
|
|
56
|
+
write: false,
|
|
57
|
+
line: 'Strategy files: not a git repository, so there is nothing to keep out of git.',
|
|
58
|
+
};
|
|
59
|
+
if (optedIn)
|
|
60
|
+
return {
|
|
61
|
+
write: false,
|
|
62
|
+
line: `Strategy files: you chose to commit them (\`${OPT_IN_LINE}\` in .gitignore).`,
|
|
63
|
+
};
|
|
64
|
+
if (tracked > 0)
|
|
65
|
+
return {
|
|
66
|
+
write: false,
|
|
67
|
+
line: `Strategy files: ${tracked} already committed here, so they stay committed. To stop, run \`git rm -r --cached ${IGNORE_LINE}\` and add \`${IGNORE_LINE}\` to .gitignore.`,
|
|
68
|
+
};
|
|
69
|
+
if (ignored) return { write: false, line: 'Strategy files: already kept out of git.' };
|
|
70
|
+
if (visibility === 'private' || visibility === 'internal')
|
|
71
|
+
return {
|
|
72
|
+
write: false,
|
|
73
|
+
line: `Strategy files: this repo is ${visibility}, so they can be committed with it.`,
|
|
74
|
+
};
|
|
75
|
+
const why = visibility === 'public' ? 'this repo is public' : "this repo's visibility couldn't be read";
|
|
76
|
+
return {
|
|
77
|
+
write: true,
|
|
78
|
+
line: `Strategy files: kept out of git because ${why} (added \`${IGNORE_LINE}\` to .gitignore). To commit them instead, delete that line or change it to \`${OPT_IN_LINE}\`.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pure — `.gitignore` with the folder appended, keeping whatever was there. */
|
|
83
|
+
export function appendIgnore(text) {
|
|
84
|
+
const base = String(text ?? '');
|
|
85
|
+
const sep = base === '' || base.endsWith('\n') ? '' : '\n';
|
|
86
|
+
return `${base}${sep}${COMMENT_LINE}\n${IGNORE_LINE}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run git against `root` only. A hook exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE, and git obeys them over `-C`:
|
|
91
|
+
* left in, a coach run from a hook would check the OUTER repo and could leave a public one unprotected (review of
|
|
92
|
+
* #315; LEARNINGS 2026-10-06 on hooks retargeting git).
|
|
93
|
+
*/
|
|
94
|
+
function git(root, args, env) {
|
|
95
|
+
const clean = { ...env };
|
|
96
|
+
delete clean.GIT_DIR;
|
|
97
|
+
delete clean.GIT_WORK_TREE;
|
|
98
|
+
delete clean.GIT_INDEX_FILE;
|
|
99
|
+
return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env: clean });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The facts `decide` needs, read from the repo. `gh` failing for any reason reads as 'unknown'. */
|
|
103
|
+
export function readFacts(root, { run = spawnSync, env = process.env } = {}) {
|
|
104
|
+
const isRepo = git(root, ['rev-parse', '--is-inside-work-tree'], env).stdout.trim() === 'true';
|
|
105
|
+
if (!isRepo) return { isRepo };
|
|
106
|
+
const gitignorePath = join(root, '.gitignore');
|
|
107
|
+
const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
108
|
+
const tracked = git(root, ['ls-files', '--', IGNORE_LINE], env).stdout.split('\n').filter(Boolean).length;
|
|
109
|
+
const probe = `${IGNORE_LINE}probe.md`;
|
|
110
|
+
const ignored = git(root, ['check-ignore', '-q', '--no-index', probe], env).status === 0;
|
|
111
|
+
// `-v -n` names the rule that decides the probe even when it is a negation: a `!` rule anywhere, in any
|
|
112
|
+
// .gitignore, is the maker un-ignoring the folder on purpose.
|
|
113
|
+
const negatedBy = git(root, ['check-ignore', '-v', '-n', '--no-index', probe], env).stdout.split('\t')[0];
|
|
114
|
+
// A nested `!` that re-includes the folder can leave git naming no rule at all; the root .gitignore listing the
|
|
115
|
+
// folder while git says it is NOT ignored means the same thing: something un-ignores it on purpose.
|
|
116
|
+
const folder = IGNORE_LINE.replace(/\/$/, '');
|
|
117
|
+
const listed = gitignore.split(/\r?\n/).some(
|
|
118
|
+
(l) =>
|
|
119
|
+
l
|
|
120
|
+
.trim()
|
|
121
|
+
.replace(/^\//, '')
|
|
122
|
+
.replace(/\/(\*\*?)?$/, '') === folder
|
|
123
|
+
);
|
|
124
|
+
const negated = /^[^:]+:\d+:!/.test(negatedBy) || (listed && !ignored);
|
|
125
|
+
const gh = run('gh', ['repo', 'view', '--json', 'visibility', '-q', '.visibility'], {
|
|
126
|
+
cwd: root,
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
});
|
|
129
|
+
const raw = gh.status === 0 ? String(gh.stdout).trim().toLowerCase() : '';
|
|
130
|
+
const visibility = ['public', 'private', 'internal'].includes(raw) ? raw : 'unknown';
|
|
131
|
+
return {
|
|
132
|
+
isRepo,
|
|
133
|
+
visibility,
|
|
134
|
+
tracked,
|
|
135
|
+
ignored,
|
|
136
|
+
optedIn: negated || hasOptIn(gitignore),
|
|
137
|
+
gitignorePath,
|
|
138
|
+
gitignore,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function main(argv) {
|
|
143
|
+
const cmd = argv[0];
|
|
144
|
+
if (cmd !== 'ensure' && cmd !== 'check') {
|
|
145
|
+
process.stderr.write('usage: strategy-private.mjs ensure | check\n');
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
const facts = readFacts(projectRoot());
|
|
149
|
+
const d = decide(facts);
|
|
150
|
+
if (d.write && cmd === 'ensure') writeFileSync(facts.gitignorePath, appendIgnore(facts.gitignore));
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
`${cmd === 'check' && d.write ? d.line.replace('kept out of git', 'would be kept out of git').replace('added', 'would add') : d.line}\n`
|
|
153
|
+
);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
|
|
158
|
+
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.
|
|
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": {
|