@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.
- package/dist/build-order.mjs +72 -100
- package/dist/build-state.mjs +4 -41
- package/dist/lib/epic-kickoff.mjs +268 -0
- package/dist/lib/roadmap-status-buckets.mjs +4 -0
- package/dist/lib/stage-facts.mjs +166 -0
- package/dist/lib/stage.mjs +168 -0
- package/dist/lib/work-branch.mjs +51 -0
- package/dist/roadmap-extract.mjs +297 -47
- package/package.json +1 -1
package/dist/build-order.mjs
CHANGED
|
@@ -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)
|
|
4
|
-
//
|
|
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
|
|
10
|
-
// and
|
|
11
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
45
|
-
const SEED_FUNNEL = SEED_FUNNEL_STATUSES;
|
|
48
|
+
const LIVE_ONLY = new Set(['Building', 'QA']);
|
|
46
49
|
|
|
47
50
|
function line(r) {
|
|
48
|
-
|
|
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.
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
const
|
|
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
|
-
|
|
77
|
+
" Stage: scripts/lib/stage.mjs, from each initiative's frontmatter (docs only in this file; the"
|
|
86
78
|
);
|
|
87
79
|
out.push(
|
|
88
|
-
'
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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 ');
|
package/dist/build-state.mjs
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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';
|