@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
@@ -18,7 +18,7 @@ import { resolvePathValue } from './paths-core.js';
18
18
  * Identical to join(cwd, featuresDir) for plain relative dirs.
19
19
  * @returns {string} absolute features dir
20
20
  */
21
- function featuresBase(cwd, featuresDir) {
21
+ export function featuresBase(cwd, featuresDir) {
22
22
  return resolvePathValue(cwd, featuresDir, 'features');
23
23
  }
24
24
 
@@ -239,6 +239,14 @@ async function applyEntry(cwd, entry) {
239
239
  const writer = new VisionWriter(join(cwd, '.compose', 'data'));
240
240
  const item = await writer.findFeatureItem(entry.feature_code);
241
241
  if (!item) return { changed: false };
242
+ if (entry._visStatus === 'complete') {
243
+ // COMP-COMPLETION-GATE slice 3 (AC-16a): repair is a projection of
244
+ // canonical truth, so it routes through the self-verifying seam rather
245
+ // than a carve-out. completeItem re-reads feature.json (and the guard
246
+ // ledger when a resource exists) before writing, and stamps the tier.
247
+ await writer.completeItem(item.id, { featureCode: entry.feature_code, cwd });
248
+ return { changed: true };
249
+ }
242
250
  await writer.updateItemStatus(item.id, entry._visStatus);
243
251
  return { changed: true };
244
252
  }
@@ -24,6 +24,16 @@
24
24
  * - XREF_URL_UNCHECKED (info) url + reserved url-class providers
25
25
  * (jira|linear|notion|obsidian) — recorded,
26
26
  * not resolved
27
+ * Plus the 5 COMP-COVERAGE-GATE project-scope kinds, which describe Compose's
28
+ * OWN MCP tool surface rather than the workspace (see lib/coverage-gate.js):
29
+ * - MISSING_EFFECT (error) tool definition declares no `effect`
30
+ * - UNGATED_MUTATION (warning) mutating tool named by no profile list
31
+ * - ORPHAN_REGISTRY_TOOL (warning) canon entry names a tool that is gone
32
+ * - UNCOVERED_WRITE (info) declared canon write absent from that
33
+ * entry's tool list (deny-message honesty)
34
+ * - COVERAGE_CHECK_SKIPPED (warning) the check itself errored — never aborts
35
+ * These appear in `findings` AND, structured, under `result.coverage`.
36
+ *
27
37
  * Full catalog + trigger/degrade/gating contract:
28
38
  * docs/features/COMP-MCP-VALIDATE/design.md
29
39
  */
@@ -46,6 +56,14 @@ import { SchemaValidator } from '../server/schema-validator.js';
46
56
  import {
47
57
  resolveRoadmapPathFromConfig, resolveFeaturesPathFromConfig, resolveJournalPathFromConfig,
48
58
  } from './project-paths.js';
59
+ // COMP-COVERAGE-GATE slice 2. Imported from server/mcp-tool-defs.js, NEVER from
60
+ // server/compose-mcp.js — the latter connects a StdioServerTransport at module
61
+ // load and hangs any importer.
62
+ import { TOOLS as MCP_TOOL_DEFS } from '../server/mcp-tool-defs.js';
63
+ import { loadToolInventory } from './tool-inventory.js';
64
+ import { canonEntries } from './canon-registry.js';
65
+ import { PROFILE_POLICY, PHASE_REFINEMENT } from '../server/mcp-tool-policy.js';
66
+ import { checkAuthorizationCoverage } from './coverage-gate.js';
49
67
 
50
68
  const __filename = fileURLToPath(import.meta.url);
51
69
  const __dirname = path.dirname(__filename);
@@ -1130,6 +1148,44 @@ async function runExternalRefChecks(ctx, findings, options = {}) {
1130
1148
  }
1131
1149
  }
1132
1150
 
1151
+ /**
1152
+ * COMP-COVERAGE-GATE slice 2 — authorization coverage of Compose's OWN MCP tool
1153
+ * surface.
1154
+ *
1155
+ * Project-scoped, not feature-scoped: it is a property of the installation, not
1156
+ * of any one feature, so it is deliberately absent from validateFeature.
1157
+ *
1158
+ * Pushes its findings into the main `findings` array (so the CLI exit code,
1159
+ * --block-on and the REST severity rollup all pick them up with no fork) AND
1160
+ * returns the structured section for callers that want the codes and
1161
+ * remediations without re-parsing prose.
1162
+ *
1163
+ * Never throws: a coverage bug must not take down a validate run that is mostly
1164
+ * about the workspace.
1165
+ *
1166
+ * @param {Array} findings — mutated in place
1167
+ * @returns {{ findings: Array }}
1168
+ */
1169
+ function runCoverageCheck(findings) {
1170
+ try {
1171
+ const inventory = loadToolInventory(MCP_TOOL_DEFS);
1172
+ const result = checkAuthorizationCoverage({
1173
+ inventory,
1174
+ registry: canonEntries(),
1175
+ policy: { PROFILE_POLICY, PHASE_REFINEMENT },
1176
+ });
1177
+ for (const f of result.findings) {
1178
+ findings.push(finding(f.severity, f.code, null, f.remediation, 'coverage'));
1179
+ }
1180
+ return result;
1181
+ } catch (e) {
1182
+ findings.push(finding('warning', 'COVERAGE_CHECK_SKIPPED', null,
1183
+ `authorization coverage check skipped (unexpected error): ${e && e.message ? e.message : e}`,
1184
+ 'coverage'));
1185
+ return { findings: [] };
1186
+ }
1187
+ }
1188
+
1133
1189
  export async function validateProject(cwd, options = {}) {
1134
1190
  const ctx = loadValidationContext(cwd, options);
1135
1191
  const findings = [];
@@ -1234,5 +1290,12 @@ export async function validateProject(cwd, options = {}) {
1234
1290
  // false positive. Strip them in one place (robust against new such checks) and
1235
1291
  // record the skip as a single info finding. feature.json↔vision drift is left
1236
1292
  // intact — it doesn't involve the roadmap.
1237
- return { scope: 'project', validated_at: nowIso(), findings: applyNarrativeSuppression(findings, ctx) };
1293
+ const coverage = runCoverageCheck(findings);
1294
+
1295
+ return {
1296
+ scope: 'project',
1297
+ validated_at: nowIso(),
1298
+ findings: applyNarrativeSuppression(findings, ctx),
1299
+ coverage,
1300
+ };
1238
1301
  }
@@ -193,6 +193,38 @@ export async function addRoadmapEntry(cwd, args) {
193
193
  if (!STATUSES.has(status)) {
194
194
  throw new Error(`feature-writer: invalid status "${status}"`);
195
195
  }
196
+ // COMP-COMPLETION-GATE: a feature cannot be BORN complete. This closed the
197
+ // single worst bypass found in the coverage audit — `compose roadmap add
198
+ // --status COMPLETE` (and proposeFollowup, which forwards caller status here)
199
+ // minted an already-finished feature in one command: no evidence, no
200
+ // lifecycle, no ledger entry, nothing to audit. Completion is a transition
201
+ // with evidence, never an initial condition.
202
+ //
203
+ // MIGRATION EXEMPTION. A migration is not minting a completion — it is
204
+ // transcribing one that already happened, from a ROADMAP row or an older
205
+ // layout into feature.json. Refusing those would make the migration tools
206
+ // unable to represent history that predates the gate. The exemption is
207
+ // explicit (callers pass a reason), narrow (creation only), and logged, so an
208
+ // exempt write is visible rather than silent. It is NOT a general escape
209
+ // hatch: ordinary callers have no reason to pass it, and `compose roadmap add`
210
+ // does not.
211
+ if (status === 'COMPLETE') {
212
+ if (!args._migration?.reason) {
213
+ const e = new Error(
214
+ 'feature-writer: cannot create a feature with status COMPLETE. A completion must be ' +
215
+ 'recorded through the completion gate (record_completion / `compose record-completion`), ' +
216
+ 'which verifies the commit and test evidence and writes a guarded ledger entry. ' +
217
+ 'Create the feature first, then complete it.',
218
+ );
219
+ e.code = 'COMPLETE_ON_CREATE_REFUSED';
220
+ throw e;
221
+ }
222
+ // eslint-disable-next-line no-console
223
+ console.warn(
224
+ `[feature-writer] migration exemption: creating "${args.code}" as COMPLETE ` +
225
+ `without a completion record — ${args._migration.reason}`,
226
+ );
227
+ }
196
228
  // COMP-ROADMAP-PLAN: minimal type validation for the plan-handshake fields.
197
229
  if (args.profile !== undefined &&
198
230
  (typeof args.profile !== 'object' || args.profile === null || Array.isArray(args.profile))) {
@@ -343,7 +375,7 @@ function readRoadmapBase(roadmapPath) {
343
375
  // losslessness is surfaced by the validator (Task 6 / validate_project), not
344
376
  // blocked here. Return only the small diagnostic fields; `canonical` is the full
345
377
  // regenerated ROADMAP and must not leak into MCP writer results.
346
- async function roundtripGuard(cwd, provider, mutate, { force, label }) {
378
+ export async function roundtripGuard(cwd, provider, mutate, { force, label }) {
347
379
  const current = await provider.listFeatures();
348
380
  const projected = mutate(current.map(f => ({ ...f })));
349
381
  const roadmapPath = resolveRoadmapPath(cwd);
@@ -379,7 +411,7 @@ async function roundtripGuard(cwd, provider, mutate, { force, label }) {
379
411
  // Routes through provider.appendEvent so GitHubProvider can post
380
412
  // <!--compose-event--> comments + mirror Projects v2. LocalFileProvider
381
413
  // delegates to feature-events.js#appendEvent producing byte-identical output.
382
- async function safeAppendEvent(cwd, event) {
414
+ export async function safeAppendEvent(cwd, event) {
383
415
  try {
384
416
  const provider = await getProvider(cwd);
385
417
  await provider.appendEvent(event.code, event);
@@ -424,6 +456,29 @@ export async function setFeatureStatus(cwd, args) {
424
456
  return { code: args.code, from, to, ts: new Date().toISOString(), noop: true };
425
457
  }
426
458
 
459
+ // COMP-COMPLETION-GATE slice 3 (AC-9, Decision 8): COMPLETE is not a status
460
+ // this writer can set. Not with `force`, not with `derived`, not with an
461
+ // override token — unconditionally. A completion is a transition WITH
462
+ // evidence (a real commit, an attested test run, a guarded ledger entry),
463
+ // and the one place that verifies those is the completion gate, which then
464
+ // performs the COMPLETE write itself through `persistFeatureRaw`. Every
465
+ // other caller that used to reach COMPLETE through here was a bypass:
466
+ // `set_feature_status` (path 5), the reconciler's derived projection (path
467
+ // 10), `projectFeatureStatus(phase:'complete')`, and the sibling-repo
468
+ // xref-push. There is no marker that lets a caller through, because any
469
+ // marker this module exported would be importable by the callers it exists
470
+ // to refuse.
471
+ if (to === 'COMPLETE') {
472
+ const e = new Error(
473
+ `feature-writer: refusing to set ${args.code} to COMPLETE — status flips to COMPLETE go ` +
474
+ `through the completion gate only (record_completion / \`compose record-completion\` / ` +
475
+ `the build runner at terminalization), which verifies commit + test evidence and takes ` +
476
+ `the guarded transition. \`force\` and \`derived\` do not apply.`,
477
+ );
478
+ e.code = 'COMPLETE_VIA_GATE_ONLY';
479
+ throw e;
480
+ }
481
+
427
482
  const allowed = TRANSITIONS[from] ?? [];
428
483
  // `derived: true` marks a lifecycle-authoritative projection (COMP-MCP-ENFORCE
429
484
  // Slice 2, lifecycle-as-truth): the roadmap transition table is not the
@@ -0,0 +1,167 @@
1
+ /**
2
+ * lib/fluid/factory.js — configured fluid-store provider selection.
3
+ *
4
+ * Mirrors `lib/tracker/factory.js` in config handling (absent means default,
5
+ * malformed means fail loud) and DELIBERATELY DIVERGES from it in one respect:
6
+ * there is no `withFallback` proxy here.
7
+ *
8
+ * The tracker seam wraps its active provider so that an entity the provider
9
+ * cannot store falls through to the local one. That is right for the tracker,
10
+ * where the fallback is STORAGE and the substituted answer is equally true.
11
+ * It is wrong here. `PROVIDER-SEAM` forbids it: "a provider without a
12
+ * capability lacks it visibly; nothing fakes it." A fallback that answered
13
+ * `recall()` or `challenge()` from the floor would return a real-looking result
14
+ * produced by machinery that does not exist, which is worse than an error —
15
+ * the caller cannot tell the difference, and neither can the user.
16
+ *
17
+ * So capability absence propagates as `FluidCapabilityUnavailable` from
18
+ * `FluidProvider.require()`, and surfaces render it as a funnel ("challenge:
19
+ * connect SmartMemory") rather than an empty state.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ import { getSmartmemoryConfig } from '../smartmemory-config.js';
26
+ import { FluidConfigError, MUTATION_SCOPE, mutationScopeAtLeast } from './provider.js';
27
+ import { LocalFluidProvider } from './local-provider.js';
28
+ import { SmartMemoryFluidProvider } from './smartmemory-provider.js';
29
+
30
+ /**
31
+ * Read `.compose/compose.json` → `fluid`.
32
+ * Absent file or absent key → local floor. That is a valid, supported
33
+ * configuration (the zero-install default), not a misconfiguration.
34
+ */
35
+ function loadFluidConfig(cwd) {
36
+ const p = join(cwd, '.compose/compose.json');
37
+ if (!existsSync(p)) return { provider: 'local' };
38
+
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(readFileSync(p, 'utf8'));
42
+ } catch (e) {
43
+ // The file EXISTS but is malformed. Falling back silently here would mask a
44
+ // typo in the user's config and quietly downgrade them to the floor —
45
+ // meaning their configured semantic capabilities would vanish with no
46
+ // signal. Fail loud.
47
+ throw new FluidConfigError(
48
+ `compose: fluid config at ${p} contains invalid JSON — ${e.message}`,
49
+ { path: p }
50
+ );
51
+ }
52
+
53
+ // Valid JSON is not a valid config. `[]`, `"smartmemory"` and `42` all parse,
54
+ // and reading `.fluid` off them yields undefined — which would silently select
55
+ // the floor, exactly the quiet downgrade this function exists to prevent.
56
+ // `null` would throw a bare TypeError instead of a config error.
57
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
58
+ throw new FluidConfigError(
59
+ `compose: fluid config at ${p} must be a JSON object ` +
60
+ `(got ${parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed})`,
61
+ { path: p }
62
+ );
63
+ }
64
+
65
+ const fluid = parsed.fluid;
66
+ if (fluid === undefined || fluid === null) return { provider: 'local' };
67
+ if (typeof fluid !== 'object' || Array.isArray(fluid)) {
68
+ throw new FluidConfigError(
69
+ `compose: fluid config at ${p} has a "fluid" key but it is not an object ` +
70
+ `(got ${Array.isArray(fluid) ? 'array' : typeof fluid})`,
71
+ { path: p }
72
+ );
73
+ }
74
+ return fluid;
75
+ }
76
+
77
+ /**
78
+ * Construct the configured provider.
79
+ *
80
+ * @param {string} cwd project root
81
+ * @param {object} [opts] passed through to the provider's init (the floor takes
82
+ * `recordsRoot` to relocate its tracked record tree)
83
+ * @returns {Promise<import('./provider.js').FluidProvider>}
84
+ */
85
+ export async function fluidProviderFor(cwd, opts = {}) {
86
+ return warnIfUnsafelyShared(await construct(cwd, opts));
87
+ }
88
+
89
+ /**
90
+ * Warn when a provider's store is reachable from more machines than its
91
+ * serialization is.
92
+ *
93
+ * DERIVED FROM THE PROVIDER'S OWN DECLARATIONS, never hardcoded per provider.
94
+ * A hardcoded warning has to be remembered by whoever adds the third provider —
95
+ * which is the same "the second implementation looked complete" failure this
96
+ * whole feature exists to close — and it has to be remembered AGAIN, in the
97
+ * other direction, on the day the gap is fixed, or it keeps crying wolf.
98
+ *
99
+ * Warns rather than refuses: storage and recall work, COMP-FOH shipped them
100
+ * deliberately, and refusing would break a configuration that is fine for
101
+ * everything except concurrent writes. Silence is the only wrong option, because
102
+ * the failure is invisible until it has cost an idea.
103
+ */
104
+ function warnIfUnsafelyShared(provider) {
105
+ if (provider.isShared() && !mutationScopeAtLeast(provider.mutationScope(), MUTATION_SCOPE.CLUSTER)) {
106
+ console.warn(
107
+ `compose: fluid provider "${provider.name()}" is shared across machines but serializes `
108
+ + `mutation only at "${provider.mutationScope()}" scope, so concurrent writes can allocate `
109
+ + `the same handle and lose records. Prefer the local provider for the ideabox until its `
110
+ + `store offers a cross-machine reservation primitive (SmartMemory SVC-LEASE-1).`
111
+ );
112
+ }
113
+ return provider;
114
+ }
115
+
116
+ async function construct(cwd, opts = {}) {
117
+ const cfg = loadFluidConfig(cwd);
118
+ const name = cfg.provider ?? 'local';
119
+
120
+ if (name === 'local') {
121
+ return new LocalFluidProvider().init(cwd, { ...cfg.local, ...opts });
122
+ }
123
+
124
+ if (name === 'smartmemory') {
125
+ // The warning that used to live here is gone, not deleted: it is now
126
+ // `warnIfUnsafelyShared`, derived from this provider's own
127
+ // `isShared()`/`mutationScope()` rather than hardcoded to its name
128
+ // (COMP-FLUID-SEAM-GUARANTEES). BOTH gaps it named are now closed — the
129
+ // restartable import first, then cross-machine serialization once
130
+ // SmartMemory shipped SVC-ALLOC-1 and SVC-LEASE-1 (COMP-FLUID-SEAM-GUARANTEES) — so this
131
+ // provider declares CLUSTER and the warning no longer fires for it. That is
132
+ // exactly the "remember it AGAIN, in the other direction" the check below
133
+ // was written to make automatic: nothing here had to be edited to stop the
134
+ // warning, the declaration moving was enough.
135
+
136
+ // The endpoint and credential come from the EXISTING top-level
137
+ // `smartmemory` block, shared with the shipped kitchen pipeline, so there
138
+ // is one source of truth for where SmartMemory lives. Only `workspaceId`
139
+ // is new, and it lives under `fluid.smartmemory` because it scopes fluid
140
+ // records specifically.
141
+ //
142
+ // A missing block is a config error, not a downgrade to the floor: a user
143
+ // who configured SmartMemory did so to get capabilities the floor does not
144
+ // have, and starting up on the floor instead would present an
145
+ // intelligence-free system as a working one.
146
+ const sm = getSmartmemoryConfig(cwd);
147
+ if (!sm || Object.keys(sm).length === 0) {
148
+ throw new FluidConfigError(
149
+ 'compose: fluid provider "smartmemory" requires a top-level "smartmemory" config ' +
150
+ 'block in .compose/compose.json (baseUrl + apiKeyEnv). It is shared with the ' +
151
+ 'SmartMemory ingest pipeline rather than duplicated under "fluid".',
152
+ { provider: name, setting: 'smartmemory' }
153
+ );
154
+ }
155
+ return new SmartMemoryFluidProvider().init(cwd, {
156
+ baseUrl: sm.baseUrl,
157
+ apiKeyEnv: sm.apiKeyEnv,
158
+ timeoutMs: sm.timeoutMs,
159
+ ...cfg.smartmemory,
160
+ ...opts,
161
+ });
162
+ }
163
+
164
+ throw new FluidConfigError(`compose: unknown fluid provider "${name}"`, { provider: name });
165
+ }
166
+
167
+ export { loadFluidConfig };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * lib/fluid/ideabox-dates.js — the date vocabulary of the markdown boundary.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-1.
5
+ *
6
+ * The record contract types every event date as `format: "date-time"`
7
+ * (`contracts/fluid-record.schema.json` — `killed.at`, `discussion[].at`), but
8
+ * the ideabox markdown has only ever carried a bare `YYYY-MM-DD`: the
9
+ * discussion grammar is `- [YYYY-MM-DD] author: text`
10
+ * (`lib/ideabox.js:53`) and `killIdea` stamps `…toISOString().slice(0, 10)`
11
+ * (`lib/ideabox.js:546`).
12
+ *
13
+ * Two directions, and BOTH were wrong before this module existed:
14
+ *
15
+ * import markdown date → record date-time (widen)
16
+ * render record date-time → markdown date (narrow)
17
+ *
18
+ * The importer passed the bare date straight into the contract, so importing
19
+ * any idea carrying a discussion entry or a kill date threw
20
+ * `must match format "date-time"`. The renderer emitted the full ISO timestamp
21
+ * straight into the markdown, where `DISCUSSION_ENTRY_RE` cannot match it — so
22
+ * a provider-written discussion entry degraded to an unparsed extra line and
23
+ * was silently lost on the next read.
24
+ *
25
+ * Neither defect was caught, because no idea on disk had ever carried a
26
+ * discussion entry and the Killed Ideas section was empty. Both paths were
27
+ * dead code with passing tests over them.
28
+ *
29
+ * They live together in one module deliberately: the two conversions are a
30
+ * single round-trip contract, and their drifting apart is precisely the bug.
31
+ * Splitting them across the importer and the renderer is what let it happen.
32
+ *
33
+ * Precision is intentionally asymmetric. A record keeps the full timestamp; the
34
+ * projection is a view and shows the day. Rendering a date-time into a file
35
+ * whose grammar is a date does not preserve information, it corrupts the line.
36
+ */
37
+
38
+ /** A bare calendar date, the only date form the ideabox markdown can carry. */
39
+ const MARKDOWN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
40
+
41
+ const EPOCH = new Date(0).toISOString();
42
+
43
+ /**
44
+ * Widen a markdown date to a contract-valid `date-time`.
45
+ *
46
+ * A value that already carries a time is passed through untouched, so this is
47
+ * safe to apply to input of mixed provenance (an imported entry and a
48
+ * provider-written one can sit in the same array).
49
+ *
50
+ * @param {string|null|undefined} value a `YYYY-MM-DD`, a full ISO timestamp, or nothing
51
+ * @returns {string} an ISO 8601 date-time
52
+ */
53
+ export function toRecordTimestamp(value) {
54
+ if (!value) return EPOCH;
55
+ if (MARKDOWN_DATE_RE.test(value)) return `${value}T00:00:00.000Z`;
56
+ return value;
57
+ }
58
+
59
+ /**
60
+ * Narrow a contract `date-time` to the markdown's `YYYY-MM-DD`.
61
+ *
62
+ * Anything unparseable is returned unchanged rather than coerced: emitting a
63
+ * wrong-but-well-formed date would be worse than emitting the raw value, which
64
+ * is at least visibly odd.
65
+ *
66
+ * @param {string|null|undefined} value an ISO 8601 date-time
67
+ * @returns {string} a `YYYY-MM-DD`
68
+ */
69
+ export function toMarkdownDate(value) {
70
+ if (typeof value !== 'string') return '';
71
+ const head = value.slice(0, 10);
72
+ return MARKDOWN_DATE_RE.test(head) ? head : value;
73
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * lib/fluid/ideabox-migrate.js — the first-use gate between a markdown ideabox
3
+ * and the record store.
4
+ *
5
+ * COMP-PLAN-IDEA-UNIFY S3b-1 (F2).
6
+ *
7
+ * THE FAILURE THIS EXISTS TO PREVENT
8
+ * ----------------------------------
9
+ * `@smartmemory/compose` is published. Other projects have their own populated
10
+ * `docs/product/ideabox.md` and no fluid records. The cutover makes records
11
+ * canon and the markdown a projection of them — so without this gate, the first
12
+ * `compose ideabox add` in an upgraded project allocates IDEA-1 against an empty
13
+ * store and the projection replaces that project's entire ideabox with the one
14
+ * idea they just typed. Their ideas would survive only in their git history.
15
+ *
16
+ * The blueprint missed this by treating the import as a one-time operation on
17
+ * THIS repository. It is not: it is an upgrade path, and it runs once per
18
+ * installation, on whatever that installation happens to have.
19
+ *
20
+ * WHY IT REFUSES RATHER THAN REPAIRS
21
+ * ----------------------------------
22
+ * The dangerous state is not "no records" — that one is unambiguous and is
23
+ * simply imported. It is a PARTIAL store: some records present, and markdown
24
+ * entries that have no record behind them. Any automatic reading of that state
25
+ * is a guess. Importing the strays assumes the markdown is authoritative, which
26
+ * it no longer is. Ignoring them assumes they were deliberately deleted, and
27
+ * projects over them. Both silently discard someone's work in one of the two
28
+ * cases. So it stops and names what it found.
29
+ *
30
+ * That check costs a parse of a small file per mutation, which is the same file
31
+ * the CLI already read on every mutation before the cutover.
32
+ */
33
+
34
+ import { existsSync, readFileSync } from 'node:fs';
35
+
36
+ import { parseIdeabox } from '../ideabox.js';
37
+ import { importIdeabox } from './import-ideabox.js';
38
+ import { KIND } from './provider.js';
39
+
40
+ export class IdeaboxMigrationConflict extends Error {
41
+ constructor(missing, ideaboxPath) {
42
+ super(
43
+ `compose: the ideabox at ${ideaboxPath} contains ${missing.length} idea(s) with no record ` +
44
+ `behind them: ${missing.join(', ')}. The record store is canon now, so this file is ` +
45
+ `generated output — which means these entries were either hand-added after the migration ` +
46
+ `or lost by a partial one, and guessing which would discard someone's work either way. ` +
47
+ `Re-add them with \`compose ideabox add\`, or delete them from the file if they are stale, ` +
48
+ `then run \`compose ideabox render\`.`
49
+ );
50
+ this.name = 'IdeaboxMigrationConflict';
51
+ this.code = 'IDEABOX_MIGRATION_CONFLICT';
52
+ this.missing = missing;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Ensure the record store reflects the markdown before any mutation touches it.
58
+ *
59
+ * Runs before every mutating ideabox command. Three states, one of which stops
60
+ * the command:
61
+ *
62
+ * - **no records, markdown has entries** → import it (the upgrade path)
63
+ * - **records exist, markdown adds nothing** → already migrated, proceed
64
+ * - **records exist, markdown has entries with no record** → refuse
65
+ *
66
+ * A fresh project with no markdown and no records is the trivial first case and
67
+ * simply proceeds.
68
+ *
69
+ * @param {import('./provider.js').FluidProvider} provider
70
+ * @param {string} ideaboxPath absolute path to the markdown ideabox
71
+ * @returns {Promise<{migrated: boolean, imported: string[]}>}
72
+ */
73
+ export async function ensureIdeaboxMigrated(provider, ideaboxPath) {
74
+ const records = await provider.listRecords({ kind: KIND.IDEA });
75
+ const markdown = existsSync(ideaboxPath) ? readFileSync(ideaboxPath, 'utf8') : null;
76
+
77
+ if (markdown === null) return { migrated: false, imported: [] };
78
+
79
+ const parsed = parseIdeabox(markdown);
80
+ const inMarkdown = [...(parsed.ideas ?? []), ...(parsed.killed ?? [])].map((i) => i.id);
81
+
82
+ const known = new Set(records.map((r) => r.handle));
83
+ const missing = inMarkdown.filter((id) => !known.has(id));
84
+
85
+ if (records.length === 0) {
86
+ if (inMarkdown.length === 0) return { migrated: false, imported: [] };
87
+ // The upgrade path.
88
+ const result = await importIdeabox(provider, { markdown, path: ideaboxPath });
89
+ return { migrated: true, imported: result.imported };
90
+ }
91
+
92
+ if (missing.length) {
93
+ // RESUME versus REFUSE, decided PER HANDLE from the events log.
94
+ //
95
+ // A crash partway through the first-use import leaves some records written
96
+ // and the rest missing, which lands here rather than in the empty-store
97
+ // branch above. Refusing that outright strands the installation: the error
98
+ // names `compose ideabox add`, `add` runs this same gate, so every command
99
+ // fails and there is no way out — and the reclaim path built for exactly
100
+ // this case is never reached.
101
+ //
102
+ // The log distinguishes the two populations. A handle the import already
103
+ // burned carries an event; `importIdeabox` skips live records and reclaims
104
+ // its own aborted allocations, so resuming is safe and lossless.
105
+ //
106
+ // The evidence has to be per-handle, not "did an import ever run". Once the
107
+ // first import succeeds the log carries `imported` events forever, so a
108
+ // global check would quietly import anything later hand-added to what is
109
+ // now generated output — losing the very protection this gate exists for.
110
+ // A handle with no event was never issued here: it was typed into the file
111
+ // by hand, and importing it would treat the markdown as authoritative when
112
+ // it no longer is.
113
+ // Three populations, and only one of them is resumable:
114
+ // - issued, no `deleted` event → a create that crashed. RESUME.
115
+ // - issued, `deleted` event → deliberately retired; this file is just
116
+ // stale output. REFUSE (a render fixes it,
117
+ // and importing would resurrect it).
118
+ // - never issued → hand-typed into generated output. REFUSE.
119
+ const { issued, deleted } = await handleHistory(provider);
120
+ const resumable = missing.filter((id) => issued.has(id) && !deleted.has(id));
121
+ const strays = missing.filter((id) => !resumable.includes(id));
122
+ if (strays.length) throw new IdeaboxMigrationConflict(strays, ideaboxPath);
123
+
124
+ const result = await importIdeabox(provider, { markdown, path: ideaboxPath });
125
+ return { migrated: true, imported: result.imported };
126
+ }
127
+
128
+ return { migrated: false, imported: [] };
129
+ }
130
+
131
+ /**
132
+ * Which handles this store's log has seen, and which of those were retired.
133
+ *
134
+ * Both sets are empty when the history cannot be read, which makes every
135
+ * missing handle a stray and every ambiguous state a refusal. That is the safe
136
+ * direction: resuming on a guess is the one thing this must not do.
137
+ */
138
+ async function handleHistory(provider) {
139
+ const empty = { issued: new Set(), deleted: new Set() };
140
+ if (typeof provider.readEvents !== 'function') return empty;
141
+ try {
142
+ const events = (await provider.readEvents()) ?? [];
143
+ const issued = new Set();
144
+ const deleted = new Set();
145
+ for (const event of events) {
146
+ if (!event?.handle) continue;
147
+ issued.add(event.handle);
148
+ if (event.type === 'deleted') deleted.add(event.handle);
149
+ }
150
+ return { issued, deleted };
151
+ } catch {
152
+ return empty;
153
+ }
154
+ }