@smartmemory/compose 0.3.6-beta → 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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,53 @@
1
+ /**
2
+ * maya-config.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * Config readers for the Maya colleague coupling. Leaf module (no fetch, no
5
+ * state writes), mirroring lib/smartmemory-config.js: uncached direct reads of
6
+ * `.compose/compose.json`, try/catch → absent on missing/malformed.
7
+ *
8
+ * The feature switch is the PRESENCE of the `maya` block (design-foh-6.md
9
+ * §Config): absent means "not installed" (no chrome button), which is a
10
+ * different state from "installed but degraded" (a funnel).
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ function readComposeJson(cwd) {
17
+ try {
18
+ return JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The `maya` block, or null when the feature is not installed.
26
+ * @param {string} cwd
27
+ * @returns {{ baseUrl?: string, auth?: { mode?: 'provision'|'static' } } | null}
28
+ */
29
+ export function getMayaConfig(cwd) {
30
+ const block = readComposeJson(cwd).maya;
31
+ return block && typeof block === 'object' ? block : null;
32
+ }
33
+
34
+ /**
35
+ * The fluid SmartMemory workspace id — the value the colleague identity's
36
+ * workspace claim must NOT equal (shallow-binding isolation, design §2).
37
+ * @param {string} cwd
38
+ * @returns {string | null}
39
+ */
40
+ export function getFluidWorkspaceId(cwd) {
41
+ const ws = readComposeJson(cwd).fluid?.smartmemory?.workspaceId;
42
+ return typeof ws === 'string' && ws ? ws : null;
43
+ }
44
+
45
+ /**
46
+ * Whether the fluid provider is SmartMemory-backed. The colleague never runs
47
+ * degraded (COLLEAGUE-ALL-IN): on the local floor the panel is a
48
+ * connect-SmartMemory funnel, not a plain chat.
49
+ * @param {string} cwd
50
+ */
51
+ export function hasSmartmemoryFluidProvider(cwd) {
52
+ return readComposeJson(cwd).fluid?.provider === 'smartmemory';
53
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * maya-identity.js — COMP-FOH FOH-6 S1
3
+ *
4
+ * The colleague identity store: `data/maya-identity.json` (covered by
5
+ * `.gitignore`'s blanket `data/` entry). One identity per project; it OWNS
6
+ * Maya's single standing conversation (session per JWT identity,
7
+ * session_registry.py:486-527), so nothing here re-provisions implicitly —
8
+ * minting a fresh identity destroys the thread AND the colleague-workspace
9
+ * memory, and is only ever an explicit user action (design §2).
10
+ *
11
+ * Provisioning uses smart-memory-service's sanctioned test surface, the same
12
+ * path the FOH-4/5/6 live-fires used:
13
+ * POST /test/provision-user {email} → {user_id, tenant_id, team_id, access_token}
14
+ * (409 on a repeated email — probed live 2026-08-11; there is no
15
+ * token-refresh path on the test surface yet, upstream ask filed with the
16
+ * channel_context issue)
17
+ * POST /memory/beta/nda/accept {"version":"v1"} (Bearer-authenticated)
18
+ * DELETE /test/provision-user {email, user_id, tenant_id}
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, renameSync, rmSync, mkdirSync, chmodSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { randomBytes } from 'node:crypto';
24
+
25
+ /** Provisioning/NDA failure. `kind` mirrors the relay's funnel taxonomy. */
26
+ export class MayaIdentityError extends Error {
27
+ constructor(message, kind = 'auth') {
28
+ super(message);
29
+ this.name = 'MayaIdentityError';
30
+ this.kind = kind;
31
+ }
32
+ }
33
+
34
+ /** The colleague token's workspace claim equals the fluid workspace —
35
+ * accepting it would silently enable the deferred deep-binding behaviour
36
+ * (Maya's turn-ingestion writing into the fluid workspace). */
37
+ export class MayaWorkspaceCollisionError extends Error {
38
+ constructor(claim) {
39
+ super(
40
+ `maya: the colleague token's workspace claim (${claim}) IS the fluid workspace — `
41
+ + 'refusing. Shallow binding requires a dedicated colleague workspace; '
42
+ + 'provision a separate identity or paste a token scoped elsewhere.',
43
+ );
44
+ this.name = 'MayaWorkspaceCollisionError';
45
+ this.claim = claim;
46
+ }
47
+ }
48
+
49
+ function identityPath(projectRoot) {
50
+ return join(projectRoot, 'data', 'maya-identity.json');
51
+ }
52
+
53
+ /** @returns {object|null} the stored identity, or null when none exists */
54
+ export function loadIdentity(projectRoot) {
55
+ const path = identityPath(projectRoot);
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
58
+ if (!parsed || typeof parsed !== 'object') return null;
59
+ // Enforce owner-only permissions on files created before the 0600 write
60
+ // path existed (Codex r1 P2). Best-effort — a read-only checkout must not
61
+ // break loading.
62
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
63
+ return parsed;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Owner-only (0600) and ATOMIC: the file holds a bearer credential, so it must
71
+ * never be group/world-readable, and a crash mid-write must never truncate it
72
+ * — a truncated file loads as null and the next turn would silently provision
73
+ * a fresh identity, destroying the standing conversation (Codex r1 P2).
74
+ */
75
+ export function saveIdentity(projectRoot, identity) {
76
+ mkdirSync(join(projectRoot, 'data'), { recursive: true });
77
+ const path = identityPath(projectRoot);
78
+ const tmp = `${path}.tmp.${randomBytes(6).toString('hex')}`;
79
+ try {
80
+ writeFileSync(tmp, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
81
+ renameSync(tmp, path);
82
+ } catch (err) {
83
+ rmSync(tmp, { force: true });
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ export function clearIdentity(projectRoot) {
89
+ rmSync(identityPath(projectRoot), { force: true });
90
+ }
91
+
92
+ async function requestJson(url, { body, token, timeoutMs = 30000, fetchFn = fetch, method = 'POST' }) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
95
+ try {
96
+ const res = await fetchFn(url, {
97
+ method,
98
+ headers: {
99
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
100
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
101
+ },
102
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
103
+ signal: controller.signal,
104
+ });
105
+ const text = await res.text();
106
+ let parsed = null;
107
+ try { parsed = JSON.parse(text); } catch { /* tolerated; shape-checked below */ }
108
+ return { status: res.status, body: parsed };
109
+ } catch (err) {
110
+ throw new MayaIdentityError(`maya: ${method} ${url} failed: ${err?.message ?? 'unknown'}`);
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Mint a fresh colleague identity. A RANDOM email per call — the provision
118
+ * surface refuses repeats (409), and a colleague identity is never shared
119
+ * across projects (documented-unsupported, design §2).
120
+ *
121
+ * Does NOT save: the caller decides persistence (ensureIdentity does both).
122
+ */
123
+ export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
124
+ const email = `maya-colleague-${randomBytes(6).toString('hex')}@compose.invalid`;
125
+ const res = await requestJson(`${smBaseUrl}/test/provision-user`, { body: { email }, fetchFn });
126
+ if (res.status !== 200 || typeof res.body?.access_token !== 'string') {
127
+ throw new MayaIdentityError(
128
+ `maya: provisioning failed (HTTP ${res.status})`
129
+ + (res.body?.detail ? ` — ${String(res.body.detail).slice(0, 200)}` : ''),
130
+ );
131
+ }
132
+ const { user_id, tenant_id, team_id, access_token } = res.body;
133
+ return {
134
+ mode: 'provision', email, user_id, tenant_id, team_id, access_token,
135
+ ndaAccepted: false, provisionedAt: new Date().toISOString(),
136
+ };
137
+ }
138
+
139
+ /** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
140
+ * every memory route until this runs — FOH-4 ledger prereq 3). */
141
+ export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
142
+ const res = await requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
143
+ body: { version: 'v1' }, token, fetchFn,
144
+ });
145
+ if (res.status < 200 || res.status >= 300) {
146
+ throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
147
+ }
148
+ }
149
+
150
+ /** Tear down a provisioned identity (explicit user action only). */
151
+ export async function teardownIdentity(projectRoot, { smBaseUrl, fetchFn = fetch }) {
152
+ const identity = loadIdentity(projectRoot);
153
+ if (!identity) return { removed: false };
154
+ if (identity.mode === 'provision' && identity.email) {
155
+ await requestJson(`${smBaseUrl}/test/provision-user`, {
156
+ method: 'DELETE', fetchFn,
157
+ body: { email: identity.email, user_id: identity.user_id, tenant_id: identity.tenant_id },
158
+ });
159
+ }
160
+ clearIdentity(projectRoot);
161
+ return { removed: true };
162
+ }
163
+
164
+ /**
165
+ * The identity's workspace claim, or null when none is derivable. Provisioned
166
+ * identities carry `team_id` from the provision response (VERIFY-1: the JWT
167
+ * payload itself carries NO workspace claims — Maya resolves the workspace
168
+ * server-side from the verified user record). Static tokens fall back to
169
+ * decoding the JWT body for the claims Maya's `_extract_team_id` reads.
170
+ */
171
+ export function workspaceClaimOf(identity) {
172
+ if (!identity) return null;
173
+ if (typeof identity.team_id === 'string' && identity.team_id) return identity.team_id;
174
+ const token = identity.access_token;
175
+ if (typeof token !== 'string') return null;
176
+ const parts = token.split('.');
177
+ if (parts.length < 2) return null;
178
+ try {
179
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
180
+ return payload.workspace_id ?? payload.current_workspace_id
181
+ ?? payload.default_team_id ?? payload.team_id ?? null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * The shallow-binding invariant (design §2, VERIFY-3): refuse a colleague
189
+ * token whose workspace claim equals the fluid workspace. An underivable claim
190
+ * is ALLOWED here for STORED identities — provisioned identities always carry
191
+ * `team_id`, and static identities have their claim resolved and stored at
192
+ * paste time by `fetchVerifiedWorkspace` (which fails CLOSED — see below), so
193
+ * an underivable claim on a stored identity means only that this project has
194
+ * no fluid workspace to collide with. Cross-project token reuse remains
195
+ * documented-unsupported, not detected.
196
+ */
197
+ export function validateWorkspaceIsolation(identity, fluidWorkspaceId) {
198
+ if (!fluidWorkspaceId) return;
199
+ const claim = workspaceClaimOf(identity);
200
+ if (claim && claim === fluidWorkspaceId) throw new MayaWorkspaceCollisionError(claim);
201
+ }
202
+
203
+ /**
204
+ * Resolve a token's VERIFIED workspace from smart-memory-service itself
205
+ * (`GET /auth/me` → `default_team_id` — the same user-record field Maya's
206
+ * `_extract_team_id` falls back to). Exists because the JWT-decode fallback is
207
+ * vacuous in practice: real tokens carry NO workspace claims (VERIFY-1), so a
208
+ * pasted static token could silently defeat the isolation check (Codex r1 P1).
209
+ *
210
+ * FAILS CLOSED: an unreachable service, a rejected token, or a user record
211
+ * without a resolvable team all throw — the paste flow refuses to store a
212
+ * token whose workspace it cannot verify.
213
+ */
214
+ export async function fetchVerifiedWorkspace({ smBaseUrl, token, fetchFn = fetch }) {
215
+ if (!smBaseUrl) {
216
+ throw new MayaIdentityError('maya: cannot verify the token — smartmemory baseUrl is not configured');
217
+ }
218
+ const res = await requestJson(`${smBaseUrl}/auth/me`, { method: 'GET', token, fetchFn });
219
+ if (res.status !== 200 || !res.body || typeof res.body !== 'object') {
220
+ throw new MayaIdentityError(
221
+ `maya: could not verify the token against SmartMemory (HTTP ${res.status}) — not storing it`,
222
+ );
223
+ }
224
+ const team = res.body.default_team_id;
225
+ if (typeof team !== 'string' || !team) {
226
+ throw new MayaIdentityError(
227
+ 'maya: the token verified but resolves no workspace (no default_team_id) — '
228
+ + 'refusing to store a token whose isolation cannot be checked',
229
+ );
230
+ }
231
+ return team;
232
+ }
233
+
234
+ /**
235
+ * Per-project serialization for ensureIdentity. Two concurrent first turns
236
+ * that both observe "no identity" would otherwise provision two competing
237
+ * users, overwrite each other's files, and fork the standing conversation
238
+ * (Codex r1 P2). The server is a single process, so an in-process promise
239
+ * chain is the whole fix: every caller re-loads at the head of its own turn
240
+ * in the chain and sees the winner's identity.
241
+ */
242
+ const _ensureChain = new Map(); // projectRoot -> tail Promise
243
+
244
+ async function ensureIdentityUnserialized(projectRoot, { smBaseUrl, mode, fetchFn = fetch }) {
245
+ let identity = loadIdentity(projectRoot);
246
+ if (!identity) {
247
+ if (mode !== 'provision') {
248
+ throw new MayaIdentityError('maya: no token configured — paste one in the panel', 'auth');
249
+ }
250
+ if (!smBaseUrl) {
251
+ throw new MayaIdentityError('maya: provisioning needs the smartmemory baseUrl', 'auth');
252
+ }
253
+ identity = await provisionIdentity({ smBaseUrl, fetchFn });
254
+ saveIdentity(projectRoot, identity);
255
+ }
256
+ // A saved-but-unaccepted NDA (crash between provision and accept) is retried
257
+ // on the SAME identity rather than minting a new one.
258
+ if (identity.mode === 'provision' && !identity.ndaAccepted) {
259
+ await acceptNda({ smBaseUrl, token: identity.access_token, fetchFn });
260
+ identity.ndaAccepted = true;
261
+ saveIdentity(projectRoot, identity);
262
+ }
263
+ return identity;
264
+ }
265
+
266
+ /**
267
+ * The relay's per-turn identity resolution: load, lazily provision on FIRST
268
+ * use (provision mode only — static mode with no pasted token is an auth
269
+ * funnel), and complete a pending NDA. Never called on a 401 — that funnel is
270
+ * explicit-action-only. Serialized per project (see `_ensureChain`).
271
+ */
272
+ export async function ensureIdentity(projectRoot, opts) {
273
+ const tail = _ensureChain.get(projectRoot) ?? Promise.resolve();
274
+ const run = tail
275
+ .catch(() => {}) // a predecessor's failure must not poison the chain
276
+ .then(() => ensureIdentityUnserialized(projectRoot, opts));
277
+ _ensureChain.set(projectRoot, run);
278
+ try {
279
+ return await run;
280
+ } finally {
281
+ if (_ensureChain.get(projectRoot) === run) _ensureChain.delete(projectRoot);
282
+ }
283
+ }
@@ -11,19 +11,20 @@
11
11
 
12
12
  import { existsSync, readFileSync } from 'fs';
13
13
  import { join } from 'path';
14
+ import {
15
+ isGuarded as registryIsGuarded,
16
+ toolsForPath as registryToolsForPath,
17
+ featureCodeForPath as registryFeatureCodeForPath,
18
+ } from './canon-registry.js';
14
19
 
15
- const GUARDED_FILES = new Set(['ROADMAP.md', 'CHANGELOG.md']);
16
-
17
- const TOOLS_FOR_ROADMAP = ['add_roadmap_entry', 'set_feature_status', 'propose_followup'];
18
- const TOOLS_FOR_CHANGELOG = ['add_changelog_entry'];
19
- const TOOLS_FOR_FEATURE_JSON = [
20
- 'add_roadmap_entry',
21
- 'set_feature_status',
22
- 'link_artifact',
23
- 'link_features',
24
- 'record_completion',
25
- 'propose_followup',
26
- ];
20
+ // COMP-CANON-GUARD S1: the guarded-path/tool declarations that used to live here
21
+ // as literal sets now live in lib/canon-registry.js — the single source of truth
22
+ // shared with the write-time hook. This module is the 'ship' enforcement point;
23
+ // it consumes ONLY the registry's ship subset, so its behavior is unchanged
24
+ // (docs/judgment/** is a hook-only path and is invisible here, exactly as before
25
+ // this refactor, when it was not declared at all). The contract test
26
+ // (test/canon-registry-contract.test.js) pins that equivalence.
27
+ const ENFORCEMENT_POINT = 'ship';
27
28
 
28
29
  /**
29
30
  * Read `enforcement.mcpForFeatureMgmt` and normalize to 'block' | 'log' | 'off'.
@@ -63,11 +64,7 @@ export function filterGuarded(dirtyFiles, featuresDir) {
63
64
  */
64
65
  export function isGuardedPath(path, featuresDir) {
65
66
  if (typeof path !== 'string') return false;
66
- if (GUARDED_FILES.has(path)) return true;
67
- // <featuresDir>/<CODE>/feature.json
68
- const prefix = featuresDir.replace(/\/$/, '') + '/';
69
- if (!path.startsWith(prefix)) return false;
70
- return path.endsWith('/feature.json');
67
+ return registryIsGuarded(path, { featuresDir, point: ENFORCEMENT_POINT });
71
68
  }
72
69
 
73
70
  /**
@@ -80,13 +77,7 @@ export function isGuardedPath(path, featuresDir) {
80
77
  * @returns {string[]}
81
78
  */
82
79
  export function expectedToolsForPath(path, featuresDir) {
83
- if (path === 'ROADMAP.md') return [...TOOLS_FOR_ROADMAP];
84
- if (path === 'CHANGELOG.md') return [...TOOLS_FOR_CHANGELOG];
85
- const prefix = featuresDir.replace(/\/$/, '') + '/';
86
- if (path.startsWith(prefix) && path.endsWith('/feature.json')) {
87
- return [...TOOLS_FOR_FEATURE_JSON];
88
- }
89
- return [];
80
+ return registryToolsForPath(path, { featuresDir, point: ENFORCEMENT_POINT });
90
81
  }
91
82
 
92
83
  /**
@@ -98,11 +89,7 @@ export function expectedToolsForPath(path, featuresDir) {
98
89
  * @returns {string|null}
99
90
  */
100
91
  export function featureCodeFromPath(path, featuresDir) {
101
- const prefix = featuresDir.replace(/\/$/, '') + '/';
102
- if (!path.startsWith(prefix) || !path.endsWith('/feature.json')) return null;
103
- const middle = path.slice(prefix.length, -'/feature.json'.length);
104
- if (!middle || middle.includes('/')) return null;
105
- return middle;
92
+ return registryFeatureCodeForPath(path, { featuresDir });
106
93
  }
107
94
 
108
95
  /**
@@ -165,9 +152,8 @@ export function enforcementError(violations) {
165
152
  return err;
166
153
  }
167
154
 
168
- export const _internals = {
169
- GUARDED_FILES,
170
- TOOLS_FOR_ROADMAP,
171
- TOOLS_FOR_CHANGELOG,
172
- TOOLS_FOR_FEATURE_JSON,
173
- };
155
+ // NOTE: the `_internals` export (guarded-file set + tool sets) was removed in
156
+ // COMP-CANON-GUARD S1. Those declarations now live in lib/canon-registry.js —
157
+ // the single source of truth — with no in-repo consumer of the old shim. Import
158
+ // from canon-registry.js if you need the raw sets, so there is exactly ONE
159
+ // declaration, not a copy here that can drift.
@@ -181,6 +181,11 @@ export async function promoteAnonRow(cwd, row, { code, status = 'PLANNED', scaff
181
181
  status,
182
182
  complexity: 'S',
183
183
  position,
184
+ // COMP-COMPLETION-GATE: promoting an anonymous ROADMAP row transcribes a
185
+ // status that already exists in the roadmap — including COMPLETE for work
186
+ // finished long before the gate. That is history, not a new completion, so
187
+ // it takes the named migration exemption rather than the completion path.
188
+ _migration: { reason: `promoting anonymous ROADMAP row in phase ${row.phaseId}` },
184
189
  });
185
190
  } catch (err) {
186
191
  if (err && err.code === 'ROADMAP_PARTIAL_WRITE') throw err;
@@ -81,6 +81,21 @@ export function migrateRoadmap(cwd, opts = {}) {
81
81
  feature.created = new Date().toISOString().slice(0, 10);
82
82
  }
83
83
 
84
+ // COMP-COMPLETION-GATE slice 3 (AC-17, path 13): the migration legitimately
85
+ // copies historical COMPLETE rows into feature.json — it transcribes a
86
+ // completion that already happened, it does not mint one. The exemption is
87
+ // NAMED and LOGGED per write and covers BOTH branches: an existing file
88
+ // (`--overwrite`) and a missing one (created without it — §2.7 round 2
89
+ // finding 7: an exemption keyed on --overwrite alone would leave the
90
+ // create path as an unlogged bypass). `writeFeature` is the raw file
91
+ // primitive; this callsite is on the completion-write allowlist.
92
+ if (feature.status === 'COMPLETE') {
93
+ console.warn(
94
+ `[migrate-roadmap] migration exemption (${existing ? 'overwrite' : 'create'}): writing ` +
95
+ `${entry.code} as COMPLETE from its ROADMAP row without a completion record — ` +
96
+ `ROADMAP is the source of truth during migration.`,
97
+ );
98
+ }
84
99
  if (opts.dryRun) {
85
100
  console.log(`${existing ? 'update' : 'create'}: ${featuresDir}/${entry.code}/feature.json`);
86
101
  } else {
package/lib/new.js CHANGED
@@ -13,6 +13,7 @@ import { promptGate } from './gate-prompt.js';
13
13
  import { VisionWriter } from './vision-writer.js';
14
14
  import { readFlowRound } from './flow-state.js';
15
15
  import { validateStep } from './step-validator.js';
16
+ import { tsCompatibilityOf, quarantineMessage } from './pipeline-compat.js';
16
17
 
17
18
  const KICKOFF_VALIDATION = new Map([
18
19
  ['research', {
@@ -81,7 +82,18 @@ export async function runNew(intent, opts = {}) {
81
82
  throw new Error(`Kickoff spec not found: ${specPath}. Run 'compose init' to get default pipelines.`);
82
83
  }
83
84
 
84
- const spec = YAML.parse(readFileSync(specPath, 'utf-8'));
85
+ // COMP-PIPELINE-QUARANTINE follow-up: `compose new` has its own runner and
86
+ // calls stratum.plan directly, so it never passed through runBuild's
87
+ // compatibility check. An existing workspace pinning an old new.stratum.yaml
88
+ // (init never overwrites one) still got the opaque engine rejection this check
89
+ // exists to replace.
90
+ const specText = readFileSync(specPath, 'utf-8');
91
+ const specCompat = tsCompatibilityOf(specText);
92
+ if (!specCompat.compatible) {
93
+ throw new Error(quarantineMessage(specPath, specCompat));
94
+ }
95
+
96
+ const spec = YAML.parse(specText);
85
97
  if (opts.skipResearch) {
86
98
  const research = spec.flows?.new?.steps?.find((step) => step.id === 'research');
87
99
  if (research) research.when = 'false';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * TS-engine spec compatibility.
3
+ *
4
+ * COMP-PIPELINE-QUARANTINE: STRAT-PY-RETIRE converted only the two production
5
+ * pipelines (build, gsd) from the v0.3 dialect to TS v1 — see compose commit
6
+ * 9221548, "Both production pipelines (build, gsd) are re-authored as TS v1".
7
+ * Everything else in pipelines/ and presets/ stayed behind — most on v0.3,
8
+ * bug-fix on v0.1 — and the older execution paths were deleted, so those specs
9
+ * cannot run at all: the engine
10
+ * refuses them at `stratum_plan` with a bare `MCP error -32602: spec validation
11
+ * failed`, which tells the user nothing about why.
12
+ *
13
+ * This module is the single place that answers "can the TS engine run this
14
+ * spec". It reads the spec's OWN version stamp rather than consulting a list of
15
+ * pipeline names — so migrating a spec to `version: 1` lifts its quarantine
16
+ * automatically, with nothing here to remember to update.
17
+ */
18
+
19
+ import { parse as parseYaml } from 'yaml';
20
+
21
+ /** The only spec dialect the TS engine accepts. */
22
+ export const TS_SPEC_VERSION = 1;
23
+
24
+ /**
25
+ * The specs `compose init` seeds into a workspace's pipelines/ directory.
26
+ *
27
+ * Shared with resolveTemplatePath, which deliberately does NOT fall back to the
28
+ * bundled copy for these: their absence means the workspace was never
29
+ * initialized, and failing loudly with "Lifecycle spec not found" is the honest
30
+ * answer. Silently running Compose's own bundled build pipeline against an
31
+ * uninitialized project would be worse than the error. Every OTHER shipped
32
+ * pipeline (content, coverage-sweep, refactor, research, review-fix) is never
33
+ * copied by init, so the bundled copy is its only source and the fallback is the
34
+ * only way `--template research` can resolve at all.
35
+ */
36
+ export const INIT_PROVISIONED_SPECS = Object.freeze([
37
+ 'build',
38
+ 'build-quick',
39
+ 'bug-fix',
40
+ 'new',
41
+ 'plan',
42
+ ]);
43
+
44
+ /**
45
+ * Classify a spec's engine compatibility.
46
+ *
47
+ * Deliberately shallow: a `version: 1` stamp means "authored for this engine",
48
+ * not "valid". Full validation belongs to the engine, which reports precise
49
+ * errors. This exists to turn the common, uninformative failure — a spec from
50
+ * the retired dialect — into a message that names the cause.
51
+ *
52
+ * @param {string} specText Raw spec YAML
53
+ * @returns {{compatible: boolean, version: unknown, reason: string|null}}
54
+ */
55
+ export function tsCompatibilityOf(specText) {
56
+ let parsed;
57
+ try {
58
+ parsed = parseYaml(specText);
59
+ } catch (err) {
60
+ return { compatible: false, version: null, reason: `spec is not parseable YAML: ${err.message}` };
61
+ }
62
+ const version = parsed?.version ?? null;
63
+ if (version === TS_SPEC_VERSION) return { compatible: true, version, reason: null };
64
+ if (version === null) {
65
+ return { compatible: false, version, reason: 'spec declares no `version`' };
66
+ }
67
+ return {
68
+ compatible: false,
69
+ version,
70
+ reason: `spec declares version ${JSON.stringify(version)}, but the TS engine only runs version ${TS_SPEC_VERSION}`,
71
+ };
72
+ }
73
+
74
+ /** True iff the TS engine will accept this spec's dialect. */
75
+ export function isTsEngineSpec(specText) {
76
+ return tsCompatibilityOf(specText).compatible;
77
+ }
78
+
79
+ /**
80
+ * The message shown when a quarantined pipeline is invoked. Names the spec, the
81
+ * cause, and the only two ways forward — never a raw engine error code.
82
+ *
83
+ * @param {string} specPath Absolute path of the offending spec
84
+ * @param {{reason: string|null}} compat Result of tsCompatibilityOf
85
+ */
86
+ export function quarantineMessage(specPath, compat) {
87
+ // Only a spec that declares an OLDER dialect gets the migration story. A
88
+ // malformed or version-less spec is far more likely to be a fresh typo, and
89
+ // telling its author it was "left behind by STRAT-PY-RETIRE" is a confident
90
+ // false history that sends them looking in the wrong place.
91
+ const isRetiredDialect = typeof compat.version === 'string' && /^0\./.test(compat.version);
92
+ const lines = [
93
+ `This pipeline cannot run: ${specPath}`,
94
+ ` ${compat.reason}.`,
95
+ ];
96
+ if (isRetiredDialect) {
97
+ lines.push(
98
+ ' It was left on a retired dialect when STRAT-PY-RETIRE cut the engine over to TS v1 and',
99
+ ' deleted the older execution path, so it has been unrunnable since that cutover.',
100
+ );
101
+ }
102
+ lines.push(` The spec must declare \`version: ${TS_SPEC_VERSION}\` and use the TS v1 dialect.`);
103
+ return lines.join('\n');
104
+ }