@skill-harness/core 0.6.0 → 0.8.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 (44) 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 +8 -1
  18. package/dist/lint.js +31 -2
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +24 -0
  22. package/dist/report.js +24 -0
  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/restamp.d.ts +68 -0
  27. package/dist/restamp.js +194 -0
  28. package/dist/results.d.ts +128 -6
  29. package/dist/results.js +155 -6
  30. package/dist/run.js +114 -8
  31. package/dist/seeded.d.ts +11 -0
  32. package/dist/seeded.js +31 -7
  33. package/dist/sources.d.ts +50 -0
  34. package/dist/sources.js +215 -7
  35. package/dist/spec-write.d.ts +62 -0
  36. package/dist/spec-write.js +106 -0
  37. package/dist/spec.d.ts +29 -0
  38. package/dist/spec.js +55 -0
  39. package/dist/stability.js +26 -4
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/workspace.d.ts +36 -0
  43. package/dist/workspace.js +61 -0
  44. package/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ import type { ExecutionTraceV1 } from "./capture-trace-types.js";
2
+ /**
3
+ * The objective gate layer: assertions evaluated against a saved execution
4
+ * trace, before any judge is asked anything.
5
+ *
6
+ * The DSL is deliberately tiny and entirely declarative — no expressions, no
7
+ * callbacks, no executable predicates. A spec is data that arrives from a
8
+ * repository; giving it a code path would make "add a test" and "run arbitrary
9
+ * code in CI" the same act. Everything here is a comparison between a value the
10
+ * trace recorded and a literal the spec wrote down.
11
+ *
12
+ * What these assertions can and cannot prove is a hard boundary, restated here
13
+ * because it is easy to over-claim: a trace proves **a registered tool was
14
+ * called with given arguments**. It proves nothing about what that tool then did
15
+ * to the machine. A `bash` command string is not a filesystem audit.
16
+ */
17
+ export interface ArgPredicate {
18
+ equals?: unknown;
19
+ contains?: string;
20
+ starts_with?: string;
21
+ ends_with?: string;
22
+ matches?: string;
23
+ exists?: boolean;
24
+ /** For array-valued arguments: at least one element satisfies the inner predicate. */
25
+ any?: ArgPredicate;
26
+ }
27
+ export declare const PREDICATE_KEYS: readonly ["equals", "contains", "starts_with", "ends_with", "matches", "exists", "any"];
28
+ export interface CountConstraint {
29
+ min?: number;
30
+ max?: number;
31
+ }
32
+ export interface RequireCall {
33
+ tool: string;
34
+ count?: CountConstraint;
35
+ args?: Record<string, ArgPredicate>;
36
+ }
37
+ export interface ForbidCall {
38
+ tool: string;
39
+ /** When present, only calls whose arguments match are forbidden. */
40
+ args?: Record<string, ArgPredicate>;
41
+ }
42
+ /**
43
+ * Convenience syntax for the orchestration case: "the parent delegated to
44
+ * `plan`, and the handoff carried X but not Y".
45
+ *
46
+ * Sugar over `require_calls`, not a second mechanism — it normalizes the known
47
+ * subagent argument shapes and then evaluates through the same path. There is
48
+ * deliberately no universal subagent extension assumed: an unknown extension can
49
+ * still be asserted on with plain `require_calls`, which is why this stays
50
+ * optional sugar rather than the only way in.
51
+ */
52
+ export interface RequireSubagent {
53
+ /** The registered tool name — declared by the spec, since pi has no standard one. */
54
+ tool: string;
55
+ /** Which subagent the parent should have selected. */
56
+ agent: string;
57
+ count?: CountConstraint;
58
+ /** Substrings the handoff MUST carry. */
59
+ task_contains?: string[];
60
+ /** Substrings the handoff must NOT carry — the leak check. */
61
+ task_excludes?: string[];
62
+ }
63
+ export interface TraceAssert {
64
+ require_calls?: RequireCall[];
65
+ require_subagents?: RequireSubagent[];
66
+ forbid_calls?: ForbidCall[];
67
+ unchanged_paths?: string[];
68
+ }
69
+ /**
70
+ * Subagent invocations extracted from one tool call.
71
+ *
72
+ * A single call can carry several: `{tasks: [...]}` fans out and `{chain: [...]}`
73
+ * sequences. Normalizing to a flat list means a `count` constraint means the same
74
+ * thing — how many subagent invocations happened — whichever shape the extension
75
+ * uses to express them.
76
+ */
77
+ export interface SubagentInvocation {
78
+ agent: string;
79
+ task: string;
80
+ }
81
+ /**
82
+ * Recognize the known subagent argument shapes.
83
+ *
84
+ * Three are supported because three exist in the wild; anything else yields an
85
+ * empty list, and the scenario should use plain `require_calls` instead. It
86
+ * deliberately does NOT guess: inventing an `agent` from an unrecognized shape
87
+ * would produce a confident assertion about a field nobody wrote.
88
+ */
89
+ export declare function normalizeSubagentCall(args: Record<string, unknown>): SubagentInvocation[];
90
+ /**
91
+ * ERROR is a third outcome, not a shade of FAIL: it means the assertion could
92
+ * not be evaluated because the evidence is absent. The two call for different
93
+ * fixes — a FAIL means change the skill, an ERROR means the harness could not
94
+ * look — and only one of them is a finding about the model.
95
+ */
96
+ export type AssertionStatus = "PASS" | "FAIL" | "ERROR";
97
+ export interface AssertionResult {
98
+ kind: "require_call" | "require_subagent" | "forbid_call" | "unchanged_path";
99
+ status: AssertionStatus;
100
+ detail: string;
101
+ }
102
+ export interface TraceGateResult {
103
+ status: AssertionStatus;
104
+ assertions: AssertionResult[];
105
+ }
106
+ /**
107
+ * Evaluate every assertion. All of them run even after the first failure — a
108
+ * scorecard that reports one problem per run makes the author re-run to find the
109
+ * second, and re-running is the expensive thing this whole layer exists to avoid.
110
+ *
111
+ * (One deliberate exception, marked inline in the `require_subagents` loop: the
112
+ * three sub-questions there are reported separately, and a later one is skipped
113
+ * when an earlier one already established there is nothing to ask it about.)
114
+ */
115
+ export declare function evaluateTraceGates(assert: TraceAssert, trace: ExecutionTraceV1): TraceGateResult;
116
+ export declare function testPredicate(value: unknown, p: ArgPredicate): boolean;
117
+ /**
118
+ * Minimal glob over workspace-relative paths: `**` any depth, `*` one segment.
119
+ *
120
+ * Paths are normalized to forward slashes and stripped of a leading `./` first,
121
+ * so `./src/a.ts` and `src/a.ts` are the same path — otherwise an assertion
122
+ * would pass or fail on how the runner happened to spell it.
123
+ */
124
+ export declare function matchesGlob(pattern: string, path: string): boolean;
125
+ /**
126
+ * Validate an `assert.trace` block from a spec.
127
+ *
128
+ * Strict on purpose: an unknown key is an error, not something ignored. A
129
+ * silently-ignored `forbid_call` (singular, say) would read in review as a gate
130
+ * that is protecting something while asserting nothing at all — the worst
131
+ * possible failure for a safety check.
132
+ */
133
+ export declare function parseTraceAssert(raw: unknown, ctx: string): TraceAssert;
@@ -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