@mlmcps/ml-specs-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +347 -0
  3. package/agents/coder.md +76 -0
  4. package/agents/developer.md +78 -0
  5. package/agents/pr-author.md +36 -0
  6. package/agents/reviewer.md +65 -0
  7. package/agents/scanner.md +66 -0
  8. package/agents/spec-author.md +91 -0
  9. package/agents/spec-reviewer.md +59 -0
  10. package/commands/code.md +29 -0
  11. package/commands/fix.md +67 -0
  12. package/commands/nfr.md +114 -0
  13. package/commands/pr.md +32 -0
  14. package/commands/repo-adopt.md +86 -0
  15. package/commands/repo-doctor.md +57 -0
  16. package/commands/repo-estate.md +79 -0
  17. package/commands/repo-impact.md +77 -0
  18. package/commands/repo-init.md +155 -0
  19. package/commands/repo-refresh.md +58 -0
  20. package/commands/repo-rollout.md +84 -0
  21. package/commands/repo-status.md +59 -0
  22. package/commands/spec-advance.md +81 -0
  23. package/commands/spec-build.md +66 -0
  24. package/commands/spec-fanout.md +64 -0
  25. package/commands/spec-review.md +24 -0
  26. package/commands/spec-verify.md +55 -0
  27. package/commands/spec.md +73 -0
  28. package/mcp/README.md +173 -0
  29. package/mcp/ml-specs-server.mjs +708 -0
  30. package/package.json +44 -0
  31. package/scripts/branch-policy.mjs +71 -0
  32. package/scripts/fix-specs.mjs +289 -0
  33. package/scripts/lib/cli.mjs +43 -0
  34. package/scripts/lib/estate.mjs +108 -0
  35. package/scripts/lib/http.mjs +73 -0
  36. package/scripts/lib/knowledge.mjs +91 -0
  37. package/scripts/lib/nfr.mjs +119 -0
  38. package/scripts/lib/policy.mjs +114 -0
  39. package/scripts/lib/scm.mjs +189 -0
  40. package/scripts/lib/specs.mjs +192 -0
  41. package/scripts/lib/trace.mjs +90 -0
  42. package/scripts/lib/tracker.mjs +257 -0
  43. package/scripts/nfr-compile.mjs +120 -0
  44. package/scripts/spec-brief.mjs +127 -0
  45. package/scripts/spec-dashboard.mjs +331 -0
  46. package/scripts/spec-fanout.mjs +120 -0
  47. package/scripts/spec-gate.mjs +329 -0
  48. package/scripts/spec-trace.mjs +91 -0
  49. package/scripts/survey-estate.mjs +230 -0
  50. package/scripts/tracker-sync.mjs +91 -0
  51. package/templates/ci/knowledge-check.mjs +176 -0
@@ -0,0 +1,192 @@
1
+ // Shared spec parsing. Imported by the MCP server (mcp/ml-specs-server.mjs) and the dashboard
2
+ // generator (scripts/spec-dashboard.mjs) so there is exactly one implementation of "what is
3
+ // a spec and what does it say" — two copies of this would have drifted within a release.
4
+ //
5
+ // Pure Node, no dependencies. Read-only.
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export const LIFECYCLE = ['Draft', 'Approved', 'Implemented', 'Verified', 'Archived'];
11
+
12
+ // `- [ ] **AC1** — text`, and the plainer `- [ ] AC-1: text`. Both appear in the
13
+ // wild: the template writes the first, people hand-write the second.
14
+ const AC_LINE = /^\s*-\s*\[([ xX])\]\s*(?:\*\*)?AC-?(\d+)(?:\*\*)?\s*[—–:-]\s*(.+?)\s*$/gm;
15
+
16
+ /** Acceptance criteria as structured rows, so a script can pair each with a test case. */
17
+ export function parseCriteria(text) {
18
+ const out = [];
19
+ for (const m of text.matchAll(AC_LINE)) {
20
+ out.push({
21
+ id: `AC-${Number(m[2])}`,
22
+ ordinal: Number(m[2]),
23
+ text: m[3],
24
+ checked: m[1].toLowerCase() === 'x',
25
+ });
26
+ }
27
+ return out.sort((a, b) => a.ordinal - b.ordinal);
28
+ }
29
+
30
+ // Template placeholders must read as absent. `<repo or service name>` treated as
31
+ // a real repo would fan a pull request out to a repository that does not exist.
32
+ const PLACEHOLDER = /^(<.*>|_TBD_|—|-|n\/?a|none|tbd)$/i;
33
+
34
+ /** A comma- or slash-separated header cell as a clean list. */
35
+ export function splitCell(value) {
36
+ if (!value) return [];
37
+ return value
38
+ .split(/[,/]/)
39
+ .map((v) => v.replace(/[`*]/g, '').trim())
40
+ .filter((v) => v && !PLACEHOLDER.test(v));
41
+ }
42
+
43
+ // A spec id is digits plus an OPTIONAL letter: 0165 and 0165b are different specs, not a clash.
44
+ const SPEC_FILE = /^(\d{4}[a-z]?)-(.+)\.md$/;
45
+
46
+ /**
47
+ * Resolve a Status cell that holds prose down to one lifecycle word.
48
+ *
49
+ * Two rules, both learned the hard way:
50
+ * 1. Match by position in the TEXT, not position in the enum. Picking the first enum member
51
+ * that appears anywhere means a status reading "Phase 1 ✓ … verified …" resolves to a stage
52
+ * its author never claimed — and this feeds a script that rewrites files.
53
+ * 2. Trust the word only when it LEADS the cell. "Implemented (2026-07-07) — …long prose…" is
54
+ * an author stating a status. A stage word buried mid-sentence is discussion, not a claim,
55
+ * so when several appear and none leads, say ambiguous and let a human decide.
56
+ */
57
+ export function resolveStatus(plain) {
58
+ const text = (plain ?? '').trim();
59
+ if (LIFECYCLE.includes(text)) {
60
+ return { status: text, canonical: true, leading: true, ambiguous: false, candidates: [text] };
61
+ }
62
+
63
+ const found = LIFECYCLE
64
+ .map((w) => ({ word: w, at: text.search(new RegExp(`\\b${w}\\b`, 'i')) }))
65
+ .filter((m) => m.at !== -1)
66
+ .sort((a, b) => a.at - b.at);
67
+
68
+ if (found.length === 0) {
69
+ return { status: null, canonical: false, leading: false, ambiguous: false, candidates: [] };
70
+ }
71
+
72
+ // "Leading" allows for markdown/whitespace the caller already stripped, plus a stray bullet.
73
+ const leading = found[0].at <= 2;
74
+ return {
75
+ status: found[0].word,
76
+ canonical: false,
77
+ leading,
78
+ ambiguous: found.length > 1 && !leading,
79
+ candidates: found.map((m) => m.word),
80
+ };
81
+ }
82
+
83
+ /** Every spec under specs/ and specs/archive/, sorted by id. */
84
+ export function listSpecs(root = process.cwd()) {
85
+ const out = [];
86
+ for (const dir of ['specs', 'specs/archive']) {
87
+ const abs = join(root, dir);
88
+ if (!existsSync(abs)) continue;
89
+ for (const f of readdirSync(abs)) {
90
+ const m = f.match(SPEC_FILE);
91
+ if (!m) continue;
92
+ const text = readFileSync(join(abs, f), 'utf8');
93
+
94
+ // Header-table cells: take to the LAST pipe on the line — the prose people write in
95
+ // Status often contains pipes, and stopping at the first would silently truncate it.
96
+ const field = (name) => {
97
+ const line = text.split('\n').find((l) =>
98
+ new RegExp(`^\\|\\s*\\*\\*${name}\\*\\*\\s*\\|`, 'i').test(l));
99
+ if (!line) return null;
100
+ const start = line.indexOf('|', line.indexOf(`**${name}**`)) + 1;
101
+ const end = line.lastIndexOf('|');
102
+ if (start <= 0 || end <= start) return null;
103
+ const v = line.slice(start, end).trim();
104
+ return v === '' || v === '—' ? null : v;
105
+ };
106
+
107
+ const rawStatus = field('Status');
108
+ const plain = (rawStatus ?? '').replace(/\*\*/g, '').replace(/`/g, '').trim();
109
+ const resolved = resolveStatus(plain);
110
+
111
+ const boxes = [...text.matchAll(/^\s*-\s*\[([ xX])\]/gm)];
112
+ const criteria = parseCriteria(text);
113
+
114
+ out.push({
115
+ file: `${dir}/${f}`,
116
+ id: m[1],
117
+ slug: m[2],
118
+ title: (text.match(/^#\s+(?:Spec:\s*)?(.+)$/m) ?? [, m[2]])[1].trim(),
119
+ status: resolved.status,
120
+ rawStatus,
121
+ statusIsCanonical: resolved.canonical,
122
+ statusLeading: resolved.leading, // the word opens the cell — safe to normalize
123
+ statusAmbiguous: resolved.ambiguous, // several stage words present, none clearly leading
124
+ statusCandidates: resolved.candidates,
125
+ branch: field('Branch'),
126
+ ticket: field('Ticket'),
127
+ acTotal: boxes.length,
128
+ acChecked: boxes.filter((b) => b[1].toLowerCase() === 'x').length,
129
+ archived: dir.endsWith('archive'),
130
+ // Added for the traceability and brief scripts. Existing fields are
131
+ // untouched — the MCP server and the dashboard both read this shape.
132
+ criteria,
133
+ repos: splitCell(field('Project / service') ?? field('Project') ?? field('Repos')),
134
+ nfrs: splitCell(field('NFRs') ?? field('NFR')),
135
+ approvedBy: field('Approved by') ?? field('Approver'),
136
+ author: field('Author'),
137
+ });
138
+ }
139
+ }
140
+ return out.sort((a, b) => a.id.localeCompare(b.id));
141
+ }
142
+
143
+ /**
144
+ * Everything a human should look at, derived once so the dashboard, /repo-status and
145
+ * /repo-doctor all flag the same things.
146
+ */
147
+ export function analyze(specs) {
148
+ const byStatus = Object.fromEntries(LIFECYCLE.map((s) => [s, 0]));
149
+ let unknownStatus = 0;
150
+ for (const s of specs) {
151
+ if (s.status) byStatus[s.status]++;
152
+ else unknownStatus++;
153
+ }
154
+
155
+ const byId = new Map();
156
+ for (const s of specs) {
157
+ if (!byId.has(s.id)) byId.set(s.id, []);
158
+ byId.get(s.id).push(s.file);
159
+ }
160
+
161
+ const acTotal = specs.reduce((n, s) => n + s.acTotal, 0);
162
+ const acChecked = specs.reduce((n, s) => n + s.acChecked, 0);
163
+
164
+ return {
165
+ total: specs.length,
166
+ active: specs.filter((s) => !s.archived).length,
167
+ byStatus,
168
+ unknownStatus,
169
+ acTotal,
170
+ acChecked,
171
+ duplicateIds: [...byId.entries()].filter(([, f]) => f.length > 1).map(([id, files]) => ({ id, files })),
172
+ nonCanonical: specs.filter((s) => s.rawStatus && !s.statusIsCanonical),
173
+ // Claims the repo can't back up, in severity order.
174
+ attention: [
175
+ ...specs
176
+ .filter((s) => s.status === 'Implemented' && s.acTotal > 0 && s.acChecked < s.acTotal)
177
+ .map((s) => ({ level: 'critical', spec: s, why: `Implemented but ${s.acTotal - s.acChecked} of ${s.acTotal} criteria unchecked` })),
178
+ ...specs
179
+ .filter((s) => s.status === 'Verified' && !s.archived && s.acTotal > 0 && s.acChecked < s.acTotal)
180
+ .map((s) => ({ level: 'critical', spec: s, why: 'Verified with unchecked criteria — the status claims evidence that is not there' })),
181
+ ...specs
182
+ .filter((s) => !s.status && s.rawStatus)
183
+ .map((s) => ({ level: 'serious', spec: s, why: `Status has no lifecycle word: "${s.rawStatus.slice(0, 60)}"` })),
184
+ ...specs
185
+ .filter((s) => !s.archived && (s.status === 'Draft' || s.status === 'Approved') && !s.branch)
186
+ .map((s) => ({ level: 'warning', spec: s, why: `${s.status} with no branch recorded — not started` })),
187
+ ...specs
188
+ .filter((s) => s.acTotal === 0 && !s.archived)
189
+ .map((s) => ({ level: 'warning', spec: s, why: 'No acceptance criteria — nothing to verify against' })),
190
+ ],
191
+ };
192
+ }
@@ -0,0 +1,90 @@
1
+ // The ID spine: one key, derived into every downstream artifact name.
2
+ //
3
+ // Imported by scripts/spec-trace.mjs and scripts/spec-brief.mjs so there is one
4
+ // implementation of "what is this change called everywhere".
5
+ //
6
+ // Pure Node, no dependencies. No I/O, no network — every function here is a
7
+ // string transform, which is what makes the chain checkable offline in CI.
8
+ //
9
+ // Why it matters: without a key carried end to end, "which requirement is
10
+ // covered by which test, and did it ship?" has no answer that survives an audit.
11
+ // Every id below is PRODUCED by a function and RECOVERED by its matching parser,
12
+ // so a link that lost the key is a parse failure rather than a silent divergence.
13
+
14
+ /** `Payment hold on partial capture` -> `payment-hold-on-partial-capture` */
15
+ export function slugify(title) {
16
+ return String(title)
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, '-')
19
+ .replace(/^-+|-+$/g, '')
20
+ .slice(0, 48)
21
+ .replace(/-+$/, '');
22
+ }
23
+
24
+ export const specId = (id) => `SPEC-${id}`;
25
+ export const branchName = (id, title) => `feat/${id}-${slugify(title)}`;
26
+ export const prTitle = (id, title) => `[${specId(id)}] ${title}`;
27
+ export const testCaseId = (id, ordinal) => `TC-${id}.${ordinal}`;
28
+
29
+ // A spec id is digits plus an OPTIONAL letter, matching lib/specs.mjs:
30
+ // 0165 and 0165b are different specs, not a clash.
31
+ const KEY = '(\\d{3,4}[a-z]?)';
32
+
33
+ const PARSERS = {
34
+ specId: new RegExp(`^SPEC-${KEY}$`),
35
+ branch: new RegExp(`^(?:feat|fix|chore)/${KEY}-`),
36
+ prTitle: new RegExp(`^\\[SPEC-${KEY}\\]`),
37
+ testCase: new RegExp(`^TC-${KEY}\\.\\d+$`),
38
+ specFile: new RegExp(`^(?:specs/(?:archive/)?)?${KEY}-.+\\.md$`),
39
+ };
40
+
41
+ /** Recover the key from a downstream artifact name, or null if it carries none. */
42
+ export function keyFrom(kind, value) {
43
+ const re = PARSERS[kind];
44
+ if (!re) throw new Error(`unknown artifact kind: ${kind}`);
45
+ const m = re.exec(String(value ?? ''));
46
+ return m ? m[1] : null;
47
+ }
48
+
49
+ export const ARTIFACT_KINDS = Object.keys(PARSERS);
50
+
51
+ /**
52
+ * Build the chain for a spec. Links are `derived` (a function above produced the
53
+ * name, so the key cannot be missing) or `declared` (a human or an external
54
+ * system supplied it). Only declared links can break — which is exactly why they
55
+ * are reported separately rather than assumed sound.
56
+ */
57
+ export function buildChain(spec, { testCases = true } = {}) {
58
+ const links = [
59
+ { kind: 'ticket', ref: spec.ticket ?? '(none)', origin: 'declared' },
60
+ { kind: 'spec', ref: specId(spec.id), origin: 'derived', parseAs: 'specId' },
61
+ { kind: 'specFile', ref: spec.file, origin: 'derived', parseAs: 'specFile' },
62
+ { kind: 'branch', ref: spec.branch ?? branchName(spec.id, spec.title), origin: spec.branch ? 'declared' : 'derived', parseAs: 'branch' },
63
+ { kind: 'pullRequest', ref: prTitle(spec.id, spec.title), origin: 'derived', parseAs: 'prTitle' },
64
+ ];
65
+
66
+ if (testCases && spec.status !== 'Draft') {
67
+ for (const ac of spec.criteria ?? []) {
68
+ links.push({ kind: 'testCase', ref: testCaseId(spec.id, ac.ordinal), origin: 'derived', parseAs: 'testCase', from: ac.id });
69
+ }
70
+ }
71
+ return { key: spec.id, links };
72
+ }
73
+
74
+ /** Does every parseable link carry the chain's key? */
75
+ export function verifyChain(chain) {
76
+ const broken = [];
77
+ const unverifiable = [];
78
+
79
+ for (const link of chain.links) {
80
+ if (!link.parseAs) { unverifiable.push(link); continue; }
81
+ const found = keyFrom(link.parseAs, link.ref);
82
+ if (found === null) broken.push({ link, reason: 'missing-key', found: null });
83
+ else if (found !== chain.key) broken.push({ link, reason: 'wrong-key', found });
84
+ }
85
+
86
+ return { ok: broken.length === 0, broken, unverifiable };
87
+ }
88
+
89
+ export const renderChain = (chain) =>
90
+ chain.links.map((l) => (l.parseAs ? l.ref : `${l.ref} (unverified)`)).join(' -> ');
@@ -0,0 +1,257 @@
1
+ // Azure DevOps and Jira, behind one seam.
2
+ //
3
+ // Two implementations ship together deliberately. An abstraction with a single
4
+ // implementation is a guess about what varies, and it is usually wrong; the
5
+ // second one is what proves the seam is in the right place. Jira's differences
6
+ // are instructive — status moves through a TRANSITION rather than a field write,
7
+ // and there is no native test-case type — and both would have leaked through a
8
+ // single-adapter design.
9
+ //
10
+ // Pure Node, no dependencies. Every call goes through an injected transport.
11
+
12
+ import { basicAuth, request } from './http.mjs';
13
+
14
+ // Which system owns which field. The spec in the repo owns the contract; the
15
+ // tracker owns the schedule. Two stores for one truth diverge silently unless
16
+ // exactly one of them may write each field.
17
+ export const FIELD_OWNER = {
18
+ title: 'spec', criteria: 'spec', contract: 'spec', nfrs: 'spec', repos: 'spec',
19
+ status: 'board', assignee: 'board', sprint: 'board', priority: 'board',
20
+ };
21
+
22
+ export const specKeyFromText = (text) => (/SPEC-(\d{3,4}[a-z]?)/.exec(text ?? '') ?? [])[1] ?? null;
23
+
24
+ // --- Azure DevOps -----------------------------------------------------------
25
+
26
+ const ADO_FIELD = {
27
+ status: 'System.State', assignee: 'System.AssignedTo',
28
+ sprint: 'System.IterationPath', priority: 'Microsoft.VSTS.Common.Priority',
29
+ };
30
+
31
+ export function adoTracker({ org, project, pat, apiVersion = '7.1', transport = request }) {
32
+ const base = `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_apis`;
33
+ // ADO's documented scheme: empty username, PAT as the password.
34
+ const headers = { Authorization: basicAuth('', pat) };
35
+ const patchType = 'application/json-patch+json';
36
+
37
+ const toItem = (raw) => ({
38
+ id: String(raw.id),
39
+ title: raw.fields?.['System.Title'] ?? '',
40
+ status: raw.fields?.['System.State'] ?? '',
41
+ assignee: raw.fields?.['System.AssignedTo']?.uniqueName ?? null,
42
+ sprint: raw.fields?.['System.IterationPath'] ?? null,
43
+ url: `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_workitems/edit/${raw.id}`,
44
+ specKey: specKeyFromText(`${raw.fields?.['System.Title'] ?? ''} ${raw.fields?.['System.Description'] ?? ''}`),
45
+ });
46
+
47
+ return {
48
+ tool: 'ado',
49
+
50
+ async getWorkItem(id) {
51
+ try { return toItem(await transport(`${base}/wit/workitems/${id}?api-version=${apiVersion}`, { headers })); }
52
+ catch { return null; }
53
+ },
54
+
55
+ async findBySpec(key) {
56
+ const res = await transport(`${base}/wit/wiql?api-version=${apiVersion}`, {
57
+ method: 'POST', headers,
58
+ body: { query: `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ` +
59
+ `AND [System.Title] CONTAINS 'SPEC-${key}'` },
60
+ });
61
+ const items = await Promise.all((res.workItems ?? []).map((w) => this.getWorkItem(String(w.id))));
62
+ return items.filter(Boolean);
63
+ },
64
+
65
+ async createWorkItem({ title, type, description, specKey, parentId }) {
66
+ const patch = [
67
+ { op: 'add', path: '/fields/System.Title', value: `[SPEC-${specKey}] ${title}` },
68
+ { op: 'add', path: '/fields/System.Description', value: description ?? '' },
69
+ ];
70
+ if (parentId) patch.push({ op: 'add', path: '/relations/-',
71
+ value: { rel: 'System.LinkTypes.Hierarchy-Reverse', url: `${base}/wit/workItems/${parentId}` } });
72
+ return toItem(await transport(
73
+ `${base}/wit/workitems/$${encodeURIComponent(type)}?api-version=${apiVersion}`,
74
+ { method: 'POST', headers, body: patch, contentType: patchType }));
75
+ },
76
+
77
+ async updateFields(id, fields) {
78
+ const patch = Object.entries(fields)
79
+ .filter(([k]) => ADO_FIELD[k])
80
+ .map(([k, v]) => ({ op: 'add', path: `/fields/${ADO_FIELD[k]}`, value: v }));
81
+ return toItem(await transport(`${base}/wit/workitems/${id}?api-version=${apiVersion}`,
82
+ { method: 'PATCH', headers, body: patch, contentType: patchType }));
83
+ },
84
+
85
+ async createTestCases(workItemId, cases) {
86
+ const created = [];
87
+ for (const tc of cases) {
88
+ const steps = tc.steps.map((s) =>
89
+ `<step><parameterizedString>${escapeXml(s)}</parameterizedString></step>`).join('');
90
+ const raw = await transport(`${base}/wit/workitems/$Test%20Case?api-version=${apiVersion}`, {
91
+ method: 'POST', headers, contentType: patchType,
92
+ body: [
93
+ { op: 'add', path: '/fields/System.Title', value: `${tc.id} ${tc.title}` },
94
+ { op: 'add', path: '/fields/Microsoft.VSTS.TCM.Steps', value: steps },
95
+ { op: 'add', path: '/relations/-',
96
+ value: { rel: 'Microsoft.VSTS.Common.TestedBy-Reverse', url: `${base}/wit/workItems/${workItemId}` } },
97
+ ],
98
+ });
99
+ created.push(String(raw.id));
100
+ }
101
+ return created;
102
+ },
103
+ };
104
+ }
105
+
106
+ const escapeXml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
107
+
108
+ // --- Jira --------------------------------------------------------------------
109
+
110
+ const JIRA_TYPE = { Epic: 'Epic', Feature: 'Story', 'User Story': 'Story', Task: 'Task' };
111
+
112
+ /** Jira Cloud wants Atlassian Document Format, not a string. */
113
+ const adf = (text) => ({ type: 'doc', version: 1,
114
+ content: String(text ?? '').split('\n\n').map((p) => ({ type: 'paragraph', content: [{ type: 'text', text: p || ' ' }] })) });
115
+
116
+ export function jiraTracker({ baseUrl, email, apiToken, projectKey, testCaseIssueType = 'Test', transport = request }) {
117
+ const headers = { Authorization: basicAuth(email, apiToken) };
118
+ const call = (path, opts = {}) => transport(`${baseUrl}${path}`, { headers, ...opts });
119
+
120
+ const toItem = (raw) => ({
121
+ id: raw.key,
122
+ title: raw.fields?.summary ?? '',
123
+ status: raw.fields?.status?.name ?? '',
124
+ assignee: raw.fields?.assignee?.emailAddress ?? null,
125
+ sprint: raw.fields?.sprint?.name ?? null,
126
+ url: `${baseUrl}/browse/${raw.key}`,
127
+ specKey: specKeyFromText(raw.fields?.summary),
128
+ });
129
+
130
+ async function transition(id, toStatus) {
131
+ const { transitions = [] } = await call(`/rest/api/3/issue/${id}/transitions`);
132
+ const match = transitions.find((t) =>
133
+ t.to?.name?.toLowerCase() === toStatus.toLowerCase() || t.name?.toLowerCase() === toStatus.toLowerCase());
134
+ if (!match) {
135
+ throw new Error(`Jira ${id} has no transition to "${toStatus}" ` +
136
+ `(available: ${transitions.map((t) => t.to?.name).join(', ') || 'none'})`);
137
+ }
138
+ await call(`/rest/api/3/issue/${id}/transitions`, { method: 'POST', body: { transition: { id: match.id } } });
139
+ }
140
+
141
+ return {
142
+ tool: 'jira',
143
+
144
+ async getWorkItem(id) {
145
+ try { return toItem(await call(`/rest/api/3/issue/${id}`)); } catch { return null; }
146
+ },
147
+
148
+ async findBySpec(key) {
149
+ const jql = `project = "${projectKey}" AND summary ~ "SPEC-${key}"`;
150
+ const res = await call(`/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=50`);
151
+ return (res.issues ?? []).map(toItem);
152
+ },
153
+
154
+ async createWorkItem({ title, type, description, specKey, parentId }) {
155
+ const fields = {
156
+ project: { key: projectKey },
157
+ summary: `[SPEC-${specKey}] ${title}`,
158
+ issuetype: { name: JIRA_TYPE[type] ?? 'Task' },
159
+ description: adf(description),
160
+ ...(parentId ? { parent: { key: parentId } } : {}),
161
+ };
162
+ const created = await call('/rest/api/3/issue', { method: 'POST', body: { fields } });
163
+ const item = await this.getWorkItem(created.key);
164
+ if (!item) throw new Error(`Jira created ${created.key} but it could not be read back`);
165
+ return item;
166
+ },
167
+
168
+ async updateFields(id, fields) {
169
+ // The instructive difference from ADO: status is a transition, not a write.
170
+ if (fields.status) await transition(id, fields.status);
171
+
172
+ const direct = {};
173
+ if (fields.assignee) direct.assignee = { emailAddress: fields.assignee };
174
+ if (fields.priority) direct.priority = { name: fields.priority };
175
+ if (Object.keys(direct).length) await call(`/rest/api/3/issue/${id}`, { method: 'PUT', body: { fields: direct } });
176
+
177
+ const item = await this.getWorkItem(id);
178
+ if (!item) throw new Error(`no Jira issue ${id}`);
179
+ return item;
180
+ },
181
+
182
+ async createTestCases(workItemId, cases) {
183
+ const created = [];
184
+ for (const tc of cases) {
185
+ const res = await call('/rest/api/3/issue', { method: 'POST', body: { fields: {
186
+ project: { key: projectKey },
187
+ summary: `${tc.id} ${tc.title}`,
188
+ issuetype: { name: testCaseIssueType },
189
+ description: adf([`Derived from ${tc.from} of SPEC-${tc.specKey}.`, ...tc.steps].join('\n')),
190
+ } } });
191
+ await call('/rest/api/3/issueLink', { method: 'POST', body: {
192
+ type: { name: 'Relates' }, inwardIssue: { key: res.key }, outwardIssue: { key: workItemId } } });
193
+ created.push(res.key);
194
+ }
195
+ return created;
196
+ },
197
+ };
198
+ }
199
+
200
+ // --- capability split (the ungoverned path) ----------------------------------
201
+
202
+ /**
203
+ * What the intent plane is allowed to hold.
204
+ *
205
+ * A direct path from planning into the tracker that bypasses the spec becomes
206
+ * the path everyone uses, because it is faster and nobody is watching. This
207
+ * returns a FROZEN object carrying only the read methods, so the write methods
208
+ * are absent at runtime and cannot be re-attached — the shortcut is not policed,
209
+ * it cannot be expressed.
210
+ */
211
+ export const readOnly = (tracker) => Object.freeze({
212
+ tool: tracker.tool,
213
+ getWorkItem: (id) => tracker.getWorkItem(id),
214
+ findBySpec: (key) => tracker.findBySpec(key),
215
+ });
216
+
217
+ /**
218
+ * The single governed write path: the spec must be past the approval gate, and
219
+ * only board-owned fields may be written.
220
+ */
221
+ export function governedWriter(tracker) {
222
+ const assertWritable = (field) => {
223
+ const owner = FIELD_OWNER[field];
224
+ if (owner && owner !== 'board') {
225
+ throw new Error(`"${field}" is owned by the spec; the board may read it but not write it`);
226
+ }
227
+ };
228
+ const assertApproved = (spec) => {
229
+ if (!spec.status || spec.status === 'Draft') {
230
+ throw new Error(`spec ${spec.id} is ${spec.status ?? 'unknown'}; nothing reaches ${tracker.tool} before the approval gate`);
231
+ }
232
+ };
233
+
234
+ return {
235
+ tool: tracker.tool,
236
+
237
+ async push(spec, fields) {
238
+ assertApproved(spec);
239
+ if (!spec.ticket) throw new Error(`spec ${spec.id} has no linked work item to write to`);
240
+ for (const f of Object.keys(fields)) assertWritable(f);
241
+ return tracker.updateFields(spec.ticket, fields);
242
+ },
243
+
244
+ async open(spec, type, parentId) {
245
+ assertApproved(spec);
246
+ return tracker.createWorkItem({ title: spec.title, type, description: '', specKey: spec.id, parentId });
247
+ },
248
+
249
+ async pushTestCases(spec, cases) {
250
+ assertApproved(spec);
251
+ if (!spec.ticket) throw new Error(`spec ${spec.id} has no linked work item`);
252
+ const foreign = cases.find((c) => c.specKey !== spec.id);
253
+ if (foreign) throw new Error(`${foreign.id} was not derived from spec ${spec.id}`);
254
+ return tracker.createTestCases(spec.ticket, cases);
255
+ },
256
+ };
257
+ }
@@ -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);