spexcode 0.1.0 → 0.1.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 (76) hide show
  1. package/README.md +86 -0
  2. package/package.json +17 -10
  3. package/{bin → spec-cli/bin}/spex.mjs +6 -2
  4. package/spec-cli/hooks/dispatch.sh +74 -0
  5. package/spec-cli/hooks/harness.sh +178 -0
  6. package/{src → spec-cli/src}/cli.ts +14 -0
  7. package/{src → spec-cli/src}/harness.ts +8 -18
  8. package/{src → spec-cli/src}/layout.ts +6 -23
  9. package/{src → spec-cli/src}/materialize.ts +2 -1
  10. package/spec-cli/src/relay.ts +28 -0
  11. package/{src → spec-cli/src}/supervise.ts +2 -1
  12. package/spec-cli/src/tsx-bin.ts +11 -0
  13. package/{dashboard-dist/assets/index-B60MILFg.js → spec-dashboard/dist/assets/index-Bk4E1EQy.js} +29 -29
  14. package/{dashboard-dist → spec-dashboard/dist}/index.html +1 -1
  15. package/spec-forge/src/__fixtures__/github-forge.json +63 -0
  16. package/spec-forge/src/cache.ts +28 -0
  17. package/spec-forge/src/cli.ts +110 -0
  18. package/spec-forge/src/drivers/github.ts +73 -0
  19. package/spec-forge/src/links.ts +76 -0
  20. package/spec-forge/src/needs-yatsu-eval.ts +30 -0
  21. package/spec-forge/src/port.ts +23 -0
  22. package/spec-forge/src/resident.ts +23 -0
  23. package/spec-yatsu/src/cache.ts +68 -0
  24. package/spec-yatsu/src/cli.ts +296 -0
  25. package/spec-yatsu/src/evaltab.ts +112 -0
  26. package/spec-yatsu/src/evaluator.ts +24 -0
  27. package/spec-yatsu/src/freshness.ts +51 -0
  28. package/spec-yatsu/src/proof.ts +491 -0
  29. package/spec-yatsu/src/sidecar.ts +44 -0
  30. package/spec-yatsu/src/yatsu.ts +181 -0
  31. /package/{src → spec-cli/src}/board.ts +0 -0
  32. /package/{src → spec-cli/src}/client.ts +0 -0
  33. /package/{src → spec-cli/src}/gateway.ts +0 -0
  34. /package/{src → spec-cli/src}/git.ts +0 -0
  35. /package/{src → spec-cli/src}/guide.ts +0 -0
  36. /package/{src → spec-cli/src}/hooks.ts +0 -0
  37. /package/{src → spec-cli/src}/index.ts +0 -0
  38. /package/{src → spec-cli/src}/init.ts +0 -0
  39. /package/{src → spec-cli/src}/lint.ts +0 -0
  40. /package/{src → spec-cli/src}/login-page.ts +0 -0
  41. /package/{src → spec-cli/src}/pty-bridge.ts +0 -0
  42. /package/{src → spec-cli/src}/ranker.ts +0 -0
  43. /package/{src → spec-cli/src}/resilience.ts +0 -0
  44. /package/{src → spec-cli/src}/search.bench.mjs +0 -0
  45. /package/{src → spec-cli/src}/search.ts +0 -0
  46. /package/{src → spec-cli/src}/self.ts +0 -0
  47. /package/{src → spec-cli/src}/sessions.ts +0 -0
  48. /package/{src → spec-cli/src}/slash-commands.ts +0 -0
  49. /package/{src → spec-cli/src}/specs.ts +0 -0
  50. /package/{src → spec-cli/src}/uploads.ts +0 -0
  51. /package/{templates → spec-cli/templates}/hooks/pre-commit +0 -0
  52. /package/{templates → spec-cli/templates}/hooks/prepare-commit-msg +0 -0
  53. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/idle.sh +0 -0
  54. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/spec.md +0 -0
  55. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/mark-active.sh +0 -0
  56. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/spec.md +0 -0
  57. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/fail.sh +0 -0
  58. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/spec.md +0 -0
  59. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec-first.sh +0 -0
  60. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec.md +0 -0
  61. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec-of-file.sh +0 -0
  62. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec.md +0 -0
  63. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec.md +0 -0
  64. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/spec.md +0 -0
  65. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/stop-gate.sh +0 -0
  66. /package/{templates → spec-cli/templates}/spec/project/.config/extract/spec.md +0 -0
  67. /package/{templates → spec-cli/templates}/spec/project/.config/forge-link/spec.md +0 -0
  68. /package/{templates → spec-cli/templates}/spec/project/.config/memory-hygiene/spec.md +0 -0
  69. /package/{templates → spec-cli/templates}/spec/project/.config/regroup/spec.md +0 -0
  70. /package/{templates → spec-cli/templates}/spec/project/.config/scenario/spec.md +0 -0
  71. /package/{templates → spec-cli/templates}/spec/project/.config/spec.md +0 -0
  72. /package/{templates → spec-cli/templates}/spec/project/.config/supervisor/spec.md +0 -0
  73. /package/{templates → spec-cli/templates}/spec/project/.config/tidy/spec.md +0 -0
  74. /package/{templates → spec-cli/templates}/spec/project/spec.md +0 -0
  75. /package/{templates → spec-cli/templates}/spexcode.json +0 -0
  76. /package/{dashboard-dist → spec-dashboard/dist}/assets/index-Cq7hwngj.css +0 -0
@@ -0,0 +1,296 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join, relative, dirname } from 'node:path'
3
+ import { repoRoot, headSha, driftIndex, historyIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
4
+ import { loadSpecs } from '../../spec-cli/src/specs.js'
5
+ import { loadConfig } from '../../spec-cli/src/lint.js'
6
+ import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
7
+ import { yatsuNodes, validateScenarios, YATSU_FILE, type YatsuNode } from './yatsu.js'
8
+ import { readReadings, appendReading, latestPerScenario, type Reading, type Verdict } from './sidecar.js'
9
+ import { staleAxes } from './freshness.js'
10
+ import { evaluatorTag } from './evaluator.js'
11
+ import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
12
+ import { evalTimeline, type EvalTimeline } from './evaltab.js'
13
+
14
+ function flag(args: string[], name: string): string | undefined {
15
+ const i = args.indexOf(`--${name}`)
16
+ return i >= 0 ? args[i + 1] : undefined
17
+ }
18
+ const has = (args: string[], name: string) => args.includes(`--${name}`)
19
+ const positional = (args: string[]) => args.find((a) => !a.startsWith('--'))
20
+
21
+ // join by node directory (not id) so a reparent/rename is seen the way lint sees it
22
+ type EvalNode = YatsuNode & { codeFiles: string[] }
23
+
24
+ async function gatherNodes(root: string): Promise<EvalNode[]> {
25
+ const specs = await loadSpecs()
26
+ const codeByDir = new Map<string, string[]>()
27
+ for (const s of specs) codeByDir.set(dirname(s.path), s.code) // s.path = repo-relative spec.md path
28
+ return yatsuNodes(root).map((n) => ({ ...n, codeFiles: codeByDir.get(relative(root, n.dir)) ?? [] }))
29
+ }
30
+
31
+ // resolve `.` → the node this worktree works on: the session record's `node` (the authoritative ref a
32
+ // dashboard session was bound to — NOT derivable from the branch, whose slug carries a `-<id4>` suffix),
33
+ // else the `node/<id>` branch. The record now lives in the GLOBAL store keyed by the harness session id
34
+ // ([[state]]), so we read it via the env session id; a self-launched agent with no record falls back to the branch.
35
+ function currentNodeId(root: string): string | null {
36
+ const id = envSessionId()
37
+ if (id) { const rec = readRawRecord(id); if (rec?.node) return rec.node }
38
+ try {
39
+ const branch = git(['-C', root, 'symbolic-ref', '--short', 'HEAD']).trim()
40
+ if (branch.startsWith('node/')) return branch.slice('node/'.length)
41
+ } catch { /* detached / no branch */ }
42
+ return null
43
+ }
44
+
45
+ const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
46
+ export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
47
+
48
+ // merge-base anchored so the main branch advancing isn't counted as this branch's change; a git error yields ∅ (--changed scan stays silent)
49
+ function changedSinceBase(root: string): Set<string> {
50
+ const out = new Set<string>()
51
+ const add = (s: string) => { for (const l of s.split('\n')) { const t = l.trim(); if (t) out.add(t) } }
52
+ try {
53
+ const base = git(['-C', root, 'merge-base', mainBranch(), 'HEAD']).trim()
54
+ if (base) add(git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--name-only', base]))
55
+ add(git(['-C', root, '-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard']))
56
+ } catch { /* no base → empty set */ }
57
+ return out
58
+ }
59
+
60
+ // a node is "changed" if the branch touched its node dir (spec.md / yatsu.md / sidecar all live there) or
61
+ // any governed code path — matched as an exact file, a directory prefix, or a `*` glob.
62
+ export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<string>): boolean {
63
+ for (const c of changed) if (c === dirRel || c.startsWith(dirRel + '/')) return true
64
+ return codeFiles.some((cf) => {
65
+ if (changed.has(cf)) return true
66
+ const dir = cf.replace(/\/+$/, '') + '/'
67
+ const re = cf.includes('*') ? new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') : null
68
+ for (const c of changed) { if (c.startsWith(dir)) return true; if (re && re.test(c)) return true }
69
+ return false
70
+ })
71
+ }
72
+
73
+ async function scan(args: string[] = []): Promise<number> {
74
+ const root = repoRoot()
75
+ const changedOnly = has(args, 'changed')
76
+ const changed = changedOnly ? changedSinceBase(root) : null
77
+ const idx = await driftIndex(root)
78
+ const hidx = await historyIndex(root)
79
+ const specs = await loadSpecs()
80
+ // a file may be governed by several nodes — ordinary composition, not a hub to skip (see governed-related).
81
+ // A change to a shared governed file legitimately triggers EVERY governing node's yatsu, mirroring how
82
+ // lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
83
+ // `owners` concern (split it), not a reason to go silent here.
84
+ const yByDir = new Map(yatsuNodes(root).map((n) => [relative(root, n.dir), n]))
85
+ let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0
86
+ for (const s of specs) {
87
+ const dirRel = dirname(s.path)
88
+ if (changed && !nodeChanged(dirRel, s.code, changed)) continue
89
+ const y = yByDir.get(dirRel)
90
+ const findings: string[] = []
91
+ if (y) {
92
+ // schema first: a malformed yatsu.md is the loudest gap — report each violation, then still scan its
93
+ // (leniently-parsed) scenarios for stale/missing so a typo doesn't mask a real freshness gap.
94
+ for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'))) {
95
+ malformed++
96
+ findings.push(` • yatsu-schema: '${s.id}' ${e} — fix ${y.yatsuPath}`)
97
+ }
98
+ const latest = latestPerScenario(readReadings(y.sidecarPath))
99
+ for (const sc of y.scenarios) {
100
+ // a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
101
+ // would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
102
+ // malformed declaration — the same loud-fail spirit as a bad node `code:`.
103
+ for (const [field, paths] of [['code', sc.code], ['related', sc.related]] as const) {
104
+ const ghosts = (paths ?? []).filter((p) => !existsSync(join(root, p)))
105
+ if (ghosts.length) {
106
+ malformed++
107
+ findings.push(` • yatsu-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.yatsuPath}`)
108
+ }
109
+ }
110
+ const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
111
+ const r = latest.get(sc.name)
112
+ if (!r) {
113
+ missingScores++
114
+ findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}' has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
115
+ continue
116
+ }
117
+ const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, hidx)
118
+ if (axes.length) {
119
+ staleScores++
120
+ findings.push(` • yatsu-drift: '${s.id}' scenario '${sc.name}' is stale (${axes.join(', ')} changed since ${r.codeSha.slice(0, 7)}) — re-measure with \`spex yatsu eval ${s.id}\``)
121
+ }
122
+ }
123
+ } else if (s.code.some(isUiPath)) {
124
+ uncovered++
125
+ findings.push(` • yatsu-uncovered: '${s.id}' governs frontend code but has no yatsu.md — give it a scenario (description + expected) so its loss can be measured`)
126
+ }
127
+ if (!findings.length) continue
128
+ flaggedNodes++
129
+ for (const f of findings) console.error(f)
130
+ }
131
+ // whole-repo only (never --changed): a structural fact, not a per-branch freshness gap. Counts only explicit scenario `code:`.
132
+ let overOwned = 0
133
+ if (!changedOnly) {
134
+ const maxOwners = loadConfig(root).maxOwners
135
+ const govCount = new Map<string, number>()
136
+ for (const n of yByDir.values()) for (const sc of n.scenarios) for (const f of sc.code ?? []) govCount.set(f, (govCount.get(f) ?? 0) + 1)
137
+ const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
138
+ if (over.length) {
139
+ overOwned = over.length
140
+ const top = over.slice(0, 5).map(([f, c]) => `${f.split('/').pop()}(${c})`).join(', ')
141
+ console.error(` • yatsu-owners: ${over.length} file(s) are governed by > ${maxOwners} scenarios — each carries more separately-measured behaviour than one file should. Worst: ${top}. SPLIT the file so each scenario measures its own surface.`)
142
+ }
143
+ }
144
+ const scope = changedOnly ? ' --changed' : ''
145
+ const ownersNote = overOwned ? `, ${overOwned} over-owned` : ''
146
+ console.error(`spex yatsu scan${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} uncovered${ownersNote})`)
147
+ return 0
148
+ }
149
+
150
+ async function evalCmd(args: string[]): Promise<number> {
151
+ const root = repoRoot()
152
+ const sel = positional(args)
153
+ const id = !sel || sel === '.' ? currentNodeId(root) : sel
154
+ if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
155
+ const node = (await gatherNodes(root)).find((n) => n.id === id)
156
+ if (!node) { console.error(`spex yatsu eval: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
157
+ if (!node.scenarios.length) { console.error(`spex yatsu eval: '${id}' declares no scenarios in its yatsu.md`); return 1 }
158
+
159
+ // which scenario this measurement is OF: --scenario, or the sole scenario when there is exactly one.
160
+ const names = node.scenarios.map((s) => s.name)
161
+ const scName = flag(args, 'scenario')
162
+ let scenario = scName ? node.scenarios.find((s) => s.name === scName) : node.scenarios.length === 1 ? node.scenarios[0] : undefined
163
+ if (!scenario) {
164
+ const why = scName ? `has no scenario '${scName}'` : `declares ${node.scenarios.length} scenarios — name one with --scenario <name>`
165
+ console.error(`spex yatsu eval: '${id}' ${why} — declared: ${names.join(', ')}`)
166
+ return 1
167
+ }
168
+
169
+ // the verdict the agent reached (required — a measurement without one is the legacy shape, not a filing).
170
+ const verdict = parseVerdict(args)
171
+ if (!verdict) { console.error('spex yatsu eval: a verdict is required — one of --pass | --fail | --note <text>'); return 2 }
172
+
173
+ // the evidence the agent captured (optional; --image XOR --result). The bytes go to the content-addressed
174
+ // cache exactly the same whether image or transcript; only `blobKind` records which they are.
175
+ const image = flag(args, 'image')
176
+ const result = flag(args, 'result')
177
+ if (image !== undefined && result !== undefined) { console.error('spex yatsu eval: pass at most one of --image / --result'); return 2 }
178
+ let blob: string | null = null
179
+ let blobKind: 'image' | 'transcript' | undefined
180
+ if (image !== undefined) { blob = putBlob(readFileSync(image)); blobKind = 'image' }
181
+ else if (result !== undefined) { blob = putBlob(readFileSync(result === '-' ? 0 : result)); blobKind = 'transcript' }
182
+
183
+ const reading: Reading = {
184
+ scenario: scenario.name,
185
+ codeSha: headSha(root),
186
+ blob,
187
+ ...(blobKind ? { blobKind } : {}),
188
+ evaluator: evaluatorTag(),
189
+ verdict,
190
+ ts: new Date().toISOString(),
191
+ }
192
+ appendReading(node.sidecarPath, reading)
193
+ const ev = blobKind === 'transcript' ? `transcript ${blob!.slice(0, 12)}…` : blobKind === 'image' ? `image ${blob!.slice(0, 12)}…` : 'no evidence'
194
+ console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
195
+ console.log(`spex yatsu eval: 1 measurement filed`)
196
+ return 0
197
+ }
198
+
199
+ // the verdict from the flags: exactly one of --pass / --fail / --note <text> (precedence pass > fail > note).
200
+ function parseVerdict(args: string[]): Verdict | null {
201
+ if (has(args, 'pass')) return { status: 'pass' }
202
+ if (has(args, 'fail')) return { status: 'fail' }
203
+ const note = flag(args, 'note')
204
+ if (note !== undefined) return { status: 'note', note }
205
+ return null
206
+ }
207
+
208
+ async function clean(args: string[]): Promise<number> {
209
+ const root = repoRoot()
210
+ const all = has(args, 'all')
211
+ const keepLatest = has(args, 'keep-latest')
212
+ const referenced = new Set<string>()
213
+ if (!all) {
214
+ for (const n of await gatherNodes(root)) {
215
+ const readings = readReadings(n.sidecarPath)
216
+ const keep = keepLatest ? [...latestPerScenario(readings).values()] : readings
217
+ for (const r of keep) if (r.blob) referenced.add(r.blob)
218
+ }
219
+ }
220
+ const before = listBlobs().length
221
+ const removed = gc(referenced)
222
+ const mode = all ? 'all' : keepLatest ? 'keep-latest' : 'unreferenced'
223
+ console.log(`spex yatsu clean: removed ${removed.length} blob(s), kept ${before - removed.length} (${mode})`)
224
+ return 0
225
+ }
226
+
227
+ function checkStaged(): number {
228
+ const root = repoRoot()
229
+ const staged = stagedFiles(root)
230
+ let bad = false
231
+
232
+ const blobs = staged.filter(isStrayBlob)
233
+ if (blobs.length) {
234
+ bad = true
235
+ console.error('✗ SpexCode yatsu: stray evidence blob(s) staged — blobs live in the shared git common dir, never in the tree:')
236
+ for (const o of blobs) console.error(` ${o}`)
237
+ console.error(' Unstage them (git rm --cached <path>); a reading references its blob by hash, it never commits the bytes.')
238
+ }
239
+
240
+ for (const rel of staged.filter((p) => p === YATSU_FILE || p.endsWith('/' + YATSU_FILE))) {
241
+ const abs = join(root, rel)
242
+ if (!existsSync(abs)) continue // staged deletion — nothing to validate
243
+ const errs = validateScenarios(readFileSync(abs, 'utf8'))
244
+ if (!errs.length) continue
245
+ bad = true
246
+ console.error(`✗ SpexCode yatsu: ${rel} — invalid scenario schema:`)
247
+ for (const e of errs) console.error(` ${e}`)
248
+ }
249
+
250
+ return bad ? 1 : 0
251
+ }
252
+
253
+ async function show(args: string[]): Promise<number> {
254
+ const root = repoRoot()
255
+ const sel = positional(args)
256
+ const id = !sel || sel === '.' ? currentNodeId(root) : sel
257
+ if (!id) { console.error('spex yatsu show .: no current node (no .session/node-branch here) — name a node'); return 2 }
258
+ const tl = await evalTimeline(id) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
259
+ if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
260
+ console.log(formatTimeline(tl))
261
+ return 0
262
+ }
263
+
264
+ // the verdict as a short tag for the terminal: ✓ pass / ✗ fail / ≈ note: <text>, or `legacy` for a reading
265
+ // taken before verdicts existed.
266
+ function verdictText(v: Verdict | undefined): string {
267
+ if (!v) return 'legacy'
268
+ if (v.status === 'pass') return '✓ pass'
269
+ if (v.status === 'fail') return '✗ fail'
270
+ return `≈ note: ${v.note ?? ''}`
271
+ }
272
+
273
+ export function formatTimeline(tl: EvalTimeline): string {
274
+ if (!tl.hasYatsu) return `spex yatsu show: '${tl.node}' declares no scenarios (no yatsu.md)`
275
+ if (!tl.readings.length) return `spex yatsu show: '${tl.node}' has scenarios but no reading yet — run \`spex yatsu eval ${tl.node}\``
276
+ const w = Math.max(...tl.readings.map((r) => r.scenario.length))
277
+ const lines = tl.readings.flatMap((r) => {
278
+ const badge = r.fresh ? '✓ current' : `⚠ stale (${r.staleAxes.join(', ')})`
279
+ const ev = r.blobState === 'present' ? `${r.blobKind === 'transcript' ? 'transcript' : 'image'} ${(r.blob ?? '').slice(0, 12)}…`
280
+ : r.blobState === 'miss' ? 'miss original file' : 'no evidence'
281
+ const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.evaluator} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
282
+ return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
283
+ })
284
+ return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines].join('\n')
285
+ }
286
+
287
+ export async function runYatsu(args: string[]): Promise<number> {
288
+ const sub = args[0]
289
+ if (sub === 'scan') return scan(args.slice(1))
290
+ if (sub === 'eval') return evalCmd(args.slice(1))
291
+ if (sub === 'clean') return clean(args.slice(1))
292
+ if (sub === 'show') return show(args.slice(1))
293
+ if (sub === 'check-staged') return checkStaged()
294
+ console.error('spex yatsu: scan [--changed] | eval [.|<node>] [--scenario <name>] (--pass|--fail|--note <text>) [--image <path>|--result <path|->] | show [.|<node>] [--json] | clean [--keep-latest|--all]')
295
+ return 2
296
+ }
@@ -0,0 +1,112 @@
1
+ import { relative, dirname } from 'node:path'
2
+ import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
3
+ import { loadSpecs } from '../../spec-cli/src/specs.js'
4
+ import { yatsuNodes, type YatsuNode } from './yatsu.js'
5
+ import { readReadings, type Verdict } from './sidecar.js'
6
+ import { staleAxes, type StaleAxis } from './freshness.js'
7
+ import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
8
+
9
+ export type EvalEntry = {
10
+ scenario: string
11
+ expected: string
12
+ codeSha: string
13
+ blob: string | null
14
+ blobKind?: 'image' | 'transcript'
15
+ evaluator: string
16
+ verdict?: Verdict
17
+ ts: string
18
+ fresh: boolean
19
+ staleAxes: StaleAxis[]
20
+ blobState: 'present' | 'miss' | 'none'
21
+ }
22
+
23
+ export type ScenarioInfo = { name: string; expected: string; code?: string[] }
24
+
25
+ // `hasYatsu` distinguishes a node that declares no scenarios (no yatsu.md) from one that declares some but
26
+ // has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
27
+ // is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
28
+ // like the history tab).
29
+ export type EvalTimeline = {
30
+ node: string
31
+ hasYatsu: boolean
32
+ scenarios: ScenarioInfo[]
33
+ readings: EvalEntry[]
34
+ }
35
+
36
+ export type EvalContext = {
37
+ root: string
38
+ specs: Awaited<ReturnType<typeof loadSpecs>>
39
+ idx: DriftIndex
40
+ hidx: HistoryIndex
41
+ ynodes: YatsuNode[]
42
+ }
43
+
44
+ // build the shared context with ONE yatsu walk, reusing the caller's already-computed specs + the two
45
+ // 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) }
49
+ }
50
+
51
+ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
52
+ const root = ctx?.root ?? repoRoot()
53
+ // short-circuit a non-yatsu node on the (short) yatsu walk — the board attaches `evals` to every node, so
54
+ // this is the common case and must stay cheap (a list the size of the few yatsu nodes, not the whole tree).
55
+ const ynode = (ctx?.ynodes ?? yatsuNodes(root)).find((n) => n.id === id)
56
+ if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [] }
57
+ // the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
58
+ // reparent/rename is seen the same way `spex lint` and `spex yatsu eval` see it (joined by directory).
59
+ const specs = ctx?.specs ?? await loadSpecs()
60
+ const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
61
+ const idx = ctx?.idx ?? await driftIndex(root)
62
+ const hidx = ctx?.hidx ?? await historyIndex(root)
63
+ const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
64
+ const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
65
+ name: s.name, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}),
66
+ }))
67
+ const readings: EvalEntry[] = readReadings(ynode.sidecarPath).map((r) => {
68
+ // a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
69
+ const sc = byName.get(r.scenario)
70
+ const axes = staleAxes(r, sc?.code?.length ? sc.code : codeFiles, ynode.yatsuPath, idx, hidx)
71
+ return {
72
+ scenario: r.scenario,
73
+ expected: byName.get(r.scenario)?.expected ?? '',
74
+ codeSha: r.codeSha,
75
+ blob: r.blob,
76
+ ...(r.blobKind ? { blobKind: r.blobKind } : {}),
77
+ evaluator: r.evaluator,
78
+ ...(r.verdict ? { verdict: r.verdict } : {}),
79
+ ts: r.ts,
80
+ fresh: axes.length === 0,
81
+ staleAxes: axes,
82
+ blobState: r.blob == null ? 'none' : hasBlob(r.blob) ? 'present' : 'miss',
83
+ }
84
+ })
85
+ readings.reverse()
86
+ return { node: id, hasYatsu: true, scenarios, readings }
87
+ }
88
+
89
+ const HEX64 = /^[0-9a-f]{64}$/
90
+
91
+ export type BlobResult =
92
+ | { ok: true; bytes: Buffer; mime: string }
93
+ | { ok: false; reason: 'invalid' | 'miss'; message: string }
94
+
95
+ export function readBlobByHash(hash: string, dir?: string): BlobResult {
96
+ if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad blob hash' }
97
+ const bytes = getBlob(hash, dir) // undefined dir → the live cache (cache.ts default); a temp dir in tests
98
+ if (!bytes) return { ok: false, reason: 'miss', message: MISS_BLOB }
99
+ return { ok: true, bytes, mime: sniffBlobMime(bytes) }
100
+ }
101
+
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.
105
+ function sniffBlobMime(b: Buffer): string {
106
+ if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
107
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
108
+ if (b.length >= 4 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif'
109
+ if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
110
+ if (b.length && !b.includes(0)) return 'text/plain; charset=utf-8'
111
+ return 'application/octet-stream'
112
+ }
@@ -0,0 +1,24 @@
1
+ export const EVALUATORS: Record<string, number> = { manual: 1 }
2
+ export const DEFAULT_EVALUATOR = 'manual'
3
+
4
+ // the tag stamped on a reading. With no name → the default `manual`; an unknown name still tags (version 1)
5
+ // so an out-of-band evaluator can record without the core having to know it yet.
6
+ export function evaluatorTag(name: string = DEFAULT_EVALUATOR): string {
7
+ return `${name}@${EVALUATORS[name] ?? 1}`
8
+ }
9
+
10
+ // parse a recorded evaluator tag back into name + version (for comparing against the current version).
11
+ export function parseEvaluator(tag: string): { name: string; version: number } {
12
+ const at = tag.lastIndexOf('@')
13
+ if (at < 0) return { name: tag, version: NaN }
14
+ return { name: tag.slice(0, at), version: Number(tag.slice(at + 1)) }
15
+ }
16
+
17
+ // a reading's evaluator tag is stale iff its evaluator is KNOWN to the core and its version is behind the
18
+ // current one. An unknown evaluator invents no staleness — we can't version an instrument we don't define.
19
+ export function isEvaluatorStale(tag: string): boolean {
20
+ const { name, version } = parseEvaluator(tag)
21
+ const cur = EVALUATORS[name]
22
+ if (cur === undefined) return false
23
+ return version !== cur
24
+ }
@@ -0,0 +1,51 @@
1
+ import { rowsFor, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
2
+ import type { Reading } from './sidecar.js'
3
+ import { isEvaluatorStale } from './evaluator.js'
4
+
5
+ // 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
+ export type StaleAxis = 'code' | 'scenario' | 'evaluator'
8
+
9
+ // true iff some commit touched `path` strictly NEWER than `sinceSha`. An unknown `sinceSha` (a reading
10
+ // taken off the current history — e.g. on a since-rebased commit) returns true: we can't prove freshness,
11
+ // so we treat it as stale rather than silently pass.
12
+ 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
18
+ }
19
+ return false
20
+ }
21
+
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 })
27
+ }
28
+
29
+ export function staleAxes(
30
+ reading: Reading,
31
+ codeFiles: string[],
32
+ yatsuPath: string,
33
+ didx: DriftIndex,
34
+ hidx: HistoryIndex,
35
+ ): StaleAxis[] {
36
+ const axes: StaleAxis[] = []
37
+ if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f))) axes.push('code')
38
+ if (scenarioMoved(hidx, didx.pos, reading.codeSha, yatsuPath)) axes.push('scenario')
39
+ if (isEvaluatorStale(reading.evaluator)) axes.push('evaluator')
40
+ return axes
41
+ }
42
+
43
+ export function isStale(
44
+ reading: Reading,
45
+ codeFiles: string[],
46
+ yatsuPath: string,
47
+ didx: DriftIndex,
48
+ hidx: HistoryIndex,
49
+ ): boolean {
50
+ return staleAxes(reading, codeFiles, yatsuPath, didx, hidx).length > 0
51
+ }