@skill-harness/core 0.6.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.
@@ -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
@@ -47,3 +47,39 @@ export declare function createWorkspace(kind: WorkspaceKind, opts: {
47
47
  specDir: string;
48
48
  remote?: boolean;
49
49
  }): Workspace;
50
+ /**
51
+ * A content snapshot of every file in a workspace: relative path → sha256.
52
+ *
53
+ * Taken immediately before the model runs, and compared after. Three reasons it
54
+ * is a content walk rather than the obvious `git diff`:
55
+ *
56
+ * 1. **`git add -A` honours `.gitignore`.** The canonical assertion this feature
57
+ * exists for is `unchanged_paths: [".env"]`, and `.env` is the canonical
58
+ * gitignored file. Overwriting it produced an empty diff, which read as
59
+ * "observed, nothing changed" — a safety gate reporting green on precisely
60
+ * the file class that motivated it. It also covers `TOOL_ARTIFACTS`, which
61
+ * the harness itself writes into `.git/info/exclude`.
62
+ * 2. **The baseline commit is not the pre-run state.** `createWorkspace` applies
63
+ * a fixture's `_staged/` and `_uncommitted/` trees AFTER `gitBaseline`, so
64
+ * those files are already dirty before the model does anything. Diffing
65
+ * against the baseline blamed the model for the fixture's own contents.
66
+ * 3. **The harness writes to the workspace too** — `runSeeded` copies the
67
+ * post-test in. A snapshot taken after setup contains it, so it cancels out
68
+ * instead of being attributed to the model.
69
+ */
70
+ export type PathSnapshot = Map<string, string>;
71
+ /** Snapshot a workspace, or null when there is no workspace to look at. */
72
+ export declare function snapshotPaths(cwd: string | undefined, kind: WorkspaceKind): PathSnapshot | null;
73
+ /**
74
+ * Paths whose content changed between two snapshots — added, removed, modified.
75
+ *
76
+ * The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
77
+ * trace proves which tool was called with which arguments; it cannot prove what
78
+ * that tool then did to the filesystem, so a path policy has to be checked
79
+ * against the filesystem.
80
+ *
81
+ * Returns null when either snapshot is missing — the caller must treat that as
82
+ * MISSING EVIDENCE, never as "nothing changed". An empty array means
83
+ * observed-and-nothing-changed; null means we could not look.
84
+ */
85
+ export declare function diffSnapshots(before: PathSnapshot | null, after: PathSnapshot | null): string[] | null;
package/dist/workspace.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import { tmpdir } from "node:os";
4
5
  import { isAbsolute, join, resolve } from "node:path";
5
6
  const GIT_TIMEOUT_MS = 30_000;
@@ -189,4 +190,64 @@ export function createWorkspace(kind, opts) {
189
190
  }
190
191
  return { cwd, cleanup };
191
192
  }
193
+ /** Never the model's work, and never worth hashing. */
194
+ const SNAPSHOT_SKIP = new Set([".git", "node_modules", "coverage", ".vitest"]);
195
+ /** Snapshot a workspace, or null when there is no workspace to look at. */
196
+ export function snapshotPaths(cwd, kind) {
197
+ if (kind === "none" || !cwd || !existsSync(cwd))
198
+ return null;
199
+ const out = new Map();
200
+ const walk = (dir, prefix) => {
201
+ let entries;
202
+ try {
203
+ entries = readdirSync(dir, { withFileTypes: true });
204
+ }
205
+ catch {
206
+ return; // an unreadable subtree is not evidence about the model
207
+ }
208
+ for (const e of entries) {
209
+ if (SNAPSHOT_SKIP.has(e.name))
210
+ continue;
211
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
212
+ const abs = join(dir, e.name);
213
+ if (e.isDirectory()) {
214
+ walk(abs, rel);
215
+ }
216
+ else if (e.isFile()) {
217
+ try {
218
+ out.set(rel, createHash("sha256").update(readFileSync(abs)).digest("hex"));
219
+ }
220
+ catch {
221
+ out.set(rel, "<unreadable>");
222
+ }
223
+ }
224
+ }
225
+ };
226
+ walk(cwd, "");
227
+ return out;
228
+ }
229
+ /**
230
+ * Paths whose content changed between two snapshots — added, removed, modified.
231
+ *
232
+ * The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
233
+ * trace proves which tool was called with which arguments; it cannot prove what
234
+ * that tool then did to the filesystem, so a path policy has to be checked
235
+ * against the filesystem.
236
+ *
237
+ * Returns null when either snapshot is missing — the caller must treat that as
238
+ * MISSING EVIDENCE, never as "nothing changed". An empty array means
239
+ * observed-and-nothing-changed; null means we could not look.
240
+ */
241
+ export function diffSnapshots(before, after) {
242
+ if (!before || !after)
243
+ return null;
244
+ const changed = new Set();
245
+ for (const [path, hash] of after)
246
+ if (before.get(path) !== hash)
247
+ changed.add(path);
248
+ for (const path of before.keys())
249
+ if (!after.has(path))
250
+ changed.add(path);
251
+ return [...changed].sort();
252
+ }
192
253
  //# sourceMappingURL=workspace.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",