@mmnto/totem 1.92.0 → 1.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/verdict.d.ts +1088 -0
- package/dist/artifacts/verdict.d.ts.map +1 -0
- package/dist/artifacts/verdict.js +863 -0
- package/dist/artifacts/verdict.js.map +1 -0
- package/dist/artifacts/verdict.test.d.ts +25 -0
- package/dist/artifacts/verdict.test.d.ts.map +1 -0
- package/dist/artifacts/verdict.test.js +878 -0
- package/dist/artifacts/verdict.test.js.map +1 -0
- package/dist/config-schema.d.ts +155 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +22 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/sys/git.d.ts +21 -0
- package/dist/sys/git.d.ts.map +1 -1
- package/dist/sys/git.js +15 -3
- package/dist/sys/git.js.map +1 -1
- package/dist/sys/git.test.js +58 -1
- package/dist/sys/git.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict-artifact contract — the single convergence point both review lanes
|
|
3
|
+
* emit (mmnto-ai/totem#2106, Proposal 302 / 304 R2 local review runner).
|
|
4
|
+
*
|
|
5
|
+
* A verdict artifact is the immutable, content-addressed record of ONE review
|
|
6
|
+
* round over ONE masked diff: the fan of lanes that attempted it (each a
|
|
7
|
+
* terminal {@link RunArtifact} reference, one hop from provenance), the
|
|
8
|
+
* deterministic #2103 post-checks, the normalized findings, the optional #2104
|
|
9
|
+
* panel it assembled, and the derived round/lineage bookkeeping. Everything
|
|
10
|
+
* downstream (the CLI round loop, the pilot ledger's covariate PR-line, the
|
|
11
|
+
* Phase-2 disposition ledger) consumes this shape, so it stays minimal but
|
|
12
|
+
* versioned.
|
|
13
|
+
*
|
|
14
|
+
* ── LANE-BLINDNESS INVARIANT (Proposal 302, DELIBERATE EXCLUSION) ────────────
|
|
15
|
+
* There is NO warm/cold runner-lane discriminator field ANYWHERE in this schema
|
|
16
|
+
* — not at the top level, not on a lane. This exclusion is deliberate: a
|
|
17
|
+
* contract consumer reads the verdict and CANNOT discriminate WHICH runner lane
|
|
18
|
+
* (a warm resident agent vs a cold SDK invocation) produced it. The wording
|
|
19
|
+
* matters (strategy 1a): "consumers cannot discriminate lanes FROM the
|
|
20
|
+
* artifact", NOT "lane identity is unknowable" — `lanes[].runArtifactHash`
|
|
21
|
+
* reaches provenance one hop away and `resolvedBackend` is panel-DIVERSITY data,
|
|
22
|
+
* neither of which is a warm/cold runner discriminator. The absence is enforced
|
|
23
|
+
* by a structural test (snapshots the key set) IN ADDITION to this note.
|
|
24
|
+
*
|
|
25
|
+
* The KEY-set structural test is not enough on its own: a runner class could be
|
|
26
|
+
* smuggled through a laneId VALUE. So `laneId` is additionally constrained to a
|
|
27
|
+
* backend-derived vocabulary — `lane-<index>:<resolvedBackendOrConfiguredLane>`
|
|
28
|
+
* (see {@link LaneIdSchema}) — with a refinement rejecting warm/cold/headless/
|
|
29
|
+
* sdk-runner substrings (strategy-codex G1). Net invariant: a consumer can
|
|
30
|
+
* identify WHICH backend participated (diversity), NEVER whether the producer was
|
|
31
|
+
* warm / cold / headless.
|
|
32
|
+
*
|
|
33
|
+
* Schema-evolution policy mirrors {@link RunArtifactSchema} / the panel artifact
|
|
34
|
+
* (F1): the reader is version-tolerant WITHIN the major — `schemaVersion`
|
|
35
|
+
* validates as `1.x`, every post-1.0.0 field is additive-optional, and a MAJOR
|
|
36
|
+
* bump requires a migration entry in `loadVerdictArtifact` before the writer
|
|
37
|
+
* ships. Hard-reject only unknown majors. Zod is the persisted-JSON boundary
|
|
38
|
+
* (read back from disk), per the repo's Zod-at-boundaries-only rule.
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { z } from 'zod';
|
|
43
|
+
import { rethrowAsParseError, TotemError, TotemParseError } from '../errors.js';
|
|
44
|
+
import { readJsonSafe } from '../sys/fs.js';
|
|
45
|
+
import { calculateDeterministicHash } from './hash.js';
|
|
46
|
+
import { classifyDiversity, PanelDiversitySchema, PersistedPostCheckFindingSchema, } from './panel.js';
|
|
47
|
+
// ─── Schema version (mirrors RunArtifact / Panel F1) ────────────────────────
|
|
48
|
+
/** The verdict schemaVersion WRITTEN by this code. Readers accept any 1.x (F1). */
|
|
49
|
+
export const VERDICT_ARTIFACT_SCHEMA_VERSION = '1.0.0';
|
|
50
|
+
/** The major this reader understands; other majors need a migration entry. */
|
|
51
|
+
export const VERDICT_ARTIFACT_KNOWN_MAJOR = 1;
|
|
52
|
+
/** Major-1 semver literal — keep in sync with {@link VERDICT_ARTIFACT_KNOWN_MAJOR} (a literal beats runtime RegExp construction; the major only changes alongside a migration entry). */
|
|
53
|
+
const VERDICT_SCHEMA_VERSION_RE = /^1\.\d+\.\d+$/;
|
|
54
|
+
/** Accept any 1.x version; reject other majors with the version NAMED (F1) —
|
|
55
|
+
* mirrors run-artifact's `schemaVersionField` refine so the rejection error
|
|
56
|
+
* carries the offending value, not just a static string. */
|
|
57
|
+
const verdictSchemaVersionField = z.string().refine((v) => VERDICT_SCHEMA_VERSION_RE.test(v), (v) => ({
|
|
58
|
+
message: `unsupported verdict-artifact schemaVersion "${v}" — this reader understands major ${VERDICT_ARTIFACT_KNOWN_MAJOR}.x; a new major requires a migration entry in loadVerdictArtifact`,
|
|
59
|
+
}));
|
|
60
|
+
/** sha256 hex content hash (full digest — identity, not display). */
|
|
61
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
62
|
+
/** Zod guard for a sha256 hex content address (mirrors schema.ts; no bare RegExp.test at the boundary). */
|
|
63
|
+
const Sha256HexSchema = z.string().regex(SHA256_HEX, 'must be a sha256 hex digest');
|
|
64
|
+
// ─── Diff scope (source-discriminated) ──────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* The four `getDiffForReview` sources. Canonical order matches the design doc.
|
|
67
|
+
*/
|
|
68
|
+
export const VERDICT_DIFF_SOURCES = [
|
|
69
|
+
'explicit-range',
|
|
70
|
+
'staged',
|
|
71
|
+
'uncommitted',
|
|
72
|
+
'branch-vs-base',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* The reviewed diff's scope, DISCRIMINATED by `source`. `diffHash` is ALWAYS
|
|
76
|
+
* required (sha256 over the MASKED review-payload bytes the lanes actually
|
|
77
|
+
* reviewed — hash symmetry with the artifact chain, never binds secret-bearing
|
|
78
|
+
* bytes; agy fold 5). The git ref fields are required only where the source
|
|
79
|
+
* makes them meaningful:
|
|
80
|
+
* - `explicit-range` — `base` AND `head` (the two endpoints).
|
|
81
|
+
* - `branch-vs-base` — `base` only (head is the working ref, implicit).
|
|
82
|
+
* - `staged` / `uncommitted` — NO refs (the index / worktree has none).
|
|
83
|
+
*/
|
|
84
|
+
export const VerdictDiffScopeSchema = z.discriminatedUnion('source', [
|
|
85
|
+
z.object({
|
|
86
|
+
source: z.literal('explicit-range'),
|
|
87
|
+
diffHash: Sha256HexSchema,
|
|
88
|
+
base: z.string().min(1),
|
|
89
|
+
head: z.string().min(1),
|
|
90
|
+
}),
|
|
91
|
+
z.object({
|
|
92
|
+
source: z.literal('branch-vs-base'),
|
|
93
|
+
diffHash: Sha256HexSchema,
|
|
94
|
+
base: z.string().min(1),
|
|
95
|
+
}),
|
|
96
|
+
z.object({
|
|
97
|
+
source: z.literal('staged'),
|
|
98
|
+
diffHash: Sha256HexSchema,
|
|
99
|
+
}),
|
|
100
|
+
z.object({
|
|
101
|
+
source: z.literal('uncommitted'),
|
|
102
|
+
diffHash: Sha256HexSchema,
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
// ─── Lanes (status-discriminated union) ─────────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Typed terminal-failure reasons for a `failed` lane. A failed lane is never
|
|
108
|
+
* handed to `assemblePanelArtifact` and never stamps the cache. NOTE (Prop 302
|
|
109
|
+
* lane-blindness): these classify the FAILURE, never the runner lane — none of
|
|
110
|
+
* them names warm/cold.
|
|
111
|
+
*/
|
|
112
|
+
export const VERDICT_LANE_FAILURE_REASONS = [
|
|
113
|
+
'invoke-error',
|
|
114
|
+
'quota-exhausted',
|
|
115
|
+
'missing-artifact-emission',
|
|
116
|
+
'config-error',
|
|
117
|
+
];
|
|
118
|
+
/** A `completed` lane's own severity tally (from its extracted structured verdict). */
|
|
119
|
+
export const VerdictLaneSummarySchema = z.object({
|
|
120
|
+
critical: z.number().int().nonnegative(),
|
|
121
|
+
warn: z.number().int().nonnegative(),
|
|
122
|
+
info: z.number().int().nonnegative(),
|
|
123
|
+
});
|
|
124
|
+
// ─── laneId value-channel vocabulary (Prop 302 G1) ──────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* The EXACT laneId shape — `lane-<index>:<resolvedBackendOrConfiguredLane>`:
|
|
127
|
+
* - `lane-` literal prefix,
|
|
128
|
+
* - `<index>` — the lane's zero-based position in the fan (`\d+`),
|
|
129
|
+
* - `:` separator,
|
|
130
|
+
* - `<resolvedBackendOrConfiguredLane>` — the resolved backend (`provider:model`,
|
|
131
|
+
* which itself carries a colon) for a lane that reached a backend, or the
|
|
132
|
+
* CONFIGURED lane string for a lane that failed before one resolved. Non-empty,
|
|
133
|
+
* opaque backend/lane text (`.+` — newlines excluded).
|
|
134
|
+
*
|
|
135
|
+
* This is backend-DERIVED vocabulary: a consumer can read the id and identify
|
|
136
|
+
* WHICH backend participated (panel-diversity data), and NOTHING about whether the
|
|
137
|
+
* producer was warm / cold / headless.
|
|
138
|
+
*/
|
|
139
|
+
export const LANE_ID_SHAPE_RE = /^lane-\d+:.+$/;
|
|
140
|
+
/**
|
|
141
|
+
* laneId: shape-validated here; the VALUE channel of lane-blindness (Prop 302
|
|
142
|
+
* G1) is closed STRUCTURALLY by the schema's superRefine — every lane's suffix
|
|
143
|
+
* must equal its binding field (`resolvedBackend` for completed/abstained,
|
|
144
|
+
* `configuredLane` for failed), so a laneId cannot carry free text at all and a
|
|
145
|
+
* runner class (`warm`/`cold`/`headless`) has no channel to ride. An earlier
|
|
146
|
+
* substring blacklist here was removed: with structural binding as the primary
|
|
147
|
+
* guard it added only false-positive risk against legitimate future model names
|
|
148
|
+
* (e.g. a model literally named `*-cold-*`; PR #2337 greptile P2).
|
|
149
|
+
*/
|
|
150
|
+
export const LaneIdSchema = z
|
|
151
|
+
.string()
|
|
152
|
+
.regex(LANE_ID_SHAPE_RE, 'laneId must have the shape `lane-<index>:<resolvedBackendOrConfiguredLane>` (e.g. `lane-0:anthropic:claude-4`) — backend-derived vocabulary only (Prop 302 G1)');
|
|
153
|
+
/**
|
|
154
|
+
* One lane's terminal outcome, DISCRIMINATED by `status`. The union makes
|
|
155
|
+
* impossible records unrepresentable (codex fold 2): a `completed` lane STRUCTURALLY
|
|
156
|
+
* requires its `runArtifactHash` (a response-cache hit emits no run artifact and so
|
|
157
|
+
* can never be `completed`); a `failed` lane carries a typed reason and never a
|
|
158
|
+
* `runArtifactHash`. `resolvedBackend` records what actually ran (post quota
|
|
159
|
+
* fallback) and is panel-diversity data — NOT a runner discriminator.
|
|
160
|
+
*/
|
|
161
|
+
export const VerdictLaneSchema = z.discriminatedUnion('status', [
|
|
162
|
+
z.object({
|
|
163
|
+
status: z.literal('completed'),
|
|
164
|
+
laneId: LaneIdSchema,
|
|
165
|
+
resolvedBackend: z.string().min(1),
|
|
166
|
+
runArtifactHash: Sha256HexSchema,
|
|
167
|
+
verdictSummary: VerdictLaneSummarySchema,
|
|
168
|
+
}),
|
|
169
|
+
z.object({
|
|
170
|
+
status: z.literal('abstained'),
|
|
171
|
+
laneId: LaneIdSchema,
|
|
172
|
+
resolvedBackend: z.string().min(1),
|
|
173
|
+
runArtifactHash: Sha256HexSchema,
|
|
174
|
+
/** Why no usable structured verdict was extractable (invoke happened, output unparseable). */
|
|
175
|
+
reason: z.string().min(1),
|
|
176
|
+
}),
|
|
177
|
+
z.object({
|
|
178
|
+
status: z.literal('failed'),
|
|
179
|
+
laneId: LaneIdSchema,
|
|
180
|
+
typedReason: z.enum(VERDICT_LANE_FAILURE_REASONS),
|
|
181
|
+
/**
|
|
182
|
+
* REQUIRED (rev-6 item 3): the configured `provider:model` string the lane was
|
|
183
|
+
* created from. A failed lane can have NO `resolvedBackend` (it failed before a
|
|
184
|
+
* backend resolved — e.g. `config-error` / `missing-artifact-emission`), so the
|
|
185
|
+
* laneId suffix has nothing to bind to unless the configured lane is persisted.
|
|
186
|
+
* The `superRefine` binds `laneId` suffix === `configuredLane` (closing the
|
|
187
|
+
* `lane-0:gemini:completely-invented` tautology): the suffix is no longer free —
|
|
188
|
+
* it must equal this declared field.
|
|
189
|
+
*/
|
|
190
|
+
configuredLane: z.string().min(1),
|
|
191
|
+
/**
|
|
192
|
+
* OPTIONAL supplementary provenance: the backend that ACTUALLY ran before the
|
|
193
|
+
* lane failed (present only when a backend resolved — e.g. a quota fallback that
|
|
194
|
+
* then failed). NOT the id binding: after a quota fallback it can legitimately
|
|
195
|
+
* DIFFER from `configuredLane`, so the laneId suffix binds to `configuredLane`
|
|
196
|
+
* (stable at lane creation), never to this field.
|
|
197
|
+
*/
|
|
198
|
+
resolvedBackend: z.string().min(1).optional(),
|
|
199
|
+
}),
|
|
200
|
+
]);
|
|
201
|
+
// ─── Findings (aligned with ShieldFinding — core must not import from cli) ────
|
|
202
|
+
/** Severity vocabulary — aligned VERBATIM with cli `ShieldFindingSeveritySchema` (defined here so core stays cli-independent). */
|
|
203
|
+
export const VerdictFindingSeveritySchema = z.enum(['CRITICAL', 'WARN', 'INFO']);
|
|
204
|
+
/**
|
|
205
|
+
* A normalized finding from the shared review-output extractor. Field names
|
|
206
|
+
* align with cli `ShieldFinding` (`severity` / `confidence` / `message` /
|
|
207
|
+
* `file` / `line`); `confidence` is optional here because not every extracted
|
|
208
|
+
* lane output carries one, but when present it is a 0..1 probability (same
|
|
209
|
+
* bound as ShieldFinding). The diagnostic `message` is NEVER dropped or
|
|
210
|
+
* renamed.
|
|
211
|
+
*/
|
|
212
|
+
export const VerdictFindingSchema = z.object({
|
|
213
|
+
severity: VerdictFindingSeveritySchema,
|
|
214
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
215
|
+
file: z.string().optional(),
|
|
216
|
+
line: z.number().optional(),
|
|
217
|
+
message: z.string(),
|
|
218
|
+
});
|
|
219
|
+
// ─── Round / lineage ─────────────────────────────────────────────────────────
|
|
220
|
+
/**
|
|
221
|
+
* Round bookkeeping (all DERIVED — see the CLI lifecycle). `lineageKey` is the
|
|
222
|
+
* composite hash over the RESOLVED scope selector (see {@link computeLineageKey}
|
|
223
|
+
* — worktree identity + branch + source + the meaningful range selectors), NOT
|
|
224
|
+
* the diff bytes, so legitimate fix rounds still chain; `priorVerdictHash` links
|
|
225
|
+
* the implicit prior round (latest verdict sharing the lineage key) or an
|
|
226
|
+
* explicit `--continues` override; absent at round 0.
|
|
227
|
+
*/
|
|
228
|
+
export const VerdictRoundSchema = z.object({
|
|
229
|
+
index: z.number().int().nonnegative(),
|
|
230
|
+
priorVerdictHash: Sha256HexSchema.optional(),
|
|
231
|
+
lineageKey: z.string().min(1),
|
|
232
|
+
});
|
|
233
|
+
/** Shared first conjunct: a nonempty fan where every attempted lane completed. */
|
|
234
|
+
function everyLaneCompleted(lanes) {
|
|
235
|
+
return lanes.length > 0 && lanes.every((l) => l.status === 'completed');
|
|
236
|
+
}
|
|
237
|
+
/** A decidable-tier post-check row failed (sensor-tier rows never gate — ADR-109 / #2106). */
|
|
238
|
+
function hasDecidablePostCheckFail(postChecks) {
|
|
239
|
+
return postChecks.some((r) => r.tier === 'decidable' && r.verdict === 'fail');
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `settled` — the current-round dryness predicate, PURE over artifact content (no
|
|
243
|
+
* cross-round input, no model output):
|
|
244
|
+
*
|
|
245
|
+
* settled = (every attempted lane completed)
|
|
246
|
+
* AND (zero actionable — WARN|CRITICAL — findings)
|
|
247
|
+
* AND (no decidable-tier post-check row with verdict 'fail')
|
|
248
|
+
* AND (reviewedState === 'matched')
|
|
249
|
+
*
|
|
250
|
+
* A failed/abstained lane ⇒ fan incomplete ⇒ never settled (a persistent CRITICAL
|
|
251
|
+
* can never settle by lane dropout — agy fold 1, satisfied structurally); drift ⇒
|
|
252
|
+
* the verdict is bound to the pre-fan diff and does NOT cover the current tree ⇒
|
|
253
|
+
* not settled (codex rev-2 fold 1). This export is the SINGLE SOURCE OF TRUTH: the
|
|
254
|
+
* CLI derives its loop-termination signal from it and {@link VerdictArtifactSchema}
|
|
255
|
+
* re-derives + checks the stored `settled` (finding 5) — a crafted lane output
|
|
256
|
+
* cannot flip it (pure function; the exemption filter is the only removal
|
|
257
|
+
* mechanism, upstream of this boundary).
|
|
258
|
+
*/
|
|
259
|
+
export function deriveSettled(v) {
|
|
260
|
+
return (everyLaneCompleted(v.lanes) &&
|
|
261
|
+
!v.findings.some((f) => f.severity === 'WARN' || f.severity === 'CRITICAL') &&
|
|
262
|
+
!hasDecidablePostCheckFail(v.postChecks) &&
|
|
263
|
+
v.reviewedState === 'matched');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Cache eligibility — the DISTINCT, weaker predicate (codex fold 4). Identical to
|
|
267
|
+
* {@link deriveSettled} except it tolerates WARNs (matching today's PASS
|
|
268
|
+
* semantics — the drip class the runner absorbs is WARN-shaped):
|
|
269
|
+
*
|
|
270
|
+
* cacheEligible = (every attempted lane completed)
|
|
271
|
+
* AND (zero CRITICAL findings)
|
|
272
|
+
* AND (no decidable-tier post-check row with verdict 'fail')
|
|
273
|
+
* AND (reviewedState === 'matched')
|
|
274
|
+
*
|
|
275
|
+
* A degraded fan (any failed/abstained lane) fails the first conjunct and is
|
|
276
|
+
* therefore never cache-eligible; drift blocks the stamp. `settled` (no WARNs) is
|
|
277
|
+
* deliberately STRICTER than cache-eligible (no CRITICALs).
|
|
278
|
+
*/
|
|
279
|
+
export function deriveCacheEligible(v) {
|
|
280
|
+
return (everyLaneCompleted(v.lanes) &&
|
|
281
|
+
!v.findings.some((f) => f.severity === 'CRITICAL') &&
|
|
282
|
+
!hasDecidablePostCheckFail(v.postChecks) &&
|
|
283
|
+
v.reviewedState === 'matched');
|
|
284
|
+
}
|
|
285
|
+
// ─── Verdict artifact ──────────────────────────────────────────────────────
|
|
286
|
+
/**
|
|
287
|
+
* The verdict artifact. See the module docstring for the LANE-BLINDNESS
|
|
288
|
+
* invariant (Prop 302): NO warm/cold runner-lane discriminator field exists,
|
|
289
|
+
* deliberately.
|
|
290
|
+
*
|
|
291
|
+
* `superRefine` enforces the cross-field invariants that a hand-edited or
|
|
292
|
+
* builder-buggy record could otherwise violate silently — mirrored counts are
|
|
293
|
+
* NEVER accepted on trust (codex):
|
|
294
|
+
* - `attemptedLaneCount === lanes.length`; `completedLaneCount === #completed`.
|
|
295
|
+
* - lanes nonempty, laneIds unique (finding 9c).
|
|
296
|
+
* - panel ⟺ diversity AND panel ⟺ ≥2 completed lanes, BOTH directions
|
|
297
|
+
* (finding 9a): a panel is assembled from — and only from — ≥2 usable lanes,
|
|
298
|
+
* and always emits its diversity summary.
|
|
299
|
+
* - round-chain shape (finding 9b): `round.index === 0` ⟺ `priorVerdictHash`
|
|
300
|
+
* absent (round 0 starts a chain; round N>0 links its prior).
|
|
301
|
+
* - stored `settled === deriveSettled(value)` (finding 5): the persisted
|
|
302
|
+
* boundary re-derives the dryness predicate, never trusting a fabricated flag.
|
|
303
|
+
*/
|
|
304
|
+
export const VerdictArtifactSchema = z
|
|
305
|
+
.object({
|
|
306
|
+
schemaVersion: verdictSchemaVersionField,
|
|
307
|
+
/** The reviewed diff's scope + masked-payload hash (source-discriminated). */
|
|
308
|
+
diffScope: VerdictDiffScopeSchema,
|
|
309
|
+
/** Every attempted lane's terminal outcome (status-discriminated union). */
|
|
310
|
+
lanes: z.array(VerdictLaneSchema),
|
|
311
|
+
/** MUST equal `lanes.length` (validated below — never trusted). */
|
|
312
|
+
attemptedLaneCount: z.number().int().nonnegative(),
|
|
313
|
+
/** MUST equal the count of `completed` lanes (validated below — never trusted). */
|
|
314
|
+
completedLaneCount: z.number().int().nonnegative(),
|
|
315
|
+
/** Present IFF a #2104 panel was actually assembled (≥2 completed lanes; guarded below). */
|
|
316
|
+
panelArtifactHash: Sha256HexSchema.optional(),
|
|
317
|
+
/** Deterministic #2103 post-checks — the persisted vocabulary VERBATIM (`ruleName`/`tier`/`verdict`/`message`). */
|
|
318
|
+
postChecks: z.array(PersistedPostCheckFindingSchema),
|
|
319
|
+
/** Normalized findings from the shared extractor (exemption-filtered by the CLI before it lands here). */
|
|
320
|
+
findings: z.array(VerdictFindingSchema),
|
|
321
|
+
/** A SINGLE top-level panel-diversity summary (classifyDiversity output) — present only with a panel; NEVER mirrored per finding. */
|
|
322
|
+
diversity: PanelDiversitySchema.optional(),
|
|
323
|
+
round: VerdictRoundSchema,
|
|
324
|
+
/**
|
|
325
|
+
* Post-fan tree compare against the PRE-fan content hash (codex rev-2 fold 1):
|
|
326
|
+
* `'matched'` when the tracked-source tree is byte-identical before and after
|
|
327
|
+
* the fan, `'drifted'` when it changed mid-fan. A DERIVED, non-sentinel field
|
|
328
|
+
* (the two hash domains stay separate — this records the OUTCOME of the compare,
|
|
329
|
+
* not the content hash itself). Drift forces `settled=false` and blocks the
|
|
330
|
+
* cache stamp: the verdict is bound to the pre-fan diff, so a dry fan over a
|
|
331
|
+
* mutated tree does NOT cover the current tree and must not settle the loop.
|
|
332
|
+
*/
|
|
333
|
+
reviewedState: z.enum(['matched', 'drifted']),
|
|
334
|
+
/** Current-round dryness predicate (see the CLI lifecycle) — pure over artifact content. */
|
|
335
|
+
settled: z.boolean(),
|
|
336
|
+
/**
|
|
337
|
+
* ISO-8601 emission time — a VALIDATED datetime (rev-5 item 8): a malformed
|
|
338
|
+
* `createdAt` is rejected at the persisted boundary rather than trusted.
|
|
339
|
+
* EXCLUDED from the content address (identical rounds dedup to one artifact
|
|
340
|
+
* regardless of when they ran) — observability only, and it is never a
|
|
341
|
+
* lineage tie-breaker (see {@link findLatestVerdictForLineage}). See
|
|
342
|
+
* {@link computeVerdictArtifactContentHash}.
|
|
343
|
+
*/
|
|
344
|
+
createdAt: z.string().datetime(),
|
|
345
|
+
})
|
|
346
|
+
.superRefine((a, ctx) => {
|
|
347
|
+
if (a.attemptedLaneCount !== a.lanes.length) {
|
|
348
|
+
ctx.addIssue({
|
|
349
|
+
code: z.ZodIssueCode.custom,
|
|
350
|
+
path: ['attemptedLaneCount'],
|
|
351
|
+
message: `attemptedLaneCount (${a.attemptedLaneCount}) must equal lanes.length (${a.lanes.length}) — counts are never mirrored on trust`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const completed = a.lanes.filter((l) => l.status === 'completed').length;
|
|
355
|
+
if (a.completedLaneCount !== completed) {
|
|
356
|
+
ctx.addIssue({
|
|
357
|
+
code: z.ZodIssueCode.custom,
|
|
358
|
+
path: ['completedLaneCount'],
|
|
359
|
+
message: `completedLaneCount (${a.completedLaneCount}) must equal the number of completed lanes (${completed}) — counts are never mirrored on trust`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// ── Finding 9c: lanes nonempty, laneIds unique ──
|
|
363
|
+
if (a.lanes.length === 0) {
|
|
364
|
+
ctx.addIssue({
|
|
365
|
+
code: z.ZodIssueCode.custom,
|
|
366
|
+
path: ['lanes'],
|
|
367
|
+
message: 'lanes must be nonempty — a verdict records at least the lane(s) attempted (even a total-failure fan lists its failed lanes)',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
const laneIds = a.lanes.map((l) => l.laneId);
|
|
371
|
+
const duplicateLaneIds = [...new Set(laneIds.filter((id, i) => laneIds.indexOf(id) !== i))];
|
|
372
|
+
if (duplicateLaneIds.length > 0) {
|
|
373
|
+
ctx.addIssue({
|
|
374
|
+
code: z.ZodIssueCode.custom,
|
|
375
|
+
path: ['lanes'],
|
|
376
|
+
message: `duplicate laneId(s) [${duplicateLaneIds.join(', ')}] — each lane needs a unique id`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// ── Finding 9a: panel ⟺ diversity AND panel ⟺ ≥2 completed lanes (both directions) ──
|
|
380
|
+
const hasPanel = a.panelArtifactHash !== undefined;
|
|
381
|
+
const hasDiversity = a.diversity !== undefined;
|
|
382
|
+
if (hasPanel !== hasDiversity) {
|
|
383
|
+
ctx.addIssue({
|
|
384
|
+
code: z.ZodIssueCode.custom,
|
|
385
|
+
path: [hasPanel ? 'diversity' : 'panelArtifactHash'],
|
|
386
|
+
message: 'panelArtifactHash and diversity must be present together — a panel always emits its diversity summary, and a diversity summary is meaningful only alongside a panel',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (hasPanel && completed < 2) {
|
|
390
|
+
ctx.addIssue({
|
|
391
|
+
code: z.ZodIssueCode.custom,
|
|
392
|
+
path: ['panelArtifactHash'],
|
|
393
|
+
message: `panelArtifactHash present requires at least 2 completed lanes (found ${completed}) — a panel is assembled only from usable lanes`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (!hasPanel && completed >= 2) {
|
|
397
|
+
ctx.addIssue({
|
|
398
|
+
code: z.ZodIssueCode.custom,
|
|
399
|
+
path: ['panelArtifactHash'],
|
|
400
|
+
message: `${completed} completed lanes require a panel (panelArtifactHash + diversity) — a panel is assembled from ALL usable lanes (≥2)`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// ── Finding 9b: round.index === 0 ⟺ priorVerdictHash absent ──
|
|
404
|
+
const isRoundZero = a.round.index === 0;
|
|
405
|
+
const hasPrior = a.round.priorVerdictHash !== undefined;
|
|
406
|
+
if (isRoundZero && hasPrior) {
|
|
407
|
+
ctx.addIssue({
|
|
408
|
+
code: z.ZodIssueCode.custom,
|
|
409
|
+
path: ['round', 'priorVerdictHash'],
|
|
410
|
+
message: 'round 0 must NOT carry priorVerdictHash — round 0 starts a lineage chain (a divergence forks back to round 0)',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (!isRoundZero && !hasPrior) {
|
|
414
|
+
ctx.addIssue({
|
|
415
|
+
code: z.ZodIssueCode.custom,
|
|
416
|
+
path: ['round', 'priorVerdictHash'],
|
|
417
|
+
message: `round ${a.round.index} (>0) requires priorVerdictHash linking the prior round in the chain`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// ── Finding 5: the persisted boundary re-derives `settled`, never trusts it ──
|
|
421
|
+
const derivedSettled = deriveSettled(a);
|
|
422
|
+
if (a.settled !== derivedSettled) {
|
|
423
|
+
ctx.addIssue({
|
|
424
|
+
code: z.ZodIssueCode.custom,
|
|
425
|
+
path: ['settled'],
|
|
426
|
+
message: `settled (${a.settled}) must equal the re-derived current-round dryness predicate (${derivedSettled}) — settled is derived at the persisted boundary, never trusted (finding 5)`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
// ── rev-5 item 6 + rev-6 item 3: structural laneId validation (array-index + binding) ──
|
|
430
|
+
// {@link LaneIdSchema} enforces the SHAPE + the runner-vocab blacklist per-value;
|
|
431
|
+
// the CROSS-FIELD invariants need the lane's array position and sibling fields, so
|
|
432
|
+
// they live here. For every lane the id must be exactly `lane-<i>:<suffix>` where
|
|
433
|
+
// `<i>` matches the array index; the suffix binds to the lane's OWN identity field —
|
|
434
|
+
// `resolvedBackend` for a completed/abstained lane (the backend that ran), and
|
|
435
|
+
// `configuredLane` for a failed lane (the configured provider:model, present even
|
|
436
|
+
// pre-resolution — rev-6 item 3, closing the free-suffix tautology). A failed lane's
|
|
437
|
+
// optional `resolvedBackend` is supplementary provenance, NOT the binding (a quota
|
|
438
|
+
// fallback can make it differ from configuredLane). The blacklist stays defense-in-depth.
|
|
439
|
+
a.lanes.forEach((lane, i) => {
|
|
440
|
+
const expectedPrefix = `lane-${i}:`;
|
|
441
|
+
if (!lane.laneId.startsWith(expectedPrefix)) {
|
|
442
|
+
ctx.addIssue({
|
|
443
|
+
code: z.ZodIssueCode.custom,
|
|
444
|
+
path: ['lanes', i, 'laneId'],
|
|
445
|
+
message: `laneId "${lane.laneId}" must begin with "${expectedPrefix}" — the index must match the lane's array position (${i})`,
|
|
446
|
+
});
|
|
447
|
+
return; // suffix checks are meaningless once the prefix is wrong
|
|
448
|
+
}
|
|
449
|
+
const suffix = lane.laneId.slice(expectedPrefix.length);
|
|
450
|
+
if (lane.status === 'completed' || lane.status === 'abstained') {
|
|
451
|
+
if (suffix !== lane.resolvedBackend) {
|
|
452
|
+
ctx.addIssue({
|
|
453
|
+
code: z.ZodIssueCode.custom,
|
|
454
|
+
path: ['lanes', i, 'laneId'],
|
|
455
|
+
message: `laneId suffix "${suffix}" must equal resolvedBackend "${lane.resolvedBackend}" for a ${lane.status} lane`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (suffix !== lane.configuredLane) {
|
|
460
|
+
// A failed lane's suffix binds to the CONFIGURED lane (rev-6 item 3): the lane
|
|
461
|
+
// may have failed before ANY backend resolved, so the configured provider:model
|
|
462
|
+
// is the only stable identity, and the id must not be a free-floating value.
|
|
463
|
+
ctx.addIssue({
|
|
464
|
+
code: z.ZodIssueCode.custom,
|
|
465
|
+
path: ['lanes', i, 'laneId'],
|
|
466
|
+
message: `failed laneId suffix "${suffix}" must equal configuredLane "${lane.configuredLane}" — a failed lane's id binds to the configured provider:model (rev-6 item 3)`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// ── rev-5 item 7 + rev-6 item 2: FULLY re-derive the diversity summary ──
|
|
471
|
+
// A present diversity summary must be RE-DERIVABLE from the completed lanes' resolved
|
|
472
|
+
// backends via the SAME {@link classifyDiversity} logic the panel uses (single source
|
|
473
|
+
// of truth). rev-6 item 2 compares the WHOLE classifyDiversity result — not just the
|
|
474
|
+
// provider SET + class — so a forged `distinctProviders` / `unrecognizedProviders` /
|
|
475
|
+
// `diversityConfidence` over same-vendor lanes can no longer slip through:
|
|
476
|
+
// - `providers` as a sorted MULTISET (order ignored — the verdict's completed lanes
|
|
477
|
+
// are in configured order while the panel's providers[] is laneId-sorted — but
|
|
478
|
+
// DUPLICATES preserved: two same-vendor lanes are two entries, not a collapsed set);
|
|
479
|
+
// - `distinctProviders`, `class`, `unrecognizedProviders`, `diversityConfidence`
|
|
480
|
+
// each re-derived and required to match (all pure functions of the multiset).
|
|
481
|
+
if (a.diversity !== undefined) {
|
|
482
|
+
const completedProviders = a.lanes
|
|
483
|
+
.filter((l) => l.status === 'completed')
|
|
484
|
+
.map((l) => providerFamilyOf(l.resolvedBackend));
|
|
485
|
+
const derived = classifyDiversity(completedProviders);
|
|
486
|
+
const storedProviders = [...a.diversity.providers].sort();
|
|
487
|
+
const derivedProviders = [...derived.providers].sort();
|
|
488
|
+
const providersEqual = storedProviders.length === derivedProviders.length &&
|
|
489
|
+
storedProviders.every((p, i) => p === derivedProviders[i]);
|
|
490
|
+
if (!providersEqual) {
|
|
491
|
+
ctx.addIssue({
|
|
492
|
+
code: z.ZodIssueCode.custom,
|
|
493
|
+
path: ['diversity', 'providers'],
|
|
494
|
+
message: `diversity providers multiset [${storedProviders.join(', ')}] must equal the multiset derived from the completed lanes' backends [${derivedProviders.join(', ')}] — the summary is re-derived at the persisted boundary, never trusted`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (a.diversity.distinctProviders !== derived.distinctProviders) {
|
|
498
|
+
ctx.addIssue({
|
|
499
|
+
code: z.ZodIssueCode.custom,
|
|
500
|
+
path: ['diversity', 'distinctProviders'],
|
|
501
|
+
message: `diversity.distinctProviders (${a.diversity.distinctProviders}) must equal the value derived from the completed lanes' backends (${derived.distinctProviders})`,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (a.diversity.class !== derived.class) {
|
|
505
|
+
ctx.addIssue({
|
|
506
|
+
code: z.ZodIssueCode.custom,
|
|
507
|
+
path: ['diversity', 'class'],
|
|
508
|
+
message: `diversity.class "${a.diversity.class}" must equal the class derived from the completed lanes' backends ("${derived.class}")`,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const storedUnrecognized = a.diversity.unrecognizedProviders;
|
|
512
|
+
const unrecognizedEqual = storedUnrecognized.length === derived.unrecognizedProviders.length &&
|
|
513
|
+
storedUnrecognized.every((p, i) => p === derived.unrecognizedProviders[i]);
|
|
514
|
+
if (!unrecognizedEqual) {
|
|
515
|
+
ctx.addIssue({
|
|
516
|
+
code: z.ZodIssueCode.custom,
|
|
517
|
+
path: ['diversity', 'unrecognizedProviders'],
|
|
518
|
+
message: `diversity.unrecognizedProviders [${storedUnrecognized.join(', ')}] must equal the set derived from the completed lanes' backends [${derived.unrecognizedProviders.join(', ')}]`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (a.diversity.diversityConfidence !== derived.diversityConfidence) {
|
|
522
|
+
ctx.addIssue({
|
|
523
|
+
code: z.ZodIssueCode.custom,
|
|
524
|
+
path: ['diversity', 'diversityConfidence'],
|
|
525
|
+
message: `diversity.diversityConfidence "${a.diversity.diversityConfidence}" must equal the value derived from the completed lanes' backends ("${derived.diversityConfidence}")`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
/**
|
|
531
|
+
* The provider FAMILY of a resolved backend (`provider:model` → `provider`) — the
|
|
532
|
+
* unit {@link classifyDiversity} clusters on (rev-5 item 7). The panel derives its
|
|
533
|
+
* diversity from each lane's `backend.provider`; a completed verdict lane records
|
|
534
|
+
* `resolvedBackend` as the `qualifiedModel` (`provider:model`), so the family is the
|
|
535
|
+
* segment before the FIRST colon (`provider` never contains a colon). A bare string
|
|
536
|
+
* with no colon is returned whole (defensive — fan lanes are always `provider:model`).
|
|
537
|
+
*/
|
|
538
|
+
function providerFamilyOf(resolvedBackend) {
|
|
539
|
+
const idx = resolvedBackend.indexOf(':');
|
|
540
|
+
return idx === -1 ? resolvedBackend : resolvedBackend.slice(0, idx);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The composite round-chain lineage key: a domain-tagged sha256 over the resolved
|
|
544
|
+
* scope selector (agy fold 3; codex rev-2 fold 2). Two branches sharing `base=main`
|
|
545
|
+
* can NEVER cross-link because `branch` participates, and two DIFFERENT explicit
|
|
546
|
+
* ranges on one branch + merge-base cannot cross-link because `base`/`head`
|
|
547
|
+
* participate.
|
|
548
|
+
*
|
|
549
|
+
* The domain tag is `verdict-lineage/3` — bumped from `/2` because the selector
|
|
550
|
+
* shape changed (source-discriminated input + `selectorForm`), so keys under the
|
|
551
|
+
* two tags are deliberately incompatible.
|
|
552
|
+
*
|
|
553
|
+
* Only the fields VALID for the discriminated `source` participate (the switch
|
|
554
|
+
* reads them per-variant), pinning the others to `null`. The selector is hashed as
|
|
555
|
+
* a canonicalized (recursively key-sorted) JSON object with the fixed domain tag,
|
|
556
|
+
* so there is NO delimiter-injection ambiguity — `branch='a', mergeBase='b|c'` and
|
|
557
|
+
* `branch='a|b', mergeBase='c'` serialize to distinct JSON and therefore distinct
|
|
558
|
+
* keys, which a naive `join('|')` would collide. A `null` hole (a source that omits
|
|
559
|
+
* a field) can never collide with an empty string a source supplies for it.
|
|
560
|
+
*/
|
|
561
|
+
export function computeLineageKey(input) {
|
|
562
|
+
let base = null;
|
|
563
|
+
let head = null;
|
|
564
|
+
let mergeBase = null;
|
|
565
|
+
switch (input.source) {
|
|
566
|
+
case 'explicit-range':
|
|
567
|
+
base = input.base;
|
|
568
|
+
head = input.head;
|
|
569
|
+
break;
|
|
570
|
+
case 'branch-vs-base':
|
|
571
|
+
base = input.base;
|
|
572
|
+
mergeBase = input.mergeBase;
|
|
573
|
+
break;
|
|
574
|
+
case 'staged':
|
|
575
|
+
case 'uncommitted':
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
return calculateDeterministicHash({
|
|
579
|
+
domain: 'verdict-lineage/3',
|
|
580
|
+
repoIdentity: input.repoIdentity,
|
|
581
|
+
branch: input.branch,
|
|
582
|
+
source: input.source,
|
|
583
|
+
selectorForm: input.selectorForm ?? null,
|
|
584
|
+
base,
|
|
585
|
+
head,
|
|
586
|
+
mergeBase,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
// ─── Content-addressed storage (mirrors storage.ts / panel.ts) ──────────────
|
|
590
|
+
/** Storage layout segments under the totem dir (exact layout = impl call). */
|
|
591
|
+
const VERDICTS_DIR_SEGMENTS = ['artifacts', 'verdicts'];
|
|
592
|
+
/** Matches a stored verdict file name and captures its content-address stem. */
|
|
593
|
+
const VERDICT_FILE_RE = /^([0-9a-f]{64})\.json$/;
|
|
594
|
+
/**
|
|
595
|
+
* Migration-on-read registry (F1). Keyed by MAJOR; each entry lifts a parsed
|
|
596
|
+
* raw object of that major to the current shape. EMPTY at 1.0.0 by design — the
|
|
597
|
+
* policy requires a major bump to land its migration entry here BEFORE the
|
|
598
|
+
* writer ships, so empty is the honest statement that no other major exists.
|
|
599
|
+
* Each entry MUST return current-schema output; the loader re-validates via
|
|
600
|
+
* parse() before returning.
|
|
601
|
+
*/
|
|
602
|
+
const MIGRATIONS = new Map();
|
|
603
|
+
/** Absolute verdicts directory for a given absolute totem dir. */
|
|
604
|
+
export function verdictsDir(totemDirAbs) {
|
|
605
|
+
return path.join(totemDirAbs, ...VERDICTS_DIR_SEGMENTS);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Content address of a verdict: deterministic hash over everything EXCEPT
|
|
609
|
+
* `createdAt` (observability, not identity). Identical rounds dedup to one
|
|
610
|
+
* artifact regardless of when they ran.
|
|
611
|
+
*/
|
|
612
|
+
export function computeVerdictArtifactContentHash(artifact) {
|
|
613
|
+
const { createdAt: _excluded, ...identity } = artifact;
|
|
614
|
+
return calculateDeterministicHash(identity);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Content address over the RAW parsed JSON payload with ONLY `createdAt` excluded
|
|
618
|
+
* (rev-5 item 5) — the canonical identity used for load verification. Unlike
|
|
619
|
+
* {@link computeVerdictArtifactContentHash} (which hashes the Zod-normalized shape),
|
|
620
|
+
* this hashes exactly the bytes on disk minus `createdAt`, so an unknown-key tamper
|
|
621
|
+
* is caught and a forward-minor additive field verifies. `calculateDeterministicHash`
|
|
622
|
+
* canonicalizes (recursive key sort), so for a same-version artifact with no unknown
|
|
623
|
+
* keys the two functions agree.
|
|
624
|
+
*/
|
|
625
|
+
function computeRawVerdictContentHash(raw) {
|
|
626
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
627
|
+
// Unreachable after a successful parse (the schema requires an object), but stay
|
|
628
|
+
// defensive rather than destructure a non-object.
|
|
629
|
+
return calculateDeterministicHash(raw);
|
|
630
|
+
}
|
|
631
|
+
const { createdAt: _excluded, ...identity } = raw;
|
|
632
|
+
return calculateDeterministicHash(identity);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Render the machine-readable covariate line — the CORE-OWNED signal every caller
|
|
636
|
+
* (CLI print, headless, `/review-reply`) emits identically so the skill stays pure
|
|
637
|
+
* transport (strategy-codex G4; resolves finding 14). Format, EXACTLY:
|
|
638
|
+
*
|
|
639
|
+
* `local-lane: <hash8> round=<n> settled=<true|false> lanes=<completed>/<attempted>`
|
|
640
|
+
*
|
|
641
|
+
* where `<hash8>` is the first 8 hex of the artifact's STORED content address. The
|
|
642
|
+
* signature takes a {@link VerdictWithAddress} (rev-6 item 1) so the rendered `<hash8>`
|
|
643
|
+
* is the VERIFIED on-disk address that survived the tolerant parse — NOT a recompute
|
|
644
|
+
* over the Zod-stripped shape, which would diverge for a forward-minor artifact and
|
|
645
|
+
* advertise a hash with no backing file. A caller with a freshly-assembled verdict
|
|
646
|
+
* pairs it with the address `saveVerdictArtifact` returned.
|
|
647
|
+
*
|
|
648
|
+
* @remarks Covariate line format v1 — do NOT alter without a spec amendment (the
|
|
649
|
+
* pilot ledger joins on this grep-able line; the format is contract, versioned with
|
|
650
|
+
* the `review-loop` skill).
|
|
651
|
+
*/
|
|
652
|
+
export function renderCovariateLine(verdict) {
|
|
653
|
+
const hash8 = verdict.contentHash.slice(0, 8);
|
|
654
|
+
const a = verdict.artifact;
|
|
655
|
+
return `local-lane: ${hash8} round=${a.round.index} settled=${a.settled} lanes=${a.completedLaneCount}/${a.attemptedLaneCount}`;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Persist a verdict at its content address, write-if-absent (`wx` create-
|
|
659
|
+
* exclusive). Validates on the way OUT so a writer bug never poisons the ledger
|
|
660
|
+
* with a record the reader would reject.
|
|
661
|
+
*
|
|
662
|
+
* EEXIST is LOGICAL-IDENTITY DEDUP (`createdAt` excluded from the address; codex
|
|
663
|
+
* fold 8 / agy fold 4): the existing record is loaded and its content hash
|
|
664
|
+
* recomputed. If it matches this address (equal MODULO `createdAt`), the stored
|
|
665
|
+
* record IS this save's outcome — first-write-wins, dedup return. If the record
|
|
666
|
+
* at this address recomputes to a DIFFERENT hash, its bytes disagree with the
|
|
667
|
+
* content address — a hard identity violation (a corrupted/tampered record or a
|
|
668
|
+
* sha256 collision), never silently accepted.
|
|
669
|
+
*/
|
|
670
|
+
export function saveVerdictArtifact(totemDirAbs, artifact) {
|
|
671
|
+
const validated = VerdictArtifactSchema.parse(artifact);
|
|
672
|
+
// Save/load address SYMMETRY (rev-6 item 1): the hash is computed over the SAME
|
|
673
|
+
// object that gets serialized (`validated`, no unknown keys), and load re-hashes the
|
|
674
|
+
// raw on-disk bytes minus `createdAt` — so a record written here always verifies back
|
|
675
|
+
// to THIS `hash`. The returned `hash` IS the raw address on disk; callers pairing a
|
|
676
|
+
// freshly-saved verdict with its address use it directly (never a re-derivation).
|
|
677
|
+
const hash = computeVerdictArtifactContentHash(validated);
|
|
678
|
+
const dir = verdictsDir(totemDirAbs);
|
|
679
|
+
const filePath = path.join(dir, `${hash}.json`);
|
|
680
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
681
|
+
try {
|
|
682
|
+
// `wx` = atomic create-exclusive: the write fails EEXIST if a record already
|
|
683
|
+
// occupies this address, so the identity-verification path below always sees
|
|
684
|
+
// the durable record (no TOCTOU between a check and the write).
|
|
685
|
+
fs.writeFileSync(filePath, JSON.stringify(validated, null, 2), {
|
|
686
|
+
encoding: 'utf-8',
|
|
687
|
+
mode: 0o600, // matches run/panel storage — verdicts reach masked prompt content one hop away
|
|
688
|
+
flag: 'wx',
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
if (err !== null && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
|
|
693
|
+
// Logical-identity dedup (codex fold 8 / agy fold 4). loadVerdictArtifact now
|
|
694
|
+
// VERIFIES the incumbent's content address (finding 4): a successful load
|
|
695
|
+
// proves the stored record hashes back to THIS address — which equals our
|
|
696
|
+
// artifact's content address — i.e. the SAME logical verdict modulo createdAt
|
|
697
|
+
// (first-write-wins). A DIFFERING or corrupt record cannot occupy this address
|
|
698
|
+
// without failing that verification, so the verified load itself surfaces the
|
|
699
|
+
// identity violation loud (its own hard error) — nothing is swallowed here.
|
|
700
|
+
loadVerdictArtifact(totemDirAbs, hash);
|
|
701
|
+
return { hash, path: filePath, existed: true };
|
|
702
|
+
}
|
|
703
|
+
throw err;
|
|
704
|
+
}
|
|
705
|
+
return { hash, path: filePath, existed: false };
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Load + validate a verdict by content address, returning the artifact WITH its
|
|
709
|
+
* verified content address (rev-6 item 1 — {@link VerdictWithAddress}). Throws
|
|
710
|
+
* {@link TotemParseError} on a missing file, corrupt JSON, schema violation, or an
|
|
711
|
+
* unknown major with no migration entry, and {@link TotemError} (`DATABASE_MISMATCH`)
|
|
712
|
+
* when the stored bytes do not hash back to their filename address (finding 4) — loud,
|
|
713
|
+
* never a silent partial (Tenet 4).
|
|
714
|
+
*
|
|
715
|
+
* Order (rev-6 item 5): the RAW stored address is verified FIRST — the content-address
|
|
716
|
+
* guarantee is over the on-disk bytes (minus `createdAt`), MAJOR-agnostic and
|
|
717
|
+
* migration-independent, so a mis-addressed / tampered file fails before it is
|
|
718
|
+
* transformed. Only THEN is any migration applied and its output validated against the
|
|
719
|
+
* current schema (a separate concern — migration correctness, not address integrity).
|
|
720
|
+
* The returned `contentHash` is always this verified filename address.
|
|
721
|
+
*/
|
|
722
|
+
export function loadVerdictArtifact(totemDirAbs, hash) {
|
|
723
|
+
if (!Sha256HexSchema.safeParse(hash).success) {
|
|
724
|
+
throw new TotemParseError(`Invalid verdict-artifact id "${hash}" — expected a 64-char sha256 hex content address.`, 'Pass the hash exactly as reported at emission (or from the artifacts/verdicts/ filename).');
|
|
725
|
+
}
|
|
726
|
+
const filePath = path.join(verdictsDir(totemDirAbs), `${hash}.json`);
|
|
727
|
+
const raw = readJsonSafe(filePath);
|
|
728
|
+
// ── rev-6 item 5 + rev-5 item 5: verify the RAW stored address BEFORE any migration ──
|
|
729
|
+
// Content-address verification hashes the RAW logical payload (`createdAt` excluded),
|
|
730
|
+
// NOT a Zod-normalized shape. Hashing the normalized output would be unsound TWO ways:
|
|
731
|
+
// (a) an unknown-key TAMPER survives — Zod strips the injected key before the recompute,
|
|
732
|
+
// so a normalized hash still matches the address; (b) a forward-minor artifact is WRONGLY
|
|
733
|
+
// rejected — its writer addressed a raw payload including an additive field this reader
|
|
734
|
+
// strips, so a normalized recompute diverges. The raw payload IS the canonical identity,
|
|
735
|
+
// it is major-AGNOSTIC (whatever major wrote the file addressed its own raw bytes), so
|
|
736
|
+
// this check precedes migration: a mis-addressed / hand-edited / collided record is
|
|
737
|
+
// rejected LOUD before we transform it (finding 4).
|
|
738
|
+
const verificationHash = computeRawVerdictContentHash(raw);
|
|
739
|
+
if (verificationHash !== hash) {
|
|
740
|
+
throw new TotemError('DATABASE_MISMATCH', `Verdict artifact at ${filePath} fails content-address verification: its recomputed content hash ${verificationHash} does not match the filename address ${hash} (modulo createdAt).`, 'This should be unreachable in a content-addressed store. Investigate a mis-addressed copy, a hand-edited/corrupted verdict file, or a hash collision, then re-emit the round.');
|
|
741
|
+
}
|
|
742
|
+
// ── THEN migrate (if a known older major) and validate the migrated/parsed output
|
|
743
|
+
// SEPARATELY against the current schema (migration correctness ≠ address integrity) ──
|
|
744
|
+
const major = readMajor(raw);
|
|
745
|
+
const migrate = major !== undefined ? MIGRATIONS.get(major) : undefined;
|
|
746
|
+
if (migrate !== undefined) {
|
|
747
|
+
// Re-validate migrated output against the CURRENT schema before returning: a
|
|
748
|
+
// migration's contract is to PRODUCE the current shape, so a migration bug must
|
|
749
|
+
// fail loud here — never return it unvalidated. The stored file remains addressed
|
|
750
|
+
// over its (verified) raw bytes, so `contentHash` stays the filename address.
|
|
751
|
+
return { artifact: VerdictArtifactSchema.parse(migrate(raw)), contentHash: hash };
|
|
752
|
+
}
|
|
753
|
+
// `.safeParse` (not try/catch) so the fail-loud rethrow is an explicit statement,
|
|
754
|
+
// never swallowed control flow: rethrowAsParseError returns `never` (always throws),
|
|
755
|
+
// normalizing ZodError to the module's TotemParseError load contract and preserving
|
|
756
|
+
// cause. No catch clause ⇒ no bare-swallow surface. The tolerant Zod parse governs
|
|
757
|
+
// SHAPE only (additive fields stripped); the RAW address verified above is identity.
|
|
758
|
+
const result = VerdictArtifactSchema.safeParse(raw);
|
|
759
|
+
if (!result.success) {
|
|
760
|
+
rethrowAsParseError(`Verdict artifact ${hash} failed schema validation`, result.error, 'The artifact may be corrupted or written by an incompatible totem version; re-emit it (or add the migration entry for its major).');
|
|
761
|
+
}
|
|
762
|
+
return { artifact: result.data, contentHash: hash };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Verified per-entry load for a SCAN (list / lineage). A KNOWN corruption class —
|
|
766
|
+
* bad JSON, schema violation, or a content-address mismatch (finding 4), all
|
|
767
|
+
* surfaced by {@link loadVerdictArtifact} as a {@link TotemError} — is routed to
|
|
768
|
+
* `onWarn` and the entry is SKIPPED (returns `undefined`). An UNEXPECTED failure
|
|
769
|
+
* (e.g. a filesystem permission error) is rethrown, never swallowed.
|
|
770
|
+
*
|
|
771
|
+
* This is the honest degradation for a scan (vs the hard error of a direct
|
|
772
|
+
* load-by-hash): one corrupt / mis-addressed artifact must neither crash an
|
|
773
|
+
* UNRELATED lineage query nor silently WIN or LOSE a lineage scan by being counted
|
|
774
|
+
* as valid — it is announced LOUDLY per entry and dropped, so a broken prior round
|
|
775
|
+
* makes the chain honestly restart at round 0 (the failure-table "prior verdict
|
|
776
|
+
* missing/corrupt" row) rather than aborting the whole command.
|
|
777
|
+
*/
|
|
778
|
+
function loadVerifiedVerdictForScan(totemDirAbs, hash, onWarn) {
|
|
779
|
+
try {
|
|
780
|
+
return loadVerdictArtifact(totemDirAbs, hash);
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
if (err instanceof TotemError) {
|
|
784
|
+
onWarn(`Skipping corrupt or mis-addressed verdict artifact ${hash} during scan: ${err.message}`);
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
throw err;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Load every stored verdict under `artifacts/verdicts/`, verifying each through
|
|
792
|
+
* the SAME content-address check as {@link loadVerdictArtifact}. A missing
|
|
793
|
+
* directory yields `[]` (nothing written yet). Non-verdict file names are skipped
|
|
794
|
+
* silently; a corrupt / mis-addressed verdict is skipped LOUDLY via `onWarn`.
|
|
795
|
+
* `onWarn` is REQUIRED — core stays console-free (no presentation-layer default),
|
|
796
|
+
* and the caller must decide where scan warnings land rather than inheriting a
|
|
797
|
+
* silent noop (Tenet 4); see {@link loadVerifiedVerdictForScan}. (PR #2337 CR.)
|
|
798
|
+
*/
|
|
799
|
+
export function listVerdictArtifacts(totemDirAbs, onWarn) {
|
|
800
|
+
const dir = verdictsDir(totemDirAbs);
|
|
801
|
+
let names;
|
|
802
|
+
try {
|
|
803
|
+
names = fs.readdirSync(dir);
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
if (err.code === 'ENOENT')
|
|
807
|
+
return [];
|
|
808
|
+
throw err;
|
|
809
|
+
}
|
|
810
|
+
const out = [];
|
|
811
|
+
for (const name of names) {
|
|
812
|
+
const match = VERDICT_FILE_RE.exec(name);
|
|
813
|
+
if (match === null)
|
|
814
|
+
continue;
|
|
815
|
+
const loaded = loadVerifiedVerdictForScan(totemDirAbs, match[1], onWarn);
|
|
816
|
+
if (loaded !== undefined)
|
|
817
|
+
out.push(loaded);
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The latest verdict sharing `lineageKey` — highest `round.index`, ties broken by
|
|
823
|
+
* the lexical STORED content address (rev-5 item 8 / rev-6 item 1), NOT `createdAt`.
|
|
824
|
+
* Returns the winning {@link VerdictWithAddress} (artifact + verified address) or
|
|
825
|
+
* `undefined` when no verdict carries the key. Used for implicit round linkage (the
|
|
826
|
+
* next round's `priorVerdictHash` = the returned `contentHash`, so the link always
|
|
827
|
+
* points at the on-disk file even for a forward-minor artifact). Goes through the same
|
|
828
|
+
* verified scan load as {@link listVerdictArtifacts}: a corrupt / mis-addressed
|
|
829
|
+
* artifact is warned + skipped (never silently winning or losing the lineage). `onWarn`
|
|
830
|
+
* is REQUIRED — core is console-free and the caller owns where warnings land (Tenet 4).
|
|
831
|
+
*
|
|
832
|
+
* The tie-break is IDENTITY-BOUND and deterministic: two same-round verdicts break on
|
|
833
|
+
* their STORED content address (the on-disk identity, `createdAt` excluded), so
|
|
834
|
+
* selection never depends on wall-clock emission time (observability-only) — the same
|
|
835
|
+
* corpus always resolves the same latest verdict regardless of when each round ran.
|
|
836
|
+
*/
|
|
837
|
+
export function findLatestVerdictForLineage(totemDirAbs, lineageKey, onWarn) {
|
|
838
|
+
const matching = listVerdictArtifacts(totemDirAbs, onWarn).filter((v) => v.artifact.round.lineageKey === lineageKey);
|
|
839
|
+
if (matching.length === 0)
|
|
840
|
+
return undefined;
|
|
841
|
+
// Tie-break on the STORED, verified content address (rev-6 item 1) — NOT a recompute
|
|
842
|
+
// over the Zod-normalized shape, which would diverge for a forward-minor artifact and
|
|
843
|
+
// could reorder same-round ties. The stored address is the on-disk identity, so the
|
|
844
|
+
// same corpus always resolves the same latest verdict, timestamp-independently.
|
|
845
|
+
const ordered = [...matching].sort((a, b) => {
|
|
846
|
+
if (b.artifact.round.index !== a.artifact.round.index) {
|
|
847
|
+
return b.artifact.round.index - a.artifact.round.index;
|
|
848
|
+
}
|
|
849
|
+
return b.contentHash.localeCompare(a.contentHash);
|
|
850
|
+
});
|
|
851
|
+
return ordered[0];
|
|
852
|
+
}
|
|
853
|
+
/** Best-effort major extraction from a raw parsed payload; undefined when absent/garbled. */
|
|
854
|
+
function readMajor(raw) {
|
|
855
|
+
if (typeof raw !== 'object' || raw === null || !('schemaVersion' in raw))
|
|
856
|
+
return undefined;
|
|
857
|
+
const version = raw.schemaVersion;
|
|
858
|
+
if (typeof version !== 'string')
|
|
859
|
+
return undefined;
|
|
860
|
+
const major = Number.parseInt(version.split('.')[0] ?? '', 10);
|
|
861
|
+
return Number.isNaN(major) ? undefined : major;
|
|
862
|
+
}
|
|
863
|
+
//# sourceMappingURL=verdict.js.map
|