@golden-frijoles/kit 1.5.0 → 1.6.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.
@@ -56,7 +56,7 @@ function repoEnv() {
56
56
  import { readFileSync, readdirSync, existsSync, statSync, realpathSync } from 'node:fs';
57
57
  import { fileURLToPath } from 'node:url';
58
58
  import { dirname, join, resolve } from 'node:path';
59
- import { PHASES, isDay, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
59
+ import { PHASES, isDay, parseDocFrontmatter, wrapWords, WHY_LINES_MAX } from './lib/roadmap-contract.mjs';
60
60
  import { parseJournal, JOURNAL_BRANCH, JOURNAL_PATH } from './lib/session-journal.mjs';
61
61
  import { branchCandidates, parseBranch } from './lib/work-branch.mjs';
62
62
  import { buildRows } from './roadmap-extract.mjs';
@@ -120,7 +120,8 @@ export function storyCheck({ root, branch, subject, env = {} }) {
120
120
  if (EXEMPT_SUBJECT_RE.test(s)) return { ok: true, why: 'merge, revert or fixup' };
121
121
  const type = CONVENTIONAL_RE.exec(s)?.[1]?.toLowerCase() ?? null;
122
122
  // An untyped subject is gated: an untyped commit must not be a silent way around the check (D8).
123
- if (type !== null && !STORY_GATED_TYPES.includes(type)) return { ok: true, why: `type ${type} is not gated` };
123
+ if (type !== null && !STORY_GATED_TYPES.includes(type))
124
+ return { ok: true, why: `type ${type} is not gated` };
124
125
  if (!branch) return { ok: true, why: 'detached HEAD' };
125
126
  let target;
126
127
  try {
@@ -134,16 +135,28 @@ export function storyCheck({ root, branch, subject, env = {} }) {
134
135
  const stories = epic.sprints.flatMap((sp) => sp.stories.map((st) => ({ ...st, sprint: sp.n })));
135
136
  const valid = stories.filter((st) => sprint === null || st.sprint === sprint);
136
137
  const scope = sprint === null ? `epic ${epic.slug}` : `epic ${epic.slug}, sprint ${sprint}`;
137
- const refuse = (why) => ({ ok: false, why, valid: valid.map((st) => ({ id: st.id, title: st.title ?? '' })), scope, epic: epic.slug });
138
+ const refuse = (why) => ({
139
+ ok: false,
140
+ why,
141
+ valid: valid.map((st) => ({ id: st.id, title: st.title ?? '' })),
142
+ scope,
143
+ epic: epic.slug,
144
+ });
138
145
  if (!valid.length) return { ok: true, why: `${scope} lists no stories` };
139
146
  // A bare continuation (`1.2` after `S1.1/`) counts only when this epic lists it: `S2.1, 3.4 GB` is one story. An id
140
147
  // spelled with its own `S` always counts, listed or not — naming a stranger is the mistake this check exists for.
141
148
  const listed = new Set(stories.map((st) => st.id));
142
- const ids = [...new Set(storyIdsWithKind(s).filter((x) => !x.bare || listed.has(x.id)).map((x) => x.id))];
149
+ const ids = [
150
+ ...new Set(
151
+ storyIdsWithKind(s)
152
+ .filter((x) => !x.bare || listed.has(x.id))
153
+ .map((x) => x.id)
154
+ ),
155
+ ];
143
156
  if (ids.length === 0) return refuse('it names no story');
144
- if (ids.length > 1) return refuse(`it names ${ids.length} stories (${ids.join(', ')}) — one commit, one story`);
145
- if (!valid.some((st) => st.id === ids[0]))
146
- return refuse(`${ids[0]} is not a story of ${scope}`);
157
+ if (ids.length > 1)
158
+ return refuse(`it names ${ids.length} stories (${ids.join(', ')}) — one commit, one story`);
159
+ if (!valid.some((st) => st.id === ids[0])) return refuse(`${ids[0]} is not a story of ${scope}`);
147
160
  return { ok: true, why: `names ${ids[0]}` };
148
161
  }
149
162
 
@@ -921,8 +934,7 @@ function stageParts(state) {
921
934
  : state.facts_mode === 'snapshot'
922
935
  ? `snapshot, ${state.stage_age ?? 'age unknown'}`
923
936
  : 'docs only, no snapshot yet';
924
- const phase =
925
- state.phase_written && state.phase_written !== stage ? ` · phase ${state.phase_written}` : '';
937
+ const phase = state.phase_written && state.phase_written !== stage ? ` · phase ${state.phase_written}` : '';
926
938
  return { stage, locking, source, age, phase };
927
939
  }
928
940
 
@@ -960,20 +972,24 @@ export function shortDay(day, now = new Date()) {
960
972
  // Four significant digits, so a small target (0.004 → 0.006) never rounds to 0 (#312 review).
961
973
  const fmtNum = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toPrecision(4))));
962
974
 
963
- /** The Why line and, with a target, its continuation — each clipped to fit 80 columns. */
975
+ // why-as-a-story D4 — the Why is read in full: it wraps (lib/roadmap-contract's wrapWords, the guard's own wrap).
976
+ export { wrapWords, WHY_LINES_MAX };
977
+
978
+ /** The Why, wrapped over as many lines as it needs (D4), then the target. Every line fits 80 columns. */
964
979
  export function whyLines(target, pad, cont, now = new Date()) {
965
980
  const room = WIDTH - cont.length;
966
981
  const t = target || {};
982
+ const story = t.hypothesis
983
+ ? wrapWords(t.hypothesis, room).map((l, i) => (i === 0 ? `${pad('Why')}${l}` : `${cont}${l}`))
984
+ : [];
967
985
  if (!t.metric) {
968
986
  if (!t.hypothesis) return [`${pad('Why')}no target set`];
969
- return [`${pad('Why')}${clip(t.hypothesis, room)}`, `${cont}no target set`];
987
+ return [...story, `${cont}no target set`];
970
988
  }
971
989
  const read = t.read_date ? `read ${shortDay(t.read_date, now)}` : 'read 30 days after shipping';
972
990
  const numbers = ` ${fmtNum(t.from)} ━━▸ ${fmtNum(t.to)} · ${read}`;
973
991
  const metric = `${clip(t.metric, Math.max(8, room - numbers.length))}${numbers}`;
974
- return t.hypothesis
975
- ? [`${pad('Why')}${clip(t.hypothesis, room)}`, `${cont}${clip(metric, room)}`]
976
- : [`${pad('Why')}${clip(metric, room)}`];
992
+ return t.hypothesis ? [...story, `${cont}${clip(metric, room)}`] : [`${pad('Why')}${clip(metric, room)}`];
977
993
  }
978
994
 
979
995
  // build-view-upgrade D2 — one cell per story, sprint by sprint: ▰ has a commit, ▱ not yet, │ between sprints.
@@ -986,7 +1002,10 @@ export function sprintBars(bySprint) {
986
1002
  .map((sp) => {
987
1003
  const width = Math.min(sp.total, SPRINT_CELLS_MAX);
988
1004
  // Rounded down, so a scaled sprint is never drawn full before every story has a commit (#312 review).
989
- const filled = sp.done >= sp.total ? width : Math.max(0, Math.min(width - 1, Math.floor((sp.done / sp.total) * width)));
1005
+ const filled =
1006
+ sp.done >= sp.total
1007
+ ? width
1008
+ : Math.max(0, Math.min(width - 1, Math.floor((sp.done / sp.total) * width)));
990
1009
  return '▰'.repeat(filled) + '▱'.repeat(width - filled);
991
1010
  })
992
1011
  .join('│');
@@ -660,7 +660,10 @@ export async function pushUsage({
660
660
  return { sent: 0, reason: `config unreadable — ${err && err.message ? err.message : err}` };
661
661
  }
662
662
  if (setting !== 'on')
663
- return { sent: 0, reason: 'off — spend.telemetry is not on (frijoles-kit config set spend.telemetry on)' };
663
+ return {
664
+ sent: 0,
665
+ reason: 'off — spend.telemetry is not on (frijoles-kit config set spend.telemetry on)',
666
+ };
664
667
  // The throttle counts ATTEMPTS, not successes: a persistent 401 or 429 retries every 10 minutes, not every minute.
665
668
  const last = Math.max(index.pushed_at ?? -Infinity, index.push_attempt_at ?? -Infinity);
666
669
  if (throttle && Number.isFinite(last) && now - last < PUSH_EVERY_MS)
@@ -733,11 +736,26 @@ export async function pushUsage({
733
736
 
734
737
  // stampFrontmatter moved to lib/frontmatter-stamp.mjs (result-record D8); re-exported above for existing callers.
735
738
 
736
- /** The three actual_* fields for an epic's report. */
739
+ /**
740
+ * why-as-a-story D6 — the crew that actually worked, by model: `claude-opus-5-5 ≈$40.12 · claude-sonnet-5-5 ≈$3.10`,
741
+ * most spent first. Subagents are in the transcripts, so a delegated builder shows here; outside tools (Codex, agy,
742
+ * Devin) are not, and the retrospective names them from the PR's review records. Null when nothing was measured.
743
+ */
744
+ export function modelsText(byModel) {
745
+ const rows = Object.entries(byModel ?? {}).filter(([, p]) => p && typeof p.usd === 'number');
746
+ if (!rows.length) return null;
747
+ return rows
748
+ .sort((a, b) => b[1].usd - a[1].usd || (a[0] < b[0] ? -1 : 1))
749
+ .map(([model, p]) => `${model} ${usdText(p.usd, p.usd_known)}`)
750
+ .join(' · ');
751
+ }
752
+
753
+ /** The actual_* fields for an epic's report: the spend, its tokens, where it came from, and by which models. */
737
754
  export function actualFields(report, basisPrefix, date) {
738
755
  return {
739
756
  actual_usd: report.usd,
740
757
  actual_mtok: report.mtok,
758
+ actual_models: modelsText(report.by_model),
741
759
  actual_basis: `${basisPrefix}${MACHINE} · ${date} · ${report.sessions} session${report.sessions === 1 ? '' : 's'}${
742
760
  report.usd_known ? '' : ' · lower bound (unpriced model)'
743
761
  } · prices ${PRICES_AS_OF}`,
@@ -226,9 +226,20 @@ export function stateOf(parsed) {
226
226
  pitch: parsed.pitch,
227
227
  claims: Object.fromEntries(parsed.claims.map((c, i) => [claimKey(i), c])),
228
228
  criteria: Object.fromEntries(parsed.criteria.map((a, j) => [criterionKey(j), a])),
229
+ // why-as-a-story D3: the Why, asked about on its own (advisory, outside the total). Only when the seed has one.
230
+ ...(whyOf(parsed) ? { why: whyOf(parsed) } : {}),
229
231
  };
230
232
  }
231
233
 
234
+ /** The seed's Why (`hypothesis`), or null when it has none. Pure. */
235
+ export function whyOf(parsed) {
236
+ const v = parsed.frontmatter?.hypothesis;
237
+ // A folded YAML block (`>-`, `|`) is not a Why the flat reader can see: never send its indicator to Jev as one.
238
+ return typeof v === 'string' && v.trim() && v.trim() !== 'null' && !/^[>|][1-9]?[+-]?[1-9]?$/.test(v.trim())
239
+ ? v.trim()
240
+ : null;
241
+ }
242
+
232
243
  /**
233
244
  * The scoring request. Pure. Claims are required — without the ask there is nothing to match against, so the
234
245
  * caller reports "could not look" rather than a clarity-only number wearing the name "intent match".
@@ -242,6 +253,7 @@ export function buildRequest(parsed) {
242
253
  questions[`out_${criterionKey(j)}`] = itemQuestion('coverage_out', `criteria.${criterionKey(j)}`);
243
254
  questions[`clar_${criterionKey(j)}`] = itemQuestion('clarity', `criteria.${criterionKey(j)}`);
244
255
  });
256
+ if (whyOf(parsed)) questions.why_story = itemQuestion('why_story', 'why');
245
257
  return { state: stateOf(parsed), questions };
246
258
  }
247
259
 
@@ -316,7 +328,12 @@ export function scoreAnswers(parsed, answers) {
316
328
  const untraced = criteria
317
329
  .filter((a) => a.traced < 0.5)
318
330
  .map((a) => ({ id: a.id, value: a.traced, text: a.text }));
319
- return { ok: true, signals, total, present, band: band(total), claims, criteria, gaps, untraced };
331
+ // why-as-a-story D3: advisory and outside the total. A missing or malformed answer is "could not look", never a
332
+ // failure of the whole score: the total's calibration must not move because this question exists.
333
+ const w = answers?.why_story;
334
+ // Not asked (no Why in the seed) is undefined, so the report leaves the line out (verifier #343).
335
+ const whyStory = !whyOf(parsed) ? undefined : w?.type === 'noul' && isUnit(w.noul) ? w.noul : null;
336
+ return { ok: true, signals, total, present, band: band(total), claims, criteria, gaps, untraced, whyStory };
320
337
  }
321
338
 
322
339
  const GAP_WORDS = {
@@ -377,6 +394,17 @@ export function formatReport(result, { source = 'the seed', agreement, reader }
377
394
  agreement == null
378
395
  ? ' agreement pending (the optional reader at the architecture lock)'
379
396
  : row('agreement', agreement, reader ? `(reader: ${reader})` : ''),
397
+ ...(result.whyStory === undefined
398
+ ? []
399
+ : [
400
+ result.whyStory === null
401
+ ? ' why story — (could not look; advisory, not in the total)'
402
+ : row(
403
+ 'why story',
404
+ result.whyStory,
405
+ `(advisory, not in the total${result.whyStory < 0.5 ? ': rewrite the Why as a story' : ''})`
406
+ ),
407
+ ]),
380
408
  `Total ${result.total} / 100 — uncalibrated · signals: ${result.present.map((k) => SIGNAL_NAMES[k]).join(', ')}`,
381
409
  `Band: ${result.band} (placeholder bands: 80 build · 60 resolve follow-ups · below 60 sketch or spike)`,
382
410
  ];
@@ -82,6 +82,17 @@
82
82
  },
83
83
  "measured": null,
84
84
  "unmeasured": "A route is a suggestion for the next artifact and never part of the score, so it has no labels (intent-match D15)."
85
+ },
86
+ {
87
+ "id": "why_story",
88
+ "type": "noul",
89
+ "instructions": "{item} is the Why of a planned change, written for a founder to approve. Would a stranger with no technical background follow it as a short story: who has a problem today, what changes for them, and why that matters?",
90
+ "criteria": {
91
+ "true": "Yes: it tells who has the problem, what changes for them and why it matters, in plain words a stranger could follow.",
92
+ "false": "No: it lists parts, components or mechanics, leans on internal or technical terms, or leaves out who it is for or why it matters."
93
+ },
94
+ "measured": null,
95
+ "unmeasured": "why-as-a-story D3, 2026-10-10: no labelled Whys yet; advisory, reported outside the total."
85
96
  }
86
97
  ]
87
98
  }
@@ -45,7 +45,9 @@ export const STORY_FIELDS = ['id', 'title', 'as_a', 'i_want', 'so_that', 'risk',
45
45
  // All optional: an epic scaffolded before FinOps has none, and an absent field is never a zero (D4). The numeric
46
46
  // ones are numbers >= 0 or null; a `*_basis` says where the number came from, in words.
47
47
  export const FINOPS_NUMERIC_FIELDS = ['quote_low_usd', 'quote_high_usd', 'actual_usd', 'actual_mtok'];
48
- export const FINOPS_BASIS_FIELDS = ['quote_basis', 'actual_basis'];
48
+ // `actual_models` (why-as-a-story D6) is text too, validated here but NOT in FINOPS_FIELDS: it stays in the README for the
49
+ // retrospective's Crew line and is never pushed, so the hosted push schema does not change.
50
+ export const FINOPS_BASIS_FIELDS = ['quote_basis', 'actual_basis', 'actual_models'];
49
51
  export const FINOPS_FIELDS = [
50
52
  'quote_low_usd',
51
53
  'quote_high_usd',
@@ -110,14 +112,16 @@ export function validateResultFields(fm) {
110
112
  for (const key of RESULT_DAY_FIELDS)
111
113
  if (has(key) && !isDay(fm[key])) bad(`${key}: "${fm[key]}" is not a day written YYYY-MM-DD (or null)`);
112
114
  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)`);
115
+ if (has(key) && (typeof fm[key] !== 'string' || !fm[key].trim()))
116
+ bad(`${key}: "${fm[key]}" is not text (or null)`);
114
117
  // A target is the three together — which number, from what, to what (fresh review, #290): from/to or a read date
115
118
  // with no metric would never come due (everything keys off target_metric), and a metric with no numbers has no
116
119
  // direction to judge. A hypothesis on its own is allowed: a sentence is not a target.
117
120
  const targetKeys = ['target_metric', 'target_from', 'target_to', 'read_date'];
118
121
  if (targetKeys.some(has)) {
119
122
  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`);
123
+ if (!has(key))
124
+ bad(`a target needs target_metric, target_from and target_to together — ${key} is missing`);
121
125
  }
122
126
  if (has('target_from') && has('target_to') && fm.target_from === fm.target_to)
123
127
  bad(`target_from and target_to are both ${fm.target_from}: a target has to move the number`);
@@ -127,9 +131,11 @@ export function validateResultFields(fm) {
127
131
  for (const key of VERDICT_FIELDS.slice(1)) if (has(key)) bad(`${key} is set but there is no verdict`);
128
132
  } else {
129
133
  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)');
134
+ if (!has('verdict_evidence'))
135
+ bad('a verdict needs verdict_evidence (a pointer, or for unclear, the reason)');
131
136
  if (fm.verdict === 'proven' || fm.verdict === 'disproven') {
132
- if (!has('verdict_actual')) bad(`verdict: ${fm.verdict} needs verdict_actual (the number that was read)`);
137
+ if (!has('verdict_actual'))
138
+ bad(`verdict: ${fm.verdict} needs verdict_actual (the number that was read)`);
133
139
  if (has('verdict_evidence') && !isEvidencePointer(fm.verdict_evidence))
134
140
  bad(
135
141
  `verdict: ${fm.verdict} needs evidence that points somewhere: an https:// link, ` +
@@ -165,7 +171,13 @@ export function validateFlagKey(fm) {
165
171
  // one-bet-wired D1 — a bet's measurement: what its flag funnel reads. Optional as a whole; once any field is set it
166
172
  // needs a flag and an adoption event, the only segment is `everyone` (named segments are tars-segments), and the window
167
173
  // is 1–90 whole days. Event names are tokens: what the SDK sends.
168
- export const BET_FIELDS = ['target_segment', 'adopted_event', 'retained_event', 'retention_days', 'satisfied_event'];
174
+ export const BET_FIELDS = [
175
+ 'target_segment',
176
+ 'adopted_event',
177
+ 'retained_event',
178
+ 'retention_days',
179
+ 'satisfied_event',
180
+ ];
169
181
  export const BET_SEGMENTS = ['everyone'];
170
182
  const EVENT_NAME = /^[A-Za-z0-9_.:$-]{1,200}$/;
171
183
 
@@ -178,15 +190,98 @@ export function validateBet(fm) {
178
190
  if (!has('flag_key')) bad('a bet with measurement needs a flag_key (the flag whose funnel it reads)');
179
191
  if (!has('adopted_event')) bad('a bet with measurement needs an adopted_event (what counts as adopting)');
180
192
  if (has('target_segment') && !BET_SEGMENTS.includes(fm.target_segment))
181
- bad(`target_segment: "${fm.target_segment}" is not one of ${BET_SEGMENTS.join(' | ')} (named segments come with tars-segments)`);
193
+ bad(
194
+ `target_segment: "${fm.target_segment}" is not one of ${BET_SEGMENTS.join(' | ')} (named segments come with tars-segments)`
195
+ );
182
196
  for (const key of ['adopted_event', 'retained_event', 'satisfied_event'])
183
197
  if (has(key) && !(typeof fm[key] === 'string' && EVENT_NAME.test(fm[key])))
184
198
  bad(`${key}: "${fm[key]}" is not an event name (letters, digits, _ . : $ -)`);
185
- if (has('retention_days') && !(Number.isInteger(fm.retention_days) && fm.retention_days >= 1 && fm.retention_days <= 90))
199
+ if (
200
+ has('retention_days') &&
201
+ !(Number.isInteger(fm.retention_days) && fm.retention_days >= 1 && fm.retention_days <= 90)
202
+ )
186
203
  bad(`retention_days: "${fm.retention_days}" is not a whole number of days from 1 to 90`);
187
204
  return offenses;
188
205
  }
189
206
 
207
+ // why-as-a-story D2/D4 — the Why is read in full, so it must fit what shows it. The build view gives the Why WHY_ROOM
208
+ // columns (80, less its 11-column label) and WHY_LINES_MAX lines; `wrapWords` is the ONE wrap both the view and the
209
+ // guard use, so "passes the guard" and "shows in full" cannot drift apart. The rest of the guard keeps it plain words:
210
+ // what a script can tell (a path, a backtick, a code name, a short list of internal words). Whether it reads as a story
211
+ // is a judgment (intent-match's advisory question), never this.
212
+ export const WHY_ROOM = 69;
213
+ export const WHY_LINES_MAX = 5;
214
+ export const WHY_INTERNAL_WORDS = Object.freeze([
215
+ 'wiring',
216
+ 'wired',
217
+ 'seam',
218
+ 'endpoint',
219
+ 'frontmatter',
220
+ 'schema',
221
+ 'payload',
222
+ 'middleware',
223
+ 'refactor',
224
+ ]);
225
+
226
+ /** Words into lines of at most `room` characters; past `max` lines, the last one ends in "…". Pure. */
227
+ export function wrapWords(text, room = WHY_ROOM, max = WHY_LINES_MAX) {
228
+ const lines = [];
229
+ let line = '';
230
+ for (const word of String(text ?? '')
231
+ .split(/\s+/)
232
+ .filter(Boolean)) {
233
+ const next = line ? `${line} ${word}` : word;
234
+ if (next.length <= room) line = next;
235
+ else {
236
+ if (line) lines.push(line);
237
+ // One word wider than the room is the only thing ever cut mid-word.
238
+ line = word.length > room ? `${word.slice(0, room - 1)}…` : word;
239
+ }
240
+ }
241
+ if (line) lines.push(line);
242
+ if (lines.length <= max) return lines;
243
+ const kept = lines.slice(0, max);
244
+ const last = kept[max - 1];
245
+ kept[max - 1] = last.length < room ? `${last}…` : `${last.slice(0, room - 1)}…`;
246
+ return kept;
247
+ }
248
+
249
+ /** A Why (a Feature's `hypothesis`) → what keeps it from reading in full and in plain words. Empty = fine. Pure. */
250
+ export function whyProblems(text) {
251
+ const why = String(text ?? '').trim();
252
+ if (!why) return ['the Why is empty'];
253
+ const problems = [];
254
+ if (/^[>|][1-9]?[+-]?[1-9]?$/.test(why))
255
+ return ['it is a folded YAML block the readers cannot see: write the Why on one line, in quotes'];
256
+ // A word wider than a line is cut mid-word in the view, so it fails here too (verifier #343).
257
+ const wide = why.split(/\s+/).find((w) => w.length > WHY_ROOM);
258
+ if (wide)
259
+ problems.push(`one word is wider than a line ("${wide.slice(0, 24)}…"): shorten it or leave it out`);
260
+ const lines = wrapWords(why, WHY_ROOM, Infinity).length;
261
+ if (lines > WHY_LINES_MAX)
262
+ problems.push(
263
+ `it takes ${lines} lines on screen; the build view shows ${WHY_LINES_MAX} (about 320 characters)`
264
+ );
265
+ if (why.includes('`')) problems.push('it has a backtick: write the words, not code');
266
+ const path = why.match(
267
+ // A file with a code extension (not `.js`: Next.js and Node.js are product names), or a path rooted at `/`, `./` or
268
+ // `../`. A bare `reader/writer/editor` or `and/or` is prose (verifier #343).
269
+ /\b[\w-]+\.(?:mjs|cjs|tsx?|md|json|sql|ya?ml)\b|(?:^|\s)\.{0,2}\/[\w.-]+(?:\/[\w.-]+)+/
270
+ );
271
+ if (path) problems.push(`it names a file or path ("${path[0].trim()}")`);
272
+ const ident = why.match(
273
+ // snake_case (and dotted keys holding one, `bets.flag_funnels_enabled`), or camelCase with a lowercase run of two or
274
+ // more before the hump and a lowercase after it, so iPhone, eBay, iOS and macOS stay prose (verifier #343).
275
+ /\b[a-z][a-z0-9]*(?:[._][a-z0-9]+)*_[a-z0-9]+\b|\b[a-z]{2,}[A-Z][a-z][A-Za-z0-9]*\b/
276
+ );
277
+ if (ident) problems.push(`it names a code identifier ("${ident[0]}")`);
278
+ const words = why.toLowerCase().match(/[a-z]+/g) ?? [];
279
+ const internal = WHY_INTERNAL_WORDS.filter((w) => words.includes(w));
280
+ if (internal.length)
281
+ problems.push(`it uses internal words (${internal.join(', ')}): say what changes for the person`);
282
+ return problems;
283
+ }
284
+
190
285
  /** `grounded:` as a boolean: the frontmatter readers keep a bare `true` as the string "true". Null when absent or neither. */
191
286
  export function groundedValue(v) {
192
287
  if (v === true || v === 'true') return true;
@@ -203,8 +298,10 @@ export function validateGrounded(fm) {
203
298
  const reason = fm.grounded_reason;
204
299
  const hasReason = reason !== undefined && reason !== null;
205
300
  if (raw !== undefined && raw !== null && g === null) bad(`grounded: "${raw}" is not true, false or null`);
206
- if (hasReason && (typeof reason !== 'string' || !reason.trim())) bad(`grounded_reason: "${reason}" is not text (or null)`);
207
- if (g === false && !hasReason) bad('grounded: false needs grounded_reason (why it was funded anyway, one sentence)');
301
+ if (hasReason && (typeof reason !== 'string' || !reason.trim()))
302
+ bad(`grounded_reason: "${reason}" is not text (or null)`);
303
+ if (g === false && !hasReason)
304
+ bad('grounded: false needs grounded_reason (why it was funded anyway, one sentence)');
208
305
  if (g !== false && hasReason) bad('grounded_reason is set but grounded is not false');
209
306
  return offenses;
210
307
  }
@@ -219,7 +316,12 @@ export function validateLockedAt(fm) {
219
316
  const v = fm.locked_at;
220
317
  if (v === undefined || v === null) return [];
221
318
  if (typeof v === 'string' && LOCKED_AT_RE.test(v) && !Number.isNaN(Date.parse(v))) return [];
222
- return [{ rule: 'contract-locked-at-invalid', detail: `locked_at: "${v}" is not an ISO date-time (e.g. "2026-10-03T20:34:34Z")` }];
319
+ return [
320
+ {
321
+ rule: 'contract-locked-at-invalid',
322
+ detail: `locked_at: "${v}" is not an ISO date-time (e.g. "2026-10-03T20:34:34Z")`,
323
+ },
324
+ ];
223
325
  }
224
326
 
225
327
  /** The FinOps fields of an epic's frontmatter data → offenses (`contract-finops-invalid`). Absent/null is fine. */
@@ -22,5 +22,13 @@ the scope `scripts/review-config.json` sets. A family never reviews its own diff
22
22
  by hand; a capped family is routed past with `--exclude <family>`, and if only one can run it runs both
23
23
  prompts and you say so in the PR body. You merge your own PR on a green gate once findings are resolved.
24
24
 
25
+ Dispatch (the strongest model, this session, is the orchestrator): keep the plan, the shared surface (CI,
26
+ `package.json`, lint config, a lib module many stories import), money, auth, migrations, tenancy, concurrency, high-risk
27
+ stories and every merge. A story with a clear acceptance check, no shared surface and risk low or medium goes to
28
+ a Sonnet-class builder in its own worktree, with a brief naming the story, the files, the checks to run and
29
+ "commit, do not push". Re-derive the result yourself (the diff, the tests); never trust the builder's final
30
+ message. After one failed attempt, take the story back. The Plan gate's Crew block is the plan; the
31
+ retrospective records what happened. Where things stand, at any time: `refine/references/checkpoint.md`.
32
+
25
33
  Sprint {{N}} of "{{EPIC_TITLE}}" — "{{SPRINT_TITLE}}" — stories:
26
34
  {{STORY_LIST}}
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // why-check.mjs — does this Why read in full and in plain words? (why-as-a-story D2)
3
+ //
4
+ // node scripts/why-check.mjs "<the Why>" check a sentence
5
+ // node scripts/why-check.mjs --seed <seed.md> check a seed's (or an epic README's) `hypothesis`
6
+ //
7
+ // Exit 0: it fits the build view and reads as plain words. Exit 1: the problems, one per line. Exit 2: usage.
8
+ // Whether it reads as a STORY is a judgment, asked by intent-match (advisory); this checks only what a script can tell.
9
+
10
+ import { readFileSync, realpathSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ parseDocFrontmatter,
14
+ whyProblems,
15
+ wrapWords,
16
+ WHY_LINES_MAX,
17
+ WHY_ROOM,
18
+ } from './lib/roadmap-contract.mjs';
19
+
20
+ const USAGE = 'usage: why-check "<the Why>" | why-check --seed <seed.md>';
21
+
22
+ /** argv → { text } | { error }. Pure, except reading the seed file it names. */
23
+ export function whyFromArgs(argv, read = (p) => readFileSync(p, 'utf8')) {
24
+ if (argv[0] === '--seed') {
25
+ if (!argv[1]) return { error: USAGE };
26
+ let md;
27
+ try {
28
+ md = read(argv[1]);
29
+ } catch (e) {
30
+ return { error: `why-check: cannot read ${argv[1]} (${e.code ?? e.message})` };
31
+ }
32
+ const { data } = parseDocFrontmatter(md);
33
+ const text = data?.hypothesis;
34
+ if (text === undefined || text === null || text === 'null')
35
+ return { error: `why-check: ${argv[1]} has no hypothesis` };
36
+ return { text: String(text) };
37
+ }
38
+ if (argv.length !== 1 || argv[0].startsWith('--')) return { error: USAGE };
39
+ return { text: argv[0] };
40
+ }
41
+
42
+ export function main(argv, out = process.stdout, err = process.stderr) {
43
+ const parsed = whyFromArgs(argv);
44
+ if (parsed.error) {
45
+ err.write(`${parsed.error}\n`);
46
+ return 2;
47
+ }
48
+ const problems = whyProblems(parsed.text);
49
+ if (problems.length) {
50
+ out.write(`The Why needs another pass:\n${problems.map((p) => ` - ${p}`).join('\n')}\n`);
51
+ return 1;
52
+ }
53
+ const lines = wrapWords(parsed.text, WHY_ROOM, Infinity).length;
54
+ out.write(`The Why reads in full (${lines} of ${WHY_LINES_MAX} lines) and in plain words.\n`);
55
+ return 0;
56
+ }
57
+
58
+ const isMain = (() => {
59
+ try {
60
+ return (
61
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
62
+ );
63
+ } catch {
64
+ return false;
65
+ }
66
+ })();
67
+ if (isMain) process.exit(main(process.argv.slice(2)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "1.5.0",
3
+ "version": "1.6.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": {