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
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
2
  import { join, dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
- import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
4
+ import { git, repoRoot, gitA, gitAbortError, currentGitBuildAbortSignal, gitInterpretationIdentity, headSha, worktreeSpecSig, worktreeSpecDelta, worktreeSpecDeltas, withGitAbortSignal, type NodeOp } from './git.js'
5
5
  import { guardWorktree } from './resilience.js'
6
6
  import { HARNESSES, type HarnessId } from './harness.js'
7
7
  import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
@@ -276,6 +276,7 @@ export type RawRecord = {
276
276
  launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
277
277
  create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
278
278
  create_payload_hash?: string // normalized create payload bound to create_request_id
279
+ base?: string // the exact fork point pinned at creation; absent/empty → the auto-detected source-of-truth branch
279
280
  launch_readiness_pending?: '' | RawLaunchReadinessPending
280
281
  }
281
282
 
@@ -445,6 +446,10 @@ export function readAliasedRawRecord(id: string): RawRecord | null {
445
446
  export function readAliasedRecordEntry(id: string): RecordEntry {
446
447
  const direct = readRecordEntry(id)
447
448
  if (direct.kind !== 'absent') return direct
449
+ // @@@ absence splits in two, and only one half is an alias question - an id owning a store dir is already
450
+ // one of ours (the sentinel-only agent above), so its emptiness is settled; searching would let an unrelated
451
+ // record answer under a live session's own name, and costs a whole-store re-parse per 1s supervisor tick.
452
+ if (existsSync(sessionStoreDir(id))) return { kind: 'absent' }
448
453
  for (const sid of listSessionIds()) {
449
454
  const r = readRawRecord(sid)
450
455
  if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
@@ -476,35 +481,99 @@ export function sessionBranchIndex(): Map<string, string> {
476
481
  return index
477
482
  }
478
483
 
479
- // memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
480
- // sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
481
- // content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
482
- // .spec-scoped.
484
+ // Retain only completed per-worktree overlays. Interpretation + main tip + HEAD + working signature completely
485
+ // determine the merge-base and projection; a landed main tip therefore dissolves now-moot ops on the next read.
483
486
  const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
484
487
  const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
485
- const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
486
- try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
487
- }
488
488
  let layoutHeadWarned = false
489
- async function cachedDelta(wtPath: string, mainRef: string, mainSha: string): Promise<NodeOp[]> {
490
- const wtHead = safeHead(wtPath)
491
- const base = await safeMergeBase(wtPath, mainRef)
492
- // fail loud, never stale: if the merge-base, HEAD, or main tip can't be read the key is untrustworthy —
493
- // bypass the cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha
494
- // across a real change.
495
- if (!base || !wtHead || !mainSha) {
496
- if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD/main tip), recomputing every read') }
497
- return worktreeSpecDelta(wtPath, mainRef)
489
+ type LayoutDeltaOutcome = { ops: NodeOp[] } | { error: unknown }
490
+ type LayoutDeltaFlight = {
491
+ promise: Promise<Map<string, LayoutDeltaOutcome>>
492
+ controller: AbortController
493
+ waiters: Set<symbol>
494
+ settled: boolean
495
+ }
496
+ const layoutDeltaFlights = new Map<string, LayoutDeltaFlight>()
497
+
498
+ // One exact public layout generation owns the cold overlay computation. The map is only an in-flight join:
499
+ // the entry is deleted at settlement and deltaCache remains the sole retained result state.
500
+ async function layoutDeltas(paths: string[], main: string, mainRef: string, mainSha: string): Promise<Map<string, LayoutDeltaOutcome>> {
501
+ const snapshots = paths.map((path) => ({ path, head: safeHead(path), sig: worktreeSpecSig(path) }))
502
+ const interpretation = gitInterpretationIdentity(main)
503
+ const flightKey = JSON.stringify([interpretation, mainRef, mainSha, snapshots.map(({ path, head, sig }) => [path, head, sig]).sort((a, b) => a[0].localeCompare(b[0]))])
504
+ let flight = layoutDeltaFlights.get(flightKey)
505
+ if (flight?.controller.signal.aborted) {
506
+ layoutDeltaFlights.delete(flightKey)
507
+ flight = undefined
508
+ }
509
+ if (!flight) {
510
+ const controller = new AbortController()
511
+ const entry: LayoutDeltaFlight = { promise: Promise.resolve(new Map()), controller, waiters: new Set(), settled: false }
512
+ entry.promise = withGitAbortSignal(controller.signal, async () => {
513
+ const outcomes = new Map<string, LayoutDeltaOutcome>()
514
+ const misses: typeof snapshots = []
515
+ for (const snapshot of snapshots) {
516
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
517
+ const hit = snapshot.head && mainSha ? deltaCache.get(snapshot.path) : null
518
+ if (hit?.key === key) outcomes.set(snapshot.path, { ops: hit.ops })
519
+ else misses.push(snapshot)
520
+ }
521
+
522
+ const valid = misses.filter(({ head }) => !!head && !!mainSha)
523
+ const batched = await worktreeSpecDeltas(main, mainSha, valid.map(({ path, head }) => ({ path, head })), interpretation)
524
+ for (const snapshot of valid) {
525
+ const outcome = batched.get(snapshot.path) ?? { error: new Error(`layout overlay batch omitted ${snapshot.path}`) }
526
+ if ('error' in outcome) outcomes.set(snapshot.path, outcome)
527
+ else {
528
+ const key = `${interpretation}\0${mainSha}\0${snapshot.head}\0${snapshot.sig}`
529
+ deltaCache.set(snapshot.path, { key, ops: outcome.ops })
530
+ outcomes.set(snapshot.path, { ops: outcome.ops })
531
+ }
532
+ }
533
+
534
+ await Promise.all(misses.filter(({ head }) => !head || !mainSha).map(async (snapshot) => {
535
+ if (!existsSync(snapshot.path)) {
536
+ outcomes.set(snapshot.path, { error: new Error(`worktree ${snapshot.path} is absent`) })
537
+ return
538
+ }
539
+ if (!layoutHeadWarned) {
540
+ layoutHeadWarned = true
541
+ console.warn('spec-cli: layout overlay cache bypassed (unreadable HEAD/main tip), recomputing every read')
542
+ }
543
+ try { outcomes.set(snapshot.path, { ops: await worktreeSpecDelta(snapshot.path, mainRef) }) }
544
+ catch (error) { outcomes.set(snapshot.path, { error }) }
545
+ }))
546
+ return outcomes
547
+ }).finally(() => {
548
+ entry.settled = true
549
+ if (layoutDeltaFlights.get(flightKey) === entry) layoutDeltaFlights.delete(flightKey)
550
+ })
551
+ layoutDeltaFlights.set(flightKey, entry)
552
+ flight = entry
553
+ }
554
+
555
+ const token = Symbol(flightKey)
556
+ const callerSignal = currentGitBuildAbortSignal()
557
+ flight.waiters.add(token)
558
+ let onAbort: (() => void) | null = null
559
+ try {
560
+ if (!callerSignal) return await flight.promise
561
+ if (callerSignal.aborted) throw gitAbortError()
562
+ return await Promise.race([
563
+ flight.promise,
564
+ new Promise<Map<string, LayoutDeltaOutcome>>((_, reject) => {
565
+ onAbort = () => reject(gitAbortError())
566
+ callerSignal.addEventListener('abort', onAbort, { once: true })
567
+ }),
568
+ ])
569
+ } finally {
570
+ if (onAbort) callerSignal?.removeEventListener('abort', onAbort)
571
+ flight.waiters.delete(token)
572
+ if (!flight.settled && flight.waiters.size === 0) flight.controller.abort()
498
573
  }
499
- const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
500
- const hit = deltaCache.get(wtPath)
501
- if (hit && hit.key === key) return hit.ops
502
- const ops = await worktreeSpecDelta(wtPath, mainRef, base)
503
- deltaCache.set(wtPath, { key, ops })
504
- return ops
505
574
  }
506
575
 
507
- export async function resolveLayout(): Promise<Layout> {
576
+ export async function resolveLayout(options: { activeSessionIds?: readonly string[] } = {}): Promise<Layout> {
508
577
  const root = repoRoot()
509
578
  const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
510
579
  const cfg = readConfig(main)
@@ -524,22 +593,36 @@ export async function resolveLayout(): Promise<Layout> {
524
593
  const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
525
594
  .filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
526
595
  const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
596
+ const projectedActive = options.activeSessionIds ? new Set(options.activeSessionIds) : null
597
+ const isActive = (record: RawRecord): boolean => projectedActive
598
+ ? projectedActive.has(record.session_id)
599
+ : !record.archived
527
600
  // main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
528
601
  // ([[worktree-linker]]: landed content must dissolve the ops it made moot).
529
602
  const mainSha = await (async () => {
530
603
  try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
531
604
  })()
605
+ const activePaths = records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path)
606
+ const deltas = await layoutDeltas(activePaths, main, mainRef, mainSha)
532
607
  const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
533
608
  const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
534
609
  const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
535
- // @@@ archived rows cost nothing - a shelved session ([[archive]]) keeps its row (the record is the
536
- // existence truth) but skips the per-worktree spec-delta entirely: that git-history probe is the board's
537
- // dominant per-row cost, and shelving is exactly the human saying "stop spending attention here". So the
538
- // price of a retained archive is one enumerated record, NOT a git walk per poll.
539
- if (r.archived) return Promise.resolve(base)
610
+ // @@@ projected shelves cost nothing - a cold archived session ([[archive]]) keeps its record but leaves
611
+ // the working-set projection and skips the per-worktree spec delta. An archived runtime hazard is still
612
+ // projected active by listSessions, so it deliberately retains the same ops in both full and splice builds.
613
+ if (!isActive(r)) return Promise.resolve(base)
540
614
  return guardWorktree<Worktree>(r.worktree_path,
541
- async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef, mainSha) }),
542
- (): Worktree => ({ ...base, ops: deltaCache.get(r.worktree_path)?.ops ?? [] }))
615
+ (): Worktree => {
616
+ const outcome = deltas.get(r.worktree_path)
617
+ if (!outcome) throw new Error(`layout overlay missing ${r.worktree_path}`)
618
+ if ('error' in outcome) throw outcome.error
619
+ return { ...base, ops: outcome.ops }
620
+ },
621
+ (): Worktree => {
622
+ const cached = deltaCache.get(r.worktree_path)
623
+ if (!cached) throw new Error(`layout overlay failed before ${r.worktree_path} had a last-known result`)
624
+ return { ...base, ops: cached.ops }
625
+ })
543
626
  }))
544
627
  const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
545
628
  ? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
@@ -549,9 +632,9 @@ export async function resolveLayout(): Promise<Layout> {
549
632
  const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
550
633
  const worktrees = [mainRow, ...sessionWorktrees]
551
634
  // drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
552
- // newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
635
+ // newly-cold archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
553
636
  // stranding them in a map nothing prunes.
554
- const live = new Set(records.filter(({ raw }) => !raw.archived).map(({ raw }) => raw.worktree_path))
637
+ const live = new Set(records.filter(({ raw }) => isActive(raw)).map(({ raw }) => raw.worktree_path))
555
638
  for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
556
639
  return { main: convention.main || main || root, convention, worktrees }
557
640
  }
@@ -1,12 +1,20 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
3
+ import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, withEventLedgerBuild, type DriftPathEvent } from './git.js'
4
4
  import { loadSpecs, parseFrontmatter } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
6
  import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, resolveSelectors, windowEvents, anchorHitQueries } from './anchors.js'
7
7
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
8
8
 
9
9
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
10
+ export const SPEC_LINT_REPORT_PROJECTION = 'spex.spec-lint.report'
11
+ export const SPEC_LINT_REPORT_SCHEMA_VERSION = 1
12
+ export type SpecLintReport = {
13
+ projection: typeof SPEC_LINT_REPORT_PROJECTION
14
+ schemaVersion: typeof SPEC_LINT_REPORT_SCHEMA_VERSION
15
+ sourceFiles: string[]
16
+ findings: Finding[]
17
+ }
10
18
 
11
19
  export type LintConfig = {
12
20
  governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
@@ -69,6 +77,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
69
77
  }
70
78
 
71
79
  export type SpecLintOptions = { tip?: string }
80
+ type SpecLintRun = { sourceFiles: string[]; findings: Finding[] }
72
81
 
73
82
  function untrackedAdoptionFiles(root: string): string[] {
74
83
  if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
@@ -126,6 +135,24 @@ export async function pendingTouchesGoverned(root: string, tip: string): Promise
126
135
  }
127
136
 
128
137
  export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
138
+ return (await specLintRun(root, regs, options)).findings
139
+ }
140
+
141
+ export async function specLintReport(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<SpecLintReport> {
142
+ const run = await specLintRun(root, regs, options)
143
+ return {
144
+ projection: SPEC_LINT_REPORT_PROJECTION,
145
+ schemaVersion: SPEC_LINT_REPORT_SCHEMA_VERSION,
146
+ sourceFiles: run.sourceFiles,
147
+ findings: run.findings,
148
+ }
149
+ }
150
+
151
+ function specLintRun(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
152
+ return withEventLedgerBuild(root, () => specLintInLedger(root, regs, options))
153
+ }
154
+
155
+ async function specLintInLedger(root: string, regs: ReturnType<typeof extractors>, options: SpecLintOptions): Promise<SpecLintRun> {
129
156
  const tip = options.tip ?? 'HEAD'
130
157
  const pending = tip !== 'HEAD'
131
158
  const changed = pending ? pendingChangedPaths(root, tip) : []
@@ -145,11 +172,14 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
145
172
  if (untracked.length) {
146
173
  const shown = untracked.slice(0, 6)
147
174
  const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
148
- return [{
149
- level: 'error',
150
- rule: 'integrity',
151
- msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
152
- }]
175
+ return {
176
+ sourceFiles: [],
177
+ findings: [{
178
+ level: 'error',
179
+ rule: 'integrity',
180
+ msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
181
+ }],
182
+ }
153
183
  }
154
184
  const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
155
185
  const [hidx, didx] = await sourceIndexes(root, tip)
@@ -436,7 +466,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
436
466
  out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no eval staleness). Most: ${worst}` })
437
467
  }
438
468
 
439
- return out
469
+ return { sourceFiles: governed.slice().sort(), findings: out }
440
470
  }
441
471
 
442
472
  export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
@@ -85,14 +85,19 @@ function publishSelection(path: string, body: string): void {
85
85
  renameSync(prepared, path)
86
86
  }
87
87
 
88
- const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
89
- export function stripSpexcodeBlock(text: string): string {
90
- const m = SENTINEL_RE.exec(text)
88
+ function managedBlockPattern(comment: readonly [string, string]): RegExp {
89
+ const [open, close] = comment
90
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
+ return new RegExp(`\\n*${escape(`${open}spexcode:start${close}`)}[\\s\\S]*?${escape(`${open}spexcode:end${close}`)}\\n*`)
92
+ }
93
+ export function stripSpexcodeBlock(text: string, comment: readonly [string, string] = ['<!-- ', ' -->']): string {
94
+ const sentinel = managedBlockPattern(comment)
95
+ const m = sentinel.exec(text)
91
96
  if (!m) return text
92
97
  // mirror removeManagedBlock exactly: our block + its surrounding blanks collapse to one '\n', and only a
93
98
  // block sitting at the TOP of the file drops the leading newline (a host file beginning with its own
94
99
  // blank lines keeps them — clean(smudge(x)) == x).
95
- const replaced = text.replace(SENTINEL_RE, '\n')
100
+ const replaced = text.replace(sentinel, '\n')
96
101
  return m.index === 0 ? replaced.replace(/^\n+/, '') : replaced
97
102
  }
98
103
  function hostContentOf(file: string): string {
@@ -387,7 +392,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
387
392
  .map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
388
393
  const ignoreFile = join(proj, '.gitignore')
389
394
  const ignoreTracked = isTrackedHere(ignoreFile)
390
- const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
395
+ const ignoreHost = existsSync(ignoreFile) ? stripSpexcodeBlock(readFileSync(ignoreFile, 'utf8'), ['# ', '']) : ''
391
396
  if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
392
397
  const ignoreBody = entries(localEntries)
393
398
  if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
@@ -4,29 +4,29 @@ import { fileURLToPath } from 'node:url'
4
4
  import { copyFileIfChanged, writeFileIfChanged } from './file-write.js'
5
5
 
6
6
  // @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
7
- // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
7
+ // Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.adopter-a` /
8
8
  // `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
9
9
  // write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
10
10
  // materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
11
11
  // (a `.claude-plugin/plugin.json` pointing at hooks/skills/commands/agents); the host's discovery order is
12
- // `.zcode-plugin > .claude-plugin > .codex-plugin` and z-code/Claude both read a `.claude-plugin` directly, so
13
- // the ONE `.claude-plugin` bundle reaches ZCode, Claude, and (future) Codex from a single emit.
12
+ // `.adopter-a-plugin > .claude-plugin > .codex-plugin` and adopter-a/Claude both read a `.claude-plugin` directly, so
13
+ // the ONE `.claude-plugin` bundle reaches AdopterA, Claude, and (future) Codex from a single emit.
14
14
  //
15
15
  // The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
16
16
  // files) — it maps to a SessionStart hook that emits hookSpecificOutput.additionalContext (the harness-neutral
17
- // injection Claude/z-code normalize, the superpowers pattern), so a plugin host gets the contract with no
17
+ // injection Claude/adopter-a normalize, the superpowers pattern), so a plugin host gets the contract with no
18
18
  // --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
19
19
  // ${CLAUDE_PLUGIN_ROOT} variable; dispatch.sh's first arg is the harness id `plugin`, so its shell mirror
20
- // (harness.sh) parses payloads as the claude family (z-code/Claude share Claude's tool names + file_path).
20
+ // (harness.sh) parses payloads as the claude family (adopter-a/Claude share Claude's tool names + file_path).
21
21
 
22
22
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
23
23
  const HOOKS_SRC = join(PKG, 'hooks') // the canonical dispatch.sh + harness.sh source
24
24
  const PLUGIN_NAME = 'spexcode'
25
25
  // the host substitutes ${CLAUDE_PLUGIN_ROOT} with the bundle's own absolute path before running a hook command
26
- // (the same variable z-code's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
26
+ // (the same variable adopter-a's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
27
27
  // where the host scanned the bundle from.
28
28
  const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}'
29
- // the lifecycle events the bundle binds — the Claude/z-code superset; a host that fires fewer (Codex) simply
29
+ // the lifecycle events the bundle binds — the Claude/adopter-a superset; a host that fires fewer (Codex) simply
30
30
  // never invokes the extras, so binding all is harmless and one emit serves every host.
31
31
  const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
32
32
 
@@ -51,7 +51,7 @@ export function pluginBundleDir(proj: string, folder: string): string {
51
51
  }
52
52
 
53
53
  // the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
54
- // the component pointers Claude/z-code discover (hooks.json + the skills/commands/agents dirs).
54
+ // the component pointers Claude/adopter-a discover (hooks.json + the skills/commands/agents dirs).
55
55
  function pluginManifest(version: string): string {
56
56
  return JSON.stringify({
57
57
  name: PLUGIN_NAME,
@@ -64,7 +64,7 @@ function pluginManifest(version: string): string {
64
64
  }, null, 2)
65
65
  }
66
66
 
67
- // hooks.json in the Claude/z-code-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
67
+ // hooks.json in the Claude/adopter-a-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
68
68
  // event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
69
69
  // ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
70
70
  function pluginHooksJson(spex: string): string {
@@ -90,7 +90,7 @@ function contractContextJson(contract: string): string {
90
90
  // stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
91
91
  const INJECT_SH = `#!/usr/bin/env bash
92
92
  # Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
93
- # superpowers pattern; Claude/z-code normalize hookSpecificOutput.additionalContext) that replaces a plugin
93
+ # superpowers pattern; Claude/adopter-a normalize hookSpecificOutput.additionalContext) that replaces a plugin
94
94
  # host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
95
95
  here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
96
96
  [ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
@@ -42,6 +42,9 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
42
42
 
43
43
  export type EvalDetailReview = {
44
44
  scope: string | null
45
+ requestedScope: string | null
46
+ scopeFallback: 'trunk' | null
47
+ availability: 'measured' | 'unmeasured' | 'missing'
45
48
  selected: ReviewItem | null
46
49
  history: ReviewItem[]
47
50
  neighbors: {
@@ -56,6 +59,16 @@ export type EvalDetailReview = {
56
59
  evalRevision?: SessionEvals['evalRevision']
57
60
  }
58
61
 
62
+ type EvalDetailMetadata = {
63
+ scope?: string | null
64
+ requestedScope?: string | null
65
+ scopeFallback?: 'trunk' | null
66
+ summary?: SessionEvals['summary']
67
+ evalRevision?: SessionEvals['evalRevision']
68
+ // the scope's whole measured sequence, when `items` deliberately holds only the rendered window
69
+ sequence?: { node: string; scenario: string }[]
70
+ }
71
+
59
72
  const revisionOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
60
73
 
61
74
  export function reviewPageNumber(value: unknown): number {
@@ -113,7 +126,7 @@ export function paginateReview<T extends ReviewItem>(
113
126
  pageCount,
114
127
  prev: page > 1 ? page - 1 : null,
115
128
  next: page < pageCount || overflow ? page + 1 : null,
116
- revision: revisionOf(revisionInputs),
129
+ revision: revisionOf({ page, source: revisionInputs }),
117
130
  ...responseModel(model),
118
131
  }
119
132
  }
@@ -259,16 +272,15 @@ export function projectEvalDetail(
259
272
  historySource: ReviewItem[],
260
273
  node: string,
261
274
  scenario: string,
262
- metadata: {
263
- scope?: string | null
264
- summary?: SessionEvals['summary']
265
- evalRevision?: SessionEvals['evalRevision']
266
- // the scope's whole measured sequence, when `items` deliberately holds only the rendered window
267
- sequence?: { node: string; scenario: string }[]
268
- } = {},
275
+ metadata: EvalDetailMetadata = {},
269
276
  ): EvalDetailReview {
270
277
  const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
271
278
  const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
279
+ const availability = selected
280
+ ? 'measured'
281
+ : items.some((item: any) => evalItemKey(item) === `${node}\0${scenario}` && item.filterKind === EVAL_FILTER_KIND.BLIND)
282
+ ? 'unmeasured'
283
+ : 'missing'
272
284
  const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
273
285
  const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
274
286
  const sequence = metadata.sequence
@@ -276,12 +288,17 @@ export function projectEvalDetail(
276
288
  const neighbors = boundedEvalNeighbors(sequence, node, scenario,
277
289
  (row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
278
290
  const scope = metadata.scope ?? null
291
+ const requestedScope = metadata.requestedScope ?? scope
292
+ const scopeFallback = metadata.scopeFallback ?? null
279
293
  return {
280
294
  scope,
295
+ requestedScope,
296
+ scopeFallback,
297
+ availability,
281
298
  selected,
282
299
  history,
283
300
  neighbors,
284
- revision: revisionOf({ scope, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
301
+ revision: revisionOf({ scope, requestedScope, scopeFallback, availability, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
285
302
  ...(metadata.summary ? { summary: metadata.summary } : {}),
286
303
  ...(metadata.evalRevision ? { evalRevision: metadata.evalRevision } : {}),
287
304
  }
@@ -306,45 +323,51 @@ export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario:
306
323
  return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
307
324
  }
308
325
 
309
- export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
326
+ async function trunkEvalDetailReview(node: string, scenario: string, metadata: EvalDetailMetadata = {}): Promise<EvalDetailReview> {
327
+ await getBoard()
328
+ const snapshot = readReviewSnapshot()
329
+ const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
330
+ return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario, metadata)
331
+ }
332
+
333
+ export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview> {
310
334
  if (scope) {
311
335
  // A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
312
336
  // So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
313
337
  // a build that finds a full cached model ignores the pick and answers from it instead.
314
338
  const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
315
- if (!model) return null
339
+ if (!model) return trunkEvalDetailReview(node, scenario, { requestedScope: scope, scopeFallback: 'trunk' })
316
340
  const sourceNode = model.nodes.find((candidate) => candidate.id === node)
317
341
  return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
318
342
  scope,
343
+ requestedScope: scope,
319
344
  summary: model.summary,
320
345
  evalRevision: model.evalRevision,
321
346
  ...(model.order ? { sequence: measuredSequence(model.order) } : {}),
322
347
  })
323
348
  }
324
- await getBoard()
325
- const snapshot = readReviewSnapshot()
326
- const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
327
- return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario)
349
+ return trunkEvalDetailReview(node, scenario)
328
350
  }
329
351
 
330
- async function timelineEvalReview(text: string, requestedPage: unknown) {
331
- const node = readToken(text, 'node')
332
- if (!node) return null
333
- const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
334
- const measured = new Set(timeline.readings.map((reading) => reading.scenario))
335
- const items = [
336
- ...timeline.scenarios.filter((scenario) => !measured.has(scenario.name)).map((scenario) => ({
352
+ export function timelineEvalReviewItems(timeline: Awaited<ReturnType<typeof evalTimeline>>, node: string): ReviewItem[] {
353
+ const declared = new Set(timeline.scenarios.map((scenario) => scenario.name))
354
+ const latest = new Map<string, any>()
355
+ for (const reading of timeline.readings) {
356
+ if (declared.has(reading.scenario) && !latest.has(reading.scenario)) latest.set(reading.scenario, reading)
357
+ }
358
+ return [
359
+ ...timeline.scenarios.filter((scenario) => !latest.has(scenario.name)).map((scenario) => ({
337
360
  ...scenario,
338
361
  scenario: scenario.name,
339
362
  node,
340
363
  filterKind: EVAL_FILTER_KIND.UNMEASURED,
341
364
  })),
342
- ...timeline.readings.map((reading, index) => ({
365
+ ...[...latest.values()].map((reading) => ({
343
366
  ...reading,
344
367
  state: evalReviewState(reading),
345
368
  node,
346
369
  filterKind: EVAL_FILTER_KIND.RESULT,
347
- filterKey: `${EVAL_FILTER_KIND.RESULT}:${index}`,
370
+ filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
348
371
  })),
349
372
  ...(timeline.dangling ?? []).map((track) => ({
350
373
  ...track,
@@ -353,6 +376,13 @@ async function timelineEvalReview(text: string, requestedPage: unknown) {
353
376
  filterKey: `${EVAL_FILTER_KIND.DANGLING}:${track.threadId}`,
354
377
  })),
355
378
  ]
379
+ }
380
+
381
+ async function timelineEvalReview(text: string, requestedPage: unknown) {
382
+ const node = readToken(text, 'node')
383
+ if (!node) return null
384
+ const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
385
+ const items = timelineEvalReviewItems(timeline, node)
356
386
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
357
387
  return {
358
388
  scope: null,
@@ -379,14 +409,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
379
409
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
380
410
  return {
381
411
  scope,
382
- gates: model.gates ?? [],
412
+ gates: [],
383
413
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
384
414
  summary: model.summary,
385
415
  evalRevision: model.evalRevision,
386
- impact: model.impact,
387
416
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
388
- domain: 'evals', scope, items, gates: model.gates ?? [], summary: model.summary,
389
- evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
417
+ domain: 'evals', scope, query: text, gates: [], summary: model.summary,
418
+ evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
390
419
  }),
391
420
  }
392
421
  }
@@ -0,0 +1,68 @@
1
+ import { streamSSE } from 'hono/streaming'
2
+ import type { Context } from 'hono'
3
+ import { harnessById } from './harness.js'
4
+ import { readAliasedRawRecord } from './layout.js'
5
+ import { currentHumanTurn } from './session-timeline.js'
6
+ import type { ExecutionStep, ExecutionTurn } from './execution-trace.js'
7
+
8
+ export type SessionExecution = Readonly<{
9
+ revision: string
10
+ turnId: string | null
11
+ workingNote: string | null
12
+ steps: readonly ExecutionStep[]
13
+ }>
14
+
15
+ const absentExecution = (turn: ExecutionTurn | null = null): SessionExecution => ({ revision: `turn:${turn?.token ?? '0'}`, turnId: turn?.token ?? null, workingNote: null, steps: [] })
16
+
17
+ // This is deliberately a read projection. Native transcript bytes stay within the adapter, while the API owns
18
+ // only enough information for the frontend's fixed renderer to paint the current execution slice.
19
+ export function readSessionExecution(id: string): SessionExecution | null {
20
+ let record: ReturnType<typeof readAliasedRawRecord>
21
+ try { record = readAliasedRawRecord(id) } catch { return null }
22
+ if (!record?.governed) return null
23
+ const turn = currentHumanTurn(id)
24
+ const threadId = typeof record.harness_session_id === 'string' ? record.harness_session_id : ''
25
+ if (!threadId) return absentExecution(turn)
26
+ try {
27
+ const trace = harnessById(typeof record.harness === 'string' && record.harness ? record.harness : 'claude')
28
+ .executionTrace(threadId, turn)
29
+ return trace || absentExecution(turn)
30
+ } catch {
31
+ // A missing/unreadable native source is an absent transient trace, not a synthetic conversation failure.
32
+ return absentExecution(turn)
33
+ }
34
+ }
35
+
36
+ const TRACE_TICK_MS = 500
37
+ const HEARTBEAT_TICKS = 20
38
+
39
+ export async function sessionExecutionStream(c: Context) {
40
+ const id = c.req.param('id') || ''
41
+ if (!readSessionExecution(id)) return c.json({ error: 'no such session' }, 404)
42
+ return streamSSE(c, async (stream) => {
43
+ let aborted = false
44
+ let lastRevision = ''
45
+ let ticks = 0
46
+ const publish = async (): Promise<boolean> => {
47
+ const execution = readSessionExecution(id)
48
+ if (!execution) return false
49
+ if (execution.revision === lastRevision) return true
50
+ lastRevision = execution.revision
51
+ await stream.writeSSE({ event: 'execution', data: JSON.stringify(execution) })
52
+ return true
53
+ }
54
+
55
+ stream.onAbort(() => { aborted = true })
56
+ try {
57
+ if (!await publish()) return
58
+ while (!aborted) {
59
+ await stream.sleep(TRACE_TICK_MS)
60
+ if (aborted) break
61
+ if (!await publish()) break
62
+ if (++ticks % HEARTBEAT_TICKS === 0) await stream.writeSSE({ event: 'ping', data: 'x' })
63
+ }
64
+ } catch {
65
+ // EventSource reconnects a dropped stream. A native trace read must never take down the session server.
66
+ }
67
+ })
68
+ }