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
|
@@ -1,26 +1,92 @@
|
|
|
1
1
|
import { relative, dirname } from 'node:path'
|
|
2
2
|
import { repoRoot, driftIndex, historyIndex, type DriftIndex, type HistoryIndex } from '../../spec-cli/src/git.js'
|
|
3
3
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
|
+
import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
|
|
4
5
|
import { yatsuNodes, type YatsuNode } from './yatsu.js'
|
|
5
|
-
import {
|
|
6
|
-
import { staleAxes, type StaleAxis } from './freshness.js'
|
|
6
|
+
import { readSidecar, applyRetractions, evidenceOf, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
7
|
+
import { staleAxes, codeDrift, type StaleAxis } from './freshness.js'
|
|
8
|
+
import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
|
|
7
9
|
import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
|
|
8
10
|
|
|
11
|
+
// one evidence entry as the tab renders it: the content hash, its kind, and its LIVE blob state (present, or
|
|
12
|
+
// miss when the bytes were pruned). The whole list is the gallery the dashboard maps.
|
|
13
|
+
export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present' | 'miss' }
|
|
14
|
+
|
|
15
|
+
// a remark overlaid onto the reading it judged ([[remark-teeth]] R2): the resolvable fields the eval
|
|
16
|
+
// surfaces read, plus `dangling` when its targetCodeSha matched no reading (so it was attached to the
|
|
17
|
+
// scenario's latest as a fallback, never hidden). The teeth read the whole scenario track; THIS is the
|
|
18
|
+
// per-reading display attachment.
|
|
19
|
+
export type RemarkView = {
|
|
20
|
+
rid: string
|
|
21
|
+
ref: string // `<thread-id>#<rid>` — the address `spex resolve`/`spex retract` take
|
|
22
|
+
by: string
|
|
23
|
+
at: string
|
|
24
|
+
body: string
|
|
25
|
+
targetCodeSha: string
|
|
26
|
+
resolved: boolean
|
|
27
|
+
resolvedAt?: string
|
|
28
|
+
resolvedBy?: string
|
|
29
|
+
dangling: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
export type EvalEntry = {
|
|
10
33
|
scenario: string
|
|
11
34
|
expected: string
|
|
12
35
|
codeSha: string
|
|
36
|
+
// the reading's whole evidence list (N images and/or a video and/or a transcript). Always populated —
|
|
37
|
+
// a legacy scalar reading normalizes to a one-entry list — so every read surface sees a gallery.
|
|
38
|
+
evidence?: EvidenceView[]
|
|
39
|
+
// primary scalar view (the video entry if any, else the first) — the single-evidence compat face for
|
|
40
|
+
// consumers that still read one blob (the session proof, the board fold's kind hint).
|
|
13
41
|
blob: string | null
|
|
14
|
-
blobKind?:
|
|
42
|
+
blobKind?: EvidenceKind
|
|
43
|
+
timelineBlob?: string
|
|
15
44
|
evaluator: string
|
|
45
|
+
// the SESSION that filed this reading ([[event-detail]] originator liveness / [[mentions]] loop-in): the
|
|
46
|
+
// reachable actor an un-@'d eval remark courtesy-delivers to (the latest reading's filer is the chain's
|
|
47
|
+
// first link). Surfaced so the eval detail can show whether that session is still alive. Absent on a legacy
|
|
48
|
+
// reading (no `by`) — the pane simply shows no originator, exactly as the offline chain runs dry silently.
|
|
49
|
+
by?: string
|
|
16
50
|
verdict?: Verdict
|
|
17
51
|
ts: string
|
|
18
52
|
fresh: boolean
|
|
19
53
|
staleAxes: StaleAxis[]
|
|
20
54
|
blobState: 'present' | 'miss' | 'none'
|
|
55
|
+
// the code axis's drift detail for a code-stale reading ([[yatsu-core]]'s codeDrift): each governed file
|
|
56
|
+
// that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
|
|
57
|
+
// ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
|
|
58
|
+
codeDrift?: { file: string; behind: number }[]
|
|
59
|
+
// the trunk remark track overlaid onto THIS reading ([[remark-teeth]]): the remarks whose targetCodeSha
|
|
60
|
+
// pins here (or the latest reading, for a dangling target). Absent when the scenario has no remark.
|
|
61
|
+
remarks?: RemarkView[]
|
|
62
|
+
// the (node, scenario) eval-remark THREAD ([[eval-issue-split]]): the SAME join the teeth read, attached
|
|
63
|
+
// so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
|
|
64
|
+
// splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
|
|
65
|
+
thread?: Issue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
|
|
69
|
+
function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkView {
|
|
70
|
+
return {
|
|
71
|
+
rid: rm.rid!,
|
|
72
|
+
ref: `${threadId}#${rm.rid}`,
|
|
73
|
+
by: rm.by, at: rm.at, body: rm.body,
|
|
74
|
+
targetCodeSha: rm.targetCodeSha ?? '',
|
|
75
|
+
resolved: !!rm.resolved,
|
|
76
|
+
...(rm.resolvedAt ? { resolvedAt: rm.resolvedAt } : {}),
|
|
77
|
+
...(rm.resolvedBy ? { resolvedBy: rm.resolvedBy } : {}),
|
|
78
|
+
dangling,
|
|
79
|
+
}
|
|
21
80
|
}
|
|
22
81
|
|
|
23
|
-
|
|
82
|
+
// a DANGLING track ([[remark-teeth]]'s dangling clause / directive 5): a (node, scenario) remark track whose
|
|
83
|
+
// scenario no reading joins — the scenario was renamed or deleted, so today the track loads but surfaces
|
|
84
|
+
// nowhere. It ages NOTHING (there is no reading to stale — the teeth read per-reading), but its remarks must
|
|
85
|
+
// stay VISIBLE and resolvable/retractable via their refs, so evalTimeline emits one synthetic row per orphan
|
|
86
|
+
// at NODE level. `scenario` is the orphaned name (rendered struck-through / gone); `remarks` are all dangling.
|
|
87
|
+
export type DanglingTrack = { scenario: string; threadId: string; thread: Issue; remarks: RemarkView[] }
|
|
88
|
+
|
|
89
|
+
export type ScenarioInfo = { name: string; expected: string; tags?: string[]; code?: string[] }
|
|
24
90
|
|
|
25
91
|
// `hasYatsu` distinguishes a node that declares no scenarios (no yatsu.md) from one that declares some but
|
|
26
92
|
// has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
|
|
@@ -31,6 +97,15 @@ export type EvalTimeline = {
|
|
|
31
97
|
hasYatsu: boolean
|
|
32
98
|
scenarios: ScenarioInfo[]
|
|
33
99
|
readings: EvalEntry[]
|
|
100
|
+
// retraction events ([[yatsu-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
|
|
101
|
+
// above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
|
|
102
|
+
// this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
|
|
103
|
+
// by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
|
|
104
|
+
retractions: Retraction[]
|
|
105
|
+
// orphaned remark tracks (renamed/deleted scenarios) — surfaced at node level so their remarks never vanish
|
|
106
|
+
// ([[remark-teeth]] dangling clause). SEPARATE from `readings` on purpose: a dangling track has no reading,
|
|
107
|
+
// so it must NOT flow into latestPerScenario / the board scoreboard — it ages nothing.
|
|
108
|
+
dangling: DanglingTrack[]
|
|
34
109
|
}
|
|
35
110
|
|
|
36
111
|
export type EvalContext = {
|
|
@@ -38,14 +113,28 @@ export type EvalContext = {
|
|
|
38
113
|
specs: Awaited<ReturnType<typeof loadSpecs>>
|
|
39
114
|
idx: DriftIndex
|
|
40
115
|
hidx: HistoryIndex
|
|
116
|
+
scidx: ScenarioIndex // per-scenario block-change history ([[scenariofresh]]) — the SCENARIO axis, built ONCE per build
|
|
41
117
|
ynodes: YatsuNode[]
|
|
118
|
+
// the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
|
|
119
|
+
// and reused for every node, so the fold never re-reads the issue store per node.
|
|
120
|
+
remarks: Map<string, RemarkTrack>
|
|
42
121
|
}
|
|
43
122
|
|
|
44
123
|
// build the shared context with ONE yatsu walk, reusing the caller's already-computed specs + the two
|
|
45
124
|
// HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
|
|
46
|
-
// hits, loadSpecs already derived them).
|
|
47
|
-
|
|
48
|
-
|
|
125
|
+
// hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
|
|
126
|
+
// ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
|
|
127
|
+
export async function evalContext(
|
|
128
|
+
root: string,
|
|
129
|
+
specs: Awaited<ReturnType<typeof loadSpecs>>,
|
|
130
|
+
idx: DriftIndex,
|
|
131
|
+
hidx: HistoryIndex,
|
|
132
|
+
remarks?: Map<string, RemarkTrack>,
|
|
133
|
+
ynodes?: YatsuNode[], // the hot board build precomputes these off the event loop (yatsuNodesAsync); a bare caller walks sync
|
|
134
|
+
): Promise<EvalContext> {
|
|
135
|
+
const nodes = ynodes ?? yatsuNodes(root)
|
|
136
|
+
const scidx = await scenarioIndex(root, nodes.map((n) => n.yatsuPath))
|
|
137
|
+
return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
|
|
49
138
|
}
|
|
50
139
|
|
|
51
140
|
export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
|
|
@@ -53,37 +142,93 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
53
142
|
// short-circuit a non-yatsu node on the (short) yatsu walk — the board attaches `evals` to every node, so
|
|
54
143
|
// this is the common case and must stay cheap (a list the size of the few yatsu nodes, not the whole tree).
|
|
55
144
|
const ynode = (ctx?.ynodes ?? yatsuNodes(root)).find((n) => n.id === id)
|
|
56
|
-
if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [] }
|
|
145
|
+
if (!ynode) return { node: id, hasYatsu: false, scenarios: [], readings: [], retractions: [], dangling: [] }
|
|
57
146
|
// the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
|
|
58
147
|
// reparent/rename is seen the same way `spex lint` and `spex yatsu eval` see it (joined by directory).
|
|
59
148
|
const specs = ctx?.specs ?? await loadSpecs()
|
|
60
149
|
const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
|
|
61
150
|
const idx = ctx?.idx ?? await driftIndex(root)
|
|
62
151
|
const hidx = ctx?.hidx ?? await historyIndex(root)
|
|
152
|
+
// the SCENARIO axis: per-scenario block-change history, built once per HEAD (cached). A bare call builds it
|
|
153
|
+
// for the WHOLE yatsu set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
|
|
154
|
+
const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? yatsuNodes(root)).map((n) => n.yatsuPath))
|
|
63
155
|
const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
|
|
156
|
+
// the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
|
|
157
|
+
const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
|
|
158
|
+
const remarksFor = (scenario: string): RemarkTrack['remarks'] => tracks.get(trackKey(id, scenario))?.remarks ?? []
|
|
159
|
+
const threadFor = (scenario: string): Issue | undefined => tracks.get(trackKey(id, scenario))?.thread
|
|
64
160
|
const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
|
|
65
|
-
name: s.name, expected: s.expected,
|
|
161
|
+
name: s.name, expected: s.expected,
|
|
162
|
+
...(s.tags?.length ? { tags: s.tags } : {}), ...(s.code?.length ? { code: s.code } : {}),
|
|
66
163
|
}))
|
|
67
|
-
|
|
164
|
+
// one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
|
|
165
|
+
// along as the undo trace (newest-first, like the readings).
|
|
166
|
+
const { readings: rawReadings, retractions } = readSidecar(ynode.sidecarPath)
|
|
167
|
+
const readings: EvalEntry[] = applyRetractions(rawReadings, retractions).map((r) => {
|
|
68
168
|
// a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
|
|
69
169
|
const sc = byName.get(r.scenario)
|
|
70
|
-
|
|
170
|
+
// the teeth feed the WHOLE scenario track against THIS reading — an unresolved (or not-yet-out-run)
|
|
171
|
+
// remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
|
|
172
|
+
// read-time overlay below; freshness never depends on that pin.
|
|
173
|
+
const cf = sc?.code?.length ? sc.code : codeFiles
|
|
174
|
+
const axes = staleAxes(r, cf, ynode.yatsuPath, idx, scidx,
|
|
175
|
+
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })))
|
|
176
|
+
// when the code axis is stale, explain it: which of THIS reading's governed files moved, by how many commits.
|
|
177
|
+
const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf) : []
|
|
178
|
+
// the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
|
|
179
|
+
// first) drives the scalar compat fields for single-evidence consumers.
|
|
180
|
+
const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
|
|
181
|
+
const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
|
|
71
182
|
return {
|
|
72
183
|
scenario: r.scenario,
|
|
73
184
|
expected: byName.get(r.scenario)?.expected ?? '',
|
|
74
185
|
codeSha: r.codeSha,
|
|
75
|
-
|
|
76
|
-
|
|
186
|
+
...(evidence.length ? { evidence } : {}),
|
|
187
|
+
blob: primary?.hash ?? null,
|
|
188
|
+
...(primary ? { blobKind: primary.kind } : {}),
|
|
189
|
+
...(r.timelineBlob ? { timelineBlob: r.timelineBlob } : {}),
|
|
77
190
|
evaluator: r.evaluator,
|
|
191
|
+
...(r.by ? { by: r.by } : {}),
|
|
78
192
|
...(r.verdict ? { verdict: r.verdict } : {}),
|
|
79
193
|
ts: r.ts,
|
|
80
194
|
fresh: axes.length === 0,
|
|
81
195
|
staleAxes: axes,
|
|
82
|
-
|
|
196
|
+
...(drift.length ? { codeDrift: drift } : {}),
|
|
197
|
+
blobState: primary ? primary.state : 'none',
|
|
198
|
+
...(threadFor(r.scenario) ? { thread: threadFor(r.scenario) } : {}),
|
|
83
199
|
}
|
|
84
200
|
})
|
|
85
|
-
readings.reverse()
|
|
86
|
-
|
|
201
|
+
readings.reverse() // newest-first
|
|
202
|
+
// R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
|
|
203
|
+
// else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the
|
|
204
|
+
// remark. A track whose SCENARIO no reading joins (renamed/deleted) has no reading to attach to, so it
|
|
205
|
+
// becomes a synthetic DANGLING row at node level (directive 5) instead of vanishing — visible, its remarks
|
|
206
|
+
// resolvable/retractable via their refs, and ageing nothing (there is no reading for the teeth to stale).
|
|
207
|
+
const declared = new Set(ynode.scenarios.map((s) => s.name))
|
|
208
|
+
const dangling: DanglingTrack[] = []
|
|
209
|
+
for (const [, track] of tracks) {
|
|
210
|
+
if (track.node !== id || !track.remarks.length) continue
|
|
211
|
+
const rows = readings.filter((r) => r.scenario === track.scenario)
|
|
212
|
+
if (!rows.length) {
|
|
213
|
+
// no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
|
|
214
|
+
// not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from yatsu.md AND
|
|
215
|
+
// has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
|
|
216
|
+
if (!declared.has(track.scenario)) {
|
|
217
|
+
dangling.push({
|
|
218
|
+
scenario: track.scenario, threadId: track.threadId, thread: track.thread,
|
|
219
|
+
remarks: track.remarks.map((rm) => toRemarkView(rm, track.threadId, true)),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const latest = rows[0]
|
|
225
|
+
for (const rm of track.remarks) {
|
|
226
|
+
const target = rows.find((r) => r.codeSha === rm.targetCodeSha)
|
|
227
|
+
const host = target ?? latest
|
|
228
|
+
;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { node: id, hasYatsu: true, scenarios, readings, retractions: [...retractions].reverse(), dangling }
|
|
87
232
|
}
|
|
88
233
|
|
|
89
234
|
const HEX64 = /^[0-9a-f]{64}$/
|
|
@@ -99,13 +244,18 @@ export function readBlobByHash(hash: string, dir?: string): BlobResult {
|
|
|
99
244
|
return { ok: true, bytes, mime: sniffBlobMime(bytes) }
|
|
100
245
|
}
|
|
101
246
|
|
|
102
|
-
// PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); a
|
|
103
|
-
//
|
|
104
|
-
//
|
|
247
|
+
// PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); MP4/WebM cover a recorded clip (--video), so
|
|
248
|
+
// the blob route serves it with a playable Content-Type; a transcript (--result) is text, so bytes with no
|
|
249
|
+
// NUL and no known header sniff to text/plain; anything else falls back to a generic binary type so it still
|
|
250
|
+
// downloads rather than being mislabeled.
|
|
105
251
|
function sniffBlobMime(b: Buffer): string {
|
|
106
252
|
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
|
|
107
253
|
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
|
108
254
|
if (b.length >= 4 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif'
|
|
255
|
+
// WebM/Matroska begin with the EBML magic 1A 45 DF A3; disambiguate from a RIFF/WEBP image above.
|
|
256
|
+
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return 'video/webm'
|
|
257
|
+
// ISO-BMFF (MP4/MOV): a `ftyp` box type at bytes 4..8, after its 4-byte size.
|
|
258
|
+
if (b.length >= 12 && b.toString('ascii', 4, 8) === 'ftyp') return 'video/mp4'
|
|
109
259
|
if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
|
110
260
|
if (b.length && !b.includes(0)) return 'text/plain; charset=utf-8'
|
|
111
261
|
return 'application/octet-stream'
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { repoRoot, headSha } from '../../spec-cli/src/git.js'
|
|
2
|
+
import { yatsuNodes } from './yatsu.js'
|
|
3
|
+
import { appendReading, readReadings, type Reading } from './sidecar.js'
|
|
4
|
+
import { putBlob } from './cache.js'
|
|
5
|
+
|
|
6
|
+
export type FileResult = { ok: true; reading: Reading } | { ok: false; error: string }
|
|
7
|
+
|
|
8
|
+
// the eval seam over DATA (no argv, no file paths): a caller with a verdict in hand — the HTTP eval route,
|
|
9
|
+
// a programmatic filer — appends through the SAME seam the CLI uses. Optional evidence arrives as text (a
|
|
10
|
+
// report referencing the clip by hash) → a transcript blob in the same content-addressed cache; the
|
|
11
|
+
// evaluator is the human hand, manual@1. yatsu still runs nothing — this only records. The dashboard files
|
|
12
|
+
// nothing through this: [[event-detail]] is read-side on readings.
|
|
13
|
+
export function fileHumanReading(
|
|
14
|
+
nodeId: string,
|
|
15
|
+
input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
|
|
16
|
+
): FileResult {
|
|
17
|
+
const root = repoRoot()
|
|
18
|
+
const node = yatsuNodes(root).find((n) => n.id === nodeId)
|
|
19
|
+
if (!node) return { ok: false, error: `no yatsu node '${nodeId}' (a node needs a yatsu.md)` }
|
|
20
|
+
const sc = node.scenarios.find((s) => s.name === input.scenario)
|
|
21
|
+
if (!sc) return { ok: false, error: `'${nodeId}' has no scenario '${input.scenario}'` }
|
|
22
|
+
if (input.status !== 'pass' && input.status !== 'fail') return { ok: false, error: 'status must be pass or fail' }
|
|
23
|
+
const blob = input.transcript ? putBlob(Buffer.from(input.transcript)) : null
|
|
24
|
+
const reading: Reading = {
|
|
25
|
+
scenario: sc.name,
|
|
26
|
+
codeSha: headSha(root),
|
|
27
|
+
...(blob ? { evidence: [{ hash: blob, kind: 'transcript' as const }] } : {}),
|
|
28
|
+
evaluator: 'manual@1',
|
|
29
|
+
// the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
|
|
30
|
+
// there and the eval-comment loop-in is silent, per [[mentions]])
|
|
31
|
+
...(input.by ? { by: input.by } : {}),
|
|
32
|
+
verdict: { status: input.status, ...(input.note ? { note: input.note } : {}) },
|
|
33
|
+
ts: new Date().toISOString(),
|
|
34
|
+
}
|
|
35
|
+
appendReading(node.sidecarPath, reading)
|
|
36
|
+
return { ok: true, reading }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The session that filed the LATEST reading for (node, scenario) — the ORIGINATOR an eval-comment thread
|
|
40
|
+
// loops in on a reply ([[mentions]] implicit loop-in). Null when the node/scenario has no reading, or the
|
|
41
|
+
// latest reading is legacy (no `by`). Store-agnostic: the caller resolves this id to a live session or nobody.
|
|
42
|
+
export function evalReadingFiler(nodeId: string, scenario: string): string | null {
|
|
43
|
+
const root = repoRoot()
|
|
44
|
+
const node = yatsuNodes(root).find((n) => n.id === nodeId)
|
|
45
|
+
if (!node) return null
|
|
46
|
+
const forScenario = readReadings(node.sidecarPath).filter((r) => r.scenario === scenario)
|
|
47
|
+
return forScenario.length ? forScenario[forScenario.length - 1].by ?? null : null
|
|
48
|
+
}
|
|
@@ -1,29 +1,61 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ancestorsOf, inAncestors, type DriftIndex } from '../../spec-cli/src/git.js'
|
|
2
2
|
import type { Reading } from './sidecar.js'
|
|
3
3
|
import { isEvaluatorStale } from './evaluator.js'
|
|
4
|
+
import { scenarioChangeCommits, type ScenarioIndex } from './scenariofresh.js'
|
|
4
5
|
|
|
5
6
|
// the CODE axis is touch-based (DriftIndex), so a code-file rename is out of scope — the same blind spot lint's code-drift has
|
|
6
7
|
|
|
7
|
-
export type StaleAxis = 'code' | 'scenario' | 'evaluator'
|
|
8
|
+
export type StaleAxis = 'code' | 'scenario' | 'evaluator' | 'remark'
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// the REMARK axis's input ([[remark-teeth]]): the teeth read only the resolvable bit + when it was resolved,
|
|
11
|
+
// not the whole remark — so freshness stays a PURE function, fed the scenario's remark track at the call
|
|
12
|
+
// sites (never reaching into the issue store). One signal per remark on the (node, scenario).
|
|
13
|
+
export type RemarkSignal = { resolved: boolean; resolvedAt?: string }
|
|
14
|
+
|
|
15
|
+
// the teeth (T1): a scenario is remark-stale unless EVERY remark is resolved AND this reading post-dates
|
|
16
|
+
// every resolution. So an UNRESOLVED remark ages it; a RESOLVED remark keeps it stale until a reading taken
|
|
17
|
+
// strictly after the resolve (reading.ts > resolvedAt) exists — you can't out-run a remark by re-measuring
|
|
18
|
+
// before the resolve, nor clear it by passive receipt. A resolved bit with no timestamp stays conservatively
|
|
19
|
+
// stale (defensive: resolveRemark always stamps one).
|
|
20
|
+
export function remarkStale(reading: { ts: string }, remarks: RemarkSignal[]): boolean {
|
|
21
|
+
return remarks.some((r) => !r.resolved || !(r.resolvedAt && reading.ts > r.resolvedAt))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// true iff some commit touched `path` that is NOT an ancestor of `sinceSha` — i.e. it lies in
|
|
25
|
+
// `sinceSha..HEAD` by true DAG reachability, never a log-position/date compare (which under-reports on
|
|
26
|
+
// branchy history). ONE conservative rule for an off-history `sinceSha` — whether rebased away (orphan)
|
|
27
|
+
// or on a reachable-but-unmerged branch: we can't prove freshness from HEAD's history, so it reads
|
|
28
|
+
// stale rather than silently pass.
|
|
12
29
|
export function changedSince(idx: DriftIndex, sinceSha: string, path: string): boolean {
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
const anc = ancestorsOf(idx, sinceSha)
|
|
31
|
+
if (!anc) return true
|
|
32
|
+
return (idx.fileCommits.get(path) ?? []).some((h) => !inAncestors(idx, anc, h))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// the code axis's DISPLAY detail: which governed files drifted since a reading, and by HOW MANY commits — so
|
|
36
|
+
// a stale eval can say "EvalsFeed.jsx +3" instead of a bare "code moved". Same DAG reachability as
|
|
37
|
+
// changedSince (a commit touching the file that is NOT an ancestor of the reading's sha lies in sinceSha..HEAD);
|
|
38
|
+
// an off-history sinceSha counts every touch (conservative, matching changedSince's stale-rather-than-pass rule).
|
|
39
|
+
// Reporting only — it never decides freshness (staleAxes does); it explains a decision already made.
|
|
40
|
+
export function codeDrift(idx: DriftIndex, sinceSha: string, codeFiles: string[]): { file: string; behind: number }[] {
|
|
41
|
+
const anc = ancestorsOf(idx, sinceSha)
|
|
42
|
+
const out: { file: string; behind: number }[] = []
|
|
43
|
+
for (const f of codeFiles) {
|
|
44
|
+
const commits = idx.fileCommits.get(f) ?? []
|
|
45
|
+
const behind = anc ? commits.filter((h) => !inAncestors(idx, anc, h)).length : commits.length
|
|
46
|
+
if (behind > 0) out.push({ file: f, behind })
|
|
18
47
|
}
|
|
19
|
-
return
|
|
48
|
+
return out
|
|
20
49
|
}
|
|
21
50
|
|
|
22
|
-
// scenario freshness
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
// scenario freshness is PER-SCENARIO, not per-file: a reading stales only when ITS OWN scenario block moved
|
|
52
|
+
// (edited/added/removed) in scenarioSha..HEAD, never when a sibling in the same yatsu.md did. Reads exactly
|
|
53
|
+
// like the code axis's changedSince — the per-scenario change-commits ([[scenariofresh]], rename-followed so a
|
|
54
|
+
// bare git-mv reparent isn't a change) tested for ancestry — off-history codeSha → conservatively stale.
|
|
55
|
+
function scenarioMoved(scIdx: ScenarioIndex, didx: DriftIndex, sinceSha: string, yatsuPath: string, scenario: string): boolean {
|
|
56
|
+
const anc = ancestorsOf(didx, sinceSha)
|
|
57
|
+
if (!anc) return true
|
|
58
|
+
return scenarioChangeCommits(scIdx, yatsuPath, scenario).some((h) => !inAncestors(didx, anc, h))
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
export function staleAxes(
|
|
@@ -31,12 +63,14 @@ export function staleAxes(
|
|
|
31
63
|
codeFiles: string[],
|
|
32
64
|
yatsuPath: string,
|
|
33
65
|
didx: DriftIndex,
|
|
34
|
-
|
|
66
|
+
scIdx: ScenarioIndex,
|
|
67
|
+
remarks: RemarkSignal[] = [],
|
|
35
68
|
): StaleAxis[] {
|
|
36
69
|
const axes: StaleAxis[] = []
|
|
37
70
|
if (codeFiles.some((f) => changedSince(didx, reading.codeSha, f))) axes.push('code')
|
|
38
|
-
if (scenarioMoved(
|
|
71
|
+
if (scenarioMoved(scIdx, didx, reading.codeSha, yatsuPath, reading.scenario)) axes.push('scenario')
|
|
39
72
|
if (isEvaluatorStale(reading.evaluator)) axes.push('evaluator')
|
|
73
|
+
if (remarkStale(reading, remarks)) axes.push('remark')
|
|
40
74
|
return axes
|
|
41
75
|
}
|
|
42
76
|
|
|
@@ -45,7 +79,8 @@ export function isStale(
|
|
|
45
79
|
codeFiles: string[],
|
|
46
80
|
yatsuPath: string,
|
|
47
81
|
didx: DriftIndex,
|
|
48
|
-
|
|
82
|
+
scIdx: ScenarioIndex,
|
|
83
|
+
remarks: RemarkSignal[] = [],
|
|
49
84
|
): boolean {
|
|
50
|
-
return staleAxes(reading, codeFiles, yatsuPath, didx,
|
|
85
|
+
return staleAxes(reading, codeFiles, yatsuPath, didx, scIdx, remarks).length > 0
|
|
51
86
|
}
|
package/spec-yatsu/src/proof.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type ProofReading = {
|
|
|
23
23
|
ts: string
|
|
24
24
|
evidence:
|
|
25
25
|
| { kind: 'image'; dataUri: string }
|
|
26
|
+
| { kind: 'video'; dataUri: string }
|
|
26
27
|
| { kind: 'transcript'; text: string }
|
|
27
28
|
| { kind: 'miss' }
|
|
28
29
|
| { kind: 'none' }
|
|
@@ -78,7 +79,8 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
78
79
|
// root the eval context at the SESSION's worktree (its branch's readings/freshness), not the backend checkout which would show main's; specs stay backend-shared (paths/titles/hues), only readings + drift are per-worktree
|
|
79
80
|
const wtPath = worktreePathForBranch(payload.branch)
|
|
80
81
|
const ctxRoot = wtPath ?? repoRoot()
|
|
81
|
-
const
|
|
82
|
+
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
83
|
+
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
82
84
|
|
|
83
85
|
// enrich each changed file with its unified diff + full before/after content (derived from the session
|
|
84
86
|
// worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
|
|
@@ -156,7 +158,6 @@ export async function buildProofModel(id: string): Promise<ProofModel | null> {
|
|
|
156
158
|
function gateRows(p: NonNullable<Awaited<ReturnType<typeof reviewPayload>>>): ProofGate[] {
|
|
157
159
|
const g = p.gates
|
|
158
160
|
return [
|
|
159
|
-
{ label: 'typecheck', ok: g.typecheck.ok, detail: g.typecheck.ok ? 'clean' : `${g.typecheck.errorCount} error(s)` },
|
|
160
161
|
{ label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
|
|
161
162
|
{ label: 'merge', ok: !g.conflictsWithMain, detail: g.conflictsWithMain ? 'conflicts with main' : 'no conflict' },
|
|
162
163
|
{ label: 'ahead', ok: p.ahead > 0, detail: `${p.ahead} commit(s) ahead of main` },
|
|
@@ -198,6 +199,7 @@ async function toProofReading(r: EvalEntry): Promise<ProofReading> {
|
|
|
198
199
|
const blob = readBlobByHash(r.blob)
|
|
199
200
|
if (!blob.ok) return { ...base, evidence: { kind: 'miss' } }
|
|
200
201
|
if (blob.mime.startsWith('image/')) return { ...base, evidence: { kind: 'image', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } }
|
|
202
|
+
if (blob.mime.startsWith('video/')) return { ...base, evidence: { kind: 'video', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } }
|
|
201
203
|
return { ...base, evidence: { kind: 'transcript', text: blob.bytes.toString('utf8') } }
|
|
202
204
|
}
|
|
203
205
|
|
|
@@ -306,11 +308,12 @@ function renderReading(r: ProofReading): string {
|
|
|
306
308
|
const ev = r.evidence
|
|
307
309
|
const body =
|
|
308
310
|
ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
|
|
311
|
+
: ev.kind === 'video' ? `<video class="shot" src="${ev.dataUri}" controls preload="metadata"></video>`
|
|
309
312
|
: ev.kind === 'transcript' ? `<pre class="transcript">${esc(ev.text)}</pre>`
|
|
310
313
|
: ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
|
|
311
314
|
: `<div class="noev">attested without a capture</div>`
|
|
312
315
|
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since the reading">stale</span>`
|
|
313
|
-
const note = r.verdict?.
|
|
316
|
+
const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : ''
|
|
314
317
|
return `<div class="reading">
|
|
315
318
|
<div class="rhead">
|
|
316
319
|
${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
|
|
@@ -489,3 +492,86 @@ h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:upperca
|
|
|
489
492
|
.glabel{font-weight:600;width:120px;color:#dbe6f2}.gdetail{color:var(--dim);font:12px/1 ui-monospace,monospace}
|
|
490
493
|
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
|
|
491
494
|
`
|
|
495
|
+
|
|
496
|
+
// ---- the session EVAL model ([[review-proof]]'s interactive face) ----
|
|
497
|
+
// The lean, TIERED counterpart of buildProofModel: the same worktree-rooted marshaling, but rows only —
|
|
498
|
+
// no diff enrichment, no inlined evidence bytes (the dashboard's Eval tab rides the shared eval
|
|
499
|
+
// components: blobs stream lazily from /api/yatsu/blob on open). Each reading carries `inSession`
|
|
500
|
+
// (this session filed it, or its codeSha is one of the branch's own commits — a diagnostic session
|
|
501
|
+
// that changed no code still owns the readings it filed at the merge-base) so the tab can lead with
|
|
502
|
+
// what THIS session measured, ✦-marked, over the inherited baseline.
|
|
503
|
+
export type SessionEvalNode = {
|
|
504
|
+
id: string
|
|
505
|
+
title: string
|
|
506
|
+
hue: number
|
|
507
|
+
desc: string
|
|
508
|
+
hasYatsu: boolean
|
|
509
|
+
uncoveredFrontend: boolean
|
|
510
|
+
scenarios: { name: string; expected: string; tags?: string[] }[]
|
|
511
|
+
// each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
|
|
512
|
+
// 3): the server-side join (attached by evalTimeline as `EvalEntry.thread`), so the session tab's event
|
|
513
|
+
// detail reads the comment/remark track directly instead of re-matching a concern key client-side. Absent
|
|
514
|
+
// until the pair has its first remark.
|
|
515
|
+
evals: (EvalEntry & { inSession: boolean })[]
|
|
516
|
+
}
|
|
517
|
+
export type SessionEvals = {
|
|
518
|
+
id: string
|
|
519
|
+
node: string | null
|
|
520
|
+
branch: string | null
|
|
521
|
+
title: string
|
|
522
|
+
ahead: number
|
|
523
|
+
dirtyNonRuntime: number
|
|
524
|
+
gates: ProofGate[]
|
|
525
|
+
nodes: SessionEvalNode[]
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
|
|
529
|
+
const payload = await reviewPayload(id)
|
|
530
|
+
if (!payload) return null
|
|
531
|
+
const specs = await loadSpecs()
|
|
532
|
+
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
533
|
+
const wtPath = worktreePathForBranch(payload.branch)
|
|
534
|
+
const ctxRoot = wtPath ?? repoRoot()
|
|
535
|
+
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
536
|
+
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
537
|
+
// this session's own commits — the membership test behind `inSession`
|
|
538
|
+
const shas = wtPath ? new Set((await gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`])).split('\n').filter(Boolean)) : new Set<string>()
|
|
539
|
+
|
|
540
|
+
const ids = new Set<string>()
|
|
541
|
+
for (const f of payload.diff) { const nid = nodeForFile(f.path, specs, payload.node); if (nid) ids.add(nid) }
|
|
542
|
+
if (payload.node && specById.has(payload.node)) ids.add(payload.node)
|
|
543
|
+
|
|
544
|
+
const nodes: SessionEvalNode[] = []
|
|
545
|
+
for (const nid of ids) {
|
|
546
|
+
const spec = specById.get(nid)
|
|
547
|
+
const tl = await evalTimeline(nid, ctx)
|
|
548
|
+
nodes.push({
|
|
549
|
+
id: nid,
|
|
550
|
+
title: spec?.title ?? nid,
|
|
551
|
+
hue: spec?.hue ?? 210,
|
|
552
|
+
desc: spec?.desc ?? '',
|
|
553
|
+
hasYatsu: tl.hasYatsu,
|
|
554
|
+
uncoveredFrontend: !tl.hasYatsu && (spec?.code ?? []).some(isUiPath),
|
|
555
|
+
scenarios: tl.scenarios,
|
|
556
|
+
// the per-scenario trunk thread rides each reading as `EvalEntry.thread` (evalTimeline's overlay), so
|
|
557
|
+
// the event detail has the comment/remark track inline — no extra join here. A reading is the
|
|
558
|
+
// session's own when it filed it (`by`) OR when its codeSha is a branch commit — filing alone counts,
|
|
559
|
+
// else a session that measured without committing code reads as if it did nothing.
|
|
560
|
+
evals: tl.readings.map((r) => ({ ...r, inSession: r.by === id || shas.has(r.codeSha) })),
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
// nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
|
|
564
|
+
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length) || (b.evals.length - a.evals.length))
|
|
565
|
+
|
|
566
|
+
const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
|
|
567
|
+
return {
|
|
568
|
+
id,
|
|
569
|
+
node: payload.node,
|
|
570
|
+
branch: payload.branch,
|
|
571
|
+
title: primary || payload.node || payload.branch || id.slice(0, 8),
|
|
572
|
+
ahead: payload.ahead,
|
|
573
|
+
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
574
|
+
gates: gateRows(payload),
|
|
575
|
+
nodes,
|
|
576
|
+
}
|
|
577
|
+
}
|