@golden-frijoles/kit 0.30.0 → 0.32.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/epic-actuals.mjs +5 -20
- package/dist/epic-read.mjs +331 -0
- package/dist/lib/frontmatter-stamp.mjs +25 -0
- package/dist/lib/result-dates.mjs +75 -0
- package/dist/lib/roadmap-contract.mjs +86 -0
- package/dist/roadmap-extract.mjs +43 -1
- package/dist/session-resume.mjs +66 -4
- package/package.json +1 -1
package/dist/epic-actuals.mjs
CHANGED
|
@@ -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 {
|
|
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,331 @@
|
|
|
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> # when is the read, and what it needs
|
|
5
|
+
// node scripts/epic-read.mjs --epic <slug> --actual 72 --evidence north-star:invoices_paid_on_time@2026-11-04
|
|
6
|
+
// node scripts/epic-read.mjs --epic <slug> --evidence "traffic too low (n = 18)" # no number: unclear
|
|
7
|
+
// node scripts/epic-read.mjs --epic <slug> --verdict proven --evidence https://… # an owner verdict
|
|
8
|
+
// … --write # the approval: stamp verdict_* into the README
|
|
9
|
+
// options: --today YYYY-MM-DD (default: today, UTC) · --repo-root <dir> · --json
|
|
10
|
+
//
|
|
11
|
+
// ── The shape (result-record D8) ─────────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// The agent does the legwork, the owner decides. Without `--write` this prints a draft and the exact command that
|
|
13
|
+
// approves it, and writes nothing. Inputs are flags, never a prompt: an agent asks the owner in the conversation and
|
|
14
|
+
// passes what they said.
|
|
15
|
+
// • Before the read date it says when the read is due and stops — the date is the README's `read_date`, or, when none
|
|
16
|
+
// was written, 30 days after shipping (lib/result-dates.mjs, the one place that rule lives).
|
|
17
|
+
// • The draft: from `target_from` toward `target_to`; reached or passed → proven, short of it → disproven; no number
|
|
18
|
+
// → unclear, with the reason in `--evidence`. `--verdict` is the owner's word over the draft, and is said as such.
|
|
19
|
+
// • Proven and disproven need evidence that points somewhere (the contract's grammar: an https:// link,
|
|
20
|
+
// `north-star:<input>@YYYY-MM-DD` or `ab:<experiment>`). `--write` runs the contract's own `validateResultFields` on
|
|
21
|
+
// the record it is about to write, so a verdict the contract refuses never lands.
|
|
22
|
+
// • More than 90 days after shipping, the read is still written, and said to be late (the extract marks it).
|
|
23
|
+
// • An epic shipped with no target can be read too — an owner verdict and its evidence, one epic per run.
|
|
24
|
+
//
|
|
25
|
+
// ── With an account, and without ─────────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// No route reads a North Star reading or an A/B decision for a key yet (the lock checked: `gf north-star` only sets,
|
|
27
|
+
// `GET /api/v1/north-star` lists metrics and inputs with no values), so the actual always comes from the owner. With the
|
|
28
|
+
// project's key (the one `roadmap-push` uses) this asks that route whether `target_metric` is one of the project's
|
|
29
|
+
// inputs — grounded or not. No key, or any failure, is "could not check", and the read carries on.
|
|
30
|
+
//
|
|
31
|
+
// Zero deps — Node 18+.
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { join, resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
36
|
+
import {
|
|
37
|
+
VERDICTS,
|
|
38
|
+
isEvidencePointer,
|
|
39
|
+
parseDocFrontmatter,
|
|
40
|
+
validateResultFields,
|
|
41
|
+
} from './lib/roadmap-contract.mjs';
|
|
42
|
+
import { READ_CAP_DAYS, isLate, readDateOf, todayUtc, isDay } from './lib/result-dates.mjs';
|
|
43
|
+
import { stampFrontmatter } from './lib/frontmatter-stamp.mjs';
|
|
44
|
+
import { buildRows } from './roadmap-extract.mjs';
|
|
45
|
+
import { apiKeyFrom } from './roadmap-push.mjs';
|
|
46
|
+
|
|
47
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
48
|
+
/** `2026-11-04` → `4 Nov 2026`. */
|
|
49
|
+
export const longDay = (d) =>
|
|
50
|
+
`${Number(d.slice(8, 10))} ${MONTHS[Number(d.slice(5, 7)) - 1]} ${d.slice(0, 4)}`;
|
|
51
|
+
|
|
52
|
+
const fig = (n) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The draft verdict for a target and a number read. Pure.
|
|
56
|
+
* From → to says the direction: a target above its start is met at or above it, one below at or below it.
|
|
57
|
+
*/
|
|
58
|
+
export function draftVerdict({ from, to, actual }) {
|
|
59
|
+
if (actual === null || actual === undefined || !Number.isFinite(actual)) return 'unclear';
|
|
60
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) return 'unclear';
|
|
61
|
+
const up = to > from;
|
|
62
|
+
return (up ? actual >= to : actual <= to) ? 'proven' : 'disproven';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Everything the read decides, from the README's frontmatter, the extract's row and the flags. Pure — no I/O.
|
|
67
|
+
* Returns `{ state, … }`: not-shipped · already-read · not-due · needs-input · refused · ready.
|
|
68
|
+
*/
|
|
69
|
+
export function planRead({ fm, shippedAt, shipped, today, actual = null, evidence = null, verdict = null }) {
|
|
70
|
+
const target = {
|
|
71
|
+
hypothesis: fm.hypothesis ?? null,
|
|
72
|
+
metric: fm.target_metric ?? null,
|
|
73
|
+
from: typeof fm.target_from === 'number' ? fm.target_from : null,
|
|
74
|
+
to: typeof fm.target_to === 'number' ? fm.target_to : null,
|
|
75
|
+
};
|
|
76
|
+
if (fm.verdict)
|
|
77
|
+
return { state: 'already-read', target, verdict: fm.verdict, verdictAt: fm.verdict_at ?? null };
|
|
78
|
+
if (!shipped) return { state: 'not-shipped', target };
|
|
79
|
+
if (verdict !== null && !VERDICTS.includes(verdict))
|
|
80
|
+
return {
|
|
81
|
+
state: 'refused',
|
|
82
|
+
target,
|
|
83
|
+
reasons: [`--verdict must be one of ${VERDICTS.join(' | ')}, got "${verdict}"`],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const { readDate, derived } = readDateOf({
|
|
87
|
+
readDate: fm.read_date,
|
|
88
|
+
targetMetric: target.metric,
|
|
89
|
+
shippedAt,
|
|
90
|
+
});
|
|
91
|
+
if (target.metric && readDate && today < readDate) return { state: 'not-due', target, readDate, derived };
|
|
92
|
+
|
|
93
|
+
const owner = verdict !== null;
|
|
94
|
+
if (!target.metric && !owner)
|
|
95
|
+
return {
|
|
96
|
+
state: 'needs-input',
|
|
97
|
+
target,
|
|
98
|
+
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).',
|
|
99
|
+
};
|
|
100
|
+
const drafted = target.metric ? draftVerdict({ from: target.from, to: target.to, actual }) : null;
|
|
101
|
+
const chosen = owner ? verdict : drafted;
|
|
102
|
+
if (chosen === 'unclear' && !evidence)
|
|
103
|
+
return {
|
|
104
|
+
state: 'needs-input',
|
|
105
|
+
target,
|
|
106
|
+
drafted,
|
|
107
|
+
readDate,
|
|
108
|
+
derived,
|
|
109
|
+
need:
|
|
110
|
+
actual === null
|
|
111
|
+
? `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>").`
|
|
112
|
+
: 'Unclear needs the reason in --evidence (e.g. "traffic too low to tell (n = 18)").',
|
|
113
|
+
};
|
|
114
|
+
if ((chosen === 'proven' || chosen === 'disproven') && !evidence)
|
|
115
|
+
return {
|
|
116
|
+
state: 'needs-input',
|
|
117
|
+
target,
|
|
118
|
+
drafted,
|
|
119
|
+
readDate,
|
|
120
|
+
derived,
|
|
121
|
+
need: `${chosen} needs evidence that points somewhere: --evidence <https://… | north-star:<input>@YYYY-MM-DD | ab:<experiment>>.`,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const fields = {
|
|
125
|
+
verdict: chosen,
|
|
126
|
+
verdict_actual: actual === null || actual === undefined ? null : actual,
|
|
127
|
+
verdict_evidence: evidence,
|
|
128
|
+
verdict_at: today,
|
|
129
|
+
};
|
|
130
|
+
const offenses = validateResultFields({ ...fm, ...fields }).map((o) => o.detail);
|
|
131
|
+
const late = isLate({ verdictAt: today, shippedAt });
|
|
132
|
+
if (offenses.length) return { state: 'refused', target, drafted, fields, reasons: offenses, late };
|
|
133
|
+
return { state: 'ready', target, drafted, owner, fields, late, readDate, derived };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `metric` against the project's North Star inputs, through the existing read route. Never throws. */
|
|
137
|
+
export async function groundedCheck({ metric, apiKey, baseUrl, fetchFn = fetch }) {
|
|
138
|
+
if (!metric) return { checked: false, why: 'no target metric' };
|
|
139
|
+
if (!apiKey)
|
|
140
|
+
return { checked: false, why: 'no project key (SELF_PROJECT_API_KEY / GROWTH_ENGINE_API_KEY)' };
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchFn(`${String(baseUrl).replace(/\/$/, '')}/api/v1/north-star`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
144
|
+
signal: AbortSignal.timeout(10_000),
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok) return { checked: false, why: `the engine answered ${res.status}` };
|
|
147
|
+
const body = await res.json();
|
|
148
|
+
const keys = (body?.metrics ?? []).flatMap((m) => (m?.inputs ?? []).map((i) => i?.key)).filter(Boolean);
|
|
149
|
+
return { checked: true, grounded: keys.includes(metric), keys };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return { checked: false, why: `could not reach ${baseUrl}: ${err?.message ?? err}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The text a person reads. Pure. */
|
|
156
|
+
export function renderPlan(plan, { slug, grounded, write, written }) {
|
|
157
|
+
const out = [];
|
|
158
|
+
const t = plan.target;
|
|
159
|
+
const head = t.metric
|
|
160
|
+
? `${slug}: ${t.metric}${t.from !== null && t.to !== null ? ` ${fig(t.from)} → ${fig(t.to)}` : ''}${t.hypothesis ? ` — "${t.hypothesis}"` : ''}`
|
|
161
|
+
: `${slug}: no target`;
|
|
162
|
+
out.push(head);
|
|
163
|
+
if (grounded) {
|
|
164
|
+
if (grounded.checked)
|
|
165
|
+
out.push(
|
|
166
|
+
grounded.grounded
|
|
167
|
+
? ` grounded: ${t.metric} is one of the project's North Star inputs`
|
|
168
|
+
: ` not grounded: ${t.metric} is not one of the project's North Star inputs (${grounded.keys.join(', ') || 'none'})`
|
|
169
|
+
);
|
|
170
|
+
else if (t.metric) out.push(` could not check the metric against the North Star (${grounded.why})`);
|
|
171
|
+
}
|
|
172
|
+
switch (plan.state) {
|
|
173
|
+
case 'already-read':
|
|
174
|
+
out.push(
|
|
175
|
+
` already read: ${plan.verdict}${plan.verdictAt ? ` on ${longDay(plan.verdictAt)}` : ''}. Nothing to do.`
|
|
176
|
+
);
|
|
177
|
+
break;
|
|
178
|
+
case 'not-shipped':
|
|
179
|
+
out.push(' not shipped yet: there is nothing out there to read.');
|
|
180
|
+
break;
|
|
181
|
+
case 'not-due':
|
|
182
|
+
out.push(
|
|
183
|
+
` read due ${longDay(plan.readDate)}${plan.derived ? ' (30 days after shipping — no read_date was written)' : ''}. Not yet: come back then.`
|
|
184
|
+
);
|
|
185
|
+
break;
|
|
186
|
+
case 'needs-input':
|
|
187
|
+
if (plan.drafted) out.push(` draft so far: ${plan.drafted}`);
|
|
188
|
+
out.push(` needs: ${plan.need}`);
|
|
189
|
+
break;
|
|
190
|
+
case 'refused':
|
|
191
|
+
out.push(' refused — nothing written:');
|
|
192
|
+
for (const r of plan.reasons) out.push(` · ${r}`);
|
|
193
|
+
break;
|
|
194
|
+
case 'ready': {
|
|
195
|
+
const f = plan.fields;
|
|
196
|
+
const word =
|
|
197
|
+
plan.owner && plan.drafted && plan.drafted !== f.verdict
|
|
198
|
+
? `${f.verdict} (owner's verdict; drafted ${plan.drafted})`
|
|
199
|
+
: plan.owner
|
|
200
|
+
? `${f.verdict} (owner's verdict)`
|
|
201
|
+
: f.verdict;
|
|
202
|
+
out.push(` verdict: ${word}`);
|
|
203
|
+
if (f.verdict_actual !== null)
|
|
204
|
+
out.push(` actual: ${fig(f.verdict_actual)}${t.to !== null ? ` (target ${fig(t.to)})` : ''}`);
|
|
205
|
+
out.push(
|
|
206
|
+
` evidence: ${f.verdict_evidence}${isEvidencePointer(f.verdict_evidence) ? '' : ' (a reason, not a pointer)'}`
|
|
207
|
+
);
|
|
208
|
+
out.push(` read on: ${longDay(f.verdict_at)}`);
|
|
209
|
+
if (plan.late)
|
|
210
|
+
out.push(
|
|
211
|
+
` late: more than ${READ_CAP_DAYS} days after shipping — it is recorded, and it will not count for the North Star.`
|
|
212
|
+
);
|
|
213
|
+
if (written)
|
|
214
|
+
out.push(
|
|
215
|
+
` written to ${written}. Commit it, then push the roadmap (node scripts/roadmap-push.mjs).`
|
|
216
|
+
);
|
|
217
|
+
else if (!write) out.push(' Not written. If the owner approves, run the same command with --write.');
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out.join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function epicReadme(root, slug) {
|
|
225
|
+
const roadmap = join(root, 'Roadmap');
|
|
226
|
+
for (const macro of existsSync(roadmap) ? readdirSync(roadmap).sort() : []) {
|
|
227
|
+
if (!/^\d{2}-/.test(macro)) continue;
|
|
228
|
+
const p = join(roadmap, macro, slug, 'README.md');
|
|
229
|
+
if (existsSync(p)) return p;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const arg = (argv, name) => {
|
|
235
|
+
const i = argv.indexOf(name);
|
|
236
|
+
return i === -1 ? null : (argv[i + 1] ?? null);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export async function main(argv, { fetchFn = fetch, env = process.env, stdout = process.stdout } = {}) {
|
|
240
|
+
const slug = arg(argv, '--epic');
|
|
241
|
+
if (!slug) {
|
|
242
|
+
process.stderr.write('epic-read: --epic <slug> is required\n');
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
const root = resolve(arg(argv, '--repo-root') ?? projectRoot());
|
|
246
|
+
const today = arg(argv, '--today') ?? todayUtc();
|
|
247
|
+
if (!isDay(today)) {
|
|
248
|
+
process.stderr.write(`epic-read: --today must be a day written YYYY-MM-DD, got "${today}"\n`);
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
const actualRaw = arg(argv, '--actual');
|
|
252
|
+
const actual = actualRaw === null ? null : Number(actualRaw);
|
|
253
|
+
if (actualRaw !== null && !Number.isFinite(actual)) {
|
|
254
|
+
process.stderr.write(`epic-read: --actual must be a number, got "${actualRaw}"\n`);
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
const path = epicReadme(root, slug);
|
|
258
|
+
if (!path) {
|
|
259
|
+
process.stderr.write(`epic-read: no Roadmap/*/${slug}/README.md under ${root}\n`);
|
|
260
|
+
return 2;
|
|
261
|
+
}
|
|
262
|
+
const md = readFileSync(path, 'utf8');
|
|
263
|
+
const parsed = parseDocFrontmatter(md);
|
|
264
|
+
if (!parsed.hasFrontmatter || parsed.error) {
|
|
265
|
+
process.stderr.write(`epic-read: cannot read ${path}'s frontmatter: ${parsed.error ?? 'none'}\n`);
|
|
266
|
+
return 2;
|
|
267
|
+
}
|
|
268
|
+
const row = buildRows({ root, facts: { mode: 'docs', prs: [], branches: [] } }).find(
|
|
269
|
+
(r) => r.grain === 'Epic' && r.slug === slug
|
|
270
|
+
);
|
|
271
|
+
const plan = planRead({
|
|
272
|
+
fm: parsed.data,
|
|
273
|
+
shippedAt: row?.shipped_at ?? null,
|
|
274
|
+
shipped: row?.stage === 'Shipped',
|
|
275
|
+
today,
|
|
276
|
+
actual,
|
|
277
|
+
evidence: arg(argv, '--evidence'),
|
|
278
|
+
verdict: arg(argv, '--verdict'),
|
|
279
|
+
});
|
|
280
|
+
const grounded =
|
|
281
|
+
plan.state === 'already-read' || plan.state === 'not-shipped'
|
|
282
|
+
? null
|
|
283
|
+
: await groundedCheck({
|
|
284
|
+
metric: plan.target.metric,
|
|
285
|
+
apiKey: apiKeyFrom(env),
|
|
286
|
+
baseUrl: env.GROWTH_ENGINE_URL || 'http://localhost:3000',
|
|
287
|
+
fetchFn,
|
|
288
|
+
});
|
|
289
|
+
const write = argv.includes('--write');
|
|
290
|
+
let written = null;
|
|
291
|
+
if (write && plan.state === 'ready') {
|
|
292
|
+
const next = stampFrontmatter(md, plan.fields);
|
|
293
|
+
// The stamped file must still satisfy the contract as a whole — a second check on what actually lands.
|
|
294
|
+
const after = parseDocFrontmatter(next);
|
|
295
|
+
const offenses = after.error ? [after.error] : validateResultFields(after.data).map((o) => o.detail);
|
|
296
|
+
if (offenses.length) {
|
|
297
|
+
const refused = { ...plan, state: 'refused', reasons: offenses };
|
|
298
|
+
stdout.write(
|
|
299
|
+
argv.includes('--json')
|
|
300
|
+
? `${JSON.stringify({ slug, ...refused, grounded, written: null })}\n`
|
|
301
|
+
: `${renderPlan(refused, { slug, grounded, write })}\n`
|
|
302
|
+
);
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
writeFileSync(path, next);
|
|
306
|
+
written = path.slice(root.length + 1);
|
|
307
|
+
}
|
|
308
|
+
if (argv.includes('--json')) stdout.write(`${JSON.stringify({ slug, ...plan, grounded, written })}\n`);
|
|
309
|
+
else stdout.write(`${renderPlan(plan, { slug, grounded, write, written })}\n`);
|
|
310
|
+
if (plan.state === 'refused') return 1;
|
|
311
|
+
if (write && plan.state !== 'ready') return 1; // asked to write, and there was nothing approvable to write
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const isMain = (() => {
|
|
316
|
+
try {
|
|
317
|
+
return (
|
|
318
|
+
!!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
319
|
+
);
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
})();
|
|
324
|
+
if (isMain) {
|
|
325
|
+
try {
|
|
326
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
327
|
+
} catch (err) {
|
|
328
|
+
process.stderr.write(`epic-read: ${err && err.message ? err.message : err}\n`);
|
|
329
|
+
process.exitCode = 2;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// result-dates.mjs — the result record's date arithmetic, written once (result-record D5). Pure: zero I/O, zero deps.
|
|
2
|
+
//
|
|
3
|
+
// An epic that carries a target (`target_metric`) is read on its `read_date`. When none was written, the read falls
|
|
4
|
+
// 30 days after shipping; a verdict written more than 90 days after shipping is late (it is kept, and it does not count
|
|
5
|
+
// for the North Star). Those two numbers are `Roadmap/00-strategy/north-star.md`'s rule. Extract, `epic-read` and
|
|
6
|
+
// `session-resume` import them from here; `apps/web/lib/roadmap-result.ts` holds a copy a spec pins to this one.
|
|
7
|
+
//
|
|
8
|
+
// Every date is a calendar day, `YYYY-MM-DD`, read and written in UTC so a reader's timezone never moves a read. The
|
|
9
|
+
// day check itself is the contract's (`isDay`), which stays dependency-free.
|
|
10
|
+
|
|
11
|
+
export const READ_DEFAULT_DAYS = 30;
|
|
12
|
+
export const READ_CAP_DAYS = 90;
|
|
13
|
+
|
|
14
|
+
import { isDay } from './roadmap-contract.mjs';
|
|
15
|
+
|
|
16
|
+
export { isDay };
|
|
17
|
+
|
|
18
|
+
const MS_PER_DAY = 86_400_000;
|
|
19
|
+
|
|
20
|
+
/** The calendar day part of a date or date-time string, or null. `2026-10-04T12:00:00Z` → `2026-10-04`. */
|
|
21
|
+
export function dayOf(v) {
|
|
22
|
+
if (typeof v !== 'string') return null;
|
|
23
|
+
const day = v.slice(0, 10);
|
|
24
|
+
return isDay(day) ? day : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `day` + `n` days, as `YYYY-MM-DD`. Null when `day` is not a day. */
|
|
28
|
+
export function addDays(day, n) {
|
|
29
|
+
const d = dayOf(day);
|
|
30
|
+
if (!d) return null;
|
|
31
|
+
return new Date(Date.parse(`${d}T00:00:00Z`) + n * MS_PER_DAY).toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whole days from `a` to `b` (positive when `b` is later). Null when either is not a day. */
|
|
35
|
+
export function daysBetween(a, b) {
|
|
36
|
+
const x = dayOf(a);
|
|
37
|
+
const y = dayOf(b);
|
|
38
|
+
if (!x || !y) return null;
|
|
39
|
+
return Math.round((Date.parse(`${y}T00:00:00Z`) - Date.parse(`${x}T00:00:00Z`)) / MS_PER_DAY);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Today in UTC, `YYYY-MM-DD`. */
|
|
43
|
+
export function todayUtc(now = new Date()) {
|
|
44
|
+
return now.toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The read date an epic is held to: the one written (or ship day, if it passed before shipping), else — only for a SHIPPED epic that HAS a target — 30 days after
|
|
49
|
+
* shipping, marked derived. An epic with no target has no read date (it can still be read by hand), so the 54 epics
|
|
50
|
+
* shipped before the result record existed are never due (the no-backfill rule).
|
|
51
|
+
*/
|
|
52
|
+
export function readDateOf({ readDate, targetMetric, shippedAt }) {
|
|
53
|
+
// A day written at grooming that passes before the epic ships is read on ship day (codex review, #290): there was
|
|
54
|
+
// nothing out there to read before then, and "overdue since before release" would be a false alarm.
|
|
55
|
+
const shipped = dayOf(shippedAt);
|
|
56
|
+
if (isDay(readDate))
|
|
57
|
+
return { readDate: shipped && readDate < shipped ? shipped : readDate, derived: false };
|
|
58
|
+
if (!targetMetric || !dayOf(shippedAt)) return { readDate: null, derived: false };
|
|
59
|
+
return { readDate: addDays(shippedAt, READ_DEFAULT_DAYS), derived: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a verdict written on `verdictAt` is late: more than 90 days after shipping. False when either is unknown. */
|
|
63
|
+
export function isLate({ verdictAt, shippedAt }) {
|
|
64
|
+
const days = daysBetween(shippedAt, verdictAt);
|
|
65
|
+
return days !== null && days > READ_CAP_DAYS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a read is due today: a shipped epic with a target, no verdict yet, and a read date that has arrived. An epic
|
|
70
|
+
* not yet shipped is never due, even past a read date written at grooming: there is nothing out there to read.
|
|
71
|
+
*/
|
|
72
|
+
export function isReadDue({ targetMetric, verdict, readDate, shipped }, today = todayUtc()) {
|
|
73
|
+
if (!shipped || !targetMetric || verdict || !isDay(readDate)) return false;
|
|
74
|
+
return today >= readDate;
|
|
75
|
+
}
|
|
@@ -55,6 +55,91 @@ export const FINOPS_FIELDS = [
|
|
|
55
55
|
'actual_basis',
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
+
// result-record D1/D2 — the result record: what an epic should move (written at grooming) and its verdict (written at
|
|
59
|
+
// the read), declared ONCE here. All optional: no target is "no target", never an error. Dates are calendar days.
|
|
60
|
+
export const VERDICTS = ['proven', 'disproven', 'unclear'];
|
|
61
|
+
export const TARGET_FIELDS = ['hypothesis', 'target_metric', 'target_from', 'target_to', 'read_date'];
|
|
62
|
+
export const VERDICT_FIELDS = ['verdict', 'verdict_actual', 'verdict_evidence', 'verdict_at'];
|
|
63
|
+
export const RESULT_FIELDS = [...TARGET_FIELDS, ...VERDICT_FIELDS];
|
|
64
|
+
export const RESULT_NUMERIC_FIELDS = ['target_from', 'target_to', 'verdict_actual'];
|
|
65
|
+
export const RESULT_DAY_FIELDS = ['read_date', 'verdict_at'];
|
|
66
|
+
export const RESULT_TEXT_FIELDS = ['hypothesis', 'target_metric', 'verdict_evidence'];
|
|
67
|
+
|
|
68
|
+
// D2 — what proven or disproven must point at, checked offline as syntax only: a link, a North Star input's reading
|
|
69
|
+
// on a day, or an experiment's A/B decision record. Keys are the platform's own shape (lowercase, digits, _ . -).
|
|
70
|
+
// Case-SENSITIVE on purpose (codex review, #290): with `/i`, `HTTPS:///` matched here and then skipped the URL parse
|
|
71
|
+
// below, which keys on the lowercase scheme. Keys take either case; the scheme and prefixes are lowercase.
|
|
72
|
+
export const EVIDENCE_POINTER_RE =
|
|
73
|
+
/^(?:https:\/\/\S+|north-star:[A-Za-z0-9][A-Za-z0-9_.-]*@\d{4}-\d{2}-\d{2}|ab:[A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
74
|
+
|
|
75
|
+
// Zero dependencies is load-bearing: projects and fixtures copy this file on its own. So the day check lives here and
|
|
76
|
+
// `result-dates.mjs` imports it, not the other way round.
|
|
77
|
+
/** Whether `v` is a real calendar day written `YYYY-MM-DD` (`2026-02-30` is not), read in UTC. */
|
|
78
|
+
export function isDay(v) {
|
|
79
|
+
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
|
|
80
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
81
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether `v` is an evidence pointer (D2). A `north-star:` pointer's day must be a real calendar day. */
|
|
85
|
+
export function isEvidencePointer(v) {
|
|
86
|
+
if (typeof v !== 'string' || !EVIDENCE_POINTER_RE.test(v.trim())) return false;
|
|
87
|
+
const t = v.trim();
|
|
88
|
+
// A link must PARSE (codex review, #290): the regex alone lets `https:///` through. The WHATWG parser refuses an
|
|
89
|
+
// https URL with no host, so a successful parse is the whole check — the scheme was already matched above.
|
|
90
|
+
if (t.startsWith('https://')) {
|
|
91
|
+
try {
|
|
92
|
+
new URL(t);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const day = /@(\d{4}-\d{2}-\d{2})$/.exec(t)?.[1];
|
|
99
|
+
return !day || isDay(day);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The result-record fields of an epic's frontmatter data → offenses (`contract-result-invalid`). Absent is fine. */
|
|
103
|
+
export function validateResultFields(fm) {
|
|
104
|
+
const offenses = [];
|
|
105
|
+
const bad = (detail) => offenses.push({ rule: 'contract-result-invalid', detail });
|
|
106
|
+
const has = (key) => fm[key] !== undefined && fm[key] !== null;
|
|
107
|
+
for (const key of RESULT_NUMERIC_FIELDS)
|
|
108
|
+
if (has(key) && (typeof fm[key] !== 'number' || !Number.isFinite(fm[key])))
|
|
109
|
+
bad(`${key}: "${fm[key]}" is not a number (or null)`);
|
|
110
|
+
for (const key of RESULT_DAY_FIELDS)
|
|
111
|
+
if (has(key) && !isDay(fm[key])) bad(`${key}: "${fm[key]}" is not a day written YYYY-MM-DD (or null)`);
|
|
112
|
+
for (const key of RESULT_TEXT_FIELDS)
|
|
113
|
+
if (has(key) && (typeof fm[key] !== 'string' || !fm[key].trim())) bad(`${key}: "${fm[key]}" is not text (or null)`);
|
|
114
|
+
// A target is the three together — which number, from what, to what (fresh review, #290): from/to or a read date
|
|
115
|
+
// with no metric would never come due (everything keys off target_metric), and a metric with no numbers has no
|
|
116
|
+
// direction to judge. A hypothesis on its own is allowed: a sentence is not a target.
|
|
117
|
+
const targetKeys = ['target_metric', 'target_from', 'target_to', 'read_date'];
|
|
118
|
+
if (targetKeys.some(has)) {
|
|
119
|
+
for (const key of ['target_metric', 'target_from', 'target_to'])
|
|
120
|
+
if (!has(key)) bad(`a target needs target_metric, target_from and target_to together — ${key} is missing`);
|
|
121
|
+
}
|
|
122
|
+
if (has('target_from') && has('target_to') && fm.target_from === fm.target_to)
|
|
123
|
+
bad(`target_from and target_to are both ${fm.target_from}: a target has to move the number`);
|
|
124
|
+
if (has('verdict') && !VERDICTS.includes(fm.verdict))
|
|
125
|
+
bad(`verdict: "${fm.verdict}" is not one of ${VERDICTS.join(' | ')}`);
|
|
126
|
+
if (!has('verdict')) {
|
|
127
|
+
for (const key of VERDICT_FIELDS.slice(1)) if (has(key)) bad(`${key} is set but there is no verdict`);
|
|
128
|
+
} else {
|
|
129
|
+
if (!has('verdict_at')) bad('a verdict needs verdict_at (the day it was read)');
|
|
130
|
+
if (!has('verdict_evidence')) bad('a verdict needs verdict_evidence (a pointer, or for unclear, the reason)');
|
|
131
|
+
if (fm.verdict === 'proven' || fm.verdict === 'disproven') {
|
|
132
|
+
if (!has('verdict_actual')) bad(`verdict: ${fm.verdict} needs verdict_actual (the number that was read)`);
|
|
133
|
+
if (has('verdict_evidence') && !isEvidencePointer(fm.verdict_evidence))
|
|
134
|
+
bad(
|
|
135
|
+
`verdict: ${fm.verdict} needs evidence that points somewhere: an https:// link, ` +
|
|
136
|
+
`north-star:<input>@YYYY-MM-DD or ab:<experiment>, not "${fm.verdict_evidence}"`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return offenses;
|
|
141
|
+
}
|
|
142
|
+
|
|
58
143
|
// live-build-view D10 — `locked_at:` is stamped by `scripts/epic-phase.mjs lock` when the architecture lock is written;
|
|
59
144
|
// the build view reads its absence as "Locking architecture". Optional (no epic before it has one), an ISO date-time
|
|
60
145
|
// string when present: the command writes `"2026-10-03T20:34:34Z"` (quoted, so the frontmatter parser keeps the colons).
|
|
@@ -269,6 +354,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
|
|
|
269
354
|
offenses.push({ rule: 'contract-total-invalid', detail: `${key}: "${fm[key]}" is not a whole number` });
|
|
270
355
|
}
|
|
271
356
|
offenses.push(...validateFinopsFields(fm));
|
|
357
|
+
offenses.push(...validateResultFields(fm));
|
|
272
358
|
offenses.push(...validateLockedAt(fm));
|
|
273
359
|
if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
|
|
274
360
|
offenses.push({
|
package/dist/roadmap-extract.mjs
CHANGED
|
@@ -68,7 +68,15 @@ import { attributeFacts, resolveStage } from './lib/stage.mjs';
|
|
|
68
68
|
import { gatherFacts } from './lib/stage-facts.mjs';
|
|
69
69
|
import { epicKickoffFromDir, sprintBranch } from './lib/epic-kickoff.mjs';
|
|
70
70
|
import { renderBoardText } from './lib/board-text.mjs';
|
|
71
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
FINOPS_FIELDS,
|
|
73
|
+
FINOPS_NUMERIC_FIELDS,
|
|
74
|
+
RESULT_DAY_FIELDS,
|
|
75
|
+
RESULT_FIELDS,
|
|
76
|
+
RESULT_NUMERIC_FIELDS,
|
|
77
|
+
VERDICTS,
|
|
78
|
+
} from './lib/roadmap-contract.mjs';
|
|
79
|
+
import { isDay, isLate, readDateOf } from './lib/result-dates.mjs';
|
|
72
80
|
import { pushRoadmap, reportPush } from './roadmap-push.mjs';
|
|
73
81
|
|
|
74
82
|
const REPO = projectRoot(); // D2 — the CLI's default root; buildRows takes its own
|
|
@@ -120,6 +128,39 @@ export function finopsFields(fm) {
|
|
|
120
128
|
return out;
|
|
121
129
|
}
|
|
122
130
|
|
|
131
|
+
/**
|
|
132
|
+
* result-record D5 — an epic's target and verdict, off its README frontmatter, plus what is DERIVED from them: the
|
|
133
|
+
* default read date (30 days after shipping, only for a shipped epic that has a target) and whether a verdict came
|
|
134
|
+
* late (more than 90 days after shipping). Derived here and labelled, never written back into the README. A value
|
|
135
|
+
* that does not read as its type is null (doc-format names it); a number is never invented as 0.
|
|
136
|
+
*/
|
|
137
|
+
export function resultFields(fm, shippedAt) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const key of RESULT_FIELDS) {
|
|
140
|
+
const raw = fm[key];
|
|
141
|
+
const blank = raw === null || raw === undefined || raw === '' || raw === 'null' || raw === '~';
|
|
142
|
+
if (RESULT_NUMERIC_FIELDS.includes(key)) {
|
|
143
|
+
// A numeral or a number, nothing else (codex review, #290): `Number(' ')` is 0 and `Number(true)` is 1.
|
|
144
|
+
const numeral = typeof raw === 'string' && /^-?\d+(?:\.\d+)?$/.test(raw.trim());
|
|
145
|
+
const n = typeof raw === 'number' ? raw : numeral ? Number(raw) : NaN;
|
|
146
|
+
out[key] = !blank && Number.isFinite(n) ? n : null;
|
|
147
|
+
} else if (RESULT_DAY_FIELDS.includes(key)) out[key] = isDay(raw) ? raw : null;
|
|
148
|
+
else if (key === 'verdict') out[key] = VERDICTS.includes(raw) ? raw : null;
|
|
149
|
+
else out[key] = !blank && typeof raw === 'string' ? raw : null;
|
|
150
|
+
}
|
|
151
|
+
// A target is the three together (D1): a metric with no numbers gets no derived read date (codex review, #290).
|
|
152
|
+
const complete = out.target_metric !== null && out.target_from !== null && out.target_to !== null;
|
|
153
|
+
const { readDate, derived } = readDateOf({
|
|
154
|
+
readDate: out.read_date,
|
|
155
|
+
targetMetric: complete ? out.target_metric : null,
|
|
156
|
+
shippedAt,
|
|
157
|
+
});
|
|
158
|
+
out.read_date = readDate;
|
|
159
|
+
out.read_date_derived = derived;
|
|
160
|
+
out.read_late = out.verdict ? isLate({ verdictAt: out.verdict_at, shippedAt }) : false;
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
function parseFrontmatter(md) {
|
|
124
165
|
if (!md.startsWith('---')) return {};
|
|
125
166
|
const end = md.indexOf('\n---', 3);
|
|
@@ -576,6 +617,7 @@ export function buildRows({
|
|
|
576
617
|
kickoff,
|
|
577
618
|
shipped_at: stage === 'Shipped' ? statusDay : null,
|
|
578
619
|
...finopsFields(epicFm),
|
|
620
|
+
...resultFields(epicFm, stage === 'Shipped' ? statusDay : null),
|
|
579
621
|
});
|
|
580
622
|
|
|
581
623
|
// Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
|
package/dist/session-resume.mjs
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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.
|
|
3
|
+
"version": "0.32.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": {
|