@golden-frijoles/kit 0.18.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.
@@ -0,0 +1,166 @@
1
+ // stage-facts.mjs — gather the git and GitHub facts the stage resolver reads, once per run (board-sinks-and-scrumban
2
+ // D14).
3
+ //
4
+ // Three modes:
5
+ // live one `git ls-remote --heads origin` and one `gh pr list --state all`, then the snapshot is rewritten.
6
+ // When either call fails (no network, no `gh`, no auth) it falls back to the snapshot and says why.
7
+ // snapshot read `.golden-frijoles/board.json`. A missing or unreadable snapshot means no facts (docs only).
8
+ // docs no facts at all — BUILD-ORDER.md (committed, so it may not hold anything that moves without a commit:
9
+ // lock C3) and fixtures.
10
+ //
11
+ // ── Why the snapshot holds FACTS, not stages ─────────────────────────────────────────────────────────────
12
+ // A snapshot of computed stages would freeze the docs too: an offline client would keep showing a seed as To groom
13
+ // after its pitch was written. Facts age; docs never do. So an offline run reads the docs as they are NOW and only
14
+ // the branch and PR lists carry an age — which every stage read from them states (`… · snapshot@<iso>`).
15
+ //
16
+ // Zero deps — Node 18+.
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ export const SNAPSHOT_PATH = join('.golden-frijoles', 'board.json');
23
+ export const FACT_MODES = Object.freeze(['live', 'snapshot', 'docs']);
24
+ const PR_LIMIT = 1000; // 223 PRs in this repo on 2026-10-01; one call, gh paginates.
25
+
26
+ // git exports GIT_DIR and friends into hooks, and they override `cwd` (build-state.mjs found this the hard way).
27
+ const GIT_ENV_TO_CLEAR = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_PREFIX'];
28
+ function cleanEnv() {
29
+ const env = { ...process.env };
30
+ for (const k of GIT_ENV_TO_CLEAR) delete env[k];
31
+ return env;
32
+ }
33
+
34
+ /** `git ls-remote --heads` output → branch names. Pure. */
35
+ export function parseLsRemote(text) {
36
+ return String(text || '')
37
+ .split('\n')
38
+ .map((line) => line.split('\t')[1] || '')
39
+ .filter((ref) => ref.startsWith('refs/heads/'))
40
+ .map((ref) => ref.slice('refs/heads/'.length));
41
+ }
42
+
43
+ /** `gh pr list --json number,headRefName,state,isDraft,url` → the resolver's PR shape. Pure. */
44
+ export function normalizePrs(list) {
45
+ if (!Array.isArray(list)) throw new Error('gh pr list did not return an array');
46
+ return list.map((p) => ({
47
+ number: Number(p.number),
48
+ head: String(p.headRefName ?? ''),
49
+ state: String(p.state ?? '').toUpperCase(),
50
+ draft: Boolean(p.isDraft),
51
+ url: String(p.url ?? ''),
52
+ }));
53
+ }
54
+
55
+ function readSnapshot(root, read) {
56
+ try {
57
+ const data = JSON.parse(read(join(root, SNAPSHOT_PATH), 'utf8'));
58
+ if (!Array.isArray(data.branches) || !Array.isArray(data.prs) || !data.generated_at) return null;
59
+ return data;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Write the snapshot, and the directory's own ignore file when it is missing (the directory is never committed). */
66
+ export function writeSnapshot(
67
+ root,
68
+ facts,
69
+ { write = writeFileSync, exists = existsSync, mkdir = mkdirSync } = {}
70
+ ) {
71
+ const file = join(root, SNAPSHOT_PATH);
72
+ mkdir(dirname(file), { recursive: true });
73
+ const ignore = join(dirname(file), '.gitignore');
74
+ if (!exists(ignore)) write(ignore, '# local roadmap facts and figures, never committed\n*\n');
75
+ write(file, `${JSON.stringify(facts, null, 2)}\n`);
76
+ }
77
+
78
+ /**
79
+ * The facts for one run.
80
+ * @returns `{ mode, branches, prs, origin, generated_at, note }` — `mode` is the mode actually used (a failed live
81
+ * run reports `snapshot` or `docs`), `origin` is `snapshot@<iso>` when the facts were read from the file
82
+ * (the resolver appends it to every git/GitHub source) and null otherwise, `note` says why a mode fell back.
83
+ */
84
+ export function gatherFacts({
85
+ root,
86
+ mode = 'snapshot',
87
+ run = spawnSync,
88
+ read = readFileSync,
89
+ persist = writeSnapshot,
90
+ now = () => new Date(),
91
+ } = {}) {
92
+ if (!FACT_MODES.includes(mode))
93
+ throw new Error(`unknown facts mode "${mode}" — one of ${FACT_MODES.join(' | ')}`);
94
+ const none = (note) => ({ mode: 'docs', branches: [], prs: [], origin: null, generated_at: null, note });
95
+ if (mode === 'docs') return none(null);
96
+
97
+ let note = null;
98
+ if (mode === 'live') {
99
+ const opts = { cwd: root, encoding: 'utf8', env: cleanEnv(), maxBuffer: 32 * 1024 * 1024 };
100
+ const ls = run('git', ['ls-remote', '--heads', 'origin'], { ...opts, timeout: 20_000 });
101
+ const gh =
102
+ ls.status === 0
103
+ ? run(
104
+ 'gh',
105
+ [
106
+ 'pr',
107
+ 'list',
108
+ '--state',
109
+ 'all',
110
+ '--limit',
111
+ String(PR_LIMIT),
112
+ '--json',
113
+ 'number,headRefName,state,isDraft,url',
114
+ ],
115
+ { ...opts, timeout: 30_000 }
116
+ )
117
+ : null;
118
+ if (ls.status !== 0) note = `live facts unavailable: git ls-remote failed (${firstLine(ls)})`;
119
+ else if (gh.status !== 0) note = `live facts unavailable: gh pr list failed (${firstLine(gh)})`;
120
+ else {
121
+ try {
122
+ const facts = {
123
+ generated_at: now().toISOString(),
124
+ source: 'github',
125
+ branches: parseLsRemote(ls.stdout),
126
+ prs: normalizePrs(JSON.parse(gh.stdout)),
127
+ };
128
+ try {
129
+ persist(root, facts);
130
+ } catch {
131
+ // A read-only checkout still gets live facts; it just cannot leave a snapshot behind.
132
+ }
133
+ return {
134
+ mode: 'live',
135
+ branches: facts.branches,
136
+ prs: facts.prs,
137
+ origin: null,
138
+ generated_at: facts.generated_at,
139
+ note: null,
140
+ };
141
+ } catch (err) {
142
+ note = `live facts unavailable: ${err.message}`;
143
+ }
144
+ }
145
+ }
146
+
147
+ const snap = readSnapshot(root, read);
148
+ if (!snap) return none(note ?? 'no snapshot yet — run with --live once');
149
+ return {
150
+ mode: 'snapshot',
151
+ branches: snap.branches,
152
+ prs: snap.prs,
153
+ origin: `snapshot@${snap.generated_at}`,
154
+ generated_at: snap.generated_at,
155
+ note,
156
+ };
157
+ }
158
+
159
+ function firstLine(res) {
160
+ if (res.error) return res.error.code === 'ENOENT' ? 'not installed' : res.error.message;
161
+ return (
162
+ String(res.stderr || '')
163
+ .trim()
164
+ .split('\n')[0] || `exit ${res.status}`
165
+ ).slice(0, 160);
166
+ }
@@ -0,0 +1,168 @@
1
+ // stage.mjs — the ONE place an initiative's stage is decided (board-sinks-and-scrumban D2, D13).
2
+ //
3
+ // Every roadmap client — the extractor that feeds the Hub and Notion, BUILD-ORDER.md, the CLI build view, the
4
+ // kickoff's WIP advice — reads a stage computed here, so no two of them can compute it differently. Before this,
5
+ // stage was written in five places in two vocabularies (seed `status:`, epic `status:`, `phase:`, a free-text
6
+ // sprint `Status:` line, and inference the CLI mod skipped offline), and the clients disagreed.
7
+ //
8
+ // ── The rule (README § Architecture lock, D13) ───────────────────────────────────────────────────────────
9
+ // 1. Off the board: `status: archived` (epic or seed) → stage null.
10
+ // 2. Shipped: `status: shipped`. Docs win over a stale branch — 12 of the 13 work branches on origin on
11
+ // 2026-10-01 were merged and never deleted (C7), including one of an epic that has shipped.
12
+ // 3. Git and GitHub next. A branch is LIVE while its newest PR is open, or it has none; merged or closed means
13
+ // done or abandoned. Any live branch → the highest sprint's: an open, non-draft PR is QA, anything else is
14
+ // Building (a tie goes to Building: work is still happening). No live branch but a merged PR → QA (merged,
15
+ // close-out owed), unless a sprint AFTER the highest merged one is still Planned by its own docs: then Ready to
16
+ // build (paused between sprints).
17
+ // 4. Docs last: raw → To groom · ready → Grooming · queued/scaffolded/in-progress → Ready to build.
18
+ // `in-progress` is never Building on its own word (D3): Building and QA are facts, never fields.
19
+ //
20
+ // Pure: facts come in as arguments (lib/stage-facts.mjs gathers them), so a fixture can pin every case.
21
+ // Zero deps beyond the branch parser the build view already used.
22
+
23
+ import { branchCandidates } from './work-branch.mjs';
24
+
25
+ /** The six stages, in board order (D1 — locked by the product owner, 2026-10-01). */
26
+ export const STAGES = Object.freeze(['To groom', 'Grooming', 'Ready to build', 'Building', 'QA', 'Shipped']);
27
+
28
+ const DOCS_STAGE = {
29
+ raw: 'To groom',
30
+ ready: 'Grooming',
31
+ queued: 'Ready to build',
32
+ scaffolded: 'Ready to build',
33
+ 'in-progress': 'Ready to build',
34
+ };
35
+
36
+ /**
37
+ * Which initiative a branch belongs to: the LONGEST reading of it that names a known slug (`known`), with a
38
+ * seed that carries `epic:` folded into that epic (`alias`: seed slug → epic slug). null when it names none.
39
+ */
40
+ export function ownerOf(branch, known, alias = new Map()) {
41
+ for (const c of branchCandidates(branch)) {
42
+ if (known.has(c.slug)) return { slug: alias.get(c.slug) ?? c.slug, sprint: c.sprint };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Split run-wide facts into per-initiative facts. `branches` are origin branch names; `prs` are
49
+ * `{ number, head, state, draft, url }` with state OPEN | MERGED | CLOSED. A PR is attributed by its head
50
+ * branch NAME, so a merged PR whose branch was deleted still counts for its initiative.
51
+ * Returns Map slug → { branches: [{ name, sprint }], prs: [{ …pr, sprint }] }.
52
+ */
53
+ export function attributeFacts({ branches = [], prs = [] } = {}, known, alias = new Map()) {
54
+ const out = new Map();
55
+ const slot = (slug) => {
56
+ if (!out.has(slug)) out.set(slug, { branches: [], prs: [] });
57
+ return out.get(slug);
58
+ };
59
+ for (const name of branches) {
60
+ const owner = ownerOf(name, known, alias);
61
+ if (owner) slot(owner.slug).branches.push({ name, sprint: owner.sprint });
62
+ }
63
+ for (const pr of prs) {
64
+ const owner = ownerOf(pr.head, known, alias);
65
+ if (owner) slot(owner.slug).prs.push({ ...pr, sprint: owner.sprint });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const rankOf = (sprint) => sprint ?? 1;
71
+ const newest = (prs) =>
72
+ prs.reduce((best, pr) => (best === null || pr.number > best.number ? pr : best), null);
73
+
74
+ /**
75
+ * The stage of one initiative.
76
+ *
77
+ * @param row `{ grain: 'Epic' | 'Seed', status: <frontmatter status, lower-case, or null>, sprints?, sprints_total? }`
78
+ * — `sprints` is `[{ n, status }]` with each sprint's own docs status (Planned · In progress · …)
79
+ * @param gitFacts `{ branches: [{ name, sprint }] }` — this initiative's branches on origin
80
+ * @param prFacts `{ prs: [{ number, head, state, draft, url, sprint }] }` — PRs from its branches, any state
81
+ * @param origin appended to the source when the facts are not live (`snapshot@<iso>`), else null
82
+ * @returns `{ stage, source, pr }` — stage is one of STAGES or null (archived: off the board); `pr` is the PR
83
+ * the stage was read from, or null.
84
+ */
85
+ export function resolveStage(row, gitFacts = { branches: [] }, prFacts = { prs: [] }, origin = null) {
86
+ const status = row.status ? String(row.status).toLowerCase() : null;
87
+ const at = (source) => (origin ? `${source} · ${origin}` : source);
88
+
89
+ if (status === 'archived') return { stage: null, source: 'docs: status archived', pr: null };
90
+ if (status === 'shipped') return { stage: 'Shipped', source: 'docs: status shipped', pr: null };
91
+
92
+ const prs = prFacts.prs ?? [];
93
+ const live = (gitFacts.branches ?? [])
94
+ .map((b) => ({ ...b, pr: newest(prs.filter((p) => p.head === b.name)) }))
95
+ .filter((b) => b.pr === null || b.pr.state === 'OPEN');
96
+
97
+ if (live.length) {
98
+ const top = Math.max(...live.map((b) => rankOf(b.sprint)));
99
+ const atTop = live.filter((b) => rankOf(b.sprint) === top);
100
+ const ready = (b) => b.pr !== null && !b.pr.draft;
101
+ // A tie goes to the branch still being built: one ready PR does not make a sprint that is also being worked
102
+ // on in a sibling branch "in QA".
103
+ const pick = atTop.find((b) => !ready(b)) ?? atTop[0];
104
+ if (ready(pick)) return { stage: 'QA', source: at(`github: PR #${pick.pr.number} ready`), pr: pick.pr };
105
+ if (pick.pr) return { stage: 'Building', source: at(`github: PR #${pick.pr.number} draft`), pr: pick.pr };
106
+ return { stage: 'Building', source: at(`git: ${pick.name}`), pr: null };
107
+ }
108
+
109
+ const merged = prs.filter((p) => p.state === 'MERGED');
110
+ if (merged.length) {
111
+ const top = Math.max(...merged.map((p) => rankOf(p.sprint)));
112
+ const last = newest(merged.filter((p) => rankOf(p.sprint) === top));
113
+ // Paused between sprints only when a LATER sprint is still Planned by its own docs. Sprint numbers alone cannot
114
+ // tell: an epic may ship every sprint in one PR from `feat/<slug>` (scenarios-pm-operable, #98 — 10 of 10
115
+ // stories), which reads as "sprint 1" (lock run, 2026-10-01). Without sprint statuses, fall back to the count.
116
+ const later = (row.sprints ?? []).filter((sp) => sp.n > top);
117
+ const paused =
118
+ row.grain === 'Epic' &&
119
+ (Array.isArray(row.sprints)
120
+ ? later.some((sp) => sp.status === 'Planned')
121
+ : top < (Number(row.sprints_total) || 0));
122
+ if (!paused) return { stage: 'QA', source: at(`github: PR #${last.number} merged`), pr: last };
123
+ const next = later.find((sp) => sp.status === 'Planned');
124
+ return {
125
+ stage: 'Ready to build',
126
+ source: at(`github: PR #${last.number} merged, S${next ? next.n : top + 1} not started`),
127
+ pr: last,
128
+ };
129
+ }
130
+
131
+ // Docs last. An absent status reads as raw for a seed (the extractor's legacy tolerance) and as scaffolded for
132
+ // an epic (it has a README and sprint files, so it was scaffolded).
133
+ const key = status ?? (row.grain === 'Epic' ? 'scaffolded' : 'raw');
134
+ const stage = DOCS_STAGE[key] ?? 'Ready to build';
135
+ return { stage, source: `docs: status ${key}`, pr: null };
136
+ }
137
+
138
+ /** `build_order_num`, with a missing one sorting last rather than first. */
139
+ const orderKey = (row) =>
140
+ Number.isFinite(row.build_order_num) ? row.build_order_num : Number.MAX_SAFE_INTEGER;
141
+
142
+ /**
143
+ * Rows with a stage, grouped into the six columns in board order. Ready to build runs in build order (D1); Shipped
144
+ * runs newest first — by `shipped_at` (`shipped: 'recent'`, the default), or by build order, highest first
145
+ * (`shipped: 'build-order'`). The second exists for a COMMITTED view: `shipped_at` is a git date, and a depth-1 CI
146
+ * clone dates every row to its one commit, so a file sorted by it is not reproducible (build-order-guard went red on
147
+ * exactly that). Every other column runs in build order, then name. Rows whose stage is null (archived) or that are
148
+ * not initiatives (sprint rows) are left out — cards are initiatives (D7).
149
+ */
150
+ export function groupByStage(rows, { shipped = 'recent' } = {}) {
151
+ const columns = Object.fromEntries(STAGES.map((s) => [s, []]));
152
+ for (const row of rows) {
153
+ if (row.grain === 'Sprint' || !row.stage || !(row.stage in columns)) continue;
154
+ columns[row.stage].push(row);
155
+ }
156
+ for (const stage of STAGES) {
157
+ columns[stage].sort((a, b) =>
158
+ stage !== 'Shipped'
159
+ ? orderKey(a) - orderKey(b) || String(a.name).localeCompare(String(b.name))
160
+ : shipped === 'build-order'
161
+ ? (Number.isFinite(b.build_order_num) ? b.build_order_num : -1) -
162
+ (Number.isFinite(a.build_order_num) ? a.build_order_num : -1) ||
163
+ String(a.name).localeCompare(String(b.name))
164
+ : String(b.shipped_at ?? '').localeCompare(String(a.shipped_at ?? '')) || orderKey(a) - orderKey(b)
165
+ );
166
+ }
167
+ return columns;
168
+ }
@@ -0,0 +1,51 @@
1
+ // work-branch.mjs — read a work branch name as `<type>/<slug>[-s<N>][-words]` (board-sinks-and-scrumban D13).
2
+ //
3
+ // Moved here, unchanged, from build-state.mjs, where it was born (build-visualization-claude-mods S3). The stage
4
+ // resolver (lib/stage.mjs) needs the SAME reading of a branch as the build view, and a second parser for one
5
+ // naming convention is how two clients come to disagree about which epic a branch belongs to. build-state.mjs
6
+ // re-exports both functions, so nothing that imported them from there had to change.
7
+ //
8
+ // Zero deps, no imports.
9
+
10
+ const BRANCH_PREFIX_RE = /^(?:feat|fix|chore|spike|bug|docs)\/(.+)$/;
11
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*$/;
12
+
13
+ /** A sprint token right after the slug: `s3`, `sprint3`, or `sprint` `3` → 3; else null. */
14
+ function sprintAt(rest) {
15
+ const m = String(rest[0] ?? '').match(/^s(?:print)?(\d+)$/);
16
+ if (m) return Number(m[1]);
17
+ return rest[0] === 'sprint' && /^\d+$/.test(rest[1] ?? '') ? Number(rest[1]) : null;
18
+ }
19
+
20
+ /**
21
+ * Every reading of a work branch as `<slug>[-s<N>][-anything]`, LONGEST slug first:
22
+ * `feat/foo-s4-licences` → foo-s4-licences, foo-s4, foo (sprint 4). The resolver takes the first reading
23
+ * whose slug names an epic (or a seed) on disk. [] for a branch that isn't a work branch.
24
+ */
25
+ export function branchCandidates(branch) {
26
+ const m = String(branch || '').match(BRANCH_PREFIX_RE);
27
+ if (!m || !SLUG_RE.test(m[1]) || m[1].includes('..')) return [];
28
+ const tokens = m[1].split('-');
29
+ const out = [];
30
+ for (let i = tokens.length; i >= 1; i--) {
31
+ out.push({
32
+ slug: tokens.slice(0, i).join('-'),
33
+ sprint: sprintAt(tokens.slice(i)),
34
+ exact: i === tokens.length,
35
+ });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * The syntactic reading alone, no disk: `feat/foo-s3` → { slug: 'foo', sprint: 3 },
42
+ * `feat/foo-s3-extra` → { slug: 'foo', sprint: 3 }; null for a branch that is not a work branch.
43
+ */
44
+ export function parseBranch(branch) {
45
+ const cands = branchCandidates(branch);
46
+ if (!cands.length) return null;
47
+ const withSprint = cands.find((c) => c.sprint !== null);
48
+ return withSprint
49
+ ? { slug: withSprint.slug, sprint: withSprint.sprint }
50
+ : { slug: cands[0].slug, sprint: null };
51
+ }