@golden-frijoles/kit 0.31.0 → 0.33.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.
@@ -52,7 +52,10 @@ import { fileURLToPath } from 'node:url';
52
52
  import { resolveTarget } from './build-state.mjs';
53
53
  import { buildRows } from './roadmap-extract.mjs';
54
54
  import { projectRoot } from './lib/project-root.mjs';
55
- import { formatScalar, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
55
+ import { parseDocFrontmatter } from './lib/roadmap-contract.mjs';
56
+ import { stampFrontmatter } from './lib/frontmatter-stamp.mjs';
57
+
58
+ export { stampFrontmatter };
56
59
  import { needSetting } from './lib/config.mjs';
57
60
  import { apiKeyFrom } from './roadmap-push.mjs';
58
61
  import { PRICES_AS_OF, PRICES_SOURCE, TOKEN_KINDS, tokensOf, usdOf } from './lib/model-prices.mjs';
@@ -728,25 +731,7 @@ export async function pushUsage({
728
731
 
729
732
  // ── Stamping actual_* (S1.4 backfill, S2.5 close) ───────────────────────────────────────────────────
730
733
 
731
- /**
732
- * Set `fields` in a doc's frontmatter: an existing `key:` line is replaced in place, a missing one is inserted just
733
- * before the closing fence. No other line is touched. Re-running with the same values is a no-op (idempotent).
734
- */
735
- export function stampFrontmatter(md, fields) {
736
- const lines = md.split('\n');
737
- if (lines[0].trim() !== '---') throw new Error('no frontmatter');
738
- const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
739
- if (end === -1) throw new Error('unterminated frontmatter');
740
- const missing = [];
741
- for (const [key, value] of Object.entries(fields)) {
742
- const line = `${key}: ${formatScalar(value)}`;
743
- const at = lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${key}:(\\s|$)`).test(l));
744
- if (at === -1) missing.push(line);
745
- else lines[at] = line;
746
- }
747
- lines.splice(end, 0, ...missing);
748
- return lines.join('\n');
749
- }
734
+ // stampFrontmatter moved to lib/frontmatter-stamp.mjs (result-record D8); re-exported above for existing callers.
750
735
 
751
736
  /** The three actual_* fields for an epic's report. */
752
737
  export function actualFields(report, basisPrefix, date) {
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ // epic-read.mjs — read an epic's result on its read date: draft the verdict with its evidence; write it on approval.
3
+ //
4
+ // node scripts/epic-read.mjs --epic <slug> # fetches the number through gf, drafts
5
+ // node scripts/epic-read.mjs --epic <slug> --experiment smart-defaults # … and cites the A/B decision record
6
+ // node scripts/epic-read.mjs --epic <slug> --actual 72 --evidence north-star:invoices_paid_on_time@2026-11-04
7
+ // node scripts/epic-read.mjs --epic <slug> --evidence "traffic too low (n = 18)" # no number: unclear
8
+ // node scripts/epic-read.mjs --epic <slug> --verdict proven --evidence https://… # an owner verdict
9
+ // … --write # the approval: stamp verdict_* into the README
10
+ // options: --project <slug> (gf's; default: the remembered one) · --today YYYY-MM-DD (default: today, UTC)
11
+ // · --repo-root <dir> · --json · GF_BIN=<path to gf> (default: gf on PATH)
12
+ //
13
+ // ── The shape (result-record D8) ─────────────────────────────────────────────────────────────────────────────────
14
+ // The agent does the legwork, the owner decides. Without `--write` this prints a draft and the exact command that
15
+ // approves it, and writes nothing. Inputs are flags, never a prompt: an agent asks the owner in the conversation and
16
+ // passes what they said.
17
+ // • Before the read date it says when the read is due and stops — the date is the README's `read_date`, or, when none
18
+ // was written, 30 days after shipping (lib/result-dates.mjs, the one place that rule lives).
19
+ // • The draft: from `target_from` toward `target_to`; reached or passed → proven, short of it → disproven; no number
20
+ // → unclear, with the reason in `--evidence`. `--verdict` is the owner's word over the draft, and is said as such.
21
+ // • Proven and disproven need evidence that points somewhere (the contract's grammar: an https:// link,
22
+ // `north-star:<input>@YYYY-MM-DD` or `ab:<experiment>`). `--write` runs the contract's own `validateResultFields` on
23
+ // the record it is about to write, so a verdict the contract refuses never lands.
24
+ // • More than 90 days after shipping, the read is still written, and said to be late (the extract marks it).
25
+ // • An epic shipped with no target can be read too — an owner verdict and its evidence, one epic per run.
26
+ //
27
+ // ── The agent fetches the number (result-record S3, D17) ─────────────────────────────────────────────────────────
28
+ // With a target and no `--actual`, this runs `gf north-star readings <target_metric> --to <today> --json` and takes
29
+ // `latest`: the actual is its value, the evidence `north-star:<input>@<its day>`. A reading dated before the epic shipped
30
+ // is no evidence for it. `--experiment <key>` also runs `gf experiments decision <key> --json`; a recorded decision
31
+ // becomes the evidence, `ab:<key>`. gf is spawned without a shell (`$GF_BIN`, else `gf` on PATH), so a shell alias of
32
+ // the same name never applies. `--actual` / `--evidence` still win: they are the owner's word. No gf, not signed in, no
33
+ // project chosen, or any failure is one "could not fetch (why)" line, and the read asks the owner as before — it never
34
+ // fails because the platform is not linked. Grounded is now "the readings route knows this input".
35
+
36
+ // Zero deps — Node 18+.
37
+ import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
38
+ import { join, resolve } from 'node:path';
39
+ import { spawnSync } from 'node:child_process';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { projectRoot } from './lib/project-root.mjs';
42
+ import {
43
+ VERDICTS,
44
+ isEvidencePointer,
45
+ parseDocFrontmatter,
46
+ validateResultFields,
47
+ } from './lib/roadmap-contract.mjs';
48
+ import { READ_CAP_DAYS, addDays, dayOf, isLate, readDateOf, todayUtc, isDay } from './lib/result-dates.mjs';
49
+ import { stampFrontmatter } from './lib/frontmatter-stamp.mjs';
50
+ import { buildRows } from './roadmap-extract.mjs';
51
+
52
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
53
+ /** `2026-11-04` → `4 Nov 2026`. */
54
+ export const longDay = (d) =>
55
+ `${Number(d.slice(8, 10))} ${MONTHS[Number(d.slice(5, 7)) - 1]} ${d.slice(0, 4)}`;
56
+
57
+ const fig = (n) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100));
58
+
59
+ /**
60
+ * The draft verdict for a target and a number read. Pure.
61
+ * From → to says the direction: a target above its start is met at or above it, one below at or below it.
62
+ */
63
+ export function draftVerdict({ from, to, actual }) {
64
+ if (actual === null || actual === undefined || !Number.isFinite(actual)) return 'unclear';
65
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return 'unclear';
66
+ const up = to > from;
67
+ return (up ? actual >= to : actual <= to) ? 'proven' : 'disproven';
68
+ }
69
+
70
+ /**
71
+ * Everything the read decides, from the README's frontmatter, the extract's row and the flags. Pure — no I/O.
72
+ * Returns `{ state, … }`: not-shipped · already-read · not-due · needs-input · refused · ready.
73
+ */
74
+ export function planRead({ fm, shippedAt, shipped, today, actual = null, evidence = null, verdict = null }) {
75
+ const target = {
76
+ hypothesis: fm.hypothesis ?? null,
77
+ metric: fm.target_metric ?? null,
78
+ from: typeof fm.target_from === 'number' ? fm.target_from : null,
79
+ to: typeof fm.target_to === 'number' ? fm.target_to : null,
80
+ };
81
+ if (fm.verdict)
82
+ return { state: 'already-read', target, verdict: fm.verdict, verdictAt: fm.verdict_at ?? null };
83
+ if (!shipped) return { state: 'not-shipped', target };
84
+ if (verdict !== null && !VERDICTS.includes(verdict))
85
+ return {
86
+ state: 'refused',
87
+ target,
88
+ reasons: [`--verdict must be one of ${VERDICTS.join(' | ')}, got "${verdict}"`],
89
+ };
90
+
91
+ const { readDate, derived } = readDateOf({
92
+ readDate: fm.read_date,
93
+ targetMetric: target.metric,
94
+ shippedAt,
95
+ });
96
+ if (target.metric && readDate && today < readDate) return { state: 'not-due', target, readDate, derived };
97
+
98
+ const owner = verdict !== null;
99
+ if (!target.metric && !owner)
100
+ return {
101
+ state: 'needs-input',
102
+ target,
103
+ need: 'This epic shipped with no target. An owner verdict is allowed, one epic at a time: --verdict <proven|disproven|unclear> --evidence <pointer, or for unclear the reason> (and --actual <n> for proven/disproven).',
104
+ };
105
+ const drafted = target.metric ? draftVerdict({ from: target.from, to: target.to, actual }) : null;
106
+ const chosen = owner ? verdict : drafted;
107
+ if (chosen === 'unclear' && !evidence)
108
+ return {
109
+ state: 'needs-input',
110
+ target,
111
+ drafted,
112
+ readDate,
113
+ derived,
114
+ need:
115
+ actual === null
116
+ ? `Ask the owner for the number read for ${target.metric ?? 'this epic'} (--actual <n>) and where it came from (--evidence <https://… | north-star:<input>@YYYY-MM-DD | ab:<experiment>>), or why it cannot be told yet (--evidence "<reason>").`
117
+ : 'Unclear needs the reason in --evidence (e.g. "traffic too low to tell (n = 18)").',
118
+ };
119
+ if ((chosen === 'proven' || chosen === 'disproven') && !evidence)
120
+ return {
121
+ state: 'needs-input',
122
+ target,
123
+ drafted,
124
+ readDate,
125
+ derived,
126
+ need: `${chosen} needs evidence that points somewhere: --evidence <https://… | north-star:<input>@YYYY-MM-DD | ab:<experiment>>.`,
127
+ };
128
+
129
+ const fields = {
130
+ verdict: chosen,
131
+ verdict_actual: actual === null || actual === undefined ? null : actual,
132
+ verdict_evidence: evidence,
133
+ verdict_at: today,
134
+ };
135
+ const offenses = validateResultFields({ ...fm, ...fields }).map((o) => o.detail);
136
+ const late = isLate({ verdictAt: today, shippedAt });
137
+ if (offenses.length) return { state: 'refused', target, drafted, fields, reasons: offenses, late };
138
+ return { state: 'ready', target, drafted, owner, fields, late, readDate, derived };
139
+ }
140
+
141
+ /** Run one `gf … --json` and read its single JSON document. Never throws; `why` says what went wrong in words. */
142
+ export function runGf(args, { env = process.env, spawnFn = spawnSync } = {}) {
143
+ const bin = env.GF_BIN || 'gf';
144
+ const res = spawnFn(bin, [...args, '--json'], { encoding: 'utf8', timeout: 30_000, env, shell: false });
145
+ if (res.error)
146
+ return {
147
+ ok: false,
148
+ why:
149
+ res.error.code === 'ENOENT'
150
+ ? 'gf is not installed (npm i -g @golden-frijoles/cli, then gf login)'
151
+ : `gf did not run: ${res.error.message}`,
152
+ };
153
+ let body = null;
154
+ try {
155
+ body = JSON.parse(res.stdout || 'null');
156
+ } catch {
157
+ body = null;
158
+ }
159
+ if (body && body.ok === true) return { ok: true, body };
160
+ if (res.status === 2) return { ok: false, code: 'unauthorized', why: 'gf is not signed in (run gf login)' };
161
+ const code = body?.code ?? null;
162
+ return { ok: false, code, why: body?.error ?? `gf exited ${res.status}` };
163
+ }
164
+
165
+ /**
166
+ * The number and its evidence, fetched through gf (D17). Pure apart from the injected `run`.
167
+ * Returns `{ fetched, actual?, evidence?, reading?, decision?, grounded, why? }`.
168
+ */
169
+ export function fetchEvidence({ metric, experiment = null, today, shippedAt, project = null, run }) {
170
+ const scope = project ? ['--project', project] : [];
171
+ const out = { fetched: false, grounded: null };
172
+ const readings = run(['north-star', 'readings', metric, '--to', today, ...scope]);
173
+ if (!readings.ok) {
174
+ // `not_found` is also what an unknown or foreign PROJECT answers; only the input's own sentence means "not grounded".
175
+ if (readings.code === 'not_found' && /No North Star input/.test(readings.why ?? '')) out.grounded = false;
176
+ out.why = readings.why;
177
+ } else {
178
+ out.grounded = true;
179
+ const latest = readings.body.latest;
180
+ const shipped = dayOf(shippedAt);
181
+ if (!latest) out.why = `no reading of ${metric} yet`;
182
+ else if (shipped && latest.date < shipped)
183
+ out.why = `no reading of ${metric} since it shipped (the latest is ${latest.date})`;
184
+ else {
185
+ out.fetched = true;
186
+ out.reading = latest;
187
+ out.actual = latest.value;
188
+ out.evidence = `north-star:${metric}@${latest.date}`;
189
+ }
190
+ }
191
+ if (experiment) {
192
+ const decision = run(['experiments', 'decision', experiment, ...scope]);
193
+ if (decision.ok && decision.body.decisions?.state === 'decided') {
194
+ out.decision = decision.body.decisions.current;
195
+ out.evidence = `ab:${experiment}`; // the decision record is the stronger pointer; the reading stays the actual
196
+ } else if (decision.ok) out.decisionWhy = `${experiment} has no decision recorded yet`;
197
+ else out.decisionWhy = decision.why;
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /** The text a person reads. Pure. */
203
+ export function renderPlan(plan, { slug, fetched, write, written }) {
204
+ const out = [];
205
+ const t = plan.target;
206
+ const head = t.metric
207
+ ? `${slug}: ${t.metric}${t.from !== null && t.to !== null ? ` ${fig(t.from)} → ${fig(t.to)}` : ''}${t.hypothesis ? ` — "${t.hypothesis}"` : ''}`
208
+ : `${slug}: no target`;
209
+ out.push(head);
210
+ if (fetched) {
211
+ if (fetched.grounded === true)
212
+ out.push(` grounded: ${t.metric} is one of the project's North Star inputs`);
213
+ if (fetched.grounded === false)
214
+ out.push(` not grounded: ${t.metric} is not one of the project's North Star inputs`);
215
+ if (fetched.fetched)
216
+ out.push(
217
+ ` fetched: ${fig(fetched.reading.value)} on ${longDay(fetched.reading.date)} (gf north-star readings)`
218
+ );
219
+ else if (fetched.why) out.push(` could not fetch the number: ${fetched.why}`);
220
+ if (fetched.decision)
221
+ out.push(
222
+ ` decision: ${fetched.decision.outcome ?? 'recorded'}${fetched.decision.chosenVariantKey ? ` (${fetched.decision.chosenVariantKey})` : ''} (gf experiments decision)`
223
+ );
224
+ else if (fetched.decisionWhy) out.push(` decision: ${fetched.decisionWhy}`);
225
+ }
226
+ switch (plan.state) {
227
+ case 'already-read':
228
+ out.push(
229
+ ` already read: ${plan.verdict}${plan.verdictAt ? ` on ${longDay(plan.verdictAt)}` : ''}. Nothing to do.`
230
+ );
231
+ break;
232
+ case 'not-shipped':
233
+ out.push(' not shipped yet: there is nothing out there to read.');
234
+ break;
235
+ case 'not-due':
236
+ out.push(
237
+ ` read due ${longDay(plan.readDate)}${plan.derived ? ' (30 days after shipping — no read_date was written)' : ''}. Not yet: come back then.`
238
+ );
239
+ break;
240
+ case 'needs-input':
241
+ if (plan.drafted) out.push(` draft so far: ${plan.drafted}`);
242
+ out.push(` needs: ${plan.need}`);
243
+ break;
244
+ case 'refused':
245
+ out.push(' refused — nothing written:');
246
+ for (const r of plan.reasons) out.push(` · ${r}`);
247
+ break;
248
+ case 'ready': {
249
+ const f = plan.fields;
250
+ const word =
251
+ plan.owner && plan.drafted && plan.drafted !== f.verdict
252
+ ? `${f.verdict} (owner's verdict; drafted ${plan.drafted})`
253
+ : plan.owner
254
+ ? `${f.verdict} (owner's verdict)`
255
+ : f.verdict;
256
+ out.push(` verdict: ${word}`);
257
+ if (f.verdict_actual !== null)
258
+ out.push(` actual: ${fig(f.verdict_actual)}${t.to !== null ? ` (target ${fig(t.to)})` : ''}`);
259
+ out.push(
260
+ ` evidence: ${f.verdict_evidence}${isEvidencePointer(f.verdict_evidence) ? '' : ' (a reason, not a pointer)'}`
261
+ );
262
+ out.push(` read on: ${longDay(f.verdict_at)}`);
263
+ if (plan.late)
264
+ out.push(
265
+ ` late: more than ${READ_CAP_DAYS} days after shipping — it is recorded, and it will not count for the North Star.`
266
+ );
267
+ if (written)
268
+ out.push(
269
+ ` written to ${written}. Commit it, then push the roadmap (node scripts/roadmap-push.mjs).`
270
+ );
271
+ else if (!write) out.push(' Not written. If the owner approves, run the same command with --write.');
272
+ break;
273
+ }
274
+ }
275
+ return out.join('\n');
276
+ }
277
+
278
+ function epicReadme(root, slug) {
279
+ const roadmap = join(root, 'Roadmap');
280
+ for (const macro of existsSync(roadmap) ? readdirSync(roadmap).sort() : []) {
281
+ if (!/^\d{2}-/.test(macro)) continue;
282
+ const p = join(roadmap, macro, slug, 'README.md');
283
+ if (existsSync(p)) return p;
284
+ }
285
+ return null;
286
+ }
287
+
288
+ const arg = (argv, name) => {
289
+ const i = argv.indexOf(name);
290
+ return i === -1 ? null : (argv[i + 1] ?? null);
291
+ };
292
+
293
+ export async function main(argv, { spawnFn = spawnSync, env = process.env, stdout = process.stdout } = {}) {
294
+ const slug = arg(argv, '--epic');
295
+ if (!slug) {
296
+ process.stderr.write('epic-read: --epic <slug> is required\n');
297
+ return 2;
298
+ }
299
+ const root = resolve(arg(argv, '--repo-root') ?? projectRoot());
300
+ const today = arg(argv, '--today') ?? todayUtc();
301
+ if (!isDay(today)) {
302
+ process.stderr.write(`epic-read: --today must be a day written YYYY-MM-DD, got "${today}"\n`);
303
+ return 2;
304
+ }
305
+ const actualRaw = arg(argv, '--actual');
306
+ const actual = actualRaw === null ? null : Number(actualRaw);
307
+ if (actualRaw !== null && !Number.isFinite(actual)) {
308
+ process.stderr.write(`epic-read: --actual must be a number, got "${actualRaw}"\n`);
309
+ return 2;
310
+ }
311
+ const path = epicReadme(root, slug);
312
+ if (!path) {
313
+ process.stderr.write(`epic-read: no Roadmap/*/${slug}/README.md under ${root}\n`);
314
+ return 2;
315
+ }
316
+ const md = readFileSync(path, 'utf8');
317
+ const parsed = parseDocFrontmatter(md);
318
+ if (!parsed.hasFrontmatter || parsed.error) {
319
+ process.stderr.write(`epic-read: cannot read ${path}'s frontmatter: ${parsed.error ?? 'none'}\n`);
320
+ return 2;
321
+ }
322
+ const row = buildRows({ root, facts: { mode: 'docs', prs: [], branches: [] } }).find(
323
+ (r) => r.grain === 'Epic' && r.slug === slug
324
+ );
325
+ const shippedAt = row?.shipped_at ?? null;
326
+ const evidenceFlag = arg(argv, '--evidence');
327
+ const base = {
328
+ fm: parsed.data,
329
+ shippedAt,
330
+ shipped: row?.stage === 'Shipped',
331
+ today,
332
+ verdict: arg(argv, '--verdict'),
333
+ };
334
+ let plan = planRead({ ...base, actual, evidence: evidenceFlag });
335
+ // D17 — fetch only when there is a read to make and the owner has not given the number: never before the read date,
336
+ // never for an epic already read or not shipped, never over the owner's own --actual.
337
+ let fetched = null;
338
+ const readable = !['already-read', 'not-shipped', 'not-due'].includes(plan.state);
339
+ // The owner's word wins: a number (--actual), a reason or pointer (--evidence) or a verdict (--verdict) means the
340
+ // owner has answered, and a fetched number must not overrule them (fresh review, #293).
341
+ const ownerAnswered = actual !== null || evidenceFlag !== null || base.verdict !== null;
342
+ if (readable && plan.target.metric && !ownerAnswered) {
343
+ fetched = fetchEvidence({
344
+ metric: plan.target.metric,
345
+ experiment: arg(argv, '--experiment'),
346
+ // Complete days only: today's telemetry count is a partial day (fresh review, #293).
347
+ today: addDays(today, -1),
348
+ shippedAt,
349
+ project: arg(argv, '--project'),
350
+ run: (args) => runGf(args, { env, spawnFn }),
351
+ });
352
+ // Only a reading is a number to draft from; a decision on its own only names the pointer for the owner to confirm.
353
+ if (fetched.fetched)
354
+ plan = planRead({
355
+ ...base,
356
+ actual: fetched.actual ?? null,
357
+ evidence: evidenceFlag ?? fetched.evidence ?? null,
358
+ });
359
+ }
360
+ const write = argv.includes('--write');
361
+ let written = null;
362
+ if (write && plan.state === 'ready') {
363
+ const next = stampFrontmatter(md, plan.fields);
364
+ // The stamped file must still satisfy the contract as a whole — a second check on what actually lands.
365
+ const after = parseDocFrontmatter(next);
366
+ const offenses = after.error ? [after.error] : validateResultFields(after.data).map((o) => o.detail);
367
+ if (offenses.length) {
368
+ const refused = { ...plan, state: 'refused', reasons: offenses };
369
+ stdout.write(
370
+ argv.includes('--json')
371
+ ? `${JSON.stringify({ slug, ...refused, fetched, written: null })}\n`
372
+ : `${renderPlan(refused, { slug, fetched, write })}\n`
373
+ );
374
+ return 1;
375
+ }
376
+ writeFileSync(path, next);
377
+ written = path.slice(root.length + 1);
378
+ }
379
+ if (argv.includes('--json')) stdout.write(`${JSON.stringify({ slug, ...plan, fetched, written })}\n`);
380
+ else stdout.write(`${renderPlan(plan, { slug, fetched, write, written })}\n`);
381
+ if (plan.state === 'refused') return 1;
382
+ if (write && plan.state !== 'ready') return 1; // asked to write, and there was nothing approvable to write
383
+ return 0;
384
+ }
385
+
386
+ const isMain = (() => {
387
+ try {
388
+ return (
389
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
390
+ );
391
+ } catch {
392
+ return false;
393
+ }
394
+ })();
395
+ if (isMain) {
396
+ try {
397
+ process.exitCode = await main(process.argv.slice(2));
398
+ } catch (err) {
399
+ process.stderr.write(`epic-read: ${err && err.message ? err.message : err}\n`);
400
+ process.exitCode = 2;
401
+ }
402
+ }
@@ -0,0 +1,25 @@
1
+ // frontmatter-stamp.mjs — set fields in a Roadmap doc's frontmatter, in place (zero deps beyond the contract).
2
+ //
3
+ // Written for `epic-actuals.mjs --write` (finops S1.4/S2.5) and moved here when `epic-read.mjs --write` needed the same
4
+ // stamp (result-record D8): two writers of one README must not each keep their own line editor.
5
+ import { formatScalar } from './roadmap-contract.mjs';
6
+
7
+ /**
8
+ * Set `fields` in a doc's frontmatter: an existing `key:` line is replaced in place, a missing one is inserted just
9
+ * before the closing fence. No other line is touched. Re-running with the same values is a no-op (idempotent).
10
+ */
11
+ export function stampFrontmatter(md, fields) {
12
+ const lines = md.split('\n');
13
+ if (lines[0].trim() !== '---') throw new Error('no frontmatter');
14
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
15
+ if (end === -1) throw new Error('unterminated frontmatter');
16
+ const missing = [];
17
+ for (const [key, value] of Object.entries(fields)) {
18
+ const line = `${key}: ${formatScalar(value)}`;
19
+ const at = lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${key}:(\\s|$)`).test(l));
20
+ if (at === -1) missing.push(line);
21
+ else lines[at] = line;
22
+ }
23
+ lines.splice(end, 0, ...missing);
24
+ return lines.join('\n');
25
+ }
@@ -61,6 +61,8 @@ import {
61
61
  JOURNAL_PATH,
62
62
  } from './lib/session-journal.mjs';
63
63
  import { loadReportingConfig, ReportingConfigError } from './lib/reporting-config.mjs';
64
+ import { isReadDue, todayUtc } from './lib/result-dates.mjs';
65
+ import { buildRows } from './roadmap-extract.mjs';
64
66
 
65
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
66
68
  const DEFAULT_ROOT = join(__dirname, '..');
@@ -495,7 +497,52 @@ export function readMemoryIndex(cwd = process.cwd(), deps = {}) {
495
497
  }
496
498
  }
497
499
 
498
- export function buildAnomalies({ repoStates, migrationResults, expandOrphans = false, memoryIndex }) {
500
+ // result-record D9 — a read that is due is something to act on before anything else, so it rides the anomaly list:
501
+ // one line per epic, from the docs alone (no network), gone as soon as its verdict is written into the README.
502
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
503
+ export function decideReadsDue(rows, today = todayUtc()) {
504
+ return (rows || [])
505
+ .filter(
506
+ (r) =>
507
+ r.grain === 'Epic' &&
508
+ // A target is metric + from + to together (D1); a partial one is never due.
509
+ r.target_from != null &&
510
+ r.target_to != null &&
511
+ isReadDue(
512
+ {
513
+ targetMetric: r.target_metric,
514
+ verdict: r.verdict,
515
+ readDate: r.read_date,
516
+ shipped: r.stage === 'Shipped',
517
+ },
518
+ today
519
+ )
520
+ )
521
+ .sort((a, b) => a.read_date.localeCompare(b.read_date) || a.slug.localeCompare(b.slug))
522
+ .map((r) => ({
523
+ type: 'read-due',
524
+ detail: `Read due: ${r.name} · since ${Number(r.read_date.slice(8, 10))} ${MONTHS[Number(r.read_date.slice(5, 7)) - 1]} · run node scripts/epic-read.mjs --epic ${r.slug}`,
525
+ }));
526
+ }
527
+
528
+ /** The roadmap rows the reads-due check reads, docs only. A failure is a gap, never a silent "nothing due". */
529
+ export function gatherRoadmapRows({ root, buildRowsFn = buildRows, existsSyncFn = existsSync }) {
530
+ // A project with no Roadmap/ has nothing to read: no gap on every run (fresh review, #291).
531
+ if (!existsSyncFn(join(root, 'Roadmap'))) return { available: true, rows: [] };
532
+ try {
533
+ return { available: true, rows: buildRowsFn({ root, facts: { mode: 'docs', prs: [], branches: [] } }) };
534
+ } catch (e) {
535
+ return { available: false, rows: [], reason: e.message };
536
+ }
537
+ }
538
+
539
+ export function buildAnomalies({
540
+ repoStates,
541
+ migrationResults,
542
+ expandOrphans = false,
543
+ memoryIndex,
544
+ readsDue = [],
545
+ }) {
499
546
  const anomalies = [];
500
547
  // First, because it is the one anomaly that changes what the rest of this report is
501
548
  // worth: if the index is truncating, the session is missing facts it does not know it
@@ -504,6 +551,8 @@ export function buildAnomalies({ repoStates, migrationResults, expandOrphans = f
504
551
  const mem = decideMemoryBudgetAnomaly(memoryIndex);
505
552
  if (mem) anomalies.push(mem);
506
553
  }
554
+ // result-record D9 — a due read leads the rest: it is the one line here that asks for a decision, not a cleanup.
555
+ anomalies.push(...readsDue);
507
556
  for (const rs of repoStates || []) {
508
557
  if (rs.git?.available) {
509
558
  const openPrs = rs.gh?.available ? rs.gh.open : undefined; // undefined → decideStrayBranch treats as "unknown"
@@ -531,8 +580,15 @@ export function buildAnomalies({ repoStates, migrationResults, expandOrphans = f
531
580
  // D4: names every degraded source instead of silently dropping it. A missing repo path, an
532
581
  // unauthenticated/unreachable gh, an unavailable migration check, and an empty/unfetchable journal all
533
582
  // land here — distinct from `buildAnomalies`, which only reports CONFIRMED surprising states.
534
- export function buildGaps({ repoStates, migrationResults, journalAvailable, journalReason }) {
583
+ export function buildGaps({
584
+ repoStates,
585
+ migrationResults,
586
+ journalAvailable,
587
+ journalReason,
588
+ roadmapReason = null,
589
+ }) {
535
590
  const gaps = [];
591
+ if (roadmapReason) gaps.push(`reads due unknown — the roadmap could not be read: ${roadmapReason}.`);
536
592
  for (const rs of repoStates || []) {
537
593
  if (!rs.git?.available)
538
594
  gaps.push(`${rs.repo}: git state unavailable — ${rs.git?.reason || 'unknown reason'}.`);
@@ -570,12 +626,14 @@ export function buildReport({
570
626
  journalLimit = JOURNAL_LIMIT_DEFAULT,
571
627
  expandOrphans = false,
572
628
  memoryIndex,
629
+ readsDue = [],
630
+ roadmapReason = null,
573
631
  generatedAt,
574
632
  }) {
575
633
  return {
576
634
  generatedAt: generatedAt || new Date().toISOString(),
577
- anomalies: buildAnomalies({ repoStates, migrationResults, expandOrphans, memoryIndex }),
578
- gaps: buildGaps({ repoStates, migrationResults, journalAvailable, journalReason }),
635
+ anomalies: buildAnomalies({ repoStates, migrationResults, expandOrphans, memoryIndex, readsDue }),
636
+ gaps: buildGaps({ repoStates, migrationResults, journalAvailable, journalReason, roadmapReason }),
579
637
  journal: {
580
638
  available: journalAvailable,
581
639
  reason: journalAvailable ? null : journalReason || null,
@@ -860,6 +918,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
860
918
  now,
861
919
  resolveReposFn = resolveRepos,
862
920
  gatherFactsFn = gatherFacts,
921
+ buildRowsFn = buildRows,
863
922
  } = deps;
864
923
 
865
924
  let args;
@@ -904,6 +963,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
904
963
  spawn,
905
964
  });
906
965
  const journal = gatherJournal({ root: args.root, readLogFromBranchFn });
966
+ const roadmap = gatherRoadmapRows({ root: args.root, buildRowsFn });
907
967
 
908
968
  const report = buildReport({
909
969
  repoStates,
@@ -914,6 +974,8 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
914
974
  journalLimit: args.journalLimit,
915
975
  expandOrphans: args.expandOrphans,
916
976
  memoryIndex: readMemoryIndex(args.root, { stat: statFn, home: homeDir }),
977
+ readsDue: decideReadsDue(roadmap.rows, todayUtc(now || new Date())),
978
+ roadmapReason: roadmap.available ? null : roadmap.reason,
917
979
  generatedAt: (now || new Date()).toISOString(),
918
980
  });
919
981
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.31.0",
3
+ "version": "0.33.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": {