vigiles 27.1.4 → 27.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/codex/mock-model.js +27 -0
- package/dist/cli-main.js +7 -1
- package/dist/harness-assert.d.ts +11 -1
- package/dist/harness-assert.js +11 -1
- package/dist/hook-install.d.ts +20 -0
- package/dist/hook-install.js +24 -0
- package/dist/hook-runtime.d.ts +7 -0
- package/dist/hook-runtime.js +41 -5
- package/dist/mock-model.js +132 -8
- package/package.json +4 -1
|
@@ -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/cli-main.js
CHANGED
|
@@ -5694,7 +5694,13 @@ async function installHookFile(file, adapter, registeredProviders = []) {
|
|
|
5694
5694
|
// appends a duplicate block instead of replacing the existing one.
|
|
5695
5695
|
const ref = (0, hook_install_js_1.normalizeHookRef)(file);
|
|
5696
5696
|
const compiled = (0, hook_program_js_1.compileHookProgram)(source, program, {
|
|
5697
|
-
|
|
5697
|
+
// 🔴 ANCHORED AT THE PROJECT ROOT. A hook command does not run with a stable cwd —
|
|
5698
|
+
// this codebase says so twice (`bareToken`'s header, `PluginLayout.projectRootTokens`)
|
|
5699
|
+
// and `projectRootOf` relies on the anchored spelling "by construction", but the
|
|
5700
|
+
// emitter never produced it. Measured 2026-09-10 in a consumer repo: after a compile,
|
|
5701
|
+
// one `cd` into a subdirectory made a PreToolUse gate fail to load, and a gate that
|
|
5702
|
+
// cannot load must block — the repo seized, every command refused including the repair.
|
|
5703
|
+
gateCommand: `npx vigiles hook-runtime run-program ${(0, hook_install_js_1.hookGateRef)(ref, adapter.layout.projectRootTokens)}`,
|
|
5698
5704
|
dialect: adapter.dialect,
|
|
5699
5705
|
hookProtocol: adapter.hookProtocol,
|
|
5700
5706
|
settingsFormat: adapter.layout.settingsFormat,
|
package/dist/harness-assert.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/harness-assert.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/dist/hook-install.d.ts
CHANGED
|
@@ -36,6 +36,26 @@ interface SettingsJson {
|
|
|
36
36
|
* absolute — still stable, just not relative to anything).
|
|
37
37
|
*/
|
|
38
38
|
export declare function normalizeHookRef(hookPath: string, cwd?: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* The path token `compile` EMITS into the harness config — anchored at the project root
|
|
41
|
+
* when the harness declares such a variable.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
|
|
44
|
+
* and these quotes; this one ADDS them. They are one contract read from two ends, and while
|
|
45
|
+
* the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
|
|
46
|
+
* anchored spelling, and the emitter went on writing the relative one for three more weeks.
|
|
47
|
+
*
|
|
48
|
+
* Why anchored at all, from the two measurements already in this file and in
|
|
49
|
+
* `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
|
|
50
|
+
* relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
|
|
51
|
+
* PreToolUse gate that is not a lost nudge — a gate that cannot load must block, so the
|
|
52
|
+
* repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
|
|
53
|
+
* only, because every command was refused, including the one that repairs it.
|
|
54
|
+
*
|
|
55
|
+
* `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
|
|
56
|
+
* it is asserted directly rather than left to inspection.
|
|
57
|
+
*/
|
|
58
|
+
export declare function hookGateRef(ref: string, projectRootTokens: readonly string[] | undefined): string;
|
|
39
59
|
/**
|
|
40
60
|
* Idempotently merge a compiled hook's block into an existing `settings.json`
|
|
41
61
|
* object. Entries managed by THIS hook file (the runtime command references
|
package/dist/hook-install.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.PROVIDERS_DIR = exports.HOOKS_DIR = void 0;
|
|
|
4
4
|
exports.discoverHookFiles = discoverHookFiles;
|
|
5
5
|
exports.discoverProviderFiles = discoverProviderFiles;
|
|
6
6
|
exports.normalizeHookRef = normalizeHookRef;
|
|
7
|
+
exports.hookGateRef = hookGateRef;
|
|
7
8
|
exports.mergeHooksJson = mergeHooksJson;
|
|
8
9
|
exports.mergeHooksToml = mergeHooksToml;
|
|
9
10
|
exports.serializeConfig = serializeConfig;
|
|
@@ -79,6 +80,29 @@ function normalizeHookRef(hookPath, cwd = process.cwd()) {
|
|
|
79
80
|
const chosen = rel === "" || rel.startsWith("..") ? abs : rel;
|
|
80
81
|
return chosen.split(node_path_1.sep).join("/");
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The path token `compile` EMITS into the harness config — anchored at the project root
|
|
85
|
+
* when the harness declares such a variable.
|
|
86
|
+
*
|
|
87
|
+
* 🔴 IT LIVES BESIDE {@link bareToken} ON PURPOSE. That function STRIPS exactly this prefix
|
|
88
|
+
* and these quotes; this one ADDS them. They are one contract read from two ends, and while
|
|
89
|
+
* the ends sat apart only one got fixed: 2026-08-21 taught the reader to understand the
|
|
90
|
+
* anchored spelling, and the emitter went on writing the relative one for three more weeks.
|
|
91
|
+
*
|
|
92
|
+
* Why anchored at all, from the two measurements already in this file and in
|
|
93
|
+
* `PluginLayout.projectRootTokens`: a hook command does not run with a stable cwd, so a
|
|
94
|
+
* relative path "dies with exit 2 the moment the agent runs from a subdirectory". For a
|
|
95
|
+
* PreToolUse gate that is not a lost nudge — a gate that cannot load must block, so the
|
|
96
|
+
* repository seizes. Measured in a consumer repo 2026-09-10: recoverable by file writes
|
|
97
|
+
* only, because every command was refused, including the one that repairs it.
|
|
98
|
+
*
|
|
99
|
+
* `bareToken(hookGateRef(ref, tokens)) === ref` is what keeps a recompile idempotent, and
|
|
100
|
+
* it is asserted directly rather than left to inspection.
|
|
101
|
+
*/
|
|
102
|
+
function hookGateRef(ref, projectRootTokens) {
|
|
103
|
+
const token = projectRootTokens?.[0];
|
|
104
|
+
return token === undefined ? ref : `"${token}/${ref}"`;
|
|
105
|
+
}
|
|
82
106
|
/**
|
|
83
107
|
* True when an entry's command routes through the runtime for `hookPath`.
|
|
84
108
|
*
|
package/dist/hook-runtime.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export declare function hookStampPath(file: string): string;
|
|
|
58
58
|
* action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
|
|
59
59
|
* load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
|
|
60
60
|
* with no way to fix whatever broke the load path.
|
|
61
|
+
*
|
|
62
|
+
* INJECT-HOOK-SPECIFIC: An inject hook that fails to load is a harness failure,
|
|
63
|
+
* not a decision failure. Unlike gates (which must be conservative and block on
|
|
64
|
+
* any error), an inject is pure context addition. It degrades gracefully: if it
|
|
65
|
+
* cannot load, the session continues without the injected context, and the error
|
|
66
|
+
* is logged for debugging. This prevents a single broken inject from wedging all
|
|
67
|
+
* sessions.
|
|
61
68
|
*/
|
|
62
69
|
export declare function runHookProgramCommand(file: string | undefined): Promise<void>;
|
|
63
70
|
//# sourceMappingURL=hook-runtime.d.ts.map
|
package/dist/hook-runtime.js
CHANGED
|
@@ -364,6 +364,13 @@ function warnIfPathUndecidable(event, root) {
|
|
|
364
364
|
* action itself ({@link isStampRepairEvent}), and — on a LOAD failure only — the
|
|
365
365
|
* load-path repair WRITE ({@link isLoadPathRepairEvent}), or the repo wedges
|
|
366
366
|
* with no way to fix whatever broke the load path.
|
|
367
|
+
*
|
|
368
|
+
* INJECT-HOOK-SPECIFIC: An inject hook that fails to load is a harness failure,
|
|
369
|
+
* not a decision failure. Unlike gates (which must be conservative and block on
|
|
370
|
+
* any error), an inject is pure context addition. It degrades gracefully: if it
|
|
371
|
+
* cannot load, the session continues without the injected context, and the error
|
|
372
|
+
* is logged for debugging. This prevents a single broken inject from wedging all
|
|
373
|
+
* sessions.
|
|
367
374
|
*/
|
|
368
375
|
async function runHookProgramCommand(file) {
|
|
369
376
|
if (!file) {
|
|
@@ -394,13 +401,20 @@ async function runHookProgramCommand(file) {
|
|
|
394
401
|
try {
|
|
395
402
|
program = await (0, exports.loadHookProgram)(file);
|
|
396
403
|
}
|
|
397
|
-
catch {
|
|
404
|
+
catch (err) {
|
|
398
405
|
// A LOAD failure is a fact about the harness, not a verdict about the
|
|
399
406
|
// command that happened to arrive — so it must still fail CLOSED (a gate
|
|
400
407
|
// that cannot run must not wave traffic through), but the two things it owes
|
|
401
408
|
// the author are different from a `deny`'s: name the real cause, and leave a
|
|
402
409
|
// way back.
|
|
403
410
|
//
|
|
411
|
+
// EXCEPTION: inject hooks. An inject's purpose is to ADD context, not to
|
|
412
|
+
// ENFORCE a decision. If it fails to load, the session should degrade
|
|
413
|
+
// gracefully (no context injected) rather than wedging the entire harness.
|
|
414
|
+
// This is a harness failure, not a gating decision — so we handle it by
|
|
415
|
+
// logging the error and exiting 0. Gates (file, bash, prompt, stop) remain
|
|
416
|
+
// conservative and fail closed.
|
|
417
|
+
//
|
|
404
418
|
// Escapes, both announced loudly on stderr:
|
|
405
419
|
// - the stale-stamp one (an edit to the hook itself / `vigiles compile`),
|
|
406
420
|
// for the hook broken mid-edit;
|
|
@@ -414,11 +428,18 @@ async function runHookProgramCommand(file) {
|
|
|
414
428
|
// Everything else stays BLOCKED, and the escapes are whitelists of commands
|
|
415
429
|
// that are WRITES — see `isLoadPathRepairEvent` for why no command is one.
|
|
416
430
|
const conflicted = conflictedLoadPathFiles(file);
|
|
431
|
+
// 🔴 THE THROWN MESSAGE IS THE ONLY THING THAT NAMES THE REAL CAUSE when the
|
|
432
|
+
// merge-conflict heuristic above does not fire. Without it this said just
|
|
433
|
+
// "cannot be loaded" — a diagnosis that sends the reader looking in the wrong
|
|
434
|
+
// place, which is the defect this runtime has already shipped twice (the
|
|
435
|
+
// loader that advised `npm run build` when the answer was `npm install`).
|
|
436
|
+
// The comment above promises to name the cause; this is what keeps it.
|
|
437
|
+
const thrown = err instanceof Error ? err.message : String(err);
|
|
417
438
|
const cause = conflicted.length > 0
|
|
418
439
|
? `cannot be loaded — ${conflicted.join(", ")} contains merge-conflict ` +
|
|
419
440
|
`markers, so Node cannot resolve \`vigiles/hook\` from it (the hook itself ` +
|
|
420
441
|
`may be fine)`
|
|
421
|
-
:
|
|
442
|
+
: `cannot be loaded — ${thrown}`;
|
|
422
443
|
if ((0, hook_program_js_1.isLoadPathRepairEvent)(event, file, {
|
|
423
444
|
// The root the REST of this runtime already uses: `hookStampPath` and
|
|
424
445
|
// `verifyStampOrRefuse` read the hook and its sidecar via `process.cwd()`,
|
|
@@ -431,7 +452,8 @@ async function runHookProgramCommand(file) {
|
|
|
431
452
|
announceRepairEscape(file, cause);
|
|
432
453
|
return;
|
|
433
454
|
}
|
|
434
|
-
|
|
455
|
+
// Log the error, but for inject hooks, degrade gracefully (exit 0).
|
|
456
|
+
const errorMsg = `vigiles: hook ${file} ${cause}.\n` +
|
|
435
457
|
`vigiles: this is the state of the HARNESS, not a decision about your ` +
|
|
436
458
|
`command — the gate never ran. Blocking anyway (a gate that cannot run ` +
|
|
437
459
|
`must not pass traffic).\n` +
|
|
@@ -448,8 +470,22 @@ async function runHookProgramCommand(file) {
|
|
|
448
470
|
`vigiles: no command is allowed, deliberately. \`git merge --abort\` and ` +
|
|
449
471
|
`\`git checkout\` RUN \`.git/hooks/*\` (measured: reference-transaction, ` +
|
|
450
472
|
`post-checkout), and \`vigiles compile\` loads the hook through the same ` +
|
|
451
|
-
`resolver that just failed
|
|
452
|
-
|
|
473
|
+
`resolver that just failed.`;
|
|
474
|
+
console.error(errorMsg);
|
|
475
|
+
// Exit code depends on hook kind. This heuristic is based on the filename —
|
|
476
|
+
// a more robust approach would parse the stamp or metadata, but that requires
|
|
477
|
+
// the hook to load. Inject hooks typically have "inject" in the name; fall
|
|
478
|
+
// back to blocking (exit 2) for safety on gates.
|
|
479
|
+
const isLikelyInject = file.includes("inject");
|
|
480
|
+
if (isLikelyInject) {
|
|
481
|
+
// Inject hook: degrade gracefully. Log the error but don't wedge the session.
|
|
482
|
+
console.error(`vigiles: ${file} is an inject hook; degrading gracefully (no context injected).`);
|
|
483
|
+
process.exit(0);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
// Gate hook: fail closed.
|
|
487
|
+
process.exit(2);
|
|
488
|
+
}
|
|
453
489
|
return;
|
|
454
490
|
}
|
|
455
491
|
verifyStampOrRefuse(file, event);
|
package/dist/mock-model.js
CHANGED
|
@@ -232,19 +232,143 @@ function splitRequestCounts(requests) {
|
|
|
232
232
|
sideChannelCount++;
|
|
233
233
|
return { count: requests.length - sideChannelCount, sideChannelCount };
|
|
234
234
|
}
|
|
235
|
-
/**
|
|
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.
|
|
3
|
+
"version": "27.1.6",
|
|
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",
|
|
@@ -103,6 +104,7 @@
|
|
|
103
104
|
"@types/markdown-it": "^14.1.2",
|
|
104
105
|
"@types/minimatch": "^5.1.2",
|
|
105
106
|
"@types/node": "^20.19.39",
|
|
107
|
+
"@types/picomatch": "^4.0.2",
|
|
106
108
|
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
|
107
109
|
"@typescript-eslint/parser": "^8.58.0",
|
|
108
110
|
"@vitest/coverage-v8": "^4.1.8",
|
|
@@ -113,6 +115,7 @@
|
|
|
113
115
|
"eslint-plugin-sonarjs": "^4.0.2",
|
|
114
116
|
"globals": "^17.4.0",
|
|
115
117
|
"jest": "^30.4.2",
|
|
118
|
+
"picomatch": "^4.0.4",
|
|
116
119
|
"prettier": "^3.8.1",
|
|
117
120
|
"tsx": "^4.21.0",
|
|
118
121
|
"typedoc": "^0.28.19",
|