@golden-frijoles/kit 0.0.0 → 0.2.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.
Files changed (51) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/lib/cross-agent-cli.mjs +695 -0
  14. package/dist/lib/gh-rest.mjs +229 -0
  15. package/dist/lib/golden-onboarding.mjs +179 -0
  16. package/dist/lib/jev.mjs +334 -0
  17. package/dist/lib/log-branch.mjs +106 -0
  18. package/dist/lib/pmo-benchmarks.mjs +61 -0
  19. package/dist/lib/pmo-delivery.mjs +102 -0
  20. package/dist/lib/pmo-metrics.mjs +200 -0
  21. package/dist/lib/pmo-templates.mjs +132 -0
  22. package/dist/lib/pmo-window-log.mjs +99 -0
  23. package/dist/lib/project-root.mjs +78 -0
  24. package/dist/lib/prose-brief.mjs +191 -0
  25. package/dist/lib/prose-guard.mjs +761 -0
  26. package/dist/lib/prose-writer.mjs +410 -0
  27. package/dist/lib/report-registry.mjs +469 -0
  28. package/dist/lib/reporting-config.mjs +266 -0
  29. package/dist/lib/roadmap-contract.mjs +305 -0
  30. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  31. package/dist/lib/standup-deck.mjs +120 -0
  32. package/dist/lib/telegram-format.mjs +91 -0
  33. package/dist/live-smoke.mjs +303 -0
  34. package/dist/pmo/benchmarks.json +77 -0
  35. package/dist/pmo/templates/metrics-sheet.md +27 -0
  36. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  37. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  38. package/dist/pmo-report.mjs +338 -0
  39. package/dist/preflight.mjs +444 -0
  40. package/dist/prose/cpo-persona.md +108 -0
  41. package/dist/prose/internal.task.md +48 -0
  42. package/dist/prose/standup.task.md +38 -0
  43. package/dist/prose/weekly.task.md +36 -0
  44. package/dist/prose-draft.mjs +198 -0
  45. package/dist/prose-lessons.md +59 -0
  46. package/dist/roadmap-extract.mjs +410 -0
  47. package/dist/standup/templates/daily-story-deck.md +46 -0
  48. package/dist/standup.mjs +634 -0
  49. package/dist/vercel-prune-previews.mjs +164 -0
  50. package/dist/weekly-recap.mjs +526 -0
  51. package/package.json +33 -1
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ // roadmap-extract.mjs — project the Roadmap docs into rows: the ONE status extractor every roadmap tool reads.
3
+ //
4
+ // node scripts/roadmap-extract.mjs # print the projected rows as JSON
5
+ //
6
+ // Readers: build-order.mjs (BUILD-ORDER.md), doc-hygiene.mjs (archived-epic checks), pmo-report.mjs (story
7
+ // progress), and — only if a project opts in — optional/notion/roadmap-to-notion.mjs, which pushes these
8
+ // same rows to a Notion board.
9
+ //
10
+ // ── Why this is its own file (plugin-audit-and-extraction S1, story 1.5) ────────────────────────────────
11
+ // This used to be the first half of roadmap-to-notion.mjs, the largest file in the copy-once skeleton, for
12
+ // an integration most projects never use. But the half every project DOES use — the extractor — was
13
+ // inside it, so the Notion sync could not be made opt-in without breaking build-order.mjs. The split puts
14
+ // the SSOT here, under a name that says what it is, and moves only the Notion push to optional/notion/.
15
+ // The extraction logic is unchanged.
16
+ //
17
+ // Grain (full funnel, 3 grains):
18
+ // • Epic — one row per epic folder under Roadmap/<NN-macro>/<slug>/ (has a README.md)
19
+ // • Sprint — one row per sprint-N.md inside an epic, linked to its Epic via the "Epic" relation
20
+ // • Seed — one row per seed in 00-ideas/seeds/ whose frontmatter epic == null (un-scaffolded funnel)
21
+ //
22
+ // Status derivation (docs win, re-derived every run):
23
+ // EPIC: the AUTHORITATIVE source is the epic README's frontmatter `status:` field
24
+ // (shipped|in-progress|scaffolded|queued|archived), set at epic close. Sprint/retro derivation is
25
+ // kept ONLY as a fallback when the frontmatter field is ABSENT, and is also emitted as `status_derived`
26
+ // so the board can flag an advisory drift (frontmatter vs derived) when a close-out forgets to set it.
27
+ // A PRESENT but unrecognized value HARD-FAILS the run — it used to silently fall back to the derived
28
+ // status, which made status === status_derived by construction, so the drift check structurally could
29
+ // not fire on invalid enum values (how `mercadolibre-sync` sat at `status: ready` while fully live;
30
+ // Roadmap/00-ideas/audits/roadmap-grooming-audit-2026-07-06.md §1). Seed `status:` is enforced the
31
+ // same way against its own enum.
32
+ // SPRINT: read its `Status:` line (controlled vocab below) → else count story ticks.
33
+ // Planned (none started) · In progress (some stories ✅) · In review (all ✅, not yet closed out /
34
+ // "built — awaiting review/draft PR") · Shipped (✅ merged/shipped, or all ✅ + smoke walkthrough written).
35
+ // EPIC fallback: rolled up from its sprints — all Shipped ⇒ Shipped · any active ⇒ In progress · all Planned ⇒ Scaffolded.
36
+ // SEED: its frontmatter status (raw|ready|queued|archived). A seed with `epic:` set is funnel-only —
37
+ // its status is NOT read for epic status (the epic README frontmatter owns that).
38
+ //
39
+ // NOTE on the sprint `**Status:**` line: the "Wrap S<n>" step (SESSION-KICKOFFS §7) should set it to one of
40
+ // ⬜ Planned · 🏗 In progress · 🟦 In review · ✅ Shipped — that keeps this projection trivially reliable.
41
+ // Legacy freeform lines are still mapped best-effort below.
42
+
43
+ import { readFileSync, readdirSync, existsSync, statSync, writeSync, realpathSync } from 'node:fs';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { join } from 'node:path';
46
+ import { projectRoot } from './lib/project-root.mjs';
47
+
48
+ const REPO = projectRoot(); // D2
49
+ const ROADMAP = join(REPO, 'Roadmap');
50
+ const SEEDS = join(ROADMAP, '00-ideas', 'seeds');
51
+
52
+ const AREA_NAMES = {
53
+ '01': '01 Discovery', '02': '02 Checkout & Payments', '03': '03 Selling & Shops',
54
+ '04': '04 Shipping', '05': '05 Trust/Offers/Messaging', '06': '06 Print',
55
+ '07': '07 Agentic/Federated', '08': '08 Growth', '09': '09 Platform-infra',
56
+ '10': '10 Events & Ticketing',
57
+ };
58
+ const SEED_STATUS_LABEL = {
59
+ raw: 'Raw', ready: 'Ready', queued: 'Queued', scaffolded: 'Scaffolded',
60
+ 'in-progress': 'In progress', shipped: 'Shipped', archived: 'Archived',
61
+ };
62
+ const PRIORITY_LABEL = {
63
+ 'wave-0': 'Wave 0 Enablers', 'wave-1': 'Wave 1', 'wave-2': 'Wave 2',
64
+ 'wave-3': 'Wave 3', 'wave-4': 'Wave 4',
65
+ };
66
+ const TYPE_LABEL = { feature: 'Feature', spike: 'Spike', chore: 'Chore', epic: 'Epic' };
67
+
68
+ function parseFrontmatter(md) {
69
+ if (!md.startsWith('---')) return {};
70
+ const end = md.indexOf('\n---', 3);
71
+ if (end === -1) return {};
72
+ const block = md.slice(3, end).trim();
73
+ const fm = {};
74
+ for (const line of block.split('\n')) {
75
+ const m = line.match(/^(\w+):\s*(.*)$/);
76
+ if (!m) continue;
77
+ let v = m[2];
78
+ if (v[0] === '"' || v[0] === "'") {
79
+ const q = v[0]; const e = v.indexOf(q, 1);
80
+ v = e > 0 ? v.slice(1, e) : v.slice(1); // quoted: take inside quotes (keeps '#5')
81
+ } else {
82
+ const h = v.search(/\s#/); if (h >= 0) v = v.slice(0, h); // strip inline ` # comment`
83
+ v = v.trim();
84
+ if (v === 'null' || v === '') v = null;
85
+ }
86
+ fm[m[1]] = v;
87
+ }
88
+ return fm;
89
+ }
90
+
91
+ function readSeeds() {
92
+ if (!existsSync(SEEDS)) return [];
93
+ return readdirSync(SEEDS).filter((f) => f.endsWith('.md')).map((f) => {
94
+ const fm = parseFrontmatter(readFileSync(join(SEEDS, f), 'utf8'));
95
+ seedStatusLabel(fm.status, `Roadmap/00-ideas/seeds/${f}`); // hard-fail on an invalid enum value
96
+ seedAppetite(fm.appetite, `Roadmap/00-ideas/seeds/${f}`); // hard-fail on an invalid appetite
97
+ return { ...fm, _file: `Roadmap/00-ideas/seeds/${f}` };
98
+ });
99
+ }
100
+
101
+ // Seed frontmatter `status:` → board label. Absent → 'Raw' (legacy tolerance); a PRESENT but
102
+ // unrecognized value THROWS — a typo'd status would otherwise silently land the seed in the wrong
103
+ // funnel bucket (e.g. `status: seed` read as Raw). Validated for EVERY seed, funnel-only ones
104
+ // included, so the documented enum holds repo-wide.
105
+ export function seedStatusLabel(status, file = 'seed') {
106
+ if (status == null) return 'Raw';
107
+ const label = SEED_STATUS_LABEL[status];
108
+ if (!label) {
109
+ throw new Error(`${file}: unrecognized seed frontmatter status "${status}" — valid values: ${Object.keys(SEED_STATUS_LABEL).join(' | ')}`);
110
+ }
111
+ return label;
112
+ }
113
+
114
+ // Seed frontmatter `appetite:` — the economics enum (WAYS-OF-WORKING → Betting & appetite).
115
+ // Absent → null (the funnel tolerates unshaped ideas); a PRESENT but unrecognized value THROWS,
116
+ // same rationale as the status enum — a typo'd appetite must not silently pass for funded work.
117
+ // Whether an appetite is REQUIRED (at `status: queued`) is build-order.mjs's enforcement; this
118
+ // only guards the vocabulary.
119
+ const APPETITES = new Set(['S', 'M', 'L']);
120
+ export function seedAppetite(appetite, file = 'seed') {
121
+ if (appetite == null) return null;
122
+ if (!APPETITES.has(appetite)) {
123
+ throw new Error(`${file}: unrecognized seed frontmatter appetite "${appetite}" — valid values: S | M | L`);
124
+ }
125
+ return appetite;
126
+ }
127
+
128
+ function listEpicDirs() {
129
+ const out = [];
130
+ for (const macro of readdirSync(ROADMAP)) {
131
+ if (!/^[0-9]{2}-/.test(macro)) continue; // macro-section folders only
132
+ const macroPath = join(ROADMAP, macro);
133
+ if (!statSync(macroPath).isDirectory()) continue;
134
+ for (const slug of readdirSync(macroPath)) {
135
+ const epicPath = join(macroPath, slug);
136
+ if (!statSync(epicPath).isDirectory()) continue;
137
+ if (!existsSync(join(epicPath, 'README.md'))) continue;
138
+ out.push({ macro, slug, path: epicPath, area: macro.slice(0, 2) });
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function epicTitle(epicPath, slug) {
145
+ try {
146
+ const first = readFileSync(join(epicPath, 'README.md'), 'utf8').split('\n').find((l) => l.startsWith('# '));
147
+ if (first) return first.replace(/^#\s+(Epic\s*[—·:\-]\s*)?/i, '').trim();
148
+ } catch {}
149
+ return slug;
150
+ }
151
+
152
+ // --- story counting: tolerant of the real heading drift. Stories appear at BOTH `## US-1` (level-2,
153
+ // e.g. support-widget) and `### S1.1` (level-3), as `Story 1.1` / `S1` / `US-1`, and the #3c B/C/D
154
+ // epics label them by epic-letter (`## C.1`, `### B1.1`, `## D.2`). Matching only `### S/US/Story`
155
+ // silently undercounted ~22 epics to "0 stories" (status then leaned on the retro-floor by luck). The
156
+ // letter form requires a `.digit` (`[A-Z]\d*\.\d+`) so it can't false-fire on `## QA` / `## Stories`. ---
157
+ const STORY_RE = /^#{2,3}\s+(?:Story\s+\d+|S\d+(?:\.\d+)?(?:\s*\([^)]*\))?|US-\d+|[A-Z]\d*\.\d+)\b/i;
158
+ function countStories(body) {
159
+ let total = 0, done = 0;
160
+ for (const line of body.split('\n')) {
161
+ if (STORY_RE.test(line)) { total++; if (line.includes('✅')) done++; }
162
+ }
163
+ return { total, done };
164
+ }
165
+
166
+ // --- sprint status: the `Status:` line first, then story ticks. Accept both `**Status:**` (bold) and a
167
+ // plain `Status:` line — real sprint files use both (e.g. support-widget writes `Status: ✅ shipped`). ---
168
+ function deriveSprintStatus(body) {
169
+ const hasSmoke = /Smoke walkthrough \(do these|—\s*Smoke walkthrough/i.test(body);
170
+ const m = body.match(/^(?:\*\*)?Status:(?:\*\*)?\s*(.+)$/mi);
171
+ const line = (m ? m[1] : '').trim();
172
+ const s = line.toLowerCase();
173
+ if (line) {
174
+ if (/⬜|not started|^planned\b/.test(s)) return 'Planned';
175
+ if (/🟦|in review|awaiting\s*(pr|review)|draft\s*\[?pr|built\s*—.*(awaiting|review|draft)/.test(s)) return 'In review';
176
+ if (/✅/.test(line) && /(shipped|merged|live|in prod|to\s*`?main`?|on\s*`?main`?)/.test(s)) return 'Shipped';
177
+ if (/✅\s*built|built\s*\(/.test(s)) return 'In review'; // "built" with no merge word ⇒ not yet shipped
178
+ if (/🏗|in progress|wip|building\b/.test(s)) return 'In progress';
179
+ if (/✅/.test(line)) return 'Shipped';
180
+ }
181
+ const { total, done } = countStories(body);
182
+ if (total > 0) {
183
+ if (done === total) return hasSmoke ? 'Shipped' : 'In review';
184
+ if (done > 0) return 'In progress';
185
+ return 'Planned';
186
+ }
187
+ return 'Planned';
188
+ }
189
+
190
+ function sprintTitleFrom(body, n) {
191
+ const first = body.split('\n').find((l) => l.startsWith('# ')) || '';
192
+ const m = first.match(/Sprint\s+\d+\s*[:—\-]\s*(.+)$/i);
193
+ return m ? m[1].trim() : `Sprint ${n}`;
194
+ }
195
+
196
+ function epicSprints(epicPath) {
197
+ return readdirSync(epicPath)
198
+ .filter((f) => /^sprint-(\d+)\.md$/.test(f))
199
+ .map((f) => Number(f.match(/^sprint-(\d+)\.md$/)[1]))
200
+ .sort((a, b) => a - b)
201
+ .map((n) => {
202
+ const body = readFileSync(join(epicPath, `sprint-${n}.md`), 'utf8');
203
+ const { total, done } = countStories(body);
204
+ return { n, title: sprintTitleFrom(body, n), status: deriveSprintStatus(body), total, done };
205
+ });
206
+ }
207
+
208
+ // A written, DATED retrospective is the strongest "this epic closed" signal. The scaffold template only
209
+ // carries a literal `<date>` placeholder, so requiring a real ISO date avoids false-firing on stubs.
210
+ function epicShippedByRetro(epicPath) {
211
+ const f = join(epicPath, 'RETROSPECTIVE.md');
212
+ if (!existsSync(f)) return false;
213
+ const t = readFileSync(f, 'utf8');
214
+ // A retrospective is written at epic CLOSE (the epic DoD) and carries a real date; the scaffold STUB
215
+ // only has a literal `_Closed: <date>_` placeholder (no real date). So a real ISO date anywhere in the
216
+ // retro ⇒ the epic was closed/shipped. This avoids false-firing on un-built scaffolds.
217
+ // NOTE: do NOT anchor with \b on either side. The scaffold renders the close date markdown-italicised
218
+ // as `_Closed: 2026-06-09_`, and `_` is a word character, so a trailing \b fails immediately after the
219
+ // date and a real underscore-wrapped close-date would be missed (this regressed agent-readable-about-
220
+ // surface Shipped→In progress). A bare date pattern matches it; the literal `<date>` stub has no digits.
221
+ return /20\d\d-\d\d-\d\d/.test(t);
222
+ }
223
+
224
+ // `epicFmStatus` is the raw README frontmatter `status:` (or undefined). Archival is a frontmatter-only
225
+ // decision — it cannot be derived from sprints/retro — so when the epic declares `archived`, the
226
+ // derivation must also say Archived; otherwise an archived epic with open-looking sprints false-flags
227
+ // drift (status=Archived vs status_derived=In progress) on EVERY board regeneration, forever.
228
+ export function deriveEpicStatus(sprints, retroShipped, epicFmStatus) {
229
+ if (epicFmStatus === 'archived') return 'Archived';
230
+ if (retroShipped) return 'Shipped';
231
+ if (sprints.length && sprints.every((s) => s.status === 'Shipped')) return 'Shipped';
232
+ if (sprints.some((s) => s.status === 'Shipped' || s.status === 'In progress' || s.status === 'In review')) return 'In progress';
233
+ return 'Scaffolded'; // scaffolded-only / all Planned
234
+ }
235
+
236
+ // AUTHORITATIVE epic status: the README frontmatter `status:` field (set at epic close). Returns the
237
+ // board bucket, or null when the README has NO frontmatter status (→ caller falls back to derivation).
238
+ // A PRESENT but unrecognized value THROWS: it used to return null and silently fall back to the derived
239
+ // status, which made `status === status_derived` by construction — so the advisory drift check could
240
+ // never fire on exactly the class of error it exists to catch (an epic mislabeled with an out-of-enum
241
+ // value, e.g. `mercadolibre-sync` at `status: ready` while fully shipped; audit 2026-07-06 §1).
242
+ const EPIC_FM_TO_BUCKET = { shipped: 'Shipped', 'in-progress': 'In progress', scaffolded: 'Scaffolded', queued: 'Scaffolded', archived: 'Archived' };
243
+ function epicFrontmatter(epicPath) {
244
+ return parseFrontmatter(readFileSync(join(epicPath, 'README.md'), 'utf8'));
245
+ }
246
+ export function frontmatterStatusBucket(fm, doc = 'epic README') {
247
+ if (!fm.status) return null;
248
+ const bucket = EPIC_FM_TO_BUCKET[fm.status];
249
+ if (!bucket) {
250
+ throw new Error(`${doc}: unrecognized epic frontmatter status "${fm.status}" — valid values: ${Object.keys(EPIC_FM_TO_BUCKET).join(' | ')}`);
251
+ }
252
+ return bucket;
253
+ }
254
+
255
+ // Coerce a `build_order` frontmatter value: numeric → Number (so the Notion views sort right), a legacy
256
+ // non-numeric seed value (e.g. "#3c") passes through unchanged, empty/absent → null.
257
+ export function normalizeBuildOrder(v) {
258
+ if (v === null || v === undefined) return null;
259
+ const s = String(v).trim();
260
+ if (s === '') return null;
261
+ return /^-?\d+$/.test(s) ? Number(s) : v;
262
+ }
263
+
264
+ // Floor a sprint's derived status against its epic's AUTHORITATIVE status, so the Sprints board never
265
+ // shows an archived epic full of "Planned" sprints (S1.1) nor a Shipped epic with stale "Planned" ones.
266
+ // Real in-flight signals (In progress / In review) are preserved on non-archived epics.
267
+ export function floorSprintStatus(epicStatus, sprintStatus) {
268
+ // A TERMINAL epic forces ALL its sprints to that terminal state — a shipped/archived epic cannot have
269
+ // an "In progress"/"In review"/"Planned" sprint (those leaked onto the board as stale in-flight rows
270
+ // when this only floored Planned). Only a non-terminal epic keeps the real per-sprint signal.
271
+ if (epicStatus === 'Archived') return 'Archived';
272
+ if (epicStatus === 'Shipped') return 'Shipped';
273
+ return sprintStatus;
274
+ }
275
+
276
+ // A sprint whose (floored) status is Shipped has shipped ALL its stories. Completion is very often
277
+ // recorded in the sprint's `Status:` line or a per-story summary table instead of a ✅ on each story
278
+ // heading — countStories() only sees heading ticks, which understated progress for ~20 epics (a fully
279
+ // shipped epic read "0/15 stories"; audit 2026-07-06 §6). Applied to the FLOORED status (not the raw
280
+ // derivation) so both undercount patterns heal: sprints that declare ✅ merged themselves, and
281
+ // stale-Planned sprints inside a frontmatter-`shipped` epic. Archived is NOT floored — archived
282
+ // stories were dropped, not done.
283
+ export function floorSprintDone(sprintStatus, total, done) {
284
+ return sprintStatus === 'Shipped' && total > 0 ? total : done;
285
+ }
286
+
287
+ // Per-sprint Claude Code kickoff — the SESSION-KICKOFFS.md §2 "Build a sprint" thin-pointer template,
288
+ // filled from values already in scope. Generated (not stored per-sprint) so it can't drift; surfaced as a
289
+ // Notion "Kickoff" property so an open Sprint card carries its ready-to-paste prompt. Mirror §2 if it changes.
290
+ function sprintKickoff({ epicKey, slug, n, risk }) {
291
+ const tier = risk === 'High' ? 'HIGH' : 'LOW';
292
+ // TEMPLATE FILL-IN: replace <AGENTS-path> with this project's real AGENTS.md path (see
293
+ // SESSION-KICKOFFS.md §2, which this function mirrors).
294
+ let k = [
295
+ `Read <AGENTS-path> (Start here) + Roadmap/LEARNINGS.md, then`,
296
+ `Roadmap/${epicKey}/README.md + sprint-${n}.md.`,
297
+ `Build Sprint ${n} of "${slug}" per WAYS-OF-WORKING, in your OWN git worktree off latest main on`,
298
+ `feat/${slug}. Plan mode → confirm stories with me → build one story at a time. Commit per story`,
299
+ `PATH-SCOPED (git add <your files> && git commit -- <those paths>; never -A). One api spec`,
300
+ `per testable story. Keep the CI gate green; open a draft PR declaring risk ${tier}.`,
301
+ `Write the sprint smoke walkthrough into sprint-${n}.md before calling it done.`,
302
+ ].join('\n');
303
+ if (risk === 'High') k += `\nHIGH-risk: all stories HIGH → the product owner merges; the authed money-path browser smoke is owed to them.`;
304
+ return k;
305
+ }
306
+
307
+ export function buildRows() {
308
+ const seeds = readSeeds();
309
+ const seedByEpic = new Map();
310
+ for (const s of seeds) if (s.epic) seedByEpic.set(s.epic, s);
311
+
312
+ const rows = [];
313
+
314
+ for (const e of listEpicDirs()) {
315
+ const epicKey = `${e.macro}/${e.slug}`;
316
+ const seed = seedByEpic.get(epicKey) || {};
317
+ const sprints = epicSprints(e.path);
318
+ const retroShipped = epicShippedByRetro(e.path);
319
+ const epicFm = epicFrontmatter(e.path); // read README frontmatter once
320
+ const statusDerived = deriveEpicStatus(sprints, retroShipped, epicFm.status); // prose/retro fallback + drift signal (archived short-circuits)
321
+ const status = frontmatterStatusBucket(epicFm, `Roadmap/${epicKey}/README.md`) || statusDerived; // README frontmatter is authoritative; invalid value throws
322
+ const buildOrder = normalizeBuildOrder(epicFm.build_order ?? seed.build_order); // epic FM is SSOT, seed fallback
323
+ // Board view of each sprint: status floored against the authoritative epic status, done-count
324
+ // floored against THAT (a Shipped sprint shipped all its stories — see floorSprintDone). The raw
325
+ // `sprints` array stays untouched above so statusDerived / the drift signal never feed on the floor.
326
+ const boardSprints = sprints.map((sp) => {
327
+ const st = floorSprintStatus(status, sp.status);
328
+ return { ...sp, status: st, done: floorSprintDone(st, sp.total, sp.done) };
329
+ });
330
+ const totStories = boardSprints.reduce((a, s) => a + s.total, 0);
331
+ const doneStories = boardSprints.reduce((a, s) => a + s.done, 0);
332
+ const area = AREA_NAMES[e.area] || e.area;
333
+ const priority = seed.priority ? PRIORITY_LABEL[seed.priority] || seed.priority : null;
334
+ const risk = seed.risk ? (seed.risk === 'high' ? 'High' : 'Low') : null;
335
+
336
+ // Epic row
337
+ rows.push({
338
+ name: epicTitle(e.path, e.slug),
339
+ slug: e.slug,
340
+ grain: 'Epic',
341
+ status,
342
+ status_derived: statusDerived, // prose/retro derivation — for the advisory drift check on the board
343
+ area,
344
+ priority,
345
+ type: TYPE_LABEL[seed.type] || 'Epic',
346
+ risk,
347
+ sprint_progress: totStories ? `${doneStories}/${totStories} stories` : `${sprints.length} sprints`,
348
+ build_order: buildOrder,
349
+ doc_link: `Roadmap/${epicKey}/README.md`,
350
+ epic_slug: null,
351
+ });
352
+
353
+ // Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
354
+ // the floored status (archived epic ⇒ Archived sprints; Shipped epic's stale "Planned" sprints ⇒
355
+ // Shipped; real in-flight signals preserved — floorSprintStatus) and the floored done-count
356
+ // (Shipped sprint ⇒ all stories done — floorSprintDone).
357
+ for (const sp of boardSprints) {
358
+ rows.push({
359
+ name: `${epicTitle(e.path, e.slug)} — S${sp.n}: ${sp.title}`,
360
+ slug: `${e.slug}--s${sp.n}`,
361
+ grain: 'Sprint',
362
+ status: sp.status,
363
+ area,
364
+ priority,
365
+ type: 'Sprint',
366
+ risk,
367
+ sprint_progress: sp.total ? `${sp.done}/${sp.total} stories` : '—',
368
+ build_order: buildOrder, // sprints inherit their epic's build order
369
+ doc_link: `Roadmap/${epicKey}/sprint-${sp.n}.md`,
370
+ epic_slug: e.slug, // resolved to the Epic page id at sync time
371
+ kickoff: sprintKickoff({ epicKey, slug: e.slug, n: sp.n, risk }),
372
+ });
373
+ }
374
+ }
375
+
376
+ // Seed rows: only seeds with no scaffolded epic (epic == null)
377
+ for (const s of seeds.filter((x) => !x.epic)) {
378
+ rows.push({
379
+ name: s.title || s.slug,
380
+ slug: s.slug,
381
+ grain: 'Seed',
382
+ status: seedStatusLabel(s.status, s._file),
383
+ area: AREA_NAMES[s.area] || s.area || null,
384
+ priority: s.priority ? PRIORITY_LABEL[s.priority] || s.priority : null,
385
+ type: TYPE_LABEL[s.type] || 'Feature',
386
+ risk: s.risk ? (s.risk === 'high' ? 'High' : 'Low') : null,
387
+ appetite: s.appetite || null,
388
+ underwritten_by: s.underwritten_by || null,
389
+ sprint_progress: null,
390
+ build_order: s.build_order || null,
391
+ doc_link: s._file,
392
+ epic_slug: null,
393
+ });
394
+ }
395
+ return rows;
396
+ }
397
+
398
+ // Print the rows. writeSync to fd 1 is synchronous on a PIPE too — `console.log` then an exit truncates
399
+ // piped stdout (the async write has not flushed), which crashed build-order.mjs's execFileSync with
400
+ // "Unexpected end of JSON input". Synchronous write guarantees the full payload.
401
+ // realpath, not resolve: on macOS a temp or symlinked path (/var → /private/var) never equals the module
402
+ // URL, and a plain comparison makes the CLI a silent no-op.
403
+ const isMain = (() => {
404
+ try {
405
+ return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
406
+ } catch {
407
+ return false;
408
+ }
409
+ })();
410
+ if (isMain) writeSync(1, JSON.stringify(buildRows(), null, 2) + '\n');
@@ -0,0 +1,46 @@
1
+ ---
2
+ title: "Daily standup - {{window.date}}"
3
+ styles:
4
+ fontFamily: "Inter"
5
+ baseFontSize: 15
6
+ lineHeight: 1.55
7
+ slideAspectRatio: "{{deck.aspectRatio}}"
8
+ h1: { fontSize: 2.0, fontWeight: 700 }
9
+ h2: { fontSize: 1.25, fontWeight: 600 }
10
+ ---
11
+
12
+ # Daily standup - {{window.date}}
13
+
14
+ ~~~slide
15
+ @extends cover
16
+ #title: Daily standup
17
+ #subtitle: {{window.date}}
18
+ #kicker: ops-nightly
19
+ #footer: The Telegram text stays the canonical read; this deck is the mobile artifact.
20
+ ~~~
21
+
22
+ ~~~slide
23
+ @extends title-body
24
+ #title: What changed
25
+ #body:
26
+ {{summary.bullets}}
27
+ #footer: Generated {{window.generatedDate}}
28
+ ~~~
29
+
30
+ ~~~slide
31
+ @extends title-body
32
+ #title: Repositories
33
+ #body:
34
+ {{repos.bullets}}
35
+ #footer: GitHub REST + the standup's incremental log.
36
+ ~~~
37
+
38
+ ~~~slide
39
+ @extends title-body
40
+ #title: Guards
41
+ #body:
42
+ - Browser smoke: **{{guards.browserSmoke}}**
43
+ - BUILD-ORDER.md: **{{guards.buildOrder}}**
44
+ - Stale previews: **{{guards.stalePreviews}}**
45
+ #footer: The same signals as the Telegram standup, ready to forward.
46
+ ~~~