spexcode 0.5.8 → 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 +185 -56
  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 +5 -3
  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
@@ -20,8 +20,8 @@
20
20
  } catch (e) { /* localStorage unavailable — the bare :root default (Minimal) stands */ }
21
21
  })()
22
22
  </script>
23
- <script type="module" crossorigin src="/assets/index-Di1ch5dd.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-CixSnz1H.css">
23
+ <script type="module" crossorigin src="/assets/index-paP-z_Vd.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-DAbQBBK_.css">
25
25
  </head>
26
26
  <body>
27
27
  <div id="root"></div>
@@ -38,8 +38,7 @@ export const sessionZone = (s) => {
38
38
  return NEED_STATUS.has(s?.status) ? 'need' : 'run'
39
39
  }
40
40
  export const ZONE_ORDER = ['need', 'run', 'offline']
41
- // @@@ archive is a separate flat cold collection, never a fourth working zone ([[archive]]). Every list surface
42
- // splits the default working population from true archived/offline rows before applying any status/nesting fold.
41
+
43
42
  export const isArchived = (s) => !!s?.archived
44
43
  export const splitArchived = (sessions = []) => ({
45
44
  live: sessions.filter((s) => !isArchived(s)),
@@ -91,11 +90,6 @@ export const sessionHandle = (s) =>
91
90
  export const sessionHeadline = (s) =>
92
91
  s?.headline || s?.name || s?.activity || s?.promptPreview || s?.node || s?.title || s?.branch || s?.id
93
92
 
94
- // @@@ session nesting ([[session-nesting]]) — a session launched by `spex new` from INSIDE another carries
95
- // that spawner's id as `parent`. Fold it into a forest, DERIVED here at read time (never stored on the child):
96
- // a child nests under its parent ONLY IF that parent is present in this list, so a closed parent's children
97
- // auto-promote to top-level on the next board read. Returns the top-level `roots` (a real parent or an orphan
98
- // whose parent is gone) and `childrenOf` (parentId → its direct children), both recursive to any depth.
99
93
  export function nestSessions(sessions) {
100
94
  const present = new Set(sessions.map((s) => s?.id))
101
95
  const childrenOf = new Map()
@@ -125,11 +119,6 @@ export function sessionAncestorIds(sessions, id) {
125
119
  return ids
126
120
  }
127
121
 
128
- // @@@ subtree rollup ([[session-nesting]]) — the count-badge COLOUR: a PURELY informational summary of the
129
- // hidden subtree that must NOT touch the parent's own status/glyph/zone/sort. Dark-yellow if ANY descendant
130
- // needs attention (the needs-you zone, error folded in — the widest signal wins); else green if any descendant
131
- // is actively running (a STATUS_COLOR-green status: working/parked); else neutral (all idle/offline). Reuses
132
- // the STATUS_COLOR hues so the badge speaks the same four-hue language as every other status mark.
133
122
  export function subtreeRollup(id, childrenOf) {
134
123
  let need = false, run = false, count = 0
135
124
  const walk = (pid, seen) => {
@@ -146,22 +135,6 @@ export function subtreeRollup(id, childrenOf) {
146
135
  return { color: need ? STATUS_COLOR.asking : run ? STATUS_COLOR.working : STATUS_COLOR.idle, count }
147
136
  }
148
137
 
149
- // @@@ the ordered render list ([[session-nesting]]) all desktop/mobile session-list surfaces share. Roots are zone-sorted by
150
- // their OWN status (no aggregation), each carrying a zone header when the zone changes; a parent's children
151
- // follow it (zone-sorted among themselves) ONLY when `isExpanded(id)` — collapsed by default, so a fleet reads
152
- // as one row. Emits {type:'zone',zone} and {type:'row', s, depth, expandable, expanded, rollup, guides}; the
153
- // visible row order is also what ↑/↓ nav and drag-reorder walk, so a collapsed child is never a hidden nav
154
- // target. `guides` is the file-tree rail vector, one bool per connector column (length === depth): the LAST
155
- // entry marks whether THIS row has a following sibling (branch tee vs end elbow), each earlier entry whether
156
- // the ancestor in that column continues (draw a pass-through vertical line vs blank).
157
- //
158
- // @@@ offline-history fold ([[session-console]]) — the OFFLINE zone rests folded behind its header, the one
159
- // disclosure for retained session history: `zoneFolded(zone)` names the folded zones (per-surface state; the
160
- // product folds only 'offline', collapsed by default), and a folded zone emits its header — enriched with the
161
- // hidden `count` and `folded: true` so the surface renders it as the disclosure — but none of its rows,
162
- // EXCEPT any row `keepVisible(s)` claims (the current selection / graph lock): a session reached by URL,
163
- // search, or a menu stays a visible row even while its zone is folded. Presentation only — no record is
164
- // touched — and never applicable to the needs-you / running zones, whose rows this fold cannot hide.
165
138
  export function sessionForest(sessions, isExpanded, { zoneFolded = () => false, keepVisible = () => false } = {}) {
166
139
  const { roots, childrenOf } = nestSessions(sessions)
167
140
  const items = []
@@ -5,19 +5,12 @@ import { gitCommonDir } from '../../spec-cli/src/layout.js'
5
5
 
6
6
  export const MISS_BLOB = 'miss original file'
7
7
 
8
- // every cache fn takes an optional `dir` (defaulting to the live cache dir) so the logic is testable
9
- // against a temp dir without a git repo. The dir is a PER-CLONE cache (never tracked): renaming it
10
- // (yatsu-blobs → evidence, v0.3.0) migrated no bytes — a missing blob re-fills via `spex evidence put`
11
- // (putBlob is idempotent by content) or streams from the backend on a `get` miss.
12
8
  export function cacheDir(): string {
13
9
  return join(gitCommonDir(), 'spexcode', 'evidence')
14
10
  }
15
11
 
16
- // a content-addressed blob name = the sha256 of its bytes (64 hex). The backstop recognises a stray one.
17
12
  const BLOB_NAME = /^[0-9a-f]{64}$/
18
13
 
19
- // store bytes, return the content hash (the value recorded as a reading's `blob`). Idempotent: identical
20
- // content maps to the same name, written once.
21
14
  export function putBlob(bytes: Buffer, dir = cacheDir()): string {
22
15
  const sha = createHash('sha256').update(bytes).digest('hex')
23
16
  mkdirSync(dir, { recursive: true })
@@ -34,14 +27,11 @@ export function hasBlob(sha: string | null, dir = cacheDir()): boolean {
34
27
  return !!sha && existsSync(blobPath(sha, dir))
35
28
  }
36
29
 
37
- // render a reading's blob reference: the cache path when present, the MISS sentinel when the record
38
- // outlived its bytes, '' when the reading had no image at all (a pixel-less observation).
39
30
  export function resolveBlob(sha: string | null, dir = cacheDir()): string {
40
31
  if (!sha) return ''
41
32
  return hasBlob(sha, dir) ? blobPath(sha, dir) : MISS_BLOB
42
33
  }
43
34
 
44
- // every content-addressed blob currently in the cache.
45
35
  export function listBlobs(dir = cacheDir()): string[] {
46
36
  if (!existsSync(dir)) return []
47
37
  return readdirSync(dir).filter((n) => BLOB_NAME.test(n)).sort()
@@ -57,14 +47,10 @@ export function gc(keep: Set<string>, dir = cacheDir()): string[] {
57
47
  return removed
58
48
  }
59
49
 
60
- // read a blob's bytes (used by callers that render/serve a reading's pixels); null when absent.
61
50
  export function getBlob(sha: string | null, dir = cacheDir()): Buffer | null {
62
51
  return hasBlob(sha, dir) ? readFileSync(blobPath(sha!, dir)) : null
63
52
  }
64
53
 
65
- // is a staged repo path a stray content-addressed blob? (a 64-hex basename, or anything under a copied-in
66
- // evidence cache dir — the retired `yatsu-blobs` name included, since a stray copy of an ARCHIVED cache is
67
- // the same mistake). The pre-commit backstop rejects these so pixels never leak into git history.
68
54
  export function isStrayBlob(path: string): boolean {
69
55
  const base = path.slice(path.lastIndexOf('/') + 1)
70
56
  return BLOB_NAME.test(base) || path.includes('spexcode/evidence/') || path.includes('/yatsu-blobs/') // dead-words-ok: archived cache dir name — a stray copy of the retired cache is still rejected
@@ -7,7 +7,7 @@ import { trackedSourceFiles } from '../../spec-cli/src/source-files.js'
7
7
  import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
8
8
  import { evalNodes, evalNodesAt, validateScenarios, resolveEvalNode, scenarioCodeAxis, scenarioHash, scenarioProjection, writeScenarioMeasurementMetadata, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
9
9
  import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
10
- import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems } from './freshness.js'
10
+ import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems, type AnchorDemand } from './freshness.js'
11
11
  import { parseRelation, relationClaimsPath } from '../../spec-cli/src/anchors.js'
12
12
  import { scenarioIndex } from './scenariofresh.js'
13
13
  import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
@@ -138,6 +138,27 @@ async function scan(args: string[] = []): Promise<number> {
138
138
  const yByDir = new Map(evalNodes(root).map((n) => [relative(root, n.dir), n]))
139
139
  const nodeDirs = specs.map((s) => dirname(s.path))
140
140
  let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
141
+ // One anchor prime for the whole scan. Every window's Git images and hunks are immutable facts the engine
142
+ // owns per CALL, so priming per reading re-forks that batch for every row scanned. The demand set mirrors
143
+ // the loop's own `driftSelected` rule exactly, so `--changed` still asks about nothing it would not judge;
144
+ // the in-loop primes stay as the correctness backstop and become cache hits.
145
+ const latestByDir = new Map<string, ReturnType<typeof latestPerScenario>>()
146
+ const anchorDemands: AnchorDemand[] = []
147
+ for (const s of specs) {
148
+ const dirRel = dirname(s.path)
149
+ const y = yByDir.get(dirRel)
150
+ if (!y) continue
151
+ const latest = latestPerScenario(readReadings(y.sidecarPath))
152
+ latestByDir.set(dirRel, latest)
153
+ for (const sc of y.scenarios) {
154
+ const r = latest.get(sc.name)
155
+ if (!r) continue
156
+ const axis = scenarioCodeAxis(sc.code, s.codeEntries)
157
+ if (changed && !nodeChanged(dirRel, axis.paths, changed, nodeDirs)) continue
158
+ anchorDemands.push({ sinceSha: r.codeSha, entries: axis.entries })
159
+ }
160
+ }
161
+ await anchors.prime?.(anchorDemands)
141
162
  for (const s of specs) {
142
163
  const dirRel = dirname(s.path)
143
164
  const nodeSelected = !changed || nodeChanged(dirRel, s.code, changed, nodeDirs)
@@ -152,7 +173,7 @@ async function scan(args: string[] = []): Promise<number> {
152
173
  findings.push(` • eval-schema: '${s.id}' ${e} — fix ${y.evalPath}`)
153
174
  }
154
175
  }
155
- const latest = latestPerScenario(readReadings(y.sidecarPath))
176
+ const latest = latestByDir.get(dirRel) ?? latestPerScenario(readReadings(y.sidecarPath))
156
177
  for (const sc of y.scenarios) {
157
178
  // a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
158
179
  // would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
@@ -160,7 +181,7 @@ async function scan(args: string[] = []): Promise<number> {
160
181
  // ONE resolution of the axis: base paths for every path consumer (existence, changed-scan selection,
161
182
  // drift display), folded selectors for the narrowing. A raw `path#symbol` matches no real file, so
162
183
  // reading it as a path would report a ghost and drop the scenario out of --changed selection.
163
- const axis = scenarioCodeAxis(sc.code, s.code)
184
+ const axis = scenarioCodeAxis(sc.code, s.codeEntries)
164
185
  if (nodeSelected) {
165
186
  for (const [field, paths] of [['code', axis.paths], ['related', parseRelation(sc.related ?? [], 'related').entries.map((e) => e.path)]] as const) {
166
187
  const ghosts = paths.filter((p) => !existsSync(join(root, p)))
@@ -193,8 +214,8 @@ async function scan(args: string[] = []): Promise<number> {
193
214
  if (!driftSelected) continue
194
215
  const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
195
216
  if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, codeFiles, y.evalPath)
196
- await anchors.prime?.(r.codeSha, axis.entries)
197
- const axes = staleAxes(r, sc.code?.length ? sc.code : s.code, y.evalPath, idx, scidx, remSignals, probe, sc, anchors)
217
+ await anchors.prime?.([{ sinceSha: r.codeSha, entries: axis.entries }])
218
+ const axes = staleAxes(r, axis.entries, y.evalPath, idx, scidx, remSignals, probe, sc, anchors)
198
219
  if (axes.length) {
199
220
  staleScores++
200
221
  // a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
@@ -3,19 +3,14 @@ import { repoRoot, driftIndex, historyIndex, commitReachable, type DriftIndex, t
3
3
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
4
  import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
5
5
  import { evalNodes, scenarioCodeAxis, type EvalNode, type ScenarioTestReference } from './scenarios.js'
6
- import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
6
+ import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction, type Reading, type HumanOk } from './sidecar.js'
7
+ import type { RelationEntry } from '../../spec-cli/src/anchors.js'
7
8
  import { staleAxes, codeDrift, contentProbeFor, anchorProbeFor, type StaleAxis } from './freshness.js'
8
9
  import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
9
10
  import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
10
11
 
11
- // one evidence entry as the tab renders it: the content hash, its kind, and its LIVE blob state (present, or
12
- // miss when the bytes were pruned). The whole list is the gallery the dashboard maps.
13
12
  export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present' | 'miss' }
14
13
 
15
- // a remark overlaid onto the reading it judged ([[remark-teeth]] R2): the resolvable fields the eval
16
- // surfaces read, plus `dangling` when its targetCodeSha matched no reading (so it was attached to the
17
- // scenario's latest as a fallback, never hidden). The teeth read the whole scenario track; THIS is the
18
- // per-reading display attachment.
19
14
  export type RemarkView = {
20
15
  rid: string
21
16
  ref: string // `<thread-id>#<rid>` — the address `spex remark resolve`/`spex remark retract` take
@@ -33,44 +28,23 @@ export type EvalEntry = {
33
28
  scenario: string
34
29
  expected: string
35
30
  codeSha: string
36
- // the reading's whole evidence list (N images and/or a video and/or a transcript). Always populated —
37
- // a legacy scalar reading normalizes to a one-entry list — so every read surface sees a gallery.
38
31
  evidence?: EvidenceView[]
39
- // primary scalar view (the video entry if any, else the first) — the single-evidence compat face for
40
- // consumers that still read one blob (the session proof, the board fold's kind hint).
41
32
  blob: string | null
42
33
  blobKind?: EvidenceKind
43
34
  timelineBlob?: string
44
- // legacy instrument tag ('manual@1') — surfaced for old readings only, never written by new filings.
45
35
  evaluator?: string
46
- // the SESSION that filed this reading ([[event-detail]] originator liveness / [[mentions]] loop-in): the
47
- // reachable actor an un-@'d eval remark courtesy-delivers to (the latest reading's filer is the chain's
48
- // first link). Surfaced so the eval detail can show whether that session is still alive. Absent on a legacy
49
- // reading (no `by`) — the pane simply shows no originator, exactly as the offline chain runs dry silently.
50
36
  by?: string
51
37
  verdict?: Verdict
52
38
  ts: string
53
39
  fresh: boolean
54
40
  staleAxes: StaleAxis[]
55
41
  blobState: 'present' | 'miss' | 'none'
56
- // the code axis's drift detail for a code-stale reading ([[eval-core]]'s codeDrift): each governed file
57
- // that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
58
- // ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
59
42
  codeDrift?: { file: string; behind: number }[]
60
- // the trunk remark track overlaid onto THIS reading ([[remark-teeth]]): the remarks whose targetCodeSha
61
- // pins here (or the latest reading, for a dangling target). Absent when the scenario has no remark.
62
43
  remarks?: RemarkView[]
63
- // the (node, scenario) eval-remark THREAD ([[eval-issue-split]]): the SAME join the teeth read, attached
64
- // so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
65
- // splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
66
44
  thread?: Issue
67
- // the human sign-off bound to THIS reading ([[human-ok]]) — present only on the exact reading an ok row
68
- // anchors (by scenario + ts), so a newer reading arrives unblessed and the feed's hide releases itself.
69
- // Rides the board fold verbatim (latestPerScenario is a filter, never a projection).
70
45
  humanOk?: { by: string; ts: string }
71
46
  }
72
47
 
73
- // a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
74
48
  function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkView {
75
49
  return {
76
50
  rid: rm.rid!,
@@ -84,32 +58,16 @@ function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkVie
84
58
  }
85
59
  }
86
60
 
87
- // a DANGLING track ([[remark-teeth]]'s dangling clause / directive 5): a (node, scenario) remark track whose
88
- // scenario no reading joins — the scenario was renamed or deleted, so today the track loads but surfaces
89
- // nowhere. It ages NOTHING (there is no reading to stale — the teeth read per-reading), but its remarks must
90
- // stay VISIBLE and resolvable/retractable via their refs, so evalTimeline emits one synthetic row per orphan
91
- // at NODE level. `scenario` is the orphaned name (rendered struck-through / gone); `remarks` are all dangling.
92
61
  export type DanglingTrack = { scenario: string; threadId: string; thread: Issue; remarks: RemarkView[] }
93
62
 
94
63
  export type ScenarioInfo = { name: string; expected: string; tags?: string[]; test?: ScenarioTestReference; code?: string[] }
95
64
 
96
- // `hasEvalFile` distinguishes a node that declares no scenarios (no eval.md) from one that declares some but
97
- // has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
98
- // is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
99
- // like the history tab).
100
65
  export type EvalTimeline = {
101
66
  node: string
102
67
  hasEvalFile: boolean
103
68
  scenarios: ScenarioInfo[]
104
69
  readings: EvalEntry[]
105
- // retraction events ([[eval-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
106
- // above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
107
- // this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
108
- // by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
109
70
  retractions: Retraction[]
110
- // orphaned remark tracks (renamed/deleted scenarios) — surfaced at node level so their remarks never vanish
111
- // ([[remark-teeth]] dangling clause). SEPARATE from `readings` on purpose: a dangling track has no reading,
112
- // so it must NOT flow into latestPerScenario / the board scoreboard — it ages nothing.
113
71
  dangling: DanglingTrack[]
114
72
  }
115
73
 
@@ -118,48 +76,84 @@ export type EvalContext = {
118
76
  specs: Awaited<ReturnType<typeof loadSpecs>>
119
77
  idx: DriftIndex
120
78
  hidx: HistoryIndex
121
- scidx: ScenarioIndex // per-scenario block-change history ([[scenariofresh]]) — the SCENARIO axis, built ONCE per build
79
+ scidx: ScenarioIndex
122
80
  ynodes: EvalNode[]
123
- // the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
124
- // and reused for every node, so the fold never re-reads the issue store per node.
125
81
  remarks: Map<string, RemarkTrack>
126
82
  }
127
83
 
128
- // build the shared context with ONE eval-file walk, reusing the caller's already-computed specs + the two
129
- // HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
130
- // hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
131
- // ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
132
84
  export async function evalContext(
133
85
  root: string,
134
86
  specs: Awaited<ReturnType<typeof loadSpecs>>,
135
87
  idx: DriftIndex,
136
88
  hidx: HistoryIndex,
137
89
  remarks?: Map<string, RemarkTrack>,
138
- ynodes?: EvalNode[], // the hot board build precomputes these off the event loop (evalNodesAsync); a bare caller walks sync
90
+ ynodes?: EvalNode[],
139
91
  ): Promise<EvalContext> {
140
92
  const nodes = ynodes ?? evalNodes(root)
141
93
  const scidx = await scenarioIndex(root, nodes.map((n) => n.evalPath))
142
94
  return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
143
95
  }
144
96
 
145
- export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
97
+ // @@@ one read, one batch - the freshness engine's Git work is immutable-object work, so it is owned ONCE
98
+ // per read rather than once per reading. evalTimelines is the plural the graph build actually wants: it
99
+ // plans every node's rows first (pure fs + in-memory projection), primes the content and anchor probes with
100
+ // the WHOLE demand set, then assembles. Singular evalTimeline is the one-id case of the same path.
101
+ export async function evalTimelines(ids: readonly string[], ctx?: EvalContext): Promise<EvalTimeline[]> {
146
102
  const root = ctx?.root ?? repoRoot()
147
- // short-circuit a non-measurable node on the (short) eval-file walk — the board attaches `evals` to every node, so
148
- // this is the common case and must stay cheap (a list the size of the few measurable nodes, not the whole tree).
149
- const ynode = (ctx?.ynodes ?? evalNodes(root)).find((n) => n.id === id)
150
- if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
151
- // the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
152
- // reparent/rename is seen the same way `spex spec lint` and `spex eval add` see it (joined by directory).
103
+ const ynodes = ctx?.ynodes ?? evalNodes(root)
153
104
  const specs = ctx?.specs ?? await loadSpecs()
154
- const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
155
105
  const idx = ctx?.idx ?? await driftIndex(root)
156
106
  const hidx = ctx?.hidx ?? await historyIndex(root)
157
- // the SCENARIO axis: per-scenario block-change history, built once per HEAD (cached). A bare call builds it
158
- // for the WHOLE measurable set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
159
- const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? evalNodes(root)).map((n) => n.evalPath))
160
- const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
161
- // the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
107
+ const scidx = ctx?.scidx ?? await scenarioIndex(root, ynodes.map((n) => n.evalPath))
162
108
  const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
109
+ const probe = contentProbeFor(root)
110
+ const anchors = anchorProbeFor(root, idx)
111
+
112
+ type Row = { reading: Reading; axis: ReturnType<typeof scenarioCodeAxis> }
113
+ type Plan = { id: string; ynode?: EvalNode; codeEntries: RelationEntry[]; rows: Row[]; retractions: Retraction[]; oks: HumanOk[] }
114
+ const plans: Plan[] = ids.map((id) => {
115
+ const ynode = ynodes.find((n) => n.id === id)
116
+ if (!ynode) return { id, codeEntries: [], rows: [], retractions: [], oks: [] }
117
+ const codeEntries = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.codeEntries ?? []
118
+ const byName = new Map(ynode.scenarios.map((s) => [s.name, s]))
119
+ const { readings, retractions, oks } = readSidecar(ynode.sidecarPath)
120
+ const rows = applyRetractions(readings, retractions).map((reading) => ({
121
+ reading, axis: scenarioCodeAxis(byName.get(reading.scenario)?.code, codeEntries),
122
+ }))
123
+ return { id, ynode, codeEntries, rows, retractions, oks }
124
+ })
125
+
126
+ // An off-history anchor is the only reading that needs a content verdict; those primes serialize inside the
127
+ // probe per anchor, so issuing them together lets one anchor's paths union into one child instead of N.
128
+ await Promise.all(plans.flatMap((plan) => plan.ynode
129
+ ? plan.rows.filter((row) => !commitReachable(idx, row.reading.codeSha))
130
+ .map((row) => probe.prime?.(row.reading.codeSha, row.axis.paths, plan.ynode!.evalPath))
131
+ : []))
132
+ await anchors.prime?.(plans.flatMap((plan) => plan.rows.map((row) => ({ sinceSha: row.reading.codeSha, entries: row.axis.entries }))))
133
+
134
+ return plans.map((plan) => assembleTimeline(plan.id, plan.ynode, plan.rows, plan.retractions, plan.oks, {
135
+ idx, scidx, tracks, probe, anchors,
136
+ }))
137
+ }
138
+
139
+ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
140
+ return (await evalTimelines([id], ctx))[0]
141
+ }
142
+
143
+ type AssembleDeps = {
144
+ idx: DriftIndex; scidx: ScenarioIndex; tracks: Map<string, RemarkTrack>
145
+ probe: ReturnType<typeof contentProbeFor>; anchors: ReturnType<typeof anchorProbeFor>
146
+ }
147
+ function assembleTimeline(
148
+ id: string,
149
+ ynode: EvalNode | undefined,
150
+ rows: { reading: Reading; axis: ReturnType<typeof scenarioCodeAxis> }[],
151
+ retractions: Retraction[],
152
+ oks: HumanOk[],
153
+ { idx, scidx, tracks, probe, anchors }: AssembleDeps,
154
+ ): EvalTimeline {
155
+ if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
156
+ const byName = new Map(ynode.scenarios.map((s) => [s.name, s]))
163
157
  const remarksFor = (scenario: string): RemarkTrack['remarks'] => tracks.get(trackKey(id, scenario))?.remarks ?? []
164
158
  const threadFor = (scenario: string): Issue | undefined => tracks.get(trackKey(id, scenario))?.thread
165
159
  const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
@@ -167,36 +161,14 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
167
161
  ...(s.tags?.length ? { tags: s.tags } : {}), ...(s.test ? { test: s.test } : {}),
168
162
  ...(s.code?.length ? { code: s.code } : {}),
169
163
  }))
170
- // one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
171
- // along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
172
- const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
173
- // the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
174
- // anchor with byte-identical governed content reads fresh. An in-history reading never probes.
175
- const probe = contentProbeFor(root)
176
- const anchors = anchorProbeFor(root, idx)
177
164
  const readings: EvalEntry[] = []
178
- for (const r of applyRetractions(rawReadings, retractions)) {
179
- // a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
165
+ for (const { reading: r, axis } of rows) {
180
166
  const sc = byName.get(r.scenario)
181
- // the teeth feed the WHOLE scenario track against THIS reading — an unresolved (or not-yet-out-run)
182
- // remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
183
- // read-time overlay below; freshness never depends on that pin.
184
- const cf = sc?.code?.length ? sc.code : codeFiles
185
- // an entry may be anchored (`path#symbol`); every PATH consumer below reads base paths, the narrowing
186
- // reads the folded selectors ([[eval-core]]).
187
- const axis = scenarioCodeAxis(sc?.code, codeFiles)
188
- if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, axis.paths, ynode.evalPath)
189
- await anchors.prime?.(r.codeSha, axis.entries)
190
- const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
167
+ const axes = staleAxes(r, axis.entries, ynode.evalPath, idx, scidx,
191
168
  remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc, anchors)
192
- // when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
193
- const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
194
- // the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
195
- // first) drives the scalar compat fields for single-evidence consumers.
169
+ const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, axis.entries, probe) : []
196
170
  const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
197
171
  const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
198
- // the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
199
- // human blessed carries it — a newer or retract-revealed reading reads unblessed.
200
172
  const okRow = humanOkFor(oks, r.scenario, r.ts)
201
173
  readings.push({
202
174
  scenario: r.scenario,
@@ -218,21 +190,13 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
218
190
  ...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
219
191
  })
220
192
  }
221
- readings.reverse() // newest-first
222
- // R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
223
- // else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the
224
- // remark. A track whose SCENARIO no reading joins (renamed/deleted) has no reading to attach to, so it
225
- // becomes a synthetic DANGLING row at node level (directive 5) instead of vanishing — visible, its remarks
226
- // resolvable/retractable via their refs, and ageing nothing (there is no reading for the teeth to stale).
193
+ readings.reverse()
227
194
  const declared = new Set(ynode.scenarios.map((s) => s.name))
228
195
  const dangling: DanglingTrack[] = []
229
196
  for (const [, track] of tracks) {
230
197
  if (track.node !== id || !track.remarks.length) continue
231
- const rows = readings.filter((r) => r.scenario === track.scenario)
232
- if (!rows.length) {
233
- // no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
234
- // not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from eval.md AND
235
- // has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
198
+ const hosts = readings.filter((r) => r.scenario === track.scenario)
199
+ if (!hosts.length) {
236
200
  if (!declared.has(track.scenario)) {
237
201
  dangling.push({
238
202
  scenario: track.scenario, threadId: track.threadId, thread: track.thread,
@@ -241,9 +205,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
241
205
  }
242
206
  continue
243
207
  }
244
- const latest = rows[0]
208
+ const latest = hosts[0]
245
209
  for (const rm of track.remarks) {
246
- const target = rows.find((r) => r.codeSha === rm.targetCodeSha)
210
+ const target = hosts.find((r) => r.codeSha === rm.targetCodeSha)
247
211
  const host = target ?? latest
248
212
  ;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
249
213
  }
@@ -264,11 +228,6 @@ export function readBlobByHash(hash: string, dir?: string): BlobResult {
264
228
  return { ok: true, bytes, mime: sniffBlobMime(bytes) }
265
229
  }
266
230
 
267
- // PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); MP4/WebM cover a recorded clip (--video), so
268
- // the blob route serves it with a playable Content-Type. Text bytes (no NUL, no known header) split by
269
- // CONTENT ([[evidence-kind-taxonomy]]): a structured export (JSON) sniffs to application/json — so the
270
- // `data` renderer knows to validate/pretty-print it — while free-form terminal text stays text/plain;
271
- // anything else falls back to a generic binary type so it still downloads rather than being mislabeled.
272
231
  export function sniffBlobMime(b: Buffer): string {
273
232
  if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
274
233
  if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
@@ -5,36 +5,24 @@ import { putBlob } from './cache.js'
5
5
 
6
6
  export type FileResult = { ok: true; reading: Reading } | { ok: false; error: string }
7
7
 
8
- // the eval seam over DATA (no argv, no file paths): a caller with a verdict in hand — the HTTP eval route,
9
- // a programmatic filer — appends through the SAME seam the CLI uses. Optional evidence arrives as text (a
10
- // report referencing the clip by hash) → a transcript blob in the same content-addressed cache. eval
11
- // still runs nothing — this only records. The dashboard files nothing through this: [[event-detail]] is
12
- // read-side on readings.
13
8
  export function fileHumanReading(
14
9
  nodeId: string,
15
10
  input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
16
11
  ): FileResult {
17
12
  const root = repoRoot()
18
- // the same loud resolution the CLI applies ([[eval-core]]): exact canonical id, else a unique bare
19
- // leaf; an ambiguous leaf returns the candidate list instead of filing against an arbitrary node.
20
13
  const res = resolveEvalNode(evalNodes(root), nodeId)
21
14
  if (!res.ok) return { ok: false, error: res.error }
22
15
  const node = res.node
23
16
  const sc = node.scenarios.find((s) => s.name === input.scenario)
24
17
  if (!sc) return { ok: false, error: `'${nodeId}' has no scenario '${input.scenario}'` }
25
18
  if (input.status !== 'pass' && input.status !== 'fail') return { ok: false, error: 'status must be pass or fail' }
26
- // the evidence bytes; its kind is derived from CONTENT ([[evidence-kind-taxonomy]]) — a structured JSON
27
- // export files as `data`, free-form text as `transcript` — so the HTTP filer agrees with the CLI.
28
19
  const buf = input.transcript ? Buffer.from(input.transcript) : null
29
20
  const blob = buf ? putBlob(buf) : null
30
21
  const reading: Reading = {
31
22
  scenario: sc.name,
32
23
  codeSha: headSha(root),
33
- // the contract this measurement was taken against — the HTTP filer stamps it exactly like the CLI
34
24
  scenarioHash: scenarioHash(sc),
35
25
  ...(blob ? { evidence: [{ hash: blob, kind: (buf && isJsonBlob(buf) ? 'data' : 'transcript') as EvidenceKind }] } : {}),
36
- // the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
37
- // there and the eval-comment loop-in is silent, per [[mentions]])
38
26
  ...(input.by ? { by: input.by } : {}),
39
27
  verdict: { status: input.status, ...(input.note ? { note: input.note } : {}) },
40
28
  ts: new Date().toISOString(),
@@ -43,12 +31,6 @@ export function fileHumanReading(
43
31
  return { ok: true, reading }
44
32
  }
45
33
 
46
- // The session that filed the LATEST reading for (node, scenario) — the ORIGINATOR an eval-comment thread
47
- // loops in on a reply ([[mentions]] implicit loop-in). Null when the node/scenario has no reading, or the
48
- // latest reading is legacy (no `by`). Store-agnostic: the caller resolves this id to a live session or nobody.
49
- // `root` defaults to the trunk; the loop-in chain also passes each LIVE session's worktree here, because an
50
- // in-flight reading (filed on an unmerged branch) is invisible to the trunk sidecar — exactly the
51
- // review-time case where the remark must still reach its filer ([[remark-polish]] strand 2).
52
34
  export function evalReadingFiler(nodeId: string, scenario: string, root: string = repoRoot()): string | null {
53
35
  const res = resolveEvalNode(evalNodes(root), nodeId)
54
36
  if (!res.ok) return null