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,20 +1,30 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join, relative, dirname } from 'node:path'
3
- import { repoRoot, headSha, driftIndex, historyIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
3
+ import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
4
4
  import { loadSpecs } from '../../spec-cli/src/specs.js'
5
5
  import { loadConfig } from '../../spec-cli/src/lint.js'
6
6
  import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
7
7
  import { yatsuNodes, validateScenarios, YATSU_FILE, type YatsuNode } from './yatsu.js'
8
- import { readReadings, appendReading, latestPerScenario, type Reading, type Verdict } from './sidecar.js'
8
+ import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, type Reading, type Verdict, type Evidence, type Retraction } from './sidecar.js'
9
9
  import { staleAxes } from './freshness.js'
10
+ import { scenarioIndex } from './scenariofresh.js'
11
+ import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
10
12
  import { evaluatorTag } from './evaluator.js'
11
13
  import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
14
+ import { validateTimeline } from './timeline.js'
12
15
  import { evalTimeline, type EvalTimeline } from './evaltab.js'
13
16
 
14
17
  function flag(args: string[], name: string): string | undefined {
15
18
  const i = args.indexOf(`--${name}`)
16
19
  return i >= 0 ? args[i + 1] : undefined
17
20
  }
21
+ // every value of a REPEATABLE flag (e.g. `--image a --image b`), in argv order; a trailing `--image` with no
22
+ // value (or another flag as its value) is dropped so a typo can't swallow the next flag.
23
+ function flags(args: string[], name: string): string[] {
24
+ const out: string[] = []
25
+ for (let i = 0; i < args.length; i++) if (args[i] === `--${name}`) { const v = args[i + 1]; if (v !== undefined && !v.startsWith('--')) out.push(v) }
26
+ return out
27
+ }
18
28
  const has = (args: string[], name: string) => args.includes(`--${name}`)
19
29
  const positional = (args: string[]) => args.find((a) => !a.startsWith('--'))
20
30
 
@@ -72,17 +82,21 @@ export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<st
72
82
 
73
83
  async function scan(args: string[] = []): Promise<number> {
74
84
  const root = repoRoot()
85
+ const cfg = loadConfig(root)
75
86
  const changedOnly = has(args, 'changed')
76
87
  const changed = changedOnly ? changedSinceBase(root) : null
77
88
  const idx = await driftIndex(root)
78
- const hidx = await historyIndex(root)
89
+ const scidx = await scenarioIndex(root, yatsuNodes(root).map((n) => n.yatsuPath))
79
90
  const specs = await loadSpecs()
91
+ // the non-git REMARK freshness axis ([[remark-teeth]]): the trunk remark track, read ONCE — the CLI is the
92
+ // whole model, so `spex yatsu scan` shows a remark-stale scenario with no server running.
93
+ const remarkTracks = loadEvalRemarkTracks()
80
94
  // a file may be governed by several nodes — ordinary composition, not a hub to skip (see governed-related).
81
95
  // A change to a shared governed file legitimately triggers EVERY governing node's yatsu, mirroring how
82
96
  // lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
83
97
  // `owners` concern (split it), not a reason to go silent here.
84
98
  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
99
+ let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
86
100
  for (const s of specs) {
87
101
  const dirRel = dirname(s.path)
88
102
  if (changed && !nodeChanged(dirRel, s.code, changed)) continue
@@ -91,7 +105,7 @@ async function scan(args: string[] = []): Promise<number> {
91
105
  if (y) {
92
106
  // schema first: a malformed yatsu.md is the loudest gap — report each violation, then still scan its
93
107
  // (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'))) {
108
+ for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'), cfg.scenarioTags)) {
95
109
  malformed++
96
110
  findings.push(` • yatsu-schema: '${s.id}' ${e} — fix ${y.yatsuPath}`)
97
111
  }
@@ -108,18 +122,36 @@ async function scan(args: string[] = []): Promise<number> {
108
122
  }
109
123
  }
110
124
  const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
125
+ // carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
126
+ // is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
127
+ // reading `spex yatsu scan` both see whether this stale score needs a real e2e/browser pass to refresh.
128
+ const tagStr = sc.tags?.length ? ` [${sc.tags.join(',')}]` : ''
111
129
  const r = latest.get(sc.name)
112
130
  if (!r) {
113
131
  missingScores++
114
- findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}' has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
132
+ findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
115
133
  continue
116
134
  }
117
- const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, hidx)
135
+ const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
136
+ const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, scidx, remSignals)
118
137
  if (axes.length) {
119
138
  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}\``)
139
+ // a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
140
+ // by a re-measure. Both read the same word: "re-measure with spex yatsu eval".
141
+ findings.push(` • yatsu-drift: '${s.id}' scenario '${sc.name}'${tagStr} is stale (${axes.join(', ')} changed since ${r.codeSha.slice(0, 7)}) — re-measure with \`spex yatsu eval ${s.id}\``)
121
142
  }
122
143
  }
144
+ // DANGLING remark tracks (directive 5): a (node, scenario) remark track whose scenario is gone from
145
+ // yatsu.md AND has no reading (renamed/deleted) — its remarks would surface nowhere on the loss signal.
146
+ // One note per node so the orphan is visible; the remarks stay resolvable/retractable via their refs
147
+ // (`spex resolve`/`spex retract`), and they age nothing (there is no reading to stale).
148
+ const declared = new Set(y.scenarios.map((sc) => sc.name))
149
+ const orphans = [...remarkTracks.values()].filter((tr) => tr.node === s.id && tr.remarks.length && !declared.has(tr.scenario) && !latest.has(tr.scenario))
150
+ if (orphans.length) {
151
+ danglingTracks += orphans.length
152
+ const names = orphans.map((o) => `'${o.scenario}' (${o.threadId}, ${o.remarks.length} remark${o.remarks.length > 1 ? 's' : ''})`).join(', ')
153
+ findings.push(` • yatsu-dangling: '${s.id}' has ${orphans.length} orphaned remark track(s) — scenario ${names} renamed/deleted; resolve/retract via \`spex resolve <ref>\` / \`spex retract <ref>\` or restore the scenario name`)
154
+ }
123
155
  } else if (s.code.some(isUiPath)) {
124
156
  uncovered++
125
157
  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`)
@@ -131,7 +163,7 @@ async function scan(args: string[] = []): Promise<number> {
131
163
  // whole-repo only (never --changed): a structural fact, not a per-branch freshness gap. Counts only explicit scenario `code:`.
132
164
  let overOwned = 0
133
165
  if (!changedOnly) {
134
- const maxOwners = loadConfig(root).maxOwners
166
+ const maxOwners = cfg.maxOwners
135
167
  const govCount = new Map<string, number>()
136
168
  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
169
  const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
@@ -143,12 +175,36 @@ async function scan(args: string[] = []): Promise<number> {
143
175
  }
144
176
  const scope = changedOnly ? ' --changed' : ''
145
177
  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})`)
178
+ const danglingNote = danglingTracks ? `, ${danglingTracks} dangling` : ''
179
+ console.error(`spex yatsu scan${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} uncovered${danglingNote}${ownersNote})`)
147
180
  return 0
148
181
  }
149
182
 
183
+ // eval's flag set is CLOSED — like the scenario schema's closed field set. An unrecognized flag is
184
+ // rejected LOUD, never silently ignored: an old CLI that didn't know `--video` once filed the clip as an
185
+ // `--image`, and a misfiled reading is worse than none (it reads as proof). Value-flags consume the next
186
+ // token, so a path/note that itself starts with `--` is never mistaken for a flag.
187
+ const EVAL_VALUE_FLAGS = new Set(['scenario', 'note', 'image', 'result', 'video', 'timeline'])
188
+ const EVAL_BOOL_FLAGS = new Set(['pass', 'fail'])
189
+ function rejectUnknownEvalFlag(args: string[]): string | null {
190
+ for (let i = 0; i < args.length; i++) {
191
+ const a = args[i]
192
+ if (!a.startsWith('--')) continue
193
+ const name = a.slice(2)
194
+ if (EVAL_VALUE_FLAGS.has(name)) { i++; continue } // its value is the next token — skip it
195
+ if (EVAL_BOOL_FLAGS.has(name)) continue
196
+ return a
197
+ }
198
+ return null
199
+ }
200
+
150
201
  async function evalCmd(args: string[]): Promise<number> {
151
202
  const root = repoRoot()
203
+ const bad = rejectUnknownEvalFlag(args)
204
+ if (bad) {
205
+ console.error(`spex yatsu eval: unknown flag '${bad}' — accepts --scenario --pass --fail --note --image --result --video --timeline`)
206
+ return 2
207
+ }
152
208
  const sel = positional(args)
153
209
  const id = !sel || sel === '.' ? currentNodeId(root) : sel
154
210
  if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
@@ -168,43 +224,140 @@ async function evalCmd(args: string[]): Promise<number> {
168
224
 
169
225
  // the verdict the agent reached (required — a measurement without one is the legacy shape, not a filing).
170
226
  const verdict = parseVerdict(args)
171
- if (!verdict) { console.error('spex yatsu eval: a verdict is required — one of --pass | --fail | --note <text>'); return 2 }
227
+ if (!verdict) { console.error('spex yatsu eval: a verdict is required — --pass or --fail (either may add --note <text>)'); return 2 }
172
228
 
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')
229
+ // the evidence the agent captured (optional; a LIST now REPEATABLE --image plus an optional --result
230
+ // and/or --video, in any combination). The bytes go to the content-addressed cache exactly the same
231
+ // whichever kind; each becomes one typed entry on the reading's `evidence` list. A --video clip is the
232
+ // truest evidence for a UI-surface scenario — a recording of the loop — and N stills can ride beside it.
233
+ const images = flags(args, 'image')
176
234
  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' }
235
+ const video = flag(args, 'video')
236
+ const evidence: Evidence[] = []
237
+ for (const p of images) evidence.push({ hash: putBlob(readFileSync(p)), kind: 'image' })
238
+ if (result !== undefined) evidence.push({ hash: putBlob(readFileSync(result === '-' ? 0 : result)), kind: 'transcript' })
239
+ if (video !== undefined) evidence.push({ hash: putBlob(readFileSync(video)), kind: 'video' })
240
+
241
+ // --timeline: the clip's step map (timeline.ts) — only meaningful beside a --video entry. Validated LOUD at
242
+ // filing (a malformed map is rejected, never silently reshaped); stored canonicalized so identical
243
+ // timelines share one blob. It anchors the reading's video evidence entry.
244
+ const timeline = flag(args, 'timeline')
245
+ let timelineBlob: string | undefined
246
+ if (timeline !== undefined) {
247
+ if (!evidence.some((e) => e.kind === 'video')) { console.error('spex yatsu eval: --timeline accompanies --video (it maps moments in the clip to steps)'); return 2 }
248
+ let parsed: unknown
249
+ try { parsed = JSON.parse(readFileSync(timeline, 'utf8')) } catch { console.error(`spex yatsu eval: --timeline ${timeline} is not readable JSON`); return 2 }
250
+ const terrs = validateTimeline(parsed)
251
+ if (terrs.length) {
252
+ console.error('spex yatsu eval: invalid step-timeline:')
253
+ for (const e of terrs) console.error(` ${e}`)
254
+ return 2
255
+ }
256
+ timelineBlob = putBlob(Buffer.from(JSON.stringify(parsed)))
257
+ }
182
258
 
183
259
  const reading: Reading = {
184
260
  scenario: scenario.name,
185
261
  codeSha: headSha(root),
186
- blob,
187
- ...(blobKind ? { blobKind } : {}),
262
+ ...(evidence.length ? { evidence } : {}),
263
+ ...(timelineBlob ? { timelineBlob } : {}),
188
264
  evaluator: evaluatorTag(),
265
+ // the filing session — the originator an eval-comment thread loops in ([[mentions]]); absent if unknown
266
+ ...((envSessionId() ?? undefined) ? { by: envSessionId()! } : {}),
189
267
  verdict,
190
268
  ts: new Date().toISOString(),
191
269
  }
192
270
  appendReading(node.sidecarPath, reading)
193
- const ev = blobKind === 'transcript' ? `transcript ${blob!.slice(0, 12)}…` : blobKind === 'image' ? `image ${blob!.slice(0, 12)}…` : 'no evidence'
271
+ const ev = evidence.length
272
+ ? evidence.map((e) => `${e.kind} ${e.hash.slice(0, 12)}…`).join(', ') + (timelineBlob ? ' +timeline' : '')
273
+ : 'no evidence'
194
274
  console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
195
275
  console.log(`spex yatsu eval: 1 measurement filed`)
196
276
  return 0
197
277
  }
198
278
 
199
- // the verdict from the flags: exactly one of --pass / --fail / --note <text> (precedence pass > fail > note).
279
+ // the verdict from the flags: --pass or --fail sets the status (pass wins if both given); --note <text> is an
280
+ // OPTIONAL annotation attached to either. No status flag → null (a measurement must commit to pass or fail).
200
281
  function parseVerdict(args: string[]): Verdict | null {
201
- if (has(args, 'pass')) return { status: 'pass' }
202
- if (has(args, 'fail')) return { status: 'fail' }
203
282
  const note = flag(args, 'note')
204
- if (note !== undefined) return { status: 'note', note }
283
+ const ann = note !== undefined ? { note } : {}
284
+ if (has(args, 'pass')) return { status: 'pass', ...ann }
285
+ if (has(args, 'fail')) return { status: 'fail', ...ann }
205
286
  return null
206
287
  }
207
288
 
289
+ // retract's flag set is closed like eval's — an unknown flag is rejected LOUD, never silently ignored.
290
+ const RETRACT_VALUE_FLAGS = new Set(['scenario', 'ts', 'note'])
291
+ const RETRACT_BOOL_FLAGS = new Set(['last'])
292
+
293
+ // `spex yatsu retract` — the sanctioned inverse of eval: undo a botched filing through the SAME surface
294
+ // that wrote it. It appends a RETRACTION event to the sidecar (append-only stays true; the target line
295
+ // stays as history; git carries who/when/why) — never a deleted line. The effective scoreboard then drops
296
+ // the retracted reading everywhere at once: the previous reading becomes the latest again, or the scenario
297
+ // honestly returns to yatsu-missing. Default target is the scenario's LATEST effective reading (--last is
298
+ // that default made explicit — repeated retracts peel junk e2e/smoke filings back one at a time); --ts
299
+ // pins an exact reading by its timestamp.
300
+ async function retractCmd(args: string[]): Promise<number> {
301
+ const root = repoRoot()
302
+ for (let i = 0; i < args.length; i++) {
303
+ const a = args[i]
304
+ if (!a.startsWith('--')) continue
305
+ const name = a.slice(2)
306
+ if (RETRACT_VALUE_FLAGS.has(name)) { i++; continue }
307
+ if (RETRACT_BOOL_FLAGS.has(name)) continue
308
+ console.error(`spex yatsu retract: unknown flag '${a}' — accepts --scenario --last --ts --note`)
309
+ return 2
310
+ }
311
+ const sel = positional(args)
312
+ const id = !sel || sel === '.' ? currentNodeId(root) : sel
313
+ if (!id) { console.error('spex yatsu retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
314
+ const node = yatsuNodes(root).find((n) => n.id === id)
315
+ if (!node) { console.error(`spex yatsu retract: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
316
+
317
+ // which scenario, resolved exactly as eval resolves it: --scenario, or the sole declared scenario. A
318
+ // reading for a since-deleted scenario is still retractable by naming it — the sidecar knows names the
319
+ // yatsu.md may have dropped.
320
+ const scName = flag(args, 'scenario')
321
+ const declared = node.scenarios.map((s) => s.name)
322
+ const scenario = scName ?? (declared.length === 1 ? declared[0] : undefined)
323
+ if (!scenario) {
324
+ console.error(`spex yatsu retract: '${id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
325
+ return 1
326
+ }
327
+
328
+ const ts = flag(args, 'ts')
329
+ if (ts !== undefined && has(args, 'last')) { console.error('spex yatsu retract: --ts and --last conflict — pin one reading or take the latest, not both'); return 2 }
330
+ const effective = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
331
+ if (!effective.length) {
332
+ const { readings } = readSidecar(node.sidecarPath)
333
+ const had = readings.some((r) => r.scenario === scenario)
334
+ console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no ${had ? 'un-retracted ' : ''}reading${had ? ' left' : ''} — nothing to retract`)
335
+ return 1
336
+ }
337
+ const target = ts !== undefined ? effective.find((r) => r.ts === ts) : effective[effective.length - 1]
338
+ if (!target) {
339
+ console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no un-retracted reading @ ${ts} — readings: ${effective.map((r) => r.ts).join(', ')}`)
340
+ return 1
341
+ }
342
+
343
+ const note = flag(args, 'note')
344
+ const retraction: Retraction = {
345
+ retracts: target.ts,
346
+ scenario,
347
+ ...(note !== undefined ? { note } : {}),
348
+ ...((envSessionId() ?? undefined) ? { by: envSessionId()! } : {}),
349
+ ts: new Date().toISOString(),
350
+ }
351
+ appendRetraction(node.sidecarPath, retraction)
352
+ const left = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
353
+ const now = left.length
354
+ ? `latest is now ${left[left.length - 1].ts} (${verdictText(left[left.length - 1].verdict)})`
355
+ : 'the scenario is unmeasured again (yatsu-missing)'
356
+ console.log(` ⟲ '${id}' scenario '${scenario}' reading @ ${target.ts} (${verdictText(target.verdict)} [${target.evaluator}]) retracted — ${now}`)
357
+ console.log('spex yatsu retract: 1 reading retracted (an appended event — commit the sidecar so the retraction is attributed)')
358
+ return 0
359
+ }
360
+
208
361
  async function clean(args: string[]): Promise<number> {
209
362
  const root = repoRoot()
210
363
  const all = has(args, 'all')
@@ -214,7 +367,10 @@ async function clean(args: string[]): Promise<number> {
214
367
  for (const n of await gatherNodes(root)) {
215
368
  const readings = readReadings(n.sidecarPath)
216
369
  const keep = keepLatest ? [...latestPerScenario(readings).values()] : readings
217
- for (const r of keep) if (r.blob) referenced.add(r.blob)
370
+ for (const r of keep) {
371
+ for (const e of evidenceOf(r)) referenced.add(e.hash) // every evidence entry (N images + a video…)
372
+ if (r.timelineBlob) referenced.add(r.timelineBlob) // a video reading's step map lives in the same cache
373
+ }
218
374
  }
219
375
  }
220
376
  const before = listBlobs().length
@@ -226,6 +382,7 @@ async function clean(args: string[]): Promise<number> {
226
382
 
227
383
  function checkStaged(): number {
228
384
  const root = repoRoot()
385
+ const tagLibrary = loadConfig(root).scenarioTags
229
386
  const staged = stagedFiles(root)
230
387
  let bad = false
231
388
 
@@ -240,7 +397,7 @@ function checkStaged(): number {
240
397
  for (const rel of staged.filter((p) => p === YATSU_FILE || p.endsWith('/' + YATSU_FILE))) {
241
398
  const abs = join(root, rel)
242
399
  if (!existsSync(abs)) continue // staged deletion — nothing to validate
243
- const errs = validateScenarios(readFileSync(abs, 'utf8'))
400
+ const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
244
401
  if (!errs.length) continue
245
402
  bad = true
246
403
  console.error(`✗ SpexCode yatsu: ${rel} — invalid scenario schema:`)
@@ -261,36 +418,68 @@ async function show(args: string[]): Promise<number> {
261
418
  return 0
262
419
  }
263
420
 
264
- // the verdict as a short tag for the terminal: ✓ pass / ✗ fail / note: <text>, or `legacy` for a reading
265
- // taken before verdicts existed.
421
+ // the verdict as a short tag for the terminal: ✓ pass / ✗ fail, with ` <note>` appended when annotated;
422
+ // `legacy` for a reading taken before verdicts existed, `≈ <note>` for a legacy note-only reading.
266
423
  function verdictText(v: Verdict | undefined): string {
267
424
  if (!v) return 'legacy'
268
- if (v.status === 'pass') return '✓ pass'
269
- if (v.status === 'fail') return '✗ fail'
270
- return `≈ note: ${v.note ?? ''}`
425
+ if (v.status === 'pass') return v.note ? `✓ pass — ${v.note}` : '✓ pass'
426
+ if (v.status === 'fail') return v.note ? `✗ fail — ${v.note}` : '✗ fail'
427
+ return v.note ? `≈ ${v.note}` : 'legacy' // legacy note-only reading
271
428
  }
272
429
 
273
430
  export function formatTimeline(tl: EvalTimeline): string {
274
431
  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}\``
432
+ // the retraction trace, newest first the undo stays visible through the same surface that shows readings.
433
+ const retractLines = (tl.retractions ?? []).map((x) =>
434
+ ` ⟲ retracted: scenario '${x.scenario}' reading @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
435
+ if (!tl.readings.length) {
436
+ const head = `spex yatsu show: '${tl.node}' has scenarios but no reading yet — run \`spex yatsu eval ${tl.node}\``
437
+ return retractLines.length ? [head, '', ...retractLines].join('\n') : head
438
+ }
276
439
  const w = Math.max(...tl.readings.map((r) => r.scenario.length))
277
440
  const lines = tl.readings.flatMap((r) => {
278
441
  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'
442
+ // the reading's whole evidence list (N images + a video…), each cell its kind + short hash, or the honest
443
+ // sentinel for a pruned/absent one; falls back to the scalar view for a legacy/test EvalEntry.
444
+ const list = r.evidence?.length ? r.evidence
445
+ : r.blob != null ? [{ hash: r.blob, kind: r.blobKind ?? 'image', state: r.blobState }] : []
446
+ const ev = list.length
447
+ ? list.map((e) => e.state === 'miss' ? 'miss original file' : `${e.kind} ${(e.hash ?? '').slice(0, 12)}…`).join(', ')
448
+ : 'no evidence'
281
449
  const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.evaluator} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
282
450
  return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
283
451
  })
284
- return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines].join('\n')
452
+ return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines, ...retractLines].join('\n')
285
453
  }
286
454
 
287
455
  export async function runYatsu(args: string[]): Promise<number> {
288
456
  const sub = args[0]
289
457
  if (sub === 'scan') return scan(args.slice(1))
290
458
  if (sub === 'eval') return evalCmd(args.slice(1))
459
+ if (sub === 'retract') return retractCmd(args.slice(1))
291
460
  if (sub === 'clean') return clean(args.slice(1))
292
461
  if (sub === 'show') return show(args.slice(1))
293
462
  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]')
463
+ console.error('spex yatsu: scan [--changed] | eval [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path> [--timeline <json>]] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | show [.|<node>] [--json] | clean [--keep-latest|--all]')
295
464
  return 2
296
465
  }
466
+
467
+ // `spex blob put <file|->` ([[blob-put]]) — the bare transport half of evidence: stash bytes in the shared
468
+ // content-addressed cache and print the hash, WITHOUT filing a reading (`yatsu eval --video` couples the
469
+ // two). putBlob is idempotent by content, so the same command re-seeds a checkout whose cache lacks a blob
470
+ // some thread already references by hash (the clone-evidence-404 repair).
471
+ export function runBlob(args: string[]): number {
472
+ const file = args[1]
473
+ if (args[0] !== 'put' || file === undefined) {
474
+ console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
475
+ return 2
476
+ }
477
+ let bytes: Buffer
478
+ try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
479
+ console.error(`spex blob put: cannot read ${file}: ${(e as Error).message}`)
480
+ return 2
481
+ }
482
+ if (bytes.length === 0) { console.error('spex blob put: refusing an empty blob'); return 2 }
483
+ console.log(putBlob(bytes))
484
+ return 0
485
+ }