spexcode 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -24,6 +24,8 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
24
24
  return null
25
25
  }
26
26
 
27
+ const OVERLAY_TO_PATH = Symbol('overlay-to-path')
28
+
27
29
  // The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
28
30
  export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
29
31
  const seen = new Set<string>()
@@ -51,12 +53,17 @@ const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<strin
51
53
  (s.archived ? [] : opsByPath[s.path] || [])
52
54
 
53
55
  export async function buildBoard() {
54
- // all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
55
- // REUSES the HEAD-keyed spec-history cache (the git-derived node data see specs.ts/git.ts), resolveLayout
56
- // reuses the per-worktree overlay cache and recomputes only the deltas that actually changed (the live
57
- // OVERLAY), and listSessions takes its liveness from ONE batched tmux snapshot. Nothing here re-walks git.
56
+ // loadSpecs starts alongside the public session census and REUSES the HEAD-keyed spec-history cache. Layout
57
+ // then receives that census's projected-active ids so an archived runtime hazard contributes the same ops to
58
+ // the full producer that spliceSessions preserves. Its per-worktree cache still recomputes only changed deltas.
58
59
  const root = repoRoot()
59
- const [layout, sessions, specs] = await Promise.all([resolveLayout(), listSessions(), loadSpecs()])
60
+ const sessionsPromise = listSessions()
61
+ const specsPromise = loadSpecs()
62
+ const sessions = await sessionsPromise
63
+ const [layout, specs] = await Promise.all([
64
+ resolveLayout({ activeSessionIds: sessions.map((session) => session.id) }),
65
+ specsPromise,
66
+ ])
60
67
  // the eval fold's freshness axes: WARM hits — loadSpecs already computed this HEAD's drift + history
61
68
  // indices, so these are the same cached walks, fetched once and reused for every measurable node (the history
62
69
  // index drives the rename-safe scenario axis, mirroring a spec node's own freshness).
@@ -88,6 +95,7 @@ export async function buildBoard() {
88
95
  op: op.op, source, label, branch: w.branch, seed,
89
96
  committed: op.committed, dirty: op.dirty,
90
97
  toParent: op.op === 'moved' ? resolveParent(op.toPath || op.path, byDir) : null,
98
+ [OVERLAY_TO_PATH]: op.op === 'moved' ? op.toPath || op.path : null,
91
99
  }
92
100
  if (op.op === 'added' && !byId[op.nodeId]) {
93
101
  if (ghostById[op.nodeId]) { ghostById[op.nodeId].overlays.push(ov); continue }
@@ -191,6 +199,7 @@ export async function buildBoard() {
191
199
  export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
192
200
  const sessions = await listSessions()
193
201
  const evalProjections = sessionEvalProjections(sessions)
202
+ const activeSources = new Set(sessions.map((session) => session.path))
194
203
  const opsByPath: Record<string, any[]> = {}
195
204
  for (const s of prev.sessions) opsByPath[s.source] = s.ops
196
205
  const sess = sessions.map((s) => ({
@@ -199,7 +208,48 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
199
208
  ops: rowOps(s, opsByPath),
200
209
  evalSummary: evalProjections.get(s.id),
201
210
  }))
202
- return { ...prev, sessions: sess }
211
+ // Archive and close are subtractive topology changes: their worktree leaves the working set, so its
212
+ // overlays must leave in the same cheap publication as its row. Filtering the already-built units is exact
213
+ // and forks nothing. A newly-active source is intentionally absent here; graphCache keeps a full obligation
214
+ // for additions/resume so resolveLayout can discover that worktree's current delta.
215
+ let nodeProjectionMoved = false
216
+ const projectedNodes = prev.nodes.flatMap((node: any) => {
217
+ const overlays = (node.overlays || []).filter((overlay: any) => activeSources.has(overlay.source))
218
+ if (node.ghost && overlays.length === 0) { nodeProjectionMoved = true; return [] }
219
+ if (overlays.length === (node.overlays || []).length) return [node]
220
+ nodeProjectionMoved = true
221
+ if (node.ghost) return [{ ...node, overlays, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }) }]
222
+ return [{ ...node, overlays, status: deriveStatus({
223
+ version: node.version,
224
+ drift: node.drift,
225
+ hasOverlay: overlays.length > 0,
226
+ hasCode: (node.code?.length ?? 0) > 0,
227
+ fmStatus: node.fmStatus ?? undefined,
228
+ }) }]
229
+ })
230
+ const nodes = nodeProjectionMoved ? projectedNodes : prev.nodes
231
+ if (nodeProjectionMoved) {
232
+ const byDir: Record<string, string> = {}
233
+ for (const node of nodes) if (node.path) byDir[node.path.replace(/\/spec\.md$/, '')] = node.id
234
+ const retainedIds = new Set(nodes.map((node: any) => node.id))
235
+ for (let index = 0; index < nodes.length; index++) {
236
+ const node: any = nodes[index]
237
+ let changed = false
238
+ let parent = node.parent
239
+ if (node.ghost) {
240
+ const next = resolveParent(node.path, byDir)
241
+ if (next !== parent) { parent = next; changed = true }
242
+ }
243
+ const overlays = (node.overlays || []).map((overlay: any) => {
244
+ if (!overlay.toParent || retainedIds.has(overlay.toParent)) return overlay
245
+ const toPath = overlay[OVERLAY_TO_PATH]
246
+ changed = true
247
+ return { ...overlay, toParent: typeof toPath === 'string' ? resolveParent(toPath, byDir) : null }
248
+ })
249
+ if (changed) nodes[index] = { ...node, parent, overlays }
250
+ }
251
+ }
252
+ return { ...prev, nodes, sessions: sess }
203
253
  }
204
254
 
205
255
  // A full producer may finish after the session lane has already shown a newer row. Reuse that published
@@ -27,9 +27,10 @@ type BoardInputRevision = {
27
27
  projections: string
28
28
  combined: string
29
29
  fullParts: Record<string, string>
30
+ worktreeParts: Record<string, string>
30
31
  projectionIds: string[]
31
32
  }
32
- type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'>
33
+ type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'> & { activeRoots: string[] }
33
34
  const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
34
35
 
35
36
  function textOrNull(path: string): string | null {
@@ -144,28 +145,38 @@ function digest(value: unknown): string {
144
145
  // The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
145
146
  function sessionInputRevision(): SessionInputRevision {
146
147
  const ids = listSessionIds().sort()
147
- // listSessions projects both the structured record and the separately-stored originating prompt into each
148
- // board row. Fold both exact artifacts so a missed store event cannot leave a stale label/prompt forever.
148
+ // listSessions projects the structured record plus a one-line preview of the separately-stored originating
149
+ // prompt into each board row. Fold both exact artifacts so a missed store event cannot leave a stale
150
+ // label/preview forever — the prompt file is folded because the preview is derived from it.
149
151
  const sessionInputs = ids.map((id) => [
150
152
  id,
151
153
  textOrNull(sessionRecordPath(id)),
152
154
  textOrNull(sessionArtifactPath(id, 'prompt')),
153
155
  ] as const)
154
156
  const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]))
155
- return { sessions: digest(sessionInputs), projections, projectionIds: ids }
157
+ const activeRoots = [...new Set(ids.flatMap((id) => {
158
+ const entry = readPublicRecordEntry(id)
159
+ return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : []
160
+ }))].sort()
161
+ return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots }
156
162
  }
157
163
 
158
164
  function boardInputRevision(board: Board | null): BoardInputRevision {
159
165
  const root = repoRoot()
160
166
  const session = sessionInputRevision()
161
- const records = listSessionIds().map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
162
- const governed = records.filter((record) => record.governed).sort((a, b) => a.session_id.localeCompare(b.session_id))
163
- const activeRoots = [...new Set(governed.filter((record) => !record.archived).map((record) => record.worktree_path))].sort()
167
+ // Durable active records are the current root set; a cached ordinary row may be stale and must not replace
168
+ // them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
169
+ // into the working set, and that marked row keeps its root monitored until cold state is repaired.
170
+ const activeRoots = [...new Set([
171
+ ...session.activeRoots,
172
+ ...(board?.sessions.flatMap((row) => row.archiveHazard ? [row.path] : []) ?? []),
173
+ ])].sort()
164
174
  const main = mainCheckout()
165
175
  const base = mainBranch()
166
176
  const mainTip = refSha(main, base)
167
177
  const nodeIds = (board?.nodes ?? []).map((node) => node.id)
168
178
  const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
179
+ const worktreeParts = Object.fromEntries(activeRoots.map((worktree) => [worktree, digest(worktreeRevision(worktree))]))
169
180
  const fullInputs = {
170
181
  root: worktreeRevision(root),
171
182
  config: [
@@ -175,7 +186,7 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
175
186
  [join(main, 'spexcode.local.json'), textOrNull(join(main, 'spexcode.local.json'))],
176
187
  ],
177
188
  main: { root: main, branch: base, tip: mainTip },
178
- worktrees: activeRoots.map((worktree) => worktreeRevision(worktree)),
189
+ worktrees: worktreeParts,
179
190
  issuesStamp,
180
191
  identity: resolveProjectIdentity(root, root),
181
192
  }
@@ -186,6 +197,7 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
186
197
  ...session,
187
198
  combined: digest([full, session.sessions, session.projections]),
188
199
  fullParts,
200
+ worktreeParts,
189
201
  }
190
202
  }
191
203
 
@@ -204,13 +216,19 @@ function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): Board
204
216
  projections,
205
217
  combined: digest([full, sample.sessions, projections]),
206
218
  fullParts,
219
+ worktreeParts: sample.worktreeParts,
207
220
  projectionIds: sample.projectionIds,
208
221
  }
209
222
  }
210
223
 
211
224
  // A sessions splice reuses its base topology. It may sample fresh record/projection inputs, but it must never
212
225
  // 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 {
226
+ function revisionCarriedBySessionSplice(
227
+ base: BoardInputRevision,
228
+ sample: Pick<SessionInputRevision, 'sessions' | 'projections' | 'projectionIds'>,
229
+ board: Board,
230
+ stable: boolean,
231
+ ): BoardInputRevision {
214
232
  const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
215
233
  const projections = stable
216
234
  ? digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
@@ -221,6 +239,7 @@ function revisionCarriedBySessionSplice(base: BoardInputRevision, sample: Sessio
221
239
  projections,
222
240
  combined: digest([base.full, sample.sessions, projections]),
223
241
  fullParts: base.fullParts,
242
+ worktreeParts: base.worktreeParts,
224
243
  projectionIds: sample.projectionIds,
225
244
  }
226
245
  }
@@ -236,10 +255,40 @@ function revisionCarriedByPublishedSessionRebase(full: BoardInputRevision, publi
236
255
  projections: published.projections,
237
256
  combined: digest([full.full, published.sessions, published.projections]),
238
257
  fullParts: full.fullParts,
258
+ worktreeParts: full.worktreeParts,
239
259
  projectionIds: published.projectionIds,
240
260
  }
241
261
  }
242
262
 
263
+ function carrySubtractiveWorktrees(base: BoardInputRevision, activeRoots: readonly string[]): { revision: BoardInputRevision; added: boolean } {
264
+ const active = new Set(activeRoots)
265
+ const known = Object.keys(base.worktreeParts)
266
+ if (activeRoots.some((root) => !(root in base.worktreeParts))) return { revision: base, added: true }
267
+ if (known.every((root) => active.has(root))) return { revision: base, added: false }
268
+ const worktreeParts = Object.fromEntries(Object.entries(base.worktreeParts).filter(([root]) => active.has(root)))
269
+ const fullParts = { ...base.fullParts, worktrees: digest(worktreeParts) }
270
+ const full = digest(fullParts)
271
+ return {
272
+ revision: {
273
+ ...base,
274
+ full,
275
+ fullParts,
276
+ worktreeParts,
277
+ combined: digest([full, base.sessions, base.projections]),
278
+ },
279
+ added: false,
280
+ }
281
+ }
282
+
283
+ function isPureSubtractiveWorktreeMove(base: BoardInputRevision, sample: BoardInputRevision): boolean {
284
+ const moved = Object.keys(sample.fullParts).filter((key) => sample.fullParts[key] !== base.fullParts[key])
285
+ if (moved.length !== 1 || moved[0] !== 'worktrees') return false
286
+ const current = Object.keys(sample.worktreeParts)
287
+ const previous = Object.keys(base.worktreeParts)
288
+ return current.length < previous.length
289
+ && current.every((root) => sample.worktreeParts[root] === base.worktreeParts[root])
290
+ }
291
+
243
292
  // a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
244
293
  // above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
245
294
  // genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
@@ -319,11 +368,16 @@ function startSessionSplice(): Flight | null {
319
368
  const board = await spliceSessions(base)
320
369
  const after = sessionInputRevision()
321
370
  if (base !== cached || revision !== cachedRevision || generation !== topologyGeneration) continue
322
- const stable = before.sessions === after.sessions && before.projections === after.projections
371
+ const stable = before.sessions === after.sessions && before.projections === after.projections &&
372
+ JSON.stringify(before.activeRoots) === JSON.stringify(after.activeRoots)
373
+ const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort()
374
+ const rootTransition = carrySubtractiveWorktrees(revision, projectedRoots)
375
+ const carried = stable ? rootTransition.revision : revision
323
376
  cached = board
324
377
  cachedJson = null
325
- cachedRevision = revisionCarriedBySessionSplice(revision, before, board, stable)
378
+ cachedRevision = revisionCarriedBySessionSplice(carried, before, board, stable)
326
379
  sessionProjectionPublication++
380
+ if (rootTransition.added) mergeDirty('full')
327
381
  if (!stable) {
328
382
  sessionOwed = true
329
383
  sessionGeneration++
@@ -383,6 +437,7 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
383
437
  let buildSessionGeneration = sessionGeneration
384
438
  let buildSessionProjectionPublication = sessionProjectionPublication
385
439
  let buildStartedWithSessionOwed = false
440
+ let buildAddedSessionRoot = false
386
441
  let completedRevision: BoardInputRevision | null = null
387
442
  // Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
388
443
  // synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
@@ -420,21 +475,23 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
420
475
  // the watcher event was about; an invalidation arriving after the sample keeps its own dirty window.
421
476
  if (prev && anchor && gen === sampledGen) {
422
477
  const fullMoved = before.full !== anchor.full
478
+ const subtractiveWorktreeMove = fullMoved && isPureSubtractiveWorktreeMove(anchor, before)
479
+ const structuralMoved = fullMoved && !subtractiveWorktreeMove
423
480
  const projectionMoved = before.sessions !== anchor.sessions || before.projections !== anchor.projections
424
481
  // A projection move nobody signalled is this validation doing the patrol's repair job.
425
- if (projectionMoved && !sessionOwed && dirty !== 'sessions') {
482
+ if ((projectionMoved || subtractiveWorktreeMove) && !sessionOwed && dirty !== 'sessions') {
426
483
  gen++
427
484
  sessionOwed = true
428
485
  sessionGeneration++
429
486
  }
430
- const owed = fullMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none'
487
+ const owed = structuralMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none'
431
488
  if (DEBUG && owed !== 'none') {
432
489
  const moved = Object.keys(before.fullParts).filter((key) => before.fullParts[key] !== anchor.fullParts[key])
433
490
  console.warn(`spec-cli: graph refresh revision moved — signalled=${dirty} scope=${owed} inputs=[${moved.join(', ')}]`)
434
491
  } else if (DEBUG && dirty !== 'none') {
435
- console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no board input moved`)
492
+ console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no graph input moved`)
436
493
  }
437
- if (fullMoved) dirty = 'full'
494
+ if (structuralMoved) dirty = 'full'
438
495
  else if (sessionOwed || dirty === 'sessions') dirty = 'sessions'
439
496
  // Discharging CONSUMES the claim. Leaving it standing would make every later read re-enter this
440
497
  // validation, hold the board permanently stale/refreshing, and never converge.
@@ -474,7 +531,19 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
474
531
  // against a revision this board never carried, and the cache would converge on nothing. So a moved
475
532
  // half anchors on `before` — the conservative direction, which costs at most one extra rebuild and can
476
533
  // never certify unread bytes.
477
- completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board)
534
+ if (sessionsOnly && anchor) {
535
+ const projectedRoots = [...new Set(board.sessions.map((row) => row.path))].sort()
536
+ const rootTransition = carrySubtractiveWorktrees(anchor, projectedRoots)
537
+ buildAddedSessionRoot = rootTransition.added
538
+ completedRevision = revisionCarriedBySessionSplice(
539
+ rootTransition.revision,
540
+ before,
541
+ board,
542
+ buildFullStable && buildSessionsStable && !rootTransition.added,
543
+ )
544
+ } else {
545
+ completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board)
546
+ }
478
547
  return board
479
548
  }))
480
549
  .then(resolveBuild, rejectBuild)
@@ -510,7 +579,7 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
510
579
  cachedRevision = completedRevision
511
580
  if (buildScope === 'full') topologyGeneration++
512
581
  else sessionProjectionPublication++
513
- if (!buildFullStable) mergeDirty('full')
582
+ if (!buildFullStable || buildAddedSessionRoot) mergeDirty('full')
514
583
  traceCacheCommit(buildScope, buildStartedAt)
515
584
  }
516
585
  retryAt = 0
@@ -504,7 +504,7 @@ const REFS_OBSERVER = 'graph:refs'
504
504
 
505
505
  // @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
506
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
507
+ // and a bot that commits continuously that means nothing is ever warm: observed on adopter-a as an input
508
508
  // generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
509
509
  // tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
510
510
  // `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
14
14
  startup commands.)
15
15
 
16
16
  2. Adopt a repo
17
- cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
17
+ cd <your-repo> && spex init --harness claude,codex,opencode,pi,zcode,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
18
18
  --harness is required and has no default — the explicit choice of which harness(es) materialize
19
19
  delivers into. The example lists every built-in; drop the ones you don't use (any one id or
20
20
  comma-separated subset is valid).
@@ -101,6 +101,13 @@ code does it. Two optional level-2 headings split ground truth from detail:
101
101
  Bodies without those headings are read whole. Link sibling nodes with [[node-id]] — every link must name
102
102
  a REAL node (lint's mention rule; backtick a placeholder like \`[[node]]\` so it reads as sample text).
103
103
 
104
+ COMMENT ALTITUDE: specs own intent, invariants, policy, and observable contracts; comments only navigate
105
+ non-obvious local decisions. Put contract or intent in the owning spec body, not a code comment. Keep a short
106
+ nearby comment only for ordering, platform behavior, measured pitfalls, or why a plausible alternative is
107
+ unsafe; delete commentary that merely translates code or repeats a name/type. Preserve measured values,
108
+ version-specific behavior, and rejected alternatives not in the spec. \`@@@title - explanation\` is reserved for
109
+ genuinely tricky surviving local reasoning.
110
+
104
111
  WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
105
112
  integrity (error) every code:/related: path exists — and every anchor RESOLVES: a dead anchor (unit
106
113
  deleted/renamed), an ambiguous one (two same-named units in one file), a file that
@@ -144,7 +151,10 @@ Heuristic spec health is deliberately outside this production gate. Bare \`spex
144
151
  altitude and breadth findings; the tidy workflow consumes that report and adds semantic judgment.
145
152
 
146
153
  LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex spec lint\` must reach 0 errors
147
- before merge. \`spex init\` seeds the first tree; \`spex guide eval\` covers the sibling eval.md, the measurement file.`
154
+ before merge. \`spex init\` seeds the first tree; \`spex guide eval\` covers the sibling eval.md, the measurement file.
155
+
156
+ SHARED LANDING: if the shared checkout is mid-merge, wait. Never abort or resolve someone else's merge; if your
157
+ own landing stops half-merged, abort it and report.`
148
158
 
149
159
  const EVAL = `spex guide eval — the eval.md file format
150
160
 
@@ -187,6 +197,13 @@ pre-commit \`internal check-staged\` BLOCKS the commit.
187
197
  BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
188
198
  looks at / calls the real product surface, not an internal helper chosen to make the evidence easy.
189
199
 
200
+ REPAIR PROOF: for a bug fix, use one scenario's fail→pass pair. A, BEFORE EDITING: find the violated scenario
201
+ or add one to \`eval.md\`, run it against the old committed behavior, and file
202
+ \`spex eval add <node> --scenario <s> --fail\` with evidence of the failure. B, AFTER EDITING: run that same
203
+ scenario against the working tree until it passes; commit the verified tree; then file
204
+ \`spex eval add <node> --scenario <s> --pass\`. The reading's \`codeSha\` must be that commit. New intent has no
205
+ prior failure to reproduce.
206
+
190
207
  MEASURING AND FILING: the agent runs the scenario however it likes (a browser run, an API
191
208
  transcript, a by-hand pass), compares the result to \`expected\`, and files it:
192
209
  spex eval add <node> [--scenario <name>] (--pass | --fail) [--note <text>]
@@ -227,6 +244,31 @@ PICK THE EVIDENCE KIND BY WHAT THE BEHAVIOUR DOES OVER TIME:
227
244
  \`data\` — rendered as a validatable data block, not flattened into scrolling transcript
228
245
  text; free-form output stays a transcript. You pick the flag; the KIND follows the bytes.
229
246
  The flags combine in ONE filing — several stills can ride beside the clip of the same run.
247
+ POPULATION DISCIPLINE: when \`expected\` quantifies over a set ("every X is Y", "nothing under Z"), the
248
+ measurement reports the set's SIZE, not only the verdict. An empty set satisfies a universal claim
249
+ VACUOUSLY, so a pass over nothing and a real pass are indistinguishable — and the universal form reads
250
+ STRICTER than an enumeration, which is what makes it the hardest wrong-population case to catch. File
251
+ \`N of N\`, never a bare boolean: \`0 of 0\` shows itself, \`true\` does not. The denominator must come from a
252
+ source that can DISAGREE with the numerator — count the population off the surface that does NOT decide the
253
+ outcome (a backend export, a ledger, the enumeration upstream of the thing under test) and the passing members
254
+ off the surface under test. A ratio whose halves share one source says only "what I selected, I selected": a
255
+ selector that silently drops half the population reports \`3 of 3\` when the truth is 6. Two weaker defences
256
+ that do not substitute — a precondition sentence depends on the next author remembering it, a printed
257
+ denominator depends on a reader noticing it. Nothing here enforces either (the schema has no population
258
+ field), so treat a zero-population run as a NON-reading and do not file it. Cheaper than any of that, when
259
+ you can get it: restate the claim over something the product CANNOT make empty. "every active node's name is
260
+ readable" needs someone to arrange activity and goes vacuous when nobody does; "the rendered size never falls
261
+ below the authored size" is a property of the viewport itself, true of a one-node graph, and has no
262
+ population to get wrong. A claim with no population beats a well-reported one.
263
+ RENDERED GEOMETRY (browser): measure the rendered BOX, never the authored STYLE. An ancestor CSS \`transform\`
264
+ — the ordinary zoom/pan wrapper on a canvas or graph view — does NOT change computed style, so
265
+ \`getComputedStyle(el).fontSize\` answers the AUTHORED size while the screen shows that size times the
266
+ ancestor scale, and the reading is a SILENT FALSE PASS. (No numbers here on purpose: the authored size is
267
+ whatever this project's stylesheet says today, and a manual that hard-codes one teaches a constant that
268
+ drifts — read it off the tree you are measuring.) Go through \`getBoundingClientRect()\` (it carries the
269
+ ancestor scale) and derive the effective size from the box, or from a known string's width. This is also why
270
+ a geometric claim ships WITH its \`--image\`: a rect can be computed wrong, while "is that text legible" is
271
+ human-judgeable, so the two evidences cover each other.
230
272
  ANCHOR DISCIPLINE: an eval's \`codeSha\` is HEAD at filing time, and a git sha names only a COMMIT — an
231
273
  uncommitted change has none. So measure the tree you are about to commit, COMMIT it, then file; confidence
232
274
  is earned on the working tree, but the anchor can only land after the commit. Filing from a dirty tree
@@ -285,7 +327,8 @@ see LAUNCHERS.
285
327
  ── LAYOUT (spexcode.json — portable; set only for a NON-DEFAULT repo layout) ──
286
328
  main path to the source-of-truth checkout. Default: the \`main\` worktree.
287
329
  mainBranch the stable source-of-truth BRANCH worktrees fork from. spex init stamps the root checkout's
288
- branch at adoption; an older omitted value uses the conventional main.
330
+ branch at adoption and the stamped value stays put for every later checkout, so a clone or a
331
+ fresh worktree does not re-guess it; an older omitted value uses the conventional main.
289
332
  branchPrefix how a node branch is named. Default "node/".
290
333
  Example — a repo whose trunk is \`staging\`, not \`main\`:
291
334
  { "mainBranch": "staging" }
@@ -575,7 +618,47 @@ not a flag flip.
575
618
  elsewhere cannot be recalled.
576
619
  back out entirely \`spex uninstall\` (add --hooks to also remove the spexcode git hooks).`
577
620
 
578
- const TOPICS: Record<string, string> = { spec: SPEC, eval: EVAL, settings: SETTINGS, footprint: FOOTPRINT }
621
+ const FILES = `spex guide files hand an agent artifact back to the human
622
+
623
+ Use the session's file list when an artifact belongs in the human's hands:
624
+
625
+ spex session files add <path> publish one current file path
626
+ spex session files ls read your published paths
627
+ spex session files retract <path> withdraw one path
628
+
629
+ Posting resolves a relative path from your current directory and records its absolute path beside the global
630
+ session record. It copies, moves, stages, and uploads NOTHING. The path is live: editing the file after
631
+ posting changes what the human downloads. The reference is host-local; opening the session elsewhere cannot
632
+ make its path point at another machine's file.
633
+
634
+ The session page's top-right files icon is grey while the list is empty. Once live, it opens the posted list;
635
+ choosing a path previews its current text or raster-image bytes in a pop-out, while the adjacent download tool
636
+ downloads it through the backend at that moment. Previews are limited to 2 MiB, text and PNG/JPEG/GIF/WebP;
637
+ other types and larger files say to download instead. A missing, moved, or unreadable target stays listed but
638
+ reports that it no longer exists. The backend refuses a preview or download for any path not on that session's
639
+ list.
640
+
641
+ This is the reverse of a dashboard attachment: [[file-attach]] sends human bytes to an agent. Files publishes
642
+ an agent-owned path for a human to download.`
643
+
644
+ const WEB = `spex guide web — hand a running local web service to the human
645
+
646
+ Use the session web list after starting a local page the human should inspect:
647
+
648
+ spex session web add http://127.0.0.1:5173/
649
+ spex session web ls
650
+ spex session web retract http://127.0.0.1:5173/
651
+
652
+ Posting records the canonical loopback HTTP URL beside the global session record. It does not fetch the page,
653
+ start a process, or move bytes. The dashboard uses its own same-origin gateway only when the human opens the
654
+ preview, including WebSocket traffic, so changes to the running service are visible at that moment.
655
+
656
+ Only loopback HTTP URLs with an explicit port are accepted: 127.0.0.1, localhost, or ::1. The service remains
657
+ host-local and must stay running. A stopped service remains listed and reports that its upstream is unavailable.
658
+ The proxy has a path prefix, so use relative asset/navigation URLs or configure your dev server's base path;
659
+ hard-coded root-absolute Vite/Next assets cannot be made portable by a proxy guessing at JavaScript.`
660
+
661
+ const TOPICS: Record<string, string> = { spec: SPEC, eval: EVAL, settings: SETTINGS, footprint: FOOTPRINT, files: FILES, web: WEB }
579
662
 
580
663
  // every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
581
664
  // the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
@@ -588,4 +671,10 @@ export function guideText(topic?: string): string | null {
588
671
  const t = TOPICS[topic]
589
672
  return t ? t + FOOTER : null
590
673
  }
674
+
675
+ // @@@ the unknown-topic list is DERIVED, never re-typed - a hand-kept enumeration goes blind the moment a
676
+ // topic is added beside it, and it did: `files` and `web` shipped while the error still named four.
677
+ export function guideTopics(): string[] {
678
+ return Object.keys(TOPICS)
679
+ }
591
680
  import { uploadPolicyDefaults } from './layout.js'
@@ -44,14 +44,14 @@ export function resolveHarnessTargets(raw: unknown): HarnessTarget[] {
44
44
  for (const m of raw) {
45
45
  if (typeof m === 'string') {
46
46
  if (m === 'plugin')
47
- throw new Error(`spexcode.json "harnesses": a plugin target needs an EXPLICIT landing folder — write {"plugin":"<folder>"} (e.g. {"plugin":".zcode"}), not the bare string "plugin", because each host agent reads a different plugins dir.`)
47
+ throw new Error(`spexcode.json "harnesses": a plugin target needs an EXPLICIT landing folder — write {"plugin":"<folder>"} (e.g. {"plugin":".adopter-a"}), not the bare string "plugin", because each host agent reads a different plugins dir.`)
48
48
  if (!KNOWN.includes(m))
49
49
  throw new Error(`spexcode.json "harnesses": unknown harness id "${m}" — known native ids are ${KNOWN.join(', ')}, or use {"plugin":"<folder>"}.`)
50
50
  targets.push({ kind: 'native', id: m as HarnessId })
51
51
  } else if (m && typeof m === 'object' && !Array.isArray(m) && 'plugin' in m) {
52
52
  const folder = (m as { plugin?: unknown }).plugin
53
53
  if (typeof folder !== 'string' || !folder.trim())
54
- throw new Error(`spexcode.json "harnesses": a {"plugin":…} target needs a NON-EMPTY folder string (e.g. {"plugin":".zcode"}) — each host agent reads a different plugins dir, so the folder must be explicit.`)
54
+ throw new Error(`spexcode.json "harnesses": a {"plugin":…} target needs a NON-EMPTY folder string (e.g. {"plugin":".adopter-a"}) — each host agent reads a different plugins dir, so the folder must be explicit.`)
55
55
  targets.push({ kind: 'plugin', folder: folder.trim() })
56
56
  } else {
57
57
  throw new Error(`spexcode.json "harnesses": each member must be a native id string (${KNOWN.join(', ')}) or a {"plugin":"<folder>"} object — got ${JSON.stringify(m)}.`)