spexcode 0.2.7 → 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.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +50 -24
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/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 {
|
|
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
|
|
32
|
+
type ScoredNode = EvalNode & { codeFiles: string[] }
|
|
33
33
|
|
|
34
|
-
async function gatherNodes(root: string): Promise<
|
|
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
|
|
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
|
|
57
|
-
// the general `sourceExtensions` knob instead (see the `
|
|
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 /
|
|
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
|
-
//
|
|
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 ([[
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
118
|
+
for (const e of validateScenarios(readFileSync(join(y.dir, EVAL_FILE), 'utf8'), cfg.scenarioTags)) {
|
|
119
119
|
malformed++
|
|
120
|
-
findings.push(` •
|
|
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(` •
|
|
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
|
|
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(` •
|
|
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.
|
|
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
|
|
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(` •
|
|
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
|
-
//
|
|
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(` •
|
|
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(` •
|
|
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(` •
|
|
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
|
|
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
|
|
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
|
|
234
|
-
// resolve LOUD ([[
|
|
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 =
|
|
237
|
-
if (!res.ok) { console.error(`spex
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
323
|
+
const dirty = dirtyGoverned(root, [...(scenario.code?.length ? scenario.code : node.codeFiles), node.evalPath])
|
|
322
324
|
if (dirty.length) {
|
|
323
|
-
console.error(` ⚠ mis-anchored
|
|
324
|
-
console.error(` this
|
|
325
|
-
console.error(` the honest flow: measure on the tree until green → commit that just-tested tree (code+spec) → THEN file the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
381
|
-
if (!res.ok) { console.error(`spex
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,9 @@ 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 (
|
|
424
|
-
console.log(` ⟲ '${id}' scenario '${scenario}'
|
|
425
|
-
console.log('spex
|
|
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)')
|
|
426
428
|
return 0
|
|
427
429
|
}
|
|
428
430
|
|
|
@@ -444,7 +446,7 @@ async function clean(args: string[]): Promise<number> {
|
|
|
444
446
|
const before = listBlobs().length
|
|
445
447
|
const removed = gc(referenced)
|
|
446
448
|
const mode = all ? 'all' : keepLatest ? 'keep-latest' : 'unreferenced'
|
|
447
|
-
console.log(`spex
|
|
449
|
+
console.log(`spex eval clean: removed ${removed.length} evidence file(s), kept ${before - removed.length} (${mode})`)
|
|
448
450
|
return 0
|
|
449
451
|
}
|
|
450
452
|
|
|
@@ -457,18 +459,18 @@ function checkStaged(): number {
|
|
|
457
459
|
const blobs = staged.filter(isStrayBlob)
|
|
458
460
|
if (blobs.length) {
|
|
459
461
|
bad = true
|
|
460
|
-
console.error('✗ SpexCode
|
|
462
|
+
console.error('✗ SpexCode eval: stray evidence file(s) staged — evidence lives in the shared git common dir, never in the tree:')
|
|
461
463
|
for (const o of blobs) console.error(` ${o}`)
|
|
462
|
-
console.error(' Unstage them (git rm --cached <path>);
|
|
464
|
+
console.error(' Unstage them (git rm --cached <path>); an eval references its evidence by hash, it never commits the bytes.')
|
|
463
465
|
}
|
|
464
466
|
|
|
465
|
-
for (const rel of staged.filter((p) => p ===
|
|
467
|
+
for (const rel of staged.filter((p) => p === EVAL_FILE || p.endsWith('/' + EVAL_FILE))) {
|
|
466
468
|
const abs = join(root, rel)
|
|
467
469
|
if (!existsSync(abs)) continue // staged deletion — nothing to validate
|
|
468
470
|
const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
|
|
469
471
|
if (!errs.length) continue
|
|
470
472
|
bad = true
|
|
471
|
-
console.error(`✗ SpexCode
|
|
473
|
+
console.error(`✗ SpexCode eval: ${rel} — invalid scenario schema:`)
|
|
472
474
|
for (const e of errs) console.error(` ${e}`)
|
|
473
475
|
}
|
|
474
476
|
|
|
@@ -479,12 +481,12 @@ async function show(args: string[]): Promise<number> {
|
|
|
479
481
|
const root = repoRoot()
|
|
480
482
|
const sel = positional(args)
|
|
481
483
|
const ref = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
482
|
-
if (!ref) { console.error('spex
|
|
484
|
+
if (!ref) { console.error('spex eval ls .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
483
485
|
// 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
|
|
485
|
-
//
|
|
486
|
-
const res =
|
|
487
|
-
if (!res.ok && res.ambiguous) { console.error(`spex
|
|
486
|
+
// fall through to a false "declares no scenarios". A ref matching NO measurable node still renders the honest
|
|
487
|
+
// hasEvalFile:false line — a spec node without an eval.md is not an error to look at.
|
|
488
|
+
const res = resolveEvalNode(evalNodes(root), ref)
|
|
489
|
+
if (!res.ok && res.ambiguous) { console.error(`spex eval ls: ${res.error}`); return 1 }
|
|
488
490
|
const tl = await evalTimeline(res.ok ? res.node.id : ref) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
|
|
489
491
|
if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
|
|
490
492
|
console.log(formatTimeline(tl))
|
|
@@ -501,12 +503,12 @@ function verdictText(v: Verdict | undefined): string {
|
|
|
501
503
|
}
|
|
502
504
|
|
|
503
505
|
export function formatTimeline(tl: EvalTimeline): string {
|
|
504
|
-
if (!tl.
|
|
506
|
+
if (!tl.hasEvalFile) return `spex eval ls: '${tl.node}' declares no scenarios (no eval.md)`
|
|
505
507
|
// the retraction trace, newest first — the undo stays visible through the same surface that shows readings.
|
|
506
508
|
const retractLines = (tl.retractions ?? []).map((x) =>
|
|
507
|
-
` ⟲ retracted: scenario '${x.scenario}'
|
|
509
|
+
` ⟲ retracted: scenario '${x.scenario}' eval @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
|
|
508
510
|
if (!tl.readings.length) {
|
|
509
|
-
const head = `spex
|
|
511
|
+
const head = `spex eval ls: '${tl.node}' has scenarios but no eval yet — run \`spex eval add ${tl.node}\``
|
|
510
512
|
return retractLines.length ? [head, '', ...retractLines].join('\n') : head
|
|
511
513
|
}
|
|
512
514
|
const w = Math.max(...tl.readings.map((r) => r.scenario.length))
|
|
@@ -522,60 +524,127 @@ export function formatTimeline(tl: EvalTimeline): string {
|
|
|
522
524
|
const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
|
|
523
525
|
return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
|
|
524
526
|
})
|
|
525
|
-
return [`spex
|
|
527
|
+
return [`spex eval ls: '${tl.node}' — ${tl.readings.length} eval(s), newest first`, '', ...lines, ...retractLines].join('\n')
|
|
526
528
|
}
|
|
527
529
|
|
|
528
|
-
|
|
530
|
+
// `spex eval scenario ls [<node>|.] [--unmeasured] [--json]` — the DECLARED half of the scoreboard: list
|
|
531
|
+
// scenarios (the measurement contracts) rather than readings. Bare = every measurable node's scenarios
|
|
532
|
+
// (the collection view); a <node>/`.` scopes to one node. --unmeasured keeps only scenarios with NO
|
|
533
|
+
// effective reading — never measured, or every filing retracted — the blind-spot worklist a measuring
|
|
534
|
+
// hand picks from. Flag set closed, like every eval verb.
|
|
535
|
+
async function scenarioLs(args: string[]): Promise<number> {
|
|
536
|
+
for (const a of args) {
|
|
537
|
+
if (!a.startsWith('--')) continue
|
|
538
|
+
const name = a.slice(2)
|
|
539
|
+
if (name !== 'unmeasured' && name !== 'json') {
|
|
540
|
+
console.error(`spex eval scenario ls: unknown flag '${a}' — accepts --unmeasured --json`)
|
|
541
|
+
return 2
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const root = repoRoot()
|
|
545
|
+
const sel = positional(args)
|
|
546
|
+
let nodes = evalNodes(root)
|
|
547
|
+
if (sel) {
|
|
548
|
+
const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
549
|
+
if (!ref) { console.error('spex eval scenario ls .: no current node (no session/node-branch here) — name a node'); return 2 }
|
|
550
|
+
const res = resolveEvalNode(nodes, ref)
|
|
551
|
+
if (!res.ok) { console.error(`spex eval scenario ls: ${res.error}`); return 1 }
|
|
552
|
+
nodes = [res.node]
|
|
553
|
+
}
|
|
554
|
+
const unmeasuredOnly = has(args, 'unmeasured')
|
|
555
|
+
type Row = { node: string; scenario: string; tags?: string[]; measured: boolean; latest?: { verdict?: Verdict; ts: string } }
|
|
556
|
+
const rows: Row[] = []
|
|
557
|
+
for (const n of nodes) {
|
|
558
|
+
const latest = latestPerScenario(readReadings(n.sidecarPath))
|
|
559
|
+
for (const sc of n.scenarios) {
|
|
560
|
+
const r = latest.get(sc.name)
|
|
561
|
+
if (unmeasuredOnly && r) continue
|
|
562
|
+
rows.push({
|
|
563
|
+
node: n.id, scenario: sc.name,
|
|
564
|
+
...(sc.tags?.length ? { tags: sc.tags } : {}),
|
|
565
|
+
measured: !!r,
|
|
566
|
+
...(r ? { latest: { ...(r.verdict ? { verdict: r.verdict } : {}), ts: r.ts } } : {}),
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
|
|
571
|
+
if (!rows.length) {
|
|
572
|
+
console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
|
|
573
|
+
return 0
|
|
574
|
+
}
|
|
575
|
+
const w = Math.max(...rows.map((r) => r.node.length))
|
|
576
|
+
for (const r of rows) {
|
|
577
|
+
const tagStr = r.tags?.length ? ` [${r.tags.join(',')}]` : ''
|
|
578
|
+
const state = r.measured ? `${verdictText(r.latest!.verdict)} ${r.latest!.ts}` : '∅ unmeasured'
|
|
579
|
+
console.log(` ${r.node.padEnd(w)} ${r.scenario}${tagStr} — ${state}`)
|
|
580
|
+
}
|
|
581
|
+
const un = rows.filter((r) => !r.measured).length
|
|
582
|
+
console.log(`spex eval scenario ls: ${rows.length} scenario(s)${unmeasuredOnly ? ', all unmeasured' : un ? ` (${un} unmeasured)` : ''}`)
|
|
583
|
+
return 0
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// the `spex eval` drawer's node-scoped verbs ([[cli-surface]]): add (file a measurement) · ls (a node's
|
|
587
|
+
// reading timeline) · scenario ls (the declared contracts, --unmeasured = blind spots) · lint (the
|
|
588
|
+
// measurement-layer lint — advisory, always exit 0) · retract · clean.
|
|
589
|
+
// The session-scoped read (`spex eval ls --session <SEL>`) is intercepted in cli.ts before this runs;
|
|
590
|
+
// `check-staged` is hook plumbing, exported separately for `spex internal check-staged`.
|
|
591
|
+
export async function runEval(args: string[]): Promise<number> {
|
|
529
592
|
const sub = args[0]
|
|
530
|
-
if (sub === '
|
|
531
|
-
if (sub === '
|
|
593
|
+
if (sub === 'lint') return scan(args.slice(1))
|
|
594
|
+
if (sub === 'add') return evalCmd(args.slice(1))
|
|
532
595
|
if (sub === 'retract') return retractCmd(args.slice(1))
|
|
533
596
|
if (sub === 'clean') return clean(args.slice(1))
|
|
534
|
-
if (sub === '
|
|
535
|
-
if (sub === '
|
|
536
|
-
|
|
597
|
+
if (sub === 'ls') return show(args.slice(1))
|
|
598
|
+
if (sub === 'scenario') {
|
|
599
|
+
if (args[1] === 'ls') return scenarioLs(args.slice(2))
|
|
600
|
+
console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] — list declared scenarios (the measurement contracts)')
|
|
601
|
+
return 2
|
|
602
|
+
}
|
|
603
|
+
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] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
|
|
537
604
|
return 2
|
|
538
605
|
}
|
|
539
606
|
|
|
540
|
-
|
|
607
|
+
export { checkStaged }
|
|
608
|
+
|
|
609
|
+
// `spex evidence put <file|->` / `spex evidence get <hash> [-o <file>]` ([[evidence-put]], [[evidence-get]]) — the bare
|
|
541
610
|
// evidence-transport pair: put stashes bytes in the shared content-addressed cache and prints the hash,
|
|
542
|
-
// WITHOUT filing a reading (`
|
|
611
|
+
// WITHOUT filing a reading (`eval add --video` couples the two); get is its symmetric read — hash in,
|
|
543
612
|
// bytes out. putBlob is idempotent by content, so re-putting re-seeds a checkout whose cache lacks a blob
|
|
544
613
|
// some thread already references by hash (the clone-evidence-404 repair).
|
|
545
|
-
export async function
|
|
614
|
+
export async function runEvidence(args: string[]): Promise<number> {
|
|
546
615
|
if (args[0] === 'put' && args[1] !== undefined) return blobPut(args[1])
|
|
547
616
|
if (args[0] === 'get') return blobGet(args.slice(1))
|
|
548
|
-
console.error('spex
|
|
549
|
-
console.error(' get <hash> [-o <file>] — read
|
|
617
|
+
console.error('spex evidence: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
|
|
618
|
+
console.error(' get <hash> [-o <file>] — read evidence back: local cache first, backend fallback')
|
|
550
619
|
return 2
|
|
551
620
|
}
|
|
552
621
|
|
|
553
622
|
function blobPut(file: string): number {
|
|
554
623
|
let bytes: Buffer
|
|
555
624
|
try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
|
|
556
|
-
console.error(`spex
|
|
625
|
+
console.error(`spex evidence put: cannot read ${file}: ${(e as Error).message}`)
|
|
557
626
|
return 2
|
|
558
627
|
}
|
|
559
|
-
if (bytes.length === 0) { console.error('spex
|
|
628
|
+
if (bytes.length === 0) { console.error('spex evidence put: refusing empty evidence'); return 2 }
|
|
560
629
|
console.log(putBlob(bytes))
|
|
561
630
|
return 0
|
|
562
631
|
}
|
|
563
632
|
|
|
564
633
|
// 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/
|
|
634
|
+
// needed), ② on a local miss the same GET /api/evidence/:hash the dashboard streams from (the blob may
|
|
566
635
|
// have been pruned here, or put on another machine sharing the backend), ③ both missed → fail loud naming
|
|
567
636
|
// both paths. No third read mechanism — this reuses readBlobByHash and the existing endpoint verbatim.
|
|
568
637
|
async function blobGet(args: string[]): Promise<number> {
|
|
569
638
|
const oIdx = args.indexOf('-o')
|
|
570
639
|
const out = oIdx >= 0 ? args[oIdx + 1] : undefined
|
|
571
|
-
if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex
|
|
640
|
+
if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex evidence get: -o needs a <file>'); return 2 }
|
|
572
641
|
const hash = args.find((a, i) => (oIdx < 0 || (i !== oIdx && i !== oIdx + 1)) && !a.startsWith('-'))
|
|
573
|
-
if (!hash) { console.error('spex
|
|
642
|
+
if (!hash) { console.error('spex evidence get: usage: spex evidence get <hash> [-o <file>]'); return 2 }
|
|
574
643
|
const local = readBlobByHash(hash) // validates 64-hex before touching the fs, then reads the shared cache
|
|
575
644
|
if (local.ok) return emitBlob(local.bytes, out)
|
|
576
|
-
if (local.reason === 'invalid') { console.error(`spex
|
|
645
|
+
if (local.reason === 'invalid') { console.error(`spex evidence get: bad hash '${hash}' — an evidence hash is 64 hex chars`); return 2 }
|
|
577
646
|
const { apiBase } = await import('../../spec-cli/src/sessions.js')
|
|
578
|
-
const url = `${await apiBase()}/api/
|
|
647
|
+
const url = `${await apiBase()}/api/evidence/${hash}`
|
|
579
648
|
let backendMiss: string
|
|
580
649
|
try {
|
|
581
650
|
const r = await fetch(url)
|
|
@@ -584,8 +653,8 @@ async function blobGet(args: string[]): Promise<number> {
|
|
|
584
653
|
} catch (e) {
|
|
585
654
|
backendMiss = `unreachable (${(e as Error).message})`
|
|
586
655
|
}
|
|
587
|
-
console.error(`spex
|
|
588
|
-
console.error(` local cache: ${blobPath(hash)} — no such
|
|
656
|
+
console.error(`spex evidence get: ${hash} — not found on either path:`)
|
|
657
|
+
console.error(` local cache: ${blobPath(hash)} — no such evidence (pruned, or put on another machine)`)
|
|
589
658
|
console.error(` backend: ${url} — ${backendMiss}`)
|
|
590
659
|
return 1
|
|
591
660
|
}
|
|
@@ -595,12 +664,12 @@ async function blobGet(args: string[]): Promise<number> {
|
|
|
595
664
|
function emitBlob(bytes: Buffer, out?: string): number {
|
|
596
665
|
if (out !== undefined) {
|
|
597
666
|
try { writeFileSync(out, bytes) } catch (e) {
|
|
598
|
-
console.error(`spex
|
|
667
|
+
console.error(`spex evidence get: cannot write ${out}: ${(e as Error).message}`)
|
|
599
668
|
return 2
|
|
600
669
|
}
|
|
601
670
|
return 0
|
|
602
671
|
}
|
|
603
|
-
if (process.stdout.isTTY) console.error(`spex
|
|
672
|
+
if (process.stdout.isTTY) console.error(`spex evidence get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
|
|
604
673
|
process.stdout.write(bytes)
|
|
605
674
|
return 0
|
|
606
675
|
}
|