@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
@@ -4,7 +4,7 @@
4
4
  * A JUDGE mode that scores a CODE subject's QUALITY (the amended/scaffolded
5
5
  * implementation of a `code`-kind subject) and emits a binary PASS/FAIL quality
6
6
  * verdict with a critique-before-verdict per criterion. It is the (b) half of the
7
- * ⑤ IMPROVE loop's code-target BOTH-gate: "converged" for a `code` subject = the
7
+ * ⑤ OPTIMIZE loop's code-target BOTH-gate: "converged" for a `code` subject = the
8
8
  * subject's OWN test suite green (a deterministic gate the session records as
9
9
  * `testsGreen`) AND this code-quality verdict PASSes. Neither alone converges.
10
10
  *
@@ -28,6 +28,9 @@
28
28
  * - PURE renderer + a thin injected-seam run wrapper. No clock, no random, no
29
29
  * network; the LLM call is the injected `JudgeInvoke` seam so tests drive it.
30
30
  */
31
+ import { readFileSync } from "node:fs";
32
+ import { fileURLToPath, URL } from "node:url";
33
+ import { parse as parseYaml } from "yaml";
31
34
  import { parseCritiqueVerdict, type JudgeInvoke } from "./determine-outcome.ts";
32
35
  import type { JudgePin } from "./build-evals.ts";
33
36
  import type { CritiqueVerdict } from "./contracts/eval-types.ts";
@@ -56,76 +59,65 @@ export interface CodeQualityCriterion {
56
59
  }
57
60
 
58
61
  /**
59
- * ⚠️ FLAGGED-FOR-OPERATOR-SIGN-OFF (Wave-2 W2I1). This is the DRAFT default set of
60
- * acceptance criteria that define "code quality-pass" BEYOND tests-green. It is
61
- * implemented behind this clearly-named default so the operator can confirm/adjust
62
- * the criteria + their severities WITHOUT a code change to the loop. The picks
63
- * below are a conservative, subject-agnostic starting point — NOT a final contract.
62
+ * ⚠️ FLAGGED-FOR-OPERATOR-SIGN-OFF (Wave-2 W2I1). The DRAFT default set of acceptance
63
+ * criteria that define "code quality-pass" BEYOND tests-green.
64
64
  *
65
- * The five drafted criteria (each BINARY, critique-before-verdict):
66
- * Q1 spec-faithful (critical) — the impl realizes the agentspec DEFINITION the
67
- * amend targeted; no dropped tool / JTBD / silently
68
- * altered system_prompt (def→impl cascade held).
69
- * Q2 addresses-diagnosis (high) — the change actually targets the diagnosed
70
- * root-cause locus, not an adjacent symptom.
71
- * Q3 no-regression (high) — no unrelated behavior removed/broken; scope stayed
72
- * on what the remedy named (scope discipline).
73
- * Q4 error-handling (high) — failure paths are handled, not silently dropped
74
- * (recovery/observability preserved).
75
- * Q5 maintainable (medium) — readable, framework-idiomatic, no dead code /
76
- * copy-paste / obvious footgun introduced.
65
+ * The rubric itself now lives in `../assets/code-quality-criteria.yaml` — a versioned,
66
+ * operator-editable ARTIFACT so the criteria + severities can be tuned WITHOUT a code
67
+ * change (FU-61.1). This module only LOADS and VALIDATES it; the shape/aggregation stay
68
+ * in code. Ids/statements/severities are unchanged from the original inline set.
77
69
  *
78
- * Aggregation (also FLAGGED): the gate is severity-gated — a `fail` OR `uncertain`
79
- * on any CRITICAL/HIGH criterion blocks the quality-pass; MEDIUM is advisory. The
80
- * operator may instead choose strict-all-pass or a different severity map.
70
+ * Aggregation (also FLAGGED, still in code): severity-gated — a `fail` OR `uncertain`
71
+ * on any CRITICAL/HIGH criterion blocks the quality-pass; MEDIUM is advisory.
72
+ *
73
+ * Fail-loud: a malformed/missing rubric THROWS at import. There is no silent fallback —
74
+ * a broken rubric must never quietly degrade the gate.
81
75
  */
82
- export const DEFAULT_CODE_QUALITY_CRITERIA: readonly CodeQualityCriterion[] = [
83
- {
84
- id: "Q1-spec-faithful",
85
- statement: "The implementation realizes the agentspec definition the amend targeted.",
86
- passDefinition:
87
- "PASS: every spec-declared tool / job-to-be-done is present and the operative system_prompt is unchanged except where the amend intended; the def→impl cascade held.",
88
- failDefinition:
89
- "FAIL: a spec-declared tool or JTBD is missing, or the system_prompt was altered beyond the amend intent (the impl forked from the SSoT).",
90
- severity: CodeQualitySeverity.Critical,
91
- },
92
- {
93
- id: "Q2-addresses-diagnosis",
94
- statement: "The change targets the diagnosed root-cause locus, not an adjacent symptom.",
95
- passDefinition:
96
- "PASS: the edit is at (or directly causes a fix at) the RCA-named locus / the failing-case root.",
97
- failDefinition:
98
- "FAIL: the change edits an unrelated place or patches a symptom while the root cause remains.",
99
- severity: CodeQualitySeverity.High,
100
- },
101
- {
102
- id: "Q3-no-regression",
103
- statement: "The change introduces no unrelated regression and stays in scope.",
104
- passDefinition:
105
- "PASS: only the remedy-named surface changed; no unrelated behavior was removed or broken.",
106
- failDefinition:
107
- "FAIL: unrelated code was changed/removed, or a previously-working path was broken.",
108
- severity: CodeQualitySeverity.High,
109
- },
110
- {
111
- id: "Q4-error-handling",
112
- statement: "Failure paths are handled, not silently dropped.",
113
- passDefinition:
114
- "PASS: error/failure branches surface, recover, or log per the subject's contract; no new silent drop.",
115
- failDefinition:
116
- "FAIL: a failure is swallowed with no recovery/notify/observability (a silent-drop regression).",
117
- severity: CodeQualitySeverity.High,
118
- },
119
- {
120
- id: "Q5-maintainable",
121
- statement: "The code is readable, framework-idiomatic, and free of obvious footguns.",
122
- passDefinition:
123
- "PASS: clear structure, idiomatic to the target framework, no dead code / copy-paste / obvious footgun introduced.",
124
- failDefinition:
125
- "FAIL: unreadable/duplicated/dead code or an obvious maintainability footgun was introduced.",
126
- severity: CodeQualitySeverity.Medium,
127
- },
128
- ] as const;
76
+ const CODE_QUALITY_CRITERIA_PATH = fileURLToPath(
77
+ new URL("../assets/code-quality-criteria.yaml", import.meta.url),
78
+ );
79
+
80
+ const VALID_SEVERITIES: readonly string[] = Object.values(CodeQualitySeverity);
81
+
82
+ /** Parse + validate the rubric artifact. PURE given the file; THROWS on any defect. */
83
+ export function parseCodeQualityCriteria(raw: string, source = CODE_QUALITY_CRITERIA_PATH): readonly CodeQualityCriterion[] {
84
+ // NOTE: a function DECLARATION (not a const arrow) so TS narrows through the
85
+ // never-returning calls below — control-flow analysis requires this form.
86
+ function bad(msg: string): never {
87
+ throw new Error(`code-quality rubric invalid (${source}): ${msg}`);
88
+ }
89
+ const doc = parseYaml(raw) as { version?: unknown; criteria?: unknown } | null;
90
+ if (doc === null || typeof doc !== "object") bad("not a YAML mapping");
91
+ if (typeof doc.version !== "number") bad("`version` must be a number");
92
+ if (!Array.isArray(doc.criteria) || doc.criteria.length === 0) bad("`criteria` must be a non-empty list");
93
+
94
+ const seen = new Set<string>();
95
+ const out = (doc.criteria as unknown[]).map((c, i) => {
96
+ const at = `criteria[${i}]`;
97
+ if (c === null || typeof c !== "object") return bad(`${at} is not a mapping`);
98
+ const r = c as Record<string, unknown>;
99
+ for (const k of ["id", "statement", "passDefinition", "failDefinition", "severity"]) {
100
+ if (typeof r[k] !== "string" || (r[k] as string).trim() === "") bad(`${at}.${k} must be a non-empty string`);
101
+ }
102
+ const id = r.id as string;
103
+ if (seen.has(id)) bad(`${at}.id "${id}" is duplicated`);
104
+ seen.add(id);
105
+ if (!VALID_SEVERITIES.includes(r.severity as string)) {
106
+ bad(`${at}.severity "${String(r.severity)}" must be one of ${VALID_SEVERITIES.join(" | ")}`);
107
+ }
108
+ return {
109
+ id,
110
+ statement: r.statement as string,
111
+ passDefinition: r.passDefinition as string,
112
+ failDefinition: r.failDefinition as string,
113
+ severity: r.severity as CodeQualitySeverityValue,
114
+ } satisfies CodeQualityCriterion;
115
+ });
116
+ return Object.freeze(out);
117
+ }
118
+
119
+ export const DEFAULT_CODE_QUALITY_CRITERIA: readonly CodeQualityCriterion[] =
120
+ parseCodeQualityCriteria(readFileSync(CODE_QUALITY_CRITERIA_PATH, "utf8"));
129
121
 
130
122
  /** The code subject under quality review (minimal, subject-agnostic). */
131
123
  export interface CodeQualitySubject {
@@ -235,9 +235,9 @@ export type GlobalSource = Static<typeof GlobalSourceSchema>;
235
235
 
236
236
  /**
237
237
  * A precise link to an Agent / Tooling definition file in a code target.
238
- * RESERVED — consumed by Build/Improve (future). The evaluator is JUDGE-ONLY (EV-051)
238
+ * RESERVED — consumed by Build/Optimize (future). The evaluator is JUDGE-ONLY (EV-051)
239
239
  * and never writes a target; `targets[].code_refs` is ported for shape parity only and
240
- * has NO evaluator consumer today. PURPOSE (future): tells BUILD/IMPROVE which files
240
+ * has NO evaluator consumer today. PURPOSE (future): tells BUILD/OPTIMIZE which files
241
241
  * realize the agent so a fix lands on the right def.
242
242
  */
243
243
  export const CodeRefSchema = Type.Object(
@@ -253,7 +253,7 @@ export type CodeRef = Static<typeof CodeRefSchema>;
253
253
  * HOW a fix is applied for a target. `kind` gates the report-only branch.
254
254
  * CONSUMER: the diagnostics APPLY path (not the evaluator — judge-only). PURPOSE:
255
255
  * selects the write mechanism for a fix.
256
- * versioning / pr — RESERVED, consumed by Build/Improve (future). No evaluator
256
+ * versioning / pr — RESERVED, consumed by Build/Optimize (future). No evaluator
257
257
  * consumer; ported for parity. PURPOSE (future): toggle spec-bump /
258
258
  * open-a-PR on apply.
259
259
  */
@@ -184,6 +184,41 @@ export type SubjectProfileProvenanceValue =
184
184
  * harness/skill marked UNKNOWN, never confabulated). */
185
185
  export const PROFILE_UNKNOWN = "unknown" as const;
186
186
 
187
+ // ── EV-5 — tool reversibility (does a tool take an IRREVERSIBLE EXTERNAL action?) ─
188
+ //
189
+ // A routing failure that only READS is materially less severe than one that also
190
+ // sends an email or mutates a live system — but nothing surfaced "this session could
191
+ // take irreversible external actions." This classifies each subject tool so the judge
192
+ // preamble can flag it. HONEST: `unknown` when the tool's effect can't be established
193
+ // (never confabulated), never coerced to a false "reversible".
194
+ export const ToolReversibility = {
195
+ /** a pure/no-op tool (no external effect). */
196
+ None: "none",
197
+ /** an external action that can be undone / re-run safely (a read, an idempotent write). */
198
+ Reversible: "reversible",
199
+ /** an external action that CANNOT be undone (send/email/charge/delete/deploy/post). */
200
+ IrreversibleExternal: "irreversible-external",
201
+ /** the tool's effect can't be established from the inputs — flagged, never guessed. */
202
+ Unknown: "unknown",
203
+ } as const;
204
+ export type ToolReversibilityValue =
205
+ (typeof ToolReversibility)[keyof typeof ToolReversibility];
206
+
207
+ /** One tool's reversibility classification (EV-5). */
208
+ export const ToolReversibilitySchema = Type.Object(
209
+ {
210
+ name: Type.String({ minLength: 1 }),
211
+ reversibility: Type.Union([
212
+ Type.Literal(ToolReversibility.None),
213
+ Type.Literal(ToolReversibility.Reversible),
214
+ Type.Literal(ToolReversibility.IrreversibleExternal),
215
+ Type.Literal(ToolReversibility.Unknown),
216
+ ]),
217
+ },
218
+ { additionalProperties: false },
219
+ );
220
+ export type ToolReversibilityEntry = Static<typeof ToolReversibilitySchema>;
221
+
187
222
  export const SubjectProfileSchema = Type.Object(
188
223
  {
189
224
  /** what the agent IS (name / role identity). */
@@ -195,6 +230,10 @@ export const SubjectProfileSchema = Type.Object(
195
230
  purpose: Type.String({ minLength: 1 }),
196
231
  /** the tools the agent wields (GIVEN inventory or reconstructed from the trace batch). */
197
232
  tools: Type.Array(Type.String({ minLength: 1 })),
233
+ /** EV-5 — per-tool reversibility (none · reversible · irreversible-external · unknown)
234
+ * so the judge sees which tools take IRREVERSIBLE EXTERNAL actions BEFORE it weighs a
235
+ * failure. ADDITIVE/OPTIONAL: absent ⇒ the judge reads the bare tool list. */
236
+ toolReversibility: Type.Optional(Type.Array(ToolReversibilitySchema)),
198
237
  /** the skill the agent runs under, when known (`unknown` ≠ confabulated). */
199
238
  skill: Type.Optional(Type.String()),
200
239
  /** the agent's responsibility boundary / scope. */
@@ -39,6 +39,14 @@ import {
39
39
  export type JudgeInvoke = (
40
40
  systemPrompt: string,
41
41
  userPrompt: string,
42
+ /**
43
+ * INF-3 — an OPTIONAL session/trace id the DETERMINER passes so its verdict is
44
+ * keyed per-trace: two sessions with an identical (e.g. empty) determiner prompt
45
+ * never collapse onto one verdict file. Absent for the per-criterion judges (their
46
+ * prompts are already distinct; keying salt-free keeps their hashes byte-identical,
47
+ * C-PIN). Both agent-dispatch seams forward it to `verdictFileName(system,user,keyId)`.
48
+ */
49
+ keyId?: string,
42
50
  ) => Promise<string>;
43
51
 
44
52
  // ── Event classification (the intended-goal half of the read) ───────────────
@@ -149,7 +149,7 @@ export interface EddLoopDecision {
149
149
  * 1. FULL GREEN — varianceStable AND accuracyMet ⇒ DONE (success).
150
150
  * 2. MAX SWINGS — swing ≥ budget.maxSwings ⇒ STOP (report delta).
151
151
  * 3. MAX WALLCLOCK— elapsedMs ≥ budget.maxWallclockMs ⇒ STOP.
152
- * 4. NO-IMPROVE — noImprovementStreak ≥ budget.noImprovementStreakLimit ⇒ STOP.
152
+ * 4. NO-OPTIMIZE — noImprovementStreak ≥ budget.noImprovementStreakLimit ⇒ STOP.
153
153
  * else CONTINUE — keep swinging; the next phase is variance until it's stable,
154
154
  * then accuracy (F19 ordering is preserved by `nextPhaseAfterVariance`, but the
155
155
  * loop controller mirrors it: not-stable ⇒ variance, stable ⇒ accuracy).
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * scripts/edd/edd-types.ts
3
3
  * ---------------------------------------------------------------------------
4
- * Shared categorical constants + TypeBox contracts for the ADL IMPROVE stage —
4
+ * Shared categorical constants + TypeBox contracts for the ADL OPTIMIZE stage —
5
5
  * the Eval-Driven-Development (EDD) loop (F18 closure + F19 variance-first).
6
6
  *
7
- * EDD is the ③ IMPROVE stage of the ADL: after the initial *build, the loop
7
+ * EDD is the ③ OPTIMIZE stage of the ADL: after the initial *build, the loop
8
8
  * drives spec ↔ impl ↔ eval to full green. Two findings live here:
9
9
  *
10
10
  * F19 — VARIANCE-FIRST. The FIRST focus after the build is eliminating per-case
@@ -48,20 +48,94 @@ function promptOf(trace: EvalTrace): string {
48
48
 
49
49
  // ── Determiner (EV-042) prompt — MIRRORS assets/agents/error-analyst.md ──────
50
50
 
51
+ /**
52
+ * EV-1 — the compact SUBJECT FRAME the determiner reads so it derives THIS subject's
53
+ * expected outcome (identity · purpose · tools + reversibility), instead of judging
54
+ * against a hardcoded email-agent signal block. Trajectory-LIGHT by design — it never
55
+ * carries the full step-by-step trajectory (that is the per-criterion judge's job).
56
+ */
57
+ export interface SubjectFrame {
58
+ identity: string;
59
+ purpose: string;
60
+ tools: string[];
61
+ toolReversibility?: { name: string; reversibility: string }[];
62
+ }
63
+
64
+ /** The distinct tool names actually invoked in a trace, in first-seen order (compact summary). */
65
+ function keyToolsUsed(trace: EvalTrace, limit = 8): string[] {
66
+ const seen: string[] = [];
67
+ for (const o of trace.observations) {
68
+ if (o.type === "TOOL" && typeof o.name === "string" && !seen.includes(o.name)) {
69
+ seen.push(o.name);
70
+ if (seen.length >= limit) break;
71
+ }
72
+ }
73
+ return seen;
74
+ }
75
+
76
+ /** Render the subject frame block for the determiner (EV-1). Trajectory-light. */
77
+ function subjectFrameBlock(subject: SubjectFrame | undefined): string[] {
78
+ if (subject === undefined) {
79
+ return [
80
+ "SUBJECT: not supplied — RECONSTRUCT what the agent is from the input event + the",
81
+ "tools it called, and derive the expected outcome from that. Do NOT assume any",
82
+ "particular domain (e.g. email).",
83
+ "",
84
+ ];
85
+ }
86
+ const rev = new Map((subject.toolReversibility ?? []).map((t) => [t.name, t.reversibility]));
87
+ const toolLine =
88
+ subject.tools.length > 0
89
+ ? subject.tools
90
+ .map((n) => {
91
+ const r = rev.get(n);
92
+ return r !== undefined && r !== "unknown" ? `${n} [${r}]` : n;
93
+ })
94
+ .join(", ")
95
+ : "(none)";
96
+ const irreversible = (subject.toolReversibility ?? [])
97
+ .filter((t) => t.reversibility === "irreversible-external")
98
+ .map((t) => t.name);
99
+ return [
100
+ "SUBJECT (derive the EXPECTED outcome for THIS subject before you judge):",
101
+ ` identity: ${subject.identity}`,
102
+ ` purpose: ${subject.purpose}`,
103
+ ` tools: ${toolLine}`,
104
+ ...(irreversible.length > 0
105
+ ? [` ⚠ irreversible-external tools: ${irreversible.join(", ")} — a failure that also fired one is materially worse.`]
106
+ : []),
107
+ "",
108
+ ];
109
+ }
110
+
51
111
  /**
52
112
  * Render the determiner judge prompt for the in-house/export substrate. The
53
113
  * AUTHORITATIVE "inaction can be success" rubric is `error-analyst.md`; this is
54
114
  * its provider-callable mirror. No decision is made here.
115
+ *
116
+ * EV-1 — subject-aware: given a `subject` frame the determiner derives the expected
117
+ * outcome from the subject's identity/purpose/tools (not a fixed email-agent signal
118
+ * block), and reads a COMPACT outcome summary (event · did-it-act · key tools ·
119
+ * terminal state) — deliberately NOT the full trajectory (the per-criterion judge's
120
+ * job). The domain-specific signals (send / guard / recovery) render ONLY when the
121
+ * subject vocab actually defines them, so a non-email subject no longer sees dead lines.
55
122
  */
56
123
  export function buildOutcomePrompt(
57
124
  trace: EvalTrace,
58
125
  signals: OutcomeSignals,
59
126
  vocab: SubjectVocab,
127
+ subject?: SubjectFrame,
60
128
  ): { system: string; user: string } {
61
129
  const system = [
62
130
  "You are a success/failure determiner for an autonomous agent session.",
63
131
  "Decide whether the session REACHED THE GOAL implied by its input event.",
64
132
  "",
133
+ "Derive the EXPECTED outcome from the SUBJECT (its identity · purpose · tools) +",
134
+ "the input event — do NOT assume any particular domain (e.g. email). This is a",
135
+ "FAST input→output outcome check: read the goal (input), the result (output/self-",
136
+ "summary) and the compact summary below — NOT the full step-by-step trajectory",
137
+ "(that is the per-criterion judge's job).",
138
+ "",
65
139
  "CRITICAL RULE — inaction can be success. Holding (sending nothing, calling",
66
140
  "no tool) is the CORRECT outcome when the event is a restraint directive",
67
141
  "(e.g. a guard/hold directive) or when acting would be wrong. You MUST NOT",
@@ -74,23 +148,39 @@ export function buildOutcomePrompt(
74
148
  "Reason first in `critique`, then commit to `result`.",
75
149
  ].join("\n");
76
150
 
77
- // Generic guard-counter label the attribute NAME comes from the subject vocab.
78
- const guardLabel = vocab.guardCounterAttr ?? "guard counter";
151
+ const responseText = typeof trace.output?.response === "string" ? trace.output.response : "";
152
+ const keyTools = keyToolsUsed(trace);
153
+
154
+ // EV-1 — domain signals render ONLY when the subject vocab defines them (else they
155
+ // were email-agent noise for every subject). honest-null preserved.
156
+ const conditionalSignals: string[] = [];
157
+ if (vocab.guardCounterAttr !== null) {
158
+ conditionalSignals.push(` Guard ${vocab.guardCounterAttr}: ${signals.guardConsecutive ?? "n/a"}`);
159
+ }
160
+ if (vocab.sendTool.length > 0) {
161
+ conditionalSignals.push(
162
+ ` Sent a message: ${signals.sentMessage === null ? "unknown" : signals.sentMessage}`,
163
+ ` Send succeeded: ${signals.sendSucceeded ?? "n/a (no send)"}`,
164
+ );
165
+ }
166
+ if (vocab.recoveryTools.length > 0) {
167
+ conditionalSignals.push(` Recovery tool present: ${signals.recoveryPresent}`);
168
+ }
169
+
79
170
  const user = [
80
- `Event kind: ${signals.eventKind}`,
81
- `Guard ${guardLabel}: ${signals.guardConsecutive ?? "n/a"}`,
82
- `Tools called: ${signals.toolCount}`,
83
- // EV-049 honest-null: render UNKNOWN as "unknown" (the subject's send tool is
84
- // unset/uninferred) never coerce it to "false", which would lie to the judge.
85
- `Sent a message: ${signals.sentMessage === null ? "unknown" : signals.sentMessage}`,
86
- `Send succeeded: ${signals.sendSucceeded ?? "n/a (no send)"}`,
87
- `Recovery tool present: ${signals.recoveryPresent}`,
171
+ ...subjectFrameBlock(subject),
172
+ "OUTCOME SUMMARY (compact NOT the full trajectory):",
173
+ ` Event kind: ${signals.eventKind}`,
174
+ ` Tools called: ${signals.toolCount}${keyTools.length > 0 ? ` · key tools: ${keyTools.join(", ")}` : ""}`,
175
+ ` Did the agent act: ${signals.toolCount > 0 ? "yes (called tools)" : "no (no tool calls)"}`,
176
+ ` Terminal state: ${responseText.length > 0 ? "produced a self-summary/response" : "no self-summary"}`,
177
+ ...conditionalSignals,
88
178
  "",
89
179
  "Input event prompt:",
90
180
  promptOf(trace),
91
181
  "",
92
182
  "Agent self-summary (output.response):",
93
- typeof trace.output?.response === "string" ? trace.output.response : "(none)",
183
+ responseText.length > 0 ? responseText : "(none)",
94
184
  ].join("\n");
95
185
 
96
186
  return { system, user };
@@ -107,10 +197,13 @@ export async function determineOutcome(
107
197
  trace: EvalTrace,
108
198
  judge: JudgeInvoke,
109
199
  vocab: SubjectVocab,
200
+ subject?: SubjectFrame,
110
201
  ): Promise<OutcomeResult> {
111
202
  const signals = extractOutcomeSignals(trace, vocab);
112
- const { system, user } = buildOutcomePrompt(trace, signals, vocab);
113
- const raw = await judge(system, user);
203
+ const { system, user } = buildOutcomePrompt(trace, signals, vocab, subject);
204
+ // INF-3 pass the trace id so the determiner verdict is keyed PER-TRACE (two
205
+ // sessions with an identical determiner prompt never share one verdict).
206
+ const raw = await judge(system, user, trace.id);
114
207
  const verdict = parseCritiqueVerdict(raw);
115
208
  return {
116
209
  traceId: trace.id,
@@ -153,7 +246,8 @@ function profilePreamble(profile?: SubjectProfile): string[] {
153
246
  ` identity: ${profile.identity}`,
154
247
  ` purpose: ${profile.purpose}`,
155
248
  ` scope: ${profile.scope}`,
156
- ` tools: ${(profile.tools ?? []).join(", ") || "(none observed)"}`,
249
+ ` tools: ${renderToolLine(profile)}`,
250
+ ...reversibilityCallout(profile),
157
251
  profile.skill !== undefined ? ` skill: ${profile.skill}` : "",
158
252
  ` harness: ${profile.harness}`,
159
253
  ` provenance: ${profile.provenance}${profile.version !== undefined ? ` · version ${profile.version}` : ""}`,
@@ -161,6 +255,36 @@ function profilePreamble(profile?: SubjectProfile): string[] {
161
255
  ].filter((l) => l !== "");
162
256
  }
163
257
 
258
+ /** Render the tools line, annotating each tool with its EV-5 reversibility when known. */
259
+ function renderToolLine(profile: SubjectProfile): string {
260
+ const tools = profile.tools ?? [];
261
+ if (tools.length === 0) return "(none observed)";
262
+ const rev = new Map((profile.toolReversibility ?? []).map((t) => [t.name, t.reversibility]));
263
+ return tools
264
+ .map((name) => {
265
+ const r = rev.get(name);
266
+ return r !== undefined && r !== "unknown" ? `${name} [${r}]` : name;
267
+ })
268
+ .join(", ");
269
+ }
270
+
271
+ /**
272
+ * EV-5 — a one-line callout naming the tools that take IRREVERSIBLE EXTERNAL actions,
273
+ * so a failure that ALSO fired one weighs heavier than a read-only failure. Absent
274
+ * reversibility data ⇒ no callout (the judge still reads the bare tool list).
275
+ */
276
+ function reversibilityCallout(profile: SubjectProfile): string[] {
277
+ const irreversible = (profile.toolReversibility ?? [])
278
+ .filter((t) => t.reversibility === "irreversible-external")
279
+ .map((t) => t.name);
280
+ if (irreversible.length === 0) return [];
281
+ return [
282
+ ` ⚠ irreversible-external tools: ${irreversible.join(", ")} — a failure that ALSO`,
283
+ " fired one of these (an email sent, a live system mutated) is MATERIALLY WORSE",
284
+ " than a read-only failure; weigh that in your critique.",
285
+ ];
286
+ }
287
+
164
288
  /**
165
289
  * Render the 4-component per-criterion judge prompt for the in-house/export
166
290
  * substrate. The AUTHORITATIVE 4-component / binary / critique-before-verdict
@@ -227,6 +351,11 @@ export async function runJudge(
227
351
  subjectTrace: EvalTrace,
228
352
  judge: JudgeInvoke,
229
353
  pin: JudgePin,
354
+ // EV-5 CUT WIRE 1 — the M1 subject profile the judge reads in its preamble (tools +
355
+ // reversibility). Pre-fix runJudge called buildJudgePrompt WITHOUT it, so the export
356
+ // path always hit the "profile not supplied — reconstruct" branch and never saw the
357
+ // reversibility flags. The pipeline now builds it once (buildSubjectProfile) and passes it.
358
+ subjectProfile?: SubjectProfile,
230
359
  ): Promise<CriterionVerdict> {
231
360
  if (typeof pin.modelId !== "string" || pin.modelId.length === 0) {
232
361
  throw new Error(
@@ -240,7 +369,7 @@ export async function runJudge(
240
369
  "MODEL INTENT IS SACRED: reruns must be byte-identical (C-PIN); refusing.",
241
370
  );
242
371
  }
243
- const { system, user } = buildJudgePrompt(spec, subjectTrace);
372
+ const { system, user } = buildJudgePrompt(spec, subjectTrace, subjectProfile);
244
373
  const raw = await judge(system, user);
245
374
  const verdict = parseCritiqueVerdict(raw);
246
375
  return {
@@ -37,7 +37,7 @@ export const Lifecycle = {
37
37
  Build: "build",
38
38
  Evaluate: "evaluate",
39
39
  Diagnose: "diagnose",
40
- Improve: "improve",
40
+ Optimize: "optimize",
41
41
  General: "general",
42
42
  } as const;
43
43
  export type LifecycleValue = (typeof Lifecycle)[keyof typeof Lifecycle];