spexcode 0.5.7 → 0.5.9

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 (103) 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 +199 -60
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -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/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +42 -30
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,30 +1,25 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { readFileSync, readdirSync, statSync } from 'node:fs'
3
3
  import { isAbsolute, join, resolve } from 'node:path'
4
- import { buildBoard, spliceSessions } from './graph.js'
4
+ import { buildBoard, rebasePublishedSessions, spliceSessions } from './graph.js'
5
5
  import { headSha, repoRoot, withGitAbortSignal } from './git.js'
6
6
  import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from './layout.js'
7
7
  import { boardThreads } from './issues.js'
8
8
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
9
9
  import { residentForgeState } from '../../spec-forge/src/resident.js'
10
10
  import { resolveProjectIdentity } from './project-identity.js'
11
+ import { readReviewSnapshot } from './reviewSnapshot.js'
11
12
  import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
12
13
 
13
- // @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
14
- // board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
15
- // route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
16
- // dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
17
- // starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
18
- // concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
19
- // result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
20
- // signals [[graph-stream]] already watches (session-store writes, git-ref/worktree moves); the cold patrol
21
- // enters this cache's input-revision validation flight instead of manufacturing a change. So a poll storm
22
- // costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and the route share the same operation.
23
-
24
14
  export type Board = Awaited<ReturnType<typeof buildBoard>>
25
15
  export type BoardConsistency = 'fresh' | 'stale-ok'
26
16
  export type BoardRead = { board: Board; freshness: 'fresh' | 'stale'; refreshing: boolean; error?: string }
27
17
  export type BoardJsonRead = BoardRead & { json: string }
18
+ export type RevisionPublication = {
19
+ revision: () => number
20
+ invalidate: () => void
21
+ wait: () => Promise<void>
22
+ }
28
23
 
29
24
  type BoardInputRevision = {
30
25
  full: string
@@ -34,6 +29,7 @@ type BoardInputRevision = {
34
29
  fullParts: Record<string, string>
35
30
  projectionIds: string[]
36
31
  }
32
+ type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'>
37
33
  const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
38
34
 
39
35
  function textOrNull(path: string): string | null {
@@ -146,8 +142,7 @@ function digest(value: unknown): string {
146
142
  // the issue-store carrier, and the already-resident session-eval projections. It intentionally does not call
147
143
  // listSessions or sessionEvalProjections: verification must neither poll tmux again nor mint/schedule eval work.
148
144
  // The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
149
- function boardInputRevision(board: Board | null): BoardInputRevision {
150
- const root = repoRoot()
145
+ function sessionInputRevision(): SessionInputRevision {
151
146
  const ids = listSessionIds().sort()
152
147
  // listSessions projects both the structured record and the separately-stored originating prompt into each
153
148
  // board row. Fold both exact artifacts so a missed store event cannot leave a stale label/prompt forever.
@@ -156,13 +151,19 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
156
151
  textOrNull(sessionRecordPath(id)),
157
152
  textOrNull(sessionArtifactPath(id, 'prompt')),
158
153
  ] as const)
159
- const records = ids.map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
154
+ const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]))
155
+ return { sessions: digest(sessionInputs), projections, projectionIds: ids }
156
+ }
157
+
158
+ function boardInputRevision(board: Board | null): BoardInputRevision {
159
+ const root = repoRoot()
160
+ const session = sessionInputRevision()
161
+ const records = listSessionIds().map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
160
162
  const governed = records.filter((record) => record.governed).sort((a, b) => a.session_id.localeCompare(b.session_id))
161
163
  const activeRoots = [...new Set(governed.filter((record) => !record.archived).map((record) => record.worktree_path))].sort()
162
164
  const main = mainCheckout()
163
165
  const base = mainBranch()
164
166
  const mainTip = refSha(main, base)
165
-
166
167
  const nodeIds = (board?.nodes ?? []).map((node) => node.id)
167
168
  const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
168
169
  const fullInputs = {
@@ -180,14 +181,12 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
180
181
  }
181
182
  const fullParts = Object.fromEntries(Object.entries(fullInputs).map(([key, value]) => [key, digest(value)]))
182
183
  const full = digest(fullParts)
183
- const sessions = digest(sessionInputs)
184
-
185
- // Every board row carries an evalSummary, including non-governed sessions. Governed filtering owns only
186
- // worktree observation; projection identity follows the whole store enumeration so a blind observer cannot
187
- // strand a non-governed row on an older resident phase/value.
188
- const projectionIds = ids
189
- const projections = digest(projectionIds.map((id) => [id, sessionEvalProjection(id)]))
190
- return { full, sessions, projections, combined: digest([full, sessions, projections]), fullParts, projectionIds }
184
+ return {
185
+ full,
186
+ ...session,
187
+ combined: digest([full, session.sessions, session.projections]),
188
+ fullParts,
189
+ }
191
190
  }
192
191
 
193
192
  // Bind an input sample to what the completed board actually carries. The sample supplies the graph/session
@@ -209,6 +208,38 @@ function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): Board
209
208
  }
210
209
  }
211
210
 
211
+ // A sessions splice reuses its base topology. It may sample fresh record/projection inputs, but it must never
212
+ // certify that those old nodes carry a full revision sampled after the base was built.
213
+ function revisionCarriedBySessionSplice(base: BoardInputRevision, sample: SessionInputRevision, board: Board, stable: boolean): BoardInputRevision {
214
+ const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
215
+ const projections = stable
216
+ ? digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
217
+ : sample.projections
218
+ return {
219
+ full: base.full,
220
+ sessions: sample.sessions,
221
+ projections,
222
+ combined: digest([base.full, sample.sessions, projections]),
223
+ fullParts: base.fullParts,
224
+ projectionIds: sample.projectionIds,
225
+ }
226
+ }
227
+
228
+ // The full producer's topology may be newer than the last published session projection. When completion
229
+ // re-bases that already-visible projection onto the new topology, keep the full carrier from the producer and
230
+ // only the sessions/projections carrier from the published rows. Do not sample current inputs here: that would
231
+ // certify a write that neither producer actually carried.
232
+ function revisionCarriedByPublishedSessionRebase(full: BoardInputRevision, published: BoardInputRevision): BoardInputRevision {
233
+ return {
234
+ full: full.full,
235
+ sessions: published.sessions,
236
+ projections: published.projections,
237
+ combined: digest([full.full, published.sessions, published.projections]),
238
+ fullParts: full.fullParts,
239
+ projectionIds: published.projectionIds,
240
+ }
241
+ }
242
+
212
243
  // a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
213
244
  // above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
214
245
  // genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
@@ -228,11 +259,8 @@ const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 1
228
259
  const RETRY_BACKOFF_MS = Number(process.env.SPEXCODE_BOARD_RETRY_BACKOFF_MS || 1000)
229
260
  const BACKGROUND_START_DELAY_MS = Number(process.env.SPEXCODE_BOARD_BACKGROUND_START_DELAY_MS || 300)
230
261
 
231
- // the cache's staleness has a DOMAIN, not just a bit: a 'sessions' change (a lifecycle write, a
232
- // liveness/activity poll flip) touches only the session rows, so the next read can SPLICE fresh sessions
233
- // onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
234
- // worktree `.spec` edit, or a changed graph-domain patrol revision) can reshape anything, so the next read
235
- // does the whole buildBoard(). 'none' = clean.
262
+ // The structural/full and sessions obligations are deliberately separate. A full build can take seconds;
263
+ // a persisted session row cannot be silently converted into time behind that unrelated topology work.
236
264
  type Scope = 'sessions' | 'full'
237
265
  let cached: Board | null = null // last completed build; served while `dirty === 'none'`
238
266
  let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
@@ -240,16 +268,19 @@ let cachedRevision: BoardInputRevision | null = null // input revision represent
240
268
  let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
241
269
  type Flight = { wait: Promise<Board>; settle: Promise<Board> }
242
270
  let inflight: Flight | null = null
271
+ let sessionFlight: Flight | null = null
272
+ let sessionOwed = false
273
+ let sessionGeneration = 0
274
+ let sessionProjectionPublication = 0
275
+ let topologyGeneration = 0
243
276
  let gen = 0 // bumped on invalidation so patrol validation cannot certify across an event
244
277
  let retryAt = 0
245
278
  let lastFailure: Error | null = null
246
279
 
247
280
  // mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
248
281
  // boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
249
- // keeps hitting the cache. The scope only ESCALATES within a dirty window: none→sessions→full, and a
250
- // 'sessions' signal arriving while 'full' is already pending stays 'full' (a full rebuild subsumes a
251
- // sessions splice). The last-good JSON stays intact while dirty so stale readers can return it without
252
- // paying serialization again; a successful replacement clears it.
282
+ // keeps hitting the cache. `dirty` is the structural producer's scope; `sessionOwed` is intentionally not
283
+ // folded into it, because full+sessions owes both a full convergence and a cheap first projection.
253
284
  function mergeDirty(scope: Scope): void {
254
285
  if (scope === 'full' || dirty === 'full') dirty = 'full'
255
286
  else dirty = 'sessions'
@@ -257,20 +288,90 @@ function mergeDirty(scope: Scope): void {
257
288
 
258
289
  export function invalidateBoard(scope: Scope = 'full'): void {
259
290
  gen++
291
+ if (scope === 'sessions') { sessionOwed = true; sessionGeneration++ }
260
292
  mergeDirty(scope)
261
293
  retryAt = 0
262
294
  lastFailure = null
263
295
  }
264
296
 
265
- // One flight owns BOTH patrol validation and a producer. A patrol first folds the cheap input revision; an
266
- // exact match resolves to `cached` without calling either producer. A mismatch derives the changed domain and
267
- // falls through to the same build path an explicit watcher invalidation takes. An invalidation during validation
268
- // also falls through under this flight, so validation can never race a second producer into existence.
297
+ function traceCacheCommit(scope: Scope, startedAt: number): void {
298
+ if (!DEBUG) return
299
+ const at = Date.now()
300
+ console.warn(`spec-cli: graph cache ${JSON.stringify({ at, stage: 'cache-commit', scope, buildMs: at - startedAt })}`)
301
+ }
302
+
303
+ function startSessionSplice(): Flight | null {
304
+ if (!cached || !cachedRevision || !sessionOwed) return null
305
+ if (sessionFlight) return sessionFlight
306
+ sessionOwed = false
307
+ if (dirty === 'sessions') dirty = 'none'
308
+ let timedOut = false
309
+ const startedAt = Date.now()
310
+ let watchdog: ReturnType<typeof setTimeout> | undefined
311
+ const timeoutError = () => new Error(`graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`)
312
+ const producer = Promise.resolve().then(async () => {
313
+ // A full completion may replace the topology while listSessions is in flight. Rebase before publishing;
314
+ // the splice is a projection over whatever last-good structure is current, never a whole-board write race.
315
+ while (true) {
316
+ const base = cached, revision = cachedRevision, generation = topologyGeneration
317
+ if (!base || !revision) throw new Error('graph session splice lost its cached topology')
318
+ const before = sessionInputRevision()
319
+ const board = await spliceSessions(base)
320
+ const after = sessionInputRevision()
321
+ if (base !== cached || revision !== cachedRevision || generation !== topologyGeneration) continue
322
+ const stable = before.sessions === after.sessions && before.projections === after.projections
323
+ cached = board
324
+ cachedJson = null
325
+ cachedRevision = revisionCarriedBySessionSplice(revision, before, board, stable)
326
+ sessionProjectionPublication++
327
+ if (!stable) {
328
+ sessionOwed = true
329
+ sessionGeneration++
330
+ mergeDirty('sessions')
331
+ }
332
+ return board
333
+ }
334
+ })
335
+ .then((board) => {
336
+ if (timedOut) throw timeoutError()
337
+ traceCacheCommit('sessions', startedAt)
338
+ return board
339
+ })
340
+ .catch((error) => {
341
+ sessionOwed = true
342
+ mergeDirty('sessions')
343
+ throw error
344
+ })
345
+ let settle!: Promise<Board>
346
+ settle = producer.finally(() => {
347
+ clearTimeout(watchdog)
348
+ if (sessionFlight?.settle === settle) sessionFlight = null
349
+ })
350
+ const wait = new Promise<Board>((resolve, reject) => {
351
+ watchdog = setTimeout(() => {
352
+ timedOut = true
353
+ console.warn(`spec-cli: graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`)
354
+ reject(timeoutError())
355
+ }, BUILD_TIMEOUT_MS)
356
+ watchdog.unref?.()
357
+ settle.then((board) => { if (!timedOut) resolve(board) }, (error) => { if (!timedOut) reject(error) })
358
+ })
359
+ const flight = { wait, settle }
360
+ sessionFlight = flight
361
+ void wait.catch(() => {})
362
+ void settle.catch(() => {})
363
+ return flight
364
+ }
365
+
366
+ // One flight owns BOTH validation and a producer. Every refresh — patrol or watcher-signalled — first folds
367
+ // the cheap input revision; an exact match resolves to `cached` without calling either producer. A mismatch
368
+ // derives the changed domain and falls through to the build path. An invalidation during validation also falls
369
+ // through under this flight, so validation can never race a second producer into existence. `mode` no longer
370
+ // selects who validates; it only says whether a stale HTTP response is waiting for a turn to flush first.
269
371
  type FlightMode = 'dirty' | 'patrol'
270
372
  function startBuild(mode: FlightMode = 'dirty'): Flight | null {
271
373
  if (inflight) return inflight
272
374
  if (Date.now() < retryAt) return null
273
- const flightStartGen = gen
274
375
  const controller = new AbortController()
275
376
  let watchdog: ReturnType<typeof setTimeout> | undefined
276
377
  let timedOut = false
@@ -279,6 +380,9 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
279
380
  let buildScope: Scope = 'full'
280
381
  let buildFullStable = true
281
382
  let buildSessionsStable = true
383
+ let buildSessionGeneration = sessionGeneration
384
+ let buildSessionProjectionPublication = sessionProjectionPublication
385
+ let buildStartedWithSessionOwed = false
282
386
  let completedRevision: BoardInputRevision | null = null
283
387
  // Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
284
388
  // synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
@@ -299,37 +403,42 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
299
403
  }
300
404
  try {
301
405
  Promise.resolve(withGitAbortSignal(controller.signal, async () => {
302
- if (mode === 'patrol' && dirty === 'none' && cached && cachedRevision) {
303
- const anchorRevision = cachedRevision
304
- const observed = await boardInputRevision(cached)
305
- if (controller.signal.aborted)
306
- throw Object.assign(new Error('graph patrol aborted'), { name: 'AbortError' })
307
- if (gen === flightStartGen && dirty === 'none' && observed.combined === anchorRevision.combined)
308
- return cached
309
- // A changed revision with no watcher event is the patrol doing its repair job. If an explicit event
310
- // arrived meanwhile it already set the correct (possibly sessions-only) scope, which we must not
311
- // overwrite. An otherwise-clean mismatch derives its scope from the moved input domain.
312
- if (gen === flightStartGen && dirty === 'none') {
313
- gen++
314
- if (DEBUG) {
315
- const moved = Object.keys(observed.fullParts)
316
- .filter((key) => observed.fullParts[key] !== anchorRevision.fullParts[key])
317
- console.warn(`spec-cli: graph patrol revision moved — scope=${observed.full === anchorRevision.full ? 'sessions' : 'full'} inputs=[${moved.join(', ')}]`)
318
- }
319
- dirty = observed.full === anchorRevision.full ? 'sessions' : 'full'
320
- }
321
- }
322
-
323
406
  const prev = cached
407
+ const anchor = cachedRevision
408
+ const sampledGen = gen
324
409
  const before = await boardInputRevision(prev)
325
410
  if (controller.signal.aborted)
326
411
  throw Object.assign(new Error('graph build aborted before producer start'), { name: 'AbortError' })
327
- // A session signal cannot vouch for the graph domain. Before splicing, compare the current full inputs
328
- // to the revision the cached node/meta units actually carry; a missed graph watcher promotes this same
329
- // flight to full instead of certifying old nodes under a new revision.
330
- if (dirty === 'sessions' && prev && cachedRevision && before.full !== cachedRevision.full) {
331
- if (DEBUG) console.warn('spec-cli: session refresh found moved graph inputs promoting to full')
332
- dirty = 'full'
412
+ // ONE rule for every refresh: the domain a producer runs is DERIVED from the inputs that actually
413
+ // moved, never assigned by whoever signalled. A watcher names the leaf it saw, not what the board
414
+ // reads a generated harness artifact rewritten inside a live worktree, or a linked worktree no
415
+ // governed record names, moves no board input at all, and used to buy a whole structural assembly
416
+ // anyway. The revision sampled here is the complete full-domain contract, so an unmoved full
417
+ // obligation is DISCHARGED without assembly. The session projection is deliberately NOT
418
+ // dischargeable: liveness lives on graph-stream's poller axis, outside this revision, so a claimed
419
+ // sessions obligation always survives and takes its splice. A sample taken after the write is what
420
+ // the watcher event was about; an invalidation arriving after the sample keeps its own dirty window.
421
+ if (prev && anchor && gen === sampledGen) {
422
+ const fullMoved = before.full !== anchor.full
423
+ const projectionMoved = before.sessions !== anchor.sessions || before.projections !== anchor.projections
424
+ // A projection move nobody signalled is this validation doing the patrol's repair job.
425
+ if (projectionMoved && !sessionOwed && dirty !== 'sessions') {
426
+ gen++
427
+ sessionOwed = true
428
+ sessionGeneration++
429
+ }
430
+ const owed = fullMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none'
431
+ if (DEBUG && owed !== 'none') {
432
+ const moved = Object.keys(before.fullParts).filter((key) => before.fullParts[key] !== anchor.fullParts[key])
433
+ console.warn(`spec-cli: graph refresh revision moved — signalled=${dirty} scope=${owed} inputs=[${moved.join(', ')}]`)
434
+ } else if (DEBUG && dirty !== 'none') {
435
+ console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no board input moved`)
436
+ }
437
+ if (fullMoved) dirty = 'full'
438
+ else if (sessionOwed || dirty === 'sessions') dirty = 'sessions'
439
+ // Discharging CONSUMES the claim. Leaving it standing would make every later read re-enter this
440
+ // validation, hold the board permanently stale/refreshing, and never converge.
441
+ else { dirty = 'none'; return prev }
333
442
  }
334
443
  const sessionsOnly = dirty === 'sessions' && prev !== null
335
444
  buildScope = sessionsOnly ? 'sessions' : 'full'
@@ -337,11 +446,14 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
337
446
  // window with its own domain: a session completion during a long full build owes one splice, not
338
447
  // another full build. The occupied `inflight` slot keeps fresh/stale readers joined while dirty is clean.
339
448
  dirty = 'none'
449
+ if (sessionsOnly) sessionOwed = false
340
450
  built = true
451
+ buildSessionGeneration = sessionGeneration
452
+ buildSessionProjectionPublication = sessionProjectionPublication
453
+ buildStartedWithSessionOwed = sessionOwed
341
454
  buildStartedAt = Date.now()
342
455
  const board = await (sessionsOnly ? spliceSessions(prev!) : buildBoard())
343
456
  const after = await boardInputRevision(prev)
344
- const carried = revisionCarriedByBoard(after, board)
345
457
  const movedFull = Object.keys(after.fullParts)
346
458
  .filter((key) => after.fullParts[key] !== before.fullParts[key])
347
459
  // The first build can initialize the resident forge carrier that it then returns. That movement is
@@ -349,13 +461,20 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
349
461
  // movement remains dirty just like a warm one; cold start is not a blanket race exemption.
350
462
  const coldIssueInitialization = prev === null
351
463
  && movedFull.every((key) => key === 'issuesStamp')
352
- && after.fullParts.issuesStamp === carried.fullParts.issuesStamp
464
+ && after.fullParts.issuesStamp === digest(board.issuesStamp)
353
465
  buildFullStable = before.full === after.full || coldIssueInitialization
354
466
  buildSessionsStable = before.sessions === after.sessions
355
467
  if (DEBUG && (!buildFullStable || !buildSessionsStable)) {
356
468
  console.warn(`spec-cli: graph inputs moved during ${buildScope} producer — next=${buildFullStable ? 'sessions' : 'full'} inputs=[${movedFull.join(', ')}] sessions=${buildSessionsStable ? 'stable' : 'moved'}`)
357
469
  }
358
- completedRevision = carried
470
+ // The anchor must name the sample this board is KNOWN to have read, because validation now discharges
471
+ // an unmoved obligation for every refresh, not only the patrol's. An input that moved DURING the
472
+ // producer leaves the board built from the pre-move value while the after-sample already names the
473
+ // post-move one; anchoring on `after` would let the next validation discharge the re-owed dirty scope
474
+ // against a revision this board never carried, and the cache would converge on nothing. So a moved
475
+ // half anchors on `before` — the conservative direction, which costs at most one extra rebuild and can
476
+ // never certify unread bytes.
477
+ completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board)
359
478
  return board
360
479
  }))
361
480
  .then(resolveBuild, rejectBuild)
@@ -368,14 +487,31 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
368
487
  // `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
369
488
  // promise settles, so a next read can never overlap an abandoned git/fs build.
370
489
  let settle!: Promise<Board>
371
- settle = build.then((board) => {
490
+ settle = build.then(async (board) => {
372
491
  if (timedOut) throw timeoutError()
373
492
  if (built) {
493
+ // A full build's session snapshot may predate a session projection that clients already saw. Rebase those
494
+ // published rows in memory on the new topology. This completion path must stay bounded: a later/unpublished
495
+ // session generation remains owed to the independent cheap splice rather than making full wait for quiet.
496
+ const publishedDuringBuild = sessionProjectionPublication !== buildSessionProjectionPublication
497
+ if (buildScope === 'full' && publishedDuringBuild && cached && cachedRevision) {
498
+ board = rebasePublishedSessions(board, cached)
499
+ completedRevision = revisionCarriedByPublishedSessionRebase(completedRevision!, cachedRevision)
500
+ }
501
+ const sessionStillOwed = sessionOwed || (!publishedDuringBuild && (
502
+ buildStartedWithSessionOwed || !buildSessionsStable || buildSessionGeneration !== sessionGeneration
503
+ ))
504
+ if (sessionStillOwed) {
505
+ sessionOwed = true
506
+ mergeDirty('sessions')
507
+ }
374
508
  cached = board
375
509
  cachedJson = null
376
510
  cachedRevision = completedRevision
511
+ if (buildScope === 'full') topologyGeneration++
512
+ else sessionProjectionPublication++
377
513
  if (!buildFullStable) mergeDirty('full')
378
- else if (!buildSessionsStable) mergeDirty('sessions')
514
+ traceCacheCommit(buildScope, buildStartedAt)
379
515
  }
380
516
  retryAt = 0
381
517
  lastFailure = null
@@ -422,12 +558,51 @@ export function getBoard(): Promise<Board> {
422
558
  // A clean-looking cache can be under patrol validation. Fresh readers join that flight before taking the
423
559
  // cache fast path, otherwise one can return stale bytes while the flight is discovering a missed change.
424
560
  if (inflight) return inflight.wait
561
+ if (dirty === 'full') {
562
+ const flight = startBuild('dirty')
563
+ if (flight) return flight.wait
564
+ }
565
+ if (sessionFlight) return sessionFlight.wait
425
566
  if (dirty === 'none' && cached) return Promise.resolve(cached)
426
567
  const flight = startBuild('dirty')
427
568
  if (flight) return flight.wait
428
569
  return Promise.reject(lastFailure ?? new Error('graph build retry is temporarily backing off'))
429
570
  }
430
571
 
572
+ export async function waitForPublishedRevision(required: number, publication: RevisionPublication): Promise<void> {
573
+ while (publication.revision() < required) {
574
+ publication.invalidate()
575
+ await publication.wait()
576
+ }
577
+ }
578
+
579
+ // A board flight can have captured a resident forge slice before its reconcile publishes a newer revision.
580
+ // That older flight may settle, but cannot discharge this request: its invalidation stays owed and the fence
581
+ // consumes the next publication instead of letting a fresh /api/issues read return the old review snapshot.
582
+ export async function getBoardForForgeRevision(required: number): Promise<Board> {
583
+ await waitForPublishedRevision(required, {
584
+ revision: () => readReviewSnapshot().forgeRevision,
585
+ invalidate: () => invalidateBoard('full'),
586
+ wait: async () => {
587
+ const current = inflight
588
+ if (current) await current.settle
589
+ else await getBoard()
590
+ },
591
+ })
592
+ return cached ?? getBoard()
593
+ }
594
+
595
+ // Delta delivery may choose the sessions obligation while a route-owned full is still in flight. Starting
596
+ // the full first preserves structural convergence; returning the splice first keeps the lifecycle surface live.
597
+ export function getBoardForSessionRefresh(): Promise<Board> {
598
+ if (cached && sessionOwed) {
599
+ if (!inflight && dirty === 'full') startBuild('dirty')
600
+ const flight = startSessionSplice()
601
+ if (flight) return flight.wait
602
+ }
603
+ return getBoard()
604
+ }
605
+
431
606
  // The delta-gated cold tick calls this instead of invalidating. Equal inputs resolve to the cached object;
432
607
  // changed inputs repair through the same flight and full producer as a watcher-owned invalidation.
433
608
  export function patrolBoard(): Promise<Board> {
@@ -439,8 +614,14 @@ export function patrolBoard(): Promise<Board> {
439
614
 
440
615
  export async function readBoard(consistency: BoardConsistency = 'fresh'): Promise<BoardRead> {
441
616
  if (consistency === 'stale-ok' && cached) {
442
- const stale = dirty !== 'none' || inflight !== null
443
- const flight = stale ? startBuild('dirty') : null
617
+ const stale = dirty !== 'none' || inflight !== null || sessionFlight !== null || sessionOwed
618
+ // A held session splice already owns this refresh. Returning stale bytes must not manufacture a full
619
+ // producer beside it; a real full obligation still starts its one structural producer independently.
620
+ const flight = inflight
621
+ ?? (dirty === 'full' ? startBuild('dirty') : null)
622
+ ?? sessionFlight
623
+ ?? (sessionOwed ? startSessionSplice() : null)
624
+ ?? (dirty === 'sessions' ? startBuild('dirty') : null)
444
625
  return { board: cached, freshness: stale ? 'stale' : 'fresh', refreshing: !!flight, ...(lastFailure ? { error: lastFailure.message } : {}) }
445
626
  }
446
627
  const board = await getBoard()
@@ -1,18 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
2
 
3
- // @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
4
- // UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[graph-stream]])
5
- // chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
6
- // instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
7
- // dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
8
- // equivalence argument (see the spec node's equivalence.md) is checkable by the property tests alone.
9
- //
10
- // Unit keys: `node:<id>` (one spec node), `sess:<id>` (one session row), `nodes#order` / `sess#order`
11
- // (id sequences, preserving array order), `meta` (every other top-level field as one small object).
12
- // Precondition P: node ids and session ids are collision-free. unitize REPORTS P (`ok`) rather than
13
- // assuming it — on a violation the transport falls back to full-snapshot sends, so a delta is only ever
14
- // chained between snapshots where the decomposition is a real bijection.
15
-
16
3
  export type Units = Map<string, { j: string; v: unknown }>
17
4
  export type Delta = { from: string; to: string; set: Record<string, unknown>; del: string[] }
18
5