spexcode 0.6.0 → 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 (88) 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 +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -1,10 +1,11 @@
1
1
  import { execFileSync, execFile, spawn } from 'node:child_process'
2
2
  import { AsyncLocalStorage } from 'node:async_hooks'
3
- import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync } from 'node:fs'
4
- import { join, isAbsolute, resolve } from 'node:path'
3
+ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
4
+ 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,7 +15,26 @@ 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
20
+ const gitByPath = new Map<string, string>()
21
+
22
+ export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
23
+ const path = env.PATH || ''
24
+ const known = gitByPath.get(path)
25
+ if (known) {
26
+ try { accessSync(known, constants.X_OK); return known } catch {}
27
+ }
28
+ for (const dir of path.split(delimiter)) {
29
+ const candidate = resolve(dir || '.', 'git')
30
+ try {
31
+ accessSync(candidate, constants.X_OK)
32
+ gitByPath.set(path, candidate)
33
+ return candidate
34
+ } catch {}
35
+ }
36
+ throw new Error('git executable not found on PATH')
37
+ }
18
38
  type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
19
39
  type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
20
40
  const gitBuild = new AsyncLocalStorage<GitBuildContext>()
@@ -107,7 +127,8 @@ export function currentGitBuildAbortSignal(): AbortSignal | undefined {
107
127
  return inheritedContext()?.signal
108
128
  }
109
129
  function warnIfTimedOut(e: any, args: string[]): void {
110
- 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`)
111
132
  }
112
133
 
113
134
  // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
@@ -115,7 +136,14 @@ export function git(args: string[]): string {
115
136
  const env = { ...process.env }
116
137
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
117
138
  try {
118
- return execFileSync('git', 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
+ })
119
147
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
120
148
  }
121
149
 
@@ -123,13 +151,13 @@ function gitBuffer(args: string[], input?: string): Buffer {
123
151
  const env = { ...process.env }
124
152
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
125
153
  try {
126
- return execFileSync('git', withBuildLimits(args), {
154
+ return execFileSync(gitBinary(env), withBuildLimits(args), {
127
155
  input,
128
156
  env,
129
157
  stdio: ['pipe', 'pipe', 'pipe'],
130
158
  timeout: GIT_TIMEOUT_MS,
131
159
  killSignal: 'SIGKILL',
132
- maxBuffer: 1 << 27,
160
+ maxBuffer: GIT_SYNC_MAX_BUFFER,
133
161
  })
134
162
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
135
163
  }
@@ -166,9 +194,10 @@ export function isGitObjectId(root: string, value: string): boolean {
166
194
  // source corpus never approaches the byte ceiling.
167
195
  const BATCH_BLOB_CHUNK = 256
168
196
  const BATCH_BLOB_MAX_BUFFER = 1 << 26
169
- 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> {
170
198
  const env = { ...process.env }
171
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)
172
201
  try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
173
202
  catch (error: any) {
174
203
  if (error?.name === 'AbortError') throw error
@@ -176,9 +205,10 @@ async function batchBuffer(args: string[], input: string, maxBuffer?: number): P
176
205
  throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
177
206
  }
178
207
  }
179
- 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)[]> {
180
209
  if (!revisions.length) return []
181
- 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')
182
212
  const lines = out.split('\n')
183
213
  if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
184
214
  return revisions.map((revision, index) => {
@@ -267,7 +297,7 @@ const GIT_MAX_BUFFER = 1 << 24
267
297
  function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
268
298
  return new Promise((resolve, reject) => {
269
299
  if (signal?.aborted) { reject(gitAbortError()); return }
270
- const child = spawn('git', args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
300
+ const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
271
301
  const stdout: Buffer[] = [], stderr: Buffer[] = []
272
302
  let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
273
303
  let spawnError: Error | null = null
@@ -335,12 +365,13 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
335
365
  // Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
336
366
  // them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
337
367
  // cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
338
- 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> {
339
369
  return new Promise((resolve, reject) => {
340
370
  if (signal?.aborted) { reject(gitAbortError()); return }
341
- const child = spawn('git', 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'] })
342
372
  const stdout: Buffer[] = [], stderr: Buffer[] = []
343
373
  let settled = false, aborted = false, timedOut = false
374
+ let stdinError: any = null
344
375
  const killTree = () => {
345
376
  if (!child.pid) return
346
377
  try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
@@ -350,8 +381,14 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
350
381
  const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
351
382
  timer.unref?.()
352
383
  signal?.addEventListener('abort', onAbort, { once: true })
353
- child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
354
- 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
+ }
355
392
  child.on('error', (error: any) => {
356
393
  if (settled) return
357
394
  settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
@@ -363,9 +400,9 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
363
400
  if (settled) return
364
401
  settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
365
402
  const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
366
- if (code === 0 && !aborted && !timedOut) { resolve(result); return }
367
- const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
368
- 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
369
406
  error.signal = childSignal
370
407
  error.stdout = result.stdout.toString('utf8')
371
408
  error.stderr = result.stderr
@@ -375,11 +412,11 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
375
412
  })
376
413
  })
377
414
  }
378
- async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
415
+ async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv, input?: string): Promise<GitExec> {
379
416
  const context = inheritedContext()
380
- if (!context) return execGitStream(args, env)
417
+ if (!context) return execGitStream(args, env, undefined, input)
381
418
  const release = await context.permits.acquire(context.signal)
382
- try { return await execGitStream(withBuildLimits(args), env, context.signal) }
419
+ try { return await execGitStream(withBuildLimits(args), env, context.signal, input) }
383
420
  finally { release() }
384
421
  }
385
422
 
@@ -396,19 +433,47 @@ export async function gitA(args: string[], input?: string): Promise<string> {
396
433
  }
397
434
  }
398
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
+
399
447
  type TextEventRecord = { hash: string; raw: string }
400
448
  type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
401
449
  type EventRecord = TextEventRecord | IdentityEventRecord
402
- 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
+ }
403
456
  type EventStreamOutput = string | IdentityRawRecord[]
404
- 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'
405
461
  const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
406
462
  type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
407
- type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
463
+ type EventCacheLocation = { path: string; identity: string; interpretation: string; objectFormat: GitObjectFormat }
408
464
  type EventLedgerSnapshot = {
409
465
  payload: Buffer
410
466
  state: EventCache
411
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 }
412
477
  type EventStreamRequest = {
413
478
  kind: EventStreamKind
414
479
  argsFor: (base: string) => string[]
@@ -460,20 +525,41 @@ function eventCacheLocation(root: string): EventCacheLocation {
460
525
  : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
461
526
  const objectFormat = gitObjectFormat(root)
462
527
  if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
463
- return { path: old.path, identity: old.identity, objectFormat }
464
- const identity = createHash('sha256')
528
+ return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat }
529
+ const interpretation = createHash('sha256')
465
530
  .update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
466
- .digest('hex').slice(0, 16)
531
+ .digest('hex')
532
+ const identity = interpretation.slice(0, 16)
467
533
  // projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
468
534
  // dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
469
535
  const gitDir = gitDirOf(root)
470
536
  const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
471
537
  const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
472
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
473
- return { path, identity, objectFormat }
474
- }
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 }
475
561
  function emptyEventCache(): EventCache {
476
- return { streams: new Map(), streamTips: new Map() }
562
+ return { streams: new Map(), streamTips: new Map(), hunks: new Map() }
477
563
  }
478
564
  function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
479
565
  const actual = Object.keys(value).sort()
@@ -498,6 +584,28 @@ function eventStreamKind(value: unknown): EventStreamKind | null {
498
584
  ? value as EventStreamKind
499
585
  : null
500
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
+ }
501
609
  function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
502
610
  const state = emptyEventCache()
503
611
  const text = payload.toString('utf8')
@@ -511,6 +619,12 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
511
619
  try { value = JSON.parse(line) } catch { return null }
512
620
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null
513
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
+ }
514
628
  if (exactKeys(row, ['k', 'tip'])) {
515
629
  const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
516
630
  if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
@@ -545,6 +659,7 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
545
659
  return state
546
660
  }
547
661
  function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
662
+ eventLedgerDiagnostics.reads++
548
663
  let file: Buffer
549
664
  try { file = readFileSync(location.path) }
550
665
  catch (error: any) {
@@ -572,18 +687,29 @@ function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
572
687
  }
573
688
  }
574
689
 
575
- type EventLockOwner = { pid: number; token: string }
690
+ type EventLockOwner = { pid: number; startToken: string; nonce: string }
576
691
  function readEventLockOwner(lock: string): EventLockOwner | null {
577
692
  try {
578
- const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; token?: unknown }
579
- return Number.isInteger(value.pid) && (value.pid as number) > 0 && typeof value.token === 'string'
580
- ? { 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 }
581
698
  : null
582
699
  } catch { return null }
583
700
  }
584
- function processAlive(pid: number): boolean {
585
- try { process.kill(pid, 0); return true }
586
- 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'
587
713
  }
588
714
  function retireLockPath(active: string): boolean {
589
715
  const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
@@ -596,14 +722,17 @@ function retireLockPath(active: string): boolean {
596
722
  return true
597
723
  }
598
724
  function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
599
- if (processAlive(held.pid)) return false
725
+ if (eventLockOwnerState(held) !== 'dead') return false
600
726
  const reclaim = join(lock, 'reclaim')
601
727
  if (existsSync(reclaim)) {
602
728
  const reclaimer = readEventLockOwner(reclaim)
603
- 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
604
733
  retireLockPath(reclaim)
605
734
  }
606
- const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.token}`)
735
+ const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`)
607
736
  try {
608
737
  mkdirSync(prepared)
609
738
  writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
@@ -614,21 +743,29 @@ function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: Even
614
743
  throw error
615
744
  }
616
745
  const current = readEventLockOwner(lock)
617
- if (current?.pid !== held.pid || current.token !== held.token || processAlive(current.pid)) {
746
+ if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
618
747
  retireLockPath(reclaim)
619
748
  return false
620
749
  }
621
750
  return retireLockPath(lock)
622
751
  }
623
- 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> {
624
759
  const lock = `${path}.lock`
625
760
  mkdirSync(join(path, '..'), { recursive: true })
626
- 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') }
627
764
  const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
628
765
  const signal = inheritedContext()?.signal
629
766
  for (let attempt = 0; ; attempt++) {
630
767
  if (signal?.aborted) throw gitAbortError()
631
- const claimant = `${lock}.claim.${owner.pid}.${owner.token}`
768
+ const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`
632
769
  try {
633
770
  mkdirSync(claimant)
634
771
  writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
@@ -638,7 +775,30 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
638
775
  rmSync(claimant, { recursive: true, force: true })
639
776
  if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
640
777
  const held = readEventLockOwner(lock)
641
- 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
+ }
642
802
  if (attempt >= attempts) {
643
803
  const heldBy = readEventLockOwner(lock)?.pid
644
804
  throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
@@ -646,8 +806,9 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
646
806
  await new Promise((resolve) => setTimeout(resolve, 5))
647
807
  }
648
808
  }
809
+ eventLedgerDiagnostics.locks++
649
810
  try { return await run() } finally {
650
- if (readEventLockOwner(lock)?.token === owner.token) retireLockPath(lock)
811
+ if (sameEventLockOwner(readEventLockOwner(lock), owner)) retireLockPath(lock)
651
812
  }
652
813
  }
653
814
  function removeEventTemps(path: string): void {
@@ -668,12 +829,78 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
668
829
  writeFileSync(fd, eventIntegrityFooter(payload, addition))
669
830
  const written = fd; fd = null; closeSync(written)
670
831
  renameSync(tmp, path)
832
+ eventLedgerDiagnostics.replaces++
671
833
  } catch (error) {
672
834
  if (fd !== null) closeSync(fd)
673
835
  rmSync(tmp, { force: true })
674
836
  throw error
675
837
  }
676
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
+ }
677
904
  function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
678
905
  return [...records].filter((record) => request.reachable.has(record.hash))
679
906
  .sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
@@ -711,6 +938,16 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
711
938
  state.streamTips.set(kind, tips)
712
939
  additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
713
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
+ }
714
951
  function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
715
952
  if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
716
953
  const records: EventRecord[] = []
@@ -771,39 +1008,46 @@ async function deriveEventStreams(
771
1008
  }
772
1009
  if (new Set(requests.map((request) => request.kind)).size !== requests.length)
773
1010
  throw new Error('one event-ledger transaction cannot request the same stream twice')
774
-
775
- const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
776
- const snapshot = loadEventLedger(location)
777
- const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
778
- const outputs = await Promise.all(missing.map((request) => {
779
- const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
780
- .find((candidate) => request.reachable.has(candidate)) ?? ''
781
- return strictEventGit(request.argsFor(base))
782
- }))
783
- if (eventCacheLocation(root).identity !== location.identity) return null
784
-
785
- const additions: string[] = []
786
- for (let index = 0; index < missing.length; index++) {
787
- const request = missing[index]
788
- for (const record of parseEventRecords(outputs[index], request.kind, location))
789
- appendEventRecord(snapshot.state, request.kind, record, additions)
790
- if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
791
- }
792
- if (persist && additions.length) {
793
- removeEventTemps(location.path)
794
- mkdirSync(join(location.path, '..'), { recursive: true })
795
- replaceEventLedger(location.path, snapshot.payload, additions)
796
- }
797
- return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
798
- }
799
-
800
- for (let attempt = 0; attempt < 8; attempt++) {
801
- const location = eventCacheLocation(root)
802
- const result = persist ? await withEventCacheLock(location.path, () => run(location)) : await run(location)
803
- if (result) return result
804
- }
805
- 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)
806
1049
  }
1050
+
807
1051
  async function eventStream(
808
1052
  root: string,
809
1053
  tip: string,
@@ -826,13 +1070,17 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
826
1070
  return value as IdentityRawRecord[]
827
1071
  }
828
1072
  export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
829
- export async function gitTry(args: string[], options: { indexFile?: string } = {}): 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 }> {
830
1074
  const env = { ...process.env }
1075
+ for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
1076
+ if (value === undefined) delete env[key]
1077
+ else env[key] = value
1078
+ }
831
1079
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
832
1080
  if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
833
1081
  const context = inheritedContext()
834
1082
  try {
835
- const { stdout, stderr } = await execGitForCaller(args, env)
1083
+ const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input)
836
1084
  return { ok: true, stdout: stdout.toString('utf8'), stderr }
837
1085
  } catch (e: any) {
838
1086
  if (context?.signal.aborted || e?.name === 'AbortError') throw e
@@ -842,10 +1090,17 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
842
1090
  }
843
1091
  }
844
1092
 
845
- 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> {
846
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
+ }
847
1102
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
848
- try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
1103
+ try { return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8') }
849
1104
  catch (error: any) {
850
1105
  if (error?.name === 'AbortError') throw error
851
1106
  warnIfTimedOut(error, args)
@@ -1445,6 +1700,15 @@ export type DriftPathEvent = {
1445
1700
  parents: { commit: string; historicalPath: string }[]
1446
1701
  }
1447
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
+
1448
1712
  export type DiffLineRange = [number, number]
1449
1713
  export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1450
1714
 
@@ -1832,7 +2096,7 @@ export function historyEventCachePathForTests(root: string): string { return eve
1832
2096
  // this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
1833
2097
  // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
1834
2098
  // ref) — callers apply their own conservative rule to that "can't prove" case.
1835
- export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
2099
+ export function ancestorsOf(idx: Reachability, sha: string): Uint8Array | undefined {
1836
2100
  const hit = idx.anc.get(sha)
1837
2101
  if (hit) return hit
1838
2102
  const start = idx.ord.get(sha)
@@ -1857,7 +2121,7 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
1857
2121
  // Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
1858
2122
  // commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
1859
2123
  // the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
1860
- export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>): void {
2124
+ export function primeAncestorClosures(idx: Reachability, shas: Iterable<string>): void {
1861
2125
  const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
1862
2126
  if (!endpoints.length) return
1863
2127
  const count = idx.ord.size
@@ -1916,11 +2180,31 @@ export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>):
1916
2180
  throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
1917
2181
  for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
1918
2182
  }
1919
- export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
2183
+ export function inAncestors(idx: Reachability, bits: Uint8Array, sha: string): boolean {
1920
2184
  const o = idx.ord.get(sha)
1921
2185
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
1922
2186
  }
1923
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
+
1924
2208
  // @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
1925
2209
  // absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
1926
2210
  // succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
@@ -2060,13 +2344,13 @@ function parseStatPath(token: string): { from: string; to: string } {
2060
2344
  const arrowAt = token.indexOf(' => ')
2061
2345
  return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
2062
2346
  }
2063
- export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
2347
+ export async function mergeBaseDiff(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<ReviewDiffFile[]> {
2064
2348
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2065
- const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
2349
+ const base = (await run(['merge-base', mainRef, headRef])).trim()
2066
2350
  if (!base) return []
2067
2351
  const [numstatOut, statusOut] = await Promise.all([
2068
- run(['diff', '--numstat', '-M', `${base}..HEAD`]),
2069
- run(['diff', '--name-status', '-M', `${base}..HEAD`]),
2352
+ run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
2353
+ run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
2070
2354
  ])
2071
2355
  const status = new Map<string, { status: string; from: string }>()
2072
2356
  for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
@@ -2087,11 +2371,11 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
2087
2371
  return files
2088
2372
  }
2089
2373
 
2090
- export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
2374
+ export function mergeConflicts(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<boolean> {
2091
2375
  return new Promise((resolve) => {
2092
2376
  const env = { ...process.env }
2093
2377
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
2094
- execFile('git', ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
2378
+ execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef],
2095
2379
  { encoding: 'utf8', env, maxBuffer: 1 << 24 },
2096
2380
  // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
2097
2381
  // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
@@ -2104,19 +2388,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
2104
2388
  // spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
2105
2389
  // touched by this branch since its fork point (attribution — main's own post-fork movement is not this
2106
2390
  // worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
2107
- export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
2108
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2109
- // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
2110
- // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
2111
- // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
2112
- const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
2113
- // the four queries are independent — run them in parallel.
2114
- const [mainOut, workOut, commOut, statusOut] = await Promise.all([
2115
- run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
2116
- run(['diff', '--name-status', '-M', base, '--', '.spec']),
2117
- run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
2118
- run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2119
- ])
2391
+ function projectWorktreeSpecDelta(mainOut: string, workOut: string, commOut: string, statusOut: string): NodeOp[] {
2120
2392
  const proposals = parseNameStatus(mainOut)
2121
2393
  // the branch's own footprint since its fork point — both sides of every row, so a rename matches
2122
2394
  // whichever side the vs-main diff names.
@@ -2140,7 +2412,7 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
2140
2412
  for (const r of proposals) {
2141
2413
  const path = r.code === 'D' ? r.from : r.to
2142
2414
  if (!isSpecMd(path)) continue
2143
- 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
2144
2416
  seen.add(path)
2145
2417
  const op = codeFor[r.code] ?? 'edited'
2146
2418
  ops.push({
@@ -2156,3 +2428,129 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
2156
2428
  }
2157
2429
  return ops
2158
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
+ }