@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
@@ -21,6 +21,7 @@ import {
21
21
  iterationDecisionEventId,
22
22
  gateDecisionEventId,
23
23
  driftThresholdDecisionEventId,
24
+ policyViolationDecisionEventId,
24
25
  } from './decision-event-id.js';
25
26
  import { mapResolveOutcomeToSchema } from './gate-log-store.js';
26
27
 
@@ -43,13 +44,15 @@ export function emitDecisionEvent(broadcastMessage, event) {
43
44
  /**
44
45
  * Build a kind=phase_transition DecisionEvent.
45
46
  *
46
- * @param {{ featureCode, from, to, outcome, agent_id, timestamp }} params
47
+ * @param {{ featureCode, from, to, outcome, agent_id, timestamp, origin, recordedAt, confidence }} params
47
48
  * - from: previous phase string, or null for the initial lifecycle start
48
49
  * - to: new phase string
49
50
  * - outcome: optional outcome string (for context)
50
51
  * - agent_id: optional operator/agent identifier
51
52
  */
52
- export function buildPhaseTransitionEvent({ featureCode, from, to, outcome, agent_id, timestamp }) {
53
+ export function buildPhaseTransitionEvent({
54
+ featureCode, from, to, outcome, agent_id, timestamp, origin, recordedAt, confidence,
55
+ }) {
53
56
  const now = timestamp || new Date().toISOString();
54
57
  const fromStr = from == null ? 'null' : String(from);
55
58
  const id = phaseTransitionDecisionEventId(featureCode, from, to, now);
@@ -64,6 +67,9 @@ export function buildPhaseTransitionEvent({ featureCode, from, to, outcome, agen
64
67
  metadata: {
65
68
  from_phase: fromStr,
66
69
  to_phase: String(to),
70
+ ...(origin !== undefined ? { origin } : {}),
71
+ ...(recordedAt !== undefined ? { recorded_at: recordedAt } : {}),
72
+ ...(confidence !== undefined ? { confidence } : {}),
67
73
  },
68
74
  roles: [{ name: 'PRODUCER', agent_id: agent_id || null }],
69
75
  };
@@ -173,3 +179,36 @@ export function buildGateEvent({ featureCode, gateLogEntryId, gateId, decision,
173
179
  roles: [],
174
180
  };
175
181
  }
182
+
183
+ /**
184
+ * Build a kind=policy_violation DecisionEvent (COMP-POLICY-CHECK-5).
185
+ *
186
+ * CONTRACT: first-class kind since `contracts/comp-obs-contract.schema.json`
187
+ * v0.2.6 (2026-08-17) — closed metadata subschema {step_id, rule, matched,
188
+ * suppressed, user_mode, build_id}. Keep the emitted shape and the subschema in
189
+ * lockstep; extending either is a versioned contract edit.
190
+ *
191
+ * @param {{ featureCode, buildId, stepId, rule, matched, suppressed, userMode, timestamp }} params
192
+ */
193
+ export function buildPolicyViolationEvent({
194
+ featureCode, buildId, stepId, rule, matched, suppressed, userMode, timestamp,
195
+ }) {
196
+ const now = timestamp || new Date().toISOString();
197
+ const id = policyViolationDecisionEventId(featureCode, buildId ?? 'no-build', stepId, rule, matched);
198
+ return {
199
+ id,
200
+ feature_code: featureCode,
201
+ timestamp: now,
202
+ kind: 'policy_violation',
203
+ title: `${suppressed ? 'Policy match suppressed' : 'Policy violation'}: ${rule}`,
204
+ metadata: {
205
+ step_id: stepId,
206
+ rule,
207
+ matched,
208
+ suppressed: Boolean(suppressed),
209
+ user_mode: userMode,
210
+ build_id: buildId ?? null,
211
+ },
212
+ roles: [],
213
+ };
214
+ }
@@ -62,3 +62,20 @@ export function driftThresholdDecisionEventId(featureCode, axisId, breachStarted
62
62
  const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
63
63
  return uuidv5(`drift_threshold:${axisId}:${breachStartedAtIso}`, featureNs);
64
64
  }
65
+
66
+ /**
67
+ * Deterministic id for a policy_violation DecisionEvent (COMP-POLICY-CHECK-5).
68
+ * Unique per (featureCode, buildId, stepId, rule, matched pattern) so a replayed
69
+ * build-stream line re-derives the same id and the timeline stays deduplicated.
70
+ *
71
+ * @param {string} featureCode
72
+ * @param {string} buildId
73
+ * @param {string} stepId
74
+ * @param {string} ruleName
75
+ * @param {string} matched — the pattern text that matched
76
+ * @returns {string} UUID v5
77
+ */
78
+ export function policyViolationDecisionEventId(featureCode, buildId, stepId, ruleName, matched) {
79
+ const featureNs = uuidv5(String(featureCode), ROOT_NAMESPACE);
80
+ return uuidv5(`policy_violation:${buildId}:${stepId}:${ruleName}:${matched}`, featureNs);
81
+ }
@@ -53,6 +53,9 @@ export function deriveDecisionEvents(state, featureCode) {
53
53
  outcome: entry.outcome,
54
54
  agent_id: entry.agent_id || null,
55
55
  timestamp: entry.timestamp,
56
+ origin: entry.origin,
57
+ recordedAt: entry.recordedAt,
58
+ confidence: entry.confidence,
56
59
  }));
57
60
  }
58
61
 
@@ -16,7 +16,7 @@ import { randomUUID } from 'node:crypto';
16
16
  import { parseDecisionBlocks } from '../src/components/vision/designSessionState.js';
17
17
  import { StratumMcpClient } from '../lib/stratum-mcp-client.js';
18
18
  import { KNOWN_VERSIONS } from '../lib/build-stream-schema.js';
19
- import { getTargetRoot, resolveProjectPath } from './project-root.js';
19
+ import { getTargetRoot, resolveProjectPath, trackProjectWork } from './project-root.js';
20
20
  import { resolveStratumMcpConnection } from '../lib/stratum-engine.js';
21
21
  import { relForDisplay } from '../lib/project-paths.js';
22
22
 
@@ -50,11 +50,11 @@ async function _getStratum(root = getTargetRoot(), { factory } = {}) {
50
50
  // Test-only export: exercise the root-keyed connect/cache without a live server.
51
51
  export { _getStratum as _getDesignStratumForTest };
52
52
 
53
- /** Tear down every cached Stratum connection. Tests should call this in after(). */
54
- export async function closeDesignStratum() {
55
- const clients = [..._stratumClients.values()];
56
- _stratumClients.clear();
57
- _stratumConnectPromises.clear();
53
+ /** Tear down an evicted workspace's connection, or all connections at shutdown. */
54
+ export async function closeDesignStratum(projectRoot) {
55
+ const keys = projectRoot === undefined ? [..._stratumConnectPromises.keys()] : [projectRoot];
56
+ const clients = keys.map(key => _stratumClients.get(key)).filter(Boolean);
57
+ for (const key of keys) { _stratumClients.delete(key); _stratumConnectPromises.delete(key); }
58
58
  for (const c of clients) {
59
59
  try { await c.close(); } catch { /* ignore */ }
60
60
  }
@@ -66,6 +66,12 @@ export const designListeners = new Map();
66
66
  /** In-flight guard — prevents overlapping agent runs for the same session. */
67
67
  const _inFlight = new Set();
68
68
 
69
+ export function hasDesignWork(projectRoot) {
70
+ const prefix = `${projectRoot}:`;
71
+ return [..._inFlight].some(key => key.startsWith(prefix))
72
+ || [...designListeners].some(([key, clients]) => key.startsWith(prefix) && clients.size > 0);
73
+ }
74
+
69
75
  /**
70
76
  * Build a session key for SSE listener scoping.
71
77
  * @param {string} scope
@@ -384,7 +390,7 @@ export function attachDesignRoutes(app, { getSessionManager, getProjectRoot }) {
384
390
  });
385
391
 
386
392
  // POST /api/design/complete — generate design doc and mark session complete
387
- app.post('/api/design/complete', async (req, res) => {
393
+ app.post('/api/design/complete', (req, res) => trackProjectWork(async () => {
388
394
  const { scope, featureCode, draftDoc } = req.body || {};
389
395
  try {
390
396
  const sessionManager = getSessionManager();
@@ -528,7 +534,7 @@ Output ONLY the Markdown content, no code fences.`;
528
534
  }
529
535
  res.status(400).json({ error: err.message });
530
536
  }
531
- });
537
+ }));
532
538
 
533
539
  // POST /api/design/revise — mark a decision as superseded and re-ask
534
540
  app.post('/api/design/revise', (req, res) => {
@@ -18,6 +18,8 @@ import path from 'node:path';
18
18
  import { parse as parseYaml } from 'yaml';
19
19
  import { getTargetRoot, resolveProjectPath } from './project-root.js';
20
20
  import { relForDisplay } from '../lib/project-paths.js';
21
+ import { featureStatusToVisionStatus } from '../lib/status-projection.js';
22
+ import { VERIFIED_BY } from './completion-projection.js';
21
23
  import { assertValidLinkShape } from '../lib/feature-write-guard.js';
22
24
  import { depsToEdges } from '../lib/roadmap-graph/model.js';
23
25
 
@@ -175,7 +177,9 @@ export function scanFeatures(featuresDir) {
175
177
  const specPath = path.join(featureDir, 'feature.json');
176
178
  if (fs.existsSync(specPath)) {
177
179
  feature.hasFeatureJson = true;
180
+ feature.featureJsonUnreadable = true; // cleared once the parse succeeds
178
181
  const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
182
+ feature.featureJsonUnreadable = false;
179
183
  if (typeof spec.group === 'string' && spec.group.trim()) {
180
184
  feature.group = spec.group.trim();
181
185
  }
@@ -185,6 +189,7 @@ export function scanFeatures(featuresDir) {
185
189
  // it (COMP-ROADMAP-GRAPH-2: kills the feature.json/cockpit status de-sync).
186
190
  if (typeof spec.status === 'string' && spec.status.trim()) {
187
191
  feature.status = normalizeStatus(spec.status);
192
+ feature.canonicalStatus = spec.status.trim().toUpperCase();
188
193
  }
189
194
  }
190
195
  } catch { /* ignore malformed feature.json */ }
@@ -473,6 +478,33 @@ export function writeFeatureGroupToDisk(item, newGroup, featuresDir) {
473
478
  * @param {object} store — VisionStore instance
474
479
  * @returns {{ features: number, updated: number, connections: number }}
475
480
  */
481
+ /**
482
+ * The synchronous startup tier for a scanned `complete` feature (§2.3b).
483
+ * @returns {{status:string, completion_projection?:object}}
484
+ */
485
+ function seedCompletionTier(feature) {
486
+ if (feature.canonicalStatus === 'COMPLETE') {
487
+ return {
488
+ status: 'complete',
489
+ completion_projection: { verified_by: VERIFIED_BY.CANONICAL, at: new Date().toISOString(), source: 'startup-scan' },
490
+ };
491
+ }
492
+ if (!feature.hasFeatureJson) {
493
+ // No feature.json at all: the scanner inferred completion from documents
494
+ // (report.md present, design.md prose). Weakest tier — and the ONLY case
495
+ // that gets it. This is what "unmanaged" means (§2.3b round 5).
496
+ return {
497
+ status: 'complete',
498
+ completion_projection: { verified_by: VERIFIED_BY.DOCUMENT, at: new Date().toISOString(), source: 'startup-scan' },
499
+ };
500
+ }
501
+ // A feature.json exists and does not say COMPLETE — malformed, or valid with
502
+ // no status while the documents claim completion. Canon wins: a managed
503
+ // feature is never seeded complete on document evidence (Codex r1 #4).
504
+ if (feature.featureJsonUnreadable) return { status: 'planned' };
505
+ return { status: featureStatusToVisionStatus(feature.canonicalStatus) || 'planned' };
506
+ }
507
+
476
508
  export function seedFeatures(features, store) {
477
509
  const seeded = { features: 0, updated: 0, connections: 0 };
478
510
  const featureItemMap = new Map(); // featureCode → itemId
@@ -489,11 +521,23 @@ export function seedFeatures(features, store) {
489
521
  );
490
522
 
491
523
  if (!featureItem) {
524
+ // COMP-COMPLETION-GATE slice 3 (AC-4d, AC-16a, AC-16b): startup seeding is
525
+ // synchronous and pre-listen, so it does not consult the guard (§2.3b,
526
+ // round 5). A `complete` status is projected through the same predicate
527
+ // as every other transport — on canonical feature.json alone, stamped
528
+ // `canonical-status-only`, or `document-derived` for an unmanaged folder
529
+ // (no feature.json; the scanner inferred completion from report.md or doc
530
+ // metadata). That is display state, never a completion compose vouches
531
+ // for. A folder whose documents say complete but whose feature.json does
532
+ // NOT is created as its feature.json says.
533
+ const projected = feature.status === 'complete'
534
+ ? seedCompletionTier(feature)
535
+ : { status: feature.status || 'planned' };
492
536
  featureItem = store.createItem({
493
537
  type: 'feature',
494
538
  title: feature.name,
495
539
  description: feature.description || '',
496
- status: feature.status || 'planned',
540
+ status: projected.status,
497
541
  phase: feature.phase || 'planning',
498
542
  confidence: feature.confidence,
499
543
  files: feature.artifacts.map(a => artifactPath(feature, a)),
@@ -503,6 +547,9 @@ export function seedFeatures(features, store) {
503
547
  try {
504
548
  store.updateLifecycle(featureItem.id, { featureCode: feature.name, currentPhase: 'explore_design' });
505
549
  } catch { /* lifecycle method may not exist */ }
550
+ if (projected.completion_projection) {
551
+ store.updateItem(featureItem.id, { completion_projection: projected.completion_projection });
552
+ }
506
553
  featureItem = store.items.get(featureItem.id);
507
554
  seeded.features++;
508
555
  } else {
@@ -511,8 +558,35 @@ export function seedFeatures(features, store) {
511
558
  if (feature.description && feature.description !== featureItem.description) {
512
559
  updates.description = feature.description;
513
560
  }
561
+ // A complete item is RE-VERIFIED on every scan, not only when its status
562
+ // differs (Codex r1 #5, r2 #2): an item stamped when canon was valid must
563
+ // be downgraded if canon is later corrupted or loses its status, and an
564
+ // item complete before tiers existed must gain its stamp.
565
+ if (feature.status === 'complete' && featureItem.status === 'complete') {
566
+ const projected = seedCompletionTier(feature);
567
+ if (projected.status !== 'complete') {
568
+ updates.status = projected.status;
569
+ updates.completion_projection = null;
570
+ } else if (!featureItem.completion_projection
571
+ || featureItem.completion_projection.verified_by !== projected.completion_projection.verified_by) {
572
+ updates.completion_projection = projected.completion_projection;
573
+ }
574
+ }
514
575
  if (feature.status && feature.status !== featureItem.status) {
515
- updates.status = feature.status;
576
+ if (feature.status === 'complete') {
577
+ const projected = seedCompletionTier(feature);
578
+ if (projected.status === 'complete') {
579
+ updates.status = 'complete';
580
+ updates.completion_projection = projected.completion_projection;
581
+ } else if (projected.status !== featureItem.status) {
582
+ updates.status = projected.status;
583
+ }
584
+ } else {
585
+ updates.status = feature.status;
586
+ // Leaving complete for any recognized status: the stamp is a claim
587
+ // about a completion that canon no longer records (Codex r3).
588
+ if (featureItem.completion_projection) updates.completion_projection = null;
589
+ }
516
590
  }
517
591
  if (feature.confidence > (featureItem.confidence || 0)) {
518
592
  updates.confidence = feature.confidence;
@@ -10,9 +10,13 @@ import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
12
  import { getTargetRoot, loadProjectConfig, ensureDataDir } from './project-root.js';
13
- import { resolveDocsPathFromConfig, resolveFeaturesPathFromConfig } from '../lib/project-paths.js';
13
+ import {
14
+ resolveDocsPathFromConfig,
15
+ resolveFeaturesPathFromConfig,
16
+ resolveIdeaboxPathFromConfig,
17
+ relForDisplay,
18
+ } from '../lib/project-paths.js';
14
19
 
15
- const PROJECT_ROOT = getTargetRoot();
16
20
 
17
21
  /**
18
22
  * fs.watch fileFilter for the pipelines/ watch (COMP-PIPE-EDIT-6): only
@@ -32,8 +36,77 @@ export function buildSpecChangedMessage(file, relativePath) {
32
36
  return { type: 'specChanged', file, path: relativePath };
33
37
  }
34
38
 
39
+ /** Coalescing window for the ideabox-projection watch. */
40
+ export const IDEABOX_COALESCE_MS = 100;
41
+
42
+ /**
43
+ * fs.watch fileFilter for the ideabox-projection watch (IDEA-24). That watch is
44
+ * NON-recursive on the projection's own parent directory, so `filename` is a bare
45
+ * name and exact equality is the whole test.
46
+ *
47
+ * Exact equality, not a suffix or basename match, because `render-ideabox.js`
48
+ * publishes atomically through `.ideabox.md.tmp.<uuid>` in the SAME directory. A
49
+ * looser predicate would fire on the temp file — announcing an update before the
50
+ * rename that makes it real, and a second time after.
51
+ */
52
+ export function isIdeaboxProjectionFile(filename, projectionBasename) {
53
+ return typeof filename === 'string'
54
+ && typeof projectionBasename === 'string'
55
+ && projectionBasename.length > 0
56
+ && filename === projectionBasename;
57
+ }
58
+
59
+ /**
60
+ * Shape of the vision-WS `ideaboxUpdated` broadcast raised by the projection
61
+ * watch (IDEA-24).
62
+ *
63
+ * `type` is byte-identical to what `server/ideabox-routes.js` broadcasts, because
64
+ * both ideabox clients compare `msg.type === 'ideaboxUpdated'` and nothing else —
65
+ * a differently-named event would be silently ignored, which is the bug this
66
+ * closes. `timestamp` matches the route's shape so one channel carries one shape;
67
+ * `source` exists so a WS log distinguishes a route-driven update from a
68
+ * file-driven one, which is precisely the diagnosis that was missing here.
69
+ */
70
+ export function buildIdeaboxUpdatedMessage(relativePath) {
71
+ return {
72
+ type: 'ideaboxUpdated',
73
+ source: 'projection-watch',
74
+ path: relativePath,
75
+ timestamp: new Date().toISOString(),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Fire `fn` once, `waitMs` after the LAST call. TRAILING, unlike the
81
+ * leading-edge-and-drop debounce the other watches use, and the difference is
82
+ * the whole point.
83
+ *
84
+ * A leading-edge debounce announces the first write of a burst and discards the
85
+ * rest — so the cockpit ends up showing the state at the START of the burst and
86
+ * never hears about the end of it. That is the same "your list is stale until
87
+ * you reload" bug IDEA-24 exists to close, just at a 100ms timescale, and it is
88
+ * reachable from two `compose ideabox add` calls in quick succession.
89
+ *
90
+ * For a NOTIFICATION the last event is the one that must survive; the payload
91
+ * is a re-fetch, so intermediate events carry nothing worth delivering. One
92
+ * broadcast per logical write, always reflecting final state, ~waitMs late.
93
+ */
94
+ export function createTrailingDebouncer(fn, waitMs) {
95
+ let timer = null;
96
+ return {
97
+ trigger() {
98
+ if (timer) clearTimeout(timer);
99
+ timer = setTimeout(() => { timer = null; fn(); }, waitMs);
100
+ },
101
+ cancel() {
102
+ if (timer) { clearTimeout(timer); timer = null; }
103
+ },
104
+ };
105
+ }
106
+
35
107
  export class FileWatcherServer {
36
- constructor() {
108
+ constructor({ projectRoot = getTargetRoot() } = {}) {
109
+ this.projectRoot = projectRoot;
37
110
  this.clients = new Set();
38
111
  this.wss = null;
39
112
  this.watchers = [];
@@ -41,8 +114,8 @@ export class FileWatcherServer {
41
114
 
42
115
  /** Resolve and validate a relative path stays within project root */
43
116
  safePath(relativePath) {
44
- const resolved = path.resolve(PROJECT_ROOT, relativePath);
45
- if (!resolved.startsWith(PROJECT_ROOT + path.sep) && resolved !== PROJECT_ROOT) {
117
+ const resolved = path.resolve(this.projectRoot, relativePath);
118
+ if (!resolved.startsWith(this.projectRoot + path.sep) && resolved !== this.projectRoot) {
46
119
  return null;
47
120
  }
48
121
  return resolved;
@@ -89,7 +162,7 @@ export class FileWatcherServer {
89
162
  const config = loadProjectConfig();
90
163
  const docsPrefix = config.paths?.docs || 'docs';
91
164
  // COMP-PATHS-EXTERNAL: list the RESOLVED docs dir (may be relocated).
92
- const docsDir = resolveDocsPathFromConfig(PROJECT_ROOT, config);
165
+ const docsDir = resolveDocsPathFromConfig(this.projectRoot, config);
93
166
  try {
94
167
  const files = this.listMarkdownFiles(docsDir, docsPrefix);
95
168
  res.json({ files });
@@ -174,26 +247,44 @@ export class FileWatcherServer {
174
247
  startWatching() {
175
248
  const debounceMap = new Map();
176
249
 
177
- const watchDir = (dir, prefix, onChanged, fileFilter = (f) => f.endsWith('.md')) => {
250
+ /**
251
+ * @param {object} [opts]
252
+ * @param {boolean} [opts.recursive=true] fs.watch recursion.
253
+ * @param {number} [opts.debounceMs=100] leading-edge suppression window.
254
+ *
255
+ * PASS 0 FOR ANY WATCH OVER AN ALREADY-WATCHED FILE. `debounceMap` is
256
+ * shared across every watchDir call and keyed by the prefixed relative
257
+ * path, so two watches whose dir+prefix resolve to the SAME relative path
258
+ * for one file suppress each other: whichever fs.watch delivers second
259
+ * inside the window is silently dropped, and which one that is depends on
260
+ * the OS. (That already happens between `docs/` and `docs/features/`; it is
261
+ * left exactly as it was rather than changed underneath callers here.) 0
262
+ * opts out of the shared map entirely — for a watch that coalesces its own
263
+ * events, which is the only reason to be in that position.
264
+ */
265
+ const watchDir = (dir, prefix, onChanged, fileFilter = (f) => f.endsWith('.md'), opts = {}) => {
266
+ const { recursive = true, debounceMs = 100 } = opts;
178
267
  if (!fs.existsSync(dir)) {
179
268
  console.warn(`[file-watcher] ${prefix}/ directory not found, skipping watch`);
180
269
  return;
181
270
  }
182
271
  try {
183
- const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
272
+ const watcher = fs.watch(dir, { recursive }, (eventType, filename) => {
184
273
  if (!filename || !fileFilter(filename)) return;
185
274
 
186
275
  const relativePath = path.join(prefix, filename);
187
276
  // COMP-PATHS-EXTERNAL: derive the real path from the WATCHED dir, not
188
- // by re-rooting under PROJECT_ROOT — the dir may be relocated outside
277
+ // by re-rooting under this.projectRoot — the dir may be relocated outside
189
278
  // the workspace. Byte-identical to the old form for an in-root dir.
190
279
  const fullPath = path.join(dir, filename);
191
280
 
192
- // Debounce: ignore events within 100ms of each other for the same file
193
- const now = Date.now();
194
- const lastEvent = debounceMap.get(relativePath);
195
- if (lastEvent && now - lastEvent < 100) return;
196
- debounceMap.set(relativePath, now);
281
+ // Debounce: ignore events within debounceMs of each other for the same file
282
+ if (debounceMs > 0) {
283
+ const now = Date.now();
284
+ const lastEvent = debounceMap.get(relativePath);
285
+ if (lastEvent && now - lastEvent < debounceMs) return;
286
+ debounceMap.set(relativePath, now);
287
+ }
197
288
 
198
289
  onChanged(relativePath, fullPath);
199
290
  });
@@ -204,10 +295,10 @@ export class FileWatcherServer {
204
295
  };
205
296
 
206
297
  // Watch docs/ — broadcast fileChanged events. COMP-PATHS-EXTERNAL: watch
207
- // the RESOLVED absolute dir (may be relocated outside PROJECT_ROOT).
298
+ // the RESOLVED absolute dir (may be relocated outside this.projectRoot).
208
299
  const config = loadProjectConfig();
209
300
  const docsPrefix = config.paths?.docs || 'docs';
210
- watchDir(resolveDocsPathFromConfig(PROJECT_ROOT, config), docsPrefix, (relativePath, fullPath) => {
301
+ watchDir(resolveDocsPathFromConfig(this.projectRoot, config), docsPrefix, (relativePath, fullPath) => {
211
302
  try {
212
303
  if (!fs.existsSync(fullPath)) return;
213
304
  const content = fs.readFileSync(fullPath, 'utf-8');
@@ -219,9 +310,9 @@ export class FileWatcherServer {
219
310
 
220
311
  // Watch features/ — notify for auto-reseed into vision store
221
312
  const featuresPrefix = config.paths?.features || 'docs/features';
222
- watchDir(resolveFeaturesPathFromConfig(PROJECT_ROOT, config), featuresPrefix, (relativePath, fullPath) => {
313
+ watchDir(resolveFeaturesPathFromConfig(this.projectRoot, config), featuresPrefix, (relativePath, fullPath) => {
223
314
  // Also broadcast as fileChanged (features are docs). fullPath comes from
224
- // the watched dir (COMP-PATHS-EXTERNAL) — do not re-root under PROJECT_ROOT.
315
+ // the watched dir (COMP-PATHS-EXTERNAL) — do not re-root under this.projectRoot.
225
316
  try {
226
317
  if (fs.existsSync(fullPath)) {
227
318
  const content = fs.readFileSync(fullPath, 'utf-8');
@@ -241,16 +332,67 @@ export class FileWatcherServer {
241
332
  // `fileChanged` on this server's /ws/files. The payload carries `file` as a
242
333
  // BASENAME because editorSpecFile is a bare filename (a prefixed relative path
243
334
  // would never match the store's compare).
244
- const pipelinesDir = path.join(PROJECT_ROOT, 'pipelines');
335
+ const pipelinesDir = path.join(this.projectRoot, 'pipelines');
245
336
  watchDir(pipelinesDir, 'pipelines', (relativePath, fullPath) => {
246
337
  if (typeof this.onSpecChanged === 'function') {
247
338
  this.onSpecChanged(buildSpecChangedMessage(path.basename(fullPath), relativePath));
248
339
  }
249
340
  }, isStratumSpecFile);
250
341
 
342
+ // Watch the ideabox projection → `ideaboxUpdated` on the VISION WS (IDEA-24).
343
+ //
344
+ // A CLI ideabox write goes straight to the record store; it never reaches
345
+ // `server/ideabox-routes.js`, which is the only thing that broadcasts. So an
346
+ // open cockpit or mobile client kept showing the pre-write list until someone
347
+ // reloaded it by hand. The `fileChanged` this file already emits for the same
348
+ // write goes out on /ws/files, which neither ideabox client subscribes to.
349
+ //
350
+ // THE PROJECTION IS THE TRIGGER, NOT THE PAYLOAD. Clients respond by
351
+ // re-fetching GET /api/ideabox, which reads RECORDS — the markdown is never
352
+ // parsed to serve a read (COMP-PLAN-IDEA-UNIFY D21). The projection is used
353
+ // only as the signal, and it is a sound one because `ideabox-ops.js`
354
+ // guarantees the record is durable BEFORE the render: an event from this
355
+ // watch can never arrive ahead of the data the re-fetch will return.
356
+ //
357
+ // Two consequences, both accepted:
358
+ // - An API-driven mutation broadcasts twice (the route's own, then this
359
+ // one). The re-fetch is idempotent, and suppressing the echo would need a
360
+ // "did I just write this?" mtime handshake — a race, to save one GET.
361
+ // - A write whose render FAILED does not notify. That is the
362
+ // `projectionStale` path: the writer is already told, and `compose ideabox
363
+ // render` both repairs the file and fires this watch.
364
+ //
365
+ // Watched NON-recursively on the projection's own parent so a relocated
366
+ // `paths.ideabox` outside `paths.docs` still works.
367
+ const ideaboxPath = resolveIdeaboxPathFromConfig(this.projectRoot, config);
368
+ const ideaboxDir = path.dirname(ideaboxPath);
369
+ // The projection's directory may not exist yet in a project that has never
370
+ // rendered one. Create it, or the watch is skipped and the very first CLI
371
+ // write — the one most likely to be watched for — silently does not refresh.
372
+ try { fs.mkdirSync(ideaboxDir, { recursive: true }); } catch { /* read-only tree — watchDir skips */ }
373
+ //
374
+ // Raw fs events are coalesced by a TRAILING debouncer rather than the
375
+ // leading-edge one `watchDir` applies (hence `debounceMs: 0`): see
376
+ // `createTrailingDebouncer` for why the LAST event is the one that has to
377
+ // survive here. That also folds the rename+change pair of a single atomic
378
+ // publish into one broadcast.
379
+ const ideaboxRelative = relForDisplay(this.projectRoot, ideaboxPath);
380
+ this._ideaboxDebouncer = createTrailingDebouncer(() => {
381
+ if (typeof this.onIdeaboxChanged === 'function') {
382
+ this.onIdeaboxChanged(buildIdeaboxUpdatedMessage(ideaboxRelative));
383
+ }
384
+ }, IDEABOX_COALESCE_MS);
385
+ watchDir(
386
+ ideaboxDir,
387
+ relForDisplay(this.projectRoot, ideaboxDir),
388
+ () => this._ideaboxDebouncer.trigger(),
389
+ (f) => isIdeaboxProjectionFile(f, path.basename(ideaboxPath)),
390
+ { recursive: false, debounceMs: 0 },
391
+ );
392
+
251
393
  // Watch .compose/data/ for active-build.json changes
252
394
  const self = this;
253
- const dataDir = path.join(PROJECT_ROOT, '.compose', 'data');
395
+ const dataDir = path.join(this.projectRoot, '.compose', 'data');
254
396
  let dataDirWatcherRegistered = false;
255
397
 
256
398
  // Guarantee .compose/data/ exists before registering the watcher
@@ -309,11 +451,18 @@ export class FileWatcherServer {
309
451
  return results;
310
452
  }
311
453
 
312
- close() {
454
+ stopWatching() {
313
455
  for (const watcher of this.watchers) {
314
456
  watcher.close();
315
457
  }
458
+ // AFTER the watchers: cancelling first would leave a window in which a last
459
+ // fs event re-arms the timer, and a pending timer holds the event loop open.
460
+ this._ideaboxDebouncer?.cancel();
316
461
  this.watchers = [];
462
+ }
463
+
464
+ close() {
465
+ this.stopWatching();
317
466
  for (const client of this.clients) {
318
467
  client.close();
319
468
  }