@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.
- package/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +179 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- 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
|
+
~~~
|