@smartmemory/compose 0.3.7 → 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.
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +470 -351
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- 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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
+
}
|