vigiles 18.0.0 → 18.1.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.
@@ -39,8 +39,17 @@ function fieldMatches(value, type) {
39
39
  return typeof value === "boolean";
40
40
  case "string[]":
41
41
  return Array.isArray(value) && value.every((v) => typeof v === "string");
42
+ default:
43
+ // An enum, declared as a readonly tuple of the permitted literals.
44
+ return typeof value === "string" && type.includes(value);
42
45
  }
43
46
  }
47
+ /** How a field type reads in a message to a human: `string`, or `"CUT" | "MERGE"`. */
48
+ function typeName(type) {
49
+ return typeof type === "string"
50
+ ? type
51
+ : type.map((v) => JSON.stringify(v)).join(" | ");
52
+ }
44
53
  /**
45
54
  * Validate a parsed object against a contract track; null when it conforms.
46
55
  *
@@ -54,7 +63,9 @@ function shapeError(obj, shape) {
54
63
  if (!(field in obj))
55
64
  return `missing field "${field}"`;
56
65
  if (!fieldMatches(obj[field], type)) {
57
- return `field "${field}" should be ${type}`;
66
+ // `typeName`, not `type`: an enum interpolated raw renders as `CUT,MERGE,KEEP`,
67
+ // which reads like a value rather than a choice among values.
68
+ return `field "${field}" should be ${typeName(type)}`;
58
69
  }
59
70
  }
60
71
  return null;
@@ -899,7 +899,9 @@ function renderAgentSections(sections, basePath) {
899
899
  /** Render a result-contract track shape as a compact `{ "f": type, … }` line. */
900
900
  function renderShape(shape) {
901
901
  const fields = Object.entries(shape)
902
- .map(([k, t]) => `"${k}": ${t}`)
902
+ // An enum renders as the choice itself — `"verdict": "CUT" | "MERGE" | "KEEP"` — so
903
+ // the fenced rail shows a worker the same permitted values the tool schema shows.
904
+ .map(([k, t]) => `"${k}": ${typeof t === "string" ? t : t.map((v) => JSON.stringify(v)).join(" | ")}`)
903
905
  .join(", ");
904
906
  return fields ? `{ ${fields} }` : "{}";
905
907
  }
@@ -695,8 +695,22 @@ export type OkOf<T> = T extends TypedOutcome<infer Ok, Shape> ? Ok : Shape;
695
695
  * erased `Shape`, and the value is still a plain `AgentSpec` — backwards-compatible.
696
696
  */
697
697
  export declare function agent<const P extends AuthoredPurity | undefined = undefined, V extends ToolVocabulary = OpenToolVocabulary, Ok extends Shape = Shape, Err extends Shape = Shape>(spec: AgentSpecInput<P, V, Ok, Err>): TypedAgentSpec<Ok, Err>;
698
- /** The field types a result contract can declare (kept tiny + dependency-free). */
699
- export type OutputFieldType = "string" | "number" | "boolean" | "string[]";
698
+ /**
699
+ * The field types a result contract can declare (kept tiny + dependency-free).
700
+ *
701
+ * The literal-array member is an ENUM: `["CUT", "MERGE", "KEEP"] as const` declares a
702
+ * field whose value must be one of those strings. It is the ONLY extension to this union,
703
+ * and it was added because a measured failure had no other cure: across 14 real payloads
704
+ * a `verdict: "string"` field held 3 mutually incomparable invented categories over 3
705
+ * runs, and vocabulary compliance was 3/19 — 16%. `string` cannot express "one of these",
706
+ * so nothing downstream could notice.
707
+ *
708
+ * Nothing RELATIONAL follows it — no `object[]`, no tuples, no per-element enums.
709
+ * Declaring one throws rather than rendering an unsatisfiable schema, because the body of
710
+ * a result is prose by decision: on those same 14 payloads, 23 scalar values carried
711
+ * every assertion anyone made while 80,981 characters of prose carried none.
712
+ */
713
+ export type OutputFieldType = "string" | "number" | "boolean" | "string[]" | readonly [string, ...string[]];
700
714
  /** A field SHAPE — a record of field-name → field-type, kept in the TYPE so a
701
715
  * typed pipeline can cross-reference one agent's `ok` against the next agent's
702
716
  * `needs`. The erased runtime form is `Record<string, OutputFieldType>`. */
@@ -68,8 +68,11 @@ export type EmitFieldSchema = {
68
68
  readonly items: {
69
69
  readonly type: "string";
70
70
  };
71
+ } | {
72
+ readonly type: "string";
73
+ readonly enum: readonly string[];
71
74
  };
72
- /** The `track` discriminator's schema — the only enum this surface emits. */
75
+ /** The `track` discriminator's schema — the enum this module owns, not the author's. */
73
76
  export interface EmitTrackSchema {
74
77
  readonly type: "string";
75
78
  readonly enum: readonly ["ok", "err"];
@@ -62,6 +62,11 @@ const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
62
62
  /** The default tool name, when `options.name` is not given. */
63
63
  const DEFAULT_EMIT_TOOL = "emit_result";
64
64
  function fieldSchema(type) {
65
+ // An enum reaches the model as a JSON-Schema `enum`, which is the whole point: the
66
+ // permitted values travel WITH the tool definition instead of living in prose the
67
+ // model may or may not have read.
68
+ if (typeof type !== "string")
69
+ return { type: "string", enum: [...type] };
65
70
  switch (type) {
66
71
  case "string":
67
72
  return { type: "string" };
@@ -189,8 +194,41 @@ function isEmitCall(observed, name) {
189
194
  */
190
195
  function experimental_parseEmitted(toolCalls, contract, options = {}) {
191
196
  const name = options.name ?? DEFAULT_EMIT_TOOL;
192
- const calls = toolCalls.filter((c) => isEmitCall(c.name, name));
197
+ const all = toolCalls.filter((c) => isEmitCall(c.name, name));
198
+ // 🔴 A CALL THAT ERRORED IS NOT AN EMISSION, AND USED TO PARSE AS ONE.
199
+ //
200
+ // Measured 2026-08-19: a permission-denied call carrying a perfectly valid payload
201
+ // returned `{"kind":"ok", …}`, because this function filtered by NAME and never looked
202
+ // at `ToolCall.isError` — a field that has been on the type all along. The call never
203
+ // reached the server; the reader reported success. That is the exact shape of defect
204
+ // this channel exists to remove from the fenced rail, reproduced inside the channel.
205
+ //
206
+ // Denial is not hypothetical: it is what a wrong `allowedTools` spelling produces, and
207
+ // MCP tool names mangle per host (`mcp__plugin_<plugin>_<server>__emit_result` on Claude
208
+ // Code, two segments on Codex), so mis-spelling it is the likely case, not the exotic one.
209
+ //
210
+ // The errored calls get their OWN branch rather than being dropped or counted, because
211
+ // all three collapses lie in a different direction:
212
+ // - counting them → this defect, success for a call nobody received;
213
+ // - dropping them silently → "no tool call in the run", which sends the reader to the
214
+ // skill's instructions when the fault is in permissions;
215
+ // - lumping them with "called twice" → a model that retries a denied call produces a
216
+ // true signal under a false name. (Observed: two denials in one run.)
217
+ // A successful call ALONGSIDE a denied one is one successful emission; the denial is
218
+ // mentioned, not fatal, because the contract was in fact satisfied.
219
+ // `isError` is a required boolean on ToolCall, so truthiness is exact here.
220
+ const errored = all.filter((c) => c.isError);
221
+ const calls = all.filter((c) => !c.isError);
193
222
  if (calls.length === 0) {
223
+ if (errored.length > 0) {
224
+ return {
225
+ kind: "malformed",
226
+ reason: `the \`${name}\` call itself errored or was denied ` +
227
+ `(${String(errored.length)} attempt${errored.length === 1 ? "" : "s"}); nothing reached the ` +
228
+ `server, so nothing was emitted. Check the tool's permissions and the exact spelling ` +
229
+ `in \`allowedTools\` — MCP names are host-mangled.`,
230
+ };
231
+ }
194
232
  return { kind: "malformed", reason: `no \`${name}\` tool call in the run` };
195
233
  }
196
234
  if (calls.length > 1) {
@@ -118,6 +118,10 @@ assertTriggerRate(report, { min: 0.8, maxFalsePositive: 0.3 });
118
118
  }
119
119
  /** A JSON value placeholder for an `OutputFieldType`, for the `vigiles:ok` block. */
120
120
  function placeholderFor(type) {
121
+ // An enum's placeholder must be a MEMBER, or the scaffolded test fails the moment it
122
+ // is run — a generated test that cannot pass teaches the author to distrust the tool.
123
+ if (Array.isArray(type) && type.length > 0)
124
+ return type[0];
121
125
  switch (type) {
122
126
  case "number":
123
127
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "18.0.0",
3
+ "version": "18.1.0",
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",