@smartmemory/compose 0.4.0 → 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.
- package/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +1 -1
- package/bin/compose.js +33 -14
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- 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
|
+
}
|
package/lib/result-normalizer.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/tool-inventory.js
CHANGED
package/lib/version-check.js
CHANGED
|
@@ -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
|
|
79
|
-
const
|
|
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
|
|
87
|
+
return { parts, pre }
|
|
82
88
|
}
|
|
83
89
|
const pa = parse(a)
|
|
84
90
|
const pb = parse(b)
|