@golden-frijoles/kit 1.4.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/config-registry.mjs +13 -0
- package/dist/lib/config.mjs +1 -0
- package/dist/lib/cross-agent-cli.mjs +2 -2
- package/dist/lib/jev-questions/intent.json +11 -0
- package/dist/lib/roadmap-contract.mjs +136 -8
- package/dist/roadmap-extract.mjs +17 -0
- 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
|
];
|
|
@@ -44,6 +44,19 @@ export const REGISTRY = Object.freeze([
|
|
|
44
44
|
question:
|
|
45
45
|
'An account adds flags you can roll out and turn off, each bet measured on its read date, every product in one place and an outcome report you can send. Sign in now (recommended: frijoles login, then frijoles init), or later?',
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
// one-bet-wired D6 — the one place an account is suggested outside setup: refine's Measure question, when the
|
|
49
|
+
// founder wants to know if a feature worked and is not signed in. Asked once per project; the answer is kept here.
|
|
50
|
+
key: 'measure.signIn',
|
|
51
|
+
module: 'Measure',
|
|
52
|
+
// Asked by refine's Measure step, never by setup, and only when the founder is signed out: `never-yet` keeps
|
|
53
|
+
// `frijoles doctor` from reporting Measure as unconfigured for everyone (verifier, #341).
|
|
54
|
+
askWhen: 'never-yet',
|
|
55
|
+
default: null,
|
|
56
|
+
choices: ['later', 'now'],
|
|
57
|
+
question:
|
|
58
|
+
'Measuring needs a Golden Frijoles account: it serves the flag and counts who used it. Sign in now (opens your browser once), or later (the bet is saved here either way)?',
|
|
59
|
+
},
|
|
47
60
|
{
|
|
48
61
|
key: 'board.sink',
|
|
49
62
|
module: 'Plan',
|
package/dist/lib/config.mjs
CHANGED
|
@@ -54,10 +54,10 @@ export const AGENT_BIN = {
|
|
|
54
54
|
// Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
|
|
55
55
|
// `agy models`), but it means a future typo in either constant would silently review with the WRONG model
|
|
56
56
|
// instead of failing loud — watch for that if either constant is ever edited.
|
|
57
|
-
// agy-doctor: last verified 2026-10-
|
|
57
|
+
// agy-doctor: last verified 2026-10-10 against 1.3.3.
|
|
58
58
|
// ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
|
|
59
59
|
// below) after a green live contract probe. Don't hand-edit the marker's shape.
|
|
60
|
-
export const AGY_PINNED = '1.3.
|
|
60
|
+
export const AGY_PINNED = '1.3.3';
|
|
61
61
|
|
|
62
62
|
// agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
|
|
63
63
|
// nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
|
|
@@ -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, ` +
|
|
@@ -162,6 +168,120 @@ export function validateFlagKey(fm) {
|
|
|
162
168
|
// grounded-bets D1 — `grounded:` is the founder's word at Stage 1.5: true (traced to a North Star input), false (funded
|
|
163
169
|
// anyway, and `grounded_reason` says why), or absent/null (a Bug, a Chore, or an epic refined before it existed).
|
|
164
170
|
// Whether a bet COUNTS as grounded is derived from its target (bets-grounded.mjs, D2), never from this field alone.
|
|
171
|
+
// one-bet-wired D1 — a bet's measurement: what its flag funnel reads. Optional as a whole; once any field is set it
|
|
172
|
+
// needs a flag and an adoption event, the only segment is `everyone` (named segments are tars-segments), and the window
|
|
173
|
+
// is 1–90 whole days. Event names are tokens: what the SDK sends.
|
|
174
|
+
export const BET_FIELDS = [
|
|
175
|
+
'target_segment',
|
|
176
|
+
'adopted_event',
|
|
177
|
+
'retained_event',
|
|
178
|
+
'retention_days',
|
|
179
|
+
'satisfied_event',
|
|
180
|
+
];
|
|
181
|
+
export const BET_SEGMENTS = ['everyone'];
|
|
182
|
+
const EVENT_NAME = /^[A-Za-z0-9_.:$-]{1,200}$/;
|
|
183
|
+
|
|
184
|
+
/** The bet's measurement fields → offenses (`contract-bet-invalid`). All absent is fine. */
|
|
185
|
+
export function validateBet(fm) {
|
|
186
|
+
const offenses = [];
|
|
187
|
+
const bad = (detail) => offenses.push({ rule: 'contract-bet-invalid', detail });
|
|
188
|
+
const has = (key) => fm[key] !== undefined && fm[key] !== null;
|
|
189
|
+
if (!BET_FIELDS.some(has)) return offenses;
|
|
190
|
+
if (!has('flag_key')) bad('a bet with measurement needs a flag_key (the flag whose funnel it reads)');
|
|
191
|
+
if (!has('adopted_event')) bad('a bet with measurement needs an adopted_event (what counts as adopting)');
|
|
192
|
+
if (has('target_segment') && !BET_SEGMENTS.includes(fm.target_segment))
|
|
193
|
+
bad(
|
|
194
|
+
`target_segment: "${fm.target_segment}" is not one of ${BET_SEGMENTS.join(' | ')} (named segments come with tars-segments)`
|
|
195
|
+
);
|
|
196
|
+
for (const key of ['adopted_event', 'retained_event', 'satisfied_event'])
|
|
197
|
+
if (has(key) && !(typeof fm[key] === 'string' && EVENT_NAME.test(fm[key])))
|
|
198
|
+
bad(`${key}: "${fm[key]}" is not an event name (letters, digits, _ . : $ -)`);
|
|
199
|
+
if (
|
|
200
|
+
has('retention_days') &&
|
|
201
|
+
!(Number.isInteger(fm.retention_days) && fm.retention_days >= 1 && fm.retention_days <= 90)
|
|
202
|
+
)
|
|
203
|
+
bad(`retention_days: "${fm.retention_days}" is not a whole number of days from 1 to 90`);
|
|
204
|
+
return offenses;
|
|
205
|
+
}
|
|
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
|
+
|
|
165
285
|
/** `grounded:` as a boolean: the frontmatter readers keep a bare `true` as the string "true". Null when absent or neither. */
|
|
166
286
|
export function groundedValue(v) {
|
|
167
287
|
if (v === true || v === 'true') return true;
|
|
@@ -178,8 +298,10 @@ export function validateGrounded(fm) {
|
|
|
178
298
|
const reason = fm.grounded_reason;
|
|
179
299
|
const hasReason = reason !== undefined && reason !== null;
|
|
180
300
|
if (raw !== undefined && raw !== null && g === null) bad(`grounded: "${raw}" is not true, false or null`);
|
|
181
|
-
if (hasReason && (typeof reason !== 'string' || !reason.trim()))
|
|
182
|
-
|
|
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)');
|
|
183
305
|
if (g !== false && hasReason) bad('grounded_reason is set but grounded is not false');
|
|
184
306
|
return offenses;
|
|
185
307
|
}
|
|
@@ -194,7 +316,12 @@ export function validateLockedAt(fm) {
|
|
|
194
316
|
const v = fm.locked_at;
|
|
195
317
|
if (v === undefined || v === null) return [];
|
|
196
318
|
if (typeof v === 'string' && LOCKED_AT_RE.test(v) && !Number.isNaN(Date.parse(v))) return [];
|
|
197
|
-
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
|
+
];
|
|
198
325
|
}
|
|
199
326
|
|
|
200
327
|
/** The FinOps fields of an epic's frontmatter data → offenses (`contract-finops-invalid`). Absent/null is fine. */
|
|
@@ -401,6 +528,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
|
|
|
401
528
|
offenses.push(...validateResultFields(fm));
|
|
402
529
|
offenses.push(...validateFlagKey(fm));
|
|
403
530
|
offenses.push(...validateGrounded(fm));
|
|
531
|
+
offenses.push(...validateBet(fm));
|
|
404
532
|
offenses.push(...validateLockedAt(fm));
|
|
405
533
|
if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
|
|
406
534
|
offenses.push({
|
package/dist/roadmap-extract.mjs
CHANGED
|
@@ -178,6 +178,22 @@ export function flagFields(fm, readme) {
|
|
|
178
178
|
return { flag_key: !blank && FLAG_KEY_RE.test(raw) ? raw : null, flag_note: note || null };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* one-bet-wired D1 — the bet's measurement as pushed: each field as written when it is well-formed, else null (the
|
|
183
|
+
* contract names a bad one). Defaults are the reader's (an absent window is 7 days), never written here.
|
|
184
|
+
*/
|
|
185
|
+
export function betFields(fm) {
|
|
186
|
+
const token = (v) => (typeof v === 'string' && /^[A-Za-z0-9_.:$-]{1,200}$/.test(v.trim()) ? v.trim() : null);
|
|
187
|
+
const days = Number(fm.retention_days);
|
|
188
|
+
return {
|
|
189
|
+
target_segment: fm.target_segment === 'everyone' ? 'everyone' : null,
|
|
190
|
+
adopted_event: token(fm.adopted_event),
|
|
191
|
+
retained_event: token(fm.retained_event),
|
|
192
|
+
retention_days: Number.isInteger(days) && days >= 1 && days <= 90 ? days : null,
|
|
193
|
+
satisfied_event: token(fm.satisfied_event),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
/**
|
|
182
198
|
* grounded-bets D9 — the founder's grounding, as recorded at Stage 1.5: `grounded` true | false | null and, when false,
|
|
183
199
|
* `grounded_reason`. Anything else is null (the contract names it); the reason travels only with false.
|
|
@@ -648,6 +664,7 @@ export function buildRows({
|
|
|
648
664
|
...resultFields(epicFm, stage === 'Shipped' ? statusDay : null),
|
|
649
665
|
...flagFields(epicFm, readme),
|
|
650
666
|
...groundedFields(epicFm),
|
|
667
|
+
...betFields(epicFm),
|
|
651
668
|
});
|
|
652
669
|
|
|
653
670
|
// Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
|
|
@@ -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": {
|