@golden-frijoles/kit 0.19.0 → 0.21.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,14 @@ 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';
62
+ import { buildRows } from './roadmap-extract.mjs';
63
+ import { gatherFacts } from './lib/stage-facts.mjs';
64
+ import { groupByStage } from './lib/stage.mjs';
65
+ import { getKey } from './lib/config.mjs';
61
66
 
62
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
63
68
 
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
69
  const SEEDS_DIR = ['Roadmap', '00-ideas', 'seeds'];
67
70
  const MAX_WORKTREES = 8; // resolved per refresh — a pile of stale agent worktrees must not stall the view
68
71
  const MAX_ELSEWHERE_LINES = 4;
@@ -74,45 +77,9 @@ export function storyIdsIn(text) {
74
77
  return [...String(text).matchAll(STORY_IN_TEXT_RE)].map((m) => `S${Number(m[1])}.${Number(m[2])}`);
75
78
  }
76
79
 
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
- }
80
+ // The branch parser lives in lib/work-branch.mjs now (board-sinks-and-scrumban D13), so the stage resolver reads a
81
+ // branch exactly as this view does. Re-exported: callers and the spec import them from here.
82
+ export { branchCandidates, parseBranch };
116
83
 
117
84
  function makeGit(root) {
118
85
  return (args) =>
@@ -276,18 +243,17 @@ function readJournalLocal(git) {
276
243
  return { entries: [], ref: null };
277
244
  }
278
245
 
279
- function ghOpenPr(root, branch) {
280
- try {
281
- const out = execFileSync(
282
- 'gh',
283
- ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'],
284
- { cwd: root, encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }
285
- );
286
- const [pr] = JSON.parse(out);
287
- return { ok: true, pr: pr || null };
288
- } catch {
289
- return { ok: false, pr: null };
290
- }
246
+ /**
247
+ * The open PR for `branch`, read from the run's facts (board-sinks-and-scrumban S3.1): the ONE `gh pr list` the facts
248
+ * made already holds every PR, so the build view asks GitHub nothing more. `ok` is whether those facts are live.
249
+ */
250
+ export function prFromFacts(facts, branch) {
251
+ const open = (facts?.prs ?? []).filter((p) => p.head === branch && p.state === 'OPEN');
252
+ const pr = open.reduce((best, p) => (best === null || p.number > best.number ? p : best), null);
253
+ // Only LIVE facts may lift the status to In review: a failed online gather that fell back to the snapshot must not
254
+ // claim a PR from hours ago (before this, a failed gh call meant no lift; fresh review, #227).
255
+ const live = facts?.mode === 'live';
256
+ return { ok: live, pr: live && pr ? { number: pr.number, url: pr.url } : null };
291
257
  }
292
258
 
293
259
  const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
@@ -298,6 +264,48 @@ export function namesSlug(text, slug) {
298
264
  return new RegExp(`(^|[^A-Za-z0-9-])${esc}($|[^A-Za-z0-9-])`).test(String(text));
299
265
  }
300
266
 
267
+ /** "just now", "12m ago", "3h ago", "2 days ago" — how old the facts behind a stage are. */
268
+ export function ageOf(iso, now = new Date()) {
269
+ const ms = now.getTime() - new Date(iso).getTime();
270
+ if (!Number.isFinite(ms)) return null;
271
+ const m = Math.floor(ms / 60_000);
272
+ if (m < 1) return 'just now';
273
+ if (m < 60) return `${m}m ago`;
274
+ const h = Math.floor(m / 60);
275
+ if (h < 24) return `${h}h ago`;
276
+ const d = Math.floor(h / 24);
277
+ return d === 1 ? 'yesterday' : `${d} days ago`;
278
+ }
279
+
280
+ /**
281
+ * The stage of what is in flight, and the board around it (board-sinks-and-scrumban S3.1, D2/D14). The stage is the
282
+ * EXTRACTOR's row for this initiative — the same `buildRows()` the Hub's push and BUILD-ORDER.md read — so the build
283
+ * view cannot say a different word than the board. `dates: false` keeps it inside the hook's budget (~50 ms).
284
+ */
285
+ export function boardState({ root, facts, state, now = new Date() }) {
286
+ const rows = buildRows({ facts: facts ?? undefined, root, dates: false });
287
+ const slug = state.in_flight ? (state.epic?.slug ?? state.seed?.slug ?? null) : null;
288
+ const row = slug ? rows.find((r) => r.grain !== 'Sprint' && r.slug === slug) : null;
289
+ const cols = groupByStage(rows);
290
+ const next = cols['Ready to build'][0] ?? null;
291
+ const hub = getKey('board.hubUrl', { root });
292
+ const hubUrl = typeof hub === 'string' && /^https:\/\//.test(hub) ? hub.replace(/\/+$/, '') : null;
293
+ return {
294
+ stage: row ? row.stage : null,
295
+ stage_source: row ? row.stage_source : null,
296
+ stage_age:
297
+ facts?.mode === 'live' ? 'just now' : facts?.generated_at ? ageOf(facts.generated_at, now) : null,
298
+ facts_mode: facts?.mode ?? 'docs',
299
+ board: {
300
+ building: cols.Building.length,
301
+ qa: cols.QA.length,
302
+ ready: cols['Ready to build'].length,
303
+ next: next ? { name: next.name, slug: next.slug, build_order: next.build_order_num ?? null } : null,
304
+ url: hubUrl ? `${hubUrl}/board${slug && row ? `?card=${encodeURIComponent(slug)}` : ''}` : null,
305
+ },
306
+ };
307
+ }
308
+
301
309
  /**
302
310
  * Resolve the build state. Every external read is injectable so the tests drive real fixture repos with
303
311
  * a fake `gh`: { root, offline, git, gh }. It NEVER throws — it runs once per turn inside a CLI hook, and
@@ -305,14 +313,31 @@ export function namesSlug(text, slug) {
305
313
  * `elsewhere: false` skips the other-worktrees / open-epics scan (used when resolving those worktrees).
306
314
  */
307
315
  export function resolveBuildState(opts = {}) {
316
+ // board-sinks-and-scrumban S3.1 — ONE facts gather per run: the snapshot offline (the hook never goes online), else
317
+ // one `git ls-remote` + one `gh pr list`, which also refreshes the snapshot. Injectable for the spec.
318
+ let facts = null;
319
+ if (opts.board !== false) {
320
+ try {
321
+ facts = (opts.gather ?? gatherFacts)({ root: opts.root, mode: opts.offline ? 'snapshot' : 'live' });
322
+ } catch {
323
+ facts = null;
324
+ }
325
+ }
308
326
  let state;
309
327
  try {
310
- state = resolve_(opts);
328
+ state = resolve_({ ...opts, facts });
311
329
  } catch (err) {
312
330
  state = notInFlight(
313
331
  `the resolver could not read this checkout (${err && err.message ? err.message : err})`
314
332
  );
315
333
  }
334
+ if (opts.board !== false) {
335
+ try {
336
+ Object.assign(state, boardState({ root: opts.root, facts, state, now: opts.now ?? new Date() }));
337
+ } catch {
338
+ // A roadmap this extractor cannot read (an out-of-enum status) leaves the view without a stage, never blank.
339
+ }
340
+ }
316
341
  if (opts.elsewhere === false) return state;
317
342
  try {
318
343
  state.elsewhere = elsewhere_({ root: opts.root, git: opts.git || makeGit(opts.root), state });
@@ -363,7 +388,7 @@ function elsewhere_({ root, git, state }) {
363
388
  if (!w.branch || w.bare || w.prunable || samePath(w.path, top) || !existsSync(w.path)) continue;
364
389
  if (!branchCandidates(w.branch).length) continue; // main, release branches: nothing to resolve
365
390
  if (resolved++ >= MAX_WORKTREES) break;
366
- const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false });
391
+ const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false, board: false });
367
392
  if (!s.in_flight || DONE_LIFECYCLES.includes(s.lifecycle)) continue;
368
393
  const item = s.epic
369
394
  ? { kind: 'epic', slug: s.epic.slug, title: s.epic.title }
@@ -438,7 +463,8 @@ function seedState({ root, git, gh, offline, branch, seed, match }) {
438
463
  };
439
464
  }
440
465
 
441
- function resolve_({ root, offline = false, git = makeGit(root), gh = ghOpenPr } = {}) {
466
+ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh } = {}) {
467
+ gh = gh ?? ((_root, branch) => prFromFacts(facts, branch));
442
468
  if (tryGit(git, ['rev-parse', '--is-inside-work-tree']) !== 'true')
443
469
  return notInFlight('not inside a git checkout (or git is not installed)');
444
470
  const branch = tryGit(git, ['symbolic-ref', '-q', '--short', 'HEAD']);
@@ -642,9 +668,40 @@ function elsewhereLines(state, pad) {
642
668
  * work in other worktrees appears as one `Also` line when this checkout is building, and as the list
643
669
  * itself when it isn't.
644
670
  */
671
+ /**
672
+ * The stage, said with where it came from and how old that is (board-sinks-and-scrumban S3.1): "Building · from git:
673
+ * feat/x · facts from the snapshot, 3h ago". The written `phase:` stays only as a detail (D12). Without a stage (the
674
+ * roadmap could not be read) it falls back to the old written-phase status, labelled as such.
675
+ */
676
+ export function statusValue(state) {
677
+ if (!state.stage) return state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`;
678
+ const source = String(state.stage_source || '').replace(/ · snapshot@\S+$/, '');
679
+ const age =
680
+ state.facts_mode === 'live'
681
+ ? 'live'
682
+ : state.facts_mode === 'snapshot'
683
+ ? `snapshot, ${state.stage_age ?? 'age unknown'}`
684
+ : 'docs only, no snapshot yet';
685
+ const phase =
686
+ state.phase_written && state.phase_written !== state.stage ? ` · phase ${state.phase_written}` : '';
687
+ return `${state.stage} · from ${source} (${age})${phase}`;
688
+ }
689
+
690
+ /** The board around this work in one line, and the link to it when `board.hubUrl` is set (S3.1). */
691
+ function boardLines(state, pad) {
692
+ const b = state.board;
693
+ if (!b) return [];
694
+ const next = b.next
695
+ ? ` · next to pull: ${clip(b.next.name, 40)}${b.next.build_order !== null ? ` (#${b.next.build_order})` : ''}`
696
+ : '';
697
+ const counts = `Building ${b.building} · QA ${b.qa} · Ready to build ${b.ready}${next}`;
698
+ return [`${pad('Board')}${counts}`, ...(b.url ? [`${pad('')}↗ ${b.url}`] : [])];
699
+ }
700
+
645
701
  export function renderLines(state) {
646
702
  const pad = (label) => ` ${label.padEnd(9)}`;
647
- if (!state.in_flight) return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad)];
703
+ if (!state.in_flight)
704
+ return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad), ...boardLines(state, pad)];
648
705
  const others = state.elsewhere?.worktrees?.length || 0;
649
706
  const also = others
650
707
  ? [
@@ -664,8 +721,9 @@ export function renderLines(state) {
664
721
  HEADINGS[seed.type] || 'Currently working on',
665
722
  `${pad(label)}${seed.title}${meta ? ` ${meta}` : ''}`,
666
723
  `${pad('Seed')}${seed.path}`,
667
- `${pad('Status')}${state.status || 'unknown'}`,
724
+ `${pad('Status')}${statusValue(state)}`,
668
725
  ...also,
726
+ ...boardLines(state, pad),
669
727
  ];
670
728
  }
671
729
  const { epic, story, progress } = state;
@@ -686,8 +744,8 @@ export function renderLines(state) {
686
744
  const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
687
745
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
688
746
  lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
689
- lines.push(`${pad('Status')}${state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`}`);
690
- return [...lines, ...also];
747
+ lines.push(`${pad('Status')}${statusValue(state)}`);
748
+ return [...lines, ...also, ...boardLines(state, pad)];
691
749
  }
692
750
 
693
751
  // realpath on both sides: a plugin or checkout reached through a symlink (macOS /tmp → /private/tmp) would
@@ -0,0 +1,50 @@
1
+ // board-text.mjs — the six-stage board as terminal text (board-sinks-and-scrumban S3.2, `roadmap-extract --sink terminal`).
2
+ //
3
+ // The terminal sink: the same rows the Hub renders, grouped by the same function (`groupByStage`), said in plain text.
4
+ // Shipped is the long column, so it shows a count and the most recent few; every other column lists every card.
5
+ //
6
+ // Pure — the caller passes the rows and how fresh their facts are.
7
+
8
+ import { STAGES, groupByStage } from './stage.mjs';
9
+
10
+ const SHIPPED_SHOWN = 5;
11
+
12
+ function cardLine(row) {
13
+ const meta = [
14
+ row.grain === 'Seed' ? 'seed' : null,
15
+ row.type,
16
+ row.risk ? `risk ${String(row.risk).toLowerCase()}` : null,
17
+ Number.isFinite(row.build_order_num) ? `#${row.build_order_num}` : null,
18
+ ]
19
+ .filter(Boolean)
20
+ .join(' · ');
21
+ return ` · ${row.name}${meta ? ` — ${meta}` : ''}`;
22
+ }
23
+
24
+ /**
25
+ * @param rows the extractor's rows
26
+ * @param facts `{ mode, origin }` from lib/stage-facts.mjs — said in the header, so a reader knows how old Building/QA are
27
+ */
28
+ export function renderBoardText(rows, facts = { mode: 'docs' }) {
29
+ const columns = groupByStage(rows);
30
+ const fresh =
31
+ facts.mode === 'live'
32
+ ? 'live from git and GitHub'
33
+ : facts.mode === 'snapshot'
34
+ ? `from the ${String(facts.origin ?? 'snapshot').replace('@', ' of ')}`
35
+ : 'from the docs alone — Building and QA need git/GitHub facts (--live)';
36
+ const out = [`Board — six stages, ${fresh}`, ''];
37
+ for (const stage of STAGES) {
38
+ const list = columns[stage];
39
+ out.push(`${stage} (${list.length})`);
40
+ const shown = stage === 'Shipped' ? list.slice(0, SHIPPED_SHOWN) : list;
41
+ for (const row of shown) out.push(cardLine(row));
42
+ if (stage === 'Shipped' && list.length > shown.length)
43
+ out.push(` … and ${list.length - shown.length} more`);
44
+ if (list.length === 0) out.push(' —');
45
+ out.push('');
46
+ }
47
+ const next = columns['Ready to build'][0];
48
+ out.push(next ? `Next to pull: ${next.name}` : 'Nothing is ready to pull.');
49
+ return `${out.join('\n')}\n`;
50
+ }
@@ -50,6 +50,23 @@ export const REGISTRY = Object.freeze([
50
50
  choices: ['terminal', 'golden-frijoles', 'notion'],
51
51
  question: 'Where should the board live?',
52
52
  },
53
+ // board-sinks-and-scrumban D22 — scrumban's pull, as advice: the kickoff warns when Building is at its limit, the Hub
54
+ // board says when a column is over. `null` = no limits, so nothing warns until a project sets them.
55
+ {
56
+ key: 'board.wip',
57
+ module: 'Plan',
58
+ askWhen: 'never-yet',
59
+ default: null,
60
+ question: 'How many initiatives may be Building and in QA at once (e.g. { "Building": 2, "QA": 3 })? Advice only.',
61
+ },
62
+ // The project's Hub base (https://…/hub/<slug>): the build view links each card to it. `null` = no link, no error.
63
+ {
64
+ key: 'board.hubUrl',
65
+ module: 'Plan',
66
+ askWhen: 'never-yet',
67
+ default: null,
68
+ question: "Your project's Golden Frijoles Hub URL (https://goldenfrijoles.com/hub/<project>), for the build view's board link?",
69
+ },
53
70
  {
54
71
  key: 'verify.depth',
55
72
  module: 'Build',