@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.
- package/dist/build-state.mjs +34 -15
- package/dist/epic-actuals.mjs +20 -2
- package/dist/intent-match.mjs +29 -1
- package/dist/lib/jev-questions/intent.json +11 -0
- package/dist/lib/roadmap-contract.mjs +113 -11
- package/dist/templates/kickoff.md +8 -0
- package/dist/why-check.mjs +67 -0
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -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))
|
|
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) => ({
|
|
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 = [
|
|
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)
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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 [
|
|
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 =
|
|
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('│');
|
package/dist/epic-actuals.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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}`,
|
package/dist/intent-match.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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()))
|
|
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))
|
|
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'))
|
|
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'))
|
|
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 = [
|
|
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(
|
|
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 (
|
|
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()))
|
|
207
|
-
|
|
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 [
|
|
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.
|
|
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": {
|