@mutagent/evaluator 0.1.0-alpha.5 → 0.2.0-alpha.1
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/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
- package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
- package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
- package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
- package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
- package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
- package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
- package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
- package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
- package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
- package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
- package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
- package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
- package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
- package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
- package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
- package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
- package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
- package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
- package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
- package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
- package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
- package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
- package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
- package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
- package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
- package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
- package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
- package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
- package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
- package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
- package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
- package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
- package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
- package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
- package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
- package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
- package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
- package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
- package/bin/mutagent-cli.mjs +9 -5
- package/package.json +2 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-review-report-v3 — the W4 FROZEN-CONTRACT review-report renderer.
|
|
3
|
+
*
|
|
4
|
+
* The template (`assets/templates/review-report.template.html`) IS the operator-signed
|
|
5
|
+
* mock (review-report-mock.html), derived mechanically by `scripts/release/
|
|
6
|
+
* build-review-template.ts` with named data slots — the navigator matrix, the drill,
|
|
7
|
+
* the right-rail verdict cards, the ruling controls, the keyboard bindings, the
|
|
8
|
+
* >48-trace cluster idiom and the propagation trail are all the frozen contract
|
|
9
|
+
* verbatim. This module ONLY computes the data: it maps real run artifacts
|
|
10
|
+
* (MatrixCriterion[] + MatrixVerdictFile[] + the ingested traces) into the template's
|
|
11
|
+
* data shape and fills the slots.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THIS SURFACE IS. The third and last v3 report. Where `*evaluate` produces the
|
|
14
|
+
* judge's verdicts and `*discover` produces the criteria, THIS surface is where a
|
|
15
|
+
* human GRADES THE JUDGE: every judge verdict gets an Agree / Revise / Refute ruling
|
|
16
|
+
* plus a why-note, and those rulings become the ground truth that `*validate` ingests
|
|
17
|
+
* and the calibration loop consumes.
|
|
18
|
+
*
|
|
19
|
+
* HONESTY RULES (the same set the eval + discovery surfaces hold to):
|
|
20
|
+
* - nothing is invented — every number/quote comes from a run artifact;
|
|
21
|
+
* - absent data renders as a NAMED absence in words, never a blank and never a
|
|
22
|
+
* placeholder number. The propagation trail's validation delta is the sharp case:
|
|
23
|
+
* TPR/TNR/Rogan-Gladen CANNOT exist before `*validate` ingests these labels, so it
|
|
24
|
+
* renders as a named not-yet, not as the mock's invented ".92→.94";
|
|
25
|
+
* - caps are declared in-render, never silent.
|
|
26
|
+
*
|
|
27
|
+
* AUDIENCE — OPERATOR RULING 2026-07-26: THIS SURFACE HAS NO INTERNAL CONTENT.
|
|
28
|
+
*
|
|
29
|
+
* The eval report strips its ⑤ Self-Eval and the discovery report its ⑥ Methodology,
|
|
30
|
+
* and both also strip clause 2 of the V11 contract (run id · pinned judge-model ·
|
|
31
|
+
* gitignored artifact path). The review surface strips NEITHER:
|
|
32
|
+
* · clause 1 does not apply — the signed mock has no internal-only SECTION, and one
|
|
33
|
+
* was not invented so that a strip would have something to remove;
|
|
34
|
+
* · clause 2 does not apply either — the operator ruled this is "just a one-pager,
|
|
35
|
+
* no internal pieces" and that the run id, judge pin and artifact path "can be
|
|
36
|
+
* left". They are review provenance the reviewer needs, not client leakage.
|
|
37
|
+
* The surface is intrinsically internal: grading the judge is the team's own job, so
|
|
38
|
+
* there is no client audience to protect from it.
|
|
39
|
+
*
|
|
40
|
+
* `audience` therefore renders IDENTICALLY in both modes. The parameter is kept —
|
|
41
|
+
* REQUIRED, not optional — for consistency with the other two renderers and for the
|
|
42
|
+
* composer contract. It is deliberately NOT read here.
|
|
43
|
+
*
|
|
44
|
+
* THE TRIPWIRE. An inert audience switch is safe today and a trap tomorrow: if
|
|
45
|
+
* internal content is ever added to this surface, nothing would strip it and no test
|
|
46
|
+
* would notice. So the suite asserts the two renders are BYTE-IDENTICAL. The moment
|
|
47
|
+
* anyone makes anything here audience-conditional, that test fails and forces the
|
|
48
|
+
* question back to the operator instead of silently shipping a leak.
|
|
49
|
+
*
|
|
50
|
+
* If this surface ever does need a strip, use `scripts/audience.ts`
|
|
51
|
+
* (`isExternal` · `cutBlock` · `stripInternalSurface`) rather than hand-rolling one —
|
|
52
|
+
* it encodes cut-AFTER-slot-fill and the provably-unique-anchor rule. Note that
|
|
53
|
+
* `stripInternalSurface` would currently THROW on this template (verified): it needs
|
|
54
|
+
* both `FOOT_ANCHOR` and a `<s onclick="tab(N)">` entry, and this one-pager has
|
|
55
|
+
* neither.
|
|
56
|
+
*
|
|
57
|
+
* The v1 `*review` UI (`build-review-ui.ts`) is UNTOUCHED and is written alongside as
|
|
58
|
+
* `review-report.v2.html` by the run composer — the transition fallback, mirroring
|
|
59
|
+
* run-evaluate's `writeRunReport` and the discovery composer.
|
|
60
|
+
*
|
|
61
|
+
* NOTE ON DUPLICATION. The criterion/trace mapping below is deliberately a sibling of
|
|
62
|
+
* render-eval-report-v3's, not a shared import: those helpers are module-private
|
|
63
|
+
* there, and this surface's trace shape genuinely diverges (it needs per-verdict card
|
|
64
|
+
* KEYS for the ruling state, which the eval walk has no concept of). Extracting the
|
|
65
|
+
* common core belongs with the already-planned `scripts/handoff-md.ts` extraction.
|
|
66
|
+
*/
|
|
67
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
68
|
+
import { join, dirname } from "node:path";
|
|
69
|
+
import type { MatrixCriterion, MatrixVerdictFile, LayerVerdict } from "./contracts/eval-matrix.ts";
|
|
70
|
+
import { detectLayerConflicts, type LayerConflict } from "./matrix-judge.ts";
|
|
71
|
+
// the v1 `*review` annotation UI — imported to write the .v2.html transition
|
|
72
|
+
// fallback. That module is UNTOUCHED; this is a call, not a change.
|
|
73
|
+
import { renderReviewUi } from "./build-review-ui.ts";
|
|
74
|
+
import type { EvalTrace } from "./contracts/eval-types.ts";
|
|
75
|
+
import type { ReportSpec } from "./verify-render.ts";
|
|
76
|
+
|
|
77
|
+
const TEMPLATE_PATH = join(import.meta.dir, "../assets/templates/review-report.template.html");
|
|
78
|
+
|
|
79
|
+
/* ── the REVIEW surface's verification spec ────────────────────────────────────
|
|
80
|
+
* Lives HERE, with the renderer, because it describes THIS surface's contract —
|
|
81
|
+
* so the unit test, the run composer and any future sim check all assert the same
|
|
82
|
+
* shape instead of each re-declaring it. The engine that consumes it
|
|
83
|
+
* (`verify-render.ts`) is surface-agnostic and needs no change to host it.
|
|
84
|
+
*/
|
|
85
|
+
export const REVIEW_REPORT_SPEC: ReportSpec = {
|
|
86
|
+
surface: "review-report-v3",
|
|
87
|
+
dataGlobal: "window.ASTER",
|
|
88
|
+
tabSelector: "#report > .tabs s", // no tab bar on this surface — matches nothing
|
|
89
|
+
tabLabels: [],
|
|
90
|
+
/**
|
|
91
|
+
* DECLARED, not defaulted. Measured on the emitted template: 0 occurrences of
|
|
92
|
+
* `onclick="tab(`. This surface is a single-page navigator + drill by design, so
|
|
93
|
+
* the tab and panel checks have nothing to run against — and the engine requires
|
|
94
|
+
* that to be STATED, because an empty list would otherwise skip the per-panel
|
|
95
|
+
* named-absence check silently and the spec would pass vacuously.
|
|
96
|
+
*/
|
|
97
|
+
tabless: true,
|
|
98
|
+
panelless: true,
|
|
99
|
+
internalOnlyTabs: [],
|
|
100
|
+
internalOnlyPanels: [],
|
|
101
|
+
panels: [],
|
|
102
|
+
/**
|
|
103
|
+
* EMPTY BY OPERATOR RULING (2026-07-26), not by oversight — see the AUDIENCE
|
|
104
|
+
* section in this module's header. There is no internal-only material on this
|
|
105
|
+
* surface, so there is nothing for an external render to strip. The real
|
|
106
|
+
* guarantee is the byte-identity tripwire in the test suite.
|
|
107
|
+
*/
|
|
108
|
+
internalMarkers: [],
|
|
109
|
+
islands: [
|
|
110
|
+
// server-rendered — the content is in the bytes
|
|
111
|
+
{ selector: '[data-component="mgt-report-hero"]', label: "report hero" },
|
|
112
|
+
{ selector: '[data-component="mgt-subject-profile"]', label: "subject card" },
|
|
113
|
+
{ selector: ".profgrid .pk", label: "subject-card fact labels", minCount: 3 },
|
|
114
|
+
{ selector: ".legend i", label: "navigator legend entries", minCount: 6 },
|
|
115
|
+
// JS-filled hosts — must EXIST; their content is asserted via dataArrays
|
|
116
|
+
{ selector: '[data-component="mgt-trace-navigator"]', label: "trace-navigator host", jsFilled: true },
|
|
117
|
+
{ selector: '[data-component="mgt-propagation-trail"]', label: "propagation-trail host", jsFilled: true },
|
|
118
|
+
{ selector: '[data-component="mgt-trace-tooltip"]', label: "navigator tooltip host", jsFilled: true },
|
|
119
|
+
{ selector: "#tracehost", label: "trace-drill host", jsFilled: true },
|
|
120
|
+
],
|
|
121
|
+
dataArrays: [
|
|
122
|
+
{ path: "criteria", label: "criteria (navigator rows + verdict-card definitions)" },
|
|
123
|
+
{ path: "traces", label: "traces (navigator columns + the drill)" },
|
|
124
|
+
],
|
|
125
|
+
dataStrings: [
|
|
126
|
+
{ path: "handoffHead", label: "calibration handoff head" },
|
|
127
|
+
{ path: "calibrationMd", label: "calibration handoff CAL items" },
|
|
128
|
+
{ path: "feedbackTitle", label: "feedback title" },
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/* ── small pure helpers ────────────────────────────────────────────────────── */
|
|
133
|
+
const esc = (s: unknown): string =>
|
|
134
|
+
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
135
|
+
const short = (id: string): string => (id.length > 10 ? id.slice(0, 8) : id);
|
|
136
|
+
|
|
137
|
+
/** read/act split for the subject card — the same SEED heuristic the eval report uses. */
|
|
138
|
+
function toolClass(name: string): "read" | "act" {
|
|
139
|
+
return /send|dispatch|publish|reply|post|delete|update|write|create|escalat|blacklist|approve/i.test(name)
|
|
140
|
+
? "act"
|
|
141
|
+
: "read";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface ReviewCriterion {
|
|
145
|
+
id: string;
|
|
146
|
+
slug: string;
|
|
147
|
+
layer: string;
|
|
148
|
+
sev: string;
|
|
149
|
+
statement: string;
|
|
150
|
+
passDef: string;
|
|
151
|
+
failDef: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface ReviewStep {
|
|
155
|
+
tool: string;
|
|
156
|
+
args: string;
|
|
157
|
+
output: string;
|
|
158
|
+
layers: string[];
|
|
159
|
+
verdicts: {
|
|
160
|
+
crit: string;
|
|
161
|
+
verdict: string;
|
|
162
|
+
conf: number;
|
|
163
|
+
critique: string;
|
|
164
|
+
ref: { obs: string; path: string; value: string } | null;
|
|
165
|
+
}[];
|
|
166
|
+
divergence: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A calibration item in the diagnostics remedy-bundle shape. */
|
|
170
|
+
interface CalItem {
|
|
171
|
+
id: string;
|
|
172
|
+
title: string;
|
|
173
|
+
trigger: string;
|
|
174
|
+
changeType: string;
|
|
175
|
+
describedChange: string;
|
|
176
|
+
applyPlan: string[];
|
|
177
|
+
verify: string[];
|
|
178
|
+
acceptance: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface RenderReviewV3Input {
|
|
182
|
+
subjectName: string;
|
|
183
|
+
runId: string;
|
|
184
|
+
audience: "internal" | "external";
|
|
185
|
+
criteria: MatrixCriterion[];
|
|
186
|
+
files: MatrixVerdictFile[];
|
|
187
|
+
subjectProfile?: Record<string, unknown>;
|
|
188
|
+
pin: { model: string; temperature: 0 };
|
|
189
|
+
/** the run's INGESTED traces — supply the real tool observations so the drill's
|
|
190
|
+
* left lane shows genuine inputs/outputs; absent ⇒ the judge's step detail
|
|
191
|
+
* renders instead, as a NAMED degrade in-lane. */
|
|
192
|
+
traces?: { id: string; observations?: { type?: string; name?: string; input?: unknown; output?: unknown }[] }[];
|
|
193
|
+
/** ISO timestamp of the run — rendered top-right in the header. */
|
|
194
|
+
generatedAt?: string;
|
|
195
|
+
/** NAMED scope note when the review covers a declared subset (never a silent cap). */
|
|
196
|
+
scopeNote?: string;
|
|
197
|
+
/** the append-only feedback-store record this session will be written to. ABSENT ⇒
|
|
198
|
+
* the trail says so in words; it never invents a record id. */
|
|
199
|
+
feedbackRecord?: { id: string; store?: string };
|
|
200
|
+
/** a REAL prior validation delta, if `*validate` has already run against this suite.
|
|
201
|
+
* ABSENT ⇒ the trail names the absence — it is never a fabricated TPR/TNR. */
|
|
202
|
+
validationDelta?: string;
|
|
203
|
+
/** the living-suite version these criteria came from. */
|
|
204
|
+
suiteVersion?: string;
|
|
205
|
+
/** DEV ONLY: keep the feedback bar. Default OFF — produced reports carry none. */
|
|
206
|
+
devFeedback?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** serialize a real observation value for the drill's left lane (bounded, declared). */
|
|
210
|
+
function laneText(v: unknown, cap = 420): string {
|
|
211
|
+
if (v === null || v === undefined) return "";
|
|
212
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
213
|
+
return s.length > cap ? `${s.slice(0, cap)} … [+${s.length - cap} chars]` : s;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ── data mapping ──────────────────────────────────────────────────────────── */
|
|
217
|
+
function mapCriteria(input: RenderReviewV3Input): { list: ReviewCriterion[]; byCid: Map<string, ReviewCriterion> } {
|
|
218
|
+
const byCid = new Map<string, ReviewCriterion>();
|
|
219
|
+
const list = input.criteria.map((c, i) => {
|
|
220
|
+
const t: ReviewCriterion = {
|
|
221
|
+
id: `C${i + 1}`,
|
|
222
|
+
slug: c.criterionId,
|
|
223
|
+
layer: (c as { layer?: string }).layer ?? "criteria",
|
|
224
|
+
sev: String(c.severity),
|
|
225
|
+
statement: c.statement,
|
|
226
|
+
passDef: c.passCondition,
|
|
227
|
+
failDef: (c as { failCondition?: string }).failCondition ?? "",
|
|
228
|
+
};
|
|
229
|
+
byCid.set(c.criterionId, t);
|
|
230
|
+
return t;
|
|
231
|
+
});
|
|
232
|
+
return { list, byCid };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cellOf(f: MatrixVerdictFile, cid: string): "pass" | "fail" | "uncertain" | "skip" {
|
|
236
|
+
const dm = (f.denseMap ?? {}) as Record<string, string>;
|
|
237
|
+
const v = dm[cid] ?? f.verdicts.find((x) => x.criterionId === cid)?.result;
|
|
238
|
+
if (v === "pass" || v === "fail" || v === "uncertain") return v;
|
|
239
|
+
return "skip";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function trajGate(f: MatrixVerdictFile, sevOf: Map<string, string>): "pass" | "fail" | "incomplete" {
|
|
243
|
+
if (f.fidelity?.complete === false) return "incomplete";
|
|
244
|
+
const gating = (cid: string): boolean => ["CRIT", "HIGH"].includes(sevOf.get(cid) ?? "");
|
|
245
|
+
if (f.verdicts.some((v) => v.result === "fail" && gating(v.criterionId))) return "fail";
|
|
246
|
+
if (f.verdicts.some((v) => v.result === "uncertain" && gating(v.criterionId))) return "incomplete";
|
|
247
|
+
return f.verdicts.some((v) => v.result === "fail") ? "fail" : "pass";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function mapTrace(
|
|
251
|
+
f: MatrixVerdictFile,
|
|
252
|
+
byCid: Map<string, ReviewCriterion>,
|
|
253
|
+
sevOf: Map<string, string>,
|
|
254
|
+
conflicts: LayerConflict[],
|
|
255
|
+
trace?: { observations?: { type?: string; name?: string; input?: unknown; output?: unknown }[] },
|
|
256
|
+
): Record<string, unknown> {
|
|
257
|
+
const lvs = (f.layerVerdicts ?? []) as LayerVerdict[];
|
|
258
|
+
const layer = (L: string): LayerVerdict | undefined => lvs.find((x) => x.layer === L);
|
|
259
|
+
const layers: Record<string, string> = {};
|
|
260
|
+
for (const L of ["L0", "L1", "L2", "L3", "L4"]) {
|
|
261
|
+
const v = layer(L)?.verdict;
|
|
262
|
+
layers[L] = v === "skipped" ? "skip" : (v ?? "skip");
|
|
263
|
+
}
|
|
264
|
+
const l1 = layer("L1");
|
|
265
|
+
|
|
266
|
+
// REAL tool observations are ground truth; this intake stores them
|
|
267
|
+
// reverse-chronologically, so reverse to reading order.
|
|
268
|
+
const toolObs = (trace?.observations ?? []).filter((o) => o.type === "TOOL").reverse();
|
|
269
|
+
const agentSteps = f.agentSteps ?? [];
|
|
270
|
+
const nRaw = Math.max(toolObs.length, agentSteps.length);
|
|
271
|
+
const steps: ReviewStep[] = [];
|
|
272
|
+
for (let i = 0; i < nRaw; i++) {
|
|
273
|
+
const o = toolObs[i];
|
|
274
|
+
const s = agentSteps[i];
|
|
275
|
+
steps.push(
|
|
276
|
+
o !== undefined
|
|
277
|
+
? {
|
|
278
|
+
tool: o.name ?? s?.tool ?? "(unnamed tool)",
|
|
279
|
+
args: laneText(o.input, 160) + (s?.status !== undefined ? ` [${s.status}]` : ""),
|
|
280
|
+
output: laneText(o.output),
|
|
281
|
+
layers: [],
|
|
282
|
+
verdicts: [],
|
|
283
|
+
divergence: false,
|
|
284
|
+
}
|
|
285
|
+
: {
|
|
286
|
+
tool: s?.tool ?? "(no tool call)",
|
|
287
|
+
args: s?.status !== undefined ? `[${s.status}]` : "",
|
|
288
|
+
output: s?.detail ?? "(no detail emitted)",
|
|
289
|
+
layers: [],
|
|
290
|
+
verdicts: [],
|
|
291
|
+
divergence: false,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (steps.length === 0)
|
|
296
|
+
steps.push({
|
|
297
|
+
tool: "(no tool calls in this session)",
|
|
298
|
+
args: "",
|
|
299
|
+
output: f.understanding?.rephrase ?? "(no session summary was emitted)",
|
|
300
|
+
layers: [],
|
|
301
|
+
verdicts: [],
|
|
302
|
+
divergence: false,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// bind each criterion verdict to the step its FIRST trajectory.N ref cites;
|
|
306
|
+
// unanchored verdicts bind to the terminal step (the decision surface).
|
|
307
|
+
const nSteps = steps.length;
|
|
308
|
+
for (const v of f.verdicts) {
|
|
309
|
+
const tc = byCid.get(v.criterionId);
|
|
310
|
+
let pos = nSteps - 1;
|
|
311
|
+
for (const r of (v.refs ?? []) as { path?: string }[]) {
|
|
312
|
+
const m = /^trajectory[.[](\d+)/.exec(r.path ?? "");
|
|
313
|
+
if (m !== null) {
|
|
314
|
+
const packetIdx = Number.parseInt(m[1]!, 10);
|
|
315
|
+
pos = Math.min(Math.max(nSteps - 1 - packetIdx, 0), nSteps - 1);
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const ref0 = (v.refs ?? [])[0] as { obs?: string; path?: string; value?: string } | undefined;
|
|
320
|
+
steps[pos]!.verdicts.push({
|
|
321
|
+
crit: tc?.id ?? v.criterionId,
|
|
322
|
+
verdict: v.result,
|
|
323
|
+
conf: v.confidence ?? 0.5,
|
|
324
|
+
critique: v.critique,
|
|
325
|
+
ref: ref0 !== undefined ? { obs: short(ref0.obs ?? ""), path: ref0.path ?? "", value: ref0.value ?? "" } : null,
|
|
326
|
+
});
|
|
327
|
+
if (v.result === "fail") steps[pos]!.divergence = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const confs = f.verdicts.map((v) => v.confidence).filter((x): x is number => typeof x === "number");
|
|
331
|
+
const conflict = conflicts.find((c) => c.trajectoryId === f.trajectoryId);
|
|
332
|
+
const cells: Record<string, string> = {};
|
|
333
|
+
for (const [cid, tc] of byCid) cells[tc.id] = cellOf(f, cid);
|
|
334
|
+
const blocked = f.verdicts.find((v) => v.blockedBy !== undefined)?.blockedBy as { text?: string } | string | undefined;
|
|
335
|
+
|
|
336
|
+
// the cards that ADJUDICATE a cross-layer conflict: the non-pass verdicts on this
|
|
337
|
+
// trajectory carry the disputed judgment, so ruling on them settles the conflict.
|
|
338
|
+
// Named from the real bound cards — never a hard-coded criterion pair.
|
|
339
|
+
const conflictCards: string[] = [];
|
|
340
|
+
let conflictKey: string | null = null;
|
|
341
|
+
if (conflict !== undefined) {
|
|
342
|
+
steps.forEach((s, six) => {
|
|
343
|
+
s.verdicts.forEach((vd, vix) => {
|
|
344
|
+
if (vd.verdict === "pass") return;
|
|
345
|
+
if (!conflictCards.includes(vd.crit)) conflictCards.push(vd.crit);
|
|
346
|
+
conflictKey ??= `${short(f.trajectoryId)}·s${six}·v${vix}`;
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
id: short(f.trajectoryId),
|
|
353
|
+
fullId: f.trajectoryId,
|
|
354
|
+
scenario: l1?.scenario !== undefined ? String((l1.scenario as { intent?: string }).intent ?? l1.scenario) : "—",
|
|
355
|
+
gate: trajGate(f, sevOf),
|
|
356
|
+
layers,
|
|
357
|
+
conflict: conflict !== undefined,
|
|
358
|
+
conflictNote:
|
|
359
|
+
conflict !== undefined
|
|
360
|
+
? `${Object.entries(conflict.verdicts).map(([l, v]) => `${l} ${v}`).join(" ⚡ ")} — ${
|
|
361
|
+
((): string => {
|
|
362
|
+
// the judge's own OT-1 statement (localize.conflict) is authoritative when
|
|
363
|
+
// emitted; the aggregate's cross-check is the fallback. NEVER resolve.
|
|
364
|
+
const l = (f as { localize?: unknown }).localize;
|
|
365
|
+
const loc = l !== null && typeof l === "object" ? (l as { conflict?: string; root?: string; independentRoots?: string[] }) : undefined;
|
|
366
|
+
const base = loc?.conflict ?? conflict.note ?? "cross-layer disagreement";
|
|
367
|
+
const roots = (loc?.independentRoots?.length ?? 0) > 0 ? ` · independent root: ${loc!.independentRoots!.join(" · ")}` : "";
|
|
368
|
+
return base + roots;
|
|
369
|
+
})()
|
|
370
|
+
}`
|
|
371
|
+
: undefined,
|
|
372
|
+
conflictCards,
|
|
373
|
+
conflictKey,
|
|
374
|
+
judgeConf: confs.length > 0 ? confs.reduce((a, b) => a + b, 0) / confs.length : 0.5,
|
|
375
|
+
outcome: {
|
|
376
|
+
verdict: l1?.verdict ?? "—",
|
|
377
|
+
expected: Array.isArray(l1?.expectedExit) ? (l1!.expectedExit as string[]).join(" | ") : (l1?.expectedExit ?? "—"),
|
|
378
|
+
actual: l1?.note ?? "—",
|
|
379
|
+
},
|
|
380
|
+
divergence: undefined,
|
|
381
|
+
gapKind: layer("L4")?.gapKind,
|
|
382
|
+
fidelity: f.fidelity?.complete === false ? (f.fidelity.reason ?? "trace incomplete") : undefined,
|
|
383
|
+
blockedBy: typeof blocked === "string" ? blocked : blocked?.text,
|
|
384
|
+
cells,
|
|
385
|
+
steps,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* ── the CALIBRATION HANDOFF (diagnostics remedy-bundle shape) ──────────────── */
|
|
390
|
+
/**
|
|
391
|
+
* Derive CAL-N items from REAL run artifacts. Every item names the artifact that
|
|
392
|
+
* triggered it; nothing is proposed without a trigger, and no item invents the fix
|
|
393
|
+
* WORDING — the described change states what must be decided and leaves the decision
|
|
394
|
+
* to the operator, because the evaluator judges and never fixes (EV-051).
|
|
395
|
+
*/
|
|
396
|
+
function calibrationItems(
|
|
397
|
+
input: RenderReviewV3Input,
|
|
398
|
+
crits: ReviewCriterion[],
|
|
399
|
+
byCid: Map<string, ReviewCriterion>,
|
|
400
|
+
conflicts: LayerConflict[],
|
|
401
|
+
): CalItem[] {
|
|
402
|
+
const items: CalItem[] = [];
|
|
403
|
+
const rec = input.feedbackRecord?.id ?? "this review session";
|
|
404
|
+
|
|
405
|
+
// (1) ABSTAINS — a criterion the evidence could not decide is a definition problem,
|
|
406
|
+
// not a subject problem. The open question is quoted verbatim from blockedBy.
|
|
407
|
+
for (const c of crits) {
|
|
408
|
+
const abstains: { traj: string; question: string }[] = [];
|
|
409
|
+
for (const f of input.files) {
|
|
410
|
+
for (const v of f.verdicts) {
|
|
411
|
+
if (byCid.get(v.criterionId)?.id !== c.id || v.result !== "uncertain") continue;
|
|
412
|
+
const b = v.blockedBy as { text?: string } | string | undefined;
|
|
413
|
+
abstains.push({
|
|
414
|
+
traj: short(f.trajectoryId),
|
|
415
|
+
question: (typeof b === "string" ? b : b?.text) ?? "no open question was recorded with the abstain",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (abstains.length === 0) continue;
|
|
420
|
+
items.push({
|
|
421
|
+
id: `CAL-${items.length + 1}`,
|
|
422
|
+
title: `Revise criterion ${c.id} (${c.slug}) — ${abstains.length} abstain(s)`,
|
|
423
|
+
trigger: `${abstains.length} undecided verdict(s) on ${c.id} ${c.slug} — ${abstains.map((a) => `${a.traj}: "${a.question}"`).join(" · ")}${input.feedbackRecord !== undefined ? ` + reviewer rulings in ${rec}` : ""}`,
|
|
424
|
+
changeType: "criterion-revision (provenance-preserving, no silent delete)",
|
|
425
|
+
describedChange: `The criterion's pass/fail definition does not resolve the open question above, so the judge correctly abstained rather than guessing. Settle that question in the definition — or split the ambiguous case into its own criterion. The exact wording is the operator's ruling: the evaluator judges and never fixes, so no replacement text is proposed here.`,
|
|
426
|
+
applyPlan: [
|
|
427
|
+
`[edit] living-suite entry ${c.slug}${input.suiteVersion !== undefined ? ` (suite ${input.suiteVersion})` : ""} — append a revision carrying provenance ${rec}`,
|
|
428
|
+
],
|
|
429
|
+
verify: [
|
|
430
|
+
"`*validate` rerun with the merged labels.json — TPR/TNR + Rogan-Gladen delta recorded",
|
|
431
|
+
"`*evaluate` rerun (pinned, temp 0) — the abstaining trajectories come back decided",
|
|
432
|
+
],
|
|
433
|
+
acceptance: `The ${abstains.length} case(s) above become decidable (no abstain) without flipping any currently-decided verdict.`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// (2) CROSS-LAYER CONFLICTS — code never resolves these; a human ruling does.
|
|
438
|
+
// ONE item covering all of them, not one per conflict: they share a single
|
|
439
|
+
// change type, apply plan and acceptance, so N near-identical proposals would
|
|
440
|
+
// be noise rather than signal (observed: six of them on a 12-trace corpus made
|
|
441
|
+
// the calibration queue unreadable). Every trajectory is still named.
|
|
442
|
+
if (conflicts.length > 0) {
|
|
443
|
+
const trajs = conflicts.map((c) => short(c.trajectoryId));
|
|
444
|
+
items.push({
|
|
445
|
+
id: `CAL-${items.length + 1}`,
|
|
446
|
+
title: `Adjudicate ${conflicts.length} ⚡ layer conflict(s)`,
|
|
447
|
+
trigger: conflicts
|
|
448
|
+
.map(
|
|
449
|
+
(cf) =>
|
|
450
|
+
`${short(cf.trajectoryId)} — ${Object.entries(cf.verdicts).map(([l, v]) => `${l} ${v}`).join(" vs ")}${cf.note !== undefined ? ` (${cf.note})` : ""}`,
|
|
451
|
+
)
|
|
452
|
+
.join(" · "),
|
|
453
|
+
changeType: "conflict-adjudication (human ruling; the gate never reads it)",
|
|
454
|
+
describedChange:
|
|
455
|
+
"On each trajectory above, two evidence layers reached opposite conclusions. Nothing auto-resolves it: the reviewer's Agree/Revise/Refute on that trajectory's verdict cards IS the adjudication, and it is recorded in the conflict ledger.",
|
|
456
|
+
applyPlan: [`[record] conflict ledger entries for ${trajs.join(", ")} — each reviewer ruling attached, with provenance ${rec}`],
|
|
457
|
+
verify: ["`*evaluate` rerun (pinned) — confirm each ledger entry clears or the conflict reproduces"],
|
|
458
|
+
acceptance: `All ${conflicts.length} trajectory(ies) carry an explicit human ruling instead of an unresolved ⚡.`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// (3) LOW-CONFIDENCE DECIDED VERDICTS — decided, but weakly. A judge rubric/few-shot
|
|
463
|
+
// concern rather than a criterion-definition one.
|
|
464
|
+
const LOW = 0.7;
|
|
465
|
+
for (const c of crits) {
|
|
466
|
+
const weak: string[] = [];
|
|
467
|
+
for (const f of input.files) {
|
|
468
|
+
for (const v of f.verdicts) {
|
|
469
|
+
if (byCid.get(v.criterionId)?.id !== c.id) continue;
|
|
470
|
+
if (v.result !== "pass" && v.result !== "fail") continue;
|
|
471
|
+
if (typeof v.confidence === "number" && v.confidence < LOW) weak.push(`${short(f.trajectoryId)} (.${Math.round(v.confidence * 100)})`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (weak.length === 0) continue;
|
|
475
|
+
items.push({
|
|
476
|
+
id: `CAL-${items.length + 1}`,
|
|
477
|
+
title: `Strengthen the judge rubric for ${c.id} (${c.slug}) — ${weak.length} low-confidence verdict(s)`,
|
|
478
|
+
trigger: `decided-but-weak verdicts below confidence .${Math.round(LOW * 100)} on ${c.id} ${c.slug} — ${weak.join(" · ")}`,
|
|
479
|
+
changeType: "judge few-shot / rubric addition (TRAIN split only)",
|
|
480
|
+
describedChange:
|
|
481
|
+
"These verdicts were decided, so they do not move the gate — but the judge was near its decision boundary, which is where drift starts. Add the reviewer-confirmed cases as exemplars for this criterion's judge task. TRAIN split only, so the validation labels stay untouched.",
|
|
482
|
+
applyPlan: [`[edit] ${c.slug} judge task few-shots — add the reviewer-AGREED cases above as exemplars`],
|
|
483
|
+
verify: [
|
|
484
|
+
"byte-identity check on unrelated criteria (pinned rerun) — no collateral movement",
|
|
485
|
+
"`*validate` — this criterion's TPR does not regress on the validation labels",
|
|
486
|
+
],
|
|
487
|
+
acceptance: `${c.id} decides these cases above .${Math.round(LOW * 100)} confidence with no regression elsewhere.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return items;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Render the CAL items as the markdown remedy bundle (server-side, real data). */
|
|
495
|
+
function calibrationMd(items: CalItem[]): string {
|
|
496
|
+
if (items.length === 0)
|
|
497
|
+
return [
|
|
498
|
+
"## Calibration items",
|
|
499
|
+
"",
|
|
500
|
+
"- **none derivable from this run** — no verdict abstained, no cross-layer conflict was detected, and no decided verdict fell below the low-confidence threshold. There is no trigger to propose a change from. The reviewer's rulings below are still recorded and still feed `*validate`.",
|
|
501
|
+
"",
|
|
502
|
+
].join("\n");
|
|
503
|
+
const out: string[] = [];
|
|
504
|
+
for (const it of items) {
|
|
505
|
+
out.push(`## ${it.id} — ${it.title}`);
|
|
506
|
+
out.push(`**Trigger:** ${it.trigger}`);
|
|
507
|
+
out.push(`**Change type:** ${it.changeType}`);
|
|
508
|
+
out.push(`### Described change`);
|
|
509
|
+
out.push(it.describedChange);
|
|
510
|
+
out.push(`### Apply plan`);
|
|
511
|
+
for (const p of it.applyPlan) out.push(`- ${p}`);
|
|
512
|
+
out.push(`**Verify:**`);
|
|
513
|
+
for (const v of it.verify) out.push(`- ${v}`);
|
|
514
|
+
out.push(`**Acceptance:** ${it.acceptance}`);
|
|
515
|
+
out.push("");
|
|
516
|
+
}
|
|
517
|
+
return out.join("\n");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ── HTML islands (server-rendered slots) ──────────────────────────────────── */
|
|
521
|
+
function subjectCard(input: RenderReviewV3Input, traces: Record<string, unknown>[], crits: ReviewCriterion[]): string {
|
|
522
|
+
const prof = (input.subjectProfile ?? {}) as Record<string, unknown>;
|
|
523
|
+
const tools = ((prof["tools"] ?? []) as { name?: string }[]).map((t) => String(t.name ?? t));
|
|
524
|
+
const reads = tools.filter((t) => toolClass(t) === "read");
|
|
525
|
+
const acts = tools.filter((t) => toolClass(t) === "act");
|
|
526
|
+
const chips = [String(prof["skill"] ?? "agent"), String(prof["harness"] ?? "harness unknown"), String(prof["version"] ?? "version unknown")];
|
|
527
|
+
const identity = String(prof["identity"] ?? input.subjectName);
|
|
528
|
+
const purpose = String(prof["purpose"] ?? "purpose not supplied by the subject profile (NAMED absence)");
|
|
529
|
+
const scenarios = [...new Set(traces.map((t) => String(t["scenario"])))];
|
|
530
|
+
|
|
531
|
+
const toolsCell =
|
|
532
|
+
tools.length === 0
|
|
533
|
+
? `<div class="pv">no tool inventory in the subject profile — the drill still shows every tool call the traces actually recorded (NAMED absence, not an empty toolset)</div>`
|
|
534
|
+
: `<div class="pv">
|
|
535
|
+
<span style="color:var(--cyan);font-size:12.5px">reads data:</span><br>
|
|
536
|
+
${reads.length > 0 ? reads.map((t) => `<span class="toolb read">${esc(t)}</span>`).join("") : '<span class="toolb read">(none classified)</span>'}<br>
|
|
537
|
+
<span style="color:var(--warn);font-size:12.5px">acts on the world:</span><br>
|
|
538
|
+
${acts.length > 0 ? acts.map((t) => `<span class="toolb act">${esc(t)}</span>`).join("") : '<span class="toolb act">(none classified)</span>'}</div>`;
|
|
539
|
+
|
|
540
|
+
// AUDIENCE: the determinism CLAIM survives externally; the model identifier does not.
|
|
541
|
+
const judgeLine = `judge: ${esc(input.pin.model)} · temp ${input.pin.temperature}`;
|
|
542
|
+
const suite = input.suiteVersion !== undefined ? `living-suite ${esc(input.suiteVersion)}` : "living-suite (version not recorded)";
|
|
543
|
+
|
|
544
|
+
return `<div class="profgrid" data-component="mgt-subject-profile">
|
|
545
|
+
<div><div class="pk">the subject being reviewed</div><div class="pv"><b>${esc(input.subjectName)}</b> — ${esc(identity)}: ${esc(purpose)}
|
|
546
|
+
<br>${chips.map((c, i) => `<span class="idc ${["k", "m", "f"][i % 3]}">${esc(c)}</span>`).join("")}</div></div>
|
|
547
|
+
<div><div class="pk">its ${tools.length} tools — reading vs acting</div>${toolsCell}</div>
|
|
548
|
+
<div><div class="pk">what this run judged</div><div class="pv"><b id="profcorpus">${traces.length} traces</b>${input.generatedAt !== undefined ? ` · ${esc(input.generatedAt.slice(0, 10))}` : ""} · unitf-jsonl export
|
|
549
|
+
<br>${scenarios.length > 0 ? esc(scenarios.join(" · ")) : "scenario kinds not recorded on these trajectories"}
|
|
550
|
+
<br><span style="font-size:12.5px;color:var(--dim)">suite: ${suite} (${crits.length} criteria) · ${judgeLine} · your rulings recalibrate THIS judge</span></div></div>
|
|
551
|
+
</div>`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function heroMeta(input: RenderReviewV3Input, traces: Record<string, unknown>[], verdictTotal: number): string {
|
|
555
|
+
const runChip = `<span class="chip" style="color:var(--primarySoft);border-color:rgba(126,71,215,.6)">run ${esc(input.runId)}</span>`;
|
|
556
|
+
const scope = input.scopeNote !== undefined ? `<span class="chip" style="color:var(--warn);border-color:var(--warn)">scope: ${esc(input.scopeNote)}</span>` : "";
|
|
557
|
+
return `${runChip}<span class="chip" id="herocount">${traces.length} sessions · ${verdictTotal} judge verdicts to review</span>
|
|
558
|
+
<span class="chip">keys: 1 pass · 2 fail · D defer · ←/→ switch trace</span>${scope}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/* ── the renderer ──────────────────────────────────────────────────────────── */
|
|
562
|
+
export function renderReviewReportV3(input: RenderReviewV3Input): string {
|
|
563
|
+
const template = readFileSync(TEMPLATE_PATH, "utf8");
|
|
564
|
+
const sevOf = new Map(input.criteria.map((c) => [c.criterionId, String(c.severity)]));
|
|
565
|
+
const { list: crits, byCid } = mapCriteria(input);
|
|
566
|
+
const conflicts = detectLayerConflicts(input.files);
|
|
567
|
+
const traceById = new Map((input.traces ?? []).map((t) => [t.id, t]));
|
|
568
|
+
const traces = input.files.map((f) => mapTrace(f, byCid, sevOf, conflicts, traceById.get(f.trajectoryId)));
|
|
569
|
+
|
|
570
|
+
const verdictTotal = traces.reduce(
|
|
571
|
+
(a, t) => a + (t["steps"] as ReviewStep[]).reduce((b, s) => b + s.verdicts.length, 0),
|
|
572
|
+
0,
|
|
573
|
+
);
|
|
574
|
+
const scenarios = [...new Set(traces.map((t) => String(t["scenario"])))];
|
|
575
|
+
const openTrace = (traces.find((t) => t["gate"] === "fail") ?? traces[0])?.["id"] ?? null;
|
|
576
|
+
|
|
577
|
+
const calItems = calibrationItems(input, crits, byCid, conflicts);
|
|
578
|
+
|
|
579
|
+
const head: string[] = [];
|
|
580
|
+
head.push(`# Calibration Handoff — ${input.subjectName} · ${input.runId}`);
|
|
581
|
+
head.push(`**From:** human review session · **To:** evaluator calibration agent`);
|
|
582
|
+
head.push(
|
|
583
|
+
`**Corpus:** ${traces.length} trajectory(ies) · ${crits.length} criteria · ${verdictTotal} judge verdicts offered for review`,
|
|
584
|
+
);
|
|
585
|
+
if (input.feedbackRecord !== undefined) head.push(`**Record:** ${input.feedbackRecord.id} (append-only feedback store)`);
|
|
586
|
+
else head.push(`**Record:** no append-only feedback store is configured for this run — this session is browser-held until one is`);
|
|
587
|
+
if (input.scopeNote !== undefined) head.push(`**Scope:** ${input.scopeNote}`);
|
|
588
|
+
|
|
589
|
+
const data = {
|
|
590
|
+
criteria: crits,
|
|
591
|
+
traces,
|
|
592
|
+
scenarios,
|
|
593
|
+
openTrace,
|
|
594
|
+
handoffHead: head.join("\n"),
|
|
595
|
+
calibrationMd: calibrationMd(calItems),
|
|
596
|
+
calItems: calItems.map((c) => ({ id: c.id, title: c.title })),
|
|
597
|
+
conflicts: traces
|
|
598
|
+
.filter((t) => t["conflict"] === true)
|
|
599
|
+
.map((t) => ({
|
|
600
|
+
trace: String(t["id"]),
|
|
601
|
+
key: t["conflictKey"] ?? "",
|
|
602
|
+
crit: (t["conflictCards"] as string[]).join(" + ") || "(no bound verdict card)",
|
|
603
|
+
note: String(t["conflictNote"] ?? "cross-layer disagreement"),
|
|
604
|
+
})),
|
|
605
|
+
// AUDIENCE: the store PATH is a gitignored artifact path — internal only. The
|
|
606
|
+
// record ID itself is the provenance the reviewer needs and survives.
|
|
607
|
+
feedbackRecord: input.feedbackRecord ?? null,
|
|
608
|
+
validationDelta: input.validationDelta ?? null,
|
|
609
|
+
feedbackTitle: `Review report · ${input.runId}`,
|
|
610
|
+
artifactPath: `.mutagent/evaluator/reports/${input.runId}/review-report.html`,
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const slots: Record<string, string> = {
|
|
614
|
+
"@@TITLE@@": `Review & Calibration — ${esc(input.subjectName)} · ${esc(input.runId)}`,
|
|
615
|
+
"@@HEADER_RIGHT@@": `<span style="margin-left:12px;font-family:var(--mono);font-size:11.5px;color:var(--muted)">run ${esc(input.runId)}${input.generatedAt !== undefined ? ` · ${esc(input.generatedAt.slice(0, 10))}` : ""}</span>`,
|
|
616
|
+
"@@HERO_TITLE@@": `Review & Calibration — ${esc(input.subjectName)}`,
|
|
617
|
+
"@@HERO_LEDE@@": `<b>You grade the judge.</b> ${traces.length} session(s) below were already scored automatically against ${crits.length} binary criteria. Your job: open a trace, read what the agent did, and for each of the ${verdictTotal} judge verdicts rule <b>Agree</b>, <b>Revise</b>, or <b>Refute</b>. Your rulings become the ground truth that recalibrates the judges — accuracy statistics, criterion revisions, conflict adjudications. Nothing changes until you submit.`,
|
|
618
|
+
"@@HERO_META@@": heroMeta(input, traces, verdictTotal),
|
|
619
|
+
"@@SUBJECT_CARD@@": subjectCard(input, traces, crits),
|
|
620
|
+
"@@FOOT@@": `🧬 MutagenT · evaluator review UI · run ${esc(input.runId)} · artifact → .mutagent/evaluator/reports/${esc(input.runId)} (gitignored) · labels.json is a monotonic superset → *validate`,
|
|
621
|
+
"@@FEEDBACK_LABEL@@": `Feedback — review report · ${esc(input.runId)} · Copy MD bundles your label state + notes`,
|
|
622
|
+
"@@DATA_JSON@@": JSON.stringify(data).replace(/</g, "\\u003c"),
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
let html = template;
|
|
626
|
+
for (const [k, v] of Object.entries(slots)) {
|
|
627
|
+
if (!html.includes(k)) throw new Error(`TEMPLATE SLOT MISSING: ${k}`);
|
|
628
|
+
// a FUNCTION replacement, never the string form: `$&` / `$'` / `` $` `` / `$$` /
|
|
629
|
+
// `$1` inside real subject text (a quoted reply, a criterion statement, a judge
|
|
630
|
+
// critique, the markdown handoff) are substitution patterns to String.replace and
|
|
631
|
+
// would silently corrupt the emitted page — `$&` even re-emits the slot marker.
|
|
632
|
+
html = html.replace(k, () => v);
|
|
633
|
+
}
|
|
634
|
+
// The feedback bar is a DEV-LOOP affordance only (report rule §2): user-produced
|
|
635
|
+
// reports never carry it, regardless of audience.
|
|
636
|
+
if (input.devFeedback !== true) {
|
|
637
|
+
const start = html.indexOf('<div id="fbpanel"');
|
|
638
|
+
const endAnchor = "</div></div>";
|
|
639
|
+
const end = start >= 0 ? html.indexOf(endAnchor, start) : -1;
|
|
640
|
+
if (start < 0 || end < 0) throw new Error("feedback-panel strip anchor missing");
|
|
641
|
+
html = html.slice(0, start) + html.slice(end + endAnchor.length);
|
|
642
|
+
}
|
|
643
|
+
// ── V11 AUDIENCE ──
|
|
644
|
+
// Clause 1 of the contract (strip the internal methodology SURFACE) has no
|
|
645
|
+
// counterpart here: the signed review mock carries no ⑤/⑥-style internal section,
|
|
646
|
+
// and one is not invented so that a strip has something to remove. Clause 2 —
|
|
647
|
+
// run-internal operational detail — is handled at the point of emission above
|
|
648
|
+
// (run-id chips, the pinned judge-model string, the gitignored artifact paths),
|
|
649
|
+
// which is why there is no post-fill cut on this surface.
|
|
650
|
+
const leftover = /@@[A-Z_]+@@/.exec(html);
|
|
651
|
+
if (leftover !== null) throw new Error(`UNFILLED SLOT: ${leftover[0]}`);
|
|
652
|
+
return html;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Write the v3 frozen-contract review report for a run. Returns the artifact path. */
|
|
656
|
+
export function writeReviewReportV3(input: RenderReviewV3Input, repoRoot: string): string {
|
|
657
|
+
const out = join(repoRoot, ".mutagent/evaluator/reports", input.runId, "review-report.html");
|
|
658
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
659
|
+
writeFileSync(out, renderReviewReportV3(input));
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The W4 review RUN composer — the analogue of run-evaluate's `writeRunReport` and
|
|
665
|
+
* the discovery composer. Writes BOTH:
|
|
666
|
+
* - `review-report.html` → the PRODUCTION frozen-contract v3 render
|
|
667
|
+
* - `review-report.v2.html` → the previous `*review` annotation UI
|
|
668
|
+
* (`renderReviewUi`, EV-045), kept as the transition fallback so nothing the
|
|
669
|
+
* operator relied on disappears in one step. That module is UNTOUCHED — it is
|
|
670
|
+
* imported and called, never modified.
|
|
671
|
+
* Returns the production path and the fallback path (`null` when no fallback was
|
|
672
|
+
* written — a NAMED absence, not a silently missing file).
|
|
673
|
+
*
|
|
674
|
+
* The v2 UI consumes `EvalTrace[]`, which the v3 surface does not otherwise need.
|
|
675
|
+
* Supply `evalTraces` to get the fallback; omit it and only the production report
|
|
676
|
+
* is written, with `fallback: null` saying so.
|
|
677
|
+
*/
|
|
678
|
+
export function writeReviewRunReportV3(
|
|
679
|
+
input: RenderReviewV3Input,
|
|
680
|
+
repoRoot: string,
|
|
681
|
+
evalTraces?: EvalTrace[],
|
|
682
|
+
): { report: string; fallback: string | null } {
|
|
683
|
+
const dir = join(repoRoot, ".mutagent/evaluator/reports", input.runId);
|
|
684
|
+
mkdirSync(dir, { recursive: true });
|
|
685
|
+
const report = join(dir, "review-report.html");
|
|
686
|
+
writeFileSync(report, renderReviewReportV3(input));
|
|
687
|
+
if (evalTraces === undefined || evalTraces.length === 0) return { report, fallback: null };
|
|
688
|
+
const fallback = join(dir, "review-report.v2.html");
|
|
689
|
+
writeFileSync(fallback, renderReviewUi(evalTraces, { title: `Review — ${input.subjectName}` }));
|
|
690
|
+
return { report, fallback: existsSync(fallback) ? fallback : null };
|
|
691
|
+
}
|