@mlmcps/ml-specs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,225 @@
1
+ // Tests for the MCP server's protocol surface, driven the way a client drives it: spawn the
2
+ // process, write newline-delimited JSON-RPC to stdin, read the replies. There is nothing to
3
+ // import — the server is a script that owns stdio the moment it loads — so a subprocess is the
4
+ // honest unit here, and it also catches the failure a unit test would miss: the server not
5
+ // starting at all.
6
+ //
7
+ // Run with: node --test ml-specs/mcp/
8
+ import { test, describe, before } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const SERVER = join(HERE, 'ml-specs-server.mjs');
17
+
18
+ const INIT = {
19
+ jsonrpc: '2.0', id: 0, method: 'initialize',
20
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
21
+ };
22
+
23
+ // Every exchange re-initializes: the server holds no cross-request state worth preserving and a
24
+ // fresh process per case keeps one failure from cascading into the next.
25
+ function rpc(...requests) {
26
+ const input = [INIT, ...requests].map((r) => JSON.stringify(r)).join('\n') + '\n';
27
+ const out = execFileSync('node', [SERVER, '--root', HERE], { input, encoding: 'utf8' });
28
+ const byId = new Map();
29
+ for (const line of out.trim().split('\n')) {
30
+ const msg = JSON.parse(line);
31
+ byId.set(msg.id, msg);
32
+ }
33
+ return byId;
34
+ }
35
+
36
+ /** Same, against an arbitrary repo root — the script-backed tools answer about a repo, not about mcp/. */
37
+ function rpcAt(root, ...requests) {
38
+ const input = [INIT, ...requests].map((r) => JSON.stringify(r)).join('\n') + '\n';
39
+ const out = execFileSync('node', [SERVER, '--root', root], { input, encoding: 'utf8' });
40
+ const byId = new Map();
41
+ for (const line of out.trim().split('\n')) { const msg = JSON.parse(line); byId.set(msg.id, msg); }
42
+ return byId;
43
+ }
44
+
45
+ const EXAMPLE_REPO = join(HERE, '..', '..', 'examples', 'promo-service');
46
+ const payload = (msg) => JSON.parse(msg.result.content[0].text);
47
+ const callAt = (root, name, args = {}) =>
48
+ rpcAt(root, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }).get(1);
49
+
50
+ describe('script-backed tools', () => {
51
+ test('every deterministic script in scripts/ that ports is exposed', () => {
52
+ const tools = rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }).get(1).result.tools.map((t) => t.name);
53
+ for (const name of ['spec_gate', 'spec_trace', 'spec_brief', 'nfr_check', 'estate_survey']) {
54
+ assert.ok(tools.includes(name), `${name} is not exposed`);
55
+ }
56
+ });
57
+
58
+ test('spec_gate accepts a bare id, not only a path', () => {
59
+ // spec-gate.mjs itself takes a path; spec-trace and spec-brief take either. A caller cannot
60
+ // be expected to know which is which, so the server resolves it.
61
+ const byPath = payload(callAt(EXAMPLE_REPO, 'spec_gate', { spec: 'specs/0001-add-coupon-expiry.md' }));
62
+ const byId = payload(callAt(EXAMPLE_REPO, 'spec_gate', { spec: '0001' }));
63
+ assert.equal(byId.spec, byPath.spec);
64
+ assert.ok(Array.isArray(byId.gates) && byId.gates.length, 'no gates returned');
65
+ });
66
+
67
+ test('spec_gate reports a failed gate as a RESULT, not a tool error', () => {
68
+ // The scripts exit 1 to mean "the gate failed". Treating that as a crash would turn a
69
+ // legitimate FAIL into something the caller cannot read.
70
+ const msg = callAt(EXAMPLE_REPO, 'spec_gate', { spec: '0001', to: 'Archived' });
71
+ assert.notEqual(msg.result.isError, true, 'a failing gate must not be an error');
72
+ const body = payload(msg);
73
+ assert.ok('ok' in body && Array.isArray(body.gates));
74
+ assert.ok(body.gates.some((g) => ['PASS', 'FAIL', 'MANUAL'].includes(g.verdict)));
75
+ });
76
+
77
+ test('spec_gate refuses an unknown id rather than guessing', () => {
78
+ const msg = callAt(EXAMPLE_REPO, 'spec_gate', { spec: '9999' });
79
+ assert.equal(msg.result.isError, true);
80
+ assert.match(msg.result.content[0].text, /no spec with id 9999/);
81
+ });
82
+
83
+ test('spec_trace returns the chain for every spec', () => {
84
+ const body = payload(callAt(EXAMPLE_REPO, 'spec_trace'));
85
+ assert.ok(Array.isArray(body.specs) && body.specs.length > 0);
86
+ assert.ok('ok' in body);
87
+ });
88
+
89
+ test('spec_brief returns markdown, deliberately not JSON', () => {
90
+ const body = payload(callAt(EXAMPLE_REPO, 'spec_brief', { spec: '0001' }));
91
+ assert.match(body.output, /^# SPEC-0001/m);
92
+ assert.ok(!body.output.trimStart().startsWith('{'), 'a brief is a document, not a payload');
93
+ });
94
+
95
+ test('nfr_check distinguishes "no NFR file" from "no NFR problems"', () => {
96
+ // Same rule as estate_lookup: absent means UNKNOWN, not zero. A false all-clear here is worse
97
+ // than no answer, because it gets believed.
98
+ const body = payload(callAt(EXAMPLE_REPO, 'nfr_check'));
99
+ assert.equal(body.present, false);
100
+ assert.match(body.note, /UNKNOWN, not absent/);
101
+ });
102
+
103
+ test('a relative --root is not applied twice', () => {
104
+ // ROOT is both the spawn cwd and the --root handed to each script. When it was relative the
105
+ // two composed and every script looked inside <root>/<root>, returning nothing.
106
+ const rel = 'examples/promo-service';
107
+ const repoRoot = join(HERE, '..', '..');
108
+ const input = [INIT, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'spec_trace', arguments: {} } }]
109
+ .map((r) => JSON.stringify(r)).join('\n') + '\n';
110
+ const out = execFileSync('node', [SERVER, '--root', rel], { input, encoding: 'utf8', cwd: repoRoot });
111
+ const msg = JSON.parse(out.trim().split('\n').find((l) => JSON.parse(l).id === 1));
112
+ assert.notEqual(msg.result.isError, true, 'a relative root must work');
113
+ assert.ok(JSON.parse(msg.result.content[0].text).specs.length > 0);
114
+ });
115
+
116
+ test('write-capable flags are never reachable through a tool', () => {
117
+ // nfr-compile --apply and spec-brief --out both write. The server promises read-only, and
118
+ // that promise has to hold by construction, not by convention.
119
+ const src = readFileSync(SERVER, 'utf8');
120
+ const dispatch = src.slice(src.indexOf('function callTool'));
121
+ for (const flag of ["'--apply'", "'--out'", "'--gates'"]) {
122
+ assert.ok(!dispatch.includes(flag), `${flag} is reachable from a tool`);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('prompts', () => {
128
+ let list;
129
+ before(() => {
130
+ list = rpc({ jsonrpc: '2.0', id: 1, method: 'prompts/list' }).get(1).result.prompts;
131
+ });
132
+
133
+ test('the server advertises the prompts capability', () => {
134
+ const caps = rpc().get(0).result.capabilities;
135
+ assert.ok(caps.prompts, 'a client that sees no prompts capability never calls prompts/list');
136
+ });
137
+
138
+ test('every command file is exposed as a prompt', () => {
139
+ assert.ok(list.length >= 18, `expected the whole commands/ dir, got ${list.length}`);
140
+ for (const name of ['spec', 'spec-build', 'spec-verify', 'code', 'fix', 'repo-init']) {
141
+ assert.ok(list.some((p) => p.name === name), `missing prompt: ${name}`);
142
+ }
143
+ });
144
+
145
+ test('descriptions come from the command frontmatter', () => {
146
+ const spec = list.find((p) => p.name === 'spec');
147
+ assert.match(spec.description, /spec-driven-development spec/);
148
+ });
149
+
150
+ test('a command with no $ARGUMENTS declares no arguments', () => {
151
+ // Otherwise a client renders a required-looking field for a command that ignores it.
152
+ assert.deepEqual(list.find((p) => p.name === 'repo-init').arguments, []);
153
+ });
154
+
155
+ test('an argument is never required, so a client cannot block on it', () => {
156
+ for (const p of list) {
157
+ for (const a of p.arguments) assert.notEqual(a.required, true, `${p.name} would block`);
158
+ }
159
+ });
160
+
161
+ test('prompts/get substitutes $ARGUMENTS', () => {
162
+ const res = rpc({
163
+ jsonrpc: '2.0', id: 1, method: 'prompts/get',
164
+ params: { name: 'spec', arguments: { arguments: 'PAY-42 refunds' } },
165
+ }).get(1).result;
166
+ const text = res.messages[0].content.text;
167
+ assert.match(text, /PAY-42 refunds/);
168
+ assert.ok(!text.includes('$ARGUMENTS'), 'an unsubstituted placeholder reaches the model as literal text');
169
+ });
170
+
171
+ test('omitting arguments leaves no placeholder behind', () => {
172
+ const text = rpc({
173
+ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'spec' },
174
+ }).get(1).result.messages[0].content.text;
175
+ assert.ok(!text.includes('$ARGUMENTS'));
176
+ });
177
+
178
+ test('an agent a command names in bold is inlined', () => {
179
+ // The client has no subagents; without this the delegation step silently does nothing.
180
+ const text = rpc({
181
+ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'spec' },
182
+ }).get(1).result.messages[0].content.text;
183
+ assert.match(text, /Inlined agent instructions/);
184
+ assert.match(text, /### Agent: spec-reviewer/);
185
+ });
186
+
187
+ test('a command that delegates to nobody gets no appendix', () => {
188
+ const text = rpc({
189
+ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'repo-doctor' },
190
+ }).get(1).result.messages[0].content.text;
191
+ assert.ok(!text.includes('Inlined agent instructions'));
192
+ });
193
+
194
+ test('an unknown prompt is an error, not an empty prompt', () => {
195
+ const msg = rpc({ jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name: 'nope' } }).get(1);
196
+ assert.ok(msg.error);
197
+ assert.match(msg.error.message, /no such prompt/);
198
+ });
199
+ });
200
+
201
+ describe('the existing surface still works', () => {
202
+ // Pinned deliberately: the tool list is a published contract, and a tool appearing or vanishing
203
+ // by accident is exactly what this catches. Changing it is meant to be a decision someone makes
204
+ // in a diff, which is why this assertion is exhaustive rather than a subset check.
205
+ test('tools/list is exactly the published surface', () => {
206
+ const names = rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }).get(1).result.tools.map((t) => t.name);
207
+ assert.deepEqual(names.sort(), [
208
+ // the original four — facts parsed out of the repo's own docs
209
+ 'estate_lookup', 'knowledge_check', 'spec_list', 'spec_next_number',
210
+ // the deterministic scripts, which used to be reachable only from Claude Code
211
+ 'estate_survey', 'nfr_check', 'spec_brief', 'spec_gate', 'spec_trace',
212
+ ].sort());
213
+ });
214
+
215
+ test('every tool declares a usable input schema', () => {
216
+ const tools = rpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' }).get(1).result.tools;
217
+ for (const t of tools) {
218
+ assert.ok(t.description && t.description.length > 40, `${t.name} has no real description`);
219
+ assert.equal(t.inputSchema.type, 'object', `${t.name} schema is not an object`);
220
+ for (const req of t.inputSchema.required ?? []) {
221
+ assert.ok(t.inputSchema.properties?.[req], `${t.name} requires undeclared property ${req}`);
222
+ }
223
+ }
224
+ });
225
+ });
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mlmcps/ml-specs-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Read-only MCP server for spec-driven development: estate contract lookup, knowledge-layer checks, and spec inventory over a local repo, plus the toolkit's spec-driven commands served as MCP prompts.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test 'scripts/lib/*.test.mjs' 'scripts/*.test.mjs' 'mcp/*.test.mjs'"
8
+ },
9
+ "private": false,
10
+ "license": "MIT",
11
+ "bin": {
12
+ "ml-specs-mcp": "mcp/ml-specs-server.mjs"
13
+ },
14
+ "files": [
15
+ "mcp/",
16
+ "commands/",
17
+ "agents/",
18
+ "scripts/",
19
+ "!scripts/**/*.test.mjs",
20
+ "!scripts/lib/*.test.mjs",
21
+ "!mcp/*.test.mjs",
22
+ "templates/ci/knowledge-check.mjs",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/MLMCPS/ml-claude-plugins.git",
36
+ "directory": "ml-specs"
37
+ },
38
+ "keywords": [
39
+ "mcp",
40
+ "spec-driven-development",
41
+ "claude"
42
+ ],
43
+ "dependencies": {}
44
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Install and audit the branch policy that makes the spec gate blocking.
3
+ //
4
+ // node branch-policy.mjs audit api-neelias neelias-pos
5
+ // node branch-policy.mjs install api-neelias --dry-run
6
+ // node branch-policy.mjs audit api-neelias --json
7
+ //
8
+ // Credentials from the environment: ADO_ORG/ADO_PROJECT/ADO_PAT, or
9
+ // GITHUB_OWNER/GITHUB_TOKEN with SDD_SCM_TOOL=github.
10
+ // Branch from SDD_PROTECTED_BRANCH (default main); build ref from SDD_BUILD_ID.
11
+ //
12
+ // Exit code 1 if any branch is ungated.
13
+ //
14
+ // Why this exists: ci/azure-pipelines.yml describes the checks, but a pipeline
15
+ // that runs and reports changes nothing. The gate is the POLICY. And the way a
16
+ // gate stops being a gate is silent — someone sets it to advisory on a Friday to
17
+ // unblock a release, nothing breaks, no test fails, and merges quietly stop
18
+ // being gated until an audit finds the holes months later.
19
+
20
+ import { adoPolicy, githubPolicy, auditOk, auditSummary } from './lib/policy.mjs';
21
+ import { args, colours, transportFor, scmConfig, env, printTranscript } from './lib/cli.mjs';
22
+
23
+ const { positional, json, dryRun } = args();
24
+ const [action, ...repos] = positional;
25
+ const C = colours(process.stdout.isTTY && !json);
26
+
27
+ if (!['install', 'audit'].includes(action) || repos.length === 0) {
28
+ console.error('usage: branch-policy.mjs <install|audit> <repo...> [--dry-run] [--json]');
29
+ process.exit(1);
30
+ }
31
+
32
+ const transport = transportFor(dryRun);
33
+ const { tool, config } = scmConfig(transport);
34
+ const mgr = tool === 'github' ? githubPolicy(config) : adoPolicy(config);
35
+ const branch = env('SDD_PROTECTED_BRANCH', 'main');
36
+ const buildRef = env('SDD_BUILD_ID', 'sdd-spec-gate');
37
+
38
+ if (dryRun && !json) console.log(`${C.bold('dry run')} ${C.dim('— nothing is sent')}\n`);
39
+
40
+ const results = [];
41
+ for (const repo of repos) {
42
+ try {
43
+ results.push(action === 'install'
44
+ ? await mgr.install(repo, branch, buildRef)
45
+ : await mgr.audit(repo, branch));
46
+ } catch (e) {
47
+ results.push({ repo, branch, tool, present: false, blocking: false,
48
+ findings: [{ severity: 'blocker', message: e.message }] });
49
+ }
50
+ }
51
+
52
+ const allOk = results.every(auditOk);
53
+
54
+ if (json) {
55
+ console.log(JSON.stringify({ action, branch, tool, ok: allOk, results }, null, 2));
56
+ } else {
57
+ for (const r of results) {
58
+ console.log(` ${auditOk(r) ? C.green('OK ') : C.red('FAIL')} ${r.repo.padEnd(22)} ${C.dim(branch)} ${auditSummary(r)}`);
59
+ for (const f of r.findings) {
60
+ const paint = f.severity === 'blocker' ? C.red : C.yellow;
61
+ console.log(` ${paint(f.severity.padEnd(8))} ${C.dim(f.message)}`);
62
+ }
63
+ }
64
+ console.log();
65
+ console.log(allOk
66
+ ? ` ${C.green('every branch is gated')}`
67
+ : ` ${C.red('an ungated branch means a pull request can merge with no approved spec')}`);
68
+ printTranscript(transport, C);
69
+ }
70
+
71
+ process.exit(allOk ? 0 : 1);
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ // Repair spec hygiene in a repo that has been using specs/ for a while.
3
+ // Pure Node, no dependencies. Runs entirely on your machine — no network calls.
4
+ //
5
+ // node fix-specs.mjs --root /path/to/repo # dry run: prints the plan, writes nothing
6
+ // node fix-specs.mjs --root /path/to/repo --apply # actually does it
7
+ //
8
+ // Two repairs, both opt-outable (--no-numbering / --no-status):
9
+ //
10
+ // NUMBERING Two specs sharing a number is a filename collision — git merges those badly.
11
+ // The later-created file is renumbered to the next free number, across ALL
12
+ // branches, and references to its old filename are rewritten.
13
+ //
14
+ // STATUS A Status cell holding prose ("Implemented — all 16 ACs met, gate green…")
15
+ // is real information in the wrong field: /spec-advance, /repo-status, and the
16
+ // MCP spec_list tool all read Status as one word. The lifecycle word moves into
17
+ // Status; the prose is PRESERVED verbatim as a note under the header table.
18
+ // Nothing is ever deleted.
19
+ //
20
+ // Idempotent: running it twice changes nothing the second time.
21
+
22
+ import { readFileSync, writeFileSync, existsSync, readdirSync, renameSync, statSync } from 'node:fs';
23
+ import { join, basename } from 'node:path';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { resolveStatus } from './lib/specs.mjs';
26
+
27
+ const args = process.argv.slice(2);
28
+ const flag = (n) => { const i = args.indexOf(n); return i === -1 ? null : args[i + 1]; };
29
+ const ROOT = flag('--root') || process.cwd();
30
+ const APPLY = args.includes('--apply');
31
+ const FORCE = args.includes('--force');
32
+ const DO_NUMBERING = !args.includes('--no-numbering');
33
+ const DO_STATUS = !args.includes('--no-status');
34
+
35
+ const LIFECYCLE = ['Draft', 'Approved', 'Implemented', 'Verified', 'Archived'];
36
+ // A spec id is digits plus an OPTIONAL letter: 0165 and 0165b are different specs, not a clash.
37
+ const SPEC_FILE = /^(\d{4}[a-z]?)-(.+)\.md$/;
38
+
39
+ const abs = (p) => join(ROOT, p);
40
+ const git = (...a) => {
41
+ try {
42
+ return execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
+ } catch {
44
+ return '';
45
+ }
46
+ };
47
+ const isRepo = git('rev-parse', '--is-inside-work-tree') === 'true';
48
+
49
+ // --- collect specs ----------------------------------------------------------
50
+
51
+ function collect() {
52
+ const out = [];
53
+ for (const dir of ['specs', 'specs/archive']) {
54
+ if (!existsSync(abs(dir))) continue;
55
+ for (const f of readdirSync(abs(dir))) {
56
+ const m = f.match(SPEC_FILE);
57
+ if (!m) continue;
58
+ out.push({ dir, file: `${dir}/${f}`, name: f, id: m[1], slug: m[2] });
59
+ }
60
+ }
61
+ return out.sort((a, b) => a.name.localeCompare(b.name));
62
+ }
63
+
64
+ function addedAt(file) {
65
+ if (isRepo) {
66
+ const t = git('log', '--diff-filter=A', '--format=%at', '-1', '--', file);
67
+ if (t) return Number(t);
68
+ }
69
+ try { return Math.floor(statSync(abs(file)).mtimeMs / 1000); } catch { return 0; }
70
+ }
71
+
72
+ // Highest number in use anywhere — working tree plus every branch's history, so a renumber
73
+ // can't land on an id that exists only on a branch nobody has checked out.
74
+ function highestUsed(specs) {
75
+ let max = 0;
76
+ const consider = (name) => {
77
+ const m = basename(name).match(/^(\d{4})/);
78
+ if (m) max = Math.max(max, Number(m[1]));
79
+ };
80
+ specs.forEach((s) => consider(s.name));
81
+ if (isRepo) {
82
+ git('log', '--all', '--pretty=format:', '--name-only', '--diff-filter=A', '--', 'specs/[0-9]*')
83
+ .split('\n').filter(Boolean).forEach(consider);
84
+ }
85
+ return max;
86
+ }
87
+
88
+ // --- repair 1: duplicate numbers -------------------------------------------
89
+
90
+ function planNumbering(specs) {
91
+ const byId = new Map();
92
+ for (const s of specs) {
93
+ if (!byId.has(s.id)) byId.set(s.id, []);
94
+ byId.get(s.id).push(s);
95
+ }
96
+
97
+ let next = highestUsed(specs);
98
+ const renames = [];
99
+ for (const [id, group] of byId) {
100
+ if (group.length < 2) continue;
101
+ // Keep the original; renumber everything created after it.
102
+ const dated = group.map((s) => ({ ...s, at: addedAt(s.file) })).sort((a, b) => a.at - b.at);
103
+ for (const later of dated.slice(1)) {
104
+ next += 1;
105
+ const id2 = String(next).padStart(4, '0');
106
+ renames.push({
107
+ from: later.file,
108
+ to: `${later.dir}/${id2}-${later.slug}.md`,
109
+ oldId: id,
110
+ newId: id2,
111
+ addedAt: later.at ? new Date(later.at * 1000).toISOString().slice(0, 10) : 'unknown',
112
+ keeping: dated[0].file,
113
+ });
114
+ }
115
+ }
116
+ return renames;
117
+ }
118
+
119
+ // Files that might mention a renamed spec by filename. Deliberately narrow: specs, docs,
120
+ // and top-level markdown. An exact-filename rewrite is safe; guessing at prose is not.
121
+ function referenceCandidates() {
122
+ const out = [];
123
+ const walk = (dir, depth) => {
124
+ if (!existsSync(abs(dir)) || depth > 3) return;
125
+ for (const f of readdirSync(abs(dir))) {
126
+ const rel = `${dir}/${f}`;
127
+ let st;
128
+ try { st = statSync(abs(rel)); } catch { continue; }
129
+ if (st.isDirectory()) walk(rel, depth + 1);
130
+ else if (f.endsWith('.md')) out.push(rel);
131
+ }
132
+ };
133
+ walk('specs', 0);
134
+ walk('docs', 0);
135
+ if (existsSync(abs('CLAUDE.md'))) out.push('CLAUDE.md');
136
+ if (existsSync(abs('README.md'))) out.push('README.md');
137
+ return [...new Set(out)];
138
+ }
139
+
140
+ // --- repair 2: status normalization ----------------------------------------
141
+
142
+ const STATUS_ROW = /^\|\s*\*\*Status\*\*\s*\|/;
143
+
144
+ function planStatus(specs) {
145
+ const changes = [];
146
+ const skipped = [];
147
+
148
+ for (const s of specs) {
149
+ const text = readFileSync(abs(s.file), 'utf8');
150
+ const lines = text.split('\n');
151
+ const i = lines.findIndex((l) => STATUS_ROW.test(l));
152
+ if (i === -1) continue;
153
+
154
+ const line = lines[i];
155
+ // Take everything between the cell delimiter and the LAST pipe: the prose itself often
156
+ // contains pipes, and truncating at the first one would silently eat content.
157
+ const start = line.indexOf('|', line.indexOf('**Status**')) + 1;
158
+ const end = line.lastIndexOf('|');
159
+ if (start <= 0 || end <= start) continue;
160
+
161
+ const raw = line.slice(start, end).trim();
162
+ const plain = raw.replace(/\*\*/g, '').replace(/`/g, '').trim();
163
+ if (LIFECYCLE.includes(plain)) continue; // already canonical
164
+ if (/^Draft \\?\| Approved/.test(plain)) continue; // untouched TEMPLATE placeholder
165
+
166
+ const r = resolveStatus(plain);
167
+ if (!r.status) {
168
+ skipped.push({ file: s.file, raw, why: 'no lifecycle word at all' });
169
+ continue;
170
+ }
171
+ // The word must LEAD the cell. "Implemented (2026-07-07) — …" is an author declaring a
172
+ // status; a stage word buried mid-sentence is narration, and promoting it writes a claim
173
+ // they never made. This is not about ambiguity — one buried word is just as much a guess
174
+ // as three, and this script rewrites files.
175
+ if (!r.leading) {
176
+ skipped.push({
177
+ file: s.file,
178
+ raw,
179
+ why: r.candidates.length > 1
180
+ ? `mentions ${r.candidates.join(', ')} mid-text, none leading`
181
+ : `"${r.status}" appears mid-text, not as the status`,
182
+ });
183
+ continue;
184
+ }
185
+ if (text.includes('> **Status note:**')) continue; // already normalized
186
+
187
+ changes.push({ file: s.file, from: raw, to: r.status, lineIndex: i });
188
+ }
189
+ return { changes, skipped };
190
+ }
191
+
192
+ function applyStatus(change) {
193
+ const lines = readFileSync(abs(change.file), 'utf8').split('\n');
194
+ const i = change.lineIndex;
195
+ const line = lines[i];
196
+ const start = line.indexOf('|', line.indexOf('**Status**')) + 1;
197
+ const end = line.lastIndexOf('|');
198
+ lines[i] = `${line.slice(0, start)} ${change.to} ${line.slice(end)}`;
199
+
200
+ // Insert the preserved prose immediately after the header table block.
201
+ let j = i;
202
+ while (j + 1 < lines.length && lines[j + 1].trimStart().startsWith('|')) j++;
203
+ lines.splice(j + 1, 0, '', `> **Status note:** ${change.from}`);
204
+
205
+ writeFileSync(abs(change.file), lines.join('\n'));
206
+ }
207
+
208
+ // --- run --------------------------------------------------------------------
209
+
210
+ const specs = collect();
211
+ if (specs.length === 0) {
212
+ console.log(`No specs found under ${ROOT}/specs — nothing to do.`);
213
+ process.exit(0);
214
+ }
215
+
216
+ if (APPLY && isRepo && !FORCE) {
217
+ const dirty = git('status', '--porcelain', '--', 'specs', 'docs').split('\n').filter(Boolean);
218
+ if (dirty.length) {
219
+ console.error('Refusing to --apply with uncommitted changes under specs/ or docs/.');
220
+ console.error('Commit or stash first so this script\'s changes are reviewable on their own,');
221
+ console.error('or pass --force if you know what you are doing.');
222
+ process.exit(1);
223
+ }
224
+ }
225
+
226
+ const renames = DO_NUMBERING ? planNumbering(specs) : [];
227
+ const { changes, skipped } = DO_STATUS ? planStatus(specs) : { changes: [], skipped: [] };
228
+
229
+ console.log(`repo: ${ROOT}`);
230
+ console.log(`specs: ${specs.length}${isRepo ? '' : ' (not a git repo — using file mtimes, local branches unchecked)'}`);
231
+ console.log(APPLY ? '\nMODE: APPLY — writing changes\n' : '\nMODE: DRY RUN — nothing will be written (pass --apply to do it)\n');
232
+
233
+ // 1. numbering
234
+ if (DO_NUMBERING) {
235
+ console.log(`── duplicate spec numbers: ${renames.length} file(s) to renumber`);
236
+ const candidates = renames.length ? referenceCandidates() : [];
237
+ for (const r of renames) {
238
+ console.log(` ${r.from}`);
239
+ console.log(` → ${r.to} (added ${r.addedAt}; keeping ${basename(r.keeping)} on ${r.oldId})`);
240
+
241
+ const refs = candidates.filter((c) => {
242
+ if (c === r.from) return false;
243
+ try { return readFileSync(abs(c), 'utf8').includes(basename(r.from)); } catch { return false; }
244
+ });
245
+ if (refs.length) console.log(` ${refs.length} file(s) reference the old filename and will be updated`);
246
+
247
+ if (APPLY) {
248
+ const tracked = isRepo && git('ls-files', '--', r.from) !== '';
249
+ if (tracked) git('mv', r.from, r.to);
250
+ else renameSync(abs(r.from), abs(r.to));
251
+ for (const c of refs) {
252
+ const t = readFileSync(abs(c), 'utf8');
253
+ writeFileSync(abs(c), t.split(basename(r.from)).join(basename(r.to)));
254
+ }
255
+ }
256
+ }
257
+ if (renames.length) {
258
+ console.log(' NOTE: only exact filename references are rewritten. Prose like "see spec 0043"');
259
+ console.log(' is left alone — search for the old number by hand if you use that style.');
260
+ }
261
+ }
262
+
263
+ // 2. status
264
+ if (DO_STATUS) {
265
+ console.log(`\n── non-canonical Status values: ${changes.length} file(s)`);
266
+ for (const c of changes.slice(0, 8)) {
267
+ const preview = c.from.length > 88 ? c.from.slice(0, 88) + '…' : c.from;
268
+ console.log(` ${c.file}`);
269
+ console.log(` "${preview}"`);
270
+ console.log(` → Status: ${c.to} (full text preserved as a "Status note" under the table)`);
271
+ }
272
+ if (changes.length > 8) console.log(` … and ${changes.length - 8} more`);
273
+ if (APPLY) changes.forEach(applyStatus);
274
+ }
275
+
276
+ if (skipped.length) {
277
+ console.log(`\n── needs a human: ${skipped.length} file(s) left untouched`);
278
+ for (const s of skipped.slice(0, 10)) {
279
+ console.log(` ${s.file} (${s.why})`);
280
+ console.log(` "${s.raw.length > 70 ? s.raw.slice(0, 70) + '…' : s.raw}"`);
281
+ }
282
+ if (skipped.length > 10) console.log(` … and ${skipped.length - 10} more`);
283
+ }
284
+
285
+ console.log(
286
+ APPLY
287
+ ? `\n✓ applied: ${renames.length} renumbered, ${changes.length} statuses normalized. Review with \`git diff\` / \`git status\` — nothing was committed.`
288
+ : `\nDry run complete: would renumber ${renames.length}, normalize ${changes.length}. Re-run with --apply to write.`,
289
+ );
@@ -0,0 +1,43 @@
1
+ // Argument and environment handling shared by the scripts that talk to a tracker
2
+ // or a source-control host. One implementation, so --dry-run and --root behave
3
+ // identically everywhere.
4
+ import { recorder, request } from './http.mjs';
5
+
6
+ export function args(argv = process.argv.slice(2)) {
7
+ const flag = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : (argv[i + 1] ?? d); };
8
+ const has = (n) => argv.includes(`--${n}`);
9
+ const positional = argv.filter((a, i) => !a.startsWith('--') && !String(argv[i - 1] ?? '').startsWith('--'));
10
+ return { argv, flag, has, positional, root: flag('root', process.cwd()), json: has('json'), dryRun: has('dry-run') };
11
+ }
12
+
13
+ export const colours = (enabled) => enabled
14
+ ? { dim: (s) => `\x1b[2m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`,
15
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }
16
+ : { dim: (s) => s, red: (s) => s, green: (s) => s, yellow: (s) => s, bold: (s) => s };
17
+
18
+ /** A recording transport for --dry-run, the real one otherwise. */
19
+ export const transportFor = (dryRun) => (dryRun ? recorder() : request);
20
+
21
+ export const env = (name, fallback = '') => process.env[name] ?? fallback;
22
+
23
+ /** Credentials are read from the environment, never from a file in the repo. */
24
+ export function trackerConfig(transport) {
25
+ const tool = env('SDD_PM_TOOL', 'ado');
26
+ return tool === 'jira'
27
+ ? { tool, config: { baseUrl: env('JIRA_BASE_URL'), email: env('JIRA_EMAIL'),
28
+ apiToken: env('JIRA_API_TOKEN'), projectKey: env('JIRA_PROJECT_KEY', 'PAY'), transport } }
29
+ : { tool, config: { org: env('ADO_ORG'), project: env('ADO_PROJECT'), pat: env('ADO_PAT'), transport } };
30
+ }
31
+
32
+ export function scmConfig(transport) {
33
+ const tool = env('SDD_SCM_TOOL', 'ado-repos');
34
+ return tool === 'github'
35
+ ? { tool, config: { owner: env('GITHUB_OWNER'), token: env('GITHUB_TOKEN'), transport } }
36
+ : { tool, config: { org: env('ADO_ORG'), project: env('ADO_PROJECT'), pat: env('ADO_PAT'), transport } };
37
+ }
38
+
39
+ export function printTranscript(transport, C) {
40
+ if (!transport.calls) return;
41
+ console.log(`\n${C.bold(`requests that would be sent (${transport.calls.length})`)}\n`);
42
+ console.log(transport.transcript());
43
+ }