@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.
- package/dist/build-state.mjs +114 -19
- package/dist/lib/board-text.mjs +50 -0
- package/dist/lib/config-registry.mjs +17 -0
- package/dist/roadmap-extract.mjs +109 -45
- package/dist/roadmap-push.mjs +210 -0
- package/dist/session-resume.mjs +11 -0
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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),
|
|
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)
|
|
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
|
|
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
|
|
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',
|
package/dist/roadmap-extract.mjs
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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(
|
|
134
|
-
return readdirSync(
|
|
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(
|
|
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(
|
|
182
|
+
for (const macro of readdirSync(roadmapDir)) {
|
|
178
183
|
if (!/^[0-9]{2}-/.test(macro)) continue; // macro-section folders only
|
|
179
|
-
const macroPath = join(
|
|
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:
|
|
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({
|
|
458
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/session-resume.mjs
CHANGED
|
@@ -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.
|
|
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": {
|