@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.
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/agents/coder.md +76 -0
- package/agents/developer.md +78 -0
- package/agents/pr-author.md +36 -0
- package/agents/reviewer.md +65 -0
- package/agents/scanner.md +66 -0
- package/agents/spec-author.md +91 -0
- package/agents/spec-reviewer.md +59 -0
- package/commands/code.md +29 -0
- package/commands/fix.md +67 -0
- package/commands/nfr.md +114 -0
- package/commands/pr.md +32 -0
- package/commands/repo-adopt.md +86 -0
- package/commands/repo-doctor.md +57 -0
- package/commands/repo-estate.md +79 -0
- package/commands/repo-impact.md +77 -0
- package/commands/repo-init.md +155 -0
- package/commands/repo-refresh.md +58 -0
- package/commands/repo-rollout.md +84 -0
- package/commands/repo-status.md +59 -0
- package/commands/spec-advance.md +81 -0
- package/commands/spec-build.md +66 -0
- package/commands/spec-fanout.md +64 -0
- package/commands/spec-review.md +24 -0
- package/commands/spec-verify.md +55 -0
- package/commands/spec.md +73 -0
- package/mcp/README.md +173 -0
- package/mcp/ml-specs-server.mjs +708 -0
- package/package.json +44 -0
- package/scripts/branch-policy.mjs +71 -0
- package/scripts/fix-specs.mjs +289 -0
- package/scripts/lib/cli.mjs +43 -0
- package/scripts/lib/estate.mjs +108 -0
- package/scripts/lib/http.mjs +73 -0
- package/scripts/lib/knowledge.mjs +91 -0
- package/scripts/lib/nfr.mjs +119 -0
- package/scripts/lib/policy.mjs +114 -0
- package/scripts/lib/scm.mjs +189 -0
- package/scripts/lib/specs.mjs +192 -0
- package/scripts/lib/trace.mjs +90 -0
- package/scripts/lib/tracker.mjs +257 -0
- package/scripts/nfr-compile.mjs +120 -0
- package/scripts/spec-brief.mjs +127 -0
- package/scripts/spec-dashboard.mjs +331 -0
- package/scripts/spec-fanout.mjs +120 -0
- package/scripts/spec-gate.mjs +329 -0
- package/scripts/spec-trace.mjs +91 -0
- package/scripts/survey-estate.mjs +230 -0
- package/scripts/tracker-sync.mjs +91 -0
- 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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|