@golden-frijoles/kit 0.25.0 → 0.26.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 +109 -4
- package/dist/lib/epic-kickoff.mjs +5 -4
- package/dist/lib/roadmap-contract.mjs +14 -0
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -77,6 +77,89 @@ export function storyIdsIn(text) {
|
|
|
77
77
|
return [...String(text).matchAll(STORY_IN_TEXT_RE)].map((m) => `S${Number(m[1])}.${Number(m[2])}`);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// A LIST or RANGE of ids in one subject — `S1.1/1.2`, `S2.2-2.4`, `S1.1, 1.3 and 1.4` — names every id in it, but
|
|
81
|
+
// storyIdsIn reads only the ones with their own `S` (so `S1.1/1.2` is one id to it). The view's story-in-flight rule
|
|
82
|
+
// (D2) is unchanged and keeps storyIdsIn; the commit-msg check and the progress count read this (live-build-view D8).
|
|
83
|
+
// Each id ends where a number ends (`(?!\.?\d)` — a sentence's full stop is fine, `.288` is not): `S1.3, 2.1.288` must
|
|
84
|
+
// not read `2.1` out of a version number (#241).
|
|
85
|
+
const STORY_LIST_RE =
|
|
86
|
+
/\b(?:S|Story\s+)(\d+)\.(\d+)\b(?!\.?\d)((?:\s*(?:[/,+&\u2013-]|\band\b)\s*(?:S|Story\s+)?\d+\.\d+\b(?!\.?\d))*)/g;
|
|
87
|
+
|
|
88
|
+
/** Every story id a piece of text names, continuations included ("S1.1/1.2" → ['S1.1', 'S1.2']), in order. */
|
|
89
|
+
export function storyIdsInWithContinuations(text) {
|
|
90
|
+
return storyIdsWithKind(text).map((x) => x.id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Like storyIdsInWithContinuations, but says which ids were bare continuations (`1.2` after `S1.1/`) — weaker evidence. */
|
|
94
|
+
function storyIdsWithKind(text) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const m of String(text).matchAll(STORY_LIST_RE)) {
|
|
97
|
+
out.push({ id: `S${Number(m[1])}.${Number(m[2])}`, bare: false });
|
|
98
|
+
for (const c of (m[3] || '').matchAll(/(S|Story\s+)?(\d+)\.(\d+)/g))
|
|
99
|
+
out.push({ id: `S${Number(c[2])}.${Number(c[3])}`, bare: !c[1] });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── The commit-msg story check (live-build-view S2.1, D7/D8) ─────────────────────────────────────────────
|
|
105
|
+
// On a branch that resolves to an epic, a commit that changes behaviour names exactly ONE story that epic (or that
|
|
106
|
+
// sprint, on `-s<N>`) lists — so "which story is in flight" is a fact git guarantees, not a phrasing habit.
|
|
107
|
+
export const STORY_GATED_TYPES = Object.freeze(['feat', 'fix', 'perf', 'refactor']);
|
|
108
|
+
const CONVENTIONAL_RE = /^([A-Za-z]+)(?:\([^)]*\))?!?:/;
|
|
109
|
+
const EXEMPT_SUBJECT_RE = /^(?:Merge |Revert "|fixup! |squash! |amend! |Squashed commit of the following)/;
|
|
110
|
+
export const STORY_CHECK_BYPASS = 'GF_SKIP_STORY_CHECK';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The verdict for one commit subject on one branch: `{ ok: true, why }` or `{ ok: false, why, valid, scope, epic }`.
|
|
114
|
+
* Pure over the files under `root` (it reads the epic's docs through resolveTarget — no second branch parser). It never
|
|
115
|
+
* throws: a checkout this cannot read lets the commit through, and says so — a broken check must not block work.
|
|
116
|
+
*/
|
|
117
|
+
export function storyCheck({ root, branch, subject, env = {} }) {
|
|
118
|
+
if (env[STORY_CHECK_BYPASS] === '1') return { ok: true, why: `${STORY_CHECK_BYPASS}=1` };
|
|
119
|
+
const s = String(subject || '').trim();
|
|
120
|
+
if (EXEMPT_SUBJECT_RE.test(s)) return { ok: true, why: 'merge, revert or fixup' };
|
|
121
|
+
const type = CONVENTIONAL_RE.exec(s)?.[1]?.toLowerCase() ?? null;
|
|
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` };
|
|
124
|
+
if (!branch) return { ok: true, why: 'detached HEAD' };
|
|
125
|
+
let target;
|
|
126
|
+
try {
|
|
127
|
+
target = resolveTarget(root, branch);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { ok: true, why: `could not read the roadmap (${err && err.message ? err.message : err})` };
|
|
130
|
+
}
|
|
131
|
+
if (!target || target.kind !== 'epic') return { ok: true, why: `${branch} is not an epic branch` };
|
|
132
|
+
const { epic, sprint } = target;
|
|
133
|
+
if (!epic.contract) return { ok: true, why: `${epic.path} predates the frontmatter contract` };
|
|
134
|
+
const stories = epic.sprints.flatMap((sp) => sp.stories.map((st) => ({ ...st, sprint: sp.n })));
|
|
135
|
+
const valid = stories.filter((st) => sprint === null || st.sprint === sprint);
|
|
136
|
+
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
|
+
if (!valid.length) return { ok: true, why: `${scope} lists no stories` };
|
|
139
|
+
// 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
|
+
// spelled with its own `S` always counts, listed or not — naming a stranger is the mistake this check exists for.
|
|
141
|
+
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))];
|
|
143
|
+
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}`);
|
|
147
|
+
return { ok: true, why: `names ${ids[0]}` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The refusal, as the commit-msg hook prints it: what was wrong, the ids that would pass, and the way around. */
|
|
151
|
+
export function storyCheckMessage(verdict, branch) {
|
|
152
|
+
const list = verdict.valid.map((st) => ` ${st.id}${st.title ? ` ${st.title}` : ''}`).join('\n');
|
|
153
|
+
const example = verdict.valid[0]?.id ?? 'S1.1';
|
|
154
|
+
return [
|
|
155
|
+
`commit-msg: refused — a feat/fix/perf/refactor commit on ${branch} (${verdict.scope}) names exactly one story; ${verdict.why}.`,
|
|
156
|
+
` The stories it can name:`,
|
|
157
|
+
list,
|
|
158
|
+
` e.g. feat(scope): ${example} <what changed>`,
|
|
159
|
+
` docs/chore/test/ci/build/style commits are not checked. Bypass once: ${STORY_CHECK_BYPASS}=1 git commit …`,
|
|
160
|
+
].join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
80
163
|
// The branch parser lives in lib/work-branch.mjs now (board-sinks-and-scrumban D13), so the stage resolver reads a
|
|
81
164
|
// branch exactly as this view does. Re-exported: callers and the spec import them from here.
|
|
82
165
|
export { branchCandidates, parseBranch };
|
|
@@ -136,6 +219,7 @@ function readEpic(root, slug) {
|
|
|
136
219
|
area: readme.data.area ?? found.macro,
|
|
137
220
|
risk: readme.data.risk ?? null,
|
|
138
221
|
phase: readme.data.phase ?? null,
|
|
222
|
+
locked_at: readme.data.locked_at ?? null, // live-build-view D10 — stamped by scripts/epic-phase.mjs lock
|
|
139
223
|
lifecycle: readme.data.status ?? null,
|
|
140
224
|
quote: quoteOf(readme.data),
|
|
141
225
|
contract: readme.hasFrontmatter && !readme.error && 'phase' in readme.data,
|
|
@@ -608,6 +692,10 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
|
|
|
608
692
|
? (tryGit(git, ['log', '--format=%s', range]) || '').split('\n').filter(Boolean)
|
|
609
693
|
: [];
|
|
610
694
|
const storyCommits = subjects.filter((s) => storyIdsIn(s).some(accepts)).length;
|
|
695
|
+
// live-build-view S2.2 (D9): how many of this EPIC's stories have a commit — distinct ids the epic lists, from every id
|
|
696
|
+
// a subject names (continuations too, so a pre-S2.1 `S1.1/1.2` bundle counts both). Not sprint-filtered: a stacked
|
|
697
|
+
// `-s2` branch carries sprint 1's commits, and those are done stories.
|
|
698
|
+
const withCommits = new Set(subjects.flatMap(storyIdsInWithContinuations).filter((id) => byId.has(id)));
|
|
611
699
|
const foreign = [...new Set(subjects.flatMap(storyIdsIn).filter((id) => !accepts(id)))];
|
|
612
700
|
let story = null;
|
|
613
701
|
let storySource = 'unknown';
|
|
@@ -706,6 +794,7 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
|
|
|
706
794
|
area: epic.area,
|
|
707
795
|
risk: epic.risk,
|
|
708
796
|
phase: epic.phase,
|
|
797
|
+
locked_at: epic.locked_at,
|
|
709
798
|
path: epic.path,
|
|
710
799
|
},
|
|
711
800
|
sprint: sprint ? { n: sprint.n, title: sprint.title, phase: sprint.phase } : null,
|
|
@@ -723,6 +812,7 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
|
|
|
723
812
|
story_note: unreadableSprint ? `${unreadableSprint}${storyNote ? ` — ${storyNote}` : ''}` : storyNote,
|
|
724
813
|
warning: unreadableSprint,
|
|
725
814
|
progress: {
|
|
815
|
+
stories_with_commits: withCommits.size,
|
|
726
816
|
story: story ? story.ordinal : null,
|
|
727
817
|
stories: allStories.length,
|
|
728
818
|
sprint: sprint ? epic.sprints.indexOf(sprint) + 1 : null,
|
|
@@ -779,6 +869,18 @@ function elsewhereLines(state, pad) {
|
|
|
779
869
|
export function statusValue(state) {
|
|
780
870
|
if (!state.stage) return state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`;
|
|
781
871
|
const source = String(state.stage_source || '').replace(/ · snapshot@\S+$/, '');
|
|
872
|
+
// live-build-view D11 — the band's refinement of Building, not a new stage (the Hub and the board keep Building): an
|
|
873
|
+
// epic whose branch is live but whose README carries no `locked_at` is still Locking architecture. The lock is a
|
|
874
|
+
// command (scripts/epic-phase.mjs lock), so from here on every rung is set by a trigger.
|
|
875
|
+
// Only while the EPIC's written phase is still before the lock (the README's, not the sprint's — sprint files are born
|
|
876
|
+
// Shaping): an epic built before the command existed (README Building or later, no stamp) keeps reading Building (#241).
|
|
877
|
+
const locking =
|
|
878
|
+
state.kind === 'epic' &&
|
|
879
|
+
state.stage === 'Building' &&
|
|
880
|
+
!state.epic?.locked_at &&
|
|
881
|
+
[null, 'Shaping', 'Locking architecture'].includes(state.epic?.phase ?? null) &&
|
|
882
|
+
/^(?:git: |github: PR #\d+ draft)/.test(source);
|
|
883
|
+
const stage = locking ? 'Locking architecture' : state.stage;
|
|
782
884
|
const age =
|
|
783
885
|
state.facts_mode === 'live'
|
|
784
886
|
? 'live'
|
|
@@ -786,8 +888,8 @@ export function statusValue(state) {
|
|
|
786
888
|
? `snapshot, ${state.stage_age ?? 'age unknown'}`
|
|
787
889
|
: 'docs only, no snapshot yet';
|
|
788
890
|
const phase =
|
|
789
|
-
state.phase_written && state.phase_written !==
|
|
790
|
-
return `${
|
|
891
|
+
state.phase_written && state.phase_written !== stage ? ` · phase ${state.phase_written}` : '';
|
|
892
|
+
return `${stage} · from ${source} (${age})${phase}`;
|
|
791
893
|
}
|
|
792
894
|
|
|
793
895
|
/** The board around this work in one line, and the link to it when `board.hubUrl` is set (S3.1). */
|
|
@@ -844,9 +946,12 @@ export function renderLines(state) {
|
|
|
844
946
|
lines.push(`${pad('Story')}unknown`);
|
|
845
947
|
lines.push(`${cont}${state.story_note}`);
|
|
846
948
|
}
|
|
847
|
-
|
|
949
|
+
// S2.2 — stories DONE (with commits), not the in-flight story's position: "Story 1 of 7" at the end of a sprint
|
|
950
|
+
// was a position, and read as progress.
|
|
951
|
+
const done = `${progress.stories_with_commits ?? 0} of ${progress.stories} stories have commits`;
|
|
952
|
+
const inFlight = story ? ` · in flight ${story.id}` : '';
|
|
848
953
|
const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
|
|
849
|
-
lines.push(`${pad('Progress')}${
|
|
954
|
+
lines.push(`${pad('Progress')}${done}${inFlight} · ${sprintPart}`);
|
|
850
955
|
if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend, state.quote ?? null)}`);
|
|
851
956
|
lines.push(`${pad('Status')}${statusValue(state)}`);
|
|
852
957
|
return [...lines, ...also, ...boardLines(state, pad)];
|
|
@@ -201,8 +201,7 @@ export const EPIC_KICKOFF_TEMPLATE = `Start by pushing the epic branch, before a
|
|
|
201
201
|
\`git switch -c feat/{{SLUG}} origin/main && git push -u origin feat/{{SLUG}}\` (resuming? \`git switch feat/{{SLUG}}\`).
|
|
202
202
|
|
|
203
203
|
Build this epic in ONE orchestrated run: "{{EPIC_TITLE}}" ({{SPRINT_COUNT}} sprints, risk {{RISK}}).
|
|
204
|
-
Docs: Roadmap/{{MACRO}}/{{SLUG}}/README.md and {{SPRINT_FILE_LIST}}.
|
|
205
|
-
and rollback boundaries inside this run, not separate sessions.
|
|
204
|
+
Docs: Roadmap/{{MACRO}}/{{SLUG}}/README.md and {{SPRINT_FILE_LIST}}.
|
|
206
205
|
|
|
207
206
|
The process is Roadmap/WAYS-OF-WORKING.md → *Epic-mode builds*, *Review & merge* and *Escalate, don't guess*,
|
|
208
207
|
under AGENTS.md. Read those, the epic docs, and the Roadmap/LEARNINGS.md entries that touch this area. Start
|
|
@@ -211,8 +210,10 @@ with \`node scripts/session-resume.mjs\`.
|
|
|
211
210
|
Non-negotiable for this run:
|
|
212
211
|
1. **Lock first.** Write \`D1…Dn\` and each sprint's build contract into the epic README, verified against
|
|
213
212
|
live code and live data, before any builder starts. Scope the live system disproves gets corrected out loud.
|
|
214
|
-
Then
|
|
215
|
-
|
|
213
|
+
Then stamp it: \`node scripts/epic-phase.mjs lock --epic {{SLUG}}\` (refuses a README with no D1; moves the band
|
|
214
|
+
from Locking architecture to Building), and run \`node scripts/intent-reader.mjs --epic {{SLUG}}\` once: off by
|
|
215
|
+
default (one line, nothing waits); on, one other family reads the pitch and any failure is a single
|
|
216
|
+
\`reader skipped\` line. Never wait on it.
|
|
216
217
|
2. **Stack** \`feat/{{SLUG}}\` → \`-s2\` → …, one PR per sprint, merged in order. Worktree or in place: decide
|
|
217
218
|
per *Epic-mode builds*.
|
|
218
219
|
3. **Review** every PR through \`node scripts/review-route.mjs --builder <who-wrote-it> <PR#>\` (one general
|
|
@@ -55,6 +55,19 @@ export const FINOPS_FIELDS = [
|
|
|
55
55
|
'actual_basis',
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
+
// live-build-view D10 — `locked_at:` is stamped by `scripts/epic-phase.mjs lock` when the architecture lock is written;
|
|
59
|
+
// the build view reads its absence as "Locking architecture". Optional (no epic before it has one), an ISO date-time
|
|
60
|
+
// string when present: the command writes `"2026-10-03T20:34:34Z"` (quoted, so the frontmatter parser keeps the colons).
|
|
61
|
+
export const LOCKED_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
62
|
+
|
|
63
|
+
/** An epic's `locked_at` → offenses (`contract-locked-at-invalid`). Absent/null is fine. */
|
|
64
|
+
export function validateLockedAt(fm) {
|
|
65
|
+
const v = fm.locked_at;
|
|
66
|
+
if (v === undefined || v === null) return [];
|
|
67
|
+
if (typeof v === 'string' && LOCKED_AT_RE.test(v) && !Number.isNaN(Date.parse(v))) return [];
|
|
68
|
+
return [{ rule: 'contract-locked-at-invalid', detail: `locked_at: "${v}" is not an ISO date-time (e.g. "2026-10-03T20:34:34Z")` }];
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
/** The FinOps fields of an epic's frontmatter data → offenses (`contract-finops-invalid`). Absent/null is fine. */
|
|
59
72
|
export function validateFinopsFields(fm) {
|
|
60
73
|
const offenses = [];
|
|
@@ -256,6 +269,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
|
|
|
256
269
|
offenses.push({ rule: 'contract-total-invalid', detail: `${key}: "${fm[key]}" is not a whole number` });
|
|
257
270
|
}
|
|
258
271
|
offenses.push(...validateFinopsFields(fm));
|
|
272
|
+
offenses.push(...validateLockedAt(fm));
|
|
259
273
|
if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
|
|
260
274
|
offenses.push({
|
|
261
275
|
rule: 'contract-total-mismatch',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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": {
|