@golden-frijoles/kit 0.19.0 → 0.20.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.
@@ -1,148 +1,117 @@
1
1
  #!/usr/bin/env node
2
- // build-order.mjs — render Roadmap/00-ideas/BUILD-ORDER.md from the SAME projection every roadmap
3
- // tool reads (roadmap-extract.mjs). One source of truth: seed frontmatter + epic/sprint
4
- // status lines. This file is GENERATED — never hand-edit BUILD-ORDER.md; run this instead.
2
+ // build-order.mjs — render Roadmap/00-ideas/BUILD-ORDER.md in the six stages, from the SAME projection every roadmap
3
+ // tool reads (roadmap-extract.mjs) and the ONE stage resolver (lib/stage.mjs). This file is GENERATED — never
4
+ // hand-edit BUILD-ORDER.md; run this instead.
5
5
  //
6
- // node scripts/build-order.mjs # write Roadmap/00-ideas/BUILD-ORDER.md
6
+ // node scripts/build-order.mjs # write Roadmap/00-ideas/BUILD-ORDER.md (docs only)
7
7
  // node scripts/build-order.mjs --check # exit 1 if the file is stale (for CI/precommit)
8
+ // node scripts/build-order.mjs --live # print the full six-stage board with git/GitHub facts; writes nothing
8
9
  //
9
- // Why this exists: BUILD-ORDER.md used to be hand-maintained, so it drifted on every merge. Status
10
- // and ordering already live in seed frontmatter (the SSOT the Notion projection reads). This makes
11
- // the in-repo board a derived view that cannot drift.
10
+ // ── Why the committed file is docs-only (board-sinks-and-scrumban lock C3) ───────────────────────────────
11
+ // Building and QA are FACTS git and GitHub hold (a branch on origin, a ready PR), never fields in a doc. A committed
12
+ // file that carried them would go stale on every PR event, and `--check` (build-order-guard, the nightly sync) would
13
+ // turn every unrelated PR red. So the file holds what the docs alone decide — To groom, Grooming, Ready to build,
14
+ // Shipped — and says where Building and QA live: `--live` here, the Hub board, and the build view. `--live` is the
15
+ // same resolver with live facts, so it agrees with the Hub by construction.
12
16
 
13
17
  import { execFileSync } from 'node:child_process';
14
18
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
19
  import { fileURLToPath } from 'node:url';
16
20
  import { dirname, join } from 'node:path';
17
- import { EPIC_STATUS_ORDER, SEED_FUNNEL_STATUSES } from './lib/roadmap-status-buckets.mjs';
21
+ import { STAGES, groupByStage } from './lib/stage.mjs';
18
22
  import { projectRoot } from './lib/project-root.mjs';
19
23
 
20
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
25
  const REPO = projectRoot(); // D2
22
26
  const OUT = join(REPO, 'Roadmap', '00-ideas', 'BUILD-ORDER.md');
23
27
  const EXTRACTOR = join(__dirname, 'roadmap-extract.mjs');
28
+ const LIVE = process.argv.includes('--live');
24
29
 
25
30
  function extract() {
26
- const json = execFileSync('node', [EXTRACTOR], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
31
+ const json = execFileSync('node', [EXTRACTOR, LIVE ? '--live' : '--docs-only'], {
32
+ encoding: 'utf8',
33
+ maxBuffer: 64 * 1024 * 1024,
34
+ stdio: ['ignore', 'pipe', 'inherit'],
35
+ });
27
36
  return JSON.parse(json);
28
37
  }
29
38
 
30
- // SSOT = each epic README's frontmatter `status:` field (00-ideas/README). The extractor resolves it
31
- // into r.status (frontmatter-authoritative, prose/retro fallback) and also emits r.status_derived (the
32
- // fallback derivation) so this board can flag an advisory drift when the two disagree. No seed read
33
- // here — a scaffolded epic's seed is funnel-only; the epic README owns its status.
34
-
35
- // Epic status → bucket. Sprints are folded into their epic via sprint_progress; seeds are the funnel.
36
- // The bucket KEYS/ORDER and the seed-funnel status set live in scripts/lib/roadmap-status-buckets.mjs —
37
- // shared with any other view of the roadmap (a reporting hub, a dashboard) so it can never independently
38
- // drift from this board's counts. Emoji/title here are this board's own presentation.
39
- const EPIC_BUCKET_META = {
40
- 'In progress': { emoji: '🏗️', title: 'Building now' },
41
- Scaffolded: { emoji: '📋', title: 'Ready to build (scaffolded, not started)' },
42
- Shipped: { emoji: '✅', title: 'Shipped' },
39
+ // What each stage means, in the words the board uses (the seed's table, locked 2026-10-01).
40
+ const STAGE_NOTE = {
41
+ 'To groom': 'seeds with no pitch yet',
42
+ Grooming: 'a pitch is waiting at the approval gate',
43
+ 'Ready to build': 'scaffolded (or a fixed-scope seed), in build order — pull from the top',
44
+ Building: 'a work branch is on origin',
45
+ QA: 'a PR is ready for review, or merged and waiting for its close-out',
46
+ Shipped: 'merged, deployed and closed',
43
47
  };
44
- const EPIC_BUCKETS = EPIC_STATUS_ORDER.map((key) => ({ key, ...EPIC_BUCKET_META[key] }));
45
- const SEED_FUNNEL = SEED_FUNNEL_STATUSES;
48
+ const LIVE_ONLY = new Set(['Building', 'QA']);
46
49
 
47
50
  function line(r) {
48
- const bits = [`[${r.name}](../../${r.doc_link.replace(/^Roadmap\//, '')})`];
51
+ // BUILD-ORDER.md lives in Roadmap/00-ideas/, so an epic README is ONE folder up — `../../` resolved to the repo
52
+ // root and 404'd every epic link on GitHub (seed board-link-depth, absorbed by board-sinks-and-scrumban S1.5).
53
+ const link = r.grain === 'Seed' ? `seeds/${r.slug}.md` : `../${r.doc_link.replace(/^Roadmap\//, '')}`;
49
54
  const meta = [];
55
+ if (r.build_order_num != null) meta.push(`#${r.build_order_num}`);
50
56
  if (r.area) meta.push(r.area);
57
+ if (r.grain === 'Seed') meta.push(`seed · ${r.type}`);
51
58
  if (r.sprint_progress) meta.push(r.sprint_progress);
52
59
  if (r.risk) meta.push(`risk: ${r.risk}`);
53
- if (r.priority) meta.push(r.priority);
54
- return `- ${bits[0]}${meta.length ? ` — ${meta.join(' · ')}` : ''}`;
60
+ if (r.appetite) meta.push(`appetite ${r.appetite}`);
61
+ // A queued seed without an underwriter is funded-but-unowned — advisory, loud on the board.
62
+ const warn =
63
+ r.grain === 'Seed' && r.status === 'Queued' && !r.underwritten_by
64
+ ? ' — ⚠️ no underwriter (set `underwritten_by:` at the betting table)'
65
+ : '';
66
+ return `- [${r.name}](${link}) — ${meta.join(' · ')} · _${r.stage_source}_${warn}`;
55
67
  }
56
68
 
57
69
  function render(rows) {
58
- const epics = rows.filter((r) => r.grain === 'Epic');
59
- const seeds = rows.filter((r) => r.grain === 'Seed');
70
+ // Shipped by build order, never by `shipped_at`: that is a git date, and a depth-1 CI clone would reorder the file.
71
+ const columns = groupByStage(rows, { shipped: 'build-order' });
60
72
  const now = new Date().toISOString().slice(0, 10);
61
-
62
- // Epic status is authoritative from the README frontmatter (the extractor resolved it into e.status).
63
- // Advisory drift = the prose/retro derivation (e.status_derived) disagreeing with that authoritative
64
- // status — usually a close-out that forgot to set `status:`, or a stale README. Non-gating signal.
65
- const drift = [];
66
- for (const e of epics) {
67
- if (e.status_derived && e.status_derived !== e.status) {
68
- drift.push({ name: e.name, frontmatter: e.status, derived: e.status_derived });
69
- }
70
- }
71
- render._drift = drift;
72
-
73
73
  const out = [];
74
74
  out.push('<!-- GENERATED FILE — do not edit by hand.');
75
75
  out.push(' Regenerate: node scripts/build-order.mjs');
76
- out.push(" Status SSOT: each epic README's frontmatter `status:` field (set at epic close). Funnel");
77
- out.push(' ordering: seed frontmatter (priority). Both projected via scripts/roadmap-extract.mjs. -->');
78
- out.push('');
79
- out.push('# Build order — generated status board');
80
- out.push('');
81
- out.push(
82
- `> **Generated ${now} — do not hand-edit.** Epic status SSOT = the epic \`README.md\` frontmatter`
83
- );
84
76
  out.push(
85
- '> `status:` field (set at epic close). To change what this shows, edit that field (or a seed for the'
77
+ " Stage: scripts/lib/stage.mjs, from each initiative's frontmatter (docs only in this file; the"
86
78
  );
87
79
  out.push(
88
- '> funnel), then run `node scripts/build-order.mjs`. This board and the Notion "Marketplace Roadmap"'
80
+ ' live board with git/GitHub facts is `node scripts/build-order.mjs --live` and the Hub). -->'
89
81
  );
90
- out.push('> DB are both *derived views* — never hand-edit the board.');
91
82
  out.push('');
92
-
93
- for (const b of EPIC_BUCKETS) {
94
- const list = epics
95
- .filter((e) => e.status === b.key)
96
- .sort((a, z) => (a.area || '').localeCompare(z.area || '') || a.name.localeCompare(z.name));
97
- out.push(`## ${b.emoji} ${b.title} (${list.length})`);
98
- out.push('');
99
- out.push(list.length ? list.map(line).join('\n') : '_None._');
100
- out.push('');
101
- }
102
-
103
- const funnel = seeds
104
- .filter((s) => SEED_FUNNEL.has(s.status))
105
- .sort((a, z) => (a.priority || 'zzz').localeCompare(z.priority || 'zzz') || a.name.localeCompare(z.name));
106
- out.push(`## ⬜ Funnel — seeds not yet scaffolded (${funnel.length})`);
83
+ out.push('# Build order — the six stages');
107
84
  out.push('');
108
- out.push(
109
- funnel.length
110
- ? funnel
111
- .map((s) => {
112
- const meta = [s.status, s.type, s.appetite ? `appetite ${s.appetite}` : null, s.priority]
113
- .filter(Boolean)
114
- .join(' · ');
115
- // A queued seed without an underwriter is funded-but-unowned — advisory drift, loud on the board.
116
- const warn =
117
- s.status === 'Queued' && !s.underwritten_by
118
- ? ' — ⚠️ no underwriter (set `underwritten_by:` at the betting table)'
119
- : '';
120
- return `- [${s.name}](seeds/${s.slug}.md)${meta ? ` — ${meta}` : ''}${warn}`;
121
- })
122
- .join('\n')
123
- : '_None._'
124
- );
85
+ out.push(`> **Generated ${now} — do not hand-edit.** One stage per initiative, decided in one place`);
86
+ out.push('> (`scripts/lib/stage.mjs`): To groom · Grooming · Ready to build · Building · QA · Shipped.');
87
+ if (LIVE) {
88
+ out.push('> **Live:** Building and QA come from git and GitHub as of this run. Printed, never written.');
89
+ } else {
90
+ out.push('> This committed file reads the docs alone, so **Building and QA are not here** — they are facts git');
91
+ out.push('> and GitHub hold. For the live board run `node scripts/build-order.mjs --live`, or open the Hub board.');
92
+ }
125
93
  out.push('');
126
-
127
- if (drift.length) {
128
- out.push(`## ⚠️ Status drift — README frontmatter vs sprint/retro-derived (${drift.length})`);
94
+ for (const stage of STAGES) {
95
+ const list = columns[stage];
96
+ if (LIVE_ONLY.has(stage) && !LIVE) {
97
+ out.push(`## ${stage} — live only`);
98
+ out.push('');
99
+ out.push(
100
+ `_${STAGE_NOTE[stage]}. Not in this committed file: \`node scripts/build-order.mjs --live\` or the Hub board._`
101
+ );
102
+ out.push('');
103
+ continue;
104
+ }
105
+ out.push(`## ${stage} (${list.length})`);
129
106
  out.push('');
130
- out.push('These epics’ authoritative README-frontmatter `status:` disagrees with what the sprint/retro');
131
- out.push('derivation infers. The board trusts the **frontmatter**; a mismatch usually means a close-out');
132
- out.push(
133
- 'forgot to set `status:` (or the README is stale). Reconcile the README, then this advisory clears.'
134
- );
107
+ out.push(`_${STAGE_NOTE[stage]}._`);
135
108
  out.push('');
136
- out.push('| Epic | frontmatter (used) | sprint/retro-derived |');
137
- out.push('|---|---|---|');
138
- for (const d of drift) out.push(`| ${d.name} | ${d.frontmatter} | ${d.derived} |`);
109
+ out.push(list.length ? list.map(line).join('\n') : '_None._');
139
110
  out.push('');
140
111
  }
141
-
112
+ const cards = STAGES.reduce((n, s) => n + columns[s].length, 0);
142
113
  out.push('---');
143
- out.push(
144
- `_Epics: ${epics.length} · seeds in funnel: ${funnel.length} · status drift: ${drift.length}. Regenerate with \`node scripts/build-order.mjs\`._`
145
- );
114
+ out.push(`_${cards} initiatives on the board. Regenerate with \`node scripts/build-order.mjs\`._`);
146
115
  out.push('');
147
116
  return out.join('\n');
148
117
  }
@@ -163,7 +132,10 @@ if (unfunded.length) {
163
132
 
164
133
  const content = render(rows);
165
134
 
166
- if (process.argv.includes('--check')) {
135
+ if (LIVE) {
136
+ // Printed, never written: the committed file must stay docs-only (lock C3).
137
+ process.stdout.write(content);
138
+ } else if (process.argv.includes('--check')) {
167
139
  const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '';
168
140
  // Ignore the "Generated <date>" line when comparing so a date-only diff isn't "stale".
169
141
  const norm = (s) => s.replace(/^> \*\*Generated \d{4}-\d{2}-\d{2} /m, '> **Generated DATE ');
@@ -58,11 +58,10 @@ import { fileURLToPath } from 'node:url';
58
58
  import { dirname, join, resolve } from 'node:path';
59
59
  import { PHASES, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
60
60
  import { parseJournal, JOURNAL_BRANCH, JOURNAL_PATH } from './lib/session-journal.mjs';
61
+ import { branchCandidates, parseBranch } from './lib/work-branch.mjs';
61
62
 
62
63
  const __dirname = dirname(fileURLToPath(import.meta.url));
63
64
 
64
- const BRANCH_PREFIX_RE = /^(?:feat|fix|chore|spike|bug|docs)\/(.+)$/;
65
- const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*$/;
66
65
  const SEEDS_DIR = ['Roadmap', '00-ideas', 'seeds'];
67
66
  const MAX_WORKTREES = 8; // resolved per refresh — a pile of stale agent worktrees must not stall the view
68
67
  const MAX_ELSEWHERE_LINES = 4;
@@ -74,45 +73,9 @@ export function storyIdsIn(text) {
74
73
  return [...String(text).matchAll(STORY_IN_TEXT_RE)].map((m) => `S${Number(m[1])}.${Number(m[2])}`);
75
74
  }
76
75
 
77
- /** A sprint token right after the slug: `s3`, `sprint3`, or `sprint` `3` → 3; else null. */
78
- function sprintAt(rest) {
79
- const m = String(rest[0] ?? '').match(/^s(?:print)?(\d+)$/);
80
- if (m) return Number(m[1]);
81
- return rest[0] === 'sprint' && /^\d+$/.test(rest[1] ?? '') ? Number(rest[1]) : null;
82
- }
83
-
84
- /**
85
- * Every reading of a work branch as `<slug>[-s<N>][-anything]`, LONGEST slug first:
86
- * `feat/foo-s4-licences` → foo-s4-licences, foo-s4, foo (sprint 4). The resolver takes the first reading
87
- * whose slug names an epic (or a seed) on disk. [] for a branch that isn't a work branch.
88
- */
89
- export function branchCandidates(branch) {
90
- const m = String(branch || '').match(BRANCH_PREFIX_RE);
91
- if (!m || !SLUG_RE.test(m[1]) || m[1].includes('..')) return [];
92
- const tokens = m[1].split('-');
93
- const out = [];
94
- for (let i = tokens.length; i >= 1; i--) {
95
- out.push({
96
- slug: tokens.slice(0, i).join('-'),
97
- sprint: sprintAt(tokens.slice(i)),
98
- exact: i === tokens.length,
99
- });
100
- }
101
- return out;
102
- }
103
-
104
- /**
105
- * The syntactic reading alone, no disk: `feat/foo-s3` → { slug: 'foo', sprint: 3 },
106
- * `feat/foo-s3-extra` → { slug: 'foo', sprint: 3 }; null for a branch that is not a work branch.
107
- */
108
- export function parseBranch(branch) {
109
- const cands = branchCandidates(branch);
110
- if (!cands.length) return null;
111
- const withSprint = cands.find((c) => c.sprint !== null);
112
- return withSprint
113
- ? { slug: withSprint.slug, sprint: withSprint.sprint }
114
- : { slug: cands[0].slug, sprint: null };
115
- }
76
+ // The branch parser lives in lib/work-branch.mjs now (board-sinks-and-scrumban D13), so the stage resolver reads a
77
+ // branch exactly as this view does. Re-exported: callers and the spec import them from here.
78
+ export { branchCandidates, parseBranch };
116
79
 
117
80
  function makeGit(root) {
118
81
  return (args) =>
@@ -0,0 +1,268 @@
1
+ // epic-kickoff.mjs — the epic-mode kickoff, as one pure builder (board-sinks-and-scrumban D17).
2
+ //
3
+ // It used to live only in the groom skill (`emit-epic-kickoff.mjs` + `templates/epic-kickoff.md`), which the kit does
4
+ // not ship and an installed extractor cannot import (lock C11). The Hub's "Ready to build" card has to carry the same
5
+ // kickoff the CLI prints — a kickoff composed twice is the exact drift `emit-epic-kickoff` was written to end — so
6
+ // the builder, the parsers it reads the docs with and the template text all live here, in the ONE source
7
+ // (`template/scripts/`). The groom skill imports a byte-identical copy that `skills/scripts/render-hook-vendor.mjs`
8
+ // writes into `groom/vendor/` and checks in CI; the extractor imports this file directly.
9
+ //
10
+ // The parsers moved here from `emit-kickoff.mjs` unchanged; that file re-exports them, so the per-sprint generator
11
+ // and this one still read the doc format with ONE set of parsers.
12
+ //
13
+ // Zero deps beyond node:fs/node:path (for `epicKickoffFromDir`, the only function that touches the disk).
14
+
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ // Same substitution approach as scaffold-epic.mjs's `sub()` — {{PLACEHOLDER}} → value, leaves
19
+ // unknown placeholders untouched (so a missing var surfaces loudly in the printed output).
20
+ export const sub = (str, vars) => str.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in vars ? vars[k] : `{{${k}}}`));
21
+
22
+ // Parses the epic README's YAML-ish frontmatter block (the two `---` fence lines and simple
23
+ // `key: value` lines between them — no nested structures, matches what scaffold-epic.mjs emits).
24
+ export function parseFrontmatter(text) {
25
+ const lines = text.split('\n');
26
+ if (lines[0].trim() !== '---') return {};
27
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
28
+ if (end === -1) return {};
29
+ const out = {};
30
+ for (const line of lines.slice(1, end)) {
31
+ const m = line.match(/^(\w+):\s*(.*)$/);
32
+ // Strip a YAML inline comment (whitespace + `#` … to end-of-line) before
33
+ // trimming: frontmatter routinely annotates values, e.g.
34
+ // `status: in-progress # AUTHORITATIVE …`. Without this, a comment on
35
+ // the `slug:` line would make `frontmatter.slug !== slug` a false mismatch
36
+ // and crash the run on otherwise-fine data. Only an UNquoted value is
37
+ // parsed here (these frontmatters never quote), so this can't eat a `#`
38
+ // inside a quoted string.
39
+ if (m) out[m[1]] = m[2].replace(/\s+#.*$/, '').trim();
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // Strips a leading `---`…`---` frontmatter fence, if present, returning only the body. Frontmatter
45
+ // commonly carries trailing `# comment` annotations on its own lines (e.g. `status: in-progress #
46
+ // AUTHORITATIVE epic status …`, or a bare `# note` line) — those must never be mistaken for the H1,
47
+ // so every H1 search below runs against this stripped body, not the raw file text.
48
+ export function stripFrontmatter(text) {
49
+ const lines = text.split('\n');
50
+ if (lines[0].trim() !== '---') return text;
51
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
52
+ if (end === -1) return text;
53
+ return lines.slice(end + 1).join('\n');
54
+ }
55
+
56
+ // The epic README's H1 is `# Epic: <title>` — strip the `Epic: ` prefix. Searches only the body
57
+ // AFTER the frontmatter fence (see stripFrontmatter) so a `# `-led YAML comment can't win first.
58
+ export function parseEpicTitle(text) {
59
+ const m = stripFrontmatter(text).match(/^#\s+(.+)$/m);
60
+ if (!m) return null;
61
+ const raw = m[1].trim();
62
+ return raw.replace(/^Epic:\s*/, '');
63
+ }
64
+
65
+ // The sprint doc's H1 is `# <epic title> — Sprint <N>: <sprint title>`. The separator accepts a
66
+ // hyphen, en-dash, or em-dash ([-–—]) — an author typing a plain "-" must not crash the generator.
67
+ export function parseSprintHeader(text) {
68
+ const m = stripFrontmatter(text).match(/^#\s+(.+?)\s+[-–—]\s+Sprint\s+(\d+):\s+(.+)$/m);
69
+ if (!m) return null;
70
+ return { epicTitle: m[1].trim(), sprintNum: m[2], sprintTitle: m[3].trim() };
71
+ }
72
+
73
+ // Story headings are `### Story N.M — <title>` — return the full heading text (minus `### `). The
74
+ // separator accepts a hyphen, en-dash, or em-dash ([-–—]): the em-dash-only regex this replaced
75
+ // silently dropped every story typed with a plain "-", producing a complete-looking kickoff with
76
+ // NO stories at all — the worst failure shape (silent, plausible-looking output).
77
+ export function parseStoryHeadings(text) {
78
+ const out = [];
79
+ const re = /^###\s+(Story\s+\d+\.\d+\s+[-–—]\s+.+)$/gm;
80
+ let m;
81
+ while ((m = re.exec(text))) out.push(m[1].trim());
82
+ return out;
83
+ }
84
+
85
+ // `sprint-3.md` → 3. Returns null for anything that isn't a sprint file, so a stray doc in the epic dir
86
+ // (a NOTES.md, an image) can't be mistaken for a sprint.
87
+ export function sprintNumFromFilename(name) {
88
+ const m = name.match(/^sprint-(\d+)\.md$/);
89
+ return m ? Number(m[1]) : null;
90
+ }
91
+
92
+ // Every sprint file in the epic dir, NUMERICALLY sorted. Numeric, not lexicographic: a 10-sprint epic
93
+ // would otherwise order sprint-10 between sprint-1 and sprint-2, and the whole point of epic mode is that
94
+ // the sprints are an ordered assembly line. Stacked branches make a wrong order actively harmful.
95
+ export function listSprintFiles(names) {
96
+ return names
97
+ .map((name) => ({ name, num: sprintNumFromFilename(name) }))
98
+ .filter((s) => s.num !== null)
99
+ .sort((a, b) => a.num - b.num);
100
+ }
101
+
102
+ // The comma-separated file list for the orientation line.
103
+ export function buildSprintFileList(sprints) {
104
+ if (!sprints.length) return '(no sprint-N.md files found)';
105
+ return sprints.map((s) => s.name).join(', ');
106
+ }
107
+
108
+ // `Story 1.1 — Persist intent ✅ #12. **Decided …**` → `1.1 Persist intent`, clipped. The breakdown is a
109
+ // checklist for the orchestrator and the product owner; the sprint files carry the detail.
110
+ export function compactStory(heading) {
111
+ const m = String(heading).match(/^Story\s+(\d+\.\d+)\s+[-–—]\s+(.+)$/);
112
+ if (!m) return String(heading);
113
+ const title = m[2]
114
+ .replace(/\s+✅.*$/, '')
115
+ .replace(/\*\*/g, '')
116
+ .trim();
117
+ return `${m[1]} ${title.length > 70 ? `${title.slice(0, 69)}…` : title}`;
118
+ }
119
+
120
+ // The per-sprint breakdown at the foot of the prompt: one line per sprint, its stories inline. This is the
121
+ // only part that genuinely varies per epic, and it is deliberately the LAST thing in the prompt — the
122
+ // contract above it is invariant, so two kickoffs diff to their scope.
123
+ export function buildSprintBreakdown(sprints) {
124
+ if (!sprints.length) return '(no sprint files found — scaffold the epic first)';
125
+ return sprints
126
+ .map((s) => {
127
+ const stories = s.stories.length
128
+ ? s.stories.map(compactStory).join(' · ')
129
+ : '(no `### Story N.M — <title>` headings found in this sprint doc)';
130
+ const clean = s.title ? s.title.replace(/^S\d+\s*[—–:-]?\s*/, '') : '';
131
+ const title = clean ? ` · ${clean}` : '';
132
+ return `- **S${s.num}${title}** (${s.name}): ${stories}`;
133
+ })
134
+ .join('\n');
135
+ }
136
+
137
+ // Rules that apply to THIS epic only, picked from its docs. A false positive costs one line; a missing one
138
+ // is the incident each rule came from, so the patterns lean inclusive.
139
+ const MIGRATION_RE = /\bmigrations?\b|supabase\/migrations|\bALTER TABLE\b|\bCREATE TABLE\b/i;
140
+ // A flag KEY (`checkout.stripe_enabled`) or a flag write — not the words "kill-switch" / "flag", which every
141
+ // scaffolded README's Definition of Done carries whether or not a flag was planned.
142
+ const FLAG_RE = /\b[a-z][a-z0-9_]*\.[a-z0-9_]+_enabled\b|\bgf flags (?:create|set|on)\b/;
143
+
144
+ // "No new table, no migration" is the most common way a doc mentions one — strip negated mentions first.
145
+ const NEGATED_RE =
146
+ /\b(?:no|without|zero|not an?|nor an?)\s+(?:new\s+)?(?:db\s+|database\s+|schema\s+)?migrations?\b/gi;
147
+
148
+ export function buildEpicRules({ risk, texts }) {
149
+ const all = texts.join('\n').replace(NEGATED_RE, '');
150
+ const rules = [];
151
+ if (String(risk).toUpperCase() === 'HIGH')
152
+ rules.push(
153
+ '- **High risk:** the fresh `pr-reviewer` pass is mandatory on every PR, on top of the routed external passes.'
154
+ );
155
+ if (MIGRATION_RE.test(all))
156
+ rules.push(
157
+ '- **Migration:** apply it BEFORE merging (merging deploys), verify live, merge, then confirm the deploy.'
158
+ );
159
+ if (FLAG_RE.test(all))
160
+ rules.push(
161
+ '- **Flag:** create it in Golden Frijoles in every env and ACTIVATE it; `gf flags get <key>` must show production.'
162
+ );
163
+ return rules.length ? `\nFor this epic:\n${rules.join('\n')}\n` : '';
164
+ }
165
+
166
+ export function buildEpicKickoff({ macro, slug, epicTitle, risk, sprints, templateText, texts = [] }) {
167
+ return sub(templateText, {
168
+ EPIC_RULES: buildEpicRules({ risk, texts }),
169
+ MACRO: macro,
170
+ SLUG: slug,
171
+ EPIC_TITLE: epicTitle,
172
+ RISK: risk,
173
+ SPRINT_COUNT: String(sprints.length),
174
+ SPRINT_FILE_LIST: buildSprintFileList(sprints),
175
+ SPRINT_BREAKDOWN: buildSprintBreakdown(sprints),
176
+ });
177
+ }
178
+
179
+ // The epic README header line carries `**Risk:** <low|high>`; the frontmatter doesn't. Falls back to
180
+ // 'high' — the WAYS-OF-WORKING rule is "when unsure, treat it as high", and a kickoff that under-declares
181
+ // risk is the one that skips the mandatory fresh-reviewer pass.
182
+ export function parseEpicRisk(text) {
183
+ const m = text.match(/\*\*Risk:\*\*\s*([A-Za-z]+)/);
184
+ return m ? m[1].toLowerCase() : 'high';
185
+ }
186
+
187
+ /**
188
+ * A sprint's branch, STACKED (WAYS-OF-WORKING → *Epic-mode builds*: `feat/<slug>` → `-s2` → …): sprint 1 is the epic
189
+ * branch itself, sprint N is `feat/<slug>-s<N>`. One function, so the per-sprint kickoff, the Notion sprint card and
190
+ * the stage resolver's branch reading (`lib/work-branch.mjs`: `-s<N>` is sprint N, no suffix is sprint 1) agree.
191
+ */
192
+ export const sprintBranch = (slug, n) => (Number(n) <= 1 ? `feat/${slug}` : `feat/${slug}-s${Number(n)}`);
193
+ /** What sprint N branches FROM: main for sprint 1, else the previous sprint's branch (stacked; main once it merged). */
194
+ export const sprintBase = (slug, n) =>
195
+ Number(n) <= 1 ? 'origin/main' : `origin/${sprintBranch(slug, Number(n) - 1)}`;
196
+
197
+ // The prompt POINTS at the process instead of restating it (WAYS-OF-WORKING → *Epic-mode builds* is the one copy of
198
+ // the doctrine). Its FIRST step pushes the epic branch (S1.4): Building is a git fact the moment work starts, not a
199
+ // status somebody remembers to write — the push is what moves the card on the board (README § lock, D13).
200
+ export const EPIC_KICKOFF_TEMPLATE = `Start by pushing the epic branch, before anything else — it is what moves this card to Building on the board:
201
+ \`git switch -c feat/{{SLUG}} origin/main && git push -u origin feat/{{SLUG}}\` (resuming? \`git switch feat/{{SLUG}}\`).
202
+
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.
206
+
207
+ The process is Roadmap/WAYS-OF-WORKING.md → *Epic-mode builds*, *Review & merge* and *Escalate, don't guess*,
208
+ under AGENTS.md. Read those, the epic docs, and the Roadmap/LEARNINGS.md entries that touch this area. Start
209
+ with \`node scripts/session-resume.mjs\`.
210
+
211
+ Non-negotiable for this run:
212
+ 1. **Lock first.** Write \`D1…Dn\` and each sprint's build contract into the epic README, verified against
213
+ 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.
216
+ 2. **Stack** \`feat/{{SLUG}}\` → \`-s2\` → …, one PR per sprint, merged in order. Worktree or in place: decide
217
+ per *Epic-mode builds*.
218
+ 3. **Review** every PR through \`node scripts/review-route.mjs --builder <who-wrote-it> <PR#>\` (one general
219
+ pass, plus the security lens when the paths trigger it); each finding is fixed or answered before merge.
220
+ 4. **Merge on green** — pre-authorized, except a new category of production mutation: ask that once.
221
+ 5. **Done means shipped** — deployed and verified live, each sprint's smoke walkthrough in its sprint file,
222
+ then the epic Definition of Done.
223
+ {{EPIC_RULES}}
224
+ ## Sprints
225
+
226
+ {{SPRINT_BREAKDOWN}}
227
+ `;
228
+
229
+ /**
230
+ * The epic kickoff for the epic in `dir` (`Roadmap/<macro>/<slug>/`), read from its README and every sprint file.
231
+ * Throws (never exits) on a missing H1 or no sprint files: the CLI turns that into its own message, and the
232
+ * extractor leaves the card without a kickoff rather than failing the whole projection.
233
+ */
234
+ export function epicKickoffFromDir({
235
+ macro,
236
+ slug,
237
+ dir,
238
+ read = (p) => readFileSync(p, 'utf8'),
239
+ list = readdirSync,
240
+ }) {
241
+ const readmeText = read(join(dir, 'README.md'));
242
+ const epicTitle = parseEpicTitle(readmeText);
243
+ if (!epicTitle) throw new Error(`couldn't find an H1 title in Roadmap/${macro}/${slug}/README.md`);
244
+ const found = listSprintFiles(list(dir));
245
+ if (!found.length)
246
+ throw new Error(`no sprint-N.md files under Roadmap/${macro}/${slug}/ — scaffold the epic first`);
247
+ const sprints = found.map(({ name, num }) => {
248
+ const text = read(join(dir, name));
249
+ const header = parseSprintHeader(text);
250
+ return { name, num, text, title: header ? header.sprintTitle : null, stories: parseStoryHeadings(text) };
251
+ });
252
+ return {
253
+ sprints,
254
+ readmeText,
255
+ kickoff: buildEpicKickoff({
256
+ macro,
257
+ slug,
258
+ epicTitle,
259
+ risk: parseEpicRisk(readmeText).toUpperCase(),
260
+ sprints,
261
+ templateText: EPIC_KICKOFF_TEMPLATE,
262
+ texts: [readmeText, ...sprints.map((s) => s.text)],
263
+ }),
264
+ };
265
+ }
266
+
267
+ /** True when `dir` holds an epic README — a convenience for callers that search macro folders. */
268
+ export const isEpicDir = (dir) => existsSync(join(dir, 'README.md'));
@@ -18,3 +18,7 @@ export const SEED_FUNNEL_STATUSES = new Set(['Raw', 'Ready', 'Queued']);
18
18
  export function isFunnelSeed(row) {
19
19
  return row?.grain === 'Seed' && SEED_FUNNEL_STATUSES.has(row.status);
20
20
  }
21
+
22
+ // The six stages (board-sinks-and-scrumban D1/D2) live in lib/stage.mjs; re-exported here so the old buckets and
23
+ // the new stages have one home. The buckets above stay for the readers that still group by `status`.
24
+ export { STAGES } from './stage.mjs';