spexcode 0.2.8 → 0.3.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 (97) 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/attach.ts +2 -2
  7. package/spec-cli/src/cli.ts +695 -536
  8. package/spec-cli/src/client.ts +31 -30
  9. package/spec-cli/src/contract-filter.ts +1 -1
  10. package/spec-cli/src/doctor.ts +40 -13
  11. package/spec-cli/src/gateway.ts +11 -7
  12. package/spec-cli/src/git.ts +2 -2
  13. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  14. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  15. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  16. package/spec-cli/src/graphStream.ts +288 -0
  17. package/spec-cli/src/guide.ts +123 -96
  18. package/spec-cli/src/harness-select.ts +2 -2
  19. package/spec-cli/src/harness.ts +30 -14
  20. package/spec-cli/src/help.ts +289 -384
  21. package/spec-cli/src/hooks.ts +1 -1
  22. package/spec-cli/src/index.ts +130 -103
  23. package/spec-cli/src/init.ts +9 -9
  24. package/spec-cli/src/issues.ts +89 -33
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +73 -8
  27. package/spec-cli/src/localIssues.ts +42 -60
  28. package/spec-cli/src/materialize.ts +1 -1
  29. package/spec-cli/src/mentions.ts +15 -15
  30. package/spec-cli/src/migrate-table.ts +397 -0
  31. package/spec-cli/src/migrate.ts +386 -0
  32. package/spec-cli/src/ranker.ts +30 -4
  33. package/spec-cli/src/reaper.ts +117 -0
  34. package/spec-cli/src/search.bench.mjs +10 -10
  35. package/spec-cli/src/search.ts +1 -1
  36. package/spec-cli/src/sessions.ts +244 -138
  37. package/spec-cli/src/specs.ts +25 -15
  38. package/spec-cli/src/supervise.ts +2 -2
  39. package/spec-cli/src/tree.ts +5 -5
  40. package/spec-cli/src/uninstall.ts +4 -4
  41. package/spec-cli/templates/hooks/post-checkout +1 -1
  42. package/spec-cli/templates/hooks/post-merge +3 -3
  43. package/spec-cli/templates/hooks/pre-commit +9 -9
  44. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  45. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  51. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  52. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
  56. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  57. package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
  59. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  60. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/spec.md +1 -1
  62. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
  63. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
  64. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
  65. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
  66. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
  68. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
  69. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
  70. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
  71. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
  72. package/spec-dashboard/dist/index.html +2 -2
  73. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  74. package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
  75. package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
  76. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  77. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  78. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  79. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  80. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  81. package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
  82. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  83. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  84. package/spec-forge/src/cli.ts +14 -13
  85. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  86. package/spec-cli/src/boardStream.ts +0 -179
  87. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  88. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  89. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  90. /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
  91. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  92. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  93. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  94. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
@@ -10,9 +10,9 @@ import { isUiPath } from './cli.js'
10
10
 
11
11
  type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
12
12
 
13
- // one yatsu reading rendered for the proof: the latest measurement of one scenario, with its evidence
13
+ // one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
14
14
  // resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
15
- export type ProofReading = {
15
+ export type ExportReading = {
16
16
  scenario: string
17
17
  expected: string
18
18
  verdict?: EvalEntry['verdict']
@@ -32,7 +32,7 @@ export type ProofReading = {
32
32
  }
33
33
 
34
34
  // patch ''/old·new null = nothing to show (added → no old, deleted → no new), past the enrichment cap (omitted), or too large (truncated)
35
- export type ProofFile = ReviewDiffFile & {
35
+ export type ExportFile = ReviewDiffFile & {
36
36
  patch: string
37
37
  oldText: string | null
38
38
  newText: string | null
@@ -41,24 +41,24 @@ export type ProofFile = ReviewDiffFile & {
41
41
  }
42
42
 
43
43
  // one changed spec node: its diff slice (the files this node owns that the session touched) joined with its
44
- // measured loss (latest reading per scenario). A frontend node with no yatsu.md is an honest blind spot.
45
- export type ProofNode = {
44
+ // measured loss (latest reading per scenario). A frontend node with no eval.md is an honest blind spot.
45
+ export type ExportNode = {
46
46
  id: string
47
47
  title: string
48
48
  hue: number
49
49
  desc: string
50
- files: ProofFile[]
50
+ files: ExportFile[]
51
51
  additions: number
52
52
  deletions: number
53
- hasYatsu: boolean
53
+ hasEvalFile: boolean
54
54
  uncoveredFrontend: boolean
55
55
  score: ScoreState
56
- readings: ProofReading[]
56
+ readings: ExportReading[]
57
57
  }
58
58
 
59
- export type ProofGate = { label: string; ok: boolean; detail: string }
59
+ export type ExportGate = { label: string; ok: boolean; detail: string }
60
60
 
61
- export type ProofModel = {
61
+ export type ExportModel = {
62
62
  id: string
63
63
  node: string | null
64
64
  branch: string | null
@@ -66,14 +66,14 @@ export type ProofModel = {
66
66
  generatedAt: string
67
67
  ahead: number
68
68
  dirtyNonRuntime: number
69
- gates: ProofGate[]
70
- score: { passed: number; total: number; fresh: number } // the yatsu summary across all nodes
71
- nodes: ProofNode[]
72
- otherFiles: ProofFile[] // changed files no spec node claims
69
+ gates: ExportGate[]
70
+ score: { passed: number; total: number; fresh: number } // the eval summary across all nodes
71
+ nodes: ExportNode[]
72
+ otherFiles: ExportFile[] // changed files no spec node claims
73
73
  }
74
74
 
75
75
  // null when no session has that id (route → 404).
76
- export async function buildProofModel(id: string): Promise<ProofModel | null> {
76
+ export async function buildExportModel(id: string): Promise<ExportModel | null> {
77
77
  const payload = await reviewPayload(id)
78
78
  if (!payload) return null
79
79
  const specs = await loadSpecs()
@@ -89,7 +89,7 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
89
89
  // extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
90
90
  // row but say so (omitted), never silently blank.
91
91
  const base = wtPath ? (await gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD'])).trim() : ''
92
- const enriched = new Map<string, ProofFile>()
92
+ const enriched = new Map<string, ExportFile>()
93
93
  let budget = MAX_ENRICHED_FILES
94
94
  for (const f of payload.diff) {
95
95
  if (wtPath && base && budget > 0) { enriched.set(f.path, await enrichFile(wtPath, base, f)); budget-- }
@@ -97,8 +97,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
97
97
  }
98
98
 
99
99
  // group the session's real changes (merge-base diff) by the spec node that owns each file.
100
- const byNode = new Map<string, ProofFile[]>()
101
- const otherFiles: ProofFile[] = []
100
+ const byNode = new Map<string, ExportFile[]>()
101
+ const otherFiles: ExportFile[] = []
102
102
  for (const f of payload.diff) {
103
103
  const nid = nodeForFile(f.path, specs, payload.node)
104
104
  const pf = enriched.get(f.path)!
@@ -108,13 +108,13 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
108
108
  // the session's primary node always appears, even if it has no file in the diff yet.
109
109
  if (payload.node && specById.has(payload.node) && !byNode.has(payload.node)) byNode.set(payload.node, [])
110
110
 
111
- const nodes: ProofNode[] = []
111
+ const nodes: ExportNode[] = []
112
112
  let passed = 0, total = 0, fresh = 0
113
113
  for (const [nid, files] of byNode) {
114
114
  const spec = specById.get(nid)
115
115
  const tl = await evalTimeline(nid, ctx)
116
116
  const latest = declaredLatest(tl)
117
- const readings = await Promise.all(latest.map(toProofReading))
117
+ const readings = await Promise.all(latest.map(toExportReading))
118
118
  for (const r of latest) {
119
119
  total++
120
120
  if (r.fresh) fresh++
@@ -128,9 +128,9 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
128
128
  files,
129
129
  additions: files.reduce((a, f) => a + f.additions, 0),
130
130
  deletions: files.reduce((a, f) => a + f.deletions, 0),
131
- hasYatsu: tl.hasYatsu,
132
- uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
133
- score: nodeScore(tl.hasYatsu, latest),
131
+ hasEvalFile: tl.hasEvalFile,
132
+ uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
133
+ score: nodeScore(tl.hasEvalFile, latest),
134
134
  readings,
135
135
  })
136
136
  }
@@ -156,8 +156,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
156
156
  }
157
157
  }
158
158
 
159
- // the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex review` prints).
160
- function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ProofGate[] {
159
+ // the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex session review` prints).
160
+ function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ExportGate[] {
161
161
  const g = p.gates
162
162
  return [
163
163
  { label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
@@ -170,7 +170,7 @@ function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): Pr
170
170
  // gitA returns '' for a missing path — exactly the added/deleted (and best-effort rename) side; each side capped to MAX_FILE_BYTES
171
171
  const MAX_ENRICHED_FILES = 60
172
172
  const MAX_FILE_BYTES = 200_000
173
- async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ProofFile> {
173
+ async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Promise<ExportFile> {
174
174
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
175
175
  const [patchRaw, oldRaw, newRaw] = await Promise.all([
176
176
  run(['diff', '-M', `${base}..HEAD`, '--', f.path]),
@@ -190,9 +190,9 @@ async function enrichFile(wtPath: string, base: string, f: ReviewDiffFile): Prom
190
190
  }
191
191
 
192
192
  // resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
193
- // data-URI; a transcript → its text; the cache miss / no-capture states pass through. yatsu owns the bytes
193
+ // data-URI; a transcript → its text; the cache miss / no-capture states pass through. the eval cache owns the bytes
194
194
  // (the content-addressed cache) — the proof only asks for them by hash.
195
- async function toProofReading(r: EvalEntry): Promise<ProofReading> {
195
+ async function toExportReading(r: EvalEntry): Promise<ExportReading> {
196
196
  const base = {
197
197
  scenario: r.scenario, expected: r.expected, verdict: r.verdict, fresh: r.fresh,
198
198
  staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
@@ -223,7 +223,7 @@ function latestPerScenario(readings: EvalEntry[]): EvalEntry[] {
223
223
 
224
224
  // the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
225
225
  // (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
226
- // yatsu.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
226
+ // eval.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
227
227
  // become a proof reading card, a passed/total tick, or a node score. The proof was the one face driven by the
228
228
  // readings that happen to exist rather than the scenarios that are declared — this bounds it like the rest, so
229
229
  // a retired scenario's stale reading can't make the proof disagree with the dashboard (phantom card, off ribbon).
@@ -246,8 +246,8 @@ function readingScore(r: EvalEntry): ScoreState {
246
246
 
247
247
  // worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
248
248
  // (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
249
- export function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
250
- if (!hasYatsu) return null
249
+ export function nodeScore(hasEvalFile: boolean, latest: EvalEntry[]): ScoreState {
250
+ if (!hasEvalFile) return null
251
251
  if (!latest.length) return 'empty'
252
252
  if (latest.some((r) => r.fresh && verdictMark(r) === 'cross')) return 'fail'
253
253
  const stale = latest.filter((r) => !r.fresh && verdictMark(r))
@@ -258,9 +258,9 @@ export function nodeScore(hasYatsu: boolean, latest: EvalEntry[]): ScoreState {
258
258
 
259
259
  // ---- file → node mapping ----
260
260
 
261
- // which spec node owns a changed file: a file inside a node's directory (its spec.md / yatsu.md / sidecar)
261
+ // which spec node owns a changed file: a file inside a node's directory (its spec.md / eval.md / sidecar)
262
262
  // belongs to the NEAREST such node; otherwise the node whose governed `code:` claims it (exact path,
263
- // directory prefix, or `*` glob — the same matching `spex yatsu scan --changed` uses). A shared file is
263
+ // directory prefix, or `*` glob — the same matching `spex eval lint --changed` uses). A shared file is
264
264
  // governed by MANY nodes (ordinary composition); when the session has a primary node that also governs it,
265
265
  // attribute it THERE so a node/<id> session's stake in cli.ts/index.ts groups under its own node, not
266
266
  // whichever sibling sorts first.
@@ -324,7 +324,7 @@ function verdictBadge(v: EvalEntry['verdict']): string {
324
324
  return `<span class="verdict note" title="${esc(v.note ?? '')}">≈ note</span>`
325
325
  }
326
326
 
327
- function renderReading(r: ProofReading): string {
327
+ function renderReading(r: ExportReading): string {
328
328
  const ev = r.evidence
329
329
  const body =
330
330
  ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
@@ -333,9 +333,9 @@ function renderReading(r: ProofReading): string {
333
333
  : ev.kind === 'data' ? `<pre class="transcript data">${esc(ev.text)}</pre>`
334
334
  : ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
335
335
  : `<div class="noev">attested without a capture</div>`
336
- const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since the reading">stale</span>`
336
+ const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since this eval">stale</span>`
337
337
  const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
338
- return `<div class="reading">
338
+ return `<div class="eval-entry">
339
339
  <div class="rhead">
340
340
  ${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
341
341
  <span class="scenario">${esc(r.scenario)}</span>
@@ -359,7 +359,7 @@ function renderPatch(patch: string): string {
359
359
  }).join('')
360
360
  }
361
361
 
362
- function renderFile(f: ProofFile): string {
362
+ function renderFile(f: ExportFile): string {
363
363
  const row = `<span class="fstatus ${esc(f.status)}">${esc(f.status)}</span> <span class="fpath">${esc(f.path)}</span> <span class="fnum"><span class="add">+${f.additions}</span> <span class="del">−${f.deletions}</span></span>`
364
364
  if (f.omitted) return `<div class="file flat"><div class="frow">${row}<span class="note">diff omitted — large changeset</span></div></div>`
365
365
  if (!f.patch && f.oldText == null && f.newText == null) return `<div class="file flat"><div class="frow">${row}</div></div>`
@@ -372,14 +372,14 @@ function renderFile(f: ProofFile): string {
372
372
  <div class="file-body">${f.patch ? `<div class="patch">${renderPatch(f.patch)}</div>` : '<div class="note">no textual diff</div>'}${cmp}</div></details>`
373
373
  }
374
374
 
375
- function renderNode(n: ProofNode): string {
375
+ function renderNode(n: ExportNode): string {
376
376
  const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`
377
377
  const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : ''
378
378
  let proof: string
379
379
  if (n.readings.length) proof = n.readings.map(renderReading).join('')
380
- else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no yatsu.md — no loss signal measured. Give it a scenario so this change can be proven.</div>`
381
- else if (n.hasYatsu) proof = `<div class="blindspot">declares scenarios but has no reading yet — measure with <code>spex yatsu eval ${esc(n.id)}</code></div>`
382
- else proof = `<div class="noev">no measurable surface (no yatsu.md)</div>`
380
+ else if (n.uncoveredFrontend) proof = `<div class="blindspot">⚠ a frontend node with no eval.md — its loss is unmeasured. Give it a scenario so this change can be verified.</div>`
381
+ else if (n.hasEvalFile) proof = `<div class="blindspot">declares scenarios but has no eval yet — measure with <code>spex eval add ${esc(n.id)}</code></div>`
382
+ else proof = `<div class="noev">no measurable surface (no eval.md)</div>`
383
383
  return `<article class="node" style="--hue:${n.hue}">
384
384
  <div class="nhead">
385
385
  <span class="huedot"></span>
@@ -390,15 +390,15 @@ function renderNode(n: ProofNode): string {
390
390
  </div>
391
391
  ${n.desc ? `<div class="ndesc">${esc(n.desc)}</div>` : ''}
392
392
  ${fileList}
393
- <div class="readings">${proof}</div>
393
+ <div class="eval-list">${proof}</div>
394
394
  </article>`
395
395
  }
396
396
 
397
- export function renderProofHtml(m: ProofModel): string {
397
+ export function renderExportHtml(m: ExportModel): string {
398
398
  const idShort = m.id.slice(0, 8)
399
399
  const ribbon = [
400
400
  ...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
401
- m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="scenarios fresh-passing (of those measured); ${m.score.fresh}/${m.score.total} fresh">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no yatsu readings on the changed nodes">★ no measured loss</span>`,
401
+ m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="scenarios fresh-passing (of those measured); ${m.score.fresh}/${m.score.total} fresh">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no evals filed on the changed nodes">★ no measured loss</span>`,
402
402
  ].join('')
403
403
  const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('')
404
404
  const otherBlock = m.otherFiles.length
@@ -409,12 +409,12 @@ export function renderProofHtml(m: ProofModel): string {
409
409
  <html lang="en"><head>
410
410
  <meta charset="utf-8">
411
411
  <meta name="viewport" content="width=device-width, initial-scale=1">
412
- <title>proof · ${esc(m.title)}</title>
412
+ <title>evals · ${esc(m.title)}</title>
413
413
  <style>${STYLE}</style>
414
414
  </head><body>
415
- <main class="proof">
415
+ <main class="evals">
416
416
  <header class="masthead">
417
- <div class="eyebrow">SpexCode · review proof</div>
417
+ <div class="eyebrow">SpexCode · session eval export</div>
418
418
  <h1 class="claim">${esc(m.title)}</h1>
419
419
  <div class="meta">session <code>${esc(idShort)}</code>${m.branch ? ` · <code>${esc(m.branch)}</code>` : ''}${m.node ? ` · node <code>${esc(m.node)}</code>` : ''} · ${m.ahead} commit(s) · ${m.nodes.length} node(s) · <span class="ts">${esc(m.generatedAt)}</span></div>
420
420
  <div class="ribbon">${ribbon}</div>
@@ -428,12 +428,12 @@ export function renderProofHtml(m: ProofModel): string {
428
428
  <h2>Merge gates</h2>
429
429
  <ul class="gates">${gates}</ul>
430
430
  </section>
431
- <footer>Generated by <code>spex review proof ${esc(idShort)}</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; yatsu is the evaluation.</footer>
431
+ <footer>Generated by <code>spex eval ls --session ${esc(idShort)} --export</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; eval keeps the score.</footer>
432
432
  </main>
433
433
  </body></html>`
434
434
  }
435
435
 
436
- // the document's inline stylesheet — dark, matching the board's palette and the yatsu score colours, so the
436
+ // the document's inline stylesheet — dark, matching the board's palette and the eval score colours, so the
437
437
  // proof reads as one surface with the dashboard. Self-contained (no external font/asset).
438
438
  const STYLE = `
439
439
  :root{--bg:#0b0e14;--panel:#11161f;--panel2:#0e131b;--ink:#c9d4e3;--dim:#7c899c;--line:#1e2733;--accent:#4cc2a0;--green:#3fb950;--red:#f85149;--grey:#6e7b8c;--amber:#d29922}
@@ -441,7 +441,7 @@ const STYLE = `
441
441
  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
442
442
  code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
443
443
  code{background:#0006;padding:.05em .35em;border-radius:4px;font-size:.88em;color:#aee1d2}
444
- .proof{max-width:920px;margin:0 auto;padding:40px 24px 80px}
444
+ .evals{max-width:920px;margin:0 auto;padding:40px 24px 80px}
445
445
  .masthead{padding:30px 30px 26px;border:1px solid var(--line);border-radius:16px;background:radial-gradient(1200px 240px at 0% 0%,#16352c66,transparent),linear-gradient(160deg,#141b26,#0d121a);box-shadow:0 20px 60px -30px #000}
446
446
  .eyebrow{font:600 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
447
447
  .claim{margin:14px 0 10px;font-size:30px;line-height:1.2;font-weight:700;color:#eef3fa;letter-spacing:-.01em}
@@ -485,8 +485,8 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
485
485
  .side-h{font:600 10px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 0 6px}
486
486
  .side pre{margin:0;padding:10px 12px;background:#05080d;border:1px solid var(--line);border-radius:8px;max-height:440px;overflow:auto;font:11px/1.5 ui-monospace,monospace;color:#aebccd;white-space:pre}
487
487
  .abs{color:var(--dim);font-style:italic}
488
- .readings{margin-top:8px}
489
- .reading{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
488
+ .eval-list{margin-top:8px}
489
+ .eval-entry{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
490
490
  .rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
491
491
  .scenario{font-weight:600;color:#dde7f1}
492
492
  .rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
@@ -515,10 +515,10 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
515
515
  footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
516
516
  `
517
517
 
518
- // ---- the session EVAL model ([[review-proof]]'s interactive face) ----
519
- // The lean, TIERED counterpart of buildProofModel: the same worktree-rooted marshaling, but rows only —
518
+ // ---- the session EVAL model ([[session-eval]]'s interactive face) ----
519
+ // The lean, TIERED counterpart of buildExportModel: the same worktree-rooted marshaling, but rows only —
520
520
  // no diff enrichment, no inlined evidence bytes (the dashboard's Eval tab rides the shared eval
521
- // components: blobs stream lazily from /api/yatsu/blob on open). Each reading carries `inSession`
521
+ // components: blobs stream lazily from /api/evidence on open). Each reading carries `inSession`
522
522
  // (this session filed it, or its codeSha is one of the branch's own commits — a diagnostic session
523
523
  // that changed no code still owns the readings it filed at the merge-base) so the tab can lead with
524
524
  // what THIS session measured, ✦-marked, over the inherited baseline.
@@ -527,7 +527,7 @@ export type SessionEvalNode = {
527
527
  title: string
528
528
  hue: number
529
529
  desc: string
530
- hasYatsu: boolean
530
+ hasEvalFile: boolean
531
531
  uncoveredFrontend: boolean
532
532
  scenarios: { name: string; expected: string; tags?: string[] }[]
533
533
  // each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
@@ -543,7 +543,7 @@ export type SessionEvals = {
543
543
  title: string
544
544
  ahead: number
545
545
  dirtyNonRuntime: number
546
- gates: ProofGate[]
546
+ gates: ExportGate[]
547
547
  nodes: SessionEvalNode[]
548
548
  }
549
549
 
@@ -572,8 +572,8 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
572
572
  title: spec?.title ?? nid,
573
573
  hue: spec?.hue ?? 210,
574
574
  desc: spec?.desc ?? '',
575
- hasYatsu: tl.hasYatsu,
576
- uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
575
+ hasEvalFile: tl.hasEvalFile,
576
+ uncoveredFrontend: !tl.hasEvalFile && (spec?.code ?? []).some(isUiPath),
577
577
  scenarios: tl.scenarios,
578
578
  // the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
579
579
  // the event detail has the comment/remark track inline — no extra join here. A reading is the
@@ -27,6 +27,12 @@ export type Evidence = { hash: string; kind: EvidenceKind }
27
27
  export type Reading = {
28
28
  scenario: string
29
29
  codeSha: string
30
+ // content hash of the scenario's semantic text (description+expected, normalized — scenarios.ts
31
+ // scenarioHash) as DECLARED at filing time: the record of which contract this measurement was taken
32
+ // against. When present it alone decides the scenario freshness axis (a pure compare against the current
33
+ // declaration's hash); a legacy reading without it is decided by the git-derived rule instead — one
34
+ // track per reading, never both ([[eval-core]]).
35
+ scenarioHash?: string
30
36
  evidence?: Evidence[]
31
37
  // legacy scalar evidence — read for old readings, never written by new filings.
32
38
  blob?: string | null
@@ -103,7 +109,7 @@ export function applyRetractions(readings: Reading[], retractions: Retraction[])
103
109
  // the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
104
110
  // consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
105
111
  // retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
106
- // or the scenario honestly returns to yatsu-missing.
112
+ // or the scenario honestly returns to eval-missing.
107
113
  export function readReadings(sidecarPath: string): Reading[] {
108
114
  const { readings, retractions } = readSidecar(sidecarPath)
109
115
  return applyRetractions(readings, retractions)
@@ -22,7 +22,7 @@ const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
22
22
  const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
23
23
 
24
24
  // validate LOUD — every violation named, [] when well-formed. Both schema versions are accepted: v1 (legacy
25
- // time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the yatsu.md
25
+ // time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the eval.md
26
26
  // scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
27
27
  // string itself is open — only its ABSENCE is an error, never an unrecognized value.
28
28
  export function validateTimeline(raw: unknown): string[] {
@@ -1,10 +1,10 @@
1
1
  {
2
- "_note": "Fixture mirroring githubDriver output (the vendor-neutral ForgeIssue[]/ForgePR[] a `gh issue/pr list` collapses to) for a sample repo. Drives the needs-yatsu-eval resolver test with no network.",
2
+ "_note": "Fixture mirroring githubDriver output (the vendor-neutral ForgeIssue[]/ForgePR[] a `gh issue/pr list` collapses to) for a sample repo. Drives the needs-eval resolver test with no network.",
3
3
  "issues": [
4
4
  {
5
5
  "number": 1,
6
6
  "title": "login redirect is broken on /logout",
7
- "body": "Steps to repro: click logout, land on a 404.\n\nSpec: sessions\nneeds-yatsu-eval\n",
7
+ "body": "Steps to repro: click logout, land on a 404.\n\nSpec: sessions\nneeds-eval\n",
8
8
  "url": "https://github.com/acme/repo/issues/1",
9
9
  "state": "open",
10
10
  "labels": []
@@ -15,7 +15,7 @@
15
15
  "body": "The open-issue badge over-counts.\n\nSpec: dashboard-issues",
16
16
  "url": "https://github.com/acme/repo/issues/2",
17
17
  "state": "open",
18
- "labels": ["bug", "needs-yatsu-eval"]
18
+ "labels": ["bug", "needs-eval"]
19
19
  },
20
20
  {
21
21
  "number": 3,
@@ -27,8 +27,8 @@
27
27
  },
28
28
  {
29
29
  "number": 4,
30
- "title": "old freshness drift already verified",
31
- "body": "Spec: freshness\nNeeds-Yatsu-Eval\n",
30
+ "title": "old forge-cache drift already verified",
31
+ "body": "Spec: forge-cache\nNeeds-Eval\n",
32
32
  "url": "https://github.com/acme/repo/issues/4",
33
33
  "state": "closed",
34
34
  "labels": []
@@ -36,18 +36,18 @@
36
36
  {
37
37
  "number": 5,
38
38
  "title": "flagged but names no node",
39
- "body": "This one is flagged but carries no Spec: marker and no closing PR.\n\nneeds-yatsu-eval\n",
39
+ "body": "This one is flagged but carries no Spec: marker and no closing PR.\n\nneeds-eval\n",
40
40
  "url": "https://github.com/acme/repo/issues/5",
41
41
  "state": "open",
42
- "labels": ["needs-yatsu-eval"]
42
+ "labels": ["needs-eval"]
43
43
  },
44
44
  {
45
45
  "number": 6,
46
46
  "title": "fixed via an open PR, no marker on the issue",
47
- "body": "A worked bug; the node comes transitively from the closing PR.\n\nneeds-yatsu-eval: ignore-the-trailing-text",
47
+ "body": "A worked bug; the node comes transitively from the closing PR.\n\nneeds-eval: ignore-the-trailing-text",
48
48
  "url": "https://github.com/acme/repo/issues/6",
49
49
  "state": "open",
50
- "labels": ["needs-yatsu-eval"]
50
+ "labels": ["needs-eval"]
51
51
  }
52
52
  ],
53
53
  "prs": [
@@ -2,7 +2,7 @@ import { loadSpecs } from '../../spec-cli/src/specs.js'
2
2
  import type { ForgeDriver, ForgeIssue, ForgePR } from './port.js'
3
3
  import { FORGE_DRIVERS, forgeDriverFor, resolveForgeHost } from './drivers.js'
4
4
  import { resolveLinks, type NodeLinks } from './links.js'
5
- import { resolveEvalPending, type NodeEvalPending } from './needs-yatsu-eval.js'
5
+ import { resolveEvalPending, type NodeEvalPending } from './needs-eval.js'
6
6
 
7
7
  // tiny flag reader over this command's own arg slice (everything after `forge`), so cli.ts stays routing-only.
8
8
  function flag(args: string[], name: string): string | undefined {
@@ -14,10 +14,12 @@ const has = (args: string[], name: string) => args.includes(`--${name}`)
14
14
  async function readForge(
15
15
  args: string[],
16
16
  ): Promise<{ driver: ForgeDriver; nodeIds: string[]; issues: ForgeIssue[]; prs: ForgePR[] } | null> {
17
- const host = flag(args, 'host') ?? resolveForgeHost()
17
+ // the forge is a VALUE, never a command ([[cli-surface]]): `--store <host>` names it, mirroring the
18
+ // store axis every other issue verb uses.
19
+ const host = flag(args, 'store') ?? resolveForgeHost()
18
20
  const driver = forgeDriverFor(host)
19
21
  if (!driver) {
20
- console.error(`forge: unknown host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
22
+ console.error(`spex issue links: unknown --store '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
21
23
  return null
22
24
  }
23
25
  const nodeIds = (await loadSpecs()).map((s) => s.id)
@@ -49,7 +51,7 @@ async function links(args: string[]): Promise<number> {
49
51
 
50
52
  const only = flag(args, 'node')
51
53
  if (only) {
52
- if (!nodeIds.includes(only)) { console.error(`forge: no such node '${only}'`); return 1 }
54
+ if (!nodeIds.includes(only)) { console.error(`spex issue links: no such node '${only}'`); return 1 }
53
55
  resolved = resolved.filter((n) => n.node === only)
54
56
  }
55
57
 
@@ -58,7 +60,7 @@ async function links(args: string[]): Promise<number> {
58
60
  const nPRs = resolved.reduce((a, n) => a + n.prs.length, 0)
59
61
  console.log(
60
62
  `spec-forge · ${driver.host} · ${resolved.length} linked node(s) · ${nIssues} issue(s), ${nPRs} pr(s)` +
61
- ` · scanned ${issues.length} issue(s), ${prs.length} pr(s)`,
63
+ ` · traced ${issues.length} issue(s), ${prs.length} pr(s)`,
62
64
  )
63
65
  if (resolved.length) console.log(render(resolved))
64
66
  return 0
@@ -81,7 +83,7 @@ async function evalPending(args: string[]): Promise<number> {
81
83
 
82
84
  const only = flag(args, 'node')
83
85
  if (only) {
84
- if (!nodeIds.includes(only)) { console.error(`forge: no such node '${only}'`); return 1 }
86
+ if (!nodeIds.includes(only)) { console.error(`spex issue links: no such node '${only}'`); return 1 }
85
87
  resolved = resolved.filter((n) => n.node === only)
86
88
  }
87
89
 
@@ -89,16 +91,15 @@ async function evalPending(args: string[]): Promise<number> {
89
91
  const nPending = resolved.reduce((a, n) => a + n.pending.length, 0)
90
92
  console.log(
91
93
  `spec-forge · ${driver.host} · ${resolved.length} node(s) with eval pending · ${nPending} issue(s)` +
92
- ` · scanned ${issues.length} issue(s), ${prs.length} pr(s)`,
94
+ ` · traced ${issues.length} issue(s), ${prs.length} pr(s)`,
93
95
  )
94
96
  if (resolved.length) console.log(renderPending(resolved))
95
97
  return 0
96
98
  }
97
99
 
98
- export async function runForge(args: string[]): Promise<number> {
99
- const sub = args[0]
100
- if (sub === 'links') return links(args.slice(1))
101
- if (sub === 'eval-pending') return evalPending(args.slice(1))
102
- console.error('spex forge: links [--host github] [--node <id>] [--json] | eval-pending [--host github] [--node <id>] [--json]')
103
- return 2
100
+ // `spex issue links [--pending]` ([[cli-surface]]): the read-only forge→spec trace, folded into the issue
101
+ // drawer (the forge drawer is dissolved — a forge is a value, `--store`). Bare = every linked node's open
102
+ // issues/PRs; --pending narrows to the threads still awaiting an eval reading (the old eval-pending view).
103
+ export async function runIssueLinks(args: string[]): Promise<number> {
104
+ return has(args, 'pending') ? evalPending(args) : links(args)
104
105
  }
@@ -1,13 +1,13 @@
1
1
  import type { ForgeIssue, ForgePR } from './port.js'
2
2
  import { resolveLinks, type LinkedIssue } from './links.js'
3
3
 
4
- export const NEEDS_YATSU_EVAL = 'needs-yatsu-eval'
4
+ export const NEEDS_EVAL = 'needs-eval'
5
5
 
6
- // a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-yatsu-eval: foo`) is NOT a match
7
- const BODY_MARKER = new RegExp(`^\\s*${NEEDS_YATSU_EVAL}\\s*:?\\s*$`, 'im')
6
+ // a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-eval: foo`) is NOT a match
7
+ const BODY_MARKER = new RegExp(`^\\s*${NEEDS_EVAL}\\s*:?\\s*$`, 'im')
8
8
 
9
- export function isNeedsYatsuEval(issue: ForgeIssue): boolean {
10
- if (issue.labels.some((l) => l.trim().toLowerCase() === NEEDS_YATSU_EVAL)) return true
9
+ export function isNeedsEval(issue: ForgeIssue): boolean {
10
+ if (issue.labels.some((l) => l.trim().toLowerCase() === NEEDS_EVAL)) return true
11
11
  return BODY_MARKER.test(issue.body || '')
12
12
  }
13
13
 
@@ -19,7 +19,7 @@ export function resolveEvalPending(
19
19
  prs: ForgePR[],
20
20
  nodeIds: string[],
21
21
  ): NodeEvalPending[] {
22
- const flagged = new Set(issues.filter(isNeedsYatsuEval).map((i) => i.number))
22
+ const flagged = new Set(issues.filter(isNeedsEval).map((i) => i.number))
23
23
  if (!flagged.size) return []
24
24
  const out: NodeEvalPending[] = []
25
25
  for (const { node, issues: linked } of resolveLinks(issues, prs, nodeIds)) {