spexcode 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +49 -13
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,26 +1,92 @@
1
1
  import { relative, dirname } from 'node:path'
2
2
  import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
3
3
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
+ import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
4
5
  import { yatsuNodes, type YatsuNode } from './yatsu.js'
5
- import { readReadings, type Verdict } from './sidecar.js'
6
- import { staleAxes, type StaleAxis } from './freshness.js'
6
+ import { readSidecar, applyRetractions, evidenceOf, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
7
+ import { staleAxes, codeDrift, type StaleAxis } from './freshness.js'
8
+ import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
7
9
  import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
8
10
 
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
+ export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present' | 'miss' }
14
+
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
+ export type RemarkView = {
20
+ rid: string
21
+ ref: string // `<thread-id>#<rid>` — the address `spex resolve`/`spex retract` take
22
+ by: string
23
+ at: string
24
+ body: string
25
+ targetCodeSha: string
26
+ resolved: boolean
27
+ resolvedAt?: string
28
+ resolvedBy?: string
29
+ dangling: boolean
30
+ }
31
+
9
32
  export type EvalEntry = {
10
33
  scenario: string
11
34
  expected: string
12
35
  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
+ 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).
13
41
  blob: string | null
14
- blobKind?: 'image' | 'transcript'
42
+ blobKind?: EvidenceKind
43
+ timelineBlob?: string
15
44
  evaluator: string
45
+ // the SESSION that filed this reading ([[event-detail]] originator liveness / [[mentions]] loop-in): the
46
+ // reachable actor an un-@'d eval remark courtesy-delivers to (the latest reading's filer is the chain's
47
+ // first link). Surfaced so the eval detail can show whether that session is still alive. Absent on a legacy
48
+ // reading (no `by`) — the pane simply shows no originator, exactly as the offline chain runs dry silently.
49
+ by?: string
16
50
  verdict?: Verdict
17
51
  ts: string
18
52
  fresh: boolean
19
53
  staleAxes: StaleAxis[]
20
54
  blobState: 'present' | 'miss' | 'none'
55
+ // the code axis's drift detail for a code-stale reading ([[yatsu-core]]'s codeDrift): each governed file
56
+ // that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
57
+ // ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
58
+ codeDrift?: { file: string; behind: number }[]
59
+ // the trunk remark track overlaid onto THIS reading ([[remark-teeth]]): the remarks whose targetCodeSha
60
+ // pins here (or the latest reading, for a dangling target). Absent when the scenario has no remark.
61
+ remarks?: RemarkView[]
62
+ // the (node, scenario) eval-remark THREAD ([[eval-issue-split]]): the SAME join the teeth read, attached
63
+ // so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
64
+ // splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
65
+ thread?: Issue
66
+ }
67
+
68
+ // a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
69
+ function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkView {
70
+ return {
71
+ rid: rm.rid!,
72
+ ref: `${threadId}#${rm.rid}`,
73
+ by: rm.by, at: rm.at, body: rm.body,
74
+ targetCodeSha: rm.targetCodeSha ?? '',
75
+ resolved: !!rm.resolved,
76
+ ...(rm.resolvedAt ? { resolvedAt: rm.resolvedAt } : {}),
77
+ ...(rm.resolvedBy ? { resolvedBy: rm.resolvedBy } : {}),
78
+ dangling,
79
+ }
21
80
  }
22
81
 
23
- export type ScenarioInfo = { name: string; expected: string; code?: string[] }
82
+ // a DANGLING track ([[remark-teeth]]'s dangling clause / directive 5): a (node, scenario) remark track whose
83
+ // scenario no reading joins — the scenario was renamed or deleted, so today the track loads but surfaces
84
+ // nowhere. It ages NOTHING (there is no reading to stale — the teeth read per-reading), but its remarks must
85
+ // stay VISIBLE and resolvable/retractable via their refs, so evalTimeline emits one synthetic row per orphan
86
+ // at NODE level. `scenario` is the orphaned name (rendered struck-through / gone); `remarks` are all dangling.
87
+ export type DanglingTrack = { scenario: string; threadId: string; thread: Issue; remarks: RemarkView[] }
88
+
89
+ export type ScenarioInfo = { name: string; expected: string; tags?: string[]; code?: string[] }
24
90
 
25
91
  // `hasYatsu` distinguishes a node that declares no scenarios (no yatsu.md) from one that declares some but
26
92
  // has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
@@ -31,6 +97,15 @@ export type EvalTimeline = {
31
97
  hasYatsu: boolean
32
98
  scenarios: ScenarioInfo[]
33
99
  readings: EvalEntry[]
100
+ // retraction events ([[yatsu-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
101
+ // above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
102
+ // this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
103
+ // by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
104
+ retractions: Retraction[]
105
+ // orphaned remark tracks (renamed/deleted scenarios) — surfaced at node level so their remarks never vanish
106
+ // ([[remark-teeth]] dangling clause). SEPARATE from `readings` on purpose: a dangling track has no reading,
107
+ // so it must NOT flow into latestPerScenario / the board scoreboard — it ages nothing.
108
+ dangling: DanglingTrack[]
34
109
  }
35
110
 
36
111
  export type EvalContext = {
@@ -38,14 +113,28 @@ export type EvalContext = {
38
113
  specs: Awaited<ReturnType<typeof loadSpecs>>
39
114
  idx: DriftIndex
40
115
  hidx: HistoryIndex
116
+ scidx: ScenarioIndex // per-scenario block-change history ([[scenariofresh]]) — the SCENARIO axis, built ONCE per build
41
117
  ynodes: YatsuNode[]
118
+ // the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
119
+ // and reused for every node, so the fold never re-reads the issue store per node.
120
+ remarks: Map<string, RemarkTrack>
42
121
  }
43
122
 
44
123
  // build the shared context with ONE yatsu walk, reusing the caller's already-computed specs + the two
45
124
  // HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
46
- // hits, loadSpecs already derived them).
47
- export function evalContext(root: string, specs: Awaited<ReturnType<typeof loadSpecs>>, idx: DriftIndex, hidx: HistoryIndex): EvalContext {
48
- return { root, specs, idx, hidx, ynodes: yatsuNodes(root) }
125
+ // hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
126
+ // ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
127
+ export async function evalContext(
128
+ root: string,
129
+ specs: Awaited<ReturnType<typeof loadSpecs>>,
130
+ idx: DriftIndex,
131
+ hidx: HistoryIndex,
132
+ remarks?: Map<string, RemarkTrack>,
133
+ ynodes?: YatsuNode[], // the hot board build precomputes these off the event loop (yatsuNodesAsync); a bare caller walks sync
134
+ ): Promise<EvalContext> {
135
+ const nodes = ynodes ?? yatsuNodes(root)
136
+ const scidx = await scenarioIndex(root, nodes.map((n) => n.yatsuPath))
137
+ return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
49
138
  }
50
139
 
51
140
  export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
@@ -53,37 +142,93 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
53
142
  // short-circuit a non-yatsu node on the (short) yatsu walk — the board attaches `evals` to every node, so
54
143
  // this is the common case and must stay cheap (a list the size of the few yatsu nodes, not the whole tree).
55
144
  const ynode = (ctx?.ynodes ?? yatsuNodes(root)).find((n) => n.id === id)
56
- if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [] }
145
+ if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [], retractions: [], dangling: [] }
57
146
  // the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
58
147
  // reparent/rename is seen the same way `spex lint` and `spex yatsu eval` see it (joined by directory).
59
148
  const specs = ctx?.specs ?? await loadSpecs()
60
149
  const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
61
150
  const idx = ctx?.idx ?? await driftIndex(root)
62
151
  const hidx = ctx?.hidx ?? await historyIndex(root)
152
+ // the SCENARIO axis: per-scenario block-change history, built once per HEAD (cached). A bare call builds it
153
+ // for the WHOLE yatsu set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
154
+ const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? yatsuNodes(root)).map((n) => n.yatsuPath))
63
155
  const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
156
+ // the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
157
+ const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
158
+ const remarksFor = (scenario: string): RemarkTrack['remarks'] => tracks.get(trackKey(id, scenario))?.remarks ?? []
159
+ const threadFor = (scenario: string): Issue | undefined => tracks.get(trackKey(id, scenario))?.thread
64
160
  const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
65
- name: s.name, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}),
161
+ name: s.name, expected: s.expected,
162
+ ...(s.tags?.length ? { tags: s.tags } : {}), ...(s.code?.length ? { code: s.code } : {}),
66
163
  }))
67
- const readings: EvalEntry[] = readReadings(ynode.sidecarPath).map((r) => {
164
+ // one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
165
+ // along as the undo trace (newest-first, like the readings).
166
+ const { readings: rawReadings, retractions } = readSidecar(ynode.sidecarPath)
167
+ const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
68
168
  // a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
69
169
  const sc = byName.get(r.scenario)
70
- const axes = staleAxes(r, sc?.code?.length ? sc.code : codeFiles, ynode.yatsuPath, idx, hidx)
170
+ // the teeth feed the WHOLE scenario track against THIS reading — an unresolved (or not-yet-out-run)
171
+ // remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
172
+ // read-time overlay below; freshness never depends on that pin.
173
+ const cf = sc?.code?.length ? sc.code : codeFiles
174
+ const axes = staleAxes(r, cf, ynode.yatsuPath, idx, scidx,
175
+ remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })))
176
+ // when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
177
+ const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf) : []
178
+ // the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
179
+ // first) drives the scalar compat fields for single-evidence consumers.
180
+ const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
181
+ const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
71
182
  return {
72
183
  scenario: r.scenario,
73
184
  expected: byName.get(r.scenario)?.expected ?? '',
74
185
  codeSha: r.codeSha,
75
- blob: r.blob,
76
- ...(r.blobKind ? { blobKind: r.blobKind } : {}),
186
+ ...(evidence.length ? { evidence } : {}),
187
+ blob: primary?.hash ?? null,
188
+ ...(primary ? { blobKind: primary.kind } : {}),
189
+ ...(r.timelineBlob ? { timelineBlob: r.timelineBlob } : {}),
77
190
  evaluator: r.evaluator,
191
+ ...(r.by ? { by: r.by } : {}),
78
192
  ...(r.verdict ? { verdict: r.verdict } : {}),
79
193
  ts: r.ts,
80
194
  fresh: axes.length === 0,
81
195
  staleAxes: axes,
82
- blobState: r.blob == null ? 'none' : hasBlob(r.blob) ? 'present' : 'miss',
196
+ ...(drift.length ? { codeDrift: drift } : {}),
197
+ blobState: primary ? primary.state : 'none',
198
+ ...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
83
199
  }
84
200
  })
85
- readings.reverse()
86
- return { node: id, hasYatsu: true, scenarios, readings }
201
+ readings.reverse() // newest-first
202
+ // R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
203
+ // else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the
204
+ // remark. A track whose SCENARIO no reading joins (renamed/deleted) has no reading to attach to, so it
205
+ // becomes a synthetic DANGLING row at node level (directive 5) instead of vanishing — visible, its remarks
206
+ // resolvable/retractable via their refs, and ageing nothing (there is no reading for the teeth to stale).
207
+ const declared = new Set(ynode.scenarios.map((s) => s.name))
208
+ const dangling: DanglingTrack[] = []
209
+ for (const [, track] of tracks) {
210
+ if (track.node !== id || !track.remarks.length) continue
211
+ const rows = readings.filter((r) => r.scenario === track.scenario)
212
+ if (!rows.length) {
213
+ // no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
214
+ // not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from yatsu.md AND
215
+ // has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
216
+ if (!declared.has(track.scenario)) {
217
+ dangling.push({
218
+ scenario: track.scenario, threadId: track.threadId, thread: track.thread,
219
+ remarks: track.remarks.map((rm) => toRemarkView(rm, track.threadId, true)),
220
+ })
221
+ }
222
+ continue
223
+ }
224
+ const latest = rows[0]
225
+ for (const rm of track.remarks) {
226
+ const target = rows.find((r) => r.codeSha === rm.targetCodeSha)
227
+ const host = target ?? latest
228
+ ;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
229
+ }
230
+ }
231
+ return { node: id, hasYatsu: true, scenarios, readings, retractions: [...retractions].reverse(), dangling }
87
232
  }
88
233
 
89
234
  const HEX64 = /^[0-9a-f]{64}$/
@@ -99,13 +244,18 @@ export function readBlobByHash(hash: string, dir?: string): BlobResult {
99
244
  return { ok: true, bytes, mime: sniffBlobMime(bytes) }
100
245
  }
101
246
 
102
- // PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); a transcript (--result) is text, so bytes
103
- // with no NUL and no image header sniff to text/plain; anything else falls back to a generic binary type so
104
- // it still downloads rather than being mislabeled.
247
+ // PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); MP4/WebM cover a recorded clip (--video), so
248
+ // the blob route serves it with a playable Content-Type; a transcript (--result) is text, so bytes with no
249
+ // NUL and no known header sniff to text/plain; anything else falls back to a generic binary type so it still
250
+ // downloads rather than being mislabeled.
105
251
  function sniffBlobMime(b: Buffer): string {
106
252
  if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
107
253
  if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
108
254
  if (b.length >= 4 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif'
255
+ // WebM/Matroska begin with the EBML magic 1A 45 DF A3; disambiguate from a RIFF/WEBP image above.
256
+ if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'video/webm'
257
+ // ISO-BMFF (MP4/MOV): a `ftyp` box type at bytes 4..8, after its 4-byte size.
258
+ if (b.length >= 12 && b.toString('ascii', 4, 8) === 'ftyp') return 'video/mp4'
109
259
  if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
110
260
  if (b.length && !b.includes(0)) return 'text/plain; charset=utf-8'
111
261
  return 'application/octet-stream'
@@ -0,0 +1,48 @@
1
+ import { repoRoot, headSha } from '../../spec-cli/src/git.js'
2
+ import { yatsuNodes } from './yatsu.js'
3
+ import { appendReading, readReadings, type Reading } from './sidecar.js'
4
+ import { putBlob } from './cache.js'
5
+
6
+ export type FileResult = { ok: true; reading: Reading } | { ok: false; error: string }
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; the
11
+ // evaluator is the human hand, manual@1. yatsu still runs nothing — this only records. The dashboard files
12
+ // nothing through this: [[event-detail]] is read-side on readings.
13
+ export function fileHumanReading(
14
+ nodeId: string,
15
+ input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
16
+ ): FileResult {
17
+ const root = repoRoot()
18
+ const node = yatsuNodes(root).find((n) => n.id === nodeId)
19
+ if (!node) return { ok: false, error: `no yatsu node '${nodeId}' (a node needs a yatsu.md)` }
20
+ const sc = node.scenarios.find((s) => s.name === input.scenario)
21
+ if (!sc) return { ok: false, error: `'${nodeId}' has no scenario '${input.scenario}'` }
22
+ if (input.status !== 'pass' && input.status !== 'fail') return { ok: false, error: 'status must be pass or fail' }
23
+ const blob = input.transcript ? putBlob(Buffer.from(input.transcript)) : null
24
+ const reading: Reading = {
25
+ scenario: sc.name,
26
+ codeSha: headSha(root),
27
+ ...(blob ? { evidence: [{ hash: blob, kind: 'transcript' as const }] } : {}),
28
+ evaluator: 'manual@1',
29
+ // the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
30
+ // there and the eval-comment loop-in is silent, per [[mentions]])
31
+ ...(input.by ? { by: input.by } : {}),
32
+ verdict: { status: input.status, ...(input.note ? { note: input.note } : {}) },
33
+ ts: new Date().toISOString(),
34
+ }
35
+ appendReading(node.sidecarPath, reading)
36
+ return { ok: true, reading }
37
+ }
38
+
39
+ // The session that filed the LATEST reading for (node, scenario) — the ORIGINATOR an eval-comment thread
40
+ // loops in on a reply ([[mentions]] implicit loop-in). Null when the node/scenario has no reading, or the
41
+ // latest reading is legacy (no `by`). Store-agnostic: the caller resolves this id to a live session or nobody.
42
+ export function evalReadingFiler(nodeId: string, scenario: string): string | null {
43
+ const root = repoRoot()
44
+ const node = yatsuNodes(root).find((n) => n.id === nodeId)
45
+ if (!node) return null
46
+ const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
47
+ return forScenario.length ? forScenario[forScenario.length - 1].by ?? null : null
48
+ }
@@ -1,29 +1,61 @@
1
- import { rowsFor, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
1
+ import { ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
2
2
  import type { Reading } from './sidecar.js'
3
3
  import { isEvaluatorStale } from './evaluator.js'
4
+ import { scenarioChangeCommits, type ScenarioIndex } from './scenariofresh.js'
4
5
 
5
6
  // the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
6
7
 
7
- export type StaleAxis = 'code' | 'scenario' | 'evaluator'
8
+ export type StaleAxis = 'code' | 'scenario' | 'evaluator' | 'remark'
8
9
 
9
- // true iff some commit touched `path` strictly NEWER than `sinceSha`. An unknown `sinceSha` (a reading
10
- // taken off the current historye.g. on a since-rebased commit) returns true: we can't prove freshness,
11
- // so we treat it as stale rather than silently pass.
10
+ // the REMARK axis's input ([[remark-teeth]]): the teeth read only the resolvable bit + when it was resolved,
11
+ // not the whole remarkso freshness stays a PURE function, fed the scenario's remark track at the call
12
+ // sites (never reaching into the issue store). One signal per remark on the (node, scenario).
13
+ export type RemarkSignal = { resolved: boolean; resolvedAt?: string }
14
+
15
+ // the teeth (T1): a scenario is remark-stale unless EVERY remark is resolved AND this reading post-dates
16
+ // every resolution. So an UNRESOLVED remark ages it; a RESOLVED remark keeps it stale until a reading taken
17
+ // strictly after the resolve (reading.ts > resolvedAt) exists — you can't out-run a remark by re-measuring
18
+ // before the resolve, nor clear it by passive receipt. A resolved bit with no timestamp stays conservatively
19
+ // stale (defensive: resolveRemark always stamps one).
20
+ export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): boolean {
21
+ return remarks.some((r) => !r.resolved || !(r.resolvedAt && reading.ts > r.resolvedAt))
22
+ }
23
+
24
+ // true iff some commit touched `path` that is NOT an ancestor of `sinceSha` — i.e. it lies in
25
+ // `sinceSha..HEAD` by true DAG reachability, never a log-position/date compare (which under-reports on
26
+ // branchy history). ONE conservative rule for an off-history `sinceSha` — whether rebased away (orphan)
27
+ // or on a reachable-but-unmerged branch: we can't prove freshness from HEAD's history, so it reads
28
+ // stale rather than silently pass.
12
29
  export function changedSince(idx: DriftIndex, sinceSha: string, path: string): boolean {
13
- const sp = idx.pos.get(sinceSha)
14
- if (sp === undefined) return true
15
- for (const h of idx.fileCommits.get(path) ?? []) {
16
- const p = idx.pos.get(h)
17
- if (p !== undefined && p < sp) return true // smaller position = newer than the reading
30
+ const anc = ancestorsOf(idx, sinceSha)
31
+ if (!anc) return true
32
+ return (idx.fileCommits.get(path) ?? []).some((h) => !inAncestors(idx, anc, h))
33
+ }
34
+
35
+ // the code axis's DISPLAY detail: which governed files drifted since a reading, and by HOW MANY commits — so
36
+ // a stale eval can say "EvalsFeed.jsx +3" instead of a bare "code moved". Same DAG reachability as
37
+ // changedSince (a commit touching the file that is NOT an ancestor of the reading's sha lies in sinceSha..HEAD);
38
+ // an off-history sinceSha counts every touch (conservative, matching changedSince's stale-rather-than-pass rule).
39
+ // Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
40
+ export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[]): { file: string; behind: number }[] {
41
+ const anc = ancestorsOf(idx, sinceSha)
42
+ const out: { file: string; behind: number }[] = []
43
+ for (const f of codeFiles) {
44
+ const commits = idx.fileCommits.get(f) ?? []
45
+ const behind = anc ? commits.filter((h) => !inAncestors(idx, anc, h)).length : commits.length
46
+ if (behind > 0) out.push({ file: f, behind })
18
47
  }
19
- return false
48
+ return out
20
49
  }
21
50
 
22
- // scenario freshness uses rowsFor (rename-followed content versions, like a spec node), not touch-based fileCommits, so a bare git-mv reparent isn't a change; off-history codeSha → stale
23
- function scenarioMoved(hidx: HistoryIndex, pos: Map<string, number>, sinceSha: string, yatsuPath: string): boolean {
24
- const sp = pos.get(sinceSha)
25
- if (sp === undefined) return true
26
- return rowsFor(hidx, yatsuPath).some((v) => { const p = pos.get(v.hash); return p !== undefined && p < sp })
51
+ // scenario freshness is PER-SCENARIO, not per-file: a reading stales only when ITS OWN scenario block moved
52
+ // (edited/added/removed) in scenarioSha..HEAD, never when a sibling in the same yatsu.md did. Reads exactly
53
+ // like the code axis's changedSince — the per-scenario change-commits ([[scenariofresh]], rename-followed so a
54
+ // bare git-mv reparent isn't a change) tested for ancestry — off-history codeSha → conservatively stale.
55
+ function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, yatsuPath: string, scenario: string): boolean {
56
+ const anc = ancestorsOf(didx, sinceSha)
57
+ if (!anc) return true
58
+ return scenarioChangeCommits(scIdx, yatsuPath, scenario).some((h) => !inAncestors(didx, anc, h))
27
59
  }
28
60
 
29
61
  export function staleAxes(
@@ -31,12 +63,14 @@ export function staleAxes(
31
63
  codeFiles: string[],
32
64
  yatsuPath: string,
33
65
  didx: DriftIndex,
34
- hidx: HistoryIndex,
66
+ scIdx: ScenarioIndex,
67
+ remarks: RemarkSignal[] = [],
35
68
  ): StaleAxis[] {
36
69
  const axes: StaleAxis[] = []
37
70
  if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f))) axes.push('code')
38
- if (scenarioMoved(hidx, didx.pos, reading.codeSha, yatsuPath)) axes.push('scenario')
71
+ if (scenarioMoved(scIdx, didx, reading.codeSha, yatsuPath, reading.scenario)) axes.push('scenario')
39
72
  if (isEvaluatorStale(reading.evaluator)) axes.push('evaluator')
73
+ if (remarkStale(reading, remarks)) axes.push('remark')
40
74
  return axes
41
75
  }
42
76
 
@@ -45,7 +79,8 @@ export function isStale(
45
79
  codeFiles: string[],
46
80
  yatsuPath: string,
47
81
  didx: DriftIndex,
48
- hidx: HistoryIndex,
82
+ scIdx: ScenarioIndex,
83
+ remarks: RemarkSignal[] = [],
49
84
  ): boolean {
50
- return staleAxes(reading, codeFiles, yatsuPath, didx, hidx).length > 0
85
+ return staleAxes(reading, codeFiles, yatsuPath, didx, scIdx, remarks).length > 0
51
86
  }
@@ -23,6 +23,7 @@ export type ProofReading = {
23
23
  ts: string
24
24
  evidence:
25
25
  | { kind: 'image'; dataUri: string }
26
+ | { kind: 'video'; dataUri: string }
26
27
  | { kind: 'transcript'; text: string }
27
28
  | { kind: 'miss' }
28
29
  | { kind: 'none' }
@@ -78,7 +79,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
78
79
  // root the eval context at the SESSION's worktree (its branch's readings/freshness), not the backend checkout which would show main's; specs stay backend-shared (paths/titles/hues), only readings + drift are per-worktree
79
80
  const wtPath = worktreePathForBranch(payload.branch)
80
81
  const ctxRoot = wtPath ?? repoRoot()
81
- const ctx = evalContext(ctxRoot, specs, await driftIndex(ctxRoot), await historyIndex(ctxRoot))
82
+ const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
83
+ const ctx = await evalContext(ctxRoot, specs, didx, hidx)
82
84
 
83
85
  // enrich each changed file with its unified diff + full before/after content (derived from the session
84
86
  // worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
@@ -156,7 +158,6 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
156
158
  function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ProofGate[] {
157
159
  const g = p.gates
158
160
  return [
159
- { label: 'typecheck', ok: g.typecheck.ok, detail: g.typecheck.ok ? 'clean' : `${g.typecheck.errorCount} error(s)` },
160
161
  { label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
161
162
  { label: 'merge', ok: !g.conflictsWithMain, detail: g.conflictsWithMain ? 'conflicts with main' : 'no conflict' },
162
163
  { label: 'ahead', ok: p.ahead > 0, detail: `${p.ahead} commit(s) ahead of main` },
@@ -198,6 +199,7 @@ async function toProofReading(r: EvalEntry): Promise<ProofReading> {
198
199
  const blob = readBlobByHash(r.blob)
199
200
  if (!blob.ok) return { ...base, evidence: { kind: 'miss' } }
200
201
  if (blob.mime.startsWith('image/')) return { ...base, evidence: { kind: 'image', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } }
202
+ if (blob.mime.startsWith('video/')) return { ...base, evidence: { kind: 'video', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } }
201
203
  return { ...base, evidence: { kind: 'transcript', text: blob.bytes.toString('utf8') } }
202
204
  }
203
205
 
@@ -306,11 +308,12 @@ function renderReading(r: ProofReading): string {
306
308
  const ev = r.evidence
307
309
  const body =
308
310
  ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
311
+ : ev.kind === 'video' ? `<video class="shot" src="${ev.dataUri}" controls preload="metadata"></video>`
309
312
  : ev.kind === 'transcript' ? `<pre class="transcript">${esc(ev.text)}</pre>`
310
313
  : ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
311
314
  : `<div class="noev">attested without a capture</div>`
312
315
  const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since the reading">stale</span>`
313
- const note = r.verdict?.status === 'note' && r.verdict.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
316
+ const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
314
317
  return `<div class="reading">
315
318
  <div class="rhead">
316
319
  ${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
@@ -489,3 +492,86 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
489
492
  .glabel{font-weight:600;width:120px;color:#dbe6f2}.gdetail{color:var(--dim);font:12px/1 ui-monospace,monospace}
490
493
  footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
491
494
  `
495
+
496
+ // ---- the session EVAL model ([[review-proof]]'s interactive face) ----
497
+ // The lean, TIERED counterpart of buildProofModel: the same worktree-rooted marshaling, but rows only —
498
+ // no diff enrichment, no inlined evidence bytes (the dashboard's Eval tab rides the shared eval
499
+ // components: blobs stream lazily from /api/yatsu/blob on open). Each reading carries `inSession`
500
+ // (this session filed it, or its codeSha is one of the branch's own commits — a diagnostic session
501
+ // that changed no code still owns the readings it filed at the merge-base) so the tab can lead with
502
+ // what THIS session measured, ✦-marked, over the inherited baseline.
503
+ export type SessionEvalNode = {
504
+ id: string
505
+ title: string
506
+ hue: number
507
+ desc: string
508
+ hasYatsu: boolean
509
+ uncoveredFrontend: boolean
510
+ scenarios: { name: string; expected: string; tags?: string[] }[]
511
+ // each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
512
+ // 3): the server-side join (attached by evalTimeline as `EvalEntry.thread`), so the session tab's event
513
+ // detail reads the comment/remark track directly instead of re-matching a concern key client-side. Absent
514
+ // until the pair has its first remark.
515
+ evals: (EvalEntry & { inSession: boolean })[]
516
+ }
517
+ export type SessionEvals = {
518
+ id: string
519
+ node: string | null
520
+ branch: string | null
521
+ title: string
522
+ ahead: number
523
+ dirtyNonRuntime: number
524
+ gates: ProofGate[]
525
+ nodes: SessionEvalNode[]
526
+ }
527
+
528
+ export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
529
+ const payload = await reviewPayload(id)
530
+ if (!payload) return null
531
+ const specs = await loadSpecs()
532
+ const specById = new Map(specs.map((s) => [s.id, s]))
533
+ const wtPath = worktreePathForBranch(payload.branch)
534
+ const ctxRoot = wtPath ?? repoRoot()
535
+ const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
536
+ const ctx = await evalContext(ctxRoot, specs, didx, hidx)
537
+ // this session's own commits — the membership test behind `inSession`
538
+ const shas = wtPath ? new Set((await gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`])).split('\n').filter(Boolean)) : new Set<string>()
539
+
540
+ const ids = new Set<string>()
541
+ for (const f of payload.diff) { const nid = nodeForFile(f.path, specs, payload.node); if (nid) ids.add(nid) }
542
+ if (payload.node && specById.has(payload.node)) ids.add(payload.node)
543
+
544
+ const nodes: SessionEvalNode[] = []
545
+ for (const nid of ids) {
546
+ const spec = specById.get(nid)
547
+ const tl = await evalTimeline(nid, ctx)
548
+ nodes.push({
549
+ id: nid,
550
+ title: spec?.title ?? nid,
551
+ hue: spec?.hue ?? 210,
552
+ desc: spec?.desc ?? '',
553
+ hasYatsu: tl.hasYatsu,
554
+ uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
555
+ scenarios: tl.scenarios,
556
+ // the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
557
+ // the event detail has the comment/remark track inline — no extra join here. A reading is the
558
+ // session's own when it filed it (`by`) OR when its codeSha is a branch commit — filing alone counts,
559
+ // else a session that measured without committing code reads as if it did nothing.
560
+ evals: tl.readings.map((r) => ({ ...r, inSession: r.by === id || shas.has(r.codeSha) })),
561
+ })
562
+ }
563
+ // nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
564
+ nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length) || (b.evals.length - a.evals.length))
565
+
566
+ const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
567
+ return {
568
+ id,
569
+ node: payload.node,
570
+ branch: payload.branch,
571
+ title: primary || payload.node || payload.branch || id.slice(0, 8),
572
+ ahead: payload.ahead,
573
+ dirtyNonRuntime: payload.dirtyNonRuntime,
574
+ gates: gateRows(payload),
575
+ nodes,
576
+ }
577
+ }