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

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 (23) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +19 -7
  2. package/.claude/skills/mutagent-evaluator/assets/agents/discovery.md +2 -2
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +65 -1
  4. package/.claude/skills/mutagent-evaluator/references/operation-inventory.md +1 -0
  5. package/.claude/skills/mutagent-evaluator/references/workflows/orchestrator-protocol.md +20 -7
  6. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +1 -1
  7. package/.claude/skills/mutagent-evaluator/scripts/artifact-paths.ts +16 -0
  8. package/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +14 -7
  9. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +18 -2
  10. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +285 -0
  11. package/.claude/skills/mutagent-evaluator/scripts/config/load.ts +45 -9
  12. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +63 -8
  13. package/.claude/skills/mutagent-evaluator/scripts/load-bundle.ts +1 -1
  14. package/.claude/skills/mutagent-evaluator/scripts/persist-eval-criteria.ts +232 -0
  15. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report.ts +1 -1
  16. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +21 -6
  17. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -2
  18. package/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +114 -18
  19. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +6 -2
  20. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +244 -0
  21. package/.claude/skills/mutagent-evaluator/workflows/audit.workflow.js +1 -1
  22. package/bin/mutagent-cli.mjs +698 -7
  23. package/package.json +3 -3
@@ -93,16 +93,29 @@ export function detectLegacyConfig(obj: unknown): string[] {
93
93
 
94
94
  /**
95
95
  * How the evaluator's source was bound (or why it couldn't be). The SELECTION
96
- * contract (v0.2.1 — parity with orchestrator + diagnostics):
97
- * bound — exactly one source resolved (single entry · `default:true` · `--source` hit).
96
+ * contract (v0.2.2 — parity with orchestrator + diagnostics; F2 disambiguation UX):
97
+ * bound — exactly one source resolved with NO ambiguity (single entry ·
98
+ * `--source` hit). No prompt.
99
+ * confirm-default — >1 source with exactly one `default:true` (F2). The default is
100
+ * PRESELECTED (`source`) but the caller ISSUES A CONFIRM ASK before
101
+ * using it (distinct from a silent bind — be explicit around ambiguity).
102
+ * NON-fatal + bindable: `source` is usable as-is if the operator accepts
103
+ * or the run is non-interactive (`--source` / CI). Carries the full
104
+ * candidate list so the confirm can offer an override.
98
105
  * none — zero sources (DISCOVER needs a pick; code/dataset runs don't need a source).
99
106
  * needs-selection — >1 source, none `default:true`, no `--source` ⇒ the caller must PROMPT the
100
- * operator to pick (NON-fatal — the source EXISTS, only the pick is missing).
107
+ * operator to pick with NO preselection (NON-fatal — the source EXISTS,
108
+ * only the pick is missing).
101
109
  * multiple-defaults— >1 source with `default:true` — an AMBIGUOUS config the operator must fix.
102
110
  * unknown-name — an explicit `--source <name>` that matched no catalog entry.
111
+ *
112
+ * confirm-default vs needs-selection: BOTH defer a run-time ASK, but confirm-default
113
+ * carries a PRESELECTED default (a yes/no confirm) whereas needs-selection has none (an
114
+ * open pick). Both are bindable-in-principle; the distinction drives the ASK shape.
103
115
  */
104
116
  export type SourceBinding =
105
117
  | { kind: "bound"; source: GlobalSource }
118
+ | { kind: "confirm-default"; source: GlobalSource; sources: GlobalSource[] }
106
119
  | { kind: "none" }
107
120
  | { kind: "needs-selection"; sources: GlobalSource[] }
108
121
  | { kind: "multiple-defaults"; sources: GlobalSource[] }
@@ -128,6 +141,17 @@ export interface EvaluatorConfigResolved {
128
141
  evaluatorContext: ContextLink[];
129
142
  /** present targets (evaluator is judge-only — carried for completeness only). */
130
143
  targets: GlobalTarget[];
144
+ /** PRD R-4 — the admin default render audience for the evaluation report. Client-by-default
145
+ * (leak-safe); an admin sets `internal` in config.yaml to show the §5 Self-Eval tab. */
146
+ defaultAudience: "client" | "internal";
147
+ }
148
+
149
+ /** PRD R-4 — resolve the effective render audience: explicit flag → config → "client". */
150
+ export function resolveEvalAudience(
151
+ explicit: "client" | "internal" | undefined,
152
+ config?: { defaultAudience?: "client" | "internal" } | undefined,
153
+ ): "client" | "internal" {
154
+ return explicit ?? config?.defaultAudience ?? "client";
131
155
  }
132
156
 
133
157
  export type LoadEvaluatorConfigResult =
@@ -142,14 +166,16 @@ export type LoadEvaluatorConfigResult =
142
166
  *
143
167
  * 1. explicit `selectName` (`--source`) WINS — find by name; miss ⇒ `unknown-name`.
144
168
  * 2. 0 sources ⇒ `none`.
145
- * 3. 1 source ⇒ `bound` (auto-bind).
169
+ * 3. 1 source ⇒ `bound` (auto-bind — no ambiguity, no prompt).
146
170
  * 4. >1 sources:
147
- * exactly one `default:true` ⇒ `bound` (silent default).
171
+ * exactly one `default:true` ⇒ `confirm-default` (F2 — preselected default; caller
172
+ * CONFIRMS before use, not a silent bind).
148
173
  * >1 `default:true` ⇒ `multiple-defaults` (ambiguous — operator fixes config).
149
- * 0 `default:true` ⇒ `needs-selection` (caller PROMPTS the operator).
174
+ * 0 `default:true` ⇒ `needs-selection` (caller PROMPTS the operator — open pick).
150
175
  *
151
- * Pure; no clock/random/network. NON-fatal on `needs-selection` — the source
152
- * exists, only the disambiguation pick is deferred to the run-start ASK.
176
+ * Pure; no clock/random/network. NON-fatal on `confirm-default`/`needs-selection` —
177
+ * the source exists; only the disambiguation confirm/pick is deferred to the run-start ASK.
178
+ * `--source` (explicit) always short-circuits both, satisfying the non-interactive/CI escape.
153
179
  */
154
180
  function bindSourceByRole(
155
181
  sources: GlobalSource[],
@@ -167,7 +193,11 @@ function bindSourceByRole(
167
193
  if (sources.length === 1) return { kind: "bound", source: sources[0] as GlobalSource };
168
194
  // (4) — multiple entries; disambiguate by `default:true`.
169
195
  const defaults = sources.filter((s) => s.default === true);
170
- if (defaults.length === 1) return { kind: "bound", source: defaults[0] as GlobalSource };
196
+ // F2: a lone default is PRESELECTED but CONFIRMED (be explicit around ambiguity) —
197
+ // it is NOT silently bound. The caller (parent session) issues a yes/no confirm ask.
198
+ if (defaults.length === 1) {
199
+ return { kind: "confirm-default", source: defaults[0] as GlobalSource, sources };
200
+ }
171
201
  if (defaults.length > 1) return { kind: "multiple-defaults", sources };
172
202
  return { kind: "needs-selection", sources };
173
203
  }
@@ -266,6 +296,11 @@ export function loadEvaluatorConfig(
266
296
  typeof evaluatorSection.judge_runtime === "string" && evaluatorSection.judge_runtime !== ""
267
297
  ? evaluatorSection.judge_runtime
268
298
  : undefined;
299
+ // PRD R-4 — admin audience override. Precedence: lifecycle.evaluator.default_audience >
300
+ // global.default_audience > "client". Only "internal" flips it; anything else stays client.
301
+ const rawAudience =
302
+ evaluatorSection.default_audience ?? globalBlock.default_audience;
303
+ const defaultAudience: "client" | "internal" = rawAudience === "internal" ? "internal" : "client";
269
304
 
270
305
  return {
271
306
  status: "ok",
@@ -278,6 +313,7 @@ export function loadEvaluatorConfig(
278
313
  globalContext,
279
314
  evaluatorContext,
280
315
  targets,
316
+ defaultAudience,
281
317
  },
282
318
  };
283
319
  }
@@ -45,7 +45,12 @@ export const LEGACY_CONFIG_VERSIONS = ["0.1.0"] as const;
45
45
 
46
46
  // ── Categorical constants (no magic strings — coding-rules) ──────────────────
47
47
 
48
- /** Source platforms a `global.sources[]` entry can pull traces from. */
48
+ /**
49
+ * Source platforms a `global.sources[]` entry can pull traces from.
50
+ * CONSUMER: the trace-fetch layer (`mutagent-cli trace fetch`, run by Helix BEFORE
51
+ * dispatch — the evaluator never fetches) selects the adapter by this value.
52
+ * PURPOSE: names WHERE the subject's traces live so they can be normalized to UniTF.
53
+ */
49
54
  export const SourcePlatform = {
50
55
  Langfuse: "langfuse",
51
56
  Otel: "otel",
@@ -56,12 +61,21 @@ export const SourcePlatform = {
56
61
  export type SourcePlatformValue =
57
62
  (typeof SourcePlatform)[keyof typeof SourcePlatform];
58
63
 
59
- /** Normalizer format hint for a source (how to parse the records). */
64
+ /**
65
+ * Normalizer format hint for a source (how to parse the records).
66
+ * CONSUMER: the trace-fetch/normalize layer (`@mutagent/tools` adapters) — picks the
67
+ * parser for a source's records. PURPOSE: disambiguates record shape within a platform.
68
+ * `unitf` (F1) — the records are ALREADY conformant UniTF jsonl (e.g. emitted by the
69
+ * code-run capture WRAP, `captureUniTF` in `@mutagent/tools`). The fetch layer then
70
+ * SKIPS per-platform normalization and reads the jsonl DIRECTLY. Pairs with
71
+ * `platform: local-jsonl` + `paths:[…]`. PARITY PORT of the orchestrator's SourceFormat.
72
+ */
60
73
  export const SourceFormat = {
61
74
  LangfuseExport: "langfuse-export",
62
75
  ClaudeCode: "claude-code",
63
76
  Codex: "codex",
64
77
  Raw: "raw",
78
+ Unitf: "unitf",
65
79
  } as const;
66
80
  export type SourceFormatValue = (typeof SourceFormat)[keyof typeof SourceFormat];
67
81
 
@@ -137,7 +151,16 @@ export const ContextLinkSchema = Type.Object(
137
151
  );
138
152
  export type ContextLink = Static<typeof ContextLinkSchema>;
139
153
 
140
- /** The default model + the pinned judge model (`judge_model` — was `pinned_judge`). */
154
+ /**
155
+ * The default model + the pinned judge model (`judge_model` — was `pinned_judge`).
156
+ * CONSUMER: config/load.ts `resolveJudgeModel` → prep.ts `--model` gap-fill.
157
+ * default — the project-wide fallback judge model. PURPOSE: used ONLY when
158
+ * `judge_model` is absent (`from: "default"`). ASYMMETRY: diagnostics
159
+ * IGNORES `models.default` for its judge (it pins its own) — the field
160
+ * is a shared config key that the two skills consume differently.
161
+ * judge_model — the C-PIN pinned judge (model-intent-sacred; temp pinned 0). PURPOSE:
162
+ * the authoritative judge model; wins over `default` (`from: "judge_model"`).
163
+ */
141
164
  export const ModelsSchema = Type.Object(
142
165
  {
143
166
  default: Type.Optional(Type.String({ minLength: 1 })),
@@ -154,7 +177,10 @@ export type Models = Static<typeof ModelsSchema>;
154
177
  */
155
178
  export const GlobalSourceSchema = Type.Object(
156
179
  {
180
+ // name — the catalog key. CONSUMER: config/load.ts `bindSourceByRole` (matched by
181
+ // `--source <name>`) + prep.ts surface messages. PURPOSE: stable id for selection.
157
182
  name: Type.String({ minLength: 1 }),
183
+ // platform — see SourcePlatform. CONSUMER: the fetch/normalize adapter selector.
158
184
  platform: Type.Union([
159
185
  Type.Literal(SourcePlatform.Langfuse),
160
186
  Type.Literal(SourcePlatform.Otel),
@@ -162,19 +188,34 @@ export const GlobalSourceSchema = Type.Object(
162
188
  Type.Literal(SourcePlatform.ClaudeCode),
163
189
  Type.Literal(SourcePlatform.Codex),
164
190
  ]),
191
+ // project — remote-source project/scope id. CONSUMER: the remote fetch adapter
192
+ // (e.g. Langfuse project). PURPOSE: scopes a pull on a multi-project backend.
165
193
  project: Type.Optional(Type.String({ minLength: 1 })),
194
+ // endpoint — remote base URL. CONSUMER: the remote fetch adapter. PURPOSE: where to
195
+ // pull from (mutually exclusive with `paths`; onboarding enforces, schema tolerates).
166
196
  endpoint: Type.Optional(Type.String({ minLength: 1 })),
197
+ // credential_ref — env-var NAME / {env,path} (NEVER a secret). CONSUMER: resolve-
198
+ // credential.ts at fetch-time. PURPOSE: names the key for a remote pull.
167
199
  credential_ref: Type.Optional(CredentialRefSchema),
200
+ // paths — file-source globs. CONSUMER: the local-jsonl fetch adapter. PURPOSE: the
201
+ // on-disk jsonl location (pairs with `platform: local-jsonl`; see `format: unitf`).
168
202
  paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
203
+ // format — see SourceFormat. CONSUMER: the normalize step (`unitf` ⇒ skip normalize,
204
+ // read jsonl direct). PURPOSE: record-shape hint within a platform.
169
205
  format: Type.Optional(
170
206
  Type.Union([
171
207
  Type.Literal(SourceFormat.LangfuseExport),
172
208
  Type.Literal(SourceFormat.ClaudeCode),
173
209
  Type.Literal(SourceFormat.Codex),
174
210
  Type.Literal(SourceFormat.Raw),
211
+ Type.Literal(SourceFormat.Unitf),
175
212
  ]),
176
213
  ),
214
+ // agent_field — record path naming the agent/subject. CONSUMER: the normalizer's
215
+ // subject-grouping. PURPOSE: which field identifies the agent when a trace is multi-agent.
177
216
  agent_field: Type.Optional(Type.String({ minLength: 1 })),
217
+ // latency_unit — see LatencyUnit (`auto` = infer). CONSUMER: the normalizer's
218
+ // latency projection. PURPOSE: overrides ambiguous ms-vs-s latency records.
178
219
  latency_unit: Type.Optional(
179
220
  Type.Union([
180
221
  Type.Literal(LatencyUnit.Auto),
@@ -182,16 +223,23 @@ export const GlobalSourceSchema = Type.Object(
182
223
  Type.Literal(LatencyUnit.S),
183
224
  ]),
184
225
  ),
185
- // PARITY (orchestrator SourceSchema): the catalog's DEFAULT pick when >1 entry
186
- // exists the selection contract's silent-bind marker (exactly one `default:true`
187
- // ⇒ auto-bind, skipping the disambiguation prompt). Optional: absent no default.
226
+ // default — PARITY (orchestrator SourceSchema): the catalog's DEFAULT pick when >1
227
+ // entry exists. CONSUMER: config/load.ts `bindSourceByRole` exactly one `default:true`
228
+ // ⇒ `confirm-default` (F2: preselected, operator confirms), not a silent bind. PURPOSE:
229
+ // marks the preferred source among many. Optional: absent ⇒ no default.
188
230
  default: Type.Optional(Type.Boolean()),
189
231
  },
190
232
  { additionalProperties: false },
191
233
  );
192
234
  export type GlobalSource = Static<typeof GlobalSourceSchema>;
193
235
 
194
- /** A precise link to an Agent / Tooling definition file in a code target. */
236
+ /**
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)
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
241
+ * realize the agent so a fix lands on the right def.
242
+ */
195
243
  export const CodeRefSchema = Type.Object(
196
244
  {
197
245
  path: Type.String({ minLength: 1 }),
@@ -201,7 +249,14 @@ export const CodeRefSchema = Type.Object(
201
249
  );
202
250
  export type CodeRef = Static<typeof CodeRefSchema>;
203
251
 
204
- /** HOW a fix is applied for a target. `kind` gates the report-only branch. */
252
+ /**
253
+ * HOW a fix is applied for a target. `kind` gates the report-only branch.
254
+ * CONSUMER: the diagnostics APPLY path (not the evaluator — judge-only). PURPOSE:
255
+ * selects the write mechanism for a fix.
256
+ * versioning / pr — RESERVED, consumed by Build/Improve (future). No evaluator
257
+ * consumer; ported for parity. PURPOSE (future): toggle spec-bump /
258
+ * open-a-PR on apply.
259
+ */
205
260
  export const ApplySchema = Type.Object(
206
261
  {
207
262
  kind: Type.Union([
@@ -31,7 +31,7 @@ export const WELL_KNOWN_ARTIFACTS: Readonly<Record<string, string>> = {
31
31
  tracesMetadata: "traces-metadata.json",
32
32
  entityContext: "entity-context.json",
33
33
  renderInput: "render-input.json",
34
- report: "report.html",
34
+ report: "evaluation-report.html",
35
35
  };
36
36
 
37
37
  /** Logical names whose VALUE is a sub-directory of evidence files. */
@@ -0,0 +1,232 @@
1
+ /**
2
+ * scripts/persist-eval-criteria.ts — the EVAL-LEG on-disk WRITER (Wave-2 FU57 · KP-003).
3
+ * ---------------------------------------------------------------------------
4
+ * The IO half of the eval-leg reconcile. `sync-eval-criteria.ts` COMPUTES the grown
5
+ * criteria set as DATA (`reconcileEvalCriteria` — pure upsert-by-id, monotonic, never
6
+ * drops); it never touches disk. This module is the deterministic WRITER the evaluator
7
+ * SESSION calls to PERSIST that computed set back to the located eval-suite criteria
8
+ * artifact — the write that bumps the artifact's freshness and thereby returns the eval
9
+ * leg of `#sync-spec` to `in-sync` (the builder freshness probe reads the artifact's
10
+ * mtime / `updatedAt`; a fresh write makes the eval criteria as new as the amended impl).
11
+ *
12
+ * SEPARATION OF CONCERNS (mirrors `artifact-paths.ts` + `aggregate-discover.ts` doing IO
13
+ * while `living-suite.ts` stays pure): the reconcile COMPUTE is pure/C-PIN; this WRITE is
14
+ * the IO layer. The SERIALIZATION here is still deterministic (stable key order, no clock
15
+ * embedded unless the caller passes an explicit `updatedAt`), so a persisted artifact
16
+ * round-trips byte-faithful.
17
+ *
18
+ * JUDGE-ONLY PRESERVED (EV-051): this file writes CRITERIA (the evaluator maintaining its
19
+ * own eval-suite / code-quality criteria set) — it NEVER scores a subject, NEVER decides a
20
+ * pass/fail, and NEVER dispatches an agent (Model-B: code is a deterministic writer; the
21
+ * reconcile REASONING is the ai-architect's, session-dispatched).
22
+ *
23
+ * LOCATION + FORMAT (mirror the living-suite persistence; FLAGGED-for-operator, see
24
+ * `EVAL_CRITERIA_ARTIFACT_FILENAMES`): the artifact lands under the evaluator's namespaced
25
+ * `.mutagent/evaluator/living-suite/` root (`artifact-paths.ts` `livingSuiteDir`, auto-
26
+ * gitignored, no-spillover-guarded) as one JSON file per LEG — the same dir + extension the
27
+ * builder `check-sync-spec.ts` `locateEvalCriteria` auto-discovers.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { type Static, Type } from "@sinclair/typebox";
32
+ import { Value } from "@sinclair/typebox/value";
33
+
34
+ import { assertUnderRoot, livingSuiteDir } from "./artifact-paths.ts";
35
+ import {
36
+ EvalCriterionSchema,
37
+ EvalLegKind,
38
+ reconcileEvalCriteria,
39
+ type EvalCriteriaReconcileRequest,
40
+ type EvalCriteriaReconcileResult,
41
+ type EvalCriterion,
42
+ type EvalLegKindValue,
43
+ } from "./sync-eval-criteria.ts";
44
+
45
+ // ── the on-disk artifact shape (mirror LivingSuite `{ …, provenance }`) ───────
46
+
47
+ /**
48
+ * The persisted eval-criteria artifact — one per LEG. Mirrors the living-suite shape
49
+ * (a keyed collection + provenance), specialized to the reconcile result: the maintained
50
+ * `criteria` set, the reconcile `provenance` counters, and an OPTIONAL `updatedAt`
51
+ * freshness marker. `updatedAt` is the ONE clock-bearing field and it is NEVER generated
52
+ * here — the caller (the session, Model-B) passes it when it wants a content-level
53
+ * freshness stamp the builder probe's `parseEvalUpdatedAt` can read; omitted, freshness
54
+ * rides on the file's mtime alone (write bumps it). `additionalProperties: false` keeps the
55
+ * header tight while each criterion stays forward-parsing (EvalCriterionSchema is open).
56
+ */
57
+ export const EvalCriteriaArtifactSchema = Type.Object(
58
+ {
59
+ subjectId: Type.String({ minLength: 1 }),
60
+ leg: Type.Union([
61
+ Type.Literal(EvalLegKind.EvalSuite),
62
+ Type.Literal(EvalLegKind.CodeQuality),
63
+ ]),
64
+ criteria: Type.Array(EvalCriterionSchema),
65
+ provenance: Type.Object(
66
+ { added: Type.Number(), updated: Type.Number(), total: Type.Number() },
67
+ { additionalProperties: false },
68
+ ),
69
+ updatedAt: Type.Optional(Type.String({ minLength: 1 })),
70
+ },
71
+ { additionalProperties: false },
72
+ );
73
+ export type EvalCriteriaArtifact = Static<typeof EvalCriteriaArtifactSchema>;
74
+
75
+ /**
76
+ * FLAGGED-for-operator: the per-leg artifact FILENAME under `.mutagent/evaluator/living-suite/`.
77
+ * The sensible default that mirrors the living-suite location AND the builder probe's
78
+ * conventional fallback names (`eval-criteria` / `code-quality-criteria`), one file per leg
79
+ * so the two legs never collide. Both are `.json` under the dir `check-sync-spec.ts`
80
+ * `locateEvalCriteria` walks (`*.{yaml,yml,json}`), so a written artifact is auto-discovered
81
+ * as the eval-leg freshness anchor with no extra wiring. (A workspace evaluates ONE subject,
82
+ * so only its leg's file exists; multi-subject-per-workspace would want a `<subjectId>/`
83
+ * sub-dir — deferred, flagged.)
84
+ */
85
+ export const EVAL_CRITERIA_ARTIFACT_FILENAMES: Readonly<Record<EvalLegKindValue, string>> = {
86
+ [EvalLegKind.EvalSuite]: "eval-suite.criteria.json",
87
+ [EvalLegKind.CodeQuality]: "code-quality.criteria.json",
88
+ };
89
+
90
+ /**
91
+ * Resolve the located eval-criteria artifact path for a leg:
92
+ * `<cwd>/.mutagent/evaluator/living-suite/<leg>.criteria.json`. Routes through the
93
+ * `artifact-paths.ts` no-spillover guard (`assertUnderRoot`), so the writer can NEVER
94
+ * escape the evaluator's namespaced root. `cwd` passed in (pure path construction).
95
+ */
96
+ export function evalCriteriaArtifactPath(leg: EvalLegKindValue, cwd?: string): string {
97
+ return assertUnderRoot(join(livingSuiteDir(cwd), EVAL_CRITERIA_ARTIFACT_FILENAMES[leg]), cwd);
98
+ }
99
+
100
+ // ── serialize / build (pure, deterministic — no clock) ───────────────────────
101
+
102
+ /** Build the on-disk artifact from a reconcile RESULT. Pure. `updatedAt` passed in. */
103
+ export function evalCriteriaArtifactFromResult(
104
+ result: EvalCriteriaReconcileResult,
105
+ updatedAt?: string,
106
+ ): EvalCriteriaArtifact {
107
+ return {
108
+ subjectId: result.subjectId,
109
+ leg: result.leg,
110
+ criteria: result.criteria,
111
+ provenance: result.provenance,
112
+ ...(updatedAt !== undefined ? { updatedAt } : {}),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Serialize an artifact to its on-disk JSON — DETERMINISTIC: explicit key order, 2-space
118
+ * indent, trailing newline. Same artifact → byte-identical string (round-trip faithful);
119
+ * no clock unless the artifact already carries a caller-supplied `updatedAt`.
120
+ */
121
+ export function serializeEvalCriteriaArtifact(artifact: EvalCriteriaArtifact): string {
122
+ const ordered: EvalCriteriaArtifact = {
123
+ subjectId: artifact.subjectId,
124
+ leg: artifact.leg,
125
+ criteria: artifact.criteria,
126
+ provenance: artifact.provenance,
127
+ ...(artifact.updatedAt !== undefined ? { updatedAt: artifact.updatedAt } : {}),
128
+ };
129
+ return `${JSON.stringify(ordered, null, 2)}\n`;
130
+ }
131
+
132
+ /** Guarded parse of a persisted artifact. THROWS with the first schema error. PURE. */
133
+ export function parseEvalCriteriaArtifact(value: unknown): EvalCriteriaArtifact {
134
+ if (!Value.Check(EvalCriteriaArtifactSchema, value)) {
135
+ const first = [...Value.Errors(EvalCriteriaArtifactSchema, value)][0];
136
+ throw new Error(
137
+ `parseEvalCriteriaArtifact: schema violation at '${first?.path ?? "(root)"}': ` +
138
+ `${first?.message ?? "invalid eval-criteria artifact"}`,
139
+ );
140
+ }
141
+ return value;
142
+ }
143
+
144
+ // ── the WRITER + reader (the IO layer) ───────────────────────────────────────
145
+
146
+ /** The result of a persist: WHERE it landed + WHAT was written (artifact + bytes). */
147
+ export interface PersistEvalCriteriaResult {
148
+ /** the located artifact path the criteria were written to. */
149
+ path: string;
150
+ /** the artifact that was serialized. */
151
+ artifact: EvalCriteriaArtifact;
152
+ /** the exact bytes written (for a byte-faithful round-trip assertion). */
153
+ json: string;
154
+ }
155
+
156
+ /**
157
+ * PERSIST a reconcile result's `criteria` back to the located eval-suite criteria artifact
158
+ * — the WRITE that returns the eval leg to `in-sync`. Resolves the per-leg path under
159
+ * `.mutagent/evaluator/living-suite/` (no-spillover-guarded via `artifact-paths.ts`),
160
+ * ensures the dir, and writes the deterministic JSON. Handles BOTH legs by `result.leg`:
161
+ * the eval-suite leg (agent/skill/composite subject) and the code-quality leg (a code
162
+ * subject, incl. an operator-overridden code-quality set). The freshness bump is the file
163
+ * write itself (mtime advances → the builder probe reads the eval leg as fresh); pass
164
+ * `options.updatedAt` (ISO 8601) to ALSO stamp a content-level freshness marker the probe's
165
+ * `parseEvalUpdatedAt` reads. IO — NOT pure (that is its job); serialization is deterministic.
166
+ */
167
+ export function persistEvalCriteria(
168
+ result: EvalCriteriaReconcileResult,
169
+ options: { cwd?: string; updatedAt?: string } = {},
170
+ ): PersistEvalCriteriaResult {
171
+ const artifact = evalCriteriaArtifactFromResult(result, options.updatedAt);
172
+ const path = evalCriteriaArtifactPath(result.leg, options.cwd);
173
+ const json = serializeEvalCriteriaArtifact(artifact);
174
+ mkdirSync(dirname(path), { recursive: true });
175
+ writeFileSync(path, json);
176
+ return { path, artifact, json };
177
+ }
178
+
179
+ /** Read + validate a persisted artifact (round-trip). THROWS on a schema violation. */
180
+ export function readEvalCriteriaArtifact(path: string): EvalCriteriaArtifact {
181
+ return parseEvalCriteriaArtifact(JSON.parse(readFileSync(path, "utf8")) as unknown);
182
+ }
183
+
184
+ /**
185
+ * Load the CURRENTLY-persisted criteria for a leg (the `existing` set the next reconcile
186
+ * grows). Returns `[]` when no artifact exists yet (the cold / first-reconcile case) so a
187
+ * caller can feed it straight into an `EvalCriteriaReconcileRequest.existing`. Reads only.
188
+ */
189
+ export function loadExistingEvalCriteria(leg: EvalLegKindValue, cwd?: string): EvalCriterion[] {
190
+ const path = evalCriteriaArtifactPath(leg, cwd);
191
+ if (!existsSync(path)) return [];
192
+ return readEvalCriteriaArtifact(path).criteria;
193
+ }
194
+
195
+ /**
196
+ * The eval-criteria artifact's effective freshness epoch (seconds) — the evaluator-side
197
+ * MIRROR of the builder `check-sync-spec.ts` eval-leg compare: `max(updatedAt, mtime)`
198
+ * (git omitted — kept in-package + git-independent). `null` when the artifact is absent.
199
+ * Feed this as `evalCriteriaEpoch` to `flagEvalLegDrift` to confirm a fresh write closed
200
+ * the loop (`in-sync`). Reads mtime (IO).
201
+ */
202
+ export function evalCriteriaEffectiveEpoch(path: string): number | null {
203
+ if (!existsSync(path)) return null;
204
+ const epochs: number[] = [];
205
+ try {
206
+ const parsed = readEvalCriteriaArtifact(path);
207
+ if (parsed.updatedAt !== undefined) {
208
+ const ms = Date.parse(parsed.updatedAt);
209
+ if (Number.isFinite(ms)) epochs.push(Math.floor(ms / 1000));
210
+ }
211
+ } catch {
212
+ // a corrupt artifact still has an mtime — fall back to it for freshness.
213
+ }
214
+ epochs.push(Math.floor(statSync(path).mtimeMs / 1000));
215
+ return Math.max(...epochs);
216
+ }
217
+
218
+ /**
219
+ * Convenience for the session: COMPUTE the reconcile (pure) THEN PERSIST it (IO) in one
220
+ * call — the exact "reconcile → write → in-sync" step the `#sync-spec` eval leg performs.
221
+ * Still Model-B: the criteria DELTA (`request.proposed`) is the ai-architect's reasoning;
222
+ * this only applies it deterministically and writes it. Returns the compute result + the
223
+ * persist result together.
224
+ */
225
+ export function reconcileAndPersistEvalCriteria(
226
+ request: EvalCriteriaReconcileRequest,
227
+ options: { cwd?: string; updatedAt?: string } = {},
228
+ ): { result: EvalCriteriaReconcileResult; persisted: PersistEvalCriteriaResult } {
229
+ const result = reconcileEvalCriteria(request);
230
+ const persisted = persistEvalCriteria(result, options);
231
+ return { result, persisted };
232
+ }
@@ -1566,7 +1566,7 @@ export function writeDiscoverRunReport(
1566
1566
  return writeDiscoverReportFromFiles(
1567
1567
  {
1568
1568
  criteriaPath: join(dir, "criteria.json"),
1569
- outPath: join(dir, "report.html"),
1569
+ outPath: join(dir, "discovery-report.html"),
1570
1570
  groundingPath: join(dir, "grounding-check.json"),
1571
1571
  triagePath: triageSummaryPath,
1572
1572
  verdictsDir: join(dir, "verdicts"),
@@ -358,6 +358,9 @@ export interface CriterionResolution {
358
358
 
359
359
  export interface EvalReportInput {
360
360
  subject: { name: string; org?: string; source?: string; models?: string[] };
361
+ /** PRD R-4 — render audience. Default "client" (leak-safe): the §5 Self-Eval [INTERNAL]
362
+ * tab + nav button are NOT emitted. "internal" (admin opt-in) shows them. */
363
+ audience?: "client" | "internal";
361
364
  /** the v2 *evaluate Scorecard (GATE + variance). */
362
365
  scorecard: Scorecard;
363
366
  /** the folded per-criterion verdicts. */
@@ -3479,6 +3482,16 @@ export function renderEvalReport(input: EvalReportInput): string {
3479
3482
  const theme = readFileSync(join(BRAND_DIR, "theme.css"), "utf8");
3480
3483
  const wordmark = readFileSync(join(BRAND_DIR, "wordmark.html"), "utf8");
3481
3484
 
3485
+ // PRD R-4 — audience gate. CLIENT is the DEFAULT (leak-safe): the §5 Self-Eval [INTERNAL]
3486
+ // tab + nav button are NOT emitted into the HTML at all (NODE-STRIP, not CSS-hidden).
3487
+ const internal = (input.audience ?? "client") === "internal";
3488
+ const selfEvalNav = internal
3489
+ ? ` <button class="tab-btn internal" data-tab="t5">⑤ Self-Eval [INTERNAL]</button>\n`
3490
+ : "";
3491
+ const selfEvalPanel = internal
3492
+ ? ` <section class="panel" id="t5">${selfEvalTab(input)}</section>\n`
3493
+ : "";
3494
+
3482
3495
  const headerTitle = "evaluator · Eval Report";
3483
3496
  const headerMeta =
3484
3497
  `<span class="mk">subject</span> <span class="mv">${esc(input.subject.name)}</span>` +
@@ -3564,15 +3577,13 @@ ${header}
3564
3577
  <button class="tab-btn" data-tab="t2">② Trajectory · Judge Behaviour</button>
3565
3578
  <button class="tab-btn" data-tab="t3">③ Eval Scorecard</button>
3566
3579
  <button class="tab-btn" data-tab="t4">④ Findings</button>
3567
- <button class="tab-btn internal" data-tab="t5">⑤ Self-Eval [INTERNAL]</button>
3568
- </nav>
3580
+ ${selfEvalNav}</nav>
3569
3581
  <main>
3570
3582
  <section class="panel active" id="t1">${overviewTab(input)}</section>
3571
3583
  <section class="panel" id="t2">${trajectoryTab(input)}</section>
3572
3584
  <section class="panel" id="t3">${scorecardTab(input)}</section>
3573
3585
  <section class="panel" id="t4">${findingsTab(input)}</section>
3574
- <section class="panel" id="t5">${selfEvalTab(input)}</section>
3575
- </main>
3586
+ ${selfEvalPanel}</main>
3576
3587
  <script data-pii="ledger">
3577
3588
  ${clientScript(ledgerJson, critJson, walksJson, resMetaJson)}
3578
3589
  </script>
@@ -3635,6 +3646,8 @@ export function writeEvalReportFromFiles(
3635
3646
  * one trace off the raw ndjson and passes it here; the system prompt is identical
3636
3647
  * across the batch so one trace suffices. ABSENT ⇒ system prompt stays UNAVAILABLE. */
3637
3648
  traces?: EvalTrace[];
3649
+ /** PRD R-4 — render audience (default "client", leak-safe). "internal" shows §5. */
3650
+ audience?: "client" | "internal";
3638
3651
  },
3639
3652
  io: EvalReportFilesIO,
3640
3653
  ): { report: string; completeness: EvalReportCompleteness } {
@@ -3746,6 +3759,8 @@ export function writeEvalReportFromFiles(
3746
3759
  // WS-6 — forward the trace(s) so the system prompt is reconstructed for the hero.
3747
3760
  ...(params.traces !== undefined ? { traces: params.traces } : {}),
3748
3761
  });
3762
+ // PRD R-4 — set audience on the built input (buildEvalReportInput does not forward it).
3763
+ if (params.audience !== undefined) input.audience = params.audience;
3749
3764
  io.writeFile(params.outPath, renderEvalReport(input));
3750
3765
 
3751
3766
  // 5) the data-completeness probe over the BOUND ledger (what the UI will render).
@@ -3803,9 +3818,9 @@ async function main(): Promise<void> {
3803
3818
  const { join: pjoin } = await import("node:path");
3804
3819
  const input = JSON.parse(rf(inputPath, "utf8")) as EvalReportInput;
3805
3820
  // P8: a bare run DEFAULTS the report under the localized dot-root
3806
- // `.mutagent/evaluator/reports/<runId>/report.html` (never /tmp or .memory).
3821
+ // `.mutagent/evaluator/reports/<runId>/evaluation-report.html` (never /tmp or .memory).
3807
3822
  const runId = runIdArg ?? input.subject.name;
3808
- const outPath = outArg ?? pjoin(reportDir(runId), "report.html");
3823
+ const outPath = outArg ?? pjoin(reportDir(runId), "evaluation-report.html");
3809
3824
  mkdirSync(pjoin(outPath, ".."), { recursive: true });
3810
3825
  console.info(renderEvalCards(input));
3811
3826
  writeFileSync(outPath, renderEvalReport(input));
@@ -40,7 +40,13 @@ export const AdlStage = {
40
40
  } as const;
41
41
  export type AdlStageValue = (typeof AdlStage)[keyof typeof AdlStage];
42
42
 
43
- export const SubjectKind = { Skill: "skill", Agent: "agent" } as const;
43
+ // Mirrors the orchestrator handover-contract SubjectKind. The CANONICAL inter-stage
44
+ // subject vocabulary is `agent | skill | code` (matches config-schema TargetSubject).
45
+ // `code` (Wave-2 follow-up) is ADDITIVE — every existing skill/agent handoff still
46
+ // validates; it only lets a `code` subject route through *evaluate's failure handoff
47
+ // the SAME way it flows through the orchestrator handover-contract (the two are kept
48
+ // in lock-step by design; the evaluator re-implements the shape, never imports it).
49
+ export const SubjectKind = { Skill: "skill", Agent: "agent", Code: "code" } as const;
44
50
  export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind];
45
51
 
46
52
  export const ArtifactKind = {
@@ -67,7 +73,11 @@ export type EscalationPolicyValue =
67
73
  // ── TypeBox schemas (closed objects — auditable boundary) ───────────────────
68
74
  const SubjectSchema = Type.Object(
69
75
  {
70
- kind: Type.Union([Type.Literal(SubjectKind.Skill), Type.Literal(SubjectKind.Agent)]),
76
+ kind: Type.Union([
77
+ Type.Literal(SubjectKind.Skill),
78
+ Type.Literal(SubjectKind.Agent),
79
+ Type.Literal(SubjectKind.Code),
80
+ ]),
71
81
  name: Type.String({ minLength: 1 }),
72
82
  path: Type.String({ minLength: 1 }),
73
83
  },