@smartmemory/compose 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -1,58 +1,75 @@
1
1
  /**
2
2
  * server/ideabox-routes.js — REST API for the ideabox feature.
3
3
  *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-2: this module reads and writes FLUID RECORDS.
5
+ *
4
6
  * Routes:
5
- * GET /api/ideabox return parsed ideabox JSON (cached)
6
- * POST /api/ideabox/ideas add new idea
7
- * PATCH /api/ideabox/ideas/:id update priority/status/etc.
8
- * POST /api/ideabox/ideas/:id/promote — promote to feature
9
- * POST /api/ideabox/ideas/:id/kill — kill with reason
10
- * DELETE /api/ideabox/ideas/:id not allowed (use kill)
7
+ * GET /api/ideabox the whole ideabox, from the records
8
+ * POST /api/ideabox/ideas capture a new idea
9
+ * PATCH /api/ideabox/ideas/:id edit fields (priority, effort, impact, …)
10
+ * POST /api/ideabox/ideas/:id/promote — promote to a feature
11
+ * POST /api/ideabox/ideas/:id/kill — kill with a reason
12
+ * POST /api/ideabox/ideas/:id/resurrect return a killed idea to the live set
13
+ * POST /api/ideabox/ideas/:id/discuss — append to the deliberation trail
14
+ * DELETE /api/ideabox/ideas/:id — not allowed (use kill)
15
+ *
16
+ * WHAT S3b-1 LEFT HERE AND WHY IT IS GONE
17
+ * ---------------------------------------
18
+ * S3b-1 cut the CLI over to the record store and closed all six mutating
19
+ * handlers with a 409, because they rewrote `docs/product/ideabox.md` directly
20
+ * and that file had just become generated output: an idea saved here would have
21
+ * reported success, been overwritten by the next render, and taken the user's
22
+ * text with it. Failing closed was the honest interim state. This slice replaces
23
+ * it with real handlers rather than restoring the old bodies.
24
+ *
25
+ * EVERY MUTATION GOES THROUGH `fluid/ideabox-ops.js`, WHICH THE CLI ALSO USES.
26
+ * Not "the same way the CLI does" — the same code. Two call sites that each had
27
+ * to remember to run the migration gate before writing, and to re-render after,
28
+ * would drift exactly the way `smartmemory-provider.js` drifted from
29
+ * `local-provider.js` in S3b-1: satisfying the interface completely while
30
+ * silently missing a guarantee.
31
+ *
32
+ * Responses come from `fluid/ideabox-view.js`, which projects records into the
33
+ * shape the cockpit and the mobile app have always consumed (`id` is the handle,
34
+ * `description` is the body, an untriaged priority is an em dash).
11
35
  */
12
36
 
13
37
  import fs from 'node:fs'
14
38
  import path from 'node:path'
39
+
15
40
  import {
16
- readIdeabox,
17
- writeIdeabox,
41
+ ideaboxContext,
18
42
  addIdea,
19
- promoteIdea,
43
+ addDiscussion,
20
44
  killIdea,
45
+ promoteIdea,
21
46
  resurrectIdea,
22
- setPriority,
23
47
  updateIdea,
24
- addDiscussion,
25
- } from '../lib/ideabox.js'
26
- import { writeFeature } from '../lib/feature-json.js'
27
- import { IdeaboxCache } from './ideabox-cache.js'
28
- import { resolveIdeaboxPathFromConfig, resolveFeaturesPathFromConfig, relForDisplay } from '../lib/project-paths.js'
48
+ IdeaboxConflict,
49
+ IdeaboxInvalid,
50
+ IdeaboxNotFound,
51
+ IdeaboxRenderFailed,
52
+ } from '../lib/fluid/ideabox-ops.js'
53
+ import { writeIdeaboxProjection } from '../lib/fluid/render-ideabox.js'
54
+ import { ideaboxView, toClientIdeaWith } from '../lib/fluid/ideabox-view.js'
55
+ import { relForDisplay } from '../lib/project-paths.js'
29
56
 
30
57
  /**
31
58
  * @param {object} app — Express app
32
59
  * @param {{ getProjectRoot, getDataDir, broadcastMessage }} deps
33
60
  */
34
- export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcastMessage }) {
35
- // Lazily created per project root — we need to handle project switches
36
- let _cache = null
37
- let _lastProjectRoot = null
38
- let _lastDataDir = null
39
-
40
- function getCache() {
61
+ export function attachIdeaboxRoutes(app, { getProjectRoot, broadcastMessage }) {
62
+ /**
63
+ * A fresh context per request, deliberately.
64
+ *
65
+ * The provider holds paths, not state — every handle lookup re-reads the
66
+ * records directory and the events log — so caching one would buy nothing and
67
+ * cost correctness the moment the project root changes underneath a long-lived
68
+ * server, which is the case this file has always had to handle.
69
+ */
70
+ function context() {
41
71
  const projectRoot = getProjectRoot()
42
- const dataDir = getDataDir()
43
- if (!_cache || _lastProjectRoot !== projectRoot || _lastDataDir !== dataDir) {
44
- const config = loadConfig(projectRoot)
45
- const sourceFile = resolveIdeaboxPathFromConfig(projectRoot, config)
46
- _cache = new IdeaboxCache(dataDir, sourceFile)
47
- _lastProjectRoot = projectRoot
48
- _lastDataDir = dataDir
49
- }
50
- return _cache
51
- }
52
-
53
- function getIdeaboxPath(projectRoot) {
54
- const config = loadConfig(projectRoot)
55
- return config?.paths?.ideabox || 'docs/product/ideabox.md'
72
+ return ideaboxContext(projectRoot, { config: loadConfig(projectRoot) ?? {}, origin: 'ui:ideabox' })
56
73
  }
57
74
 
58
75
  function broadcastUpdate() {
@@ -61,220 +78,145 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
61
78
  }
62
79
  }
63
80
 
64
- // GET /api/ideabox
65
- app.get('/api/ideabox', (_req, res) => {
81
+ /**
82
+ * Map an op failure onto HTTP.
83
+ *
84
+ * `IdeaboxRenderFailed` is deliberately NOT an error response. The record is
85
+ * durable; only the generated markdown is stale. Both clients roll their
86
+ * optimistic update back on any non-ok response, so reporting a failure here
87
+ * would make a committed idea vanish from the UI and invite the user to type
88
+ * it again — producing the duplicate the whole record store exists to prevent.
89
+ * It is answered as a success carrying `projectionStale`, so a client that
90
+ * wants to surface the staleness can, and one that ignores unknown fields
91
+ * behaves correctly by default.
92
+ */
93
+ async function send(res, run, okStatus = 200) {
66
94
  try {
67
- const cache = getCache()
68
- const data = cache.get()
69
- res.json(data)
95
+ const { body, broadcast = true } = await run()
96
+ if (broadcast) broadcastUpdate()
97
+ res.status(okStatus).json(body)
70
98
  } catch (err) {
71
- res.status(500).json({ error: err.message })
99
+ if (err instanceof IdeaboxRenderFailed) {
100
+ // The write landed. Tell the truth about both halves.
101
+ broadcastUpdate()
102
+ const idea = err.record ? await safeIdea(err.record) : null
103
+ return res.status(okStatus).json({
104
+ ...(idea ?? {}),
105
+ projectionStale: true,
106
+ warning: err.message,
107
+ })
108
+ }
109
+ if (err instanceof IdeaboxNotFound) return res.status(404).json({ error: err.message, code: err.code })
110
+ if (err instanceof IdeaboxInvalid) return res.status(400).json({ error: err.message, code: err.code, field: err.field })
111
+ if (err instanceof IdeaboxConflict) return res.status(409).json({ error: err.message, code: err.code })
112
+ return res.status(500).json({ error: err.message })
72
113
  }
73
- })
114
+ }
74
115
 
75
- // POST /api/ideabox/ideas
76
- app.post('/api/ideabox/ideas', (req, res) => {
116
+ /** Best-effort client projection for the render-failed path, which must not
117
+ * fail a second time on the way out. */
118
+ async function safeIdea(record) {
77
119
  try {
78
- const { title, description, source, tags, cluster } = req.body || {}
79
- if (!title) return res.status(400).json({ error: 'title is required' })
120
+ const ctx = await context()
121
+ return await toClientIdeaWith(ctx.provider, record)
122
+ } catch {
123
+ return null
124
+ }
125
+ }
80
126
 
81
- const projectRoot = getProjectRoot()
82
- const ideaboxPath = getIdeaboxPath(projectRoot)
83
- const parsed = readIdeabox(projectRoot, ideaboxPath)
84
- addIdea(parsed, { title, description, source, tags, cluster })
85
- writeIdeabox(projectRoot, ideaboxPath, parsed)
86
- getCache().invalidate()
87
- broadcastUpdate()
88
- // Return the newly created idea
89
- const newIdea = parsed.ideas[parsed.ideas.length - 1]
90
- res.status(201).json(newIdea)
127
+ // GET /api/ideabox — served from the records, per the feature's acceptance
128
+ // criterion. NOT from `docs/product/ideabox.md`: that file is a local
129
+ // projection, and on a store shared across machines (the SmartMemory
130
+ // provider) a write on one machine never regenerates another's copy, so
131
+ // reading it would serve an indefinitely stale view of a current store.
132
+ app.get('/api/ideabox', async (_req, res) => {
133
+ try {
134
+ const ctx = await context()
135
+ res.json(await ideaboxView(ctx.provider))
91
136
  } catch (err) {
92
137
  res.status(500).json({ error: err.message })
93
138
  }
94
139
  })
95
140
 
96
- // PATCH /api/ideabox/ideas/:id
97
- app.patch('/api/ideabox/ideas/:id', (req, res) => {
98
- try {
99
- const { id } = req.params
100
- const fields = req.body || {}
101
-
102
- const projectRoot = getProjectRoot()
103
- const ideaboxPath = getIdeaboxPath(projectRoot)
104
- const parsed = readIdeabox(projectRoot, ideaboxPath)
105
-
106
- // Handle priority shortcut
107
- if (fields.priority) {
108
- setPriority(parsed, id, fields.priority)
109
- delete fields.priority
110
- }
111
-
112
- // Reject status changes via PATCH — must use /promote or /kill endpoints
113
- // to ensure proper transition logic (move to killed section, set fields, etc.)
114
- if (fields.status !== undefined) {
115
- return res.status(400).json({
116
- error: 'Status changes must go through /promote or /kill endpoints, not PATCH'
117
- })
118
- }
119
-
120
- // Handle remaining fields (no status)
121
- const allowed = ['title', 'description', 'source', 'tags', 'cluster', 'mapsTo', 'effort', 'impact']
122
- const safeFields = {}
123
- for (const k of allowed) {
124
- if (fields[k] !== undefined) safeFields[k] = fields[k]
125
- }
126
- // Validate enum fields
127
- if (safeFields.effort !== undefined && safeFields.effort !== null && !['S', 'M', 'L'].includes(safeFields.effort)) {
128
- return res.status(400).json({ error: 'effort must be S, M, L, or null' })
129
- }
130
- if (safeFields.impact !== undefined && safeFields.impact !== null && !['low', 'medium', 'high'].includes(safeFields.impact)) {
131
- return res.status(400).json({ error: 'impact must be low, medium, high, or null' })
132
- }
133
- if (Object.keys(safeFields).length > 0) {
134
- updateIdea(parsed, id, safeFields)
135
- }
136
-
137
- writeIdeabox(projectRoot, ideaboxPath, parsed)
138
- getCache().invalidate()
139
- broadcastUpdate()
141
+ // POST /api/ideabox/ideas
142
+ app.post('/api/ideabox/ideas', async (req, res) => {
143
+ const { title, description, source, tags, cluster } = req.body || {}
144
+ await send(res, async () => {
145
+ const ctx = await context()
146
+ const { record } = await addIdea(ctx, { title, body: description, source, tags, cluster })
147
+ return { body: await toClientIdeaWith(ctx.provider, record) }
148
+ }, 201)
149
+ })
140
150
 
141
- // Find and return the updated idea
142
- const upper = id.toUpperCase()
143
- const updated = [...parsed.ideas, ...parsed.killed].find(i => i.id.toUpperCase() === upper)
144
- res.json(updated || { id })
145
- } catch (err) {
146
- const status = err.message.includes('not found') ? 404 : 500
147
- res.status(status).json({ error: err.message })
148
- }
151
+ // PATCH /api/ideabox/ideas/:id
152
+ app.patch('/api/ideabox/ideas/:id', async (req, res) => {
153
+ await send(res, async () => {
154
+ const ctx = await context()
155
+ const { record } = await updateIdea(ctx, req.params.id, req.body || {})
156
+ return { body: await toClientIdeaWith(ctx.provider, record) }
157
+ })
149
158
  })
150
159
 
151
160
  // POST /api/ideabox/ideas/:id/promote
152
- app.post('/api/ideabox/ideas/:id/promote', (req, res) => {
153
- try {
154
- const { id } = req.params
155
- const { featureCode } = req.body || {}
156
-
157
- const projectRoot = getProjectRoot()
158
- const ideaboxPath = getIdeaboxPath(projectRoot)
159
- const parsed = readIdeabox(projectRoot, ideaboxPath)
160
-
161
- // Find the idea before promoting (need title for feature folder seed)
162
- const upper = id.toUpperCase()
163
- const sourceIdea = parsed.ideas.find(i => i.id.toUpperCase() === upper)
164
- if (!sourceIdea) {
165
- return res.status(404).json({ error: `Idea not found: ${id}` })
166
- }
167
-
168
- // Resolve feature code: explicit, or derived from idea
169
- let resolvedCode = featureCode
170
- if (!resolvedCode) {
171
- const slug = sourceIdea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '')
172
- resolvedCode = `IDEA-${sourceIdea.num}-${slug}`.toUpperCase()
173
- }
174
-
175
- // Create feature folder + feature.json (same logic as CLI promote).
176
- // COMP-PATHS-EXTERNAL: resolve the ABSOLUTE features base (may be
177
- // relocated outside projectRoot). Re-rooting under projectRoot would make
178
- // existsSync check the wrong path and risk overwriting external canon.
179
- const composeJsonPath = path.join(projectRoot, '.compose', 'compose.json')
180
- let cfg = {}
181
- if (fs.existsSync(composeJsonPath)) {
182
- try { cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8')) } catch {}
183
- }
184
- const featuresBase = resolveFeaturesPathFromConfig(projectRoot, cfg) // absolute
185
- const featuresDir = path.join(featuresBase, resolvedCode)
186
- if (!fs.existsSync(featuresDir)) {
187
- // COMP-MCP-VALIDATE-1: route through the validated writer instead of a
188
- // raw fs.writeFileSync so the promoted feature.json is schema-guarded.
189
- writeFeature(projectRoot, {
190
- code: resolvedCode,
191
- description: sourceIdea.title,
192
- status: 'PLANNED',
193
- promotedFrom: sourceIdea.id,
194
- createdAt: new Date().toISOString(),
195
- }, featuresBase)
161
+ app.post('/api/ideabox/ideas/:id/promote', async (req, res) => {
162
+ await send(res, async () => {
163
+ const ctx = await context()
164
+ const { record, featureCode, featurePath } = await promoteIdea(
165
+ ctx, req.params.id, (req.body || {}).featureCode || ''
166
+ )
167
+ // `featureCode` and `featurePath` are a promotion-specific envelope around
168
+ // the idea, not fields of it. The mobile client reads `result.featureCode`
169
+ // to name what it just created, and it matters most in exactly the case
170
+ // where the client did not supply one and the server derived it.
171
+ return {
172
+ body: {
173
+ ...(await toClientIdeaWith(ctx.provider, record)),
174
+ featureCode,
175
+ featurePath: relForDisplay(ctx.cwd, featurePath),
176
+ },
196
177
  }
197
-
198
- promoteIdea(parsed, id, resolvedCode)
199
- writeIdeabox(projectRoot, ideaboxPath, parsed)
200
- getCache().invalidate()
201
- broadcastUpdate()
202
-
203
- const updated = parsed.ideas.find(i => i.id.toUpperCase() === upper)
204
- res.json({ ...(updated || { id }), featureCode: resolvedCode, featurePath: relForDisplay(projectRoot, featuresDir) })
205
- } catch (err) {
206
- const status = err.message.includes('not found') ? 404 : 500
207
- res.status(status).json({ error: err.message })
208
- }
178
+ })
209
179
  })
210
180
 
211
181
  // POST /api/ideabox/ideas/:id/kill
212
- app.post('/api/ideabox/ideas/:id/kill', (req, res) => {
213
- try {
214
- const { id } = req.params
215
- const { reason } = req.body || {}
216
-
217
- const projectRoot = getProjectRoot()
218
- const ideaboxPath = getIdeaboxPath(projectRoot)
219
- const parsed = readIdeabox(projectRoot, ideaboxPath)
220
- killIdea(parsed, id, reason || '')
221
- writeIdeabox(projectRoot, ideaboxPath, parsed)
222
- getCache().invalidate()
223
- broadcastUpdate()
224
-
225
- const upper = id.toUpperCase()
226
- const killed = parsed.killed.find(i => i.id.toUpperCase() === upper)
227
- res.json(killed || { id })
228
- } catch (err) {
229
- const status = err.message.includes('not found') ? 404 : 500
230
- res.status(status).json({ error: err.message })
231
- }
182
+ app.post('/api/ideabox/ideas/:id/kill', async (req, res) => {
183
+ await send(res, async () => {
184
+ const ctx = await context()
185
+ const { record } = await killIdea(ctx, req.params.id, (req.body || {}).reason || '')
186
+ return { body: await toClientIdeaWith(ctx.provider, record) }
187
+ })
232
188
  })
233
189
 
234
190
  // POST /api/ideabox/ideas/:id/resurrect
235
- app.post('/api/ideabox/ideas/:id/resurrect', (req, res) => {
236
- try {
237
- const { id } = req.params
238
- const projectRoot = getProjectRoot()
239
- const ideaboxPath = getIdeaboxPath(projectRoot)
240
- const parsed = readIdeabox(projectRoot, ideaboxPath)
241
- resurrectIdea(parsed, id)
242
- writeIdeabox(projectRoot, ideaboxPath, parsed)
243
- getCache().invalidate()
244
- broadcastUpdate()
245
-
246
- const upper = id.toUpperCase()
247
- const restored = parsed.ideas.find(i => i.id.toUpperCase() === upper)
248
- res.json(restored || { id })
249
- } catch (err) {
250
- const status = err.message.includes('not found') ? 404 : 500
251
- res.status(status).json({ error: err.message })
252
- }
191
+ app.post('/api/ideabox/ideas/:id/resurrect', async (req, res) => {
192
+ await send(res, async () => {
193
+ const ctx = await context()
194
+ const { record } = await resurrectIdea(ctx, req.params.id)
195
+ return { body: await toClientIdeaWith(ctx.provider, record) }
196
+ })
253
197
  })
254
198
 
255
199
  // POST /api/ideabox/ideas/:id/discuss
256
- app.post('/api/ideabox/ideas/:id/discuss', (req, res) => {
257
- try {
258
- const { id } = req.params
259
- const { author, text } = req.body || {}
260
- if (!author) return res.status(400).json({ error: 'author is required' })
261
- if (!text) return res.status(400).json({ error: 'text is required' })
262
-
263
- const projectRoot = getProjectRoot()
264
- const ideaboxPath = getIdeaboxPath(projectRoot)
265
- const parsed = readIdeabox(projectRoot, ideaboxPath)
266
- addDiscussion(parsed, id, author, text)
267
- writeIdeabox(projectRoot, ideaboxPath, parsed)
268
- getCache().invalidate()
269
- broadcastUpdate()
200
+ app.post('/api/ideabox/ideas/:id/discuss', async (req, res) => {
201
+ const { author, text } = req.body || {}
202
+ if (!author) return res.status(400).json({ error: 'author is required' })
203
+ await send(res, async () => {
204
+ const ctx = await context()
205
+ const { record } = await addDiscussion(ctx, req.params.id, { author, text })
206
+ return { body: await toClientIdeaWith(ctx.provider, record) }
207
+ }, 201)
208
+ })
270
209
 
271
- const upper = id.toUpperCase()
272
- const updated = [...parsed.ideas, ...parsed.killed].find(i => i.id.toUpperCase() === upper)
273
- res.status(201).json(updated || { id })
274
- } catch (err) {
275
- const status = err.message.includes('not found') ? 404 : 500
276
- res.status(status).json({ error: err.message })
277
- }
210
+ // POST /api/ideabox/render — rebuild the projection from the records (the
211
+ // repair for a landed-unrendered write: the durable record is fine, only the
212
+ // generated file is stale). The HTTP twin of `compose ideabox render`;
213
+ // touches no record. (FOH-6 S4 — the colleague panel's repair affordance.)
214
+ app.post('/api/ideabox/render', async (_req, res) => {
215
+ await send(res, async () => {
216
+ const ctx = await context()
217
+ await writeIdeaboxProjection(ctx.provider, ctx.ideaboxPath)
218
+ return { body: { ok: true } }
219
+ })
278
220
  })
279
221
 
280
222
  // DELETE /api/ideabox/ideas/:id — not allowed