@mutagent/evaluator 0.1.0-alpha.5 → 0.1.0-alpha.6

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.
Files changed (34) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +5 -5
  2. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +16 -16
  3. package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
  4. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
  5. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
  6. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +36 -3
  7. package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
  8. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  9. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  10. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  11. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  12. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  13. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
  14. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  15. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +39 -0
  16. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  17. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  18. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  19. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  20. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  21. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  22. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  23. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  24. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  25. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  26. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +9 -91
  27. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  28. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +2 -2
  29. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  30. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  31. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
  32. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  33. package/bin/mutagent-cli.mjs +9 -5
  34. package/package.json +1 -1
@@ -0,0 +1,165 @@
1
+ /**
2
+ * scripts/memory/ratify.ts — EV-4: persist ratify / do-not decisions + hold the verdict.
3
+ * ---------------------------------------------------------------------------
4
+ * The dominant dogfood finding ("router-as-doer") rested on a NORMATIVE call —
5
+ * what counts as correct routing — that only the operator can settle. The review
6
+ * UI already CAPTURES the operator's verify/eliminate decisions on each surfaced
7
+ * assumption (`build-review-ui.ts` `setCalib` → `calibration.json`), but wrote them
8
+ * NOWHERE: a verdict resting on an unratified standard still read as final.
9
+ *
10
+ * This is the LEAN wiring (decision EV-4 option a — NO new criterion schema):
11
+ * 1. ROUTE each calibration decision into `appendMemory` — the same store the
12
+ * next run's judges read at start (`memory/read.ts`), deduped by slug.
13
+ * 2. HOLD the verdict: a NORMATIVE criterion with no `verify` ratification in the
14
+ * decision set is `needs-ratification`; while any is pending the run's verdict
15
+ * is HELD (not final) — the value call must be settled first.
16
+ *
17
+ * PURE cores (deterministic; `now` injected). The only fs edge reuses `appendMemory`.
18
+ */
19
+ import { appendMemory, Lifecycle, MemoryType, type AppendResult } from "./append.ts";
20
+ import { AssumptionKind, type CriterionVerdict } from "../contracts/eval-types.ts";
21
+
22
+ /**
23
+ * The criterion ids whose verdict rests on a NORMATIVE assumption (a value/standard
24
+ * call — `AssumptionKind.Normative`), whether surfaced in `assumptions[]` or the
25
+ * `blockedBy` payload. These are the criteria that REQUIRE operator ratification
26
+ * before their verdict can be final. PURE.
27
+ */
28
+ export function normativeCriterionIds(verdicts: CriterionVerdict[]): string[] {
29
+ const ids = new Set<string>();
30
+ for (const v of verdicts) {
31
+ const inAssumptions = (v.assumptions ?? []).some((a) => a.kind === AssumptionKind.Normative);
32
+ const inBlock = v.blockedBy?.kind === AssumptionKind.Normative;
33
+ if (inAssumptions || inBlock) ids.add(v.criterionId);
34
+ }
35
+ return [...ids].sort();
36
+ }
37
+
38
+ /** The verify/eliminate action a review-UI calibration button emits (`data-action`). */
39
+ export const RatifyAction = {
40
+ /** the operator RATIFIES the surfaced assumption/standard (accept it). */
41
+ Verify: "verify",
42
+ /** the operator rejects it (do-not — drop this standard). */
43
+ Eliminate: "eliminate",
44
+ } as const;
45
+ export type RatifyActionValue = (typeof RatifyAction)[keyof typeof RatifyAction];
46
+
47
+ /**
48
+ * One calibration decision as the review UI exports it (`calibration.json`). Shape
49
+ * mirrors `build-review-ui.ts` `setCalib` EXACTLY so the operator's export routes
50
+ * with no transform.
51
+ */
52
+ export interface CalibrationDecision {
53
+ traceId: string;
54
+ criterionId: string;
55
+ assumptionIndex: number;
56
+ action: RatifyActionValue;
57
+ decidedAt?: string;
58
+ }
59
+
60
+ /** A stable slug for a ratification memory (dedupe: re-deciding the same pair updates). */
61
+ export function ratificationSlug(d: CalibrationDecision): string {
62
+ return `ratify-${d.criterionId}-a${d.assumptionIndex}`;
63
+ }
64
+
65
+ /**
66
+ * Project a calibration decision → an AutoMemory FeedbackInput. A `verify` is a
67
+ * standing "this standard IS ratified" fact the next run's judge honours; an
68
+ * `eliminate` records the operator dropped it. PURE.
69
+ */
70
+ export function calibrationToMemory(
71
+ d: CalibrationDecision,
72
+ criterionStatement?: string,
73
+ ): { title: string; description: string; body: string } {
74
+ const ratified = d.action === RatifyAction.Verify;
75
+ const what = criterionStatement !== undefined && criterionStatement.length > 0 ? criterionStatement : d.criterionId;
76
+ const title = ratificationSlug(d);
77
+ const verb = ratified ? "RATIFIED" : "REJECTED (do-not)";
78
+ return {
79
+ title,
80
+ description: `Normative ratification — criterion ${d.criterionId} assumption #${d.assumptionIndex} ${verb}`,
81
+ body:
82
+ `Operator ${verb} the normative standard for criterion \`${d.criterionId}\` ` +
83
+ `(assumption #${d.assumptionIndex}).\n\n` +
84
+ `**What:** ${what}\n` +
85
+ `**Why:** a normative criterion (what SHOULD count as good) is an operator value call — ` +
86
+ `the judge cannot settle it. This decision is the settled standard.\n` +
87
+ `**How to apply:** ${ratified
88
+ ? "treat this standard as ratified — the verdict on this criterion may now be final."
89
+ : "do NOT gate on this standard — the operator dropped it."}\n` +
90
+ (d.decidedAt !== undefined ? `\n(decided ${d.decidedAt})` : ""),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * ROUTE calibration decisions into AutoMemory (EV-4). One `appendMemory` per
96
+ * decision, deduped by slug (re-deciding UPDATES). Returns the append results.
97
+ * `now` (YYYY-MM-DD) is INJECTED. `statements` optionally maps criterionId → its
98
+ * statement for a richer memory body.
99
+ */
100
+ export function persistCalibrations(
101
+ memoryDir: string,
102
+ decisions: CalibrationDecision[],
103
+ now: string,
104
+ statements: Record<string, string> = {},
105
+ ): AppendResult[] {
106
+ return decisions.map((d) => {
107
+ const m = calibrationToMemory(d, statements[d.criterionId]);
108
+ return appendMemory(
109
+ memoryDir,
110
+ {
111
+ title: m.title,
112
+ description: m.description,
113
+ lifecycle: Lifecycle.Evaluate,
114
+ type: MemoryType.Feedback, // a standing steer the next run's judge honours
115
+ feedback: { text: m.body },
116
+ },
117
+ now,
118
+ );
119
+ });
120
+ }
121
+
122
+ /** The ratification state of one normative criterion (EV-4). */
123
+ export interface RatificationStatus {
124
+ criterionId: string;
125
+ /** true when a `verify` decision ratified it; false ⇒ needs-ratification. */
126
+ ratified: boolean;
127
+ }
128
+
129
+ /**
130
+ * Given the NORMATIVE criterion ids (those resting on a normative assumption) and
131
+ * the operator's calibration decisions, report which are RATIFIED (a `verify`
132
+ * exists) vs still `needs-ratification`. An `eliminate` does NOT ratify — the
133
+ * standard was dropped, not settled-as-correct. PURE.
134
+ */
135
+ export function ratificationStatus(
136
+ normativeCriterionIds: string[],
137
+ decisions: CalibrationDecision[],
138
+ ): RatificationStatus[] {
139
+ const ratified = new Set(
140
+ decisions.filter((d) => d.action === RatifyAction.Verify).map((d) => d.criterionId),
141
+ );
142
+ return normativeCriterionIds.map((id) => ({ criterionId: id, ratified: ratified.has(id) }));
143
+ }
144
+
145
+ /**
146
+ * The criterion ids that HOLD the verdict (EV-4): normative criteria not yet
147
+ * ratified. While this list is non-empty the run's verdict is NOT final — the
148
+ * operator must settle the value call first. PURE.
149
+ */
150
+ export function pendingRatifications(
151
+ normativeCriterionIds: string[],
152
+ decisions: CalibrationDecision[],
153
+ ): string[] {
154
+ return ratificationStatus(normativeCriterionIds, decisions)
155
+ .filter((s) => !s.ratified)
156
+ .map((s) => s.criterionId);
157
+ }
158
+
159
+ /** Is the run's verdict HELD pending an operator ratification? (EV-4) */
160
+ export function isVerdictHeldForRatification(
161
+ normativeCriterionIds: string[],
162
+ decisions: CalibrationDecision[],
163
+ ): boolean {
164
+ return pendingRatifications(normativeCriterionIds, decisions).length > 0;
165
+ }
@@ -27,7 +27,7 @@
27
27
  import { existsSync, readFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { extractOutcomeSignals, type JudgeInvoke } from "./determine-outcome.ts";
30
- import { buildOutcomePrompt } from "./judge-prompt-template.ts";
30
+ import { buildOutcomePrompt, type SubjectFrame } from "./judge-prompt-template.ts";
31
31
  import {
32
32
  writeJudgeTask,
33
33
  verdictFileName,
@@ -53,19 +53,27 @@ const PREP_PLACEHOLDER = JSON.stringify({
53
53
  */
54
54
  export function prepDeterminerTasks(
55
55
  traces: EvalTrace[],
56
- opts: { dir: string; pin: PinnedEnvelope; vocab: SubjectVocab },
56
+ opts: { dir: string; pin: PinnedEnvelope; vocab: SubjectVocab; subject?: SubjectFrame },
57
57
  ): JudgeTaskSpec[] {
58
58
  return traces.map((trace) => {
59
+ // EV-1 — the subject frame MUST match what the Stage-B pipeline renders (byte-
60
+ // identity for the cross-stage cache), so the caller derives it from the SAME
61
+ // full trace batch buildSubjectProfile sees in run-pipeline.
59
62
  const { system, user } = buildOutcomePrompt(
60
63
  trace,
61
64
  extractOutcomeSignals(trace, opts.vocab),
62
65
  opts.vocab,
66
+ opts.subject,
63
67
  );
64
68
  return writeJudgeTask(opts.dir, {
65
69
  unit: { kind: "discover", traceId: trace.id },
66
70
  system,
67
71
  user,
68
72
  pin: opts.pin,
73
+ // INF-3 — salt the determiner cache key with the trace id so two sessions with
74
+ // an identical (e.g. empty) determiner prompt never collapse onto one verdict.
75
+ // AGGREGATE reads the stored `spec.verdictFile`, so this needs no re-derive there.
76
+ keyId: trace.id,
69
77
  });
70
78
  });
71
79
  }
@@ -84,8 +92,11 @@ export function createCapturingJudge(opts: {
84
92
  pin: PinnedEnvelope;
85
93
  }): { judge: JudgeInvoke; captured: JudgeTaskSpec[] } {
86
94
  const captured: JudgeTaskSpec[] = [];
87
- const judge: JudgeInvoke = (system, user) => {
88
- const vpath = join(opts.verdictDir, verdictFileName(system, user));
95
+ const judge: JudgeInvoke = (system, user, keyId) => {
96
+ // INF-3 the determiner passes its trace id (keyId) so the salted stage-A
97
+ // verdict file is found; criterion-judge prompts pass no keyId ⇒ unsalted,
98
+ // and are CAPTURED as build-evals tasks below (byte-identical key, C-PIN).
99
+ const vpath = join(opts.verdictDir, verdictFileName(system, user, keyId));
89
100
  if (existsSync(vpath)) return Promise.resolve(readFileSync(vpath, "utf8"));
90
101
  captured.push(
91
102
  // D4 — the criterion judging axis (*build-evals / Step-2b). `kind` now matches the
@@ -0,0 +1,120 @@
1
+ /**
2
+ * scripts/read-manifest.ts — EV-6: the sibling TraceManifest leg of the Discovery handoff.
3
+ * ---------------------------------------------------------------------------
4
+ * The Discovery handoff is a PATH PAIR — the UniTF `.jsonl` PLUS a sibling
5
+ * `TraceManifest` that reports the slice (count · format · truncation · coverage).
6
+ * Diagnostics reads BOTH and audits the slice BEFORE it judges; the evaluator used
7
+ * to read only the JSONL (a bare `--traces` path) and never opened the manifest —
8
+ * so a truncated or partially-malformed export was judged silently as if complete
9
+ * (EV-6). This wires the manifest leg into the evaluator intake, WARN-not-fail, the
10
+ * way diagnostics' `read-unitf.ts` `validateUniTFManifest` does.
11
+ *
12
+ * STANDALONE — the manifest shape is VENDORED (a minimal structural subset of
13
+ * `@mutagent/tools` `trace-manifest.ts`), NOT imported: the standalone-publish
14
+ * guard forbids the evaluator reaching into `@mutagent/tools`
15
+ * (MIGRATION-diagnostics-evaluator.md:417). Only the fields the audit reads are
16
+ * mirrored; diagnostics vendors its own identical copy.
17
+ *
18
+ * PURE except for the effectful `validateSiblingManifest` (one fs read of a sibling
19
+ * path). No clock / random / network.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+
23
+ /** The manifest `format` marker that pairs with the UniTF version. */
24
+ export const UNITF_MANIFEST_FORMAT = "unitf@0.1" as const;
25
+
26
+ /**
27
+ * The sibling TraceManifest (frozen `TraceManifest v0.1.0`), structural subset —
28
+ * only the fields the slice audit inspects. Every field is optional so a partial
29
+ * or older manifest degrades to fewer warnings rather than throwing.
30
+ */
31
+ export interface UnitfTraceManifest {
32
+ manifest_version?: string;
33
+ count?: number;
34
+ truncated?: boolean;
35
+ truncationReason?: string;
36
+ format?: string;
37
+ coverage?: {
38
+ fetched?: number;
39
+ malformed?: number;
40
+ deduped?: number;
41
+ };
42
+ warnings?: string[];
43
+ }
44
+
45
+ /**
46
+ * The sibling manifest path for a handed-over traces file: `<path>.manifest.json`,
47
+ * with a `.jsonl` suffix REPLACED (`traces.jsonl` → `traces.manifest.json`) —
48
+ * mirrors `@mutagent/tools` `manifestPathFor`. A `.gz` suffix is stripped first
49
+ * (the evaluator loads `.jsonl.gz` too) so `traces.jsonl.gz` also resolves to
50
+ * `traces.manifest.json`. PURE.
51
+ */
52
+ export function manifestPathForTraces(tracesPath: string): string {
53
+ const base = tracesPath.endsWith(".gz") ? tracesPath.slice(0, -".gz".length) : tracesPath;
54
+ return base.endsWith(".jsonl")
55
+ ? base.slice(0, -".jsonl".length) + ".manifest.json"
56
+ : base + ".manifest.json";
57
+ }
58
+
59
+ /**
60
+ * Validate a parsed manifest against the parsed trace count. Returns WARNINGS
61
+ * (never throws, never aborts) — the migration's "surface warnings but do NOT
62
+ * abort a partial export" rule. Empty array = clean / no manifest. Mirrors
63
+ * diagnostics `validateUniTFManifest`.
64
+ */
65
+ export function validateUnitfManifest(
66
+ manifest: UnitfTraceManifest | undefined,
67
+ actualTraceCount: number,
68
+ ): string[] {
69
+ const warnings: string[] = [];
70
+ if (!manifest) return warnings;
71
+ if (manifest.format !== undefined && manifest.format !== UNITF_MANIFEST_FORMAT) {
72
+ warnings.push(`manifest.format is "${manifest.format}" — expected "${UNITF_MANIFEST_FORMAT}"`);
73
+ }
74
+ if (manifest.count !== undefined && manifest.count !== actualTraceCount) {
75
+ warnings.push(
76
+ `manifest.count (${manifest.count}) != parsed trace count (${actualTraceCount}) — ` +
77
+ `the slice is incomplete (a truncated/partially-malformed export judged as if complete)`,
78
+ );
79
+ }
80
+ if (manifest.truncated === true) {
81
+ warnings.push(
82
+ `export is truncated${manifest.truncationReason ? ` (${manifest.truncationReason})` : ""} — ` +
83
+ `judging a PARTIAL slice`,
84
+ );
85
+ }
86
+ // Coverage cross-check: fetched should account for the parsed + malformed + deduped
87
+ // records; a fetched count that exceeds what landed on disk is a silent drop.
88
+ const cov = manifest.coverage;
89
+ if (cov?.fetched !== undefined) {
90
+ const accountedFor = actualTraceCount + (cov.malformed ?? 0) + (cov.deduped ?? 0);
91
+ if (cov.fetched !== accountedFor) {
92
+ warnings.push(
93
+ `manifest.coverage.fetched (${cov.fetched}) != parsed(${actualTraceCount}) + ` +
94
+ `malformed(${cov.malformed ?? 0}) + deduped(${cov.deduped ?? 0}) = ${accountedFor} — ` +
95
+ `records went missing between fetch and hand-off`,
96
+ );
97
+ }
98
+ }
99
+ if (manifest.warnings) warnings.push(...manifest.warnings);
100
+ return warnings;
101
+ }
102
+
103
+ /**
104
+ * Read + validate the sibling manifest for a handed-over traces file. Effectful
105
+ * (one fs read). NON-fatal throughout: an ABSENT manifest returns [] (the handoff
106
+ * may predate the manifest leg); an UNREADABLE / unparseable manifest yields a
107
+ * single warning and proceeds. Returns the human-readable warning list the caller
108
+ * surfaces on stderr.
109
+ */
110
+ export function validateSiblingManifest(tracesPath: string, actualTraceCount: number): string[] {
111
+ const manifestPath = manifestPathForTraces(tracesPath);
112
+ if (!existsSync(manifestPath)) return []; // no manifest leg — nothing to audit
113
+ let manifest: UnitfTraceManifest | undefined;
114
+ try {
115
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as UnitfTraceManifest;
116
+ } catch {
117
+ return [`sibling manifest '${manifestPath}' failed to read/parse — proceeding without slice audit`];
118
+ }
119
+ return validateUnitfManifest(manifest, actualTraceCount);
120
+ }
@@ -23,6 +23,30 @@ export interface ParsedUnitfTraces {
23
23
  traces: EvalTrace[];
24
24
  /** lines that failed to parse OR lacked the minimal UniTF shape (surfaced). */
25
25
  skipped: number;
26
+ /**
27
+ * INF-1 — traceIds of records that carry a prompt-bearing span (a user/system
28
+ * turn, or an assistant/agent GENERATION span) yet projected to an EMPTY
29
+ * `input.prompt`. This is the exact silent-empty symptom that read 21/21 blank
30
+ * on Claude Code traces: the reader must WARN, never emit empty in silence. The
31
+ * effectful reader/CLI surfaces this list; a non-empty list is a projection miss.
32
+ */
33
+ emptyPromptTraceIds: string[];
34
+ }
35
+
36
+ /**
37
+ * Does this record carry a span that SHOULD yield a prompt — an explicit user/
38
+ * system turn, or a GENERATION span (kind llm/agent, the Langfuse shape whose
39
+ * `input` holds the prompt)? Used to WARN when such a record still projects an
40
+ * empty `input.prompt` (the INF-1 silent-empty class). PURE.
41
+ */
42
+ function hasPromptBearingSpan(rec: UnifiedTraceLike): boolean {
43
+ return rec.spans.some(
44
+ (s) =>
45
+ s.role === "user" ||
46
+ s.role === "system" ||
47
+ s.kind === "llm" ||
48
+ s.kind === "agent",
49
+ );
26
50
  }
27
51
 
28
52
  /**
@@ -45,6 +69,7 @@ function asUnitfRecord(value: unknown): UnifiedTraceLike | null {
45
69
  */
46
70
  export function parseUnitfJsonl(text: string): ParsedUnitfTraces {
47
71
  const traces: EvalTrace[] = [];
72
+ const emptyPromptTraceIds: string[] = [];
48
73
  let skipped = 0;
49
74
  for (const line of text.split("\n")) {
50
75
  const trimmed = line.trim();
@@ -61,9 +86,16 @@ export function parseUnitfJsonl(text: string): ParsedUnitfTraces {
61
86
  skipped += 1;
62
87
  continue;
63
88
  }
64
- traces.push(projectUnitfToEvalTrace(rec));
89
+ const trace = projectUnitfToEvalTrace(rec);
90
+ traces.push(trace);
91
+ // INF-1 — WARN, never silently emit empty: a record with a prompt-bearing span
92
+ // that still projected an empty prompt is a projection miss (the Claude Code
93
+ // 21/21-blank class). Recorded here; the CLI/reader surfaces it.
94
+ if (hasPromptBearingSpan(rec) && (trace.input?.prompt ?? "").length === 0) {
95
+ emptyPromptTraceIds.push(trace.id);
96
+ }
65
97
  }
66
- return { traces, skipped };
98
+ return { traces, skipped, emptyPromptTraceIds };
67
99
  }
68
100
 
69
101
  /**
@@ -83,6 +83,33 @@ export function renderBuildEvalsProgressCard(p: BuildEvalsProgress): string {
83
83
  ].join("\n");
84
84
  }
85
85
 
86
+ // ── EVAL-1 — "what this stage will produce" notice (up-front, at stage start) ─
87
+ export interface StageProducesNotice {
88
+ subject: string;
89
+ /** the command about to run (e.g. "*build-dataset" | "*build-evals" | "*eval"). */
90
+ command: string;
91
+ /** the named deliverables this stage emits (dataset · criteria · which files). */
92
+ produces: string[];
93
+ }
94
+
95
+ /**
96
+ * EVAL-1 (1) — render the up-front notice that names WHAT this stage will produce,
97
+ * so a build never runs silently with the operator unaware a dataset/criteria are
98
+ * coming. Emitted at Step 0, BEFORE the build. PURE. NOT an approval gate — a
99
+ * notification only (operator constraint).
100
+ */
101
+ export function renderStageProducesNotice(n: StageProducesNotice): string {
102
+ const lines = [
103
+ TOP,
104
+ row(`▷ ${n.command} — ${n.subject}`),
105
+ MID,
106
+ row("this stage will produce:"),
107
+ ];
108
+ for (const p of n.produces) lines.push(row(` • ${p}`));
109
+ lines.push(BOT);
110
+ return lines.join("\n");
111
+ }
112
+
86
113
  // ── F22 — dataset entity card (verbose, after *build-dataset) ─────────────────
87
114
  export interface DatasetEntity {
88
115
  subject: string;
@@ -99,6 +126,12 @@ export function renderDatasetEntityCard(e: DatasetEntity): string {
99
126
  MID,
100
127
  row(`categories (${e.categories.length}) · ${e.totalItems} items total`),
101
128
  ];
129
+ // EVAL-1 — a build that produced NOTHING must WARN, never render a blank card that
130
+ // reads as a silent success (the dogfood symptom: a build that reported and a build
131
+ // that stayed silent looked identical).
132
+ if (e.categories.length === 0 || e.totalItems === 0) {
133
+ lines.push(row("⚠ EMPTY — this build produced no dataset items (nothing to evaluate)."));
134
+ }
102
135
  for (const c of e.categories) {
103
136
  lines.push(row(` • ${c.id}: ${c.items} items (edge: ${c.edgeItems})`));
104
137
  }
@@ -129,6 +162,10 @@ export function renderEvalsEntityCard(e: EvalsEntity): string {
129
162
  row(`output sink: ${e.outputSink}`),
130
163
  row(`criteria (${e.criteria.length}):`),
131
164
  ];
165
+ // EVAL-1 — an eval suite with zero criteria is a silent no-op build; WARN loudly.
166
+ if (e.criteria.length === 0) {
167
+ lines.push(row("⚠ EMPTY — this build produced no criteria (the suite judges nothing)."));
168
+ }
132
169
  for (const c of e.criteria) {
133
170
  lines.push(row(` • ${c.id} [${c.severity}·${c.kind}]`));
134
171
  }
@@ -69,6 +69,14 @@ import { assessEmitCompleteness, type EmitCompleteness } from "./emit-completene
69
69
  import type { Scorecard } from "./evaluate.ts";
70
70
  import type { SourceMap } from "./source-map.ts";
71
71
  import { routeFailures, type FailureRef, type HandoverBundle } from "./route-failures.ts";
72
+ // EV-3 — the step<->criterion side-by-side render fns (`cvRefs` + `critiqueBlock`
73
+ // + `sideBySide`, the last carrying its local `refExaminesStep` + `covEntry`) are
74
+ // EXTRACTED VERBATIM into report-fragments.ts and spliced back into the client
75
+ // script IN PLACE below, so this report's emitted HTML is byte-identical to
76
+ // before the extraction. The review UI (`build-review-ui.ts`) imports the SAME
77
+ // constant to render the identical view. Byte-identity is guarded by this file's
78
+ // render tests + a before/after HTML diff.
79
+ import { SIDE_BY_SIDE_FRAGMENT_JS } from "./report-fragments.ts";
72
80
 
73
81
  const HERE = dirname(fileURLToPath(import.meta.url));
74
82
  const BRAND_DIR = join(HERE, "..", "assets", "brand");
@@ -3319,97 +3327,7 @@ function render(){var qi=document.getElementById('q');if(!qi)return;var q=(qi.va
3319
3327
  // critique-before-verdict + grounding refs, read off the verdict file's verdicts[].
3320
3328
  // Shared by BOTH the side-by-side drill (walk traces) and the per-trajectory
3321
3329
  // scorecard drill (no-walk traces) so NO judged trace renders an empty panel.
3322
- function cvRefs(refs){if(!refs||!refs.length)return '';
3323
- return '<div class="cvrefs">'+refs.map(function(rf){var s=(typeof rf==='string')?rf:[rf.obs,rf.path,rf.value].filter(Boolean).join(' · ');return s?'<span class="cvref">'+esc(s)+'</span>':'';}).join('')+'</div>';}
3324
- function critiqueBlock(cvs){if(!cvs||!cvs.length)return '';
3325
- var order={fail:0,uncertain:1,indeterminate:1,pass:2,na:3};
3326
- var rows=cvs.slice().sort(function(a,b){return (order[a.result]==null?9:order[a.result])-(order[b.result]==null?9:order[b.result]);}).map(function(v){
3327
- var res=v.result||'na';var disp=res==='uncertain'?'indeterminate':res;
3328
- var conf=(v.confidence!=null)?'<span class="cvconf">conf '+esc(v.confidence)+(v.confidenceBand?' · '+esc(v.confidenceBand):'')+'</span>':'';
3329
- var crit=v.critique?'<div class="cvcrit">'+esc(v.critique)+'</div>':'<div class="cvcrit dim">— no critique recorded for this criterion</div>';
3330
- return '<div class="cvrow '+esc(res)+'"><div class="cvh"><span class="cvb '+esc(res)+'">'+esc(disp)+'</span><span class="cvid">'+esc(v.criterionId||'')+'</span>'+conf+'</div>'+crit+cvRefs(v.refs)+'</div>';}).join('');
3331
- return '<div class="ctx cvblock"><div class="ctx-h">◇ how the judge reasoned — per-criterion verdict · critique-before-verdict · grounding refs ('+cvs.length+')</div><div class="cvbody">'+rows+'</div></div>';}
3332
- function sideBySide(d){var ctx=d.context||{};
3333
- // Gap A — the §2 "input + scenario" cell renders the RAW triggering INPUT (the
3334
- // thing that fired the agent) ABOVE the judge's scenario LABEL. Long inputs
3335
- // collapse into a <details> (lean, no JS) so the cell stays compact; short ones
3336
- // render inline (clamped). ABSENT input ⇒ the cell shows the scenario alone (or
3337
- // "—"). Font stays at the --fs-2xs (11px) floor + brand mono.
3338
- var inputCell=function(raw,scen){
3339
- var sc=scen?'<div class="iscn">scenario · '+esc(scen)+'</div>':'';
3340
- if(!raw)return (scen?'<div class="ival">'+esc(scen)+'</div>':'—');
3341
- var long=String(raw).length>180;
3342
- var body=long
3343
- ? '<details class="iexp"><summary>raw input · '+String(raw).length+' chars</summary><pre class="iraw">'+esc(raw)+'</pre></details>'
3344
- : '<pre class="iraw clamp">'+esc(raw)+'</pre>';
3345
- return body+sc;};
3346
- var refStr=function(rf){if(!rf)return '';if(typeof rf==='string')return rf;return [rf.obs,rf.path,rf.value].filter(Boolean).join(':');};
3347
- // UI-4 — surface the judge's REASONING (why the verdict + why the exit-states were
3348
- // concluded) from the EXISTING judge walk: an ordered why-chain band + the decide/bind
3349
- // text inlined where it explains a conclusion. No emit-contract change — read-only over
3350
- // the judge_steps already on the verdict file.
3351
- var KORD={gather:0,context:1,examine:2,detect:3,bind:4,ground:5,critique:6,decide:7,verify:8};
3352
- var allJs=(d.judgeSteps||[]).slice();
3353
- var stepText=function(kind){return allJs.filter(function(s){return s.kind===kind;}).map(function(s){return s.text||'';}).filter(Boolean).join(' · ');};
3354
- var decideWhy=stepText('decide')||stepText('critique');
3355
- var stateWhy=[stepText('bind'),stepText('gather')].filter(Boolean).join(' · ');
3356
- var chainSteps=allJs.slice().sort(function(a,b){var x=KORD[a.kind];var y=KORD[b.kind];return (x==null?9:x)-(y==null?9:y);});
3357
- var whyChain=chainSteps.length?('<div class="ctx whychain"><div class="ctx-h">◇ judge reasoning — full why-chain (gather → bind → ground → decide)</div><div class="wc-body">'+chainSteps.map(function(s){var rs=refStr(s.ref);return '<div class="jstep"><span class="k '+esc(s.kind)+'">'+esc(s.kind)+'</span><span class="t">'+esc(s.text||'')+(rs?' <span class=ref>'+esc(rs)+'</span>':'')+'</span></div>';}).join('')+'</div></div>'):'';
3358
- var verdictWhy=decideWhy?('<div class="band whyverdict"><div class="bh" style="color:var(--primary-soft)">◇ why this verdict</div><div class="wv-t">'+esc(decideWhy)+'</div></div>'):'';
3359
- // -- §2 judge lane = per-step EVAL COVERAGE --
3360
- // The judge lane (.step-r) used to filter judgeSteps by anchor === a.n -- but
3361
- // judges never emit an anchor, so EVERY step rendered a bare dash. Instead we
3362
- // map each agent step -> the per-criterion verdicts (d.criterionVerdicts) whose
3363
- // grounding refs EXAMINED that step: a precise ref.obs === step.obs match plus
3364
- // the tool-name fallback (ref.path === 'name' && ref.value === step.tool).
3365
- // Each examining criterion renders one compact entry -- result + CODE/JUDGE tag
3366
- // + criterionId + the judge reasoning (critique). A step no criterion references
3367
- // says 'not examined by any eval' (honest), never a bare dash. Any judge step
3368
- // that DOES carry a real anchor is still honored (future-proof).
3369
- var cvAll=(d.criterionVerdicts||[]);
3370
- var refExaminesStep=function(rf,a){
3371
- if(!rf||typeof rf==='string')return false;
3372
- if(a.obs&&rf.obs&&String(rf.obs)===String(a.obs))return true;
3373
- if(rf.path==='name'&&rf.value!=null&&a.tool&&String(rf.value)===String(a.tool))return true;
3374
- return false;};
3375
- var covEntry=function(v,a){
3376
- var res=v.result||'na';var disp=res==='uncertain'?'indeterminate':res;
3377
- // the router carries TWO vocabularies: matrix-derived ('deterministic') and
3378
- // mined ('code-based'); 'hybrid' is shared. A '[code-eval ...]'-prefixed critique
3379
- // is the deterministic-logic fallback when the method is unset.
3380
- var method=((typeof C!=='undefined'&&C[v.criterionId])||{}).m||'';
3381
- var isCode=method==='deterministic'||method==='code-based'||method==='hybrid'||String(v.critique||'').trim().indexOf('[code-eval')===0;
3382
- var tag=method==='hybrid'?'HYBRID':(isCode?'CODE':'JUDGE');
3383
- var matched=(v.refs||[]).filter(function(rf){return typeof rf!=='string'&&refExaminesStep(rf,a);});
3384
- var crit=v.critique?esc(v.critique):'<span class="dim">— no critique recorded</span>';
3385
- return '<div class="jcov '+esc(res)+'"><div class="jcov-h"><span class="cvb '+esc(res)+'">'+esc(disp)+'</span><span class="jm '+(isCode?'code':'judge')+'" title="'+(isCode?'deterministically checked by a code-eval (not LLM-judged)':'reasoned by the LLM judge')+'">'+esc(tag)+'</span><span class="jcid">'+esc(v.criterionId||'')+'</span></div><div class="jcrit">'+crit+'</div>'+cvRefs(matched)+'</div>';};
3386
- var rowsHtml='';(d.agentSteps||[]).forEach(function(a){
3387
- // future-proof: a judge step that carries a REAL anchor still renders.
3388
- var js=(d.judgeSteps||[]).filter(function(s){return s.anchor!=null&&String(s.anchor)===String(a.n)&&s.kind!=='context';});
3389
- var anchoredHtml=js.map(function(s){var rs=refStr(s.ref);return '<div class="jstep"><span class="k '+esc(s.kind)+'">'+esc(s.kind)+'</span><span class="t">'+esc(s.text||'')+' '+(rs?'<span class=ref>'+esc(rs)+'</span>':'')+'</span></div>';}).join('');
3390
- var examiners=cvAll.filter(function(v){return (v.refs||[]).some(function(rf){return refExaminesStep(rf,a);});});
3391
- var covHtml=examiners.map(function(v){return covEntry(v,a);}).join('');
3392
- var jhtml=anchoredHtml+covHtml;
3393
- if(!jhtml)jhtml='<div class="jstep noexam"><span class="t">— not examined by any eval</span></div>';
3394
- rowsHtml+='<div class="step-l"><div class="evb"><div class="top"><span class="tool">'+esc(a.tool||'')+'</span><span class="st '+esc(a.status||'')+'">'+esc(a.status||'')+'</span></div><div class="det">'+esc(a.detail||'')+'</div></div></div><div class="node '+esc(a.status||'')+'"><div class="ln"></div><div class="n">'+esc(a.n)+'</div><div class="ln"></div></div><div class="step-r"><div class="evb r">'+jhtml+'</div></div>';});
3395
- var h=d.health||{};
3396
- var sp=d.subjectProfile;
3397
- var spHtml=sp?'<div class="ctx"><div class="ctx-h">◇ judge · subject profile (M1) · '+esc(sp.provenance||'')+'</div><div class="ctx-g"><div class="ctx-c"><div class="l">identity</div><div class="v">'+esc(sp.identity||'—')+'</div></div><div class="ctx-c"><div class="l">purpose</div><div class="v">'+esc(sp.purpose||'—')+'</div></div><div class="ctx-c"><div class="l">scope · harness</div><div class="v">'+esc((sp.scope||'—'))+' · harness: '+esc(sp.harness||'—')+'</div></div></div></div>':'';
3398
- var u=d.understanding;
3399
- var uHtml=u?'<div class="band"><div class="bh" style="color:var(--cyan)">◇ node-0 GATHER — understanding (M2)</div><div class="re-rephrase" style="margin-top:4px">“'+esc(u.rephrase||'')+'”</div></div>':'';
3400
- var et=d.expectedTrajectory||[];
3401
- var etHtml=et.length?'<div class="band"><div class="bh" style="color:var(--primary-soft)">◇ node-0.5 EXPECTED-TRAJECTORY (M3) — how it SHOULD have acted</div><ol class="re-ul" style="margin-top:4px">'+et.map(function(s,i){return '<li><b>'+esc(s.step||i+1)+'.</b> '+esc(s.expected||'')+(s.rationale?' <span class=re-rat>— '+esc(s.rationale)+'</span>':'')+'</li>';}).join('')+'</ol></div>':'';
3402
- return '<div class="drillbox"><div style="display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-bottom:6px"><b class="mono">'+esc(d.traceId)+'</b><span class="chip">'+esc(d.route||'all')+'</span><span class="verd '+(d.verdict=='FAIL'?'fail':d.verdict=='PASS'?'pass':'inc')+'">'+esc(d.verdict)+'</span></div>'+
3403
- resRouting(d.res||'judge-walk')+
3404
- spHtml+
3405
- verdictWhy+
3406
- critiqueBlock(d.criterionVerdicts)+
3407
- '<div class="ctx"><div class="ctx-h">◇ judge · gather context</div><div class="ctx-g"><div class="ctx-c"><div class="l">harness</div><div class="v">'+esc(ctx.harness||'—')+'</div></div><div class="ctx-c"><div class="l">input + scenario</div><div class="v">'+inputCell(d.input,ctx.scenario)+'</div></div><div class="ctx-c"><div class="l">exit states</div><div class="v">'+esc(ctx.exitStates||'—')+(stateWhy?'<div class="why-note"><span class="ref">why concluded:</span> '+esc(stateWhy)+'</div>':'')+'</div></div></div></div>'+
3408
- whyChain+
3409
- uHtml+etHtml+
3410
- '<div class="lanehdr"><div class="a">target agent — what it did</div><div class="x">step</div><div class="j">judge — eval coverage (which criteria examined this step)</div></div>'+
3411
- '<div class="grid2">'+rowsHtml+'<div class="band loc"><div class="bh">↯ localize (root, not symptom)</div><div style="font-size:var(--fs-sm);margin-top:4px">'+esc(d.localize||'—')+'</div></div></div>'+
3412
- '<div class="health"><div class="hc"><div class="l">context</div><div class="v good">'+(h.contextGathered?'✓':'—')+'</div></div><div class="hc"><div class="l">grounded</div><div class="v good">'+esc(h.grounded||0)+'</div></div><div class="hc"><div class="l">assumed</div><div class="v '+((h.assumed||0)>0?'warn':'good')+'">'+esc(h.assumed||0)+'</div></div><div class="hc"><div class="l">root vs symptom</div><div class="v good">'+(h.stoppedAtSymptom?'symptom':'✓ root')+'</div></div></div></div>';}
3330
+ ${SIDE_BY_SIDE_FRAGMENT_JS}
3413
3331
  function scorecard(r){var order={fail:0,uncertain:1,indeterminate:1,pass:2,na:3};
3414
3332
  var cells=Object.keys(r.c).sort(function(a,b){return (order[r.c[a]]||9)-(order[r.c[b]]||9);}).map(function(k){var v=r.c[k];var disp=v=='uncertain'?'indeterminate':v;
3415
3333
  return '<div class="scc '+esc(v)+'"><span class="nm" title="'+esc((C[k]||{}).n||k)+'">'+esc(k)+'</span><span style="margin-left:auto;color:var(--muted)">'+esc(disp)+'</span></div>';}).join('');