@smartmemory/compose 0.4.1 → 0.5.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 (114) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +1 -1
  5. package/bin/compose.js +33 -14
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/build-cancel.js +205 -0
  61. package/lib/build.js +552 -87
  62. package/lib/canon-guard.js +3 -24
  63. package/lib/canon-registry.js +2 -71
  64. package/lib/codex-preflight.js +8 -0
  65. package/lib/colleague/context.js +123 -0
  66. package/lib/consumer-fanout.js +24 -1
  67. package/lib/decision-blocks.js +38 -0
  68. package/lib/dispatch-ledger.js +7 -0
  69. package/lib/fluid/factory.js +112 -1
  70. package/lib/fluid/ideabox-manifest.js +203 -0
  71. package/lib/fluid/ideabox-migrate.js +177 -29
  72. package/lib/fluid/ideabox-preamble.js +155 -0
  73. package/lib/fluid/ideabox-readable.js +83 -0
  74. package/lib/fluid/ideabox-recover.js +393 -0
  75. package/lib/fluid/import-ideabox.js +188 -45
  76. package/lib/fluid/local-provider.js +6 -0
  77. package/lib/fluid/portfolio.js +255 -0
  78. package/lib/fluid/record-shape.js +7 -0
  79. package/lib/fluid/render-ideabox.js +153 -7
  80. package/lib/fluid/smartmemory-provider.js +6 -0
  81. package/lib/gate-prompt.js +14 -7
  82. package/lib/ideabox-cli.js +68 -0
  83. package/lib/ideabox.js +209 -9
  84. package/lib/maya-identity.js +16 -2
  85. package/lib/process-termination.js +121 -3
  86. package/lib/receipts-gate.js +268 -0
  87. package/lib/result-normalizer.js +28 -1
  88. package/lib/smartmemory-client.js +68 -1
  89. package/lib/stratum-mcp-client.js +104 -5
  90. package/lib/tool-inventory.js +0 -1
  91. package/lib/version-check.js +9 -3
  92. package/package.json +7 -5
  93. package/server/build-stream-bridge.js +43 -1
  94. package/server/cc-session-watcher.js +54 -5
  95. package/server/compose-mcp-tools.js +48 -50
  96. package/server/compose-mcp.js +0 -2
  97. package/server/design-routes.js +1 -1
  98. package/server/file-watcher.js +14 -0
  99. package/server/ideabox-routes.js +10 -0
  100. package/server/index.js +5 -1
  101. package/server/lifecycle-guard.js +13 -0
  102. package/server/maya-routes.js +111 -7
  103. package/server/mcp-tool-defs.js +0 -25
  104. package/server/mcp-tool-policy.js +6 -13
  105. package/server/stratum-client.js +61 -15
  106. package/server/supervisor.js +18 -4
  107. package/server/vision-routes.js +9 -3
  108. package/dist/assets/channel-SnZzzh7k.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  111. package/dist/assets/clone-DgklGjHm.js +0 -1
  112. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  113. package/lib/append-integrity.js +0 -81
  114. package/lib/canon-override.js +0 -196
@@ -0,0 +1,268 @@
1
+ /**
2
+ * receipts-gate.js — a claim written as a fact must carry its receipt.
3
+ *
4
+ * Runs on every push (pre-push hook, including docs-only pushes) over the ADDED
5
+ * lines of the pushed range — never the corpus. Scope: every `*.md` at any depth
6
+ * (git pathspecs match `*` across `/`; rules files are in, deliberately) plus
7
+ * docs/ JSON files, and every commit message in the range. That is what makes it a gate
8
+ * that fires at the moment of the mistake rather than a memory nobody reads at
9
+ * that moment, and what lets it ship with no baseline to reconcile.
10
+ *
11
+ * It is narrow on purpose. Three claim shapes, each one that did measurable
12
+ * damage in this repo (2026-09-07, five wrong facts in one sweep, one habit):
13
+ *
14
+ * test-coverage a CHECKED acceptance box or a commit line saying a test pins
15
+ * something, with no test path. FOH-7 had three of these; the
16
+ * boxes were ticked off an audit that sampled 2 of 17 citations.
17
+ * Receipt: a `test/...` path.
18
+ * flake-label `flake`/`flaky` written as a state. build-stream-smoke was
19
+ * labelled that way in three places over two months and was a
20
+ * live product defect the whole time (12a357a). Receipt: a
21
+ * measurement (`14/450`, `0 of 40`, `15 runs`) or a resolution
22
+ * pinned to a sha.
23
+ * suite-green a commit message claiming the suite passes with no counts.
24
+ * The prior session reported green off a wrapper's exit code
25
+ * and put numbers measured on an older tree into a commit.
26
+ * Receipt: `N/M` anywhere in the message.
27
+ *
28
+ * Talking ABOUT a phrase is not asserting it: inline code spans and fenced
29
+ * blocks are stripped before matching, so `pinned by test` in backticks passes.
30
+ * An UNCHECKED box may say "(pinned by test)" — that is a design intent, and the
31
+ * gate fires when the box is ticked, which is when the claim becomes a fact.
32
+ *
33
+ * Extending it: add a shape here with the incident that justifies it, and a
34
+ * fixture in test/receipts-gate.test.js that MUST fire and one that MUST pass.
35
+ * A shape with no incident behind it is a philosophy checker, not a gate.
36
+ */
37
+
38
+ import { execFileSync } from 'node:child_process';
39
+
40
+ const CHECKED_BOX = /^\s*[-*]\s+\[[xX]\]/;
41
+ const TEST_CLAIM = /\b(pinned|covered|guarded|locked|protected)\s+by\s+(a\s+|the\s+|its\s+)?tests?\b|\bhas\s+(a\s+|its\s+own\s+)?tests?\b|\btested\s+by\b/i;
42
+ const TEST_RECEIPT = /\btests?\/[\w./@-]+|\.test\.(m?js|jsx|ts)\b/;
43
+
44
+ const FLAKE_CLAIM = /\bflak(e|y|es|ed|ing|iness)\b/i;
45
+ const MEASUREMENT = /\b\d+\s*(\/|of|out\s+of|in)\s*\d+\b|\b\d+\s*(\w+\s+)?(runs?|x|times|iterations)\b/i;
46
+ const HEADING = /^\s*#{1,6}\s/;
47
+ // A flake claim is receipted by a measurement, or by being CLOSED the way this
48
+ // repo closes claims at their origin: an uppercase status word next to a sha
49
+ // or a date. Lowercase "fixed"/"closed" do NOT count — they are everywhere in
50
+ // prose and a neighbouring thread's "fixed @sha" was found vouching for an
51
+ // unrelated open flake claim. DISOWNED is for the line itself saying the flake
52
+ // was never one.
53
+ const CLOSED = /\b(RESOLVED|KILLED|SUPERSEDED|FIXED)\b/;
54
+ const CLOSER = /\b[0-9a-f]{7,40}\b|\b20\d\d-\d\d-\d\d\b/;
55
+ const DISOWNED = /\b(not\s+a\s+flake|never\s+a\s+flake|was\s+real|defect|root\s+cause)\b/i;
56
+
57
+ const GREEN_CLAIM = /\b(suite|tests?|everything|all)\s+(is\s+|are\s+|was\s+|were\s+|went\s+)?(green|pass(es|ed|ing)?)\b|\ball\s+green\b|\bgreen\s+suite\b/i;
58
+ const COUNTS = /\b\d+\s*\/\s*\d+\b/;
59
+
60
+ /** Remove inline code spans — a quoted phrase is mentioned, not asserted. */
61
+ function stripCodeSpans(line) {
62
+ return line.replace(/`[^`]*`/g, '');
63
+ }
64
+
65
+ /** How far "beside it" reaches: a wrapped checkbox or sentence, or a
66
+ * RESOLVED annotation indented under the claim. Not a paragraph. */
67
+ export const RECEIPT_WINDOW = 3;
68
+
69
+ /**
70
+ * Classify one ADDED line from a doc. The CLAIM must be on the line; the
71
+ * RECEIPT may be anywhere in `context` (the line plus its neighbours within
72
+ * RECEIPT_WINDOW added lines of the same file — `scanRange` builds it). With
73
+ * no context given, the line is its own context.
74
+ *
75
+ * Headings are exempt from `flake-label`: a heading titles the body that
76
+ * follows, and that body is scanned line by line on its own.
77
+ *
78
+ * @param {string} rawLine
79
+ * @param {string} [context]
80
+ * @returns {{shape: string, needs: string} | null}
81
+ */
82
+ export function classifyDocLine(rawLine, context = rawLine) {
83
+ // The CLAIM is read with code spans stripped (quoting a phrase is mentioning
84
+ // it). The RECEIPT is read raw: a path or a count in backticks is still a
85
+ // receipt — paths are conventionally written that way.
86
+ const line = stripCodeSpans(rawLine);
87
+ const near = context;
88
+ if (CHECKED_BOX.test(line) && TEST_CLAIM.test(line) && !TEST_RECEIPT.test(near)) {
89
+ return { shape: 'test-coverage', needs: `the test path (test/<file>.test.js[:line]) within ${RECEIPT_WINDOW} lines` };
90
+ }
91
+ if (!HEADING.test(line) && FLAKE_CLAIM.test(line) && !DISOWNED.test(line)
92
+ && !MEASUREMENT.test(near) && !(CLOSED.test(near) && CLOSER.test(near))) {
93
+ return { shape: 'flake-label', needs: `a measurement (e.g. "3/40 under load"), or RESOLVED/KILLED with a sha or date, within ${RECEIPT_WINDOW} lines` };
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Classify one commit message (whole body — receipts for a suite claim are
100
+ * usually on their own line). Returns violations.
101
+ * @param {string} message
102
+ * @returns {Array<{shape: string, needs: string, excerpt: string}>}
103
+ */
104
+ export function classifyCommitMessage(message) {
105
+ const out = [];
106
+ const stripped = stripFences(message).split('\n').map(stripCodeSpans);
107
+ const whole = stripFences(message); // receipts are read raw, claims stripped
108
+ for (const line of stripped) {
109
+ if (TEST_CLAIM.test(line) && !TEST_RECEIPT.test(line) && !TEST_RECEIPT.test(whole)) {
110
+ out.push({ shape: 'test-coverage', needs: 'a test path somewhere in the message', excerpt: line.trim() });
111
+ break;
112
+ }
113
+ }
114
+ for (const line of stripped) {
115
+ if (GREEN_CLAIM.test(line) && !COUNTS.test(whole)) {
116
+ out.push({ shape: 'suite-green', needs: 'pass/total counts (e.g. "node 6412/6412") somewhere in the message', excerpt: line.trim() });
117
+ break;
118
+ }
119
+ }
120
+ for (const line of stripped) {
121
+ // A commit message is one unit: the disowning phrase, like every other
122
+ // receipt here, may sit anywhere in it.
123
+ const v = FLAKE_CLAIM.test(line) && !DISOWNED.test(whole)
124
+ && !MEASUREMENT.test(whole) && !(CLOSED.test(whole) && CLOSER.test(whole))
125
+ ? { shape: 'flake-label', needs: 'a measurement, or RESOLVED/KILLED with a sha or date, somewhere in the message', excerpt: line.trim() }
126
+ : null;
127
+ if (v) { out.push(v); break; }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Drop fenced code blocks (``` ... ```). */
133
+ function stripFences(text) {
134
+ return text.replace(/```[\s\S]*?```/g, '');
135
+ }
136
+
137
+ /**
138
+ * Added doc lines in `base..head`, as {file, line, text}. Fenced blocks are
139
+ * skipped by tracking fence state per file — the diff is unified=0 so context
140
+ * is absent, which means a fence opened in an UNCHANGED line is invisible; a
141
+ * miss there is a false positive the author fixes by quoting the phrase inline.
142
+ */
143
+ export function addedDocLines(base, head, { cwd = process.cwd(), git = runGit } = {}) {
144
+ const diff = git(['diff', '--unified=0', '--no-color', `${base}..${head}`, '--',
145
+ 'docs/**/*.md', 'docs/**/*.json', '*.md'], cwd);
146
+ const out = [];
147
+ let file = null;
148
+ let lineNo = 0;
149
+ let inFence = false;
150
+ for (const raw of diff.split('\n')) {
151
+ if (raw.startsWith('+++ ')) { file = raw.slice(4).replace(/^b\//, ''); inFence = false; continue; }
152
+ if (raw.startsWith('--- ') || raw.startsWith('diff ') || raw.startsWith('index ')) continue;
153
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
154
+ if (hunk) { lineNo = Number(hunk[1]); continue; }
155
+ if (raw.startsWith('+')) {
156
+ const text = raw.slice(1);
157
+ if (/^\s*```/.test(text)) { inFence = !inFence; lineNo++; continue; }
158
+ if (!inFence && file && file !== '/dev/null') out.push({ file, line: lineNo, text });
159
+ lineNo++;
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /** Commit messages in `base..head`, oldest first, as {sha, message}. */
166
+ export function commitMessages(base, head, { cwd = process.cwd(), git = runGit } = {}) {
167
+ const sep = '\u001e'; // record separator — commit bodies contain every printable char
168
+ const raw = git(['log', '--reverse', `--format=%H${sep}%B${sep}`, `${base}..${head}`], cwd);
169
+ const out = [];
170
+ const parts = raw.split(sep);
171
+ for (let i = 0; i + 1 < parts.length; i += 2) {
172
+ const sha = parts[i].trim();
173
+ if (!sha) continue;
174
+ out.push({ sha, message: parts[i + 1] });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Scan a range. Returns every violation with its location.
181
+ * @returns {Array<{where: string, shape: string, needs: string, excerpt: string}>}
182
+ */
183
+ export function scanRange(base, head, opts = {}) {
184
+ const found = [];
185
+ const added = addedDocLines(base, head, opts);
186
+ const { cwd = process.cwd(), git = runGit } = opts;
187
+ const filesAtHead = new Map();
188
+ const fileAtHead = (file) => {
189
+ if (!filesAtHead.has(file)) {
190
+ let body = '';
191
+ try { body = git(['show', `${head}:${file}`], cwd); } catch { body = ''; }
192
+ filesAtHead.set(file, body);
193
+ }
194
+ return filesAtHead.get(file);
195
+ };
196
+ for (let i = 0; i < added.length; i++) {
197
+ const { file, line, text } = added[i];
198
+ const context = CHECKED_BOX.test(text)
199
+ ? listItemOf(fileAtHead(file), line)
200
+ : neighboursOf(added, i);
201
+ const v = classifyDocLine(text, context);
202
+ if (v) found.push({ where: `${file}:${line}`, ...v, excerpt: text.trim() });
203
+ }
204
+ for (const { sha, message } of commitMessages(base, head, opts)) {
205
+ for (const v of classifyCommitMessage(message)) {
206
+ found.push({ where: `commit ${sha.slice(0, 7)}`, ...v });
207
+ }
208
+ }
209
+ return found;
210
+ }
211
+
212
+ /**
213
+ * Prose context: same file, within RECEIPT_WINDOW by LINE NUMBER — two hunks
214
+ * far apart in one file are not "beside" each other just because the diff
215
+ * lists them consecutively.
216
+ */
217
+ function neighboursOf(added, i) {
218
+ const { file, line } = added[i];
219
+ return added
220
+ .slice(Math.max(0, i - RECEIPT_WINDOW), i + RECEIPT_WINDOW + 1)
221
+ .filter((n) => n.file === file && Math.abs(n.line - line) <= RECEIPT_WINDOW)
222
+ .map((n) => n.text)
223
+ .join('\n');
224
+ }
225
+
226
+ /**
227
+ * Checklist context: the item ITSELF — its line plus the indented continuation
228
+ * lines under it, up to the next item, heading, or blank line. Checklists are
229
+ * dense and every item is its own claim, so a symmetric window let a
230
+ * neighbouring item's test path vouch for a box that had none (that is exactly
231
+ * how the FOH-7 audit commit slipped through a first draft of this gate).
232
+ *
233
+ * Read from the FILE AT `head`, not the diff: a box flipped [ ]→[x] with its
234
+ * receipt already on an unchanged continuation line is fine, and a zero-context
235
+ * diff cannot see that line.
236
+ */
237
+ function listItemOf(fileAtHead, lineNo) {
238
+ const lines = fileAtHead.split('\n');
239
+ const parts = [lines[lineNo - 1] ?? ''];
240
+ for (let j = lineNo; j < lines.length; j++) {
241
+ const t = lines[j];
242
+ if (!/^\s+\S/.test(t) || /^\s*[-*]\s+\[/.test(t) || HEADING.test(t)) break;
243
+ parts.push(t);
244
+ }
245
+ return parts.join('\n');
246
+ }
247
+
248
+ /** Render violations for the hook. */
249
+ export function formatViolations(found) {
250
+ const lines = [`receipts-gate: ${found.length} claim(s) written as fact with no receipt beside them:`, ''];
251
+ for (const v of found) {
252
+ lines.push(` ${v.where} [${v.shape}]`);
253
+ lines.push(` ${truncate(v.excerpt, 140)}`);
254
+ lines.push(` needs: ${v.needs}`);
255
+ lines.push('');
256
+ }
257
+ lines.push('A fact without a measurement is a guess with good posture. Add the receipt, or');
258
+ lines.push('quote the phrase in backticks if you are talking about it rather than asserting it.');
259
+ return lines.join('\n');
260
+ }
261
+
262
+ function truncate(s, n) {
263
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
264
+ }
265
+
266
+ function runGit(args, cwd) {
267
+ return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
268
+ }
@@ -14,6 +14,7 @@ import { resolveAgentConfig } from './agent-string.js';
14
14
  import { normalizeReviewResult } from './review-normalize.js';
15
15
  import { KNOWN_VERSIONS } from './build-stream-schema.js';
16
16
  import { runLocalClaudeAgent } from './local-claude-connector.js';
17
+ import { confirmCancellation } from './build-cancel.js';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Error classes
@@ -433,6 +434,15 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
433
434
  const abortController = new AbortController();
434
435
  const stopRun = () => abortController.abort();
435
436
 
437
+ // COMP-BUILD-CANCEL S03-2 (D-F, C13): the build-level cancel handle. ONE hook covers
438
+ // BOTH dispatch branches, because both hang off this single controller — the local SDK
439
+ // agent (via opts.abortController) and the two MCP dispatches (via abortController
440
+ // .signal). `executionOptions` is deliberately untouched: it is spread into the MCP
441
+ // wire request, where an AbortSignal does not belong.
442
+ const buildSignal = opts.buildSignal ?? null;
443
+ if (buildSignal?.aborted) stopRun();
444
+ else buildSignal?.addEventListener('abort', stopRun, { once: true });
445
+
436
446
  // Subscribe BEFORE calling agentRun — events fire during the call.
437
447
  const unsub = stratum.onEvent(correlationId, subStepId, (env) => {
438
448
  // Accept every KNOWN_VERSIONS envelope (producer emits 0.2.6). Hard-pinning
@@ -581,6 +591,10 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
581
591
  runResult = await stratum.agentRun(agentType, actualPrompt, {
582
592
  ...executionOptions,
583
593
  signal: abortController.signal,
594
+ // S03-3: the tag is supplied by the CALL SITE, never inferred from
595
+ // stepDispatch.flow_id — inference would silently tag sites the driver
596
+ // does not mean to tag, and an explicit opts.flow keeps them greppable.
597
+ ...(opts.flow ? { flow: opts.flow } : {}),
584
598
  correlationId,
585
599
  telemetry: primaryTelemetry,
586
600
  });
@@ -590,6 +604,9 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
590
604
  : null;
591
605
  } catch (err) {
592
606
  if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(err?.code)) throw primaryFailure(err, err);
607
+ if (await confirmCancellation(err, {
608
+ stratum, flowId: opts.flowId, buildCancel: opts.buildCancel, tagged: Boolean(opts.flow),
609
+ })) throw primaryFailure(err, err);
593
610
  // F3/G3: preserve any billable usage the failed run reported (the local
594
611
  // connector attaches it on a non-success result / usage-bearing rejection) so
595
612
  // the consumer failure envelope can still debit the engine/GSD ledgers. G3:
@@ -747,6 +764,7 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
747
764
  const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
748
765
  ...executionOptions,
749
766
  signal: abortController.signal,
767
+ ...(opts.flow ? { flow: opts.flow } : {}),
750
768
  telemetry: { ...primaryTelemetry, site: 'review-repair' },
751
769
  });
752
770
  repairResultForControl = repairResult;
@@ -765,6 +783,11 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
765
783
  return repairResult?.text ?? '';
766
784
  } catch (error) {
767
785
  repairFailure = error;
786
+ if (!['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(error?.code)) {
787
+ await confirmCancellation(error, {
788
+ stratum, flowId: opts.flowId, buildCancel: opts.buildCancel, tagged: Boolean(opts.flow),
789
+ });
790
+ }
768
791
  repairDispatchId = typeof error?.dispatchId === 'string'
769
792
  ? error.dispatchId
770
793
  : null;
@@ -791,7 +814,8 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
791
814
  });
792
815
  // The tolerant text parser can recover ordinary repair failures, but it
793
816
  // must never turn a cancelled or still-running repair into a clean review.
794
- if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(repairFailure?.code)) {
817
+ if (repairFailure && (opts.buildCancel?.cancelled
818
+ || ['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT'].includes(repairFailure.code))) {
795
819
  repairFailure.usages = usages;
796
820
  throw repairFailure;
797
821
  }
@@ -854,6 +878,9 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
854
878
  } finally {
855
879
  if (timeoutHandle) clearTimeout(timeoutHandle);
856
880
  if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
881
+ // Release the build-level listener, so a long build does not accumulate one per
882
+ // dispatch across hundreds of runs.
883
+ buildSignal?.removeEventListener('abort', stopRun);
857
884
  unsub();
858
885
  }
859
886
  }
@@ -47,6 +47,7 @@
47
47
  * is, how it is identified, how it serializes — belongs to the caller.
48
48
  */
49
49
 
50
+ import { randomUUID } from 'node:crypto';
50
51
  import { APIError, SmartMemoryClient } from '@smartmemory/sdk-js/core';
51
52
 
52
53
  /**
@@ -58,12 +59,28 @@ import { APIError, SmartMemoryClient } from '@smartmemory/sdk-js/core';
58
59
  * both still surface as one failure type upstream (sync: `failed`; emitter:
59
60
  * counts toward the circuit breaker).
60
61
  */
62
+ /**
63
+ * Marker for an authorization refusal carried through the SDK's error wrapping.
64
+ * Format: `<marker><status>:<scope-error or empty>`.
65
+ */
66
+ const REFUSED_MARKER = 'compose-sm-refused';
67
+
61
68
  export class SmartmemoryHttpError extends Error {
62
- constructor(message, status, kind) {
69
+ constructor(message, status, kind, scopeError = null) {
63
70
  super(message);
64
71
  this.name = 'SmartmemoryHttpError';
65
72
  this.status = status;
66
73
  this.kind = kind;
74
+ /**
75
+ * The upstream `X-SM-Scope-Error` value, when it sent one.
76
+ *
77
+ * Additive and defaulted to null: no existing caller reads it, so retaining
78
+ * it changes nothing for them. It exists because "you are not a member of
79
+ * that workspace" and "your key lacks the required scope" are two different
80
+ * problems with two different fixes, and a 403 that cannot tell them apart
81
+ * sends the user to guess.
82
+ */
83
+ this.scopeError = scopeError;
67
84
  }
68
85
  }
69
86
 
@@ -81,6 +98,14 @@ const MALFORMED = 'malformed-response';
81
98
  * @returns {object} the client surface documented per-method below
82
99
  */
83
100
  export function createSmartmemoryClient(cfg) {
101
+ // A per-client nonce, because the marker travels through the SDK inside an
102
+ // error MESSAGE and upstream controls message content. A static marker meant a
103
+ // 500 whose JSON body happened to contain the marker text was converted into a
104
+ // 403 membership refusal — upstream could forge an authorization verdict by
105
+ // echoing a string. The nonce is generated per client and never leaves the
106
+ // process, so a body cannot carry a matching one.
107
+ const refusedNonce = randomUUID();
108
+ const refusedRe = new RegExp(`^${REFUSED_MARKER}:${refusedNonce}:(\\d{3}):(\\S*)$`);
84
109
  const baseUrl = cfg.baseUrl;
85
110
  const timeoutMs = cfg.timeoutMs ?? 3000;
86
111
 
@@ -125,6 +150,26 @@ export function createSmartmemoryClient(cfg) {
125
150
  parseFailed = true;
126
151
  }
127
152
 
153
+ // An authorization refusal is converted HERE, where the response and its
154
+ // headers are demonstrably in hand. The SDK builds its own error objects and
155
+ // does not carry headers onto them, so classifying downstream produced
156
+ // `scopeError: null` for every 403 — leaving the two actionable diagnoses
157
+ // ("not a member" vs "the key lacks scope") permanently collapsed into
158
+ // "reason undetermined". `asHttpError` passes a SmartmemoryHttpError through
159
+ // untouched, so throwing ours early is compatible with every caller.
160
+ if (res.status === 401 || res.status === 403) {
161
+ const scope = res.headers?.get?.('x-sm-scope-error');
162
+ const value = typeof scope === 'string' && scope.trim() ? scope.trim() : '';
163
+ // Carried in the MESSAGE, not on the object. The SDK wraps whatever the
164
+ // fetchFn throws and keeps only its text — no `cause`, no status, no
165
+ // custom fields (verified: the wrapper arrives with status 0 and every
166
+ // property lost). Since we author both the throw and the parse, and the
167
+ // marker is our own text rather than user data, encoding it here is
168
+ // deterministic; reconstructing it downstream from an object that no
169
+ // longer exists is not.
170
+ throw new Error(`${REFUSED_MARKER}:${refusedNonce}:${res.status}:${value}`);
171
+ }
172
+
128
173
  // 204 is exempt: it has no body by definition and BaseAPI returns null for
129
174
  // it without ever asking for one.
130
175
  if (res.ok && res.status !== 204 && parseFailed) {
@@ -204,8 +249,30 @@ export function createSmartmemoryClient(cfg) {
204
249
  }
205
250
  }
206
251
 
252
+ /**
253
+ * The upstream scope diagnosis, if the response carried one. Headers are
254
+ * available on the transport result and were simply not carried forward.
255
+ */
256
+ function refusalOf(err) {
257
+ // The SDK prefixes its own text, so scan lines and match each EXACTLY —
258
+ // an unanchored search would accept the marker anywhere in a body we do not
259
+ // control.
260
+ for (const part of String(err?.message ?? '').split(/[\s]+/)) {
261
+ const m = part.match(refusedRe);
262
+ if (m) return { status: Number(m[1]), scopeError: m[2] ? m[2] : null };
263
+ }
264
+ return null;
265
+ }
266
+
207
267
  function asHttpError(op, err) {
208
268
  if (err instanceof SmartmemoryHttpError) return err;
269
+ const refusal = refusalOf(err);
270
+ if (refusal) {
271
+ return new SmartmemoryHttpError(
272
+ `smartmemory: ${op} refused (HTTP ${refusal.status})`,
273
+ refusal.status, undefined, refusal.scopeError,
274
+ );
275
+ }
209
276
  if (err?.detail?.composeKind === MALFORMED) {
210
277
  return new SmartmemoryHttpError(
211
278
  `smartmemory: ${op} returned a 2xx (HTTP ${err.status}) with a non-JSON body`,
@@ -151,12 +151,34 @@ export function resolveStepProfile(profiles, stepId) {
151
151
  return undefined;
152
152
  }
153
153
 
154
+ /** The stratum MCP surface version compose's request vocabulary requires.
155
+ * Pinned in stratum at ts/contracts/mcp-surface.json ("surface": 19) and asserted by
156
+ * ts/tests/mcp/contracts-grammar.test.ts. Bump this and the package floor together. */
157
+ export const REQUIRED_STRATUM_SURFACE = 19;
158
+ export const REQUIRED_STRATUM_RANGE = '>=0.5.0';
159
+
154
160
  /**
155
161
  * Build the TS `stratum_agent_run` request from an agent string + compose-side
156
162
  * options. Capability restrictions and reasoning are execution requirements,
157
163
  * not telemetry: preserve them on the wire. The server validates provider
158
164
  * support, and refuses unsupported options instead of silently dropping them.
159
165
  */
166
+ /**
167
+ * Compose's DERIVED transport for an agent run — a deterministic rule over compose's own
168
+ * inputs, not an observation: stratum reports no transport on its `ConnectorResult`
169
+ * (stratum/ts/src/connectors/base.ts). Owning a process group requires exec, and a
170
+ * `cancellationId` is what asks for one (stratum/ts/src/connectors/codex.ts), so a codex
171
+ * run with an id is exec. Without an id the server may select either transport
172
+ * (including through its own environment), so derive unknown. Only codex has two transports; every
173
+ * other provider derives null. The `_derived` suffix carries the distinction, so a later
174
+ * reader does not mistake this for ground truth (C33).
175
+ */
176
+ export function derivedTransport(agentType, cancellationId) {
177
+ const provider = String(agentType ?? 'claude').split(':', 1)[0] || 'claude';
178
+ if (provider !== 'codex') return null;
179
+ return cancellationId ? 'exec' : 'unknown';
180
+ }
181
+
160
182
  export function buildAgentRunRequest(agentType, prompt, opts = {}) {
161
183
  const provider = String(agentType ?? 'claude').split(':', 1)[0] || 'claude';
162
184
  return {
@@ -170,6 +192,7 @@ export function buildAgentRunRequest(agentType, prompt, opts = {}) {
170
192
  ...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
171
193
  ...(opts.effort !== undefined ? { effort: opts.effort } : {}),
172
194
  ...(opts.cancellationId !== undefined ? { cancellationId: opts.cancellationId } : {}),
195
+ ...(opts.flow !== undefined ? { flow: opts.flow } : {}),
173
196
  };
174
197
  }
175
198
 
@@ -191,7 +214,7 @@ export class StratumMcpClient {
191
214
  // STRAT-PAR-STREAM: subscribers keyed by `${flowId}::${stepId}` → Set<handler>
192
215
  #eventSubs = new Map();
193
216
 
194
- #recordAgentDispatch(dispatchId, agentType, opts, resultOrError, outcome, elapsedMs) {
217
+ #recordAgentDispatch(dispatchId, agentType, opts, resultOrError, outcome, elapsedMs, dispatchMeta = {}) {
195
218
  try {
196
219
  const context = opts.telemetry && typeof opts.telemetry === 'object'
197
220
  ? opts.telemetry
@@ -223,6 +246,10 @@ export class StratumMcpClient {
223
246
  duration_ms: finiteOrNull(usage.ms)
224
247
  ?? finiteOrNull(returnedTelemetry.durationMs)
225
248
  ?? finiteOrNull(elapsedMs),
249
+ // C51: the cancellationId that decides the transport is minted later and
250
+ // locally, inside #invokeAgentRun, so the builder cannot see it. The fact is
251
+ // derived where the id is minted and handed forward on `dispatchMeta`.
252
+ transport_derived: dispatchMeta.transportDerived ?? null,
226
253
  };
227
254
  for (const [field, value] of [
228
255
  ['build_id', context.build_id],
@@ -241,8 +268,11 @@ export class StratumMcpClient {
241
268
  async #dispatchAgentRun(agentType, prompt, opts, callOpts) {
242
269
  const dispatchId = randomUUID();
243
270
  const startedAt = Date.now();
271
+ // Carries facts #invokeAgentRun derives after the event builder's inputs are
272
+ // fixed — today only the transport (C51). Mutated in place by the invoke below.
273
+ const dispatchMeta = {};
244
274
  try {
245
- const result = await this.#invokeAgentRun(agentType, prompt, opts, callOpts);
275
+ const result = await this.#invokeAgentRun(agentType, prompt, opts, callOpts, dispatchMeta);
246
276
  this.#recordAgentDispatch(
247
277
  dispatchId,
248
278
  agentType,
@@ -250,6 +280,7 @@ export class StratumMcpClient {
250
280
  result,
251
281
  isBlockedDispatch(result) ? 'blocked' : 'ok',
252
282
  Date.now() - startedAt,
283
+ dispatchMeta,
253
284
  );
254
285
  attachDispatchId(result, dispatchId);
255
286
  return result;
@@ -261,15 +292,22 @@ export class StratumMcpClient {
261
292
  error,
262
293
  isBlockedDispatch(error) ? 'blocked' : 'error',
263
294
  Date.now() - startedAt,
295
+ dispatchMeta,
264
296
  );
265
297
  attachDispatchId(error, dispatchId);
266
298
  throw error;
267
299
  }
268
300
  }
269
301
 
270
- async #invokeAgentRun(agentType, prompt, opts, callOpts) {
302
+ async #invokeAgentRun(agentType, prompt, opts, callOpts, dispatchMeta = {}) {
271
303
  const signal = opts.signal;
272
- const cancellationId = opts.cancellationId ?? (signal ? randomUUID() : undefined);
304
+ // `flow` REQUIRES a cancellationId server-side (stratum/ts/src/mcp/server.ts:182-186):
305
+ // without an owned process group there is nothing for a cross-process cancel to kill.
306
+ // Minting one without a signal is inert locally — the abort path below is armed only by
307
+ // `signal` — but it is what makes the run reachable from another process at all.
308
+ const cancellationId = opts.cancellationId ?? ((signal || opts.flow) ? randomUUID() : undefined);
309
+ // C51: derive the transport HERE, where the id exists, and hand it to the ledger row.
310
+ dispatchMeta.transportDerived = derivedTransport(agentType, cancellationId);
273
311
  const request = buildAgentRunRequest(agentType, prompt, { ...opts, cancellationId });
274
312
  signal?.throwIfAborted();
275
313
  // Probe under the same deadline as execution. No run has been dispatched yet,
@@ -285,7 +323,8 @@ export class StratumMcpClient {
285
323
  const installed = this.#client.getServerVersion()?.version ?? 'unknown';
286
324
  throw new StratumError('UNSUPPORTED_AGENT_OPTIONS',
287
325
  `Installed Stratum ${installed} does not support ${missing.join(', ')} required by this call; ` +
288
- 'required execution surface: 17 (@smartmemory/stratum >=0.4.0).', '');
326
+ `required execution surface: ${REQUIRED_STRATUM_SURFACE} `
327
+ + `(@smartmemory/stratum ${REQUIRED_STRATUM_RANGE}).`, '');
289
328
  }
290
329
  }
291
330
  signal?.throwIfAborted();
@@ -878,6 +917,66 @@ export class StratumMcpClient {
878
917
  async cancelAgentRun(runId) {
879
918
  return this.#callTool('stratum_cancel_agent_run', { runId });
880
919
  }
920
+
921
+ /**
922
+ * Cancel a running FOREGROUND flow by flow id (stratum >= 0.5.0). Settles the run and
923
+ * sweeps the agent groups stratum spawned for it. Resolves on success (including an
924
+ * already-terminal run); throws a StratumError on an incomplete sweep or a refusal.
925
+ */
926
+ async flowCancel(runId) {
927
+ try {
928
+ return await this.#callTool('stratum_flow_cancel', { runId });
929
+ } catch (error) {
930
+ const data = (error && typeof error.data === 'object' && error.data) || {};
931
+ if (error?.code === 'CANCELLATION_UNCONFIRMED' || error?.code === 'CANCELLATION_TEARDOWN_TIMEOUT') {
932
+ const wrapped = new StratumError(error.code, error.message, '');
933
+ wrapped.status = data.status ?? null;
934
+ wrapped.flowSettled = data.flowSettled === true;
935
+ wrapped.reason = data.reason ?? null;
936
+ wrapped.holderPid = data.holderPid ?? null;
937
+ wrapped.agents = data.agents ?? null;
938
+ if (error.rpcCode !== undefined) wrapped.rpcCode = error.rpcCode;
939
+ throw wrapped;
940
+ }
941
+ if (isUnknownFlowError(error)) {
942
+ const missing = new StratumError('FLOW_NOT_FOUND', `Stratum has no run ${runId}: ${error.message}`, '');
943
+ missing.status = null; missing.flowSettled = false; missing.reason = 'flow_not_found';
944
+ missing.holderPid = null; missing.agents = null;
945
+ throw missing;
946
+ }
947
+ // C38: NORMALISE, never rethrow. A dead server, a broken pipe or an undeclared engine
948
+ // error must still land in abortBuild's outcome table, and a refusal sweeps nothing —
949
+ // so flowSettled is false and the counters are zero, which is the literal truth.
950
+ throw asTransportRefusal(error);
951
+ }
952
+ }
953
+ }
954
+
955
+ /** The all-zero agent-sweep counters used whenever a refusal swept nothing. */
956
+ const ZERO_AGENTS = Object.freeze({
957
+ signalled: 0, reaped: 0, gone: 0, unreachable: 0,
958
+ alreadySettled: 0, unresolved: 0, unsettled: 0, unreaped: 0,
959
+ });
960
+
961
+ /** The one shape every unclassifiable cancel failure takes. Exported because abortBuild wraps
962
+ * its own connect() in it: connect happens OUTSIDE flowCancel, so a spawn or handshake failure
963
+ * would otherwise bypass the table entirely (C38). */
964
+ export function asTransportRefusal(error) {
965
+ const refusal = new StratumError('CANCELLATION_UNCONFIRMED',
966
+ `Stratum cancel could not be attempted: ${error?.message ?? String(error)}`, '');
967
+ refusal.status = null; refusal.flowSettled = false; refusal.reason = 'transport';
968
+ refusal.holderPid = null; refusal.agents = ZERO_AGENTS;
969
+ return refusal;
970
+ }
971
+
972
+ /** An unknown run id has NO structured envelope on the MCP surface — the SDK wraps the raw
973
+ * ENOENT as a generic InternalError with no `data` at all. Both halves are required: `data`
974
+ * absent AND an ENOENT-shaped message. Over-matching here turns a LIVE build into "nothing to
975
+ * cancel", which is the one misclassification on this surface that loses work. */
976
+ export function isUnknownFlowError(error) {
977
+ if (!error || error.data !== undefined) return false;
978
+ if (typeof error.code === 'string') return false; // a coded failure is never this path
979
+ return /ENOENT|no such run|not found/i.test(String(error.message ?? ''));
881
980
  }
882
981
 
883
982
  function failureUsage(error) {
@@ -35,7 +35,6 @@ export const EFFECTS = /** @type {const} */ (['read', 'mutating', 'setup']);
35
35
  */
36
36
  export const CANON_IDS = new Set([
37
37
  'roadmap', 'changelog', 'feature-json', 'judgment',
38
- 'override-ledger', 'override-attest', 'override-grants',
39
38
  ]);
40
39
 
41
40
  /**
@@ -75,10 +75,16 @@ async function fetchLatest(pkg) {
75
75
  export function compareVersions(a, b) {
76
76
  if (typeof a !== 'string' || typeof b !== 'string') return null
77
77
  const parse = (s) => {
78
- const [core, pre] = s.split('-')
79
- const parts = core.split('.').map(n => Number.parseInt(n, 10))
78
+ const buildIndex = s.indexOf('+')
79
+ const withoutBuild = buildIndex === -1 ? s : s.slice(0, buildIndex)
80
+ const prereleaseIndex = withoutBuild.indexOf('-')
81
+ const core = prereleaseIndex === -1 ? withoutBuild : withoutBuild.slice(0, prereleaseIndex)
82
+ const pre = prereleaseIndex === -1 ? null : withoutBuild.slice(prereleaseIndex + 1)
83
+ // parseInt is lenient: '3garbage' -> 3. A strict all-digits test is what makes
84
+ // the NaN guard below actually fire for trailing junk (COMP-SEMVER-STRICT).
85
+ const parts = core.split('.').map(n => (/^\d+$/.test(n) ? Number(n) : NaN))
80
86
  if (parts.length !== 3 || parts.some(n => Number.isNaN(n))) return null
81
- return { parts, pre: pre ?? null }
87
+ return { parts, pre }
82
88
  }
83
89
  const pa = parse(a)
84
90
  const pb = parse(b)