@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,177 @@
1
+ // Contract tests for the tracker clients.
2
+ //
3
+ // These cannot prove a remote accepts the request — only a live organisation can
4
+ // do that. They prove the client builds the document the API DOCUMENTS, which is
5
+ // where the bugs actually are: a wrong JSON-patch path, a field written directly
6
+ // that Jira only moves through a transition, a missing content type.
7
+ //
8
+ // Every call goes through the injected recorder, so none of this touches a network.
9
+ import { test, describe } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { recorder } from './http.mjs';
12
+ import { adoTracker, jiraTracker, readOnly, governedWriter, specKeyFromText, FIELD_OWNER } from './tracker.mjs';
13
+
14
+ const ADO = { org: 'contoso', project: 'Payments Core', pat: 's3cret' };
15
+ const JIRA = { baseUrl: 'https://contoso.atlassian.net', email: 'bot@m.example', apiToken: 't0ken', projectKey: 'PAY' };
16
+ const ops = (body) => Object.fromEntries(body.map((o) => [o.path, o.value]));
17
+ const decode = (h) => Buffer.from(h.replace('Basic ', ''), 'base64').toString();
18
+
19
+ const SPEC = { id: '0031', title: 'Idempotent refund submission', status: 'Approved', ticket: '1847' };
20
+ const CASE = { id: 'TC-0031.1', title: 'replay returns the original', from: 'AC-1', specKey: '0031', steps: ['a < b & c'] };
21
+
22
+ describe('Azure DevOps', () => {
23
+ test('auth is Basic with an empty username', async () => {
24
+ // ADO's documented scheme: empty user, PAT as the password.
25
+ const { basicAuth } = await import('./http.mjs');
26
+ assert.equal(decode(basicAuth('', 's3cret')), ':s3cret');
27
+ });
28
+
29
+ test('a project with a space is url-encoded, api-version pinned', async () => {
30
+ const t = recorder();
31
+ await adoTracker({ ...ADO, transport: t }).getWorkItem('1847');
32
+ assert.match(t.last().url, /\/Payments%20Core\/_apis\/wit\/workitems\/1847/);
33
+ assert.match(t.last().url, /api-version=7\.1/);
34
+ });
35
+
36
+ test('create uses json-patch and stamps the key into the title', async () => {
37
+ const t = recorder();
38
+ await adoTracker({ ...ADO, transport: t })
39
+ .createWorkItem({ title: 'Payment hold', type: 'User Story', description: 'why', specKey: '0031' });
40
+ assert.equal(t.last().method, 'POST');
41
+ assert.equal(t.last().contentType, 'application/json-patch+json');
42
+ assert.match(t.last().url, /\/wit\/workitems\/\$User%20Story/);
43
+ assert.equal(ops(t.last().body)['/fields/System.Title'], '[SPEC-0031] Payment hold');
44
+ });
45
+
46
+ test('a parent is linked as Hierarchy-Reverse', async () => {
47
+ const t = recorder();
48
+ await adoTracker({ ...ADO, transport: t })
49
+ .createWorkItem({ title: 'child', type: 'Task', specKey: '0031', parentId: '900' });
50
+ const rel = ops(t.last().body)['/relations/-'];
51
+ assert.equal(rel.rel, 'System.LinkTypes.Hierarchy-Reverse');
52
+ assert.match(rel.url, /\/wit\/workItems\/900$/);
53
+ });
54
+
55
+ test('field names map to ADO reference names', async () => {
56
+ const t = recorder();
57
+ await adoTracker({ ...ADO, transport: t }).updateFields('1847', { status: 'Active', sprint: 'Payments\\Sprint 4' });
58
+ const o = ops(t.last().body);
59
+ assert.equal(o['/fields/System.State'], 'Active');
60
+ assert.equal(o['/fields/System.IterationPath'], 'Payments\\Sprint 4');
61
+ assert.equal(t.last().method, 'PATCH');
62
+ });
63
+
64
+ test('a contract-owned field has no mapping, so it cannot be sent', async () => {
65
+ const t = recorder();
66
+ await adoTracker({ ...ADO, transport: t }).updateFields('1847', { title: 'rewritten' });
67
+ assert.deepEqual(ops(t.last().body), {});
68
+ });
69
+
70
+ test('find queries WIQL on the key', async () => {
71
+ const t = recorder([{ workItems: [] }]);
72
+ await adoTracker({ ...ADO, transport: t }).findBySpec('0031');
73
+ assert.match(t.calls[0].body.query, /SPEC-0031/);
74
+ assert.match(t.calls[0].body.query, /@project/);
75
+ });
76
+
77
+ test('test cases link back and escape their steps', async () => {
78
+ const t = recorder();
79
+ await adoTracker({ ...ADO, transport: t }).createTestCases('1847', [CASE]);
80
+ const o = ops(t.last().body);
81
+ assert.match(t.last().url, /\$Test%20Case/);
82
+ assert.equal(o['/fields/System.Title'], 'TC-0031.1 replay returns the original');
83
+ assert.match(o['/fields/Microsoft.VSTS.TCM.Steps'], /a &lt; b &amp; c/);
84
+ assert.equal(o['/relations/-'].rel, 'Microsoft.VSTS.Common.TestedBy-Reverse');
85
+ });
86
+ });
87
+
88
+ describe('Jira', () => {
89
+ test('the neutral type is mapped and the description is ADF', async () => {
90
+ const t = recorder([{ key: 'PAY-9' }, { key: 'PAY-9', fields: {} }]);
91
+ await jiraTracker({ ...JIRA, transport: t })
92
+ .createWorkItem({ title: 'Payment hold', type: 'User Story', description: 'why', specKey: '0031' });
93
+ const f = t.calls[0].body.fields;
94
+ assert.equal(f.issuetype.name, 'Story'); // not "User Story"
95
+ assert.equal(f.summary, '[SPEC-0031] Payment hold');
96
+ assert.equal(f.description.type, 'doc'); // Jira rejects a plain string
97
+ });
98
+
99
+ test('status moves through a transition, never a field write', async () => {
100
+ // The instructive difference from ADO, and the reason two adapters ship.
101
+ const t = recorder([
102
+ { transitions: [{ id: '31', name: 'Start', to: { name: 'In Progress' } }] },
103
+ null,
104
+ { key: 'PAY-9', fields: { status: { name: 'In Progress' } } },
105
+ ]);
106
+ await jiraTracker({ ...JIRA, transport: t }).updateFields('PAY-9', { status: 'In Progress' });
107
+ assert.equal(t.calls[0].method, 'GET');
108
+ assert.match(t.calls[0].url, /\/transitions$/);
109
+ assert.equal(t.calls[1].body.transition.id, '31');
110
+ assert.ok(!t.calls.some((c) => c.method === 'PUT'));
111
+ });
112
+
113
+ test('an impossible transition names what is available', async () => {
114
+ const t = recorder([{ transitions: [{ id: '31', name: 'Start', to: { name: 'In Progress' } }] }]);
115
+ await assert.rejects(
116
+ () => jiraTracker({ ...JIRA, transport: t }).updateFields('PAY-9', { status: 'Done' }),
117
+ /In Progress/);
118
+ });
119
+
120
+ test('find builds JQL on the key', async () => {
121
+ const t = recorder([{ issues: [] }]);
122
+ await jiraTracker({ ...JIRA, transport: t }).findBySpec('0031');
123
+ assert.match(decodeURIComponent(t.last().url), /summary ~ "SPEC-0031"/);
124
+ });
125
+
126
+ test('test cases are created then linked', async () => {
127
+ const t = recorder([{ key: 'PAY-77' }, null]);
128
+ const created = await jiraTracker({ ...JIRA, transport: t }).createTestCases('PAY-9', [CASE]);
129
+ assert.deepEqual(created, ['PAY-77']);
130
+ assert.match(t.calls[1].url, /\/issueLink$/);
131
+ assert.equal(t.calls[1].body.inwardIssue.key, 'PAY-77');
132
+ assert.equal(t.calls[1].body.outwardIssue.key, 'PAY-9');
133
+ });
134
+ });
135
+
136
+ describe('the ungoverned write path does not exist', () => {
137
+ test('a reader has no write methods, and they cannot be re-attached', () => {
138
+ const reader = readOnly(adoTracker({ ...ADO, transport: recorder() }));
139
+ assert.equal(reader.createWorkItem, undefined);
140
+ assert.equal(reader.updateFields, undefined);
141
+ assert.equal(typeof reader.getWorkItem, 'function');
142
+ assert.throws(() => { 'use strict'; reader.createWorkItem = () => {}; });
143
+ });
144
+
145
+ test('nothing reaches the tracker before the approval gate', async () => {
146
+ const w = governedWriter(adoTracker({ ...ADO, transport: recorder() }));
147
+ await assert.rejects(() => w.push({ id: '0031', status: 'Draft', ticket: '1' }, { status: 'Active' }), /before the approval gate/);
148
+ await assert.rejects(() => w.open({ id: '0031', status: 'Draft', title: 'x' }, 'Task'), /before the approval gate/);
149
+ });
150
+
151
+ test('a contract-owned field is refused even through the governed path', async () => {
152
+ const w = governedWriter(adoTracker({ ...ADO, transport: recorder() }));
153
+ await assert.rejects(() => w.push(SPEC, { criteria: 'rewritten' }), /owned by the spec/);
154
+ assert.equal(FIELD_OWNER.status, 'board');
155
+ assert.equal(FIELD_OWNER.criteria, 'spec');
156
+ });
157
+
158
+ test('test cases from another spec cannot be smuggled onto this work item', async () => {
159
+ const w = governedWriter(adoTracker({ ...ADO, transport: recorder() }));
160
+ await assert.rejects(() => w.pushTestCases(SPEC, [{ ...CASE, specKey: '0019' }]), /not derived from spec 0031/);
161
+ });
162
+
163
+ test('an approved spec writes board-owned fields', async () => {
164
+ const t = recorder();
165
+ const item = await governedWriter(adoTracker({ ...ADO, transport: t })).push(SPEC, { status: 'Active' });
166
+ assert.equal(t.last().method, 'PATCH');
167
+ assert.ok(item);
168
+ });
169
+ });
170
+
171
+ describe('key recovery', () => {
172
+ test('the key is read back out of a title or description', () => {
173
+ assert.equal(specKeyFromText('[SPEC-0031] Idempotent refund'), '0031');
174
+ assert.equal(specKeyFromText('a suffixed spec SPEC-0165b here'), '0165b');
175
+ assert.equal(specKeyFromText('no key here'), null);
176
+ });
177
+ });
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // Compile non-functional requirements into the two things that enforce them:
3
+ // standing constraints in docs/CONSTRAINTS.md, and blocking pipeline gates.
4
+ // Pure Node, no dependencies. No network calls — nothing leaves this machine.
5
+ //
6
+ // node nfr-compile.mjs --root /path/to/repo # dry run: prints, writes nothing
7
+ // node nfr-compile.mjs --root /path/to/repo --apply # writes docs/CONSTRAINTS.md
8
+ // node nfr-compile.mjs --root /path/to/repo --gates ci/nfr-gates.yml
9
+ // node nfr-compile.mjs --json
10
+ //
11
+ // Exit code 1 if an NFR cannot be compiled, or if one has been flattened into an
12
+ // acceptance criterion.
13
+ //
14
+ // Reads docs/NFRS.md (a markdown table, so it reviews in a pull request like
15
+ // anything else), falling back to nfrs.json.
16
+ //
17
+ // Why this exists: NFRs are the requirements most likely to be agreed and then
18
+ // lost. They do not decompose into user stories — a story breakdown flattens
19
+ // them into prose nothing checks. They decompose into a constraint the author
20
+ // reads and a gate the build fails on, and this produces both or refuses.
21
+
22
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { listSpecs } from './lib/specs.mjs';
25
+ import { compile, findFlattened, toPipelineYaml, parseNfrMarkdown } from './lib/nfr.mjs';
26
+ import * as knowledge from './lib/knowledge.mjs';
27
+
28
+ const argv = process.argv.slice(2);
29
+ const flag = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : (argv[i + 1] ?? d); };
30
+ const has = (n) => argv.includes(`--${n}`);
31
+
32
+ const root = flag('root', process.cwd());
33
+ const json = has('json');
34
+ const apply = has('apply');
35
+ const gatesOut = flag('gates');
36
+
37
+ const C = process.stdout.isTTY && !json
38
+ ? { dim: (s) => `\x1b[2m${s}\x1b[0m`, red: (s) => `\x1b[31m${s}\x1b[0m`,
39
+ green: (s) => `\x1b[32m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }
40
+ : { dim: (s) => s, red: (s) => s, green: (s) => s, bold: (s) => s };
41
+
42
+ // --- read -------------------------------------------------------------------
43
+ const md = join(root, 'docs', 'NFRS.md');
44
+ const jsonFile = join(root, 'nfrs.json');
45
+ let nfrs = [];
46
+ let source = null;
47
+ if (existsSync(md)) { nfrs = parseNfrMarkdown(readFileSync(md, 'utf8')); source = 'docs/NFRS.md'; }
48
+ else if (existsSync(jsonFile)) { nfrs = JSON.parse(readFileSync(jsonFile, 'utf8')); source = 'nfrs.json'; }
49
+
50
+ if (!source) {
51
+ console.error(`no docs/NFRS.md or nfrs.json under ${root}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ // --- compile ----------------------------------------------------------------
56
+ const compiled = [];
57
+ const refused = [];
58
+ for (const nfr of nfrs) {
59
+ try { compiled.push({ nfr, ...compile(nfr) }); }
60
+ catch (e) { refused.push({ id: nfr.id ?? '(unnamed)', why: e.message }); }
61
+ }
62
+
63
+ const specs = listSpecs(root);
64
+ const flattened = findFlattened(specs);
65
+
66
+ // An NFR named by a spec but absent from the source is worse than an unrouted
67
+ // one: the spec claims a constraint that does not exist anywhere.
68
+ const known = new Set(nfrs.map((n) => n.id));
69
+ const dangling = [];
70
+ for (const s of specs) {
71
+ for (const id of s.nfrs ?? []) if (!known.has(id)) dangling.push({ spec: s.id, nfr: id });
72
+ }
73
+
74
+ const constraintsPath = join(root, 'docs', 'CONSTRAINTS.md');
75
+ let changed = [];
76
+ if (apply && refused.length === 0) {
77
+ changed = knowledge.write(constraintsPath, compiled.map((c) => c.constraint));
78
+ }
79
+ if (gatesOut && refused.length === 0) {
80
+ const out = join(root, gatesOut);
81
+ mkdirSync(dirname(out), { recursive: true });
82
+ writeFileSync(out, toPipelineYaml(compiled.map((c) => c.gate)) + '\n');
83
+ }
84
+
85
+ const ok = refused.length === 0 && flattened.length === 0 && dangling.length === 0;
86
+
87
+ if (json) {
88
+ console.log(JSON.stringify({ root, source, ok,
89
+ compiled: compiled.map((c) => ({ id: c.nfr.id, constraint: c.constraint, gate: c.gate })),
90
+ refused, flattened, dangling, changed, applied: apply }, null, 2));
91
+ } else {
92
+ console.log(`${C.bold('source')} ${C.dim(source)}\n`);
93
+ for (const { nfr, constraint, gate } of compiled) {
94
+ console.log(` ${C.green(nfr.id)} ${C.dim(nfr.kind.padEnd(12))} ${constraint.text}`);
95
+ console.log(` ${C.dim(`gate: ${gate.tool} — ${gate.assertion} scope: ${constraint.scope.join(', ')}`)}`);
96
+ }
97
+ for (const r of refused) console.log(` ${C.red(r.id)} ${C.red('refused')} ${C.dim(r.why)}`);
98
+
99
+ if (flattened.length) {
100
+ console.log(`\n ${C.red('flattened into a story — an NFR here stops being enforced:')}`);
101
+ for (const f of flattened) console.log(` ${C.dim(`${f.spec} ${f.criterion} mentions ${f.nfr}`)}`);
102
+ }
103
+ if (dangling.length) {
104
+ console.log(`\n ${C.red('named by a spec but defined nowhere:')}`);
105
+ for (const d of dangling) console.log(` ${C.dim(`spec ${d.spec} -> ${d.nfr}`)}`);
106
+ }
107
+
108
+ console.log();
109
+ if (apply && refused.length === 0) {
110
+ console.log(changed.length
111
+ ? ` ${C.green('knowledge layer updated')} ${C.dim(`${changed.join(', ')} -> docs/CONSTRAINTS.md`)}`
112
+ : ` ${C.dim('knowledge layer already current')}`);
113
+ } else if (!apply) {
114
+ console.log(` ${C.dim('dry run — pass --apply to write docs/CONSTRAINTS.md')}`);
115
+ }
116
+ if (gatesOut && refused.length === 0) console.log(` ${C.green('pipeline gates written')} ${C.dim(gatesOut)}`);
117
+ console.log(ok ? ` ${C.green('all NFRs routed')}` : ` ${C.red('not every NFR is enforced')}`);
118
+ }
119
+
120
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,182 @@
1
+ // Script-level behaviour: what each CLI does at its boundaries.
2
+ //
3
+ // The lib tests cover the logic; these cover the decision a script makes about
4
+ // whether to act at all. That is where the interesting bugs live — a script that
5
+ // notices a precondition is violated, says so in prose, and proceeds anyway
6
+ // passes every unit test its lib has.
7
+ import { test, describe } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const SCRIPTS = dirname(fileURLToPath(import.meta.url));
16
+
17
+ function repo(specs = {}) {
18
+ const dir = mkdtempSync(join(tmpdir(), 'sdd-scripts-'));
19
+ mkdirSync(join(dir, 'specs'), { recursive: true });
20
+ mkdirSync(join(dir, 'docs'), { recursive: true });
21
+ for (const [name, body] of Object.entries(specs)) writeFileSync(join(dir, 'specs', name), body);
22
+ return dir;
23
+ }
24
+
25
+ /** Run a script; return { code, stdout, stderr } instead of throwing on non-zero. */
26
+ function run(script, args, cwd) {
27
+ try {
28
+ const stdout = execFileSync('node', [join(SCRIPTS, script), ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
29
+ return { code: 0, stdout, stderr: '' };
30
+ } catch (e) {
31
+ return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
32
+ }
33
+ }
34
+
35
+ const spec = ({ status = 'Approved', criteria = '- [ ] **AC1** — Given x, when y, then z.' }) =>
36
+ `# Spec: Fixture\n\n| | |\n|---|---|\n| **Ticket** | PAY-1 |\n| **Status** | ${status} |\n\n## 5. Acceptance criteria\n\n${criteria}\n`;
37
+
38
+ describe('spec-brief refuses rather than warns', () => {
39
+ test('an approved spec with NO criteria is refused', () => {
40
+ // A brief exists to state the definition of done. Without criteria there is
41
+ // none, so emitting one under a heading that claims to supply it is worse
42
+ // than refusing. spec-gate.mjs fails this spec; the two must agree.
43
+ const dir = repo({ '0200-none.md': spec({ criteria: '' }) });
44
+ const r = run('spec-brief.mjs', ['0200'], dir);
45
+ assert.equal(r.code, 1);
46
+ assert.match(r.stderr, /no acceptance criteria/);
47
+ assert.doesNotMatch(r.stdout, /Definition of done/);
48
+ rmSync(dir, { recursive: true, force: true });
49
+ });
50
+
51
+ test('spec-gate and spec-brief agree on that spec', () => {
52
+ const dir = repo({ '0200-none.md': spec({ criteria: '' }) });
53
+ assert.equal(run('spec-gate.mjs', ['specs/0200-none.md'], dir).code,
54
+ run('spec-brief.mjs', ['0200'], dir).code);
55
+ rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ test('a Draft spec is refused — the contract is still being negotiated', () => {
59
+ const dir = repo({ '0201-draft.md': spec({ status: 'Draft' }) });
60
+ const r = run('spec-brief.mjs', ['0201'], dir);
61
+ assert.equal(r.code, 1);
62
+ assert.match(r.stderr, /before the approval gate/);
63
+ rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ test('an approved spec with criteria produces a brief pairing each with its test case', () => {
67
+ const dir = repo({ '0202-ok.md': spec({}) });
68
+ const r = run('spec-brief.mjs', ['0202'], dir);
69
+ assert.equal(r.code, 0);
70
+ assert.match(r.stdout, /\*\*AC-1\*\* \(TC-0202\.1\)/);
71
+ assert.match(r.stdout, /Definition of done/);
72
+ rmSync(dir, { recursive: true, force: true });
73
+ });
74
+ });
75
+
76
+ describe('spec-trace', () => {
77
+ test('a suffixed spec id is a distinct spec, not a clash', () => {
78
+ const dir = repo({ '0165b-follow-up.md': spec({}) });
79
+ const r = run('spec-trace.mjs', ['0165b'], dir);
80
+ assert.equal(r.code, 0);
81
+ assert.match(r.stdout, /SPEC-0165b/);
82
+ assert.match(r.stdout, /TC-0165b\.1/);
83
+ rmSync(dir, { recursive: true, force: true });
84
+ });
85
+
86
+ test('a hand-typed ticket is reported unverifiable, not counted as passing', () => {
87
+ const dir = repo({ '0203-x.md': spec({}) });
88
+ const r = run('spec-trace.mjs', ['0203'], dir);
89
+ assert.match(r.stdout, /cannot be verified from the repo/);
90
+ assert.match(r.stdout, /chain intact/);
91
+ rmSync(dir, { recursive: true, force: true });
92
+ });
93
+
94
+ test('no matching spec is an error, not an empty pass', () => {
95
+ const dir = repo({ '0204-x.md': spec({}) });
96
+ assert.equal(run('spec-trace.mjs', ['9999'], dir).code, 1);
97
+ rmSync(dir, { recursive: true, force: true });
98
+ });
99
+ });
100
+
101
+ describe('spec-fanout never gives a false all-clear', () => {
102
+ const estate = `| Event / queue / topic | Producer | Consumer | Notes | Evidence |
103
+ |---|---|---|---|---|
104
+ | \`payment.captured\` | svc-a | svc-b (\`Listener\`) | n | e |`;
105
+
106
+ test('asked who breaks with no estate index, it refuses rather than reporting none', () => {
107
+ // An empty list plus a zero exit reads as "nothing else is affected". That is
108
+ // the most expensive wrong answer this script can give.
109
+ const dir = repo({ '0001-x.md': spec({}) });
110
+ const r = run('spec-fanout.mjs', ['0001', 'payment.captured', '--plan'], dir);
111
+ assert.equal(r.code, 1);
112
+ assert.match(r.stderr, /cannot say who else breaks/);
113
+ assert.match(r.stderr, /indistinguishable from "nothing is affected"/);
114
+ rmSync(dir, { recursive: true, force: true });
115
+ });
116
+
117
+ test('a spec that fans out to nothing says which reason, not an empty plan', () => {
118
+ const dir = repo({ '0001-x.md': spec({}) });
119
+ const r = run('spec-fanout.mjs', ['0001', '--plan'], dir);
120
+ assert.equal(r.code, 1);
121
+ assert.match(r.stderr, /fans out to nothing/);
122
+ assert.doesNotMatch(r.stdout, /would open/);
123
+ rmSync(dir, { recursive: true, force: true });
124
+ });
125
+
126
+ test('with an index present it plans normally', () => {
127
+ const dir = repo({ '0001-x.md': spec({}) });
128
+ writeFileSync(join(dir, 'docs', 'ESTATE.md'), estate);
129
+ const r = run('spec-fanout.mjs', ['0001', 'payment.captured', '--plan'], dir);
130
+ assert.equal(r.code, 0);
131
+ assert.match(r.stdout, /svc-b/);
132
+ assert.match(r.stdout, /consumes payment\.captured/);
133
+ rmSync(dir, { recursive: true, force: true });
134
+ });
135
+
136
+ test('every target shares one branch name', () => {
137
+ const dir = repo({ '0001-x.md': spec({}) });
138
+ writeFileSync(join(dir, 'docs', 'ESTATE.md'), estate);
139
+ const out = run('spec-fanout.mjs', ['0001', 'payment.captured', '--plan'], dir).stdout;
140
+ const branches = [...out.matchAll(/feat\/0001-[a-z-]+/g)].map((m) => m[0]);
141
+ assert.ok(branches.length > 0);
142
+ assert.equal(new Set(branches).size, 1);
143
+ rmSync(dir, { recursive: true, force: true });
144
+ });
145
+ });
146
+
147
+ describe('nfr-compile', () => {
148
+ const NFRS = `| NFR | Kind | Statement | Metric | Op | Value | Unit | Applies to |
149
+ |-----|------|-----------|--------|----|-------|------|------------|
150
+ | NFR-01 | performance | responsive | p95 | < | 300 | ms | svc-a |
151
+ | NFR-02 | security | no findings | | | | | * |`;
152
+
153
+ test('an NFR with no measurable threshold makes the run fail', () => {
154
+ const dir = repo({});
155
+ writeFileSync(join(dir, 'docs', 'NFRS.md'), NFRS);
156
+ const r = run('nfr-compile.mjs', [], dir);
157
+ assert.equal(r.code, 1);
158
+ assert.match(r.stdout, /NFR-02[\s\S]*refused/);
159
+ rmSync(dir, { recursive: true, force: true });
160
+ });
161
+
162
+ test('dry run writes nothing', () => {
163
+ const dir = repo({});
164
+ writeFileSync(join(dir, 'docs', 'NFRS.md'),
165
+ NFRS.split('\n').filter((l) => !l.includes('NFR-02')).join('\n'));
166
+ run('nfr-compile.mjs', [], dir);
167
+ assert.throws(() => execFileSync('cat', [join(dir, 'docs', 'CONSTRAINTS.md')], { stdio: 'ignore' }));
168
+ rmSync(dir, { recursive: true, force: true });
169
+ });
170
+
171
+ test('--apply writes the constraint and reports what changed', () => {
172
+ const dir = repo({});
173
+ writeFileSync(join(dir, 'docs', 'NFRS.md'),
174
+ NFRS.split('\n').filter((l) => !l.includes('NFR-02')).join('\n'));
175
+ const r = run('nfr-compile.mjs', ['--apply'], dir);
176
+ assert.equal(r.code, 0);
177
+ assert.match(r.stdout, /knowledge layer updated/);
178
+ // second run is a no-op and must say so rather than claiming a write
179
+ assert.match(run('nfr-compile.mjs', ['--apply'], dir).stdout, /already current/);
180
+ rmSync(dir, { recursive: true, force: true });
181
+ });
182
+ });
@@ -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);