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
@@ -2,8 +2,8 @@ import { relative, dirname } from 'node:path'
2
2
  import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
3
3
  import { loadSpecs } from '../../spec-cli/src/specs.js'
4
4
  import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
5
- import { yatsuNodes, type YatsuNode } from './yatsu.js'
6
- import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
5
+ import { evalNodes, type EvalNode } from './scenarios.js'
6
+ import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
7
7
  import { staleAxes, codeDrift, contentProbeFor, type StaleAxis } from './freshness.js'
8
8
  import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
9
9
  import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
@@ -18,7 +18,7 @@ export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present'
18
18
  // per-reading display attachment.
19
19
  export type RemarkView = {
20
20
  rid: string
21
- ref: string // `<thread-id>#<rid>` — the address `spex resolve`/`spex retract` take
21
+ ref: string // `<thread-id>#<rid>` — the address `spex remark resolve`/`spex remark retract` take
22
22
  by: string
23
23
  at: string
24
24
  body: string
@@ -53,7 +53,7 @@ export type EvalEntry = {
53
53
  fresh: boolean
54
54
  staleAxes: StaleAxis[]
55
55
  blobState: 'present' | 'miss' | 'none'
56
- // the code axis's drift detail for a code-stale reading ([[yatsu-core]]'s codeDrift): each governed file
56
+ // the code axis's drift detail for a code-stale reading ([[eval-core]]'s codeDrift): each governed file
57
57
  // that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
58
58
  // ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
59
59
  codeDrift?: { file: string; behind: number }[]
@@ -64,6 +64,10 @@ export type EvalEntry = {
64
64
  // so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
65
65
  // splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
66
66
  thread?: Issue
67
+ // the human sign-off bound to THIS reading ([[human-ok]]) — present only on the exact reading an ok row
68
+ // anchors (by scenario + ts), so a newer reading arrives unblessed and the feed's hide releases itself.
69
+ // Rides the board fold verbatim (latestPerScenario is a filter, never a projection).
70
+ humanOk?: { by: string; ts: string }
67
71
  }
68
72
 
69
73
  // a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
@@ -89,16 +93,16 @@ export type DanglingTrack = { scenario: string; threadId: string; thread: Issue;
89
93
 
90
94
  export type ScenarioInfo = { name: string; expected: string; tags?: string[]; code?: string[] }
91
95
 
92
- // `hasYatsu` distinguishes a node that declares no scenarios (no yatsu.md) from one that declares some but
96
+ // `hasEvalFile` distinguishes a node that declares no scenarios (no eval.md) from one that declares some but
93
97
  // has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
94
98
  // is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
95
99
  // like the history tab).
96
100
  export type EvalTimeline = {
97
101
  node: string
98
- hasYatsu: boolean
102
+ hasEvalFile: boolean
99
103
  scenarios: ScenarioInfo[]
100
104
  readings: EvalEntry[]
101
- // retraction events ([[yatsu-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
105
+ // retraction events ([[eval-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
102
106
  // above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
103
107
  // this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
104
108
  // by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
@@ -115,13 +119,13 @@ export type EvalContext = {
115
119
  idx: DriftIndex
116
120
  hidx: HistoryIndex
117
121
  scidx: ScenarioIndex // per-scenario block-change history ([[scenariofresh]]) — the SCENARIO axis, built ONCE per build
118
- ynodes: YatsuNode[]
122
+ ynodes: EvalNode[]
119
123
  // the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
120
124
  // and reused for every node, so the fold never re-reads the issue store per node.
121
125
  remarks: Map<string, RemarkTrack>
122
126
  }
123
127
 
124
- // build the shared context with ONE yatsu walk, reusing the caller's already-computed specs + the two
128
+ // build the shared context with ONE eval-file walk, reusing the caller's already-computed specs + the two
125
129
  // HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
126
130
  // hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
127
131
  // ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
@@ -131,28 +135,28 @@ export async function evalContext(
131
135
  idx: DriftIndex,
132
136
  hidx: HistoryIndex,
133
137
  remarks?: Map<string, RemarkTrack>,
134
- ynodes?: YatsuNode[], // the hot board build precomputes these off the event loop (yatsuNodesAsync); a bare caller walks sync
138
+ ynodes?: EvalNode[], // the hot board build precomputes these off the event loop (evalNodesAsync); a bare caller walks sync
135
139
  ): Promise<EvalContext> {
136
- const nodes = ynodes ?? yatsuNodes(root)
137
- const scidx = await scenarioIndex(root, nodes.map((n) => n.yatsuPath))
140
+ const nodes = ynodes ?? evalNodes(root)
141
+ const scidx = await scenarioIndex(root, nodes.map((n) => n.evalPath))
138
142
  return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
139
143
  }
140
144
 
141
145
  export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
142
146
  const root = ctx?.root ?? repoRoot()
143
- // short-circuit a non-yatsu node on the (short) yatsu walk — the board attaches `evals` to every node, so
144
- // this is the common case and must stay cheap (a list the size of the few yatsu nodes, not the whole tree).
145
- const ynode = (ctx?.ynodes ?? yatsuNodes(root)).find((n) => n.id === id)
146
- if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [], retractions: [], dangling: [] }
147
+ // short-circuit a non-measurable node on the (short) eval-file walk — the board attaches `evals` to every node, so
148
+ // this is the common case and must stay cheap (a list the size of the few measurable nodes, not the whole tree).
149
+ const ynode = (ctx?.ynodes ?? evalNodes(root)).find((n) => n.id === id)
150
+ if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
147
151
  // the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
148
- // reparent/rename is seen the same way `spex lint` and `spex yatsu eval` see it (joined by directory).
152
+ // reparent/rename is seen the same way `spex spec lint` and `spex eval add` see it (joined by directory).
149
153
  const specs = ctx?.specs ?? await loadSpecs()
150
154
  const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
151
155
  const idx = ctx?.idx ?? await driftIndex(root)
152
156
  const hidx = ctx?.hidx ?? await historyIndex(root)
153
157
  // the SCENARIO axis: per-scenario block-change history, built once per HEAD (cached). A bare call builds it
154
- // for the WHOLE yatsu set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
155
- const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? yatsuNodes(root)).map((n) => n.yatsuPath))
158
+ // for the WHOLE measurable set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
159
+ const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? evalNodes(root)).map((n) => n.evalPath))
156
160
  const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
157
161
  // the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
158
162
  const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
@@ -163,9 +167,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
163
167
  ...(s.tags?.length ? { tags: s.tags } : {}), ...(s.code?.length ? { code: s.code } : {}),
164
168
  }))
165
169
  // one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
166
- // along as the undo trace (newest-first, like the readings).
167
- const { readings: rawReadings, retractions } = readSidecar(ynode.sidecarPath)
168
- // the off-history content fallback ([[yatsu-core]]): fed to both git axes so a rebased/folded-away
170
+ // along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
171
+ const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
172
+ // the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
169
173
  // anchor with byte-identical governed content reads fresh. Lazy — an in-history reading never probes.
170
174
  const probe = contentProbeFor(root)
171
175
  const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
@@ -175,14 +179,17 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
175
179
  // remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
176
180
  // read-time overlay below; freshness never depends on that pin.
177
181
  const cf = sc?.code?.length ? sc.code : codeFiles
178
- const axes = staleAxes(r, cf, ynode.yatsuPath, idx, scidx,
179
- remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe)
182
+ const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
183
+ remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc)
180
184
  // when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
181
185
  const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
182
186
  // the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
183
187
  // first) drives the scalar compat fields for single-evidence consumers.
184
188
  const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
185
189
  const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
190
+ // the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
191
+ // human blessed carries it — a newer or retract-revealed reading reads unblessed.
192
+ const okRow = humanOkFor(oks, r.scenario, r.ts)
186
193
  return {
187
194
  scenario: r.scenario,
188
195
  expected: byName.get(r.scenario)?.expected ?? '',
@@ -200,6 +207,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
200
207
  ...(drift.length ? { codeDrift: drift } : {}),
201
208
  blobState: primary ? primary.state : 'none',
202
209
  ...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
210
+ ...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
203
211
  }
204
212
  })
205
213
  readings.reverse() // newest-first
@@ -215,7 +223,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
215
223
  const rows = readings.filter((r) => r.scenario === track.scenario)
216
224
  if (!rows.length) {
217
225
  // no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
218
- // not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from yatsu.md AND
226
+ // not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from eval.md AND
219
227
  // has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
220
228
  if (!declared.has(track.scenario)) {
221
229
  dangling.push({
@@ -232,7 +240,7 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
232
240
  ;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
233
241
  }
234
242
  }
235
- return { node: id, hasYatsu: true, scenarios, readings, retractions: [...retractions].reverse(), dangling }
243
+ return { node: id, hasEvalFile: true, scenarios, readings, retractions: [...retractions].reverse(), dangling }
236
244
  }
237
245
 
238
246
  const HEX64 = /^[0-9a-f]{64}$/
@@ -242,7 +250,7 @@ export type BlobResult =
242
250
  | { ok: false; reason: 'invalid' | 'miss'; message: string }
243
251
 
244
252
  export function readBlobByHash(hash: string, dir?: string): BlobResult {
245
- if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad blob hash' }
253
+ if (!HEX64.test(hash)) return { ok: false, reason: 'invalid', message: 'bad evidence hash' }
246
254
  const bytes = getBlob(hash, dir) // undefined dir → the live cache (cache.ts default); a temp dir in tests
247
255
  if (!bytes) return { ok: false, reason: 'miss', message: MISS_BLOB }
248
256
  return { ok: true, bytes, mime: sniffBlobMime(bytes) }
@@ -1,5 +1,5 @@
1
1
  import { repoRoot, headSha } from '../../spec-cli/src/git.js'
2
- import { yatsuNodes, resolveYatsuNode } from './yatsu.js'
2
+ import { evalNodes, resolveEvalNode, scenarioHash } from './scenarios.js'
3
3
  import { appendReading, readReadings, isJsonBlob, type Reading, type EvidenceKind } from './sidecar.js'
4
4
  import { putBlob } from './cache.js'
5
5
 
@@ -7,7 +7,7 @@ export type FileResult = { ok: true; reading: Reading } | { ok: false; error: st
7
7
 
8
8
  // the eval seam over DATA (no argv, no file paths): a caller with a verdict in hand — the HTTP eval route,
9
9
  // a programmatic filer — appends through the SAME seam the CLI uses. Optional evidence arrives as text (a
10
- // report referencing the clip by hash) → a transcript blob in the same content-addressed cache. yatsu
10
+ // report referencing the clip by hash) → a transcript blob in the same content-addressed cache. eval
11
11
  // still runs nothing — this only records. The dashboard files nothing through this: [[event-detail]] is
12
12
  // read-side on readings.
13
13
  export function fileHumanReading(
@@ -15,9 +15,9 @@ export function fileHumanReading(
15
15
  input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
16
16
  ): FileResult {
17
17
  const root = repoRoot()
18
- // the same loud resolution the CLI applies ([[yatsu-core]]): exact canonical id, else a unique bare
18
+ // the same loud resolution the CLI applies ([[eval-core]]): exact canonical id, else a unique bare
19
19
  // leaf; an ambiguous leaf returns the candidate list instead of filing against an arbitrary node.
20
- const res = resolveYatsuNode(yatsuNodes(root), nodeId)
20
+ const res = resolveEvalNode(evalNodes(root), nodeId)
21
21
  if (!res.ok) return { ok: false, error: res.error }
22
22
  const node = res.node
23
23
  const sc = node.scenarios.find((s) => s.name === input.scenario)
@@ -30,6 +30,8 @@ export function fileHumanReading(
30
30
  const reading: Reading = {
31
31
  scenario: sc.name,
32
32
  codeSha: headSha(root),
33
+ // the contract this measurement was taken against — the HTTP filer stamps it exactly like the CLI
34
+ scenarioHash: scenarioHash(sc),
33
35
  ...(blob ? { evidence: [{ hash: blob, kind: (buf && isJsonBlob(buf) ? 'data' : 'transcript') as EvidenceKind }] } : {}),
34
36
  // the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
35
37
  // there and the eval-comment loop-in is silent, per [[mentions]])
@@ -48,7 +50,7 @@ export function fileHumanReading(
48
50
  // in-flight reading (filed on an unmerged branch) is invisible to the trunk sidecar — exactly the
49
51
  // review-time case where the remark must still reach its filer ([[remark-polish]] strand 2).
50
52
  export function evalReadingFiler(nodeId: string, scenario: string, root: string = repoRoot()): string | null {
51
- const res = resolveYatsuNode(yatsuNodes(root), nodeId)
53
+ const res = resolveEvalNode(evalNodes(root), nodeId)
52
54
  if (!res.ok) return null
53
55
  const forScenario = readReadings(res.node.sidecarPath).filter((r) => r.scenario === scenario)
54
56
  return forScenario.length ? forScenario[forScenario.length - 1].by ?? null : null
@@ -1,5 +1,6 @@
1
1
  import { git, headSha, ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
2
2
  import type { Reading } from './sidecar.js'
3
+ import { scenarioHash, type Scenario } from './scenarios.js'
3
4
  import { scenarioChangeCommits, scenarioBlocksAt, type ScenarioIndex } from './scenariofresh.js'
4
5
 
5
6
  // the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
@@ -18,7 +19,7 @@ export type ContentProbe = {
18
19
  // paths whose content differs between the anchor commit's tree and HEAD's; null = anchor object gone
19
20
  changedPaths(anchorSha: string): Set<string> | null
20
21
  // did THIS scenario's semantic block (description+expected) move between anchor and HEAD ([[scenariofresh]])
21
- scenarioDiffers(anchorSha: string, yatsuPath: string, scenario: string): boolean
22
+ scenarioDiffers(anchorSha: string, evalPath: string, scenario: string): boolean
22
23
  // codeDrift's display detail: commits in anchor..HEAD touching path (floored at 1 — the content differs)
23
24
  behind(anchorSha: string, path: string): number
24
25
  }
@@ -50,10 +51,10 @@ export function contentProbeFor(root: string): ContentProbe {
50
51
  } catch { return null } // the anchor commit object is gone — content can't testify
51
52
  })
52
53
  },
53
- scenarioDiffers(sha, yatsuPath, scenario) {
54
- const a = scenarioBlocksAt(root, sha, yatsuPath)
55
- if (!a) return true // yatsu.md unreadable at the anchor (renamed/absent) → can't prove → stale
56
- return a.get(scenario) !== scenarioBlocksAt(root, headOf(), yatsuPath)?.get(scenario)
54
+ scenarioDiffers(sha, evalPath, scenario) {
55
+ const a = scenarioBlocksAt(root, sha, evalPath)
56
+ if (!a) return true // eval.md unreadable at the anchor (renamed/absent) → can't prove → stale
57
+ return a.get(scenario) !== scenarioBlocksAt(root, headOf(), evalPath)?.get(scenario)
57
58
  },
58
59
  behind(sha, path) {
59
60
  return memo(behindMemo, `${root}\x1f${sha}\x1f${headOf()}\x1f${path}`, () => {
@@ -112,39 +113,59 @@ export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[]
112
113
  return out
113
114
  }
114
115
 
115
- // scenario freshness is PER-SCENARIO and SEMANTIC, not per-file: a reading stales only when ITS OWN
116
- // scenario's semantic block (description+expected [[scenariofresh]]'s blockContent projection) moved in
117
- // scenarioSha..HEAD never when a sibling in the same yatsu.md did, and never on a metadata-only edit
118
- // (tags/test/code/related). Reads exactly like the code axis's changedSince the per-scenario
119
- // change-commits ([[scenariofresh]], rename-followed so a bare git-mv reparent isn't a change) tested for
120
- // ancestryand an off-history codeSha takes the same content fallback at the same granularity and the
121
- // same projection: an unchanged yatsu.md clears it outright, a changed one stales only if THIS scenario's
122
- // semantic block differs between the anchor and HEAD.
123
- function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, yatsuPath: string, scenario: string, probe?: ContentProbe): boolean {
116
+ // @@@scenario axis decides by stored contract hash - a reading filed since #61 carries `scenarioHash`, the
117
+ // content hash of the semantic text it was measured against (scenarios.ts scenarioHash — normalized
118
+ // description+expected). For such a reading the scenario axis is a PURE TEXT COMPARE: recorded hash vs the
119
+ // CURRENT declaration's hash no git walk, no chain, no ancestry. That is what makes it converge under
120
+ // fleet-parallel filing+merging: a sibling scenario's edit, a sidecar-only commit, a merge's textual shift,
121
+ // a whitespace re-wrap none of them move THIS scenario's hash, so none can re-stale its reading; only the
122
+ // contract actually changing (or the scenario disappearing from eval.md `current` undefined) does. A
123
+ // LEGACY reading without the hash is decided by the git-derived per-scenario rule below, unchanged — the
124
+ // one-shot degradation: exactly ONE track decides each reading (hash if present, else git), never both
125
+ // OR-ed together.
126
+ function scenarioStaleByHash(reading: Reading, current: Scenario | undefined): boolean | undefined {
127
+ if (reading.scenarioHash === undefined) return undefined // legacy → the git rule decides
128
+ return current ? scenarioHash(current) !== reading.scenarioHash : true
129
+ }
130
+
131
+ // LEGACY scenario freshness (readings filed before the stored contract hash) — PER-SCENARIO and SEMANTIC,
132
+ // not per-file: a reading stales only when ITS OWN scenario's semantic block (description+expected —
133
+ // [[scenariofresh]]'s blockContent projection) moved in scenarioSha..HEAD — never when a sibling in the
134
+ // same eval.md did, and never on a metadata-only edit (tags/test/code/related). Reads like the code axis's
135
+ // changedSince — the per-scenario change-commits ([[scenariofresh]], rename-followed so a bare git-mv
136
+ // reparent isn't a change) tested for ancestry — and an off-history codeSha takes the same content fallback
137
+ // at the same granularity and the same projection. Known limit (#61, why the hash replaced it for new
138
+ // readings): the change-commit chain is built off a LINEARIZED log walk, so parallel branches editing one
139
+ // eval.md cross-attribute each other's edits and can false-stale a sibling's reading across a merge.
140
+ function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, evalPath: string, scenario: string, probe?: ContentProbe): boolean {
124
141
  const anc = ancestorsOf(didx, sinceSha)
125
- if (anc) return scenarioChangeCommits(scIdx, yatsuPath, scenario).some((h) => !inAncestors(didx, anc, h))
142
+ if (anc) return scenarioChangeCommits(scIdx, evalPath, scenario).some((h) => !inAncestors(didx, anc, h))
126
143
  const diff = probe?.changedPaths(sinceSha)
127
144
  if (!diff) return true
128
- if (!diff.has(yatsuPath)) return false // whole file byte-identical → this block too
129
- return probe!.scenarioDiffers(sinceSha, yatsuPath, scenario)
145
+ if (!diff.has(evalPath)) return false // whole file byte-identical → this block too
146
+ return probe!.scenarioDiffers(sinceSha, evalPath, scenario)
130
147
  }
131
148
 
132
149
  export function staleAxes(
133
150
  reading: Reading,
134
151
  codeFiles: string[],
135
- yatsuPath: string,
152
+ evalPath: string,
136
153
  didx: DriftIndex,
137
154
  scIdx: ScenarioIndex,
138
155
  remarks: RemarkSignal[] = [],
139
156
  probe?: ContentProbe,
157
+ current?: Scenario, // the scenario's CURRENT declaration (undefined = gone from eval.md) — the hash compare's other side
140
158
  ): StaleAxis[] {
141
159
  const axes: StaleAxis[] = []
160
+ const byHash = scenarioStaleByHash(reading, current)
142
161
  if (probe && !ancestorsOf(didx, reading.codeSha) && probe.changedPaths(reading.codeSha) === null) {
143
- // the anchor commit object is GONE — neither git axis can testify; say that, not "content changed"
162
+ // the anchor commit object is GONE — neither git axis can testify; say that, not "content changed".
163
+ // The stored contract hash needs no anchor, so it still decides the scenario axis when present.
144
164
  axes.push('anchor')
165
+ if (byHash) axes.push('scenario')
145
166
  } else {
146
167
  if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f, probe))) axes.push('code')
147
- if (scenarioMoved(scIdx, didx, reading.codeSha, yatsuPath, reading.scenario, probe)) axes.push('scenario')
168
+ if (byHash ?? scenarioMoved(scIdx, didx, reading.codeSha, evalPath, reading.scenario, probe)) axes.push('scenario')
148
169
  }
149
170
  if (remarkStale(reading, remarks)) axes.push('remark')
150
171
  return axes
@@ -153,11 +174,12 @@ export function staleAxes(
153
174
  export function isStale(
154
175
  reading: Reading,
155
176
  codeFiles: string[],
156
- yatsuPath: string,
177
+ evalPath: string,
157
178
  didx: DriftIndex,
158
179
  scIdx: ScenarioIndex,
159
180
  remarks: RemarkSignal[] = [],
160
181
  probe?: ContentProbe,
182
+ current?: Scenario,
161
183
  ): boolean {
162
- return staleAxes(reading, codeFiles, yatsuPath, didx, scIdx, remarks, probe).length > 0
184
+ return staleAxes(reading, codeFiles, evalPath, didx, scIdx, remarks, probe, current).length > 0
163
185
  }
@@ -0,0 +1,43 @@
1
+ import { relative } from 'node:path'
2
+ import { repoRoot } from '../../spec-cli/src/git.js'
3
+ import { commitTrunkData } from '../../spec-cli/src/localIssues.js'
4
+ import { evalNodes, resolveEvalNode } from './scenarios.js'
5
+ import { readReadings, readSidecar, appendHumanOk, humanOkFor, type HumanOk } from './sidecar.js'
6
+
7
+ // @@@ human-ok - the human sign-off on an eval reading ([[human-ok]]). One write, both surfaces: the CLI
8
+ // (`spex eval ok`) and the HTTP route (`POST /api/specs/:id/evals/ok`, identity server-derived 'human')
9
+ // call THIS. The ok binds to ONE immutable reading — the scenario's latest effective reading at ok-time,
10
+ // anchored by its (ts, codeSha) — and is MONOTONIC: no un-ok verb exists, because a newer reading is a
11
+ // different object the ok never transfers to, and staleness is computed live; both bring the scenario back
12
+ // on their own. Durability follows the checkout: on the trunk checkout the append is committed straight to
13
+ // trunk (`--no-verify`, path-scoped, under the shared store lock — the [[local-issues]] discipline,
14
+ // commitTrunkData); on a linked worktree the append stays in that tree and the session's own ritual commit
15
+ // carries it, exactly like every other sidecar write.
16
+ export type OkResult =
17
+ | { ok: true; humanOk: HumanOk; already: boolean; landed: 'committed' | 'uncommitted' }
18
+ | { ok: false; error: string }
19
+
20
+ export function fileHumanOk(nodeId: string, scenario: string, by: string): OkResult {
21
+ const root = repoRoot()
22
+ // the same loud resolution every eval verb applies ([[eval-core]]): exact canonical id, else a unique
23
+ // bare leaf; an ambiguous leaf returns the candidate list instead of blessing an arbitrary node.
24
+ const res = resolveEvalNode(evalNodes(root), nodeId)
25
+ if (!res.ok) return { ok: false, error: res.error }
26
+ const node = res.node
27
+ if (!node.scenarios.some((s) => s.name === scenario) &&
28
+ !readSidecar(node.sidecarPath).readings.some((r) => r.scenario === scenario))
29
+ return { ok: false, error: `'${node.id}' has no scenario '${scenario}'` }
30
+ // the ok's one possible target: the latest EFFECTIVE reading — an ok is a judgment on a measurement that
31
+ // exists and currently counts; an unmeasured (or fully-retracted) scenario has nothing to bless.
32
+ const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
33
+ if (!forScenario.length) return { ok: false, error: `'${node.id}' scenario '${scenario}' has no effective reading — nothing to ok` }
34
+ const latest = forScenario[forScenario.length - 1]
35
+ // a duplicate ok is idempotent success (the store already IS the requested state — the local-issue
36
+ // close's `already` semantics), never an error and never a second appended row.
37
+ const existing = humanOkFor(readSidecar(node.sidecarPath).oks, scenario, latest.ts)
38
+ if (existing) return { ok: true, humanOk: existing, already: true, landed: 'committed' }
39
+ const row: HumanOk = { kind: 'human-ok', scenario, okTs: latest.ts, okSha: latest.codeSha, by, ts: new Date().toISOString() }
40
+ appendHumanOk(node.sidecarPath, row)
41
+ const landed = commitTrunkData(relative(root, node.sidecarPath), `eval(${node.id}): human-ok '${scenario}' @ ${latest.ts} by ${by}`)
42
+ return { ok: true, humanOk: row, already: false, landed: landed === 'not-primary' ? 'uncommitted' : 'committed' }
43
+ }
@@ -1,11 +1,11 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { git, gitA, headSha } from '../../spec-cli/src/git.js'
3
- import { parseScenarios } from './yatsu.js'
3
+ import { parseScenarios } from './scenarios.js'
4
4
 
5
5
  // @@@ per-scenario content freshness — the SCENARIO axis, sub-file
6
- // A yatsu.md holds many scenarios, but a reading measures ONE. yatsu-core's contract says "a scenario is the
6
+ // An eval.md holds many scenarios, but a reading measures ONE. eval-core's contract says "a scenario is the
7
7
  // unit of measurement, so its freshness is its OWN — two scenarios stale independently." Git has no sub-file
8
- // history, so we build it: for each scenario NAME in a yatsu.md, the commits where THAT scenario's SEMANTIC
8
+ // history, so we build it: for each scenario NAME in an eval.md, the commits where THAT scenario's SEMANTIC
9
9
  // block content (description + expected — see blockContent) changed (added / removed / edited),
10
10
  // rename-followed. `scenarioMoved` then reads exactly like the code axis's `changedSince` — a pure ancestry
11
11
  // lookup over this per-scenario commit list — so editing one scenario never re-stales its siblings (the
@@ -13,7 +13,7 @@ import { parseScenarios } from './yatsu.js'
13
13
 
14
14
  const RS = '\x1e'
15
15
 
16
- // yatsuPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
16
+ // evalPath (head path) -> scenario name -> commit hashes that changed that scenario's block (newest-first)
17
17
  export type ScenarioIndex = Map<string, Map<string, string[]>>
18
18
 
19
19
  // the block content that stales a reading: the scenario's SEMANTIC fields only — description (what to
@@ -42,7 +42,7 @@ const EMPTY: Map<string, string> = new Map()
42
42
  // already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
43
43
  const blockByOid = new Map<string, Map<string, string>>()
44
44
 
45
- // ONE `git log --raw` walk over all yatsu.md → per head-path version chain [{hash, oid}], newest-first,
45
+ // ONE `git log --raw` walk over all eval.md → per head-path version chain [{hash, oid}], newest-first,
46
46
  // rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
47
47
  // an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
48
48
  // no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
@@ -53,8 +53,13 @@ const blockByOid = new Map<string, Map<string, string>>()
53
53
  async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
54
54
  const chains = new Map<string, { hash: string; oid: string }[]>()
55
55
  const alias = new Map<string, string>()
56
+ // DUAL pathspec — the live name AND the archived one ([[eval-core]]): this walk reads IMMUTABLE history,
57
+ // and pre-rename commits touched files literally named yatsu.md, so an archive answers only to its
58
+ // archive name. A single '*eval.md' pathspec truncates every chain at the yatsu.md→eval.md rename commit,
59
+ // making the rename read as each scenario's birth — a change-commit newer than every pre-rename reading's
60
+ // codeSha — and false-stales whole adopter corpora (the scenariofresh.test.ts regression pins this).
56
61
  const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
57
- '--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*yatsu.md'])
62
+ '--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*eval.md', '*yatsu.md']) // dead-words-ok: archived pathspec — immutable pre-rename history is read under its archived name
58
63
  for (const rec of out.split(RS)) {
59
64
  const nl = rec.indexOf('\n')
60
65
  if (nl < 0) continue
@@ -115,7 +120,7 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
115
120
  p.on('close', (_code, signal) => {
116
121
  // a child that never exited was SIGKILLed at the timeout (a hung git must not pin this promise —
117
122
  // same bound as git.ts's helpers); warn loudly and parse whatever arrived.
118
- if (signal === 'SIGKILL') console.warn(`spec-yatsu: git cat-file --batch killed after timeout — child never exited`)
123
+ if (signal === 'SIGKILL') console.warn(`spec-eval: git cat-file --batch killed after timeout — child never exited`)
119
124
  const buf = Buffer.concat(chunks)
120
125
  let i = 0
121
126
  while (i < buf.length) {
@@ -139,8 +144,8 @@ function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>
139
144
 
140
145
  // TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
141
146
  // chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
142
- async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
143
- const chains = await fileChains(root, new Set(yatsuPaths))
147
+ async function build(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
148
+ const chains = await fileChains(root, new Set(evalPaths))
144
149
  const need = new Set<string>()
145
150
  for (const chain of chains.values()) for (const v of chain) if (v.oid !== ZERO && !blockByOid.has(v.oid)) need.add(v.oid)
146
151
  if (need.size) {
@@ -148,7 +153,7 @@ async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex>
148
153
  for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
149
154
  }
150
155
  const idx: ScenarioIndex = new Map()
151
- for (const p of yatsuPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
156
+ for (const p of evalPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
152
157
  return idx
153
158
  }
154
159
 
@@ -156,20 +161,20 @@ async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex>
156
161
  // whatever the caller. Holds the in-flight promise so concurrent board builds share one build.
157
162
  const SLOTS = 16
158
163
  const cache = new Map<string, Promise<ScenarioIndex>>()
159
- export function scenarioIndex(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
164
+ export function scenarioIndex(root: string, evalPaths: string[]): Promise<ScenarioIndex> {
160
165
  let head: string
161
- try { head = headSha(root) } catch { return build(root, yatsuPaths) }
166
+ try { head = headSha(root) } catch { return build(root, evalPaths) }
162
167
  const hit = cache.get(head)
163
168
  if (hit) { cache.delete(head); cache.set(head, hit); return hit }
164
- const p = build(root, yatsuPaths)
169
+ const p = build(root, evalPaths)
165
170
  p.catch(() => cache.delete(head))
166
171
  cache.set(head, p)
167
172
  while (cache.size > SLOTS) cache.delete(cache.keys().next().value!)
168
173
  return p
169
174
  }
170
175
 
171
- export function scenarioChangeCommits(idx: ScenarioIndex, yatsuPath: string, scenario: string): string[] {
172
- return idx.get(yatsuPath)?.get(scenario) ?? []
176
+ export function scenarioChangeCommits(idx: ScenarioIndex, evalPath: string, scenario: string): string[] {
177
+ return idx.get(evalPath)?.get(scenario) ?? []
173
178
  }
174
179
 
175
180
  // (root, sha, path) -> the blob oid at that commit ('' = unresolvable: path absent there, or the commit
@@ -194,7 +199,7 @@ function oidAt(root: string, rev: string, path: string): string {
194
199
  }
195
200
 
196
201
  // canonical per-scenario SEMANTIC blocks of `rev:path` (the blockContent projection), for the off-history
197
- // content fallback ([[yatsu-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
202
+ // content fallback ([[eval-core]]'s ContentProbe): resolve the blob oid first (memoized above for a full
198
203
  // sha) — oids are content-addressed, so an unchanged file usually hits blockByOid straight from the index
199
204
  // build — and parse only on a genuine miss. null = the path is unreadable at that rev (absent, renamed
200
205
  // since, or the rev itself is gone).
@@ -204,7 +209,7 @@ export function scenarioBlocksAt(root: string, rev: string, path: string): Map<s
204
209
  const hit = blockByOid.get(oid)
205
210
  if (hit) return hit
206
211
  try {
207
- const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid]))
212
+ const m = blockContent(git(['-C', root, 'cat-file', 'blob', oid])) // dead-words-ok: git plumbing — 'blob' is git's object type, not our vocabulary
208
213
  blockByOid.set(oid, m)
209
214
  return m
210
215
  } catch { return null }