@golden-frijoles/kit 0.20.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.
@@ -59,6 +59,10 @@ 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
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';
62
66
 
63
67
  const __dirname = dirname(fileURLToPath(import.meta.url));
64
68
 
@@ -239,18 +243,17 @@ function readJournalLocal(git) {
239
243
  return { entries: [], ref: null };
240
244
  }
241
245
 
242
- function ghOpenPr(root, branch) {
243
- try {
244
- const out = execFileSync(
245
- 'gh',
246
- ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'],
247
- { cwd: root, encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }
248
- );
249
- const [pr] = JSON.parse(out);
250
- return { ok: true, pr: pr || null };
251
- } catch {
252
- return { ok: false, pr: null };
253
- }
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 };
254
257
  }
255
258
 
256
259
  const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
@@ -261,6 +264,48 @@ export function namesSlug(text, slug) {
261
264
  return new RegExp(`(^|[^A-Za-z0-9-])${esc}($|[^A-Za-z0-9-])`).test(String(text));
262
265
  }
263
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
+
264
309
  /**
265
310
  * Resolve the build state. Every external read is injectable so the tests drive real fixture repos with
266
311
  * a fake `gh`: { root, offline, git, gh }. It NEVER throws — it runs once per turn inside a CLI hook, and
@@ -268,14 +313,31 @@ export function namesSlug(text, slug) {
268
313
  * `elsewhere: false` skips the other-worktrees / open-epics scan (used when resolving those worktrees).
269
314
  */
270
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
+ }
271
326
  let state;
272
327
  try {
273
- state = resolve_(opts);
328
+ state = resolve_({ ...opts, facts });
274
329
  } catch (err) {
275
330
  state = notInFlight(
276
331
  `the resolver could not read this checkout (${err && err.message ? err.message : err})`
277
332
  );
278
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
+ }
279
341
  if (opts.elsewhere === false) return state;
280
342
  try {
281
343
  state.elsewhere = elsewhere_({ root: opts.root, git: opts.git || makeGit(opts.root), state });
@@ -326,7 +388,7 @@ function elsewhere_({ root, git, state }) {
326
388
  if (!w.branch || w.bare || w.prunable || samePath(w.path, top) || !existsSync(w.path)) continue;
327
389
  if (!branchCandidates(w.branch).length) continue; // main, release branches: nothing to resolve
328
390
  if (resolved++ >= MAX_WORKTREES) break;
329
- const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false });
391
+ const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false, board: false });
330
392
  if (!s.in_flight || DONE_LIFECYCLES.includes(s.lifecycle)) continue;
331
393
  const item = s.epic
332
394
  ? { kind: 'epic', slug: s.epic.slug, title: s.epic.title }
@@ -401,7 +463,8 @@ function seedState({ root, git, gh, offline, branch, seed, match }) {
401
463
  };
402
464
  }
403
465
 
404
- 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));
405
468
  if (tryGit(git, ['rev-parse', '--is-inside-work-tree']) !== 'true')
406
469
  return notInFlight('not inside a git checkout (or git is not installed)');
407
470
  const branch = tryGit(git, ['symbolic-ref', '-q', '--short', 'HEAD']);
@@ -605,9 +668,40 @@ function elsewhereLines(state, pad) {
605
668
  * work in other worktrees appears as one `Also` line when this checkout is building, and as the list
606
669
  * itself when it isn't.
607
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
+
608
701
  export function renderLines(state) {
609
702
  const pad = (label) => ` ${label.padEnd(9)}`;
610
- 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)];
611
705
  const others = state.elsewhere?.worktrees?.length || 0;
612
706
  const also = others
613
707
  ? [
@@ -627,8 +721,9 @@ export function renderLines(state) {
627
721
  HEADINGS[seed.type] || 'Currently working on',
628
722
  `${pad(label)}${seed.title}${meta ? ` ${meta}` : ''}`,
629
723
  `${pad('Seed')}${seed.path}`,
630
- `${pad('Status')}${state.status || 'unknown'}`,
724
+ `${pad('Status')}${statusValue(state)}`,
631
725
  ...also,
726
+ ...boardLines(state, pad),
632
727
  ];
633
728
  }
634
729
  const { epic, story, progress } = state;
@@ -649,8 +744,8 @@ export function renderLines(state) {
649
744
  const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
650
745
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
651
746
  lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
652
- lines.push(`${pad('Status')}${state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`}`);
653
- return [...lines, ...also];
747
+ lines.push(`${pad('Status')}${statusValue(state)}`);
748
+ return [...lines, ...also, ...boardLines(state, pad)];
654
749
  }
655
750
 
656
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',
@@ -5,6 +5,9 @@
5
5
  // node scripts/roadmap-extract.mjs --live # gather git/GitHub facts now (one ls-remote, one gh call)
6
6
  // node scripts/roadmap-extract.mjs --docs-only # no facts at all — what the committed BUILD-ORDER.md reads
7
7
  // … --live --require-live # exit 3 instead of falling back (what a publisher passes)
8
+ // node scripts/roadmap-extract.mjs --sink terminal # the six-stage board as text (live facts unless told otherwise)
9
+ // node scripts/roadmap-extract.mjs --sink hub # push the board to the Hub (GROWTH_ENGINE_URL + the project key, see roadmap-push)
10
+ // node scripts/roadmap-extract.mjs --sink notion # run the optional Notion sync copied beside this file
8
11
  //
9
12
  // ── One extractor (board-sinks-and-scrumban D15) ─────────────────────────────────────────────────────────
10
13
  // This file is THE projection, byte-identical in every project's scripts/ and in the kit. It used to have a fork in
@@ -57,35 +60,37 @@
57
60
  // Legacy freeform lines are still mapped best-effort below.
58
61
 
59
62
  import { readFileSync, readdirSync, existsSync, statSync, writeSync, realpathSync } from 'node:fs';
60
- import { execFileSync } from 'node:child_process';
63
+ import { execFileSync, spawnSync } from 'node:child_process';
61
64
  import { fileURLToPath } from 'node:url';
62
- import { join } from 'node:path';
65
+ import { dirname, join } from 'node:path';
63
66
  import { projectRoot } from './lib/project-root.mjs';
64
67
  import { attributeFacts, resolveStage } from './lib/stage.mjs';
65
68
  import { gatherFacts } from './lib/stage-facts.mjs';
66
69
  import { epicKickoffFromDir, sprintBranch } from './lib/epic-kickoff.mjs';
70
+ import { renderBoardText } from './lib/board-text.mjs';
71
+ import { pushRoadmap, reportPush } from './roadmap-push.mjs';
67
72
 
68
- const REPO = projectRoot(); // D2
69
- const ROADMAP = join(REPO, 'Roadmap');
70
- const SEEDS = join(ROADMAP, '00-ideas', 'seeds');
73
+ const REPO = projectRoot(); // D2 — the CLI's default root; buildRows takes its own
71
74
 
72
75
  // Area labels are DERIVED from the project's own macro-section folders (`Roadmap/NN-slug/` → "NN Slug Title-Cased"),
73
76
  // never hardcoded: a hardcoded map was another product's area list and mislabeled every line it touched. A number
74
77
  // with no folder yet (a seed for a future area) falls back to the raw number.
75
- const AREA_NAMES = existsSync(ROADMAP)
76
- ? Object.fromEntries(
77
- readdirSync(ROADMAP)
78
- .filter((d) => /^\d{2}-/.test(d) && statSync(join(ROADMAP, d)).isDirectory())
79
- .map((d) => {
80
- const [, num, slug] = d.match(/^(\d{2})-(.+)$/);
81
- const label = slug
82
- .split('-')
83
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
84
- .join(' ');
85
- return [num, `${num} ${label}`];
86
- })
87
- )
88
- : {};
78
+ export function areaNames(roadmapDir) {
79
+ if (!existsSync(roadmapDir)) return {};
80
+ return Object.fromEntries(
81
+ readdirSync(roadmapDir)
82
+ .filter((d) => /^\d{2}-/.test(d) && statSync(join(roadmapDir, d)).isDirectory())
83
+ .map((d) => {
84
+ const [, num, slug] = d.match(/^(\d{2})-(.+)$/);
85
+ const label = slug
86
+ .split('-')
87
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
88
+ .join(' ');
89
+ return [num, `${num} ${label}`];
90
+ })
91
+ );
92
+ }
93
+
89
94
  const SEED_STATUS_LABEL = {
90
95
  raw: 'Raw',
91
96
  ready: 'Ready',
@@ -129,12 +134,12 @@ function parseFrontmatter(md) {
129
134
  return fm;
130
135
  }
131
136
 
132
- function readSeeds() {
133
- if (!existsSync(SEEDS)) return [];
134
- return readdirSync(SEEDS)
137
+ function readSeeds(seedsDir) {
138
+ if (!existsSync(seedsDir)) return [];
139
+ return readdirSync(seedsDir)
135
140
  .filter((f) => f.endsWith('.md'))
136
141
  .map((f) => {
137
- const fm = parseFrontmatter(readFileSync(join(SEEDS, f), 'utf8'));
142
+ const fm = parseFrontmatter(readFileSync(join(seedsDir, f), 'utf8'));
138
143
  seedStatusLabel(fm.status, `Roadmap/00-ideas/seeds/${f}`); // hard-fail on an invalid enum value
139
144
  seedAppetite(fm.appetite, `Roadmap/00-ideas/seeds/${f}`); // hard-fail on an invalid appetite
140
145
  return { ...fm, _file: `Roadmap/00-ideas/seeds/${f}` };
@@ -172,11 +177,11 @@ export function seedAppetite(appetite, file = 'seed') {
172
177
  return appetite;
173
178
  }
174
179
 
175
- function listEpicDirs() {
180
+ function listEpicDirs(roadmapDir) {
176
181
  const out = [];
177
- for (const macro of readdirSync(ROADMAP)) {
182
+ for (const macro of readdirSync(roadmapDir)) {
178
183
  if (!/^[0-9]{2}-/.test(macro)) continue; // macro-section folders only
179
- const macroPath = join(ROADMAP, macro);
184
+ const macroPath = join(roadmapDir, macro);
180
185
  if (!statSync(macroPath).isDirectory()) continue;
181
186
  for (const slug of readdirSync(macroPath)) {
182
187
  const epicPath = join(macroPath, slug);
@@ -337,10 +342,10 @@ export function buildOrderNum(v) {
337
342
 
338
343
  // When a row ENTERED its current status: the last commit whose diff touched the status-bearing line, else the
339
344
  // file's last commit, else today (a brand-new uncommitted scaffold entered its status now). Docs + git stay the SSOT.
340
- function gitDate(args) {
345
+ function gitDate(root, args) {
341
346
  try {
342
347
  return execFileSync('git', ['log', '-1', '--format=%as', ...args], {
343
- cwd: REPO,
348
+ cwd: root,
344
349
  encoding: 'utf8',
345
350
  stdio: ['ignore', 'pipe', 'ignore'],
346
351
  }).trim();
@@ -348,10 +353,10 @@ function gitDate(args) {
348
353
  return '';
349
354
  }
350
355
  }
351
- function statusDate(relFile, statusLineRegex) {
356
+ function statusDate(root, relFile, statusLineRegex) {
352
357
  return (
353
- (statusLineRegex && gitDate([`-G${statusLineRegex}`, '--', relFile])) ||
354
- gitDate(['--', relFile]) ||
358
+ (statusLineRegex && gitDate(root, [`-G${statusLineRegex}`, '--', relFile])) ||
359
+ gitDate(root, ['--', relFile]) ||
355
360
  new Date().toISOString().slice(0, 10)
356
361
  );
357
362
  }
@@ -454,13 +459,22 @@ const prOf = (pr) =>
454
459
  * Build the rows. `facts` is what lib/stage-facts.mjs gathered (`{ branches, prs, origin }`); the default is no
455
460
  * facts, so an import — doc-hygiene, pmo-report, a spec — never touches the network or the snapshot.
456
461
  */
457
- export function buildRows({ facts = { branches: [], prs: [], origin: null } } = {}) {
458
- const seeds = readSeeds();
462
+ export function buildRows({
463
+ facts = { branches: [], prs: [], origin: null },
464
+ root = REPO,
465
+ dates = true,
466
+ } = {}) {
467
+ const ROADMAP = join(root, 'Roadmap');
468
+ const AREA_NAMES = areaNames(ROADMAP);
469
+ // `dates: false` skips the per-row `git log` (one per doc) — the build view runs inside a 5-second hook budget and
470
+ // reads no date; every other field is the same either way.
471
+ const when = (rel, re) => (dates ? statusDate(root, rel, re) : null);
472
+ const seeds = readSeeds(join(ROADMAP, '00-ideas', 'seeds'));
459
473
  const seedByEpic = new Map();
460
474
  for (const s of seeds) if (s.epic) seedByEpic.set(s.epic, s);
461
475
 
462
476
  const rows = [];
463
- const epicDirs = listEpicDirs();
477
+ const epicDirs = listEpicDirs(ROADMAP);
464
478
 
465
479
  // Which initiative each branch and PR belongs to (D13): epic slugs and funnel-seed slugs are the names a branch can
466
480
  // carry; a seed that carries `epic:` names that epic.
@@ -508,7 +522,7 @@ export function buildRows({ facts = { branches: [], prs: [], origin: null } } =
508
522
  sprints: sprints.map((sp) => ({ n: sp.n, status: sp.status })), // each sprint's OWN docs status, not floored
509
523
  sprints_total: sprints.length,
510
524
  });
511
- const statusDay = statusDate(readmePath, '^status:');
525
+ const statusDay = when(readmePath, '^status:');
512
526
  let kickoff = null;
513
527
  if (stage === 'Ready to build') {
514
528
  try {
@@ -562,7 +576,7 @@ export function buildRows({ facts = { branches: [], prs: [], origin: null } } =
562
576
  slug: `${e.slug}--s${sp.n}`,
563
577
  grain: 'Sprint',
564
578
  status: sp.status,
565
- status_date: statusDate(`Roadmap/${epicKey}/sprint-${sp.n}.md`, '^(\\*\\*)?Status:'),
579
+ status_date: when(`Roadmap/${epicKey}/sprint-${sp.n}.md`, '^(\\*\\*)?Status:'),
566
580
  area,
567
581
  priority,
568
582
  type: 'Sprint',
@@ -580,7 +594,7 @@ export function buildRows({ facts = { branches: [], prs: [], origin: null } } =
580
594
  // Seed rows: only seeds with no scaffolded epic (epic == null)
581
595
  for (const s of seeds.filter((x) => !x.epic)) {
582
596
  const { stage, source: stageSource, pr } = stageFor(s.slug, { grain: 'Seed', status: s.status });
583
- const statusDay = statusDate(s._file, '^status:');
597
+ const statusDay = when(s._file, '^status:');
584
598
  const name = s.title || s.slug;
585
599
  rows.push({
586
600
  name,
@@ -601,7 +615,7 @@ export function buildRows({ facts = { branches: [], prs: [], origin: null } } =
601
615
  build_order_num: buildOrderNum(s.build_order),
602
616
  doc_link: s._file,
603
617
  epic_slug: null,
604
- goal: firstParagraph(readFileSync(join(REPO, s._file), 'utf8'), [
618
+ goal: firstParagraph(readFileSync(join(root, s._file), 'utf8'), [
605
619
  'Problem',
606
620
  'Why',
607
621
  'Outcome & signal',
@@ -619,6 +633,17 @@ export function buildRows({ facts = { branches: [], prs: [], origin: null } } =
619
633
  return rows;
620
634
  }
621
635
 
636
+ /** The sinks one projector feeds (board-sinks-and-scrumban S3.2): no `--sink` prints the rows as JSON. */
637
+ export const SINKS = Object.freeze(['terminal', 'hub', 'notion']);
638
+
639
+ /** `--sink <name>` / `--sink=<name>` → the name, `''` for a bare `--sink`, or null when absent. */
640
+ export function sinkFrom(argv) {
641
+ const eq = argv.find((a) => a.startsWith('--sink='));
642
+ if (eq) return eq.slice('--sink='.length);
643
+ const i = argv.indexOf('--sink');
644
+ return i === -1 ? null : (argv[i + 1] ?? '');
645
+ }
646
+
622
647
  /** `--live` · `--offline` (the default) · `--docs-only` → a facts mode. The last one given wins. */
623
648
  export function factsModeFrom(argv) {
624
649
  let mode = 'snapshot';
@@ -643,18 +668,57 @@ const isMain = (() => {
643
668
  }
644
669
  })();
645
670
  if (isMain) {
646
- const mode = factsModeFrom(process.argv.slice(2));
671
+ const argv = process.argv.slice(2);
672
+ const sink = sinkFrom(argv);
673
+ if (sink !== null && !SINKS.includes(sink)) {
674
+ process.stderr.write(`roadmap-extract: --sink takes one of ${SINKS.join(' | ')} (got "${sink}")\n`);
675
+ process.exit(2);
676
+ }
677
+ // A sink shows or sends the board, so it wants Building and QA: live facts unless a mode was asked for.
678
+ const explicit = argv.some((a) => a === '--live' || a === '--offline' || a === '--docs-only');
679
+ const mode = sink !== null && !explicit ? 'live' : factsModeFrom(argv);
647
680
  const facts = gatherFacts({ root: REPO, mode });
648
681
  // A FAILED --live run says why on stderr (the rows say where their facts came from in every stage_source). An
649
682
  // offline run with no snapshot is the normal case for the six callers that spawn this with no flags — one of them
650
683
  // in the pre-commit hook — so it stays quiet.
651
684
  if (facts.note && mode === 'live') process.stderr.write(`roadmap-extract: ${facts.note}\n`);
652
- // --require-live: a publisher (roadmap-push in CI) must never send a board whose live facts silently fell back. A
653
- // fresh CI checkout has no snapshot, so the fallback is docs-only — every Building/QA card would drop to its docs
654
- // stage on the public Hub and the unchanged-board check would store it as a new version. Fail instead.
655
- if (process.argv.includes('--require-live') && facts.mode !== 'live') {
656
- process.stderr.write('roadmap-extract: --require-live, and live facts could not be gathered — nothing printed.\n');
685
+ // --require-live: a publisher must never send a board whose live facts silently fell back (a fresh CI checkout has
686
+ // no snapshot, so the fallback is docs-only: every Building/QA card would drop to its docs stage on the Hub and the
687
+ // unchanged-board check would store it as a new version). The Hub sink implies it whenever it asked for live facts.
688
+ const requireLive = argv.includes('--require-live') || (sink === 'hub' && mode === 'live');
689
+ if (requireLive && facts.mode !== 'live') {
690
+ process.stderr.write(
691
+ 'roadmap-extract: live facts could not be gathered — nothing printed or pushed. ' +
692
+ '(The Hub sink needs git and an authenticated `gh`; pass --docs-only to push the docs-only stages.)\n'
693
+ );
657
694
  process.exit(3);
658
695
  }
659
- writeSync(1, JSON.stringify(buildRows({ facts }), null, 2) + '\n');
696
+ const rows = buildRows({ facts });
697
+ if (sink === null) writeSync(1, JSON.stringify(rows, null, 2) + '\n');
698
+ else if (sink === 'terminal') writeSync(1, renderBoardText(rows, facts));
699
+ else if (sink === 'hub')
700
+ process.exitCode = reportPush(await pushRoadmap(rows, { root: REPO }), rows.length);
701
+ else {
702
+ // Notion is opt-in (template/optional/notion/): a project that wants it copies roadmap-to-notion.mjs beside this.
703
+ // Beside this file when it IS the project's copy; the project's scripts/ when this runs from the kit (fresh review, #227).
704
+ const notion = [
705
+ join(dirname(fileURLToPath(import.meta.url)), 'roadmap-to-notion.mjs'),
706
+ join(REPO, 'scripts', 'roadmap-to-notion.mjs'),
707
+ ].find((p) => existsSync(p));
708
+ if (!notion) {
709
+ process.stderr.write(
710
+ 'roadmap-extract: no roadmap-to-notion.mjs beside this script or in the project\'s scripts/ — the Notion sink is opt-in: copy ' +
711
+ 'template/optional/notion/roadmap-to-notion.mjs into scripts/ and set NOTION_TOKEN + NOTION_DB_ID.\n'
712
+ );
713
+ process.exit(2);
714
+ }
715
+ const r = spawnSync(
716
+ process.execPath,
717
+ [notion, '--sync', `--${mode === 'live' ? 'live' : mode === 'docs' ? 'docs-only' : 'offline'}`],
718
+ {
719
+ stdio: 'inherit',
720
+ }
721
+ );
722
+ process.exitCode = r.status ?? 1;
723
+ }
660
724
  }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ // roadmap-push.mjs — push a project's roadmap extract to a Growth Engine as a report artifact (the Hub).
3
+ //
4
+ // board-sinks-and-scrumban S3.2 — this file ships in the kit (`npx -y @golden-frijoles/kit roadmap-push`, or
5
+ // `roadmap-extract --sink hub`, which calls `pushRoadmap()` in-process), byte-identical in every project's scripts/.
6
+ // So a stranger feeds the hosted board with the same rail this repo uses.
7
+ //
8
+ // pod-report · Sprint 1, Story 1.1. golden-beans is tenant #0: it dogfoods the same client-pushes
9
+ // rail a customer uses, through the same public endpoint, with the same kind of API key. There is
10
+ // no privileged internal path — if this script works, a customer's does too.
11
+ //
12
+ // node scripts/roadmap-push.mjs # push to $GROWTH_ENGINE_URL
13
+ // node scripts/roadmap-push.mjs --dry-run # print the envelope, send nothing
14
+ // node scripts/roadmap-push.mjs --url http://localhost:3000
15
+ //
16
+ // Env: GROWTH_ENGINE_URL (default http://localhost:3000) and the project's ingest key: SELF_PROJECT_API_KEY FIRST (it only
17
+ // ever means this project's own key), else the SDK's GROWTH_ENGINE_API_KEY. Not the other way round — see apiKeyFrom.
18
+ // A missing key is a CLEAN SKIP (exit 0), not a failure — see the note in the CI step.
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import { writeSync } from 'node:fs';
22
+ import { dirname, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { getKey } from './lib/config.mjs';
25
+ import { projectRoot } from './lib/project-root.mjs';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ // The PROJECT, not this file's parent: installed from the kit, this file lives in node_modules (golden-frijoles-plugin D2).
29
+ const REPO_ROOT = projectRoot();
30
+
31
+ // Must match apps/web/lib/roadmap-artifact-schema.ts's ROADMAP_SCHEMA_VERSION. Duplicated rather
32
+ // than imported because this is a zero-dependency .mjs script and that module is TypeScript behind
33
+ // a Next.js path alias — a spec asserts the two agree so the duplication cannot silently drift.
34
+ export const ROADMAP_SCHEMA_VERSION = 1;
35
+
36
+ function git(args, root = REPO_ROOT) {
37
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
38
+ return r.status === 0 ? (r.stdout || '').trim() : null;
39
+ }
40
+
41
+ /**
42
+ * Build the push envelope from the extract rows.
43
+ *
44
+ * Pure and exported so a unit test pins the contract without running git, spawning the generator or
45
+ * touching the network — the envelope is the thing the server validates, so it is the thing worth
46
+ * testing.
47
+ */
48
+ export function buildEnvelope(items, { commit, ref, generatedAt, board = null } = {}) {
49
+ return {
50
+ schemaVersion: ROADMAP_SCHEMA_VERSION,
51
+ generatedAt: generatedAt ?? new Date().toISOString(),
52
+ source: {
53
+ // null, never undefined: JSON.stringify DROPS undefined keys, and a silently absent field is
54
+ // harder to diagnose server-side than an explicit null.
55
+ commit: commit ?? null,
56
+ ref: ref ?? null,
57
+ },
58
+ items,
59
+ // board-sinks-and-scrumban D21 — optional and additive; absent rather than null when there is nothing in it,
60
+ // so the server's unchanged-board check (D16) never sees a difference that carries no information.
61
+ ...(board ? { board } : {}),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The https base a card's repo-relative doc links resolve against, from the `origin` remote:
67
+ * `git@github.com:o/r.git` or `https://github.com/o/r(.git)` → `https://github.com/o/r/blob/<branch>/`.
68
+ * null for any other host — a link the Hub cannot build is better left out than guessed.
69
+ */
70
+ export function repoBlobBase(remoteUrl, branch = 'main') {
71
+ const m = String(remoteUrl || '')
72
+ .trim()
73
+ .match(
74
+ /^(?:git@github\.com:|https:\/\/(?:[^@/]+@)?github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/
75
+ );
76
+ if (!m || !/^[A-Za-z0-9_./-]+$/.test(branch)) return null;
77
+ return `https://github.com/${m[1]}/${m[2]}/blob/${branch}/`;
78
+ }
79
+
80
+ /** The envelope's `board` block: the WIP limits from config (D22) and the repo base for doc links. null when empty. */
81
+ export function buildBoard({ wip, repo }) {
82
+ const limits = {};
83
+ for (const stage of ['Building', 'QA']) {
84
+ const n = wip?.[stage];
85
+ if (Number.isInteger(n) && n > 0) limits[stage] = n;
86
+ }
87
+ const board = {};
88
+ if (Object.keys(limits).length) board.wip = limits;
89
+ if (repo) board.repo = repo;
90
+ return Object.keys(board).length ? board : null;
91
+ }
92
+
93
+ /**
94
+ * Rows from `roadmap-extract.mjs --live` — the ONE extractor (board-sinks-and-scrumban D15), with git and GitHub facts
95
+ * gathered now, so the pushed stages are the ones the event that triggered this run produced. Dies loudly on empty
96
+ * output (LEARNINGS: treat empty as failure).
97
+ */
98
+ export function readExtract(run = spawnSync) {
99
+ // --require-live: a failed git/GitHub gather turns the run red rather than publishing a docs-only board.
100
+ const r = run('node', [resolve(__dirname, 'roadmap-extract.mjs'), '--live', '--require-live'], {
101
+ cwd: REPO_ROOT,
102
+ encoding: 'utf8',
103
+ maxBuffer: 64 * 1024 * 1024,
104
+ });
105
+ if (r.status !== 0) {
106
+ throw new Error(`roadmap-extract.mjs --live failed: ${(r.stderr || '').trim().split('\n').pop()}`);
107
+ }
108
+ const out = (r.stdout || '').trim();
109
+ if (!out) throw new Error('roadmap-extract.mjs --live produced no output — treating empty as failure.');
110
+ const items = JSON.parse(out);
111
+ if (!Array.isArray(items) || items.length === 0) {
112
+ throw new Error('extract returned no rows — refusing to push an empty roadmap.');
113
+ }
114
+ return items;
115
+ }
116
+
117
+ /** The project's ingest key: `SELF_PROJECT_API_KEY` first, else the SDK's `GROWTH_ENGINE_API_KEY`. */
118
+ // ⚠️ ORDER MATTERS (fresh review, #227): a repo that ALSO syncs data from another project can hold that other project's
119
+ // key in `GROWTH_ENGINE_API_KEY` (the SDK's name for "the engine key"), so preferring it would push this roadmap into
120
+ // someone else's project from any shell that has it exported. `SELF_PROJECT_API_KEY` — the name that only ever means
121
+ // "this project's own key" — wins; a project that never sets it uses the SDK's name.
122
+ export const apiKeyFrom = (env = process.env) =>
123
+ env.SELF_PROJECT_API_KEY || env.GROWTH_ENGINE_API_KEY || null;
124
+
125
+ /** The envelope for `items` as pushed from the checkout at `root`: provenance, the WIP limits, the repo base. */
126
+ export function envelopeFor(items, root = REPO_ROOT) {
127
+ const defaultBranch = (
128
+ git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root) || 'origin/main'
129
+ ).replace(/^origin\//, '');
130
+ return buildEnvelope(items, {
131
+ commit: git(['rev-parse', 'HEAD'], root),
132
+ ref: git(['rev-parse', '--abbrev-ref', 'HEAD'], root),
133
+ board: buildBoard({
134
+ wip: getKey('board.wip', { root }),
135
+ repo: repoBlobBase(git(['remote', 'get-url', 'origin'], root), defaultBranch),
136
+ }),
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Push rows to the engine. Returns `{ ok, skipped, status, text }` and writes nothing to stdout itself — the CLI and
142
+ * `roadmap-extract --sink hub` say what happened in their own words. No key is a clean skip, never an error.
143
+ */
144
+ export async function pushRoadmap(
145
+ items,
146
+ {
147
+ root = REPO_ROOT,
148
+ baseUrl = process.env.GROWTH_ENGINE_URL || 'http://localhost:3000',
149
+ apiKey = apiKeyFrom(),
150
+ fetchFn = fetch,
151
+ } = {}
152
+ ) {
153
+ if (!apiKey) return { ok: true, skipped: true, status: null, text: '' };
154
+ let res;
155
+ try {
156
+ res = await fetchFn(`${String(baseUrl).replace(/\/$/, '')}/api/v1/roadmap/push`, {
157
+ method: 'POST',
158
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
159
+ body: JSON.stringify(envelopeFor(items, root)),
160
+ signal: AbortSignal.timeout(30_000),
161
+ });
162
+ } catch (err) {
163
+ // An unreachable engine is a failed push with its reason — never a stack trace (fresh review, #227).
164
+ return { ok: false, skipped: false, status: null, text: `could not reach ${baseUrl}: ${err.message}` };
165
+ }
166
+ return { ok: res.ok, skipped: false, status: res.status, text: await res.text() };
167
+ }
168
+
169
+ /** The CLI's report for one push result. Exported so `roadmap-extract --sink hub` says it the same way. */
170
+ export function reportPush(result, count) {
171
+ if (result.skipped) {
172
+ // Clean skip, not a failure: a repo without the key must not turn every CI run red over an observability-grade
173
+ // nicety — same stance as the Telegram workflow.
174
+ process.stderr.write(
175
+ 'SELF_PROJECT_API_KEY (or GROWTH_ENGINE_API_KEY) not set — skipping the roadmap push cleanly.\n' +
176
+ ` ${count} rows were generated and discarded. Set the project's ingest key to enable this.\n`
177
+ );
178
+ return 0;
179
+ }
180
+ if (!result.ok) {
181
+ // The server's own error body names WHICH field failed and whether it was a version or a shape problem.
182
+ process.stderr.write(`✗ roadmap push failed (${result.status}): ${result.text}\n`);
183
+ return 1;
184
+ }
185
+ writeSync(1, `✓ roadmap pushed (${count} rows): ${result.text}\n`);
186
+ return 0;
187
+ }
188
+
189
+ async function main() {
190
+ const args = process.argv.slice(2);
191
+ const urlFlag = args.indexOf('--url');
192
+ const baseUrl =
193
+ (urlFlag !== -1 ? args[urlFlag + 1] : undefined) ||
194
+ process.env.GROWTH_ENGINE_URL ||
195
+ 'http://localhost:3000';
196
+ const items = readExtract();
197
+ if (args.includes('--dry-run')) {
198
+ writeSync(1, `${JSON.stringify(envelopeFor(items), null, 2)}\n`);
199
+ return;
200
+ }
201
+ process.exitCode = reportPush(await pushRoadmap(items, { baseUrl }), items.length);
202
+ }
203
+
204
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
205
+ if (isMain) {
206
+ main().catch((err) => {
207
+ process.stderr.write(`✗ ${err.message}\n`);
208
+ process.exit(1);
209
+ });
210
+ }
@@ -52,6 +52,7 @@ import { fileURLToPath } from 'node:url';
52
52
  import { dirname, join, resolve } from 'node:path';
53
53
  import { listPulls, getPullMergeability, getStatusRollup } from './lib/gh-rest.mjs';
54
54
  import { readLogFromBranch } from './lib/log-branch.mjs';
55
+ import { gatherFacts } from './lib/stage-facts.mjs';
55
56
  import {
56
57
  parseJournal,
57
58
  lastNEntries,
@@ -858,6 +859,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
858
859
  homeDir = process.env.HOME,
859
860
  now,
860
861
  resolveReposFn = resolveRepos,
862
+ gatherFactsFn = gatherFacts,
861
863
  } = deps;
862
864
 
863
865
  let args;
@@ -875,6 +877,15 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
875
877
  try {
876
878
  const { repos, note: reposNote } = resolveReposFn({ root: args.root, spawn });
877
879
  if (reposNote) warn(`⚠ session-resume: ${reposNote}`);
880
+ // board-sinks-and-scrumban S3.1 — refresh the board snapshot (.golden-frijoles/board.json) once per session start.
881
+ // The build view's hook never goes online, so this is what keeps the age it prints short. Best-effort: a failed
882
+ // gather keeps the old snapshot, and the build view says how old that one is.
883
+ try {
884
+ const facts = gatherFactsFn({ root: args.root, mode: 'live', run: spawn });
885
+ if (facts.note) warn(`⚠ session-resume: board snapshot not refreshed — ${facts.note}`);
886
+ } catch {
887
+ // never block a resume on the board
888
+ }
878
889
  const repoStates = repos.map((r) => ({
879
890
  repo: r.repo,
880
891
  dir: r.dir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {