spexcode 0.5.8 → 0.6.0

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 (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -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
+ }
@@ -1,8 +1,3 @@
1
- // @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
2
- // spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
3
- // process in the launch, the workflow reaches it only through the files materialize() writes. The audit
4
- // loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
5
- // `--contract` and `--conflicts` remain focused representations of that same diagnosis.
6
1
  import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
7
2
  import { join, dirname, basename } from 'node:path'
8
3
  import { fileURLToPath } from 'node:url'
@@ -179,8 +174,6 @@ function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
179
174
  return lines
180
175
  }
181
176
 
182
- // @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
183
- // folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
184
177
  function contractText(): { names: string[]; body: string } {
185
178
  let cfgs: { name: string; body: string }[] = []
186
179
  try { cfgs = loadSystemConfig().map((c) => ({ name: c.name, body: c.body })) } catch { /* tree-less cwd → empty */ }
@@ -223,15 +216,6 @@ function manifestScripts(text: string): string[] {
223
216
  return [...out]
224
217
  }
225
218
 
226
- // @@@ double-delivery - the SILENT conflict on the OTHER axis from under-delivery: not "did the contract
227
- // land?" but "did it land TWICE?". A self-launched agent can be reached by BOTH the loose native delivery
228
- // materialize() writes into the worktree AND a `spexcode` plugin bundle the user installed independently
229
- // (Claude marketplace) or a stale leftover — doubling every hook (dispatch.sh fires per copy), shadowing
230
- // skills, confusing the `/` menu. We never sniff payload: every count is by IDENTITY STAMP — a shim's
231
- // `dispatch.sh` command line (the hook-routing stamp, the same one cleanHarness keys on), a plugin.json
232
- // `name:"spexcode"` (the bundle stamp), and our own materialized skill NAMES. Per harness we count, on three
233
- // channels, how many spexcode-stamped copies reach the agent; any channel >1 is a double-delivery conflict.
234
-
235
219
  // a plugin bundle dir found under a harness's plugins root, carrying a spexcode stamp.
236
220
  type Bundle = { dir: string; name: string; scope: string; hooksToDispatch: boolean; skillsDir: string }
237
221
 
@@ -1,16 +1,3 @@
1
- // @@@ gateway auth - the ONE authorization mechanism of the multi-project gateway ([[gateway-hub]]).
2
- // Everything about passwords and visitors lives HERE, at the gateway: the project backends stay loopback
3
- // internal services that never see a credential, a cookie, or a visitor identity. Two signed scopes only:
4
- // an ADMIN session grants /projects (management) plus every /p/:projectId route; a PROJECT session grants
5
- // exactly its own /p/:projectId route. A project with no configured password is open; with no admin
6
- // password, loopback may manage implicitly while non-loopback /projects stays locked.
7
- //
8
- // Secrets discipline: password VERIFIERS (scrypt, salted) live only in the per-user private store
9
- // (~/.spexcode/gateway/auth.json, 0600) — never in a repo, never in a backend record, never plaintext.
10
- // Session tokens are HMAC-signed claims under a random per-user secret, so they survive a gateway restart
11
- // with no server-side session table, verified in constant time. Each verifier carries a random `gen` that
12
- // rotates on every set/clear and is embedded in the tokens it authenticates — changing or clearing a
13
- // password instantly invalidates every session it minted.
14
1
  import { createHmac, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
15
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
3
  import { dirname, join } from 'node:path'
@@ -1,8 +1,3 @@
1
- // @@@ public gateway - the internet face of `spex serve --public`. The supervisor (supervise.ts) and its
2
- // Hono child stay bound to 127.0.0.1; THIS is the only listener on 0.0.0.0. It terminates TLS, gates every
3
- // request behind one password (a designed login → signed cookie), serves the built dashboard, and reverse-
4
- // proxies /api + the terminal WebSocket to the loopback supervisor. Loopback is the trust boundary (local
5
- // agents hit the supervisor directly, no password); the gateway is the boundary crossed from outside.
6
1
  import http from 'node:http'
7
2
  import https from 'node:https'
8
3
  import net from 'node:net'
@@ -17,10 +12,6 @@ import { loginPage } from './login-page.js'
17
12
  import { listenOrExit } from './listen.js'
18
13
  import { installConnectionReaper } from './reaper.js'
19
14
 
20
- // @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
21
- // chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
22
- // is off (the supervisor then serves plain loopback, unchanged). process.argv carries the `spex serve …`
23
- // flags since the supervisor runs in the same process as the CLI command.
24
15
  export type PublicConfig = { password: string; tls: { cert: string; key: string } | null }
25
16
  function argFlag(name: string): string | undefined {
26
17
  const i = process.argv.indexOf(`--${name}`)
@@ -82,10 +73,6 @@ function selfSignedCert(): { cert: string; key: string } {
82
73
  return { cert: readFileSync(certFile, 'utf8'), key: readFileSync(keyFile, 'utf8') }
83
74
  }
84
75
 
85
- // @@@ cookie auth - the gate is a designed login, NOT the browser's Basic dialog. The auth cookie is a
86
- // keyed HMAC of a constant under a secret DERIVED from the password, so it (a) survives a restart with no
87
- // server-side session store and (b) reveals nothing about the password. Verified in constant time. The same
88
- // cookie authorises /api and the WebSocket upgrade — the browser sends it on the same-origin handshake.
89
76
  const COOKIE = 'spex_auth'
90
77
  function authToken(password: string): string {
91
78
  const secret = createHmac('sha256', password).update('spexcode-public-gateway-v1').digest()
@@ -150,20 +137,11 @@ export function startGateway(opts: GatewayOpts): void {
150
137
  return serveStatic(req, res, opts.distDir, url)
151
138
  }
152
139
 
153
- // server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
154
- // public server in public mode, so it carries the SAME reaping as the child: the socket-level
155
- // `installConnectionReaper` (reaper.ts), the single owner of the header/idle deadlines — it disables
156
- // Node's own overlapping HTTP timeouts, which were measured to race and shadow it (issue #65), so no
157
- // timeout options are passed here. Idle keep-alive / slow-loris / never-completing request only; the
158
- // gated WS upgrade (handled below) is an active stream and exempt for its lifetime.
159
140
  const server = secure
160
141
  ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, handler)
161
142
  : http.createServer(handler)
162
143
  installConnectionReaper(server)
163
144
 
164
- // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
165
- // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
166
- // upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
167
145
  server.on('upgrade', (req, socket, head) => {
168
146
  if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
169
147
  const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
@@ -4,6 +4,7 @@ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSy
4
4
  import { join, isAbsolute, resolve } from 'node:path'
5
5
  import { createHash, randomBytes } from 'node:crypto'
6
6
  import { projectRuntimeRoot } from './project-store.js'
7
+ import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
7
8
 
8
9
  const US = '\x1f', RS = '\x1e'
9
10
 
@@ -152,9 +153,32 @@ export function isGitObjectId(root: string, value: string): boolean {
152
153
  // Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
153
154
  // batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
154
155
  // changing the returned bytes or object ids.
155
- export function batchRevisionOids(root: string, revisions: string[]): (string | null)[] {
156
+ //
157
+ // These are async for two reasons a synchronous execFileSync cannot serve once the caller batches its WHOLE
158
+ // invocation rather than one reading at a time. A sync child is invisible to the build's permit pool and its
159
+ // abort signal, so a watchdog abort could not kill it; and one build-wide `cat-file --batch` reading tens of
160
+ // megabytes would be a single uninterruptible stretch — exactly the /health-blocking shape [[graph-cache]]
161
+ // closed for the fs walks.
162
+ //
163
+ // Chunking is a real output bound, not ceremony: the async transport caps a child's stdout at GIT_MAX_BUFFER
164
+ // and overflow is a loud error. Revision rows are 41 bytes each, so only the blob read (payload-sized) needs
165
+ // a chunk; the cap is on COUNT because sizes are unknown until git answers, and it is set so an ordinary
166
+ // source corpus never approaches the byte ceiling.
167
+ const BATCH_BLOB_CHUNK = 256
168
+ const BATCH_BLOB_MAX_BUFFER = 1 << 26
169
+ async function batchBuffer(args: string[], input: string, maxBuffer?: number): Promise<Buffer> {
170
+ const env = { ...process.env }
171
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
172
+ try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
173
+ catch (error: any) {
174
+ if (error?.name === 'AbortError') throw error
175
+ warnIfTimedOut(error, args)
176
+ throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
177
+ }
178
+ }
179
+ export async function batchRevisionOids(root: string, revisions: string[]): Promise<(string | null)[]> {
156
180
  if (!revisions.length) return []
157
- const out = gitBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n').toString('utf8')
181
+ const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n')).toString('utf8')
158
182
  const lines = out.split('\n')
159
183
  if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
160
184
  return revisions.map((revision, index) => {
@@ -164,25 +188,28 @@ export function batchRevisionOids(root: string, revisions: string[]): (string |
164
188
  throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`)
165
189
  })
166
190
  }
167
- export function batchBlobTexts(root: string, oids: string[]): Map<string, string> {
191
+ export async function batchBlobTexts(root: string, oids: string[]): Promise<Map<string, string>> {
168
192
  const unique = [...new Set(oids.filter(Boolean))]
169
193
  const files = new Map<string, string>()
170
194
  if (!unique.length) return files
171
195
  for (const oid of unique) if (!isGitObjectId(root, oid)) throw new Error(`invalid object id '${oid}'`)
172
- const out = gitBuffer(['-C', root, 'cat-file', '--batch'], unique.join('\n') + '\n')
173
- let offset = 0
174
- for (const oid of unique) {
175
- const newline = out.indexOf(10, offset)
176
- if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
177
- const header = out.subarray(offset, newline).toString('utf8')
178
- const size = Number(header.match(/ blob (\d+)$/)?.[1])
179
- if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
180
- const start = newline + 1, end = start + size
181
- if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
182
- files.set(oid, out.subarray(start, end).toString('utf8'))
183
- offset = end + 1
196
+ for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
197
+ const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK)
198
+ const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER)
199
+ let offset = 0
200
+ for (const oid of chunk) {
201
+ const newline = out.indexOf(10, offset)
202
+ if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
203
+ const header = out.subarray(offset, newline).toString('utf8')
204
+ const size = Number(header.match(/ blob (\d+)$/)?.[1])
205
+ if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
206
+ const start = newline + 1, end = start + size
207
+ if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
208
+ files.set(oid, out.subarray(start, end).toString('utf8'))
209
+ offset = end + 1
210
+ }
211
+ if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
184
212
  }
185
- if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
186
213
  return files
187
214
  }
188
215
 
@@ -229,7 +256,9 @@ export function treeFileText(root: string, tip: string, path: string): string |
229
256
  catch { return null }
230
257
  }
231
258
 
232
- type GitExec = { stdout: string; stderr: string }
259
+ // stdout stays a Buffer to the transport's edge: `cat-file --batch` frames each payload by BYTE length, so
260
+ // a decode before framing mis-slices every object after the first multi-byte character. Text callers decode.
261
+ type GitExec = { stdout: Buffer; stderr: string }
233
262
 
234
263
  // execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
235
264
  // deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
@@ -265,14 +294,21 @@ function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, m
265
294
  child.once('close', (code, childSignal) => {
266
295
  clearTimeout(timer)
267
296
  signal?.removeEventListener('abort', onAbort)
268
- const result = { stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }
297
+ const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
269
298
  if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
270
299
  const error: any = spawnError ?? new Error(overflow
271
300
  ? `git output exceeded ${maxBuffer} bytes`
272
301
  : `git exited with ${code ?? childSignal ?? 'unknown status'}`)
273
- error.code = overflow ? 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' : code
302
+ // @@@ a spawn failure keeps its OWN cause - a child that never started still emits `close`, with the
303
+ // negated errno as its code (EACCES arrives as -13). Overwriting the spawn error's `'EACCES'`/`'ENOENT'`
304
+ // with that number turns "git could not RUN" into "git ran and exited", and every caller that separates
305
+ // the two reads the second as a real git answer: the freshness batch concluded "the anchor object is
306
+ // unreadable", session create concluded "the branch does not exist". A machine where git is missing or
307
+ // unexecutable would have been told, silently, that the thing it asked about is absent.
308
+ if (overflow) error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
309
+ else if (!spawnError) error.code = code
274
310
  error.signal = childSignal
275
- error.stdout = result.stdout
311
+ error.stdout = result.stdout.toString('utf8')
276
312
  error.stderr = result.stderr
277
313
  if (aborted) error.name = 'AbortError'
278
314
  if (timedOut) error.spexcodeGitTimeout = true
@@ -326,12 +362,12 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
326
362
  child.on('close', (code, childSignal) => {
327
363
  if (settled) return
328
364
  settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
329
- const result = { stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }
365
+ const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
330
366
  if (code === 0 && !aborted && !timedOut) { resolve(result); return }
331
367
  const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
332
368
  error.code = code
333
369
  error.signal = childSignal
334
- error.stdout = result.stdout
370
+ error.stdout = result.stdout.toString('utf8')
335
371
  error.stderr = result.stderr
336
372
  if (aborted) error.name = 'AbortError'
337
373
  if (timedOut) error.spexcodeGitTimeout = true
@@ -353,7 +389,7 @@ export async function gitA(args: string[], input?: string): Promise<string> {
353
389
  const context = inheritedContext()
354
390
  try {
355
391
  const { stdout } = await execGitForCaller(args, env, undefined, input)
356
- return stdout
392
+ return stdout.toString('utf8')
357
393
  } catch (e: any) {
358
394
  if (context?.signal.aborted || e?.name === 'AbortError') throw e
359
395
  warnIfTimedOut(e, args); return ''
@@ -797,7 +833,7 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
797
833
  const context = inheritedContext()
798
834
  try {
799
835
  const { stdout, stderr } = await execGitForCaller(args, env)
800
- return { ok: true, stdout, stderr }
836
+ return { ok: true, stdout: stdout.toString('utf8'), stderr }
801
837
  } catch (e: any) {
802
838
  if (context?.signal.aborted || e?.name === 'AbortError') throw e
803
839
  warnIfTimedOut(e, args)
@@ -809,7 +845,7 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
809
845
  export async function gitRequiredA(args: string[], purpose: string): Promise<string> {
810
846
  const env = { ...process.env }
811
847
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
812
- try { return (await execGitStreamForCaller(args, env)).stdout }
848
+ try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
813
849
  catch (error: any) {
814
850
  if (error?.name === 'AbortError') throw error
815
851
  warnIfTimedOut(error, args)
@@ -990,7 +1026,7 @@ const indexCache = new Map<string, Promise<HistoryIndex>>()
990
1026
  const indexRoots = new Map<string, string>()
991
1027
  const driftRoots = new Map<string, string>()
992
1028
  const driftIdxCache = new Map<string, Promise<DriftIndex>>()
993
- const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROOTS || 32))
1029
+ const INDEX_ROOT_SLOTS = rootSlots(process.env.SPEXCODE_INDEX_CACHE_ROOTS, 32)
994
1030
 
995
1031
  function rootKey(root: string): string { return resolve(root) }
996
1032
 
@@ -999,23 +1035,9 @@ function indexCacheKey(root: string, head: string): string { return `${eventCach
999
1035
  // A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
1000
1036
  // common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
1001
1037
  // The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
1038
+ // The bookkeeping itself is [[root-lru]]'s — this only names the store and the bound.
1002
1039
  function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
1003
- const key = rootKey(root)
1004
- const previous = roots.get(key)
1005
- if (previous !== cacheKey) {
1006
- roots.set(key, cacheKey)
1007
- if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
1008
- } else {
1009
- roots.delete(key)
1010
- roots.set(key, cacheKey)
1011
- }
1012
- while (roots.size > INDEX_ROOT_SLOTS) {
1013
- const oldest = roots.keys().next().value as string | undefined
1014
- if (oldest === undefined) break
1015
- const oldHead = roots.get(oldest)
1016
- roots.delete(oldest)
1017
- if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
1018
- }
1040
+ touchRootLru(roots, cache, rootKey(root), cacheKey, INDEX_ROOT_SLOTS)
1019
1041
  }
1020
1042
 
1021
1043
  function dropFailed(cache: Map<string, Promise<unknown>>, head: string, promise: Promise<unknown>): void {
@@ -1804,9 +1826,10 @@ export function resetHistoryCachesForTests(): void {
1804
1826
  eventPathMemo.clear(); gitObjectFormatMemo.clear()
1805
1827
  }
1806
1828
  export function historyEventCachePathForTests(root: string): string { return eventCacheLocation(root).path }
1807
- // the reachability set of `sha` itself plus every ancestor as a bitset over the walk's dense ids.
1808
- // Built once per queried sha by following parent edges in memory (no git fork), memoized on the index;
1809
- // a bitset costs history-length BITS, so hundreds of cached shas stay cheap on the board hot path.
1829
+ // @@@ one ordinary ancestry memo, with a batch entrance - every closure below has exactly the same dense
1830
+ // bitset shape in idx.anc. A caller that already knows several bases can prime them in one child-to-parent
1831
+ // topology pass; the short-lived frontier is released before returning. Unknown or ad-hoc revisions retain
1832
+ // this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
1810
1833
  // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
1811
1834
  // ref) — callers apply their own conservative rule to that "can't prove" case.
1812
1835
  export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
@@ -1830,13 +1853,81 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
1830
1853
  idx.anc.set(sha, bits)
1831
1854
  return bits
1832
1855
  }
1856
+
1857
+ // Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
1858
+ // commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
1859
+ // the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
1860
+ export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>): void {
1861
+ const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
1862
+ if (!endpoints.length) return
1863
+ const count = idx.ord.size
1864
+ const width = (endpoints.length + 7) >> 3
1865
+ const closureWidth = (count + 7) >> 3
1866
+ const endpointAt = new Map(endpoints.map((sha, position) => [sha, position]))
1867
+ const hashAt = new Array<string>(count)
1868
+ for (const [hash, position] of idx.ord) hashAt[position] = hash
1869
+
1870
+ // Every row waits until all of its children have contributed. Parents outside the walked topology are
1871
+ // deliberately absent: they are the same shallow boundary ancestorsOf() stops at.
1872
+ const childrenLeft = new Int32Array(count)
1873
+ for (const parents of idx.parents.values()) for (const parent of parents) {
1874
+ const position = idx.ord.get(parent)
1875
+ if (position !== undefined) childrenLeft[position]++
1876
+ }
1877
+ const ready: number[] = []
1878
+ for (let position = 0; position < count; position++) if (childrenLeft[position] === 0) ready.push(position)
1879
+
1880
+ const closures = endpoints.map(() => new Uint8Array(closureWidth))
1881
+ const rows = new Map<number, Uint8Array>(), pool: Uint8Array[] = []
1882
+ const rowFor = (position: number): Uint8Array => {
1883
+ const existing = rows.get(position)
1884
+ if (existing) return existing
1885
+ const row = pool.pop() ?? new Uint8Array(width)
1886
+ rows.set(position, row)
1887
+ return row
1888
+ }
1889
+ let visited = 0
1890
+ while (ready.length) {
1891
+ const position = ready.pop()!
1892
+ visited++
1893
+ const row = rowFor(position)
1894
+ rows.delete(position)
1895
+ const endpoint = endpointAt.get(hashAt[position])
1896
+ if (endpoint !== undefined) row[endpoint >> 3] |= 1 << (endpoint & 7)
1897
+ for (let byte = 0; byte < width; byte++) {
1898
+ let pending = row[byte]
1899
+ while (pending) {
1900
+ const bit = 31 - Math.clz32(pending & -pending)
1901
+ closures[(byte << 3) + bit][position >> 3] |= 1 << (position & 7)
1902
+ pending &= pending - 1
1903
+ }
1904
+ }
1905
+ for (const parent of idx.parents.get(hashAt[position]) ?? []) {
1906
+ const parentPosition = idx.ord.get(parent)
1907
+ if (parentPosition === undefined) continue
1908
+ const target = rowFor(parentPosition)
1909
+ for (let byte = 0; byte < width; byte++) target[byte] |= row[byte]
1910
+ if (--childrenLeft[parentPosition] === 0) ready.push(parentPosition)
1911
+ }
1912
+ row.fill(0)
1913
+ pool.push(row)
1914
+ }
1915
+ if (visited !== count)
1916
+ throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
1917
+ for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
1918
+ }
1833
1919
  export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
1834
1920
  const o = idx.ord.get(sha)
1835
1921
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
1836
1922
  }
1837
1923
 
1924
+ // @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
1925
+ // absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
1926
+ // succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
1927
+ // question by walking the whole parent DAG and allocating an ord.size-wide bitset per distinct sha —
1928
+ // per eval reading, on every board build.
1838
1929
  export function commitReachable(idx: DriftIndex, sha: string): boolean {
1839
- return ancestorsOf(idx, sha) !== undefined
1930
+ return idx.ord.has(sha)
1840
1931
  }
1841
1932
 
1842
1933
  // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
@@ -1891,10 +1982,22 @@ export function pathRangeEvents(idx: DriftIndex, sinceHash: string, path: string
1891
1982
  })
1892
1983
  }
1893
1984
 
1894
- export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
1985
+ // @@@ eventsSince - THE meaning of "this path changed since <sha>", in one place. A commit touching `path`
1986
+ // lies in `sha..HEAD` exactly when it is NOT an ancestor of `sha` — true DAG reachability, wherever a
1987
+ // date-ordered log happens to place it. `null` is the honest third answer: the anchor commit is not reachable
1988
+ // (folded, rebased, cherry-picked away), so ancestry cannot testify at all and the caller must decide what to
1989
+ // do about that — the spec layer reports no window, the eval layer falls back to comparing content.
1990
+ // Callers add their OWN layer's decoration on top (ack cover is spec-only; the content probe is eval-only);
1991
+ // what none of them may do is restate the reachability rule, which is how it came to exist four times.
1992
+ export function eventsSince(idx: DriftIndex, sinceHash: string, path: string): DriftPathEvent[] | null {
1895
1993
  const base = ancestorsOf(idx, sinceHash)
1896
1994
  if (!base) return null
1897
- const events = pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
1995
+ return pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
1996
+ }
1997
+
1998
+ export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
1999
+ const events = eventsSince(idx, sinceHash, path)
2000
+ if (!events) return null
1898
2001
  const cover = ackCoverFor(idx, sinceHash, nodeId)
1899
2002
  return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
1900
2003
  && !selfAckCovers(idx, sinceHash, event.commit, nodeId))
@@ -2,10 +2,10 @@ import { loadSpecs, deriveStatus } from './specs.js'
2
2
  import { resolveLayout } from './layout.js'
3
3
  import { listSessions } from './sessions.js'
4
4
  import { repoRoot, driftIndex, historyIndex } from './git.js'
5
- import { residentForgeState } from '../../spec-forge/src/resident.js'
5
+ import { residentForgeRevision, residentForgeState } from '../../spec-forge/src/resident.js'
6
6
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
7
7
  import { boardThreads } from './issues.js'
8
- import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
8
+ import { evalContext, evalTimelines } from '../../spec-eval/src/evaltab.js'
9
9
  import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
10
10
  import { resolveProjectIdentity } from './project-identity.js'
11
11
  import { sessionEvalProjections } from '../../spec-eval/src/sessioneval.js'
@@ -123,7 +123,9 @@ export async function buildBoard() {
123
123
  const isOpen = (i: { status: string }) => i.status === 'open'
124
124
  // ONE store walk yielding both halves ([[issues]] boardThreads): the ISSUE surfaces get the split
125
125
  // population, the freshness carrier gets the whole store.
126
- const { issues: merged, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
126
+ const forgeState = residentForgeState()
127
+ const forgeRevision = residentForgeRevision()
128
+ const { issues: merged, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: forgeState }, nodes.map((n) => n.id))
127
129
  // `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread — noded or nodeless,
128
130
  // both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
129
131
  // scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
@@ -149,15 +151,16 @@ export async function buildBoard() {
149
151
  // eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
150
152
  const ynodes = await evalNodesAsync(root)
151
153
  const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
152
- const evalReviewNodes = (await Promise.all(nodes.map(async (n) => {
153
- const tl = await evalTimeline(n.id, ectx)
154
+ const timelines = await evalTimelines(nodes.map((n) => n.id), ectx)
155
+ const evalReviewNodes = timelines.map((tl, index) => {
156
+ const n = nodes[index]
154
157
  if (!tl.hasEvalFile) return null
155
158
  const latest = latestPerScenario(tl.readings)
156
159
  n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(tl.scenarios, latest) }
157
160
  return { id: n.id, hue: n.hue, scenarios: tl.scenarios, evals: latest, readings: tl.readings }
158
- }))).filter((node): node is NonNullable<typeof node> => node !== null)
161
+ }).filter((node): node is NonNullable<typeof node> => node !== null)
159
162
 
160
- publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes })
163
+ publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes, forgeRevision })
161
164
 
162
165
  const opsByPath: Record<string, any[]> = {}
163
166
  opWts.forEach((w) => { opsByPath[w.path] = w.ops })
@@ -198,3 +201,21 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
198
201
  }))
199
202
  return { ...prev, sessions: sess }
200
203
  }
204
+
205
+ // A full producer may finish after the session lane has already shown a newer row. Reuse that published
206
+ // projection on the full topology without another store read: topology owns the current per-path ops, while
207
+ // the published row owns lifecycle/eval fields. This is deliberately synchronous so a full completion never
208
+ // waits for a quiet session store.
209
+ export function rebasePublishedSessions(
210
+ topology: Awaited<ReturnType<typeof buildBoard>>,
211
+ published: Awaited<ReturnType<typeof buildBoard>>,
212
+ ): Awaited<ReturnType<typeof buildBoard>> {
213
+ const opsByPath: Record<string, any[]> = {}
214
+ for (const session of topology.sessions) opsByPath[session.source] = session.ops
215
+ const sessions = published.sessions.map((session) => ({
216
+ ...session,
217
+ source: session.path,
218
+ ops: rowOps(session, opsByPath),
219
+ }))
220
+ return { ...topology, sessions }
221
+ }