@golden-frijoles/kit 0.40.0 → 0.42.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.
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ // cold-read.mjs — the deterministic half of the `cold-read` skill: the brief, the run, the seal and the compare
3
+ // (coaches-v2 D2–D4).
4
+ //
5
+ // node scripts/cold-read.mjs brief [--out <file>] print the agent's brief (the exclusion list is in it); --out
6
+ // tells a same-family agent to write the read there and reply
7
+ // only `written`, so the read never reaches the facilitator
8
+ // node scripts/cold-read.mjs run run the brief on Codex, write + seal the read; exit 3 = no
9
+ // other model family reachable (the skill then runs it itself)
10
+ // node scripts/cold-read.mjs seal <file> record the read's sha256 beside it, as <file>.sha256; refuses
11
+ // a read missing its reading log, contamination or riskiest assumption
12
+ // node scripts/cold-read.mjs verify <file> exit 1 when the read changed after it was sealed
13
+ // node scripts/cold-read.mjs compare <file> [--expect <hash>]
14
+ // verify (and match the hash the maker was shown), then write
15
+ // the compare skeleton next to it
16
+ //
17
+ // ── Why a seal ────────────────────────────────────────────────────────────────────────────────────────────────────
18
+ // The cold read is worth something only if nobody edited it after the coaches started: a read quietly "corrected"
19
+ // towards the coached answers would turn every disagreement into agreement. So the read is sealed when written, the
20
+ // hash is printed to the maker, and the compare refuses to start while the file no longer matches it. A seal is
21
+ // never replaced: a read worth changing is a new read, with a new date.
22
+ //
23
+ // ── Why another model family ──────────────────────────────────────────────────────────────────────────────────────
24
+ // Two runs of one model agree for reasons that have nothing to do with the product. `run` asks Codex (another family)
25
+ // when it is installed and signed in; otherwise it says so and exits 3, and the skill writes the read with a
26
+ // same-family agent and records `family: claude (same family)`, so the compare can discount the agreement.
27
+ //
28
+ // Exit codes: 0 ok · 1 refused (hash changed, not sealed, already sealed differently, file exists) · 2 usage or
29
+ // could not read · 3 no other family reachable.
30
+ //
31
+ // Zero deps — Node 18+.
32
+ import { createHash } from 'node:crypto';
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { basename, dirname, join, relative, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { hasCmd, tryCodex } from './lib/cross-agent-cli.mjs';
37
+ import { kitRoot, projectRoot } from './lib/project-root.mjs';
38
+ import {
39
+ COLD_READ_DIR,
40
+ KINDS,
41
+ STRATEGY_DIR,
42
+ headings,
43
+ parseFrontmatter,
44
+ proposedSections,
45
+ section,
46
+ } from './lib/strategy-files.mjs';
47
+
48
+ export const PROMPT_FILE = 'cold-read.prompt.md';
49
+
50
+ export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
51
+
52
+ /** `shasum -a 256` format, so `shasum -a 256 -c <file>.sha256` checks it with no tool of ours. */
53
+ export const sealLine = (hash, file) => `${hash} ${basename(file)}\n`;
54
+
55
+ /** The hash in a seal file, or null when it is not one. */
56
+ export function parseSealLine(text) {
57
+ const match = String(text).match(/^([0-9a-f]{64}) {2}\S/);
58
+ return match ? match[1] : null;
59
+ }
60
+
61
+ /** Pure — the verdict on a read against its seal. */
62
+ export function verifySeal({ text, seal }) {
63
+ if (seal === null || seal === undefined)
64
+ return { ok: false, reason: 'not sealed: no .sha256 file beside it' };
65
+ const sealed = parseSealLine(seal);
66
+ if (!sealed) return { ok: false, reason: 'the .sha256 file is not a seal (want "<64 hex> <name>")' };
67
+ const now = sha256(text);
68
+ if (now !== sealed) return { ok: false, reason: `hash changed: sealed ${sealed}, now ${now}`, sealed, now };
69
+ return { ok: true, sealed };
70
+ }
71
+
72
+ /** Today in local time, YYYY-MM-DD — the date a maker would write on the file. */
73
+ export function localDate(d = new Date()) {
74
+ const pad = (n) => String(n).padStart(2, '0');
75
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
76
+ }
77
+
78
+ /**
79
+ * The frontmatter `run` puts on a read the other family wrote, so the file says who wrote it. `kind`, `family` and
80
+ * `date` are ALWAYS ours: a read that arrives with its own frontmatter keeps its other keys, never its own family —
81
+ * otherwise a Codex read that wrote `kind: cold-read` alone would be sealed with no family, and its compare would lose
82
+ * the very independence it was run for (review of #314).
83
+ */
84
+ export function stampRead(body, { family, date }) {
85
+ let text = String(body).trim();
86
+ const kept = [];
87
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
88
+ if (fm) {
89
+ kept.push(...fm[1].split(/\r?\n/).filter((l) => l.trim() && !/^(kind|family|date)\s*:/.test(l)));
90
+ text = text.slice(fm[0].length).trim();
91
+ }
92
+ return `---\nkind: cold-read\nfamily: ${family}\ndate: ${date}\n${kept.map((l) => `${l}\n`).join('')}---\n\n${text}\n`;
93
+ }
94
+
95
+ /**
96
+ * The eleven sections `cold-read.prompt.md` asks for, matched by a word or two so a numbered or lightly reworded
97
+ * heading ("## 2. Reading log", "## Who it's for") still counts. Keep in step with the prompt; the spec reads both.
98
+ */
99
+ export const REQUIRED_SECTIONS = [
100
+ ['Header', /header/i],
101
+ ['Reading log', /reading log/i],
102
+ ['What this product is', /what this product/i],
103
+ ['Who it is for', /who it(?: i|')s for/i],
104
+ ["The problem, in the customer's words", /problem/i],
105
+ ['Market and competitors', /competitor|market/i],
106
+ ['Positioning', /positioning/i],
107
+ ['Growth and business model', /growth|business model/i],
108
+ ['Riskiest assumption and the cheapest test', /riskiest assumption/i],
109
+ ['Confidence and open questions', /confidence|open questions/i],
110
+ ['Sources', /sources/i],
111
+ ];
112
+
113
+ /**
114
+ * Pure — the mandatory parts a read must have before it is sealed (D2, sprint-1 acceptance): a reading log that
115
+ * discloses contamination, and the riskiest assumption with its cheapest test. Checked HERE because nobody else can:
116
+ * the facilitator must not open the read before the compare. Headings may be numbered ("## 2. Reading log").
117
+ */
118
+ export function missingSections(text) {
119
+ const all = headings(text);
120
+ // A heading with nothing under it is a missing section. Whether the words under it are any good is the compare's
121
+ // judgement, not a script's: this only refuses the read that plainly isn't finished.
122
+ const filled = (h) => (section(text, h) ?? '').trim() !== '';
123
+ const missing = REQUIRED_SECTIONS.filter(([, re]) => !all.some((h) => re.test(h) && filled(h))).map(
124
+ ([name]) => name
125
+ );
126
+ const log = all.find((h) => /reading log/i.test(h));
127
+ // The disclosure has to be more than the word: a "Contamination" line followed by something.
128
+ if (log && !/contamination\W*\s*\S+/i.test((section(text, log) ?? '').replace(/contamination\W*$/i, '')))
129
+ missing.push('Contamination (inside the reading log)');
130
+ return missing;
131
+ }
132
+
133
+ /** Where the brief tells the agent to put the read: back to the caller (Codex), or into a file (a subagent). */
134
+ export function deliveryTail(out) {
135
+ if (!out) return '\n## Delivery\n\nReply with the document only. Change no file.\n';
136
+ return `\n## Delivery\n\nWrite the document to \`${out}\`, opening with this frontmatter, then change no other file:\n\n\`\`\`\n---\nkind: cold-read\nfamily: claude (same family)\ndate: <today>\n---\n\`\`\`\n\nThen reply with the single word \`written\`. Do not repeat or summarise the read in your reply: the person you reply to must not see it yet.\n`;
137
+ }
138
+
139
+ /**
140
+ * Pure — the compare skeleton (D4). Its sections are the reference compare's; the rows are the facilitator's to fill.
141
+ * `coached` is `[{ path, text }]` for each strategy file that exists. A section a coach marked proposed is listed by
142
+ * name as facilitator-authored, read from the file, so the compare never treats the coach's words as the maker's.
143
+ */
144
+ export function renderCompare({ readPath, hash, family, date, coached, expected = false }) {
145
+ const independence = !family
146
+ ? '- **Model family not recorded.** Treat agreement as weaker evidence than it looks.'
147
+ : /same family|claude/i.test(family)
148
+ ? '- **Same model family.** The cold read and the coach are the same family, so agreement is weaker evidence than it looks, and disagreement is stronger.'
149
+ : `- **Different model family** (${family}). Agreement counts as independent evidence.`;
150
+ const authored = coached.flatMap(({ path, text }) =>
151
+ proposedSections(text).map(
152
+ (h) => `- \`${path}\` → **${h}**: written by the coach, not decided by the maker.`
153
+ )
154
+ );
155
+ const fm = [
156
+ '---',
157
+ 'kind: cold-read-compare',
158
+ 'status: draft',
159
+ `updated: ${date}`,
160
+ `cold_read: ${readPath}`,
161
+ `cold_read_sha256: ${hash}`,
162
+ `family: ${family || 'not recorded'}`,
163
+ `coached: [${coached.map((c) => c.path).join(', ')}]`,
164
+ '---',
165
+ ];
166
+ return [
167
+ ...fm,
168
+ '',
169
+ "# Cold read vs coached: what agreed, what didn't, what each missed",
170
+ '',
171
+ ...(expected
172
+ ? [
173
+ `**Seal check (${date}):** verified. The cold read matches its seal (sha256 \`${hash}\`), and that seal is the`,
174
+ 'one the maker was shown when it was sealed.',
175
+ ]
176
+ : [
177
+ `**Seal check (${date}): UNVERIFIED.** The cold read matches the seal beside it (sha256 \`${hash}\`), but no hash`,
178
+ 'from sealing time was checked, and a seal file can be replaced. Until the maker confirms this hash against the one',
179
+ 'they were shown, treat this compare as unverified.',
180
+ ]),
181
+ '',
182
+ '**How independent were they?**',
183
+ independence,
184
+ ...(authored.length
185
+ ? [
186
+ '- **Facilitator-authored sections** (treat these rows as coach vs cold read, not maker vs cold read):',
187
+ ...authored.map((l) => ` ${l}`),
188
+ ]
189
+ : [
190
+ '- **Facilitator-authored sections:** none marked. Coaches mark a section they wrote for the maker from plugin 0.42.0; a file written before that may still hold one, so check by hand.',
191
+ ]),
192
+ "- **The cold read's own contamination:** copy its *Contamination* paragraph here.",
193
+ '',
194
+ '---',
195
+ '',
196
+ '## 1. Where they converged',
197
+ '',
198
+ '| Topic | Cold read | Coached | Read |',
199
+ '|---|---|---|---|',
200
+ '',
201
+ '## 2. Where they diverged',
202
+ '',
203
+ '| Topic | Cold read | Coached | Which is stronger, and why |',
204
+ '|---|---|---|---|',
205
+ '',
206
+ '## 3. What only the cold read saw',
207
+ '',
208
+ '## 4. What only the coached run saw',
209
+ '',
210
+ '## 5. Decisions for the maker',
211
+ '',
212
+ '<Numbered, one decision each. An edit to an agreed strategy file is a decision, never a silent change.>',
213
+ '',
214
+ '## 6. Did the cold read earn its place?',
215
+ '',
216
+ '<What it changed, what it cost, and whether to run one next time.>',
217
+ '',
218
+ ].join('\n');
219
+ }
220
+
221
+ function readOrNull(path) {
222
+ try {
223
+ return readFileSync(path, 'utf8');
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ function seal(file) {
230
+ const text = readOrNull(file);
231
+ if (text === null) return { code: 2, err: `cold-read: could not read ${file}` };
232
+ const missing = missingSections(text);
233
+ if (missing.length)
234
+ return {
235
+ code: 1,
236
+ err: `cold-read: not sealed — ${file} is missing: ${missing.join('; ')}. Ask the agent that wrote it to finish it.`,
237
+ };
238
+ const hash = sha256(text);
239
+ const sealPath = `${file}.sha256`;
240
+ const existing = readOrNull(sealPath);
241
+ if (existing !== null) {
242
+ const was = parseSealLine(existing);
243
+ if (was === hash) return { code: 0, out: `cold-read: already sealed, unchanged. sha256 ${hash}` };
244
+ return {
245
+ code: 1,
246
+ err: `cold-read: ${file} is already sealed as ${was ?? '(unreadable seal)'}; a seal is never replaced. A changed read is a new read: write it under a new name.`,
247
+ };
248
+ }
249
+ writeFileSync(sealPath, sealLine(hash, file));
250
+ return {
251
+ code: 0,
252
+ out: `cold-read: sealed ${file}\nsha256 ${hash}\nKeep this hash: the compare refuses the file if it changes.`,
253
+ };
254
+ }
255
+
256
+ function verify(file) {
257
+ const text = readOrNull(file);
258
+ if (text === null) return { code: 2, err: `cold-read: could not read ${file}` };
259
+ const v = verifySeal({ text, seal: readOrNull(`${file}.sha256`) });
260
+ if (!v.ok) return { code: 1, err: `cold-read: refused — ${file}: ${v.reason}` };
261
+ return { code: 0, out: `cold-read: seal holds. sha256 ${v.sealed}` };
262
+ }
263
+
264
+ function compare(file, root, expect) {
265
+ const text = readOrNull(file);
266
+ if (text === null) return { code: 2, err: `cold-read: could not read ${file}` };
267
+ const v = verifySeal({ text, seal: readOrNull(`${file}.sha256`) });
268
+ if (!v.ok) return { code: 1, err: `cold-read: refused — ${file}: ${v.reason}` };
269
+ // The seal sits beside the read, so whoever can edit the read can replace the seal too. The hash the maker was
270
+ // shown at sealing time is the check a swapped sidecar cannot pass (review of #314).
271
+ // At least 12 hex characters: a shorter prefix could be matched by grinding a few edits of the read.
272
+ if (expect !== null && (!/^[0-9a-f]{12,64}$/i.test(expect) || !v.sealed.startsWith(expect.toLowerCase())))
273
+ return {
274
+ code: 1,
275
+ err: `cold-read: refused — the seal beside ${file} is ${v.sealed}, not the ${expect || '(empty)'} you were shown when it was sealed. The seal was replaced.`,
276
+ };
277
+ const coached = KINDS.map((k) => join(STRATEGY_DIR, `${k}.md`))
278
+ .map((rel) => ({ path: rel.split('\\').join('/'), text: readOrNull(join(root, rel)) }))
279
+ .filter((c) => c.text !== null);
280
+ const date = localDate();
281
+ // Named after its read, not the day: two reads sealed and compared on one day each get their own compare.
282
+ const out = join(dirname(file), `${basename(file).replace(/\.md$/, '')}-compare.md`);
283
+ if (existsSync(out))
284
+ return { code: 1, err: `cold-read: ${out} exists; edit it rather than starting again.` };
285
+ writeFileSync(
286
+ out,
287
+ renderCompare({
288
+ readPath: relative(root, file).split('\\').join('/'),
289
+ hash: v.sealed,
290
+ family: parseFrontmatter(text).family,
291
+ date,
292
+ coached,
293
+ expected: expect !== null,
294
+ })
295
+ );
296
+ return {
297
+ code: 0,
298
+ out:
299
+ expect !== null
300
+ ? `cold-read: seal verified (${v.sealed}).\nWrote ${out}: fill each section from the two runs.`
301
+ : `cold-read: UNVERIFIED — the read matches the seal beside it (${v.sealed}), but no hash from sealing time was given (--expect). Ask the maker to confirm this hash.\nWrote ${out}, marked unverified.`,
302
+ };
303
+ }
304
+
305
+ function run(root) {
306
+ const brief = readFileSync(join(kitRoot(), PROMPT_FILE), 'utf8') + deliveryTail(null);
307
+ const date = localDate();
308
+ const dir = join(root, COLD_READ_DIR);
309
+ const file = join(dir, `${date}-cold-read.md`);
310
+ // Before the Codex call, not after: a second run on the same day must not spend a whole read to then discard it.
311
+ if (existsSync(file))
312
+ return { code: 1, err: `cold-read: ${file} exists; a sealed read is never replaced.` };
313
+ if (!hasCmd('codex')) {
314
+ return {
315
+ code: 3,
316
+ err: 'cold-read: no other model family reachable (codex is not installed). Run the brief with a same-family agent and record `family: claude (same family)`.',
317
+ };
318
+ }
319
+ // Codex reads the repository it is started in. Under `gf-kit --root <dir>` that is not this process's cwd, so move
320
+ // there first: otherwise it reads one repo and the read is sealed into another's strategy folder (review of #314).
321
+ process.chdir(root);
322
+ const r = tryCodex(brief, '');
323
+ if (!r.ok || !r.text) {
324
+ const why = r.capped
325
+ ? 'codex is at its usage cap'
326
+ : r.authFailed
327
+ ? 'codex is signed out'
328
+ : r.cliOutdated
329
+ ? 'codex is out of date'
330
+ : 'codex failed';
331
+ return {
332
+ code: 3,
333
+ err: `cold-read: no other model family reachable (${why}). Run the brief with a same-family agent and record \`family: claude (same family)\`.`,
334
+ };
335
+ }
336
+ const read = stampRead(r.text, { family: 'codex', date });
337
+ // Checked BEFORE anything is written: an unfinished read on disk would block every retry today (the same-day guard
338
+ // above), and nobody may open it to see why (review of #314, round 2). Only a sealable read ever lands.
339
+ const missing = missingSections(read);
340
+ if (missing.length)
341
+ return {
342
+ code: 1,
343
+ err: `cold-read: Codex returned an unfinished read (missing: ${missing.join('; ')}); nothing was written. Run it again, or use a same-family agent (exit 3's route).`,
344
+ };
345
+ mkdirSync(dir, { recursive: true });
346
+ writeFileSync(file, read);
347
+ const s = seal(file);
348
+ return { code: s.code, out: `cold-read: Codex wrote ${file}.\n${s.out ?? ''}`, err: s.err };
349
+ }
350
+
351
+ function flag(argv, name) {
352
+ const i = argv.indexOf(name);
353
+ return i === -1 ? null : (argv[i + 1] ?? '');
354
+ }
355
+
356
+ function main(argv) {
357
+ const [cmd, file] = argv;
358
+ const root = projectRoot();
359
+ let r;
360
+ if (cmd === 'brief')
361
+ r = {
362
+ code: 0,
363
+ out: (readFileSync(join(kitRoot(), PROMPT_FILE), 'utf8') + deliveryTail(flag(argv, '--out'))).trimEnd(),
364
+ };
365
+ else if (cmd === 'run') r = run(root);
366
+ else if (['seal', 'verify', 'compare'].includes(cmd) && file) {
367
+ const path = resolve(file); // a path the maker typed is relative to where they typed it
368
+ r =
369
+ cmd === 'seal'
370
+ ? seal(path)
371
+ : cmd === 'verify'
372
+ ? verify(path)
373
+ : compare(path, root, flag(argv, '--expect'));
374
+ } else
375
+ r = {
376
+ code: 2,
377
+ err: 'usage: cold-read.mjs brief [--out <file>] | run | seal <file> | verify <file> | compare <file> [--expect <hash>]',
378
+ };
379
+ if (r.out) process.stdout.write(`${r.out}\n`);
380
+ if (r.err) process.stderr.write(`${r.err}\n`);
381
+ return r.code;
382
+ }
383
+
384
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
385
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,42 @@
1
+ You are writing a **cold read** of the product in this repository: an independent view of what it is, who it is for
2
+ and what is riskiest, written before its makers' own strategy work can shape yours. Someone will compare your read with
3
+ theirs later, so your independence is the whole value. Read the repository and write one Markdown document; *Delivery*, at
4
+ the end, says where it goes.
5
+
6
+ ## What you must not read
7
+
8
+ Skip these, and anything whose purpose is positioning, brand, messaging, landing copy or a strategy method:
9
+
10
+ - `Roadmap/00-strategy/` (all of it): the makers' own strategy and any earlier cold read
11
+ - any `pmf-narrative`, `north-star`, `risk-validation` or `cold-read` skill folder: the coaching methods
12
+ - `references/`, `brand/`, landing-page and marketing copy, positioning or messaging modules
13
+ - `.env*`, secrets, `node_modules/`, `.git/`
14
+
15
+ If you open something and then realise it is on this list, stop reading it and say so under *Contamination*.
16
+
17
+ ## What you may read
18
+
19
+ The README, `AGENTS.md`, the code, the product's roadmap and its shipped work, issues, and the public web (competitors,
20
+ analogs, current market facts). If you cannot reach the web, say so once and work from the repository.
21
+
22
+ ## The document
23
+
24
+ Write exactly these sections, in this order, as `##` headings:
25
+
26
+ 1. **Header**: today's date, and the line "Cold read, written without the strategy files or the coaching methods".
27
+ 2. **Reading log**: a table of what you read (files, depth); a table of what you skipped deliberately and why; and a
28
+ **Contamination** paragraph naming anything you saw that the list above excludes, even a summary line. This section
29
+ is mandatory: a read without it cannot be trusted.
30
+ 3. **What this product is**: one sentence, then the evidence from the code.
31
+ 4. **Who it is for**: the first customer, and later ones.
32
+ 5. **The problem, in the customer's words**: their need, not the product's features. Every claim cites a source or is
33
+ labelled *hypothesis*.
34
+ 6. **Market and competitors**: what they would use instead today, including doing nothing.
35
+ 7. **Positioning**: the category, and one line on how it differs.
36
+ 8. **Growth and business model**: the first channels, and how it could charge.
37
+ 9. **Riskiest assumption and the cheapest test**: in your own words, the one belief that sinks the rest if it is wrong,
38
+ and the cheapest test that would tell. This section is mandatory.
39
+ 10. **Confidence and open questions**.
40
+ 11. **Sources**: every link you used.
41
+
42
+ Plain, short sentences. Numbers over adjectives. Never invent a figure; "couldn't find" is an answer.
@@ -0,0 +1,57 @@
1
+ // strategy-files.mjs — where the strategy coaches' files live, and the few shapes every strategy script reads
2
+ // (coaches-v2 D1, D4, D7).
3
+ //
4
+ // groom's own reader (`skills/groom/strategy.mjs`) ships inside the plugin and cannot import from the kit, so it keeps
5
+ // its own STRATEGY_DIR and KINDS; `skills/scripts/strategy-templates.test.mjs` fails while the two disagree, so a
6
+ // rename lands in both or in neither.
7
+ //
8
+ // Zero deps — Node 18+.
9
+ import { join } from 'node:path';
10
+
11
+ export const STRATEGY_DIR = join('Roadmap', '00-strategy');
12
+ /** The three coached files, in the order the coaches run. */
13
+ export const KINDS = ['pmf-narrative', 'north-star', 'risk-validation'];
14
+ export const COLD_READ_DIR = join(STRATEGY_DIR, 'cold-read');
15
+ export const ONE_PAGERS_DIR = join(STRATEGY_DIR, 'one-pagers');
16
+
17
+ /**
18
+ * The line a coach puts first under a heading the maker handed over (D7). Until the maker picks, the section is the
19
+ * coach's work, not the maker's: the compare lists it as facilitator-authored and the Strategy gate asks about it.
20
+ */
21
+ export const PROPOSED_LINE = '_Proposed by the coach, not decided yet._';
22
+
23
+ /** Flat `key: value` frontmatter. `{}` when there is none. A value keeps any trailing `# comment` stripped. */
24
+ export function parseFrontmatter(text) {
25
+ const match = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
26
+ if (!match) return {};
27
+ const out = {};
28
+ for (const line of match[1].split(/\r?\n/)) {
29
+ const at = line.indexOf(':');
30
+ if (at > 0)
31
+ out[line.slice(0, at).trim()] = line
32
+ .slice(at + 1)
33
+ .replace(/\s+#.*$/, '')
34
+ .trim();
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** The body of `## <heading>`, up to the next `## `. Null when the heading is absent. */
40
+ export function section(text, heading) {
41
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
42
+ const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
43
+ if (start === -1) return null;
44
+ const end = lines.findIndex((line, i) => i > start && line.startsWith('## '));
45
+ return lines
46
+ .slice(start + 1, end === -1 ? undefined : end)
47
+ .join('\n')
48
+ .trim();
49
+ }
50
+
51
+ /** Every `## ` heading in order. */
52
+ export const headings = (text) => [...String(text).matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
53
+
54
+ /** The headings whose section opens with PROPOSED_LINE: the parts the coach wrote and the maker has not decided. */
55
+ export function proposedSections(text) {
56
+ return headings(text).filter((h) => (section(text, h) ?? '').split('\n')[0].trim() === PROPOSED_LINE);
57
+ }
@@ -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.40.0",
3
+ "version": "0.42.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": {