@smartmemory/compose 0.1.0 → 0.1.2-beta

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 (124) hide show
  1. package/.claude/skills/bug-fix/SKILL.md +143 -0
  2. package/.claude/skills/compose/SKILL.md +604 -0
  3. package/.compose-deps.json +89 -0
  4. package/README.md +14 -3
  5. package/bin/compose.js +473 -0
  6. package/contracts/comp-obs-contract.schema.json +362 -0
  7. package/contracts/cross-model-review-result.json +78 -0
  8. package/contracts/review-result.json +126 -0
  9. package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
  10. package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
  14. package/dist/assets/channel-LRG9kHqJ.js +1 -0
  15. package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
  25. package/dist/assets/clone-dRxgFrBv.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
  36. package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
  37. package/dist/assets/index-DKBsEUJ-.css +1 -0
  38. package/dist/assets/index-DkRKLuNr.js +1144 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
  43. package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
  44. package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
  45. package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/lib/budget-ledger.js +45 -0
  61. package/lib/bug-bisect.js +292 -0
  62. package/lib/bug-checkpoint.js +191 -0
  63. package/lib/bug-escalation.js +306 -0
  64. package/lib/bug-index-gen.js +136 -0
  65. package/lib/bug-ledger.js +126 -0
  66. package/lib/build-stream-schema.js +176 -0
  67. package/lib/build-stream-writer.js +3 -1
  68. package/lib/build.js +854 -284
  69. package/lib/connector-factory-shim.js +167 -0
  70. package/lib/constants.js +18 -0
  71. package/lib/debug-discipline.js +176 -27
  72. package/lib/deps.js +205 -0
  73. package/lib/health-score.js +4 -4
  74. package/lib/import.js +26 -13
  75. package/lib/inject-schema.js +21 -0
  76. package/lib/new.js +27 -53
  77. package/lib/result-normalizer.js +160 -144
  78. package/lib/review-lenses.js +5 -5
  79. package/lib/review-normalize.js +413 -0
  80. package/lib/review-prompt.js +163 -0
  81. package/lib/sections.js +325 -0
  82. package/lib/step-prompt.js +21 -1
  83. package/lib/step-validator.js +5 -3
  84. package/lib/stratum-mcp-client.js +172 -7
  85. package/package.json +14 -3
  86. package/pipelines/bug-fix.stratum.yaml +39 -1
  87. package/pipelines/build.stratum.yaml +28 -45
  88. package/pipelines/review-fix.stratum.yaml +1 -1
  89. package/presets/team-review.stratum.yaml +21 -14
  90. package/server/build-stream-bridge.js +28 -0
  91. package/server/cc-session-feature-resolver.js +111 -0
  92. package/server/cc-session-reader.js +327 -0
  93. package/server/cc-session-watcher.js +318 -0
  94. package/server/compose-mcp-tools.js +0 -125
  95. package/server/compose-mcp.js +2 -4
  96. package/server/contract-diff.js +192 -0
  97. package/server/decision-event-emit.js +175 -0
  98. package/server/decision-event-id.js +64 -0
  99. package/server/decision-events-snapshot.js +166 -0
  100. package/server/design-routes.js +92 -49
  101. package/server/drift-axes.js +365 -0
  102. package/server/drift-emit.js +121 -0
  103. package/server/gate-log-store.js +102 -0
  104. package/server/lifecycle-phase-history.js +44 -0
  105. package/server/open-loops-store.js +102 -0
  106. package/server/schema-validator.js +49 -0
  107. package/server/status-emit.js +27 -0
  108. package/server/status-snapshot.js +218 -0
  109. package/server/vision-routes.js +332 -4
  110. package/server/vision-server.js +104 -12
  111. package/server/vision-store.js +21 -0
  112. package/dist/assets/channel-DGElom1e.js +0 -1
  113. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
  115. package/dist/assets/clone-DUJKJXd7.js +0 -1
  116. package/dist/assets/index-CUd6pFGF.css +0 -1
  117. package/dist/assets/index-DReRlzZI.js +0 -1144
  118. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
  119. package/server/connectors/agent-connector.js +0 -78
  120. package/server/connectors/claude-sdk-connector.js +0 -198
  121. package/server/connectors/codex-connector.js +0 -240
  122. package/server/connectors/connector-discovery.js +0 -18
  123. package/server/connectors/connector-runtime.js +0 -13
  124. package/server/connectors/opencode-connector.js +0 -200
@@ -31,9 +31,24 @@ import path from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { extractFilePaths } from './vision-utils.js';
33
33
  import { ArtifactManager } from './artifact-manager.js';
34
- import { recordIteration, checkCumulativeBudget } from '../lib/budget-ledger.js';
34
+ import { recordIteration, checkCumulativeBudget, readBudget } from '../lib/budget-ledger.js';
35
+ import { SchemaValidator } from './schema-validator.js';
36
+ import { appendPhaseHistory } from './lifecycle-phase-history.js';
37
+ import { emitDecisionEvent, buildPhaseTransitionEvent, buildIterationEvent, buildGateEvent } from './decision-event-emit.js';
38
+ import { emitStatusSnapshot } from './status-emit.js';
39
+ import { computeStatusSnapshot } from './status-snapshot.js';
40
+ import { emitDriftAxes } from './drift-emit.js';
41
+
42
+ let _schemaValidator = null;
43
+ function getSchemaValidator() {
44
+ if (!_schemaValidator) _schemaValidator = new SchemaValidator();
45
+ return _schemaValidator;
46
+ }
35
47
 
48
+ import { randomUUID } from 'node:crypto';
36
49
  import { getTargetRoot, resolveProjectPath, loadProjectConfig } from './project-root.js';
50
+ import { appendGateLogEntry, readGateLog, mapResolveOutcomeToSchema } from './gate-log-store.js';
51
+ import { addOpenLoop, resolveOpenLoop, listOpenLoops } from './open-loops-store.js';
37
52
 
38
53
  const PROJECT_ROOT = getTargetRoot();
39
54
 
@@ -154,6 +169,19 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
154
169
  }
155
170
  });
156
171
 
172
+ // COMP-OBS-STATUS: GET /api/lifecycle/status?featureCode=<FC>
173
+ // Returns the latest StatusSnapshot for the given featureCode.
174
+ // Missing or unknown featureCode returns the no-feature snapshot (not 400/404).
175
+ app.get('/api/lifecycle/status', (req, res) => {
176
+ try {
177
+ const fc = req.query.featureCode || null;
178
+ const snapshot = computeStatusSnapshot(store, fc, new Date().toISOString());
179
+ res.json({ snapshot });
180
+ } catch (err) {
181
+ res.status(500).json({ error: err.message });
182
+ }
183
+ });
184
+
157
185
  app.post('/api/vision/items/:id/lifecycle/start', (req, res) => {
158
186
  try {
159
187
  const { featureCode } = req.body;
@@ -171,9 +199,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
171
199
  killedAt: null,
172
200
  killReason: null,
173
201
  };
202
+ // COMP-OBS-TIMELINE: populate phaseHistory before storing
203
+ appendPhaseHistory({ lifecycle }, { from: null, to: 'explore_design', outcome: null, timestamp: now });
174
204
  store.updateLifecycle(req.params.id, lifecycle);
175
205
  scheduleBroadcast();
176
206
  broadcastMessage({ type: 'lifecycleStarted', itemId: req.params.id, phase: 'explore_design', featureCode, timestamp: now });
207
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode, from: null, to: 'explore_design', outcome: null, timestamp: now }));
208
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
209
+ const startedItem = store.items.get(req.params.id);
210
+ if (startedItem) emitDriftAxes(broadcastMessage, store, startedItem, projectRoot, now);
211
+ // COMP-OBS-STATUS: emit status snapshot after lifecycle started
212
+ emitStatusSnapshot(broadcastMessage, store, featureCode, now);
177
213
  res.json(lifecycle);
178
214
  } catch (err) {
179
215
  const status = err.message.includes('not found') ? 404 : 400;
@@ -181,6 +217,38 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
181
217
  }
182
218
  });
183
219
 
220
+ // COMP-OBS-BRANCH: accept BranchLineage payloads from the CC-session watcher.
221
+ app.post('/api/vision/items/:id/lifecycle/branch-lineage', (req, res) => {
222
+ try {
223
+ const item = store.items.get(req.params.id);
224
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
225
+
226
+ const body = req.body || {};
227
+ const { valid, errors } = getSchemaValidator().validate('BranchLineage', body);
228
+ if (!valid) {
229
+ return res.status(400).json({ error: 'Invalid BranchLineage payload', details: errors });
230
+ }
231
+
232
+ const itemFC = item.lifecycle?.featureCode;
233
+ if (!itemFC) {
234
+ return res.status(400).json({ error: 'Item has no lifecycle.featureCode; cannot attach branch lineage.' });
235
+ }
236
+ if (body.feature_code !== itemFC) {
237
+ return res.status(400).json({
238
+ error: `Lineage feature_code (${body.feature_code}) does not match item featureCode (${itemFC}).`,
239
+ });
240
+ }
241
+
242
+ store.updateLifecycleExt(req.params.id, 'branch_lineage', body);
243
+ scheduleBroadcast();
244
+ broadcastMessage({ type: 'branchLineageUpdate', itemId: req.params.id, ...body });
245
+ res.json({ ok: true, branch_lineage: body });
246
+ } catch (err) {
247
+ const status = err.message.includes('not found') ? 404 : 400;
248
+ res.status(status).json({ error: err.message });
249
+ }
250
+ });
251
+
184
252
  app.post('/api/vision/items/:id/lifecycle/advance', (req, res) => {
185
253
  try {
186
254
  const { targetPhase, outcome } = req.body;
@@ -192,10 +260,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
192
260
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
193
261
 
194
262
  item.lifecycle.currentPhase = targetPhase;
195
- store.updateLifecycle(req.params.id, item.lifecycle);
263
+ // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
196
264
  const now = new Date().toISOString();
265
+ appendPhaseHistory(item, { from, to: targetPhase, outcome: outcome ?? null, timestamp: now });
266
+ store.updateLifecycle(req.params.id, item.lifecycle);
197
267
  scheduleBroadcast();
198
268
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome, timestamp: now });
269
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome, timestamp: now }));
270
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
271
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
272
+ // COMP-OBS-STATUS: emit status snapshot after lifecycle transition (advance)
273
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
199
274
  res.json({ from, to: targetPhase, outcome });
200
275
  } catch (err) {
201
276
  const status = err.message.includes('not found') ? 404 : 400;
@@ -215,10 +290,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
215
290
  if (!valid?.includes(targetPhase)) return res.status(400).json({ error: `Invalid transition: ${from} → ${targetPhase}` });
216
291
 
217
292
  item.lifecycle.currentPhase = targetPhase;
218
- store.updateLifecycle(req.params.id, item.lifecycle);
293
+ // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
219
294
  const now = new Date().toISOString();
295
+ appendPhaseHistory(item, { from, to: targetPhase, outcome: 'skipped', timestamp: now });
296
+ store.updateLifecycle(req.params.id, item.lifecycle);
220
297
  scheduleBroadcast();
221
298
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: targetPhase, outcome: 'skipped', timestamp: now });
299
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: targetPhase, outcome: 'skipped', timestamp: now }));
300
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
301
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
302
+ // COMP-OBS-STATUS: emit status snapshot after lifecycle transition (skip)
303
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
222
304
  res.json({ from, to: targetPhase, outcome: 'skipped', reason });
223
305
  } catch (err) {
224
306
  const status = err.message.includes('not found') ? 404 : 400;
@@ -238,10 +320,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
238
320
  item.lifecycle.currentPhase = 'killed';
239
321
  item.lifecycle.killedAt = now;
240
322
  item.lifecycle.killReason = reason;
323
+ // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
324
+ appendPhaseHistory(item, { from, to: 'killed', outcome: 'killed', timestamp: now });
241
325
  store.updateLifecycle(req.params.id, item.lifecycle);
242
326
  store.updateItem(req.params.id, { status: 'killed' });
243
327
  scheduleBroadcast();
244
328
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from, to: 'killed', outcome: 'killed', timestamp: now });
329
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from, to: 'killed', outcome: 'killed', timestamp: now }));
330
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
331
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
332
+ // COMP-OBS-STATUS: emit status snapshot after lifecycle transition (kill)
333
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
245
334
  res.json({ phase: from, reason });
246
335
  } catch (err) {
247
336
  const status = err.message.includes('not found') ? 404 : 400;
@@ -260,10 +349,17 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
260
349
  const now = new Date().toISOString();
261
350
  item.lifecycle.currentPhase = 'complete';
262
351
  item.lifecycle.completedAt = now;
352
+ // COMP-OBS-TIMELINE: populate phaseHistory + emit phase_transition DecisionEvent
353
+ appendPhaseHistory(item, { from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
263
354
  store.updateLifecycle(req.params.id, item.lifecycle);
264
355
  store.updateItem(req.params.id, { status: 'complete' });
265
356
  scheduleBroadcast();
266
357
  broadcastMessage({ type: 'lifecycleTransition', itemId: req.params.id, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now });
358
+ emitDecisionEvent(broadcastMessage, buildPhaseTransitionEvent({ featureCode: item.lifecycle.featureCode, from: 'ship', to: 'complete', outcome: 'approved', timestamp: now }));
359
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
360
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
361
+ // COMP-OBS-STATUS: emit status snapshot after lifecycle transition (complete)
362
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
267
363
  res.json({ completedAt: now });
268
364
  } catch (err) {
269
365
  const status = err.message.includes('not found') ? 404 : 400;
@@ -316,6 +412,22 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
316
412
  store.updateLifecycle(req.params.id, item.lifecycle);
317
413
  scheduleBroadcast();
318
414
  broadcastMessage({ type: 'iterationStarted', itemId: req.params.id, loopId: item.lifecycle.iterationState.loopId, loopType, maxIterations: max, timestamp: now, startedAt: now, wallClockTimeout: timeoutMinutes, maxActions: maxActions ?? null });
415
+ // COMP-OBS-TIMELINE: emit iteration DecisionEvent at loop start
416
+ if (item.lifecycle.featureCode) {
417
+ emitDecisionEvent(broadcastMessage, buildIterationEvent({
418
+ featureCode: item.lifecycle.featureCode,
419
+ loopId: item.lifecycle.iterationState.loopId,
420
+ loopType,
421
+ stage: 'start',
422
+ attempt: null,
423
+ outcome: 'retry',
424
+ timestamp: now,
425
+ }));
426
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
427
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
428
+ // COMP-OBS-STATUS: emit status snapshot after iteration started
429
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
430
+ }
319
431
  res.json(item.lifecycle.iterationState);
320
432
  } catch (err) {
321
433
  res.status(400).json({ error: err.message });
@@ -375,8 +487,31 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
375
487
  const completeMsg = { type: 'iterationComplete', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, outcome: iter.outcome, finalCount: iter.count, timestamp: now };
376
488
  if (iter.elapsedMinutes != null) completeMsg.elapsedMinutes = iter.elapsedMinutes;
377
489
  broadcastMessage(completeMsg);
490
+ // COMP-OBS-TIMELINE: emit iteration DecisionEvent at loop complete (not per-attempt)
491
+ if (item.lifecycle.featureCode) {
492
+ emitDecisionEvent(broadcastMessage, buildIterationEvent({
493
+ featureCode: item.lifecycle.featureCode,
494
+ loopId: iter.loopId,
495
+ loopType: iter.loopType,
496
+ stage: 'complete',
497
+ attempt: iter.count,
498
+ outcome: iter.outcome,
499
+ timestamp: now,
500
+ }));
501
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
502
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
503
+ // COMP-OBS-STATUS: emit status snapshot after iteration complete
504
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
505
+ }
378
506
  } else {
507
+ // per-attempt update: NO DecisionEvent (Decision 2 — would flood strip)
379
508
  broadcastMessage({ type: 'iterationUpdate', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, count: iter.count, maxIterations: iter.maxIterations, exitCriteriaMet: false, findingsCount: result.findings?.length ?? 0, timestamp: now });
509
+ // COMP-OBS-STATUS: STATUS broadcasts on iterationUpdate per Decision 4 (TIMELINE does not)
510
+ if (item.lifecycle.featureCode) {
511
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
512
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
513
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
514
+ }
380
515
  }
381
516
  res.json({ continue: shouldContinue, count: iter.count, maxIterations: iter.maxIterations, outcome: iter.outcome });
382
517
  } catch (err) {
@@ -405,6 +540,22 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
405
540
  store.updateLifecycle(req.params.id, item.lifecycle);
406
541
  scheduleBroadcast();
407
542
  broadcastMessage({ type: 'iterationComplete', itemId: req.params.id, loopId: iter.loopId, loopType: iter.loopType, outcome: 'aborted', finalCount: iter.count, timestamp: now });
543
+ // COMP-OBS-TIMELINE: emit iteration DecisionEvent on abort (outcome maps to 'fail')
544
+ if (item.lifecycle.featureCode) {
545
+ emitDecisionEvent(broadcastMessage, buildIterationEvent({
546
+ featureCode: item.lifecycle.featureCode,
547
+ loopId: iter.loopId,
548
+ loopType: iter.loopType,
549
+ stage: 'complete',
550
+ attempt: iter.count,
551
+ outcome: 'aborted',
552
+ timestamp: now,
553
+ }));
554
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
555
+ emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
556
+ // COMP-OBS-STATUS: emit status snapshot after iteration abort
557
+ emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
558
+ }
408
559
  res.json({ aborted: true });
409
560
  } catch (err) {
410
561
  res.status(400).json({ error: err.message });
@@ -511,6 +662,15 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
511
662
  store.createGate(gate);
512
663
  scheduleBroadcast();
513
664
  broadcastMessage({ type: 'gateCreated', gateId: id, itemId: itemId || null, timestamp: gate.createdAt });
665
+ // COMP-OBS-STATUS: emit status snapshot after gate created
666
+ if (itemId) {
667
+ const gateItem = store.items.get(itemId);
668
+ if (gateItem?.lifecycle?.featureCode) {
669
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
670
+ emitDriftAxes(broadcastMessage, store, gateItem, projectRoot, gate.createdAt);
671
+ emitStatusSnapshot(broadcastMessage, store, gateItem.lifecycle.featureCode, gate.createdAt);
672
+ }
673
+ }
514
674
  res.status(201).json(gate);
515
675
  } catch (err) {
516
676
  res.status(400).json({ error: err.message });
@@ -540,8 +700,21 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
540
700
  // Normalize legacy outcome values
541
701
  const outcomeMap = { approved: 'approve', killed: 'kill', revised: 'revise' };
542
702
  const outcome = outcomeMap[rawOutcome] || rawOutcome;
703
+ // Whitelist — anything outside the route vocabulary is rejected so we
704
+ // can't persist contract-invalid GateLogEntry.decision values.
705
+ const VALID_RESOLVE_OUTCOMES = new Set(['approve', 'revise', 'kill']);
706
+ if (!VALID_RESOLVE_OUTCOMES.has(outcome)) {
707
+ return res.status(400).json({ error: `outcome must be one of: approve, revise, kill (got '${rawOutcome}')` });
708
+ }
543
709
  const gate = store.gates.get(req.params.id);
544
710
  if (!gate) return res.status(404).json({ error: `Gate not found: ${req.params.id}` });
711
+ // Lazy expiry parity with GET — expired gates can't be resolved or audited.
712
+ const gateTimeout = Number(process.env.COMPOSE_GATE_TIMEOUT) || 30 * 60 * 1000;
713
+ if (gate.status === 'pending' && (Date.now() - new Date(gate.createdAt).getTime()) > gateTimeout) {
714
+ gate.status = 'expired';
715
+ store._save();
716
+ return res.status(409).json({ error: `Gate ${req.params.id} has expired and cannot be resolved` });
717
+ }
545
718
  // Idempotent: already-resolved gates return 200
546
719
  if (gate.status !== 'pending') {
547
720
  return res.status(200).json({ gateId: req.params.id, gateOutcome: gate.outcome });
@@ -551,7 +724,60 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
551
724
  store.resolveGate(req.params.id, { outcome, comment, resolvedBy });
552
725
 
553
726
  scheduleBroadcast();
554
- broadcastMessage({ type: 'gateResolved', gateId: req.params.id, itemId: gate.itemId, outcome, timestamp: new Date().toISOString() });
727
+ const resolvedAt = new Date().toISOString();
728
+ broadcastMessage({ type: 'gateResolved', gateId: req.params.id, itemId: gate.itemId, outcome, timestamp: resolvedAt });
729
+
730
+ // COMP-OBS-GATELOG: persist audit entry + emit gate DecisionEvent (Decision 3 — emit-first-then-append).
731
+ // Skip when gate has no resolvable feature_code (Decision 1b).
732
+ const gateLogItem = gate.itemId ? store.items.get(gate.itemId) : null;
733
+ const gateFeatureCode = gateLogItem?.lifecycle?.featureCode ?? null;
734
+ if (gateFeatureCode) {
735
+ const entryId = randomUUID();
736
+ const schemaDecision = mapResolveOutcomeToSchema(outcome);
737
+ const createdAtMs = gate.createdAt ? Date.parse(gate.createdAt) : Date.parse(resolvedAt);
738
+ const durationMs = Date.parse(resolvedAt) - createdAtMs;
739
+
740
+ const gateEvent = buildGateEvent({
741
+ featureCode: gateFeatureCode,
742
+ gateLogEntryId: entryId,
743
+ gateId: req.params.id,
744
+ decision: outcome,
745
+ timestamp: resolvedAt,
746
+ });
747
+
748
+ // Emit first — if it throws, we still write but with decision_event_id: null
749
+ let emittedEventId = null;
750
+ try {
751
+ emitDecisionEvent(broadcastMessage, gateEvent);
752
+ emittedEventId = gateEvent.id;
753
+ } catch (emitErr) {
754
+ console.warn('[vision-routes] gate DecisionEvent emit failed:', emitErr.message);
755
+ }
756
+
757
+ /** @type {import('./gate-log-store.js').GateLogEntry} */
758
+ const entry = {
759
+ id: entryId,
760
+ gate_id: req.params.id,
761
+ decision: schemaDecision,
762
+ operator: gate.resolvedBy ?? null,
763
+ duration_to_decide_ms: durationMs >= 0 ? durationMs : null,
764
+ timestamp: resolvedAt,
765
+ feature_code: gateFeatureCode,
766
+ decision_event_id: emittedEventId,
767
+ };
768
+ appendGateLogEntry(entry);
769
+ }
770
+ // else: featureless gate — skip both writes (Decision 1b)
771
+
772
+ // COMP-OBS-STATUS: emit status snapshot after gate resolved
773
+ if (gate.itemId) {
774
+ const resolvedItem = store.items.get(gate.itemId);
775
+ if (resolvedItem?.lifecycle?.featureCode) {
776
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
777
+ emitDriftAxes(broadcastMessage, store, resolvedItem, projectRoot, resolvedAt);
778
+ emitStatusSnapshot(broadcastMessage, store, resolvedItem.lifecycle.featureCode, resolvedAt);
779
+ }
780
+ }
555
781
  res.json({ gateId: req.params.id, gateOutcome: outcome });
556
782
  } catch (err) {
557
783
  const status = err.message.includes('not found') ? 404 : 400;
@@ -559,6 +785,94 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
559
785
  }
560
786
  });
561
787
 
788
+ // ── COMP-OBS-LOOPS routes ─────────────────────────────────────────────────
789
+
790
+ // GET /api/vision/items/:id/loops — list open loops for an item
791
+ app.get('/api/vision/items/:id/loops', (req, res) => {
792
+ try {
793
+ const item = store.items.get(req.params.id);
794
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
795
+ const includeResolved = req.query.includeResolved === 'true';
796
+ const loops = listOpenLoops(item, { includeResolved });
797
+ res.json({ loops });
798
+ } catch (err) {
799
+ res.status(500).json({ error: err.message });
800
+ }
801
+ });
802
+
803
+ // POST /api/vision/items/:id/loops — add a new open loop
804
+ app.post('/api/vision/items/:id/loops', (req, res) => {
805
+ try {
806
+ const item = store.items.get(req.params.id);
807
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
808
+ if (!item.lifecycle?.featureCode) {
809
+ return res.status(400).json({ error: 'item has no lifecycle.featureCode — cannot create feature-scoped open loop' });
810
+ }
811
+ const { kind, summary, parent_branch, ttl_days } = req.body;
812
+ // Schema-aligned validation (CONTRACT v0.2.3 OpenLoop)
813
+ const VALID_KINDS = new Set(['deferred', 'blocked', 'open_question']);
814
+ if (!kind) return res.status(400).json({ error: 'kind is required' });
815
+ if (!VALID_KINDS.has(kind)) {
816
+ return res.status(400).json({ error: `kind must be one of: deferred, blocked, open_question (got '${kind}')` });
817
+ }
818
+ if (typeof summary !== 'string' || summary.length === 0) {
819
+ return res.status(400).json({ error: 'summary is required (non-empty string)' });
820
+ }
821
+ if (summary.length > 280) {
822
+ return res.status(400).json({ error: 'summary exceeds 280-char schema limit' });
823
+ }
824
+ if (ttl_days !== undefined && (!Number.isInteger(ttl_days) || ttl_days < 0)) {
825
+ return res.status(400).json({ error: 'ttl_days must be a non-negative integer' });
826
+ }
827
+
828
+ const { loop, nextLoops } = addOpenLoop(item, { kind, summary, parent_branch, ttl_days });
829
+ store.updateLifecycleExt(item.id, 'open_loops', nextLoops);
830
+
831
+ const updatedItem = store.items.get(item.id);
832
+ broadcastMessage({ type: 'openLoopsUpdate', itemId: item.id, loops: nextLoops });
833
+ const featureCode = updatedItem.lifecycle.featureCode;
834
+ const now = new Date().toISOString();
835
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
836
+ emitDriftAxes(broadcastMessage, store, updatedItem, projectRoot, now);
837
+ emitStatusSnapshot(broadcastMessage, store, featureCode, now);
838
+
839
+ res.status(201).json({ loop });
840
+ } catch (err) {
841
+ const status = err.status || (err.message.includes('not found') ? 404 : 400);
842
+ res.status(status).json({ error: err.message });
843
+ }
844
+ });
845
+
846
+ // POST /api/vision/items/:id/loops/:loopId/resolve — resolve a loop
847
+ app.post('/api/vision/items/:id/loops/:loopId/resolve', (req, res) => {
848
+ try {
849
+ const item = store.items.get(req.params.id);
850
+ if (!item) return res.status(404).json({ error: `Item not found: ${req.params.id}` });
851
+ const { note, resolved_by } = req.body;
852
+
853
+ const { loop, nextLoops } = resolveOpenLoop(item, req.params.loopId, {
854
+ note,
855
+ resolved_by: resolved_by || process.env.USER || 'unknown',
856
+ });
857
+ store.updateLifecycleExt(item.id, 'open_loops', nextLoops);
858
+
859
+ broadcastMessage({ type: 'openLoopsUpdate', itemId: item.id, loops: nextLoops });
860
+ const featureCode = item.lifecycle?.featureCode;
861
+ if (featureCode) {
862
+ const now = new Date().toISOString();
863
+ // COMP-OBS-DRIFT: emit drift axes before status so STATUS reads fresh drift_axes
864
+ const resolvedLoopItem = store.items.get(item.id);
865
+ if (resolvedLoopItem) emitDriftAxes(broadcastMessage, store, resolvedLoopItem, projectRoot, now);
866
+ emitStatusSnapshot(broadcastMessage, store, featureCode, now);
867
+ }
868
+
869
+ res.json({ loop });
870
+ } catch (err) {
871
+ const status = err.status || (err.message.includes('not found') ? 404 : 400);
872
+ res.status(status).json({ error: err.message });
873
+ }
874
+ });
875
+
562
876
  // GET /api/vision/summary — structured board summary
563
877
  app.get('/api/vision/summary', (_req, res) => {
564
878
  const { items, connections } = store.getState();
@@ -635,6 +949,20 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
635
949
  res.json({ ok: true });
636
950
  });
637
951
 
952
+ // GET /api/lifecycle/budget?featureCode=<FC> — COMP-OBS-STEPDETAIL
953
+ // Returns per-loop-type budget breakdown from ledger + settings.
954
+ // v1 limitation: usedIterations is feature-wide (ledger doesn't split by loopType).
955
+ app.get('/api/lifecycle/budget', (req, res) => {
956
+ const { featureCode } = req.query;
957
+ if (!featureCode) {
958
+ return res.status(400).json({ error: 'featureCode query param is required' });
959
+ }
960
+ const composeDir = path.join(projectRoot, '.compose');
961
+ const settings = settingsStore?.get();
962
+ const budget = readBudget(composeDir, featureCode, settings);
963
+ res.json(budget);
964
+ });
965
+
638
966
  // POST /api/plan/parse — extract file paths from plan/spec markdown
639
967
  app.post('/api/plan/parse', (req, res) => {
640
968
  const { filePath, itemId } = req.body || {};
@@ -18,13 +18,17 @@ import { AgentRegistry } from './agent-registry.js';
18
18
  import { HealthMonitor } from './agent-health.js';
19
19
  import { WorktreeGC } from './worktree-gc.js';
20
20
  import { attachVisionRoutes } from './vision-routes.js';
21
+ import { deriveDecisionEvents } from './decision-events-snapshot.js';
22
+ import { CCSessionWatcher } from './cc-session-watcher.js';
23
+ import { emitStatusSnapshot } from './status-emit.js';
24
+ import { emitDriftAxes } from './drift-emit.js';
25
+ import { SchemaValidator } from './schema-validator.js';
21
26
  import { attachSessionRoutes } from './session-routes.js';
22
27
  import { attachActivityRoutes } from './activity-routes.js';
23
28
  import { SettingsStore } from './settings-store.js';
24
29
  import { attachSettingsRoutes } from './settings-routes.js';
25
30
  import { attachDesignRoutes } from './design-routes.js';
26
31
  import { DesignSessionManager } from './design-session.js';
27
- import { ClaudeSDKConnector } from './connectors/claude-sdk-connector.js';
28
32
  import { attachPipelineRoutes } from './pipeline-routes.js';
29
33
  import { attachIdeaboxRoutes } from './ideabox-routes.js';
30
34
  import { CoalescingBuffer } from './coalescing-buffer.js';
@@ -126,8 +130,6 @@ export class VisionServer {
126
130
  // Re-resolve on every call so project switches get fresh instances.
127
131
  let _designSessionManager = null;
128
132
  let _designDataDir = null;
129
- let _designConnector = null;
130
- let _designCwd = null;
131
133
 
132
134
  attachDesignRoutes(app, {
133
135
  getSessionManager: () => {
@@ -139,14 +141,6 @@ export class VisionServer {
139
141
  }
140
142
  return _designSessionManager;
141
143
  },
142
- getConnector: () => {
143
- const cwd = getTargetRoot();
144
- if (cwd !== _designCwd) {
145
- _designConnector = new ClaudeSDKConnector({ cwd });
146
- _designCwd = cwd;
147
- }
148
- return _designConnector;
149
- },
150
144
  getProjectRoot: () => getTargetRoot(),
151
145
  });
152
146
 
@@ -244,6 +238,81 @@ export class VisionServer {
244
238
  });
245
239
  }
246
240
 
241
+ // ── COMP-OBS-BRANCH: CC-session watcher (opt-in) ──────────────────────
242
+ // Default OFF. Enable by setting `capabilities.cc_session_watcher: true` in compose.json
243
+ // or by setting the `CC_SESSION_WATCHER=1` env var. When enabled, Forge reads
244
+ // `~/.claude/projects/**/*.jsonl` and emits BranchLineage + DecisionEvents tied to the
245
+ // current feature via sessions.json's transcriptPath basename.
246
+ const ccWatcherEnabled =
247
+ this._config.capabilities?.cc_session_watcher === true ||
248
+ process.env.CC_SESSION_WATCHER === '1';
249
+ if (ccWatcherEnabled) {
250
+ try {
251
+ const projectsRoot = process.env.CC_PROJECTS_ROOT ||
252
+ path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects');
253
+ const sessionsFile = this.sessionManager?.sessionsFile || path.join(getDataDir(), 'sessions.json');
254
+ const featureRoot = path.join(getTargetRoot(), 'docs', 'features');
255
+
256
+ const findItemIdByFeatureCode = (fc) => {
257
+ for (const it of this.store.items.values()) {
258
+ if (it.lifecycle?.featureCode === fc) return it.id;
259
+ }
260
+ return null;
261
+ };
262
+
263
+ const schemaValidator = new SchemaValidator();
264
+ const postBranchLineage = async (itemId, lineage) => {
265
+ // Validate at the producer boundary — the watcher path bypasses the HTTP
266
+ // route, so we must re-check here to guarantee contract conformance.
267
+ const { valid, errors } = schemaValidator.validate('BranchLineage', lineage);
268
+ if (!valid) {
269
+ throw new Error(`Invalid BranchLineage payload: ${JSON.stringify(errors)}`);
270
+ }
271
+ const item = this.store.items.get(itemId);
272
+ const itemFC = item?.lifecycle?.featureCode;
273
+ if (!itemFC || itemFC !== lineage.feature_code) {
274
+ throw new Error(
275
+ `feature_code mismatch: lineage=${lineage.feature_code} item=${itemFC || '<none>'}`
276
+ );
277
+ }
278
+ this.store.updateLifecycleExt(itemId, 'branch_lineage', lineage);
279
+ this.scheduleBroadcast();
280
+ this.broadcastMessage({ type: 'branchLineageUpdate', itemId, ...lineage });
281
+ };
282
+
283
+ this._ccWatcher = new CCSessionWatcher({
284
+ projectsRoot,
285
+ sessionsFile,
286
+ featureRoot,
287
+ findItemIdByFeatureCode,
288
+ postBranchLineage,
289
+ broadcastMessage: (msg) => this.broadcastMessage(msg),
290
+ // COMP-OBS-STATUS: inject emitStatusSnapshot for post-lineage status broadcast
291
+ emitStatusSnapshot,
292
+ getState: () => this.store,
293
+ // COMP-OBS-DRIFT: inject emitDriftAxes for post-lineage drift broadcast
294
+ emitDriftAxes,
295
+ projectRoot: getTargetRoot(),
296
+ });
297
+
298
+ // Seed emitted_event_ids from any existing lineage so startup doesn't replay.
299
+ for (const it of this.store.items.values()) {
300
+ const l = it.lifecycle?.lifecycle_ext?.branch_lineage;
301
+ if (l?.feature_code && Array.isArray(l.emitted_event_ids)) {
302
+ this._ccWatcher.seedEmittedEventIds(l.feature_code, l.emitted_event_ids);
303
+ }
304
+ }
305
+
306
+ this._ccWatcher.fullScan().catch(err => {
307
+ console.warn('[vision] cc-session-watcher initial scan failed:', err.message);
308
+ });
309
+ this._ccWatcher.start();
310
+ console.log(`[vision] cc-session-watcher enabled (projectsRoot=${projectsRoot})`);
311
+ } catch (err) {
312
+ console.warn('[vision] cc-session-watcher failed to start:', err.message);
313
+ }
314
+ }
315
+
247
316
  // ── Haiku summary broadcast ─────────────────────────────────────────────
248
317
  if (this.sessionManager) {
249
318
  this.sessionManager.onSummary((summary) => {
@@ -307,10 +376,33 @@ export class VisionServer {
307
376
  if ('type' in state) {
308
377
  throw new Error('store.getState() must not include a `type` field — would collide with hydrate envelope');
309
378
  }
379
+
380
+ // COMP-OBS-TIMELINE: derive DecisionEvents for the active feature
381
+ // (derive from all features present in state — client filters by featureCode)
382
+ let decisionEventsSnapshot = [];
383
+ try {
384
+ const internalState = this.store;
385
+ // Use internal items Map for deriveDecisionEvents (avoids serialization round-trip)
386
+ const featureCodes = new Set();
387
+ for (const item of (internalState.items?.values?.() || [])) {
388
+ if (item?.lifecycle?.featureCode) featureCodes.add(item.lifecycle.featureCode);
389
+ }
390
+ for (const fc of featureCodes) {
391
+ decisionEventsSnapshot = decisionEventsSnapshot.concat(
392
+ deriveDecisionEvents(internalState, fc)
393
+ );
394
+ }
395
+ } catch (snapshotErr) {
396
+ console.error('[vision] decisionEventsSnapshot derivation error:', snapshotErr.message);
397
+ }
398
+
310
399
  const snapshot = Object.assign(
311
400
  { type: 'hydrate' },
312
401
  state,
313
- { sessions: this.sessionManager?.getRecentSessions?.() || [] }
402
+ {
403
+ sessions: this.sessionManager?.getRecentSessions?.() || [],
404
+ decisionEventsSnapshot,
405
+ }
314
406
  );
315
407
  ws.send(JSON.stringify(snapshot));
316
408
  } catch (err) {
@@ -217,7 +217,13 @@ export class VisionStore {
217
217
  updateLifecycle(id, lifecycle) {
218
218
  const item = this.items.get(id);
219
219
  if (!item) throw new Error(`Item not found: ${id}`);
220
+ const priorExt = item.lifecycle?.lifecycle_ext;
220
221
  item.lifecycle = lifecycle;
222
+ // Preserve lifecycle_ext across partial-update callers (Wave 6 additive slot).
223
+ // A caller that truly wants to clear it passes `lifecycle_ext: {}` or `null` explicitly.
224
+ if (priorExt && lifecycle && !('lifecycle_ext' in lifecycle)) {
225
+ item.lifecycle.lifecycle_ext = priorExt;
226
+ }
221
227
  // Re-derive group when lifecycle gains a featureCode
222
228
  if (lifecycle?.featureCode && !item.group) {
223
229
  item.group = deriveGroup(item.title, lifecycle.featureCode);
@@ -228,6 +234,21 @@ export class VisionStore {
228
234
  return item;
229
235
  }
230
236
 
237
+ /** Merge a single lifecycle_ext.<key> = value slot without touching other extension keys.
238
+ * Wave 6 callers (BRANCH, OPEN-LOOPS, DRIFT, STATUS) use this rather than reaching into
239
+ * item.lifecycle.lifecycle_ext directly. */
240
+ updateLifecycleExt(id, key, value) {
241
+ const item = this.items.get(id);
242
+ if (!item) throw new Error(`Item not found: ${id}`);
243
+ if (!item.lifecycle) item.lifecycle = {};
244
+ if (!item.lifecycle.lifecycle_ext) item.lifecycle.lifecycle_ext = {};
245
+ item.lifecycle.lifecycle_ext[key] = value;
246
+ item.updatedAt = new Date().toISOString();
247
+ this.items.set(id, item);
248
+ this._save();
249
+ return item;
250
+ }
251
+
231
252
  /** Delete an item and all its connections and gates */
232
253
  deleteItem(id) {
233
254
  if (!this.items.has(id)) throw new Error(`Item not found: ${id}`);
@@ -1 +0,0 @@
1
- import{aq as o,ar as n}from"./index-DReRlzZI.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-DE0WBDkj.js";import{_ as i}from"./index-DReRlzZI.js";import"./chunk-FMBD7UC4-CTDIPA3p.js";import"./chunk-YZCP3GAM-ojGkzcZK.js";import"./chunk-55IACEB6-CE1EXenG.js";import"./chunk-EDXVE4YY-DA7Ana6H.js";var u={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{u as diagram};