@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.
- package/dist/build-order.mjs +72 -100
- package/dist/build-state.mjs +118 -60
- package/dist/lib/board-text.mjs +50 -0
- package/dist/lib/config-registry.mjs +17 -0
- package/dist/lib/epic-kickoff.mjs +268 -0
- package/dist/lib/roadmap-status-buckets.mjs +4 -0
- package/dist/lib/stage-facts.mjs +166 -0
- package/dist/lib/stage.mjs +168 -0
- package/dist/lib/work-branch.mjs +51 -0
- package/dist/roadmap-extract.mjs +371 -57
- package/dist/roadmap-push.mjs +210 -0
- package/dist/session-resume.mjs +11 -0
- package/package.json +1 -1
package/dist/build-order.mjs
CHANGED
|
@@ -1,148 +1,117 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// build-order.mjs — render Roadmap/00-ideas/BUILD-ORDER.md from the SAME projection every roadmap
|
|
3
|
-
// tool reads (roadmap-extract.mjs)
|
|
4
|
-
//
|
|
2
|
+
// build-order.mjs — render Roadmap/00-ideas/BUILD-ORDER.md in the six stages, from the SAME projection every roadmap
|
|
3
|
+
// tool reads (roadmap-extract.mjs) and the ONE stage resolver (lib/stage.mjs). This file is GENERATED — never
|
|
4
|
+
// hand-edit BUILD-ORDER.md; run this instead.
|
|
5
5
|
//
|
|
6
|
-
// node scripts/build-order.mjs # write Roadmap/00-ideas/BUILD-ORDER.md
|
|
6
|
+
// node scripts/build-order.mjs # write Roadmap/00-ideas/BUILD-ORDER.md (docs only)
|
|
7
7
|
// node scripts/build-order.mjs --check # exit 1 if the file is stale (for CI/precommit)
|
|
8
|
+
// node scripts/build-order.mjs --live # print the full six-stage board with git/GitHub facts; writes nothing
|
|
8
9
|
//
|
|
9
|
-
// Why
|
|
10
|
-
// and
|
|
11
|
-
//
|
|
10
|
+
// ── Why the committed file is docs-only (board-sinks-and-scrumban lock C3) ───────────────────────────────
|
|
11
|
+
// Building and QA are FACTS git and GitHub hold (a branch on origin, a ready PR), never fields in a doc. A committed
|
|
12
|
+
// file that carried them would go stale on every PR event, and `--check` (build-order-guard, the nightly sync) would
|
|
13
|
+
// turn every unrelated PR red. So the file holds what the docs alone decide — To groom, Grooming, Ready to build,
|
|
14
|
+
// Shipped — and says where Building and QA live: `--live` here, the Hub board, and the build view. `--live` is the
|
|
15
|
+
// same resolver with live facts, so it agrees with the Hub by construction.
|
|
12
16
|
|
|
13
17
|
import { execFileSync } from 'node:child_process';
|
|
14
18
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
15
19
|
import { fileURLToPath } from 'node:url';
|
|
16
20
|
import { dirname, join } from 'node:path';
|
|
17
|
-
import {
|
|
21
|
+
import { STAGES, groupByStage } from './lib/stage.mjs';
|
|
18
22
|
import { projectRoot } from './lib/project-root.mjs';
|
|
19
23
|
|
|
20
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
25
|
const REPO = projectRoot(); // D2
|
|
22
26
|
const OUT = join(REPO, 'Roadmap', '00-ideas', 'BUILD-ORDER.md');
|
|
23
27
|
const EXTRACTOR = join(__dirname, 'roadmap-extract.mjs');
|
|
28
|
+
const LIVE = process.argv.includes('--live');
|
|
24
29
|
|
|
25
30
|
function extract() {
|
|
26
|
-
const json = execFileSync('node', [EXTRACTOR
|
|
31
|
+
const json = execFileSync('node', [EXTRACTOR, LIVE ? '--live' : '--docs-only'], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
35
|
+
});
|
|
27
36
|
return JSON.parse(json);
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// drift from this board's counts. Emoji/title here are this board's own presentation.
|
|
39
|
-
const EPIC_BUCKET_META = {
|
|
40
|
-
'In progress': { emoji: '🏗️', title: 'Building now' },
|
|
41
|
-
Scaffolded: { emoji: '📋', title: 'Ready to build (scaffolded, not started)' },
|
|
42
|
-
Shipped: { emoji: '✅', title: 'Shipped' },
|
|
39
|
+
// What each stage means, in the words the board uses (the seed's table, locked 2026-10-01).
|
|
40
|
+
const STAGE_NOTE = {
|
|
41
|
+
'To groom': 'seeds with no pitch yet',
|
|
42
|
+
Grooming: 'a pitch is waiting at the approval gate',
|
|
43
|
+
'Ready to build': 'scaffolded (or a fixed-scope seed), in build order — pull from the top',
|
|
44
|
+
Building: 'a work branch is on origin',
|
|
45
|
+
QA: 'a PR is ready for review, or merged and waiting for its close-out',
|
|
46
|
+
Shipped: 'merged, deployed and closed',
|
|
43
47
|
};
|
|
44
|
-
const
|
|
45
|
-
const SEED_FUNNEL = SEED_FUNNEL_STATUSES;
|
|
48
|
+
const LIVE_ONLY = new Set(['Building', 'QA']);
|
|
46
49
|
|
|
47
50
|
function line(r) {
|
|
48
|
-
|
|
51
|
+
// BUILD-ORDER.md lives in Roadmap/00-ideas/, so an epic README is ONE folder up — `../../` resolved to the repo
|
|
52
|
+
// root and 404'd every epic link on GitHub (seed board-link-depth, absorbed by board-sinks-and-scrumban S1.5).
|
|
53
|
+
const link = r.grain === 'Seed' ? `seeds/${r.slug}.md` : `../${r.doc_link.replace(/^Roadmap\//, '')}`;
|
|
49
54
|
const meta = [];
|
|
55
|
+
if (r.build_order_num != null) meta.push(`#${r.build_order_num}`);
|
|
50
56
|
if (r.area) meta.push(r.area);
|
|
57
|
+
if (r.grain === 'Seed') meta.push(`seed · ${r.type}`);
|
|
51
58
|
if (r.sprint_progress) meta.push(r.sprint_progress);
|
|
52
59
|
if (r.risk) meta.push(`risk: ${r.risk}`);
|
|
53
|
-
if (r.
|
|
54
|
-
|
|
60
|
+
if (r.appetite) meta.push(`appetite ${r.appetite}`);
|
|
61
|
+
// A queued seed without an underwriter is funded-but-unowned — advisory, loud on the board.
|
|
62
|
+
const warn =
|
|
63
|
+
r.grain === 'Seed' && r.status === 'Queued' && !r.underwritten_by
|
|
64
|
+
? ' — ⚠️ no underwriter (set `underwritten_by:` at the betting table)'
|
|
65
|
+
: '';
|
|
66
|
+
return `- [${r.name}](${link}) — ${meta.join(' · ')} · _${r.stage_source}_${warn}`;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
function render(rows) {
|
|
58
|
-
|
|
59
|
-
const
|
|
70
|
+
// Shipped by build order, never by `shipped_at`: that is a git date, and a depth-1 CI clone would reorder the file.
|
|
71
|
+
const columns = groupByStage(rows, { shipped: 'build-order' });
|
|
60
72
|
const now = new Date().toISOString().slice(0, 10);
|
|
61
|
-
|
|
62
|
-
// Epic status is authoritative from the README frontmatter (the extractor resolved it into e.status).
|
|
63
|
-
// Advisory drift = the prose/retro derivation (e.status_derived) disagreeing with that authoritative
|
|
64
|
-
// status — usually a close-out that forgot to set `status:`, or a stale README. Non-gating signal.
|
|
65
|
-
const drift = [];
|
|
66
|
-
for (const e of epics) {
|
|
67
|
-
if (e.status_derived && e.status_derived !== e.status) {
|
|
68
|
-
drift.push({ name: e.name, frontmatter: e.status, derived: e.status_derived });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
render._drift = drift;
|
|
72
|
-
|
|
73
73
|
const out = [];
|
|
74
74
|
out.push('<!-- GENERATED FILE — do not edit by hand.');
|
|
75
75
|
out.push(' Regenerate: node scripts/build-order.mjs');
|
|
76
|
-
out.push(" Status SSOT: each epic README's frontmatter `status:` field (set at epic close). Funnel");
|
|
77
|
-
out.push(' ordering: seed frontmatter (priority). Both projected via scripts/roadmap-extract.mjs. -->');
|
|
78
|
-
out.push('');
|
|
79
|
-
out.push('# Build order — generated status board');
|
|
80
|
-
out.push('');
|
|
81
|
-
out.push(
|
|
82
|
-
`> **Generated ${now} — do not hand-edit.** Epic status SSOT = the epic \`README.md\` frontmatter`
|
|
83
|
-
);
|
|
84
76
|
out.push(
|
|
85
|
-
|
|
77
|
+
" Stage: scripts/lib/stage.mjs, from each initiative's frontmatter (docs only in this file; the"
|
|
86
78
|
);
|
|
87
79
|
out.push(
|
|
88
|
-
'
|
|
80
|
+
' live board with git/GitHub facts is `node scripts/build-order.mjs --live` and the Hub). -->'
|
|
89
81
|
);
|
|
90
|
-
out.push('> DB are both *derived views* — never hand-edit the board.');
|
|
91
82
|
out.push('');
|
|
92
|
-
|
|
93
|
-
for (const b of EPIC_BUCKETS) {
|
|
94
|
-
const list = epics
|
|
95
|
-
.filter((e) => e.status === b.key)
|
|
96
|
-
.sort((a, z) => (a.area || '').localeCompare(z.area || '') || a.name.localeCompare(z.name));
|
|
97
|
-
out.push(`## ${b.emoji} ${b.title} (${list.length})`);
|
|
98
|
-
out.push('');
|
|
99
|
-
out.push(list.length ? list.map(line).join('\n') : '_None._');
|
|
100
|
-
out.push('');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const funnel = seeds
|
|
104
|
-
.filter((s) => SEED_FUNNEL.has(s.status))
|
|
105
|
-
.sort((a, z) => (a.priority || 'zzz').localeCompare(z.priority || 'zzz') || a.name.localeCompare(z.name));
|
|
106
|
-
out.push(`## ⬜ Funnel — seeds not yet scaffolded (${funnel.length})`);
|
|
83
|
+
out.push('# Build order — the six stages');
|
|
107
84
|
out.push('');
|
|
108
|
-
out.push(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const warn =
|
|
117
|
-
s.status === 'Queued' && !s.underwritten_by
|
|
118
|
-
? ' — ⚠️ no underwriter (set `underwritten_by:` at the betting table)'
|
|
119
|
-
: '';
|
|
120
|
-
return `- [${s.name}](seeds/${s.slug}.md)${meta ? ` — ${meta}` : ''}${warn}`;
|
|
121
|
-
})
|
|
122
|
-
.join('\n')
|
|
123
|
-
: '_None._'
|
|
124
|
-
);
|
|
85
|
+
out.push(`> **Generated ${now} — do not hand-edit.** One stage per initiative, decided in one place`);
|
|
86
|
+
out.push('> (`scripts/lib/stage.mjs`): To groom · Grooming · Ready to build · Building · QA · Shipped.');
|
|
87
|
+
if (LIVE) {
|
|
88
|
+
out.push('> **Live:** Building and QA come from git and GitHub as of this run. Printed, never written.');
|
|
89
|
+
} else {
|
|
90
|
+
out.push('> This committed file reads the docs alone, so **Building and QA are not here** — they are facts git');
|
|
91
|
+
out.push('> and GitHub hold. For the live board run `node scripts/build-order.mjs --live`, or open the Hub board.');
|
|
92
|
+
}
|
|
125
93
|
out.push('');
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
94
|
+
for (const stage of STAGES) {
|
|
95
|
+
const list = columns[stage];
|
|
96
|
+
if (LIVE_ONLY.has(stage) && !LIVE) {
|
|
97
|
+
out.push(`## ${stage} — live only`);
|
|
98
|
+
out.push('');
|
|
99
|
+
out.push(
|
|
100
|
+
`_${STAGE_NOTE[stage]}. Not in this committed file: \`node scripts/build-order.mjs --live\` or the Hub board._`
|
|
101
|
+
);
|
|
102
|
+
out.push('');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
out.push(`## ${stage} (${list.length})`);
|
|
129
106
|
out.push('');
|
|
130
|
-
out.push(
|
|
131
|
-
out.push('derivation infers. The board trusts the **frontmatter**; a mismatch usually means a close-out');
|
|
132
|
-
out.push(
|
|
133
|
-
'forgot to set `status:` (or the README is stale). Reconcile the README, then this advisory clears.'
|
|
134
|
-
);
|
|
107
|
+
out.push(`_${STAGE_NOTE[stage]}._`);
|
|
135
108
|
out.push('');
|
|
136
|
-
out.push(
|
|
137
|
-
out.push('|---|---|---|');
|
|
138
|
-
for (const d of drift) out.push(`| ${d.name} | ${d.frontmatter} | ${d.derived} |`);
|
|
109
|
+
out.push(list.length ? list.map(line).join('\n') : '_None._');
|
|
139
110
|
out.push('');
|
|
140
111
|
}
|
|
141
|
-
|
|
112
|
+
const cards = STAGES.reduce((n, s) => n + columns[s].length, 0);
|
|
142
113
|
out.push('---');
|
|
143
|
-
out.push(
|
|
144
|
-
`_Epics: ${epics.length} · seeds in funnel: ${funnel.length} · status drift: ${drift.length}. Regenerate with \`node scripts/build-order.mjs\`._`
|
|
145
|
-
);
|
|
114
|
+
out.push(`_${cards} initiatives on the board. Regenerate with \`node scripts/build-order.mjs\`._`);
|
|
146
115
|
out.push('');
|
|
147
116
|
return out.join('\n');
|
|
148
117
|
}
|
|
@@ -163,7 +132,10 @@ if (unfunded.length) {
|
|
|
163
132
|
|
|
164
133
|
const content = render(rows);
|
|
165
134
|
|
|
166
|
-
if (
|
|
135
|
+
if (LIVE) {
|
|
136
|
+
// Printed, never written: the committed file must stay docs-only (lock C3).
|
|
137
|
+
process.stdout.write(content);
|
|
138
|
+
} else if (process.argv.includes('--check')) {
|
|
167
139
|
const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '';
|
|
168
140
|
// Ignore the "Generated <date>" line when comparing so a date-only diff isn't "stale".
|
|
169
141
|
const norm = (s) => s.replace(/^> \*\*Generated \d{4}-\d{2}-\d{2} /m, '> **Generated DATE ');
|
package/dist/build-state.mjs
CHANGED
|
@@ -58,11 +58,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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (m) return Number(m[1]);
|
|
81
|
-
return rest[0] === 'sprint' && /^\d+$/.test(rest[1] ?? '') ? Number(rest[1]) : null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Every reading of a work branch as `<slug>[-s<N>][-anything]`, LONGEST slug first:
|
|
86
|
-
* `feat/foo-s4-licences` → foo-s4-licences, foo-s4, foo (sprint 4). The resolver takes the first reading
|
|
87
|
-
* whose slug names an epic (or a seed) on disk. [] for a branch that isn't a work branch.
|
|
88
|
-
*/
|
|
89
|
-
export function branchCandidates(branch) {
|
|
90
|
-
const m = String(branch || '').match(BRANCH_PREFIX_RE);
|
|
91
|
-
if (!m || !SLUG_RE.test(m[1]) || m[1].includes('..')) return [];
|
|
92
|
-
const tokens = m[1].split('-');
|
|
93
|
-
const out = [];
|
|
94
|
-
for (let i = tokens.length; i >= 1; i--) {
|
|
95
|
-
out.push({
|
|
96
|
-
slug: tokens.slice(0, i).join('-'),
|
|
97
|
-
sprint: sprintAt(tokens.slice(i)),
|
|
98
|
-
exact: i === tokens.length,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The syntactic reading alone, no disk: `feat/foo-s3` → { slug: 'foo', sprint: 3 },
|
|
106
|
-
* `feat/foo-s3-extra` → { slug: 'foo', sprint: 3 }; null for a branch that is not a work branch.
|
|
107
|
-
*/
|
|
108
|
-
export function parseBranch(branch) {
|
|
109
|
-
const cands = branchCandidates(branch);
|
|
110
|
-
if (!cands.length) return null;
|
|
111
|
-
const withSprint = cands.find((c) => c.sprint !== null);
|
|
112
|
-
return withSprint
|
|
113
|
-
? { slug: withSprint.slug, sprint: withSprint.sprint }
|
|
114
|
-
: { slug: cands[0].slug, sprint: null };
|
|
115
|
-
}
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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),
|
|
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)
|
|
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
|
|
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
|
|
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',
|