@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,380 @@
1
+ /**
2
+ * trace.js — read-side ancestry for judgment positions (COMP-JUDGMENT-PRECEDENT
3
+ * slice A).
4
+ *
5
+ * The store persists full causal history — revision chains, the
6
+ * `supersedes: <slug>#r<N>` reference, retraction tombstones — and exposes none
7
+ * of it. `get_judgment_state` returns the latest revision per position and drops
8
+ * everything behind it, so a decision's precedent is on disk and unreadable.
9
+ *
10
+ * Two additions, both read-only:
11
+ * - buildSupersessionIndex: one pass over the slugs yielding forward AND
12
+ * reverse refs. Replaces the O(n^2) rescan inside derivePositionStatus,
13
+ * which reads every other slug's full chain to answer "am I superseded".
14
+ * - tracePosition: the ancestry walk, pinned to the referenced revision and
15
+ * cycle-guarded.
16
+ *
17
+ * No writes, no schema change, no migration — every field read here is already
18
+ * persisted.
19
+ */
20
+
21
+ /** Same shape as the writer's private helper (judgment-writer.js:65). */
22
+ function typedError(code, message) {
23
+ const err = new Error(message);
24
+ err.code = code;
25
+ return err;
26
+ }
27
+
28
+ /** `<slug>#r<N>` → {slug, rev}, or null when the ref is absent/malformed. */
29
+ export function parseRevisionRef(ref) {
30
+ if (typeof ref !== 'string') return null;
31
+ const m = ref.match(/^(.+)#r(\d+)$/);
32
+ if (!m) return null;
33
+ return { slug: m[1], rev: Number(m[2]) };
34
+ }
35
+
36
+ /**
37
+ * Forward + reverse supersession refs for every position, in a single pass.
38
+ *
39
+ * Status semantics mirror `derivePositionStatus` EXACTLY — this is a
40
+ * behaviour-preserving replacement for its O(n^2) rescan, so it reproduces two
41
+ * of its quirks deliberately:
42
+ *
43
+ * 1. **Self-edges do not count.** The original skips `other === slug`, so a
44
+ * position superseding its own earlier revision (`a#r2 supersedes a#r1`)
45
+ * stays `live`. Treating that as `superseded` would strand every position
46
+ * that ever re-based on itself.
47
+ * 2. **Matching is prefix-based, not strictly parsed.** The original tests
48
+ * `ref.startsWith(`${slug}#r`)`, so a malformed tail (`a#rX`) still counts
49
+ * as superseding `a`. Strict parsing here would silently change status for
50
+ * malformed records.
51
+ *
52
+ * `supersededBy` is an ARRAY, not a scalar: `supersedes` refs are unconstrained,
53
+ * so two distinct live positions can both supersede the same target (a fork —
54
+ * `nextA -> old#r1` and `nextB -> old#r1`). A scalar was last-writer-wins and
55
+ * silently dropped every fork but the last. Each entry also records WHICH target
56
+ * revision it superseded (`rev`, or null when the tail is malformed), so a
57
+ * revision-pinned reader can filter reverse refs to the revision it is showing
58
+ * rather than mixing in ones aimed at a different revision of the same slug.
59
+ *
60
+ * @param {object} store effective judgment store (createJudgmentStore)
61
+ * @returns {Map<string, {supersedes: string|null, supersededBy: Array<{ref: string, rev: number|null}>, status: string}>}
62
+ */
63
+ export function buildSupersessionIndex(store) {
64
+ const index = new Map();
65
+ const latestBySlug = new Map();
66
+
67
+ for (const slug of store.listPositionSlugs()) {
68
+ const latest = store.latestPositionRevision(slug);
69
+ if (!latest) continue;
70
+ latestBySlug.set(slug, latest);
71
+ index.set(slug, {
72
+ supersedes: typeof latest.supersedes === 'string' ? latest.supersedes : null,
73
+ supersededBy: [],
74
+ status: latest.retracted === true ? 'retracted' : 'live',
75
+ });
76
+ }
77
+
78
+ for (const [slug, latest] of latestBySlug) {
79
+ if (latest.retracted === true) continue;
80
+ const ref = latest.supersedes;
81
+ if (typeof ref !== 'string') continue;
82
+ // `target.startsWith(ref + '#r')` for every target would be O(n) per ref and
83
+ // put the quadratic cost straight back. A target can only match if the ref
84
+ // literally begins with it followed by '#r', so enumerate the ref's own '#r'
85
+ // split points instead — same result, O(occurrences) per ref. A ref like
86
+ // `a#rb#r1` still matches BOTH `a` and `a#rb`, matching the original.
87
+ for (let i = ref.indexOf('#r'); i !== -1; i = ref.indexOf('#r', i + 1)) {
88
+ const targetSlug = ref.slice(0, i);
89
+ if (targetSlug === slug) continue; // quirk 1: no self-edges
90
+ const target = index.get(targetSlug);
91
+ if (!target) continue;
92
+ // The chars after this split point are the target revision. A clean integer
93
+ // pins it; a malformed tail (e.g. `a#rb#r1` matching target `a`) leaves it
94
+ // null — unknown, so a pinned reader treats it as matching any revision,
95
+ // preserving the permissive prefix quirk.
96
+ const tail = ref.slice(i + 2);
97
+ const rev = /^\d+$/.test(tail) ? Number(tail) : null;
98
+ target.supersededBy.push({ ref: `${slug}#r${latest.rev}`, rev });
99
+ if (target.status !== 'retracted') target.status = 'superseded';
100
+ }
101
+ }
102
+
103
+ return index;
104
+ }
105
+
106
+ /**
107
+ * The writer-legal fields of `position_revision` / `claim` that a delta must
108
+ * cover, i.e. every schema property EXCEPT identity/derived ones
109
+ * (position_revision.{slug,rev,provenance}, claim.id — those don't describe a
110
+ * belief change). These lists are the whole point of finding-1's fix: earlier
111
+ * rounds hand-picked a subset (level + grounding), which silently rendered
112
+ * source / elicitation / supports / rejected_alternatives / provider_ids /
113
+ * retraction changes as "no change". A test locks both lists to
114
+ * contracts/judgment-record.schema.json — if the contract grows a field, that
115
+ * test fails until it is diffed here. Enumerate the schema; do not cherry-pick.
116
+ */
117
+ export const REVISION_DELTA_FIELDS = Object.freeze([
118
+ 'conviction', 'claims', 'rejected_alternatives', 'supersedes', 'retracted', 'provider_ids',
119
+ ]);
120
+ export const CLAIM_DELTA_FIELDS = Object.freeze([
121
+ 'text', 'grounding', 'supports', 'owner_locked', 'elicitation',
122
+ ]);
123
+
124
+ /** Normalised, LOSSLESS view of a claim — every writer-legal field, none dropped. */
125
+ function claimView(c) {
126
+ return {
127
+ id: c.id,
128
+ text: c.text ?? null,
129
+ grounding: c.grounding ?? null,
130
+ supports: Array.isArray(c.supports) ? c.supports : [],
131
+ owner_locked: c.owner_locked === true,
132
+ elicitation: c.elicitation ?? null,
133
+ };
134
+ }
135
+
136
+ const jsonEq = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
137
+
138
+ /** Field-by-field diff of two claim views (both already normalised). */
139
+ function claimDelta(before, after) {
140
+ const changes = [];
141
+ if (before.text !== after.text) changes.push(`claim ${after.id} text changed`);
142
+ if (before.grounding !== after.grounding) {
143
+ changes.push(`claim ${after.id} grounding ${before.grounding} -> ${after.grounding}`);
144
+ }
145
+ if (!jsonEq(before.supports, after.supports)) changes.push(`claim ${after.id} supports changed`);
146
+ if (before.owner_locked !== after.owner_locked) {
147
+ changes.push(`claim ${after.id} owner_locked ${before.owner_locked} -> ${after.owner_locked}`);
148
+ }
149
+ if (!jsonEq(before.elicitation, after.elicitation)) changes.push(`claim ${after.id} elicitation changed`);
150
+ return changes;
151
+ }
152
+
153
+ /**
154
+ * Diff two claim-view lists. `id` is the natural key, but the contract does NOT
155
+ * constrain claim ids to be unique within a revision, so an id-keyed map would
156
+ * collapse duplicates and could hide a change entirely (delete one of two `c1`
157
+ * claims -> `delta: []`). When either side has duplicate ids, fall back to a
158
+ * multiset diff over the full normalised claim so nothing is silent; the common
159
+ * unique-id path keeps the field-level messages (`grounding X -> Y`, etc.).
160
+ */
161
+ function claimsDelta(before, after) {
162
+ const hasDup = (list) => new Set(list.map((c) => c.id)).size !== list.length;
163
+ if (hasDup(before) || hasDup(after)) {
164
+ const changes = [];
165
+ const bag = new Map(); // normalised-claim JSON -> count in `before`
166
+ for (const c of before) { const k = JSON.stringify(c); bag.set(k, (bag.get(k) ?? 0) + 1); }
167
+ for (const c of after) {
168
+ const k = JSON.stringify(c);
169
+ const n = bag.get(k) ?? 0;
170
+ if (n > 0) bag.set(k, n - 1);
171
+ else changes.push(`claim ${c.id} added`);
172
+ }
173
+ for (const [k, n] of bag) {
174
+ if (n <= 0) continue;
175
+ const { id } = JSON.parse(k);
176
+ for (let i = 0; i < n; i++) changes.push(`claim ${id} removed`);
177
+ }
178
+ return changes;
179
+ }
180
+
181
+ const changes = [];
182
+ const prev = new Map(before.map((c) => [c.id, c]));
183
+ for (const c of after) {
184
+ const b = prev.get(c.id);
185
+ if (!b) { changes.push(`claim ${c.id} added`); continue; }
186
+ changes.push(...claimDelta(b, c));
187
+ prev.delete(c.id);
188
+ }
189
+ for (const id of prev.keys()) changes.push(`claim ${id} removed`);
190
+ return changes;
191
+ }
192
+
193
+ /**
194
+ * Per-revision view + a schema-complete delta against the previous revision.
195
+ *
196
+ * Both the claim view and the delta cover every field in REVISION_DELTA_FIELDS /
197
+ * CLAIM_DELTA_FIELDS — see that comment. `judgment_position_amend` is restricted
198
+ * to grounding and conviction, so those are the commonest changes, but a create
199
+ * with `supersedes` / `rejected_alternatives` / `provider_ids`, and a retraction,
200
+ * are all legal and must show up too.
201
+ */
202
+ function summarizeRevision(record, previous) {
203
+ const claims = (Array.isArray(record.claims) ? record.claims : []).map(claimView);
204
+
205
+ let delta = null;
206
+ if (previous) {
207
+ const changes = [];
208
+
209
+ // conviction — level AND source
210
+ const prevConviction = previous.conviction?.level ?? null;
211
+ const conviction = record.conviction?.level ?? null;
212
+ if (prevConviction !== conviction) changes.push(`conviction ${prevConviction} -> ${conviction}`);
213
+ const prevSource = previous.conviction?.source ?? null;
214
+ const source = record.conviction?.source ?? null;
215
+ if (prevSource !== source) changes.push(`conviction source ${prevSource} -> ${source}`);
216
+
217
+ // claims — added / removed / every writer-legal field per claim (duplicate-id safe)
218
+ const prevViews = (Array.isArray(previous.claims) ? previous.claims : []).map(claimView);
219
+ changes.push(...claimsDelta(prevViews, claims));
220
+
221
+ // rejected_alternatives, provider_ids, supersedes. rejected_alternatives has
222
+ // a schema default of [], so a revision that omits it and one that writes []
223
+ // are the SAME state — normalise both before diffing or every such pair reads
224
+ // as a spurious change.
225
+ const normRA = (x) => (Array.isArray(x) ? x : []);
226
+ if (!jsonEq(normRA(previous.rejected_alternatives), normRA(record.rejected_alternatives))) {
227
+ changes.push('rejected_alternatives changed');
228
+ }
229
+ if (!jsonEq(previous.provider_ids, record.provider_ids)) changes.push('provider_ids changed');
230
+ const prevSupersedes = typeof previous.supersedes === 'string' ? previous.supersedes : null;
231
+ const supersedes = typeof record.supersedes === 'string' ? record.supersedes : null;
232
+ if (prevSupersedes !== supersedes) changes.push(`supersedes ${prevSupersedes} -> ${supersedes}`);
233
+
234
+ // retraction — both directions (a tombstone, and the rare un-retraction)
235
+ if (record.retracted === true && previous.retracted !== true) changes.push('retracted');
236
+ if (previous.retracted === true && record.retracted !== true) changes.push('unretracted');
237
+
238
+ delta = changes;
239
+ }
240
+
241
+ return {
242
+ rev: record.rev,
243
+ written_at: record.provenance?.written_at ?? null,
244
+ conviction: record.conviction?.level ?? null,
245
+ conviction_source: record.conviction?.source ?? null,
246
+ claim_count: claims.length,
247
+ claims,
248
+ rejected_alternatives: Array.isArray(record.rejected_alternatives) ? record.rejected_alternatives : [],
249
+ provider_ids: record.provider_ids ?? null,
250
+ retracted: record.retracted === true,
251
+ supersedes: typeof record.supersedes === 'string' ? record.supersedes : null,
252
+ delta,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Status of ONE revision, not of the slug's latest.
258
+ *
259
+ * A pinned ancestor node (`b -> a#r1`) must report a#r1's status, not a's. Reading
260
+ * the slug-level index would stamp the node with the latest revision's status —
261
+ * so `a#r1`, live when `b` was decided, would read `retracted` after a later
262
+ * `a#r2` tombstone. Instead:
263
+ * - this revision itself carries `retracted: true` -> 'retracted'
264
+ * - a later revision of the same slug exists -> 'superseded' (self-succession)
265
+ * - this IS the latest revision -> defer to the slug-level index, which is the
266
+ * only thing that knows about cross-position supersession
267
+ */
268
+ function revisionStatus(chain, head, indexEntry) {
269
+ if (head.retracted === true) return 'retracted';
270
+ const latestRev = chain[chain.length - 1].rev;
271
+ if (head.rev < latestRev) return 'superseded';
272
+ return indexEntry?.status ?? 'live';
273
+ }
274
+
275
+ /**
276
+ * Full causal ancestry for one position.
277
+ *
278
+ * **Ancestry is pinned to the referenced revision.** When `b` supersedes
279
+ * `a#r1`, the ancestor node shows `a` as it stood at r1 — not `a`'s current
280
+ * latest. Following the latest would report state that did not exist when the
281
+ * decision was made, which is the exact question this feature exists to answer.
282
+ *
283
+ * `supersedes` is a free-form string ref and nothing in the write path prevents
284
+ * a cycle (a → b → a) or a dangling target, so the walk carries a visited set
285
+ * and reports both rather than recursing forever or throwing.
286
+ *
287
+ * @param {object} store
288
+ * @param {string} slug
289
+ * @param {object} [opts] {index} to reuse a prebuilt supersession index
290
+ */
291
+ export function tracePosition(store, slug, opts = {}) {
292
+ const index = opts.index ?? buildSupersessionIndex(store);
293
+ const warnings = [];
294
+ const visited = new Set();
295
+ let cycle = null;
296
+
297
+ /**
298
+ * @param {string} currentSlug
299
+ * @param {number|null} atRev pin: show state as of this revision (null = latest)
300
+ */
301
+ const walk = (currentSlug, atRev) => {
302
+ const chain = store.readPositionChain(currentSlug);
303
+ if (!chain || chain.length === 0) return null;
304
+
305
+ let pinned = atRev;
306
+ if (pinned !== null && !chain.some((r) => r.rev === pinned)) {
307
+ warnings.push(
308
+ `${currentSlug}#r${pinned} referenced but that revision does not exist ` +
309
+ `(chain has r1..r${chain[chain.length - 1].rev}) — showing latest`,
310
+ );
311
+ pinned = null;
312
+ }
313
+
314
+ // State as of the pinned revision: everything up to and including it.
315
+ const upTo = pinned === null ? chain : chain.filter((r) => r.rev <= pinned);
316
+ const head = upTo[upTo.length - 1];
317
+
318
+ // Cycle check keys on the RESOLVED revision, not the requested pin —
319
+ // otherwise the root (entered unpinned) and a return visit to that same
320
+ // concrete revision look like different nodes and a real cycle escapes.
321
+ // Ancestry is a DAG over (slug, rev), so revisiting (a, r1) is the cycle;
322
+ // revisiting slug `a` at a different revision is not.
323
+ const key = `${currentSlug}#r${head.rev}`;
324
+ if (visited.has(key)) {
325
+ cycle = [...visited, key].join(' -> ');
326
+ return null;
327
+ }
328
+ visited.add(key);
329
+
330
+ const entry = index.get(currentSlug);
331
+
332
+ // Follow the ref recorded on the PINNED record, not on the latest one.
333
+ let ancestor = null;
334
+ const parsed = parseRevisionRef(head.supersedes);
335
+ if (parsed) {
336
+ if (!index.has(parsed.slug)) {
337
+ warnings.push(
338
+ `${currentSlug} supersedes "${head.supersedes}" but position "${parsed.slug}" does not exist`,
339
+ );
340
+ } else {
341
+ ancestor = walk(parsed.slug, parsed.rev);
342
+ }
343
+ } else if (typeof head.supersedes === 'string') {
344
+ warnings.push(`${currentSlug} has a malformed supersedes ref "${head.supersedes}"`);
345
+ }
346
+
347
+ // Reverse refs are scoped by whether this node was reached by a specific
348
+ // `#rN` reference. The queried root — and a dangling ref that fell back to
349
+ // latest — is UNPINNED: `status` is slug-level, so show every reverse ref
350
+ // (otherwise a slug superseded at an earlier revision reads `superseded` with
351
+ // an empty supersededBy). A node reached by a specific reference is PINNED:
352
+ // keep only refs aimed at that revision (plus unknown-rev/malformed ones,
353
+ // matched by prefix and so inseparable), EVEN when that revision happens to
354
+ // be the slug's latest — otherwise a pinned `a#r2` would inherit the ref that
355
+ // superseded `a#r1`.
356
+ const unpinned = pinned === null;
357
+ const supersededBy = (entry?.supersededBy ?? [])
358
+ .filter((e) => unpinned || e.rev === null || e.rev === head.rev)
359
+ .map((e) => parseRevisionRef(e.ref))
360
+ .filter(Boolean);
361
+
362
+ return {
363
+ slug: currentSlug,
364
+ rev: head.rev,
365
+ pinned: pinned !== null,
366
+ status: revisionStatus(chain, head, entry),
367
+ revisions: upTo.map((r, i) => summarizeRevision(r, i > 0 ? upTo[i - 1] : null)),
368
+ supersedes: ancestor,
369
+ supersededBy,
370
+ };
371
+ };
372
+
373
+ const root = walk(slug, null);
374
+ if (!root) throw typedError('JUDGMENT_NOT_FOUND', `position ${slug} does not exist`);
375
+
376
+ let depth = 0;
377
+ for (let node = root; node; node = node.supersedes) depth++;
378
+
379
+ return { ...root, depth, cycle, warnings };
380
+ }
@@ -0,0 +1,259 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import {
12
+ basename,
13
+ dirname,
14
+ isAbsolute,
15
+ join,
16
+ relative,
17
+ resolve,
18
+ sep,
19
+ } from 'node:path';
20
+
21
+ const RECORDS_REL = 'docs/judgment/records';
22
+ // The manifest lives OUTSIDE `docs/judgment/**` entirely, so no deletion of the
23
+ // canon can also delete the baseline that would expose the deletion (R4
24
+ // fail-closed). Two review findings and one earlier fix converge here: a
25
+ // baseline stored inside the tree it attests dies with that tree and reads as a
26
+ // false GREEN. `.compose/` is git-tracked (only `.compose/data/` is ignored), so
27
+ // this location is BOTH committed — a fresh clone can verify — and immune to
28
+ // `rm -rf docs/judgment`. A wiped canon now surfaces every record as `removed`
29
+ // drift; a genuinely fresh repo (no canon, no manifest) stays green.
30
+ const MANIFEST_REL = '.compose/judgment-attest.json';
31
+
32
+ function absolutePath(cwd, relPath) {
33
+ return join(cwd, ...relPath.split('/'));
34
+ }
35
+
36
+ function sha256(bytes) {
37
+ return createHash('sha256').update(bytes).digest('hex');
38
+ }
39
+
40
+ function inspectRecord(cwd, relPath) {
41
+ const bytes = readFileSync(absolutePath(cwd, relPath));
42
+ let malformed = false;
43
+
44
+ if (relPath.endsWith('.json')) {
45
+ try {
46
+ JSON.parse(bytes.toString('utf8'));
47
+ } catch {
48
+ malformed = true;
49
+ }
50
+ } else if (relPath.endsWith('.jsonl')) {
51
+ // `ledger.jsonl` is a record too, and R4's fail-closed rule covers it. Only
52
+ // hashing it was a hole: the store's reader silently DROPS unparseable
53
+ // ledger lines, so a corrupted ledger could be baselined and then verify
54
+ // green while its bad lines quietly vanished from every projection.
55
+ // Validate line-by-line instead.
56
+ const text = bytes.toString('utf8');
57
+ for (const line of text.split('\n')) {
58
+ if (line.trim() === '') continue;
59
+ try {
60
+ JSON.parse(line);
61
+ } catch {
62
+ malformed = true;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+
68
+ return { hash: sha256(bytes), malformed };
69
+ }
70
+
71
+ function malformedRecordError(relPath) {
72
+ const error = new SyntaxError(`Malformed JSON judgment record: ${relPath}`);
73
+ error.code = 'JUDGMENT_RECORD_MALFORMED';
74
+ error.path = relPath;
75
+ error.kind = 'malformed';
76
+ return error;
77
+ }
78
+
79
+ export function recordFileSet(cwd) {
80
+ const recordsRoot = absolutePath(cwd, RECORDS_REL);
81
+ if (!existsSync(recordsRoot)) return [];
82
+
83
+ const paths = [];
84
+
85
+ function walk(absDir, relDir) {
86
+ const entries = readdirSync(absDir, { withFileTypes: true })
87
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
88
+
89
+ for (const entry of entries) {
90
+ const absPath = join(absDir, entry.name);
91
+ const relPath = `${relDir}/${entry.name}`;
92
+ if (entry.isDirectory()) {
93
+ walk(absPath, relPath);
94
+ } else if (entry.isFile() && basename(relPath) !== '.attest.json') {
95
+ paths.push(relPath);
96
+ }
97
+ }
98
+ }
99
+
100
+ walk(recordsRoot, RECORDS_REL);
101
+ return paths.sort();
102
+ }
103
+
104
+ export function computeRecordHashes(cwd) {
105
+ const hashes = {};
106
+
107
+ for (const relPath of recordFileSet(cwd)) {
108
+ const record = inspectRecord(cwd, relPath);
109
+ if (record.malformed) throw malformedRecordError(relPath);
110
+ hashes[relPath] = record.hash;
111
+ }
112
+
113
+ return hashes;
114
+ }
115
+
116
+ export function readManifest(cwd) {
117
+ const manifestPath = absolutePath(cwd, MANIFEST_REL);
118
+ try {
119
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
120
+ } catch (error) {
121
+ if (error?.code === 'ENOENT') return null;
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ export function writeManifest(cwd, hashes) {
127
+ const manifestPath = absolutePath(cwd, MANIFEST_REL);
128
+ mkdirSync(dirname(manifestPath), { recursive: true });
129
+
130
+ const sortedHashes = {};
131
+ for (const relPath of Object.keys(hashes).sort()) {
132
+ sortedHashes[relPath] = hashes[relPath];
133
+ }
134
+
135
+ const tempPath = `${manifestPath}.tmp.${process.pid}.${randomUUID()}`;
136
+ try {
137
+ writeFileSync(tempPath, `${JSON.stringify(sortedHashes, null, 2)}\n`);
138
+ renameSync(tempPath, manifestPath);
139
+ } catch (error) {
140
+ try {
141
+ unlinkSync(tempPath);
142
+ } catch {
143
+ // The temp file may not exist or may already have been renamed.
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ /** Absolute path of the manifest for `cwd` — for user-facing messages. */
150
+ export function manifestPathFor(cwd) {
151
+ return absolutePath(cwd, MANIFEST_REL);
152
+ }
153
+
154
+ /**
155
+ * Write the FIRST baseline, failing with EEXIST if one is already there.
156
+ * Exclusive (`wx`) on purpose: overwriting an existing manifest is the
157
+ * laundering step, so the refusal has to be enforced by the write itself
158
+ * rather than by a check that a race — or a manifest whose contents are
159
+ * literally `null` — can slip past.
160
+ */
161
+ export function initManifestExclusive(cwd, hashes) {
162
+ const manifestPath = absolutePath(cwd, MANIFEST_REL);
163
+ mkdirSync(dirname(manifestPath), { recursive: true });
164
+
165
+ const sortedHashes = {};
166
+ for (const relPath of Object.keys(hashes).sort()) {
167
+ sortedHashes[relPath] = hashes[relPath];
168
+ }
169
+ // 'wx' → EEXIST if the path already exists. No temp+rename here: atomicity
170
+ // against a concurrent initializer IS the exclusivity.
171
+ writeFileSync(manifestPath, `${JSON.stringify(sortedHashes, null, 2)}\n`, { flag: 'wx' });
172
+ }
173
+
174
+ export function stampRecord(cwd, relPath) {
175
+ const record = inspectRecord(cwd, relPath);
176
+ if (record.malformed) throw malformedRecordError(relPath);
177
+
178
+ const hashes = readManifest(cwd) ?? {};
179
+ hashes[relPath] = record.hash;
180
+ writeManifest(cwd, hashes);
181
+ }
182
+
183
+ export function removeRecord(cwd, relPath) {
184
+ const hashes = readManifest(cwd) ?? {};
185
+ delete hashes[relPath];
186
+ writeManifest(cwd, hashes);
187
+ }
188
+
189
+ /**
190
+ * Synchronize the manifest for touched records under an absolute workspace
191
+ * root. Path entries may be absolute filesystem paths or repo-relative POSIX
192
+ * paths; non-record paths are ignored.
193
+ */
194
+ export function syncManifest(cwd, paths) {
195
+ if (typeof cwd !== 'string' || !isAbsolute(cwd)) {
196
+ const error = new TypeError(
197
+ 'syncManifest contract violation: cwd must be an absolute workspace path',
198
+ );
199
+ error.code = 'JUDGMENT_ATTEST_CWD_ABSOLUTE';
200
+ throw error;
201
+ }
202
+
203
+ const cwdAbs = resolve(cwd);
204
+ const recordPaths = new Map();
205
+
206
+ for (const path of paths) {
207
+ if (typeof path !== 'string') continue;
208
+ const absPath = resolve(cwdAbs, path);
209
+ const nativeRelPath = relative(cwdAbs, absPath);
210
+ if (
211
+ nativeRelPath === ''
212
+ || nativeRelPath === '..'
213
+ || nativeRelPath.startsWith(`..${sep}`)
214
+ || isAbsolute(nativeRelPath)
215
+ ) {
216
+ continue;
217
+ }
218
+
219
+ const relPath = nativeRelPath.split(sep).join('/');
220
+ if (!relPath.startsWith(`${RECORDS_REL}/`)) continue;
221
+ recordPaths.set(relPath, absPath);
222
+ }
223
+
224
+ for (const [relPath, absPath] of recordPaths) {
225
+ if (existsSync(absPath)) stampRecord(cwdAbs, relPath);
226
+ else removeRecord(cwdAbs, relPath);
227
+ }
228
+ }
229
+
230
+ export function verifyRecords(cwd) {
231
+ const manifest = readManifest(cwd) ?? {};
232
+ const currentPaths = recordFileSet(cwd);
233
+ const currentPathSet = new Set(currentPaths);
234
+ const drift = [];
235
+
236
+ for (const relPath of Object.keys(manifest)) {
237
+ if (!currentPathSet.has(relPath)) {
238
+ drift.push({ path: relPath, kind: 'removed' });
239
+ }
240
+ }
241
+
242
+ for (const relPath of currentPaths) {
243
+ const record = inspectRecord(cwd, relPath);
244
+ if (record.malformed) {
245
+ drift.push({ path: relPath, kind: 'malformed' });
246
+ } else if (!Object.hasOwn(manifest, relPath)) {
247
+ drift.push({ path: relPath, kind: 'added' });
248
+ } else if (manifest[relPath] !== record.hash) {
249
+ drift.push({ path: relPath, kind: 'modified' });
250
+ }
251
+ }
252
+
253
+ drift.sort((a, b) => {
254
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
255
+ return a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0;
256
+ });
257
+
258
+ return { ok: drift.length === 0, drift };
259
+ }