vigiles 27.1.5 → 27.1.7

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.
@@ -109,6 +109,33 @@ function renderResponsesSSE(text, opts = {}) {
109
109
  `data: ${JSON.stringify({ type: e.type, ...e.data })}\n\n`)
110
110
  .join("");
111
111
  }
112
+ /**
113
+ * 🔴 KNOWN BLIND SPOT — NARROW ON PURPOSE, AND THAT IS NOT THE SAME AS COMPLETE.
114
+ * Roadmapped P1 2026-09-10; do not read a `requestContains` miss on a Codex trace
115
+ * as "not delivered" until this is closed.
116
+ *
117
+ * This reads `.text` and nothing else, and its caller takes only the LAST input
118
+ * item with `role:"user", type:"message"`. Measured against openai@7.13.0:
119
+ * `ResponseInputItem` has THIRTY-TWO variants and we look at one. The ignored
120
+ * ones include `FunctionCallOutput`, `ShellCallOutput` and `LocalShellCallOutput`
121
+ * — the Responses-API analogues of an Anthropic `tool_result`, i.e. exactly where
122
+ * Claude Code relocated a hook's additionalContext in 2.1.228 and where the same
123
+ * payload would land here.
124
+ *
125
+ * WHY THIS IS A COMMENT AND NOT A FIX. `driver.ts` adapts this into a
126
+ * `ModelRequest`, so it feeds `requestContains` — the same predicate whose
127
+ * `.text`-only twin in src/mock-model.ts produced a false "not delivered", a
128
+ * false issue (zernie/vigiles#231) and a near-miss upstream report. The trap is
129
+ * armed; nobody has stepped on it only because both delivery harnesses are
130
+ * Claude-Code-only today. Closing it properly means deciding what `prompt` MEANS
131
+ * across 32 item types — a semantic change, not a bug fix, and too large to ride
132
+ * along with the mock-model repair.
133
+ *
134
+ * The Claude Code side now takes `ContentBlockParam` from @anthropic-ai/sdk and
135
+ * fails the BUILD on an unhandled variant (see `flattenBlock` in
136
+ * src/mock-model.ts). openai ships the matching union under Apache-2.0; applying
137
+ * the same construction here is the fix, and it is the roadmapped work.
138
+ */
112
139
  function joinInputText(content) {
113
140
  if (!Array.isArray(content))
114
141
  return "";
package/dist/eval.js CHANGED
@@ -1412,6 +1412,37 @@ exports.claudeEvalDriver = {
1412
1412
  parse: parseClaudeRun,
1413
1413
  harness: "claude-code",
1414
1414
  };
1415
+ /**
1416
+ * Is this directory entry a skill DIRECTORY — following a symlink to one?
1417
+ *
1418
+ * 🔴 `Dirent.isDirectory()` describes the ENTRY, not its target: for a symlink
1419
+ * pointing at a directory it is FALSE. Skipping on it therefore skips symlinked
1420
+ * skills entirely, and the failure is silent and misreads as a finding — the skill
1421
+ * never enters the temp install, so nothing fires on any prompt and the run reports
1422
+ * 0% recall plus a competing-skill count short by however many were linked. The
1423
+ * tool then says "usually SETUP, not the description", which is true and useless,
1424
+ * because the setup it means is its own.
1425
+ *
1426
+ * A symlinked skills tree is not exotic: it is what a consumer gets when skills are
1427
+ * distributed as an npm package and linked into `.claude/skills/`, which is exactly
1428
+ * how a package publishes them.
1429
+ *
1430
+ * `statSync` follows the link, so it answers about the TARGET. A dangling link
1431
+ * throws, and that is not-a-skill-dir — the same answer the `SKILL.md` check
1432
+ * downstream would give it, one step earlier and without an exception escaping.
1433
+ */
1434
+ function isSkillDirEntry(parent, entry) {
1435
+ if (entry.isDirectory())
1436
+ return true;
1437
+ if (!entry.isSymbolicLink())
1438
+ return false;
1439
+ try {
1440
+ return (0, node_fs_1.statSync)((0, node_path_1.join)(parent, entry.name)).isDirectory();
1441
+ }
1442
+ catch {
1443
+ return false;
1444
+ }
1445
+ }
1415
1446
  /**
1416
1447
  * Package loose `<skillsDir>/<name>/SKILL.md` skills into a throwaway plugin dir
1417
1448
  * that `claude --plugin-dir` accepts — so repo-local skills (e.g. `.claude/skills`)
@@ -1432,7 +1463,7 @@ function packageSkillsDir(skillsDir, opts = {}) {
1432
1463
  (0, node_fs_1.mkdirSync)(skillsOut, { recursive: true });
1433
1464
  let copied = 0;
1434
1465
  for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1435
- if (!entry.isDirectory())
1466
+ if (!isSkillDirEntry(abs, entry))
1436
1467
  continue;
1437
1468
  const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
1438
1469
  if (!(0, node_fs_1.existsSync)(srcSkill))
@@ -1589,7 +1620,7 @@ function copySkillsInto(src, skillsOut, stub, present) {
1589
1620
  throw new Error(`installSet source not found: ${src} (resolved ${abs})`);
1590
1621
  let copied = 0;
1591
1622
  for (const entry of (0, node_fs_1.readdirSync)(abs, { withFileTypes: true })) {
1592
- if (!entry.isDirectory() || present.has(entry.name))
1623
+ if (!isSkillDirEntry(abs, entry) || present.has(entry.name))
1593
1624
  continue;
1594
1625
  const srcSkill = (0, node_path_1.join)(abs, entry.name, "SKILL.md");
1595
1626
  if (!(0, node_fs_1.existsSync)(srcSkill))
@@ -1656,7 +1687,7 @@ function countSkills(pluginDir) {
1656
1687
  return 0;
1657
1688
  let n = 0;
1658
1689
  for (const e of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }))
1659
- if (e.isDirectory() && (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, e.name, "SKILL.md")))
1690
+ if (isSkillDirEntry(dir, e) && (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, e.name, "SKILL.md")))
1660
1691
  n++;
1661
1692
  return n;
1662
1693
  }
@@ -156,7 +156,17 @@ export declare function outputContains(trace: Trace, needle: string | RegExp): b
156
156
  * Did ANY request the model received contain `needle` — searching the system
157
157
  * prompt and every message across all requests? The predicate that proves
158
158
  * injected context *reached the model*: a SessionStart hook's `additionalContext`
159
- * or a slash command's expansion. Harness tier only — the eval tier drives the
159
+ * or a slash command's expansion.
160
+ *
161
+ * THIS IS A PROJECTION OF THE REQUEST, NOT THE REQUEST — state what it omits
162
+ * before building a claim on it. Until 2026-09-10 this sentence was false: the
163
+ * flattener read only `.text`, so the eight `ContentBlockParam` variants that
164
+ * carry `content` were invisible, and a payload Claude Code had delivered inside
165
+ * a `tool_result` read as "never arrived" (zernie/vigiles#231, a false finding).
166
+ * It now covers every block type the pinned SDK union names, and serialises any
167
+ * it does not. STILL OMITTED BY DESIGN: `tool_use` / `server_tool_use` inputs —
168
+ * those are what the model SAID, not what it was TOLD, so a needle in a tool
169
+ * argument must not read as delivery. See `flattenBlock` in mock-model.ts. Harness tier only — the eval tier drives the
160
170
  * real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
161
171
  */
162
172
  export declare function requestContains(trace: Trace, needle: string | RegExp): boolean;
@@ -423,7 +423,17 @@ function requestText(trace) {
423
423
  * Did ANY request the model received contain `needle` — searching the system
424
424
  * prompt and every message across all requests? The predicate that proves
425
425
  * injected context *reached the model*: a SessionStart hook's `additionalContext`
426
- * or a slash command's expansion. Harness tier only — the eval tier drives the
426
+ * or a slash command's expansion.
427
+ *
428
+ * THIS IS A PROJECTION OF THE REQUEST, NOT THE REQUEST — state what it omits
429
+ * before building a claim on it. Until 2026-09-10 this sentence was false: the
430
+ * flattener read only `.text`, so the eight `ContentBlockParam` variants that
431
+ * carry `content` were invisible, and a payload Claude Code had delivered inside
432
+ * a `tool_result` read as "never arrived" (zernie/vigiles#231, a false finding).
433
+ * It now covers every block type the pinned SDK union names, and serialises any
434
+ * it does not. STILL OMITTED BY DESIGN: `tool_use` / `server_tool_use` inputs —
435
+ * those are what the model SAID, not what it was TOLD, so a needle in a tool
436
+ * argument must not read as delivery. See `flattenBlock` in mock-model.ts. Harness tier only — the eval tier drives the
427
437
  * real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
428
438
  */
429
439
  function requestContains(trace, needle) {
@@ -232,19 +232,143 @@ function splitRequestCounts(requests) {
232
232
  sideChannelCount++;
233
233
  return { count: requests.length - sideChannelCount, sideChannelCount };
234
234
  }
235
- /** Flatten Anthropic content (string, or an array of text/other blocks) to text. */
235
+ /**
236
+ * Flatten one Anthropic content block to the text the MODEL actually received.
237
+ *
238
+ * WHY THIS IS A TYPED, EXHAUSTIVE SWITCH AND NOT A `.text` LOOKUP — the whole
239
+ * point of the file, and it was paid for. Until 2026-09-10 this read `b.text`
240
+ * and returned `""` for anything else. Eight of the sixteen `ContentBlockParam`
241
+ * variants carry their payload in `content`, not `text`, so half the union was
242
+ * invisible to every instrument built on `extractRequest` — `requestContains`,
243
+ * `refs-nudge.harness.mjs`, `injectable-events-delivery.harness.mjs`.
244
+ *
245
+ * WHAT THAT COST. Claude Code <= 2.1.227 delivered a `PostToolUse` hook's
246
+ * `additionalContext` as its own `text` block. From 2.1.228 (a PATCH release,
247
+ * 2026-08-11) it arrives appended to the `tool_result` block's `content`, inside
248
+ * a `<system-reminder>`. Nothing broke: the model received the payload on both
249
+ * versions. Our probe went blind, every test above reported "not delivered", and
250
+ * that false reading was written up as zernie/vigiles#231 and very nearly filed
251
+ * upstream as a regression in somebody else's product. MEASURED both ways on one
252
+ * machine, claude 2.1.267, changing only this function: blind = "landed=false",
253
+ * typed = "landed=true".
254
+ *
255
+ * WHAT THE TYPE BUYS, precisely — it is NOT a change detector:
256
+ * - it does NOT notice a payload moving between fields the type already allows
257
+ * (`ToolResultBlockParam.content` predates the relocation; nothing changed);
258
+ * - it DOES make a silently-unhandled variant impossible: the `never` binding
259
+ * below fails `tsc` until every case is written out, so the seventeenth
260
+ * block type Anthropic ships breaks the BUILD instead of quietly emptying a
261
+ * measurement.
262
+ *
263
+ * WHY THE DEFAULT SERIALISES INSTEAD OF RETURNING `""`. This is a measurement
264
+ * instrument, and its proven failure mode is the FALSE NEGATIVE — a payload that
265
+ * was there, reported missing. So an unrecognised block is over-included (its
266
+ * JSON) rather than dropped: a stale pin then costs a noisy match, never a
267
+ * silent hole. That asymmetry is deliberate; do not "tidy" it to `""`.
268
+ *
269
+ * The repo's `assertNever` is deliberately NOT used: it throws, and this parses
270
+ * untrusted wire JSON where an unknown block must degrade, not crash.
271
+ */
272
+ function flattenBlock(b) {
273
+ switch (b.type) {
274
+ // WALKED — every string field is readable text the model was shown, and
275
+ // reading any one of them by name is what this function keeps getting
276
+ // wrong. `text` also carries `citations[].cited_text` / `document_title`
277
+ // (quoted source text); `search_result` carries `title` and `source`
278
+ // BESIDE its `content`; `document` spreads its text across `title`,
279
+ // `context` and `source` (`PlainTextSource.data`,
280
+ // `ContentBlockSource.content`). A base64 source is skipped inside
281
+ // `flattenUnknown` — bytes, not text.
282
+ case "text":
283
+ case "search_result":
284
+ case "document":
285
+ return flattenUnknown(b);
286
+ // READ NARROWLY, and the dropped field is named so the next reader can
287
+ // check the claim instead of trusting it: `signature` is an opaque
288
+ // attestation blob, not context.
289
+ case "thinking":
290
+ return b.thinking;
291
+ // The families whose payload hangs off `content`. Their only other field is
292
+ // `tool_use_id` — a correlation identifier, not text the model was shown.
293
+ // `content` is optional on `tool_result` alone; on the rest it is required
294
+ // and is an OBJECT, which `flattenContent` hands to `flattenUnknown`.
295
+ case "tool_result":
296
+ case "web_search_tool_result":
297
+ case "web_fetch_tool_result":
298
+ case "code_execution_tool_result":
299
+ case "bash_code_execution_tool_result":
300
+ case "text_editor_code_execution_tool_result":
301
+ case "tool_search_tool_result":
302
+ return b.content === undefined ? "" : flattenContent(b.content);
303
+ // The model's own call, not context delivered TO it — kept out of
304
+ // `requestContains` on purpose so a needle in a tool ARGUMENT is never read
305
+ // as "the model was told this". Their `id` / `name` are identifiers.
306
+ case "tool_use":
307
+ case "server_tool_use":
308
+ return "";
309
+ // No readable text by construction: `redacted_thinking.data` is encrypted,
310
+ // `container_upload.file_id` is an identifier, an image is pixels.
311
+ case "image":
312
+ case "redacted_thinking":
313
+ case "container_upload":
314
+ return "";
315
+ default: {
316
+ // Compile-time: unreachable, and that is the guard — a new variant makes
317
+ // this assignment fail. Run-time: reachable via wire JSON from a newer
318
+ // API than the pinned types, so it degrades loudly instead of throwing.
319
+ const unhandled = b;
320
+ return JSON.stringify(unhandled);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * Flatten an arbitrary wire payload to text by walking every string leaf.
326
+ *
327
+ * WHY A GENERIC WALK AND NOT ONE MORE NAMED FIELD. The first version of
328
+ * `flattenBlock` grouped eight variants as "the ones that carry `content`" and
329
+ * handed each to `flattenContent`, which accepts only a string or an array.
330
+ * Six of those eight carry an OBJECT there — `web_fetch_tool_result`,
331
+ * `web_search_tool_result`, `code_execution_tool_result`,
332
+ * `bash_code_execution_tool_result`, `text_editor_code_execution_tool_result`,
333
+ * `tool_search_tool_result` — so they still flattened to "". The grouping was
334
+ * made on the field's NAME while the defect lives in its TYPE, which is the
335
+ * same mistake, one level up, as the `.text`-only read it replaced. Found by
336
+ * review on this PR, not by a run (zernie/vigiles#233).
337
+ *
338
+ * And no single field would have fixed it: the payload's text sits at a
339
+ * different key in each shape — `stdout`/`stderr` on a bash result, a nested
340
+ * `content` document on a fetch result, `data` on a plain-text source. Keying
341
+ * on any one of them re-commits the shape assumption. Walking commits to none.
342
+ */
343
+ function flattenUnknown(v) {
344
+ if (typeof v === "string")
345
+ return v;
346
+ if (Array.isArray(v))
347
+ return v.map(flattenUnknown).join("");
348
+ if (typeof v !== "object" || v === null)
349
+ return "";
350
+ const o = v;
351
+ // A base64 source is bytes, not text. Including it would bury every real
352
+ // match under megabytes of encoding — the one over-inclusion that costs more
353
+ // than the false negative it avoids.
354
+ if (o.type === "base64")
355
+ return "";
356
+ return Object.entries(o)
357
+ .filter(([k]) => k !== "type" && k !== "media_type") // discriminators
358
+ .map(([, val]) => flattenUnknown(val))
359
+ .join("");
360
+ }
361
+ /**
362
+ * Flatten Anthropic content to text: a string, an array of blocks, or the
363
+ * OBJECT a server-tool result carries (see `flattenUnknown`).
364
+ */
236
365
  function flattenContent(content) {
237
366
  if (typeof content === "string")
238
367
  return content;
239
368
  if (!Array.isArray(content))
240
- return "";
369
+ return flattenUnknown(content);
241
370
  return content
242
- .map((b) => {
243
- if (typeof b === "string")
244
- return b;
245
- const t = b.text;
246
- return typeof t === "string" ? t : "";
247
- })
371
+ .map((b) => typeof b === "string" ? b : flattenBlock(b))
248
372
  .join("");
249
373
  }
250
374
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "27.1.5",
3
+ "version": "27.1.7",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -95,6 +95,7 @@
95
95
  "docs:api": "typedoc"
96
96
  },
97
97
  "devDependencies": {
98
+ "@anthropic-ai/sdk": "^0.124.0",
98
99
  "@eslint/js": "^10.0.1",
99
100
  "@jackchuka/mdschema": "^0.12.8",
100
101
  "@microsoft/api-extractor": "^7.58.9",