@smartmemory/compose 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
package/lib/ideabox.js CHANGED
@@ -51,7 +51,22 @@ const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
51
51
  const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
52
52
 
53
53
  // Matches a discussion entry: - [2026-04-10] author: text
54
- const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+(\w+):\s+(.+)$/
54
+ //
55
+ // The author is "everything up to the first colon", not `\w+`.
56
+ //
57
+ // COMP-FLUID-SEAM-GUARANTEES F7-1. `\w+` matched no real person's name: an entry
58
+ // written by `Jane Doe` rendered correctly into the file and then parsed to
59
+ // ZERO discussion entries, silently, taking the comment with it and breaking the
60
+ // `serialize(parse(projection))` fixed point the cutover rests on. It was
61
+ // unreachable while the CLI was the only writer (it always writes `human`) and
62
+ // became reachable the moment the REST API accepted an author from a request
63
+ // body (COMP-PLAN-IDEA-UNIFY S3b-2).
64
+ //
65
+ // Lazy, so the FIRST colon delimits: an author cannot contain one (the contract
66
+ // forbids it, since the file could not represent it), while the comment text
67
+ // routinely does — `- [2026-08-05] human: see this: it matters` keeps the whole
68
+ // sentence.
69
+ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+([^:\n]+?):\s+(.+)$/
55
70
 
56
71
  // ---------------------------------------------------------------------------
57
72
  // parseIdeabox(markdown) → { ideas, killed, nextId }
@@ -67,11 +82,22 @@ export function parseIdeabox(markdown) {
67
82
 
68
83
  const ideas = []
69
84
  const killed = []
85
+ // Cluster-scoped data. An umbrella heading carries a hand-authored multi-
86
+ // sentence `**Theme:**` paragraph, which is information, not decoration —
87
+ // before this was captured it sat between the H3 and the first H4 where the
88
+ // loop had no `currentIdea`, so it was silently dropped and every
89
+ // parse→serialize cycle (i.e. every `compose ideabox` mutation) deleted it.
90
+ const clusters = []
91
+ const clusterIndex = new Map()
92
+ // Everything before `## Ideas`. Regenerating this from IDEABOX_TEMPLATE
93
+ // instead of preserving it drops hand-authored convention bullets.
94
+ const preambleLines = []
70
95
 
71
96
  let inIdeasSection = false
72
97
  let inKilledSection = false
73
98
  let currentCluster = null
74
99
  let currentIdea = null
100
+ let seenAnySection = false
75
101
 
76
102
  function flushCurrentIdea() {
77
103
  if (!currentIdea) return
@@ -93,6 +119,7 @@ export function parseIdeabox(markdown) {
93
119
  flushCurrentIdea()
94
120
  inIdeasSection = true
95
121
  inKilledSection = false
122
+ seenAnySection = true
96
123
  currentCluster = null
97
124
  continue
98
125
  }
@@ -100,6 +127,7 @@ export function parseIdeabox(markdown) {
100
127
  flushCurrentIdea()
101
128
  inIdeasSection = false
102
129
  inKilledSection = true
130
+ seenAnySection = true
103
131
  currentCluster = null
104
132
  continue
105
133
  }
@@ -108,18 +136,48 @@ export function parseIdeabox(markdown) {
108
136
  flushCurrentIdea()
109
137
  inIdeasSection = false
110
138
  inKilledSection = false
139
+ // Still part of the preamble when it precedes the first real section —
140
+ // `## Conventions` lives here and was being dropped on the floor.
141
+ if (!seenAnySection) preambleLines.push(line)
111
142
  continue
112
143
  }
113
144
 
114
- if (!inIdeasSection && !inKilledSection) continue
145
+ if (!inIdeasSection && !inKilledSection) {
146
+ // Preamble = everything before the first section heading.
147
+ if (!seenAnySection) preambleLines.push(line)
148
+ continue
149
+ }
150
+
151
+ // Horizontal rules inside the ideas section are structural separators
152
+ // between umbrellas. They are regenerated by the serializer from the
153
+ // cluster list, so capturing them here would attach each one to the
154
+ // PRECEDING idea's extra lines and duplicate every rule on write.
155
+ if (/^---\s*$/.test(line)) {
156
+ continue
157
+ }
115
158
 
116
159
  // H3 = cluster heading
117
160
  if (/^###\s/.test(line)) {
118
161
  flushCurrentIdea()
119
162
  currentCluster = line.replace(/^###\s+/, '').trim()
163
+ if (!clusterIndex.has(currentCluster)) {
164
+ const entry = { name: currentCluster, theme: '', order: clusters.length }
165
+ clusters.push(entry)
166
+ clusterIndex.set(currentCluster, entry)
167
+ }
120
168
  continue
121
169
  }
122
170
 
171
+ // Cluster-scoped `**Theme:**` paragraph — appears after the H3 and before
172
+ // the first H4, i.e. exactly where there is no current idea to attach it to.
173
+ if (currentCluster && !currentIdea) {
174
+ const themeMatch = line.match(/^\*\*Theme:\*\*\s*(.*)$/)
175
+ if (themeMatch) {
176
+ clusterIndex.get(currentCluster).theme = themeMatch[1].trim()
177
+ continue
178
+ }
179
+ }
180
+
123
181
  // H4 = idea heading
124
182
  const headingMatch = line.match(IDEA_HEADING_RE)
125
183
  if (headingMatch) {
@@ -211,7 +269,11 @@ export function parseIdeabox(markdown) {
211
269
  const maxNum = allNums.length ? Math.max(...allNums) : 0
212
270
  const nextId = maxNum + 1
213
271
 
214
- return { ideas, killed, nextId }
272
+ // Trailing blank lines before `## Ideas` are structural, not content — the
273
+ // serializer re-adds the separator itself.
274
+ while (preambleLines.length && preambleLines.at(-1).trim() === '') preambleLines.pop()
275
+
276
+ return { ideas, killed, nextId, clusters, preamble: preambleLines.join('\n') }
215
277
  }
216
278
 
217
279
  function extractStatus(raw) {
@@ -232,7 +294,17 @@ function applyField(idea, key, val) {
232
294
  idea.priority = val.replace(/`/g, '').trim() || '—'
233
295
  break
234
296
  case 'Tags':
235
- idea.tags = (val.match(/#\w+/g) || [])
297
+ // Accepts BOTH spellings. The documented convention is `` `#ux` ``, but
298
+ // the real ideabox writes bare words (`stratum integrity research-
299
+ // influence`), and the old `/#\w+/g` matched none of them — so all 20
300
+ // ideas parsed with zero tags and every CLI mutation stripped the lot.
301
+ // Tokens are kept verbatim (a leading `#` is preserved, never added) so
302
+ // whichever spelling a file uses survives a round-trip unchanged.
303
+ idea.tags = val
304
+ .replace(/`/g, ' ')
305
+ .trim()
306
+ .split(/\s+/)
307
+ .filter(Boolean)
236
308
  break
237
309
  case 'Source':
238
310
  idea.source = val
@@ -280,28 +352,42 @@ function applyField(idea, key, val) {
280
352
  * @param {{ ideas: IdeaEntry[], killed: IdeaEntry[], nextId: number }} parsedData
281
353
  * @returns {string}
282
354
  */
283
- export function serializeIdeabox({ ideas, killed }) {
355
+ export function serializeIdeabox({ ideas, killed, clusters: clusterMeta = [], preamble = '' }) {
284
356
  const lines = []
285
357
 
286
- lines.push('# Ideabox')
287
- lines.push('')
288
- lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
289
- lines.push('')
290
- lines.push('## Conventions')
291
- lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
292
- lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
293
- lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
294
- lines.push('- **Source:** Where the idea came from')
295
- lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
296
- lines.push('')
358
+ if (preamble) {
359
+ // Round-trip the author's own preamble. Regenerating it from the template
360
+ // silently deletes any convention the project added (the real ideabox
361
+ // documents an `**Umbrella:**` rule the template has never known about).
362
+ lines.push(...preamble.split('\n'))
363
+ lines.push('')
364
+ } else {
365
+ lines.push('# Ideabox')
366
+ lines.push('')
367
+ lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
368
+ lines.push('')
369
+ lines.push('## Conventions')
370
+ lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
371
+ lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
372
+ lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
373
+ lines.push('- **Source:** Where the idea came from')
374
+ lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
375
+ lines.push('')
376
+ }
297
377
  lines.push('## Ideas')
298
378
  lines.push('')
299
- lines.push('<!-- Ideas grouped by potential feature cluster -->')
300
- lines.push('')
379
+ if (!clusterMeta.length) {
380
+ lines.push('<!-- Ideas grouped by potential feature cluster -->')
381
+ lines.push('')
382
+ }
301
383
 
302
384
  // Group active ideas by cluster
303
385
  const clusters = new Map()
304
386
  const unclustered = []
387
+ // Seed in declared cluster order so an empty cluster keeps its place and its
388
+ // theme rather than disappearing.
389
+ for (const c of clusterMeta) clusters.set(c.name, [])
390
+ const themeOf = new Map(clusterMeta.map((c) => [c.name, c.theme]))
305
391
  for (const idea of ideas) {
306
392
  if (idea.cluster) {
307
393
  if (!clusters.has(idea.cluster)) clusters.set(idea.cluster, [])
@@ -312,8 +398,17 @@ export function serializeIdeabox({ ideas, killed }) {
312
398
  }
313
399
 
314
400
  for (const [cluster, clusterIdeas] of clusters) {
401
+ if (clusterMeta.length) {
402
+ lines.push('---')
403
+ lines.push('')
404
+ }
315
405
  lines.push(`### ${cluster}`)
316
406
  lines.push('')
407
+ const theme = themeOf.get(cluster)
408
+ if (theme) {
409
+ lines.push(`**Theme:** ${theme}`)
410
+ lines.push('')
411
+ }
317
412
  for (const idea of clusterIdeas) {
318
413
  lines.push(...serializeIdea(idea))
319
414
  }
@@ -348,14 +443,19 @@ function serializeIdea(idea) {
348
443
 
349
444
  if (idea.source) out.push(`**Source:** ${idea.source}`)
350
445
  if (idea.description) out.push(`**Idea:** ${idea.description}`)
351
- if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
352
- if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
353
- if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
354
446
 
447
+ // Unrecognized field lines are emitted here, BEFORE the trailing known
448
+ // fields, because that is where they occur in practice: an idea that carries
449
+ // custom `**Idea (original):**` / `**Re-aim:**` blocks ends with `**Maps
450
+ // to:**`, and emitting extras last reordered the file on every write.
355
451
  for (const extra of (idea._extraLines || [])) {
356
452
  out.push(extra)
357
453
  }
358
454
 
455
+ if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
456
+ if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
457
+ if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
458
+
359
459
  // Discussion thread
360
460
  if (idea.discussion && idea.discussion.length > 0) {
361
461
  out.push('**Discussion:**')
@@ -95,10 +95,18 @@ export function effectiveStore(rawStore) {
95
95
  .filter((slug) => adapter.readPositionChain(slug).length > 0);
96
96
  },
97
97
 
98
- derivePositionStatus(slug) {
98
+ /**
99
+ * @param {string} slug
100
+ * @param {Map} [index] optional supersession index (lib/judgment/trace.js
101
+ * buildSupersessionIndex). Callers looping over every slug should pass one:
102
+ * without it this rescans all other slugs' chains per call, so a whole-store
103
+ * status pass is O(n^2). Result is identical either way.
104
+ */
105
+ derivePositionStatus(slug, index) {
99
106
  const latest = adapter.latestPositionRevision(slug);
100
107
  if (!latest) return null;
101
108
  if (latest.retracted === true) return 'retracted';
109
+ if (index) return index.get(slug)?.status ?? 'live';
102
110
  for (const other of adapter.listPositionSlugs()) {
103
111
  if (other === slug) continue;
104
112
  const otherLatest = adapter.latestPositionRevision(other);
@@ -29,7 +29,7 @@ import {
29
29
  } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
 
32
- function atomicWrite(path, content) {
32
+ export function atomicWrite(path, content) {
33
33
  const tmp = `${path}.tmp.${process.pid}`;
34
34
  try {
35
35
  writeFileSync(tmp, content);
@@ -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
+ }