@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,266 @@
1
+ // reporting-config.mjs — the ONE config seam for the reporting family (standup, weekly-recap, pmo-report).
2
+ //
3
+ // ── Why one file, and why it is committed ─────────────────────────────────────────────────────
4
+ // The three reporting scripts were extracted from an origin project with its repo list, Telegram
5
+ // target, smoke workflow and artifact-hosting URLs written straight into source. That is why they sat
6
+ // dark for six weeks (plugin-audit-and-extraction D1/D2): there was no place for a consuming project to
7
+ // put its own values, so porting meant either shipping someone else's targets or rewriting the logic.
8
+ //
9
+ // This module is that place. Every project-specific value the three scripts ACTUALLY read (locked
10
+ // against the source, not against what they appear to read) lives in `reporting.config.json` at the repo
11
+ // root. It is COMMITTED — none of it is a secret (the bot token stays in TELEGRAM_BOT_TOKEN), and a
12
+ // routine's cloud sandbox is a fresh checkout every run, so a gitignored per-skill config.json never
13
+ // survived to the next run anyway. The per-skill `config.json` files this replaces were exactly that trap.
14
+ //
15
+ // ── Public repos: reporting.config.local.json ─────────────────────────────────────────────────
16
+ // A chat id is not a secret, but a PUBLIC repo should still not publish it. An optional, gitignored
17
+ // `reporting.config.local.json` next to the committed file is merged over it (top-level keys replace;
18
+ // `telegram` merges one level deep) — so the committed file carries the repos and signals and the local
19
+ // file carries the chat. Routines keep using TELEGRAM_CHAT_ID, which needs neither.
20
+ //
21
+ // ── Fail loudly, never borrow ─────────────────────────────────────────────────────────────────
22
+ // A missing or malformed file THROWS a ReportingConfigError that names the file. There is no default
23
+ // repo list and no default chat: a reporting script that silently falls back to a baked-in target posts
24
+ // one project's status into another project's channel. Optional sections are optional in the explicit
25
+ // sense — absent means "this signal is off here", and the scripts say so rather than guessing.
26
+ //
27
+ // Zero deps — Node 18+.
28
+
29
+ import { readFileSync, existsSync } from 'node:fs';
30
+ import { dirname, join, resolve } from 'node:path';
31
+ import { projectRoot } from './project-root.mjs';
32
+
33
+ export const DEFAULT_ROOT = projectRoot(); // D2
34
+ export const CONFIG_FILENAME = 'reporting.config.json';
35
+ export const EXAMPLE_FILENAME = 'reporting.config.example.json';
36
+ export const LOCAL_FILENAME = 'reporting.config.local.json';
37
+
38
+ export const SURFACES = ['standup', 'weekly', 'pmo', 'merge'];
39
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
40
+
41
+ export class ReportingConfigError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'ReportingConfigError';
45
+ }
46
+ }
47
+
48
+ /** Where the config lives. `REPORTING_CONFIG` overrides it (tests, or a project that keeps it elsewhere). */
49
+ export function configPath({ root = DEFAULT_ROOT, env = process.env } = {}) {
50
+ return env.REPORTING_CONFIG ? resolve(root, env.REPORTING_CONFIG) : join(root, CONFIG_FILENAME);
51
+ }
52
+
53
+ function fail(path, msg) {
54
+ throw new ReportingConfigError(`${path}: ${msg}`);
55
+ }
56
+
57
+ function repoList(path, value, key) {
58
+ if (!Array.isArray(value)) fail(path, `"${key}" must be an array of "owner/name" strings`);
59
+ for (const r of value) {
60
+ if (typeof r !== 'string' || !REPO_RE.test(r))
61
+ fail(path, `"${key}" has an invalid repo "${r}" — expected "owner/name"`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ /**
67
+ * Pure — validate a parsed config object and return it normalized. Throws ReportingConfigError on any
68
+ * shape problem, naming the key. `path` is only used in the messages.
69
+ */
70
+ export function validateReportingConfig(raw, path = CONFIG_FILENAME) {
71
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(path, 'must be a JSON object');
72
+
73
+ const repos = repoList(path, raw.repos, 'repos');
74
+ if (!repos.length) fail(path, '"repos" is empty — list every repo the reports aggregate over');
75
+
76
+ const deployRepos = (raw.deployRepos ?? []).map((d, i) => {
77
+ if (!d || typeof d !== 'object' || typeof d.label !== 'string' || !d.label.trim()) {
78
+ fail(path, `"deployRepos[${i}]" needs a "label" (e.g. "Frontend") and a "repo"`);
79
+ }
80
+ repoList(path, [d.repo], `deployRepos[${i}].repo`);
81
+ return { label: d.label, repo: d.repo };
82
+ });
83
+
84
+ const telegram = raw.telegram ?? {};
85
+ if (!telegram || typeof telegram !== 'object' || Array.isArray(telegram))
86
+ fail(path, '"telegram" must be an object');
87
+ for (const k of ['chatId']) {
88
+ if (telegram[k] != null && typeof telegram[k] !== 'string' && typeof telegram[k] !== 'number')
89
+ fail(path, `"telegram.${k}" must be a string or number`);
90
+ }
91
+ const chatIds = telegram.chatIds ?? {};
92
+ for (const k of Object.keys(chatIds)) {
93
+ if (!SURFACES.includes(k))
94
+ fail(path, `"telegram.chatIds.${k}" is not a surface — use one of ${SURFACES.join(', ')}`);
95
+ if (typeof chatIds[k] !== 'string' && typeof chatIds[k] !== 'number')
96
+ fail(path, `"telegram.chatIds.${k}" must be a string or number`);
97
+ }
98
+
99
+ let smoke = null;
100
+ if (raw.smoke != null) {
101
+ if (typeof raw.smoke !== 'object' || Array.isArray(raw.smoke))
102
+ fail(path, '"smoke" must be an object {repo, workflow}');
103
+ repoList(path, [raw.smoke.repo], 'smoke.repo');
104
+ if (typeof raw.smoke.workflow !== 'string' || !raw.smoke.workflow)
105
+ fail(path, '"smoke.workflow" must name a workflow file');
106
+ smoke = { repo: raw.smoke.repo, workflow: raw.smoke.workflow };
107
+ }
108
+
109
+ let liveFlags = null;
110
+ if (raw.liveFlags != null) {
111
+ const cmd = raw.liveFlags.command;
112
+ if (!Array.isArray(cmd) || !cmd.length || cmd.some((c) => typeof c !== 'string')) {
113
+ fail(
114
+ path,
115
+ '"liveFlags.command" must be a non-empty argv array (e.g. ["node", "scripts/flags.mjs", "--list-on"])'
116
+ );
117
+ }
118
+ liveFlags = { command: cmd, cwd: raw.liveFlags.cwd || '.' };
119
+ }
120
+
121
+ // Where each repo is checked out locally (session-resume.mjs's git read), relative to the repo root.
122
+ const checkouts = raw.checkouts ?? {};
123
+ if (typeof checkouts !== 'object' || Array.isArray(checkouts))
124
+ fail(path, '"checkouts" must map "owner/name" to a local directory');
125
+ for (const [repo, dir] of Object.entries(checkouts)) {
126
+ if (!repos.includes(repo)) fail(path, `"checkouts.${repo}" is not one of "repos"`);
127
+ if (
128
+ typeof dir !== 'string' ||
129
+ !dir ||
130
+ dir.startsWith('/') ||
131
+ dir.includes('\\') ||
132
+ dir.split('/').includes('..')
133
+ ) {
134
+ fail(path, `"checkouts.${repo}" must be a relative directory inside the repo root`);
135
+ }
136
+ }
137
+
138
+ // What owed-ledger.mjs counts: who manual checks are owed to, and where the specs live.
139
+ const owed = raw.owed ?? {};
140
+ if (typeof owed !== 'object' || Array.isArray(owed))
141
+ fail(path, '"owed" must be an object {owners, specDirs}');
142
+ for (const k of ['owners', 'specDirs']) {
143
+ if (
144
+ owed[k] != null &&
145
+ (!Array.isArray(owed[k]) || owed[k].some((v) => typeof v !== 'string' || !v.trim()))
146
+ ) {
147
+ fail(path, `"owed.${k}" must be an array of non-empty strings`);
148
+ }
149
+ }
150
+ for (const d of owed.specDirs ?? []) {
151
+ if (d.startsWith('/') || d.includes('\\') || d.split('/').includes('..'))
152
+ fail(path, `"owed.specDirs" entry "${d}" must be relative, inside the repo`);
153
+ }
154
+
155
+ const stalePreviewAgeDays = raw.stalePreviewAgeDays ?? null;
156
+ if (stalePreviewAgeDays !== null && !(Number.isInteger(stalePreviewAgeDays) && stalePreviewAgeDays > 0)) {
157
+ fail(
158
+ path,
159
+ '"stalePreviewAgeDays" must be a positive integer, or absent to skip the stale-preview signal'
160
+ );
161
+ }
162
+ // The prune script has no default project (a defaulted name prunes someone else's project), so the
163
+ // signal needs one named here. Age without a project is a config error, not a silently-dark signal.
164
+ const vercelProject = raw.vercelProject ?? null;
165
+ if (vercelProject !== null && (typeof vercelProject !== 'string' || !/^[\w.-]+$/.test(vercelProject)))
166
+ fail(path, '"vercelProject" must be a Vercel project name');
167
+ if (stalePreviewAgeDays !== null && vercelProject === null)
168
+ fail(path, '"stalePreviewAgeDays" needs "vercelProject" — the Vercel project whose previews it counts');
169
+
170
+ const artifacts = raw.artifacts ?? {};
171
+ const docViewerUrl = artifacts.docViewerUrl ?? null;
172
+ if (docViewerUrl !== null && !/^https?:\/\//.test(docViewerUrl))
173
+ fail(path, '"artifacts.docViewerUrl" must be an http(s) URL');
174
+ const registry = artifacts.registry ?? null;
175
+ if (registry !== null) {
176
+ if (!/^https?:\/\//.test(registry.resolverBaseUrl || ''))
177
+ fail(path, '"artifacts.registry.resolverBaseUrl" must be an http(s) URL');
178
+ if (typeof registry.bucket !== 'string' || !registry.bucket)
179
+ fail(path, '"artifacts.registry.bucket" must name a bucket');
180
+ }
181
+
182
+ const prose = raw.prose ?? {};
183
+ const extraBannedToolNames = prose.extraBannedToolNames ?? [];
184
+ if (!Array.isArray(extraBannedToolNames) || extraBannedToolNames.some((t) => typeof t !== 'string')) {
185
+ fail(path, '"prose.extraBannedToolNames" must be an array of regex fragments');
186
+ }
187
+ // Compile each fragment NOW: a bad one would otherwise throw inside the prose guard at --post time.
188
+ for (const t of extraBannedToolNames) {
189
+ try {
190
+ new RegExp(`\\b${t}\\b`, 'i');
191
+ } catch (e) {
192
+ fail(path, `"prose.extraBannedToolNames" has an invalid regex fragment "${t}" (${e.message})`);
193
+ }
194
+ }
195
+
196
+ return {
197
+ repos,
198
+ deployRepos,
199
+ checkouts,
200
+ owed: { owners: owed.owners ?? null, specDirs: owed.specDirs ?? null },
201
+ telegram: { chatId: telegram.chatId ?? null, chatIds },
202
+ smoke,
203
+ liveFlags,
204
+ stalePreviewAgeDays,
205
+ vercelProject,
206
+ artifacts: { docViewerUrl, registry },
207
+ prose: { extraBannedToolNames },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Read + validate the project's reporting config. Throws ReportingConfigError — naming the file and how
213
+ * to create it — when it is absent. Never returns a default.
214
+ */
215
+ export function loadReportingConfig({
216
+ root = DEFAULT_ROOT,
217
+ env = process.env,
218
+ read = readFileSync,
219
+ exists = existsSync,
220
+ } = {}) {
221
+ const path = configPath({ root, env });
222
+ if (!exists(path)) {
223
+ throw new ReportingConfigError(
224
+ `${path} not found — the reporting scripts refuse to guess which repos to read or where to post.\n` +
225
+ ` Copy ${EXAMPLE_FILENAME} to ${CONFIG_FILENAME} at the repo root, fill it in, and commit it.`
226
+ );
227
+ }
228
+ const parse = (p) => {
229
+ try {
230
+ return JSON.parse(read(p, 'utf8'));
231
+ } catch (e) {
232
+ return fail(p, `is not valid JSON (${e.message})`);
233
+ }
234
+ };
235
+ let raw = parse(path);
236
+ const localPath = join(dirname(path), LOCAL_FILENAME);
237
+ if (exists(localPath)) raw = mergeLocal(raw, parse(localPath));
238
+ return validateReportingConfig(raw, path);
239
+ }
240
+
241
+ /** Pure — overlay the gitignored local file: top-level keys replace, `telegram` merges one level deep. */
242
+ export function mergeLocal(base, local) {
243
+ if (!local || typeof local !== 'object' || Array.isArray(local)) return base;
244
+ const out = { ...base, ...local };
245
+ if (base?.telegram || local.telegram) {
246
+ out.telegram = {
247
+ ...(base?.telegram || {}),
248
+ ...(local.telegram || {}),
249
+ chatIds: { ...(base?.telegram?.chatIds || {}), ...(local.telegram?.chatIds || {}) },
250
+ };
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * The chat to post a surface to: the surface's own id, then the project-wide one, then TELEGRAM_CHAT_ID.
257
+ * Returns null when none is set — the caller must refuse to send rather than pick one.
258
+ */
259
+ export function chatIdFor(config, surface, env = process.env) {
260
+ return config?.telegram?.chatIds?.[surface] || config?.telegram?.chatId || env.TELEGRAM_CHAT_ID || null;
261
+ }
262
+
263
+ /** "owner/name" → "name", the label the reports print. */
264
+ export function shortRepo(repo) {
265
+ return repo.split('/')[1] || repo;
266
+ }
@@ -0,0 +1,305 @@
1
+ // roadmap-contract.mjs — THE machine-readable frontmatter contract for Roadmap epic docs, defined once
2
+ // (build-visualization-claude-mods D8). doc-format.mjs enforces it, roadmap-backfill.mjs writes it and
3
+ // build-state.mjs reads it; none of them restates a field list. Pure: zero I/O, zero dependencies.
4
+ //
5
+ // What it replaces: epic title/area/risk lived in a bold prose line, sprint files had NO frontmatter
6
+ // at all, and a story's identity and user story were prose under a `### Story N.M —` heading that
7
+ // came in six shapes across the corpus. A tool that scrapes those reports confidently while wrong.
8
+ //
9
+ // ── The shape ──────────────────────────────────────────────────────────────────────────────────────
10
+ // Epic README frontmatter — the lifecycle `status:` (scaffolded|in-progress|shipped|archived, the
11
+ // board's SSOT, untouched by this contract) plus:
12
+ // title · area · risk · type · phase · sprints_total · stories_total (and the existing slug, build_order)
13
+ // sprint-N.md frontmatter:
14
+ // epic · sprint · title · risk · phase · stories_total · stories
15
+ // Each `stories:` entry (D1 — a list in the sprint frontmatter, one flat map per story):
16
+ // id · title · as_a · i_want · so_that · risk · status
17
+ // The three user-story fields are the prose's own words, article included, so they render back as
18
+ // "As <as_a>, I want <i_want>, so that <so_that>." — `as_a: a buyer's agent`, `as_a: the product owner`.
19
+ //
20
+ // ── Why the ladder is `phase:` and not `status:` (D6) ─────────────────────────────────────────────
21
+ // An epic README's `status:` is the lifecycle every roadmap tool reads, and an unknown value there
22
+ // hard-fails the extractor. On a sprint file, a frontmatter `status:` line would be captured by the
23
+ // extractor's case-insensitive `^Status:` regex before the prose Status line, silently re-deriving
24
+ // the board. So the executive ladder is its own field on both.
25
+ //
26
+ // ── The YAML subset ────────────────────────────────────────────────────────────────────────────────
27
+ // Deliberately small, so a zero-dependency parser owns it completely: top-level `key: scalar` lines
28
+ // (an unquoted value may carry a trailing ` # comment`), whole-line `#` comments at any indent, and a
29
+ // top-level `key:` followed by a list of flat maps (` - k: v` then ` k: v`). A scalar is `null`,
30
+ // `~` or empty (→ null), `[]` (an empty list), an integer, a double-quoted JSON string, a single-quoted
31
+ // YAML string, or a bare string; any of them may carry a trailing ` # comment`. Booleans and floats are
32
+ // not in the subset (no contract field uses one). Anything else is a parse error, not guessed around.
33
+
34
+ export const PHASES = ['Shaping', 'Locking architecture', 'Building', 'Verifying', 'In review', 'Shipped'];
35
+ export const STORY_STATUSES = ['planned', 'in-progress', 'done'];
36
+ export const RISKS = ['low', 'high'];
37
+ export const TYPES = ['feature', 'spike', 'bug', 'chore'];
38
+
39
+ export const EPIC_FIELDS = ['title', 'area', 'risk', 'type', 'phase', 'sprints_total', 'stories_total'];
40
+ export const SPRINT_FIELDS = ['epic', 'sprint', 'title', 'risk', 'phase', 'stories_total', 'stories'];
41
+ export const STORY_FIELDS = ['id', 'title', 'as_a', 'i_want', 'so_that', 'risk', 'status'];
42
+
43
+ // A story id names its sprint: S<sprint>.<ordinal>. The commit convention D2 derives from uses it.
44
+ export const STORY_ID_RE = /^S(\d+)\.(\d+)$/;
45
+
46
+ // ── Parse ────────────────────────────────────────────────────────────────────────────────────────
47
+
48
+ function parseScalar(raw, where) {
49
+ let v = raw.trim();
50
+ // A trailing ` # comment` is allowed after any value — quoted ones included, so a hand-edit that
51
+ // annotates a quoted title does not turn into a parse error.
52
+ const quoted = v.match(/^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')(?:\s+#.*)?$/);
53
+ if (quoted) v = quoted[1];
54
+ else {
55
+ const hash = v.search(/\s#/);
56
+ if (hash >= 0) v = v.slice(0, hash).trim();
57
+ }
58
+ if (v === '' || v === 'null' || v === '~') return null;
59
+ if (v === '[]') return [];
60
+ if (/^-?\d+$/.test(v)) return Number(v);
61
+ if (v.startsWith('"')) {
62
+ try {
63
+ return JSON.parse(v);
64
+ } catch {
65
+ throw new Error(`${where}: malformed double-quoted value ${v}`);
66
+ }
67
+ }
68
+ if (v.startsWith("'")) {
69
+ if (!/^'(?:[^']|'')*'$/.test(v)) throw new Error(`${where}: malformed single-quoted value ${v}`);
70
+ return v.slice(1, -1).replace(/''/g, "'");
71
+ }
72
+ return v;
73
+ }
74
+
75
+ /**
76
+ * Split a markdown doc into its frontmatter data and body.
77
+ * Returns { hasFrontmatter, data, body, raw, error } — `error` is a parse error message (the data is
78
+ * then partial); `raw` is the text between the fences.
79
+ */
80
+ export function parseDocFrontmatter(md) {
81
+ const lines = md.split('\n');
82
+ if (lines[0].trim() !== '---') return { hasFrontmatter: false, data: {}, body: md, raw: null, error: null };
83
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
84
+ if (end === -1)
85
+ return { hasFrontmatter: false, data: {}, body: md, raw: null, error: 'unterminated frontmatter' };
86
+ const block = lines.slice(1, end);
87
+ const data = {};
88
+ let error = null;
89
+ let list = null; // the list currently being filled
90
+ let item = null; // the map currently being filled
91
+ try {
92
+ for (let i = 0; i < block.length; i++) {
93
+ const line = block[i];
94
+ const where = `frontmatter line ${i + 2}`;
95
+ if (line.trim() === '' || /^\s*#/.test(line)) continue;
96
+ const top = line.match(/^(\w+):(?:\s+(.*))?$/);
97
+ if (top) {
98
+ const [, key, rest = ''] = top;
99
+ if (
100
+ rest.trim() === '' &&
101
+ /^\s+-\s/.test(block.slice(i + 1).find((l) => l.trim() && !/^\s*#/.test(l)) || '')
102
+ ) {
103
+ list = data[key] = [];
104
+ item = null;
105
+ } else {
106
+ data[key] = parseScalar(rest, where);
107
+ list = null;
108
+ item = null;
109
+ }
110
+ continue;
111
+ }
112
+ const start = line.match(/^\s+-\s+(\w+):(?:\s+(.*))?$/);
113
+ if (start && list) {
114
+ item = {};
115
+ list.push(item);
116
+ item[start[1]] = parseScalar(start[2] || '', where);
117
+ continue;
118
+ }
119
+ const field = line.match(/^\s+(\w+):(?:\s+(.*))?$/);
120
+ if (field && item) {
121
+ item[field[1]] = parseScalar(field[2] || '', where);
122
+ continue;
123
+ }
124
+ throw new Error(`${where}: not in the contract's YAML subset: "${line.trim()}"`);
125
+ }
126
+ } catch (e) {
127
+ error = e.message;
128
+ }
129
+ return { hasFrontmatter: true, data, body: lines.slice(end + 1).join('\n'), raw: block.join('\n'), error };
130
+ }
131
+
132
+ // ── Serialize ────────────────────────────────────────────────────────────────────────────────────
133
+
134
+ const BARE_SAFE = /^[A-Za-z0-9][A-Za-z0-9 _.,/()&+-]*$/;
135
+ const RESERVED = new Set(['null', 'true', 'false', 'yes', 'no', 'on', 'off', '~']);
136
+
137
+ /** One scalar in the subset's syntax: bare when unambiguous, else a double-quoted JSON string. */
138
+ export function formatScalar(v) {
139
+ if (v === null || v === undefined) return 'null';
140
+ if (typeof v === 'number') return String(v);
141
+ const s = String(v);
142
+ if (BARE_SAFE.test(s) && !/^-?\d+$/.test(s) && !RESERVED.has(s.toLowerCase()) && !s.endsWith(' ')) return s;
143
+ return JSON.stringify(s);
144
+ }
145
+
146
+ /** `key: value` lines (and `key:` + a list of maps) for the given keys, in order. No fences. */
147
+ export function serializeFields(data, keys) {
148
+ const out = [];
149
+ for (const key of keys) {
150
+ const v = data[key];
151
+ if (Array.isArray(v) && !v.length) {
152
+ out.push(`${key}: []`); // an empty list stays a list on the way back in
153
+ } else if (Array.isArray(v)) {
154
+ out.push(`${key}:`);
155
+ for (const entry of v) {
156
+ Object.keys(entry).forEach((k, i) =>
157
+ out.push(`${i === 0 ? ' - ' : ' '}${k}: ${formatScalar(entry[k])}`)
158
+ );
159
+ }
160
+ } else {
161
+ out.push(`${key}: ${formatScalar(v)}`);
162
+ }
163
+ }
164
+ return out.join('\n');
165
+ }
166
+
167
+ // ── Validate ─────────────────────────────────────────────────────────────────────────────────────
168
+ // Each validator returns a list of { rule, detail } offenses — the shape doc-format.mjs reports.
169
+
170
+ const isInt = (v) => Number.isInteger(v) && v >= 0;
171
+ const oneOf = (v, set) => set.includes(v);
172
+
173
+ /**
174
+ * An epic README's frontmatter. `ctx.sprintCount` / `ctx.storyCount`, when given, are cross-checked
175
+ * against the declared totals. An `archived` epic is frozen record and exempt from the new fields.
176
+ */
177
+ export function validateEpicFrontmatter(parsed, ctx = {}) {
178
+ const offenses = [];
179
+ if (!parsed.hasFrontmatter) return offenses; // frontmatter-missing is doc-format's existing rule
180
+ if (parsed.error) return [{ rule: 'contract-parse', detail: parsed.error }];
181
+ const fm = parsed.data;
182
+ if (fm.status === 'archived') return offenses;
183
+ for (const key of EPIC_FIELDS) {
184
+ if (!(key in fm) || fm[key] === null)
185
+ offenses.push({ rule: 'contract-epic-field-missing', detail: `no \`${key}:\`` });
186
+ }
187
+ if (fm.phase != null && !oneOf(fm.phase, PHASES))
188
+ offenses.push({
189
+ rule: 'contract-phase-invalid',
190
+ detail: `phase: "${fm.phase}" is not one of ${PHASES.join(' | ')}`,
191
+ });
192
+ if (fm.risk != null && !oneOf(fm.risk, RISKS))
193
+ offenses.push({
194
+ rule: 'contract-risk-invalid',
195
+ detail: `risk: "${fm.risk}" is not one of ${RISKS.join(' | ')}`,
196
+ });
197
+ if (fm.type != null && !oneOf(fm.type, TYPES))
198
+ offenses.push({
199
+ rule: 'contract-type-invalid',
200
+ detail: `type: "${fm.type}" is not one of ${TYPES.join(' | ')}`,
201
+ });
202
+ for (const key of ['sprints_total', 'stories_total']) {
203
+ if (fm[key] != null && !isInt(fm[key]))
204
+ offenses.push({ rule: 'contract-total-invalid', detail: `${key}: "${fm[key]}" is not a whole number` });
205
+ }
206
+ if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
207
+ offenses.push({
208
+ rule: 'contract-total-mismatch',
209
+ detail: `sprints_total: ${fm.sprints_total}, but the epic has ${ctx.sprintCount} sprint-N.md file(s)`,
210
+ });
211
+ if (isInt(fm.stories_total) && isInt(ctx.storyCount) && fm.stories_total !== ctx.storyCount)
212
+ offenses.push({
213
+ rule: 'contract-total-mismatch',
214
+ detail: `stories_total: ${fm.stories_total}, but its sprints declare ${ctx.storyCount} stories`,
215
+ });
216
+ return offenses;
217
+ }
218
+
219
+ /** A sprint-N.md's frontmatter. `ctx.n` is the file's sprint number, `ctx.slug` its epic's slug. */
220
+ export function validateSprintFrontmatter(parsed, ctx = {}) {
221
+ if (!parsed.hasFrontmatter)
222
+ return [
223
+ {
224
+ rule: 'contract-sprint-frontmatter-missing',
225
+ detail: 'no --- frontmatter block at the top of the sprint file',
226
+ },
227
+ ];
228
+ if (parsed.error) return [{ rule: 'contract-parse', detail: parsed.error }];
229
+ const offenses = [];
230
+ const fm = parsed.data;
231
+ for (const key of SPRINT_FIELDS) {
232
+ if (!(key in fm) || (fm[key] === null && key !== 'stories'))
233
+ offenses.push({ rule: 'contract-sprint-field-missing', detail: `no \`${key}:\`` });
234
+ }
235
+ if (ctx.slug && fm.epic != null && fm.epic !== ctx.slug)
236
+ offenses.push({
237
+ rule: 'contract-sprint-epic-mismatch',
238
+ detail: `epic: "${fm.epic}", but the file is in "${ctx.slug}"`,
239
+ });
240
+ if (isInt(ctx.n) && fm.sprint != null && fm.sprint !== ctx.n)
241
+ offenses.push({
242
+ rule: 'contract-sprint-number-mismatch',
243
+ detail: `sprint: ${fm.sprint}, but the file is sprint-${ctx.n}.md`,
244
+ });
245
+ if (fm.phase != null && !oneOf(fm.phase, PHASES))
246
+ offenses.push({
247
+ rule: 'contract-phase-invalid',
248
+ detail: `phase: "${fm.phase}" is not one of ${PHASES.join(' | ')}`,
249
+ });
250
+ if (fm.risk != null && !oneOf(fm.risk, RISKS))
251
+ offenses.push({
252
+ rule: 'contract-risk-invalid',
253
+ detail: `risk: "${fm.risk}" is not one of ${RISKS.join(' | ')}`,
254
+ });
255
+
256
+ // A sprint with no stories says so with `stories: []`. A bare `stories:` (null) or a scalar is not a
257
+ // list, and would let `stories_total: 0` pass with nothing machine-readable behind it (found by the
258
+ // codex pass on golden-beans#156).
259
+ if ('stories' in fm && !Array.isArray(fm.stories)) {
260
+ offenses.push({
261
+ rule: 'contract-stories-invalid',
262
+ detail: '`stories:` must be a list of story maps (write `stories: []` for none)',
263
+ });
264
+ return offenses;
265
+ }
266
+ const stories = fm.stories || [];
267
+ if (fm.stories_total != null && (!isInt(fm.stories_total) || fm.stories_total !== stories.length))
268
+ offenses.push({
269
+ rule: 'contract-total-mismatch',
270
+ detail: `stories_total: ${fm.stories_total}, but \`stories:\` lists ${stories.length}`,
271
+ });
272
+ const seen = new Set();
273
+ stories.forEach((s, i) => {
274
+ const label = s.id ? `story ${s.id}` : `story #${i + 1}`;
275
+ for (const key of STORY_FIELDS) {
276
+ if (!(key in s))
277
+ offenses.push({ rule: 'contract-story-field-missing', detail: `${label}: no \`${key}:\`` });
278
+ }
279
+ const m = typeof s.id === 'string' ? s.id.match(STORY_ID_RE) : null;
280
+ if (!m)
281
+ offenses.push({
282
+ rule: 'contract-story-id-invalid',
283
+ detail: `${label}: id must look like S<sprint>.<n>`,
284
+ });
285
+ else if (isInt(ctx.n) && Number(m[1]) !== ctx.n)
286
+ offenses.push({
287
+ rule: 'contract-story-id-invalid',
288
+ detail: `${label}: id names sprint ${m[1]}, but the file is sprint-${ctx.n}.md`,
289
+ });
290
+ if (s.id && seen.has(s.id))
291
+ offenses.push({ rule: 'contract-story-id-duplicate', detail: `${label} appears twice` });
292
+ seen.add(s.id);
293
+ if ('risk' in s && !oneOf(s.risk, RISKS))
294
+ offenses.push({
295
+ rule: 'contract-risk-invalid',
296
+ detail: `${label}: risk "${s.risk}" is not one of ${RISKS.join(' | ')}`,
297
+ });
298
+ if ('status' in s && !oneOf(s.status, STORY_STATUSES))
299
+ offenses.push({
300
+ rule: 'contract-story-status-invalid',
301
+ detail: `${label}: status "${s.status}" is not one of ${STORY_STATUSES.join(' | ')}`,
302
+ });
303
+ });
304
+ return offenses;
305
+ }
@@ -0,0 +1,20 @@
1
+ // roadmap-status-buckets.mjs — the SSOT for epic-status buckets and the seed-funnel definition, shared
2
+ // by scripts/build-order.mjs (Roadmap/00-ideas/BUILD-ORDER.md — the canonical in-repo board) and any
3
+ // other view of the roadmap a project builds (a reporting hub's live views, a dashboard). A single
4
+ // export means two views CANNOT independently drift on WHICH statuses count as "the funnel" or
5
+ // "building now" — matching counts are enforced by construction, not by a regression test alone.
6
+ //
7
+ // Extracted in the origin project after a real bug: a hub view counted EVERY grain:'Seed' row as a
8
+ // funnel member (including seeds that had since shipped/scaffolded/archived), while the board correctly
9
+ // counted only Raw/Ready/Queued — the hub's funnel stat was silently overcounting.
10
+
11
+ export const EPIC_STATUS_ORDER = ['In progress', 'Scaffolded', 'Shipped'];
12
+
13
+ // Seeds not yet scaffolded = the funnel. A seed can also carry Shipped/Scaffolded/Archived (it
14
+ // graduated into a real epic, or was retired) — those are NOT funnel members even though they're still
15
+ // grain: 'Seed' rows in the projection.
16
+ export const SEED_FUNNEL_STATUSES = new Set(['Raw', 'Ready', 'Queued']);
17
+
18
+ export function isFunnelSeed(row) {
19
+ return row?.grain === 'Seed' && SEED_FUNNEL_STATUSES.has(row.status);
20
+ }