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.
- 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/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -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 +23 -15
- 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 +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -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 +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- 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 +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- 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-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.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 +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- 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 +42 -10
- 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/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- 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/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/prompts}/memory-hygiene/spec.md +0 -0
|
@@ -4,7 +4,7 @@ import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/
|
|
|
4
4
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
5
5
|
import { loadConfig, sourceExtRe } from '../../spec-cli/src/lint.js'
|
|
6
6
|
import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
|
|
7
|
-
import {
|
|
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,54 @@ async function retractCmd(args: string[]): Promise<number> {
|
|
|
420
422
|
const left = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
421
423
|
const now = left.length
|
|
422
424
|
? `latest is now ${left[left.length - 1].ts} (${verdictText(left[left.length - 1].verdict)})`
|
|
423
|
-
: 'the scenario is unmeasured again (
|
|
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)')
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// `spex eval ok` — the HUMAN sign-off on a scenario's latest reading ([[human-ok]]): appends a monotonic
|
|
432
|
+
// human-ok event bound to that one immutable reading (a newer reading or live-computed staleness brings the
|
|
433
|
+
// scenario back on its own — no un-ok verb exists). CLI parity with the dashboard's affordance (LAW L);
|
|
434
|
+
// identity is the surface's: 'human' here — and a GOVERNED session is REFUSED, because the sign-off is the
|
|
435
|
+
// human's own deliberate judgment and an agent blessing its own reading would hide it from exactly the
|
|
436
|
+
// review the ok certifies (the no-self-resolve analogue). Flag set closed, like every eval verb.
|
|
437
|
+
async function okCmd(args: string[]): Promise<number> {
|
|
438
|
+
const root = repoRoot()
|
|
439
|
+
for (let i = 0; i < args.length; i++) {
|
|
440
|
+
const a = args[i]
|
|
441
|
+
if (!a.startsWith('--')) continue
|
|
442
|
+
if (a === '--scenario') { i++; continue }
|
|
443
|
+
console.error(`spex eval ok: unknown flag '${a}' — accepts --scenario`)
|
|
444
|
+
return 2
|
|
445
|
+
}
|
|
446
|
+
if (envSessionId()) {
|
|
447
|
+
console.error('spex eval ok: refusing under a governed session — human-ok is the HUMAN\'s sign-off, never an agent\'s self-blessing. Ask the human to ok it from the dashboard or their own terminal; an agent\'s judgment on a reading is a remark (`spex remark add`).')
|
|
448
|
+
return 1
|
|
449
|
+
}
|
|
450
|
+
const sel = positional(args)
|
|
451
|
+
if (!sel || sel === '.') { console.error('spex eval ok: name a node — usage: spex eval ok <node> --scenario <name>'); return 2 }
|
|
452
|
+
const node = stripRefSigil(sel)
|
|
453
|
+
const scName = flag(args, 'scenario')
|
|
454
|
+
const res = resolveEvalNode(evalNodes(root), node)
|
|
455
|
+
if (!res.ok) { console.error(`spex eval ok: ${res.error}`); return 1 }
|
|
456
|
+
const declared = res.node.scenarios.map((s) => s.name)
|
|
457
|
+
const scenario = scName ?? (declared.length === 1 ? declared[0] : undefined)
|
|
458
|
+
if (!scenario) {
|
|
459
|
+
console.error(`spex eval ok: '${res.node.id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
|
|
460
|
+
return 1
|
|
461
|
+
}
|
|
462
|
+
const { fileHumanOk } = await import('./humanok.js')
|
|
463
|
+
const r = fileHumanOk(res.node.id, scenario, 'human')
|
|
464
|
+
if (!r.ok) { console.error(`spex eval ok: ${r.error}`); return 1 }
|
|
465
|
+
if (r.already) {
|
|
466
|
+
console.log(`spex eval ok: '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} is already human-ok'd (by ${r.humanOk.by}, ${r.humanOk.ts}) — monotonic, nothing appended`)
|
|
467
|
+
return 0
|
|
468
|
+
}
|
|
469
|
+
console.log(` ☑ '${res.node.id}' scenario '${scenario}' reading @ ${r.humanOk.okTs} (${r.humanOk.okSha.slice(0, 7)}) human-ok'd`)
|
|
470
|
+
console.log(r.landed === 'committed'
|
|
471
|
+
? 'spex eval ok: 1 sign-off filed (committed straight to the trunk)'
|
|
472
|
+
: 'spex eval ok: 1 sign-off filed (an appended event — commit the sidecar so the sign-off lands)')
|
|
426
473
|
return 0
|
|
427
474
|
}
|
|
428
475
|
|
|
@@ -444,7 +491,7 @@ async function clean(args: string[]): Promise<number> {
|
|
|
444
491
|
const before = listBlobs().length
|
|
445
492
|
const removed = gc(referenced)
|
|
446
493
|
const mode = all ? 'all' : keepLatest ? 'keep-latest' : 'unreferenced'
|
|
447
|
-
console.log(`spex
|
|
494
|
+
console.log(`spex eval clean: removed ${removed.length} evidence file(s), kept ${before - removed.length} (${mode})`)
|
|
448
495
|
return 0
|
|
449
496
|
}
|
|
450
497
|
|
|
@@ -457,18 +504,18 @@ function checkStaged(): number {
|
|
|
457
504
|
const blobs = staged.filter(isStrayBlob)
|
|
458
505
|
if (blobs.length) {
|
|
459
506
|
bad = true
|
|
460
|
-
console.error('✗ SpexCode
|
|
507
|
+
console.error('✗ SpexCode eval: stray evidence file(s) staged — evidence lives in the shared git common dir, never in the tree:')
|
|
461
508
|
for (const o of blobs) console.error(` ${o}`)
|
|
462
|
-
console.error(' Unstage them (git rm --cached <path>);
|
|
509
|
+
console.error(' Unstage them (git rm --cached <path>); an eval references its evidence by hash, it never commits the bytes.')
|
|
463
510
|
}
|
|
464
511
|
|
|
465
|
-
for (const rel of staged.filter((p) => p ===
|
|
512
|
+
for (const rel of staged.filter((p) => p === EVAL_FILE || p.endsWith('/' + EVAL_FILE))) {
|
|
466
513
|
const abs = join(root, rel)
|
|
467
514
|
if (!existsSync(abs)) continue // staged deletion — nothing to validate
|
|
468
515
|
const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
|
|
469
516
|
if (!errs.length) continue
|
|
470
517
|
bad = true
|
|
471
|
-
console.error(`✗ SpexCode
|
|
518
|
+
console.error(`✗ SpexCode eval: ${rel} — invalid scenario schema:`)
|
|
472
519
|
for (const e of errs) console.error(` ${e}`)
|
|
473
520
|
}
|
|
474
521
|
|
|
@@ -479,12 +526,12 @@ async function show(args: string[]): Promise<number> {
|
|
|
479
526
|
const root = repoRoot()
|
|
480
527
|
const sel = positional(args)
|
|
481
528
|
const ref = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
482
|
-
if (!ref) { console.error('spex
|
|
529
|
+
if (!ref) { console.error('spex eval ls .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
483
530
|
// resolve LOUD before the timeline: an ambiguous bare leaf must list its candidate canonical ids, never
|
|
484
|
-
// fall through to a false "declares no scenarios". A ref matching NO
|
|
485
|
-
//
|
|
486
|
-
const res =
|
|
487
|
-
if (!res.ok && res.ambiguous) { console.error(`spex
|
|
531
|
+
// fall through to a false "declares no scenarios". A ref matching NO measurable node still renders the honest
|
|
532
|
+
// hasEvalFile:false line — a spec node without an eval.md is not an error to look at.
|
|
533
|
+
const res = resolveEvalNode(evalNodes(root), ref)
|
|
534
|
+
if (!res.ok && res.ambiguous) { console.error(`spex eval ls: ${res.error}`); return 1 }
|
|
488
535
|
const tl = await evalTimeline(res.ok ? res.node.id : ref) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
|
|
489
536
|
if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
|
|
490
537
|
console.log(formatTimeline(tl))
|
|
@@ -501,12 +548,12 @@ function verdictText(v: Verdict | undefined): string {
|
|
|
501
548
|
}
|
|
502
549
|
|
|
503
550
|
export function formatTimeline(tl: EvalTimeline): string {
|
|
504
|
-
if (!tl.
|
|
551
|
+
if (!tl.hasEvalFile) return `spex eval ls: '${tl.node}' declares no scenarios (no eval.md)`
|
|
505
552
|
// the retraction trace, newest first — the undo stays visible through the same surface that shows readings.
|
|
506
553
|
const retractLines = (tl.retractions ?? []).map((x) =>
|
|
507
|
-
` ⟲ retracted: scenario '${x.scenario}'
|
|
554
|
+
` ⟲ retracted: scenario '${x.scenario}' eval @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
|
|
508
555
|
if (!tl.readings.length) {
|
|
509
|
-
const head = `spex
|
|
556
|
+
const head = `spex eval ls: '${tl.node}' has scenarios but no eval yet — run \`spex eval add ${tl.node}\``
|
|
510
557
|
return retractLines.length ? [head, '', ...retractLines].join('\n') : head
|
|
511
558
|
}
|
|
512
559
|
const w = Math.max(...tl.readings.map((r) => r.scenario.length))
|
|
@@ -519,63 +566,132 @@ export function formatTimeline(tl: EvalTimeline): string {
|
|
|
519
566
|
const ev = list.length
|
|
520
567
|
? list.map((e) => e.state === 'miss' ? 'miss original file' : `${e.kind} ${(e.hash ?? '').slice(0, 12)}…`).join(', ')
|
|
521
568
|
: 'no evidence'
|
|
522
|
-
const
|
|
569
|
+
const okTag = r.humanOk ? ` ☑ human-ok (${r.humanOk.by})` : ''
|
|
570
|
+
const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge}${okTag} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
|
|
523
571
|
return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
|
|
524
572
|
})
|
|
525
|
-
return [`spex
|
|
573
|
+
return [`spex eval ls: '${tl.node}' — ${tl.readings.length} eval(s), newest first`, '', ...lines, ...retractLines].join('\n')
|
|
526
574
|
}
|
|
527
575
|
|
|
528
|
-
|
|
576
|
+
// `spex eval scenario ls [<node>|.] [--unmeasured] [--json]` — the DECLARED half of the scoreboard: list
|
|
577
|
+
// scenarios (the measurement contracts) rather than readings. Bare = every measurable node's scenarios
|
|
578
|
+
// (the collection view); a <node>/`.` scopes to one node. --unmeasured keeps only scenarios with NO
|
|
579
|
+
// effective reading — never measured, or every filing retracted — the blind-spot worklist a measuring
|
|
580
|
+
// hand picks from. Flag set closed, like every eval verb.
|
|
581
|
+
async function scenarioLs(args: string[]): Promise<number> {
|
|
582
|
+
for (const a of args) {
|
|
583
|
+
if (!a.startsWith('--')) continue
|
|
584
|
+
const name = a.slice(2)
|
|
585
|
+
if (name !== 'unmeasured' && name !== 'json') {
|
|
586
|
+
console.error(`spex eval scenario ls: unknown flag '${a}' — accepts --unmeasured --json`)
|
|
587
|
+
return 2
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const root = repoRoot()
|
|
591
|
+
const sel = positional(args)
|
|
592
|
+
let nodes = evalNodes(root)
|
|
593
|
+
if (sel) {
|
|
594
|
+
const ref = sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
595
|
+
if (!ref) { console.error('spex eval scenario ls .: no current node (no session/node-branch here) — name a node'); return 2 }
|
|
596
|
+
const res = resolveEvalNode(nodes, ref)
|
|
597
|
+
if (!res.ok) { console.error(`spex eval scenario ls: ${res.error}`); return 1 }
|
|
598
|
+
nodes = [res.node]
|
|
599
|
+
}
|
|
600
|
+
const unmeasuredOnly = has(args, 'unmeasured')
|
|
601
|
+
type Row = { node: string; scenario: string; tags?: string[]; measured: boolean; latest?: { verdict?: Verdict; ts: string } }
|
|
602
|
+
const rows: Row[] = []
|
|
603
|
+
for (const n of nodes) {
|
|
604
|
+
const latest = latestPerScenario(readReadings(n.sidecarPath))
|
|
605
|
+
for (const sc of n.scenarios) {
|
|
606
|
+
const r = latest.get(sc.name)
|
|
607
|
+
if (unmeasuredOnly && r) continue
|
|
608
|
+
rows.push({
|
|
609
|
+
node: n.id, scenario: sc.name,
|
|
610
|
+
...(sc.tags?.length ? { tags: sc.tags } : {}),
|
|
611
|
+
measured: !!r,
|
|
612
|
+
...(r ? { latest: { ...(r.verdict ? { verdict: r.verdict } : {}), ts: r.ts } } : {}),
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (has(args, 'json')) { console.log(JSON.stringify(rows, null, 2)); return 0 }
|
|
617
|
+
if (!rows.length) {
|
|
618
|
+
console.log(`spex eval scenario ls: ${unmeasuredOnly ? 'no unmeasured scenarios' : 'no scenarios declared'}${sel ? ` on '${nodes[0]?.id ?? sel}'` : ''}`)
|
|
619
|
+
return 0
|
|
620
|
+
}
|
|
621
|
+
const w = Math.max(...rows.map((r) => r.node.length))
|
|
622
|
+
for (const r of rows) {
|
|
623
|
+
const tagStr = r.tags?.length ? ` [${r.tags.join(',')}]` : ''
|
|
624
|
+
const state = r.measured ? `${verdictText(r.latest!.verdict)} ${r.latest!.ts}` : '∅ unmeasured'
|
|
625
|
+
console.log(` ${r.node.padEnd(w)} ${r.scenario}${tagStr} — ${state}`)
|
|
626
|
+
}
|
|
627
|
+
const un = rows.filter((r) => !r.measured).length
|
|
628
|
+
console.log(`spex eval scenario ls: ${rows.length} scenario(s)${unmeasuredOnly ? ', all unmeasured' : un ? ` (${un} unmeasured)` : ''}`)
|
|
629
|
+
return 0
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// the `spex eval` drawer's node-scoped verbs ([[cli-surface]]): add (file a measurement) · ls (a node's
|
|
633
|
+
// reading timeline) · scenario ls (the declared contracts, --unmeasured = blind spots) · lint (the
|
|
634
|
+
// measurement-layer lint — advisory, always exit 0) · ok (the human sign-off) · retract · clean.
|
|
635
|
+
// The session-scoped read (`spex eval ls --session <SEL>`) is intercepted in cli.ts before this runs;
|
|
636
|
+
// `check-staged` is hook plumbing, exported separately for `spex internal check-staged`.
|
|
637
|
+
export async function runEval(args: string[]): Promise<number> {
|
|
529
638
|
const sub = args[0]
|
|
530
|
-
if (sub === '
|
|
531
|
-
if (sub === '
|
|
639
|
+
if (sub === 'lint') return scan(args.slice(1))
|
|
640
|
+
if (sub === 'add') return evalCmd(args.slice(1))
|
|
641
|
+
if (sub === 'ok') return okCmd(args.slice(1))
|
|
532
642
|
if (sub === 'retract') return retractCmd(args.slice(1))
|
|
533
643
|
if (sub === 'clean') return clean(args.slice(1))
|
|
534
|
-
if (sub === '
|
|
535
|
-
if (sub === '
|
|
536
|
-
|
|
644
|
+
if (sub === 'ls') return show(args.slice(1))
|
|
645
|
+
if (sub === 'scenario') {
|
|
646
|
+
if (args[1] === 'ls') return scenarioLs(args.slice(2))
|
|
647
|
+
console.error('spex eval scenario: ls [<node>|.] [--unmeasured] [--json] — list declared scenarios (the measurement contracts)')
|
|
648
|
+
return 2
|
|
649
|
+
}
|
|
650
|
+
console.error('spex eval: add [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path>] [--timeline <json>] | ls [.|<node>] [--json] | ls --session <SEL> [--export] | scenario ls [<node>|.] [--unmeasured] [--json] | lint [--changed] | ok <node> [--scenario <name>] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | clean [--keep-latest|--all]')
|
|
537
651
|
return 2
|
|
538
652
|
}
|
|
539
653
|
|
|
540
|
-
|
|
654
|
+
export { checkStaged }
|
|
655
|
+
|
|
656
|
+
// `spex evidence put <file|->` / `spex evidence get <hash> [-o <file>]` ([[evidence-put]], [[evidence-get]]) — the bare
|
|
541
657
|
// evidence-transport pair: put stashes bytes in the shared content-addressed cache and prints the hash,
|
|
542
|
-
// WITHOUT filing a reading (`
|
|
658
|
+
// WITHOUT filing a reading (`eval add --video` couples the two); get is its symmetric read — hash in,
|
|
543
659
|
// bytes out. putBlob is idempotent by content, so re-putting re-seeds a checkout whose cache lacks a blob
|
|
544
660
|
// some thread already references by hash (the clone-evidence-404 repair).
|
|
545
|
-
export async function
|
|
661
|
+
export async function runEvidence(args: string[]): Promise<number> {
|
|
546
662
|
if (args[0] === 'put' && args[1] !== undefined) return blobPut(args[1])
|
|
547
663
|
if (args[0] === 'get') return blobGet(args.slice(1))
|
|
548
|
-
console.error('spex
|
|
549
|
-
console.error(' get <hash> [-o <file>] — read
|
|
664
|
+
console.error('spex evidence: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
|
|
665
|
+
console.error(' get <hash> [-o <file>] — read evidence back: local cache first, backend fallback')
|
|
550
666
|
return 2
|
|
551
667
|
}
|
|
552
668
|
|
|
553
669
|
function blobPut(file: string): number {
|
|
554
670
|
let bytes: Buffer
|
|
555
671
|
try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
|
|
556
|
-
console.error(`spex
|
|
672
|
+
console.error(`spex evidence put: cannot read ${file}: ${(e as Error).message}`)
|
|
557
673
|
return 2
|
|
558
674
|
}
|
|
559
|
-
if (bytes.length === 0) { console.error('spex
|
|
675
|
+
if (bytes.length === 0) { console.error('spex evidence put: refusing empty evidence'); return 2 }
|
|
560
676
|
console.log(putBlob(bytes))
|
|
561
677
|
return 0
|
|
562
678
|
}
|
|
563
679
|
|
|
564
680
|
// the read half: ① the local content-addressed cache (the evidence usually IS on this disk — no backend
|
|
565
|
-
// needed), ② on a local miss the same GET /api/
|
|
681
|
+
// needed), ② on a local miss the same GET /api/evidence/:hash the dashboard streams from (the blob may
|
|
566
682
|
// have been pruned here, or put on another machine sharing the backend), ③ both missed → fail loud naming
|
|
567
683
|
// both paths. No third read mechanism — this reuses readBlobByHash and the existing endpoint verbatim.
|
|
568
684
|
async function blobGet(args: string[]): Promise<number> {
|
|
569
685
|
const oIdx = args.indexOf('-o')
|
|
570
686
|
const out = oIdx >= 0 ? args[oIdx + 1] : undefined
|
|
571
|
-
if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex
|
|
687
|
+
if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex evidence get: -o needs a <file>'); return 2 }
|
|
572
688
|
const hash = args.find((a, i) => (oIdx < 0 || (i !== oIdx && i !== oIdx + 1)) && !a.startsWith('-'))
|
|
573
|
-
if (!hash) { console.error('spex
|
|
689
|
+
if (!hash) { console.error('spex evidence get: usage: spex evidence get <hash> [-o <file>]'); return 2 }
|
|
574
690
|
const local = readBlobByHash(hash) // validates 64-hex before touching the fs, then reads the shared cache
|
|
575
691
|
if (local.ok) return emitBlob(local.bytes, out)
|
|
576
|
-
if (local.reason === 'invalid') { console.error(`spex
|
|
692
|
+
if (local.reason === 'invalid') { console.error(`spex evidence get: bad hash '${hash}' — an evidence hash is 64 hex chars`); return 2 }
|
|
577
693
|
const { apiBase } = await import('../../spec-cli/src/sessions.js')
|
|
578
|
-
const url = `${await apiBase()}/api/
|
|
694
|
+
const url = `${await apiBase()}/api/evidence/${hash}`
|
|
579
695
|
let backendMiss: string
|
|
580
696
|
try {
|
|
581
697
|
const r = await fetch(url)
|
|
@@ -584,8 +700,8 @@ async function blobGet(args: string[]): Promise<number> {
|
|
|
584
700
|
} catch (e) {
|
|
585
701
|
backendMiss = `unreachable (${(e as Error).message})`
|
|
586
702
|
}
|
|
587
|
-
console.error(`spex
|
|
588
|
-
console.error(` local cache: ${blobPath(hash)} — no such
|
|
703
|
+
console.error(`spex evidence get: ${hash} — not found on either path:`)
|
|
704
|
+
console.error(` local cache: ${blobPath(hash)} — no such evidence (pruned, or put on another machine)`)
|
|
589
705
|
console.error(` backend: ${url} — ${backendMiss}`)
|
|
590
706
|
return 1
|
|
591
707
|
}
|
|
@@ -595,12 +711,12 @@ async function blobGet(args: string[]): Promise<number> {
|
|
|
595
711
|
function emitBlob(bytes: Buffer, out?: string): number {
|
|
596
712
|
if (out !== undefined) {
|
|
597
713
|
try { writeFileSync(out, bytes) } catch (e) {
|
|
598
|
-
console.error(`spex
|
|
714
|
+
console.error(`spex evidence get: cannot write ${out}: ${(e as Error).message}`)
|
|
599
715
|
return 2
|
|
600
716
|
}
|
|
601
717
|
return 0
|
|
602
718
|
}
|
|
603
|
-
if (process.stdout.isTTY) console.error(`spex
|
|
719
|
+
if (process.stdout.isTTY) console.error(`spex evidence get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
|
|
604
720
|
process.stdout.write(bytes)
|
|
605
721
|
return 0
|
|
606
722
|
}
|