@smartmemory/compose 0.3.7 → 0.4.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 (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -124,7 +124,10 @@ export class VisionStore {
124
124
  }
125
125
  }
126
126
 
127
- /** Save state to disk atomically (temp file + rename) */
127
+ /** Save state to disk atomically (temp file + rename).
128
+ * Returns true when the state reached disk, false when it did not. Existing
129
+ * callers ignore the return and keep their prior log-and-continue behavior;
130
+ * callers that must not report success on a failed write check it. */
128
131
  _save() {
129
132
  try {
130
133
  fs.mkdirSync(this._dataDir, { recursive: true });
@@ -132,8 +135,10 @@ export class VisionStore {
132
135
  const tmp = path.join(this._dataDir, `vision-state.json.tmp.${Date.now()}`);
133
136
  fs.writeFileSync(tmp, data, 'utf-8');
134
137
  fs.renameSync(tmp, this._dataFile);
138
+ return true;
135
139
  } catch (err) {
136
140
  console.error('[vision] Failed to save state:', err.message);
141
+ return false;
137
142
  }
138
143
  }
139
144
 
@@ -200,7 +205,7 @@ export class VisionStore {
200
205
  throw new Error(`Invalid phase: ${updates.phase}`);
201
206
  }
202
207
 
203
- const allowed = ['type', 'title', 'description', 'confidence', 'status', 'phase', 'position', 'parentId', 'summary', 'files', 'featureCode', 'stratumFlowId', 'evidence', 'group', 'priority'];
208
+ const allowed = ['type', 'title', 'description', 'confidence', 'status', 'phase', 'position', 'parentId', 'summary', 'files', 'featureCode', 'stratumFlowId', 'evidence', 'group', 'priority', 'completion_projection'];
204
209
  for (const key of allowed) {
205
210
  if (updates[key] !== undefined) {
206
211
  item[key] = updates[key];
@@ -221,7 +226,7 @@ export class VisionStore {
221
226
  item.updatedAt = new Date().toISOString();
222
227
 
223
228
  this.items.set(id, item);
224
- this._save();
229
+ this.lastSaveOk = this._save();
225
230
  return item;
226
231
  }
227
232
 
@@ -243,7 +248,7 @@ export class VisionStore {
243
248
  }
244
249
  item.updatedAt = new Date().toISOString();
245
250
  this.items.set(id, item);
246
- this._save();
251
+ this.lastSaveOk = this._save();
247
252
  return item;
248
253
  }
249
254
 
@@ -262,14 +267,54 @@ export class VisionStore {
262
267
  return item;
263
268
  }
264
269
 
265
- /** Delete an item and all its connections and gates */
270
+ /** Set the additive `fluid_ext` namespace on an item bypasses the generic
271
+ * updateItem allowlist, exactly as updateLifecycle/updateLifecycleExt do for
272
+ * the lifecycle namespace.
273
+ *
274
+ * The fluid-store provider seam (lib/fluid/, PROVIDER-SEAM ruling) is the only
275
+ * caller. It exists because a fluid record carries fields the vision store has
276
+ * no generic slot for (handle, cluster, tags, source, links) and the ruling
277
+ * forbids a second store — so the record rides in one additive namespace on
278
+ * the item rather than leaking record-specific fields into the allowlist.
279
+ *
280
+ * `touch: false` preserves updatedAt, which the provider uses when writing the
281
+ * namespace as part of record CREATION so created_at and updated_at agree. */
282
+ setFluidExt(id, ext, { touch = true } = {}) {
283
+ const item = this.items.get(id);
284
+ if (!item) throw new Error(`Item not found: ${id}`);
285
+ const priorExt = item.fluid_ext;
286
+ const priorUpdatedAt = item.updatedAt;
287
+ item.fluid_ext = ext;
288
+ if (touch) item.updatedAt = new Date().toISOString();
289
+ this.items.set(id, item);
290
+ if (!this._save()) {
291
+ // Roll the in-memory change back and fail. Reporting success here would
292
+ // let the provider append a lifecycle event asserting a change that
293
+ // vanishes on restart — a log that disagrees with the store is worse than
294
+ // a write that visibly failed.
295
+ item.fluid_ext = priorExt;
296
+ item.updatedAt = priorUpdatedAt;
297
+ this.items.set(id, item);
298
+ throw new Error(`[vision] failed to persist fluid_ext for item ${id}`);
299
+ }
300
+ return item;
301
+ }
302
+
303
+ /** Delete an item and all its connections and gates.
304
+ * Throws when the deletion does not reach disk — reporting success on a
305
+ * failed save would let a caller believe a record is gone while it is still
306
+ * on disk to reappear at the next load. */
266
307
  deleteItem(id) {
267
308
  if (!this.items.has(id)) throw new Error(`Item not found: ${id}`);
309
+ const removedItem = this.items.get(id);
310
+ const removedConnections = [];
311
+ const removedGates = [];
268
312
  this.items.delete(id);
269
313
 
270
314
  // Remove connections referencing this item
271
315
  for (const [connId, conn] of this.connections) {
272
316
  if (conn.fromId === id || conn.toId === id) {
317
+ removedConnections.push([connId, conn]);
273
318
  this.connections.delete(connId);
274
319
  }
275
320
  }
@@ -277,11 +322,20 @@ export class VisionStore {
277
322
  // Remove gates associated with this item
278
323
  for (const [gateId, gate] of this.gates) {
279
324
  if (gate.itemId === id) {
325
+ removedGates.push([gateId, gate]);
280
326
  this.gates.delete(gateId);
281
327
  }
282
328
  }
283
329
 
284
- this._save();
330
+ if (!this._save()) {
331
+ // Restore everything the delete removed, then fail. The alternative is an
332
+ // in-memory state that disagrees with disk and a caller told the record
333
+ // is gone when the next load will bring it back.
334
+ this.items.set(id, removedItem);
335
+ for (const [connId, conn] of removedConnections) this.connections.set(connId, conn);
336
+ for (const [gateId, gate] of removedGates) this.gates.set(gateId, gate);
337
+ throw new Error(`[vision] failed to persist deletion of item ${id}`);
338
+ }
285
339
  return { ok: true };
286
340
  }
287
341
 
@@ -13,7 +13,6 @@ import { fileURLToPath } from 'node:url';
13
13
  import { getTargetRoot } from './project-root.js';
14
14
  import { resolveJournalPathFromConfig, relForDisplay } from '../lib/project-paths.js';
15
15
 
16
- const PROJECT_ROOT = getTargetRoot();
17
16
 
18
17
  /**
19
18
  * Read the compose.json config for an ARBITRARY workspace root (not the bound
@@ -83,9 +82,9 @@ export function detectError(tool, input, responseText) {
83
82
  *
84
83
  * @param {object} session — serialized session object
85
84
  * @param {string} transcriptPath
86
- * @param {string} [projectRoot] — defaults to PROJECT_ROOT
85
+ * @param {string} [projectRoot] — defaults to the current request workspace
87
86
  */
88
- export function spawnJournalAgent(session, transcriptPath, projectRoot = PROJECT_ROOT) {
87
+ export function spawnJournalAgent(session, transcriptPath, projectRoot = getTargetRoot()) {
89
88
  // Resolve the journal path against the PASSED projectRoot's own config — not
90
89
  // the process-global cached config of the bound target (COMP-PATHS-EXTERNAL).
91
90
  const journalAbs = resolveJournalPathFromConfig(projectRoot, loadComposeConfig(projectRoot));
@@ -126,7 +125,7 @@ Session data:
126
125
  After writing the entry, update ${journalRel}/README.md with the new entry row.
127
126
  Then commit both files.`;
128
127
 
129
- const cleanEnv = { ...process.env, NO_COLOR: '1' };
128
+ const cleanEnv = { ...process.env, COMPOSE_TARGET: projectRoot, NO_COLOR: '1' };
130
129
  delete cleanEnv.CLAUDECODE;
131
130
  const proc = spawn('claude', ['-p', prompt, '--dangerously-skip-permissions'], {
132
131
  cwd: projectRoot,
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** Persisted work is shared by CLI, vision and SDK processes. Missing is idle;
5
+ * unreadable state is uncertain and must never authorize eviction. */
6
+ export function hasPersistedWork(dataDir) {
7
+ const read = file => {
8
+ try { return JSON.parse(readFileSync(path.join(dataDir, file), 'utf8')); }
9
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
10
+ };
11
+ try {
12
+ const build = read('active-build.json');
13
+ if (build && !['complete', 'completed', 'failed', 'aborted', 'cancelled'].includes(build.status)) return true;
14
+ const agents = read('agents.json');
15
+ const records = Array.isArray(agents) ? agents : Object.values(agents ?? {});
16
+ return records.some(agent => agent?.status === 'running');
17
+ } catch { return true; }
18
+ }
@@ -41,7 +41,7 @@ const EXEMPT_PATHS = new Set([
41
41
  * the workspace header soft-fallback to the target root with a hint header.
42
42
  * When false, missing header surfaces as a WorkspaceUnknown(null) → 400.
43
43
  */
44
- export function createWorkspaceMiddleware({ allowGetFallback = true } = {}) {
44
+ export function createWorkspaceMiddleware({ allowGetFallback = true, resolveKnownWorkspace = () => null } = {}) {
45
45
  return function workspaceMiddleware(req, res, next) {
46
46
  if (EXEMPT_PATHS.has(req.path)) {
47
47
  req.workspace = { id: null, root: getTargetRoot(), source: 'exempt' };
@@ -60,7 +60,7 @@ export function createWorkspaceMiddleware({ allowGetFallback = true } = {}) {
60
60
  // Hard-fail mode: missing header is treated as an unknown id.
61
61
  throw new WorkspaceUnknown(null);
62
62
  }
63
- const resolved = resolveWorkspace({
63
+ const resolved = resolveKnownWorkspace(headerId) || resolveWorkspace({
64
64
  workspaceId: headerId,
65
65
  cwd: getTargetRoot(),
66
66
  });
@@ -0,0 +1,243 @@
1
+ /** A workspace owns its stores, routes and background services for its lifetime.
2
+ * Requests and async work keep their binding even when the UI switches projects.
3
+ */
4
+ import express from 'express';
5
+ import path from 'node:path';
6
+ import { realpathSync } from 'node:fs';
7
+ import { hasPersistedWork } from './workspace-activity.js';
8
+ import { hasDesignWork, closeDesignStratum } from './design-routes.js';
9
+ import { FileWatcherServer } from './file-watcher.js';
10
+ import { VisionStore } from './vision-store.js';
11
+ import { VisionServer } from './vision-server.js';
12
+ import { SessionManager } from './session-manager.js';
13
+ import { attachGraphLayoutRoutes } from './graph-layout-routes.js';
14
+ import { attachAgentProxy } from './remote-utils.js';
15
+ import { scanFeatures, seedFeatures, scanSubPackages, seedSubPackages } from './feature-scan.js';
16
+ import { prepareProject, withProjectContext, resolveProjectPath, switchProject } from './project-root.js';
17
+ import { deriveId } from '../lib/discover-workspaces.js';
18
+ import { probeStratumBin, resolveStratumBin } from '../lib/stratum-engine.js';
19
+
20
+ export class WorkspaceRuntime {
21
+ constructor(httpServer, { agentPort = 4002, maxWorkspaces = 8 } = {}) {
22
+ this.server = httpServer;
23
+ this.agentPort = agentPort;
24
+ if (!Number.isInteger(maxWorkspaces) || maxWorkspaces < 1) throw new Error("maxWorkspaces must be a positive integer");
25
+ this.maxWorkspaces = maxWorkspaces;
26
+ this.contexts = new Map();
27
+ this.active = null;
28
+ // Monotonic stamp for LRU ordering; Date.now() ties within a millisecond.
29
+ this._tick = 0;
30
+ }
31
+
32
+ /** Record a workspace as most-recently-used. */
33
+ touch(context) { if (context) context.usedAt = ++this._tick; return context; }
34
+
35
+ /**
36
+ * Whether a retained workspace still has work that eviction would destroy.
37
+ * The active workspace is never evictable. Everything else is judged on
38
+ * signals the runtime can see: live vision/file sockets, an open session, a
39
+ * running build (`active-build.json`) and running spawned agents
40
+ * (`agents.json`). `isBusy` lets the host add its own signal.
41
+ */
42
+ busy(context) {
43
+ if (context === this.active) return true;
44
+ if (context.visionServer?.clients?.size) return true;
45
+ if (context.fileWatcher?.clients?.size) return true;
46
+ if (context.sessionManager?.currentSession) return true;
47
+ if (context.requests || context.binding.activeWork) return true;
48
+ if (context.visionServer?._healthMonitor?.hasActiveProcesses) return true;
49
+ if (hasDesignWork(context.binding.targetRoot)) return true;
50
+ if (hasPersistedWork(context.binding.dataDir)) return true;
51
+ return Boolean(this.isBusy?.(context));
52
+ }
53
+
54
+ /**
55
+ * Close the least-recently-used idle workspace to make room for another.
56
+ * Retention is an LRU cache, not a hard wall: refusing at the cap stranded
57
+ * users behind "restart to release retained sessions" while every retained
58
+ * workspace sat idle. The cap still refuses when nothing is evictable.
59
+ * @returns {boolean} whether a workspace was evicted
60
+ */
61
+ evictIdle() {
62
+ let victimKey = null; let victim = null;
63
+ for (const [key, context] of this.contexts) {
64
+ if (this.busy(context)) continue;
65
+ if (!victim || (context.usedAt ?? 0) < (victim.usedAt ?? 0)) { victimKey = key; victim = context; }
66
+ }
67
+ if (!victim) return false;
68
+ victim.fileWatcher.close();
69
+ victim.visionServer.close();
70
+ void closeDesignStratum(victim.binding.targetRoot);
71
+ this.contexts.delete(victimKey);
72
+ console.error(`[workspace] Evicted idle workspace ${victim.binding.targetRoot} (cap ${this.maxWorkspaces})`);
73
+ return true;
74
+ }
75
+
76
+ /**
77
+ * Context-map key. The agent server (`createAgentApp`) realpaths its target
78
+ * root, so keying on `path.resolve` alone made `/var/...` and
79
+ * `/private/var/...` two entries for ONE directory — the two capacity counts
80
+ * then disagreed. Both sides now key on the real path.
81
+ */
82
+ key(root) {
83
+ const resolved = path.resolve(root);
84
+ try { return realpathSync(resolved); } catch { return resolved; }
85
+ }
86
+
87
+ /** The retained context for a root, keyed on its real path. */
88
+ get(root) { return this.contexts.get(this.key(root)); }
89
+
90
+ prepare(root, config) {
91
+ const existing = this.get(root);
92
+ // Validate the destination before evicting any retained state.
93
+ const binding = prepareProject(root);
94
+ if (!existing && this.contexts.size >= this.maxWorkspaces && !this.evictIdle()) {
95
+ throw Object.assign(new Error(`Workspace capacity reached (${this.maxWorkspaces}); every retained workspace is busy (open sockets, a live session, a running build or agent)`), { code: 'WorkspaceCapacityExceeded' });
96
+ }
97
+ if (config) binding.config = config;
98
+ else if (binding.config.capabilities?.stratum) {
99
+ const probe = probeStratumBin(resolveStratumBin('cli', binding.targetRoot));
100
+ if (!probe.ok) binding.config.capabilities.stratum = false;
101
+ }
102
+ if (existing) {
103
+ withProjectContext(existing.binding, () => existing.visionServer.refreshConfig(binding.config));
104
+ // One holder, two references: the binding and the vision server must not
105
+ // drift apart, or a request resolved through the binding reads a config
106
+ // the routes no longer use.
107
+ existing.binding.config = existing.visionServer.config;
108
+ return this.touch(existing);
109
+ }
110
+ return withProjectContext(binding, () => {
111
+ const router = express.Router();
112
+ const fileWatcher = new FileWatcherServer({ projectRoot: binding.targetRoot });
113
+ const store = new VisionStore(binding.dataDir);
114
+ const sessionManager = new SessionManager({
115
+ projectRoot: binding.targetRoot,
116
+ sessionsFile: path.join(binding.dataDir, 'sessions.json'),
117
+ featureRoot: resolveProjectPath('features'),
118
+ getFeaturePhase: (code) => store.getItemByFeatureCode(code)?.lifecycle?.currentPhase || null,
119
+ });
120
+ const visionServer = new VisionServer(store, sessionManager, { config: binding.config });
121
+ const context = { binding, router, fileWatcher, store, sessionManager, visionServer };
122
+ try {
123
+ attachGraphLayoutRoutes(router);
124
+ attachAgentProxy(router, { agentPort: this.agentPort });
125
+ fileWatcher.attach(this.server, router);
126
+ visionServer.attach(this.server, router);
127
+ const reseed = () => {
128
+ seedFeatures(scanFeatures(), store);
129
+ seedSubPackages(scanSubPackages(), store);
130
+ };
131
+ reseed();
132
+ fileWatcher.onFeatureChanged = () => {
133
+ try { reseed(); visionServer.scheduleBroadcast(); }
134
+ catch (err) { console.error('[compose] Feature reseed error:', err.message); }
135
+ };
136
+ fileWatcher.onBuildStateChanged = (state) => {
137
+ if (state) visionServer.broadcastMessage({ type: 'buildState', ...state });
138
+ };
139
+ fileWatcher.onSpecChanged = fileWatcher.onIdeaboxChanged = (msg) => visionServer.broadcastMessage(msg);
140
+ return context;
141
+ } catch (err) {
142
+ fileWatcher.close();
143
+ visionServer.close();
144
+ throw err;
145
+ }
146
+ });
147
+ }
148
+
149
+ switch(root, config) {
150
+ // Preparation may fail. Never mutate the current root/services until it succeeds.
151
+ const next = this.prepare(root, config);
152
+ if (next === this.active) {
153
+ // C8: switching to the workspace already active is a config refresh, not
154
+ // a workspace change. suspend() closes every vision WebSocket with 1000,
155
+ // so the old suspend/resume pair dropped the whole cockpit on a no-op
156
+ // switch. prepare() has already refreshed the config in place; restart
157
+ // only the OS watchers (paths may have moved) and re-arm the background
158
+ // services that a config refresh stopped. Neither closes a socket.
159
+ withProjectContext(next.binding, () => {
160
+ next.fileWatcher.stopWatching(); next.fileWatcher.startWatching();
161
+ next.visionServer.resume(); switchProject(next.binding.targetRoot);
162
+ });
163
+ return next;
164
+ }
165
+ const retained = this.get(next.binding.targetRoot) !== undefined;
166
+ try {
167
+ if (retained) withProjectContext(next.binding, () => {
168
+ next.fileWatcher.startWatching();
169
+ next.visionServer.resume();
170
+ });
171
+ switchProject(next.binding.targetRoot);
172
+ } catch (err) {
173
+ if (retained) {
174
+ next.fileWatcher.stopWatching(); next.visionServer.suspend();
175
+ } else {
176
+ next.fileWatcher.close(); next.visionServer.close();
177
+ }
178
+ throw err;
179
+ }
180
+ if (this.active) {
181
+ this.active.fileWatcher.stopWatching();
182
+ for (const client of this.active.fileWatcher.clients) client.close(1000, 'Workspace changed');
183
+ this.active.fileWatcher.clients.clear();
184
+ this.active.visionServer.suspend();
185
+ }
186
+ this.contexts.set(this.key(next.binding.targetRoot), next);
187
+ this.active = this.touch(next);
188
+ return next;
189
+ }
190
+
191
+ resolveKnownWorkspace(id) {
192
+ const matches = [...this.contexts.values()].filter(c => deriveId({ root: c.binding.targetRoot }).id === id);
193
+ if (matches.length > 1) {
194
+ const err = new Error(`Workspace id collision: ${id}`);
195
+ Object.assign(err, { code: 'WorkspaceIdCollision', id, roots: matches.map(c => c.binding.targetRoot) });
196
+ throw err;
197
+ }
198
+ return matches.length ? { id, root: matches[0].binding.targetRoot, source: 'explicit-header' } : null;
199
+ }
200
+
201
+ handle(req, res, next) {
202
+ let context = req.workspace?.root ? this.get(req.workspace.root) : this.active;
203
+ // C6: ONLY workspace preparation is translated into a workspace error. A
204
+ // route that throws is a route bug, not an unavailable workspace, and
205
+ // reporting it as `400 {error: <internal text>}` both mislabels it and
206
+ // leaks internals past the error handler.
207
+ try {
208
+ if (!context && req.workspace?.root) {
209
+ context = this.prepare(req.workspace.root);
210
+ context.fileWatcher.stopWatching(); context.visionServer.suspend();
211
+ this.contexts.set(this.key(context.binding.targetRoot), context);
212
+ }
213
+ } catch (error) {
214
+ return res.status(error.code === 'WorkspaceCapacityExceeded' ? 409 : 400).json({ error: error.message, code: error.code ?? 'WorkspaceUnavailable' });
215
+ }
216
+ if (!context) return res.status(409).json({ error: 'No workspace selected', code: 'WorkspaceUnset', root: null });
217
+ this.touch(context);
218
+ context.requests = (context.requests ?? 0) + 1;
219
+ let released = false;
220
+ const release = () => {
221
+ if (released) return;
222
+ released = true;
223
+ context.requests--;
224
+ res.off('finish', release); res.off('close', release);
225
+ };
226
+ res.once('finish', release); res.once('close', release);
227
+ try {
228
+ return withProjectContext(context.binding, () => context.router(req, res, error => { release(); next(error); }));
229
+ } catch (error) {
230
+ release();
231
+ return next(error);
232
+ }
233
+ }
234
+
235
+ close() {
236
+ for (const context of this.contexts.values()) {
237
+ context.fileWatcher.close(); context.visionServer.close();
238
+ void closeDesignStratum(context.binding.targetRoot);
239
+ }
240
+ this.contexts.clear();
241
+ this.active = null;
242
+ }
243
+ }
@@ -37,6 +37,7 @@ export class WorktreeGC {
37
37
 
38
38
  /** Start periodic scanning. Also runs an initial scan. */
39
39
  start() {
40
+ if (this.#timer) return;
40
41
  this.runNow().catch(() => {}); // fire-and-forget initial scan
41
42
  this.#timer = setInterval(() => {
42
43
  this.runNow().catch(() => {});