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,7 +1,7 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join, dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
- import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
4
+ import { git, repoRoot, gitA, gitAbortError, currentGitBuildAbortSignal, gitInterpretationIdentity, headSha, worktreeSpecSig, worktreeSpecDelta, worktreeSpecDeltas, withGitAbortSignal, type NodeOp } from './git.js'
5
5
  import { guardWorktree } from './resilience.js'
6
6
  import { HARNESSES, type HarnessId } from './harness.js'
7
7
  import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
@@ -170,10 +170,28 @@ export function readUploadPolicy(root: string): UploadPolicy {
170
170
  // the shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it). Memoized:
171
171
  // it's a process constant, but mainBranch()/mainRoot() resolve it per call (~60 git rev-parse forks per board build without the cache).
172
172
  let commonDirCache: string | null = null
173
+ const commonDirsByPath = new Map<string, string>()
174
+ const topsByPath = new Map<string, string>()
173
175
  export function gitCommonDir(): string {
174
176
  if (commonDirCache === null) commonDirCache = git(['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
175
177
  return commonDirCache
176
178
  }
179
+ function commonDirFor(proj: string): string {
180
+ const key = resolve(proj)
181
+ const known = commonDirsByPath.get(key)
182
+ if (known) return known
183
+ const common = git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
184
+ commonDirsByPath.set(key, common)
185
+ return common
186
+ }
187
+ function topFor(proj: string): string {
188
+ const key = resolve(proj)
189
+ const known = topsByPath.get(key)
190
+ if (known) return known
191
+ const top = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
192
+ topsByPath.set(key, top)
193
+ return top
194
+ }
177
195
 
178
196
  export function mainBranch(): string {
179
197
  let checkout: string
@@ -186,9 +204,7 @@ export function mainBranch(): string {
186
204
  // `.codex` (codex-rs hooks_config_folder override), NOT the worktree's, so the codex hooks shim + trust
187
205
  // materialize here while AGENTS.md/skills stay per-worktree — see [[harness-adapter]] (harness.ts).
188
206
  export function mainCheckout(proj?: string): string {
189
- const gcd = proj
190
- ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
191
- : gitCommonDir()
207
+ const gcd = proj ? commonDirFor(proj) : gitCommonDir()
192
208
  return dirname(gcd)
193
209
  }
194
210
 
@@ -222,9 +238,7 @@ export function mainRoot(proj?: string): string {
222
238
  // proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
223
239
  // hooks mirror this as hp_runtime_dir.
224
240
  export function runtimeRoot(proj?: string): string {
225
- const gcd = proj
226
- ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
227
- : gitCommonDir()
241
+ const gcd = proj ? commonDirFor(proj) : gitCommonDir()
228
242
  return projectRuntimeRoot(gcd)
229
243
  }
230
244
  // the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
@@ -236,7 +250,7 @@ export function runtimeRoot(proj?: string): string {
236
250
  // land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
237
251
  // git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
238
252
  export function treeSlotDir(wt: string): string {
239
- const top = git(['-C', wt, 'rev-parse', '--show-toplevel']).trim()
253
+ const top = topFor(wt)
240
254
  return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
241
255
  }
242
256
  // this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
@@ -262,6 +276,7 @@ export type RawRecord = {
262
276
  launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
263
277
  create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
264
278
  create_payload_hash?: string // normalized create payload bound to create_request_id
279
+ base?: string // the exact fork point pinned at creation; absent/empty → the auto-detected source-of-truth branch
265
280
  launch_readiness_pending?: '' | RawLaunchReadinessPending
266
281
  }
267
282
 
@@ -431,6 +446,10 @@ export function readAliasedRawRecord(id: string): RawRecord | null {
431
446
  export function readAliasedRecordEntry(id: string): RecordEntry {
432
447
  const direct = readRecordEntry(id)
433
448
  if (direct.kind !== 'absent') return direct
449
+ // @@@ absence splits in two, and only one half is an alias question - an id owning a store dir is already
450
+ // one of ours (the sentinel-only agent above), so its emptiness is settled; searching would let an unrelated
451
+ // record answer under a live session's own name, and costs a whole-store re-parse per 1s supervisor tick.
452
+ if (existsSync(sessionStoreDir(id))) return { kind: 'absent' }
434
453
  for (const sid of listSessionIds()) {
435
454
  const r = readRawRecord(sid)
436
455
  if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
@@ -448,35 +467,113 @@ export function listSessionIds(): string[] {
448
467
  return ents.filter((d) => d.isDirectory()).map((d) => d.name)
449
468
  }
450
469
 
451
- // memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
452
- // sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
453
- // content dissolve a worktree's now-moot ops the recompute it triggers is cheap because every diff is
454
- // .spec-scoped.
470
+ // @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
471
+ // on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
472
+ // map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
473
+ // every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
474
+ export function sessionBranchIndex(): Map<string, string> {
475
+ const index = new Map<string, string>()
476
+ for (const id of listSessionIds()) {
477
+ let branch: unknown
478
+ try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
479
+ if (typeof branch === 'string' && branch) index.set(branch, id)
480
+ }
481
+ return index
482
+ }
483
+
484
+ // Retain only completed per-worktree overlays. Interpretation + main tip + HEAD + working signature completely
485
+ // determine the merge-base and projection; a landed main tip therefore dissolves now-moot ops on the next read.
455
486
  const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
456
487
  const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
457
- const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
458
- try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
459
- }
460
488
  let layoutHeadWarned = false
461
- async function cachedDelta(wtPath: string, mainRef: string, mainSha: string): Promise<NodeOp[]> {
462
- const wtHead = safeHead(wtPath)
463
- const base = await safeMergeBase(wtPath, mainRef)
464
- // fail loud, never stale: if the merge-base, HEAD, or main tip can't be read the key is untrustworthy —
465
- // bypass the cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha
466
- // across a real change.
467
- if (!base || !wtHead || !mainSha) {
468
- if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD/main tip), recomputing every read') }
469
- return worktreeSpecDelta(wtPath, mainRef)
489
+ type LayoutDeltaOutcome = { ops: NodeOp[] } | { error: unknown }
490
+ type LayoutDeltaFlight = {
491
+ promise: Promise<Map<string, LayoutDeltaOutcome>>
492
+ controller: AbortController
493
+ waiters: Set<symbol>
494
+ settled: boolean
495
+ }
496
+ const layoutDeltaFlights = new Map<string, LayoutDeltaFlight>()
497
+
498
+ // One exact public layout generation owns the cold overlay computation. The map is only an in-flight join:
499
+ // the entry is deleted at settlement and deltaCache remains the sole retained result state.
500
+ async function layoutDeltas(paths: string[], main: string, mainRef: string, mainSha: string): Promise<Map<string, LayoutDeltaOutcome>> {
501
+ const snapshots = paths.map((path) => ({ path, head: safeHead(path), sig: worktreeSpecSig(path) }))
502
+ const interpretation = gitInterpretationIdentity(main)
503
+ const flightKey = JSON.stringify([interpretation, mainRef, mainSha, snapshots.map(({ path, head, sig }) => [path, head, sig]).sort((a, b) => a[0].localeCompare(b[0]))])
504
+ let flight = layoutDeltaFlights.get(flightKey)
505
+ if (flight?.controller.signal.aborted) {
506
+ layoutDeltaFlights.delete(flightKey)
507
+ flight = undefined
508
+ }
509
+ if (!flight) {
510
+ const controller = new AbortController()
511
+ const entry: LayoutDeltaFlight = { promise: Promise.resolve(new Map()), controller, waiters: new Set(), settled: false }
512
+ entry.promise = withGitAbortSignal(controller.signal, async () => {
513
+ const outcomes = new Map<string, LayoutDeltaOutcome>()
514
+ const misses: typeof snapshots = []
515
+ for (const snapshot of snapshots) {
516
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
517
+ const hit = snapshot.head && mainSha ? deltaCache.get(snapshot.path) : null
518
+ if (hit?.key === key) outcomes.set(snapshot.path, { ops: hit.ops })
519
+ else misses.push(snapshot)
520
+ }
521
+
522
+ const valid = misses.filter(({ head }) => !!head && !!mainSha)
523
+ const batched = await worktreeSpecDeltas(main, mainSha, valid.map(({ path, head }) => ({ path, head })), interpretation)
524
+ for (const snapshot of valid) {
525
+ const outcome = batched.get(snapshot.path) ?? { error: new Error(`layout overlay batch omitted ${snapshot.path}`) }
526
+ if ('error' in outcome) outcomes.set(snapshot.path, outcome)
527
+ else {
528
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
529
+ deltaCache.set(snapshot.path, { key, ops: outcome.ops })
530
+ outcomes.set(snapshot.path, { ops: outcome.ops })
531
+ }
532
+ }
533
+
534
+ await Promise.all(misses.filter(({ head }) => !head || !mainSha).map(async (snapshot) => {
535
+ if (!existsSync(snapshot.path)) {
536
+ outcomes.set(snapshot.path, { error: new Error(`worktree ${snapshot.path} is absent`) })
537
+ return
538
+ }
539
+ if (!layoutHeadWarned) {
540
+ layoutHeadWarned = true
541
+ console.warn('spec-cli: layout overlay cache bypassed (unreadable HEAD/main tip), recomputing every read')
542
+ }
543
+ try { outcomes.set(snapshot.path, { ops: await worktreeSpecDelta(snapshot.path, mainRef) }) }
544
+ catch (error) { outcomes.set(snapshot.path, { error }) }
545
+ }))
546
+ return outcomes
547
+ }).finally(() => {
548
+ entry.settled = true
549
+ if (layoutDeltaFlights.get(flightKey) === entry) layoutDeltaFlights.delete(flightKey)
550
+ })
551
+ layoutDeltaFlights.set(flightKey, entry)
552
+ flight = entry
553
+ }
554
+
555
+ const token = Symbol(flightKey)
556
+ const callerSignal = currentGitBuildAbortSignal()
557
+ flight.waiters.add(token)
558
+ let onAbort: (() => void) | null = null
559
+ try {
560
+ if (!callerSignal) return await flight.promise
561
+ if (callerSignal.aborted) throw gitAbortError()
562
+ return await Promise.race([
563
+ flight.promise,
564
+ new Promise<Map<string, LayoutDeltaOutcome>>((_, reject) => {
565
+ onAbort = () => reject(gitAbortError())
566
+ callerSignal.addEventListener('abort', onAbort, { once: true })
567
+ }),
568
+ ])
569
+ } finally {
570
+ if (onAbort) callerSignal?.removeEventListener('abort', onAbort)
571
+ flight.waiters.delete(token)
572
+ if (!flight.settled && flight.waiters.size === 0) flight.controller.abort()
470
573
  }
471
- const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
472
- const hit = deltaCache.get(wtPath)
473
- if (hit && hit.key === key) return hit.ops
474
- const ops = await worktreeSpecDelta(wtPath, mainRef, base)
475
- deltaCache.set(wtPath, { key, ops })
476
- return ops
477
574
  }
478
575
 
479
- export async function resolveLayout(): Promise<Layout> {
576
+ export async function resolveLayout(options: { activeSessionIds?: readonly string[] } = {}): Promise<Layout> {
480
577
  const root = repoRoot()
481
578
  const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
482
579
  const cfg = readConfig(main)
@@ -496,22 +593,36 @@ export async function resolveLayout(): Promise<Layout> {
496
593
  const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
497
594
  .filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
498
595
  const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
596
+ const projectedActive = options.activeSessionIds ? new Set(options.activeSessionIds) : null
597
+ const isActive = (record: RawRecord): boolean => projectedActive
598
+ ? projectedActive.has(record.session_id)
599
+ : !record.archived
499
600
  // main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
500
601
  // ([[worktree-linker]]: landed content must dissolve the ops it made moot).
501
602
  const mainSha = await (async () => {
502
603
  try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
503
604
  })()
605
+ const activePaths = records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path)
606
+ const deltas = await layoutDeltas(activePaths, main, mainRef, mainSha)
504
607
  const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
505
608
  const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
506
609
  const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
507
- // @@@ archived rows cost nothing - a shelved session ([[archive]]) keeps its row (the record is the
508
- // existence truth) but skips the per-worktree spec-delta entirely: that git-history probe is the board's
509
- // dominant per-row cost, and shelving is exactly the human saying "stop spending attention here". So the
510
- // price of a retained archive is one enumerated record, NOT a git walk per poll.
511
- if (r.archived) return Promise.resolve(base)
610
+ // @@@ projected shelves cost nothing - a cold archived session ([[archive]]) keeps its record but leaves
611
+ // the working-set projection and skips the per-worktree spec delta. An archived runtime hazard is still
612
+ // projected active by listSessions, so it deliberately retains the same ops in both full and splice builds.
613
+ if (!isActive(r)) return Promise.resolve(base)
512
614
  return guardWorktree<Worktree>(r.worktree_path,
513
- async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef, mainSha) }),
514
- (): Worktree => ({ ...base, ops: deltaCache.get(r.worktree_path)?.ops ?? [] }))
615
+ (): Worktree => {
616
+ const outcome = deltas.get(r.worktree_path)
617
+ if (!outcome) throw new Error(`layout overlay missing ${r.worktree_path}`)
618
+ if ('error' in outcome) throw outcome.error
619
+ return { ...base, ops: outcome.ops }
620
+ },
621
+ (): Worktree => {
622
+ const cached = deltaCache.get(r.worktree_path)
623
+ if (!cached) throw new Error(`layout overlay failed before ${r.worktree_path} had a last-known result`)
624
+ return { ...base, ops: cached.ops }
625
+ })
515
626
  }))
516
627
  const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
517
628
  ? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
@@ -521,9 +632,9 @@ export async function resolveLayout(): Promise<Layout> {
521
632
  const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
522
633
  const worktrees = [mainRow, ...sessionWorktrees]
523
634
  // drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
524
- // newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
635
+ // newly-cold archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
525
636
  // stranding them in a map nothing prunes.
526
- const live = new Set(records.filter(({ raw }) => !raw.archived).map(({ raw }) => raw.worktree_path))
637
+ const live = new Set(records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path))
527
638
  for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
528
639
  return { main: convention.main || main || root, convention, worktrees }
529
640
  }
@@ -1,12 +1,20 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
3
+ import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, withEventLedgerBuild, type DriftPathEvent } from './git.js'
4
4
  import { loadSpecs, parseFrontmatter } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
6
  import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, resolveSelectors, windowEvents, anchorHitQueries } from './anchors.js'
7
7
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
8
8
 
9
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
10
+ export const SPEC_LINT_REPORT_PROJECTION = 'spex.spec-lint.report'
11
+ export const SPEC_LINT_REPORT_SCHEMA_VERSION = 1
12
+ export type SpecLintReport = {
13
+ projection: typeof SPEC_LINT_REPORT_PROJECTION
14
+ schemaVersion: typeof SPEC_LINT_REPORT_SCHEMA_VERSION
15
+ sourceFiles: string[]
16
+ findings: Finding[]
17
+ }
10
18
 
11
19
  export type LintConfig = {
12
20
  governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
@@ -69,6 +77,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
69
77
  }
70
78
 
71
79
  export type SpecLintOptions = { tip?: string }
80
+ type SpecLintRun = { sourceFiles: string[]; findings: Finding[] }
72
81
 
73
82
  function untrackedAdoptionFiles(root: string): string[] {
74
83
  if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
@@ -126,6 +135,24 @@ export async function pendingTouchesGoverned(root: string, tip: string): Promise
126
135
  }
127
136
 
128
137
  export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
138
+ return (await specLintRun(root, regs, options)).findings
139
+ }
140
+
141
+ export async function specLintReport(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<SpecLintReport> {
142
+ const run = await specLintRun(root, regs, options)
143
+ return {
144
+ projection: SPEC_LINT_REPORT_PROJECTION,
145
+ schemaVersion: SPEC_LINT_REPORT_SCHEMA_VERSION,
146
+ sourceFiles: run.sourceFiles,
147
+ findings: run.findings,
148
+ }
149
+ }
150
+
151
+ function specLintRun(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
152
+ return withEventLedgerBuild(root, () => specLintInLedger(root, regs, options))
153
+ }
154
+
155
+ async function specLintInLedger(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
129
156
  const tip = options.tip ?? 'HEAD'
130
157
  const pending = tip !== 'HEAD'
131
158
  const changed = pending ? pendingChangedPaths(root, tip) : []
@@ -145,11 +172,14 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
145
172
  if (untracked.length) {
146
173
  const shown = untracked.slice(0, 6)
147
174
  const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
148
- return [{
149
- level: 'error',
150
- rule: 'integrity',
151
- msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
152
- }]
175
+ return {
176
+ sourceFiles: [],
177
+ findings: [{
178
+ level: 'error',
179
+ rule: 'integrity',
180
+ msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
181
+ }],
182
+ }
153
183
  }
154
184
  const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
155
185
  const [hidx, didx] = await sourceIndexes(root, tip)
@@ -436,7 +466,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
436
466
  out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no eval staleness). Most: ${worst}` })
437
467
  }
438
468
 
439
- return out
469
+ return { sourceFiles: governed.slice().sort(), findings: out }
440
470
  }
441
471
 
442
472
  export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth