spexcode 0.6.0 → 0.6.1

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 (53) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +68 -47
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/doctor.ts +2 -1
  8. package/spec-cli/src/file-write.ts +22 -0
  9. package/spec-cli/src/git.ts +30 -8
  10. package/spec-cli/src/graphStream.ts +79 -11
  11. package/spec-cli/src/harness.ts +11 -9
  12. package/spec-cli/src/help.ts +8 -13
  13. package/spec-cli/src/index.ts +8 -1
  14. package/spec-cli/src/init.ts +8 -17
  15. package/spec-cli/src/layout.ts +35 -7
  16. package/spec-cli/src/materialize.ts +150 -107
  17. package/spec-cli/src/plugin-harness.ts +19 -8
  18. package/spec-cli/src/process-identity.ts +13 -0
  19. package/spec-cli/src/reviews.ts +55 -11
  20. package/spec-cli/src/sessions.ts +166 -9
  21. package/spec-cli/src/uninstall.ts +2 -1
  22. package/spec-cli/templates/hooks/post-checkout +3 -0
  23. package/spec-cli/templates/hooks/post-merge +1 -0
  24. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  25. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  26. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  27. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  28. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  29. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  30. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  32. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  33. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  34. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  35. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  36. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  37. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  38. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  39. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  41. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  42. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  43. package/spec-dashboard/dist/index.html +2 -2
  44. package/spec-dashboard/src/reviewFilters.js +5 -0
  45. package/spec-dashboard/src/session.js +5 -1
  46. package/spec-eval/src/cli.ts +7 -8
  47. package/spec-eval/src/evaltab.ts +25 -5
  48. package/spec-eval/src/freshness.ts +52 -8
  49. package/spec-eval/src/scenariofresh.ts +58 -8
  50. package/spec-eval/src/scenarios.ts +60 -12
  51. package/spec-eval/src/sessioneval.ts +122 -30
  52. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  53. package/spec-eval/src/matrix.ts +0 -693
@@ -1,7 +1,7 @@
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'
@@ -15,6 +15,24 @@ const US = '\x1f', RS = '\x1e'
15
15
  // than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
16
16
  const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
17
17
  export const BOARD_GIT_CONCURRENCY = 4
18
+ const gitByPath = new Map<string, string>()
19
+
20
+ export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
21
+ const path = env.PATH || ''
22
+ const known = gitByPath.get(path)
23
+ if (known) {
24
+ try { accessSync(known, constants.X_OK); return known } catch {}
25
+ }
26
+ for (const dir of path.split(delimiter)) {
27
+ const candidate = resolve(dir || '.', 'git')
28
+ try {
29
+ accessSync(candidate, constants.X_OK)
30
+ gitByPath.set(path, candidate)
31
+ return candidate
32
+ } catch {}
33
+ }
34
+ throw new Error('git executable not found on PATH')
35
+ }
18
36
  type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
19
37
  type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
20
38
  const gitBuild = new AsyncLocalStorage<GitBuildContext>()
@@ -115,7 +133,7 @@ export function git(args: string[]): string {
115
133
  const env = { ...process.env }
116
134
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
117
135
  try {
118
- return execFileSync('git', withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
136
+ return execFileSync(gitBinary(env), withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
119
137
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
120
138
  }
121
139
 
@@ -123,7 +141,7 @@ function gitBuffer(args: string[], input?: string): Buffer {
123
141
  const env = { ...process.env }
124
142
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
125
143
  try {
126
- return execFileSync('git', withBuildLimits(args), {
144
+ return execFileSync(gitBinary(env), withBuildLimits(args), {
127
145
  input,
128
146
  env,
129
147
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -267,7 +285,7 @@ const GIT_MAX_BUFFER = 1 << 24
267
285
  function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
268
286
  return new Promise((resolve, reject) => {
269
287
  if (signal?.aborted) { reject(gitAbortError()); return }
270
- const child = spawn('git', args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
288
+ const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
271
289
  const stdout: Buffer[] = [], stderr: Buffer[] = []
272
290
  let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
273
291
  let spawnError: Error | null = null
@@ -338,7 +356,7 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
338
356
  function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
339
357
  return new Promise((resolve, reject) => {
340
358
  if (signal?.aborted) { reject(gitAbortError()); return }
341
- const child = spawn('git', args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
359
+ const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
342
360
  const stdout: Buffer[] = [], stderr: Buffer[] = []
343
361
  let settled = false, aborted = false, timedOut = false
344
362
  const killTree = () => {
@@ -826,8 +844,12 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
826
844
  return value as IdentityRawRecord[]
827
845
  }
828
846
  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 }> {
847
+ export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
830
848
  const env = { ...process.env }
849
+ for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
850
+ if (value === undefined) delete env[key]
851
+ else env[key] = value
852
+ }
831
853
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
832
854
  if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
833
855
  const context = inheritedContext()
@@ -2091,7 +2113,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
2091
2113
  return new Promise((resolve) => {
2092
2114
  const env = { ...process.env }
2093
2115
  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'],
2116
+ execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
2095
2117
  { encoding: 'utf8', env, maxBuffer: 1 << 24 },
2096
2118
  // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
2097
2119
  // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
@@ -1,9 +1,9 @@
1
1
  import { streamSSE } from 'hono/streaming'
2
2
  import type { Context } from 'hono'
3
3
  import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
4
- import { join, dirname, relative, resolve } from 'node:path'
5
- import { sessionsRoot, gitCommonDir } from './layout.js'
6
- import { hotSignature, warmSignature, listSessions } from './sessions.js'
4
+ import { join, dirname, relative, resolve, basename } from 'node:path'
5
+ import { sessionsRoot, gitCommonDir, sessionBranchIndex, mainBranch } from './layout.js'
6
+ import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js'
7
7
  import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js'
8
8
  import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
9
9
  import {
@@ -217,6 +217,16 @@ export const addPendingGraphChange = (pending: PendingGraphChanges, scope: Scope
217
217
  sessions: pending.sessions || scope === 'sessions',
218
218
  })
219
219
 
220
+ // Git creates the registry entry before the session transaction publishes its durable record. A registry
221
+ // event for that private candidate must not start a full board build while the `git worktree add` child is
222
+ // still running; the create route flushes the deferred event after publication (or cleanup).
223
+ export function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean {
224
+ const entry = relativePath.split(/[\\/]+/).filter(Boolean)[0] ?? ''
225
+ if (!entry) return false
226
+ for (const path of candidatePaths) if (basename(resolve(path)) === entry) return true
227
+ return false
228
+ }
229
+
220
230
  // under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
221
231
  const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
222
232
  function traceLatency(stage: 'sessions-signal' | 'session-projection-complete' | 'broadcast', detail: Record<string, unknown> = {}): void {
@@ -466,7 +476,7 @@ function ensureWatcher(root: string): void {
466
476
  root,
467
477
  source: 'store',
468
478
  scope: 'sessions',
469
- onInput: () => fireChanged('sessions'),
479
+ onInput: () => { dropBranchIndex(); fireChanged('sessions') }, // a created/renamed session may own a new branch
470
480
  onFailure: (error) => {
471
481
  if (storeWatcher === registry) storeWatcher = null
472
482
  noteSourceFailure('store', error)
@@ -492,9 +502,34 @@ type RegistryGroup = {
492
502
  let refsWatchers: RegistryGroup | null = null
493
503
  const REFS_OBSERVER = 'graph:refs'
494
504
 
505
+ // @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
506
+ // every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
507
+ // and a bot that commits continuously that means nothing is ever warm: observed on z-code as an input
508
+ // generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
509
+ // tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
510
+ // `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
511
+ // the index is read from the session store, so it is only ever stale when that store moved — and the store
512
+ // has its own watcher, which drops it. A ref burst therefore costs one map lookup, not 76 record reads.
513
+ let branchIndexMemo: Map<string, string> | null = null
514
+ function branchIndex(): Map<string, string> {
515
+ return (branchIndexMemo ??= sessionBranchIndex())
516
+ }
517
+ function dropBranchIndex(): void { branchIndexMemo = null }
518
+
519
+ export function evalTargetForRef(ref: string | undefined, base: string, branches: Map<string, string>): EvalTarget | undefined {
520
+ if (ref === undefined) return 'all' // an unnamed movement must assume the worst
521
+ const rel = ref.replace(/\\/g, '/')
522
+ if (!rel.startsWith('heads/')) return undefined // tags and remotes feed no session fingerprint
523
+ const branch = rel.slice('heads/'.length)
524
+ if (!branch) return 'all'
525
+ if (branch === base) return 'all' // every merge-base may have moved
526
+ const id = branches.get(branch)
527
+ return id ? { id } : undefined // a branch no session owns moves no session's evaluation
528
+ }
529
+
495
530
  export function watchSessionEvalRefs(
496
531
  common: string,
497
- onInput: () => void,
532
+ onInput: (ref?: string) => void,
498
533
  onFailure: (error: Error) => void,
499
534
  ): RegistryGroup {
500
535
  let attached: TreeWatcherRegistry[] = []
@@ -517,7 +552,7 @@ export function watchSessionEvalRefs(
517
552
  root: join(common, 'refs'),
518
553
  source: 'refs',
519
554
  scope: 'full',
520
- onInput: () => onInput(),
555
+ onInput: (_event, rel) => onInput(rel),
521
556
  onFailure: fail,
522
557
  })
523
558
  attached.push(refs)
@@ -528,7 +563,7 @@ export function watchSessionEvalRefs(
528
563
  source: 'refs-common',
529
564
  scope: 'full',
530
565
  recursive: false,
531
- onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() },
566
+ onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() }, // names nothing -> 'all'
532
567
  onFailure: fail,
533
568
  })
534
569
  attached.push(commonFiles)
@@ -553,7 +588,7 @@ function ensureRefsWatcher(common = activeCommonRoot): void {
553
588
  }
554
589
  if (!mayAttach('refs')) return
555
590
  try {
556
- refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full', 'all'), refsWatcherFailed)
591
+ refsWatchers = watchSessionEvalRefs(common, (ref) => fireChanged('full', evalTargetForRef(ref, mainBranch(), branchIndex())), refsWatcherFailed)
557
592
  noteSourceHealthy('refs')
558
593
  if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
559
594
  } catch (error) {
@@ -745,10 +780,37 @@ function reconcileWorktrees(forceSessionId?: string): Promise<void> {
745
780
  return flight
746
781
  }
747
782
  const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
783
+ let deferredRegistryChange = false
784
+ let deferredRegistryTimer: ReturnType<typeof setTimeout> | null = null
785
+
786
+ function flushDeferredRegistryChange(): void {
787
+ if (!deferredRegistryChange) return
788
+ deferredRegistryChange = false
789
+ if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
790
+ void reconcileWorktrees()
791
+ fireChanged('full', 'all')
792
+ }
793
+
794
+ // A crashed or disconnected create request must not leave the registry event deferred forever. The normal
795
+ // route flush is immediate; this bounded fallback preserves the watcher contract if the process never reaches
796
+ // its response boundary.
797
+ function deferRegistryChange(): void {
798
+ deferredRegistryChange = true
799
+ if (deferredRegistryTimer) return
800
+ deferredRegistryTimer = setTimeout(() => {
801
+ deferredRegistryTimer = null
802
+ flushDeferredRegistryChange()
803
+ }, 10_000)
804
+ deferredRegistryTimer.unref?.()
805
+ }
806
+
807
+ export function flushDeferredWorktreeRegistryChange(): void {
808
+ flushDeferredRegistryChange()
809
+ }
748
810
 
749
811
  export function watchSessionEvalRegistry(
750
812
  dir: string,
751
- onInput: () => void,
813
+ onInput: (event: 'rename' | 'change', relativePath: string) => void,
752
814
  onFailure: (error: Error) => void,
753
815
  ): TreeWatcherRegistry {
754
816
  let ready = false
@@ -758,7 +820,7 @@ export function watchSessionEvalRegistry(
758
820
  source: 'worktree-registry',
759
821
  scope: 'full',
760
822
  recursive: false,
761
- onInput: () => onInput(),
823
+ onInput: (_event, relativePath) => onInput(_event, relativePath),
762
824
  onFailure: (error) => {
763
825
  if (ready) onFailure(error)
764
826
  else attachError = error
@@ -807,7 +869,11 @@ async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
807
869
  catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
808
870
  // a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
809
871
  // reconcile the per-worktree `.spec` watchers on every registry event.
810
- registryWatcher = watchSessionEvalRegistry(dir, () => {
872
+ registryWatcher = watchSessionEvalRegistry(dir, (_event, relativePath) => {
873
+ if (isSessionCreateCandidateRegistryEvent(relativePath, pendingSessionCreateWorktreePaths())) {
874
+ deferRegistryChange()
875
+ return
876
+ }
811
877
  void reconcileWorktrees()
812
878
  fireChanged('full', 'all')
813
879
  }, registryWatcherFailed)
@@ -841,6 +907,8 @@ export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<
841
907
  export function closeBoardFileWatchers(): void {
842
908
  watcherEra++
843
909
  if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
910
+ if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
911
+ deferredRegistryChange = false
844
912
  heldSources.clear()
845
913
  repairStep = 0
846
914
  repairing = false
@@ -18,6 +18,7 @@ import { git } from './git.js'
18
18
  import { shQuote } from './sh.js'
19
19
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
20
20
  import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
21
+ import { writeFileIfChanged } from './file-write.js'
21
22
 
22
23
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
23
24
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -702,10 +703,12 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
702
703
  // thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
703
704
  `export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
704
705
  // The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
705
- // resume resolves its existing session/thread binding and therefore cannot jump an old conversation to a
706
- // replacement root. It prints only shell assignments for the exact proven endpoint.
706
+ // resume resolves its existing session/thread binding, so a LIVE root never has its conversation moved to a
707
+ // replacement. Both spellings carry the server command because either may be the launch that has to start a
708
+ // root: after a host restart the bound generation is a corpse, and resume rebuilds one to load the same
709
+ // on-disk rollout. It prints only shell assignments for the exact proven endpoint.
707
710
  'if [ "$1" = "--resume" ]; then',
708
- ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
711
+ ` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" ${shQuote(server)} )" || exit 1`,
709
712
  'else',
710
713
  ` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
711
714
  'fi',
@@ -1797,17 +1800,16 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
1797
1800
  // idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
1798
1801
  // comment STYLE is a parameter so ONE primitive serves every managed file — HTML for the md contracts
1799
1802
  // (CLAUDE.md/AGENTS.md), `#` for .gitignore — instead of a per-file-type writer. Default = HTML (the md case).
1800
- export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): void {
1803
+ export function writeManagedBlock(file: string, body: string, comment: readonly [string, string] = ['<!-- ', ' -->']): boolean {
1801
1804
  const [open, close] = comment
1802
1805
  const START = `${open}spexcode:start${close}`
1803
1806
  const END = `${open}spexcode:end${close}`
1804
1807
  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1805
1808
  const block = `${START}\n${body}\n${END}`
1806
- let cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
1809
+ const cur = existsSync(file) ? readFileSync(file, 'utf8') : ''
1807
1810
  const re = new RegExp(`${esc(START)}[\\s\\S]*?${esc(END)}`)
1808
- if (re.test(cur)) cur = cur.replace(re, block)
1809
- else cur = cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
1810
- writeFileSync(file, cur)
1811
+ const next = re.test(cur) ? cur.replace(re, block) : cur.trim() ? `${cur.replace(/\n*$/, '')}\n\n${block}\n` : `${block}\n`
1812
+ return writeFileIfChanged(file, next)
1811
1813
  }
1812
1814
 
1813
1815
  // the INVERSE of writeManagedBlock: strip the spexcode sentinel block (with the blank space around it),
@@ -1916,7 +1918,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
1916
1918
  const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`
1917
1919
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
1918
1920
  if (!existsSync(home)) mkdirSync(home, { recursive: true })
1919
- writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
1921
+ writeFileIfChanged(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
1920
1922
  return file
1921
1923
  }
1922
1924
 
@@ -44,8 +44,8 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
44
44
  ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
45
45
  'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
46
46
  resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
47
- watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
48
- 'Streams lifecycle transitions and blocks until killed; `session wait` is the one-shot alternative.', ['selector']],
47
+ watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
48
+ 'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
49
49
  wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
50
50
  `EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
51
51
  the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
@@ -79,7 +79,7 @@ you are running in, mid-turn. Your own ending is a declaration: \`done --propose
79
79
  done: ['spex session done --propose merge|nothing|close [--note T]',
80
80
  '`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` declares done: committed work, but no merge proposal. `close` declares close-pending: PROPOSE discarding this worktree — the human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
81
81
  park: ['spex session park --note <what-you-await>',
82
- 'Declare parked only when a real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
82
+ 'Declare parked only when a managed watch delivery or real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
83
83
  ask: ['spex session ask --note <your-question>',
84
84
  'Declare asking when the session needs a human reply or direction. It resumes only when the human replies; a background wake-up is parked instead.'],
85
85
  attach: ['spex session attach <SEL>', `Attaches the current terminal to the worker's tmux (detach: C-b d) and blocks until detached.
@@ -273,7 +273,7 @@ edit the spec instead — same commit as the code.`,
273
273
  see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
274
274
  },
275
275
  eval: {
276
- line: 'eval <verb> the measurement system: add · ls · scenario ls/write · matrix · lint · ok · retract · clean',
276
+ line: 'eval <verb> the measurement system: add · ls · scenario ls/write · lint · ok · retract · clean',
277
277
  body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
278
278
  [--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
279
279
  spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
@@ -281,7 +281,6 @@ edit the spec instead — same commit as the code.`,
281
281
  spex eval ls --session <SEL> --export [--open | --out <path>]
282
282
  spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; JSON = canonical index
283
283
  spex eval scenario write --mutation <json> < eval.md propose one canonical metadata mutation
284
- spex eval matrix <launcher> [--node <id>] [--rows k1,k2] the harness live-behavior matrix
285
284
  spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
286
285
  spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest measurement
287
286
  spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
@@ -305,12 +304,6 @@ scenario write — the fixed-tree declaration writer for an external measurement
305
304
  eval.md, --mutation is one closed JSON insert/delete request for one scenario's test metadata, and stdout is only
306
305
  the proposed eval.md bytes. It reads no worktree or runner and fails without stdout on malformed or ambiguous input.
307
306
 
308
- matrix — run the eight-row harness live-behavior matrix against a REAL dispatched session of the named
309
- launcher (the harness-adapter acceptance rule, defined once in spec-eval/src/matrix.ts): it syncs the
310
- rows into the \`<harness>-harness\` node's eval.md scenarios, drives one worker through undeclared-stop ·
311
- pretooluse-block · ask-note · deliver-steer · resume · liveness · commit-gate · close-residue, and files
312
- a per-row measurement with its evidence transcript. A new harness needs only its launcher + spec node.
313
-
314
307
  lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
315
308
  stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source with no eval.md
316
309
  (eval-coverage — the same name and shape as spec lint's coverage, one rule per layer) · over-owned
@@ -439,10 +432,12 @@ export function commandHelp(name: string, verb?: string): string | null {
439
432
  return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`
440
433
  }
441
434
 
442
- export function sessionLaunchReceipt(id: string): string {
435
+ export function sessionLaunchReceipt(id: string, managedWatch = false): string {
443
436
  return `spex: launched session ${id}
444
437
  current result: the session JSON is on stdout now; \`spex session ls ${id}\` is the later one-shot snapshot
445
- next lifecycle change: background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` streams and NEVER EXITS
438
+ next lifecycle change: ${managedWatch
439
+ ? `managed watch registered — this parent receives ${id}'s state changes through its normal send queue; \`spex session watch cancel ${id}\` ends it`
440
+ : `background \`spex session wait ${id}\` (edge-triggered; exits on the next non-actionable→actionable transition); \`spex session watch ${id}\` registers send-backed delivery when the caller is governed`}; \`spex session watch stream ${id}\` NEVER EXITS
446
441
  response channel: \`spex session send ${id} "<msg>"\`; \`send --keys\` is an UNSTABLE LAST RESORT after a plain send cannot land`
447
442
  }
448
443
 
@@ -15,7 +15,7 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
15
15
  import { summarizeLoopIn } from './mentions.js'
16
16
  import { resolveLayout, mainBranch } from './layout.js'
17
17
  import { getBoardJson } from './graphCache.js'
18
- import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
18
+ import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js'
19
19
  import { gitA, gitTry, repoRoot } from './git.js'
20
20
  import { cockpitReview } from './cockpit.js'
21
21
  import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
@@ -455,12 +455,19 @@ app.post('/api/sessions', async (c) => {
455
455
  try {
456
456
  const body = await c.req.json().catch(() => null)
457
457
  const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
458
+ // The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
459
+ // wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
460
+ if (result.status === 201) notifyBoardChanged('sessions')
461
+ // A candidate registry event is intentionally held while Git creates the private worktree. Once the
462
+ // transaction has published or cleaned up its record, release the one deferred full refresh.
463
+ flushDeferredWorktreeRegistryChange()
458
464
  if (result.status === 201) {
459
465
  c.header('Idempotency-Key', requestKey)
460
466
  return c.json(result.session, 201)
461
467
  }
462
468
  return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
463
469
  } finally {
470
+ flushDeferredWorktreeRegistryChange()
464
471
  rawSignal.removeEventListener('abort', cancelFromRequest)
465
472
  outgoing?.off('close', cancel)
466
473
  }
@@ -1,29 +1,20 @@
1
1
  import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
2
- import { createHash } from 'node:crypto'
3
2
  import { join, resolve, relative, dirname } from 'node:path'
4
3
  import { fileURLToPath } from 'node:url'
5
4
  import { execFileSync } from 'node:child_process'
6
5
  import { readConfig, readJsonConfig } from './layout.js'
7
6
  import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
7
+ import { gitBinary } from './git.js'
8
8
 
9
9
  // this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
10
10
  // launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
11
11
  const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
12
12
  const TEMPLATES = join(pkgRoot, 'templates')
13
13
  const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
14
- const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
15
- 'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
16
- 'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
17
- }
18
-
19
- function hookDigest(path: string): string {
20
- return createHash('sha256').update(readFileSync(path)).digest('hex')
21
- }
22
14
 
23
- function isManagedHook(path: string, name: string): boolean {
15
+ function isManagedHook(path: string): boolean {
24
16
  const source = readFileSync(path, 'utf8')
25
17
  return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
26
- || LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
27
18
  }
28
19
 
29
20
  function replaceHook(source: string, dest: string): void {
@@ -71,7 +62,7 @@ function copyTreeNoClobber(srcDir: string, destDir: string, base: string): strin
71
62
  // hooks/ (shared across all worktrees). Returns null (with a loud reason) when <dir> isn't a git repo.
72
63
  function resolveHooksDir(dir: string): string | null {
73
64
  try {
74
- const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
65
+ const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
75
66
  encoding: 'utf8',
76
67
  stdio: ['ignore', 'pipe', 'ignore'], // swallow git's own "fatal: not a git repository" — our warning is the signal
77
68
  }).trim()
@@ -86,8 +77,8 @@ function resolveHooksDir(dir: string): string | null {
86
77
  // turn that feature branch into trunk).
87
78
  function adoptionMainBranch(dir: string): string {
88
79
  try {
89
- const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
90
- const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
80
+ const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
81
+ const branch = execFileSync(gitBinary(process.env), ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
91
82
  if (branch) return branch
92
83
  } catch { /* render the one product-level repair below */ }
93
84
  const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
@@ -113,7 +104,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
113
104
  // NOT `git init` for them: creating a repo is a side effect beyond init's remit (a subdir, a dir not
114
105
  // meant as a repo root), and `git init` is one deliberate command.
115
106
  try {
116
- execFileSync('git', ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
107
+ execFileSync(gitBinary(process.env), ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
117
108
  } catch {
118
109
  console.error(`spex init: ${targetDir} is not a git repository. SpexCode is git-backed (git is the version database; the hooks live in .git). Run \`git init\` there first, then \`spex init\`.`)
119
110
  process.exit(1)
@@ -210,8 +201,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
210
201
  const source = join(hooksSrc, e.name)
211
202
  const dest = join(hooksDir, e.name)
212
203
  if (existsSync(dest)) {
213
- if (hookDigest(dest) === hookDigest(source)) continue
214
- if (isManagedHook(dest, e.name)) {
204
+ if (readFileSync(dest).equals(readFileSync(source))) continue
205
+ if (isManagedHook(dest)) {
215
206
  replaceHook(source, dest)
216
207
  refreshed.push(e.name)
217
208
  } else {
@@ -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 —
@@ -448,6 +462,20 @@ export function listSessionIds(): string[] {
448
462
  return ents.filter((d) => d.isDirectory()).map((d) => d.name)
449
463
  }
450
464
 
465
+ // @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
466
+ // on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
467
+ // map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
468
+ // every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
469
+ export function sessionBranchIndex(): Map<string, string> {
470
+ const index = new Map<string, string>()
471
+ for (const id of listSessionIds()) {
472
+ let branch: unknown
473
+ try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
474
+ if (typeof branch === 'string' && branch) index.set(branch, id)
475
+ }
476
+ return index
477
+ }
478
+
451
479
  // memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
452
480
  // sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
453
481
  // content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is