@mutagent/evaluator 0.1.0-alpha.4 → 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 (38) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +6 -5
  2. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +76 -12
  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/references/operation-inventory.md +1 -0
  9. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  10. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  11. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  12. package/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +6 -3
  13. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  14. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  15. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +277 -0
  16. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  17. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +39 -0
  18. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  19. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  20. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  21. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  22. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  23. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  24. package/.claude/skills/mutagent-evaluator/scripts/persist-eval-criteria.ts +232 -0
  25. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  26. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  27. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  28. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  29. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +9 -91
  30. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  31. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +14 -4
  32. package/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +114 -18
  33. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  34. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  35. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +244 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  37. package/bin/mutagent-cli.mjs +659 -8
  38. package/package.json +2 -2
@@ -0,0 +1,165 @@
1
+ /**
2
+ * scripts/memory/ratify.ts — EV-4: persist ratify / do-not decisions + hold the verdict.
3
+ * ---------------------------------------------------------------------------
4
+ * The dominant dogfood finding ("router-as-doer") rested on a NORMATIVE call —
5
+ * what counts as correct routing — that only the operator can settle. The review
6
+ * UI already CAPTURES the operator's verify/eliminate decisions on each surfaced
7
+ * assumption (`build-review-ui.ts` `setCalib` → `calibration.json`), but wrote them
8
+ * NOWHERE: a verdict resting on an unratified standard still read as final.
9
+ *
10
+ * This is the LEAN wiring (decision EV-4 option a — NO new criterion schema):
11
+ * 1. ROUTE each calibration decision into `appendMemory` — the same store the
12
+ * next run's judges read at start (`memory/read.ts`), deduped by slug.
13
+ * 2. HOLD the verdict: a NORMATIVE criterion with no `verify` ratification in the
14
+ * decision set is `needs-ratification`; while any is pending the run's verdict
15
+ * is HELD (not final) — the value call must be settled first.
16
+ *
17
+ * PURE cores (deterministic; `now` injected). The only fs edge reuses `appendMemory`.
18
+ */
19
+ import { appendMemory, Lifecycle, MemoryType, type AppendResult } from "./append.ts";
20
+ import { AssumptionKind, type CriterionVerdict } from "../contracts/eval-types.ts";
21
+
22
+ /**
23
+ * The criterion ids whose verdict rests on a NORMATIVE assumption (a value/standard
24
+ * call — `AssumptionKind.Normative`), whether surfaced in `assumptions[]` or the
25
+ * `blockedBy` payload. These are the criteria that REQUIRE operator ratification
26
+ * before their verdict can be final. PURE.
27
+ */
28
+ export function normativeCriterionIds(verdicts: CriterionVerdict[]): string[] {
29
+ const ids = new Set<string>();
30
+ for (const v of verdicts) {
31
+ const inAssumptions = (v.assumptions ?? []).some((a) => a.kind === AssumptionKind.Normative);
32
+ const inBlock = v.blockedBy?.kind === AssumptionKind.Normative;
33
+ if (inAssumptions || inBlock) ids.add(v.criterionId);
34
+ }
35
+ return [...ids].sort();
36
+ }
37
+
38
+ /** The verify/eliminate action a review-UI calibration button emits (`data-action`). */
39
+ export const RatifyAction = {
40
+ /** the operator RATIFIES the surfaced assumption/standard (accept it). */
41
+ Verify: "verify",
42
+ /** the operator rejects it (do-not — drop this standard). */
43
+ Eliminate: "eliminate",
44
+ } as const;
45
+ export type RatifyActionValue = (typeof RatifyAction)[keyof typeof RatifyAction];
46
+
47
+ /**
48
+ * One calibration decision as the review UI exports it (`calibration.json`). Shape
49
+ * mirrors `build-review-ui.ts` `setCalib` EXACTLY so the operator's export routes
50
+ * with no transform.
51
+ */
52
+ export interface CalibrationDecision {
53
+ traceId: string;
54
+ criterionId: string;
55
+ assumptionIndex: number;
56
+ action: RatifyActionValue;
57
+ decidedAt?: string;
58
+ }
59
+
60
+ /** A stable slug for a ratification memory (dedupe: re-deciding the same pair updates). */
61
+ export function ratificationSlug(d: CalibrationDecision): string {
62
+ return `ratify-${d.criterionId}-a${d.assumptionIndex}`;
63
+ }
64
+
65
+ /**
66
+ * Project a calibration decision → an AutoMemory FeedbackInput. A `verify` is a
67
+ * standing "this standard IS ratified" fact the next run's judge honours; an
68
+ * `eliminate` records the operator dropped it. PURE.
69
+ */
70
+ export function calibrationToMemory(
71
+ d: CalibrationDecision,
72
+ criterionStatement?: string,
73
+ ): { title: string; description: string; body: string } {
74
+ const ratified = d.action === RatifyAction.Verify;
75
+ const what = criterionStatement !== undefined && criterionStatement.length > 0 ? criterionStatement : d.criterionId;
76
+ const title = ratificationSlug(d);
77
+ const verb = ratified ? "RATIFIED" : "REJECTED (do-not)";
78
+ return {
79
+ title,
80
+ description: `Normative ratification — criterion ${d.criterionId} assumption #${d.assumptionIndex} ${verb}`,
81
+ body:
82
+ `Operator ${verb} the normative standard for criterion \`${d.criterionId}\` ` +
83
+ `(assumption #${d.assumptionIndex}).\n\n` +
84
+ `**What:** ${what}\n` +
85
+ `**Why:** a normative criterion (what SHOULD count as good) is an operator value call — ` +
86
+ `the judge cannot settle it. This decision is the settled standard.\n` +
87
+ `**How to apply:** ${ratified
88
+ ? "treat this standard as ratified — the verdict on this criterion may now be final."
89
+ : "do NOT gate on this standard — the operator dropped it."}\n` +
90
+ (d.decidedAt !== undefined ? `\n(decided ${d.decidedAt})` : ""),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * ROUTE calibration decisions into AutoMemory (EV-4). One `appendMemory` per
96
+ * decision, deduped by slug (re-deciding UPDATES). Returns the append results.
97
+ * `now` (YYYY-MM-DD) is INJECTED. `statements` optionally maps criterionId → its
98
+ * statement for a richer memory body.
99
+ */
100
+ export function persistCalibrations(
101
+ memoryDir: string,
102
+ decisions: CalibrationDecision[],
103
+ now: string,
104
+ statements: Record<string, string> = {},
105
+ ): AppendResult[] {
106
+ return decisions.map((d) => {
107
+ const m = calibrationToMemory(d, statements[d.criterionId]);
108
+ return appendMemory(
109
+ memoryDir,
110
+ {
111
+ title: m.title,
112
+ description: m.description,
113
+ lifecycle: Lifecycle.Evaluate,
114
+ type: MemoryType.Feedback, // a standing steer the next run's judge honours
115
+ feedback: { text: m.body },
116
+ },
117
+ now,
118
+ );
119
+ });
120
+ }
121
+
122
+ /** The ratification state of one normative criterion (EV-4). */
123
+ export interface RatificationStatus {
124
+ criterionId: string;
125
+ /** true when a `verify` decision ratified it; false ⇒ needs-ratification. */
126
+ ratified: boolean;
127
+ }
128
+
129
+ /**
130
+ * Given the NORMATIVE criterion ids (those resting on a normative assumption) and
131
+ * the operator's calibration decisions, report which are RATIFIED (a `verify`
132
+ * exists) vs still `needs-ratification`. An `eliminate` does NOT ratify — the
133
+ * standard was dropped, not settled-as-correct. PURE.
134
+ */
135
+ export function ratificationStatus(
136
+ normativeCriterionIds: string[],
137
+ decisions: CalibrationDecision[],
138
+ ): RatificationStatus[] {
139
+ const ratified = new Set(
140
+ decisions.filter((d) => d.action === RatifyAction.Verify).map((d) => d.criterionId),
141
+ );
142
+ return normativeCriterionIds.map((id) => ({ criterionId: id, ratified: ratified.has(id) }));
143
+ }
144
+
145
+ /**
146
+ * The criterion ids that HOLD the verdict (EV-4): normative criteria not yet
147
+ * ratified. While this list is non-empty the run's verdict is NOT final — the
148
+ * operator must settle the value call first. PURE.
149
+ */
150
+ export function pendingRatifications(
151
+ normativeCriterionIds: string[],
152
+ decisions: CalibrationDecision[],
153
+ ): string[] {
154
+ return ratificationStatus(normativeCriterionIds, decisions)
155
+ .filter((s) => !s.ratified)
156
+ .map((s) => s.criterionId);
157
+ }
158
+
159
+ /** Is the run's verdict HELD pending an operator ratification? (EV-4) */
160
+ export function isVerdictHeldForRatification(
161
+ normativeCriterionIds: string[],
162
+ decisions: CalibrationDecision[],
163
+ ): boolean {
164
+ return pendingRatifications(normativeCriterionIds, decisions).length > 0;
165
+ }
@@ -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
+ }
@@ -27,7 +27,7 @@
27
27
  import { existsSync, readFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { extractOutcomeSignals, type JudgeInvoke } from "./determine-outcome.ts";
30
- import { buildOutcomePrompt } from "./judge-prompt-template.ts";
30
+ import { buildOutcomePrompt, type SubjectFrame } from "./judge-prompt-template.ts";
31
31
  import {
32
32
  writeJudgeTask,
33
33
  verdictFileName,
@@ -53,19 +53,27 @@ const PREP_PLACEHOLDER = JSON.stringify({
53
53
  */
54
54
  export function prepDeterminerTasks(
55
55
  traces: EvalTrace[],
56
- opts: { dir: string; pin: PinnedEnvelope; vocab: SubjectVocab },
56
+ opts: { dir: string; pin: PinnedEnvelope; vocab: SubjectVocab; subject?: SubjectFrame },
57
57
  ): JudgeTaskSpec[] {
58
58
  return traces.map((trace) => {
59
+ // EV-1 — the subject frame MUST match what the Stage-B pipeline renders (byte-
60
+ // identity for the cross-stage cache), so the caller derives it from the SAME
61
+ // full trace batch buildSubjectProfile sees in run-pipeline.
59
62
  const { system, user } = buildOutcomePrompt(
60
63
  trace,
61
64
  extractOutcomeSignals(trace, opts.vocab),
62
65
  opts.vocab,
66
+ opts.subject,
63
67
  );
64
68
  return writeJudgeTask(opts.dir, {
65
69
  unit: { kind: "discover", traceId: trace.id },
66
70
  system,
67
71
  user,
68
72
  pin: opts.pin,
73
+ // INF-3 — salt the determiner cache key with the trace id so two sessions with
74
+ // an identical (e.g. empty) determiner prompt never collapse onto one verdict.
75
+ // AGGREGATE reads the stored `spec.verdictFile`, so this needs no re-derive there.
76
+ keyId: trace.id,
69
77
  });
70
78
  });
71
79
  }
@@ -84,8 +92,11 @@ export function createCapturingJudge(opts: {
84
92
  pin: PinnedEnvelope;
85
93
  }): { judge: JudgeInvoke; captured: JudgeTaskSpec[] } {
86
94
  const captured: JudgeTaskSpec[] = [];
87
- const judge: JudgeInvoke = (system, user) => {
88
- const vpath = join(opts.verdictDir, verdictFileName(system, user));
95
+ const judge: JudgeInvoke = (system, user, keyId) => {
96
+ // INF-3 the determiner passes its trace id (keyId) so the salted stage-A
97
+ // verdict file is found; criterion-judge prompts pass no keyId ⇒ unsalted,
98
+ // and are CAPTURED as build-evals tasks below (byte-identical key, C-PIN).
99
+ const vpath = join(opts.verdictDir, verdictFileName(system, user, keyId));
89
100
  if (existsSync(vpath)) return Promise.resolve(readFileSync(vpath, "utf8"));
90
101
  captured.push(
91
102
  // D4 — the criterion judging axis (*build-evals / Step-2b). `kind` now matches the
@@ -0,0 +1,120 @@
1
+ /**
2
+ * scripts/read-manifest.ts — EV-6: the sibling TraceManifest leg of the Discovery handoff.
3
+ * ---------------------------------------------------------------------------
4
+ * The Discovery handoff is a PATH PAIR — the UniTF `.jsonl` PLUS a sibling
5
+ * `TraceManifest` that reports the slice (count · format · truncation · coverage).
6
+ * Diagnostics reads BOTH and audits the slice BEFORE it judges; the evaluator used
7
+ * to read only the JSONL (a bare `--traces` path) and never opened the manifest —
8
+ * so a truncated or partially-malformed export was judged silently as if complete
9
+ * (EV-6). This wires the manifest leg into the evaluator intake, WARN-not-fail, the
10
+ * way diagnostics' `read-unitf.ts` `validateUniTFManifest` does.
11
+ *
12
+ * STANDALONE — the manifest shape is VENDORED (a minimal structural subset of
13
+ * `@mutagent/tools` `trace-manifest.ts`), NOT imported: the standalone-publish
14
+ * guard forbids the evaluator reaching into `@mutagent/tools`
15
+ * (MIGRATION-diagnostics-evaluator.md:417). Only the fields the audit reads are
16
+ * mirrored; diagnostics vendors its own identical copy.
17
+ *
18
+ * PURE except for the effectful `validateSiblingManifest` (one fs read of a sibling
19
+ * path). No clock / random / network.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+
23
+ /** The manifest `format` marker that pairs with the UniTF version. */
24
+ export const UNITF_MANIFEST_FORMAT = "unitf@0.1" as const;
25
+
26
+ /**
27
+ * The sibling TraceManifest (frozen `TraceManifest v0.1.0`), structural subset —
28
+ * only the fields the slice audit inspects. Every field is optional so a partial
29
+ * or older manifest degrades to fewer warnings rather than throwing.
30
+ */
31
+ export interface UnitfTraceManifest {
32
+ manifest_version?: string;
33
+ count?: number;
34
+ truncated?: boolean;
35
+ truncationReason?: string;
36
+ format?: string;
37
+ coverage?: {
38
+ fetched?: number;
39
+ malformed?: number;
40
+ deduped?: number;
41
+ };
42
+ warnings?: string[];
43
+ }
44
+
45
+ /**
46
+ * The sibling manifest path for a handed-over traces file: `<path>.manifest.json`,
47
+ * with a `.jsonl` suffix REPLACED (`traces.jsonl` → `traces.manifest.json`) —
48
+ * mirrors `@mutagent/tools` `manifestPathFor`. A `.gz` suffix is stripped first
49
+ * (the evaluator loads `.jsonl.gz` too) so `traces.jsonl.gz` also resolves to
50
+ * `traces.manifest.json`. PURE.
51
+ */
52
+ export function manifestPathForTraces(tracesPath: string): string {
53
+ const base = tracesPath.endsWith(".gz") ? tracesPath.slice(0, -".gz".length) : tracesPath;
54
+ return base.endsWith(".jsonl")
55
+ ? base.slice(0, -".jsonl".length) + ".manifest.json"
56
+ : base + ".manifest.json";
57
+ }
58
+
59
+ /**
60
+ * Validate a parsed manifest against the parsed trace count. Returns WARNINGS
61
+ * (never throws, never aborts) — the migration's "surface warnings but do NOT
62
+ * abort a partial export" rule. Empty array = clean / no manifest. Mirrors
63
+ * diagnostics `validateUniTFManifest`.
64
+ */
65
+ export function validateUnitfManifest(
66
+ manifest: UnitfTraceManifest | undefined,
67
+ actualTraceCount: number,
68
+ ): string[] {
69
+ const warnings: string[] = [];
70
+ if (!manifest) return warnings;
71
+ if (manifest.format !== undefined && manifest.format !== UNITF_MANIFEST_FORMAT) {
72
+ warnings.push(`manifest.format is "${manifest.format}" — expected "${UNITF_MANIFEST_FORMAT}"`);
73
+ }
74
+ if (manifest.count !== undefined && manifest.count !== actualTraceCount) {
75
+ warnings.push(
76
+ `manifest.count (${manifest.count}) != parsed trace count (${actualTraceCount}) — ` +
77
+ `the slice is incomplete (a truncated/partially-malformed export judged as if complete)`,
78
+ );
79
+ }
80
+ if (manifest.truncated === true) {
81
+ warnings.push(
82
+ `export is truncated${manifest.truncationReason ? ` (${manifest.truncationReason})` : ""} — ` +
83
+ `judging a PARTIAL slice`,
84
+ );
85
+ }
86
+ // Coverage cross-check: fetched should account for the parsed + malformed + deduped
87
+ // records; a fetched count that exceeds what landed on disk is a silent drop.
88
+ const cov = manifest.coverage;
89
+ if (cov?.fetched !== undefined) {
90
+ const accountedFor = actualTraceCount + (cov.malformed ?? 0) + (cov.deduped ?? 0);
91
+ if (cov.fetched !== accountedFor) {
92
+ warnings.push(
93
+ `manifest.coverage.fetched (${cov.fetched}) != parsed(${actualTraceCount}) + ` +
94
+ `malformed(${cov.malformed ?? 0}) + deduped(${cov.deduped ?? 0}) = ${accountedFor} — ` +
95
+ `records went missing between fetch and hand-off`,
96
+ );
97
+ }
98
+ }
99
+ if (manifest.warnings) warnings.push(...manifest.warnings);
100
+ return warnings;
101
+ }
102
+
103
+ /**
104
+ * Read + validate the sibling manifest for a handed-over traces file. Effectful
105
+ * (one fs read). NON-fatal throughout: an ABSENT manifest returns [] (the handoff
106
+ * may predate the manifest leg); an UNREADABLE / unparseable manifest yields a
107
+ * single warning and proceeds. Returns the human-readable warning list the caller
108
+ * surfaces on stderr.
109
+ */
110
+ export function validateSiblingManifest(tracesPath: string, actualTraceCount: number): string[] {
111
+ const manifestPath = manifestPathForTraces(tracesPath);
112
+ if (!existsSync(manifestPath)) return []; // no manifest leg — nothing to audit
113
+ let manifest: UnitfTraceManifest | undefined;
114
+ try {
115
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as UnitfTraceManifest;
116
+ } catch {
117
+ return [`sibling manifest '${manifestPath}' failed to read/parse — proceeding without slice audit`];
118
+ }
119
+ return validateUnitfManifest(manifest, actualTraceCount);
120
+ }
@@ -23,6 +23,30 @@ export interface ParsedUnitfTraces {
23
23
  traces: EvalTrace[];
24
24
  /** lines that failed to parse OR lacked the minimal UniTF shape (surfaced). */
25
25
  skipped: number;
26
+ /**
27
+ * INF-1 — traceIds of records that carry a prompt-bearing span (a user/system
28
+ * turn, or an assistant/agent GENERATION span) yet projected to an EMPTY
29
+ * `input.prompt`. This is the exact silent-empty symptom that read 21/21 blank
30
+ * on Claude Code traces: the reader must WARN, never emit empty in silence. The
31
+ * effectful reader/CLI surfaces this list; a non-empty list is a projection miss.
32
+ */
33
+ emptyPromptTraceIds: string[];
34
+ }
35
+
36
+ /**
37
+ * Does this record carry a span that SHOULD yield a prompt — an explicit user/
38
+ * system turn, or a GENERATION span (kind llm/agent, the Langfuse shape whose
39
+ * `input` holds the prompt)? Used to WARN when such a record still projects an
40
+ * empty `input.prompt` (the INF-1 silent-empty class). PURE.
41
+ */
42
+ function hasPromptBearingSpan(rec: UnifiedTraceLike): boolean {
43
+ return rec.spans.some(
44
+ (s) =>
45
+ s.role === "user" ||
46
+ s.role === "system" ||
47
+ s.kind === "llm" ||
48
+ s.kind === "agent",
49
+ );
26
50
  }
27
51
 
28
52
  /**
@@ -45,6 +69,7 @@ function asUnitfRecord(value: unknown): UnifiedTraceLike | null {
45
69
  */
46
70
  export function parseUnitfJsonl(text: string): ParsedUnitfTraces {
47
71
  const traces: EvalTrace[] = [];
72
+ const emptyPromptTraceIds: string[] = [];
48
73
  let skipped = 0;
49
74
  for (const line of text.split("\n")) {
50
75
  const trimmed = line.trim();
@@ -61,9 +86,16 @@ export function parseUnitfJsonl(text: string): ParsedUnitfTraces {
61
86
  skipped += 1;
62
87
  continue;
63
88
  }
64
- traces.push(projectUnitfToEvalTrace(rec));
89
+ const trace = projectUnitfToEvalTrace(rec);
90
+ traces.push(trace);
91
+ // INF-1 — WARN, never silently emit empty: a record with a prompt-bearing span
92
+ // that still projected an empty prompt is a projection miss (the Claude Code
93
+ // 21/21-blank class). Recorded here; the CLI/reader surfaces it.
94
+ if (hasPromptBearingSpan(rec) && (trace.input?.prompt ?? "").length === 0) {
95
+ emptyPromptTraceIds.push(trace.id);
96
+ }
65
97
  }
66
- return { traces, skipped };
98
+ return { traces, skipped, emptyPromptTraceIds };
67
99
  }
68
100
 
69
101
  /**