@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.
- package/.claude/skills/mutagent-evaluator/SKILL.md +6 -5
- package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +76 -12
- package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
- package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
- package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
- package/.claude/skills/mutagent-evaluator/references/eval-stage.md +36 -3
- package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
- package/.claude/skills/mutagent-evaluator/references/operation-inventory.md +1 -0
- package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
- package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
- package/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +6 -3
- package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
- package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +277 -0
- package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
- package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +39 -0
- package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
- package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
- package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
- package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
- package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
- package/.claude/skills/mutagent-evaluator/scripts/persist-eval-criteria.ts +232 -0
- package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
- package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
- package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
- package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
- package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +9 -91
- package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
- package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +14 -4
- package/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +114 -18
- package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
- package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
- package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +244 -0
- package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
- package/bin/mutagent-cli.mjs +659 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
/**
|