@mlmcps/ml-specs-mcp 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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +347 -0
  3. package/agents/coder.md +76 -0
  4. package/agents/developer.md +78 -0
  5. package/agents/pr-author.md +36 -0
  6. package/agents/reviewer.md +65 -0
  7. package/agents/scanner.md +66 -0
  8. package/agents/spec-author.md +91 -0
  9. package/agents/spec-reviewer.md +59 -0
  10. package/commands/code.md +29 -0
  11. package/commands/fix.md +67 -0
  12. package/commands/nfr.md +114 -0
  13. package/commands/pr.md +32 -0
  14. package/commands/repo-adopt.md +86 -0
  15. package/commands/repo-doctor.md +57 -0
  16. package/commands/repo-estate.md +79 -0
  17. package/commands/repo-impact.md +77 -0
  18. package/commands/repo-init.md +155 -0
  19. package/commands/repo-refresh.md +58 -0
  20. package/commands/repo-rollout.md +84 -0
  21. package/commands/repo-status.md +59 -0
  22. package/commands/spec-advance.md +81 -0
  23. package/commands/spec-build.md +66 -0
  24. package/commands/spec-fanout.md +64 -0
  25. package/commands/spec-review.md +24 -0
  26. package/commands/spec-verify.md +55 -0
  27. package/commands/spec.md +73 -0
  28. package/mcp/README.md +173 -0
  29. package/mcp/ml-specs-server.mjs +708 -0
  30. package/package.json +44 -0
  31. package/scripts/branch-policy.mjs +71 -0
  32. package/scripts/fix-specs.mjs +289 -0
  33. package/scripts/lib/cli.mjs +43 -0
  34. package/scripts/lib/estate.mjs +108 -0
  35. package/scripts/lib/http.mjs +73 -0
  36. package/scripts/lib/knowledge.mjs +91 -0
  37. package/scripts/lib/nfr.mjs +119 -0
  38. package/scripts/lib/policy.mjs +114 -0
  39. package/scripts/lib/scm.mjs +189 -0
  40. package/scripts/lib/specs.mjs +192 -0
  41. package/scripts/lib/trace.mjs +90 -0
  42. package/scripts/lib/tracker.mjs +257 -0
  43. package/scripts/nfr-compile.mjs +120 -0
  44. package/scripts/spec-brief.mjs +127 -0
  45. package/scripts/spec-dashboard.mjs +331 -0
  46. package/scripts/spec-fanout.mjs +120 -0
  47. package/scripts/spec-gate.mjs +329 -0
  48. package/scripts/spec-trace.mjs +91 -0
  49. package/scripts/survey-estate.mjs +230 -0
  50. package/scripts/tracker-sync.mjs +91 -0
  51. package/templates/ci/knowledge-check.mjs +176 -0
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // Package an approved spec for whoever implements it — a person, or an agent.
3
+ // Pure Node, no dependencies. Read-only. No network calls.
4
+ //
5
+ // node spec-brief.mjs 0031 # to stdout
6
+ // node spec-brief.mjs 0031 --repo api-neelias
7
+ // node spec-brief.mjs 0031 --out brief.md
8
+ // node spec-brief.mjs 0031 --root /path/to/repo
9
+ //
10
+ // Exit code 1 if the spec is not past the approval gate.
11
+ //
12
+ // Why this exists: an approved spec is a contract, but it is scattered — the
13
+ // criteria are in one section, the constraints in force are in another file, and
14
+ // the gates that will fail the build are in a third. Whoever implements it has
15
+ // to assemble that themselves, and the parts most often skipped are the ones
16
+ // that cause the rework.
17
+ //
18
+ // A brief is deliberately NOT a prompt. It is the same document whether a person
19
+ // or an agent implements the spec, because anything an agent would need that a
20
+ // new engineer would not is a sign the spec is underspecified — and the fix for
21
+ // that belongs in the spec, not in a wrapper around it.
22
+
23
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { listSpecs } from './lib/specs.mjs';
26
+ import { specId, branchName, prTitle, testCaseId } from './lib/trace.mjs';
27
+ import { compile, parseNfrMarkdown } from './lib/nfr.mjs';
28
+ import * as knowledge from './lib/knowledge.mjs';
29
+
30
+ const argv = process.argv.slice(2);
31
+ const flag = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : (argv[i + 1] ?? d); };
32
+
33
+ const root = flag('root', process.cwd());
34
+ const target = argv.find((a, i) => !a.startsWith('--') && !String(argv[i - 1] ?? '').startsWith('--'));
35
+ if (!target) { console.error('usage: spec-brief.mjs <spec-id|path> [--repo r] [--out f] [--root path]'); process.exit(1); }
36
+
37
+ const spec = listSpecs(root).find((s) => s.id === target || s.file.endsWith(target) || s.file === target);
38
+ if (!spec) { console.error(`no spec matching "${target}" under ${root}`); process.exit(1); }
39
+
40
+ // The contract is still being negotiated until the gate passes; there is nothing
41
+ // stable to build against.
42
+ if (!spec.status || spec.status === 'Draft') {
43
+ console.error(`spec ${spec.id} is ${spec.status ?? 'unknown'} — nothing is handed to an implementer ` +
44
+ `before the approval gate. The contract is still being negotiated.`);
45
+ process.exit(1);
46
+ }
47
+
48
+ // A brief exists to state the definition of done. Without criteria there is no
49
+ // definition of done, so emitting one that says so in prose and proceeding is
50
+ // worse than refusing: it hands an implementer a document whose whole purpose is
51
+ // missing, over a heading that claims to supply it. spec-gate.mjs already fails
52
+ // this spec; the two must agree, or one of them is lying.
53
+ if (!(spec.criteria ?? []).length) {
54
+ console.error(`spec ${spec.id} is ${spec.status} but has no acceptance criteria — there is ` +
55
+ `nothing to build against, and nothing to verify. Fix the spec (and the approval ` +
56
+ `that let it through) rather than starting from a brief with no definition of done.`);
57
+ process.exit(1);
58
+ }
59
+
60
+ const repo = flag('repo', spec.repos?.[0] ?? '.');
61
+ const branch = spec.branch ?? branchName(spec.id, spec.title);
62
+
63
+ const nfrPath = join(root, 'docs', 'NFRS.md');
64
+ const allNfrs = existsSync(nfrPath) ? parseNfrMarkdown(readFileSync(nfrPath, 'utf8')) : [];
65
+ const gates = (spec.nfrs ?? [])
66
+ .map((id) => allNfrs.find((n) => n.id === id))
67
+ .filter(Boolean)
68
+ .map((n) => { try { return compile(n).gate; } catch { return null; } })
69
+ .filter(Boolean);
70
+
71
+ const inForce = knowledge.forRepos(
72
+ knowledge.read(join(root, 'docs', 'CONSTRAINTS.md')),
73
+ spec.repos?.length ? spec.repos : [repo]);
74
+
75
+ const section = (name) => {
76
+ const body = readFileSync(join(root, spec.file), 'utf8');
77
+ const re = new RegExp(`^##\\s*(?:\\d+\\.\\s*)?${name}\\b.*$`, 'im');
78
+ const m = re.exec(body);
79
+ if (!m) return '';
80
+ return body.slice(m.index + m[0].length).split(/\n##\s/)[0].trim();
81
+ };
82
+
83
+ const lines = [
84
+ `# ${specId(spec.id)} — ${spec.title}`, '',
85
+ `**Repo:** \`${repo}\` `,
86
+ `**Branch:** \`${branch}\` `,
87
+ `**Pull request title:** \`${prTitle(spec.id, spec.title)}\` `,
88
+ `**Status:** ${spec.status}${spec.ticket ? ` · **Ticket:** ${spec.ticket}` : ''}`, '',
89
+ ];
90
+
91
+ const contract = section('Contract') || section('Scope') || section('Problem');
92
+ if (contract) lines.push('## The contract', '', contract, '');
93
+
94
+ lines.push('## Definition of done', '',
95
+ 'Every criterion below has a test case id already reserved for it. The change is ' +
96
+ 'done when each one passes — not when the code looks finished.', '');
97
+ for (const ac of spec.criteria ?? []) {
98
+ lines.push(`- **${ac.id}** (${testCaseId(spec.id, ac.ordinal)}) — ${ac.text}`);
99
+ }
100
+
101
+
102
+ if (inForce.length) {
103
+ lines.push('', '## Constraints in force', '',
104
+ 'Not negotiable within this change. They come from the knowledge layer and ' +
105
+ 'apply to every spec touching this repo.', '',
106
+ ...inForce.map((c) => `- **${c.id}** — ${c.text}`));
107
+ }
108
+
109
+ if (gates.length) {
110
+ lines.push('', '## Gates that will run against this', '',
111
+ 'The build fails if any of these does. They are not advisory.', '',
112
+ ...gates.map((g) => `- \`${g.tool}\` — ${g.assertion} (${g.nfr})`));
113
+ }
114
+
115
+ const outOfScope = section('Out of scope');
116
+ if (outOfScope) lines.push('', '## Out of scope', '', outOfScope);
117
+
118
+ lines.push('', '---', '',
119
+ 'If a decision is needed that this brief does not answer, the spec is incomplete: ' +
120
+ 'stop and amend the spec rather than deciding in the code. An amendment after ' +
121
+ `approval sends ${specId(spec.id)} back through the approval gate, which is the ` +
122
+ 'cheap outcome, not the expensive one.');
123
+
124
+ const markdown = lines.join('\n') + '\n';
125
+ const out = flag('out');
126
+ if (out) { writeFileSync(join(root, out), markdown); console.log(`brief written ${out} (${branch})`); }
127
+ else console.log(markdown);
@@ -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);