@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.4.1",
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=300000 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",
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=300000 test/integration/*.test.js",
27
- "test:wave-6": "node --test --test-timeout=300000 test/wave-6-integration.test.js test/wave-6-contract-compliance.test.js",
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.4.5",
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 = watch(this.#composeDir, (eventType, filename) => {
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._onFileChange(full).catch(err => {
291
- console.warn(`[cc-watcher] change handler failed: ${err.message}`);
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._onFileChange(f);
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
- /** True iff a valid, non-agent-mintable override token accompanies the call. */
72
- function _overrideOk(args) {
73
- const expected = process.env.STRATUM_GUARD_OVERRIDE_TOKEN;
74
- return !!expected && args?.override_token === expected;
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
- if (!_overrideOk(args)) {
81
- const e = new Error(
82
- `${toolName}: force is disabled under capabilities.guard supply a valid override_token ` +
83
- `(out-of-band STRATUM_GUARD_OVERRIDE_TOKEN; not agent-mintable) to deviate, or drive the ` +
84
- `change through the lifecycle.`,
85
- );
86
- e.code = 'FORCE_REQUIRES_OVERRIDE';
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
- if (!_overrideOk(args)) {
107
- const e = new Error(
108
- `${toolName}: status ${status} is lifecycle-owned under capabilities.guard drive it through ` +
109
- `/lifecycle (evidence-gated for complete, guarded for kill) instead of setting it directly, ` +
110
- `or supply a valid override_token.`,
111
- );
112
- e.code = 'STATUS_OWNED_BY_LIFECYCLE';
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) or on a valid
1006
- * override token. On unresolved CONTEXT the behavior is graduated, NOT blanket
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
- `Supply a valid override_token to deviate.`,
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;
@@ -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;
@@ -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 '../src/components/vision/designSessionState.js';
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';
@@ -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);
@@ -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
- app.use(express.static(_distDir, { index: false }));
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>