spexcode 0.5.4 → 0.5.6

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 (32) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/hooks/dispatch.sh +10 -0
  3. package/spec-cli/src/cli.ts +6 -5
  4. package/spec-cli/src/contract-filter.ts +73 -29
  5. package/spec-cli/src/git.ts +80 -40
  6. package/spec-cli/src/harness.ts +127 -8
  7. package/spec-cli/src/index.ts +9 -3
  8. package/spec-cli/src/materialize.ts +118 -49
  9. package/spec-cli/src/reviewSnapshot.ts +4 -0
  10. package/spec-cli/src/reviews.ts +10 -5
  11. package/spec-cli/src/sessions.ts +113 -9
  12. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  13. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  14. package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-B72LuS5I.js} +2 -2
  15. package/spec-dashboard/dist/assets/Dashboard-C5X4Va3V.js +27 -0
  16. package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-BTvJIW8Q.js} +2 -2
  17. package/spec-dashboard/dist/assets/IssuesPage-Bn94h_HQ.js +1 -0
  18. package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-ClbtwZ1e.js} +2 -2
  19. package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-6l_QtCKF.js} +1 -1
  20. package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-B2kxcqJJ.js} +1 -1
  21. package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-C8IPsMKV.js} +1 -1
  22. package/spec-dashboard/dist/assets/{SessionInterface-DKU4c1Z-.js → SessionInterface-B5jf7dW7.js} +11 -11
  23. package/spec-dashboard/dist/assets/SessionWindow-Dag_GiJB.js +1 -0
  24. package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-J3aibcXo.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-Dg35J-Pu.js} +3 -3
  26. package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-f0UF9fXq.js} +1 -1
  27. package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-SNi0AmVT.js} +1 -1
  28. package/spec-dashboard/dist/assets/{index-BqBNCa1V.js → index-BUKLPN_4.js} +10 -10
  29. package/spec-dashboard/dist/index.html +1 -1
  30. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
  31. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
  32. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -43,6 +43,16 @@ proj="${CLAUDE_PROJECT_DIR:-$PWD}"
43
43
  rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
44
44
  slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
45
45
 
46
+ # A project transport can outlive the tree that installed it. The current tree's last successful materialize
47
+ # is the authority for whether its events are active. Before the v1 marker, an absent allowlist is the legacy
48
+ # shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
49
+ allowed="$slot/harnesses"
50
+ if [ -f "$allowed" ]; then
51
+ grep -Fxq "$harness" "$allowed" || exit 0
52
+ elif [ -f "$rt/harness-selection-v1" ]; then
53
+ exit 0
54
+ fi
55
+
46
56
  # --- dispatch ---------------------------------------------------------------------------------------------
47
57
  if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
48
58
  manifest="$SPEX_HOOK_MANIFEST"
@@ -1130,10 +1130,11 @@ if (cmd === 'serve') {
1130
1130
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
1131
1131
  console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
1132
1132
  } else if (sub === 'session-fail') {
1133
- // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
1134
- const { s, sess, mark, noRecord } = await stateKit()
1135
- const failed = mark(() => s.markError(sess))
1136
- console.log(failed.ok ? 'marked error' : failed.reason ?? noRecord())
1133
+ // StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
1134
+ // stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
1135
+ const { s, sess, mark } = await stateKit()
1136
+ const failed = mark(() => s.markTurnFailure(sess, 'claude turn failed'))
1137
+ console.log(failed.ok ? 'marked error' : failed.reason ?? 'noop (session is not live active)')
1137
1138
  } else if (sub === 'session-turn-fail') {
1138
1139
  // Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
1139
1140
  // that landed before teardown wins, so a late child close can never erase an agent-authored state.
@@ -1143,7 +1144,7 @@ if (cmd === 'serve') {
1143
1144
  process.exit(2)
1144
1145
  }
1145
1146
  const { markHeadlessTurnFailure } = await import('./sessions.js')
1146
- console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (no active session record)')
1147
+ console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (session is not live active)')
1147
1148
  } else if (sub === 'session-idle') {
1148
1149
  // the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
1149
1150
  // at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
@@ -1,8 +1,9 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
2
2
  import { join, relative } from 'node:path'
3
3
  import { execFileSync } from 'node:child_process'
4
4
  import { git } from './git.js'
5
5
  import { writeManagedBlock, removeManagedBlock } from './harness.js'
6
+ import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
6
7
 
7
8
  // @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
8
9
  // CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
@@ -34,30 +35,54 @@ function commonDirOf(proj: string): string {
34
35
  const filterDir = (common: string) => join(common, 'spexcode')
35
36
  const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
36
37
  const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
38
+ const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
39
+ const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
40
+ const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
37
41
  const attributesPath = (common: string) => join(common, 'info', 'attributes')
38
42
 
39
- // the per-clone shim both filter directions run through. Pure shell/awk (no node boot on git's hot path),
40
- // mirroring writeManagedBlock/removeManagedBlock's normalization exactly so the two writers agree:
43
+ const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
44
+ .filter((row) => row.startsWith('worktree ')).map((row) =>
45
+ join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
46
+ const legacyFilterTree = (proj: string) => registeredSlots(proj)
47
+ .some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
48
+
49
+ export type ContractFilterPayload = { file: string; content: string }
50
+ export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
51
+
52
+ // The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
53
+ // resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
41
54
  // clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
42
- // smudge: clean first (defensive — a block already in the index can never double-inject), then append one
43
- // blank line + START + the contract-block file's content + END.
55
+ // smudge: clean first (a block already in the index can never double-inject), then append the tree payload.
44
56
  // Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
45
57
  // file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
46
58
  const SHIM = `#!/usr/bin/env bash
47
- # spexcode contract filter (generated by spex materialize; see [[content-filter]]).
48
- # clean(smudge(x)) == x: the repo keeps the pristine host prose, the working tree carries prose + block.
59
+ # spexcode managed-text filter (generated by spex materialize; see [[content-filter]]).
60
+ # One common driver selects the payload belonging to the Git checkout that invoked it.
49
61
  set -u
50
- mode="\${1:?usage: contract-filter.sh smudge|clean}"
62
+ mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
63
+ path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
51
64
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
52
- block="$here/contract-block.md"
65
+ binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
66
+ [ -n "$binding" ] || { cat; exit 0; }
67
+ start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
68
+ end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
69
+ top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
70
+ root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
71
+ key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
72
+ manifest="$root/trees/$key/contract-filter/manifest"
73
+ payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
74
+ # A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
75
+ # per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
76
+ marker="$root/trees/$key/contract-filter-v2"
77
+ if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
53
78
  strip() {
54
- awk 'BEGIN { n = 0 }
79
+ awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
55
80
  { lines[n++] = $0 }
56
81
  END {
57
82
  s = -1; e = -1
58
83
  for (i = 0; i < n; i++) {
59
- if (lines[i] == "<!-- spexcode:start -->" && s < 0) s = i
60
- if (lines[i] == "<!-- spexcode:end -->" && s >= 0 && e < 0) e = i
84
+ if (lines[i] == sline && s < 0) s = i
85
+ if (lines[i] == eline && s >= 0 && e < 0) e = i
61
86
  }
62
87
  if (s >= 0 && e >= s) {
63
88
  a = s; while (a > 0 && lines[a-1] == "") a--
@@ -66,24 +91,22 @@ strip() {
66
91
  for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
67
92
  n = j
68
93
  }
69
- # NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
70
- # blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
71
94
  for (i = 0; i < n; i++) print lines[i]
72
95
  }'
73
96
  }
74
97
  case "$mode" in
75
98
  clean) strip ;;
76
99
  smudge)
77
- if [ ! -r "$block" ]; then cat; exit 0; fi # no block content → identity (graceful, never fatal)
78
- strip | awk -v b="$block" 'BEGIN { n = 0 }
100
+ if [ ! -r "$payload" ]; then strip; exit 0; fi
101
+ strip | awk -v b="$payload" -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
79
102
  { lines[n++] = $0 }
80
103
  END {
81
- while (n > 0 && lines[n-1] == "") n-- # trim trailing blanks (writeManagedBlock parity)
104
+ while (n > 0 && lines[n-1] == "") n--
82
105
  for (i = 0; i < n; i++) print lines[i]
83
106
  if (n > 0) print ""
84
- print "<!-- spexcode:start -->"
107
+ print sline
85
108
  while ((getline l < b) > 0) print l
86
- print "<!-- spexcode:end -->"
109
+ print eline
87
110
  }' ;;
88
111
  *) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
89
112
  esac
@@ -92,25 +115,37 @@ esac
92
115
  // edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
93
116
  // unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
94
117
  const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
95
- `sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
118
+ `sh -c 'test -r "$0" && exec bash "$0" ${mode} "$1" || exec cat' '${shim.replace(/'/g, `'\\''`)}' %f`
96
119
 
97
120
  // plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
98
121
  // pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
99
122
  // binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
100
123
  // block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
101
- export function plantContractFilter(proj: string, trackedFiles: string[], contract: string): void {
124
+ export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
102
125
  const common = commonDirOf(proj)
103
126
  mkdirSync(filterDir(common), { recursive: true })
104
- writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the materialize
105
127
  writeFileSync(shimPath(common), SHIM)
106
128
  chmodSync(shimPath(common), 0o755)
107
129
  git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
108
130
  git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
109
- // attribute patterns are checkout-relative, so one line serves the main checkout and every worktree.
110
- const entries = trackedFiles.map((f) => `/${relative(proj, f)} filter=spexcode`).sort().join('\n')
131
+ writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
132
+ const dir = treeFilterDir(proj)
133
+ rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
134
+ const manifest: string[] = []
135
+ for (const [i, payload] of payloads.entries()) {
136
+ const target = join(dir, String(i))
137
+ writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
138
+ manifest.push(`${payload.file}\t${target}`)
139
+ }
140
+ const manifestPath = join(dir, 'manifest')
141
+ writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
142
+ writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
143
+ // Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
144
+ // the driver selects a payload from the invoking checkout's tree slot.
145
+ const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
111
146
  mkdirSync(join(common, 'info'), { recursive: true })
112
147
  writeManagedBlock(attributesPath(common), entries, ['# ', ''])
113
- settleIndexStat(proj, trackedFiles)
148
+ settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
114
149
  }
115
150
 
116
151
  // settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
@@ -139,15 +174,24 @@ export function settleIndexStat(proj: string, files: string[]): void {
139
174
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
140
175
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
141
176
  // (evidence blobs), so only OUR two files go, never the dir.
142
- export function removeContractFilter(proj: string): void {
177
+ export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
143
178
  let common: string
144
179
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
180
+ try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
181
+ settleIndexStat(proj, files)
182
+ const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
183
+ const legacyTree = legacyFilterTree(proj)
184
+ if (!legacyTree) rmSync(blockPath(common), { force: true })
185
+ if (!final && (anotherPayload || legacyTree)) return
145
186
  removeManagedBlock(attributesPath(common), ['# ', ''], true)
146
187
  for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
147
- try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
188
+ try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
148
189
  }
149
- rmSync(shimPath(common), { force: true })
150
- rmSync(blockPath(common), { force: true })
190
+ for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
191
+ }
192
+
193
+ export function retireLegacyContractBlock(proj: string): void {
194
+ if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
151
195
  }
152
196
 
153
197
  // is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
@@ -360,9 +360,36 @@ type EventStreamRequest = {
360
360
  }
361
361
 
362
362
  type EventPathMemo = EventCacheLocation & {
363
- common: string; shallowPath: string; grafts: string; shallow: string; replacements: string
363
+ common: string; shallowPath: string; grafts: string; shallow: string
364
+ replacementStorage: string; replacements: string
364
365
  }
365
366
  const eventPathMemo = new Map<string, EventPathMemo>()
367
+ function replacementStorageIdentity(common: string): string {
368
+ const hash = createHash('sha256')
369
+ const addTree = (root: string, rel: string) => {
370
+ if (!existsSync(root)) return
371
+ const stack = [{ dir: root, rel }]
372
+ while (stack.length) {
373
+ const current = stack.pop()!
374
+ const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
375
+ for (const entry of entries) {
376
+ const path = join(current.dir, entry.name)
377
+ const name = `${current.rel}/${entry.name}`
378
+ if (entry.isDirectory()) stack.push({ dir: path, rel: name })
379
+ else hash.update(`\0${name}\0`).update(readFileSync(path))
380
+ }
381
+ }
382
+ }
383
+ addTree(join(common, 'refs', 'replace'), 'refs/replace')
384
+ for (const name of ['packed-refs']) {
385
+ const path = join(common, name)
386
+ if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
387
+ }
388
+ // Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
389
+ // parser and supplies the canonical refs/replace targets when those bytes change.
390
+ addTree(join(common, 'reftable'), 'reftable')
391
+ return hash.digest('hex')
392
+ }
366
393
  function eventCacheLocation(root: string): EventCacheLocation {
367
394
  const rootId = rootKey(root), old = eventPathMemo.get(rootId)
368
395
  const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
@@ -370,7 +397,10 @@ function eventCacheLocation(root: string): EventCacheLocation {
370
397
  const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
371
398
  const graftsPath = join(common, 'info', 'grafts')
372
399
  const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
373
- const replacements = git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
400
+ const replacementStorage = replacementStorageIdentity(common)
401
+ const replacements = old?.replacementStorage === replacementStorage
402
+ ? old.replacements
403
+ : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
374
404
  const objectFormat = gitObjectFormat(root)
375
405
  if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
376
406
  return { path: old.path, identity: old.identity, objectFormat }
@@ -382,7 +412,7 @@ function eventCacheLocation(root: string): EventCacheLocation {
382
412
  const gitDir = gitDirOf(root)
383
413
  const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
384
414
  const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
385
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacements, path, identity, objectFormat })
415
+ eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
386
416
  return { path, identity, objectFormat }
387
417
  }
388
418
  function emptyEventCache(): EventCache {
@@ -1002,38 +1032,45 @@ function canonicalPathProjector(
1002
1032
  }
1003
1033
 
1004
1034
  type SharedIndexInputs = {
1005
- allPathsOut: string
1006
- topologyOut: string
1035
+ allPaths: Set<string>
1036
+ specPaths: Set<string>
1037
+ topology: TopologyProjection
1007
1038
  historyOut: string
1008
1039
  driftOut: string
1009
1040
  mergeIndex: MergeHistoryEvents
1010
1041
  }
1011
1042
 
1043
+ type TopologyProjection = {
1044
+ order: Map<string, number>
1045
+ parents: Map<string, string[]>
1046
+ reachable: Set<string>
1047
+ }
1048
+
1012
1049
  async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
1013
1050
  const versions = new Map<string, Version[]>()
1014
1051
  const stats = new Map<string, Map<string, DiffStat>>()
1015
1052
  const mergeVersions = new Set<string>()
1016
1053
  const commitVersions = new Map<string, Version>()
1017
1054
  const commitOrder = new Map<string, number>()
1018
- const [tipPathsOut, topologyOut] = shared
1019
- ? [shared.allPathsOut, shared.topologyOut]
1020
- : await Promise.all([
1055
+ const rawVersions = new Map<string, Version[]>()
1056
+ const rawStats = new Map<string, Map<string, DiffStat>>()
1057
+ let currentPaths: Set<string>
1058
+ let topology: TopologyProjection
1059
+ if (shared) {
1060
+ currentPaths = shared.specPaths
1061
+ topology = shared.topology
1062
+ } else {
1063
+ const [tipPathsOut, topologyOut] = await Promise.all([
1021
1064
  strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
1022
1065
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1023
1066
  ])
1024
- const rawVersions = new Map<string, Version[]>()
1025
- const rawStats = new Map<string, Map<string, DiffStat>>()
1026
- const currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1027
- const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
1028
- const topologyOrd = new Map<string, number>(), topologyParents = new Map<string, string[]>()
1029
- let topologyPosition = 0
1030
- for (const line of topologyOut.trim().split('\n')) {
1031
- if (!line) continue
1032
- const [hash, ...parents] = line.split(' ')
1033
- topologyOrd.set(hash, topologyPosition++)
1034
- topologyParents.set(hash, parents)
1067
+ currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1068
+ topology = topologyProjection(topologyOut)
1035
1069
  }
1036
- const topologyReachable = new Set(topologyOrd.keys())
1070
+ const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
1071
+ const topologyOrd = topology.order
1072
+ const topologyParents = topology.parents
1073
+ const topologyReachable = topology.reachable
1037
1074
  const out = shared?.historyOut ?? await eventStream(root, tip,
1038
1075
  indexEventRequests(root, tip, topologyOrd, topologyReachable).numstat, !transient, useCache)
1039
1076
  if (!out) return { versions, stats, mergeVersions }
@@ -1363,23 +1400,22 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1363
1400
  const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
1364
1401
  const ackCandidates = new Map<string, Set<string>>(), trees = new Map<string, string>()
1365
1402
  const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
1366
- const [tipPathsOut, topology] = shared
1367
- ? [shared.allPathsOut, shared.topologyOut]
1368
- : await Promise.all([
1369
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
1403
+ let currentPaths: Set<string>
1404
+ let topology: TopologyProjection
1405
+ if (shared) {
1406
+ currentPaths = shared.allPaths
1407
+ topology = shared.topology
1408
+ } else {
1409
+ const [tipPathsOut, topologyOut] = await Promise.all([
1410
+ strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
1370
1411
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1371
1412
  ])
1372
- const currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1373
- const topologyOrder = new Map<string, number>(), topologyParents = new Map<string, string[]>(), topologyReachable = new Set<string>()
1374
- let topologyPosition = 0
1375
- for (const line of topology.trim().split('\n')) {
1376
- const [hash, ...parentList] = line.split(' ')
1377
- if (hash) {
1378
- topologyOrder.set(hash, topologyPosition++)
1379
- topologyParents.set(hash, parentList)
1380
- topologyReachable.add(hash)
1381
- }
1413
+ currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1414
+ topology = topologyProjection(topologyOut)
1382
1415
  }
1416
+ const topologyOrder = topology.order
1417
+ const topologyParents = topology.parents
1418
+ const topologyReachable = topology.reachable
1383
1419
  // Numstat is the immutable identity event: unlike a name-only stream it records both sides of a rename,
1384
1420
  // allowing the same event-scoped projection used by spec history to follow forks and reject path reuse.
1385
1421
  const out = shared?.driftOut ?? await eventStream(root, tip,
@@ -1507,16 +1543,17 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1507
1543
  return p
1508
1544
  }
1509
1545
 
1510
- function topologyProjection(out: string): { order: Map<string, number>; reachable: Set<string> } {
1511
- const order = new Map<string, number>(), reachable = new Set<string>()
1546
+ function topologyProjection(out: string): TopologyProjection {
1547
+ const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
1512
1548
  let position = 0
1513
1549
  for (const line of out.trim().split('\n')) {
1514
- const hash = line.split(' ', 1)[0]
1550
+ const [hash, ...parentList] = line.split(' ')
1515
1551
  if (!hash) continue
1516
1552
  order.set(hash, position++)
1553
+ parents.set(hash, parentList)
1517
1554
  reachable.add(hash)
1518
1555
  }
1519
- return { order, reachable }
1556
+ return { order, parents, reachable }
1520
1557
  }
1521
1558
 
1522
1559
  async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
@@ -1525,11 +1562,14 @@ async function buildIndexPair(root: string, tip: string, transient: boolean, use
1525
1562
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1526
1563
  ])
1527
1564
  const topology = topologyProjection(topologyOut)
1565
+ const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
1566
+ const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
1528
1567
  const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
1529
1568
  const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
1530
1569
  const shared: SharedIndexInputs = {
1531
- allPathsOut,
1532
- topologyOut,
1570
+ allPaths,
1571
+ specPaths,
1572
+ topology,
1533
1573
  historyOut: streams.get('numstat') ?? '',
1534
1574
  driftOut: streams.get('drift-numstat') ?? '',
1535
1575
  mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),