spexcode 0.5.8 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
} catch (e) { /* localStorage unavailable — the bare :root default (Minimal) stands */ }
|
|
21
21
|
})()
|
|
22
22
|
</script>
|
|
23
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
23
|
+
<script type="module" crossorigin src="/assets/index-paP-z_Vd.js"></script>
|
|
24
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DAbQBBK_.css">
|
|
25
25
|
</head>
|
|
26
26
|
<body>
|
|
27
27
|
<div id="root"></div>
|
|
@@ -38,8 +38,7 @@ export const sessionZone = (s) => {
|
|
|
38
38
|
return NEED_STATUS.has(s?.status) ? 'need' : 'run'
|
|
39
39
|
}
|
|
40
40
|
export const ZONE_ORDER = ['need', 'run', 'offline']
|
|
41
|
-
|
|
42
|
-
// splits the default working population from true archived/offline rows before applying any status/nesting fold.
|
|
41
|
+
|
|
43
42
|
export const isArchived = (s) => !!s?.archived
|
|
44
43
|
export const splitArchived = (sessions = []) => ({
|
|
45
44
|
live: sessions.filter((s) => !isArchived(s)),
|
|
@@ -91,11 +90,6 @@ export const sessionHandle = (s) =>
|
|
|
91
90
|
export const sessionHeadline = (s) =>
|
|
92
91
|
s?.headline || s?.name || s?.activity || s?.promptPreview || s?.node || s?.title || s?.branch || s?.id
|
|
93
92
|
|
|
94
|
-
// @@@ session nesting ([[session-nesting]]) — a session launched by `spex new` from INSIDE another carries
|
|
95
|
-
// that spawner's id as `parent`. Fold it into a forest, DERIVED here at read time (never stored on the child):
|
|
96
|
-
// a child nests under its parent ONLY IF that parent is present in this list, so a closed parent's children
|
|
97
|
-
// auto-promote to top-level on the next board read. Returns the top-level `roots` (a real parent or an orphan
|
|
98
|
-
// whose parent is gone) and `childrenOf` (parentId → its direct children), both recursive to any depth.
|
|
99
93
|
export function nestSessions(sessions) {
|
|
100
94
|
const present = new Set(sessions.map((s) => s?.id))
|
|
101
95
|
const childrenOf = new Map()
|
|
@@ -125,11 +119,6 @@ export function sessionAncestorIds(sessions, id) {
|
|
|
125
119
|
return ids
|
|
126
120
|
}
|
|
127
121
|
|
|
128
|
-
// @@@ subtree rollup ([[session-nesting]]) — the count-badge COLOUR: a PURELY informational summary of the
|
|
129
|
-
// hidden subtree that must NOT touch the parent's own status/glyph/zone/sort. Dark-yellow if ANY descendant
|
|
130
|
-
// needs attention (the needs-you zone, error folded in — the widest signal wins); else green if any descendant
|
|
131
|
-
// is actively running (a STATUS_COLOR-green status: working/parked); else neutral (all idle/offline). Reuses
|
|
132
|
-
// the STATUS_COLOR hues so the badge speaks the same four-hue language as every other status mark.
|
|
133
122
|
export function subtreeRollup(id, childrenOf) {
|
|
134
123
|
let need = false, run = false, count = 0
|
|
135
124
|
const walk = (pid, seen) => {
|
|
@@ -146,22 +135,6 @@ export function subtreeRollup(id, childrenOf) {
|
|
|
146
135
|
return { color: need ? STATUS_COLOR.asking : run ? STATUS_COLOR.working : STATUS_COLOR.idle, count }
|
|
147
136
|
}
|
|
148
137
|
|
|
149
|
-
// @@@ the ordered render list ([[session-nesting]]) all desktop/mobile session-list surfaces share. Roots are zone-sorted by
|
|
150
|
-
// their OWN status (no aggregation), each carrying a zone header when the zone changes; a parent's children
|
|
151
|
-
// follow it (zone-sorted among themselves) ONLY when `isExpanded(id)` — collapsed by default, so a fleet reads
|
|
152
|
-
// as one row. Emits {type:'zone',zone} and {type:'row', s, depth, expandable, expanded, rollup, guides}; the
|
|
153
|
-
// visible row order is also what ↑/↓ nav and drag-reorder walk, so a collapsed child is never a hidden nav
|
|
154
|
-
// target. `guides` is the file-tree rail vector, one bool per connector column (length === depth): the LAST
|
|
155
|
-
// entry marks whether THIS row has a following sibling (branch tee vs end elbow), each earlier entry whether
|
|
156
|
-
// the ancestor in that column continues (draw a pass-through vertical line vs blank).
|
|
157
|
-
//
|
|
158
|
-
// @@@ offline-history fold ([[session-console]]) — the OFFLINE zone rests folded behind its header, the one
|
|
159
|
-
// disclosure for retained session history: `zoneFolded(zone)` names the folded zones (per-surface state; the
|
|
160
|
-
// product folds only 'offline', collapsed by default), and a folded zone emits its header — enriched with the
|
|
161
|
-
// hidden `count` and `folded: true` so the surface renders it as the disclosure — but none of its rows,
|
|
162
|
-
// EXCEPT any row `keepVisible(s)` claims (the current selection / graph lock): a session reached by URL,
|
|
163
|
-
// search, or a menu stays a visible row even while its zone is folded. Presentation only — no record is
|
|
164
|
-
// touched — and never applicable to the needs-you / running zones, whose rows this fold cannot hide.
|
|
165
138
|
export function sessionForest(sessions, isExpanded, { zoneFolded = () => false, keepVisible = () => false } = {}) {
|
|
166
139
|
const { roots, childrenOf } = nestSessions(sessions)
|
|
167
140
|
const items = []
|
package/spec-eval/src/cache.ts
CHANGED
|
@@ -5,19 +5,12 @@ import { gitCommonDir } from '../../spec-cli/src/layout.js'
|
|
|
5
5
|
|
|
6
6
|
export const MISS_BLOB = 'miss original file'
|
|
7
7
|
|
|
8
|
-
// every cache fn takes an optional `dir` (defaulting to the live cache dir) so the logic is testable
|
|
9
|
-
// against a temp dir without a git repo. The dir is a PER-CLONE cache (never tracked): renaming it
|
|
10
|
-
// (yatsu-blobs → evidence, v0.3.0) migrated no bytes — a missing blob re-fills via `spex evidence put`
|
|
11
|
-
// (putBlob is idempotent by content) or streams from the backend on a `get` miss.
|
|
12
8
|
export function cacheDir(): string {
|
|
13
9
|
return join(gitCommonDir(), 'spexcode', 'evidence')
|
|
14
10
|
}
|
|
15
11
|
|
|
16
|
-
// a content-addressed blob name = the sha256 of its bytes (64 hex). The backstop recognises a stray one.
|
|
17
12
|
const BLOB_NAME = /^[0-9a-f]{64}$/
|
|
18
13
|
|
|
19
|
-
// store bytes, return the content hash (the value recorded as a reading's `blob`). Idempotent: identical
|
|
20
|
-
// content maps to the same name, written once.
|
|
21
14
|
export function putBlob(bytes: Buffer, dir = cacheDir()): string {
|
|
22
15
|
const sha = createHash('sha256').update(bytes).digest('hex')
|
|
23
16
|
mkdirSync(dir, { recursive: true })
|
|
@@ -34,14 +27,11 @@ export function hasBlob(sha: string | null, dir = cacheDir()): boolean {
|
|
|
34
27
|
return !!sha && existsSync(blobPath(sha, dir))
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
// render a reading's blob reference: the cache path when present, the MISS sentinel when the record
|
|
38
|
-
// outlived its bytes, '' when the reading had no image at all (a pixel-less observation).
|
|
39
30
|
export function resolveBlob(sha: string | null, dir = cacheDir()): string {
|
|
40
31
|
if (!sha) return ''
|
|
41
32
|
return hasBlob(sha, dir) ? blobPath(sha, dir) : MISS_BLOB
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
// every content-addressed blob currently in the cache.
|
|
45
35
|
export function listBlobs(dir = cacheDir()): string[] {
|
|
46
36
|
if (!existsSync(dir)) return []
|
|
47
37
|
return readdirSync(dir).filter((n) => BLOB_NAME.test(n)).sort()
|
|
@@ -57,14 +47,10 @@ export function gc(keep: Set<string>, dir = cacheDir()): string[] {
|
|
|
57
47
|
return removed
|
|
58
48
|
}
|
|
59
49
|
|
|
60
|
-
// read a blob's bytes (used by callers that render/serve a reading's pixels); null when absent.
|
|
61
50
|
export function getBlob(sha: string | null, dir = cacheDir()): Buffer | null {
|
|
62
51
|
return hasBlob(sha, dir) ? readFileSync(blobPath(sha!, dir)) : null
|
|
63
52
|
}
|
|
64
53
|
|
|
65
|
-
// is a staged repo path a stray content-addressed blob? (a 64-hex basename, or anything under a copied-in
|
|
66
|
-
// evidence cache dir — the retired `yatsu-blobs` name included, since a stray copy of an ARCHIVED cache is
|
|
67
|
-
// the same mistake). The pre-commit backstop rejects these so pixels never leak into git history.
|
|
68
54
|
export function isStrayBlob(path: string): boolean {
|
|
69
55
|
const base = path.slice(path.lastIndexOf('/') + 1)
|
|
70
56
|
return BLOB_NAME.test(base) || path.includes('spexcode/evidence/') || path.includes('/yatsu-blobs/') // dead-words-ok: archived cache dir name — a stray copy of the retired cache is still rejected
|
package/spec-eval/src/cli.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { trackedSourceFiles } from '../../spec-cli/src/source-files.js'
|
|
|
7
7
|
import { mainBranch, envSessionId, readRawRecord } from '../../spec-cli/src/layout.js'
|
|
8
8
|
import { evalNodes, evalNodesAt, validateScenarios, resolveEvalNode, scenarioCodeAxis, scenarioHash, scenarioProjection, writeScenarioMeasurementMetadata, EVAL_FILE, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
9
9
|
import { readReadings, readSidecar, appendReading, appendRetraction, latestPerScenario, evidenceOf, isJsonBlob, type Reading, type Verdict, type Evidence, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
10
|
-
import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems } from './freshness.js'
|
|
10
|
+
import { staleAxes, contentProbeFor, anchorProbeFor, anchorProblems, type AnchorDemand } from './freshness.js'
|
|
11
11
|
import { parseRelation, relationClaimsPath } from '../../spec-cli/src/anchors.js'
|
|
12
12
|
import { scenarioIndex } from './scenariofresh.js'
|
|
13
13
|
import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
|
|
@@ -138,6 +138,27 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
138
138
|
const yByDir = new Map(evalNodes(root).map((n) => [relative(root, n.dir), n]))
|
|
139
139
|
const nodeDirs = specs.map((s) => dirname(s.path))
|
|
140
140
|
let flaggedNodes = 0, malformed = 0, staleScores = 0, missingScores = 0, uncovered = 0, danglingTracks = 0
|
|
141
|
+
// One anchor prime for the whole scan. Every window's Git images and hunks are immutable facts the engine
|
|
142
|
+
// owns per CALL, so priming per reading re-forks that batch for every row scanned. The demand set mirrors
|
|
143
|
+
// the loop's own `driftSelected` rule exactly, so `--changed` still asks about nothing it would not judge;
|
|
144
|
+
// the in-loop primes stay as the correctness backstop and become cache hits.
|
|
145
|
+
const latestByDir = new Map<string, ReturnType<typeof latestPerScenario>>()
|
|
146
|
+
const anchorDemands: AnchorDemand[] = []
|
|
147
|
+
for (const s of specs) {
|
|
148
|
+
const dirRel = dirname(s.path)
|
|
149
|
+
const y = yByDir.get(dirRel)
|
|
150
|
+
if (!y) continue
|
|
151
|
+
const latest = latestPerScenario(readReadings(y.sidecarPath))
|
|
152
|
+
latestByDir.set(dirRel, latest)
|
|
153
|
+
for (const sc of y.scenarios) {
|
|
154
|
+
const r = latest.get(sc.name)
|
|
155
|
+
if (!r) continue
|
|
156
|
+
const axis = scenarioCodeAxis(sc.code, s.codeEntries)
|
|
157
|
+
if (changed && !nodeChanged(dirRel, axis.paths, changed, nodeDirs)) continue
|
|
158
|
+
anchorDemands.push({ sinceSha: r.codeSha, entries: axis.entries })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await anchors.prime?.(anchorDemands)
|
|
141
162
|
for (const s of specs) {
|
|
142
163
|
const dirRel = dirname(s.path)
|
|
143
164
|
const nodeSelected = !changed || nodeChanged(dirRel, s.code, changed, nodeDirs)
|
|
@@ -152,7 +173,7 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
152
173
|
findings.push(` • eval-schema: '${s.id}' ${e} — fix ${y.evalPath}`)
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
|
-
const latest = latestPerScenario(readReadings(y.sidecarPath))
|
|
176
|
+
const latest = latestByDir.get(dirRel) ?? latestPerScenario(readReadings(y.sidecarPath))
|
|
156
177
|
for (const sc of y.scenarios) {
|
|
157
178
|
// a scenario's own `code` narrows its freshness CODE axis to a subset; a path that does not exist
|
|
158
179
|
// would make that axis silently immortal (changedSince finds no commits for it), so flag it LOUD as a
|
|
@@ -160,7 +181,7 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
160
181
|
// ONE resolution of the axis: base paths for every path consumer (existence, changed-scan selection,
|
|
161
182
|
// drift display), folded selectors for the narrowing. A raw `path#symbol` matches no real file, so
|
|
162
183
|
// reading it as a path would report a ghost and drop the scenario out of --changed selection.
|
|
163
|
-
const axis = scenarioCodeAxis(sc.code, s.
|
|
184
|
+
const axis = scenarioCodeAxis(sc.code, s.codeEntries)
|
|
164
185
|
if (nodeSelected) {
|
|
165
186
|
for (const [field, paths] of [['code', axis.paths], ['related', parseRelation(sc.related ?? [], 'related').entries.map((e) => e.path)]] as const) {
|
|
166
187
|
const ghosts = paths.filter((p) => !existsSync(join(root, p)))
|
|
@@ -193,8 +214,8 @@ async function scan(args: string[] = []): Promise<number> {
|
|
|
193
214
|
if (!driftSelected) continue
|
|
194
215
|
const remSignals = (remarkTracks.get(trackKey(s.id, sc.name))?.remarks ?? []).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt }))
|
|
195
216
|
if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, codeFiles, y.evalPath)
|
|
196
|
-
await anchors.prime?.(r.codeSha, axis.entries)
|
|
197
|
-
const axes = staleAxes(r,
|
|
217
|
+
await anchors.prime?.([{ sinceSha: r.codeSha, entries: axis.entries }])
|
|
218
|
+
const axes = staleAxes(r, axis.entries, y.evalPath, idx, scidx, remSignals, probe, sc, anchors)
|
|
198
219
|
if (axes.length) {
|
|
199
220
|
staleScores++
|
|
200
221
|
// a remark-stale scenario is unlocked by a second-party resolve, then a fresh reading; the git axes
|
package/spec-eval/src/evaltab.ts
CHANGED
|
@@ -3,19 +3,14 @@ import { repoRoot, driftIndex, historyIndex, commitReachable, type DriftIndex, t
|
|
|
3
3
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
4
4
|
import { loadEvalRemarkTracks, trackKey, type RemarkTrack, type Issue, type Reply } from '../../spec-cli/src/issues.js'
|
|
5
5
|
import { evalNodes, scenarioCodeAxis, type EvalNode, type ScenarioTestReference } from './scenarios.js'
|
|
6
|
-
import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction } from './sidecar.js'
|
|
6
|
+
import { readSidecar, applyRetractions, evidenceOf, isJsonBlob, humanOkFor, type Verdict, type EvidenceKind, type Retraction, type Reading, type HumanOk } from './sidecar.js'
|
|
7
|
+
import type { RelationEntry } from '../../spec-cli/src/anchors.js'
|
|
7
8
|
import { staleAxes, codeDrift, contentProbeFor, anchorProbeFor, type StaleAxis } from './freshness.js'
|
|
8
9
|
import { scenarioIndex, type ScenarioIndex } from './scenariofresh.js'
|
|
9
10
|
import { hasBlob, getBlob, MISS_BLOB } from './cache.js'
|
|
10
11
|
|
|
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
12
|
export type EvidenceView = { hash: string; kind: EvidenceKind; state: 'present' | 'miss' }
|
|
14
13
|
|
|
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
14
|
export type RemarkView = {
|
|
20
15
|
rid: string
|
|
21
16
|
ref: string // `<thread-id>#<rid>` — the address `spex remark resolve`/`spex remark retract` take
|
|
@@ -33,44 +28,23 @@ export type EvalEntry = {
|
|
|
33
28
|
scenario: string
|
|
34
29
|
expected: string
|
|
35
30
|
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
31
|
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).
|
|
41
32
|
blob: string | null
|
|
42
33
|
blobKind?: EvidenceKind
|
|
43
34
|
timelineBlob?: string
|
|
44
|
-
// legacy instrument tag ('manual@1') — surfaced for old readings only, never written by new filings.
|
|
45
35
|
evaluator?: string
|
|
46
|
-
// the SESSION that filed this reading ([[event-detail]] originator liveness / [[mentions]] loop-in): the
|
|
47
|
-
// reachable actor an un-@'d eval remark courtesy-delivers to (the latest reading's filer is the chain's
|
|
48
|
-
// first link). Surfaced so the eval detail can show whether that session is still alive. Absent on a legacy
|
|
49
|
-
// reading (no `by`) — the pane simply shows no originator, exactly as the offline chain runs dry silently.
|
|
50
36
|
by?: string
|
|
51
37
|
verdict?: Verdict
|
|
52
38
|
ts: string
|
|
53
39
|
fresh: boolean
|
|
54
40
|
staleAxes: StaleAxis[]
|
|
55
41
|
blobState: 'present' | 'miss' | 'none'
|
|
56
|
-
// the code axis's drift detail for a code-stale reading ([[eval-core]]'s codeDrift): each governed file
|
|
57
|
-
// that moved since this reading + how many commits behind, so the eval detail can EXPLAIN the staleness
|
|
58
|
-
// ("EvalsFeed.jsx +3") rather than just flag it. Absent when the reading isn't code-stale.
|
|
59
42
|
codeDrift?: { file: string; behind: number }[]
|
|
60
|
-
// the trunk remark track overlaid onto THIS reading ([[remark-teeth]]): the remarks whose targetCodeSha
|
|
61
|
-
// pins here (or the latest reading, for a dangling target). Absent when the scenario has no remark.
|
|
62
43
|
remarks?: RemarkView[]
|
|
63
|
-
// the (node, scenario) eval-remark THREAD ([[eval-issue-split]]): the SAME join the teeth read, attached
|
|
64
|
-
// so the eval detail pane reads its whole comment thread from the reading overlay — the counterpart to
|
|
65
|
-
// splitting eval-remark threads OUT of the issue surfaces (mergedIssues). Absent until the first remark.
|
|
66
44
|
thread?: Issue
|
|
67
|
-
// the human sign-off bound to THIS reading ([[human-ok]]) — present only on the exact reading an ok row
|
|
68
|
-
// anchors (by scenario + ts), so a newer reading arrives unblessed and the feed's hide releases itself.
|
|
69
|
-
// Rides the board fold verbatim (latestPerScenario is a filter, never a projection).
|
|
70
45
|
humanOk?: { by: string; ts: string }
|
|
71
46
|
}
|
|
72
47
|
|
|
73
|
-
// a remark overlaid onto its display host (a reading, above) → the RemarkView the surfaces read.
|
|
74
48
|
function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkView {
|
|
75
49
|
return {
|
|
76
50
|
rid: rm.rid!,
|
|
@@ -84,32 +58,16 @@ function toRemarkView(rm: Reply, threadId: string, dangling: boolean): RemarkVie
|
|
|
84
58
|
}
|
|
85
59
|
}
|
|
86
60
|
|
|
87
|
-
// a DANGLING track ([[remark-teeth]]'s dangling clause / directive 5): a (node, scenario) remark track whose
|
|
88
|
-
// scenario no reading joins — the scenario was renamed or deleted, so today the track loads but surfaces
|
|
89
|
-
// nowhere. It ages NOTHING (there is no reading to stale — the teeth read per-reading), but its remarks must
|
|
90
|
-
// stay VISIBLE and resolvable/retractable via their refs, so evalTimeline emits one synthetic row per orphan
|
|
91
|
-
// at NODE level. `scenario` is the orphaned name (rendered struck-through / gone); `remarks` are all dangling.
|
|
92
61
|
export type DanglingTrack = { scenario: string; threadId: string; thread: Issue; remarks: RemarkView[] }
|
|
93
62
|
|
|
94
63
|
export type ScenarioInfo = { name: string; expected: string; tags?: string[]; test?: ScenarioTestReference; code?: string[] }
|
|
95
64
|
|
|
96
|
-
// `hasEvalFile` distinguishes a node that declares no scenarios (no eval.md) from one that declares some but
|
|
97
|
-
// has no readings yet — the tab says different things for each. `scenarios` is the declared set; `readings`
|
|
98
|
-
// is NEWEST-FIRST (the sidecar is append-only oldest→newest; the tab leads with the latest measurement,
|
|
99
|
-
// like the history tab).
|
|
100
65
|
export type EvalTimeline = {
|
|
101
66
|
node: string
|
|
102
67
|
hasEvalFile: boolean
|
|
103
68
|
scenarios: ScenarioInfo[]
|
|
104
69
|
readings: EvalEntry[]
|
|
105
|
-
// retraction events ([[eval-core]]'s retract verb), newest first — the sanctioned-undo TRACE. `readings`
|
|
106
|
-
// above is already the effective view (a retracted reading is dropped from the scoreboard everywhere);
|
|
107
|
-
// this list is how a surface still shows that the undo happened: which (scenario, ts) was withdrawn,
|
|
108
|
-
// by whom, why. Additive — a consumer that ignores it sees exactly the effective scoreboard.
|
|
109
70
|
retractions: Retraction[]
|
|
110
|
-
// orphaned remark tracks (renamed/deleted scenarios) — surfaced at node level so their remarks never vanish
|
|
111
|
-
// ([[remark-teeth]] dangling clause). SEPARATE from `readings` on purpose: a dangling track has no reading,
|
|
112
|
-
// so it must NOT flow into latestPerScenario / the board scoreboard — it ages nothing.
|
|
113
71
|
dangling: DanglingTrack[]
|
|
114
72
|
}
|
|
115
73
|
|
|
@@ -118,48 +76,84 @@ export type EvalContext = {
|
|
|
118
76
|
specs: Awaited<ReturnType<typeof loadSpecs>>
|
|
119
77
|
idx: DriftIndex
|
|
120
78
|
hidx: HistoryIndex
|
|
121
|
-
scidx: ScenarioIndex
|
|
79
|
+
scidx: ScenarioIndex
|
|
122
80
|
ynodes: EvalNode[]
|
|
123
|
-
// the trunk remark tracks ([[remark-teeth]]), keyed (node, scenario) — loaded ONCE per board/proof build
|
|
124
|
-
// and reused for every node, so the fold never re-reads the issue store per node.
|
|
125
81
|
remarks: Map<string, RemarkTrack>
|
|
126
82
|
}
|
|
127
83
|
|
|
128
|
-
// build the shared context with ONE eval-file walk, reusing the caller's already-computed specs + the two
|
|
129
|
-
// HEAD-keyed git indices (drift for the code axis, history for the rename-safe scenario axis — both warm
|
|
130
|
-
// hits, loadSpecs already derived them). The remark tracks are the fourth, non-git freshness input
|
|
131
|
-
// ([[remark-teeth]]); a caller that omits them gets a live load, so a bare evalTimeline still has teeth.
|
|
132
84
|
export async function evalContext(
|
|
133
85
|
root: string,
|
|
134
86
|
specs: Awaited<ReturnType<typeof loadSpecs>>,
|
|
135
87
|
idx: DriftIndex,
|
|
136
88
|
hidx: HistoryIndex,
|
|
137
89
|
remarks?: Map<string, RemarkTrack>,
|
|
138
|
-
ynodes?: EvalNode[],
|
|
90
|
+
ynodes?: EvalNode[],
|
|
139
91
|
): Promise<EvalContext> {
|
|
140
92
|
const nodes = ynodes ?? evalNodes(root)
|
|
141
93
|
const scidx = await scenarioIndex(root, nodes.map((n) => n.evalPath))
|
|
142
94
|
return { root, specs, idx, hidx, scidx, ynodes: nodes, remarks: remarks ?? loadEvalRemarkTracks() }
|
|
143
95
|
}
|
|
144
96
|
|
|
145
|
-
|
|
97
|
+
// @@@ one read, one batch - the freshness engine's Git work is immutable-object work, so it is owned ONCE
|
|
98
|
+
// per read rather than once per reading. evalTimelines is the plural the graph build actually wants: it
|
|
99
|
+
// plans every node's rows first (pure fs + in-memory projection), primes the content and anchor probes with
|
|
100
|
+
// the WHOLE demand set, then assembles. Singular evalTimeline is the one-id case of the same path.
|
|
101
|
+
export async function evalTimelines(ids: readonly string[], ctx?: EvalContext): Promise<EvalTimeline[]> {
|
|
146
102
|
const root = ctx?.root ?? repoRoot()
|
|
147
|
-
|
|
148
|
-
// this is the common case and must stay cheap (a list the size of the few measurable nodes, not the whole tree).
|
|
149
|
-
const ynode = (ctx?.ynodes ?? evalNodes(root)).find((n) => n.id === id)
|
|
150
|
-
if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
|
|
151
|
-
// the governed `code:` files are the freshness CODE axis; read them from the canonical spec loader so a
|
|
152
|
-
// reparent/rename is seen the same way `spex spec lint` and `spex eval add` see it (joined by directory).
|
|
103
|
+
const ynodes = ctx?.ynodes ?? evalNodes(root)
|
|
153
104
|
const specs = ctx?.specs ?? await loadSpecs()
|
|
154
|
-
const codeFiles = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.code ?? []
|
|
155
105
|
const idx = ctx?.idx ?? await driftIndex(root)
|
|
156
106
|
const hidx = ctx?.hidx ?? await historyIndex(root)
|
|
157
|
-
|
|
158
|
-
// for the WHOLE measurable set (not just this node) so the shared HEAD-keyed cache is complete for later callers.
|
|
159
|
-
const scidx = ctx?.scidx ?? await scenarioIndex(root, (ctx?.ynodes ?? evalNodes(root)).map((n) => n.evalPath))
|
|
160
|
-
const byName = new Map(ynode.scenarios.map((s) => [s.name, s])) // join each reading to its scenario's expected + code
|
|
161
|
-
// the trunk remark track per scenario ([[remark-teeth]]) — the non-git freshness input, fed to the teeth.
|
|
107
|
+
const scidx = ctx?.scidx ?? await scenarioIndex(root, ynodes.map((n) => n.evalPath))
|
|
162
108
|
const tracks = ctx?.remarks ?? loadEvalRemarkTracks()
|
|
109
|
+
const probe = contentProbeFor(root)
|
|
110
|
+
const anchors = anchorProbeFor(root, idx)
|
|
111
|
+
|
|
112
|
+
type Row = { reading: Reading; axis: ReturnType<typeof scenarioCodeAxis> }
|
|
113
|
+
type Plan = { id: string; ynode?: EvalNode; codeEntries: RelationEntry[]; rows: Row[]; retractions: Retraction[]; oks: HumanOk[] }
|
|
114
|
+
const plans: Plan[] = ids.map((id) => {
|
|
115
|
+
const ynode = ynodes.find((n) => n.id === id)
|
|
116
|
+
if (!ynode) return { id, codeEntries: [], rows: [], retractions: [], oks: [] }
|
|
117
|
+
const codeEntries = specs.find((s) => dirname(s.path) === relative(root, ynode.dir))?.codeEntries ?? []
|
|
118
|
+
const byName = new Map(ynode.scenarios.map((s) => [s.name, s]))
|
|
119
|
+
const { readings, retractions, oks } = readSidecar(ynode.sidecarPath)
|
|
120
|
+
const rows = applyRetractions(readings, retractions).map((reading) => ({
|
|
121
|
+
reading, axis: scenarioCodeAxis(byName.get(reading.scenario)?.code, codeEntries),
|
|
122
|
+
}))
|
|
123
|
+
return { id, ynode, codeEntries, rows, retractions, oks }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// An off-history anchor is the only reading that needs a content verdict; those primes serialize inside the
|
|
127
|
+
// probe per anchor, so issuing them together lets one anchor's paths union into one child instead of N.
|
|
128
|
+
await Promise.all(plans.flatMap((plan) => plan.ynode
|
|
129
|
+
? plan.rows.filter((row) => !commitReachable(idx, row.reading.codeSha))
|
|
130
|
+
.map((row) => probe.prime?.(row.reading.codeSha, row.axis.paths, plan.ynode!.evalPath))
|
|
131
|
+
: []))
|
|
132
|
+
await anchors.prime?.(plans.flatMap((plan) => plan.rows.map((row) => ({ sinceSha: row.reading.codeSha, entries: row.axis.entries }))))
|
|
133
|
+
|
|
134
|
+
return plans.map((plan) => assembleTimeline(plan.id, plan.ynode, plan.rows, plan.retractions, plan.oks, {
|
|
135
|
+
idx, scidx, tracks, probe, anchors,
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalTimeline> {
|
|
140
|
+
return (await evalTimelines([id], ctx))[0]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type AssembleDeps = {
|
|
144
|
+
idx: DriftIndex; scidx: ScenarioIndex; tracks: Map<string, RemarkTrack>
|
|
145
|
+
probe: ReturnType<typeof contentProbeFor>; anchors: ReturnType<typeof anchorProbeFor>
|
|
146
|
+
}
|
|
147
|
+
function assembleTimeline(
|
|
148
|
+
id: string,
|
|
149
|
+
ynode: EvalNode | undefined,
|
|
150
|
+
rows: { reading: Reading; axis: ReturnType<typeof scenarioCodeAxis> }[],
|
|
151
|
+
retractions: Retraction[],
|
|
152
|
+
oks: HumanOk[],
|
|
153
|
+
{ idx, scidx, tracks, probe, anchors }: AssembleDeps,
|
|
154
|
+
): EvalTimeline {
|
|
155
|
+
if (!ynode) return { node: id, hasEvalFile: false, scenarios: [], readings: [], retractions: [], dangling: [] }
|
|
156
|
+
const byName = new Map(ynode.scenarios.map((s) => [s.name, s]))
|
|
163
157
|
const remarksFor = (scenario: string): RemarkTrack['remarks'] => tracks.get(trackKey(id, scenario))?.remarks ?? []
|
|
164
158
|
const threadFor = (scenario: string): Issue | undefined => tracks.get(trackKey(id, scenario))?.thread
|
|
165
159
|
const scenarios: ScenarioInfo[] = ynode.scenarios.map((s) => ({
|
|
@@ -167,36 +161,14 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
167
161
|
...(s.tags?.length ? { tags: s.tags } : {}), ...(s.test ? { test: s.test } : {}),
|
|
168
162
|
...(s.code?.length ? { code: s.code } : {}),
|
|
169
163
|
}))
|
|
170
|
-
// one raw sidecar read: the effective readings feed the scoreboard rows below; the retraction events ride
|
|
171
|
-
// along as the undo trace (newest-first, like the readings), the human-ok events as the sign-off overlay.
|
|
172
|
-
const { readings: rawReadings, retractions, oks } = readSidecar(ynode.sidecarPath)
|
|
173
|
-
// the off-history content fallback ([[eval-core]]): fed to both git axes so a rebased/folded-away
|
|
174
|
-
// anchor with byte-identical governed content reads fresh. An in-history reading never probes.
|
|
175
|
-
const probe = contentProbeFor(root)
|
|
176
|
-
const anchors = anchorProbeFor(root, idx)
|
|
177
164
|
const readings: EvalEntry[] = []
|
|
178
|
-
for (const r of
|
|
179
|
-
// a scenario's own `code` is its freshness code axis when it declares one; else the whole node's list.
|
|
165
|
+
for (const { reading: r, axis } of rows) {
|
|
180
166
|
const sc = byName.get(r.scenario)
|
|
181
|
-
|
|
182
|
-
// remark makes it remark-stale (T1). Display attachment (which reading each remark pins to) is a separate
|
|
183
|
-
// read-time overlay below; freshness never depends on that pin.
|
|
184
|
-
const cf = sc?.code?.length ? sc.code : codeFiles
|
|
185
|
-
// an entry may be anchored (`path#symbol`); every PATH consumer below reads base paths, the narrowing
|
|
186
|
-
// reads the folded selectors ([[eval-core]]).
|
|
187
|
-
const axis = scenarioCodeAxis(sc?.code, codeFiles)
|
|
188
|
-
if (!commitReachable(idx, r.codeSha)) await probe.prime?.(r.codeSha, axis.paths, ynode.evalPath)
|
|
189
|
-
await anchors.prime?.(r.codeSha, axis.entries)
|
|
190
|
-
const axes = staleAxes(r, cf, ynode.evalPath, idx, scidx,
|
|
167
|
+
const axes = staleAxes(r, axis.entries, ynode.evalPath, idx, scidx,
|
|
191
168
|
remarksFor(r.scenario).map((rm) => ({ resolved: !!rm.resolved, resolvedAt: rm.resolvedAt })), probe, sc, anchors)
|
|
192
|
-
|
|
193
|
-
const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, cf, probe) : []
|
|
194
|
-
// the reading's evidence list, each entry resolved to its live blob state; the primary (video-first, else
|
|
195
|
-
// first) drives the scalar compat fields for single-evidence consumers.
|
|
169
|
+
const drift = axes.includes('code') ? codeDrift(idx, r.codeSha, axis.entries, probe) : []
|
|
196
170
|
const evidence: EvidenceView[] = evidenceOf(r).map((e) => ({ hash: e.hash, kind: e.kind, state: hasBlob(e.hash) ? 'present' : 'miss' }))
|
|
197
171
|
const primary = evidence.find((e) => e.kind === 'video') ?? evidence[0]
|
|
198
|
-
// the sign-off join ([[human-ok]]): the ok binds by exact (scenario, ts), so only the very reading the
|
|
199
|
-
// human blessed carries it — a newer or retract-revealed reading reads unblessed.
|
|
200
172
|
const okRow = humanOkFor(oks, r.scenario, r.ts)
|
|
201
173
|
readings.push({
|
|
202
174
|
scenario: r.scenario,
|
|
@@ -218,21 +190,13 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
218
190
|
...(okRow ? { humanOk: { by: okRow.by, ts: okRow.ts } } : {}),
|
|
219
191
|
})
|
|
220
192
|
}
|
|
221
|
-
readings.reverse()
|
|
222
|
-
// R2 display overlay ([[remark-teeth]]): pin each remark to the reading it JUDGED (targetCodeSha match),
|
|
223
|
-
// else the scenario's latest reading (first in newest-first order) — a dangling target never HIDES the
|
|
224
|
-
// remark. A track whose SCENARIO no reading joins (renamed/deleted) has no reading to attach to, so it
|
|
225
|
-
// becomes a synthetic DANGLING row at node level (directive 5) instead of vanishing — visible, its remarks
|
|
226
|
-
// resolvable/retractable via their refs, and ageing nothing (there is no reading for the teeth to stale).
|
|
193
|
+
readings.reverse()
|
|
227
194
|
const declared = new Set(ynode.scenarios.map((s) => s.name))
|
|
228
195
|
const dangling: DanglingTrack[] = []
|
|
229
196
|
for (const [, track] of tracks) {
|
|
230
197
|
if (track.node !== id || !track.remarks.length) continue
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
233
|
-
// no reading joins this track. If the scenario is still DECLARED it is just a blind spot (unmeasured),
|
|
234
|
-
// not orphaned — its remarks wait for a reading. Only a scenario that is BOTH gone from eval.md AND
|
|
235
|
-
// has no reading is truly dangling (renamed/deleted), and that is the one we surface at node level.
|
|
198
|
+
const hosts = readings.filter((r) => r.scenario === track.scenario)
|
|
199
|
+
if (!hosts.length) {
|
|
236
200
|
if (!declared.has(track.scenario)) {
|
|
237
201
|
dangling.push({
|
|
238
202
|
scenario: track.scenario, threadId: track.threadId, thread: track.thread,
|
|
@@ -241,9 +205,9 @@ export async function evalTimeline(id: string, ctx?: EvalContext): Promise<EvalT
|
|
|
241
205
|
}
|
|
242
206
|
continue
|
|
243
207
|
}
|
|
244
|
-
const latest =
|
|
208
|
+
const latest = hosts[0]
|
|
245
209
|
for (const rm of track.remarks) {
|
|
246
|
-
const target =
|
|
210
|
+
const target = hosts.find((r) => r.codeSha === rm.targetCodeSha)
|
|
247
211
|
const host = target ?? latest
|
|
248
212
|
;(host.remarks ??= []).push(toRemarkView(rm, track.threadId, !target))
|
|
249
213
|
}
|
|
@@ -264,11 +228,6 @@ export function readBlobByHash(hash: string, dir?: string): BlobResult {
|
|
|
264
228
|
return { ok: true, bytes, mime: sniffBlobMime(bytes) }
|
|
265
229
|
}
|
|
266
230
|
|
|
267
|
-
// PNG/JPEG/GIF/WebP cover every screenshot (a manual --image); MP4/WebM cover a recorded clip (--video), so
|
|
268
|
-
// the blob route serves it with a playable Content-Type. Text bytes (no NUL, no known header) split by
|
|
269
|
-
// CONTENT ([[evidence-kind-taxonomy]]): a structured export (JSON) sniffs to application/json — so the
|
|
270
|
-
// `data` renderer knows to validate/pretty-print it — while free-form terminal text stays text/plain;
|
|
271
|
-
// anything else falls back to a generic binary type so it still downloads rather than being mislabeled.
|
|
272
231
|
export function sniffBlobMime(b: Buffer): string {
|
|
273
232
|
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
|
|
274
233
|
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
package/spec-eval/src/filing.ts
CHANGED
|
@@ -5,36 +5,24 @@ import { putBlob } from './cache.js'
|
|
|
5
5
|
|
|
6
6
|
export type FileResult = { ok: true; reading: Reading } | { ok: false; error: string }
|
|
7
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. eval
|
|
11
|
-
// still runs nothing — this only records. The dashboard files nothing through this: [[event-detail]] is
|
|
12
|
-
// read-side on readings.
|
|
13
8
|
export function fileHumanReading(
|
|
14
9
|
nodeId: string,
|
|
15
10
|
input: { scenario: string; status: 'pass' | 'fail'; note?: string; transcript?: string; by?: string },
|
|
16
11
|
): FileResult {
|
|
17
12
|
const root = repoRoot()
|
|
18
|
-
// the same loud resolution the CLI applies ([[eval-core]]): exact canonical id, else a unique bare
|
|
19
|
-
// leaf; an ambiguous leaf returns the candidate list instead of filing against an arbitrary node.
|
|
20
13
|
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
21
14
|
if (!res.ok) return { ok: false, error: res.error }
|
|
22
15
|
const node = res.node
|
|
23
16
|
const sc = node.scenarios.find((s) => s.name === input.scenario)
|
|
24
17
|
if (!sc) return { ok: false, error: `'${nodeId}' has no scenario '${input.scenario}'` }
|
|
25
18
|
if (input.status !== 'pass' && input.status !== 'fail') return { ok: false, error: 'status must be pass or fail' }
|
|
26
|
-
// the evidence bytes; its kind is derived from CONTENT ([[evidence-kind-taxonomy]]) — a structured JSON
|
|
27
|
-
// export files as `data`, free-form text as `transcript` — so the HTTP filer agrees with the CLI.
|
|
28
19
|
const buf = input.transcript ? Buffer.from(input.transcript) : null
|
|
29
20
|
const blob = buf ? putBlob(buf) : null
|
|
30
21
|
const reading: Reading = {
|
|
31
22
|
scenario: sc.name,
|
|
32
23
|
codeSha: headSha(root),
|
|
33
|
-
// the contract this measurement was taken against — the HTTP filer stamps it exactly like the CLI
|
|
34
24
|
scenarioHash: scenarioHash(sc),
|
|
35
25
|
...(blob ? { evidence: [{ hash: blob, kind: (buf && isJsonBlob(buf) ? 'data' : 'transcript') as EvidenceKind }] } : {}),
|
|
36
|
-
// the filing session (caller-passed — the human annotator has no reachable session, so it stays absent
|
|
37
|
-
// there and the eval-comment loop-in is silent, per [[mentions]])
|
|
38
26
|
...(input.by ? { by: input.by } : {}),
|
|
39
27
|
verdict: { status: input.status, ...(input.note ? { note: input.note } : {}) },
|
|
40
28
|
ts: new Date().toISOString(),
|
|
@@ -43,12 +31,6 @@ export function fileHumanReading(
|
|
|
43
31
|
return { ok: true, reading }
|
|
44
32
|
}
|
|
45
33
|
|
|
46
|
-
// The session that filed the LATEST reading for (node, scenario) — the ORIGINATOR an eval-comment thread
|
|
47
|
-
// loops in on a reply ([[mentions]] implicit loop-in). Null when the node/scenario has no reading, or the
|
|
48
|
-
// latest reading is legacy (no `by`). Store-agnostic: the caller resolves this id to a live session or nobody.
|
|
49
|
-
// `root` defaults to the trunk; the loop-in chain also passes each LIVE session's worktree here, because an
|
|
50
|
-
// in-flight reading (filed on an unmerged branch) is invisible to the trunk sidecar — exactly the
|
|
51
|
-
// review-time case where the remark must still reach its filer ([[remark-polish]] strand 2).
|
|
52
34
|
export function evalReadingFiler(nodeId: string, scenario: string, root: string = repoRoot()): string | null {
|
|
53
35
|
const res = resolveEvalNode(evalNodes(root), nodeId)
|
|
54
36
|
if (!res.ok) return null
|