spexcode 0.5.9 → 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 (58) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +69 -70
  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/delivery-queue.ts +107 -0
  8. package/spec-cli/src/doctor.ts +2 -1
  9. package/spec-cli/src/file-write.ts +22 -0
  10. package/spec-cli/src/git.ts +30 -8
  11. package/spec-cli/src/graphStream.ts +79 -11
  12. package/spec-cli/src/harness.ts +12 -10
  13. package/spec-cli/src/help.ts +9 -14
  14. package/spec-cli/src/index.ts +10 -2
  15. package/spec-cli/src/init.ts +8 -17
  16. package/spec-cli/src/layout.ts +35 -7
  17. package/spec-cli/src/materialize.ts +150 -107
  18. package/spec-cli/src/plugin-harness.ts +19 -8
  19. package/spec-cli/src/process-identity.ts +13 -0
  20. package/spec-cli/src/reviews.ts +55 -11
  21. package/spec-cli/src/session-cursors.ts +11 -17
  22. package/spec-cli/src/session-follow.ts +6 -6
  23. package/spec-cli/src/sessions.ts +224 -38
  24. package/spec-cli/src/uninstall.ts +2 -1
  25. package/spec-cli/templates/hooks/post-checkout +3 -0
  26. package/spec-cli/templates/hooks/post-merge +1 -0
  27. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  28. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
  29. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
  30. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  32. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  33. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  34. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  35. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  36. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  37. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  38. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  39. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  40. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  41. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  42. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  43. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  44. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  45. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  46. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  47. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  48. package/spec-dashboard/dist/index.html +2 -2
  49. package/spec-dashboard/src/reviewFilters.js +5 -0
  50. package/spec-dashboard/src/session.js +5 -1
  51. package/spec-eval/src/cli.ts +7 -8
  52. package/spec-eval/src/evaltab.ts +25 -5
  53. package/spec-eval/src/freshness.ts +52 -8
  54. package/spec-eval/src/scenariofresh.ts +58 -8
  55. package/spec-eval/src/scenarios.ts +60 -12
  56. package/spec-eval/src/sessioneval.ts +122 -30
  57. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  58. package/spec-eval/src/matrix.ts +0 -693
@@ -0,0 +1,107 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
4
+
5
+ // @@@ delivery-queue - what a session still OWES its agent. The log ([[session-timeline]]) is the record and
6
+ // grows forever; this is the debt and is consumed, so it lives in its own small file whose resting state is
7
+ // EMPTY. Nothing here reads the log: an entry carries the text it will hand over, so history could be trimmed
8
+ // or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
9
+ // queue is only ever filled by an enqueue — which is why no backlog migration exists.
10
+
11
+ export type PendingMessage = { mid: string; text: string; from: string | null }
12
+
13
+ const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
14
+
15
+ // @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
16
+ // is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
17
+ // record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
18
+ // and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
19
+ // so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
20
+ const lockRoot = (): string => join(runtimeRoot(), '.delivery-locks')
21
+ const lockPath = (id: string): string => join(lockRoot(), `${id}.lock`)
22
+
23
+ const pause = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
24
+
25
+ async function acquire(id: string, timeoutMs: number): Promise<(() => void) | null> {
26
+ mkdirSync(lockRoot(), { recursive: true })
27
+ const path = lockPath(id), deadline = Date.now() + timeoutMs
28
+ for (;;) {
29
+ try {
30
+ const fd = openSync(path, 'wx')
31
+ writeSync(fd, String(process.pid))
32
+ closeSync(fd)
33
+ return () => { try { unlinkSync(path) } catch { /* a liveness reclaim already removed it */ } }
34
+ } catch (e) {
35
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
36
+ let owner = 0
37
+ try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
38
+ if (owner && owner !== process.pid) {
39
+ try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
40
+ }
41
+ // A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
42
+ // and the retry sweep will come back. Declining is not a lost message.
43
+ if (Date.now() >= deadline) return null
44
+ await pause(25)
45
+ }
46
+ }
47
+ }
48
+
49
+ function read(id: string): PendingMessage[] {
50
+ try {
51
+ const raw = JSON.parse(readFileSync(queuePath(id), 'utf8')) as unknown
52
+ if (!Array.isArray(raw)) return []
53
+ return raw.filter((m): m is PendingMessage =>
54
+ !!m && typeof m === 'object'
55
+ && typeof (m as PendingMessage).mid === 'string'
56
+ && typeof (m as PendingMessage).text === 'string')
57
+ } catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
58
+ }
59
+
60
+ // Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
61
+ // one existsSync on the sweep's hot path.
62
+ function write(id: string, msgs: PendingMessage[]): void {
63
+ const path = queuePath(id)
64
+ if (!msgs.length) { try { unlinkSync(path) } catch { /* already gone */ } ; return }
65
+ mkdirSync(sessionStoreDir(id), { recursive: true })
66
+ const tmp = `${path}.${process.pid}.tmp`
67
+ writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n')
68
+ renameSync(tmp, path)
69
+ }
70
+
71
+ // The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
72
+ // both, and the record is written first, so a crash between them leaves a message visible but undelivered —
73
+ // never delivered but unrecorded.
74
+ export function enqueue(id: string, msg: PendingMessage): void {
75
+ write(id, [...read(id), msg])
76
+ }
77
+
78
+ export const pendingMessages = (id: string): PendingMessage[] => read(id)
79
+
80
+ export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
81
+
82
+ // Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
83
+ // then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
84
+ // still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
85
+ // one. Returns how many were handed over and how many are still owed.
86
+ export async function drain(
87
+ id: string,
88
+ insert: (msg: PendingMessage) => Promise<boolean>,
89
+ timeoutMs = 5_000,
90
+ ): Promise<{ delivered: number; remaining: number }> {
91
+ const release = await acquire(id, timeoutMs)
92
+ if (!release) return { delivered: 0, remaining: read(id).length }
93
+ let delivered = 0
94
+ try {
95
+ for (;;) {
96
+ const queued = read(id)
97
+ if (!queued.length) return { delivered, remaining: 0 }
98
+ let ok = false
99
+ try { ok = await insert(queued[0]) } catch { ok = false }
100
+ if (!ok) return { delivered, remaining: queued.length }
101
+ // Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
102
+ // stale snapshot minus the head would silently drop it.
103
+ write(id, read(id).filter((m) => m.mid !== queued[0].mid))
104
+ delivered++
105
+ }
106
+ } finally { release() }
107
+ }
@@ -7,6 +7,7 @@ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
7
7
  import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
8
8
  import { loadConfig } from './lint.js'
9
9
  import { trackedSourceFiles } from './source-files.js'
10
+ import { gitBinary } from './git.js'
10
11
 
11
12
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
12
13
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -14,7 +15,7 @@ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
14
15
 
15
16
  // run a git query in `dir`, swallowing git's own stderr — a non-repo returns null (the absence IS the signal).
16
17
  function git(dir: string, args: string[]): string | null {
17
- try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
18
+ try { return execFileSync(gitBinary(process.env), ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
18
19
  catch { return null }
19
20
  }
20
21
  function repoRoot(dir: string): string | null { return git(dir, ['rev-parse', '--show-toplevel']) }
@@ -0,0 +1,22 @@
1
+ import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ export function writeFileIfChanged(path: string, content: string | Uint8Array): boolean {
4
+ const next = Buffer.from(content)
5
+ try {
6
+ if (readFileSync(path).equals(next)) return false
7
+ } catch (error: any) {
8
+ if (error?.code !== 'ENOENT') throw error
9
+ }
10
+ writeFileSync(path, content)
11
+ return true
12
+ }
13
+
14
+ export function copyFileIfChanged(source: string, target: string): boolean {
15
+ try {
16
+ if (readFileSync(target).equals(readFileSync(source))) return false
17
+ } catch (error: any) {
18
+ if (error?.code !== 'ENOENT') throw error
19
+ }
20
+ copyFileSync(source, target)
21
+ return true
22
+ }
@@ -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
@@ -282,7 +283,7 @@ export interface Harness {
282
283
  // write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
283
284
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
284
285
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
285
- // `ok=false` leaves the durable timeline line for the turn-boundary reader.
286
+ // `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
286
287
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
287
288
  // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
288
289
  // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
@@ -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,10 +44,10 @@ 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
- `EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own inbox, and exits 0 on
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
52
52
  status into an actionable one (stdout = the observed path, e.g. working→review; read the LAST
53
53
  token as the status reached), or a message arriving for you (stdout = message). The arrival
@@ -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,10 +15,10 @@ 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
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
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'
22
22
  import { readTimeline } from './session-timeline.js'
23
23
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
24
24
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.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
  }
@@ -700,6 +707,7 @@ injectWebSocket(server)
700
707
  superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
701
708
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
702
709
  superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
710
+ superviseDelivery() // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
703
711
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
704
712
 
705
713
  let graphWatchersClosed = false