@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,91 @@
1
+ // The knowledge layer: standing constraints on disk, in docs/CONSTRAINTS.md.
2
+ //
3
+ // Pure Node, no dependencies. Writes one file, only when asked.
4
+ //
5
+ // Why a markdown file and not a database: this is read by people during spec
6
+ // review far more often than by this code, and a change to what is in force
7
+ // should diff in a pull request like any other change. A store only a machine
8
+ // can read becomes a store nobody checks — which is worse than none, because it
9
+ // looks like governance while enforcing nothing.
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ const BEGIN = '<!-- sdd:constraints -->';
15
+ const END = '<!-- /sdd:constraints -->';
16
+ const REFRESHED = '<!-- sdd:refreshed ';
17
+ const LINE = /^-\s+`([A-Za-z0-9_.-]+)`\s+\[([^\]]*)\]\s+(.+)$/;
18
+
19
+ const HEADER = `# Standing constraints
20
+
21
+ Constraints in force while authoring any spec. Generated — change the source
22
+ (the NFR table, or the incident that produced a lesson) rather than this file.
23
+
24
+ `;
25
+
26
+ export function read(path) {
27
+ if (!existsSync(path)) return [];
28
+ const body = readFileSync(path, 'utf8');
29
+ if (!body.includes(BEGIN)) return [];
30
+ const block = body.split(BEGIN)[1].split(END)[0];
31
+
32
+ const out = [];
33
+ for (const line of block.split('\n')) {
34
+ const m = LINE.exec(line.trim());
35
+ if (!m) continue;
36
+ const scope = m[2].split(',').map((s) => s.trim()).filter(Boolean);
37
+ out.push({ id: m[1], scope: scope.length ? scope : ['*'], text: m[3] });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Days since the file was last written, or null if it never was. */
43
+ export function staleness(path, today = new Date()) {
44
+ if (!existsSync(path)) return null;
45
+ const m = readFileSync(path, 'utf8').match(new RegExp(`${REFRESHED}(\\d{4}-\\d{2}-\\d{2})`));
46
+ if (!m) return null;
47
+ const then = new Date(`${m[1]}T00:00:00Z`);
48
+ return Math.floor((today - then) / 86400000);
49
+ }
50
+
51
+ /**
52
+ * What a spec touching these repos inherits. A constraint scoped `*` applies
53
+ * everywhere; a spec that names no repo yet still inherits the estate-wide ones,
54
+ * which is the common case early in a spec's life.
55
+ */
56
+ export function forRepos(constraints, repos = []) {
57
+ if (!repos.length) return constraints.filter((c) => c.scope.includes('*'));
58
+ return constraints.filter((c) => c.scope.includes('*') || repos.some((r) => c.scope.includes(r)));
59
+ }
60
+
61
+ /** Upsert by id. Returns the ids that actually changed, so a caller need not
62
+ * claim a write that was a no-op. */
63
+ export function write(path, incoming, today = new Date()) {
64
+ const existing = new Map(read(path).map((c) => [c.id, c]));
65
+ const changed = [];
66
+
67
+ for (const c of incoming) {
68
+ const prev = existing.get(c.id);
69
+ if (!prev || prev.text !== c.text || prev.scope.join() !== c.scope.join()) changed.push(c.id);
70
+ existing.set(c.id, c);
71
+ }
72
+
73
+ const rows = [...existing.values()].sort((a, b) => a.id.localeCompare(b.id));
74
+ const lines = rows.length
75
+ ? rows.map((c) => `- \`${c.id}\` [${c.scope.join(', ')}] ${c.text}`)
76
+ : ['_none — no NFR has been compiled and no incident has produced a lesson._'];
77
+
78
+ const stamp = today.toISOString().slice(0, 10);
79
+ mkdirSync(dirname(path), { recursive: true });
80
+ writeFileSync(path, `${HEADER}${REFRESHED}${stamp} -->\n\n${BEGIN}\n${lines.join('\n')}\n${END}\n`);
81
+ return changed;
82
+ }
83
+
84
+ /** The block stamped into a new or briefed spec, so constraints are in front of
85
+ * whoever writes it rather than in a file they are trusted to have read. */
86
+ export function renderForSpec(constraints) {
87
+ if (!constraints.length) return '';
88
+ return ['## Constraints in force', '',
89
+ '_Read while authoring. Generated from the knowledge layer._', '',
90
+ ...constraints.map((c) => `- **${c.id}** — ${c.text}`)].join('\n') + '\n';
91
+ }
@@ -0,0 +1,119 @@
1
+ // Non-functional requirements: reading them, and compiling them into the two
2
+ // things that actually enforce them.
3
+ //
4
+ // Pure Node, no dependencies. Read-only; the caller decides what to write.
5
+ //
6
+ // Why this exists: NFRs enter a project at the top — "the API shall be
7
+ // performant" — and then vanish. They do not decompose into user stories, so a
8
+ // story breakdown flattens them into prose that nothing checks. They decompose
9
+ // into exactly two things: a standing constraint read while authoring every
10
+ // spec, and a blocking pipeline gate. `compile` produces both or throws, so an
11
+ // NFR cannot be admitted and then quietly lost.
12
+
13
+ const TOOL_FOR = {
14
+ performance: 'load',
15
+ security: 'sast',
16
+ availability: 'policy',
17
+ compliance: 'policy',
18
+ };
19
+
20
+ export const NFR_KINDS = Object.keys(TOOL_FOR);
21
+ const OPERATORS = ['<', '<=', '>', '>=', '=='];
22
+
23
+ /**
24
+ * An NFR without a machine-checkable threshold is a wish. Refusing it here is
25
+ * the whole point: a wish gets flattened into a feature spec and lost, and
26
+ * nobody notices until the load test that was never written would have caught it.
27
+ */
28
+ export function compile(nfr) {
29
+ const t = nfr.threshold;
30
+ if (!t || !Number.isFinite(Number(t.value)) || !t.metric) {
31
+ throw new Error(
32
+ `${nfr.id} has no machine-checkable threshold, so it cannot become a pipeline gate. ` +
33
+ `Give it metric/operator/value, or drop it — an NFR nothing can fail is not a requirement.`);
34
+ }
35
+ if (!OPERATORS.includes(t.operator)) {
36
+ throw new Error(`${nfr.id} has operator "${t.operator}"; expected one of ${OPERATORS.join(' ')}`);
37
+ }
38
+ const tool = TOOL_FOR[nfr.kind];
39
+ if (!tool) {
40
+ throw new Error(`${nfr.id} has kind "${nfr.kind}"; expected one of ${NFR_KINDS.join(', ')}`);
41
+ }
42
+
43
+ const assertion = `${t.metric} ${t.operator} ${t.value}${t.unit ?? ''}`;
44
+ return {
45
+ constraint: { id: nfr.id, scope: nfr.appliesTo?.length ? nfr.appliesTo : ['*'],
46
+ text: `${nfr.statement} (${assertion})` },
47
+ gate: { nfr: nfr.id, tool, metric: t.metric, operator: t.operator,
48
+ value: Number(t.value), unit: t.unit ?? '', assertion, blocking: true },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * The guard that keeps this honest: an NFR id must never appear inside an
54
+ * acceptance criterion. That is the exact failure mode — the NFR flattened into
55
+ * a feature spec, where it stops being enforced and starts being a sentence.
56
+ */
57
+ export function findFlattened(specs) {
58
+ const hits = [];
59
+ for (const spec of specs) {
60
+ for (const ac of spec.criteria ?? []) {
61
+ for (const id of spec.nfrs ?? []) {
62
+ if (ac.text.includes(id)) hits.push({ spec: spec.id, criterion: ac.id, nfr: id, text: ac.text });
63
+ }
64
+ }
65
+ }
66
+ return hits;
67
+ }
68
+
69
+ /** The gate block a pipeline consumes. */
70
+ export const toPipelineYaml = (gates) =>
71
+ gates.length === 0
72
+ ? '# no NFR gates compiled\n'
73
+ : gates.map((g) =>
74
+ `- gate: ${g.nfr}\n tool: ${g.tool}\n assert: ${g.assertion}\n blocking: true`).join('\n');
75
+
76
+ // --- reading -----------------------------------------------------------------
77
+ // A markdown table, to match docs/ESTATE.md — this file is read by people during
78
+ // spec review far more often than by this code, and a format only a machine can
79
+ // read becomes a format nobody checks.
80
+
81
+ const ROW = /^\|(.+)\|\s*$/;
82
+ const SEP = /^\|[\s:|-]+\|\s*$/;
83
+ const clean = (v) => (v ?? '').replace(/[`*]/g, '').trim();
84
+ const PLACEHOLDER = /^(<.*>|_TBD_|—|-|)$/;
85
+
86
+ export function parseNfrMarkdown(text) {
87
+ const out = [];
88
+ let header = null;
89
+ for (const line of text.split('\n')) {
90
+ const trimmed = line.trim();
91
+ if (SEP.test(trimmed)) continue;
92
+ const m = ROW.exec(trimmed);
93
+ if (!m) { header = null; continue; }
94
+ const cells = m[1].split('|').map(clean);
95
+
96
+ if (!header) {
97
+ const lower = cells.map((c) => c.toLowerCase());
98
+ header = lower.includes('nfr') && lower.some((c) => c.includes('metric')) ? lower : null;
99
+ continue;
100
+ }
101
+
102
+ const at = (name) => {
103
+ const i = header.findIndex((h) => h.includes(name));
104
+ return i === -1 ? '' : (cells[i] ?? '');
105
+ };
106
+ const id = at('nfr');
107
+ if (!id || PLACEHOLDER.test(id)) continue;
108
+
109
+ out.push({
110
+ id,
111
+ kind: (at('kind') || 'compliance').toLowerCase(),
112
+ statement: at('statement'),
113
+ threshold: { metric: at('metric'), operator: at('op') || '<',
114
+ value: Number(at('value')), unit: at('unit') },
115
+ appliesTo: at('applies').split(',').map(clean).filter((v) => v && !PLACEHOLDER.test(v)),
116
+ });
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,114 @@
1
+ // The branch policy that makes a pipeline into a gate.
2
+ //
3
+ // A build that runs and reports changes nothing. The load-bearing artifact is
4
+ // the POLICY: build validation, scoped to the protected branch, blocking.
5
+ //
6
+ // The audit half matters more than the install half. A gate quietly demoted to
7
+ // advisory — to unblock a release, reasonably, on a Friday — is how this kind of
8
+ // governance dies: nothing breaks, no test fails, and the first anyone notices
9
+ // is an audit months later.
10
+ //
11
+ // Pure Node, no dependencies. Every call goes through an injected transport.
12
+
13
+ import { basicAuth, request } from './http.mjs';
14
+
15
+ /** ADO's well-known policy type for "Build validation". */
16
+ export const BUILD_VALIDATION_TYPE = '0609b952-1397-4640-95ec-e00a01b2c241';
17
+
18
+ export function adoPolicy({ org, project, pat, apiVersion = '7.1', transport = request }) {
19
+ const base = `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_apis`;
20
+ const headers = { Authorization: basicAuth('', pat) };
21
+
22
+ return {
23
+ tool: 'ado-repos',
24
+
25
+ async install(repo, branch, buildRef) {
26
+ await transport(`${base}/policy/configurations?api-version=${apiVersion}`, {
27
+ method: 'POST', headers,
28
+ body: {
29
+ isEnabled: true,
30
+ isBlocking: true, // the whole point; there is no flag to turn this off here
31
+ type: { id: BUILD_VALIDATION_TYPE },
32
+ settings: {
33
+ buildDefinitionId: /^\d+$/.test(String(buildRef)) ? Number(buildRef) : buildRef,
34
+ displayName: 'SDD spec gate',
35
+ manualQueueOnly: false,
36
+ queueOnSourceUpdateOnly: true,
37
+ validDuration: 720,
38
+ scope: [{ refName: `refs/heads/${branch}`, matchKind: 'Exact', repositoryId: repo }],
39
+ },
40
+ },
41
+ });
42
+ return { repo, branch, tool: 'ado-repos', present: true, blocking: true, findings: [] };
43
+ },
44
+
45
+ async audit(repo, branch) {
46
+ const res = await transport(
47
+ `${base}/policy/configurations?repositoryId=${encodeURIComponent(repo)}` +
48
+ `&refName=refs/heads/${branch}&api-version=${apiVersion}`, { headers });
49
+
50
+ const builds = (res.value ?? []).filter((c) => c.type?.id === BUILD_VALIDATION_TYPE);
51
+ if (!builds.length) {
52
+ return { repo, branch, tool: 'ado-repos', present: false, blocking: false,
53
+ findings: [{ severity: 'blocker', message: `no build-validation policy on refs/heads/${branch}` }] };
54
+ }
55
+
56
+ const p = builds[0];
57
+ const s = p.settings ?? {};
58
+ const findings = [];
59
+ if (!p.isEnabled) findings.push({ severity: 'blocker', message: 'policy exists but is disabled' });
60
+ if (!p.isBlocking) findings.push({ severity: 'blocker', message: 'policy is advisory — the build reports but cannot fail the merge' });
61
+ if (s.manualQueueOnly) findings.push({ severity: 'blocker', message: 'manualQueueOnly is set — the gate only runs if someone remembers' });
62
+ if (s.queueOnSourceUpdateOnly === false) findings.push({ severity: 'warning', message: 'does not re-run on source update; a later push is ungated' });
63
+ if (builds.length > 1) findings.push({ severity: 'warning', message: `${builds.length} build policies on this branch — which one is the gate?` });
64
+
65
+ return { repo, branch, tool: 'ado-repos', present: true,
66
+ blocking: Boolean(p.isBlocking && p.isEnabled), findings };
67
+ },
68
+ };
69
+ }
70
+
71
+ export function githubPolicy({ owner, token, apiBase = 'https://api.github.com', transport = request }) {
72
+ const headers = { Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' };
73
+ const url = (repo, branch) => `${apiBase}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`;
74
+
75
+ return {
76
+ tool: 'github',
77
+
78
+ async install(repo, branch, buildRef) {
79
+ await transport(url(repo, branch), { method: 'PUT', headers, body: {
80
+ required_status_checks: { strict: true, contexts: [buildRef] },
81
+ enforce_admins: true,
82
+ required_pull_request_reviews: { required_approving_review_count: 1 },
83
+ restrictions: null,
84
+ } });
85
+ return { repo, branch, tool: 'github', present: true, blocking: true, findings: [] };
86
+ },
87
+
88
+ async audit(repo, branch) {
89
+ const raw = await transport(url(repo, branch), { headers });
90
+ const contexts = raw.required_status_checks?.contexts ?? [];
91
+ if (!contexts.length) {
92
+ return { repo, branch, tool: 'github', present: false, blocking: false,
93
+ findings: [{ severity: 'blocker', message: `no required status check on ${branch}` }] };
94
+ }
95
+ const findings = [];
96
+ if (!raw.required_status_checks?.strict) {
97
+ findings.push({ severity: 'warning', message: 'strict is off — a stale branch can merge without re-running the gate' });
98
+ }
99
+ // On GitHub an admin bypass is the equivalent of an advisory policy.
100
+ if (!raw.enforce_admins?.enabled) {
101
+ findings.push({ severity: 'blocker', message: 'enforce_admins is off — administrators can merge past the gate' });
102
+ }
103
+ return { repo, branch, tool: 'github', present: true,
104
+ blocking: !findings.some((f) => f.severity === 'blocker'), findings };
105
+ },
106
+ };
107
+ }
108
+
109
+ export const auditOk = (a) => a.present && a.blocking && !a.findings.some((f) => f.severity === 'blocker');
110
+
111
+ export const auditSummary = (a) =>
112
+ !a.present ? 'no spec gate on this branch'
113
+ : !a.blocking ? 'spec gate is ADVISORY — it cannot fail the merge, so it is not a gate'
114
+ : 'spec gate present and blocking';
@@ -0,0 +1,189 @@
1
+ // Source control: branches and pull requests, for ADO Repos and GitHub.
2
+ //
3
+ // Deliberately separate from lib/tracker.mjs. A work item and a pull request are
4
+ // different objects owned by different systems, and they diverge under exactly
5
+ // the load this exists for: one spec fans out to N repositories but links to ONE
6
+ // work item, so the cardinality differs. GitHub-plus-Jira is also an ordinary
7
+ // combination that a merged interface could not express.
8
+ //
9
+ // GitHub ships alongside ADO because these customers migrate there for code
10
+ // while keeping Boards or Jira for work — the two boundaries move independently.
11
+ //
12
+ // Pure Node, no dependencies. Every call goes through an injected transport.
13
+
14
+ import { basicAuth, request } from './http.mjs';
15
+ import { branchName, prTitle, keyFrom, specId, testCaseId } from './trace.mjs';
16
+
17
+ const EMPTY_SHA = '0'.repeat(40);
18
+
19
+ // --- Azure DevOps Repos ------------------------------------------------------
20
+
21
+ export function adoRepos({ org, project, pat, apiVersion = '7.1', transport = request }) {
22
+ const repoBase = (repo) =>
23
+ `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repo)}`;
24
+ const headers = { Authorization: basicAuth('', pat) };
25
+
26
+ return {
27
+ tool: 'ado-repos',
28
+
29
+ async defaultBranchSha(repo, branch = 'main') {
30
+ const res = await transport(`${repoBase(repo)}/refs?filter=heads/${branch}&api-version=${apiVersion}`, { headers });
31
+ const refs = res.value ?? [];
32
+ if (!refs.length) throw new Error(`ADO: ${repo} has no branch ${branch}`);
33
+ return String(refs[0].objectId);
34
+ },
35
+
36
+ async createBranch(repo, name, fromSha) {
37
+ // ADO has no "create branch" call — a branch is a ref updated from zeros.
38
+ await transport(`${repoBase(repo)}/refs?api-version=${apiVersion}`, {
39
+ method: 'POST', headers,
40
+ body: [{ name: `refs/heads/${name}`, oldObjectId: EMPTY_SHA, newObjectId: fromSha }],
41
+ });
42
+ return { repo, name, sha: fromSha };
43
+ },
44
+
45
+ async openPullRequest(repo, { source, target, title, body }) {
46
+ const raw = await transport(`${repoBase(repo)}/pullrequests?api-version=${apiVersion}`, {
47
+ method: 'POST', headers,
48
+ body: { sourceRefName: `refs/heads/${source}`, targetRefName: `refs/heads/${target}`,
49
+ title, description: body },
50
+ });
51
+ const id = String(raw.pullRequestId ?? '');
52
+ return { repo, id, title, source, target,
53
+ url: `https://dev.azure.com/${org}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}/pullrequest/${id}` };
54
+ },
55
+
56
+ async findPullRequests(repo, key) {
57
+ const res = await transport(
58
+ `${repoBase(repo)}/pullrequests?searchCriteria.status=all&api-version=${apiVersion}`, { headers });
59
+ return (res.value ?? [])
60
+ .filter((pr) => keyFrom('prTitle', pr.title ?? '') === key)
61
+ .map((pr) => ({ repo, id: String(pr.pullRequestId), title: pr.title,
62
+ source: String(pr.sourceRefName ?? '').replace(/^refs\/heads\//, ''),
63
+ target: String(pr.targetRefName ?? '').replace(/^refs\/heads\//, ''),
64
+ url: `https://dev.azure.com/${org}/_git/${encodeURIComponent(repo)}/pullrequest/${pr.pullRequestId}` }));
65
+ },
66
+ };
67
+ }
68
+
69
+ // --- GitHub ------------------------------------------------------------------
70
+
71
+ export function github({ owner, token, apiBase = 'https://api.github.com', transport = request }) {
72
+ const headers = { Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' };
73
+ const url = (repo, path) => `${apiBase}/repos/${owner}/${repo}${path}`;
74
+
75
+ return {
76
+ tool: 'github',
77
+
78
+ async defaultBranchSha(repo, branch = 'main') {
79
+ const raw = await transport(url(repo, `/git/ref/heads/${branch}`), { headers });
80
+ return String(raw.object.sha);
81
+ },
82
+
83
+ async createBranch(repo, name, fromSha) {
84
+ await transport(url(repo, '/git/refs'), {
85
+ method: 'POST', headers, body: { ref: `refs/heads/${name}`, sha: fromSha } });
86
+ return { repo, name, sha: fromSha };
87
+ },
88
+
89
+ async openPullRequest(repo, { source, target, title, body }) {
90
+ // The shape difference from ADO: bare head/base, and it is called `body`.
91
+ const raw = await transport(url(repo, '/pulls'), {
92
+ method: 'POST', headers, body: { title, head: source, base: target, body } });
93
+ return { repo, id: String(raw.number ?? ''), title, source, target,
94
+ url: String(raw.html_url ?? `https://github.com/${owner}/${repo}/pulls`) };
95
+ },
96
+
97
+ async findPullRequests(repo, key) {
98
+ const raw = await transport(url(repo, '/pulls?state=all&per_page=100'), { headers });
99
+ return (raw ?? [])
100
+ .filter((pr) => keyFrom('prTitle', pr.title ?? '') === key)
101
+ .map((pr) => ({ repo, id: String(pr.number), title: pr.title,
102
+ source: pr.head?.ref ?? '', target: pr.base?.ref ?? '', url: pr.html_url ?? '' }));
103
+ },
104
+ };
105
+ }
106
+
107
+ // --- the governed path -------------------------------------------------------
108
+
109
+ /** Reads only. Frozen, so the write methods are absent and stay absent. */
110
+ export const readOnlyScm = (scm) => Object.freeze({
111
+ tool: scm.tool,
112
+ defaultBranchSha: (repo, branch) => scm.defaultBranchSha(repo, branch),
113
+ findPullRequests: (repo, key) => scm.findPullRequests(repo, key),
114
+ });
115
+
116
+ /**
117
+ * The only way a branch or pull request gets created. A pull request that cannot
118
+ * be traced back to an approved contract is exactly what this exists to prevent,
119
+ * so it is refused here rather than reported later.
120
+ */
121
+ export function governedScm(scm, { target = 'main' } = {}) {
122
+ return {
123
+ tool: scm.tool,
124
+ async openFor(spec, repo, body) {
125
+ if (!spec.status || spec.status === 'Draft') {
126
+ throw new Error(`spec ${spec.id} is ${spec.status ?? 'unknown'}; no branch is cut before the approval gate`);
127
+ }
128
+ const name = spec.branch ?? branchName(spec.id, spec.title);
129
+ const title = prTitle(spec.id, spec.title);
130
+ // Both are derived, so these can only fail if a derivation changed without
131
+ // its parser changing with it.
132
+ if (keyFrom('branch', name) !== spec.id) throw new Error(`branch "${name}" does not carry spec ${spec.id}`);
133
+ if (keyFrom('prTitle', title) !== spec.id) throw new Error(`title "${title}" does not carry spec ${spec.id}`);
134
+
135
+ const sha = await scm.defaultBranchSha(repo, target);
136
+ const branch = await scm.createBranch(repo, name, sha);
137
+ const pr = await scm.openPullRequest(repo, { source: name, target, title, body });
138
+ return { branch, pr };
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * The pull request body. It carries the whole chain so a reviewer never has to
145
+ * hunt, and the criteria arrive as a checklist — their job is to confirm each
146
+ * one, not to infer what the change was for.
147
+ */
148
+ export function pullRequestBody(spec, reason, { constraints = [] } = {}) {
149
+ const branch = spec.branch ?? branchName(spec.id, spec.title);
150
+ const lines = [
151
+ `## ${specId(spec.id)} — ${spec.title}`, '',
152
+ `**Why this repo:** ${reason}`,
153
+ `**Spec:** \`${spec.file}\` (status: ${spec.status})`,
154
+ ];
155
+ if (spec.ticket) lines.push(`**Ticket:** ${spec.ticket}`);
156
+ if (spec.repos?.length) lines.push(`**Fan-out:** ${spec.repos.join(', ')}`);
157
+ if (spec.nfrs?.length) lines.push(`**NFRs in force:** ${spec.nfrs.join(', ')}`);
158
+
159
+ lines.push('', '## Acceptance criteria', '');
160
+ lines.push(...((spec.criteria ?? []).length
161
+ ? spec.criteria.map((ac) => `- [${ac.checked ? 'x' : ' '}] ${ac.id} (${testCaseId(spec.id, ac.ordinal)}) — ${ac.text}`)
162
+ : ['_none — this spec should not have been approved._']));
163
+
164
+ if (constraints.length) {
165
+ lines.push('', '## Constraints in force', '', ...constraints.map((c) => `- **${c.id}** — ${c.text}`));
166
+ }
167
+
168
+ lines.push('', '---',
169
+ `Every branch in this fan-out is \`${branch}\`. Same key, so these pull requests are one change.`);
170
+ return lines.join('\n');
171
+ }
172
+
173
+ /** Which repos this change reaches: those named on the spec, plus those the
174
+ * estate index says consume what it touches. */
175
+ export function fanOutTargets(spec, impact = null) {
176
+ const targets = new Map();
177
+ for (const repo of spec.repos ?? []) targets.set(repo, 'named on the spec');
178
+ for (const d of impact?.directlyAffected ?? []) {
179
+ if (!targets.has(d.repo)) targets.set(d.repo, `consumes ${d.via.join(', ')}`);
180
+ }
181
+ for (const t of impact?.transitivelyAffected ?? []) {
182
+ if (!targets.has(t.repo)) targets.set(t.repo, `one hop: consumes ${t.via.join(', ')}`);
183
+ }
184
+ return [...targets].map(([repo, reason]) => ({
185
+ repo, reason,
186
+ branch: spec.branch ?? branchName(spec.id, spec.title),
187
+ title: prTitle(spec.id, spec.title),
188
+ }));
189
+ }