spexcode 0.1.6 → 0.2.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 +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-yatsu/src/cli.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative, dirname } from 'node:path'
|
|
3
|
-
import { repoRoot, headSha, driftIndex,
|
|
3
|
+
import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
|
|
4
4
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
5
5
|
import { loadConfig } from '../../spec-cli/src/lint.js'
|
|
6
6
|
import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
|
|
7
7
|
import { yatsuNodes, validateScenarios, YATSU_FILE, type YatsuNode } from './yatsu.js'
|
|
8
|
-
import { readReadings, appendReading, latestPerScenario, type Reading, type Verdict } from './sidecar.js'
|
|
8
|
+
import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, type Reading, type Verdict, type Evidence, type Retraction } from './sidecar.js'
|
|
9
9
|
import { staleAxes } from './freshness.js'
|
|
10
|
+
import { scenarioIndex } from './scenariofresh.js'
|
|
11
|
+
import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
|
|
10
12
|
import { evaluatorTag } from './evaluator.js'
|
|
11
13
|
import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
|
|
14
|
+
import { validateTimeline } from './timeline.js'
|
|
12
15
|
import { evalTimeline, type EvalTimeline } from './evaltab.js'
|
|
13
16
|
|
|
14
17
|
function flag(args: string[], name: string): string | undefined {
|
|
15
18
|
const i = args.indexOf(`--${name}`)
|
|
16
19
|
return i >= 0 ? args[i + 1] : undefined
|
|
17
20
|
}
|
|
21
|
+
// every value of a REPEATABLE flag (e.g. `--image a --image b`), in argv order; a trailing `--image` with no
|
|
22
|
+
// value (or another flag as its value) is dropped so a typo can't swallow the next flag.
|
|
23
|
+
function flags(args: string[], name: string): string[] {
|
|
24
|
+
const out: string[] = []
|
|
25
|
+
for (let i = 0; i < args.length; i++) if (args[i] === `--${name}`) { const v = args[i + 1]; if (v !== undefined && !v.startsWith('--')) out.push(v) }
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
18
28
|
const has = (args: string[], name: string) => args.includes(`--${name}`)
|
|
19
29
|
const positional = (args: string[]) => args.find((a) => !a.startsWith('--'))
|
|
20
30
|
|
|
@@ -72,17 +82,21 @@ export function nodeChanged(dirRel: string, codeFiles: string[], changed: Set<st
|
|
|
72
82
|
|
|
73
83
|
async function scan(args: string[] = []): Promise<number> {
|
|
74
84
|
const root = repoRoot()
|
|
85
|
+
const cfg = loadConfig(root)
|
|
75
86
|
const changedOnly = has(args, 'changed')
|
|
76
87
|
const changed = changedOnly ? changedSinceBase(root) : null
|
|
77
88
|
const idx = await driftIndex(root)
|
|
78
|
-
const
|
|
89
|
+
const scidx = await scenarioIndex(root, yatsuNodes(root).map((n) => n.yatsuPath))
|
|
79
90
|
const specs = await loadSpecs()
|
|
91
|
+
// the non-git REMARK freshness axis ([[remark-teeth]]): the trunk remark track, read ONCE — the CLI is the
|
|
92
|
+
// whole model, so `spex yatsu scan` shows a remark-stale scenario with no server running.
|
|
93
|
+
const remarkTracks = loadEvalRemarkTracks()
|
|
80
94
|
// a file may be governed by several nodes — ordinary composition, not a hub to skip (see governed-related).
|
|
81
95
|
// A change to a shared governed file legitimately triggers EVERY governing node's yatsu, mirroring how
|
|
82
96
|
// lint's drift now fans to every owner; nobody's loss signal is suppressed. An over-owned file is lint's
|
|
83
97
|
// `owners` concern (split it), not a reason to go silent here.
|
|
84
98
|
const yByDir = new Map(yatsuNodes(root).map((n) => [relative(root, n.dir), n]))
|
|
85
|
-
let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0
|
|
99
|
+
let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
|
|
86
100
|
for (const s of specs) {
|
|
87
101
|
const dirRel = dirname(s.path)
|
|
88
102
|
if (changed && !nodeChanged(dirRel, s.code, changed)) continue
|
|
@@ -91,7 +105,7 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
91
105
|
if (y) {
|
|
92
106
|
// schema first: a malformed yatsu.md is the loudest gap — report each violation, then still scan its
|
|
93
107
|
// (leniently-parsed) scenarios for stale/missing so a typo doesn't mask a real freshness gap.
|
|
94
|
-
for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'))) {
|
|
108
|
+
for (const e of validateScenarios(readFileSync(join(y.dir, YATSU_FILE), 'utf8'), cfg.scenarioTags)) {
|
|
95
109
|
malformed++
|
|
96
110
|
findings.push(` • yatsu-schema: '${s.id}' ${e} — fix ${y.yatsuPath}`)
|
|
97
111
|
}
|
|
@@ -108,18 +122,36 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
108
122
|
}
|
|
109
123
|
}
|
|
110
124
|
const codeFiles = sc.code?.length ? sc.code : s.code // scenario's own subset, else the node's list
|
|
125
|
+
// carry the scenario's tags on the finding line — its SURFACE (e.g. frontend-e2e = browser-measured)
|
|
126
|
+
// is what routes a drift/missing gap to the right measuring hand, so the proactive nudge and a human
|
|
127
|
+
// reading `spex yatsu scan` both see whether this stale score needs a real e2e/browser pass to refresh.
|
|
128
|
+
const tagStr = sc.tags?.length ? ` [${sc.tags.join(',')}]` : ''
|
|
111
129
|
const r = latest.get(sc.name)
|
|
112
130
|
if (!r) {
|
|
113
131
|
missingScores++
|
|
114
|
-
findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}' has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
|
|
132
|
+
findings.push(` • yatsu-missing: '${s.id}' scenario '${sc.name}'${tagStr} has no reading yet — measure with \`spex yatsu eval ${s.id}\``)
|
|
115
133
|
continue
|
|
116
134
|
}
|
|
117
|
-
const
|
|
135
|
+
const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
|
|
136
|
+
const axes = staleAxes(r, codeFiles, y.yatsuPath, idx, scidx, remSignals)
|
|
118
137
|
if (axes.length) {
|
|
119
138
|
staleScores++
|
|
120
|
-
|
|
139
|
+
// a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
|
|
140
|
+
// by a re-measure. Both read the same word: "re-measure with spex yatsu eval".
|
|
141
|
+
findings.push(` • yatsu-drift: '${s.id}' scenario '${sc.name}'${tagStr} is stale (${axes.join(', ')} changed since ${r.codeSha.slice(0, 7)}) — re-measure with \`spex yatsu eval ${s.id}\``)
|
|
121
142
|
}
|
|
122
143
|
}
|
|
144
|
+
// DANGLING remark tracks (directive 5): a (node, scenario) remark track whose scenario is gone from
|
|
145
|
+
// yatsu.md AND has no reading (renamed/deleted) — its remarks would surface nowhere on the loss signal.
|
|
146
|
+
// One note per node so the orphan is visible; the remarks stay resolvable/retractable via their refs
|
|
147
|
+
// (`spex resolve`/`spex retract`), and they age nothing (there is no reading to stale).
|
|
148
|
+
const declared = new Set(y.scenarios.map((sc) => sc.name))
|
|
149
|
+
const orphans = [...remarkTracks.values()].filter((tr) => tr.node === s.id && tr.remarks.length && !declared.has(tr.scenario) && !latest.has(tr.scenario))
|
|
150
|
+
if (orphans.length) {
|
|
151
|
+
danglingTracks += orphans.length
|
|
152
|
+
const names = orphans.map((o) => `'${o.scenario}' (${o.threadId}, ${o.remarks.length} remark${o.remarks.length > 1 ? 's' : ''})`).join(', ')
|
|
153
|
+
findings.push(` • yatsu-dangling: '${s.id}' has ${orphans.length} orphaned remark track(s) — scenario ${names} renamed/deleted; resolve/retract via \`spex resolve <ref>\` / \`spex retract <ref>\` or restore the scenario name`)
|
|
154
|
+
}
|
|
123
155
|
} else if (s.code.some(isUiPath)) {
|
|
124
156
|
uncovered++
|
|
125
157
|
findings.push(` • yatsu-uncovered: '${s.id}' governs frontend code but has no yatsu.md — give it a scenario (description + expected) so its loss can be measured`)
|
|
@@ -131,7 +163,7 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
131
163
|
// whole-repo only (never --changed): a structural fact, not a per-branch freshness gap. Counts only explicit scenario `code:`.
|
|
132
164
|
let overOwned = 0
|
|
133
165
|
if (!changedOnly) {
|
|
134
|
-
const maxOwners =
|
|
166
|
+
const maxOwners = cfg.maxOwners
|
|
135
167
|
const govCount = new Map<string, number>()
|
|
136
168
|
for (const n of yByDir.values()) for (const sc of n.scenarios) for (const f of sc.code ?? []) govCount.set(f, (govCount.get(f) ?? 0) + 1)
|
|
137
169
|
const over = [...govCount].filter(([, c]) => c > maxOwners).sort((a, b) => b[1] - a[1])
|
|
@@ -143,12 +175,36 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
143
175
|
}
|
|
144
176
|
const scope = changedOnly ? ' --changed' : ''
|
|
145
177
|
const ownersNote = overOwned ? `, ${overOwned} over-owned` : ''
|
|
146
|
-
|
|
178
|
+
const danglingNote = danglingTracks ? `, ${danglingTracks} dangling` : ''
|
|
179
|
+
console.error(`spex yatsu scan${scope}: ${flaggedNodes} node(s) flagged (${malformed} malformed, ${staleScores} stale, ${missingScores} missing, ${uncovered} uncovered${danglingNote}${ownersNote})`)
|
|
147
180
|
return 0
|
|
148
181
|
}
|
|
149
182
|
|
|
183
|
+
// eval's flag set is CLOSED — like the scenario schema's closed field set. An unrecognized flag is
|
|
184
|
+
// rejected LOUD, never silently ignored: an old CLI that didn't know `--video` once filed the clip as an
|
|
185
|
+
// `--image`, and a misfiled reading is worse than none (it reads as proof). Value-flags consume the next
|
|
186
|
+
// token, so a path/note that itself starts with `--` is never mistaken for a flag.
|
|
187
|
+
const EVAL_VALUE_FLAGS = new Set(['scenario', 'note', 'image', 'result', 'video', 'timeline'])
|
|
188
|
+
const EVAL_BOOL_FLAGS = new Set(['pass', 'fail'])
|
|
189
|
+
function rejectUnknownEvalFlag(args: string[]): string | null {
|
|
190
|
+
for (let i = 0; i < args.length; i++) {
|
|
191
|
+
const a = args[i]
|
|
192
|
+
if (!a.startsWith('--')) continue
|
|
193
|
+
const name = a.slice(2)
|
|
194
|
+
if (EVAL_VALUE_FLAGS.has(name)) { i++; continue } // its value is the next token — skip it
|
|
195
|
+
if (EVAL_BOOL_FLAGS.has(name)) continue
|
|
196
|
+
return a
|
|
197
|
+
}
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
150
201
|
async function evalCmd(args: string[]): Promise<number> {
|
|
151
202
|
const root = repoRoot()
|
|
203
|
+
const bad = rejectUnknownEvalFlag(args)
|
|
204
|
+
if (bad) {
|
|
205
|
+
console.error(`spex yatsu eval: unknown flag '${bad}' — accepts --scenario --pass --fail --note --image --result --video --timeline`)
|
|
206
|
+
return 2
|
|
207
|
+
}
|
|
152
208
|
const sel = positional(args)
|
|
153
209
|
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
154
210
|
if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
@@ -168,43 +224,140 @@ async function evalCmd(args: string[]): Promise<number> {
|
|
|
168
224
|
|
|
169
225
|
// the verdict the agent reached (required — a measurement without one is the legacy shape, not a filing).
|
|
170
226
|
const verdict = parseVerdict(args)
|
|
171
|
-
if (!verdict) { console.error('spex yatsu eval: a verdict is required —
|
|
227
|
+
if (!verdict) { console.error('spex yatsu eval: a verdict is required — --pass or --fail (either may add --note <text>)'); return 2 }
|
|
172
228
|
|
|
173
|
-
// the evidence the agent captured (optional;
|
|
174
|
-
//
|
|
175
|
-
|
|
229
|
+
// the evidence the agent captured (optional; a LIST now — REPEATABLE --image plus an optional --result
|
|
230
|
+
// and/or --video, in any combination). The bytes go to the content-addressed cache exactly the same
|
|
231
|
+
// whichever kind; each becomes one typed entry on the reading's `evidence` list. A --video clip is the
|
|
232
|
+
// truest evidence for a UI-surface scenario — a recording of the loop — and N stills can ride beside it.
|
|
233
|
+
const images = flags(args, 'image')
|
|
176
234
|
const result = flag(args, 'result')
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
235
|
+
const video = flag(args, 'video')
|
|
236
|
+
const evidence: Evidence[] = []
|
|
237
|
+
for (const p of images) evidence.push({ hash: putBlob(readFileSync(p)), kind: 'image' })
|
|
238
|
+
if (result !== undefined) evidence.push({ hash: putBlob(readFileSync(result === '-' ? 0 : result)), kind: 'transcript' })
|
|
239
|
+
if (video !== undefined) evidence.push({ hash: putBlob(readFileSync(video)), kind: 'video' })
|
|
240
|
+
|
|
241
|
+
// --timeline: the clip's step map (timeline.ts) — only meaningful beside a --video entry. Validated LOUD at
|
|
242
|
+
// filing (a malformed map is rejected, never silently reshaped); stored canonicalized so identical
|
|
243
|
+
// timelines share one blob. It anchors the reading's video evidence entry.
|
|
244
|
+
const timeline = flag(args, 'timeline')
|
|
245
|
+
let timelineBlob: string | undefined
|
|
246
|
+
if (timeline !== undefined) {
|
|
247
|
+
if (!evidence.some((e) => e.kind === 'video')) { console.error('spex yatsu eval: --timeline accompanies --video (it maps moments in the clip to steps)'); return 2 }
|
|
248
|
+
let parsed: unknown
|
|
249
|
+
try { parsed = JSON.parse(readFileSync(timeline, 'utf8')) } catch { console.error(`spex yatsu eval: --timeline ${timeline} is not readable JSON`); return 2 }
|
|
250
|
+
const terrs = validateTimeline(parsed)
|
|
251
|
+
if (terrs.length) {
|
|
252
|
+
console.error('spex yatsu eval: invalid step-timeline:')
|
|
253
|
+
for (const e of terrs) console.error(` ${e}`)
|
|
254
|
+
return 2
|
|
255
|
+
}
|
|
256
|
+
timelineBlob = putBlob(Buffer.from(JSON.stringify(parsed)))
|
|
257
|
+
}
|
|
182
258
|
|
|
183
259
|
const reading: Reading = {
|
|
184
260
|
scenario: scenario.name,
|
|
185
261
|
codeSha: headSha(root),
|
|
186
|
-
|
|
187
|
-
...(
|
|
262
|
+
...(evidence.length ? { evidence } : {}),
|
|
263
|
+
...(timelineBlob ? { timelineBlob } : {}),
|
|
188
264
|
evaluator: evaluatorTag(),
|
|
265
|
+
// the filing session — the originator an eval-comment thread loops in ([[mentions]]); absent if unknown
|
|
266
|
+
...((envSessionId() ?? undefined) ? { by: envSessionId()! } : {}),
|
|
189
267
|
verdict,
|
|
190
268
|
ts: new Date().toISOString(),
|
|
191
269
|
}
|
|
192
270
|
appendReading(node.sidecarPath, reading)
|
|
193
|
-
const ev =
|
|
271
|
+
const ev = evidence.length
|
|
272
|
+
? evidence.map((e) => `${e.kind} ${e.hash.slice(0, 12)}…`).join(', ') + (timelineBlob ? ' +timeline' : '')
|
|
273
|
+
: 'no evidence'
|
|
194
274
|
console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
|
|
195
275
|
console.log(`spex yatsu eval: 1 measurement filed`)
|
|
196
276
|
return 0
|
|
197
277
|
}
|
|
198
278
|
|
|
199
|
-
// the verdict from the flags:
|
|
279
|
+
// the verdict from the flags: --pass or --fail sets the status (pass wins if both given); --note <text> is an
|
|
280
|
+
// OPTIONAL annotation attached to either. No status flag → null (a measurement must commit to pass or fail).
|
|
200
281
|
function parseVerdict(args: string[]): Verdict | null {
|
|
201
|
-
if (has(args, 'pass')) return { status: 'pass' }
|
|
202
|
-
if (has(args, 'fail')) return { status: 'fail' }
|
|
203
282
|
const note = flag(args, 'note')
|
|
204
|
-
|
|
283
|
+
const ann = note !== undefined ? { note } : {}
|
|
284
|
+
if (has(args, 'pass')) return { status: 'pass', ...ann }
|
|
285
|
+
if (has(args, 'fail')) return { status: 'fail', ...ann }
|
|
205
286
|
return null
|
|
206
287
|
}
|
|
207
288
|
|
|
289
|
+
// retract's flag set is closed like eval's — an unknown flag is rejected LOUD, never silently ignored.
|
|
290
|
+
const RETRACT_VALUE_FLAGS = new Set(['scenario', 'ts', 'note'])
|
|
291
|
+
const RETRACT_BOOL_FLAGS = new Set(['last'])
|
|
292
|
+
|
|
293
|
+
// `spex yatsu retract` — the sanctioned inverse of eval: undo a botched filing through the SAME surface
|
|
294
|
+
// that wrote it. It appends a RETRACTION event to the sidecar (append-only stays true; the target line
|
|
295
|
+
// stays as history; git carries who/when/why) — never a deleted line. The effective scoreboard then drops
|
|
296
|
+
// the retracted reading everywhere at once: the previous reading becomes the latest again, or the scenario
|
|
297
|
+
// honestly returns to yatsu-missing. Default target is the scenario's LATEST effective reading (--last is
|
|
298
|
+
// that default made explicit — repeated retracts peel junk e2e/smoke filings back one at a time); --ts
|
|
299
|
+
// pins an exact reading by its timestamp.
|
|
300
|
+
async function retractCmd(args: string[]): Promise<number> {
|
|
301
|
+
const root = repoRoot()
|
|
302
|
+
for (let i = 0; i < args.length; i++) {
|
|
303
|
+
const a = args[i]
|
|
304
|
+
if (!a.startsWith('--')) continue
|
|
305
|
+
const name = a.slice(2)
|
|
306
|
+
if (RETRACT_VALUE_FLAGS.has(name)) { i++; continue }
|
|
307
|
+
if (RETRACT_BOOL_FLAGS.has(name)) continue
|
|
308
|
+
console.error(`spex yatsu retract: unknown flag '${a}' — accepts --scenario --last --ts --note`)
|
|
309
|
+
return 2
|
|
310
|
+
}
|
|
311
|
+
const sel = positional(args)
|
|
312
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
313
|
+
if (!id) { console.error('spex yatsu retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
314
|
+
const node = yatsuNodes(root).find((n) => n.id === id)
|
|
315
|
+
if (!node) { console.error(`spex yatsu retract: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
|
|
316
|
+
|
|
317
|
+
// which scenario, resolved exactly as eval resolves it: --scenario, or the sole declared scenario. A
|
|
318
|
+
// reading for a since-deleted scenario is still retractable by naming it — the sidecar knows names the
|
|
319
|
+
// yatsu.md may have dropped.
|
|
320
|
+
const scName = flag(args, 'scenario')
|
|
321
|
+
const declared = node.scenarios.map((s) => s.name)
|
|
322
|
+
const scenario = scName ?? (declared.length === 1 ? declared[0] : undefined)
|
|
323
|
+
if (!scenario) {
|
|
324
|
+
console.error(`spex yatsu retract: '${id}' declares ${declared.length} scenarios — name one with --scenario <name> (declared: ${declared.join(', ')})`)
|
|
325
|
+
return 1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ts = flag(args, 'ts')
|
|
329
|
+
if (ts !== undefined && has(args, 'last')) { console.error('spex yatsu retract: --ts and --last conflict — pin one reading or take the latest, not both'); return 2 }
|
|
330
|
+
const effective = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
331
|
+
if (!effective.length) {
|
|
332
|
+
const { readings } = readSidecar(node.sidecarPath)
|
|
333
|
+
const had = readings.some((r) => r.scenario === scenario)
|
|
334
|
+
console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no ${had ? 'un-retracted ' : ''}reading${had ? ' left' : ''} — nothing to retract`)
|
|
335
|
+
return 1
|
|
336
|
+
}
|
|
337
|
+
const target = ts !== undefined ? effective.find((r) => r.ts === ts) : effective[effective.length - 1]
|
|
338
|
+
if (!target) {
|
|
339
|
+
console.error(`spex yatsu retract: '${id}' scenario '${scenario}' has no un-retracted reading @ ${ts} — readings: ${effective.map((r) => r.ts).join(', ')}`)
|
|
340
|
+
return 1
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const note = flag(args, 'note')
|
|
344
|
+
const retraction: Retraction = {
|
|
345
|
+
retracts: target.ts,
|
|
346
|
+
scenario,
|
|
347
|
+
...(note !== undefined ? { note } : {}),
|
|
348
|
+
...((envSessionId() ?? undefined) ? { by: envSessionId()! } : {}),
|
|
349
|
+
ts: new Date().toISOString(),
|
|
350
|
+
}
|
|
351
|
+
appendRetraction(node.sidecarPath, retraction)
|
|
352
|
+
const left = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
353
|
+
const now = left.length
|
|
354
|
+
? `latest is now ${left[left.length - 1].ts} (${verdictText(left[left.length - 1].verdict)})`
|
|
355
|
+
: 'the scenario is unmeasured again (yatsu-missing)'
|
|
356
|
+
console.log(` ⟲ '${id}' scenario '${scenario}' reading @ ${target.ts} (${verdictText(target.verdict)} [${target.evaluator}]) retracted — ${now}`)
|
|
357
|
+
console.log('spex yatsu retract: 1 reading retracted (an appended event — commit the sidecar so the retraction is attributed)')
|
|
358
|
+
return 0
|
|
359
|
+
}
|
|
360
|
+
|
|
208
361
|
async function clean(args: string[]): Promise<number> {
|
|
209
362
|
const root = repoRoot()
|
|
210
363
|
const all = has(args, 'all')
|
|
@@ -214,7 +367,10 @@ async function clean(args: string[]): Promise<number> {
|
|
|
214
367
|
for (const n of await gatherNodes(root)) {
|
|
215
368
|
const readings = readReadings(n.sidecarPath)
|
|
216
369
|
const keep = keepLatest ? [...latestPerScenario(readings).values()] : readings
|
|
217
|
-
for (const r of keep)
|
|
370
|
+
for (const r of keep) {
|
|
371
|
+
for (const e of evidenceOf(r)) referenced.add(e.hash) // every evidence entry (N images + a video…)
|
|
372
|
+
if (r.timelineBlob) referenced.add(r.timelineBlob) // a video reading's step map lives in the same cache
|
|
373
|
+
}
|
|
218
374
|
}
|
|
219
375
|
}
|
|
220
376
|
const before = listBlobs().length
|
|
@@ -226,6 +382,7 @@ async function clean(args: string[]): Promise<number> {
|
|
|
226
382
|
|
|
227
383
|
function checkStaged(): number {
|
|
228
384
|
const root = repoRoot()
|
|
385
|
+
const tagLibrary = loadConfig(root).scenarioTags
|
|
229
386
|
const staged = stagedFiles(root)
|
|
230
387
|
let bad = false
|
|
231
388
|
|
|
@@ -240,7 +397,7 @@ function checkStaged(): number {
|
|
|
240
397
|
for (const rel of staged.filter((p) => p === YATSU_FILE || p.endsWith('/' + YATSU_FILE))) {
|
|
241
398
|
const abs = join(root, rel)
|
|
242
399
|
if (!existsSync(abs)) continue // staged deletion — nothing to validate
|
|
243
|
-
const errs = validateScenarios(readFileSync(abs, 'utf8'))
|
|
400
|
+
const errs = validateScenarios(readFileSync(abs, 'utf8'), tagLibrary)
|
|
244
401
|
if (!errs.length) continue
|
|
245
402
|
bad = true
|
|
246
403
|
console.error(`✗ SpexCode yatsu: ${rel} — invalid scenario schema:`)
|
|
@@ -261,36 +418,68 @@ async function show(args: string[]): Promise<number> {
|
|
|
261
418
|
return 0
|
|
262
419
|
}
|
|
263
420
|
|
|
264
|
-
// the verdict as a short tag for the terminal: ✓ pass / ✗ fail
|
|
265
|
-
// taken before verdicts existed.
|
|
421
|
+
// the verdict as a short tag for the terminal: ✓ pass / ✗ fail, with ` — <note>` appended when annotated;
|
|
422
|
+
// `legacy` for a reading taken before verdicts existed, `≈ <note>` for a legacy note-only reading.
|
|
266
423
|
function verdictText(v: Verdict | undefined): string {
|
|
267
424
|
if (!v) return 'legacy'
|
|
268
|
-
if (v.status === 'pass') return '✓ pass'
|
|
269
|
-
if (v.status === 'fail') return '✗ fail'
|
|
270
|
-
return `≈
|
|
425
|
+
if (v.status === 'pass') return v.note ? `✓ pass — ${v.note}` : '✓ pass'
|
|
426
|
+
if (v.status === 'fail') return v.note ? `✗ fail — ${v.note}` : '✗ fail'
|
|
427
|
+
return v.note ? `≈ ${v.note}` : 'legacy' // legacy note-only reading
|
|
271
428
|
}
|
|
272
429
|
|
|
273
430
|
export function formatTimeline(tl: EvalTimeline): string {
|
|
274
431
|
if (!tl.hasYatsu) return `spex yatsu show: '${tl.node}' declares no scenarios (no yatsu.md)`
|
|
275
|
-
|
|
432
|
+
// the retraction trace, newest first — the undo stays visible through the same surface that shows readings.
|
|
433
|
+
const retractLines = (tl.retractions ?? []).map((x) =>
|
|
434
|
+
` ⟲ retracted: scenario '${x.scenario}' reading @ ${x.retracts}${x.note ? ` — ${x.note}` : ''}${x.by ? ` by ${x.by}` : ''} ${x.ts}`)
|
|
435
|
+
if (!tl.readings.length) {
|
|
436
|
+
const head = `spex yatsu show: '${tl.node}' has scenarios but no reading yet — run \`spex yatsu eval ${tl.node}\``
|
|
437
|
+
return retractLines.length ? [head, '', ...retractLines].join('\n') : head
|
|
438
|
+
}
|
|
276
439
|
const w = Math.max(...tl.readings.map((r) => r.scenario.length))
|
|
277
440
|
const lines = tl.readings.flatMap((r) => {
|
|
278
441
|
const badge = r.fresh ? '✓ current' : `⚠ stale (${r.staleAxes.join(', ')})`
|
|
279
|
-
|
|
280
|
-
|
|
442
|
+
// the reading's whole evidence list (N images + a video…), each cell its kind + short hash, or the honest
|
|
443
|
+
// sentinel for a pruned/absent one; falls back to the scalar view for a legacy/test EvalEntry.
|
|
444
|
+
const list = r.evidence?.length ? r.evidence
|
|
445
|
+
: r.blob != null ? [{ hash: r.blob, kind: r.blobKind ?? 'image', state: r.blobState }] : []
|
|
446
|
+
const ev = list.length
|
|
447
|
+
? list.map((e) => e.state === 'miss' ? 'miss original file' : `${e.kind} ${(e.hash ?? '').slice(0, 12)}…`).join(', ')
|
|
448
|
+
: 'no evidence'
|
|
281
449
|
const head = ` ${r.scenario.padEnd(w)} ${verdictText(r.verdict)} ${badge} ${r.evaluator} ${r.codeSha.slice(0, 7)} ${ev} ${r.ts}`
|
|
282
450
|
return r.expected ? [head, ` ${' '.repeat(w)} expected: ${r.expected}`] : [head]
|
|
283
451
|
})
|
|
284
|
-
return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines].join('\n')
|
|
452
|
+
return [`spex yatsu show: '${tl.node}' — ${tl.readings.length} reading(s), newest first`, '', ...lines, ...retractLines].join('\n')
|
|
285
453
|
}
|
|
286
454
|
|
|
287
455
|
export async function runYatsu(args: string[]): Promise<number> {
|
|
288
456
|
const sub = args[0]
|
|
289
457
|
if (sub === 'scan') return scan(args.slice(1))
|
|
290
458
|
if (sub === 'eval') return evalCmd(args.slice(1))
|
|
459
|
+
if (sub === 'retract') return retractCmd(args.slice(1))
|
|
291
460
|
if (sub === 'clean') return clean(args.slice(1))
|
|
292
461
|
if (sub === 'show') return show(args.slice(1))
|
|
293
462
|
if (sub === 'check-staged') return checkStaged()
|
|
294
|
-
console.error('spex yatsu: scan [--changed] | eval [.|<node>] [--scenario <name>] (--pass|--fail
|
|
463
|
+
console.error('spex yatsu: scan [--changed] | eval [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>] [--image <path> …repeatable] [--result <path|->] [--video <path> [--timeline <json>]] | retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>] | show [.|<node>] [--json] | clean [--keep-latest|--all]')
|
|
295
464
|
return 2
|
|
296
465
|
}
|
|
466
|
+
|
|
467
|
+
// `spex blob put <file|->` ([[blob-put]]) — the bare transport half of evidence: stash bytes in the shared
|
|
468
|
+
// content-addressed cache and print the hash, WITHOUT filing a reading (`yatsu eval --video` couples the
|
|
469
|
+
// two). putBlob is idempotent by content, so the same command re-seeds a checkout whose cache lacks a blob
|
|
470
|
+
// some thread already references by hash (the clone-evidence-404 repair).
|
|
471
|
+
export function runBlob(args: string[]): number {
|
|
472
|
+
const file = args[1]
|
|
473
|
+
if (args[0] !== 'put' || file === undefined) {
|
|
474
|
+
console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
|
|
475
|
+
return 2
|
|
476
|
+
}
|
|
477
|
+
let bytes: Buffer
|
|
478
|
+
try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
|
|
479
|
+
console.error(`spex blob put: cannot read ${file}: ${(e as Error).message}`)
|
|
480
|
+
return 2
|
|
481
|
+
}
|
|
482
|
+
if (bytes.length === 0) { console.error('spex blob put: refusing an empty blob'); return 2 }
|
|
483
|
+
console.log(putBlob(bytes))
|
|
484
|
+
return 0
|
|
485
|
+
}
|