spexcode 0.6.1 → 0.6.2

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 (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -5,6 +5,7 @@ import { join, isAbsolute, resolve, delimiter } from 'node:path'
5
5
  import { createHash, randomBytes } from 'node:crypto'
6
6
  import { projectRuntimeRoot } from './project-store.js'
7
7
  import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
8
+ import { processStartToken } from './process-identity.js'
8
9
 
9
10
  const US = '\x1f', RS = '\x1e'
10
11
 
@@ -14,6 +15,7 @@ const US = '\x1f', RS = '\x1e'
14
15
  // permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
15
16
  // than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
16
17
  const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
18
+ const GIT_SYNC_MAX_BUFFER = 1 << 27
17
19
  export const BOARD_GIT_CONCURRENCY = 4
18
20
  const gitByPath = new Map<string, string>()
19
21
 
@@ -125,7 +127,8 @@ export function currentGitBuildAbortSignal(): AbortSignal | undefined {
125
127
  return inheritedContext()?.signal
126
128
  }
127
129
  function warnIfTimedOut(e: any, args: string[]): void {
128
- if (e?.signal === 'SIGKILL') console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
130
+ if (e?.code === 'ETIMEDOUT' || e?.spexcodeGitTimeout === true)
131
+ console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
129
132
  }
130
133
 
131
134
  // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
@@ -133,7 +136,14 @@ export function git(args: string[]): string {
133
136
  const env = { ...process.env }
134
137
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
135
138
  try {
136
- return execFileSync(gitBinary(env), withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
139
+ return execFileSync(gitBinary(env), withBuildLimits(args), {
140
+ encoding: 'utf8',
141
+ env,
142
+ stdio: ['ignore', 'pipe', 'pipe'],
143
+ timeout: GIT_TIMEOUT_MS,
144
+ killSignal: 'SIGKILL',
145
+ maxBuffer: GIT_SYNC_MAX_BUFFER,
146
+ })
137
147
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
138
148
  }
139
149
 
@@ -147,7 +157,7 @@ function gitBuffer(args: string[], input?: string): Buffer {
147
157
  stdio: ['pipe', 'pipe', 'pipe'],
148
158
  timeout: GIT_TIMEOUT_MS,
149
159
  killSignal: 'SIGKILL',
150
- maxBuffer: 1 << 27,
160
+ maxBuffer: GIT_SYNC_MAX_BUFFER,
151
161
  })
152
162
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
153
163
  }
@@ -184,9 +194,10 @@ export function isGitObjectId(root: string, value: string): boolean {
184
194
  // source corpus never approaches the byte ceiling.
185
195
  const BATCH_BLOB_CHUNK = 256
186
196
  const BATCH_BLOB_MAX_BUFFER = 1 << 26
187
- async function batchBuffer(args: string[], input: string, maxBuffer?: number): Promise<Buffer> {
197
+ async function batchBuffer(args: string[], input: string, maxBuffer?: number, extraEnv: Record<string, string> = {}): Promise<Buffer> {
188
198
  const env = { ...process.env }
189
199
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
200
+ Object.assign(env, extraEnv)
190
201
  try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
191
202
  catch (error: any) {
192
203
  if (error?.name === 'AbortError') throw error
@@ -194,9 +205,10 @@ async function batchBuffer(args: string[], input: string, maxBuffer?: number): P
194
205
  throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
195
206
  }
196
207
  }
197
- export async function batchRevisionOids(root: string, revisions: string[]): Promise<(string | null)[]> {
208
+ export async function batchRevisionOids(root: string, revisions: string[], options: { replaceObjects?: boolean } = {}): Promise<(string | null)[]> {
198
209
  if (!revisions.length) return []
199
- const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n')).toString('utf8')
210
+ const extraEnv: Record<string, string> = options.replaceObjects === false ? { GIT_NO_REPLACE_OBJECTS: '1' } : {}
211
+ const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n', undefined, extraEnv)).toString('utf8')
200
212
  const lines = out.split('\n')
201
213
  if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
202
214
  return revisions.map((revision, index) => {
@@ -353,12 +365,13 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
353
365
  // Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
354
366
  // them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
355
367
  // cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
356
- function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
368
+ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, input?: string): Promise<GitExec> {
357
369
  return new Promise((resolve, reject) => {
358
370
  if (signal?.aborted) { reject(gitAbortError()); return }
359
- const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
371
+ const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
360
372
  const stdout: Buffer[] = [], stderr: Buffer[] = []
361
373
  let settled = false, aborted = false, timedOut = false
374
+ let stdinError: any = null
362
375
  const killTree = () => {
363
376
  if (!child.pid) return
364
377
  try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
@@ -368,8 +381,14 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
368
381
  const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
369
382
  timer.unref?.()
370
383
  signal?.addEventListener('abort', onAbort, { once: true })
371
- child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
372
- child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
384
+ child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
385
+ child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
386
+ if (input !== undefined) {
387
+ // A command can reject its input before the pipe drains. Keep that write failure on the same close
388
+ // path as exit and abort failures instead of letting Node raise an unhandled EPIPE.
389
+ child.stdin!.once('error', (error) => { stdinError ??= error })
390
+ child.stdin!.end(input)
391
+ }
373
392
  child.on('error', (error: any) => {
374
393
  if (settled) return
375
394
  settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
@@ -381,9 +400,9 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
381
400
  if (settled) return
382
401
  settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
383
402
  const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
384
- if (code === 0 && !aborted && !timedOut) { resolve(result); return }
385
- const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
386
- error.code = code
403
+ if (code === 0 && !aborted && !timedOut && !stdinError) { resolve(result); return }
404
+ const error: any = stdinError ?? new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
405
+ error.code = stdinError ? error.code : code
387
406
  error.signal = childSignal
388
407
  error.stdout = result.stdout.toString('utf8')
389
408
  error.stderr = result.stderr
@@ -393,11 +412,11 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
393
412
  })
394
413
  })
395
414
  }
396
- async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
415
+ async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv, input?: string): Promise<GitExec> {
397
416
  const context = inheritedContext()
398
- if (!context) return execGitStream(args, env)
417
+ if (!context) return execGitStream(args, env, undefined, input)
399
418
  const release = await context.permits.acquire(context.signal)
400
- try { return await execGitStream(withBuildLimits(args), env, context.signal) }
419
+ try { return await execGitStream(withBuildLimits(args), env, context.signal, input) }
401
420
  finally { release() }
402
421
  }
403
422
 
@@ -414,19 +433,47 @@ export async function gitA(args: string[], input?: string): Promise<string> {
414
433
  }
415
434
  }
416
435
 
436
+ async function gitARequired(args: string[], input?: string): Promise<string> {
437
+ const env = { ...process.env }
438
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
439
+ try { return (await execGitForCaller(args, env, undefined, input)).stdout.toString('utf8') }
440
+ catch (error: any) {
441
+ if (error?.name === 'AbortError') throw error
442
+ warnIfTimedOut(error, args)
443
+ throw new Error(`git ${args.slice(2, 6).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
444
+ }
445
+ }
446
+
417
447
  type TextEventRecord = { hash: string; raw: string }
418
448
  type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
419
449
  type EventRecord = TextEventRecord | IdentityEventRecord
420
- type EventCache = { streams: Map<EventStreamKind, Map<string, EventRecord>>; streamTips: Map<EventStreamKind, string[]> }
450
+ export type ImmutableHunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
451
+ type EventCache = {
452
+ streams: Map<EventStreamKind, Map<string, EventRecord>>
453
+ streamTips: Map<EventStreamKind, string[]>
454
+ hunks: Map<string, ImmutableHunkRanges>
455
+ }
421
456
  type EventStreamOutput = string | IdentityRawRecord[]
422
- const EVENT_CACHE_SCHEMA = 'history-events-v15'
457
+ // The schema names both the ledger grammar and its on-disk namespace. A reader that predates a row type
458
+ // must never share a ledger with its writer: it seeds the next namespace from Git instead.
459
+ const EVENT_CACHE_SCHEMA = 'history-events-v16'
460
+ const IMMUTABLE_HUNK_FACT = 'immutable-hunk-v1'
423
461
  const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
424
462
  type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
425
- type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
463
+ type EventCacheLocation = { path: string; identity: string; interpretation: string; objectFormat: GitObjectFormat }
426
464
  type EventLedgerSnapshot = {
427
465
  payload: Buffer
428
466
  state: EventCache
429
467
  }
468
+ type EventLedgerBuild = {
469
+ location: EventCacheLocation
470
+ snapshot: EventLedgerSnapshot
471
+ additions: string[]
472
+ }
473
+ type EventLedgerDiagnostics = { reads: number; locks: number; replaces: number }
474
+ const eventLedgerBuild = new AsyncLocalStorage<EventLedgerBuild>()
475
+ const eventLedgerDemandPolicy = new AsyncLocalStorage<{ path: string }>()
476
+ const eventLedgerDiagnostics: EventLedgerDiagnostics = { reads: 0, locks: 0, replaces: 0 }
430
477
  type EventStreamRequest = {
431
478
  kind: EventStreamKind
432
479
  argsFor: (base: string) => string[]
@@ -478,20 +525,41 @@ function eventCacheLocation(root: string): EventCacheLocation {
478
525
  : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
479
526
  const objectFormat = gitObjectFormat(root)
480
527
  if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
481
- return { path: old.path, identity: old.identity, objectFormat }
482
- const identity = createHash('sha256')
528
+ return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat }
529
+ const interpretation = createHash('sha256')
483
530
  .update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
484
- .digest('hex').slice(0, 16)
531
+ .digest('hex')
532
+ const identity = interpretation.slice(0, 16)
485
533
  // projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
486
534
  // dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
487
535
  const gitDir = gitDirOf(root)
488
536
  const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
489
537
  const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
490
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
491
- return { path, identity, objectFormat }
492
- }
538
+ eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, interpretation, objectFormat })
539
+ return { path, identity, interpretation, objectFormat }
540
+ }
541
+
542
+ // One existing source-of-truth identity governs every Git reader that interprets commit images. Consumers
543
+ // may retain the full digest in memory while the ledger keeps its established short directory name.
544
+ export function gitObjectInterpretation(root: string): {
545
+ identity: string
546
+ objectFormat: GitObjectFormat
547
+ replacements: ReadonlyMap<string, string>
548
+ } {
549
+ const location = eventCacheLocation(root)
550
+ const raw = eventPathMemo.get(rootKey(root))?.replacements ?? ''
551
+ const replacements = new Map<string, string>()
552
+ for (const line of raw.split('\n').filter(Boolean)) {
553
+ const match = line.match(/^refs\/replace\/([0-9a-f]+) ([0-9a-f]+)$/)
554
+ if (!match || !isGitObjectIdForFormat(location.objectFormat, match[1]) || !isGitObjectIdForFormat(location.objectFormat, match[2]))
555
+ throw new Error(`malformed refs/replace projection '${line}' at ${root}`)
556
+ replacements.set(match[1], match[2])
557
+ }
558
+ return { identity: location.interpretation, objectFormat: location.objectFormat, replacements }
559
+ }
560
+ export function gitInterpretationIdentity(root: string): string { return eventCacheLocation(root).identity }
493
561
  function emptyEventCache(): EventCache {
494
- return { streams: new Map(), streamTips: new Map() }
562
+ return { streams: new Map(), streamTips: new Map(), hunks: new Map() }
495
563
  }
496
564
  function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
497
565
  const actual = Object.keys(value).sort()
@@ -516,6 +584,28 @@ function eventStreamKind(value: unknown): EventStreamKind | null {
516
584
  ? value as EventStreamKind
517
585
  : null
518
586
  }
587
+ function decodeHunkRanges(value: unknown): DiffLineRange[] | null {
588
+ if (!Array.isArray(value)) return null
589
+ const ranges: DiffLineRange[] = []
590
+ for (const row of value) {
591
+ if (!Array.isArray(row) || row.length !== 2 || !Number.isSafeInteger(row[0]) || !Number.isSafeInteger(row[1])
592
+ || row[0] <= 0 || row[1] < row[0]) return null
593
+ ranges.push([row[0], row[1]])
594
+ }
595
+ return ranges
596
+ }
597
+ function decodeImmutableHunkFact(row: Record<string, unknown>): { key: string; ranges: ImmutableHunkRanges } | null {
598
+ if (!exactKeys(row, ['a', 'b', 'i', 'k']) || row.k !== IMMUTABLE_HUNK_FACT || typeof row.i !== 'string' || !row.i) return null
599
+ const after = decodeHunkRanges(row.a)
600
+ if (!after || !Array.isArray(row.b)) return null
601
+ const before: DiffLineRange[][] = []
602
+ for (const parent of row.b) {
603
+ const ranges = decodeHunkRanges(parent)
604
+ if (!ranges) return null
605
+ before.push(ranges)
606
+ }
607
+ return { key: row.i, ranges: { after, before } }
608
+ }
519
609
  function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
520
610
  const state = emptyEventCache()
521
611
  const text = payload.toString('utf8')
@@ -529,6 +619,12 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
529
619
  try { value = JSON.parse(line) } catch { return null }
530
620
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null
531
621
  const row = value as Record<string, unknown>
622
+ if (row.k === IMMUTABLE_HUNK_FACT) {
623
+ const fact = decodeImmutableHunkFact(row)
624
+ if (!fact || state.hunks.has(fact.key)) return null
625
+ state.hunks.set(fact.key, fact.ranges)
626
+ continue
627
+ }
532
628
  if (exactKeys(row, ['k', 'tip'])) {
533
629
  const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
534
630
  if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
@@ -563,6 +659,7 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
563
659
  return state
564
660
  }
565
661
  function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
662
+ eventLedgerDiagnostics.reads++
566
663
  let file: Buffer
567
664
  try { file = readFileSync(location.path) }
568
665
  catch (error: any) {
@@ -590,18 +687,29 @@ function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
590
687
  }
591
688
  }
592
689
 
593
- type EventLockOwner = { pid: number; token: string }
690
+ type EventLockOwner = { pid: number; startToken: string; nonce: string }
594
691
  function readEventLockOwner(lock: string): EventLockOwner | null {
595
692
  try {
596
- const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; token?: unknown }
597
- return Number.isInteger(value.pid) && (value.pid as number) > 0 && typeof value.token === 'string'
598
- ? { pid: value.pid as number, token: value.token }
693
+ const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; startToken?: unknown; nonce?: unknown }
694
+ return Number.isInteger(value.pid) && (value.pid as number) > 0
695
+ && typeof value.startToken === 'string' && !!value.startToken
696
+ && typeof value.nonce === 'string' && !!value.nonce
697
+ ? { pid: value.pid as number, startToken: value.startToken, nonce: value.nonce }
599
698
  : null
600
699
  } catch { return null }
601
700
  }
602
- function processAlive(pid: number): boolean {
603
- try { process.kill(pid, 0); return true }
604
- catch (error: any) { return error?.code !== 'ESRCH' }
701
+ const sameEventLockOwner = (left: EventLockOwner | null, right: EventLockOwner | null): boolean =>
702
+ !!left && !!right && left.pid === right.pid && left.startToken === right.startToken && left.nonce === right.nonce
703
+
704
+ function eventLockOwnerState(owner: EventLockOwner): 'live' | 'dead' | 'unknown' {
705
+ try { process.kill(owner.pid, 0) }
706
+ catch (error: any) {
707
+ if (error?.code === 'ESRCH') return 'dead'
708
+ if (error?.code !== 'EPERM') return 'unknown'
709
+ }
710
+ const observed = processStartToken(owner.pid)
711
+ if (!observed) return 'unknown'
712
+ return observed === owner.startToken ? 'live' : 'dead'
605
713
  }
606
714
  function retireLockPath(active: string): boolean {
607
715
  const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
@@ -614,14 +722,17 @@ function retireLockPath(active: string): boolean {
614
722
  return true
615
723
  }
616
724
  function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
617
- if (processAlive(held.pid)) return false
725
+ if (eventLockOwnerState(held) !== 'dead') return false
618
726
  const reclaim = join(lock, 'reclaim')
619
727
  if (existsSync(reclaim)) {
620
728
  const reclaimer = readEventLockOwner(reclaim)
621
- if (!reclaimer || processAlive(reclaimer.pid)) return false
729
+ if (!reclaimer) throw new Error(`history event cache lock has an unprovable reclaimer: ${lock}`)
730
+ const state = eventLockOwnerState(reclaimer)
731
+ if (state === 'unknown') throw new Error(`history event cache lock reclaimer identity is unreadable: ${lock}`)
732
+ if (state === 'live') return false
622
733
  retireLockPath(reclaim)
623
734
  }
624
- const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.token}`)
735
+ const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`)
625
736
  try {
626
737
  mkdirSync(prepared)
627
738
  writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
@@ -632,21 +743,29 @@ function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: Even
632
743
  throw error
633
744
  }
634
745
  const current = readEventLockOwner(lock)
635
- if (current?.pid !== held.pid || current.token !== held.token || processAlive(current.pid)) {
746
+ if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
636
747
  retireLockPath(reclaim)
637
748
  return false
638
749
  }
639
750
  return retireLockPath(lock)
640
751
  }
641
- async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): Promise<T> {
752
+ const EVENT_LEDGER_BUSY = Symbol('event ledger held by a live writer')
753
+
754
+ async function withEventCacheLock<T>(
755
+ path: string,
756
+ run: () => Promise<T> | T,
757
+ waitForWriter = true,
758
+ ): Promise<T | typeof EVENT_LEDGER_BUSY> {
642
759
  const lock = `${path}.lock`
643
760
  mkdirSync(join(path, '..'), { recursive: true })
644
- const owner: EventLockOwner = { pid: process.pid, token: randomBytes(16).toString('hex') }
761
+ const startToken = processStartToken(process.pid)
762
+ if (!startToken) throw new Error(`cannot prove history event cache lock claimant identity: ${path}`)
763
+ const owner: EventLockOwner = { pid: process.pid, startToken, nonce: randomBytes(16).toString('hex') }
645
764
  const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
646
765
  const signal = inheritedContext()?.signal
647
766
  for (let attempt = 0; ; attempt++) {
648
767
  if (signal?.aborted) throw gitAbortError()
649
- const claimant = `${lock}.claim.${owner.pid}.${owner.token}`
768
+ const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`
650
769
  try {
651
770
  mkdirSync(claimant)
652
771
  writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
@@ -656,7 +775,30 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
656
775
  rmSync(claimant, { recursive: true, force: true })
657
776
  if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
658
777
  const held = readEventLockOwner(lock)
659
- if (held && reclaimDeadEventLock(lock, held, owner)) continue
778
+ if (!held) {
779
+ if (!existsSync(lock)) continue
780
+ throw new Error(`history event cache lock has no provable exact owner: ${path}`)
781
+ }
782
+ const heldState = eventLockOwnerState(held)
783
+ if (heldState === 'unknown')
784
+ throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
785
+ if (heldState === 'dead') {
786
+ if (reclaimDeadEventLock(lock, held, owner)) continue
787
+ if (!existsSync(lock)) continue
788
+ }
789
+ if (!waitForWriter) {
790
+ const current = readEventLockOwner(lock)
791
+ if (!current) {
792
+ if (!existsSync(lock)) continue
793
+ throw new Error(`history event cache lock has no provable exact owner: ${path}`)
794
+ }
795
+ const state = eventLockOwnerState(current)
796
+ if (state === 'live') return EVENT_LEDGER_BUSY
797
+ // A live reclaimer may be arbitrating this exact dead owner. Foreground demand can safely derive
798
+ // from the atomic snapshot, but must not spin synchronously until that separate lease finishes.
799
+ if (state === 'dead') return EVENT_LEDGER_BUSY
800
+ throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
801
+ }
660
802
  if (attempt >= attempts) {
661
803
  const heldBy = readEventLockOwner(lock)?.pid
662
804
  throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
@@ -664,8 +806,9 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
664
806
  await new Promise((resolve) => setTimeout(resolve, 5))
665
807
  }
666
808
  }
809
+ eventLedgerDiagnostics.locks++
667
810
  try { return await run() } finally {
668
- if (readEventLockOwner(lock)?.token === owner.token) retireLockPath(lock)
811
+ if (sameEventLockOwner(readEventLockOwner(lock), owner)) retireLockPath(lock)
669
812
  }
670
813
  }
671
814
  function removeEventTemps(path: string): void {
@@ -686,12 +829,78 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
686
829
  writeFileSync(fd, eventIntegrityFooter(payload, addition))
687
830
  const written = fd; fd = null; closeSync(written)
688
831
  renameSync(tmp, path)
832
+ eventLedgerDiagnostics.replaces++
689
833
  } catch (error) {
690
834
  if (fd !== null) closeSync(fd)
691
835
  rmSync(tmp, { force: true })
692
836
  throw error
693
837
  }
694
838
  }
839
+ const EVENT_LEDGER_RETRY = Symbol('event ledger identity moved')
840
+
841
+ function activeEventLedger(root: string): EventLedgerBuild | null {
842
+ const build = eventLedgerBuild.getStore()
843
+ if (!build) return null
844
+ return eventCacheLocation(root).path === build.location.path ? build : null
845
+ }
846
+
847
+ async function runEventLedgerAttempt<T>(
848
+ root: string,
849
+ location: EventCacheLocation,
850
+ run: () => Promise<T>,
851
+ persist: boolean,
852
+ ): Promise<T | typeof EVENT_LEDGER_RETRY> {
853
+ const build: EventLedgerBuild = { location, snapshot: loadEventLedger(location), additions: [] }
854
+ const result = await eventLedgerBuild.run(build, run)
855
+ if (eventCacheLocation(root).identity !== location.identity) return EVENT_LEDGER_RETRY
856
+ if (persist && build.additions.length) {
857
+ removeEventTemps(location.path)
858
+ mkdirSync(join(location.path, '..'), { recursive: true })
859
+ replaceEventLedger(location.path, build.snapshot.payload, build.additions)
860
+ }
861
+ return result
862
+ }
863
+
864
+ async function eventLedgerTransaction<T>(
865
+ root: string,
866
+ run: () => Promise<T>,
867
+ demand: boolean,
868
+ ): Promise<T> {
869
+ if (activeEventLedger(root)) return run()
870
+ for (let attempt = 0; attempt < 8; attempt++) {
871
+ const location = eventCacheLocation(root)
872
+ const locked = await withEventCacheLock(
873
+ location.path,
874
+ () => runEventLedgerAttempt(root, location, run, true),
875
+ !demand,
876
+ )
877
+ const value = locked === EVENT_LEDGER_BUSY
878
+ ? await runEventLedgerAttempt(root, location, run, false)
879
+ : locked
880
+ if (value !== EVENT_LEDGER_RETRY) return value as T
881
+ }
882
+ throw new Error('history event cache identity changed repeatedly during one ledger build')
883
+ }
884
+
885
+ // The event ledger is one build transaction, not one transaction per consumer: stream extraction and
886
+ // immutable hunk derivation share the snapshot, integrity verdict, lock, and final replacement.
887
+ export function withEventLedgerBuild<T>(root: string, run: () => Promise<T>): Promise<T> {
888
+ const demand = eventLedgerDemandPolicy.getStore()
889
+ return eventLedgerTransaction(root, run, !!demand && demand.path === eventCacheLocation(root).path)
890
+ }
891
+
892
+ // Demand is an ambient acquisition POLICY, not an eager lock. Nested ledger consumers therefore use the
893
+ // read-only path under a live writer, while observer waits, revision reads, and stable-cut replay take no lock.
894
+ export function withEventLedgerDemand<T>(root: string, run: () => Promise<T>): Promise<T> {
895
+ const path = eventCacheLocation(root).path
896
+ if (eventLedgerDemandPolicy.getStore()?.path === path) return run()
897
+ return eventLedgerDemandPolicy.run({ path }, run)
898
+ }
899
+
900
+ export function eventLedgerDiagnosticsForTests(): EventLedgerDiagnostics { return { ...eventLedgerDiagnostics } }
901
+ export function resetEventLedgerDiagnosticsForTests(): void {
902
+ eventLedgerDiagnostics.reads = 0; eventLedgerDiagnostics.locks = 0; eventLedgerDiagnostics.replaces = 0
903
+ }
695
904
  function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
696
905
  return [...records].filter((record) => request.reachable.has(record.hash))
697
906
  .sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
@@ -729,6 +938,16 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
729
938
  state.streamTips.set(kind, tips)
730
939
  additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
731
940
  }
941
+ function appendImmutableHunkFact(state: EventCache, key: string, ranges: ImmutableHunkRanges, additions: string[]): void {
942
+ const existing = state.hunks.get(key)
943
+ if (existing) {
944
+ if (JSON.stringify(existing) !== JSON.stringify(ranges))
945
+ throw new Error(`immutable hunk ledger fact disagrees for image identity ${JSON.stringify(key)}`)
946
+ return
947
+ }
948
+ state.hunks.set(key, ranges)
949
+ additions.push(JSON.stringify({ k: IMMUTABLE_HUNK_FACT, i: key, a: ranges.after, b: ranges.before }) + '\n')
950
+ }
732
951
  function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
733
952
  if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
734
953
  const records: EventRecord[] = []
@@ -789,39 +1008,46 @@ async function deriveEventStreams(
789
1008
  }
790
1009
  if (new Set(requests.map((request) => request.kind)).size !== requests.length)
791
1010
  throw new Error('one event-ledger transaction cannot request the same stream twice')
792
-
793
- const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
794
- const snapshot = loadEventLedger(location)
795
- const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
796
- const outputs = await Promise.all(missing.map((request) => {
797
- const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
798
- .find((candidate) => request.reachable.has(candidate)) ?? ''
799
- return strictEventGit(request.argsFor(base))
800
- }))
801
- if (eventCacheLocation(root).identity !== location.identity) return null
802
-
803
- const additions: string[] = []
804
- for (let index = 0; index < missing.length; index++) {
805
- const request = missing[index]
806
- for (const record of parseEventRecords(outputs[index], request.kind, location))
807
- appendEventRecord(snapshot.state, request.kind, record, additions)
808
- if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
809
- }
810
- if (persist && additions.length) {
811
- removeEventTemps(location.path)
812
- mkdirSync(join(location.path, '..'), { recursive: true })
813
- replaceEventLedger(location.path, snapshot.payload, additions)
814
- }
815
- return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
816
- }
817
-
818
- for (let attempt = 0; attempt < 8; attempt++) {
819
- const location = eventCacheLocation(root)
820
- const result = persist ? await withEventCacheLock(location.path, () => run(location)) : await run(location)
821
- if (result) return result
822
- }
823
- throw new Error(`history event cache identity changed repeatedly while deriving ${tip}`)
1011
+ const build = activeEventLedger(root)
1012
+ if (!build) return withEventLedgerBuild(root, () => deriveEventStreams(root, tip, requests, persist, cache))
1013
+ const { location, snapshot, additions } = build
1014
+ const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
1015
+ const outputs = await Promise.all(missing.map((request) => {
1016
+ const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
1017
+ .find((candidate) => request.reachable.has(candidate)) ?? ''
1018
+ return strictEventGit(request.argsFor(base))
1019
+ }))
1020
+ for (let index = 0; index < missing.length; index++) {
1021
+ const request = missing[index]
1022
+ for (const record of parseEventRecords(outputs[index], request.kind, location))
1023
+ appendEventRecord(snapshot.state, request.kind, record, additions)
1024
+ if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
1025
+ }
1026
+ return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
1027
+ }
1028
+
1029
+ // Immutable hunk ranges are a ledger fact, not a second anchor cache: callers name their whole image-key
1030
+ // demand, receive only facts the shared ledger already certified, and derive/persist misses in their own read.
1031
+ export function readImmutableHunkFacts(root: string, keys: Iterable<string>): Map<string, ImmutableHunkRanges> {
1032
+ const wanted = new Set(keys)
1033
+ if (!wanted.size) return new Map()
1034
+ const build = activeEventLedger(root)
1035
+ if (!build) return new Map()
1036
+ const found = new Map<string, ImmutableHunkRanges>()
1037
+ for (const key of wanted) {
1038
+ const ranges = build.snapshot.state.hunks.get(key)
1039
+ if (ranges) found.set(key, ranges)
1040
+ }
1041
+ return found
1042
+ }
1043
+
1044
+ export async function persistImmutableHunkFacts(root: string, facts: ReadonlyMap<string, ImmutableHunkRanges>): Promise<void> {
1045
+ if (!facts.size) return
1046
+ const build = activeEventLedger(root)
1047
+ if (!build) return withEventLedgerBuild(root, () => persistImmutableHunkFacts(root, facts))
1048
+ for (const [key, ranges] of facts) appendImmutableHunkFact(build.snapshot.state, key, ranges, build.additions)
824
1049
  }
1050
+
825
1051
  async function eventStream(
826
1052
  root: string,
827
1053
  tip: string,
@@ -844,7 +1070,7 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
844
1070
  return value as IdentityRawRecord[]
845
1071
  }
846
1072
  export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
847
- export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
1073
+ export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined>; input?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
848
1074
  const env = { ...process.env }
849
1075
  for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
850
1076
  if (value === undefined) delete env[key]
@@ -854,7 +1080,7 @@ export async function gitTry(args: string[], options: { indexFile?: string; extr
854
1080
  if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
855
1081
  const context = inheritedContext()
856
1082
  try {
857
- const { stdout, stderr } = await execGitForCaller(args, env)
1083
+ const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input)
858
1084
  return { ok: true, stdout: stdout.toString('utf8'), stderr }
859
1085
  } catch (e: any) {
860
1086
  if (context?.signal.aborted || e?.name === 'AbortError') throw e
@@ -864,10 +1090,17 @@ export async function gitTry(args: string[], options: { indexFile?: string; extr
864
1090
  }
865
1091
  }
866
1092
 
867
- export async function gitRequiredA(args: string[], purpose: string): Promise<string> {
1093
+ // A walk whose OUTPUT is a projection the caller is intentionally building reads through the streamed
1094
+ // transport (no fixed stdout bound); `input` lets its revision roster ride stdin, so argv cannot grow with
1095
+ // the roster and needs no chunking.
1096
+ export async function gitRequiredA(args: string[], purpose: string, options: { input?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<string> {
868
1097
  const env = { ...process.env }
1098
+ for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
1099
+ if (value === undefined) delete env[key]
1100
+ else env[key] = value
1101
+ }
869
1102
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
870
- try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
1103
+ try { return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8') }
871
1104
  catch (error: any) {
872
1105
  if (error?.name === 'AbortError') throw error
873
1106
  warnIfTimedOut(error, args)
@@ -1467,6 +1700,15 @@ export type DriftPathEvent = {
1467
1700
  parents: { commit: string; historicalPath: string }[]
1468
1701
  }
1469
1702
 
1703
+ // The ancestry substrate the reachability functions below actually read: a topology projection plus its
1704
+ // memoized closures. `DriftIndex` is one instance of it (HEAD's history); `unionTopology` builds another for
1705
+ // revisions HEAD cannot reach. Sharing the type is what keeps ONE reachability rule for both.
1706
+ export type Reachability = {
1707
+ ord: Map<string, number>
1708
+ parents: Map<string, string[]>
1709
+ anc: Map<string, Uint8Array>
1710
+ }
1711
+
1470
1712
  export type DiffLineRange = [number, number]
1471
1713
  export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1472
1714
 
@@ -1854,7 +2096,7 @@ export function historyEventCachePathForTests(root: string): string { return eve
1854
2096
  // this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
1855
2097
  // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
1856
2098
  // ref) — callers apply their own conservative rule to that "can't prove" case.
1857
- export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
2099
+ export function ancestorsOf(idx: Reachability, sha: string): Uint8Array | undefined {
1858
2100
  const hit = idx.anc.get(sha)
1859
2101
  if (hit) return hit
1860
2102
  const start = idx.ord.get(sha)
@@ -1879,7 +2121,7 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
1879
2121
  // Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
1880
2122
  // commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
1881
2123
  // the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
1882
- export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>): void {
2124
+ export function primeAncestorClosures(idx: Reachability, shas: Iterable<string>): void {
1883
2125
  const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
1884
2126
  if (!endpoints.length) return
1885
2127
  const count = idx.ord.size
@@ -1938,11 +2180,31 @@ export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>):
1938
2180
  throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
1939
2181
  for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
1940
2182
  }
1941
- export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
2183
+ export function inAncestors(idx: Reachability, bits: Uint8Array, sha: string): boolean {
1942
2184
  const o = idx.ord.get(sha)
1943
2185
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
1944
2186
  }
1945
2187
 
2188
+ // @@@ one walk for a whole roster of revisions, HEAD-reachable or not - the index above projects HEAD's
2189
+ // history, so a revision HEAD cannot reach (an unmerged branch, a rebased-away measurement anchor) has NO
2190
+ // ancestry there and `ancestorsOf` correctly answers undefined. A caller that needs those revisions' own
2191
+ // past gets it from the SAME projection shape, built by one `rev-list --parents` walk over the union of the
2192
+ // roster's histories, with the roster on stdin so argv never grows with it. Deliberately a SEPARATE
2193
+ // structure, never a graft into the shared index: making off-history tips reachable there would silently
2194
+ // switch every ancestry-vs-content decision that keys on `undefined`.
2195
+ export async function unionTopology(root: string, revisions: readonly string[]): Promise<Reachability> {
2196
+ const roster = [...new Set(revisions)].filter(Boolean)
2197
+ const reach: Reachability = { ord: new Map(), parents: new Map(), anc: new Map() }
2198
+ if (!roster.length) return reach
2199
+ const out = await gitRequiredA(['-C', root, 'rev-list', '--parents', '--stdin'], 'cannot walk revision topology', {
2200
+ input: roster.map((revision) => `${revision}\n`).join(''),
2201
+ // the roster is already exact object ids; do not reinterpret them if refs/replace moves mid-read.
2202
+ extraEnv: { GIT_NO_REPLACE_OBJECTS: '1' },
2203
+ })
2204
+ const projection = topologyProjection(out)
2205
+ return { ord: projection.order, parents: projection.parents, anc: new Map() }
2206
+ }
2207
+
1946
2208
  // @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
1947
2209
  // absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
1948
2210
  // succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
@@ -2082,13 +2344,13 @@ function parseStatPath(token: string): { from: string; to: string } {
2082
2344
  const arrowAt = token.indexOf(' => ')
2083
2345
  return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
2084
2346
  }
2085
- export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
2347
+ export async function mergeBaseDiff(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<ReviewDiffFile[]> {
2086
2348
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2087
- const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
2349
+ const base = (await run(['merge-base', mainRef, headRef])).trim()
2088
2350
  if (!base) return []
2089
2351
  const [numstatOut, statusOut] = await Promise.all([
2090
- run(['diff', '--numstat', '-M', `${base}..HEAD`]),
2091
- run(['diff', '--name-status', '-M', `${base}..HEAD`]),
2352
+ run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
2353
+ run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
2092
2354
  ])
2093
2355
  const status = new Map<string, { status: string; from: string }>()
2094
2356
  for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
@@ -2109,11 +2371,11 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
2109
2371
  return files
2110
2372
  }
2111
2373
 
2112
- export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
2374
+ export function mergeConflicts(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<boolean> {
2113
2375
  return new Promise((resolve) => {
2114
2376
  const env = { ...process.env }
2115
2377
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
2116
- execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
2378
+ execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef],
2117
2379
  { encoding: 'utf8', env, maxBuffer: 1 << 24 },
2118
2380
  // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
2119
2381
  // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
@@ -2126,19 +2388,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
2126
2388
  // spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
2127
2389
  // touched by this branch since its fork point (attribution — main's own post-fork movement is not this
2128
2390
  // worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
2129
- export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
2130
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2131
- // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
2132
- // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
2133
- // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
2134
- const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
2135
- // the four queries are independent — run them in parallel.
2136
- const [mainOut, workOut, commOut, statusOut] = await Promise.all([
2137
- run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
2138
- run(['diff', '--name-status', '-M', base, '--', '.spec']),
2139
- run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
2140
- run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2141
- ])
2391
+ function projectWorktreeSpecDelta(mainOut: string, workOut: string, commOut: string, statusOut: string): NodeOp[] {
2142
2392
  const proposals = parseNameStatus(mainOut)
2143
2393
  // the branch's own footprint since its fork point — both sides of every row, so a rename matches
2144
2394
  // whichever side the vs-main diff names.
@@ -2162,7 +2412,7 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
2162
2412
  for (const r of proposals) {
2163
2413
  const path = r.code === 'D' ? r.from : r.to
2164
2414
  if (!isSpecMd(path)) continue
2165
- if (!touched.has(r.to) && !touched.has(r.from)) continue // main moved it, not this branch → no op
2415
+ if (!touched.has(r.to) && !touched.has(r.from)) continue
2166
2416
  seen.add(path)
2167
2417
  const op = codeFor[r.code] ?? 'edited'
2168
2418
  ops.push({
@@ -2178,3 +2428,129 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
2178
2428
  }
2179
2429
  return ops
2180
2430
  }
2431
+
2432
+ type WorktreeSpecDemand = { path: string; head: string }
2433
+ export type WorktreeSpecDeltaOutcome = { base: string; ops: NodeOp[] } | { error: unknown }
2434
+
2435
+ async function boundedMap<T, R>(values: T[], concurrency: number, fn: (value: T) => Promise<R>): Promise<R[]> {
2436
+ const results = new Array<R>(values.length)
2437
+ let next = 0
2438
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
2439
+ while (true) {
2440
+ const index = next++
2441
+ if (index >= values.length) return
2442
+ results[index] = await fn(values[index])
2443
+ }
2444
+ }))
2445
+ return results
2446
+ }
2447
+
2448
+ // One cold layout demand across many linked worktrees. Merge-base and working-status are path facts, so they
2449
+ // stay per worktree under a fixed concurrency. Clean rows then collapse to immutable main→HEAD and base→HEAD
2450
+ // pairs in one framed child; dirty rows retain the ordinary worktree-aware projection.
2451
+ export async function worktreeSpecDeltas(root: string, mainSha: string, demands: WorktreeSpecDemand[], interpretation = gitInterpretationIdentity(root)): Promise<Map<string, WorktreeSpecDeltaOutcome>> {
2452
+ const results = new Map<string, WorktreeSpecDeltaOutcome>()
2453
+ if (!demands.length) return results
2454
+ if (gitInterpretationIdentity(root) !== interpretation) {
2455
+ const error = new Error('Git interpretation changed before the worktree overlay batch')
2456
+ for (const demand of demands) results.set(demand.path, { error })
2457
+ return results
2458
+ }
2459
+ const prepared = await boundedMap(demands, 4, async (demand) => {
2460
+ try {
2461
+ const run = (args: string[]) => gitARequired(['-C', demand.path, '-c', 'core.quotePath=false', ...args])
2462
+ const [baseOut, statusOut] = await Promise.all([
2463
+ run(['merge-base', mainSha, demand.head]),
2464
+ run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2465
+ ])
2466
+ const base = baseOut.trim()
2467
+ if (!base) throw new Error(`git merge-base returned no base for ${demand.path}`)
2468
+ return { ...demand, base, statusOut }
2469
+ } catch (error) {
2470
+ results.set(demand.path, { error })
2471
+ return null
2472
+ }
2473
+ })
2474
+
2475
+ const clean = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() === '')
2476
+ const dirty = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() !== '')
2477
+ const pairs: Array<{ from: string; to: string }> = []
2478
+ const pairIndex = new Map<string, number>()
2479
+ const addPair = (from: string, to: string): number => {
2480
+ const key = `${from}\0${to}`
2481
+ const hit = pairIndex.get(key)
2482
+ if (hit != null) return hit
2483
+ const index = pairs.length
2484
+ pairIndex.set(key, index)
2485
+ pairs.push({ from, to })
2486
+ return index
2487
+ }
2488
+ const cleanIndexes = clean.map((row) => ({
2489
+ row,
2490
+ main: addPair(mainSha, row.head),
2491
+ branch: addPair(row.base, row.head),
2492
+ }))
2493
+
2494
+ if (pairs.length) {
2495
+ try {
2496
+ // diff-tree's stdin pair syntax is `<new> <old>`; reverse each ordinary from→to pair deliberately.
2497
+ // --always is load-bearing: it emits a frame for an empty pair, preserving positional ownership.
2498
+ const input = pairs.map(({ from, to }) => `${to} ${from}`).join('\n') + '\n'
2499
+ const out = await gitARequired([
2500
+ '-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--no-commit-id', '--always',
2501
+ '-r', '--name-status', '-M', `--format=${RS}%H`, '--', '.spec',
2502
+ ], input)
2503
+ // For two-tree stdin rows, diff-tree writes that row's name-status payload BEFORE its --format marker.
2504
+ // The first split segment is therefore pair 0; each later pair lives after the previous marker, while
2505
+ // the final marker carries no following pair. Treating markers as openers shifts every non-empty result.
2506
+ const records = out.split(RS)
2507
+ if (records.length - 1 !== pairs.length) throw new Error(`git diff-tree --stdin returned ${records.length - 1} frames for ${pairs.length} pairs`)
2508
+ const frames = pairs.map((_, index) => {
2509
+ if (index === 0) return records[0].split('\n').filter(Boolean).join('\n')
2510
+ const lines = records[index].replace(/^\n/, '').split('\n')
2511
+ lines.shift()
2512
+ return lines.filter(Boolean).join('\n')
2513
+ })
2514
+ for (const { row, main, branch } of cleanIndexes) {
2515
+ results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(frames[main], frames[branch], frames[branch], '') })
2516
+ }
2517
+ } catch (error) {
2518
+ for (const { row } of cleanIndexes) results.set(row.path, { error })
2519
+ }
2520
+ }
2521
+
2522
+ await Promise.all(dirty.map(async (row) => {
2523
+ try {
2524
+ const run = (args: string[]) => gitARequired(['-C', row.path, '-c', 'core.quotePath=false', ...args])
2525
+ const [mainOut, workOut, commOut] = await Promise.all([
2526
+ run(['diff', '--name-status', '-M', mainSha, '--', '.spec']),
2527
+ run(['diff', '--name-status', '-M', row.base, '--', '.spec']),
2528
+ run(['diff', '--name-status', '-M', `${row.base}...${row.head}`, '--', '.spec']),
2529
+ ])
2530
+ results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(mainOut, workOut, commOut, row.statusOut) })
2531
+ } catch (error) {
2532
+ results.set(row.path, { error })
2533
+ }
2534
+ }))
2535
+ if (gitInterpretationIdentity(root) !== interpretation) {
2536
+ const error = new Error('Git interpretation changed during the worktree overlay batch')
2537
+ for (const demand of demands) results.set(demand.path, { error })
2538
+ }
2539
+ return results
2540
+ }
2541
+
2542
+ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
2543
+ const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2544
+ // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
2545
+ // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
2546
+ // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
2547
+ const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
2548
+ // the four queries are independent — run them in parallel.
2549
+ const [mainOut, workOut, commOut, statusOut] = await Promise.all([
2550
+ run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
2551
+ run(['diff', '--name-status', '-M', base, '--', '.spec']),
2552
+ run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
2553
+ run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2554
+ ])
2555
+ return projectWorktreeSpecDelta(mainOut, workOut, commOut, statusOut)
2556
+ }