@smartmemory/compose 0.3.7 → 0.3.8

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 (215) hide show
  1. package/.compose-deps.json +1 -13
  2. package/README.md +72 -5
  3. package/bin/compose.js +470 -351
  4. package/bin/judgment-migrate.js +387 -0
  5. package/contracts/comp-obs-contract.schema.json +9 -3
  6. package/contracts/fluid-record.schema.json +209 -0
  7. package/contracts/lifecycle-backfill.schema.json +322 -0
  8. package/dist/assets/App-Z4MU-H_F.js +916 -0
  9. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  10. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  13. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  15. package/dist/assets/channel-SnZzzh7k.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  26. package/dist/assets/clone-DgklGjHm.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  37. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  38. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  39. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  40. package/dist/assets/index-yyrA5OZd.css +1 -0
  41. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  44. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  45. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  46. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  47. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  48. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  49. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  50. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  52. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  53. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  54. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  55. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  56. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  57. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  58. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  59. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  60. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  61. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  62. package/dist/index.html +3 -3
  63. package/lib/agent-string.js +7 -5
  64. package/lib/append-integrity.js +81 -0
  65. package/lib/backfill-evidence.js +109 -0
  66. package/lib/bug-escalation.js +9 -0
  67. package/lib/build-stream-schema.js +3 -1
  68. package/lib/build-stream-writer.js +25 -0
  69. package/lib/build.js +874 -170
  70. package/lib/canon-guard.js +28 -6
  71. package/lib/canon-override.js +196 -0
  72. package/lib/canon-registry.js +104 -0
  73. package/lib/cli-commands.js +144 -0
  74. package/lib/codex-preflight.js +26 -13
  75. package/lib/colleague/context.js +215 -0
  76. package/lib/colleague/writeback.js +95 -0
  77. package/lib/completion-gate.js +1421 -0
  78. package/lib/completion-writer.js +47 -47
  79. package/lib/consumer-fanout.js +105 -11
  80. package/lib/coverage-gate.js +200 -0
  81. package/lib/dir-lock.js +170 -0
  82. package/lib/dispatch-ledger.js +3 -3
  83. package/lib/feature-json.js +1 -1
  84. package/lib/feature-reconciler.js +8 -0
  85. package/lib/feature-validator.js +64 -1
  86. package/lib/feature-writer.js +57 -2
  87. package/lib/fluid/factory.js +167 -0
  88. package/lib/fluid/ideabox-dates.js +73 -0
  89. package/lib/fluid/ideabox-migrate.js +154 -0
  90. package/lib/fluid/ideabox-ops.js +585 -0
  91. package/lib/fluid/ideabox-view.js +146 -0
  92. package/lib/fluid/import-ideabox.js +186 -0
  93. package/lib/fluid/local-provider.js +606 -0
  94. package/lib/fluid/provider.js +684 -0
  95. package/lib/fluid/record-shape.js +214 -0
  96. package/lib/fluid/record-store.js +328 -0
  97. package/lib/fluid/render-ideabox.js +261 -0
  98. package/lib/fluid/schema.js +40 -0
  99. package/lib/fluid/smartmemory-provider.js +1695 -0
  100. package/lib/gsd.js +63 -23
  101. package/lib/guard-cli.js +175 -0
  102. package/lib/guard-custody.js +141 -0
  103. package/lib/guard-descriptors.js +530 -0
  104. package/lib/guard-enrol.js +254 -0
  105. package/lib/health-score.js +1 -1
  106. package/lib/ideabox-cli.js +315 -0
  107. package/lib/ideabox.js +121 -21
  108. package/lib/judgment/store/index.js +9 -1
  109. package/lib/judgment/store/records.js +1 -1
  110. package/lib/judgment/trace.js +380 -0
  111. package/lib/judgment-decision-write.js +277 -0
  112. package/lib/judgment-decisions.js +466 -0
  113. package/lib/judgment-gen.js +5 -1
  114. package/lib/judgment-writer.js +56 -2
  115. package/lib/lifecycle-modes.js +4 -4
  116. package/lib/lineage.js +400 -0
  117. package/lib/local-claude-connector.js +52 -1
  118. package/lib/maya-client.js +302 -0
  119. package/lib/maya-config.js +53 -0
  120. package/lib/maya-identity.js +283 -0
  121. package/lib/migrate-anon.js +5 -0
  122. package/lib/migrate-roadmap.js +15 -0
  123. package/lib/new.js +13 -1
  124. package/lib/pipeline-compat.js +104 -0
  125. package/lib/policy-catalog.js +295 -0
  126. package/lib/policy-check.js +0 -0
  127. package/lib/process-termination.js +98 -0
  128. package/lib/resolve-workspace.js +5 -1
  129. package/lib/result-normalizer.js +396 -199
  130. package/lib/roadmap-errors.js +65 -0
  131. package/lib/roadmap-preservers.js +24 -4
  132. package/lib/roadmap-residue.js +299 -0
  133. package/lib/smartmemory-client.js +614 -78
  134. package/lib/smartmemory-config.js +54 -0
  135. package/lib/smartmemory-ingest.js +19 -2
  136. package/lib/step-prompt.js +7 -6
  137. package/lib/stratum-engine.js +53 -4
  138. package/lib/stratum-mcp-client.js +271 -36
  139. package/lib/test-bootstrap.js +31 -0
  140. package/lib/tool-inventory.js +122 -0
  141. package/lib/version-check.js +91 -19
  142. package/lib/vision-writer.js +88 -1
  143. package/package.json +7 -6
  144. package/pipelines/bug-fix.stratum.yaml +205 -211
  145. package/pipelines/build-quick.profiles.json +12 -0
  146. package/pipelines/build-quick.stratum.yaml +263 -350
  147. package/pipelines/content.stratum.yaml +81 -77
  148. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  149. package/pipelines/plan.stratum.yaml +76 -86
  150. package/pipelines/refactor.stratum.yaml +125 -125
  151. package/pipelines/research.stratum.yaml +56 -58
  152. package/pipelines/review-fix.profiles.json +6 -0
  153. package/pipelines/review-fix.stratum.yaml +110 -83
  154. package/presets/team-feature.profiles.json +6 -0
  155. package/presets/team-feature.stratum.yaml +93 -66
  156. package/presets/team-research.profiles.json +6 -0
  157. package/presets/team-research.stratum.yaml +89 -80
  158. package/presets/team-review.profiles.json +8 -0
  159. package/presets/team-review.stratum.yaml +98 -80
  160. package/scripts/cost-census.mjs +70 -0
  161. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  162. package/server/agent-health.js +22 -0
  163. package/server/agent-hooks.js +14 -1
  164. package/server/agent-server.js +5 -248
  165. package/server/agent-spawn.js +3 -4
  166. package/server/agent-workspace.js +294 -0
  167. package/server/build-routes.js +6 -5
  168. package/server/build-stream-bridge.js +53 -0
  169. package/server/cc-session-watcher.js +4 -1
  170. package/server/coalescing-buffer.js +7 -1
  171. package/server/completion-projection.js +228 -0
  172. package/server/compose-mcp-tools.js +109 -23
  173. package/server/compose-mcp.js +88 -882
  174. package/server/decision-event-emit.js +41 -2
  175. package/server/decision-event-id.js +17 -0
  176. package/server/decision-events-snapshot.js +3 -0
  177. package/server/design-routes.js +14 -8
  178. package/server/feature-scan.js +76 -2
  179. package/server/file-watcher.js +170 -21
  180. package/server/ideabox-routes.js +166 -224
  181. package/server/index.js +70 -100
  182. package/server/lifecycle-guard.js +240 -10
  183. package/server/lifecycle-phase-history.js +276 -0
  184. package/server/maya-routes.js +507 -0
  185. package/server/mcp-tool-defs.js +940 -0
  186. package/server/mcp-tool-policy.js +34 -2
  187. package/server/model-tiers.js +22 -5
  188. package/server/pipeline-routes.js +21 -11
  189. package/server/project-root.js +58 -19
  190. package/server/remote-utils.js +3 -1
  191. package/server/schema-validator.js +7 -1
  192. package/server/session-manager.js +5 -6
  193. package/server/session-routes.js +3 -1
  194. package/server/stratum-client.js +57 -10
  195. package/server/stratum-sync.js +6 -3
  196. package/server/summarizer.js +3 -4
  197. package/server/supervisor.js +0 -1
  198. package/server/vision-routes.js +208 -98
  199. package/server/vision-server.js +86 -23
  200. package/server/vision-store.js +60 -6
  201. package/server/vision-utils.js +3 -4
  202. package/server/workspace-activity.js +18 -0
  203. package/server/workspace-middleware.js +2 -2
  204. package/server/workspace-runtime.js +243 -0
  205. package/server/worktree-gc.js +1 -0
  206. package/dist/assets/App-PkZzHeMj.js +0 -894
  207. package/dist/assets/channel-qVK_qn4E.js +0 -1
  208. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  209. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  210. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  211. package/dist/assets/index-LIwREYgH.css +0 -1
  212. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  213. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  214. package/lib/staleness.js +0 -87
  215. package/server/ideabox-cache.js +0 -77
@@ -44,6 +44,7 @@ function getSchemaValidator() {
44
44
  import { randomUUID } from 'node:crypto';
45
45
  import { getTargetRoot, resolveProjectPath } from './project-root.js';
46
46
  import { resolveFeaturesPathFromConfig } from '../lib/project-paths.js';
47
+ import { isManagedBuildItem, applyVerifiedProjection } from './completion-projection.js';
47
48
  import { getCompletions } from '../lib/completion-writer.js';
48
49
  import { isFeatureCode } from '../lib/feature-code.js';
49
50
  import { anchorBoundary } from '../lib/checkpoint/checkpoint-writer.js';
@@ -59,7 +60,6 @@ import {
59
60
  } from '../lib/lifecycle-modes.js';
60
61
  import { requireSensitiveOrPaired as requireSensitiveToken } from './security.js';
61
62
 
62
- const PROJECT_ROOT = getTargetRoot();
63
63
 
64
64
  /**
65
65
  * Read the compose.json config for an ARBITRARY workspace root (not the bound
@@ -81,10 +81,10 @@ function loadComposeConfig(root) {
81
81
  * @param {object} app — Express app
82
82
  * @param {{ store: object, scheduleBroadcast: function, broadcastMessage: function, projectRoot: string, settingsStore?: object, capabilities?: object }} deps
83
83
  */
84
- export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = PROJECT_ROOT, settingsStore, capabilities }) {
84
+ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMessage, projectRoot = getTargetRoot(), settingsStore, capabilities }) {
85
85
  // COMP-MCP-ENFORCE: when enabled, lifecycle transitions are verdict-gated by
86
86
  // stratum's STRAT-GUARD (fail-closed). Default OFF — legacy behavior intact.
87
- const guardEnabled = capabilities?.guard === true;
87
+ const guardEnabled = () => capabilities?.guard === true;
88
88
 
89
89
  // COMP-MCP-ENFORCE Slice 4: opt-in loopback REST auth on vision MUTATION
90
90
  // endpoints (lifecycle transitions, iterations, gate resolve, item CRUD,
@@ -95,9 +95,8 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
95
95
  // returns 503 (mutations disabled) rather than silently allowing them — enabling
96
96
  // auth without a token is a misconfiguration, not an open door. Pass-through
97
97
  // when off, so route wiring is unconditional.
98
- const guardAuthEnabled = capabilities?.guardAuth === true;
99
98
  const guardAuth = (req, res, next) =>
100
- guardAuthEnabled ? requireSensitiveToken(req, res, next) : next();
99
+ capabilities?.guardAuth === true ? requireSensitiveToken(req, res, next) : next();
101
100
 
102
101
  // PARITY-5: GET /api/completions — read-only recorded-completion log for a
103
102
  // feature. Reads are open (not guardAuth-wrapped); wraps the same
@@ -148,6 +147,34 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
148
147
  // PATCH /api/vision/items/:id — update item
149
148
  app.patch('/api/vision/items/:id', guardAuth, (req, res) => {
150
149
  try {
150
+ // COMP-COMPLETION-GATE slice 3 (AC-10, path 7): `status: 'complete'` on a
151
+ // MANAGED BUILD-MODE feature item is refused here. The general PATCH has
152
+ // no evidence to offer; the legitimate write is the self-verifying
153
+ // projection endpoint below, which the gate reaches after the guard has
154
+ // applied. Fix/plan items and unmanaged items are untouched (§2.3d) —
155
+ // round 5 caught that an unconditional refusal bricks those modes
156
+ // whenever a server is running, since every mode shares the terminal call.
157
+ // The verification stamp is SERVER-OWNED: only the §2.3b predicate writes
158
+ // it. A PATCH carrying it could relabel a legacy item as `guarded` with a
159
+ // fake ledger ref (Codex r2 #1).
160
+ if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'completion_projection')) {
161
+ return res.status(422).json({
162
+ error: 'completion_projection is written by the completion projection only and cannot be set through PATCH',
163
+ code: 'PROJECTION_STAMP_READONLY',
164
+ });
165
+ }
166
+ if (req.body?.status === 'complete') {
167
+ const existing = store.items.get(req.params.id);
168
+ if (existing && isManagedBuildItem(existing, projectRoot)) {
169
+ return res.status(422).json({
170
+ error: `refusing status: complete for ${existing.lifecycle.featureCode} — a managed build-mode ` +
171
+ 'feature completes through the completion gate (record_completion / the build runner / ' +
172
+ 'POST …/lifecycle/complete), which verifies evidence and projects the item via ' +
173
+ 'POST /api/vision/items/:id/completion-projection.',
174
+ code: 'COMPLETE_VIA_GATE_ONLY',
175
+ });
176
+ }
177
+ }
151
178
  const item = store.updateItem(req.params.id, req.body);
152
179
  // If group changed, write back to docs/features/<code>/feature.json
153
180
  // so the change survives restart and re-scan. Non-fatal on failure.
@@ -166,6 +193,31 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
166
193
  }
167
194
  });
168
195
 
196
+ // POST /api/vision/items/:id/completion-projection — COMP-COMPLETION-GATE
197
+ // slice 3 (§2.3b, AC-4b/4d). Self-verifying, NOT privileged: before writing
198
+ // it re-reads feature.json (must already be COMPLETE — §2.3a step 2, the
199
+ // durable truth) and, if a guard resource exists, the guard ledger (must be
200
+ // complete). It cannot be used to complete anything; it only mirrors a
201
+ // completion canonical state already records — which is why it needs no
202
+ // token and does not reopen the bypass the PATCH refusal closes. Never calls
203
+ // the gate (no recursion: gate → here is one-directional).
204
+ app.post('/api/vision/items/:id/completion-projection', guardAuth, async (req, res) => {
205
+ try {
206
+ const { featureCode, commitSha, ledgerRef } = req.body || {};
207
+ if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
208
+ if (!store.items.get(req.params.id)) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
209
+ const r = await applyVerifiedProjection(store, {
210
+ itemId: req.params.id, featureCode, cwd: projectRoot, consultGuard: true,
211
+ evidence: { commitSha: commitSha || undefined, ledgerRef: ledgerRef || undefined, source: 'rest' },
212
+ });
213
+ if (!r.ok) return res.status(422).json({ error: 'completion projection refused', reasons: r.reasons });
214
+ scheduleBroadcast();
215
+ res.json({ ok: true, itemId: req.params.id, verified_by: r.verified_by });
216
+ } catch (err) {
217
+ res.status(500).json({ error: err.message });
218
+ }
219
+ });
220
+
169
221
  // DELETE /api/vision/items/:id — delete item + connections
170
222
  app.delete('/api/vision/items/:id', guardAuth, (req, res) => {
171
223
  try {
@@ -213,9 +265,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
213
265
  });
214
266
 
215
267
  // ── Lifecycle endpoints (simplified — no state machine) ──────────────
216
- const featuresPath = projectRoot !== PROJECT_ROOT
217
- ? resolveFeaturesPathFromConfig(projectRoot, loadComposeConfig(projectRoot))
218
- : resolveProjectPath('features');
268
+ const featuresPath = () => resolveFeaturesPathFromConfig(projectRoot, loadComposeConfig(projectRoot));
219
269
 
220
270
  // Phase graph + skippable + terminal are owned by the mode registry (single
221
271
  // source of truth shared with the STRAT-GUARD graph) — see COMP-MCP-ENFORCE /
@@ -283,7 +333,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
283
333
  // (the clean bootstrap path; backfill for pre-rollout items happens lazily
284
334
  // on first transition). Best-effort — a registration hiccup must not block
285
335
  // starting a lifecycle; the next guarded transition re-attempts ensureGuard.
286
- if (guardEnabled) {
336
+ if (guardEnabled()) {
287
337
  try { await ensureGuard(featureCode, genesis, projectRoot, mode); }
288
338
  catch (e) { console.warn(`[lifecycle/start] guard register for ${featureCode} failed: ${e.message}`); }
289
339
  // Slice 2: starting a lifecycle projects the genesis phase → IN_PROGRESS so
@@ -352,7 +402,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
352
402
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
353
403
 
354
404
  // COMP-MCP-ENFORCE: verdict-gate the transition (fail-closed) before mutating.
355
- if (guardEnabled) {
405
+ if (guardEnabled()) {
356
406
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
357
407
  if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
358
408
  }
@@ -365,7 +415,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
365
415
  // COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project the new phase onto
366
416
  // feature.json STATUS (best-effort; idempotent — only writes on a real change).
367
417
  // COMP-ROADMAP-PLAN: skipped for tracksFeatureJson:false modes (no feature.json).
368
- if (guardEnabled && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
418
+ if (guardEnabled() && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
369
419
  scheduleBroadcast();
370
420
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
371
421
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome, timestamp: now }));
@@ -394,7 +444,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
394
444
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
395
445
 
396
446
  // COMP-MCP-ENFORCE: verdict-gate the skip (fail-closed) before mutating.
397
- if (guardEnabled) {
447
+ if (guardEnabled()) {
398
448
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
399
449
  if (!g.applied) return res.status(422).json({ error: 'transition refused by guard', from, to: targetPhase, verdict: g.verdict, guardError: g.error });
400
450
  }
@@ -406,7 +456,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
406
456
  store.updateLifecycle(req.params.id, item.lifecycle);
407
457
  // COMP-MCP-ENFORCE Slice 2 (lifecycle-as-truth): project phase → STATUS.
408
458
  // COMP-ROADMAP-PLAN: skipped for tracksFeatureJson:false modes (no feature.json).
409
- if (guardEnabled && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
459
+ if (guardEnabled() && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: targetPhase, cwd: projectRoot });
410
460
  scheduleBroadcast();
411
461
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
412
462
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome: 'skipped', timestamp: now }));
@@ -435,7 +485,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
435
485
  // The `<from>→killed` edge has no predicate, so it only fails if the guard
436
486
  // is unreachable — consistent with advance/skip/complete. Authorized
437
487
  // bypass remains stratum_guard_override (Slice 3).
438
- if (guardEnabled) {
488
+ if (guardEnabled()) {
439
489
  const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from, to: 'killed', workspaceRoot: projectRoot, resolvedBy: 'agent', mode: modeOf(item) });
440
490
  if (!g.applied) return res.status(422).json({ error: 'kill refused by guard', from, to: 'killed', verdict: g.verdict, guardError: g.error });
441
491
  }
@@ -451,7 +501,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
451
501
  // COMP-MCP-ENFORCE Slice 2: project kill → KILLED onto feature.json
452
502
  // (closes the COMP-PARITY-7 gap — kill previously wrote vision-state only).
453
503
  // COMP-ROADMAP-PLAN: skipped for tracksFeatureJson:false modes (no feature.json).
454
- if (guardEnabled && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: 'killed', cwd: projectRoot });
504
+ if (guardEnabled() && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode: item.lifecycle.featureCode, phase: 'killed', cwd: projectRoot });
455
505
  scheduleBroadcast();
456
506
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
457
507
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: 'killed', outcome: 'killed', timestamp: now }));
@@ -479,39 +529,93 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
479
529
  return res.status(400).json({ error: `Can only complete from ${completable} phase, currently in: ${item.lifecycle.currentPhase}` });
480
530
  }
481
531
 
482
- // COMP-MCP-ENFORCE Slice 3: evidence-bound completion. Under the guard,
483
- // ship→complete requires REAL evidence the commit must exist (server-read
484
- // git) and tests must be attested (configured test command exits 0, or
485
- // tests_pass is explicitly true; never a silent default). Then the guard
486
- // verdict gates the transition (fail-closed).
487
- let verifiedTestsPass = req.body?.tests_pass;
488
- if (guardEnabled) {
489
- const ev = await verifyCompletionEvidence({
490
- commitSha: req.body?.commit_sha,
491
- cwd: projectRoot,
492
- testCommand: guardTestCommand(projectRoot),
493
- testsPassClaim: req.body?.tests_pass,
532
+ const featureCode = item.lifecycle.featureCode;
533
+ const completionResult = { partial: false };
534
+ let now;
535
+
536
+ // MANAGED build item = bound to a code, build mode, feature.json exists
537
+ // (the same key AC-10/AC-16 use). An unmanaged build item — a lifecycle
538
+ // started for a code that has no feature.json — has nothing canonical to
539
+ // complete and keeps the pre-slice-3 path, as fix/plan do.
540
+ if (isManagedBuildItem(item, projectRoot)) {
541
+ // COMP-COMPLETION-GATE slice 3 (§2.1, AC-10): for a mode that tracks
542
+ // feature.json (build), the cockpit completion IS a completion and goes
543
+ // through the gate — evidence, ONE guarded transition, then the gate
544
+ // performs record → status → ROADMAP → vision → events (§2.3a). The
545
+ // vision step is an in-process projector against this server's live
546
+ // store (a REST call back into ourselves would race it; a direct file
547
+ // write would leave it stale). Under the guard a request with no
548
+ // commit_sha is refused by the evidence check: an evidence-free cockpit
549
+ // completion was path 7's quieter twin and is closed on purpose.
550
+ const { completionGate } = await import('../lib/completion-gate.js');
551
+ const { commit_sha, tests_pass, files_changed, notes } = req.body || {};
552
+ const gated = await completionGate({
553
+ featureCode,
554
+ ...(commit_sha ? { commitSha: commit_sha } : {}),
555
+ // Guard on: no default — the evidence check attests (test command) or
556
+ // the caller states it. Guard off: the cockpit's pre-existing default
557
+ // (flag-off parity, the same opt-out the gate itself honours in AC-5).
558
+ testsPass: guardEnabled() ? tests_pass : (tests_pass ?? true),
559
+ filesChanged: files_changed ?? [],
560
+ notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
561
+ workspaceRoot: projectRoot,
562
+ mode: modeOf(item),
563
+ visionItemId: req.params.id,
564
+ visionProjector: ({ visionItemId, commitSha, ledgerRef, guarded }) => applyVerifiedProjection(store, {
565
+ itemId: visionItemId, featureCode, cwd: projectRoot, consultGuard: guarded ?? true,
566
+ guardEnabledOverride: guarded,
567
+ evidence: { commitSha, ledgerRef, source: 'lifecycle/complete' },
568
+ }).then((r) => { if (!r.ok) throw new Error(r.reasons.join('; ')); return r; }),
494
569
  });
495
- if (!ev.ok) {
496
- return res.status(422).json({ error: 'completion evidence not satisfied', reasons: ev.reasons });
570
+ if (!gated.ok) {
571
+ return res.status(422).json({
572
+ error: gated.refusedAt === 'evidence' ? 'completion evidence not satisfied' : 'completion refused',
573
+ refusedAt: gated.refusedAt, reasons: gated.reasons,
574
+ ...(gated.verdict ? { verdict: gated.verdict } : {}),
575
+ ...(gated.error ? { guardError: gated.error } : {}),
576
+ });
497
577
  }
498
- verifiedTestsPass = ev.testsAttested ? true : (req.body?.tests_pass === true);
499
-
500
- const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: completable, to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent', mode: modeOf(item) });
501
- if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: completable, to: 'complete', verdict: g.verdict, guardError: g.error });
578
+ now = new Date().toISOString();
579
+ item.lifecycle.currentPhase = 'complete';
580
+ item.lifecycle.completedAt = now;
581
+ appendPhaseHistory(item, { from: completable, to: 'complete', outcome: 'approved', timestamp: now });
582
+ store.updateLifecycle(req.params.id, item.lifecycle);
583
+ const failures = [...(gated.failures || [])];
584
+ if (store.lastSaveOk === false) {
585
+ // The projection reached disk; this lifecycle write did not. Report it
586
+ // as a partial like any other projection failure (Codex r2 #3).
587
+ failures.push({ step: 'lifecycle-persist', message: 'vision-state could not be persisted after the lifecycle transition', recover: 'restart the server or re-run compose validate --fix' });
588
+ }
589
+ if (failures.length > 0) {
590
+ completionResult.partial = true;
591
+ completionResult.failures = failures;
592
+ }
593
+ completionResult.ledgerRef = gated.ledgerRef ?? null;
594
+ completionResult.verified_by = gated.visionProjection?.verified_by ?? null;
595
+ } else {
596
+ // Fix/plan (no feature.json — COMP-COMPLETION-GATE-MODES) and items
597
+ // with no feature code: the pre-slice-3 path, byte-identical.
598
+ if (guardEnabled()) {
599
+ const ev = await verifyCompletionEvidence({
600
+ commitSha: req.body?.commit_sha,
601
+ cwd: projectRoot,
602
+ testCommand: guardTestCommand(projectRoot),
603
+ testsPassClaim: req.body?.tests_pass,
604
+ });
605
+ if (!ev.ok) {
606
+ return res.status(422).json({ error: 'completion evidence not satisfied', reasons: ev.reasons });
607
+ }
608
+ const g = await guardedTransition({ featureCode: item.lifecycle.featureCode, from: completable, to: 'complete', workspaceRoot: projectRoot, commitSha: req.body?.commit_sha, resolvedBy: 'agent', mode: modeOf(item) });
609
+ if (!g.applied) return res.status(422).json({ error: 'completion refused by guard', from: completable, to: 'complete', verdict: g.verdict, guardError: g.error });
610
+ }
611
+ now = new Date().toISOString();
612
+ item.lifecycle.currentPhase = 'complete';
613
+ item.lifecycle.completedAt = now;
614
+ appendPhaseHistory(item, { from: completable, to: 'complete', outcome: 'approved', timestamp: now });
615
+ store.updateLifecycle(req.params.id, item.lifecycle);
616
+ store.updateItem(req.params.id, { status: 'complete' });
502
617
  }
503
618
 
504
- const now = new Date().toISOString();
505
- item.lifecycle.currentPhase = 'complete';
506
- item.lifecycle.completedAt = now;
507
- // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
508
- appendPhaseHistory(item, { from: completable, to: 'complete', outcome: 'approved', timestamp: now });
509
- store.updateLifecycle(req.params.id, item.lifecycle);
510
- store.updateItem(req.params.id, { status: 'complete' });
511
- // COMP-MCP-ENFORCE Slice 2: status projection for the no-commit path is
512
- // applied BELOW (in the `else` branch) so it does not pre-empt and mask the
513
- // recordCompletion bridge, which is the authority + partial-write reporter
514
- // on the commit_sha path.
515
619
  scheduleBroadcast();
516
620
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: completable, to: 'complete', outcome: 'approved', timestamp: now });
517
621
  emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from: completable, to: 'complete', outcome: 'approved', timestamp: now }));
@@ -522,61 +626,67 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
522
626
  // COMP-RESUME: best-effort anchor checkpoint at the (terminal) phase boundary
523
627
  anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
524
628
 
525
- // COMP-MCP-MIGRATION: reconcile cockpit complete with record_completion
526
- // (commit-bound completion writer). Best-effort: failures emit a decision
527
- // event but never roll back the lifecycle transition.
629
+ res.json({ completedAt: now, ...completionResult });
630
+ } catch (err) {
631
+ const status = err.message.includes('not found') ? 404 : 400;
632
+ res.status(status).json({ error: err.message });
633
+ }
634
+ });
635
+
636
+ // A reconstructed completion has the same completion evidence and durable
637
+ // writes as the normal gate, but may be reached from any active lifecycle
638
+ // phase. The gate owns the terminal history/item mutation as one recoverable
639
+ // transaction; this route only supplies the live store and emits its effects.
640
+ app.post('/api/vision/items/:id/lifecycle/backfill', guardAuth, async (req, res) => {
641
+ try {
642
+ const item = store.items.get(req.params.id);
643
+ if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
644
+ const { commit_sha, tests_pass, files_changed, notes, reason, occurrences } = req.body || {};
645
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
646
+ return res.status(400).json({ error: 'reason is required' });
647
+ }
648
+ const { completionGate } = await import('../lib/completion-gate.js');
528
649
  const featureCode = item.lifecycle.featureCode;
529
- const completionResult = { partial: false };
530
- if (featureCode) {
531
- const { commit_sha, tests_pass, files_changed, notes } = req.body || {};
532
- if (commit_sha) {
533
- try {
534
- const { recordCompletion } = await import('../lib/completion-writer.js');
535
- await recordCompletion(projectRoot, {
536
- feature_code: featureCode,
537
- commit_sha,
538
- // Slice 3: under the guard, tests_pass reflects verified evidence
539
- // (attested or explicit), NOT a silent default-to-true.
540
- tests_pass: guardEnabled ? (verifiedTestsPass === true) : (tests_pass ?? true),
541
- files_changed: files_changed ?? [],
542
- notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
543
- });
544
- } catch (err) {
545
- completionResult.partial = true;
546
- const eventType = err.code === 'STATUS_FLIP_AFTER_COMPLETION_RECORDED'
547
- ? 'cockpit_completion_partial_status_flip'
548
- : 'cockpit_completion_failed';
549
- completionResult.completion_failed = err.code || 'UNKNOWN';
550
- completionResult.message = err.message;
551
- try {
552
- emitDecisionEvent(broadcastMessage, {
553
- type: eventType,
554
- featureCode,
555
- timestamp: now,
556
- error: { code: err.code, message: err.message },
557
- });
558
- } catch { /* decision event emit best-effort */ }
559
- // eslint-disable-next-line no-console
560
- console.warn(`[lifecycle/complete] record_completion ${eventType} for ${featureCode}: ${err.message}`);
561
- }
562
- } else {
563
- // No SHA — emit a skip event so validate_feature can flag drift later
564
- try {
565
- emitDecisionEvent(broadcastMessage, {
566
- type: 'cockpit_completion_skipped',
567
- featureCode,
568
- timestamp: now,
569
- reason: 'no_commit_sha',
570
- });
571
- } catch { /* decision event emit best-effort */ }
572
- // No recordCompletion bridge ran — project complete → COMPLETE so
573
- // lifecycle-as-truth still reaches feature.json (best-effort).
574
- // COMP-ROADMAP-PLAN: skipped for tracksFeatureJson:false modes (no feature.json).
575
- if (guardEnabled && getMode(modeOf(item)).runner.tracksFeatureJson) await projectFeatureStatus({ featureCode, phase: 'complete', cwd: projectRoot });
576
- }
650
+ const gated = await completionGate({
651
+ intent: 'backfill', featureCode,
652
+ ...(commit_sha !== undefined ? { commitSha: commit_sha } : {}),
653
+ testsPass: tests_pass,
654
+ filesChanged: files_changed ?? [], notes, reason, occurrences: occurrences ?? [],
655
+ workspaceRoot: projectRoot, mode: modeOf(item), item, store,
656
+ visionItemId: req.params.id,
657
+ visionProjector: ({ visionItemId, commitSha, ledgerRef, guarded }) => applyVerifiedProjection(store, {
658
+ itemId: visionItemId, featureCode, cwd: projectRoot,
659
+ consultGuard: guarded ?? true, guardEnabledOverride: guarded,
660
+ evidence: { commitSha, ledgerRef, source: 'lifecycle/backfill' },
661
+ }).then((r) => { if (!r.ok) throw new Error(r.reasons.join('; ')); return r; }),
662
+ });
663
+ if (!gated.ok) {
664
+ return res.status(422).json({
665
+ error: 'backfill refused', refusedAt: gated.refusedAt, reasons: gated.reasons,
666
+ ...(gated.verdict ? { verdict: gated.verdict } : {}),
667
+ ...(gated.error ? { guardError: gated.error } : {}),
668
+ ...(gated.error?.hint ? { hint: gated.error.hint } : {}),
669
+ });
577
670
  }
578
671
 
579
- res.json({ completedAt: now, ...completionResult });
672
+ const timestamp = item.lifecycle.completedAt;
673
+ if (!gated.replayed) {
674
+ const terminal = item.lifecycle.phaseHistory?.findLast((entry) => entry.phase === 'complete_backfilled');
675
+ scheduleBroadcast();
676
+ broadcastMessage({
677
+ type: 'lifecycleTransition', itemId: req.params.id, from: terminal?.from ?? null,
678
+ to: 'complete_backfilled', outcome: terminal?.outcome ?? 'backfilled', timestamp,
679
+ });
680
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({
681
+ featureCode, from: terminal?.from ?? null, to: 'complete_backfilled',
682
+ outcome: terminal?.outcome ?? 'backfilled', timestamp,
683
+ origin: terminal?.origin, recordedAt: terminal?.recordedAt, confidence: terminal?.confidence,
684
+ }));
685
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, timestamp);
686
+ emitStatusSnapshot(broadcastMessage, store, featureCode, timestamp);
687
+ anchorBoundary(getTargetRoot(), { item, trigger: 'phase-transition' });
688
+ }
689
+ res.json({ completedAt: timestamp, backfill: gated.backfill ?? null, ...gated });
580
690
  } catch (err) {
581
691
  const status = err.message.includes('not found') ? 404 : 400;
582
692
  res.status(status).json({ error: err.message });
@@ -794,7 +904,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
794
904
  if (!item.lifecycle?.featureCode) {
795
905
  return res.status(400).json({ error: 'Item has no lifecycle featureCode' });
796
906
  }
797
- const assessment = new ArtifactManager(featuresPath, item.lifecycle?.mode).assess(item.lifecycle.featureCode);
907
+ const assessment = new ArtifactManager(featuresPath(), item.lifecycle?.mode).assess(item.lifecycle.featureCode);
798
908
  res.json(assessment);
799
909
  } catch (err) {
800
910
  res.status(500).json({ error: err.message });
@@ -808,7 +918,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
808
918
  if (!item.lifecycle?.featureCode) {
809
919
  return res.status(400).json({ error: 'Item has no lifecycle featureCode' });
810
920
  }
811
- const result = new ArtifactManager(featuresPath, item.lifecycle?.mode).scaffold(item.lifecycle.featureCode, req.body);
921
+ const result = new ArtifactManager(featuresPath(), item.lifecycle?.mode).scaffold(item.lifecycle.featureCode, req.body);
812
922
  res.json(result);
813
923
  } catch (err) {
814
924
  res.status(400).json({ error: err.message });
@@ -1,3 +1,4 @@
1
+ import express from 'express';
1
2
  /**
2
3
  * Vision Server — REST endpoints + WebSocket broadcast for the vision surface.
3
4
  * Follows the same attach() pattern as FileWatcherServer.
@@ -31,6 +32,7 @@ import { attachHealthRoutes } from './health-routes.js';
31
32
  import { attachValidateRoutes } from './validate-routes.js';
32
33
  import { attachQaScopeRoutes } from './qa-scope-routes.js';
33
34
  import { attachSmartmemoryRoutes } from './smartmemory-routes.js';
35
+ import { attachMayaRoutes } from './maya-routes.js';
34
36
  import { attachDesignRoutes } from './design-routes.js';
35
37
  import { DesignSessionManager } from './design-session.js';
36
38
  import { attachPipelineRoutes } from './pipeline-routes.js';
@@ -80,6 +82,18 @@ export class VisionServer {
80
82
  this._stratumSync = null;
81
83
  }
82
84
 
85
+ /**
86
+ * Create the Stratum poller on first use. Never called while
87
+ * `capabilities.stratum` is false, so a stratum-disabled workspace keeps
88
+ * `_stratumSync === null` (no timers, no poller) for its whole lifetime.
89
+ */
90
+ _ensureStratumSync() {
91
+ if (!this._stratumSync) {
92
+ this._stratumSync = new StratumSync(this.store, () => this.scheduleBroadcast());
93
+ }
94
+ return this._stratumSync;
95
+ }
96
+
83
97
  attach(httpServer, app) {
84
98
  // ── Settings store ────────────────────────────────────────────────────
85
99
  this.settingsStore = new SettingsStore(undefined, SETTINGS_DEFAULTS);
@@ -108,6 +122,11 @@ export class VisionServer {
108
122
  // answers {enabled:false} from config alone when the flag is OFF.
109
123
  attachSmartmemoryRoutes(app);
110
124
 
125
+ // ── Maya colleague relay (COMP-FOH FOH-6, opt-in) ──────────────────────
126
+ // GET /api/maya/status (degrade-never-fail funnel probe) + POST
127
+ // /api/maya/message. Auth-gated in remote mode — never allowlisted.
128
+ attachMayaRoutes(app);
129
+
111
130
  // ── Vision CRUD + lifecycle routes ─────────────────────────────────────
112
131
  attachVisionRoutes(app, {
113
132
  store: this.store,
@@ -155,17 +174,14 @@ export class VisionServer {
155
174
  });
156
175
 
157
176
  // ── Design conversation routes ──────────────────────────────────────────
158
- // Re-resolve on every call so project switches get fresh instances.
177
+ // The workspace owns its manager; pending conversations retain their data root.
159
178
  let _designSessionManager = null;
160
- let _designDataDir = null;
161
179
 
162
180
  attachDesignRoutes(app, {
163
181
  getSessionManager: () => {
164
- const dataDir = getDataDir();
165
- if (dataDir !== _designDataDir) {
166
- if (_designSessionManager) _designSessionManager.destroy();
167
- _designSessionManager = new DesignSessionManager(dataDir);
168
- _designDataDir = dataDir;
182
+ if (!_designSessionManager) {
183
+ _designSessionManager = new DesignSessionManager(getDataDir());
184
+ this._designSessionManager = _designSessionManager;
169
185
  }
170
186
  return _designSessionManager;
171
187
  },
@@ -262,28 +278,32 @@ export class VisionServer {
262
278
  });
263
279
 
264
280
  // ── Stratum (conditional) ────────────────────────────────────────────
265
- if (this._config.capabilities?.stratum) {
266
- app.use('/api/stratum', createStratumRouter());
267
- this._stratumSync = new StratumSync(this.store, () => this.scheduleBroadcast());
268
- attachStratumRoutes(app, {
269
- store: this.store,
270
- scheduleBroadcast: () => this.scheduleBroadcast(),
271
- broadcastMessage: (msg) => this.broadcastMessage(msg),
272
- sync: this._stratumSync,
273
- });
274
- this._stratumSync.start();
275
- console.log('[vision] Stratum sync enabled');
276
- } else {
277
- app.use('/api/stratum', (_req, res) => {
278
- res.status(503).json({ error: 'Stratum not enabled', hint: 'compose init (TS engine; python retired — see python-legacy branch)' });
279
- });
280
- }
281
+ const self = this;
282
+ const stratumRouter = express.Router();
283
+ stratumRouter.use('/api/stratum', createStratumRouter());
284
+ attachStratumRoutes(stratumRouter, {
285
+ store: this.store,
286
+ scheduleBroadcast: () => this.scheduleBroadcast(),
287
+ broadcastMessage: (msg) => this.broadcastMessage(msg),
288
+ // Lazily resolved: the poller only exists while stratum is enabled
289
+ // (capabilities can flip at runtime via refreshConfig/switch).
290
+ get sync() { return self._stratumSync; },
291
+ });
292
+ app.use((req, res, next) => {
293
+ if (this._config.capabilities?.stratum) return stratumRouter(req, res, next);
294
+ if (req.path.startsWith('/api/stratum')) return res.status(503).json({ error: 'Stratum not enabled', hint: 'compose init (TS engine; python retired — see python-legacy branch)' });
295
+ return next();
296
+ });
297
+ if (this._config.capabilities?.stratum) this._ensureStratumSync().start();
281
298
 
282
299
  // ── COMP-OBS-BRANCH: CC-session watcher (opt-in) ──────────────────────
283
300
  // Default OFF. Enable by setting `capabilities.cc_session_watcher: true` in compose.json
284
301
  // or by setting the `CC_SESSION_WATCHER=1` env var. When enabled, Forge reads
285
302
  // `~/.claude/projects/**/*.jsonl` and emits BranchLineage + DecisionEvents tied to the
286
303
  // current feature via sessions.json's transcriptPath basename.
304
+ this._refreshCCWatcher = () => {
305
+ this._ccWatcher?.stop();
306
+ this._ccWatcher = null;
287
307
  const ccWatcherEnabled =
288
308
  this._config.capabilities?.cc_session_watcher === true ||
289
309
  process.env.CC_SESSION_WATCHER === '1';
@@ -354,6 +374,9 @@ export class VisionServer {
354
374
  }
355
375
  }
356
376
 
377
+ };
378
+ this._refreshCCWatcher();
379
+
357
380
  // ── Haiku summary broadcast ─────────────────────────────────────────────
358
381
  if (this.sessionManager) {
359
382
  this.sessionManager.onSummary((summary) => {
@@ -470,7 +493,47 @@ export class VisionServer {
470
493
  }
471
494
  }
472
495
 
496
+ /** The live config object routes and the workspace binding both hold. */
497
+ get config() { return this._config; }
498
+
499
+ refreshConfig(config) {
500
+ this._stratumSync?.stop();
501
+ // C4: the incoming config may BE the object we already hold — the workspace
502
+ // binding aliases it, so `switch(root, ctx.binding.config)` hands it straight
503
+ // back. Snapshot first: clearing the holder would otherwise empty the very
504
+ // config being installed, leaving the workspace with no capabilities at all.
505
+ const next = JSON.parse(JSON.stringify(config ?? {}));
506
+ // Existing routes hold this capabilities object; update it in place.
507
+ const capabilities = this._config.capabilities ?? {};
508
+ for (const key of Object.keys(capabilities)) delete capabilities[key];
509
+ Object.assign(capabilities, next.capabilities);
510
+ for (const key of Object.keys(this._config)) delete this._config[key];
511
+ Object.assign(this._config, next, { capabilities });
512
+ if (this.sessionManager) this.sessionManager._featureRoot = resolveProjectPath('features');
513
+ this._refreshCCWatcher?.();
514
+ }
515
+
516
+ suspend() {
517
+ this._healthMonitor?.suspend();
518
+ this._coalescingBuffer?.stop();
519
+ this._stratumSync?.stop();
520
+ this._ccWatcher?.stop();
521
+ this._worktreeGC?.stop();
522
+ for (const client of this.clients) client.close(1000, 'Workspace changed');
523
+ this.clients.clear();
524
+ }
525
+
526
+ resume() {
527
+ this._healthMonitor?.resume();
528
+ this._coalescingBuffer?.start();
529
+ if (this._config.capabilities?.stratum) this._ensureStratumSync().start();
530
+ this._ccWatcher?.start();
531
+ this._worktreeGC?.start();
532
+ }
533
+
473
534
  close() {
535
+ this.suspend();
536
+ this._designSessionManager?.destroy();
474
537
  this._coalescingBuffer?.stop();
475
538
  if (this._stratumSync) this._stratumSync.stop();
476
539
  if (this._healthMonitor) this._healthMonitor.destroy();