@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,69 @@
1
+ // Tests for the shared spec parser. Run with: node --test ml-specs/scripts/lib/
2
+ import { test, describe } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ import { parseCriteria, splitCell, resolveStatus, LIFECYCLE } from './specs.mjs';
6
+
7
+ describe('acceptance criteria', () => {
8
+ test('the template style parses', () => {
9
+ const acs = parseCriteria('- [ ] **AC1** — Given a key, when replayed, then the original returns.');
10
+ assert.equal(acs.length, 1);
11
+ assert.deepEqual(acs[0], { id: 'AC-1', ordinal: 1, text: 'Given a key, when replayed, then the original returns.', checked: false });
12
+ });
13
+
14
+ test('the hand-written style parses too', () => {
15
+ const acs = parseCriteria('- [x] AC-2: plain style, colon separator');
16
+ assert.equal(acs[0].id, 'AC-2');
17
+ assert.equal(acs[0].checked, true);
18
+ });
19
+
20
+ test('criteria come back in ordinal order regardless of file order', () => {
21
+ const acs = parseCriteria('- [ ] **AC3** — third\n- [ ] **AC1** — first\n- [ ] **AC2** — second');
22
+ assert.deepEqual(acs.map((a) => a.ordinal), [1, 2, 3]);
23
+ });
24
+
25
+ test('a plain checklist that is not a criterion is ignored', () => {
26
+ assert.equal(parseCriteria('- [ ] buy milk\n- [x] deploy').length, 0);
27
+ });
28
+ });
29
+
30
+ describe('header cells', () => {
31
+ test('comma and slash separated values split', () => {
32
+ assert.deepEqual(splitCell('api-neelias, neelias-pos'), ['api-neelias', 'neelias-pos']);
33
+ assert.deepEqual(splitCell('NFR-03 / NFR-07'), ['NFR-03', 'NFR-07']);
34
+ });
35
+
36
+ test('unfilled template placeholders read as absent', () => {
37
+ // Treated as a real repo, `<repo or service name>` would fan a pull request
38
+ // out to a repository that does not exist.
39
+ assert.deepEqual(splitCell('<repo or service name>'), []);
40
+ assert.deepEqual(splitCell('_TBD_'), []);
41
+ assert.deepEqual(splitCell('—'), []);
42
+ assert.deepEqual(splitCell(null), []);
43
+ });
44
+
45
+ test('backticks and bold are stripped', () => {
46
+ assert.deepEqual(splitCell('`api-neelias`, **neelias-pos**'), ['api-neelias', 'neelias-pos']);
47
+ });
48
+ });
49
+
50
+ describe('status resolution is unchanged by the new fields', () => {
51
+ test('a canonical status resolves cleanly', () => {
52
+ const r = resolveStatus('Approved');
53
+ assert.equal(r.status, 'Approved');
54
+ assert.equal(r.canonical, true);
55
+ });
56
+
57
+ test('prose leading with a stage word is a claim', () => {
58
+ assert.equal(resolveStatus('Implemented (2026-07-07) — all 16 ACs met').status, 'Implemented');
59
+ });
60
+
61
+ test('a stage word buried mid-sentence is discussion, not a claim', () => {
62
+ const r = resolveStatus('Phase 1 done, not yet verified by QA');
63
+ assert.equal(r.leading, false);
64
+ });
65
+
66
+ test('the lifecycle is the five stages the toolkit ships', () => {
67
+ assert.deepEqual(LIFECYCLE, ['Draft', 'Approved', 'Implemented', 'Verified', 'Archived']);
68
+ });
69
+ });
@@ -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,110 @@
1
+ // Tests for the ID spine. Run with: node --test ml-specs/scripts/lib/
2
+ // Pure Node's built-in runner — no dependencies, consistent with everything else here.
3
+ import { test, describe } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+
6
+ import {
7
+ slugify, specId, branchName, prTitle, testCaseId,
8
+ keyFrom, buildChain, verifyChain, renderChain,
9
+ } from './trace.mjs';
10
+
11
+ const spec = {
12
+ id: '0031',
13
+ title: 'Idempotent refund submission',
14
+ file: 'specs/0031-idempotent-refund-submission.md',
15
+ ticket: 'PAY-2204',
16
+ status: 'Approved',
17
+ branch: 'feat/0031-idempotent-refund-submission',
18
+ criteria: [{ id: 'AC-1', ordinal: 1 }, { id: 'AC-2', ordinal: 2 }],
19
+ };
20
+
21
+ describe('derivation and recovery are symmetric', () => {
22
+ test('every id a function produces, a parser recovers', () => {
23
+ assert.equal(branchName('0031', 'Idempotent refund submission'), 'feat/0031-idempotent-refund-submission');
24
+ assert.equal(prTitle('0031', 'X'), '[SPEC-0031] X');
25
+ assert.equal(testCaseId('0031', 3), 'TC-0031.3');
26
+
27
+ assert.equal(keyFrom('branch', branchName('0031', 'X')), '0031');
28
+ assert.equal(keyFrom('prTitle', prTitle('0031', 'X')), '0031');
29
+ assert.equal(keyFrom('testCase', testCaseId('0031', 3)), '0031');
30
+ assert.equal(keyFrom('specId', specId('0031')), '0031');
31
+ });
32
+
33
+ test('a suffixed spec id is a different spec, not a clash', () => {
34
+ // Matches lib/specs.mjs: 0165 and 0165b coexist.
35
+ assert.equal(keyFrom('branch', 'feat/0165b-follow-up'), '0165b');
36
+ assert.notEqual(keyFrom('branch', 'feat/0165b-x'), keyFrom('branch', 'feat/0165-x'));
37
+ });
38
+
39
+ test('three-digit ids still read, so existing repos are not orphaned', () => {
40
+ assert.equal(keyFrom('branch', 'feat/023-legacy'), '023');
41
+ assert.equal(keyFrom('specFile', 'specs/023-legacy.md'), '023');
42
+ assert.equal(keyFrom('specFile', 'specs/archive/0023-old.md'), '0023');
43
+ });
44
+
45
+ test('an artifact carrying no key returns null rather than guessing', () => {
46
+ assert.equal(keyFrom('branch', 'feature/payment-thing'), null);
47
+ assert.equal(keyFrom('prTitle', 'fix the thing'), null);
48
+ assert.equal(keyFrom('testCase', 'TC-abc.1'), null);
49
+ });
50
+
51
+ test('slugify is bounded and never trails a hyphen', () => {
52
+ assert.equal(slugify('Payment hold — on partial capture!'), 'payment-hold-on-partial-capture');
53
+ const long = slugify('x'.repeat(80));
54
+ assert.ok(long.length <= 48);
55
+ assert.ok(!long.endsWith('-'));
56
+ });
57
+
58
+ test('an unknown artifact kind is a programming error, not a silent null', () => {
59
+ assert.throws(() => keyFrom('nonsense', 'x'), /unknown artifact kind/);
60
+ });
61
+ });
62
+
63
+ describe('chain verification', () => {
64
+ test('a well-formed spec produces an intact chain', () => {
65
+ const report = verifyChain(buildChain(spec));
66
+ assert.equal(report.ok, true);
67
+ assert.equal(report.broken.length, 0);
68
+ });
69
+
70
+ test('the hand-typed ticket is reported as unverifiable, not as passing', () => {
71
+ // Counting it as passing would make the whole report a lie.
72
+ const report = verifyChain(buildChain(spec));
73
+ assert.deepEqual(report.unverifiable.map((l) => l.kind), ['ticket']);
74
+ });
75
+
76
+ test('a branch carrying the wrong key is caught with the key it does carry', () => {
77
+ const report = verifyChain(buildChain({ ...spec, branch: 'feat/0019-wrong' }));
78
+ assert.equal(report.ok, false);
79
+ assert.equal(report.broken[0].reason, 'wrong-key');
80
+ assert.equal(report.broken[0].found, '0019');
81
+ });
82
+
83
+ test('a branch with no key at all is caught as missing', () => {
84
+ const report = verifyChain(buildChain({ ...spec, branch: 'hotfix-urgent' }));
85
+ assert.equal(report.broken[0].reason, 'missing-key');
86
+ assert.equal(report.broken[0].found, null);
87
+ });
88
+
89
+ test('a Draft spec has no test cases in its chain yet', () => {
90
+ const chain = buildChain({ ...spec, status: 'Draft' });
91
+ assert.ok(!chain.links.some((l) => l.kind === 'testCase'));
92
+ });
93
+
94
+ test('an approved spec pairs every criterion with a test case', () => {
95
+ const cases = buildChain(spec).links.filter((l) => l.kind === 'testCase');
96
+ assert.deepEqual(cases.map((l) => l.ref), ['TC-0031.1', 'TC-0031.2']);
97
+ assert.deepEqual(cases.map((l) => l.from), ['AC-1', 'AC-2']);
98
+ });
99
+
100
+ test('a spec with no recorded branch falls back to the derived one', () => {
101
+ const chain = buildChain({ ...spec, branch: null });
102
+ const branch = chain.links.find((l) => l.kind === 'branch');
103
+ assert.equal(branch.origin, 'derived');
104
+ assert.equal(verifyChain(chain).ok, true);
105
+ });
106
+
107
+ test('renderChain marks what it could not check', () => {
108
+ assert.match(renderChain(buildChain(spec)), /PAY-2204 \(unverified\)/);
109
+ });
110
+ });
@@ -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
+ }