@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
@@ -0,0 +1,585 @@
1
+ /**
2
+ * lib/fluid/ideabox-ops.js — the ideabox mutations, owned once.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-2 (D20).
5
+ *
6
+ * WHY THIS MODULE EXISTS
7
+ * ----------------------
8
+ * S3b-1 cut the CLI over to the record store and closed the cockpit's write
9
+ * path with a 409 rather than let it overwrite generated output. The obvious way
10
+ * to reopen it is to have `server/ideabox-routes.js` call the provider "the way
11
+ * the CLI does". That is the S3b-1 mistake, repeated.
12
+ *
13
+ * S3b-1 put serialized mutation and `reclaimAborted` into `local-provider.js`
14
+ * instead of the seam, so `smartmemory-provider.js` satisfied the interface
15
+ * completely while having neither — a second implementation that looks finished
16
+ * and silently lacks the guarantees. Two independent call sites that must each
17
+ * remember to run the migration gate before writing, and to re-render after,
18
+ * fail in exactly that shape: the route looks correct, passes review, and
19
+ * destroys an upgrading user's ideabox the first time it is used.
20
+ *
21
+ * So the two invariants live HERE, in code neither surface can skip:
22
+ *
23
+ * 1. `ensureIdeaboxMigrated` runs FIRST, before any write. Without it, a
24
+ * project with a populated markdown ideabox and an empty store has its ideas
25
+ * replaced by whatever the caller just typed.
26
+ * 2. The projection is rewritten AFTER the record is durable, never before. If
27
+ * the render throws, the record is already saved and `compose ideabox
28
+ * render` completes the job. The other order writes a file describing a
29
+ * state that was never stored.
30
+ *
31
+ * WHAT AN OP RETURNS, AND WHY IT IS NOT A MESSAGE
32
+ * ----------------------------------------------
33
+ * `{ record, markdown, ...facts }`. The op does no printing and no serializing:
34
+ * the CLI needs a line of text and the API needs a JSON body, and an op that
35
+ * chose for them would force one surface to parse the other's presentation. The
36
+ * `markdown` it hands back is the exact projection it just wrote, which is what
37
+ * lets the API derive its response by parsing that string instead of re-reading
38
+ * a file whose mtime it would have to race (D21).
39
+ */
40
+
41
+ import { existsSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+
44
+ import { fluidProviderFor } from './factory.js';
45
+ import { ensureIdeaboxMigrated } from './ideabox-migrate.js';
46
+ import { writeIdeaboxProjection } from './render-ideabox.js';
47
+ import { FluidAmbiguousMatch, FluidRecordNotFound, KIND } from './provider.js';
48
+ import { resolveIdeaboxPath, resolveFeaturesPathFromConfig } from '../project-paths.js';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Typed failures
52
+ //
53
+ // Typed rather than string-matched. The routes previously decided their status
54
+ // code with `err.message.includes('not found')`, which makes every error message
55
+ // load-bearing: rewording one silently turns a 404 into a 500. Each op names its
56
+ // failure and the caller maps it once.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** The caller addressed a record that does not exist. HTTP 404. */
60
+ export class IdeaboxNotFound extends Error {
61
+ constructor(id) {
62
+ super(`Idea not found: ${id}`);
63
+ this.name = 'IdeaboxNotFound';
64
+ this.code = 'IDEA_NOT_FOUND';
65
+ this.id = id;
66
+ }
67
+ }
68
+
69
+ /** The caller's input is malformed or out of range. HTTP 400. */
70
+ export class IdeaboxInvalid extends Error {
71
+ constructor(message, field) {
72
+ super(message);
73
+ this.name = 'IdeaboxInvalid';
74
+ this.code = 'IDEA_INVALID';
75
+ this.field = field;
76
+ }
77
+ }
78
+
79
+ /** The record exists but is in a state that forbids the operation. HTTP 409. */
80
+ export class IdeaboxConflict extends Error {
81
+ constructor(message, detail = {}) {
82
+ super(message);
83
+ this.name = 'IdeaboxConflict';
84
+ this.code = 'IDEA_CONFLICT';
85
+ this.detail = detail;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The record was written and the projection was not.
91
+ *
92
+ * A distinct type because it is the one failure where the caller MUST NOT tell
93
+ * the user the operation did not happen. It did; only the generated file is
94
+ * stale, and `compose ideabox render` fixes it without touching a record.
95
+ */
96
+ export class IdeaboxRenderFailed extends Error {
97
+ constructor(cause, record) {
98
+ super(
99
+ `The idea was saved, but the ideabox file could not be regenerated from the records: ` +
100
+ `${cause?.message ?? cause}. Nothing is lost — fix the cause and run ` +
101
+ `\`compose ideabox render\` to rebuild the file.`
102
+ );
103
+ this.name = 'IdeaboxRenderFailed';
104
+ this.code = 'IDEA_RENDER_FAILED';
105
+ this.cause = cause;
106
+ this.record = record;
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Context
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Build the context every op takes.
116
+ *
117
+ * @param {string} cwd project root
118
+ * @param {object} [opts]
119
+ * @param {string} [opts.origin] provenance door — `cli:ideabox` or `ui:ideabox`.
120
+ * Stamped at write time and never retrofitted, so a record captured in the
121
+ * cockpit stays distinguishable from one typed at a terminal for its lifetime.
122
+ * @param {object} [opts.config] already-loaded `.compose/compose.json`
123
+ * @param {import('./provider.js').FluidProvider} [opts.provider] an existing
124
+ * provider, for tests and for callers that hold one already
125
+ */
126
+ export async function ideaboxContext(cwd, opts = {}) {
127
+ return {
128
+ cwd,
129
+ provider: opts.provider ?? await fluidProviderFor(cwd),
130
+ ideaboxPath: opts.ideaboxPath ?? resolveIdeaboxPath(cwd),
131
+ config: opts.config ?? {},
132
+ origin: opts.origin ?? 'cli:ideabox',
133
+ };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Internals shared by every op
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const PRIORITIES = ['P0', 'P1', 'P2'];
141
+ const EFFORTS = ['S', 'M', 'L'];
142
+ const IMPACTS = ['low', 'medium', 'high'];
143
+
144
+ /** Invariant 1. Every mutating op calls this before it writes anything. */
145
+ const gate = (ctx) => ensureIdeaboxMigrated(ctx.provider, ctx.ideaboxPath);
146
+
147
+ /**
148
+ * Invariant 2. Called only after the record is durable.
149
+ *
150
+ * Wrapped so the failure carries the record: a caller that reported "could not
151
+ * save the idea" here would be telling the user the opposite of what happened.
152
+ */
153
+ async function project(ctx, record) {
154
+ try {
155
+ return await writeIdeaboxProjection(ctx.provider, ctx.ideaboxPath);
156
+ } catch (err) {
157
+ throw new IdeaboxRenderFailed(err, record);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Find by handle, case-insensitively, the way the CLI has always accepted
163
+ * `idea-3`. Killed ideas are included: a kill can be discussed, re-prioritised
164
+ * and resurrected, and only promotion refuses one (see `promoteIdea`).
165
+ */
166
+ export async function findIdea(provider, id) {
167
+ const wanted = String(id ?? '').toUpperCase();
168
+ const records = await provider.listRecords({ kind: KIND.IDEA });
169
+ return records.find((r) => r.handle.toUpperCase() === wanted) ?? null;
170
+ }
171
+
172
+ async function requireIdea(ctx, id) {
173
+ const idea = await findIdea(ctx.provider, id);
174
+ if (!idea) throw new IdeaboxNotFound(id);
175
+ return idea;
176
+ }
177
+
178
+ /**
179
+ * Tags are stored bare — the parser accepts a leading `#` verbatim but no record
180
+ * on disk uses one, and the projection's own convention line documents them as
181
+ * bare words.
182
+ */
183
+ export function normalizeTags(raw) {
184
+ const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
185
+ return list.map((t) => String(t).trim().replace(/^#/, '')).filter(Boolean);
186
+ }
187
+
188
+ /** `—`, empty and null all mean untriaged; the record stores that as null. */
189
+ function normalizePriority(raw) {
190
+ if (raw === null || raw === undefined || raw === '' || raw === '—') return null;
191
+ const p = String(raw).toUpperCase();
192
+ if (!PRIORITIES.includes(p)) {
193
+ throw new IdeaboxInvalid(`Invalid priority: ${raw}. Use P0, P1, P2 or —`, 'priority');
194
+ }
195
+ return p;
196
+ }
197
+
198
+ function normalizeEnum(raw, allowed, field) {
199
+ if (raw === null || raw === undefined || raw === '') return null;
200
+ const v = String(raw);
201
+ if (!allowed.includes(v)) {
202
+ throw new IdeaboxInvalid(`${field} must be ${allowed.join(', ')}, or null`, field);
203
+ }
204
+ return v;
205
+ }
206
+
207
+ /**
208
+ * Resolve a cluster argument to a cluster HANDLE, creating one if the name is
209
+ * new.
210
+ *
211
+ * The renderer matches members by handle, so storing a raw name puts the idea in
212
+ * neither its cluster nor the unclustered bucket: it vanishes from the file
213
+ * while its record sits on disk. Resolution therefore happens BEFORE the write,
214
+ * not after — a durable record that cannot be rendered is the one failure this
215
+ * module cannot undo for the caller.
216
+ *
217
+ * @returns {Promise<{handle: string|null, created: object|null}>}
218
+ */
219
+ export async function resolveCluster(provider, name) {
220
+ if (name === null || name === undefined || name === '') return { handle: null, created: null };
221
+
222
+ // A handle is accepted as-is but must exist. This lookup does not race: a
223
+ // handle either names a record or it does not, and nothing here creates one.
224
+ const clusters = await provider.listRecords({ kind: KIND.CLUSTER });
225
+ const byHandle = clusters.find((c) => c.handle.toUpperCase() === String(name).toUpperCase());
226
+ if (byHandle) return { handle: byHandle.handle, created: null };
227
+
228
+ // The by-name path DOES race, and used to lose (COMP-FLUID-SEAM-GUARANTEES
229
+ // F6-1): looking up and then creating are two operations, and the provider's
230
+ // lock covers each one but not the pair, so two concurrent
231
+ // `add --cluster "Umbrella A"` both missed and both created — leaving two
232
+ // clusters with one name and the ideas split between them. `findOrCreateRecord`
233
+ // is the seam's single atomic operation for exactly this.
234
+ try {
235
+ const { record, created } = await provider.findOrCreateRecord(
236
+ { kind: KIND.CLUSTER, title: String(name) },
237
+ {}
238
+ );
239
+ return { handle: record.handle, created: created ? record : null };
240
+ } catch (err) {
241
+ if (err instanceof FluidAmbiguousMatch) {
242
+ throw new IdeaboxInvalid(
243
+ `"${name}" matches ${err.handles.length} clusters (${err.handles.join(', ')}). ` +
244
+ `Pass the handle instead.`,
245
+ 'cluster'
246
+ );
247
+ }
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Operations
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Capture a new idea.
258
+ * @returns {Promise<{record: object, markdown: string, createdCluster: object|null}>}
259
+ */
260
+ export async function addIdea(ctx, { title, body = '', source = '', tags = [], cluster = null } = {}) {
261
+ if (!title || !String(title).trim()) throw new IdeaboxInvalid('title is required', 'title');
262
+ await gate(ctx);
263
+
264
+ const { handle: clusterHandle, created: createdCluster } = await resolveCluster(ctx.provider, cluster);
265
+ const record = await ctx.provider.createRecord({
266
+ kind: KIND.IDEA,
267
+ title: String(title).trim(),
268
+ body: body ?? '',
269
+ source: source ?? '',
270
+ tags: normalizeTags(tags),
271
+ cluster: clusterHandle,
272
+ provenance: { origin: ctx.origin },
273
+ });
274
+
275
+ return { record, markdown: await project(ctx, record), createdCluster };
276
+ }
277
+
278
+ /**
279
+ * Patch an idea's editable fields.
280
+ *
281
+ * `status` is deliberately absent from the allow-list: promotion and kill are
282
+ * lifecycle events with their own consequences (a feature folder, a dated
283
+ * reason), and a status set through a generic field patch would produce a record
284
+ * claiming an outcome that never happened.
285
+ *
286
+ * @param {object} fields any of title, description/body, source, tags, cluster,
287
+ * mapsTo, effort, impact, priority
288
+ */
289
+ export async function updateIdea(ctx, id, fields = {}) {
290
+ if (fields.status !== undefined) {
291
+ throw new IdeaboxInvalid(
292
+ 'Status changes must go through promote, kill or resurrect, not a field update',
293
+ 'status'
294
+ );
295
+ }
296
+
297
+ await gate(ctx);
298
+ const idea = await requireIdea(ctx, id);
299
+
300
+ const patch = {};
301
+ if (fields.title !== undefined) {
302
+ if (!String(fields.title ?? '').trim()) throw new IdeaboxInvalid('title cannot be empty', 'title');
303
+ patch.title = String(fields.title).trim();
304
+ }
305
+ // `description` is the client-facing name for the record's `body`; both are
306
+ // accepted so a caller need not know which side of the seam it is on.
307
+ const body = fields.body !== undefined ? fields.body : fields.description;
308
+ if (body !== undefined) patch.body = body ?? '';
309
+ if (fields.source !== undefined) patch.source = fields.source ?? null;
310
+ if (fields.tags !== undefined) patch.tags = normalizeTags(fields.tags);
311
+ if (fields.priority !== undefined) patch.priority = normalizePriority(fields.priority);
312
+ if (fields.effort !== undefined) patch.effort = normalizeEnum(fields.effort, EFFORTS, 'effort');
313
+ if (fields.impact !== undefined) patch.impact = normalizeEnum(fields.impact, IMPACTS, 'impact');
314
+
315
+ let createdCluster = null;
316
+ if (fields.cluster !== undefined) {
317
+ const resolved = await resolveCluster(ctx.provider, fields.cluster);
318
+ patch.cluster = resolved.handle;
319
+ createdCluster = resolved.created;
320
+ }
321
+
322
+ if (fields.mapsTo !== undefined) {
323
+ const rest = idea.links.filter((l) => l.type !== 'maps_to');
324
+ patch.links = fields.mapsTo
325
+ ? [...rest, { type: 'maps_to', target: String(fields.mapsTo) }]
326
+ : rest;
327
+ }
328
+
329
+ // Nothing to do is not an error — a PATCH carrying only unknown keys has
330
+ // simply asked for nothing. Returning the record unchanged, and re-rendering,
331
+ // keeps the caller's "what does it look like now" answer correct.
332
+ const record = Object.keys(patch).length
333
+ ? await ctx.provider.updateRecord(idea.handle, patch)
334
+ : idea;
335
+
336
+ return { record, markdown: await project(ctx, record), createdCluster };
337
+ }
338
+
339
+ /** Set or clear triage priority. `—`, `''` and null all mean untriaged. */
340
+ export async function setPriority(ctx, id, priority) {
341
+ // Validated before the migration gate runs: a bad argument should not be the
342
+ // thing that triggers a project's one-time import.
343
+ normalizePriority(priority);
344
+ return updateIdea(ctx, id, { priority });
345
+ }
346
+
347
+ /**
348
+ * Kill an idea, with a dated reason.
349
+ *
350
+ * Killing an already-killed idea is a no-op that still re-renders. Not
351
+ * politeness: every op writes its record before the projection, so "record
352
+ * committed, render failed" invites a retry — and an unconditional write would
353
+ * replace the original date and reason, most likely with "(no reason given)". A
354
+ * kill is dated evidence; a retry must not rewrite it. The render still runs, so
355
+ * the retry finishes the job it failed at.
356
+ *
357
+ * @returns {Promise<{record, markdown, alreadyKilled: boolean}>}
358
+ */
359
+ export async function killIdea(ctx, id, reason = '') {
360
+ await gate(ctx);
361
+ const idea = await requireIdea(ctx, id);
362
+
363
+ if (idea.status === 'killed') {
364
+ return { record: idea, markdown: await project(ctx, idea), alreadyKilled: true };
365
+ }
366
+
367
+ const record = await ctx.provider.updateRecord(idea.handle, {
368
+ status: 'killed',
369
+ killed: { at: new Date().toISOString(), reason: reason || '(no reason given)' },
370
+ });
371
+ return { record, markdown: await project(ctx, record), alreadyKilled: false };
372
+ }
373
+
374
+ /**
375
+ * Return a killed idea to the live set, preserving its handle.
376
+ *
377
+ * `killed` and `status_label` are both cleared. Leaving the label would render a
378
+ * resurrected idea under whatever free-form token it carried when it died, and
379
+ * leaving `killed` would leave a live idea holding a dated kill reason — a
380
+ * record that contradicts itself.
381
+ */
382
+ export async function resurrectIdea(ctx, id) {
383
+ await gate(ctx);
384
+ const idea = await requireIdea(ctx, id);
385
+
386
+ if (idea.status !== 'killed') {
387
+ throw new IdeaboxConflict(`${idea.handle} is not killed, so there is nothing to resurrect`, {
388
+ handle: idea.handle,
389
+ status: idea.status,
390
+ });
391
+ }
392
+
393
+ const record = await ctx.provider.updateRecord(idea.handle, {
394
+ status: 'new',
395
+ status_label: null,
396
+ killed: null,
397
+ });
398
+ return { record, markdown: await project(ctx, record) };
399
+ }
400
+
401
+ /**
402
+ * Promote an idea to a feature, creating the feature folder if it is absent.
403
+ *
404
+ * A killed idea is refused (D22). The CLI's lookup finds killed records, so
405
+ * without this guard `compose ideabox promote` on a killed idea would quietly
406
+ * flip it to `promoted` — undoing a dated kill through a command that never
407
+ * mentions kills. The old REST route refused by accident, having searched only
408
+ * the live array; here it is refused on purpose, and both surfaces agree.
409
+ *
410
+ * The promotion is recorded as a typed `promoted_to` link rather than a
411
+ * formatted status string, so the idea-to-feature edge is data in the graph
412
+ * instead of prose to re-parse.
413
+ */
414
+ export async function promoteIdea(ctx, id, featureCode = '') {
415
+ await gate(ctx);
416
+ const idea = await requireIdea(ctx, id);
417
+
418
+ if (idea.status === 'killed') {
419
+ throw new IdeaboxConflict(
420
+ `${idea.handle} was killed on ${String(idea.killed?.at ?? '').slice(0, 10)} and cannot be promoted. ` +
421
+ `Resurrect it first if the kill was wrong.`,
422
+ { handle: idea.handle, status: idea.status }
423
+ );
424
+ }
425
+
426
+ let code = featureCode || '';
427
+ if (!code) {
428
+ const slug = idea.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20).replace(/-+$/, '');
429
+ code = `${idea.handle}-${slug}`.toUpperCase();
430
+ }
431
+
432
+ const featuresBase = resolveFeaturesPathFromConfig(ctx.cwd, ctx.config ?? {});
433
+ const featurePath = join(featuresBase, code);
434
+ let createdFeature = false;
435
+ if (!existsSync(featurePath)) {
436
+ // COMP-MCP-VALIDATE-1: route through the validated writer rather than a raw
437
+ // write, so a promoted feature.json is schema-guarded like any other.
438
+ const { writeFeature } = await import('../feature-json.js');
439
+ writeFeature(ctx.cwd, {
440
+ code,
441
+ description: idea.title,
442
+ status: 'PLANNED',
443
+ promotedFrom: idea.handle,
444
+ createdAt: new Date().toISOString(),
445
+ }, featuresBase);
446
+ createdFeature = true;
447
+ }
448
+
449
+ const record = await ctx.provider.updateRecord(idea.handle, {
450
+ status: 'promoted',
451
+ links: [...idea.links.filter((l) => l.type !== 'promoted_to'), { type: 'promoted_to', target: code }],
452
+ });
453
+
454
+ return {
455
+ record,
456
+ markdown: await project(ctx, record),
457
+ featureCode: code,
458
+ featurePath,
459
+ createdFeature,
460
+ };
461
+ }
462
+
463
+ /** Append to an idea's deliberation trail. Append-only by contract. */
464
+ export async function addDiscussion(ctx, id, { author = null, text } = {}) {
465
+ if (!text || !String(text).trim()) throw new IdeaboxInvalid('text is required', 'text');
466
+ await gate(ctx);
467
+ const idea = await requireIdea(ctx, id);
468
+
469
+ const record = await ctx.provider.appendDiscussion(idea.handle, {
470
+ text: String(text),
471
+ author: author ?? null,
472
+ });
473
+ return { record, markdown: await project(ctx, record) };
474
+ }
475
+
476
+ /**
477
+ * Push-back (FOH-3): contradiction-detect a decision or idea against same-kind
478
+ * records, via the provider's CHALLENGE capability.
479
+ *
480
+ * Read-only — no gate, no projection write. Resolution is kind-agnostic (a
481
+ * decision handle resolves, not just an idea) and case-insensitive, matching the
482
+ * other ops; the provider owns the capability check and the challengeable-kind
483
+ * gate, so a miss surfaces as `IdeaboxNotFound` and everything else propagates.
484
+ *
485
+ * @returns {Promise<import('./provider.js').ChallengeResult>}
486
+ */
487
+ export async function challengeIdea(ctx, id, opts = {}) {
488
+ const handle = String(id ?? '').toUpperCase(); // getRecord rejects lowercase
489
+ try {
490
+ return await ctx.provider.challenge(handle, opts);
491
+ } catch (err) {
492
+ if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
493
+ throw err; // FluidCapabilityUnavailable / FluidKindUnsupported propagate as-is
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Belief-strength read (FOH-4): a record's current confidence and its decay
499
+ * history, via the provider's CONVICTION capability.
500
+ *
501
+ * Unlike `challengeIdea` this DOES run the migration gate first: on a
502
+ * markdown-only project the record only exists after migration, and answering
503
+ * "not found" for an idea sitting right there in the markdown would be wrong.
504
+ * No projection write — nothing in the record body changes on a read.
505
+ *
506
+ * @returns {Promise<import('./provider.js').ConvictionResult>}
507
+ */
508
+ export async function convictionOf(ctx, id) {
509
+ await gate(ctx);
510
+ const handle = String(id ?? '').toUpperCase();
511
+ try {
512
+ return await ctx.provider.conviction(handle);
513
+ } catch (err) {
514
+ if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
515
+ throw err;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * The records that contradict `id` (FOH-5), via the provider's CONTRADICTION
521
+ * capability — the itemized, resolvable form of what conviction shows only as
522
+ * truncated history text. A best-effort LOWER BOUND (see the seam contract on
523
+ * `contradictions()`): an abandoned edge write can under-report, and the
524
+ * authoritative decay record is `convictionOf(id).history`.
525
+ *
526
+ * Runs the migration gate for the same reason `convictionOf` does: on a
527
+ * markdown-only project the record only exists after migration.
528
+ *
529
+ * @returns {Promise<import('./provider.js').ContradictionHit[]>}
530
+ */
531
+ export async function contradictionsOf(ctx, id) {
532
+ await gate(ctx);
533
+ const handle = String(id ?? '').toUpperCase();
534
+ try {
535
+ return await ctx.provider.contradictions(handle);
536
+ } catch (err) {
537
+ if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(id);
538
+ throw err;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Gated resolution of a detected contradiction (FOH-4): decay `against`'s
544
+ * confidence because `id` supersedes it.
545
+ *
546
+ * `id` is the record whose challenge surfaced the conflict; `against` is a
547
+ * conflict handle from that `challengeIdea(id)` result. The caller is TRUSTED
548
+ * on that provenance (v1 boundary — the provider verifies `against` is a real
549
+ * same-kind, non-self record, but cannot verify it was genuinely challenged:
550
+ * detection is LLM-based and there is no durable challenge record yet).
551
+ *
552
+ * Mutation contract, restated where the caller reads it:
553
+ * - the strategy is always explicit (v1: `accept_new` only) — never a default;
554
+ * - a 0.5 decay is near-irreversible (no fluid reinforce path exists);
555
+ * - calling it again decays again (1.0 → 0.5 → 0.0), deliberately un-deduped;
556
+ * - of the typed failures only `FluidResolutionNoOp` is safe to retry —
557
+ * `FluidResolutionIndeterminate` means the decay may still land, and a
558
+ * retry can double-decay.
559
+ *
560
+ * No projection write: the record body is untouched; only the SmartMemory-side
561
+ * confidence moved.
562
+ *
563
+ * @param {object} ctx
564
+ * @param {string} id the challenged (surviving) record
565
+ * @param {{against: string, strategy: string}} opts
566
+ * @returns {Promise<import('./provider.js').ConvictionResult>} `against`'s
567
+ * post-decay conviction
568
+ */
569
+ export async function resolveIdeaChallenge(ctx, id, { against, strategy } = {}) {
570
+ await gate(ctx); // FIRST, like every mutating op — before even input checks
571
+ if (!against || !String(against).trim()) {
572
+ throw new IdeaboxInvalid('against is required — the conflict handle from a prior challenge', 'against');
573
+ }
574
+ const sourceHandle = String(id ?? '').toUpperCase();
575
+ const targetHandle = String(against).toUpperCase();
576
+ try {
577
+ return await ctx.provider.resolveConflict(sourceHandle, targetHandle, { strategy });
578
+ } catch (err) {
579
+ if (err instanceof FluidRecordNotFound) throw new IdeaboxNotFound(err.handle ?? id);
580
+ // FluidInvalidStrategy / FluidInvalidTarget / FluidResolutionNoOp /
581
+ // FluidResolutionConflict / FluidResolutionIndeterminate are typed for the
582
+ // caller and propagate as-is (same convention as challengeIdea).
583
+ throw err;
584
+ }
585
+ }