@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,245 @@
1
+ /**
2
+ * canon-guard.js — COMP-CANON-GUARD S4 (pure logic for the write-time hook).
3
+ *
4
+ * Two responsibilities, both pure (no I/O — the runtime wrapper
5
+ * .claude/hooks/canon-guard.mjs and bin/compose.js do the I/O):
6
+ *
7
+ * 1. decideCanonGuard — the PreToolUse decision. Deny a raw Write/Edit to a
8
+ * path the registry marks hook-enforced (docs/judgment/**), naming the
9
+ * tool that owns it. Allow everything else. FAIL OPEN on any malformed
10
+ * input — a guard that wedges the session is worse than one that misses.
11
+ *
12
+ * 2. installGuardHook / uninstallGuardHook / guardHookStatus — idempotent
13
+ * transforms over a parsed .claude/settings.json object that register the
14
+ * hook under hooks.PreToolUse without disturbing existing hooks.
15
+ *
16
+ * The hook is the 'hook' enforcement point of the shared canon-registry. It is
17
+ * Claude-runtime-scoped: Codex-dispatched edits and Bash (sed/heredoc) bypass
18
+ * it — the runtime-neutral backstop is S5/S6 (see design.md honest limits).
19
+ */
20
+ import { relative, resolve, isAbsolute, dirname, basename, join } from 'node:path';
21
+ import { existsSync, realpathSync } from 'node:fs';
22
+ import { matchEntry } from './canon-registry.js';
23
+
24
+ /** Tools whose file writes the hook intercepts. */
25
+ export const GUARDED_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
26
+
27
+ /** How the hook registers in .claude/settings.json. */
28
+ export const HOOK_MATCHER = 'Write|Edit|NotebookEdit';
29
+ export const HOOK_COMMAND = 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/canon-guard.mjs"';
30
+
31
+ /**
32
+ * A command is ours iff it executes a file whose leaf name is exactly
33
+ * canon-guard.mjs. This is stricter than a loose substring: it matches our
34
+ * command and a drifted path (node old/canon-guard.mjs → still ours, so status
35
+ * reports 'stale'), but NOT a different script (canon-guard-v2.mjs) and not an
36
+ * unrelated command that merely names the file inside a larger word.
37
+ */
38
+ const OUR_SCRIPT_RE = /(?:^|[\s"'/\\=])canon-guard\.mjs(?:$|[\s"';:&|)(<>])/;
39
+
40
+ /** True if a hook entry is our canon-guard hook (by executed-script leaf name). */
41
+ function isOurHookEntry(h) {
42
+ return !!h && h.type === 'command' && typeof h.command === 'string' && OUR_SCRIPT_RE.test(h.command);
43
+ }
44
+
45
+ /**
46
+ * Strip the macOS data-volume firmlink prefix. /System/Volumes/Data mirrors /,
47
+ * so /System/Volumes/Data/Users/x IS /Users/x — but realpath does NOT collapse
48
+ * firmlinks (unlike symlinks), so this must be done by hand.
49
+ */
50
+ function stripFirmlink(x) {
51
+ return x.replace(/^\/System\/Volumes\/Data(?=\/)/, '') || x;
52
+ }
53
+
54
+ /**
55
+ * Canonicalize a path to defeat filesystem aliasing before lexical matching:
56
+ * realpath collapses symlinks and case folding; stripFirmlink handles the macOS
57
+ * /System/Volumes/Data firmlink realpath leaves alone. The target of a Write may
58
+ * not exist yet, so realpath the longest existing ancestor and re-append the
59
+ * not-yet-created tail. Best-effort — falls back to a lexical resolve on any
60
+ * error (the caller fails open regardless).
61
+ *
62
+ * NOT alias-proof against every vector (bind mounts, hardlinks) — those are the
63
+ * same runtime-scoped bucket as the Bash bypass, closed by S5/S6 on the tree.
64
+ */
65
+ export function realpathCanonicalize(p) {
66
+ try {
67
+ // Do NOT resolve(p) up front: resolve() collapses `..` LEXICALLY before any
68
+ // symlink resolves, so `symlink/../real` mis-normalizes (Codex S4 r2 finding
69
+ // 1). realpath resolves `..` and symlinks together but needs an existing
70
+ // path — so walk up the RAW path to the longest existing prefix, realpath
71
+ // THAT, and re-append the not-yet-created tail.
72
+ let cur = p;
73
+ const tail = [];
74
+ while (cur && !existsSync(cur)) {
75
+ const parent = dirname(cur);
76
+ if (parent === cur) { cur = ''; break; } // reached root, nothing existed
77
+ tail.unshift(basename(cur));
78
+ cur = parent;
79
+ }
80
+ if (!cur) return stripFirmlink(resolve(p)); // nothing existed → lexical fallback
81
+ let base;
82
+ try { base = realpathSync.native(cur); } catch { base = resolve(cur); }
83
+ const full = tail.length ? join(base, ...tail) : base;
84
+ return stripFirmlink(full);
85
+ } catch {
86
+ return resolve(p);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Decide whether to deny a PreToolUse tool call.
92
+ *
93
+ * @param {object} args
94
+ * @param {string} [args.toolName]
95
+ * @param {object} [args.toolInput] - the tool's arguments (file_path / notebook_path)
96
+ * @param {string} [args.cwd] - session cwd (for resolving a relative file_path)
97
+ * @param {string} [args.projectRoot] - repo root the registry patterns are relative to
98
+ * @param {string} [args.featuresDir='docs/features']
99
+ * @param {(p:string)=>string} [args.canonicalize] - map a path to its real, alias-free form.
100
+ * The runtime wrapper passes realpathCanonicalize; pure tests inject a stub or omit it.
101
+ * @param {string} [args.profile] - the caller's MCP profile (COMPOSE_SESSION_PROFILE).
102
+ * Only changes the ESCAPE SENTENCE of the deny message, never the verdict:
103
+ * `canon_override_grant` is denied to restricted profiles by IMPLEMENTER_DENY /
104
+ * REVIEWER_ALLOW, so telling a restricted caller to mint a grant sends it to a
105
+ * tool it cannot call. Absent/unknown → the unrestricted wording (fail-open).
106
+ * @returns {{deny: boolean, reason?: string, path?: string}}
107
+ */
108
+ export function decideCanonGuard({ toolName, toolInput, cwd, projectRoot, featuresDir = 'docs/features', canonicalize, profile } = {}) {
109
+ try {
110
+ if (!GUARDED_TOOLS.has(toolName)) return { deny: false };
111
+ const raw = toolInput && (toolInput.file_path ?? toolInput.notebook_path);
112
+ if (typeof raw !== 'string' || !raw) return { deny: false };
113
+ if (typeof projectRoot !== 'string' || !projectRoot) return { deny: false };
114
+
115
+ // Canonicalize BOTH root and target with the same function so an aliased
116
+ // target (firmlink/symlink/case) can't slip a real canonical write past a
117
+ // lexical relative() (Codex S4 finding 1). Default = identity (lexical).
118
+ const canon = typeof canonicalize === 'function' ? canonicalize : (x) => x;
119
+ const abs = isAbsolute(raw) ? raw : resolve(cwd || projectRoot, raw);
120
+ const rel = relative(canon(projectRoot), canon(abs));
121
+ // Outside the repo (empty, parent-relative, or still absolute) → not our canon.
122
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return { deny: false };
123
+ // Normalize Windows separators defensively so registry patterns (forward-slash) match.
124
+ const relPosix = rel.split(/[\\/]/).join('/');
125
+
126
+ const entry = matchEntry(relPosix, { featuresDir, point: 'hook' });
127
+ if (!entry) return { deny: false };
128
+
129
+ // Classification only — this function stays pure. The atomic claim happens
130
+ // in the hook wrapper, because a destructive callback here would make the
131
+ // verdict depend on how many times it was evaluated.
132
+ const overrideEligible = entry.overrideEligible !== false;
133
+ const tools = entry.tools.join(', ');
134
+ // COMP-COVERAGE-GATE C4: canon_override_grant is now denied to restricted
135
+ // profiles, so pointing one at it would loop it against its own tool gate.
136
+ const restricted = profile === 'implementer' || profile === 'reviewer';
137
+ const escape = !overrideEligible
138
+ ? `This path is the override's own governance state, so it cannot be overridden: a bypass `
139
+ + `must not be able to authorise rewriting its own record.`
140
+ : restricted
141
+ ? `A canon override exists, but canon_override_grant is not available to the '${profile}' `
142
+ + `profile — the session subject to canon enforcement cannot exempt itself from it. `
143
+ + `Escalate: report what you need to write and why, and let the orchestrator decide.`
144
+ : `To do it deliberately anyway, mint a single-use grant with canon_override_grant `
145
+ + `({ path, reason, operation }) — the bypass is recorded before the grant exists.`;
146
+
147
+ return {
148
+ deny: true,
149
+ path: relPosix,
150
+ overrideEligible,
151
+ reason:
152
+ `${relPosix} is tool-owned canon (COMP-CANON-GUARD). A direct ${toolName} is blocked — `
153
+ + `write it through one of: ${tools}. These tools stamp provenance and regenerate the `
154
+ + `projections from records; a hand-edit is unattributed and overwritten on the next regen. `
155
+ + `${escape}`,
156
+ };
157
+ } catch {
158
+ return { deny: false }; // fail open — never wedge the session
159
+ }
160
+ }
161
+
162
+ // ── settings.json registration ───────────────────────────────────────────────
163
+
164
+ function clone(obj) {
165
+ return obj == null ? {} : structuredClone(obj);
166
+ }
167
+
168
+ /** Our canonical PreToolUse group. */
169
+ function ourGroup() {
170
+ return { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_COMMAND }] };
171
+ }
172
+
173
+ /**
174
+ * Remove OUR hook ENTRIES from each group, preserving sibling hooks that happen
175
+ * to share the group, and dropping only groups left empty. Operates at the hook
176
+ * level, not the group level (Codex S4 finding 2 — a group-level "any child is
177
+ * ours → delete the group" wiped unrelated sibling hooks).
178
+ *
179
+ * @returns {Array<object>} the pruned groups
180
+ */
181
+ function pruneOurHooks(groups) {
182
+ const out = [];
183
+ for (const g of groups) {
184
+ if (!Array.isArray(g.hooks)) { out.push(g); continue; }
185
+ const kept = g.hooks.filter((h) => !isOurHookEntry(h));
186
+ if (kept.length === 0) continue; // group had only our hook → drop it
187
+ if (kept.length === g.hooks.length) { out.push(g); continue; } // nothing of ours here
188
+ out.push({ ...g, hooks: kept }); // keep siblings, our entry removed
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * Ensure our hook is registered with the current matcher + command in its own
195
+ * dedicated group, preserving every other hook. Idempotent.
196
+ *
197
+ * @returns {{settings: object, changed: boolean}}
198
+ */
199
+ export function installGuardHook(settings) {
200
+ const s = clone(settings);
201
+ s.hooks = s.hooks ?? {};
202
+ const before = Array.isArray(s.hooks.PreToolUse) ? JSON.stringify(s.hooks.PreToolUse) : null;
203
+ const existing = Array.isArray(s.hooks.PreToolUse) ? s.hooks.PreToolUse.map((g) => structuredClone(g)) : [];
204
+
205
+ // Strip any prior copy of our hook (from anywhere, incl. mixed groups), then
206
+ // append one clean dedicated group. Siblings in mixed groups are preserved.
207
+ const next = pruneOurHooks(existing);
208
+ next.push(ourGroup());
209
+ s.hooks.PreToolUse = next;
210
+
211
+ const changed = before !== JSON.stringify(next);
212
+ return { settings: s, changed };
213
+ }
214
+
215
+ /**
216
+ * Remove our hook registration, preserving sibling hooks, pruning emptied
217
+ * groups and an emptied PreToolUse array. Idempotent.
218
+ *
219
+ * @returns {{settings: object, changed: boolean}}
220
+ */
221
+ export function uninstallGuardHook(settings) {
222
+ const s = clone(settings);
223
+ if (!s.hooks || !Array.isArray(s.hooks.PreToolUse)) return { settings: s, changed: false };
224
+ const before = JSON.stringify(s.hooks.PreToolUse);
225
+ const next = pruneOurHooks(s.hooks.PreToolUse.map((g) => structuredClone(g)));
226
+ if (next.length === 0) delete s.hooks.PreToolUse;
227
+ else s.hooks.PreToolUse = next;
228
+ const after = s.hooks.PreToolUse ? JSON.stringify(s.hooks.PreToolUse) : null;
229
+ return { settings: s, changed: before !== after };
230
+ }
231
+
232
+ /**
233
+ * Report the guard hook's registration state.
234
+ * @returns {{state: 'installed'|'stale'|'absent'}}
235
+ */
236
+ export function guardHookStatus(settings) {
237
+ const groups = settings?.hooks?.PreToolUse;
238
+ if (!Array.isArray(groups)) return { state: 'absent' };
239
+ const ours = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.some(isOurHookEntry));
240
+ if (ours.length === 0) return { state: 'absent' };
241
+ const current = ours.some(
242
+ (g) => g.matcher === HOOK_MATCHER && g.hooks.length === 1 && g.hooks[0].command === HOOK_COMMAND,
243
+ );
244
+ return { state: current && ours.length === 1 ? 'installed' : 'stale' };
245
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * canon-override.js — the canon override: grant-then-write, ledger-first.
3
+ *
4
+ * COMP-CANON-OVERRIDE S2, implementing COMP-CANON-GUARD Decision 4.
5
+ *
6
+ * WHAT THIS IS: audit and careless-drift tooling. It makes the cooperative
7
+ * path logged and the accidental path hard. It is NOT enforcement and must
8
+ * never be described as such — `Bash` and Codex writes never reach the hook,
9
+ * and every piece of state here lives in the workspace the agent can write.
10
+ * A determined actor who forges a token AND its ledger row AND the baseline
11
+ * passes. See design.md, "The in-workspace ceiling".
12
+ *
13
+ * ORDERING IS THE ATOMICITY ANSWER. The bypass row and the attest baseline are
14
+ * written BEFORE the token is minted, so the failure mode is over-recording,
15
+ * never under-recording: a token cannot exist without its row. If the write
16
+ * that follows never happens, a bypass entry exists for a write that did not
17
+ * occur — deliberately the safe direction.
18
+ *
19
+ * SINGLE-USE IS A RENAME, NOT A LOCK. Each grant is its own file, claimed by
20
+ * renaming it into `consumed/`. Exactly one caller can rename a given path;
21
+ * every loser gets ENOENT. An earlier draft used one shared JSON file with
22
+ * temp-write-plus-rename, which gives atomic *publication* but not mutual
23
+ * exclusion — two readers could each drop the token from their own snapshot
24
+ * and both succeed.
25
+ */
26
+ import {
27
+ mkdirSync, writeFileSync, readFileSync, renameSync, readdirSync, existsSync, appendFileSync,
28
+ } from 'node:fs';
29
+ import { join, dirname } from 'node:path';
30
+ import { randomUUID } from 'node:crypto';
31
+ import { isOverrideEligible } from './canon-registry.js';
32
+ import { baselineFor } from './append-integrity.js';
33
+
34
+ export const LEDGER_REL = '.compose/canon-overrides.jsonl';
35
+ export const ATTEST_REL = '.compose/canon-overrides-attest.json';
36
+ export const GRANTS_REL = '.compose/data/canon-grants';
37
+ const CONSUMED_SUBDIR = 'consumed';
38
+
39
+ /** 5 minutes: long enough for a grant-then-write round trip through an agent
40
+ * turn, short enough that a forgotten grant is not a standing hole. */
41
+ export const GRANT_TTL_MS = 5 * 60 * 1000;
42
+
43
+ function typedError(code, message) {
44
+ const err = new Error(message);
45
+ err.code = code;
46
+ return err;
47
+ }
48
+
49
+ function ensureDir(path) {
50
+ mkdirSync(path, { recursive: true });
51
+ }
52
+
53
+ /** Read the bypass ledger as parsed rows. Malformed lines are skipped rather
54
+ * than thrown on — a corrupt row must not make every claim fail open. */
55
+ export function readOverrideLedger(cwd) {
56
+ const path = join(cwd, LEDGER_REL);
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(path, 'utf8');
60
+ } catch (err) {
61
+ if (err?.code === 'ENOENT') return [];
62
+ throw err;
63
+ }
64
+ const rows = [];
65
+ for (const line of raw.split('\n')) {
66
+ if (!line.trim()) continue;
67
+ try { rows.push(JSON.parse(line)); } catch { /* skip a corrupt row */ }
68
+ }
69
+ return rows;
70
+ }
71
+
72
+ /**
73
+ * Mint a single-use, path-scoped grant.
74
+ *
75
+ * @param {string} cwd workspace root
76
+ * @param {{path:string, reason:string, operation?:string, featuresDir?:string}} args
77
+ * `actor` is deliberately NOT accepted — it is stamped here per Decision 3.
78
+ * @returns {{token_id:string, path:string, expires_at:string}}
79
+ */
80
+ export function mintGrant(cwd, { path, reason, operation, featuresDir = 'docs/features' } = {}) {
81
+ if (typeof reason !== 'string' || reason.trim() === '') {
82
+ throw typedError(
83
+ 'CANON_OVERRIDE_REASON',
84
+ 'canon_override_grant: a non-empty reason is required — the bypass row is the whole point',
85
+ );
86
+ }
87
+ if (typeof path !== 'string' || path.trim() === '') {
88
+ throw typedError('CANON_OVERRIDE_PATH', 'canon_override_grant: path is required');
89
+ }
90
+ if (!isOverrideEligible(path, { featuresDir, point: 'hook' })) {
91
+ throw typedError(
92
+ 'CANON_OVERRIDE_INELIGIBLE',
93
+ `canon_override_grant: ${path} is not override-eligible. Either it is not guarded at the `
94
+ + 'write-time hook (nothing is blocking it), or it is the override\'s own governance state, '
95
+ + 'which is deliberately ungrantable so a bypass cannot authorise rewriting its own record.',
96
+ );
97
+ }
98
+
99
+ const now = new Date();
100
+ const tokenId = randomUUID();
101
+ const row = {
102
+ ts: now.toISOString(),
103
+ actor: 'agent', // stamped here; never caller-supplied
104
+ path,
105
+ reason: reason.trim(),
106
+ operation: typeof operation === 'string' ? operation : null,
107
+ token_id: tokenId,
108
+ };
109
+
110
+ // ── Ledger and baseline FIRST ──────────────────────────────────────────────
111
+ const ledgerPath = join(cwd, LEDGER_REL);
112
+ ensureDir(dirname(ledgerPath));
113
+ appendFileSync(ledgerPath, `${JSON.stringify(row)}\n`);
114
+ writeAttestBaseline(cwd);
115
+
116
+ // ── Then the token ─────────────────────────────────────────────────────────
117
+ const grantsDir = join(cwd, GRANTS_REL);
118
+ ensureDir(grantsDir);
119
+ const expiresAt = new Date(now.getTime() + GRANT_TTL_MS).toISOString();
120
+ writeFileSync(
121
+ join(grantsDir, `${tokenId}.json`),
122
+ `${JSON.stringify({
123
+ token_id: tokenId,
124
+ path,
125
+ actor: 'agent',
126
+ operation: row.operation,
127
+ issued_at: now.toISOString(),
128
+ expires_at: expiresAt,
129
+ }, null, 2)}\n`,
130
+ { flag: 'wx' }, // exclusive create: mint is serialized too
131
+ );
132
+
133
+ return { token_id: tokenId, path, expires_at: expiresAt };
134
+ }
135
+
136
+ /** Re-attest the bypass ledger. Called only from the append path — never as a
137
+ * standalone "fix", which would be the laundering step (S5 R1). */
138
+ function writeAttestBaseline(cwd) {
139
+ const ledgerPath = join(cwd, LEDGER_REL);
140
+ const bytes = existsSync(ledgerPath) ? readFileSync(ledgerPath) : Buffer.alloc(0);
141
+ const attestPath = join(cwd, ATTEST_REL);
142
+ ensureDir(dirname(attestPath));
143
+ writeFileSync(attestPath, `${JSON.stringify(baselineFor(bytes), null, 2)}\n`);
144
+ }
145
+
146
+ /**
147
+ * Claim a live grant for exactly `path`. Returns true if the write may proceed.
148
+ *
149
+ * Never throws: the hook's policy is fail-open, and a claim that explodes must
150
+ * not wedge the session. A false return means "no grant" and the caller denies
151
+ * on its own terms.
152
+ */
153
+ export function claimGrant(cwd, path) {
154
+ try {
155
+ const grantsDir = join(cwd, GRANTS_REL);
156
+ if (!existsSync(grantsDir)) return false;
157
+
158
+ // A token is only honoured if its row is in the ledger. This is what stops
159
+ // a raw-written token file from being consumable — the Bash-forgery case.
160
+ // It binds the two artifacts without pretending to be crypto: an actor who
161
+ // writes BOTH still passes, which is the documented ceiling.
162
+ const ledgerTokenIds = new Set(
163
+ readOverrideLedger(cwd).filter((r) => r?.path === path).map((r) => r?.token_id),
164
+ );
165
+ if (ledgerTokenIds.size === 0) return false;
166
+
167
+ const now = Date.now();
168
+ for (const entry of readdirSync(grantsDir, { withFileTypes: true })) {
169
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
170
+ const tokenPath = join(grantsDir, entry.name);
171
+
172
+ let token;
173
+ try { token = JSON.parse(readFileSync(tokenPath, 'utf8')); } catch { continue; }
174
+ if (token?.path !== path) continue;
175
+ if (!ledgerTokenIds.has(token?.token_id)) continue;
176
+
177
+ // Expiry from the immutable stamp, NOT mtime: a checkout gives a file a
178
+ // fresh mtime, which would revive a mistakenly committed token.
179
+ const expiresAt = Date.parse(token?.expires_at ?? '');
180
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) continue;
181
+
182
+ // The claim. Exactly one caller can rename a given path; losers get ENOENT.
183
+ const consumedDir = join(grantsDir, CONSUMED_SUBDIR);
184
+ ensureDir(consumedDir);
185
+ try {
186
+ renameSync(tokenPath, join(consumedDir, entry.name));
187
+ return true;
188
+ } catch {
189
+ continue; // another process won; try the next token
190
+ }
191
+ }
192
+ return false;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }