@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.
Files changed (46) hide show
  1. package/dist/adapters/types.d.ts +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +16 -1
  18. package/dist/lint.js +52 -0
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +48 -0
  22. package/dist/report.js +39 -1
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/results.d.ts +128 -6
  27. package/dist/results.js +155 -6
  28. package/dist/run.d.ts +9 -1
  29. package/dist/run.js +129 -9
  30. package/dist/seeded.d.ts +11 -0
  31. package/dist/seeded.js +31 -7
  32. package/dist/sources.d.ts +26 -0
  33. package/dist/sources.js +82 -3
  34. package/dist/spec-write.d.ts +62 -0
  35. package/dist/spec-write.js +106 -0
  36. package/dist/spec.d.ts +29 -0
  37. package/dist/spec.js +55 -0
  38. package/dist/stability.d.ts +144 -0
  39. package/dist/stability.js +232 -0
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/trends.d.ts +28 -0
  43. package/dist/trends.js +76 -61
  44. package/dist/workspace.d.ts +36 -0
  45. package/dist/workspace.js +61 -0
  46. 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