spexcode 0.2.8 → 0.4.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 (101) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -4,7 +4,7 @@ import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/
4
4
  import { loadSpecs } from '../../spec-cli/src/specs.js'
5
5
  import { loadConfig, sourceExtRe } from '../../spec-cli/src/lint.js'
6
6
  import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
7
- import { yatsuNodes, validateScenarios, resolveYatsuNode, YATSU_FILE, type YatsuNode } from './yatsu.js'
7
+ import { evalNodes, validateScenarios, resolveEvalNode, scenarioHash, EVAL_FILE, type EvalNode } from './scenarios.js'
8
8
  import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
9
9
  import { staleAxes, contentProbeFor } from './freshness.js'
10
10
  import { scenarioIndex } from './scenariofresh.js'
@@ -29,13 +29,13 @@ const has = (args: string[], name: string) => args.includes(`--${name}`)
29
29
  const positional = (args: string[]) => args.find((a) => !a.startsWith('--'))
30
30
 
31
31
  // join by node directory (not id) so a reparent/rename is seen the way lint sees it
32
- type EvalNode = YatsuNode & { codeFiles: string[] }
32
+ type ScoredNode = EvalNode & { codeFiles: string[] }
33
33
 
34
- async function gatherNodes(root: string): Promise<EvalNode[]> {
34
+ async function gatherNodes(root: string): Promise<ScoredNode[]> {
35
35
  const specs = await loadSpecs()
36
36
  const codeByDir = new Map<string, string[]>()
37
37
  for (const s of specs) codeByDir.set(dirname(s.path), s.code) // s.path = repo-relative spec.md path
38
- return yatsuNodes(root).map((n) => ({ ...n, codeFiles: codeByDir.get(relative(root, n.dir)) ?? [] }))
38
+ return evalNodes(root).map((n) => ({ ...n, codeFiles: codeByDir.get(relative(root, n.dir)) ?? [] }))
39
39
  }
40
40
 
41
41
  // resolve `.` → the node this worktree works on: the session record's `node` (the authoritative ref a
@@ -53,8 +53,8 @@ function currentNodeId(root: string): string | null {
53
53
  }
54
54
 
55
55
  // isUiPath answers a FRONTEND-specific question — "does this node need a real BROWSER reading?" — and is
56
- // consumed by the review-proof's `uncoveredFrontend` blindspot, NOT by scan's uncovered check. Scan keys off
57
- // the general `sourceExtensions` knob instead (see the `yatsu-uncovered` branch below), so a non-web project's
56
+ // consumed by the session-eval's `uncoveredFrontend` blindspot, NOT by eval lint's coverage check. Scan keys off
57
+ // the general `sourceExtensions` knob instead (see the `eval-coverage` branch below), so a non-web project's
58
58
  // sources are held to the loss discipline too; this stays web-shaped on purpose.
59
59
  const UI_FILE = /\.(jsx|tsx|vue|svelte|css)$/
60
60
  export const isUiPath = (p: string) => UI_FILE.test(p) || p.includes('spec-dashboard/')
@@ -71,7 +71,7 @@ function changedSinceBase(root: string): Set<string> {
71
71
  return out
72
72
  }
73
73
 
74
- // a node is "changed" if the branch touched its node dir (spec.md / yatsu.md / sidecar all live there) or
74
+ // a node is "changed" if the branch touched its node dir (spec.md / eval.md / sidecar all live there) or
75
75
  // any governed code path — matched as an exact file, a directory prefix, or a `*` glob.
76
76
  export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<string>): boolean {
77
77
  for (const c of changed) if (c === dirRel || c.startsWith(dirRel + '/')) return true
@@ -87,25 +87,25 @@ export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<st
87
87
  async function scan(args: string[] = []): Promise<number> {
88
88
  const root = repoRoot()
89
89
  const cfg = loadConfig(root)
90
- // yatsu-uncovered fires on ANY governed source file (per the shared `sourceExtensions` knob), not just
90
+ // eval-coverage fires on ANY governed source file (per the shared `sourceExtensions` knob), not just
91
91
  // frontend — so a backend/CLI/non-web project's sources are held to the loss discipline too, not exempted.
92
92
  const srcRe = sourceExtRe(cfg.sourceExtensions)
93
93
  const changedOnly = has(args, 'changed')
94
94
  const changed = changedOnly ? changedSinceBase(root) : null
95
95
  const idx = await driftIndex(root)
96
- // the off-history content fallback ([[yatsu-core]]): a rebased/folded-away anchor with byte-identical
96
+ // the off-history content fallback ([[eval-core]]): a rebased/folded-away anchor with byte-identical
97
97
  // governed content reads fresh instead of false-positive stale. Lazy — in-history readings never probe.
98
98
  const probe = contentProbeFor(root)
99
- const scidx = await scenarioIndex(root, yatsuNodes(root).map((n) => n.yatsuPath))
99
+ const scidx = await scenarioIndex(root, evalNodes(root).map((n) => n.evalPath))
100
100
  const specs = await loadSpecs()
101
101
  // the non-git REMARK freshness axis ([[remark-teeth]]): the trunk remark track, read ONCE — the CLI is the
102
- // whole model, so `spex yatsu scan` shows a remark-stale scenario with no server running.
102
+ // whole model, so `spex eval lint` shows a remark-stale scenario with no server running.
103
103
  const remarkTracks = loadEvalRemarkTracks()
104
104
  // a file may be governed by several nodes — ordinary composition, not a hub to skip (see governed-related).
105
- // A change to a shared governed file legitimately triggers EVERY governing node's yatsu, mirroring how
105
+ // A change to a shared governed file legitimately triggers EVERY governing node's eval signal, mirroring how
106
106
  // lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
107
107
  // `owners` concern (split it), not a reason to go silent here.
108
- const yByDir = new Map(yatsuNodes(root).map((n) => [relative(root, n.dir), n]))
108
+ const yByDir = new Map(evalNodes(root).map((n) => [relative(root, n.dir), n]))
109
109
  let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
110
110
  for (const s of specs) {
111
111
  const dirRel = dirname(s.path)
@@ -113,11 +113,11 @@ async function scan(args: string[] = []): Promise<number> {
113
113
  const y = yByDir.get(dirRel)
114
114
  const findings: string[] = []
115
115
  if (y) {
116
- // schema first: a malformed yatsu.md is the loudest gap — report each violation, then still scan its
116
+ // schema first: a malformed eval.md is the loudest gap — report each violation, then still scan its
117
117
  // (leniently-parsed) scenarios for stale/missing so a typo doesn't mask a real freshness gap.
118
- for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'), cfg.scenarioTags)) {
118
+ for (const e of validateScenarios(readFileSync(join(y.dir, EVAL_FILE), 'utf8'), cfg.scenarioTags)) {
119
119
  malformed++
120
- findings.push(` • yatsu-schema: '${s.id}' ${e} — fix ${y.yatsuPath}`)
120
+ findings.push(` • eval-schema: '${s.id}' ${e} — fix ${y.evalPath}`)
121
121
  }
122
122
  const latest = latestPerScenario(readReadings(y.sidecarPath))
123
123
  for (const sc of y.scenarios) {
@@ -128,49 +128,49 @@ async function scan(args: string[] = []): Promise<number> {
128
128
  const ghosts = (paths ?? []).filter((p) => !existsSync(join(root, p)))
129
129
  if (ghosts.length) {
130
130
  malformed++
131
- findings.push(` • yatsu-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.yatsuPath}`)
131
+ findings.push(` • eval-schema: '${s.id}' scenario '${sc.name}' \`${field}\` path(s) not found: ${ghosts.join(', ')} — fix ${y.evalPath}`)
132
132
  }
133
133
  }
134
134
  const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
135
135
  // carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
136
136
  // is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
137
- // reading `spex yatsu scan` both see whether this stale score needs a real e2e/browser pass to refresh.
137
+ // reading `spex eval lint` both see whether this stale score needs a real e2e/browser pass to refresh.
138
138
  const tagStr = sc.tags?.length ? ` [${sc.tags.join(',')}]` : ''
139
139
  const r = latest.get(sc.name)
140
140
  if (!r) {
141
141
  missingScores++
142
- findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
142
+ findings.push(` • eval-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no eval yet — measure with \`spex eval add ${s.id}\``)
143
143
  continue
144
144
  }
145
145
  const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
146
- const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, scidx, remSignals, probe)
146
+ const axes = staleAxes(r, codeFiles, y.evalPath, idx, scidx, remSignals, probe, sc)
147
147
  if (axes.length) {
148
148
  staleScores++
149
149
  // a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
150
- // by a re-measure. Both read the same word: "re-measure with spex yatsu eval". The anchor axis is
150
+ // by a re-measure. Both read the same word: "re-measure with spex eval add". The anchor axis is
151
151
  // a LOSS, not a change — the reading's commit object no longer exists, so content can't testify.
152
152
  const others = axes.filter((a) => a !== 'anchor')
153
153
  const why = [
154
154
  ...(axes.includes('anchor') ? [`anchor commit ${r.codeSha.slice(0, 7)} is gone — history rewritten and pruned`] : []),
155
155
  ...(others.length ? [`${others.join(', ')} changed since ${r.codeSha.slice(0, 7)}`] : []),
156
156
  ].join('; ')
157
- findings.push(` • yatsu-drift: '${s.id}' scenario '${sc.name}'${tagStr} is stale (${why}) — re-measure with \`spex yatsu eval ${s.id}\``)
157
+ findings.push(` • eval-drift: '${s.id}' scenario '${sc.name}'${tagStr} is stale (${why}) — re-measure with \`spex eval add ${s.id}\``)
158
158
  }
159
159
  }
160
160
  // DANGLING remark tracks (directive 5): a (node, scenario) remark track whose scenario is gone from
161
- // yatsu.md AND has no reading (renamed/deleted) — its remarks would surface nowhere on the loss signal.
161
+ // eval.md AND has no reading (renamed/deleted) — its remarks would surface nowhere on the loss signal.
162
162
  // One note per node so the orphan is visible; the remarks stay resolvable/retractable via their refs
163
- // (`spex resolve`/`spex retract`), and they age nothing (there is no reading to stale).
163
+ // (`spex remark resolve`/`spex remark retract`), and they age nothing (there is no reading to stale).
164
164
  const declared = new Set(y.scenarios.map((sc) => sc.name))
165
165
  const orphans = [...remarkTracks.values()].filter((tr) => tr.node === s.id && tr.remarks.length && !declared.has(tr.scenario) && !latest.has(tr.scenario))
166
166
  if (orphans.length) {
167
167
  danglingTracks += orphans.length
168
168
  const names = orphans.map((o) => `'${o.scenario}' (${o.threadId}, ${o.remarks.length} remark${o.remarks.length > 1 ? 's' : ''})`).join(', ')
169
- 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`)
169
+ findings.push(` • eval-dangling: '${s.id}' has ${orphans.length} orphaned remark track(s) — scenario ${names} renamed/deleted; resolve/retract via \`spex remark resolve <ref>\` / \`spex remark retract <ref>\` or restore the scenario name`)
170
170
  }
171
171
  } else if (s.code.some((p) => srcRe.test(p))) {
172
172
  uncovered++
173
- findings.push(` • yatsu-uncovered: '${s.id}' governs source code but has no yatsu.md — give it a scenario (description + expected) so its loss can be measured`)
173
+ findings.push(` • eval-coverage: '${s.id}' governs source code but has no eval.md — give it a scenario (description + expected) so its loss can be measured`)
174
174
  }
175
175
  if (!findings.length) continue
176
176
  flaggedNodes++
@@ -186,13 +186,13 @@ async function scan(args: string[] = []): Promise<number> {
186
186
  if (over.length) {
187
187
  overOwned = over.length
188
188
  const top = over.slice(0, 5).map(([f, c]) => `${f.split('/').pop()}(${c})`).join(', ')
189
- 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.`)
189
+ console.error(` • eval-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.`)
190
190
  }
191
191
  }
192
192
  const scope = changedOnly ? ' --changed' : ''
193
193
  const ownersNote = overOwned ? `, ${overOwned} over-owned` : ''
194
194
  const danglingNote = danglingTracks ? `, ${danglingTracks} dangling` : ''
195
- console.error(`spex yatsu scan${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} uncovered${danglingNote}${ownersNote})`)
195
+ console.error(`spex eval lint${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} coverage gap(s)${danglingNote}${ownersNote})`)
196
196
  return 0
197
197
  }
198
198
 
@@ -225,19 +225,19 @@ async function evalCmd(args: string[]): Promise<number> {
225
225
  const root = repoRoot()
226
226
  const bad = rejectUnknownEvalFlag(args)
227
227
  if (bad) {
228
- console.error(`spex yatsu eval: unknown flag '${bad}' — accepts --scenario --pass --fail --note --image --result --video --timeline`)
228
+ console.error(`spex eval add: unknown flag '${bad}' — accepts --scenario --pass --fail --note --image --result --video --timeline`)
229
229
  return 2
230
230
  }
231
231
  const sel = positional(args)
232
232
  const ref = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel) // node args tolerate @/[[ ]] sigils ([[mentions]])
233
- if (!ref) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
234
- // resolve LOUD ([[yatsu-core]]): exact canonical id, else a unique bare leaf; an ambiguous leaf lists its
233
+ if (!ref) { console.error('spex eval add .: no current node (no .session/node-branch here) — name a node'); return 2 }
234
+ // resolve LOUD ([[eval-core]]): exact canonical id, else a unique bare leaf; an ambiguous leaf lists its
235
235
  // candidate canonical ids instead of filing against an arbitrary node.
236
- const res = resolveYatsuNode(await gatherNodes(root), ref)
237
- if (!res.ok) { console.error(`spex yatsu eval: ${res.error}`); return 1 }
236
+ const res = resolveEvalNode(await gatherNodes(root), ref)
237
+ if (!res.ok) { console.error(`spex eval add: ${res.error}`); return 1 }
238
238
  const node = res.node
239
239
  const id = node.id
240
- if (!node.scenarios.length) { console.error(`spex yatsu eval: '${id}' declares no scenarios in its yatsu.md`); return 1 }
240
+ if (!node.scenarios.length) { console.error(`spex eval add: '${id}' declares no scenarios in its eval.md`); return 1 }
241
241
 
242
242
  // which scenario this measurement is OF: --scenario, or the sole scenario when there is exactly one.
243
243
  const names = node.scenarios.map((s) => s.name)
@@ -245,13 +245,13 @@ async function evalCmd(args: string[]): Promise<number> {
245
245
  let scenario = scName ? node.scenarios.find((s) => s.name === scName) : node.scenarios.length === 1 ? node.scenarios[0] : undefined
246
246
  if (!scenario) {
247
247
  const why = scName ? `has no scenario '${scName}'` : `declares ${node.scenarios.length} scenarios — name one with --scenario <name>`
248
- console.error(`spex yatsu eval: '${id}' ${why} — declared: ${names.join(', ')}`)
248
+ console.error(`spex eval add: '${id}' ${why} — declared: ${names.join(', ')}`)
249
249
  return 1
250
250
  }
251
251
 
252
252
  // the verdict the agent reached (required — a measurement without one is the legacy shape, not a filing).
253
253
  const verdict = parseVerdict(args)
254
- if (!verdict) { console.error('spex yatsu eval: a verdict is required — --pass or --fail (either may add --note <text>)'); return 2 }
254
+ if (!verdict) { console.error('spex eval add: a verdict is required — --pass or --fail (either may add --note <text>)'); return 2 }
255
255
 
256
256
  // the evidence the agent captured (optional; a LIST now — REPEATABLE --image plus an optional --result
257
257
  // and/or --video, in any combination). The bytes go to the content-addressed cache exactly the same
@@ -278,12 +278,12 @@ async function evalCmd(args: string[]): Promise<number> {
278
278
  const timeline = flag(args, 'timeline')
279
279
  let timelineBlob: string | undefined
280
280
  if (timeline !== undefined) {
281
- if (!evidence.length) { console.error('spex yatsu eval: --timeline needs axis-bearing evidence — attach the --video/--image/--result whose axis its steps anchor to'); return 2 }
281
+ if (!evidence.length) { console.error('spex eval add: --timeline needs axis-bearing evidence — attach the --video/--image/--result whose axis its steps anchor to'); return 2 }
282
282
  let parsed: unknown
283
- try { parsed = JSON.parse(readFileSync(timeline, 'utf8')) } catch { console.error(`spex yatsu eval: --timeline ${timeline} is not readable JSON`); return 2 }
283
+ try { parsed = JSON.parse(readFileSync(timeline, 'utf8')) } catch { console.error(`spex eval add: --timeline ${timeline} is not readable JSON`); return 2 }
284
284
  const terrs = validateTimeline(parsed)
285
285
  if (terrs.length) {
286
- console.error('spex yatsu eval: invalid step-timeline:')
286
+ console.error('spex eval add: invalid step-timeline:')
287
287
  for (const e of terrs) console.error(` ${e}`)
288
288
  return 2
289
289
  }
@@ -291,7 +291,7 @@ async function evalCmd(args: string[]): Promise<number> {
291
291
  const present = new Set(evidence.map((e) => AXIS_FOR_KIND[e.kind]))
292
292
  if (!present.has(axis)) {
293
293
  const have = evidence.map((e) => `${e.kind}→${AXIS_FOR_KIND[e.kind]}`).join(', ')
294
- console.error(`spex yatsu eval: --timeline axis '${axis}' matches none of this reading's evidence (${have}) — a step-map's axis must be the axis of an attached evidence entry`)
294
+ console.error(`spex eval add: --timeline axis '${axis}' matches none of this eval's evidence (${have}) — a step-map's axis must be the axis of an attached evidence entry`)
295
295
  return 2
296
296
  }
297
297
  timelineBlob = putBlob(Buffer.from(JSON.stringify(parsed)))
@@ -300,6 +300,8 @@ async function evalCmd(args: string[]): Promise<number> {
300
300
  const reading: Reading = {
301
301
  scenario: scenario.name,
302
302
  codeSha: headSha(root),
303
+ // the contract this measurement was taken against — decides the scenario freshness axis by pure compare
304
+ scenarioHash: scenarioHash(scenario),
303
305
  ...(evidence.length ? { evidence } : {}),
304
306
  ...(timelineBlob ? { timelineBlob } : {}),
305
307
  // the filing session — the originator an eval-comment thread loops in ([[mentions]]); absent if unknown
@@ -312,17 +314,17 @@ async function evalCmd(args: string[]): Promise<number> {
312
314
  ? evidence.map((e) => `${e.kind} ${e.hash.slice(0, 12)}…`).join(', ') + (timelineBlob ? ' +timeline' : '')
313
315
  : 'no evidence'
314
316
  console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} (${ev})`)
315
- console.log(`spex yatsu eval: 1 measurement filed`)
317
+ console.log(`spex eval add: 1 measurement filed`)
316
318
 
317
319
  // @@@mis-anchor guard - a codeSha names a COMMIT, never a working tree: filed over uncommitted governed
318
320
  // edits, this reading claims a verdict at HEAD while HEAD lacks the code actually measured — wrong from
319
321
  // birth, and the stale flag after the next commit is freshness exposing it, not an engine bug. Warn,
320
322
  // never block: the honest flow is measure on the tree until green → commit that tested tree → file.
321
- const dirty = dirtyGoverned(root, [...(scenario.code?.length ? scenario.code : node.codeFiles), node.yatsuPath])
323
+ const dirty = dirtyGoverned(root, [...(scenario.code?.length ? scenario.code : node.codeFiles), node.evalPath])
322
324
  if (dirty.length) {
323
- console.error(` ⚠ mis-anchored reading: uncommitted changes in governed ${dirty.join(', ')}`)
324
- console.error(` this reading anchors to HEAD ${reading.codeSha.slice(0, 7)}, which does NOT contain those edits — it claims a ${verdict.status} for code that never ran, and the commit that lands them will (correctly) read it stale.`)
325
- console.error(` the honest flow: measure on the tree until green → commit that just-tested tree (code+spec) → THEN file the reading against the clean HEAD (retract this one if it recorded the dirty run).`)
325
+ console.error(` ⚠ mis-anchored eval: uncommitted changes in governed ${dirty.join(', ')}`)
326
+ console.error(` this eval anchors to HEAD ${reading.codeSha.slice(0, 7)}, which does NOT contain those edits — it claims a ${verdict.status} for code that never ran, and the commit that lands them will (correctly) read it stale.`)
327
+ console.error(` the honest flow: measure on the tree until green → commit that just-tested tree (code+spec) → THEN file the eval against the clean HEAD (retract this one if it recorded the dirty run).`)
326
328
  }
327
329
  return 0
328
330
  }
@@ -355,11 +357,11 @@ function parseVerdict(args: string[]): Verdict | null {
355
357
  const RETRACT_VALUE_FLAGS = new Set(['scenario', 'ts', 'note'])
356
358
  const RETRACT_BOOL_FLAGS = new Set(['last'])
357
359
 
358
- // `spex yatsu retract` — the sanctioned inverse of eval: undo a botched filing through the SAME surface
360
+ // `spex eval retract` — the sanctioned inverse of eval: undo a botched filing through the SAME surface
359
361
  // that wrote it. It appends a RETRACTION event to the sidecar (append-only stays true; the target line
360
362
  // stays as history; git carries who/when/why) — never a deleted line. The effective scoreboard then drops
361
363
  // the retracted reading everywhere at once: the previous reading becomes the latest again, or the scenario
362
- // honestly returns to yatsu-missing. Default target is the scenario's LATEST effective reading (--last is
364
+ // honestly returns to eval-missing. Default target is the scenario's LATEST effective reading (--last is
363
365
  // that default made explicit — repeated retracts peel junk e2e/smoke filings back one at a time); --ts
364
366
  // pins an exact reading by its timestamp.
365
367
  async function retractCmd(args: string[]): Promise<number> {
@@ -370,41 +372,41 @@ async function retractCmd(args: string[]): Promise<number> {
370
372
  const name = a.slice(2)
371
373
  if (RETRACT_VALUE_FLAGS.has(name)) { i++; continue }
372
374
  if (RETRACT_BOOL_FLAGS.has(name)) continue
373
- console.error(`spex yatsu retract: unknown flag '${a}' — accepts --scenario --last --ts --note`)
375
+ console.error(`spex eval retract: unknown flag '${a}' — accepts --scenario --last --ts --note`)
374
376
  return 2
375
377
  }
376
378
  const sel = positional(args)
377
379
  const ref = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
378
- if (!ref) { console.error('spex yatsu retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
380
+ if (!ref) { console.error('spex eval retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
379
381
  // node resolution mirrors eval's: exact canonical id, else a unique bare leaf, ambiguous fails loud.
380
- const res = resolveYatsuNode(yatsuNodes(root), ref)
381
- if (!res.ok) { console.error(`spex yatsu retract: ${res.error}`); return 1 }
382
+ const res = resolveEvalNode(evalNodes(root), ref)
383
+ if (!res.ok) { console.error(`spex eval retract: ${res.error}`); return 1 }
382
384
  const node = res.node
383
385
  const id = node.id
384
386
 
385
387
  // which scenario, resolved exactly as eval resolves it: --scenario, or the sole declared scenario. A
386
388
  // reading for a since-deleted scenario is still retractable by naming it — the sidecar knows names the
387
- // yatsu.md may have dropped.
389
+ // eval.md may have dropped.
388
390
  const scName = flag(args, 'scenario')
389
391
  const declared = node.scenarios.map((s) => s.name)
390
392
  const scenario = scName ?? (declared.length === 1 ? declared[0] : undefined)
391
393
  if (!scenario) {
392
- console.error(`spex yatsu retract: '${id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
394
+ console.error(`spex eval retract: '${id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
393
395
  return 1
394
396
  }
395
397
 
396
398
  const ts = flag(args, 'ts')
397
- 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 }
399
+ if (ts !== undefined && has(args, 'last')) { console.error('spex eval retract: --ts and --last conflict — pin one eval or take the latest, not both'); return 2 }
398
400
  const effective = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
399
401
  if (!effective.length) {
400
402
  const { readings } = readSidecar(node.sidecarPath)
401
403
  const had = readings.some((r) => r.scenario === scenario)
402
- console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no ${had ? 'un-retracted ' : ''}reading${had ? ' left' : ''} — nothing to retract`)
404
+ console.error(`spex eval retract: '${id}' scenario '${scenario}' has no ${had ? 'un-retracted ' : ''}eval${had ? ' left' : ''} — nothing to retract`)
403
405
  return 1
404
406
  }
405
407
  const target = ts !== undefined ? effective.find((r) => r.ts === ts) : effective[effective.length - 1]
406
408
  if (!target) {
407
- console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no un-retracted reading @ ${ts} — readings: ${effective.map((r) => r.ts).join(', ')}`)
409
+ console.error(`spex eval retract: '${id}' scenario '${scenario}' has no un-retracted eval @ ${ts} — evals: ${effective.map((r) => r.ts).join(', ')}`)
408
410
  return 1
409
411
  }
410
412
 
@@ -420,9 +422,54 @@ async function retractCmd(args: string[]): Promise<number> {
420
422
  const left = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
421
423
  const now = left.length
422
424
  ? `latest is now ${left[left.length - 1].ts} (${verdictText(left[left.length - 1].verdict)})`
423
- : 'the scenario is unmeasured again (yatsu-missing)'
424
- console.log(` ⟲ '${id}' scenario '${scenario}' reading @ ${target.ts} (${verdictText(target.verdict)}) retracted — ${now}`)
425
- console.log('spex yatsu retract: 1 reading retracted (an appended event — commit the sidecar so the retraction is attributed)')
425
+ : 'the scenario is unmeasured again (eval-missing)'
426
+ console.log(` ⟲ '${id}' scenario '${scenario}' eval @ ${target.ts} (${verdictText(target.verdict)}) retracted — ${now}`)
427
+ console.log('spex eval retract: 1 eval retracted (an appended event — commit the sidecar so the retraction is attributed)')
428
+ return 0
429
+ }
430
+
431
+ // `spex eval ok` — the HUMAN sign-off on a scenario's latest reading ([[human-ok]]): appends a monotonic
432
+ // human-ok event bound to that one immutable reading (a newer reading or live-computed staleness brings the
433
+ // scenario back on its own — no un-ok verb exists). CLI parity with the dashboard's affordance (LAW L);
434
+ // identity is the surface's: 'human' here — and a GOVERNED session is REFUSED, because the sign-off is the
435
+ // human's own deliberate judgment and an agent blessing its own reading would hide it from exactly the
436
+ // review the ok certifies (the no-self-resolve analogue). Flag set closed, like every eval verb.
437
+ async function okCmd(args: string[]): Promise<number> {
438
+ const root = repoRoot()
439
+ for (let i = 0; i < args.length; i++) {
440
+ const a = args[i]
441
+ if (!a.startsWith('--')) continue
442
+ if (a === '--scenario') { i++; continue }
443
+ console.error(`spex eval ok: unknown flag '${a}' — accepts --scenario`)
444
+ return 2
445
+ }
446
+ if (envSessionId()) {
447
+ console.error('spex eval ok: refusing under a governed session — human-ok is the HUMAN\'s sign-off, never an agent\'s self-blessing. Ask the human to ok it from the dashboard or their own terminal; an agent\'s judgment on a reading is a remark (`spex remark add`).')
448
+ return 1
449
+ }
450
+ const sel = positional(args)
451
+ if (!sel || sel === '.') { console.error('spex eval ok: name a node — usage: spex eval ok <node> --scenario <name>'); return 2 }
452
+ const node = stripRefSigil(sel)
453
+ const scName = flag(args, 'scenario')
454
+ const res = resolveEvalNode(evalNodes(root), node)
455
+ if (!res.ok) { console.error(`spex eval ok: ${res.error}`); return 1 }
456
+ const declared = res.node.scenarios.map((s) => s.name)
457
+ const scenario = scName ?? (declared.length === 1 ? declared[0] : undefined)
458
+ if (!scenario) {
459
+ console.error(`spex eval ok: '${res.node.id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
460
+ return 1
461
+ }
462
+ const { fileHumanOk } = await import('./humanok.js')
463
+ const r = fileHumanOk(res.node.id, scenario, 'human')
464
+ if (!r.ok) { console.error(`spex eval ok: ${r.error}`); return 1 }
465
+ if (r.already) {
466
+ console.log(`spex eval ok: '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} is already human-ok'd (by ${r.humanOk.by}, ${r.humanOk.ts}) — monotonic, nothing appended`)
467
+ return 0
468
+ }
469
+ console.log(` ☑ '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} (${r.humanOk.okSha.slice(0, 7)}) human-ok'd`)
470
+ console.log(r.landed === 'committed'
471
+ ? 'spex eval ok: 1 sign-off filed (committed straight to the trunk)'
472
+ : 'spex eval ok: 1 sign-off filed (an appended event — commit the sidecar so the sign-off lands)')
426
473
  return 0
427
474
  }
428
475
 
@@ -444,7 +491,7 @@ async function clean(args: string[]): Promise<number> {
444
491
  const before = listBlobs().length
445
492
  const removed = gc(referenced)
446
493
  const mode = all ? 'all' : keepLatest ? 'keep-latest' : 'unreferenced'
447
- console.log(`spex yatsu clean: removed ${removed.length} blob(s), kept ${before - removed.length} (${mode})`)
494
+ console.log(`spex eval clean: removed ${removed.length} evidence file(s), kept ${before - removed.length} (${mode})`)
448
495
  return 0
449
496
  }
450
497
 
@@ -457,18 +504,18 @@ function checkStaged(): number {
457
504
  const blobs = staged.filter(isStrayBlob)
458
505
  if (blobs.length) {
459
506
  bad = true
460
- console.error('✗ SpexCode yatsu: stray evidence blob(s) staged — blobs live in the shared git common dir, never in the tree:')
507
+ console.error('✗ SpexCode eval: stray evidence file(s) staged — evidence lives in the shared git common dir, never in the tree:')
461
508
  for (const o of blobs) console.error(` ${o}`)
462
- console.error(' Unstage them (git rm --cached <path>); a reading references its blob by hash, it never commits the bytes.')
509
+ console.error(' Unstage them (git rm --cached <path>); an eval references its evidence by hash, it never commits the bytes.')
463
510
  }
464
511
 
465
- for (const rel of staged.filter((p) => p === YATSU_FILE || p.endsWith('/' + YATSU_FILE))) {
512
+ for (const rel of staged.filter((p) => p === EVAL_FILE || p.endsWith('/' + EVAL_FILE))) {
466
513
  const abs = join(root, rel)
467
514
  if (!existsSync(abs)) continue // staged deletion — nothing to validate
468
515
  const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
469
516
  if (!errs.length) continue
470
517
  bad = true
471
- console.error(`✗ SpexCode yatsu: ${rel} — invalid scenario schema:`)
518
+ console.error(`✗ SpexCode eval: ${rel} — invalid scenario schema:`)
472
519
  for (const e of errs) console.error(` ${e}`)
473
520
  }
474
521
 
@@ -479,12 +526,12 @@ async function show(args: string[]): Promise<number> {
479
526
  const root = repoRoot()
480
527
  const sel = positional(args)
481
528
  const ref = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
482
- if (!ref) { console.error('spex yatsu show .: no current node (no .session/node-branch here) — name a node'); return 2 }
529
+ if (!ref) { console.error('spex eval ls .: no current node (no .session/node-branch here) — name a node'); return 2 }
483
530
  // resolve LOUD before the timeline: an ambiguous bare leaf must list its candidate canonical ids, never
484
- // fall through to a false "declares no scenarios". A ref matching NO yatsu node still renders the honest
485
- // hasYatsu:false line — a spec node without a yatsu.md is not an error to look at.
486
- const res = resolveYatsuNode(yatsuNodes(root), ref)
487
- if (!res.ok && res.ambiguous) { console.error(`spex yatsu show: ${res.error}`); return 1 }
531
+ // fall through to a false "declares no scenarios". A ref matching NO measurable node still renders the honest
532
+ // hasEvalFile:false line — a spec node without an eval.md is not an error to look at.
533
+ const res = resolveEvalNode(evalNodes(root), ref)
534
+ if (!res.ok && res.ambiguous) { console.error(`spex eval ls: ${res.error}`); return 1 }
488
535
  const tl = await evalTimeline(res.ok ? res.node.id : ref) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
489
536
  if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
490
537
  console.log(formatTimeline(tl))
@@ -501,12 +548,12 @@ function verdictText(v: Verdict | undefined): string {
501
548
  }
502
549
 
503
550
  export function formatTimeline(tl: EvalTimeline): string {
504
- if (!tl.hasYatsu) return `spex yatsu show: '${tl.node}' declares no scenarios (no yatsu.md)`
551
+ if (!tl.hasEvalFile) return `spex eval ls: '${tl.node}' declares no scenarios (no eval.md)`
505
552
  // the retraction trace, newest first — the undo stays visible through the same surface that shows readings.
506
553
  const retractLines = (tl.retractions ?? []).map((x) =>
507
- ` ⟲ retracted: scenario '${x.scenario}' reading @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
554
+ ` ⟲ retracted: scenario '${x.scenario}' eval @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
508
555
  if (!tl.readings.length) {
509
- const head = `spex yatsu show: '${tl.node}' has scenarios but no reading yet — run \`spex yatsu eval ${tl.node}\``
556
+ const head = `spex eval ls: '${tl.node}' has scenarios but no eval yet — run \`spex eval add ${tl.node}\``
510
557
  return retractLines.length ? [head, '', ...retractLines].join('\n') : head
511
558
  }
512
559
  const w = Math.max(...tl.readings.map((r) => r.scenario.length))
@@ -519,63 +566,132 @@ export function formatTimeline(tl: EvalTimeline): string {
519
566
  const ev = list.length
520
567
  ? list.map((e) => e.state === 'miss' ? 'miss original file' : `${e.kind} ${(e.hash ?? '').slice(0, 12)}…`).join(', ')
521
568
  : 'no evidence'
522
- const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
569
+ const okTag = r.humanOk ? ` ☑ human-ok (${r.humanOk.by})` : ''
570
+ const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge}${okTag} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
523
571
  return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
524
572
  })
525
- return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines, ...retractLines].join('\n')
573
+ return [`spex eval ls: '${tl.node}' — ${tl.readings.length} eval(s), newest first`, '', ...lines, ...retractLines].join('\n')
526
574
  }
527
575
 
528
- export async function runYatsu(args: string[]): Promise<number> {
576
+ // `spex eval scenario ls [<node>|.] [--unmeasured] [--json]` — the DECLARED half of the scoreboard: list
577
+ // scenarios (the measurement contracts) rather than readings. Bare = every measurable node's scenarios
578
+ // (the collection view); a <node>/`.` scopes to one node. --unmeasured keeps only scenarios with NO
579
+ // effective reading — never measured, or every filing retracted — the blind-spot worklist a measuring
580
+ // hand picks from. Flag set closed, like every eval verb.
581
+ async function scenarioLs(args: string[]): Promise<number> {
582
+ for (const a of args) {
583
+ if (!a.startsWith('--')) continue
584
+ const name = a.slice(2)
585
+ if (name !== 'unmeasured' && name !== 'json') {
586
+ console.error(`spex eval scenario ls: unknown flag '${a}' — accepts --unmeasured --json`)
587
+ return 2
588
+ }
589
+ }
590
+ const root = repoRoot()
591
+ const sel = positional(args)
592
+ let nodes = evalNodes(root)
593
+ if (sel) {
594
+ const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
595
+ if (!ref) { console.error('spex eval scenario ls .: no current node (no session/node-branch here) — name a node'); return 2 }
596
+ const res = resolveEvalNode(nodes, ref)
597
+ if (!res.ok) { console.error(`spex eval scenario ls: ${res.error}`); return 1 }
598
+ nodes = [res.node]
599
+ }
600
+ const unmeasuredOnly = has(args, 'unmeasured')
601
+ type Row = { node: string; scenario: string; tags?: string[]; measured: boolean; latest?: { verdict?: Verdict; ts: string } }
602
+ const rows: Row[] = []
603
+ for (const n of nodes) {
604
+ const latest = latestPerScenario(readReadings(n.sidecarPath))
605
+ for (const sc of n.scenarios) {
606
+ const r = latest.get(sc.name)
607
+ if (unmeasuredOnly && r) continue
608
+ rows.push({
609
+ node: n.id, scenario: sc.name,
610
+ ...(sc.tags?.length ? { tags: sc.tags } : {}),
611
+ measured: !!r,
612
+ ...(r ? { latest: { ...(r.verdict ? { verdict: r.verdict } : {}), ts: r.ts } } : {}),
613
+ })
614
+ }
615
+ }
616
+ if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
617
+ if (!rows.length) {
618
+ console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
619
+ return 0
620
+ }
621
+ const w = Math.max(...rows.map((r) => r.node.length))
622
+ for (const r of rows) {
623
+ const tagStr = r.tags?.length ? ` [${r.tags.join(',')}]` : ''
624
+ const state = r.measured ? `${verdictText(r.latest!.verdict)} ${r.latest!.ts}` : '∅ unmeasured'
625
+ console.log(` ${r.node.padEnd(w)} ${r.scenario}${tagStr} — ${state}`)
626
+ }
627
+ const un = rows.filter((r) => !r.measured).length
628
+ console.log(`spex eval scenario ls: ${rows.length} scenario(s)${unmeasuredOnly ? ', all unmeasured' : un ? ` (${un} unmeasured)` : ''}`)
629
+ return 0
630
+ }
631
+
632
+ // the `spex eval` drawer's node-scoped verbs ([[cli-surface]]): add (file a measurement) · ls (a node's
633
+ // reading timeline) · scenario ls (the declared contracts, --unmeasured = blind spots) · lint (the
634
+ // measurement-layer lint — advisory, always exit 0) · ok (the human sign-off) · retract · clean.
635
+ // The session-scoped read (`spex eval ls --session <SEL>`) is intercepted in cli.ts before this runs;
636
+ // `check-staged` is hook plumbing, exported separately for `spex internal check-staged`.
637
+ export async function runEval(args: string[]): Promise<number> {
529
638
  const sub = args[0]
530
- if (sub === 'scan') return scan(args.slice(1))
531
- if (sub === 'eval') return evalCmd(args.slice(1))
639
+ if (sub === 'lint') return scan(args.slice(1))
640
+ if (sub === 'add') return evalCmd(args.slice(1))
641
+ if (sub === 'ok') return okCmd(args.slice(1))
532
642
  if (sub === 'retract') return retractCmd(args.slice(1))
533
643
  if (sub === 'clean') return clean(args.slice(1))
534
- if (sub === 'show') return show(args.slice(1))
535
- if (sub === 'check-staged') return checkStaged()
536
- 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]')
644
+ if (sub === 'ls') return show(args.slice(1))
645
+ if (sub === 'scenario') {
646
+ if (args[1] === 'ls') return scenarioLs(args.slice(2))
647
+ console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] — list declared scenarios (the measurement contracts)')
648
+ return 2
649
+ }
650
+ console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
537
651
  return 2
538
652
  }
539
653
 
540
- // `spex blob put <file|->` / `spex blob get <hash> [-o <file>]` ([[blob-put]], [[blob-get]]) — the bare
654
+ export { checkStaged }
655
+
656
+ // `spex evidence put <file|->` / `spex evidence get <hash> [-o <file>]` ([[evidence-put]], [[evidence-get]]) — the bare
541
657
  // evidence-transport pair: put stashes bytes in the shared content-addressed cache and prints the hash,
542
- // WITHOUT filing a reading (`yatsu eval --video` couples the two); get is its symmetric read — hash in,
658
+ // WITHOUT filing a reading (`eval add --video` couples the two); get is its symmetric read — hash in,
543
659
  // bytes out. putBlob is idempotent by content, so re-putting re-seeds a checkout whose cache lacks a blob
544
660
  // some thread already references by hash (the clone-evidence-404 repair).
545
- export async function runBlob(args: string[]): Promise<number> {
661
+ export async function runEvidence(args: string[]): Promise<number> {
546
662
  if (args[0] === 'put' && args[1] !== undefined) return blobPut(args[1])
547
663
  if (args[0] === 'get') return blobGet(args.slice(1))
548
- console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
549
- console.error(' get <hash> [-o <file>] — read a blob back: local cache first, backend fallback')
664
+ console.error('spex evidence: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
665
+ console.error(' get <hash> [-o <file>] — read evidence back: local cache first, backend fallback')
550
666
  return 2
551
667
  }
552
668
 
553
669
  function blobPut(file: string): number {
554
670
  let bytes: Buffer
555
671
  try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
556
- console.error(`spex blob put: cannot read ${file}: ${(e as Error).message}`)
672
+ console.error(`spex evidence put: cannot read ${file}: ${(e as Error).message}`)
557
673
  return 2
558
674
  }
559
- if (bytes.length === 0) { console.error('spex blob put: refusing an empty blob'); return 2 }
675
+ if (bytes.length === 0) { console.error('spex evidence put: refusing empty evidence'); return 2 }
560
676
  console.log(putBlob(bytes))
561
677
  return 0
562
678
  }
563
679
 
564
680
  // the read half: ① the local content-addressed cache (the evidence usually IS on this disk — no backend
565
- // needed), ② on a local miss the same GET /api/yatsu/blob/:hash the dashboard streams from (the blob may
681
+ // needed), ② on a local miss the same GET /api/evidence/:hash the dashboard streams from (the blob may
566
682
  // have been pruned here, or put on another machine sharing the backend), ③ both missed → fail loud naming
567
683
  // both paths. No third read mechanism — this reuses readBlobByHash and the existing endpoint verbatim.
568
684
  async function blobGet(args: string[]): Promise<number> {
569
685
  const oIdx = args.indexOf('-o')
570
686
  const out = oIdx >= 0 ? args[oIdx + 1] : undefined
571
- if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex blob get: -o needs a <file>'); return 2 }
687
+ if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex evidence get: -o needs a <file>'); return 2 }
572
688
  const hash = args.find((a, i) => (oIdx < 0 || (i !== oIdx && i !== oIdx + 1)) && !a.startsWith('-'))
573
- if (!hash) { console.error('spex blob get: usage: spex blob get <hash> [-o <file>]'); return 2 }
689
+ if (!hash) { console.error('spex evidence get: usage: spex evidence get <hash> [-o <file>]'); return 2 }
574
690
  const local = readBlobByHash(hash) // validates 64-hex before touching the fs, then reads the shared cache
575
691
  if (local.ok) return emitBlob(local.bytes, out)
576
- if (local.reason === 'invalid') { console.error(`spex blob get: bad hash '${hash}' — a blob hash is 64 hex chars`); return 2 }
692
+ if (local.reason === 'invalid') { console.error(`spex evidence get: bad hash '${hash}' — an evidence hash is 64 hex chars`); return 2 }
577
693
  const { apiBase } = await import('../../spec-cli/src/sessions.js')
578
- const url = `${await apiBase()}/api/yatsu/blob/${hash}`
694
+ const url = `${await apiBase()}/api/evidence/${hash}`
579
695
  let backendMiss: string
580
696
  try {
581
697
  const r = await fetch(url)
@@ -584,8 +700,8 @@ async function blobGet(args: string[]): Promise<number> {
584
700
  } catch (e) {
585
701
  backendMiss = `unreachable (${(e as Error).message})`
586
702
  }
587
- console.error(`spex blob get: ${hash} — not found on either path:`)
588
- console.error(` local cache: ${blobPath(hash)} — no such blob (pruned, or put on another machine)`)
703
+ console.error(`spex evidence get: ${hash} — not found on either path:`)
704
+ console.error(` local cache: ${blobPath(hash)} — no such evidence (pruned, or put on another machine)`)
589
705
  console.error(` backend: ${url} — ${backendMiss}`)
590
706
  return 1
591
707
  }
@@ -595,12 +711,12 @@ async function blobGet(args: string[]): Promise<number> {
595
711
  function emitBlob(bytes: Buffer, out?: string): number {
596
712
  if (out !== undefined) {
597
713
  try { writeFileSync(out, bytes) } catch (e) {
598
- console.error(`spex blob get: cannot write ${out}: ${(e as Error).message}`)
714
+ console.error(`spex evidence get: cannot write ${out}: ${(e as Error).message}`)
599
715
  return 2
600
716
  }
601
717
  return 0
602
718
  }
603
- if (process.stdout.isTTY) console.error(`spex blob get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
719
+ if (process.stdout.isTTY) console.error(`spex evidence get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
604
720
  process.stdout.write(bytes)
605
721
  return 0
606
722
  }