@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.
Files changed (55) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
  2. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
  4. package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
  5. package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
  6. package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
  7. package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
  8. package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
  9. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
  10. package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
  11. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
  12. package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
  13. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
  14. package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
  15. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  16. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  17. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
  18. package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
  19. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  20. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  21. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  22. package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
  23. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
  24. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  25. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
  26. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
  27. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
  28. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  29. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  30. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  31. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  32. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
  33. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
  34. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  35. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
  37. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  38. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  39. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  40. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  41. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
  42. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
  43. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
  44. package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
  45. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  46. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
  47. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
  48. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  49. package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
  50. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  51. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
  52. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  53. package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
  54. package/bin/mutagent-cli.mjs +9 -5
  55. package/package.json +2 -2
@@ -0,0 +1,266 @@
1
+ /**
2
+ * run-review — the `*review` RUN COMPOSER.
3
+ *
4
+ * WHAT THIS CLOSES. `render-review-report-v3.ts` could render the W4 review surface,
5
+ * but nothing ASSEMBLED its input: producing the report meant hand-building a
6
+ * `RenderReviewV3Input` in a session driver. This module reads an existing run
7
+ * directory — the artifacts `*evaluate` already persists, no new ones — and produces
8
+ * `review-report.html` beside `evaluation-report.html`. After this, the SYSTEM
9
+ * produces the review report; a human no longer does.
10
+ *
11
+ * It is the analogue of run-evaluate's `writeRunReport` and the discovery composer,
12
+ * and it reuses their conventions: the same run/report directory layout, the same
13
+ * strict verdict-file parser, the same "v2 output kept alongside" transition rule.
14
+ *
15
+ * ── WHAT IT READS (all pre-existing) ──────────────────────────────────────────
16
+ * <runDir>/run-input.full.json subject · criteria · pin · producedAt, and the
17
+ * INGESTED trajectories (with their observations,
18
+ * which give the drill's left lane real tool I/O)
19
+ * <runDir>/run-input.json the same minus the heavy arrays — the fallback,
20
+ * used only when the full file is absent
21
+ * <runDir>/verdicts/*.verdict.json one judged trajectory each
22
+ *
23
+ * `*.verify.json` files live in the same directory and are NOT verdicts (they are
24
+ * the independent-verify ledger); they are excluded by suffix, not by guesswork.
25
+ *
26
+ * ── THE JOIN, AND WHY IT IS NOT THE FILENAME ──────────────────────────────────
27
+ * A verdict file is named for its PACKET, not its trajectory: `ffc2f7c7.verdict.json`
28
+ * carries `trajectoryId: "b90818a24afc5069"`. The join is therefore on the parsed
29
+ * `trajectoryId` — never on the file stem, which would silently produce a report
30
+ * whose traces match nothing.
31
+ *
32
+ * ── HONESTY ───────────────────────────────────────────────────────────────────
33
+ * An ingested trajectory with no verdict file is NOT silently dropped: the composer
34
+ * counts it and emits a `scopeNote` naming how many of the ingested trajectories were
35
+ * judged, which the surface renders as a declared scope reduction. `run-input.json`'s
36
+ * placeholder strings (it stores `"<5 EvalTraces>"` in place of the arrays) are
37
+ * detected and treated as ABSENT rather than parsed as data.
38
+ *
39
+ * ── DELIBERATELY OUT OF SCOPE — this is NOT W4 ────────────────────────────────
40
+ * No feedback store, no ruling persistence, no propagation of a ruling into
41
+ * `*validate`, no calibration application. This module PRODUCES the artifact; it does
42
+ * not consume a reviewer's answer. That loop is task #9 and is a separate contract.
43
+ *
44
+ * PURE except the file reads and the report write.
45
+ */
46
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
47
+ import { join } from "node:path";
48
+ import { parseMatrixVerdictFile } from "./contracts/eval-matrix.ts";
49
+ import type { MatrixCriterion, MatrixVerdictFile } from "./contracts/eval-matrix.ts";
50
+ import type { EvalTrace } from "./contracts/eval-types.ts";
51
+ import { runDir as defaultRunDir } from "./artifact-paths.ts";
52
+ import { renderReviewReportV3, writeReviewRunReportV3, type RenderReviewV3Input } from "./render-review-report-v3.ts";
53
+
54
+ /** the trajectory shape the review surface needs — id plus real observations. */
55
+ interface IngestedTrace {
56
+ id: string;
57
+ observations?: { type?: string; name?: string; input?: unknown; output?: unknown }[];
58
+ }
59
+
60
+ /** the subset of a persisted run-input this composer reads. */
61
+ interface PersistedRunInput {
62
+ subject?: { name?: string; kind?: string };
63
+ trajectories?: unknown;
64
+ criteria?: MatrixCriterion[];
65
+ subjectProfile?: unknown;
66
+ pin?: { model?: string; temperature?: number };
67
+ producedAt?: string;
68
+ }
69
+
70
+ export interface ReviewRunArgs {
71
+ runId: string;
72
+ /** repo root; defaults to process.cwd(). Reports land under its `.mutagent`. */
73
+ cwd?: string;
74
+ /** override the run directory (defaults to the standard runs/<runId> path). */
75
+ runDir?: string;
76
+ /** see render-review-report-v3: this surface renders identically either way. */
77
+ audience?: "internal" | "external";
78
+ devFeedback?: boolean;
79
+ /** the living-suite version these criteria came from, when known. */
80
+ suiteVersion?: string;
81
+ /** appended to the composer's own derived scope note. */
82
+ scopeNote?: string;
83
+ }
84
+
85
+ export interface ReviewRunResult {
86
+ report: string;
87
+ fallback: string | null;
88
+ runId: string;
89
+ trajectoriesIngested: number;
90
+ trajectoriesJudged: number;
91
+ verdicts: number;
92
+ criteria: number;
93
+ /** verdict files whose trajectoryId matched no ingested trajectory. */
94
+ unjoinedTrajectoryIds: string[];
95
+ }
96
+
97
+ /** `run-input.json` stores `"<5 EvalTraces>"` where the full file stores the array. */
98
+ function asArray<T>(v: unknown): T[] | null {
99
+ return Array.isArray(v) ? (v as T[]) : null;
100
+ }
101
+
102
+ /** Read the persisted run-input, preferring the full file. FAIL-LOUD when neither exists. */
103
+ function readRunInput(dir: string): { data: PersistedRunInput; path: string } {
104
+ for (const name of ["run-input.full.json", "run-input.json"]) {
105
+ const p = join(dir, name);
106
+ if (existsSync(p)) return { data: JSON.parse(readFileSync(p, "utf8")) as PersistedRunInput, path: p };
107
+ }
108
+ throw new Error(
109
+ `run-review: no run-input.full.json or run-input.json in '${dir}'. ` +
110
+ "The review surface is composed from an EXISTING *evaluate run — point it at a run directory that has one.",
111
+ );
112
+ }
113
+
114
+ /** Every `*.verdict.json` in the run's verdict dir, parsed strictly. */
115
+ function readVerdictFiles(dir: string): MatrixVerdictFile[] {
116
+ const vdir = join(dir, "verdicts");
117
+ if (!existsSync(vdir)) throw new Error(`run-review: no verdicts/ directory in '${dir}' — nothing has been judged for this run.`);
118
+ // `.verify.json` is the independent-verify ledger, NOT a verdict — excluded by
119
+ // suffix so a future sibling artifact cannot silently be parsed as a verdict.
120
+ const names = readdirSync(vdir).filter((f) => f.endsWith(".verdict.json")).sort();
121
+ if (names.length === 0) throw new Error(`run-review: no *.verdict.json files in '${vdir}' — nothing has been judged for this run.`);
122
+ return names.map((n) => {
123
+ try {
124
+ return parseMatrixVerdictFile(readFileSync(join(vdir, n), "utf8"));
125
+ } catch (e) {
126
+ // name the FILE — a schema violation with no filename is unactionable when
127
+ // a run carries dozens of verdicts.
128
+ throw new Error(`run-review: ${n} is not a valid verdict file — ${String((e as Error).message).slice(0, 200)}`);
129
+ }
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Assemble a `RenderReviewV3Input` from a run directory. Pure apart from the reads,
135
+ * and exported so the composition can be asserted without writing a report.
136
+ */
137
+ export function readReviewRunInput(args: ReviewRunArgs): { input: RenderReviewV3Input; stats: Omit<ReviewRunResult, "report" | "fallback"> } {
138
+ const dir = args.runDir ?? defaultRunDir(args.runId, args.cwd);
139
+ if (!existsSync(dir)) throw new Error(`run-review: run directory not found: '${dir}'`);
140
+ const { data } = readRunInput(dir);
141
+
142
+ const criteria = asArray<MatrixCriterion>(data.criteria);
143
+ if (criteria === null || criteria.length === 0)
144
+ throw new Error(`run-review: the run-input in '${dir}' carries no criteria array — the review surface has nothing to show rows for.`);
145
+
146
+ const files = readVerdictFiles(dir);
147
+ // the full run-input holds real trajectories; the lean one holds a placeholder
148
+ // STRING, which must read as absent rather than be coerced into a trace list.
149
+ const ingested = asArray<IngestedTrace>(data.trajectories) ?? [];
150
+ const ingestedIds = new Set(ingested.map((t) => t.id));
151
+ const judgedIds = new Set(files.map((f) => f.trajectoryId));
152
+ const unjoined = [...judgedIds].filter((id) => !ingestedIds.has(id));
153
+
154
+ // NAMED scope: an ingested-but-unjudged trajectory is declared, never dropped.
155
+ const notes: string[] = [];
156
+ if (ingested.length > 0 && judgedIds.size < ingested.length)
157
+ notes.push(`${judgedIds.size} of ${ingested.length} ingested trajectories carry a judge verdict; the rest are not shown`);
158
+ if (ingested.length === 0)
159
+ notes.push("the run-input carried no trajectory array, so the drill shows the judge's own step record rather than raw tool I/O");
160
+ if (unjoined.length > 0)
161
+ notes.push(`${unjoined.length} verdict file(s) reference a trajectory absent from the run-input (${unjoined.join(", ")})`);
162
+ if (args.scopeNote !== undefined) notes.push(args.scopeNote);
163
+
164
+ const input: RenderReviewV3Input = {
165
+ subjectName: data.subject?.name ?? args.runId,
166
+ runId: args.runId,
167
+ audience: args.audience ?? "internal",
168
+ criteria,
169
+ files,
170
+ ...(ingested.length > 0 ? { traces: ingested } : {}),
171
+ ...(data.subjectProfile !== undefined && typeof data.subjectProfile === "object" && data.subjectProfile !== null
172
+ ? { subjectProfile: data.subjectProfile as Record<string, unknown> }
173
+ : {}),
174
+ pin: { model: data.pin?.model ?? "(judge model not recorded in the run-input)", temperature: 0 },
175
+ ...(data.producedAt !== undefined ? { generatedAt: data.producedAt } : {}),
176
+ ...(args.suiteVersion !== undefined ? { suiteVersion: args.suiteVersion } : {}),
177
+ ...(notes.length > 0 ? { scopeNote: notes.join(" · ") } : {}),
178
+ ...(args.devFeedback !== undefined ? { devFeedback: args.devFeedback } : {}),
179
+ };
180
+
181
+ return {
182
+ input,
183
+ stats: {
184
+ runId: args.runId,
185
+ trajectoriesIngested: ingested.length,
186
+ trajectoriesJudged: judgedIds.size,
187
+ verdicts: files.reduce((a, f) => a + f.verdicts.length, 0),
188
+ criteria: criteria.length,
189
+ unjoinedTrajectoryIds: unjoined,
190
+ },
191
+ };
192
+ }
193
+
194
+ /**
195
+ * THE COMPOSER — read a run directory and write `review-report.html` (plus the v1
196
+ * `review-report.v2.html` fallback when the run carries ingested traces the v1 UI
197
+ * can consume). Returns the paths and what was actually composed.
198
+ */
199
+ export function writeReviewRunReport(args: ReviewRunArgs): ReviewRunResult {
200
+ const { input, stats } = readReviewRunInput(args);
201
+ const repoRoot = args.cwd ?? process.cwd();
202
+ // the v1 annotation UI consumes EvalTrace[]; the ingested trajectories ARE that
203
+ // shape in a persisted run-input. Absent ⇒ no fallback is written and the result
204
+ // says so with `fallback: null`, rather than an empty file pretending to be one.
205
+ const evalTraces = (input.traces ?? []) as unknown as EvalTrace[];
206
+ const { report, fallback } = writeReviewRunReportV3(input, repoRoot, evalTraces.length > 0 ? evalTraces : undefined);
207
+ return { ...stats, report, fallback };
208
+ }
209
+
210
+ /** Render without writing — for tests and for callers that want the HTML in hand. */
211
+ export function renderReviewRunReport(args: ReviewRunArgs): string {
212
+ return renderReviewReportV3(readReviewRunInput(args).input);
213
+ }
214
+
215
+ /* ── CLI entrypoint ────────────────────────────────────────────────────────────
216
+ *
217
+ * bun scripts/run-review.ts <runId> [--cwd <repoRoot>] [--run-dir <dir>]
218
+ * [--suite <version>] [--dev-feedback]
219
+ *
220
+ * This is the production caller the `*review` command's parent Bash invokes, mirroring
221
+ * build-review-ui.ts's CLI. Costs nothing: it reads artifacts an `*evaluate` run has
222
+ * already produced and dispatches no judge.
223
+ */
224
+ declare const Bun: { argv: string[] } | undefined;
225
+
226
+ function parseArgv(argv: string[]): ReviewRunArgs | null {
227
+ const [runId] = argv;
228
+ if (runId === undefined || runId.startsWith("--")) return null;
229
+ const flag = (name: string): string | undefined => {
230
+ const i = argv.indexOf(`--${name}`);
231
+ return i >= 0 ? argv[i + 1] : undefined;
232
+ };
233
+ const cwd = flag("cwd");
234
+ const rd = flag("run-dir");
235
+ const suite = flag("suite");
236
+ return {
237
+ runId,
238
+ ...(cwd !== undefined ? { cwd } : {}),
239
+ ...(rd !== undefined ? { runDir: rd } : {}),
240
+ ...(suite !== undefined ? { suiteVersion: suite } : {}),
241
+ ...(argv.includes("--dev-feedback") ? { devFeedback: true } : {}),
242
+ };
243
+ }
244
+
245
+ async function main(): Promise<void> {
246
+ const argv = typeof Bun !== "undefined" ? Bun.argv.slice(2) : process.argv.slice(2);
247
+ const args = parseArgv(argv);
248
+ if (args === null) {
249
+ console.error("usage: run-review.ts <runId> [--cwd <repoRoot>] [--run-dir <dir>] [--suite <version>] [--dev-feedback]");
250
+ process.exit(2);
251
+ return;
252
+ }
253
+ const r = writeReviewRunReport(args);
254
+ console.info(
255
+ `review report written: ${r.report}\n` +
256
+ ` ${r.trajectoriesJudged}/${r.trajectoriesIngested || r.trajectoriesJudged} trajectories judged · ` +
257
+ `${r.verdicts} judge verdicts · ${r.criteria} criteria` +
258
+ (r.fallback !== null ? `\n v1 fallback: ${r.fallback}` : "\n v1 fallback: not written (the run carries no ingested traces)") +
259
+ (r.unjoinedTrajectoryIds.length > 0 ? `\n WARNING: ${r.unjoinedTrajectoryIds.length} verdict(s) reference an unknown trajectory` : ""),
260
+ );
261
+ process.exit(0);
262
+ }
263
+
264
+ if (typeof Bun !== "undefined" && Bun.argv[1]?.endsWith("run-review.ts") === true) {
265
+ await main();
266
+ }
@@ -22,11 +22,62 @@
22
22
  import {
23
23
  PROFILE_UNKNOWN,
24
24
  SubjectProfileProvenance,
25
+ ToolReversibility,
25
26
  type SubjectProfile,
27
+ type ToolReversibilityEntry,
28
+ type ToolReversibilityValue,
26
29
  } from "./contracts/eval-matrix.ts";
27
30
  import { inferToolInventory, inferSystemPrompt } from "./profile-subject.ts";
28
31
  import type { EvalTrace } from "./contracts/eval-types.ts";
29
32
 
33
+ // ── EV-5 — tool reversibility classification ─────────────────────────────────
34
+ //
35
+ // LEAN start (operator: "classify its tools by what they do — irreversible external
36
+ // or not; keep the start lean"). A GIVEN classification (from the subject definition)
37
+ // ALWAYS wins; absent, a conservative NAME heuristic flags well-known irreversible-
38
+ // external verbs and well-known read-only verbs, and marks everything else `unknown`
39
+ // (HONEST — never a false "reversible"). The heavier per-trace side-effect harness
40
+ // (decision option b) can supersede this later without changing the field shape.
41
+
42
+ /** Verb stems whose presence in a tool name signals an IRREVERSIBLE EXTERNAL action. */
43
+ const IRREVERSIBLE_VERBS = [
44
+ "send", "email", "post", "publish", "delete", "remove", "destroy", "drop",
45
+ "charge", "pay", "refund", "transfer", "deploy", "release", "submit", "create",
46
+ "insert", "write", "update", "patch", "put", "execute", "provision", "terminate",
47
+ "cancel", "notify", "dispatch", "message", "reply", "comment", "merge", "push",
48
+ ];
49
+ /** Verb stems whose presence signals a READ-ONLY / safely-repeatable action. */
50
+ const REVERSIBLE_VERBS = [
51
+ "get", "list", "read", "search", "fetch", "lookup", "view", "query", "find",
52
+ "check", "inspect", "describe", "count", "peek", "preview", "validate",
53
+ ];
54
+
55
+ /**
56
+ * Classify one tool's reversibility (EV-5). A GIVEN `override` (subject definition)
57
+ * wins; else a conservative name heuristic — irreversible-external verbs first (the
58
+ * safety-relevant class), then read-only verbs, else `unknown` (never guessed). PURE.
59
+ */
60
+ export function classifyToolReversibility(
61
+ name: string,
62
+ override?: Record<string, ToolReversibilityValue>,
63
+ ): ToolReversibilityValue {
64
+ const given = override?.[name];
65
+ if (given !== undefined) return given;
66
+ const n = name.toLowerCase();
67
+ const hits = (verbs: string[]): boolean => verbs.some((v) => n.includes(v));
68
+ if (hits(IRREVERSIBLE_VERBS)) return ToolReversibility.IrreversibleExternal;
69
+ if (hits(REVERSIBLE_VERBS)) return ToolReversibility.Reversible;
70
+ return ToolReversibility.Unknown;
71
+ }
72
+
73
+ /** Classify every tool in the inventory (EV-5). Deterministic; preserves order. */
74
+ export function classifyToolList(
75
+ tools: string[],
76
+ override?: Record<string, ToolReversibilityValue>,
77
+ ): ToolReversibilityEntry[] {
78
+ return tools.map((name) => ({ name, reversibility: classifyToolReversibility(name, override) }));
79
+ }
80
+
30
81
  /** The GIVEN facts a caller with code/metadata access can supply (M1). Any field
31
82
  * left absent is RECONSTRUCTED from the trace batch or MARKED `unknown`. */
32
83
  export interface GivenSubjectFacts {
@@ -41,6 +92,9 @@ export interface GivenSubjectFacts {
41
92
  version?: string;
42
93
  /** the agent's system prompt (code access). Rendered COLLAPSED; never confabulated. */
43
94
  systemPrompt?: string;
95
+ /** EV-5 — a GIVEN per-tool reversibility classification (from the subject definition);
96
+ * WINS over the name heuristic. Any tool not named here falls back to the heuristic. */
97
+ toolReversibility?: Record<string, ToolReversibilityValue>;
44
98
  }
45
99
 
46
100
  export interface BuildSubjectProfileParams {
@@ -117,10 +171,14 @@ export function buildSubjectProfile(params: BuildSubjectProfileParams): SubjectP
117
171
  if (systemPrompt === undefined) systemPrompt = inferSystemPrompt(traces);
118
172
  if (given?.systemPrompt === undefined) inferredFields.push("systemPrompt");
119
173
 
174
+ // EV-5 — classify each tool's reversibility (GIVEN override wins; else name heuristic).
175
+ const toolReversibility = classifyToolList(tools, given?.toolReversibility);
176
+
120
177
  const profile: SubjectProfile = {
121
178
  identity,
122
179
  purpose,
123
180
  tools,
181
+ toolReversibility,
124
182
  scope,
125
183
  harness,
126
184
  provenance: hasGiven ? SubjectProfileProvenance.Given : SubjectProfileProvenance.Reconstructed,
@@ -132,3 +190,27 @@ export function buildSubjectProfile(params: BuildSubjectProfileParams): SubjectP
132
190
  };
133
191
  return profile;
134
192
  }
193
+
194
+ /** The compact determiner subject frame (EV-1) — a structural subset of the M1 profile. */
195
+ export interface SubjectFrameShape {
196
+ identity: string;
197
+ purpose: string;
198
+ tools: string[];
199
+ toolReversibility?: ToolReversibilityEntry[];
200
+ }
201
+
202
+ /**
203
+ * Derive the determiner/judge subject frame (EV-1 / EV-5) from a subject name + trace
204
+ * batch. THE single derivation used by run-pipeline (Stage-B) AND prep (Stage-A) so
205
+ * the determiner prompt is byte-identical across the two stages (the cross-stage cache
206
+ * would miss on any divergence). PURE — same inputs ⇒ same frame.
207
+ */
208
+ export function deriveSubjectFrame(subjectName: string, traces: EvalTrace[]): SubjectFrameShape {
209
+ const m1 = buildSubjectProfile({ subjectName, traces });
210
+ return {
211
+ identity: m1.identity,
212
+ purpose: m1.purpose,
213
+ tools: m1.tools,
214
+ ...(m1.toolReversibility !== undefined ? { toolReversibility: m1.toolReversibility } : {}),
215
+ };
216
+ }
@@ -2,7 +2,7 @@
2
2
  * scripts/sync-eval-criteria.ts — the EVAL-LEG reconcile CONTRACT (Wave-2 W2I5 · KP-003).
3
3
  * ---------------------------------------------------------------------------
4
4
  * The THIRD leg of the def → impl → eval triad. Today `#sync-spec` reconciles two
5
- * legs — spec ↔ impl. When an implementation amends (the ⑤ IMPROVE loop's ai-engineer,
5
+ * legs — spec ↔ impl. When an implementation amends (the ⑤ OPTIMIZE loop's ai-engineer,
6
6
  * or a brownfield drift), the eval criteria that GROUND the subject's evaluation can go
7
7
  * stale w.r.t. the changed impl. This module is the EVALUATOR-SIDE CONTRACT that the
8
8
  * reconcile (ai-architect REASONING, session-dispatched — Model-B) READS and WRITES:
@@ -35,7 +35,7 @@ import { assertMonotonicGrowth } from "./living-suite.ts";
35
35
  // ── subject kind → eval leg ──────────────────────────────────────────────────
36
36
 
37
37
  /** The subject kinds the triad reconciles. `agent|skill|composite` come from the
38
- * agentspec `definition.identity.kind`; `code` is the ⑤ IMPROVE code-target subject. */
38
+ * agentspec `definition.identity.kind`; `code` is the ⑤ OPTIMIZE code-target subject. */
39
39
  export const EvalSubjectKind = {
40
40
  Agent: "agent",
41
41
  Skill: "skill",
@@ -164,6 +164,34 @@ function effectiveRole(span: UnitfSpan): UnitfRole {
164
164
  }
165
165
  }
166
166
 
167
+ // ── Message-text pick (INF-1) ────────────────────────────────────────────────
168
+ //
169
+ // ROOT CAUSE of the Claude Code loss: the Claude Code exporter writes BOTH user
170
+ // and assistant TEXT into `span.output` (a user turn is `kind:"event", role:"user",
171
+ // output:<text>`; an assistant turn is `kind:"llm", role:"assistant", output:<text>`
172
+ // with `input` UNSET). The old projection read a user/system span's `.input` — empty
173
+ // for EVERY Claude Code trace ⇒ `input.prompt` silently empty for all 21/21.
174
+ //
175
+ // This VENDORS the shared reader's role-conditional pick + fallback
176
+ // (@mutagent/tools `messagesView`, derive.ts:173-174): a user/system turn's text is
177
+ // canonically in `input`, everything else in `output`, but the `?? input ?? output`
178
+ // fallback recovers the text WHICHEVER field a producer used. Vendored — never
179
+ // imported: the standalone-publish guard forbids the evaluator reaching into
180
+ // @mutagent/tools (MIGRATION-diagnostics-evaluator.md:417); diagnostics vendors its
181
+ // own copy and this mirrors that.
182
+
183
+ /**
184
+ * The span's effective message TEXT: the role-conditional field pick with the
185
+ * `?? input ?? output` fallback (mirrors @mutagent/tools `messagesView`). Recovers
186
+ * a Claude Code turn (text in `output`) AND a Langfuse turn (text in `input`).
187
+ * PURE; "" when the span carries no text.
188
+ */
189
+ function messageText(span: UnitfSpan): string {
190
+ const role = effectiveRole(span);
191
+ const primary = role === "user" || role === "system" ? span.input : span.output;
192
+ return asText(primary ?? span.input ?? span.output);
193
+ }
194
+
167
195
  // ── Token total fallback (XF-FIX Finding D) ──────────────────────────────────
168
196
  //
169
197
  // MIRRORS diagnostics `unitf-adapter.ts` `fallbackTotalTokens` so both lanes
@@ -185,10 +213,11 @@ function fallbackTotalTokens(t: UnitfTokens | undefined): number | undefined {
185
213
  *
186
214
  * id ← ut.traceId
187
215
  * name ← ut.agentName
188
- * output ← first EFFECTIVE-assistant llm span → { response: <text of span.output> }
189
- * (XF-FIX A: role-less GENERATION spans read as assistant via kind)
190
- * input ← first user|system span's input, ELSE the response span's own
191
- * input { prompt } (XF-FIX A: recovers the Langfuse prompt)
216
+ * output ← LAST EFFECTIVE-assistant llm span WITH text → { response: <messageText> }
217
+ * (XF-FIX A: role-less GENERATION spans read as assistant via kind;
218
+ * INF-1: last-with-text, and messageText recovers Claude Code's `output`)
219
+ * input first user|system span's messageText, ELSE the response span's own
220
+ * input → { prompt } (XF-FIX A: Langfuse prompt; INF-1: Claude Code `output`)
192
221
  * observations ← ut.spans → { type: kind.toUpperCase(), name, input, output }
193
222
  * errored/status ← computed.hasError ?? (status==="error" || any span error) / ut.status (XF-FIX C)
194
223
  * tokens/totalTokens← ut.tokens split / computed.totalTokens ?? tokens.total ?? in+out (XF-FIX D)
@@ -216,31 +245,45 @@ export function projectUnitfToEvalTrace(ut: UnifiedTraceLike): EvalTrace {
216
245
 
217
246
  if (ut.agentName !== undefined) out.name = ut.agentName;
218
247
 
219
- // output ← first assistant LLM span's output, wrapped to { response }. The
220
- // assistant role is now EFFECTIVE (XF-FIX Finding A): a role-less GENERATION
221
- // span (kind==="llm", the Langfuse shape) reads as assistant via `effectiveRole`,
222
- // so its output is picked up instead of being silently dropped.
223
- const responseSpan = ut.spans.find(
248
+ // output ← the LAST assistant LLM span WITH text, wrapped to { response }. The
249
+ // assistant role is EFFECTIVE (XF-FIX Finding A): a role-less GENERATION span
250
+ // (kind==="llm", the Langfuse shape) reads as assistant via `effectiveRole`. The
251
+ // second INF-1 miss: the old code took the FIRST assistant span — on Claude Code
252
+ // that is usually a tool_use turn with EMPTY output text, so the real reply (a
253
+ // LATER assistant span) was dropped. Take the LAST assistant span that carries
254
+ // text; the `messageText` pick recovers it from `output` (Claude Code) or `input`
255
+ // (whichever field the producer used).
256
+ const assistantSpans = ut.spans.filter(
224
257
  (s) => effectiveRole(s) === "assistant" && s.kind === "llm",
225
258
  );
226
- if (responseSpan !== undefined && responseSpan.output !== undefined) {
227
- out.output = { response: asText(responseSpan.output) };
259
+ let responseSpan: UnitfSpan | undefined;
260
+ for (const s of assistantSpans) {
261
+ if (messageText(s).length > 0) responseSpan = s; // keep the LAST with text
262
+ }
263
+ // Fall back to the last assistant span even without text, so the response SLOT is
264
+ // still derived from a real span (the empty case is WARNED at intake, not here).
265
+ const lastAssistant =
266
+ responseSpan ?? (assistantSpans.length > 0 ? assistantSpans[assistantSpans.length - 1] : undefined);
267
+ if (responseSpan !== undefined) {
268
+ out.output = { response: messageText(responseSpan) };
228
269
  }
229
270
 
230
- // input ← first user|system span's input, wrapped to { prompt }. A role-less
231
- // span never infers to user/system, so when there is no explicit user/system
232
- // turn (again the Langfuse shape one GENERATION span carrying prompt in its
233
- // OWN `input` and response in `output`) the prompt is recovered from the
234
- // response span's `input`. Without this fallback `input.prompt` was empty for
235
- // EVERY Langfuse trace (Finding A).
271
+ // input ← the first user|system span's TEXT, wrapped to { prompt }. `messageText`
272
+ // recovers the text from `output` (Claude Code writes the user turn there) or
273
+ // `input` (Langfuse). When there is no explicit user/system turn (the Langfuse
274
+ // shape one GENERATION span carrying the prompt in its OWN `input` and the
275
+ // response in `output`) the prompt is recovered from the response span's `input`
276
+ // SPECIFICALLY (not `messageText`, which for an assistant span would return the
277
+ // response). Without these fallbacks `input.prompt` was empty for every Claude
278
+ // Code trace (INF-1) and every Langfuse trace (Finding A).
236
279
  const promptSpan = ut.spans.find(
237
280
  (s) => effectiveRole(s) === "user" || effectiveRole(s) === "system",
238
281
  );
239
- if (promptSpan !== undefined && promptSpan.input !== undefined) {
240
- out.input = { prompt: asText(promptSpan.input) };
241
- } else if (responseSpan !== undefined && responseSpan.input !== undefined) {
242
- out.input = { prompt: asText(responseSpan.input) };
282
+ let promptText = promptSpan !== undefined ? messageText(promptSpan) : "";
283
+ if (promptText.length === 0 && lastAssistant !== undefined && lastAssistant.input !== undefined) {
284
+ promptText = asText(lastAssistant.input);
243
285
  }
286
+ if (promptText.length > 0) out.input = { prompt: promptText };
244
287
 
245
288
  // Finding C — carry structured error/status so an error-KIND criterion has a
246
289
  // field to read (previously error state survived only as textual span output).