@mlmcps/ml-specs 1.0.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 (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // Generate a self-contained HTML dashboard of a repo's specs.
3
+ // Pure Node, no dependencies. Read-only. NO network calls, and the output embeds everything
4
+ // it needs — no CDN, no fonts, no telemetry. Open the file, or commit it; either way the
5
+ // spec data never leaves the machine that ran this.
6
+ //
7
+ // node spec-dashboard.mjs --root /path/to/repo [--out spec-dashboard.html] [--open]
8
+ //
9
+ // Design notes, so later edits don't undo the reasoning:
10
+ // * ONE chart. The lifecycle bars are the only thing where a picture beats a number; the
11
+ // rest are stat tiles and tables, because that is what the data actually is.
12
+ // * The bars are a SINGLE hue with the stage named on the axis and the count direct-labeled.
13
+ // Colouring five stages five ways would be decoration — the axis already carries identity.
14
+ // * Status colours (critical/serious/warning) appear ONLY in the attention list, always with
15
+ // an icon and a word, so meaning is never carried by colour alone.
16
+
17
+ import { writeFileSync, existsSync } from 'node:fs';
18
+ import { join, basename, resolve } from 'node:path';
19
+ import { execFileSync } from 'node:child_process';
20
+ import { listSpecs, analyze, LIFECYCLE } from './lib/specs.mjs';
21
+
22
+ const args = process.argv.slice(2);
23
+ const flag = (n) => { const i = args.indexOf(n); return i === -1 ? null : args[i + 1]; };
24
+ const ROOT = resolve(flag('--root') || process.cwd());
25
+ const OUT = resolve(flag('--out') || join(ROOT, 'spec-dashboard.html'));
26
+
27
+ if (!existsSync(join(ROOT, 'specs'))) {
28
+ console.error(`No specs/ directory in ${ROOT}. Nothing to chart.`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const specs = listSpecs(ROOT);
33
+ if (specs.length === 0) {
34
+ console.error(`No spec files (specs/NNNN-*.md) found in ${ROOT}.`);
35
+ process.exit(1);
36
+ }
37
+ const a = analyze(specs);
38
+
39
+ const repoName = (() => {
40
+ try {
41
+ const url = execFileSync('git', ['-C', ROOT, 'remote', 'get-url', 'origin'],
42
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
+ return url.replace(/\.git$/, '').split(/[/:]/).slice(-2).join('/');
44
+ } catch { return basename(ROOT); }
45
+ })();
46
+
47
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
48
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
49
+
50
+ // ── in-flight work ──────────────────────────────────────────────────────────
51
+ // Running several specs at once (the three-developers pattern) means several worktrees on
52
+ // several branches, and it gets hard to tell which is which. This joins live worktrees back
53
+ // to the spec each one is building, so four concurrent builds are four labelled rows.
54
+ function inFlight() {
55
+ const g = (cwd, ...a) => {
56
+ try {
57
+ return execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
58
+ } catch { return ''; }
59
+ };
60
+ const porcelain = g(ROOT, 'worktree', 'list', '--porcelain');
61
+ if (!porcelain) return [];
62
+
63
+ const trees = [];
64
+ let cur = null;
65
+ for (const line of porcelain.split('\n')) {
66
+ if (line.startsWith('worktree ')) { cur = { path: line.slice(9) }; trees.push(cur); }
67
+ else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).replace('refs/heads/', '');
68
+ else if (cur && line === 'detached') cur.branch = '(detached)';
69
+ }
70
+
71
+ const mainPath = trees[0]?.path;
72
+ const dflt = g(ROOT, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD').split('/').pop()
73
+ || (g(ROOT, 'branch', '--list', 'main') ? 'main' : 'master');
74
+
75
+ return trees.map((t) => {
76
+ const dirty = g(t.path, 'status', '--porcelain').split('\n').filter(Boolean).length;
77
+ const ahead = t.branch && t.branch !== dflt
78
+ ? Number(g(t.path, 'rev-list', '--count', `${dflt}..HEAD`) || 0) : 0;
79
+ // Match the spec by its recorded Branch first, then by its id appearing in the branch name.
80
+ const spec = specs.find((s) => s.branch && s.branch === t.branch)
81
+ ?? specs.find((s) => t.branch && new RegExp(`(^|[^0-9])${s.id}([^0-9]|$)`).test(t.branch));
82
+ return {
83
+ path: t.path, branch: t.branch ?? '(none)', dirty, ahead, spec,
84
+ isMain: t.path === mainPath,
85
+ };
86
+ });
87
+ }
88
+
89
+ const trees = inFlight();
90
+ // Only worth showing when work is genuinely spread out.
91
+ const showTrees = trees.filter((t) => !t.isMain).length > 0;
92
+ const treeRows = trees.map((t) => `
93
+ <tr>
94
+ <td>${t.spec
95
+ ? `<code>${esc(t.spec.id)}</code> ${esc(t.spec.slug)}`
96
+ : `<span class="muted">${t.isMain ? 'main checkout' : 'no matching spec'}</span>`}</td>
97
+ <td>${t.spec?.status ? esc(t.spec.status) : '<span class="muted">—</span>'}</td>
98
+ <td class="num">${t.spec?.acTotal ? `${t.spec.acChecked}/${t.spec.acTotal}` : '<span class="muted">—</span>'}</td>
99
+ <td><code>${esc(t.branch)}</code></td>
100
+ <td class="num">${t.ahead || '<span class="muted">0</span>'}</td>
101
+ <td>${t.dirty
102
+ ? `<span class="pill pill-warning"><span aria-hidden="true">!</span> ${t.dirty} uncommitted</span>`
103
+ : '<span class="muted">clean</span>'}</td>
104
+ <td><code>${esc(t.path.replace(process.env.HOME ?? '~~~', '~'))}</code></td>
105
+ </tr>`).join('');
106
+
107
+ const inFlightSection = !showTrees ? '' : `
108
+ <section class="card">
109
+ <h2>In flight — parallel work</h2>
110
+ <p class="sub">One row per git worktree, joined to the spec it's building. This is the view
111
+ for running several specs at once: which spec, which branch, which directory, how far along.</p>
112
+ <div class="scroll">
113
+ <table class="tbl">
114
+ <thead><tr><th>Spec</th><th>Status</th><th class="num">AC</th><th>Branch</th>
115
+ <th class="num">Commits</th><th>Working tree</th><th>Path</th></tr></thead>
116
+ <tbody>${treeRows}</tbody>
117
+ </table>
118
+ </div>
119
+ </section>`;
120
+
121
+ const pct = (n, d) => (d === 0 ? 0 : Math.round((n / d) * 100));
122
+ const maxCount = Math.max(1, ...LIFECYCLE.map((s) => a.byStatus[s]));
123
+
124
+ // ── the one chart: lifecycle stages, single hue, direct-labeled ──────────────
125
+ const bars = LIFECYCLE.map((stage) => {
126
+ const n = a.byStatus[stage];
127
+ return `<div class="bar-row">
128
+ <div class="bar-label">${stage}</div>
129
+ <div class="bar-track" title="${n} spec${n === 1 ? '' : 's'} at ${stage}">
130
+ <div class="bar-fill" style="width:${(n / maxCount) * 100}%"></div>
131
+ </div>
132
+ <div class="bar-value">${n}</div>
133
+ </div>`;
134
+ }).join('\n');
135
+
136
+ const ICON = { critical: '✕', serious: '▲', warning: '!' };
137
+ const attention = a.attention.length === 0
138
+ ? `<p class="empty">Nothing needs attention — every spec's status is backed by what's on disk.</p>`
139
+ : `<table class="tbl">
140
+ <thead><tr><th>Level</th><th>Spec</th><th>What's wrong</th></tr></thead>
141
+ <tbody>${a.attention.map((x) => `
142
+ <tr>
143
+ <td><span class="pill pill-${x.level}"><span aria-hidden="true">${ICON[x.level]}</span> ${x.level}</span></td>
144
+ <td><code>${esc(x.spec.id)}</code> ${esc(x.spec.slug)}</td>
145
+ <td>${esc(x.why)}</td>
146
+ </tr>`).join('')}
147
+ </tbody></table>`;
148
+
149
+ const dupes = a.duplicateIds.length === 0 ? '' : `
150
+ <section class="card">
151
+ <h2>Duplicate spec numbers</h2>
152
+ <p class="sub">Two files sharing an id collide at merge — git resolves that badly.
153
+ <code>scripts/fix-specs.mjs</code> renumbers the later one and keeps history.</p>
154
+ <table class="tbl"><thead><tr><th>Id</th><th>Files</th></tr></thead><tbody>
155
+ ${a.duplicateIds.map((d) => `<tr><td><code>${esc(d.id)}</code></td>
156
+ <td>${d.files.map((f) => `<code>${esc(f)}</code>`).join('<br>')}</td></tr>`).join('')}
157
+ </tbody></table>
158
+ </section>`;
159
+
160
+ const rows = specs.map((s) => `
161
+ <tr data-status="${esc(s.status ?? 'Unknown')}" data-text="${esc((s.id + ' ' + s.slug + ' ' + (s.ticket ?? '')).toLowerCase())}">
162
+ <td><code>${esc(s.id)}</code></td>
163
+ <td>${esc(s.title)}${s.archived ? ' <span class="tag">archived</span>' : ''}</td>
164
+ <td>${s.status ? esc(s.status) : '<span class="muted">—</span>'}${
165
+ s.rawStatus && !s.statusIsCanonical ? ' <span class="tag tag-warn" title="Status holds prose, not a lifecycle word">prose</span>' : ''}</td>
166
+ <td class="num">${s.acTotal ? `${s.acChecked}/${s.acTotal}` : '<span class="muted">—</span>'}</td>
167
+ <td>${s.ticket ? esc(s.ticket) : '<span class="muted">—</span>'}</td>
168
+ <td>${s.branch ? `<code>${esc(s.branch)}</code>` : '<span class="muted">—</span>'}</td>
169
+ </tr>`).join('');
170
+
171
+ const html = `<!doctype html>
172
+ <html lang="en">
173
+ <head>
174
+ <meta charset="utf-8">
175
+ <meta name="viewport" content="width=device-width, initial-scale=1">
176
+ <title>Specs — ${esc(repoName)}</title>
177
+ <style>
178
+ :root {
179
+ color-scheme: light;
180
+ --surface-0:#f4f4f2; --surface-1:#fcfcfb; --border:#e0e0dc;
181
+ --text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#8a8985;
182
+ --series-1:#2a78d6; --track:#e8e8e4;
183
+ --critical:#d03b3b; --serious:#ec835a; --warning:#fab219;
184
+ }
185
+ @media (prefers-color-scheme: dark) {
186
+ :root:not([data-theme="light"]) {
187
+ color-scheme: dark;
188
+ --surface-0:#121211; --surface-1:#1a1a19; --border:#33332f;
189
+ --text-primary:#ffffff; --text-secondary:#c3c2b7; --text-muted:#8d8c84;
190
+ --series-1:#3987e5; --track:#2a2a27;
191
+ --critical:#d03b3b; --serious:#ec835a; --warning:#fab219;
192
+ }
193
+ }
194
+ :root[data-theme="dark"] {
195
+ color-scheme: dark;
196
+ --surface-0:#121211; --surface-1:#1a1a19; --border:#33332f;
197
+ --text-primary:#ffffff; --text-secondary:#c3c2b7; --text-muted:#8d8c84;
198
+ --series-1:#3987e5; --track:#2a2a27;
199
+ }
200
+ * { box-sizing:border-box; }
201
+ body { margin:0; padding:32px 24px 64px; background:var(--surface-0); color:var(--text-primary);
202
+ font:15px/1.55 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif; }
203
+ .wrap { max-width:1120px; margin:0 auto; }
204
+ header { display:flex; justify-content:space-between; align-items:baseline; gap:16px; flex-wrap:wrap; margin-bottom:24px; }
205
+ h1 { font-size:22px; margin:0; letter-spacing:-0.01em; }
206
+ h2 { font-size:15px; margin:0 0 4px; letter-spacing:-0.01em; }
207
+ .sub, .meta { color:var(--text-secondary); font-size:13px; margin:0 0 16px; }
208
+ .meta { margin:0; }
209
+ .card { background:var(--surface-1); border:1px solid var(--border); border-radius:10px; padding:20px; margin-bottom:16px; }
210
+ .tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; margin-bottom:16px; }
211
+ .tile { background:var(--surface-1); border:1px solid var(--border); border-radius:10px; padding:16px 18px; }
212
+ .tile .n { font-size:30px; font-weight:600; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; }
213
+ .tile .k { color:var(--text-secondary); font-size:13px; margin-top:2px; }
214
+ .bar-row { display:grid; grid-template-columns:110px 1fr 48px; align-items:center; gap:12px; margin:7px 0; }
215
+ .bar-label { color:var(--text-secondary); font-size:13px; }
216
+ .bar-track { background:var(--track); border-radius:4px; height:22px; overflow:hidden; }
217
+ .bar-fill { background:var(--series-1); height:100%; border-radius:4px; min-width:2px; }
218
+ .bar-value { text-align:right; font-variant-numeric:tabular-nums; font-size:13px; color:var(--text-secondary); }
219
+ .meter { background:var(--track); border-radius:4px; height:10px; overflow:hidden; margin-top:10px; }
220
+ .meter > div { background:var(--series-1); height:100%; border-radius:4px; }
221
+ .tbl { width:100%; border-collapse:collapse; font-size:13.5px; }
222
+ .tbl th { text-align:left; font-weight:600; color:var(--text-secondary); font-size:12px;
223
+ text-transform:uppercase; letter-spacing:0.04em; padding:8px 10px; border-bottom:1px solid var(--border); }
224
+ .tbl td { padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
225
+ .tbl tr:last-child td { border-bottom:none; }
226
+ .num { text-align:right; font-variant-numeric:tabular-nums; }
227
+ code { font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--text-secondary); }
228
+ .muted { color:var(--text-muted); }
229
+ .tag { font-size:11px; padding:1px 6px; border-radius:4px; border:1px solid var(--border); color:var(--text-secondary); }
230
+ .tag-warn { border-color:var(--warning); color:var(--text-primary); }
231
+ .pill { display:inline-flex; align-items:center; gap:5px; font-size:12px; padding:2px 8px;
232
+ border-radius:999px; border:1px solid currentColor; white-space:nowrap; }
233
+ .pill-critical { color:var(--critical); } .pill-serious { color:var(--serious); } .pill-warning { color:var(--warning); }
234
+ .filters { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
235
+ input[type=search], select { background:var(--surface-0); color:var(--text-primary);
236
+ border:1px solid var(--border); border-radius:7px; padding:7px 10px; font:inherit; font-size:13.5px; }
237
+ input[type=search] { flex:1; min-width:200px; }
238
+ .scroll { overflow-x:auto; }
239
+ .empty { color:var(--text-secondary); margin:0; }
240
+ footer { color:var(--text-muted); font-size:12px; margin-top:28px; }
241
+ </style>
242
+ </head>
243
+ <body>
244
+ <div class="wrap">
245
+ <header>
246
+ <div>
247
+ <h1>Specs — ${esc(repoName)}</h1>
248
+ <p class="meta">${a.total} spec${a.total === 1 ? '' : 's'} · ${a.active} active · generated locally, no data left this machine</p>
249
+ </div>
250
+ </header>
251
+
252
+ <div class="tiles">
253
+ <div class="tile"><div class="n">${a.total}</div><div class="k">Total specs</div></div>
254
+ <div class="tile"><div class="n">${a.byStatus.Verified}</div><div class="k">Verified</div></div>
255
+ <div class="tile"><div class="n">${a.byStatus.Draft + a.byStatus.Approved + a.byStatus.Implemented}</div><div class="k">In flight</div></div>
256
+ <div class="tile"><div class="n">${a.attention.length}</div><div class="k">Need attention</div></div>
257
+ </div>
258
+
259
+ ${inFlightSection}
260
+
261
+ <section class="card">
262
+ <h2>Lifecycle</h2>
263
+ <p class="sub">Where every spec sits. ${a.unknownStatus > 0
264
+ ? `${a.unknownStatus} spec${a.unknownStatus === 1 ? '' : 's'} had no recognisable lifecycle word and ${a.unknownStatus === 1 ? 'is' : 'are'} excluded from these bars.`
265
+ : 'Every spec has a recognisable status.'}</p>
266
+ ${bars}
267
+ </section>
268
+
269
+ <section class="card">
270
+ <h2>Acceptance criteria</h2>
271
+ <p class="sub">${a.acChecked} of ${a.acTotal} criteria checked across all specs (${pct(a.acChecked, a.acTotal)}%).
272
+ A checked box is a claim; <code>/spec-verify</code> is what tests it.</p>
273
+ <div class="meter"><div style="width:${pct(a.acChecked, a.acTotal)}%"></div></div>
274
+ </section>
275
+
276
+ <section class="card">
277
+ <h2>Needs attention</h2>
278
+ <p class="sub">Statuses the repo can't back up, ordered by severity.</p>
279
+ <div class="scroll">${attention}</div>
280
+ </section>
281
+
282
+ ${dupes}
283
+
284
+ <section class="card">
285
+ <h2>All specs</h2>
286
+ <div class="filters">
287
+ <input type="search" id="q" placeholder="Search id, slug, or ticket…" aria-label="Search specs">
288
+ <select id="st" aria-label="Filter by status">
289
+ <option value="">All statuses</option>
290
+ ${LIFECYCLE.map((s) => `<option>${s}</option>`).join('')}
291
+ <option value="Unknown">Unknown</option>
292
+ </select>
293
+ </div>
294
+ <div class="scroll">
295
+ <table class="tbl" id="specs">
296
+ <thead><tr><th>Id</th><th>Title</th><th>Status</th><th class="num">AC</th><th>Ticket</th><th>Branch</th></tr></thead>
297
+ <tbody>${rows}</tbody>
298
+ </table>
299
+ </div>
300
+ <p class="meta" id="count" style="margin-top:10px"></p>
301
+ </section>
302
+
303
+ <footer>Generated by ml-specs · ${esc(ROOT)}</footer>
304
+ </div>
305
+ <script>
306
+ const q = document.getElementById('q'), st = document.getElementById('st');
307
+ const rows = [...document.querySelectorAll('#specs tbody tr')];
308
+ const count = document.getElementById('count');
309
+ function apply() {
310
+ const term = q.value.trim().toLowerCase(), status = st.value;
311
+ let shown = 0;
312
+ for (const r of rows) {
313
+ const ok = (!term || r.dataset.text.includes(term)) && (!status || r.dataset.status === status);
314
+ r.hidden = !ok; if (ok) shown++;
315
+ }
316
+ count.textContent = shown === rows.length
317
+ ? \`Showing all \${rows.length} specs\`
318
+ : \`Showing \${shown} of \${rows.length} specs\`;
319
+ }
320
+ q.addEventListener('input', apply); st.addEventListener('change', apply); apply();
321
+ </script>
322
+ </body>
323
+ </html>`;
324
+
325
+ writeFileSync(OUT, html);
326
+ console.log(`✓ ${OUT}`);
327
+ console.log(` ${a.total} specs · ${a.attention.length} needing attention · ${a.duplicateIds.length} duplicate id(s) · ${a.unknownStatus} without a lifecycle word`);
328
+ console.log(' Self-contained, offline, no network calls — open it in a browser.');
329
+ if (args.includes('--open')) {
330
+ try { execFileSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [OUT]); } catch {}
331
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // One spec, N repos, N pull requests, one key.
3
+ //
4
+ // node spec-fanout.mjs 0031 payment.captured --dry-run
5
+ // node spec-fanout.mjs 0031 payment.captured "POST /payments/{id}/capture"
6
+ // node spec-fanout.mjs 0031 --plan # show the targets, open nothing
7
+ //
8
+ // Reads docs/ESTATE.md for the contract graph. Credentials from the environment.
9
+ // Exit code 1 if any repo fails, or if the spec is not past the approval gate.
10
+ //
11
+ // Why this exists: a change spanning four services is four pull requests that a
12
+ // reviewer correlates by hand and hopes they got right. Every branch here is the
13
+ // same derived name, so the four are provably one change — and the impact query
14
+ // finds the service one hop out that nobody remembered.
15
+ //
16
+ // Partial failure is reported, not thrown: with five repos, a permissions error
17
+ // on the fourth must not hide that three succeeded. Until there is a queue, the
18
+ // caller deciding what to retry is a human, which is why the result has to be
19
+ // legible.
20
+
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { listSpecs } from './lib/specs.mjs';
24
+ import { parseEstate, impactOf } from './lib/estate.mjs';
25
+ import { adoRepos, github, governedScm, pullRequestBody, fanOutTargets } from './lib/scm.mjs';
26
+ import * as knowledge from './lib/knowledge.mjs';
27
+ import { args, colours, transportFor, scmConfig, env, printTranscript } from './lib/cli.mjs';
28
+
29
+ const { positional, json, dryRun, has, root } = args();
30
+ const [target, ...contracts] = positional;
31
+ const C = colours(process.stdout.isTTY && !json);
32
+
33
+ if (!target) { console.error('usage: spec-fanout.mjs <spec-id> [contract...] [--plan] [--dry-run]'); process.exit(1); }
34
+
35
+ const spec = listSpecs(root).find((s) => s.id === target || s.file.endsWith(target));
36
+ if (!spec) { console.error(`no spec matching "${target}" under ${root}`); process.exit(1); }
37
+
38
+ const estatePath = join(root, 'docs', 'ESTATE.md');
39
+ const index = existsSync(estatePath) ? parseEstate(readFileSync(estatePath, 'utf8')) : { repos: [] };
40
+
41
+ // "Who else breaks?" answered from an index that does not exist is a FALSE
42
+ // ALL-CLEAR, and a confident one: an empty list and a zero exit read as "nothing
43
+ // else is affected". That is the most expensive wrong answer this script can
44
+ // give, so it refuses to answer at all rather than answer emptily.
45
+ if (contracts.length && index.repos.length === 0) {
46
+ console.error(
47
+ `cannot say who else breaks: ${existsSync(estatePath) ? `${estatePath} lists no services` : 'no docs/ESTATE.md'}.\n` +
48
+ `An empty answer here is indistinguishable from "nothing is affected", which is why this ` +
49
+ `refuses rather than reporting none. Run /repo-estate to build the index, then retry.`);
50
+ process.exit(1);
51
+ }
52
+
53
+ const impact = contracts.length ? impactOf(index, contracts) : null;
54
+ const targets = fanOutTargets(spec, impact);
55
+
56
+ // No targets at all means the spec names no repos and nothing consumes what it
57
+ // touches. Opening zero pull requests silently is the same class of quiet
58
+ // non-answer, so say which of the two it is.
59
+ if (targets.length === 0) {
60
+ console.error(
61
+ `spec ${spec.id} fans out to nothing: it names no repos in its header table` +
62
+ `${contracts.length ? ', and no service in the estate index consumes ' + contracts.join(', ') : ''}.\n` +
63
+ `Add a "Project / service" row to the spec, or pass the contracts this change touches.`);
64
+ process.exit(1);
65
+ }
66
+ const constraints = knowledge.forRepos(
67
+ knowledge.read(join(root, 'docs', 'CONSTRAINTS.md')), spec.repos ?? []);
68
+
69
+ if (!json) {
70
+ console.log(`${C.bold(`SPEC-${spec.id}`)} ${spec.title} ${C.dim(spec.status ?? 'unknown')}`);
71
+ console.log(`${C.dim(`branch: ${targets[0]?.branch ?? '(none)'}`)}\n`);
72
+ for (const d of impact?.directlyAffected ?? []) console.log(` ${C.red('direct')} ${d.repo.padEnd(22)} ${C.dim(d.via.join(', '))}`);
73
+ for (const t of impact?.transitivelyAffected ?? []) console.log(` ${C.dim(`one hop ${t.repo.padEnd(22)} ${t.via.join(', ')}`)}`);
74
+ }
75
+
76
+ if (has('plan')) {
77
+ if (json) console.log(JSON.stringify({ spec: spec.id, targets, impact }, null, 2));
78
+ else { console.log(`\n${C.bold('would open')}`); for (const t of targets) console.log(` ${t.repo.padEnd(22)} ${t.branch} ${C.dim(t.reason)}`); }
79
+ process.exit(0);
80
+ }
81
+
82
+ const transport = transportFor(dryRun);
83
+ const { tool, config } = scmConfig(transport);
84
+ const scm = governedScm(tool === 'github' ? github(config) : adoRepos(config),
85
+ { target: env('SDD_PROTECTED_BRANCH', 'main') });
86
+
87
+ if (dryRun && !json) console.log(`\n${C.bold('dry run')} ${C.dim('— nothing is sent')}`);
88
+
89
+ const results = [];
90
+ for (const t of targets) {
91
+ try {
92
+ const { pr } = await scm.openFor(spec, t.repo, pullRequestBody(spec, t.reason, { constraints }));
93
+ results.push({ ...t, pr, error: null });
94
+ } catch (e) {
95
+ // One repo failing must not stop the rest.
96
+ results.push({ ...t, pr: null, error: e.message });
97
+ }
98
+ }
99
+
100
+ const opened = results.filter((r) => r.pr);
101
+ const failed = results.filter((r) => r.error);
102
+ const oneChange = new Set(results.map((r) => r.branch)).size <= 1;
103
+
104
+ if (json) {
105
+ console.log(JSON.stringify({ spec: spec.id, oneChange, opened: opened.length, failed: failed.length, results }, null, 2));
106
+ } else {
107
+ console.log(`\n${C.bold('pull requests')}`);
108
+ for (const r of results) {
109
+ console.log(r.pr
110
+ ? ` ${C.green('opened')} ${r.repo.padEnd(22)} ${r.pr.url} ${C.dim(r.reason)}`
111
+ : ` ${C.red('failed')} ${r.repo.padEnd(22)} ${C.dim(r.error)}`);
112
+ }
113
+ console.log();
114
+ console.log(oneChange ? ` ${C.green(`one change across ${results.length} repo(s)`)}`
115
+ : ` ${C.red('branches diverged — not one change')}`);
116
+ if (failed.length) console.log(` ${C.red(`${failed.length} failed`)}${C.dim(`; ${opened.length} open`)}`);
117
+ printTranscript(transport, C);
118
+ }
119
+
120
+ process.exit(failed.length === 0 ? 0 : 1);