@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.
@@ -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 !== state.stage ? ` · phase ${state.phase_written}` : '';
790
- return `${state.stage} · from ${source} (${age})${phase}`;
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
- const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
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')}${storyPart} · ${sprintPart}`);
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}}. The sprint files are integration, review
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 run \`node scripts/intent-reader.mjs --epic {{SLUG}}\` once: off by default (one line, nothing waits); on,
215
- one other family reads the pitch and any failure is a single \`reader skipped\` line. Never wait on it.
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.25.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": {