@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Structured AI dev pipeline: your agent writes the code, Compose makes it prove it. Gated design decisions, enforced postconditions, and independent review from goal to shipped code.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"dev:client": "vite",
|
|
21
21
|
"build": "vite build",
|
|
22
22
|
"preview": "vite preview",
|
|
23
|
-
"test": "node --import ./test/suppress-expected-drift.js --test --test-timeout=
|
|
23
|
+
"test": "node --import ./test/suppress-expected-drift.js --test --test-timeout=900000 test/*.test.js test/comp-obs-branch/*.test.js test/integration/*.test.js test/golden/*.test.js && npm run test:ui && npm run test:tracker",
|
|
24
24
|
"test:ui": "vitest run",
|
|
25
25
|
"test:tracker": "vitest run --config vitest.tracker.config.js",
|
|
26
|
-
"test:integration": "node --test --test-timeout=
|
|
27
|
-
"test:wave-6": "node --test --test-timeout=
|
|
26
|
+
"test:integration": "node --test --test-timeout=900000 test/integration/*.test.js",
|
|
27
|
+
"test:wave-6": "node --test --test-timeout=900000 test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
|
|
28
28
|
"prepublishOnly": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
},
|
|
56
56
|
"files": [
|
|
57
57
|
".compose-deps.json",
|
|
58
|
+
".claude/agents/**",
|
|
59
|
+
".claude/hooks/**",
|
|
58
60
|
".claude/skills/**",
|
|
59
61
|
"bin/**",
|
|
60
62
|
"server/**",
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
87
89
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
88
90
|
"@smartmemory/sdk-js": "^1.4.60",
|
|
89
|
-
"@smartmemory/stratum": "^0.
|
|
91
|
+
"@smartmemory/stratum": "^0.5.0",
|
|
90
92
|
"@tanstack/react-virtual": "^3.13.23",
|
|
91
93
|
"ajv": "^8.18.0",
|
|
92
94
|
"ajv-formats": "^3.0.1",
|
|
@@ -45,18 +45,26 @@ export class BuildStreamBridge {
|
|
|
45
45
|
#debounceTimer = null;
|
|
46
46
|
#crashTimer = null;
|
|
47
47
|
#polling = false;
|
|
48
|
+
#safetyInterval = null;
|
|
49
|
+
#watchFn;
|
|
50
|
+
#pollIntervalMs;
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* @param {string} composeDir Path to .compose directory
|
|
51
54
|
* @param {Function} broadcast broadcast(msg) function from agent-server
|
|
52
55
|
* @param {object} [opts]
|
|
53
56
|
* @param {number} [opts.crashTimeoutMs] Crash detection timeout (default 5min)
|
|
57
|
+
* @param {number} [opts.pollIntervalMs] Safety re-read cadence (default 2s)
|
|
58
|
+
* @param {Function} [opts.watchFn] Injected for tests that need a watcher
|
|
59
|
+
* which never delivers — the condition the safety poll exists to survive.
|
|
54
60
|
*/
|
|
55
61
|
constructor(composeDir, broadcast, opts = {}) {
|
|
56
62
|
this.#composeDir = composeDir;
|
|
57
63
|
this.#filePath = join(composeDir, JSONL_FILENAME);
|
|
58
64
|
this.#broadcast = broadcast;
|
|
59
65
|
this.#crashTimeoutMs = opts.crashTimeoutMs ?? DEFAULT_CRASH_TIMEOUT_MS;
|
|
66
|
+
this.#pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
67
|
+
this.#watchFn = opts.watchFn ?? watch;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
@@ -97,6 +105,10 @@ export class BuildStreamBridge {
|
|
|
97
105
|
clearInterval(this.#pollInterval);
|
|
98
106
|
this.#pollInterval = null;
|
|
99
107
|
}
|
|
108
|
+
if (this.#safetyInterval) {
|
|
109
|
+
clearInterval(this.#safetyInterval);
|
|
110
|
+
this.#safetyInterval = null;
|
|
111
|
+
}
|
|
100
112
|
if (this.#debounceTimer) {
|
|
101
113
|
clearTimeout(this.#debounceTimer);
|
|
102
114
|
this.#debounceTimer = null;
|
|
@@ -112,8 +124,11 @@ export class BuildStreamBridge {
|
|
|
112
124
|
// ---------------------------------------------------------------------------
|
|
113
125
|
|
|
114
126
|
_startWatching() {
|
|
127
|
+
// The safety poll is armed FIRST and unconditionally, because the watcher
|
|
128
|
+
// cannot be trusted to be listening (see _startSafetyPoll).
|
|
129
|
+
this._startSafetyPoll();
|
|
115
130
|
try {
|
|
116
|
-
this.#watcher =
|
|
131
|
+
this.#watcher = this.#watchFn(this.#composeDir, (eventType, filename) => {
|
|
117
132
|
if (filename === JSONL_FILENAME || filename === null) {
|
|
118
133
|
this._debouncedRead();
|
|
119
134
|
}
|
|
@@ -129,6 +144,33 @@ export class BuildStreamBridge {
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Re-read on a timer for as long as we are tailing, regardless of the watcher.
|
|
149
|
+
*
|
|
150
|
+
* `fs.watch` is an OPTIMISATION here, never the guarantee. It is not armed
|
|
151
|
+
* when the call returns: on macOS libuv registers the FSEvents stream
|
|
152
|
+
* asynchronously, so writes landing between `watch()` returning and the stream
|
|
153
|
+
* actually listening are delivered to nobody. `start()` arms the watcher and
|
|
154
|
+
* then reads synchronously, which is exactly that window — and under load the
|
|
155
|
+
* window stretches to cover a whole build's first events.
|
|
156
|
+
*
|
|
157
|
+
* Measured before this existed (600 runs of the scenario, 6 concurrent
|
|
158
|
+
* processes, full suite as load): 14/450 runs saw the watcher deliver NOTHING
|
|
159
|
+
* after start, so the bridge broadcast the pre-existing lines and then went
|
|
160
|
+
* permanently deaf — no periodic re-check existed to recover it. The same 450
|
|
161
|
+
* runs with a settle delay before the writes: 0 failures. That was a live
|
|
162
|
+
* cockpit stream that silently never starts, not a test-timing problem.
|
|
163
|
+
*
|
|
164
|
+
* A missed event is therefore recoverable rather than terminal. `_readNewLines`
|
|
165
|
+
* exits on a single `statSync` when the file has not grown, so the standing
|
|
166
|
+
* cost is one stat per interval.
|
|
167
|
+
*/
|
|
168
|
+
_startSafetyPoll() {
|
|
169
|
+
if (this.#safetyInterval) return;
|
|
170
|
+
this.#safetyInterval = setInterval(() => this._readNewLines(), this.#pollIntervalMs);
|
|
171
|
+
this.#safetyInterval.unref();
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
_pollForDirectory() {
|
|
133
175
|
if (this.#polling) return;
|
|
134
176
|
this.#polling = true;
|
|
@@ -68,6 +68,8 @@ export class CCSessionWatcher {
|
|
|
68
68
|
// COMP-OBS-DRIFT: optional deps for post-lineage drift broadcast
|
|
69
69
|
emitDriftAxes = null,
|
|
70
70
|
projectRoot = null,
|
|
71
|
+
// Backstop-poll cadence. Injectable so a test need not wait 2s for it.
|
|
72
|
+
pollIntervalMs = 2000,
|
|
71
73
|
}) {
|
|
72
74
|
if (!projectsRoot) throw new Error('projectsRoot required');
|
|
73
75
|
if (!sessionsFile) throw new Error('sessionsFile required');
|
|
@@ -87,6 +89,7 @@ export class CCSessionWatcher {
|
|
|
87
89
|
// COMP-OBS-DRIFT: optional drift emitter
|
|
88
90
|
this._emitDriftAxes = emitDriftAxes;
|
|
89
91
|
this._projectRoot = projectRoot;
|
|
92
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
90
93
|
|
|
91
94
|
// featureCode → (cc_session_id → BranchOutcome[])
|
|
92
95
|
this._accum = new Map();
|
|
@@ -272,6 +275,29 @@ export class CCSessionWatcher {
|
|
|
272
275
|
if (scanned) await this._flush([scanned.featureCode]);
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Dispatch one file change, never concurrently with another.
|
|
280
|
+
*
|
|
281
|
+
* `_flush` is idempotent SEQUENTIALLY — the lineage POST is a replace-by-key
|
|
282
|
+
* (`updateLifecycleExt`), and a fork already in `emitted_event_ids` is not
|
|
283
|
+
* re-broadcast. It is NOT idempotent CONCURRENTLY: `emitted.add(eventId)` runs
|
|
284
|
+
* only after `await postBranchLineage`, so two overlapping flushes both see
|
|
285
|
+
* the same fork as new and both broadcast its DecisionEvent.
|
|
286
|
+
*
|
|
287
|
+
* With the watcher as the sole dispatcher that overlap was rare. Arming the
|
|
288
|
+
* safety poll beside it (see `start`) would have made it ordinary, so
|
|
289
|
+
* dispatch is serialised here rather than left to chance — which also closes
|
|
290
|
+
* the same pre-existing overlap between `fullScan()` and a watcher event.
|
|
291
|
+
*/
|
|
292
|
+
_dispatch(jsonlPath) {
|
|
293
|
+
this._chain = (this._chain ?? Promise.resolve())
|
|
294
|
+
.then(() => this._onFileChange(jsonlPath))
|
|
295
|
+
.catch(err => {
|
|
296
|
+
console.warn(`[cc-watcher] change handler failed: ${err.message}`);
|
|
297
|
+
});
|
|
298
|
+
return this._chain;
|
|
299
|
+
}
|
|
300
|
+
|
|
275
301
|
start() {
|
|
276
302
|
// C5: the fs.watch fallback leaves `_watcher` null, so guarding on it alone
|
|
277
303
|
// let every resume() start ANOTHER poll interval — one leaked per switch.
|
|
@@ -279,6 +305,20 @@ export class CCSessionWatcher {
|
|
|
279
305
|
if (!fs.existsSync(this.projectsRoot)) {
|
|
280
306
|
fs.mkdirSync(this.projectsRoot, { recursive: true });
|
|
281
307
|
}
|
|
308
|
+
// The poll is armed ALONGSIDE the watcher, not only when fs.watch throws.
|
|
309
|
+
//
|
|
310
|
+
// A watcher that stops delivering does not throw and does not emit 'error';
|
|
311
|
+
// it simply goes quiet, and every subsequent session write is lost with no
|
|
312
|
+
// symptom. Polling used to be reachable only from the synchronous throw
|
|
313
|
+
// below, so the one failure mode that actually needs recovery — a live
|
|
314
|
+
// watcher that has silently stopped — had none, and missed CC sessions meant
|
|
315
|
+
// branch DecisionEvents that never fire and never self-heal.
|
|
316
|
+
//
|
|
317
|
+
// (Unlike the build-stream bridge, the fs.watch ARMING window is not the
|
|
318
|
+
// hazard here: CC sessions are written minutes after start, not in the
|
|
319
|
+
// microseconds between `watch()` returning and the stream listening. The
|
|
320
|
+
// justification is dead-watcher recovery.)
|
|
321
|
+
this._startPolling();
|
|
282
322
|
try {
|
|
283
323
|
this._watcher = fs.watch(this.projectsRoot, { recursive: true }, (_evt, filename) => {
|
|
284
324
|
if (!filename || !filename.endsWith('.jsonl')) return;
|
|
@@ -287,9 +327,16 @@ export class CCSessionWatcher {
|
|
|
287
327
|
const now = Date.now();
|
|
288
328
|
if (now - last < DEFAULT_DEBOUNCE_MS) return;
|
|
289
329
|
this._debounce.set(full, now);
|
|
290
|
-
this.
|
|
291
|
-
|
|
292
|
-
|
|
330
|
+
this._dispatch(full);
|
|
331
|
+
});
|
|
332
|
+
// A watcher can die AFTER construction. Without this the failure is
|
|
333
|
+
// completely silent; the poll below is already running, so recovery is
|
|
334
|
+
// just dropping the dead handle.
|
|
335
|
+
this._watcher.on?.('error', (err) => {
|
|
336
|
+
console.warn(`[cc-watcher] watcher died, polling continues: ${err?.message}`);
|
|
337
|
+
try { this._watcher?.close(); } catch { /* already gone */ }
|
|
338
|
+
this._watcher = null;
|
|
339
|
+
this._startPolling();
|
|
293
340
|
});
|
|
294
341
|
} catch (err) {
|
|
295
342
|
console.warn(`[cc-watcher] fs.watch unavailable, falling back to polling: ${err.message}`);
|
|
@@ -297,7 +344,7 @@ export class CCSessionWatcher {
|
|
|
297
344
|
}
|
|
298
345
|
}
|
|
299
346
|
|
|
300
|
-
_startPolling(intervalMs = 2000) {
|
|
347
|
+
_startPolling(intervalMs = this.pollIntervalMs ?? 2000) {
|
|
301
348
|
if (this._pollTimer) return;
|
|
302
349
|
this._pollTimer = setInterval(async () => {
|
|
303
350
|
const files = listJsonlFiles(this.projectsRoot);
|
|
@@ -308,10 +355,12 @@ export class CCSessionWatcher {
|
|
|
308
355
|
const last = this._debounce.get(key) || 0;
|
|
309
356
|
if (stat.mtimeMs > last) {
|
|
310
357
|
this._debounce.set(key, stat.mtimeMs);
|
|
311
|
-
await this.
|
|
358
|
+
await this._dispatch(f);
|
|
312
359
|
}
|
|
313
360
|
}
|
|
314
361
|
}, intervalMs);
|
|
362
|
+
// Never hold the process open on this alone — it is a backstop, not work.
|
|
363
|
+
this._pollTimer.unref?.();
|
|
315
364
|
}
|
|
316
365
|
|
|
317
366
|
stop() {
|
|
@@ -68,24 +68,46 @@ function _guardOn(capsOverride) {
|
|
|
68
68
|
try { return loadProjectConfig()?.capabilities?.guard === true; } catch { return false; }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
/**
|
|
72
|
+
* COMP-MCP-ENFORCE Slice 3, amended 2026-09-07: the override token is GONE.
|
|
73
|
+
*
|
|
74
|
+
* These gates used to admit a caller who supplied `override_token` matching this
|
|
75
|
+
* server process's `STRATUM_GUARD_OVERRIDE_TOKEN`. The audit in
|
|
76
|
+
* docs/decisions/2026-09-07-override-token-audit.md removed it, on evidence
|
|
77
|
+
* rather than on threat-modelling taste:
|
|
78
|
+
*
|
|
79
|
+
* - the hatch had no user and could not have one — the variable was unset in
|
|
80
|
+
* every environment we ship, and `override_token` appeared in no MCP tool
|
|
81
|
+
* schema, so no agent could discover it and no operator workflow used it;
|
|
82
|
+
* - both capabilities it nominally unlocked already have first-class doors:
|
|
83
|
+
* KILLED through the guarded lifecycle route (`kill_feature`), COMPLETE
|
|
84
|
+
* through the completion gate (which `lib/feature-writer.js` enforces
|
|
85
|
+
* unconditionally anyway);
|
|
86
|
+
* - what remained was `force`, i.e. skipping the roadmap transition table and
|
|
87
|
+
* the prose-loss and duplicate-match refusals — the thing these gates exist
|
|
88
|
+
* to stop, kept reachable by a secret harder to use than editing the file;
|
|
89
|
+
* - and it was never the real protection. Anything that can call these tools
|
|
90
|
+
* can write the files directly. The tamper-EVIDENT ledger and the pre-push
|
|
91
|
+
* canon guard are what hold; this gate only ever stopped a well-meaning
|
|
92
|
+
* agent from casually passing force:true, and a plain refusal does that
|
|
93
|
+
* better than a secret nobody can hold.
|
|
94
|
+
*
|
|
95
|
+
* If a break-glass path is ever genuinely needed, the answer is stratum's signed
|
|
96
|
+
* one-shot authorization (`stratum/ts/src/guard/authorization.ts`), not a shared
|
|
97
|
+
* secret — and the trigger for building it is a real incident where someone hit
|
|
98
|
+
* one of these refusals with nowhere to go.
|
|
99
|
+
*/
|
|
76
100
|
|
|
77
101
|
export function assertForceAuthorized(args, toolName, capsOverride) {
|
|
78
102
|
if (!args?.force) return;
|
|
79
103
|
if (!_guardOn(capsOverride)) return;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
throw e;
|
|
88
|
-
}
|
|
104
|
+
const e = new Error(
|
|
105
|
+
`${toolName}: force is disabled under capabilities.guard. There is no override token — ` +
|
|
106
|
+
`drive the change through the lifecycle (/lifecycle routes, kill_feature) or the completion ` +
|
|
107
|
+
`gate (record_completion). If neither can express it, that is a gap to fix, not to bypass.`,
|
|
108
|
+
);
|
|
109
|
+
e.code = 'FORCE_REQUIRES_OVERRIDE';
|
|
110
|
+
throw e;
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
/**
|
|
@@ -103,15 +125,13 @@ export function assertTerminalStatusAuthorized(args, toolName, capsOverride) {
|
|
|
103
125
|
const status = args?.status;
|
|
104
126
|
if (!status || !LIFECYCLE_OWNED_STATUS.has(status)) return;
|
|
105
127
|
if (!_guardOn(capsOverride)) return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
throw e;
|
|
114
|
-
}
|
|
128
|
+
const e = new Error(
|
|
129
|
+
`${toolName}: status ${status} is lifecycle-owned under capabilities.guard — drive it through ` +
|
|
130
|
+
`/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly. ` +
|
|
131
|
+
`There is no override token.`,
|
|
132
|
+
);
|
|
133
|
+
e.code = 'STATUS_OWNED_BY_LIFECYCLE';
|
|
134
|
+
throw e;
|
|
115
135
|
}
|
|
116
136
|
import { resolveWorkspace } from '../lib/resolve-workspace.js';
|
|
117
137
|
import { discoverWorkspaces } from '../lib/discover-workspaces.js';
|
|
@@ -474,29 +494,6 @@ export async function toolAddChangelogEntry(args) {
|
|
|
474
494
|
return addChangelogEntry(getTargetRoot(), args);
|
|
475
495
|
}
|
|
476
496
|
|
|
477
|
-
/**
|
|
478
|
-
* COMP-CANON-OVERRIDE — mint a single-use, path-scoped canon override.
|
|
479
|
-
*
|
|
480
|
-
* `actor` is deliberately not forwarded from args: it is stamped by the writer
|
|
481
|
-
* per Decision 3 and must never be caller-supplied.
|
|
482
|
-
*/
|
|
483
|
-
export async function toolCanonOverrideGrant(args) {
|
|
484
|
-
const { mintGrant } = await import('../lib/canon-override.js');
|
|
485
|
-
const { loadFeaturesDir } = await import('../lib/project-paths.js');
|
|
486
|
-
const root = getTargetRoot();
|
|
487
|
-
const grant = mintGrant(root, {
|
|
488
|
-
path: args?.path,
|
|
489
|
-
reason: args?.reason,
|
|
490
|
-
operation: args?.operation,
|
|
491
|
-
featuresDir: loadFeaturesDir(root),
|
|
492
|
-
});
|
|
493
|
-
return {
|
|
494
|
-
...grant,
|
|
495
|
-
recorded_in: '.compose/canon-overrides.jsonl',
|
|
496
|
-
note: 'Single-use and path-scoped. Audit tooling for the Claude Write/Edit path, not enforcement.',
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
|
|
500
497
|
export async function toolGetChangelogEntries(args) {
|
|
501
498
|
const { getChangelogEntries } = await import('../lib/changelog-writer.js');
|
|
502
499
|
return getChangelogEntries(getTargetRoot(), args);
|
|
@@ -1002,8 +999,10 @@ function _targetMatchesBoundFeature(tool, args) {
|
|
|
1002
999
|
|
|
1003
1000
|
/**
|
|
1004
1001
|
* Throw PHASE_TOOL_DENIED if the tool is not allowed for the current
|
|
1005
|
-
* profile×phase. No-op when the capability is off (default)
|
|
1006
|
-
*
|
|
1002
|
+
* profile×phase. No-op when the capability is off (default). The override-token
|
|
1003
|
+
* escape was REMOVED 2026-09-07 with the other two (see the note above
|
|
1004
|
+
* `assertForceAuthorized`): same secret, same absence of any caller who could
|
|
1005
|
+
* hold it. On unresolved CONTEXT the behavior is graduated, NOT blanket
|
|
1007
1006
|
* fail-open: an unresolved PROFILE (no/unknown env) normalizes to orchestrator →
|
|
1008
1007
|
* unrestricted; an unresolved PHASE only fails open the phase *refinement* — the
|
|
1009
1008
|
* profile BASE policy (implementer deny / reviewer allowlist) still applies
|
|
@@ -1013,7 +1012,6 @@ function _targetMatchesBoundFeature(tool, args) {
|
|
|
1013
1012
|
export function assertToolPhaseAllowed(tool, args = {}, _testCtx) {
|
|
1014
1013
|
const guardOn = _testCtx?.phaseScopedTools ?? (loadProjectConfig()?.capabilities?.phaseScopedTools === true);
|
|
1015
1014
|
if (!guardOn) return;
|
|
1016
|
-
if (_overrideOk(args)) return;
|
|
1017
1015
|
|
|
1018
1016
|
const profile = _testCtx?.profile ?? sessionContext().profile;
|
|
1019
1017
|
const phase = _testCtx?.phase ?? resolveBoundPhase();
|
|
@@ -1024,7 +1022,7 @@ export function assertToolPhaseAllowed(tool, args = {}, _testCtx) {
|
|
|
1024
1022
|
const e = new Error(
|
|
1025
1023
|
`${tool} is not available to profile '${profile}'` +
|
|
1026
1024
|
(phase ? ` in phase '${phase}'` : '') + `: ${verdict.reason}. ` +
|
|
1027
|
-
`
|
|
1025
|
+
`There is no override token; use a session bound to a profile that owns this tool.`,
|
|
1028
1026
|
);
|
|
1029
1027
|
e.code = 'PHASE_TOOL_DENIED';
|
|
1030
1028
|
e.profile = profile;
|
package/server/compose-mcp.js
CHANGED
|
@@ -55,7 +55,6 @@ import {
|
|
|
55
55
|
toolGetFeatureLinks,
|
|
56
56
|
toolProposeFollowup,
|
|
57
57
|
toolAddChangelogEntry,
|
|
58
|
-
toolCanonOverrideGrant,
|
|
59
58
|
toolGetChangelogEntries,
|
|
60
59
|
toolWriteJournalEntry,
|
|
61
60
|
toolGetJournalEntries,
|
|
@@ -175,7 +174,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
175
174
|
case 'get_feature_artifacts': result = await toolGetFeatureArtifacts(args); break;
|
|
176
175
|
case 'get_feature_links': result = await toolGetFeatureLinks(args); break;
|
|
177
176
|
case 'add_changelog_entry': result = await toolAddChangelogEntry(args); break;
|
|
178
|
-
case 'canon_override_grant': result = await toolCanonOverrideGrant(args); break;
|
|
179
177
|
case 'get_changelog_entries': result = await toolGetChangelogEntries(args); break;
|
|
180
178
|
case 'write_journal_entry': result = await toolWriteJournalEntry(args); break;
|
|
181
179
|
case 'get_journal_entries': result = await toolGetJournalEntries(args); break;
|
package/server/design-routes.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
|
-
import { parseDecisionBlocks } from '../
|
|
16
|
+
import { parseDecisionBlocks } from '../lib/decision-blocks.js';
|
|
17
17
|
import { StratumMcpClient } from '../lib/stratum-mcp-client.js';
|
|
18
18
|
import { KNOWN_VERSIONS } from '../lib/build-stream-schema.js';
|
|
19
19
|
import { getTargetRoot, resolveProjectPath, trackProjectWork } from './project-root.js';
|
package/server/file-watcher.js
CHANGED
|
@@ -288,6 +288,20 @@ export class FileWatcherServer {
|
|
|
288
288
|
|
|
289
289
|
onChanged(relativePath, fullPath);
|
|
290
290
|
});
|
|
291
|
+
// A watcher can die AFTER construction, and fs.watch reports that as an
|
|
292
|
+
// 'error' event, not a throw — with no handler Node has historically
|
|
293
|
+
// treated it as unhandled. Either way the failure was completely silent:
|
|
294
|
+
// the pane simply stops updating.
|
|
295
|
+
//
|
|
296
|
+
// Deliberately NOT given the backstop poll that cc-session-watcher and
|
|
297
|
+
// the build-stream bridge now carry. Those lose data that never returns
|
|
298
|
+
// (branch DecisionEvents, a build's live output); this one loses a
|
|
299
|
+
// hot-reload, and the REST path already serves current content on load,
|
|
300
|
+
// so a refresh recovers it. A recursive re-stat of docs/** on a timer is
|
|
301
|
+
// real cost for a recoverable symptom. Logged, so it stops being silent.
|
|
302
|
+
watcher.on?.('error', (err) => {
|
|
303
|
+
console.error(`[file-watcher] watch on ${prefix}/ died — changes there stop live-updating until restart: ${err?.message}`);
|
|
304
|
+
});
|
|
291
305
|
this.watchers.push(watcher);
|
|
292
306
|
} catch (err) {
|
|
293
307
|
console.error(`[file-watcher] Failed to watch ${prefix}/:`, err.message);
|
package/server/ideabox-routes.js
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
IdeaboxRenderFailed,
|
|
52
52
|
} from '../lib/fluid/ideabox-ops.js'
|
|
53
53
|
import { writeIdeaboxProjection } from '../lib/fluid/render-ideabox.js'
|
|
54
|
+
import { IdeaboxMigrationConflict, IdeaboxUnreadable } from '../lib/fluid/ideabox-migrate.js'
|
|
54
55
|
import { ideaboxView, toClientIdeaWith } from '../lib/fluid/ideabox-view.js'
|
|
55
56
|
import { relForDisplay } from '../lib/project-paths.js'
|
|
56
57
|
|
|
@@ -109,6 +110,12 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, broadcastMessage }) {
|
|
|
109
110
|
if (err instanceof IdeaboxNotFound) return res.status(404).json({ error: err.message, code: err.code })
|
|
110
111
|
if (err instanceof IdeaboxInvalid) return res.status(400).json({ error: err.message, code: err.code, field: err.field })
|
|
111
112
|
if (err instanceof IdeaboxConflict) return res.status(409).json({ error: err.message, code: err.code })
|
|
113
|
+
// The migration gate's two refusals. Both mean "the file on disk is in a
|
|
114
|
+
// state this operation must not write over", which is a conflict, not a
|
|
115
|
+
// server fault — and the client needs the code to say so.
|
|
116
|
+
if (err instanceof IdeaboxMigrationConflict || err instanceof IdeaboxUnreadable) {
|
|
117
|
+
return res.status(409).json({ error: err.message, code: err.code })
|
|
118
|
+
}
|
|
112
119
|
return res.status(500).json({ error: err.message })
|
|
113
120
|
}
|
|
114
121
|
}
|
|
@@ -214,6 +221,9 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, broadcastMessage }) {
|
|
|
214
221
|
app.post('/api/ideabox/render', async (_req, res) => {
|
|
215
222
|
await send(res, async () => {
|
|
216
223
|
const ctx = await context()
|
|
224
|
+
// The migration gate is no longer applied here: it lives inside
|
|
225
|
+
// `writeIdeaboxProjection`, which every projection write goes through.
|
|
226
|
+
// This route reached that writer without a guard once already.
|
|
217
227
|
await writeIdeaboxProjection(ctx.provider, ctx.ideaboxPath)
|
|
218
228
|
return { body: { ok: true } }
|
|
219
229
|
})
|
package/server/index.js
CHANGED
|
@@ -222,7 +222,11 @@ const _distExists = () => {
|
|
|
222
222
|
catch { return false; }
|
|
223
223
|
};
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
// Source checkouts keep Vite/HMR on :5195. Published installs have no Vite or
|
|
226
|
+
// src/ by design, so compose start serves the prebuilt desktop shell on :4001.
|
|
227
|
+
app.use(express.static(_distDir, {
|
|
228
|
+
index: process.env.COMPOSE_PACKAGED_UI === '1' ? 'index.html' : false,
|
|
229
|
+
}));
|
|
226
230
|
|
|
227
231
|
// /m/* SPA fallback — paths matching /m or /m/...
|
|
228
232
|
app.get(/^\/m(\/|$)/, (_req, res) => {
|
|
@@ -70,6 +70,19 @@ export const isGuardError = (result) => !result || Boolean(result.error) || resu
|
|
|
70
70
|
export const guardErrorType = (result) => (result && (result.error?.code ?? result.error_type)) ?? null;
|
|
71
71
|
export const guardErrorMessage = (result) => (result && (result.error?.message ?? result.message)) ?? 'no guard response';
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Infrastructure failures: the guard was never reached, or never answered, so
|
|
75
|
+
* the evidence was NOT evaluated. Distinct from a refusal (the guard ran and
|
|
76
|
+
* said no) and from a policy error (the guard ran and could not apply). Routes
|
|
77
|
+
* must not render these as "refused by guard" — for two months a timed-out
|
|
78
|
+
* `guard transition` was reported to the user as a rejection of their evidence
|
|
79
|
+
* (stratum-client.js, f7865d4), and nothing at the surface could tell the two
|
|
80
|
+
* apart.
|
|
81
|
+
*/
|
|
82
|
+
const GUARD_INFRA_CODES = new Set(['TIMEOUT', 'SPAWN', 'GUARD_UNREACHABLE', 'PARSE_ERROR', 'UNKNOWN']);
|
|
83
|
+
export const isGuardInfraError = (result) =>
|
|
84
|
+
!!result && result.applied !== true && result.refused !== true && GUARD_INFRA_CODES.has(guardErrorType(result));
|
|
85
|
+
|
|
73
86
|
/**
|
|
74
87
|
* Assemble the FULL guarded graph the design requires: the forward
|
|
75
88
|
* `BASE_TRANSITIONS` PLUS the `ship → complete` edge and a `<any non-terminal>
|