spexcode 0.5.7 → 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 +199 -60
- 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 +42 -30
- 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
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type RelationEntry,
|
|
37
37
|
type Unit,
|
|
38
38
|
} from '../../spec-cli/src/anchors.js'
|
|
39
|
-
import {
|
|
39
|
+
import { evalTimelines, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
|
|
40
40
|
import { isUiPath } from './cli.js'
|
|
41
41
|
import { readReadings } from './sidecar.js'
|
|
42
42
|
import { parseScenarios, scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
|
|
@@ -101,7 +101,7 @@ export type SessionImpactOverlay = {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export type SessionImpactSpecSnapshot = Pick<LoadedSpec,
|
|
104
|
-
'id' | 'path' | 'code' | 'codeScoped' | 'related' | 'relatedScoped' | 'relationProblems'>
|
|
104
|
+
'id' | 'path' | 'code' | 'codeEntries' | 'codeScoped' | 'related' | 'relatedEntries' | 'relatedScoped' | 'relationProblems'>
|
|
105
105
|
|
|
106
106
|
export class SessionEvalUnavailableError extends Error {
|
|
107
107
|
override name = 'SessionEvalUnavailableError'
|
|
@@ -166,12 +166,12 @@ async function revisionFile(root: string, revision: string, path: string): Promi
|
|
|
166
166
|
return impactGit(root, ['show', `${revision}:${path}`], `read ${revision}:${path}`)
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
// the snapshot already carries parsed entries; a caller wanting one relation just picks it. This used to mint
|
|
170
|
+
// `path#selector` STRINGS from the split path/scoped pair and hand them back to the parser to be turned into
|
|
171
|
+
// the entries the loader had all along — a serialize/reparse round-trip through a form nobody stored.
|
|
172
|
+
function loadedRelationEntries(spec: SessionImpactSpecSnapshot | undefined, relation: 'code' | 'related'): readonly RelationEntry[] {
|
|
170
173
|
if (!spec) return []
|
|
171
|
-
|
|
172
|
-
const scoped = relation === 'code' ? spec.codeScoped : spec.relatedScoped
|
|
173
|
-
const selectors = new Map(scoped.map((entry) => [entry.path, entry.selectors]))
|
|
174
|
-
return paths.flatMap((path) => selectors.get(path)?.map((selector) => `${path}#${selector}`) ?? [path])
|
|
174
|
+
return relation === 'code' ? spec.codeEntries : spec.relatedEntries
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
function scenarioMetadata(scenario: Scenario, effectiveCode: readonly RelationEntry[]): string {
|
|
@@ -265,8 +265,8 @@ function selectorEntriesForSnapshot(
|
|
|
265
265
|
): RelationEntry[] {
|
|
266
266
|
const entries: RelationEntry[] = []
|
|
267
267
|
for (const spec of specs) {
|
|
268
|
-
entries.push(...
|
|
269
|
-
entries.push(...
|
|
268
|
+
entries.push(...loadedRelationEntries(spec, 'code'))
|
|
269
|
+
entries.push(...loadedRelationEntries(spec, 'related'))
|
|
270
270
|
for (const scenario of scenariosById.get(spec.id) ?? []) {
|
|
271
271
|
entries.push(...parsedRelation(scenario.code ?? [], 'code', `scenario '${spec.id} · ${scenario.name}'`))
|
|
272
272
|
entries.push(...parsedRelation(scenario.related ?? [], 'related', `scenario '${spec.id} · ${scenario.name}'`))
|
|
@@ -277,20 +277,20 @@ function selectorEntriesForSnapshot(
|
|
|
277
277
|
return [...unique.values()]
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
function primeSelectorSources(
|
|
280
|
+
async function primeSelectorSources(
|
|
281
281
|
context: ImpactReadContext,
|
|
282
282
|
revision: string,
|
|
283
283
|
paths: readonly string[],
|
|
284
284
|
sourceView: 'base' | 'head',
|
|
285
|
-
): void {
|
|
285
|
+
): Promise<void> {
|
|
286
286
|
const exactPaths = [...new Set(paths)].filter((path) => (
|
|
287
287
|
!context.sources.has(`${revision}\0${path}`)
|
|
288
288
|
&& !(sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path))
|
|
289
289
|
)).sort()
|
|
290
290
|
if (!exactPaths.length) return
|
|
291
291
|
try {
|
|
292
|
-
const oids = batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`))
|
|
293
|
-
const blobs = batchBlobTexts(context.root, oids.filter((oid): oid is string => !!oid))
|
|
292
|
+
const oids = await batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`))
|
|
293
|
+
const blobs = await batchBlobTexts(context.root, oids.filter((oid): oid is string => !!oid))
|
|
294
294
|
for (let index = 0; index < exactPaths.length; index++) {
|
|
295
295
|
const path = exactPaths[index]
|
|
296
296
|
const oid = oids[index]
|
|
@@ -497,8 +497,8 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
497
497
|
const headScenariosById = new Map(headSpecs.map((spec) => [spec.id, scenariosAt('head', spec)]))
|
|
498
498
|
const baseSelectorEntries = selectorEntriesForSnapshot(baseSpecs, baseScenariosById)
|
|
499
499
|
const headSelectorEntries = selectorEntriesForSnapshot(headSpecs, headScenariosById)
|
|
500
|
-
primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base')
|
|
501
|
-
primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head')
|
|
500
|
+
await primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base')
|
|
501
|
+
await primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head')
|
|
502
502
|
await Promise.all([
|
|
503
503
|
...baseSelectorEntries.map((entry) => validateSelectorEntry(context, base, entry, 'base')),
|
|
504
504
|
...headSelectorEntries.map((entry) => validateSelectorEntry(context, head, entry, 'head')),
|
|
@@ -529,23 +529,18 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
529
529
|
const evalChanged = [...changedPaths].filter((path) => evalPaths.some((claim) => codeClaims([claim], path)))
|
|
530
530
|
if (evalChanged.length) pushNodeCause(causes, 'eval', evalChanged)
|
|
531
531
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
if (baseNodeCode.problems.length || headNodeCode.problems.length) {
|
|
537
|
-
throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid code: ${[
|
|
538
|
-
...baseNodeCode.problems, ...headNodeCode.problems,
|
|
539
|
-
].join('; ')}`)
|
|
540
|
-
}
|
|
532
|
+
// no second validity gate here: relationProblems above already threw for either side, and it is the
|
|
533
|
+
// SAME parse — a re-parse of rows minted from those entries cannot surface a problem the loader did not.
|
|
534
|
+
const baseNodeCodeEntries = loadedRelationEntries(baseSpec, 'code')
|
|
535
|
+
const headNodeCodeEntries = loadedRelationEntries(headSpec, 'code')
|
|
541
536
|
const nodeCodeMoved = !!baseSpec && !!headSpec
|
|
542
|
-
&& JSON.stringify(
|
|
537
|
+
&& JSON.stringify(baseNodeCodeEntries) !== JSON.stringify(headNodeCodeEntries)
|
|
543
538
|
const nodeCodeReads = nodeCodeMoved
|
|
544
539
|
? await Promise.all([
|
|
545
|
-
impactForEntries(context,
|
|
546
|
-
impactForEntries(context,
|
|
540
|
+
impactForEntries(context, baseNodeCodeEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
541
|
+
impactForEntries(context, headNodeCodeEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
547
542
|
])
|
|
548
|
-
: [await impactForEntries(context,
|
|
543
|
+
: [await impactForEntries(context, headSpec ? headNodeCodeEntries : baseNodeCodeEntries, {
|
|
549
544
|
validateUnchanged: !baseSpec || !headSpec,
|
|
550
545
|
validationRevision: headSpec ? head : base,
|
|
551
546
|
validationSide: headSpec ? 'head' : 'base',
|
|
@@ -560,7 +555,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
560
555
|
// parsing one synthetic concatenated relation would turn two legitimate owners naming the same path into
|
|
561
556
|
// a fake duplicate/mixed-form schema error.
|
|
562
557
|
const relatedFor = (spec: SessionImpactSpecSnapshot | undefined, scenarios: readonly Scenario[]) => [
|
|
563
|
-
...
|
|
558
|
+
...loadedRelationEntries(spec, 'related'),
|
|
564
559
|
...scenarios.flatMap((scenario) => (
|
|
565
560
|
parsedRelation(scenario.related ?? [], 'related', `scenario '${id} · ${scenario.name}'`)
|
|
566
561
|
)),
|
|
@@ -597,8 +592,8 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
597
592
|
const baseScenarioHash = before ? scenarioHash(before) : null
|
|
598
593
|
const headScenarioHash = after ? scenarioHash(after) : null
|
|
599
594
|
const semantic = !before || !after || baseScenarioHash !== headScenarioHash
|
|
600
|
-
const baseAxis = before ? scenarioCodeAxis(before.code,
|
|
601
|
-
const headAxis = after ? scenarioCodeAxis(after.code,
|
|
595
|
+
const baseAxis = before ? scenarioCodeAxis(before.code, loadedRelationEntries(baseSpec, 'code')) : { entries: [], paths: [], problems: [] }
|
|
596
|
+
const headAxis = after ? scenarioCodeAxis(after.code, loadedRelationEntries(headSpec, 'code')) : { entries: [], paths: [], problems: [] }
|
|
602
597
|
if (baseAxis.problems.length || headAxis.problems.length) {
|
|
603
598
|
throw new SessionImpactUnavailableError(`session impact scenario '${id} · ${name}' has invalid code: ${[
|
|
604
599
|
...baseAxis.problems, ...headAxis.problems,
|
|
@@ -1107,8 +1102,10 @@ export async function sessionImpactOverlay(
|
|
|
1107
1102
|
id: spec.id,
|
|
1108
1103
|
path: spec.path,
|
|
1109
1104
|
code: spec.code,
|
|
1105
|
+
codeEntries: spec.codeEntries,
|
|
1110
1106
|
codeScoped: spec.codeScoped,
|
|
1111
1107
|
related: spec.related,
|
|
1108
|
+
relatedEntries: spec.relatedEntries,
|
|
1112
1109
|
relatedScoped: spec.relatedScoped,
|
|
1113
1110
|
relationProblems: spec.relationProblems,
|
|
1114
1111
|
}))
|
|
@@ -1458,6 +1455,14 @@ async function sessionScopeNodes(
|
|
|
1458
1455
|
const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
|
|
1459
1456
|
const nodes: SessionEvalNode[] = []
|
|
1460
1457
|
|
|
1458
|
+
// @@@one prime pass for the whole scope - evalTimelines unions the off-history content probes and the
|
|
1459
|
+
// anchor probes across every id it is given, so asking it once per scope issues one child per probe kind
|
|
1460
|
+
// instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
|
|
1461
|
+
const timelineIds = impact.nodes
|
|
1462
|
+
.filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
|
|
1463
|
+
.map((projected) => projected.id)
|
|
1464
|
+
const timelineById = new Map((await evalTimelines(timelineIds, ctx)).map((timeline, i) => [timelineIds[i], timeline]))
|
|
1465
|
+
|
|
1461
1466
|
for (const projected of impact.nodes) {
|
|
1462
1467
|
const spec = specById.get(projected.id)
|
|
1463
1468
|
if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
|
|
@@ -1482,7 +1487,7 @@ async function sessionScopeNodes(
|
|
|
1482
1487
|
continue
|
|
1483
1488
|
}
|
|
1484
1489
|
|
|
1485
|
-
const timeline =
|
|
1490
|
+
const timeline = timelineById.get(spec.id)!
|
|
1486
1491
|
// A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
|
|
1487
1492
|
// the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
|
|
1488
1493
|
// another attribution rule.
|
|
@@ -1663,9 +1668,18 @@ type ProjectionEntry = {
|
|
|
1663
1668
|
current?: { generation: number; revision: string; value: SessionEvalSummary }
|
|
1664
1669
|
scheduled: number | null
|
|
1665
1670
|
running: number | null
|
|
1671
|
+
demandCancelledGeneration: number | null
|
|
1666
1672
|
observerHolds: Set<string>
|
|
1667
1673
|
}
|
|
1668
1674
|
|
|
1675
|
+
type StagedProjection = {
|
|
1676
|
+
entry: ProjectionEntry
|
|
1677
|
+
generation: number
|
|
1678
|
+
result: SummaryBuildResult
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
type ProjectionCohortRow = Pick<StagedProjection, 'entry' | 'generation'>
|
|
1682
|
+
|
|
1669
1683
|
type ProjectionTarget = 'all' | { id?: string; path?: string }
|
|
1670
1684
|
|
|
1671
1685
|
// Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
|
|
@@ -1721,6 +1735,7 @@ export class SessionEvalProjectionCache {
|
|
|
1721
1735
|
|
|
1722
1736
|
private authorize(entry: ProjectionEntry): void {
|
|
1723
1737
|
if (!this.precompute || entry.liveness === 'offline' || entry.observerHolds.size) return
|
|
1738
|
+
if (entry.demandCancelledGeneration === entry.generation) return
|
|
1724
1739
|
if ((entry.phase === 'loading' || entry.phase === 'updating')
|
|
1725
1740
|
&& entry.running !== entry.generation && entry.scheduled !== entry.generation)
|
|
1726
1741
|
entry.scheduled = entry.generation
|
|
@@ -1740,6 +1755,7 @@ export class SessionEvalProjectionCache {
|
|
|
1740
1755
|
phase: 'loading',
|
|
1741
1756
|
scheduled: null,
|
|
1742
1757
|
running: null,
|
|
1758
|
+
demandCancelledGeneration: null,
|
|
1743
1759
|
observerHolds: new Set(),
|
|
1744
1760
|
}
|
|
1745
1761
|
this.entries.set(id, entry)
|
|
@@ -1762,6 +1778,7 @@ export class SessionEvalProjectionCache {
|
|
|
1762
1778
|
phase: 'loading',
|
|
1763
1779
|
scheduled: null,
|
|
1764
1780
|
running: null,
|
|
1781
|
+
demandCancelledGeneration: null,
|
|
1765
1782
|
observerHolds: new Set(),
|
|
1766
1783
|
}
|
|
1767
1784
|
this.entries.set(session.id, entry)
|
|
@@ -1791,6 +1808,7 @@ export class SessionEvalProjectionCache {
|
|
|
1791
1808
|
entry.generation++
|
|
1792
1809
|
entry.phase = 'updating'
|
|
1793
1810
|
entry.scheduled = null
|
|
1811
|
+
entry.demandCancelledGeneration = null
|
|
1794
1812
|
changed++
|
|
1795
1813
|
}
|
|
1796
1814
|
return changed
|
|
@@ -1805,6 +1823,7 @@ export class SessionEvalProjectionCache {
|
|
|
1805
1823
|
entry.generation++
|
|
1806
1824
|
entry.phase = 'updating'
|
|
1807
1825
|
entry.scheduled = null
|
|
1826
|
+
entry.demandCancelledGeneration = null
|
|
1808
1827
|
}
|
|
1809
1828
|
return true
|
|
1810
1829
|
}
|
|
@@ -1816,6 +1835,7 @@ export class SessionEvalProjectionCache {
|
|
|
1816
1835
|
entry.generation++
|
|
1817
1836
|
entry.phase = 'updating'
|
|
1818
1837
|
entry.scheduled = null
|
|
1838
|
+
entry.demandCancelledGeneration = null
|
|
1819
1839
|
}
|
|
1820
1840
|
for (const check of [...this.observerWaiters]) check()
|
|
1821
1841
|
return true
|
|
@@ -1855,9 +1875,13 @@ export class SessionEvalProjectionCache {
|
|
|
1855
1875
|
const existing = this.demands.get(id)
|
|
1856
1876
|
if (existing) return existing as Promise<T>
|
|
1857
1877
|
const entry = this.ensureEntry(id, path)
|
|
1858
|
-
// A queued summary for this same generation is superseded by the full demand build.
|
|
1859
|
-
//
|
|
1860
|
-
|
|
1878
|
+
// A queued summary for this same generation is superseded by the full demand build. If that demand
|
|
1879
|
+
// rejects, snapshots must not recreate the cancelled eager work until a later invalidation advances g.
|
|
1880
|
+
// A running summary is left alone; the priority job waits for it to settle before taking the slot.
|
|
1881
|
+
if (entry.running == null && entry.scheduled === entry.generation) {
|
|
1882
|
+
entry.scheduled = null
|
|
1883
|
+
entry.demandCancelledGeneration = entry.generation
|
|
1884
|
+
}
|
|
1861
1885
|
let resolve!: (value: T) => void
|
|
1862
1886
|
let reject!: (error: unknown) => void
|
|
1863
1887
|
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
|
@@ -1902,35 +1926,69 @@ export class SessionEvalProjectionCache {
|
|
|
1902
1926
|
return target === 'all' || target.id === entry.id || target.path === entry.path
|
|
1903
1927
|
}
|
|
1904
1928
|
|
|
1929
|
+
private hasPending(): boolean {
|
|
1930
|
+
return this.demandQueue.length > 0
|
|
1931
|
+
|| [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
private publishCohort(staged: StagedProjection[]): boolean {
|
|
1935
|
+
let changed = false
|
|
1936
|
+
for (const { entry, generation, result } of staged) {
|
|
1937
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) continue
|
|
1938
|
+
if (result.kind === 'unstable') {
|
|
1939
|
+
entry.generation++
|
|
1940
|
+
entry.phase = 'updating'
|
|
1941
|
+
entry.scheduled = null
|
|
1942
|
+
entry.demandCancelledGeneration = null
|
|
1943
|
+
this.authorize(entry)
|
|
1944
|
+
changed = true
|
|
1945
|
+
continue
|
|
1946
|
+
}
|
|
1947
|
+
if (result.kind === 'missing') {
|
|
1948
|
+
changed = changed || entry.phase !== 'error'
|
|
1949
|
+
entry.phase = 'error'
|
|
1950
|
+
entry.scheduled = null
|
|
1951
|
+
continue
|
|
1952
|
+
}
|
|
1953
|
+
changed = changed || entry.phase !== 'ready' || entry.current?.revision !== result.revision
|
|
1954
|
+
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
1955
|
+
entry.phase = 'ready'
|
|
1956
|
+
entry.scheduled = null
|
|
1957
|
+
}
|
|
1958
|
+
return changed
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1905
1961
|
private startBatch(): void {
|
|
1906
1962
|
if (this.batch) return
|
|
1907
|
-
|
|
1908
|
-
|| [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
|
|
1909
|
-
if (!hasPending()) return
|
|
1963
|
+
if (!this.hasPending()) return
|
|
1910
1964
|
this.batch = (async () => {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1965
|
+
const demand = this.demandQueue.shift()
|
|
1966
|
+
if (demand) {
|
|
1967
|
+
try { demand.resolve(await demand.run()) }
|
|
1968
|
+
catch (error) { demand.reject(error) }
|
|
1969
|
+
return
|
|
1970
|
+
}
|
|
1971
|
+
// Freeze this finite cohort. Inputs that arrive later remain scheduled for the next batch, so a
|
|
1972
|
+
// busy stream cannot keep a completed cohort unpublished forever.
|
|
1973
|
+
const cohort = [...this.entries.values()]
|
|
1974
|
+
.filter((entry) => entry.scheduled != null && entry.running == null)
|
|
1975
|
+
.map((entry) => ({ entry, generation: entry.scheduled! }))
|
|
1976
|
+
const staged: StagedProjection[] = []
|
|
1977
|
+
for (let offset = 0; offset < cohort.length && !this.demandQueue.length; offset += PROJECTION_CONCURRENCY) {
|
|
1978
|
+
const chunk = cohort.slice(offset, offset + PROJECTION_CONCURRENCY)
|
|
1979
|
+
const results = await Promise.all(chunk.map((row) => this.runEntry(row)))
|
|
1980
|
+
for (const result of results) if (result) staged.push(result)
|
|
1924
1981
|
}
|
|
1925
|
-
if (
|
|
1982
|
+
if (this.publishCohort(staged)) this.notify()
|
|
1926
1983
|
})().finally(() => {
|
|
1927
1984
|
this.batch = null
|
|
1928
|
-
if (hasPending()) this.startBatch()
|
|
1985
|
+
if (this.hasPending()) this.startBatch()
|
|
1929
1986
|
})
|
|
1930
1987
|
}
|
|
1931
1988
|
|
|
1932
|
-
private async runEntry(entry:
|
|
1933
|
-
|
|
1989
|
+
private async runEntry({ entry, generation }: ProjectionCohortRow): Promise<StagedProjection | null> {
|
|
1990
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation
|
|
1991
|
+
|| entry.scheduled !== generation || entry.running != null) return null
|
|
1934
1992
|
entry.scheduled = null
|
|
1935
1993
|
entry.running = generation
|
|
1936
1994
|
let result: SummaryBuildResult
|
|
@@ -1940,21 +1998,8 @@ export class SessionEvalProjectionCache {
|
|
|
1940
1998
|
result = { kind: 'missing' }
|
|
1941
1999
|
}
|
|
1942
2000
|
entry.running = null
|
|
1943
|
-
if (this.entries.get(entry.id) !== entry || entry.generation !== generation) return
|
|
1944
|
-
|
|
1945
|
-
entry.generation++
|
|
1946
|
-
entry.phase = 'updating'
|
|
1947
|
-
entry.scheduled = null
|
|
1948
|
-
this.notify()
|
|
1949
|
-
return false
|
|
1950
|
-
}
|
|
1951
|
-
if (result.kind === 'missing') {
|
|
1952
|
-
entry.phase = 'error'
|
|
1953
|
-
return true
|
|
1954
|
-
}
|
|
1955
|
-
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
1956
|
-
entry.phase = 'ready'
|
|
1957
|
-
return true
|
|
2001
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.scheduled !== null) return null
|
|
2002
|
+
return { entry, generation, result }
|
|
1958
2003
|
}
|
|
1959
2004
|
}
|
|
1960
2005
|
|
|
@@ -1969,20 +2014,37 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
|
|
|
1969
2014
|
if (cached) {
|
|
1970
2015
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
1971
2016
|
return before === after
|
|
1972
|
-
? { kind: 'stable', revision: after, summary: cached }
|
|
2017
|
+
? { kind: 'stable', revision: after, summary: cached.summary }
|
|
1973
2018
|
: { kind: 'unstable' }
|
|
1974
2019
|
}
|
|
1975
2020
|
const model = await buildSessionEvalModel(id, payload, wtPath, true)
|
|
1976
2021
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
1977
2022
|
if (before !== after) return { kind: 'unstable' }
|
|
1978
2023
|
const summary = sessionEvalSummary(model.nodes)
|
|
1979
|
-
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
1980
|
-
|
|
1981
|
-
|
|
2024
|
+
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse. This
|
|
2025
|
+
// fold is latestOnly, so it deposits no model — it only carries any model an earlier demand left here.
|
|
2026
|
+
depositStableCut(id, after, { summary })
|
|
1982
2027
|
return { kind: 'stable', revision: after, summary }
|
|
1983
2028
|
}
|
|
1984
2029
|
|
|
1985
|
-
|
|
2030
|
+
// ONE content-addressed cut per session, keyed by id + content revision. It carries the summary the graph
|
|
2031
|
+
// reads and — only when the demand path built it — the derived full model a repeat open replays.
|
|
2032
|
+
// @@@the two builders do not produce the same model - buildSummaryAttempt folds latestOnly=true (latest
|
|
2033
|
+
// reading per scenario, enough for counts) while the demand path needs the complete A/B history. Only the
|
|
2034
|
+
// demand path may deposit `model`, and only a demand read may consume it; serving the summary path's fold
|
|
2035
|
+
// to a demand would silently truncate every scenario's history.
|
|
2036
|
+
type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
|
|
2037
|
+
const summaryByContent = new Map<string, StableCut>()
|
|
2038
|
+
|
|
2039
|
+
// Content addressing IS the invalidation: a moved input yields a different key. Keeping only the newest key
|
|
2040
|
+
// per session bounds the map at one cut per session and drops summary and model together, so the two can
|
|
2041
|
+
// never disagree about which revision they describe.
|
|
2042
|
+
function depositStableCut(id: string, revision: string, cut: StableCut): void {
|
|
2043
|
+
const key = `${id}\0${revision}`
|
|
2044
|
+
const existing = summaryByContent.get(key)
|
|
2045
|
+
for (const other of [...summaryByContent.keys()]) if (other.startsWith(`${id}\0`)) summaryByContent.delete(other)
|
|
2046
|
+
summaryByContent.set(key, { summary: cut.summary, model: cut.model ?? existing?.model })
|
|
2047
|
+
}
|
|
1986
2048
|
const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt, () => {}, randomUUID(), false)
|
|
1987
2049
|
const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000
|
|
1988
2050
|
|
|
@@ -2029,6 +2091,23 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2029
2091
|
const generation = known?.generation ?? 0
|
|
2030
2092
|
await awaitObservableInputs(id, ctxPath)
|
|
2031
2093
|
const before = await sessionEvalContentRevision(ctxPath)
|
|
2094
|
+
// A repeat open at an unmoved revision replays the cut this same owner already published, instead of
|
|
2095
|
+
// re-deriving it from Git. The stability, observer and generation fences below still decide whether the
|
|
2096
|
+
// answer may be published, so a replay is certified exactly like a fresh fold.
|
|
2097
|
+
// @@@a replay cannot hide a dead selector - the validation that raises 503 reads the declarations and
|
|
2098
|
+
// trees the content revision already covers, so an identical revision has an identical verdict; and a
|
|
2099
|
+
// failing fold deposits nothing, so an unavailable projection always re-derives and re-raises.
|
|
2100
|
+
const replay = summaryByContent.get(`${id}\0${before}`)
|
|
2101
|
+
if (replay?.model) {
|
|
2102
|
+
const settled = await sessionEvalContentRevision(ctxPath)
|
|
2103
|
+
const live = projectionCache.get(id)
|
|
2104
|
+
if (before === settled && !projectionCache.isObserverHeld(id, ctxPath)
|
|
2105
|
+
&& !(live && live.generation !== generation)) {
|
|
2106
|
+
return { kind: 'ready' as const, model: replay.model, summary: replay.summary, generation, revision: settled }
|
|
2107
|
+
}
|
|
2108
|
+
if (before !== settled) projectionCache.invalidate({ id })
|
|
2109
|
+
return { kind: 'retry' as const }
|
|
2110
|
+
}
|
|
2032
2111
|
const model = await buildSessionEvalModel(id, payload, wtPath, false)
|
|
2033
2112
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2034
2113
|
const current = projectionCache.get(id)
|
|
@@ -2038,9 +2117,9 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2038
2117
|
return { kind: 'retry' as const }
|
|
2039
2118
|
}
|
|
2040
2119
|
const summary = sessionEvalSummary(model.nodes)
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2120
|
+
// Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
|
|
2121
|
+
// neither line, so a failure can never poison the entry.
|
|
2122
|
+
depositStableCut(id, after, { summary, model })
|
|
2044
2123
|
if (current) projectionCache.accept(id, generation, after, summary)
|
|
2045
2124
|
return { kind: 'ready' as const, model, summary, generation, revision: after }
|
|
2046
2125
|
})
|
package/spec-eval/src/sidecar.ts
CHANGED
|
@@ -1,66 +1,30 @@
|
|
|
1
1
|
import { readFileSync, appendFileSync, existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
// the verdict is pass | fail; `note` is an OPTIONAL one-line annotation on either (why it failed, how far
|
|
4
|
-
// a pass is from ideal) — not a third status. (Legacy readings filed when `note` was its own status survive
|
|
5
|
-
// on disk with status:'note'; render stays tolerant of them, the CLI no longer mints them.)
|
|
6
3
|
export type Verdict = { status: 'pass' | 'fail'; note?: string }
|
|
7
4
|
|
|
8
|
-
// The evidence-kind taxonomy ([[evidence-kind-taxonomy]]) is a MEDIA/RENDER type — how a blob's bytes are
|
|
9
|
-
// shown — kept ORTHOGONAL to the step-map AXIS (which is derived from the kind, not welded to it): `image`
|
|
10
|
-
// (a still), `transcript` (free-form text), `video` (a screenshot with a time axis), `data` (a structured
|
|
11
|
-
// machine export — a JSON/metrics dump — rendered as a validatable data block, not flattened into scrolling
|
|
12
|
-
// transcript text). A `data` reading is honest about being structured: it can be parsed and checked, and it
|
|
13
|
-
// is derived from CONTENT (isJsonBlob), never from which filing flag was used.
|
|
14
5
|
export type EvidenceKind = 'image' | 'transcript' | 'video' | 'data'
|
|
15
|
-
// one piece of a reading's evidence: a content-addressed blob (`hash`) tagged by `kind`.
|
|
16
6
|
export type Evidence = { hash: string; kind: EvidenceKind }
|
|
17
7
|
|
|
18
|
-
// A reading's evidence is a LIST of typed entries — N images and/or a video (with its step-timeline) and/or
|
|
19
|
-
// a transcript, in filing order. New filings write `evidence`; the legacy scalar shape (`blob` + `blobKind`,
|
|
20
|
-
// absent kind → image) is still READ and normalized to a one-entry list by `evidenceOf`, so old readings
|
|
21
|
-
// still render. A video reading may carry `timelineBlob`: the content hash of its step-timeline sidecar
|
|
22
|
-
// (timeline.ts) mapping clip moments to named steps — it anchors the reading's VIDEO evidence entry.
|
|
23
|
-
// `by` is the SESSION that filed this reading (the filer, from envSessionId) — the ORIGINATOR an eval-comment
|
|
24
|
-
// thread loops in on a reply ([[mentions]] implicit loop-in). Pure additive: a legacy reading without it simply
|
|
25
|
-
// has no originator → silent. WHO measured is deliberately NOT a schema axis — the agent is the measuring
|
|
26
|
-
// hand; the retired `evaluator` tag survives on old lines only, read-tolerated like the scalar blob.
|
|
27
8
|
export type Reading = {
|
|
28
9
|
scenario: string
|
|
29
10
|
codeSha: string
|
|
30
|
-
// content hash of the scenario's semantic text (description+expected, normalized — scenarios.ts
|
|
31
|
-
// scenarioHash) as DECLARED at filing time: the record of which contract this measurement was taken
|
|
32
|
-
// against. When present it alone decides the scenario freshness axis (a pure compare against the current
|
|
33
|
-
// declaration's hash); a legacy reading without it is decided by the git-derived rule instead — one
|
|
34
|
-
// track per reading, never both ([[eval-core]]).
|
|
35
11
|
scenarioHash?: string
|
|
36
12
|
evidence?: Evidence[]
|
|
37
|
-
// legacy scalar evidence — read for old readings, never written by new filings.
|
|
38
13
|
blob?: string | null
|
|
39
14
|
blobKind?: EvidenceKind
|
|
40
15
|
timelineBlob?: string
|
|
41
|
-
// legacy instrument tag (always 'manual@1') — read for old readings, never written by new filings.
|
|
42
16
|
evaluator?: string
|
|
43
17
|
by?: string
|
|
44
18
|
verdict?: Verdict
|
|
45
19
|
ts: string
|
|
46
20
|
}
|
|
47
21
|
|
|
48
|
-
// the one scalar→list bridge every evidence consumer passes through: the `evidence` list when present, else
|
|
49
|
-
// the legacy scalar (blob + blobKind, absent kind → image) as a one-entry list, else empty.
|
|
50
22
|
export function evidenceOf(r: { evidence?: Evidence[]; blob?: string | null; blobKind?: EvidenceKind }): Evidence[] {
|
|
51
23
|
if (r.evidence?.length) return r.evidence
|
|
52
24
|
if (r.blob) return [{ hash: r.blob, kind: r.blobKind ?? 'image' }]
|
|
53
25
|
return []
|
|
54
26
|
}
|
|
55
27
|
|
|
56
|
-
// Is this blob STRUCTURED DATA (a machine export — a JSON object/array) rather than free-form transcript
|
|
57
|
-
// text? The `data` evidence kind ([[evidence-kind-taxonomy]]) is derived from CONTENT, never from which
|
|
58
|
-
// filing flag was used: a hyperfine `--export-json`, an API payload, a metrics dump is data, and flattening
|
|
59
|
-
// it into a scrolling transcript loses that it can be structurally validated and rendered as a data block.
|
|
60
|
-
// Sniffed cheaply and self-contained (no deps): a text blob (no NUL) whose trimmed body brackets as an
|
|
61
|
-
// object/array AND parses to one; anything else (plain logs, terminal text) is not data. This is the ONE
|
|
62
|
-
// predicate both the blob MIME sniff (application/json) and the CLI `--result` kind derive from, so the
|
|
63
|
-
// stored kind and the served Content-Type always agree.
|
|
64
28
|
export function isJsonBlob(b: Buffer): boolean {
|
|
65
29
|
if (!b.length || b.includes(0)) return false // empty or binary → not JSON text
|
|
66
30
|
if (b.length > 4_000_000) return false // don't parse an unbounded blob just to sniff a type
|
|
@@ -70,25 +34,10 @@ export function isJsonBlob(b: Buffer): boolean {
|
|
|
70
34
|
try { const v = JSON.parse(s); return v !== null && typeof v === 'object' } catch { return false }
|
|
71
35
|
}
|
|
72
36
|
|
|
73
|
-
// a RETRACTION is the sanctioned inverse of a filing — itself an appended event, never a deleted line
|
|
74
|
-
// (the sidecar stays append-only; git shows who retracted what, when). `retracts` is the target reading's
|
|
75
|
-
// `ts` within `scenario` (its natural key). The event kinds are told apart POSITIVELY — a retraction
|
|
76
|
-
// carries `retracts`, a reading carries `codeSha`, a human-ok carries `kind: 'human-ok'`; none is ever
|
|
77
|
-
// recognized by another field's absence. `by` is the retracting session; `note` says why (a botched e2e
|
|
78
|
-
// filing, a wrong verdict).
|
|
79
37
|
export type Retraction = { retracts: string; scenario: string; note?: string; by?: string; ts: string }
|
|
80
38
|
|
|
81
|
-
// a HUMAN-OK ([[human-ok]]) is the human's sign-off on ONE immutable reading — an appended event like the
|
|
82
|
-
// others, never a mutation. `okTs`+`okSha` anchor the blessed reading (its ts is the natural key within
|
|
83
|
-
// `scenario`, exactly retraction's join; the sha rides for the human reader). The ok is MONOTONIC — there
|
|
84
|
-
// is no un-ok event: a newer reading is a different object the ok never transfers to, and staleness is
|
|
85
|
-
// computed live, so both automatically bring the scenario back. A pre-human-ok toolchain skips these lines
|
|
86
|
-
// silently (no top-level `codeSha`, so its reading parse never claims them).
|
|
87
39
|
export type HumanOk = { kind: 'human-ok'; scenario: string; okTs: string; okSha: string; by: string; ts: string }
|
|
88
40
|
|
|
89
|
-
// parse the sidecar RAW: one event per non-blank line — a Reading, a Retraction (a line carrying a string
|
|
90
|
-
// `retracts`), or a HumanOk (kind 'human-ok'). A malformed line is skipped (the file is append-only and
|
|
91
|
-
// git-tracked, so a partial write or a hand-edit shouldn't sink the whole read) — fail soft per line.
|
|
92
41
|
export function readSidecar(sidecarPath: string): { readings: Reading[]; retractions: Retraction[]; oks: HumanOk[] } {
|
|
93
42
|
const readings: Reading[] = []
|
|
94
43
|
const retractions: Retraction[] = []
|
|
@@ -103,58 +52,40 @@ export function readSidecar(sidecarPath: string): { readings: Reading[]; retract
|
|
|
103
52
|
if (typeof r.retracts === 'string') retractions.push(r as Retraction)
|
|
104
53
|
else if (r.kind === 'human-ok' && typeof r.okTs === 'string') oks.push(r as HumanOk)
|
|
105
54
|
else if (typeof r.codeSha === 'string') readings.push(r as Reading)
|
|
106
|
-
} catch { /*
|
|
55
|
+
} catch { /* Keep earlier events readable after an incomplete append. */ }
|
|
107
56
|
}
|
|
108
57
|
return { readings, retractions, oks }
|
|
109
58
|
}
|
|
110
59
|
|
|
111
|
-
// the retraction join, shared by every effective-view reader: drop each reading a retraction targets by
|
|
112
|
-
// (scenario, ts) — NUL-joined, since a scenario name may contain spaces. A retraction matching nothing is
|
|
113
|
-
// inert: it excludes no reading and harms no read.
|
|
114
60
|
export function applyRetractions(readings: Reading[], retractions: Retraction[]): Reading[] {
|
|
115
61
|
if (!retractions.length) return readings
|
|
116
62
|
const gone = new Set(retractions.map((x) => `${x.scenario}\0${x.retracts}`))
|
|
117
63
|
return readings.filter((r) => !gone.has(`${r.scenario}\0${r.ts}`))
|
|
118
64
|
}
|
|
119
65
|
|
|
120
|
-
// the EFFECTIVE readings — what the scoreboard sees: every reading minus the retracted. Every score
|
|
121
|
-
// consumer (freshness, scan, clean's referenced set, the eval tab, the proof) reads through here, so a
|
|
122
|
-
// retract undoes a botched filing on ALL of them at once — the previous reading becomes the latest again,
|
|
123
|
-
// or the scenario honestly returns to eval-missing.
|
|
124
66
|
export function readReadings(sidecarPath: string): Reading[] {
|
|
125
67
|
const { readings, retractions } = readSidecar(sidecarPath)
|
|
126
68
|
return applyRetractions(readings, retractions)
|
|
127
69
|
}
|
|
128
70
|
|
|
129
|
-
// append ONE reading as a JSON line — the only mutation eval performs (a reading is an event, never an
|
|
130
|
-
// overwrite; superseding readings are newer lines, freshness picks the latest per scenario).
|
|
131
71
|
export function appendReading(sidecarPath: string, r: Reading): void {
|
|
132
72
|
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
133
73
|
}
|
|
134
74
|
|
|
135
|
-
// append ONE retraction as a JSON line — the sanctioned undo writes through the same append-only surface
|
|
136
|
-
// that filed the reading; the target line stays in place as history.
|
|
137
75
|
export function appendRetraction(sidecarPath: string, r: Retraction): void {
|
|
138
76
|
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
139
77
|
}
|
|
140
78
|
|
|
141
|
-
// append ONE human-ok as a JSON line — the sign-off writes through the same append-only surface; the
|
|
142
|
-
// blessed reading stays untouched, the ok binds to it by (scenario, okTs).
|
|
143
79
|
export function appendHumanOk(sidecarPath: string, r: HumanOk): void {
|
|
144
80
|
appendFileSync(sidecarPath, JSON.stringify(r) + '\n')
|
|
145
81
|
}
|
|
146
82
|
|
|
147
|
-
// the ok that binds to a reading — the LAST ok row targeting (scenario, ts), or null. An ok anchored to a
|
|
148
|
-
// retracted/superseded reading is inert history: it binds to nothing current, so the join is by exact
|
|
149
|
-
// (scenario, okTs) against whichever readings the caller passes.
|
|
150
83
|
export function humanOkFor(oks: HumanOk[], scenario: string, readingTs: string): HumanOk | null {
|
|
151
84
|
let hit: HumanOk | null = null
|
|
152
85
|
for (const o of oks) if (o.scenario === scenario && o.okTs === readingTs) hit = o
|
|
153
86
|
return hit
|
|
154
87
|
}
|
|
155
88
|
|
|
156
|
-
// the latest reading per scenario (the file is chronological, so the LAST line for a name wins). clean's
|
|
157
|
-
// --keep-latest uses it to decide which blob to keep.
|
|
158
89
|
export function latestPerScenario(readings: Reading[]): Map<string, Reading> {
|
|
159
90
|
const m = new Map<string, Reading>()
|
|
160
91
|
for (const r of readings) m.set(r.scenario, r) // later lines overwrite earlier → last wins
|
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
// step-timeline — the map from a POSITION on a piece of evidence's own axis to a named step. SpexCode owns
|
|
2
|
-
// only this FORMAT (a tiny data contract any userland emitter satisfies — a Playwright reporter, a WebDriver
|
|
3
|
-
// listener, a computer-use hand narrating as it drives, a CLI run stamping line numbers); aligning the
|
|
4
|
-
// emitter's positions to the evidence is the emitter's own job. The map rides as a second content-addressed
|
|
5
|
-
// blob on the reading, never a new ndjson column beyond the one hash.
|
|
6
|
-
//
|
|
7
|
-
// The step is anchored to the evidence's OWN axis, tagged by `axis`: `time` (ms, a video), `frame` (a still
|
|
8
|
-
// SEQUENCE by index), `line` (a transcript by line number), `index` (a bare action ordinal). The set is
|
|
9
|
-
// OPEN by convention — an unknown axis is legal and a reader renders its positions as bare numbers. `stepAt`
|
|
10
|
-
// (last step at or before a position) is axis-agnostic and unchanged.
|
|
11
|
-
|
|
12
1
|
export type TimelineEvent = { at: number; step: string; node?: string }
|
|
13
2
|
export type StepTimeline = { v: 2; axis: string; events: TimelineEvent[] }
|
|
14
3
|
|
|
15
|
-
// legacy v1 is the TIME axis with `tMs` as the position — read losslessly, normalized to the axis-tagged
|
|
16
|
-
// shape (`axis: 'time'`, `at: tMs`). Kept forever: an emitter that only knew `{ v: 1, events: [{ tMs }] }`
|
|
17
|
-
// still files a valid video step-map.
|
|
18
4
|
export type LegacyTimelineEvent = { tMs: number; step: string; node?: string }
|
|
19
5
|
export type LegacyStepTimeline = { v: 1; events: LegacyTimelineEvent[] }
|
|
20
6
|
|
|
21
7
|
const V2_EVENT_KEYS = new Set(['at', 'step', 'node'])
|
|
22
8
|
const V1_EVENT_KEYS = new Set(['tMs', 'step', 'node'])
|
|
23
9
|
|
|
24
|
-
// validate LOUD — every violation named, [] when well-formed. Both schema versions are accepted: v1 (legacy
|
|
25
|
-
// time axis, `tMs`) and v2 (axis-tagged, `at`). The key set is closed per version (like the eval.md
|
|
26
|
-
// scenario schema): a malformed timeline is rejected at filing time, never silently reshaped. The `axis`
|
|
27
|
-
// string itself is open — only its ABSENCE is an error, never an unrecognized value.
|
|
28
10
|
export function validateTimeline(raw: unknown): string[] {
|
|
29
11
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['timeline must be a JSON object { v, axis, events }']
|
|
30
12
|
const o = raw as Record<string, unknown>
|
|
@@ -55,9 +37,6 @@ export function validateTimeline(raw: unknown): string[] {
|
|
|
55
37
|
return errs
|
|
56
38
|
}
|
|
57
39
|
|
|
58
|
-
// normalize any VALID timeline (v1 or v2) to the axis-tagged shape every reader uses: v1 IS the time axis
|
|
59
|
-
// with `tMs` as the position. Call only on input `validateTimeline` accepted. This is the whole of the
|
|
60
|
-
// lossless back-compat: an old v1 blob and a new `{ v: 2, axis: 'time' }` render identically.
|
|
61
40
|
export function normalizeTimeline(raw: unknown): { axis: string; events: TimelineEvent[] } {
|
|
62
41
|
const o = (raw ?? {}) as Record<string, any>
|
|
63
42
|
const events: any[] = Array.isArray(o.events) ? o.events : []
|
|
@@ -65,8 +44,6 @@ export function normalizeTimeline(raw: unknown): { axis: string; events: Timelin
|
|
|
65
44
|
return { axis: typeof o.axis === 'string' ? o.axis : 'time', events: events.map((e) => ({ at: e.at, step: e.step, ...(e.node ? { node: e.node } : {}) })) }
|
|
66
45
|
}
|
|
67
46
|
|
|
68
|
-
// the whole of "which step is this position": the last event at or before `pos`; null before the first event
|
|
69
|
-
// (a plain moment, no step to name — graceful, never an error). Axis-agnostic — `pos` is on the events' axis.
|
|
70
47
|
export function stepAt(events: TimelineEvent[], pos: number): TimelineEvent | null {
|
|
71
48
|
let hit: TimelineEvent | null = null
|
|
72
49
|
for (const e of events) {
|