@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.
- package/.claude-plugin/marketplace.json +15 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/ml-specs/.claude-plugin/plugin.json +11 -0
- package/ml-specs/.mcp.json +17 -0
- package/ml-specs/CHANGELOG.md +860 -0
- package/ml-specs/LICENSE +21 -0
- package/ml-specs/README.md +347 -0
- package/ml-specs/agents/coder.md +76 -0
- package/ml-specs/agents/developer.md +78 -0
- package/ml-specs/agents/pr-author.md +36 -0
- package/ml-specs/agents/reviewer.md +65 -0
- package/ml-specs/agents/scanner.md +66 -0
- package/ml-specs/agents/spec-author.md +91 -0
- package/ml-specs/agents/spec-reviewer.md +59 -0
- package/ml-specs/commands/code.md +29 -0
- package/ml-specs/commands/fix.md +67 -0
- package/ml-specs/commands/nfr.md +114 -0
- package/ml-specs/commands/pr.md +32 -0
- package/ml-specs/commands/repo-adopt.md +86 -0
- package/ml-specs/commands/repo-doctor.md +57 -0
- package/ml-specs/commands/repo-estate.md +79 -0
- package/ml-specs/commands/repo-impact.md +77 -0
- package/ml-specs/commands/repo-init.md +155 -0
- package/ml-specs/commands/repo-refresh.md +58 -0
- package/ml-specs/commands/repo-rollout.md +84 -0
- package/ml-specs/commands/repo-status.md +59 -0
- package/ml-specs/commands/spec-advance.md +81 -0
- package/ml-specs/commands/spec-build.md +66 -0
- package/ml-specs/commands/spec-fanout.md +64 -0
- package/ml-specs/commands/spec-review.md +24 -0
- package/ml-specs/commands/spec-verify.md +55 -0
- package/ml-specs/commands/spec.md +73 -0
- package/ml-specs/hooks/hooks.json +23 -0
- package/ml-specs/hooks/knowledge-drift.sh +43 -0
- package/ml-specs/hooks/secret-scan.sh +56 -0
- package/ml-specs/mcp/README.md +173 -0
- package/ml-specs/mcp/ml-specs-server.mjs +708 -0
- package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
- package/ml-specs/package.json +44 -0
- package/ml-specs/scripts/branch-policy.mjs +71 -0
- package/ml-specs/scripts/fix-specs.mjs +289 -0
- package/ml-specs/scripts/lib/cli.mjs +43 -0
- package/ml-specs/scripts/lib/estate.mjs +108 -0
- package/ml-specs/scripts/lib/http.mjs +73 -0
- package/ml-specs/scripts/lib/knowledge.mjs +91 -0
- package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
- package/ml-specs/scripts/lib/nfr.mjs +119 -0
- package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
- package/ml-specs/scripts/lib/policy.mjs +114 -0
- package/ml-specs/scripts/lib/policy.test.mjs +139 -0
- package/ml-specs/scripts/lib/scm.mjs +189 -0
- package/ml-specs/scripts/lib/scm.test.mjs +155 -0
- package/ml-specs/scripts/lib/specs.mjs +192 -0
- package/ml-specs/scripts/lib/specs.test.mjs +69 -0
- package/ml-specs/scripts/lib/trace.mjs +90 -0
- package/ml-specs/scripts/lib/trace.test.mjs +110 -0
- package/ml-specs/scripts/lib/tracker.mjs +257 -0
- package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
- package/ml-specs/scripts/nfr-compile.mjs +120 -0
- package/ml-specs/scripts/scripts.test.mjs +182 -0
- package/ml-specs/scripts/spec-brief.mjs +127 -0
- package/ml-specs/scripts/spec-dashboard.mjs +331 -0
- package/ml-specs/scripts/spec-fanout.mjs +120 -0
- package/ml-specs/scripts/spec-gate.mjs +329 -0
- package/ml-specs/scripts/spec-trace.mjs +91 -0
- package/ml-specs/scripts/survey-estate.mjs +230 -0
- package/ml-specs/scripts/tracker-sync.mjs +91 -0
- package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
- package/ml-specs/templates/CLAUDE.fragment.md +87 -0
- package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
- package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
- package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
- package/ml-specs/templates/ci/spec-gate.yml +69 -0
- package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
- package/ml-specs/templates/docs/ESTATE.template.md +55 -0
- package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
- package/ml-specs/templates/gitattributes +18 -0
- package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
- package/ml-specs/templates/mcp/.mcp.json +34 -0
- package/ml-specs/templates/settings.json +18 -0
- package/ml-specs/templates/specs/AGENTS.md +57 -0
- package/ml-specs/templates/specs/README.md +104 -0
- package/ml-specs/templates/specs/TEMPLATE.md +114 -0
- package/ml-specs/templates/standards/.mlskills.json +24 -0
- 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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|