@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
package/lib/deps.js CHANGED
@@ -45,7 +45,16 @@ export function loadDeps(packageRoot) {
45
45
  const installOk = typeof dep?.install === 'string'
46
46
  const fallbackOk = dep?.fallback === null || typeof dep?.fallback === 'string'
47
47
  const optOk = typeof dep?.optional === 'boolean'
48
- if (!(idOk && reqOk && installOk && fallbackOk && optOk)) {
48
+ // COMP-DEPS-AUTOINSTALL: `plugin` is optional and additive. When present it
49
+ // is the `<plugin>[@marketplace]` spec passed to `claude plugin install`.
50
+ // Its absence means "no automated install path" (several deps are prose,
51
+ // e.g. "user-installed skill at ~/.claude/skills/refactor/"), never an error.
52
+ const pluginOk = dep?.plugin === undefined || typeof dep?.plugin === 'string'
53
+ // `marketplace_source` is the `owner/repo` (or URL) used to register the
54
+ // marketplace when the plugin's marketplace is not configured yet — the
55
+ // normal state on a brand-new machine.
56
+ const srcOk = dep?.marketplace_source === undefined || typeof dep?.marketplace_source === 'string'
57
+ if (!(idOk && reqOk && installOk && fallbackOk && optOk && pluginOk && srcOk)) {
49
58
  console.warn(`Warning: skipping invalid dep entry in .compose-deps.json: ${JSON.stringify(dep)}`)
50
59
  continue
51
60
  }
@@ -93,6 +102,36 @@ export function loadDeps(packageRoot) {
93
102
  * - <home>/.claude/plugins/marketplaces/<m>/.claude/commands/<n>.md (pattern B')
94
103
  * - <home>/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md (pattern C)
95
104
  */
105
+ /**
106
+ * Read `~/.claude/plugins/installed_plugins.json` — Claude Code's authoritative
107
+ * record of which plugins are installed and where their tree lives.
108
+ *
109
+ * Returns `[{ plugin, installPath }]`, or `null` when the file is absent or
110
+ * unreadable, which the caller treats as "fall back to walking the cache".
111
+ * `null` and `[]` mean different things: `[]` is "nothing is installed".
112
+ */
113
+ function readInstalledPluginPaths(home) {
114
+ const path = join(home, '.claude', 'plugins', 'installed_plugins.json')
115
+ if (!existsSync(path)) return null
116
+ let parsed
117
+ try {
118
+ parsed = JSON.parse(readFileSync(path, 'utf-8'))
119
+ } catch {
120
+ return null
121
+ }
122
+ const plugins = parsed?.plugins
123
+ if (!plugins || typeof plugins !== 'object') return null
124
+ const out = []
125
+ for (const [key, entries] of Object.entries(plugins)) {
126
+ // Key is "<plugin>@<marketplace>"; the namespace agents use is the plugin half.
127
+ const plugin = key.split('@')[0]
128
+ for (const entry of Array.isArray(entries) ? entries : []) {
129
+ if (typeof entry?.installPath === 'string') out.push({ plugin, installPath: entry.installPath })
130
+ }
131
+ }
132
+ return out
133
+ }
134
+
96
135
  export function checkExternalSkills(deps, home = homedir()) {
97
136
  const userSkillsRoot = join(home, '.claude', 'skills')
98
137
  const marketplacesRoot = join(home, '.claude', 'plugins', 'marketplaces')
@@ -159,15 +198,34 @@ export function checkExternalSkills(deps, home = homedir()) {
159
198
  }
160
199
 
161
200
  // 3. Cache — pattern C: cache/<marketplace>/<plugin>/<version>/skills/<s>/SKILL.md
162
- for (const marketplace of listDirs(cacheRoot)) {
163
- for (const plugin of listDirs(join(cacheRoot, marketplace))) {
164
- for (const version of listDirs(join(cacheRoot, marketplace, plugin))) {
165
- const skillsDir = join(cacheRoot, marketplace, plugin, version, 'skills')
166
- for (const s of listDirs(skillsDir)) {
167
- if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
201
+ //
202
+ // COMP-DEPS-AUTOINSTALL: the cache OUTLIVES an uninstall — `claude plugin
203
+ // uninstall` drops the plugin from installed_plugins.json but leaves its
204
+ // cached tree on disk, so walking the cache alone reports an uninstalled
205
+ // plugin as present. Claude Code will not load it, and auto-install would
206
+ // never re-install it (nothing looks missing). When installed_plugins.json is
207
+ // readable it is the authority: only its recorded installPaths count. Older
208
+ // Claude Code versions have no such file — there, fall back to the cache walk
209
+ // rather than reporting every plugin missing.
210
+ const installedPaths = readInstalledPluginPaths(home)
211
+ if (installedPaths === null) {
212
+ for (const marketplace of listDirs(cacheRoot)) {
213
+ for (const plugin of listDirs(join(cacheRoot, marketplace))) {
214
+ for (const version of listDirs(join(cacheRoot, marketplace, plugin))) {
215
+ const skillsDir = join(cacheRoot, marketplace, plugin, version, 'skills')
216
+ for (const s of listDirs(skillsDir)) {
217
+ if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
218
+ }
168
219
  }
169
220
  }
170
221
  }
222
+ } else {
223
+ for (const { plugin, installPath } of installedPaths) {
224
+ const skillsDir = join(installPath, 'skills')
225
+ for (const s of listDirs(skillsDir)) {
226
+ if (existsSync(join(skillsDir, s, 'SKILL.md'))) addNs(plugin, s)
227
+ }
228
+ }
171
229
  }
172
230
 
173
231
  const present = []
@@ -206,6 +264,8 @@ export function buildDepReport(result) {
206
264
  id: d.id,
207
265
  required_for: d.required_for,
208
266
  install: d.install,
267
+ plugin: d.plugin ?? null,
268
+ marketplace_source: d.marketplace_source ?? null,
209
269
  fallback: d.fallback ?? null,
210
270
  optional: d.optional,
211
271
  })
@@ -263,6 +323,103 @@ export function buildBinaryReport(result) {
263
323
  }
264
324
  }
265
325
 
326
+ /**
327
+ * COMP-DEPS-AUTOINSTALL — install the plugins that provide missing REQUIRED deps.
328
+ *
329
+ * Only deps carrying a `plugin` spec are installable; everything else stays a
330
+ * printed hint. Optional deps are never auto-installed: the ones we ship
331
+ * (interface-design, openai-codex) live in third-party marketplaces that would
332
+ * each need a `claude plugin marketplace add` first, which is a consent step of
333
+ * a different size than pulling a plugin from a marketplace the user already has.
334
+ *
335
+ * Several deps resolve to the SAME plugin (six superpowers skills, one plugin),
336
+ * so specs are deduped — six missing deps produce one install.
337
+ *
338
+ * Never throws and never blocks: a missing `claude` binary, a non-zero exit or a
339
+ * timeout all come back as data for the caller to print. `-y` is required
340
+ * whenever stdin/stdout is not a TTY, so it is always passed.
341
+ *
342
+ * @param {object} result - from checkExternalSkills
343
+ * @param {object} [opts]
344
+ * @param {(cmd:string,args:string[])=>{status:number|null,stderr?:string,error?:Error}} [opts.spawn]
345
+ * @param {boolean} [opts.enabled] - false short-circuits to a skip (opt-out flag / env)
346
+ * @returns {{installed:string[], failed:{spec:string,reason:string}[], skipped:string|null}}
347
+ */
348
+ export function installMissingPlugins(result, opts = {}) {
349
+ const none = { installed: [], failed: [], skipped: null }
350
+ if (opts.enabled === false) return { ...none, skipped: 'disabled' }
351
+
352
+ const specs = [...new Set(
353
+ result.missing.filter(d => !d.optional && typeof d.plugin === 'string' && d.plugin).map(d => d.plugin),
354
+ )]
355
+ if (specs.length === 0) return none
356
+
357
+ const spawn = opts.spawn ?? ((cmd, args) => {
358
+ // 120s: installing clones a git repo, which is slow on a cold cache.
359
+ try {
360
+ return spawnSync(cmd, args, { encoding: 'utf-8', timeout: 120000, stdio: 'pipe' })
361
+ } catch (error) {
362
+ return { status: null, error }
363
+ }
364
+ })
365
+
366
+ // spec -> marketplace source, for the add-then-retry path below.
367
+ const sourceFor = new Map()
368
+ for (const d of result.missing) {
369
+ if (d.plugin && d.marketplace_source && !sourceFor.has(d.plugin)) {
370
+ sourceFor.set(d.plugin, d.marketplace_source)
371
+ }
372
+ }
373
+
374
+ const installed = []
375
+ const failed = []
376
+ const install = (spec) => spawn('claude', ['plugin', 'install', spec, '-y', '--scope', 'user'])
377
+
378
+ for (const spec of specs) {
379
+ let res = install(spec)
380
+
381
+ // A brand-new machine has no marketplaces registered, so the first install
382
+ // fails with "not found in marketplace". Register the declared source and
383
+ // retry exactly once — measured against a clean HOME, where the un-retried
384
+ // path leaves every required dep missing. Only ever adds the marketplace the
385
+ // manifest names, never one derived from the failure text.
386
+ const notFound = /not found in marketplace|marketplace .* not found|unknown marketplace/i
387
+ .test(`${res?.stderr ?? ''}${res?.stdout ?? ''}`)
388
+ if (res?.status !== 0 && notFound && sourceFor.has(spec)) {
389
+ const add = spawn('claude', ['plugin', 'marketplace', 'add', sourceFor.get(spec)])
390
+ if (add?.status === 0) res = install(spec)
391
+ }
392
+
393
+ if (res?.status === 0) { installed.push(spec); continue }
394
+ // ENOENT on the `claude` binary is a skip for the whole batch, not a failure
395
+ // of this spec: there is no install path at all on this machine.
396
+ if (res?.error?.code === 'ENOENT') {
397
+ return { installed, failed, skipped: 'the `claude` CLI is not on PATH' }
398
+ }
399
+ const reason = (res?.stderr || '').trim()
400
+ || res?.error?.message
401
+ || `exit ${res?.status ?? 'unknown'}`
402
+ failed.push({ spec, reason })
403
+ }
404
+ return { installed, failed, skipped: null }
405
+ }
406
+
407
+ /**
408
+ * Print what installMissingPlugins did. Returns nothing; the caller re-scans and
409
+ * prints the authoritative dep report afterwards.
410
+ */
411
+ export function printInstallReport(report) {
412
+ if (report.skipped === 'disabled') return
413
+ if (!report.installed.length && !report.failed.length && !report.skipped) return
414
+
415
+ console.log('\nInstalling missing required plugins:')
416
+ for (const spec of report.installed) console.log(` + ${spec}`)
417
+ for (const { spec, reason } of report.failed) {
418
+ console.log(` ! ${spec} — install failed: ${reason}`)
419
+ }
420
+ if (report.skipped) console.log(` - skipped: ${report.skipped}`)
421
+ }
422
+
266
423
  /**
267
424
  * Print a human-readable external-binary report. Returns true if all required
268
425
  * (non-optional) binaries are present. Prints nothing when no binaries are declared.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * lib/dir-lock.js — the one advisory lock, extracted.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-1 (D17).
5
+ *
6
+ * `mkdir` is the primitive: it is atomic on every filesystem this runs on, and
7
+ * it fails with EEXIST rather than clobbering. Everything below exists to make
8
+ * that primitive survive the cases a bare `mkdirSync` does not.
9
+ *
10
+ * There were six independent copy-pasted versions of this in `lib/` when this
11
+ * module was written, and they did NOT agree — the weakest set its stale
12
+ * threshold equal to its acquire timeout, which makes ordinary contention
13
+ * indistinguishable from a crashed holder, so a busy lock gets stolen from a
14
+ * live owner. This is the hardened shape (from `lib/judgment-writer.js`),
15
+ * lifted so there is one implementation to reason about instead of six.
16
+ *
17
+ * The four things that are not obvious:
18
+ *
19
+ * 1. **An owner token inside the dir.** Without it, release cannot tell "my
20
+ * lock" from "the lock that replaced mine after it was declared stale", so
21
+ * a slow holder deletes the new owner's lock on the way out. This is the
22
+ * ABA case and it is the reason release reads before it removes.
23
+ *
24
+ * 2. **A partial acquisition is undone.** If the dir is created but the token
25
+ * write fails, every later release declines to remove a lock it cannot
26
+ * prove is its own — stranding it until the stale window expires. So that
27
+ * window is closed by hand rather than waited out.
28
+ *
29
+ * 3. **A heartbeat, and why the threshold is high.** The holder touches the
30
+ * dir's mtime on a timer so a long-but-live operation is not mistaken for a
31
+ * dead one. The timer runs on the event loop, so a SYNCHRONOUS block longer
32
+ * than the stale threshold defeats it. 20s is set well above any sync
33
+ * section this codebase produces (small-file fs I/O; long work is async).
34
+ *
35
+ * 4. **Stale reclaim is by mtime, not by pid.** A pid check cannot see across
36
+ * containers or a reused pid, and this lock guards files that two clones
37
+ * can reach.
38
+ *
39
+ * The lock is advisory: it coordinates writers that agree to use it. Nothing
40
+ * stops a writer that does not, which is why tool-owned canon also has the
41
+ * guard layer.
42
+ */
43
+
44
+ import {
45
+ mkdirSync,
46
+ readFileSync,
47
+ rmSync,
48
+ statSync,
49
+ utimesSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import { dirname, join } from 'node:path';
53
+
54
+ /** A holder is presumed dead once its mtime is this old. See note 3. */
55
+ const LOCK_STALE_MS = 20000;
56
+ /** How long to keep trying before giving up on a live holder. */
57
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
58
+ /** Keeps a live holder from being declared stale. */
59
+ const LOCK_HEARTBEAT_MS = 1000;
60
+ /** Poll interval while waiting on a holder. */
61
+ const LOCK_RETRY_MS = 25;
62
+
63
+ /** The holder's token, or null if it cannot be read (racing, or never written). */
64
+ function readOwner(ownerFile) {
65
+ try { return readFileSync(ownerFile, 'utf8'); } catch { return null; }
66
+ }
67
+
68
+ export class DirLockTimeout extends Error {
69
+ constructor(path, ms) {
70
+ super(`dir-lock: timed out after ${ms}ms waiting for ${path}`);
71
+ this.name = 'DirLockTimeout';
72
+ this.code = 'DIR_LOCK_TIMEOUT';
73
+ this.path = path;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Acquire the lock at `path`, returning a release function.
79
+ *
80
+ * @param {string} path directory to create as the lock
81
+ * @returns {Promise<() => void>} release — idempotent, and a no-op if the lock
82
+ * is no longer provably ours
83
+ */
84
+ export async function acquireDirLock(path, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS } = {}) {
85
+ const ownerFile = join(path, 'owner');
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
88
+ const start = Date.now();
89
+
90
+ for (;;) {
91
+ try {
92
+ mkdirSync(path);
93
+ try {
94
+ writeFileSync(ownerFile, token);
95
+ } catch (err) {
96
+ // Note 2: undo our own partial acquisition rather than strand the lock.
97
+ rmSync(path, { recursive: true, force: true });
98
+ throw err;
99
+ }
100
+ const heartbeat = setInterval(() => {
101
+ try {
102
+ utimesSync(path, new Date(), new Date());
103
+ } catch { /* stolen or gone — release will no-op */ }
104
+ }, LOCK_HEARTBEAT_MS);
105
+ heartbeat.unref?.();
106
+
107
+ let released = false;
108
+ return () => {
109
+ if (released) return;
110
+ released = true;
111
+ clearInterval(heartbeat);
112
+ try {
113
+ // Note 1: only remove a lock still provably ours.
114
+ if (readFileSync(ownerFile, 'utf8') === token) {
115
+ rmSync(path, { recursive: true, force: true });
116
+ }
117
+ } catch { /* not ours anymore — leave it */ }
118
+ };
119
+ } catch (err) {
120
+ if (err.code !== 'EEXIST') throw err;
121
+ try {
122
+ const { mtimeMs } = statSync(path);
123
+ if (Date.now() - mtimeMs > LOCK_STALE_MS) {
124
+ // Reclaim is compare-and-delete, not blind delete. Two contenders can
125
+ // both stat the same stale lock and both decide to remove it; if the
126
+ // first then acquires a fresh one, a blind `rmSync` from the second
127
+ // deletes the NEW owner's lock and both proceed into the critical
128
+ // section. That is the ABA case on the acquire side — the release side
129
+ // already guarded against it, and this side did not.
130
+ //
131
+ // Re-reading the owner token and removing only if it still matches
132
+ // what we saw when we judged it stale closes the window: the winner
133
+ // rewrote the token, so the loser's compare fails and it retries.
134
+ const staleOwner = readOwner(ownerFile);
135
+ const { mtimeMs: recheck } = statSync(path);
136
+ if (recheck === mtimeMs && readOwner(ownerFile) === staleOwner) {
137
+ rmSync(path, { recursive: true, force: true });
138
+ }
139
+ continue;
140
+ }
141
+ } catch { /* stat raced the holder's release; loop and retry */ }
142
+ if (Date.now() - start > timeoutMs) {
143
+ throw new DirLockTimeout(path, timeoutMs);
144
+ }
145
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Run `fn` holding the lock at `path`. The lock is always released, including
152
+ * when `fn` throws.
153
+ *
154
+ * Not reentrant: calling this for the same path from inside `fn` deadlocks
155
+ * until the acquire timeout. Callers compose by locking once at the outermost
156
+ * mutating boundary.
157
+ *
158
+ * @template T
159
+ * @param {string} path
160
+ * @param {() => Promise<T>|T} fn
161
+ * @returns {Promise<T>}
162
+ */
163
+ export async function withDirLock(path, fn) {
164
+ const release = await acquireDirLock(path);
165
+ try {
166
+ return await fn();
167
+ } finally {
168
+ release();
169
+ }
170
+ }
@@ -37,9 +37,9 @@ export function resolveDispatchLedgerCwd(explicitCwd) {
37
37
  }
38
38
 
39
39
  export const DISPATCH_SITES = Object.freeze([
40
- 'build-step', 'consumer', 'review', 'review-repair', 'gsd', 'gate-qa',
41
- 'escalation', 'preflight', 'judge', 'design-chat', 'import', 'validation',
42
- 'new-project', 'unattributed',
40
+ 'build-step', 'consumer', 'review', 'review-repair', 'policy-revision', 'gsd',
41
+ 'gate-qa', 'escalation', 'preflight', 'judge', 'design-chat', 'import',
42
+ 'validation', 'new-project', 'unattributed',
43
43
  ]);
44
44
  export const DISPATCH_OUTCOMES = Object.freeze(['ok', 'error', 'blocked']);
45
45
  export const SETTLEMENT_FAILURE_CLASSES = Object.freeze([
@@ -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