@skill-harness/core 0.5.0 → 0.7.0
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/dist/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +16 -1
- package/dist/lint.js +52 -0
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +48 -0
- package/dist/report.js +39 -1
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.d.ts +9 -1
- package/dist/run.js +129 -9
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +26 -0
- package/dist/sources.js +82 -3
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.d.ts +144 -0
- package/dist/stability.js +232 -0
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/trends.d.ts +28 -0
- package/dist/trends.js +76 -61
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
export const PREDICATE_KEYS = ["equals", "contains", "starts_with", "ends_with", "matches", "exists", "any"];
|
|
2
|
+
/**
|
|
3
|
+
* Recognize the known subagent argument shapes.
|
|
4
|
+
*
|
|
5
|
+
* Three are supported because three exist in the wild; anything else yields an
|
|
6
|
+
* empty list, and the scenario should use plain `require_calls` instead. It
|
|
7
|
+
* deliberately does NOT guess: inventing an `agent` from an unrecognized shape
|
|
8
|
+
* would produce a confident assertion about a field nobody wrote.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeSubagentCall(args) {
|
|
11
|
+
const one = (v) => {
|
|
12
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
13
|
+
return null;
|
|
14
|
+
const o = v;
|
|
15
|
+
const agent = typeof o.agent === "string" ? o.agent : typeof o.name === "string" ? o.name : undefined;
|
|
16
|
+
if (agent === undefined)
|
|
17
|
+
return null;
|
|
18
|
+
const task = typeof o.task === "string" ? o.task : typeof o.prompt === "string" ? o.prompt : "";
|
|
19
|
+
return { agent, task };
|
|
20
|
+
};
|
|
21
|
+
// Parallel: { tasks: [ {agent, task}, … ] }
|
|
22
|
+
if (Array.isArray(args.tasks))
|
|
23
|
+
return args.tasks.map(one).filter((x) => x !== null);
|
|
24
|
+
// Chain: { chain: [ {agent, task}, … ] }
|
|
25
|
+
if (Array.isArray(args.chain))
|
|
26
|
+
return args.chain.map(one).filter((x) => x !== null);
|
|
27
|
+
// Single: { agent, task }
|
|
28
|
+
const single = one(args);
|
|
29
|
+
return single ? [single] : [];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Evaluate every assertion. All of them run even after the first failure — a
|
|
33
|
+
* scorecard that reports one problem per run makes the author re-run to find the
|
|
34
|
+
* second, and re-running is the expensive thing this whole layer exists to avoid.
|
|
35
|
+
*
|
|
36
|
+
* (One deliberate exception, marked inline in the `require_subagents` loop: the
|
|
37
|
+
* three sub-questions there are reported separately, and a later one is skipped
|
|
38
|
+
* when an earlier one already established there is nothing to ask it about.)
|
|
39
|
+
*/
|
|
40
|
+
export function evaluateTraceGates(assert, trace) {
|
|
41
|
+
const assertions = [];
|
|
42
|
+
for (const req of assert.require_calls ?? []) {
|
|
43
|
+
const matched = trace.tool_calls.filter((c) => c.name === req.tool && argsMatch(c, req.args));
|
|
44
|
+
const min = req.count?.min ?? 1;
|
|
45
|
+
const max = req.count?.max;
|
|
46
|
+
const described = describeArgs(req.args);
|
|
47
|
+
if (matched.length < min) {
|
|
48
|
+
assertions.push({
|
|
49
|
+
kind: "require_call",
|
|
50
|
+
status: "FAIL",
|
|
51
|
+
detail: `expected at least ${min} call(s) to \`${req.tool}\`${described}, saw ${matched.length}${nearMiss(trace, req)}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (max !== undefined && matched.length > max) {
|
|
55
|
+
assertions.push({
|
|
56
|
+
kind: "require_call",
|
|
57
|
+
status: "FAIL",
|
|
58
|
+
detail: `expected at most ${max} call(s) to \`${req.tool}\`${described}, saw ${matched.length}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
assertions.push({
|
|
63
|
+
kind: "require_call",
|
|
64
|
+
status: "PASS",
|
|
65
|
+
detail: `\`${req.tool}\`${described} called ${matched.length} time(s)`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const req of assert.require_subagents ?? []) {
|
|
70
|
+
// Three independent questions, reported separately, because they send the
|
|
71
|
+
// author to three different places: selection (did it delegate at all, and to
|
|
72
|
+
// the right agent), handoff-completeness (did the task carry what the child
|
|
73
|
+
// needs), and handoff-leakage (did it carry something it must not).
|
|
74
|
+
const invocations = trace.tool_calls
|
|
75
|
+
.filter((c) => c.name === req.tool)
|
|
76
|
+
.flatMap((c) => normalizeSubagentCall(c.args));
|
|
77
|
+
const matched = invocations.filter((i) => i.agent === req.agent);
|
|
78
|
+
const min = req.count?.min ?? 1;
|
|
79
|
+
const max = req.count?.max;
|
|
80
|
+
if (matched.length < min || (max !== undefined && matched.length > max)) {
|
|
81
|
+
const bound = matched.length < min ? `at least ${min}` : `at most ${max}`;
|
|
82
|
+
const seen = invocations.length === 0
|
|
83
|
+
? `no \`${req.tool}\` invocation was recorded`
|
|
84
|
+
: `saw agents: ${[...new Set(invocations.map((i) => i.agent))].join(", ")}`;
|
|
85
|
+
assertions.push({
|
|
86
|
+
kind: "require_subagent",
|
|
87
|
+
status: "FAIL",
|
|
88
|
+
detail: `expected ${bound} delegation(s) to \`${req.agent}\` via \`${req.tool}\`, saw ${matched.length} (${seen})`,
|
|
89
|
+
});
|
|
90
|
+
// Handoff assertions are meaningless with nothing to inspect, and reporting
|
|
91
|
+
// them as failures too would triple one root cause.
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
assertions.push({
|
|
95
|
+
kind: "require_subagent",
|
|
96
|
+
status: "PASS",
|
|
97
|
+
detail: `delegated to \`${req.agent}\` ${matched.length} time(s) via \`${req.tool}\``,
|
|
98
|
+
});
|
|
99
|
+
for (const needle of req.task_contains ?? []) {
|
|
100
|
+
const ok = matched.some((i) => i.task.includes(needle));
|
|
101
|
+
assertions.push({
|
|
102
|
+
kind: "require_subagent",
|
|
103
|
+
status: ok ? "PASS" : "FAIL",
|
|
104
|
+
detail: ok
|
|
105
|
+
? `handoff to \`${req.agent}\` carried ${JSON.stringify(needle)}`
|
|
106
|
+
: `handoff to \`${req.agent}\` omitted required context ${JSON.stringify(needle)}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const needle of req.task_excludes ?? []) {
|
|
110
|
+
const leaked = matched.filter((i) => i.task.includes(needle));
|
|
111
|
+
// Same asymmetry as `forbid_calls`: a leak check that ran over a truncated
|
|
112
|
+
// or redacted task has not established that nothing leaked.
|
|
113
|
+
const lost = leaked.length === 0 && matched.some((i) => valueWasLost(i.task));
|
|
114
|
+
assertions.push({
|
|
115
|
+
kind: "require_subagent",
|
|
116
|
+
status: lost ? "ERROR" : leaked.length === 0 ? "PASS" : "FAIL",
|
|
117
|
+
detail: lost
|
|
118
|
+
? `leak check on the handoff to \`${req.agent}\` could not be run — the task text was redacted or truncated before the trace was written`
|
|
119
|
+
: leaked.length === 0
|
|
120
|
+
? `handoff to \`${req.agent}\` did not carry ${JSON.stringify(needle)}`
|
|
121
|
+
: `handoff to \`${req.agent}\` leaked forbidden content ${JSON.stringify(needle)}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const forbid of assert.forbid_calls ?? []) {
|
|
126
|
+
const hits = trace.tool_calls.filter((c) => c.name === forbid.tool && argsMatch(c, forbid.args));
|
|
127
|
+
// A "not called" verdict is only trustworthy if the arguments it searched
|
|
128
|
+
// were intact. Where redaction destroyed one, the honest answer is that the
|
|
129
|
+
// assertion could not be checked.
|
|
130
|
+
const lost = [...new Set(trace.tool_calls.filter((c) => c.name === forbid.tool).flatMap((c) => lostArgs(c, forbid.args)))];
|
|
131
|
+
if (hits.length === 0 && lost.length > 0) {
|
|
132
|
+
assertions.push({
|
|
133
|
+
kind: "forbid_call",
|
|
134
|
+
status: "ERROR",
|
|
135
|
+
detail: `\`${forbid.tool}\`${describeArgs(forbid.args)} could not be checked — ${lost.map((k) => `\`${k}\``).join(", ")} was redacted or truncated before the trace was written`,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
assertions.push(hits.length === 0
|
|
140
|
+
? { kind: "forbid_call", status: "PASS", detail: `\`${forbid.tool}\`${describeArgs(forbid.args)} not called` }
|
|
141
|
+
: {
|
|
142
|
+
kind: "forbid_call",
|
|
143
|
+
status: "FAIL",
|
|
144
|
+
detail: `\`${forbid.tool}\`${describeArgs(forbid.args)} called ${hits.length} time(s) — forbidden`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
for (const pattern of assert.unchanged_paths ?? []) {
|
|
148
|
+
// `null` is "we never looked", and it must not be graded. The whole tri-state
|
|
149
|
+
// exists so this branch can be written: an unobserved workspace produces
|
|
150
|
+
// ERROR, which blocks the ship, rather than the vacuous PASS an empty list
|
|
151
|
+
// used to produce.
|
|
152
|
+
if (trace.changed_paths === null) {
|
|
153
|
+
assertions.push({
|
|
154
|
+
kind: "unchanged_path",
|
|
155
|
+
status: "ERROR",
|
|
156
|
+
detail: `\`${pattern}\` could not be checked — the workspace was never observed`,
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const changed = trace.changed_paths.filter((p) => matchesGlob(pattern, p));
|
|
161
|
+
assertions.push(changed.length === 0
|
|
162
|
+
? { kind: "unchanged_path", status: "PASS", detail: `\`${pattern}\` unchanged` }
|
|
163
|
+
: { kind: "unchanged_path", status: "FAIL", detail: `\`${pattern}\` changed: ${changed.join(", ")}` });
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
// ERROR outranks FAIL: "the evidence is missing" must never be reported as
|
|
167
|
+
// "the assertion held", and it must not be softened into a plain failure
|
|
168
|
+
// either — the two call for different fixes.
|
|
169
|
+
status: assertions.some((a) => a.status === "ERROR")
|
|
170
|
+
? "ERROR"
|
|
171
|
+
: assertions.some((a) => a.status === "FAIL")
|
|
172
|
+
? "FAIL"
|
|
173
|
+
: "PASS",
|
|
174
|
+
assertions,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* When a required call is missing, say whether the tool was called at all.
|
|
179
|
+
*
|
|
180
|
+
* "expected Agent(agent=plan), saw 0" and "…, saw 0 (Agent called 1x with
|
|
181
|
+
* different arguments)" send the author to completely different places.
|
|
182
|
+
*/
|
|
183
|
+
function nearMiss(trace, req) {
|
|
184
|
+
if (!req.args)
|
|
185
|
+
return "";
|
|
186
|
+
const byName = trace.tool_calls.filter((c) => c.name === req.tool);
|
|
187
|
+
if (byName.length === 0)
|
|
188
|
+
return ` (\`${req.tool}\` was never called)`;
|
|
189
|
+
return ` (\`${req.tool}\` called ${byName.length}x, but with different arguments)`;
|
|
190
|
+
}
|
|
191
|
+
function describeArgs(args) {
|
|
192
|
+
if (!args || Object.keys(args).length === 0)
|
|
193
|
+
return "";
|
|
194
|
+
const parts = Object.entries(args).map(([k, p]) => {
|
|
195
|
+
const [op] = PREDICATE_KEYS.filter((key) => p[key] !== undefined);
|
|
196
|
+
return op ? `${k} ${op} ${JSON.stringify(p[op])}` : k;
|
|
197
|
+
});
|
|
198
|
+
return ` (${parts.join(", ")})`;
|
|
199
|
+
}
|
|
200
|
+
function argsMatch(call, args) {
|
|
201
|
+
if (!args)
|
|
202
|
+
return true;
|
|
203
|
+
return Object.entries(args).every(([key, predicate]) => testPredicate(call.args[key], predicate));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Apply one predicate to one value.
|
|
207
|
+
*
|
|
208
|
+
* Multiple operators on the same field are ANDed. An unknown operator can never
|
|
209
|
+
* reach here — `parseTraceAssert` rejects it at load time, so a typo'd operator
|
|
210
|
+
* is a spec error rather than an assertion that silently passes.
|
|
211
|
+
*/
|
|
212
|
+
/**
|
|
213
|
+
* Did redaction destroy the value this predicate needs to read?
|
|
214
|
+
*
|
|
215
|
+
* Trace arguments are redacted, truncated and depth-bounded before they are
|
|
216
|
+
* persisted — necessary, since they reach disk. But the gate then evaluates
|
|
217
|
+
* predicates against that lossy projection, and the two failure directions are
|
|
218
|
+
* not symmetric:
|
|
219
|
+
*
|
|
220
|
+
* - `require_calls` degrades SAFELY: a needle that redaction removed simply is
|
|
221
|
+
* not found, and the assertion FAILS. Over-strict, never over-permissive.
|
|
222
|
+
* - `forbid_calls` and `task_excludes` degrade DANGEROUSLY: the predicate cannot
|
|
223
|
+
* match, so the forbidden thing is reported as absent. `forbid_calls` on
|
|
224
|
+
* `{ authorization: { contains: "Bearer" } }` could never fire, because the
|
|
225
|
+
* value is always `[redacted]` by the time the gate sees it.
|
|
226
|
+
*
|
|
227
|
+
* So the negative assertions ask this first, and report ERROR — "could not be
|
|
228
|
+
* checked" — instead of a PASS they have not earned.
|
|
229
|
+
*/
|
|
230
|
+
function valueWasLost(value) {
|
|
231
|
+
if (typeof value === "string") {
|
|
232
|
+
return value === "[redacted]" || value === "[nested]" || value.includes("… [truncated ");
|
|
233
|
+
}
|
|
234
|
+
if (Array.isArray(value))
|
|
235
|
+
return value.some(valueWasLost);
|
|
236
|
+
if (value && typeof value === "object")
|
|
237
|
+
return Object.values(value).some(valueWasLost);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** The arg names a predicate set reads whose values redaction has destroyed. */
|
|
241
|
+
function lostArgs(call, args) {
|
|
242
|
+
if (!args)
|
|
243
|
+
return [];
|
|
244
|
+
return Object.keys(args).filter((key) => valueWasLost(call.args[key]));
|
|
245
|
+
}
|
|
246
|
+
export function testPredicate(value, p) {
|
|
247
|
+
if (p.exists !== undefined) {
|
|
248
|
+
if (p.exists !== (value !== undefined && value !== null))
|
|
249
|
+
return false;
|
|
250
|
+
// `exists: false` is satisfied and nothing else can be tested on an absent value.
|
|
251
|
+
if (p.exists === false)
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (p.equals !== undefined && !deepEqual(value, p.equals))
|
|
255
|
+
return false;
|
|
256
|
+
if (p.contains !== undefined && !asString(value).includes(p.contains))
|
|
257
|
+
return false;
|
|
258
|
+
if (p.starts_with !== undefined && !asString(value).startsWith(p.starts_with))
|
|
259
|
+
return false;
|
|
260
|
+
if (p.ends_with !== undefined && !asString(value).endsWith(p.ends_with))
|
|
261
|
+
return false;
|
|
262
|
+
if (p.matches !== undefined) {
|
|
263
|
+
let re;
|
|
264
|
+
try {
|
|
265
|
+
re = new RegExp(p.matches);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false; // unreachable via parseTraceAssert, which compiles it first
|
|
269
|
+
}
|
|
270
|
+
if (!re.test(asString(value)))
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
if (p.any !== undefined) {
|
|
274
|
+
if (!Array.isArray(value))
|
|
275
|
+
return false;
|
|
276
|
+
if (!value.some((v) => testPredicate(v, p.any)))
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
/** Stringify for text operators without inventing a match on an absent value. */
|
|
282
|
+
function asString(v) {
|
|
283
|
+
if (typeof v === "string")
|
|
284
|
+
return v;
|
|
285
|
+
if (v === undefined || v === null)
|
|
286
|
+
return "";
|
|
287
|
+
return JSON.stringify(v) ?? "";
|
|
288
|
+
}
|
|
289
|
+
function deepEqual(a, b) {
|
|
290
|
+
if (a === b)
|
|
291
|
+
return true;
|
|
292
|
+
if (typeof a !== typeof b || a === null || b === null)
|
|
293
|
+
return false;
|
|
294
|
+
if (typeof a !== "object")
|
|
295
|
+
return false;
|
|
296
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Minimal glob over workspace-relative paths: `**` any depth, `*` one segment.
|
|
300
|
+
*
|
|
301
|
+
* Paths are normalized to forward slashes and stripped of a leading `./` first,
|
|
302
|
+
* so `./src/a.ts` and `src/a.ts` are the same path — otherwise an assertion
|
|
303
|
+
* would pass or fail on how the runner happened to spell it.
|
|
304
|
+
*/
|
|
305
|
+
export function matchesGlob(pattern, path) {
|
|
306
|
+
const p = normalizePath(path);
|
|
307
|
+
const pat = normalizePath(pattern);
|
|
308
|
+
if (pat === p)
|
|
309
|
+
return true;
|
|
310
|
+
const escaped = pat
|
|
311
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
312
|
+
.replace(/\*\*\//g, "SLASHSTAR")
|
|
313
|
+
.replace(/\*\*/g, "GLOBSTAR")
|
|
314
|
+
.replace(/\*/g, "[^/]*")
|
|
315
|
+
.replace(/SLASHSTAR/g, "(?:.*/)?")
|
|
316
|
+
.replace(/GLOBSTAR/g, ".*");
|
|
317
|
+
return new RegExp(`^${escaped}$`).test(p);
|
|
318
|
+
}
|
|
319
|
+
function normalizePath(p) {
|
|
320
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Parsing / validation
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* Validate an `assert.trace` block from a spec.
|
|
327
|
+
*
|
|
328
|
+
* Strict on purpose: an unknown key is an error, not something ignored. A
|
|
329
|
+
* silently-ignored `forbid_call` (singular, say) would read in review as a gate
|
|
330
|
+
* that is protecting something while asserting nothing at all — the worst
|
|
331
|
+
* possible failure for a safety check.
|
|
332
|
+
*/
|
|
333
|
+
export function parseTraceAssert(raw, ctx) {
|
|
334
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
335
|
+
throw new Error(`${ctx}: \`assert.trace\` must be a mapping`);
|
|
336
|
+
}
|
|
337
|
+
const obj = raw;
|
|
338
|
+
const allowed = new Set(["require_calls", "require_subagents", "forbid_calls", "unchanged_paths"]);
|
|
339
|
+
for (const key of Object.keys(obj)) {
|
|
340
|
+
if (!allowed.has(key)) {
|
|
341
|
+
throw new Error(`${ctx}: unknown \`assert.trace\` key \`${key}\` (allowed: ${[...allowed].join(", ")})`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const out = {};
|
|
345
|
+
if (obj.require_calls !== undefined) {
|
|
346
|
+
out.require_calls = asArray(obj.require_calls, `${ctx}: \`require_calls\``).map((item, i) => {
|
|
347
|
+
const entry = asObject(item, `${ctx}: \`require_calls[${i}]\``);
|
|
348
|
+
const tool = requireToolName(entry.tool, `${ctx}: \`require_calls[${i}]\``);
|
|
349
|
+
const req = { tool };
|
|
350
|
+
if (entry.count !== undefined)
|
|
351
|
+
req.count = parseCount(entry.count, `${ctx}: \`require_calls[${i}].count\``);
|
|
352
|
+
if (entry.args !== undefined)
|
|
353
|
+
req.args = parseArgs(entry.args, `${ctx}: \`require_calls[${i}].args\``);
|
|
354
|
+
for (const key of Object.keys(entry)) {
|
|
355
|
+
if (!["tool", "count", "args"].includes(key)) {
|
|
356
|
+
throw new Error(`${ctx}: unknown key \`${key}\` in \`require_calls[${i}]\``);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return req;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (obj.require_subagents !== undefined) {
|
|
363
|
+
out.require_subagents = asArray(obj.require_subagents, `${ctx}: \`require_subagents\``).map((item, i) => {
|
|
364
|
+
const where = `${ctx}: \`require_subagents[${i}]\``;
|
|
365
|
+
const entry = asObject(item, where);
|
|
366
|
+
for (const key of Object.keys(entry)) {
|
|
367
|
+
if (!["tool", "agent", "count", "task_contains", "task_excludes"].includes(key)) {
|
|
368
|
+
throw new Error(`${ctx}: unknown key \`${key}\` in \`require_subagents[${i}]\``);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const sub = {
|
|
372
|
+
tool: requireToolName(entry.tool, where),
|
|
373
|
+
agent: requireNonEmpty(entry.agent, `${where}: \`agent\``),
|
|
374
|
+
};
|
|
375
|
+
if (entry.count !== undefined)
|
|
376
|
+
sub.count = parseCount(entry.count, `${where}.count`);
|
|
377
|
+
if (entry.task_contains !== undefined)
|
|
378
|
+
sub.task_contains = parseNeedles(entry.task_contains, `${where}.task_contains`);
|
|
379
|
+
if (entry.task_excludes !== undefined)
|
|
380
|
+
sub.task_excludes = parseNeedles(entry.task_excludes, `${where}.task_excludes`);
|
|
381
|
+
return sub;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (obj.forbid_calls !== undefined) {
|
|
385
|
+
out.forbid_calls = asArray(obj.forbid_calls, `${ctx}: \`forbid_calls\``).map((item, i) => {
|
|
386
|
+
// A bare string is the common case — `forbid_calls: [write]`.
|
|
387
|
+
if (typeof item === "string")
|
|
388
|
+
return { tool: item };
|
|
389
|
+
const entry = asObject(item, `${ctx}: \`forbid_calls[${i}]\``);
|
|
390
|
+
const forbid = { tool: requireToolName(entry.tool, `${ctx}: \`forbid_calls[${i}]\``) };
|
|
391
|
+
if (entry.args !== undefined)
|
|
392
|
+
forbid.args = parseArgs(entry.args, `${ctx}: \`forbid_calls[${i}].args\``);
|
|
393
|
+
for (const key of Object.keys(entry)) {
|
|
394
|
+
if (!["tool", "args"].includes(key)) {
|
|
395
|
+
throw new Error(`${ctx}: unknown key \`${key}\` in \`forbid_calls[${i}]\``);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return forbid;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (obj.unchanged_paths !== undefined) {
|
|
402
|
+
const paths = asArray(obj.unchanged_paths, `${ctx}: \`unchanged_paths\``);
|
|
403
|
+
out.unchanged_paths = paths.map((p, i) => {
|
|
404
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
405
|
+
throw new Error(`${ctx}: \`unchanged_paths[${i}]\` must be a non-empty string`);
|
|
406
|
+
}
|
|
407
|
+
return p;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (!out.require_calls && !out.require_subagents && !out.forbid_calls && !out.unchanged_paths) {
|
|
411
|
+
throw new Error(`${ctx}: \`assert.trace\` declares no assertions — remove it or add one`);
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function requireNonEmpty(v, ctx) {
|
|
416
|
+
if (typeof v !== "string" || v.trim() === "")
|
|
417
|
+
throw new Error(`${ctx} must be a non-empty string`);
|
|
418
|
+
return v;
|
|
419
|
+
}
|
|
420
|
+
/** Needles must be non-empty: an empty one matches everything, so the check could never fail. */
|
|
421
|
+
function parseNeedles(raw, ctx) {
|
|
422
|
+
return asArray(raw, ctx).map((n, i) => {
|
|
423
|
+
if (typeof n !== "string" || n === "")
|
|
424
|
+
throw new Error(`${ctx}[${i}] must be a non-empty string`);
|
|
425
|
+
return n;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function requireToolName(v, ctx) {
|
|
429
|
+
if (typeof v !== "string" || v.trim() === "")
|
|
430
|
+
throw new Error(`${ctx}: needs a non-empty \`tool\` name`);
|
|
431
|
+
return v;
|
|
432
|
+
}
|
|
433
|
+
function asArray(v, ctx) {
|
|
434
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
435
|
+
throw new Error(`${ctx} must be a non-empty list`);
|
|
436
|
+
return v;
|
|
437
|
+
}
|
|
438
|
+
function asObject(v, ctx) {
|
|
439
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
440
|
+
throw new Error(`${ctx} must be a mapping`);
|
|
441
|
+
return v;
|
|
442
|
+
}
|
|
443
|
+
function parseCount(raw, ctx) {
|
|
444
|
+
const obj = asObject(raw, ctx);
|
|
445
|
+
const out = {};
|
|
446
|
+
for (const key of Object.keys(obj)) {
|
|
447
|
+
if (key !== "min" && key !== "max")
|
|
448
|
+
throw new Error(`${ctx}: unknown key \`${key}\` (allowed: min, max)`);
|
|
449
|
+
}
|
|
450
|
+
for (const key of ["min", "max"]) {
|
|
451
|
+
if (obj[key] === undefined)
|
|
452
|
+
continue;
|
|
453
|
+
const n = obj[key];
|
|
454
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
455
|
+
throw new Error(`${ctx}: \`${key}\` must be a non-negative integer`);
|
|
456
|
+
}
|
|
457
|
+
out[key] = n;
|
|
458
|
+
}
|
|
459
|
+
if (out.min !== undefined && out.max !== undefined && out.min > out.max) {
|
|
460
|
+
throw new Error(`${ctx}: min (${out.min}) exceeds max (${out.max}) — nothing can satisfy it`);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
function parseArgs(raw, ctx) {
|
|
465
|
+
const obj = asObject(raw, ctx);
|
|
466
|
+
const out = {};
|
|
467
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
468
|
+
out[key] = parsePredicate(value, `${ctx}.${key}`);
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
function parsePredicate(raw, ctx) {
|
|
473
|
+
// `agent: plan` is shorthand for `agent: { equals: plan }` — the common case
|
|
474
|
+
// should not require the author to know the operator vocabulary.
|
|
475
|
+
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
|
|
476
|
+
return { equals: raw };
|
|
477
|
+
}
|
|
478
|
+
const obj = asObject(raw, ctx);
|
|
479
|
+
const out = {};
|
|
480
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
481
|
+
if (!PREDICATE_KEYS.includes(key)) {
|
|
482
|
+
throw new Error(`${ctx}: unknown operator \`${key}\` (allowed: ${PREDICATE_KEYS.join(", ")})`);
|
|
483
|
+
}
|
|
484
|
+
if (key === "matches") {
|
|
485
|
+
if (typeof value !== "string")
|
|
486
|
+
throw new Error(`${ctx}: \`matches\` must be a string pattern`);
|
|
487
|
+
try {
|
|
488
|
+
new RegExp(value);
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
throw new Error(`${ctx}: \`matches\` is not a valid regular expression: ${e instanceof Error ? e.message : e}`);
|
|
492
|
+
}
|
|
493
|
+
out.matches = value;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (key === "exists") {
|
|
497
|
+
if (typeof value !== "boolean")
|
|
498
|
+
throw new Error(`${ctx}: \`exists\` must be true or false`);
|
|
499
|
+
out.exists = value;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (key === "any") {
|
|
503
|
+
out.any = parsePredicate(value, `${ctx}.any`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (key === "equals") {
|
|
507
|
+
out.equals = value;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
// contains / starts_with / ends_with
|
|
511
|
+
if (typeof value !== "string")
|
|
512
|
+
throw new Error(`${ctx}: \`${key}\` must be a string`);
|
|
513
|
+
out[key] = value;
|
|
514
|
+
}
|
|
515
|
+
if (Object.keys(out).length === 0)
|
|
516
|
+
throw new Error(`${ctx}: predicate declares no operator`);
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
//# sourceMappingURL=trace-gates.js.map
|
package/dist/trends.d.ts
CHANGED
|
@@ -38,6 +38,34 @@ export interface TrendData {
|
|
|
38
38
|
}[];
|
|
39
39
|
models: TrendModel[];
|
|
40
40
|
}
|
|
41
|
+
/** One model tag's scored run history in ONE delivery mode, chronologically ascending. */
|
|
42
|
+
export interface ScoredRunGroup {
|
|
43
|
+
tag: string;
|
|
44
|
+
mode: string;
|
|
45
|
+
model: string;
|
|
46
|
+
runs: ResultsFile[];
|
|
47
|
+
/** Runs in this tag whose results.yaml could not be parsed (per tag, not per mode). */
|
|
48
|
+
skipped: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Walk `<skillDir>/tests/results/` and group every SCORED run by model tag × delivery
|
|
52
|
+
* mode, chronologically (timestamp-slug dir names sort correctly).
|
|
53
|
+
*
|
|
54
|
+
* The single history reader: `collectTrends` renders it, `collectStability` derives
|
|
55
|
+
* run-over-run flips from it. Two walkers over the same tree is how "which runs count"
|
|
56
|
+
* drifts — the mistake that had force runs excluded from scoring in seven places at
|
|
57
|
+
* once (see SCORED_MODES).
|
|
58
|
+
*
|
|
59
|
+
* Red runs are excluded: a baseline has no grade, and pairing it with anything would
|
|
60
|
+
* compare a skill-off run to a skill-on one. Green and force are never pooled into one
|
|
61
|
+
* group — placement moves verdicts, so a green run and a force run of the same scenario
|
|
62
|
+
* are two measurements, not two samples.
|
|
63
|
+
*
|
|
64
|
+
* A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic write) is
|
|
65
|
+
* logged via `console.warn`, skipped, and counted in `skipped` — never thrown, because
|
|
66
|
+
* one torn file must not take down a whole read-only view.
|
|
67
|
+
*/
|
|
68
|
+
export declare function collectScoredRuns(skillDir: string): ScoredRunGroup[];
|
|
41
69
|
/**
|
|
42
70
|
* Per model-tag, read the full run history (not just the latest) from
|
|
43
71
|
* <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
|