spexcode 0.5.2 → 0.5.4

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 (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -1,7 +1,10 @@
1
- import { git, headSha, ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
1
+ import { readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { gitA, gitTry, headSha, currentGitBuildAbortSignal, gitAbortError, ancestorsOf, inAncestors, commitReachable, pathEvents, type DriftIndex, type DriftPathEvent } from '../../spec-cli/src/git.js'
4
+ import { anchorHitCommits, extOf, extractorFor, extractors, resolveAnchor, type Extractor, type RelationEntry } from '../../spec-cli/src/anchors.js'
2
5
  import type { Reading } from './sidecar.js'
3
- import { scenarioHash, type Scenario } from './scenarios.js'
4
- import { scenarioChangeCommits, scenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
6
+ import { scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
7
+ import { scenarioChangeCommits, scenarioBlocksAt, primeScenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
5
8
 
6
9
  // the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
7
10
 
@@ -9,47 +12,229 @@ export type StaleAxis = 'code' | 'scenario' | 'remark' | 'anchor'
9
12
 
10
13
  // @@@ off-history content fallback - ancestry can't testify for a codeSha that isn't reachable from HEAD
11
14
  // (fold/rebase/squash-merge/cherry-pick all orphan the anchor), but the TREES still can: while the anchor
12
- // commit object exists locally, `git diff <anchor> HEAD` names exactly the paths whose content differs, so
13
- // a history rewrite that left governed content byte-identical reads FRESH instead of false-positive stale.
14
- // The probe is fed at the call sites (like the remark track) so the decision functions stay pure over their
15
- // inputs and the in-history fast path pays no extra git call; only when the commit object is truly gone
16
- // (gc'd orphan) does the conservative rule remain surfaced as the 'anchor' axis, so "anchor lost" reads
17
- // differently from "content moved". No probe fed the old always-conservative rule.
15
+ // commit object exists locally, a pathspec-scoped `git diff <anchor> HEAD -- :(literal)<path>…` answers,
16
+ // for exactly the governed paths a caller asks about, whether content differs so a history rewrite that
17
+ // left governed content byte-identical reads FRESH instead of false-positive stale. The question is per
18
+ // REQUESTED path on purpose: one whole-repo changed-path set per anchor is what melted a production fold
19
+ // (522 anchors × ~6k retained paths 520MB of Node heap), while the verdicts actually consumed are a
20
+ // handful of governed files plus the eval.md. The probe is fed at the call sites (like the remark track) so
21
+ // the decision functions stay pure over their inputs and the in-history fast path pays no extra git call;
22
+ // only when the commit object is truly gone (gc'd orphan) does the conservative rule remain — surfaced as
23
+ // the 'anchor' axis, so "anchor lost" reads differently from "content moved". No probe fed → the old
24
+ // always-conservative rule.
18
25
  export type ContentProbe = {
19
- // paths whose content differs between the anchor commit's tree and HEAD's; null = anchor object gone
20
- changedPaths(anchorSha: string): Set<string> | null
26
+ // did THIS path's content change between the anchor tree and HEAD's? null = can't testify (anchor gone,
27
+ // or the path never primed) callers stay conservative. Only primed paths are ever retained.
28
+ changed(anchorSha: string, path: string): boolean | null
29
+ // content CAN testify for this anchor: the object is readable and at least one verdict has settled
30
+ canTestify(anchorSha: string): boolean
21
31
  // did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
22
32
  scenarioDiffers(anchorSha: string, evalPath: string, scenario: string): boolean
23
33
  // codeDrift's display detail: commits in anchor..HEAD touching path (floored at 1 — the content differs)
24
34
  behind(anchorSha: string, path: string): number
35
+ prime?(anchorSha: string, paths: string[], evalPath: string): Promise<void>
25
36
  }
26
37
 
27
- // (anchor, HEAD) name two immutable trees, so entries never invalidate; the LRU only bounds memory,
28
- // sized above the largest adopter reading corpus one entry per (reading, path) worst case so a
29
- // repeat board build never thrashes back into forking (a bound below the corpus's distinct key count
30
- // turns a fixed-order rebuild into sequential thrash: every pass evicts the whole memo before cycling
31
- // back, re-forking one git child per key forever scenariofresh's oidMemo sizing rule).
32
- const diffMemo = new Map<string, Set<string> | null>()
33
- const behindMemo = new Map<string, number>()
34
- function memo<V>(m: Map<string, V>, k: string, build: () => V): V {
35
- if (m.has(k)) { const v = m.get(k)!; m.delete(k); m.set(k, v); return v }
36
- const v = build()
37
- m.set(k, v)
38
- if (m.size > 4096) m.delete(m.keys().next().value!)
39
- return v
38
+ // (anchor, HEAD) name two immutable trees, so a settled verdict never invalidates but "never invalidates"
39
+ // is not "keep forever". A checkout answers freshness questions at ONE head at a time, so a root owns
40
+ // exactly one head's verdicts ([[source-of-truth]]'s current-root rule, the same one the history and drift
41
+ // indices follow). Keying the head into a shared memo instead made every rebuild ADD a generation: three
42
+ // full invalidations left three heads' worth of anchors resident, and the cache grew with rebuild count
43
+ // rather than with corpus size. A head move therefore swaps the root's scope atomically. An in-flight batch
44
+ // keeps the entry object its caller already holds, so that caller still settles, but a detached entry can
45
+ // never be read back through the root's current scope an old flight cannot backfill the new head.
46
+ // An anchor entry holds only PER-REQUESTED-PATH verdicts (plus the gone bit and the batch bookkeeping),
47
+ // never a whole-repo path set.
48
+ type AnchorVerdicts = {
49
+ verdicts: Map<string, boolean> // requested path → content differs between the two immutable trees
50
+ gone: boolean // the anchor commit object is locally unreadable — content can't testify
51
+ pending: Set<string> // paths awaiting the next batch child
52
+ flight: Promise<void> | null // the single in-flight batch for this anchor
53
+ }
54
+ type RootScope = { head: string; anchors: Map<string, AnchorVerdicts>; behind: Map<string, number> }
55
+ const rootScopes = new Map<string, RootScope>()
56
+ // Roots come and go (a closed worktree never asks again), so cap how many stay warm — the same bounded-slot
57
+ // guard the index caches use, and the only bound needed once per-root cardinality is the corpus, not history.
58
+ const ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_FRESHNESS_ROOT_SLOTS || 64))
59
+
60
+ function scopeFor(rootKey: string, head: string): RootScope {
61
+ const current = rootScopes.get(rootKey)
62
+ if (current?.head === head) {
63
+ rootScopes.delete(rootKey)
64
+ rootScopes.set(rootKey, current)
65
+ return current
66
+ }
67
+ const scope: RootScope = { head, anchors: new Map(), behind: new Map() }
68
+ rootScopes.set(rootKey, scope)
69
+ while (rootScopes.size > ROOT_SLOTS) {
70
+ const oldest = rootScopes.keys().next().value
71
+ if (oldest === undefined || oldest === rootKey) break
72
+ rootScopes.delete(oldest)
73
+ }
74
+ return scope
75
+ }
76
+ // a read only ever sees the root's CURRENT head — a probe pinned to a superseded head can't testify
77
+ function currentScope(rootKey: string, head: string): RootScope | undefined {
78
+ const scope = rootScopes.get(rootKey)
79
+ return scope?.head === head ? scope : undefined
80
+ }
81
+
82
+ // the cardinality invariant, made observable: how many anchor entries are resident at a root's CURRENT head
83
+ // (every root when none is named). It must track the corpus's anchors, never how many times the board has
84
+ // been rebuilt — one retained generation, not one per rebuild.
85
+ export function freshnessCacheSize(root?: string): number {
86
+ if (root !== undefined) return rootScopes.get(resolve(root))?.anchors.size ?? 0
87
+ let total = 0
88
+ for (const scope of rootScopes.values()) total += scope.anchors.size
89
+ return total
90
+ }
91
+
92
+ function touchAnchor(scope: RootScope, sha: string): AnchorVerdicts {
93
+ const hit = scope.anchors.get(sha)
94
+ if (hit) return hit
95
+ const entry: AnchorVerdicts = { verdicts: new Map(), gone: false, pending: new Set(), flight: null }
96
+ scope.anchors.set(sha, entry)
97
+ return entry
98
+ }
99
+
100
+ type HeavyDiffWaiter = {
101
+ signal?: AbortSignal
102
+ resolve: (release: () => void) => void
103
+ reject: (error: Error) => void
104
+ onAbort: () => void
105
+ }
106
+ type HeavyDiffScope = { active: boolean; waiting: HeavyDiffWaiter[] }
107
+ const heavyDiffScopes = new Map<string, HeavyDiffScope>()
108
+
109
+ // One tree comparison at a time is enough for a production repo's pack/index memory. The graph-wide git
110
+ // permits still bound every child; this narrower domain scheduler keeps these immutable-tree
111
+ // comparisons serial per (repo, HEAD), without teaching the git transport what a content fallback is.
112
+ function acquireHeavyDiff(scopeKey: string, signal?: AbortSignal): Promise<() => void> {
113
+ if (signal?.aborted) return Promise.reject(gitAbortError())
114
+ let scope = heavyDiffScopes.get(scopeKey)
115
+ if (!scope) {
116
+ scope = { active: false, waiting: [] }
117
+ heavyDiffScopes.set(scopeKey, scope)
118
+ }
119
+
120
+ const releaseFor = (): (() => void) => {
121
+ let released = false
122
+ return () => {
123
+ if (released) return
124
+ released = true
125
+ scope!.active = false
126
+ while (scope!.waiting.length) {
127
+ const waiter = scope!.waiting.shift()!
128
+ waiter.signal?.removeEventListener('abort', waiter.onAbort)
129
+ if (waiter.signal?.aborted) {
130
+ waiter.reject(gitAbortError())
131
+ continue
132
+ }
133
+ scope!.active = true
134
+ waiter.resolve(releaseFor())
135
+ return
136
+ }
137
+ if (heavyDiffScopes.get(scopeKey) === scope) heavyDiffScopes.delete(scopeKey)
138
+ }
139
+ }
140
+
141
+ if (!scope.active) {
142
+ scope.active = true
143
+ return Promise.resolve(releaseFor())
144
+ }
145
+ return new Promise((resolvePermit, reject) => {
146
+ const waiter: HeavyDiffWaiter = {
147
+ signal,
148
+ resolve: resolvePermit,
149
+ reject,
150
+ onAbort: () => {
151
+ const index = scope!.waiting.indexOf(waiter)
152
+ if (index < 0) return
153
+ scope!.waiting.splice(index, 1)
154
+ signal?.removeEventListener('abort', waiter.onAbort)
155
+ reject(gitAbortError())
156
+ if (!scope!.active && scope!.waiting.length === 0 && heavyDiffScopes.get(scopeKey) === scope)
157
+ heavyDiffScopes.delete(scopeKey)
158
+ },
159
+ }
160
+ scope!.waiting.push(waiter)
161
+ signal?.addEventListener('abort', waiter.onAbort, { once: true })
162
+ })
163
+ }
164
+
165
+ // @@@ one batch per anchor, only the paths actually asked about - the tree comparison is pathspec-scoped
166
+ // (`:(literal)` so a path holding a glob char, a space or a leading colon is matched verbatim, `-z` so the
167
+ // answer needs no unquoting), and the answer retained is one boolean per REQUESTED path. Concurrent primes
168
+ // on the same anchor therefore union their paths BEFORE the child starts: a caller records what it needs in
169
+ // `pending`, then either starts the batch that drains pending or joins the running one and re-checks after —
170
+ // so a path requested mid-flight rides the NEXT batch instead of racing this one, and a settled path is
171
+ // never asked again. Different anchors stay serial through the same scope permit.
172
+ function startAnchorBatch(root: string, rootKey: string, sha: string, current: string, entry: AnchorVerdicts): Promise<void> {
173
+ const run = async (): Promise<void> => {
174
+ const release = await acquireHeavyDiff(`${rootKey}\x1f${current}`, currentGitBuildAbortSignal())
175
+ // drain AFTER the permit, so every caller that registered while this batch was waiting rides THIS
176
+ // child instead of paying for another one; anything registered after this line is the next batch.
177
+ const batch = [...entry.pending]
178
+ entry.pending.clear()
179
+ try {
180
+ if (!batch.length) return
181
+ const result = await gitTry(['-C', root, 'diff', '--name-only', '-z', '--no-renames', sha, current,
182
+ '--', ...batch.map((path) => `:(literal)${path}`)])
183
+ if (!result.ok && result.failure !== 'exit')
184
+ throw new Error(`git content diff failed (${result.failure ?? 'unknown'}): ${result.stderr.trim() || 'unknown git error'}`)
185
+ if (!result.ok) { entry.gone = true; return } // the anchor commit object is unreadable — content can't testify
186
+ const changed = new Set(result.stdout.split('\0').filter(Boolean))
187
+ for (const path of batch) entry.verdicts.set(path, changed.has(path))
188
+ } finally {
189
+ release()
190
+ }
191
+ }
192
+ const flight = run().finally(() => { if (entry.flight === flight) entry.flight = null })
193
+ entry.flight = flight
194
+ return flight
40
195
  }
41
196
 
42
197
  export function contentProbeFor(root: string): ContentProbe {
198
+ const rootKey = resolve(root)
43
199
  let head: string | undefined
44
200
  const headOf = () => (head ??= headSha(root))
45
201
  return {
46
- changedPaths(sha) {
47
- return memo(diffMemo, `${root}\x1f${sha}\x1f${headOf()}`, () => {
48
- try {
49
- return new Set(git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-only', '--no-renames', sha, headOf()])
50
- .split('\n').map((s) => s.trim()).filter(Boolean))
51
- } catch { return null } // the anchor commit object is gone — content can't testify
52
- })
202
+ async prime(sha, paths, evalPath) {
203
+ const current = headOf()
204
+ const wanted = [...new Set([...paths, evalPath])].filter(Boolean)
205
+ // one scope resolve per prime: the entry stays this call's to settle even if the root's head moves
206
+ // under it, but the swap means nothing it writes afterwards can be read back at the new head.
207
+ const scope = scopeFor(rootKey, current)
208
+ const entry = touchAnchor(scope, sha)
209
+ while (!entry.gone) {
210
+ const missing = wanted.filter((path) => !entry.verdicts.has(path))
211
+ if (!missing.length) break
212
+ for (const path of missing) entry.pending.add(path)
213
+ // record first, then join: whoever starts the next batch drains everything pending by now.
214
+ if (entry.flight) await entry.flight
215
+ else await startAnchorBatch(root, rootKey, sha, current, entry)
216
+ }
217
+ if (entry.gone) return
218
+ for (const path of new Set(paths)) {
219
+ if (entry.verdicts.get(path) !== true) continue
220
+ const behindKey = `${sha}\x1f${path}`
221
+ if (scope.behind.has(behindKey)) continue
222
+ const n = Number((await gitA(['-C', root, 'rev-list', '--count', `${sha}..${current}`, '--', path])).trim())
223
+ scope.behind.set(behindKey, Number.isFinite(n) && n > 0 ? n : 1)
224
+ }
225
+ if (entry.verdicts.get(evalPath) === true) await primeScenarioBlocksAt(root, [sha, current], evalPath)
226
+ },
227
+ changed(sha, path) {
228
+ // The async prime owns all I/O. An unprimed path is 'can't testify', never a fresh sync diff: a miss
229
+ // stays conservative and no abort/transient failure can turn into an unbounded synchronous fallback.
230
+ const entry = currentScope(rootKey, headOf())?.anchors.get(sha)
231
+ if (!entry || entry.gone) return null
232
+ return entry.verdicts.get(path) ?? null
233
+ },
234
+ canTestify(sha) {
235
+ // a settled verdict — of either polarity — is the proof the anchor's tree was readable
236
+ const entry = currentScope(rootKey, headOf())?.anchors.get(sha)
237
+ return !!entry && !entry.gone && entry.verdicts.size > 0
53
238
  },
54
239
  scenarioDiffers(sha, evalPath, scenario) {
55
240
  const a = scenarioBlocksAt(root, sha, evalPath)
@@ -57,16 +242,99 @@ export function contentProbeFor(root: string): ContentProbe {
57
242
  return a.get(scenario) !== scenarioBlocksAt(root, headOf(), evalPath)?.get(scenario)
58
243
  },
59
244
  behind(sha, path) {
60
- return memo(behindMemo, `${root}\x1f${sha}\x1f${headOf()}\x1f${path}`, () => {
61
- try {
62
- const n = Number(git(['-C', root, 'rev-list', '--count', `${sha}..${headOf()}`, '--', path]).trim())
63
- return Number.isFinite(n) && n > 0 ? n : 1
64
- } catch { return 1 }
65
- })
245
+ return currentScope(rootKey, headOf())?.behind.get(`${sha}\x1f${path}`) ?? 1
246
+ },
247
+ }
248
+ }
249
+
250
+ // @@@ the code axis's SPATIAL narrowing ([[code-anchor]]'s path#symbol, reused whole) - a shared FILE is not a
251
+ // shared BEHAVIOUR: harness.ts carries eight adapters, so a one-adapter edit re-flagged every other adapter's
252
+ // reading, and those refresh only through a real dispatched session of that harness. An anchored entry
253
+ // therefore asks the spatial question instead of the file question — did a commit in codeSha..HEAD intersect
254
+ // one of the named units? — through the SAME parse/extract/resolve/hunk∩range engine spec drift runs. Two
255
+ // deliberate differences from that engine: the window carries NO ack filter (an ack vindicates a spec, not a
256
+ // reading), and it never widens — the file question runs FIRST and the anchor can only subtract from it.
257
+ // Fed at the call sites like the ContentProbe, so the decision functions stay pure over their inputs.
258
+ export type AnchorProbe = {
259
+ // did any commit in sinceSha..HEAD touch one of THESE anchored units?
260
+ // null = cannot testify (unprimed, off-history, no usable extractor) — callers stay conservatively stale.
261
+ hit(sinceSha: string, path: string, selectors: readonly string[]): boolean | null
262
+ prime?(sinceSha: string, entries: readonly RelationEntry[]): Promise<void>
263
+ }
264
+
265
+ // a verdict answers ONE selector set, so the set is part of its identity: several scenarios anchoring
266
+ // DIFFERENT units of one shared file is the whole point of narrowing, and keying only by (sha, path) would
267
+ // hand the first one's answer to all the others — silently, and in the fresh direction.
268
+ const anchorKey = (sinceSha: string, path: string, selectors: readonly string[]) =>
269
+ `${sinceSha}\x1f${path}\x1f${[...selectors].sort().join('\x1e')}`
270
+
271
+ // the eval code window: commits touching `path` in sinceSha..HEAD by the same true ancestry `changedSince`
272
+ // uses, from the same index source — so the anchor check can only narrow the very set the file question just
273
+ // answered `true` for. null = ancestry cannot testify (off-history anchor) → the caller stays conservative.
274
+ function evalWindowCommits(idx: DriftIndex, sinceSha: string, path: string): DriftPathEvent[] | null {
275
+ const anc = ancestorsOf(idx, sinceSha)
276
+ if (!anc) return null
277
+ return pathEvents(idx, path).filter((event) => !inAncestors(idx, anc, event.commit))
278
+ }
279
+
280
+ // every selector of one entry resolves to exactly one unit in the CURRENT tree, or the entry cannot testify.
281
+ // This gate is what stops a DEAD selector from reading fresh: the hit engine answers "no commit touched a
282
+ // unit of that name", which for a name that exists nowhere is a vacuous no — true of spec drift, where the
283
+ // dead anchor is a separate blocking error, and dangerously false here, where the same silence would retire
284
+ // a reading's whole code axis. `problem` names the repair for lint; null means the entry is verifiable.
285
+ function entryUnverifiable(root: string, regs: Extractor[], entry: RelationEntry): string | null {
286
+ const x = extractorFor(regs, extOf(entry.path))
287
+ if (!x) return `\`code\` selector \`${entry.path}#${entry.selectors[0]}\` — no designated extractor for that language; drop the #anchor or add a language row`
288
+ const ready = x.ready()
289
+ if (ready !== true) return `\`code\` anchors on ${entry.path} are unverified: ${ready}`
290
+ let units
291
+ try { units = x.extract(readFileSync(join(root, entry.path), 'utf8'), entry.path) }
292
+ catch (err: any) { return `\`code\` anchors on ${entry.path} are unverified: ${err?.message ?? String(err)}` }
293
+ for (const sym of entry.selectors) {
294
+ const r = resolveAnchor(units, sym)
295
+ if ('dead' in r) return `\`code\` selector \`${entry.path}#${sym}\` names no unit in that file — follow the rename or drop the selector (evals stay stale until then)`
296
+ if ('ambiguous' in r) return `\`code\` selector \`${entry.path}#${sym}\` is ambiguous — ${r.ambiguous} units share that name; pin a unique one`
297
+ }
298
+ return null
299
+ }
300
+
301
+ export function anchorProbeFor(root: string, idx: DriftIndex): AnchorProbe {
302
+ const regs = extractors(root)
303
+ const verdicts = new Map<string, boolean>()
304
+ return {
305
+ async prime(sinceSha, entries) {
306
+ for (const e of entries) {
307
+ if (!e.selectors.length) continue
308
+ const key = anchorKey(sinceSha, e.path, e.selectors)
309
+ if (verdicts.has(key)) continue
310
+ if (entryUnverifiable(root, regs, e)) continue // no verdict → conservative stale (lint says why)
311
+ const win = evalWindowCommits(idx, sinceSha, e.path)
312
+ if (win === null) continue
313
+ if (!win.length) { verdicts.set(key, false); continue }
314
+ const hits = await anchorHitCommits(root, win, [...e.selectors], regs)
315
+ verdicts.set(key, hits.length > 0)
316
+ }
317
+ },
318
+ hit(sinceSha, path, selectors) {
319
+ return verdicts.get(anchorKey(sinceSha, path, selectors)) ?? null
66
320
  },
67
321
  }
68
322
  }
69
323
 
324
+ // the LOUD half: a selector is a claim that a named unit EXISTS, held to the same standard as a ghost path.
325
+ // Dead, ambiguous, unparseable, or no designated extractor — each names itself and its repair, and until
326
+ // repaired the probe issues no verdict, so the reading stays stale. Over-warn, never a silent pass.
327
+ export function anchorProblems(root: string, entries: readonly RelationEntry[]): string[] {
328
+ const regs = extractors(root)
329
+ const out: string[] = []
330
+ for (const e of entries) {
331
+ if (!e.selectors.length) continue
332
+ const problem = entryUnverifiable(root, regs, e)
333
+ if (problem) out.push(problem)
334
+ }
335
+ return out
336
+ }
337
+
70
338
  // the REMARK axis's input ([[remark-teeth]]): the teeth read only the resolvable bit + when it was resolved,
71
339
  // not the whole remark — so freshness stays a PURE function, fed the scenario's remark track at the call
72
340
  // sites (never reaching into the issue store). One signal per remark on the (node, scenario).
@@ -88,9 +356,8 @@ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): b
88
356
  // from HEAD's history, so it reads stale rather than silently pass.
89
357
  export function changedSince(idx: DriftIndex, sinceSha: string, path: string, probe?: ContentProbe): boolean {
90
358
  const anc = ancestorsOf(idx, sinceSha)
91
- if (anc) return (idx.fileCommits.get(path) ?? []).some((h) => !inAncestors(idx, anc, h))
92
- const diff = probe?.changedPaths(sinceSha)
93
- return diff ? diff.has(path) : true
359
+ if (anc) return pathEvents(idx, path).some((event) => !inAncestors(idx, anc, event.commit))
360
+ return probe?.changed(sinceSha, path) ?? true
94
361
  }
95
362
 
96
363
  // the code axis's DISPLAY detail: which governed files drifted since a reading, and by HOW MANY commits — so
@@ -99,15 +366,19 @@ export function changedSince(idx: DriftIndex, sinceSha: string, path: string, pr
99
366
  // an off-history sinceSha reports through the same content fallback (only files whose content differs, counted
100
367
  // by rev-list); with no probe or a gone anchor it counts every touch (conservative, matching changedSince).
101
368
  // Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
102
- export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[], probe?: ContentProbe): { file: string; behind: number }[] {
369
+ export function codeDrift(idx: DriftIndex, sinceSha: string, codeAxis: string[], probe?: ContentProbe): { file: string; behind: number }[] {
370
+ // an entry may be anchored (`path#symbol`); drift is reported per BASE FILE — a raw selector string names
371
+ // no real path, so counting commits against it would silently report nothing.
372
+ const codeFiles = scenarioCodeAxis(codeAxis).paths
103
373
  const anc = ancestorsOf(idx, sinceSha)
104
- const diff = anc ? undefined : probe?.changedPaths(sinceSha)
105
374
  const out: { file: string; behind: number }[] = []
106
375
  for (const f of codeFiles) {
107
- const commits = idx.fileCommits.get(f) ?? []
108
- const behind = anc ? commits.filter((h) => !inAncestors(idx, anc, h)).length
109
- : diff ? (diff.has(f) ? probe!.behind(sinceSha, f) : 0)
110
- : commits.length
376
+ const events = pathEvents(idx, f)
377
+ const differs = anc ? undefined : probe?.changed(sinceSha, f)
378
+ const behind = anc ? new Set(events.filter((event) => !inAncestors(idx, anc, event.commit)).map((event) => event.commit)).size
379
+ : differs === true ? probe!.behind(sinceSha, f)
380
+ : differs === false ? 0
381
+ : new Set(events.map((event) => event.commit)).size
111
382
  if (behind > 0) out.push({ file: f, behind })
112
383
  }
113
384
  return out
@@ -140,31 +411,41 @@ function scenarioStaleByHash(reading: Reading, current: Scenario | undefined): b
140
411
  function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, evalPath: string, scenario: string, probe?: ContentProbe): boolean {
141
412
  const anc = ancestorsOf(didx, sinceSha)
142
413
  if (anc) return scenarioChangeCommits(scIdx, evalPath, scenario).some((h) => !inAncestors(didx, anc, h))
143
- const diff = probe?.changedPaths(sinceSha)
144
- if (!diff) return true
145
- if (!diff.has(evalPath)) return false // whole file byte-identical → this block too
414
+ const differs = probe?.changed(sinceSha, evalPath)
415
+ if (differs == null) return true
416
+ if (!differs) return false // whole file byte-identical → this block too
146
417
  return probe!.scenarioDiffers(sinceSha, evalPath, scenario)
147
418
  }
148
419
 
420
+ // one declared entry's contribution to the code axis. The FILE question runs first and is unchanged; only an
421
+ // ANCHORED entry whose file really moved asks the narrower spatial one, so an anchor can subtract from the
422
+ // file verdict but never add to it. No verdict (unprimed, off-history, unverifiable selector) → conservative.
423
+ function entryMoved(idx: DriftIndex, sinceSha: string, entry: RelationEntry, probe?: ContentProbe, anchors?: AnchorProbe): boolean {
424
+ if (!changedSince(idx, sinceSha, entry.path, probe)) return false
425
+ if (!entry.selectors.length) return true
426
+ return anchors?.hit(sinceSha, entry.path, entry.selectors) ?? true
427
+ }
428
+
149
429
  export function staleAxes(
150
430
  reading: Reading,
151
- codeFiles: string[],
431
+ codeAxis: string[],
152
432
  evalPath: string,
153
433
  didx: DriftIndex,
154
434
  scIdx: ScenarioIndex,
155
435
  remarks: RemarkSignal[] = [],
156
436
  probe?: ContentProbe,
157
437
  current?: Scenario, // the scenario's CURRENT declaration (undefined = gone from eval.md) — the hash compare's other side
438
+ anchors?: AnchorProbe,
158
439
  ): StaleAxis[] {
159
440
  const axes: StaleAxis[] = []
160
441
  const byHash = scenarioStaleByHash(reading, current)
161
- if (probe && !ancestorsOf(didx, reading.codeSha) && probe.changedPaths(reading.codeSha) === null) {
442
+ if (probe && !commitReachable(didx, reading.codeSha) && !probe.canTestify(reading.codeSha)) {
162
443
  // the anchor commit object is GONE — neither git axis can testify; say that, not "content changed".
163
444
  // The stored contract hash needs no anchor, so it still decides the scenario axis when present.
164
445
  axes.push('anchor')
165
446
  if (byHash) axes.push('scenario')
166
447
  } else {
167
- if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f, probe))) axes.push('code')
448
+ if (scenarioCodeAxis(codeAxis).entries.some((e) => entryMoved(didx, reading.codeSha, e, probe, anchors))) axes.push('code')
168
449
  if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
169
450
  }
170
451
  if (remarkStale(reading, remarks)) axes.push('remark')
@@ -173,13 +454,14 @@ export function staleAxes(
173
454
 
174
455
  export function isStale(
175
456
  reading: Reading,
176
- codeFiles: string[],
457
+ codeAxis: string[],
177
458
  evalPath: string,
178
459
  didx: DriftIndex,
179
460
  scIdx: ScenarioIndex,
180
461
  remarks: RemarkSignal[] = [],
181
462
  probe?: ContentProbe,
182
463
  current?: Scenario,
464
+ anchors?: AnchorProbe,
183
465
  ): boolean {
184
- return staleAxes(reading, codeFiles, evalPath, didx, scIdx, remarks, probe, current).length > 0
466
+ return staleAxes(reading, codeAxis, evalPath, didx, scIdx, remarks, probe, current, anchors).length > 0
185
467
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { git, gitA, headSha } from '../../spec-cli/src/git.js'
2
+ import { git, gitA, gitTry, headSha } from '../../spec-cli/src/git.js'
3
3
  import { parseScenarios } from './scenarios.js'
4
4
 
5
5
  // @@@ per-scenario content freshness — the SCENARIO axis, sub-file
@@ -161,9 +161,33 @@ async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex>
161
161
  // whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
162
162
  const SLOTS = 16
163
163
  const cache = new Map<string, Promise<ScenarioIndex>>()
164
+ const roots = new Map<string, string>()
165
+
166
+ // A checkout moving from HEAD A to HEAD B no longer needs A's full scenario chains. Keep an old index only
167
+ // while another checkout still points at that immutable HEAD; otherwise a sequence of successful rebuilds
168
+ // retains one whole history-shaped index per commit until the broad LRU fills.
169
+ function touchRoot(root: string, head: string): void {
170
+ const previous = roots.get(root)
171
+ if (previous === head) {
172
+ roots.delete(root)
173
+ roots.set(root, head)
174
+ return
175
+ }
176
+ roots.set(root, head)
177
+ if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
178
+ while (roots.size > SLOTS) {
179
+ const oldest = roots.keys().next().value as string | undefined
180
+ if (oldest === undefined) break
181
+ const oldHead = roots.get(oldest)
182
+ roots.delete(oldest)
183
+ if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
184
+ }
185
+ }
186
+
164
187
  export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
165
188
  let head: string
166
189
  try { head = headSha(root) } catch { return build(root, evalPaths) }
190
+ touchRoot(root, head)
167
191
  const hit = cache.get(head)
168
192
  if (hit) { cache.delete(head); cache.set(head, hit); return hit }
169
193
  const p = build(root, evalPaths)
@@ -173,6 +197,10 @@ export function scenarioIndex(root: string, evalPaths: string[]): Promise<Scenar
173
197
  return p
174
198
  }
175
199
 
200
+ export function scenarioCacheStats(): { heads: number; roots: number } {
201
+ return { heads: cache.size, roots: roots.size }
202
+ }
203
+
176
204
  export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scenario: string): string[] {
177
205
  return idx.get(evalPath)?.get(scenario) ?? []
178
206
  }
@@ -198,6 +226,28 @@ function oidAt(root: string, rev: string, path: string): string {
198
226
  return v
199
227
  }
200
228
 
229
+ async function oidAtAsync(root: string, rev: string, path: string): Promise<string> {
230
+ if (!FULL_SHA.test(rev)) return (await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])).stdout.trim()
231
+ const k = `${root}\x1f${rev}\x1f${path}`
232
+ const hit = oidMemo.get(k)
233
+ if (hit !== undefined) { oidMemo.delete(k); oidMemo.set(k, hit); return hit }
234
+ const result = await gitTry(['-C', root, 'rev-parse', `${rev}:${path}`])
235
+ const oid = result.ok ? result.stdout.trim() : ''
236
+ oidMemo.set(k, oid)
237
+ if (oidMemo.size > 4096) oidMemo.delete(oidMemo.keys().next().value!)
238
+ return oid
239
+ }
240
+
241
+
242
+ export async function primeScenarioBlocksAt(root: string, revs: string[], path: string): Promise<void> {
243
+ for (const rev of revs) {
244
+ const oid = await oidAtAsync(root, rev, path)
245
+ if (!oid || blockByOid.has(oid)) continue
246
+ const src = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing
247
+ if (src) blockByOid.set(oid, blockContent(src))
248
+ }
249
+ }
250
+
201
251
  // canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
202
252
  // content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
203
253
  // sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index