vigiles 15.2.0 → 15.3.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.
- package/dist/adapters/claude-code/agent-result.d.ts +10 -1
- package/dist/adapters/claude-code/agent-result.js +14 -2
- package/dist/eval.js +17 -1
- package/dist/experimental-emit.d.ts +163 -0
- package/dist/experimental-emit.js +260 -0
- package/dist/experimental.d.ts +9 -2
- package/dist/experimental.js +13 -3
- package/dist/mutations.d.ts +96 -0
- package/dist/mutations.js +237 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +9 -1
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* third `malformed` track for a worker that didn't honor its contract (no block,
|
|
18
18
|
* bad JSON, or a shape that doesn't match the declared schema).
|
|
19
19
|
*/
|
|
20
|
-
import type { OutputContract } from "../../core/spec.js";
|
|
20
|
+
import type { OutputContract, OutputFieldType } from "../../core/spec.js";
|
|
21
21
|
/** The outcome of parsing a worker's result block. */
|
|
22
22
|
export type ParsedAgentResult<S = Record<string, unknown>, E = Record<string, unknown>> = {
|
|
23
23
|
readonly kind: "ok";
|
|
@@ -29,6 +29,15 @@ export type ParsedAgentResult<S = Record<string, unknown>, E = Record<string, un
|
|
|
29
29
|
readonly kind: "malformed";
|
|
30
30
|
readonly reason: string;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Validate a parsed object against a contract track; null when it conforms.
|
|
34
|
+
*
|
|
35
|
+
* Exported because the EXPERIMENTAL emit channel (`src/experimental-emit.ts`)
|
|
36
|
+
* validates the SAME `OutputContract` on a different delivery. One contract, two
|
|
37
|
+
* deliveries, one validator — a second copy would drift, and the two rails
|
|
38
|
+
* disagreeing about what satisfies a contract is the worst outcome available.
|
|
39
|
+
*/
|
|
40
|
+
export declare function shapeError(obj: Record<string, unknown>, shape: Readonly<Record<string, OutputFieldType>>): string | null;
|
|
32
41
|
/**
|
|
33
42
|
* Parse the last `vigiles:ok` / `vigiles:err` block from a worker's output.
|
|
34
43
|
*
|
|
@@ -19,10 +19,15 @@
|
|
|
19
19
|
* bad JSON, or a shape that doesn't match the declared schema).
|
|
20
20
|
*/
|
|
21
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.shapeError = shapeError;
|
|
22
23
|
exports.parseAgentResult = parseAgentResult;
|
|
23
24
|
// Capture every vigiles:ok / vigiles:err fenced block; the LAST one is the
|
|
24
25
|
// worker's final answer (earlier ones may be illustrative in its reasoning).
|
|
25
|
-
|
|
26
|
+
// The CLOSING fence must own its line: a ``` inside a JSON string value sits
|
|
27
|
+
// mid-line, so it no longer terminates the block (measured 2026-08-13 — a
|
|
28
|
+
// contract field carrying a code snippet made every such answer `malformed`
|
|
29
|
+
// even though the model's JSON was valid).
|
|
30
|
+
const BLOCK_RE = /^[ \t]*```vigiles:(ok|err)[ \t]*\r?\n([\s\S]*?)\r?\n^[ \t]*```[ \t]*$/gm;
|
|
26
31
|
/** Does a runtime value match a declared field type? */
|
|
27
32
|
function fieldMatches(value, type) {
|
|
28
33
|
switch (type) {
|
|
@@ -36,7 +41,14 @@ function fieldMatches(value, type) {
|
|
|
36
41
|
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Validate a parsed object against a contract track; null when it conforms.
|
|
46
|
+
*
|
|
47
|
+
* Exported because the EXPERIMENTAL emit channel (`src/experimental-emit.ts`)
|
|
48
|
+
* validates the SAME `OutputContract` on a different delivery. One contract, two
|
|
49
|
+
* deliveries, one validator — a second copy would drift, and the two rails
|
|
50
|
+
* disagreeing about what satisfies a contract is the worst outcome available.
|
|
51
|
+
*/
|
|
40
52
|
function shapeError(obj, shape) {
|
|
41
53
|
for (const [field, type] of Object.entries(shape)) {
|
|
42
54
|
if (!(field in obj))
|
package/dist/eval.js
CHANGED
|
@@ -981,7 +981,23 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
|
981
981
|
}
|
|
982
982
|
// A signal in the captured streams that the model call was rate-limited /
|
|
983
983
|
// overloaded — worth a backoff + retry rather than counting as a real sample.
|
|
984
|
-
|
|
984
|
+
//
|
|
985
|
+
// 🔴 The separator is `[ -]?`, NOT `.?`, and that is load-bearing. Claude Code's
|
|
986
|
+
// stream-json emits an INFORMATIONAL `{"type":"rate_limit_event","rate_limit_info":
|
|
987
|
+
// {"status":"allowed",…}}` line on EVERY run (captured verbatim in
|
|
988
|
+
// examples/experimental-emit/records/rate-limit-event.json). `rate.?limit` matched
|
|
989
|
+
// `rate_limit_event`, because `.` matches `_` — so every trial looked rate-limited,
|
|
990
|
+
// every trial was retried `retries + 1` = 4 times, and only the LAST attempt's cost
|
|
991
|
+
// reached `maxCostUsd`. Measured 2026-08-13: 2 trials → 8 model runs, a budget cap
|
|
992
|
+
// of $0.60 crossed at roughly $3, and the run reported one trial. `[ -]?` cannot
|
|
993
|
+
// match `_`, so the telemetry line is no longer a match; a REAL limit still is,
|
|
994
|
+
// because the API reports it as `rate_limit_error` / 429 / "overloaded".
|
|
995
|
+
//
|
|
996
|
+
// Known boundary, stated rather than hidden: a `rate_limit_event` whose `status`
|
|
997
|
+
// is a rejection is no longer a match either. It never was one on its merits — the
|
|
998
|
+
// old pattern fired on the event's NAME regardless of status, so status-aware
|
|
999
|
+
// detection has never existed here.
|
|
1000
|
+
const RATE_LIMIT_RE = /rate[ -]?limit(?:ed)?\b|rate_limit_error|\b429\b|overloaded|too many requests/i;
|
|
985
1001
|
/** Whether a run's captured output looks like a rate-limit / overload. Pure. */
|
|
986
1002
|
function isRateLimited(out) {
|
|
987
1003
|
return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ EXPERIMENTAL — the EMIT delivery for a typed result: the skill CALLS a tool
|
|
3
|
+
* carrying its outcome instead of ENDING its turn with a fenced block.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* A typed `output` (an `OutputContract`) is valid only on a forked skill: compile
|
|
8
|
+
* hard-errors `output-without-fork` on the other 31, because an inline skill is
|
|
9
|
+
* spliced into the conversation, has no call→return boundary, and therefore has
|
|
10
|
+
* no return value to type (`research/spec-syntax-and-railway-scope.md`).
|
|
11
|
+
*
|
|
12
|
+
* A TOOL CALL needs no return boundary. The skill does not return the structure —
|
|
13
|
+
* it EMITS it, mid-conversation, and the call lands in `Trace.toolCalls`. So the
|
|
14
|
+
* objection that grounds the exclusion does not apply to this delivery. Same
|
|
15
|
+
* `OutputContract`; a different way of getting it out.
|
|
16
|
+
*
|
|
17
|
+
* ## What is UNPROVEN (why the `experimental_` prefix is on every runtime export)
|
|
18
|
+
*
|
|
19
|
+
* 1. **N=8, one skill, one model.** Measured 2026-08-13 against `paper-status`
|
|
20
|
+
* (unforked; `allowed-tools: Bash, Read, Grep, Glob`) on sonnet: 8 runs, 8
|
|
21
|
+
* emits, all on the `ok` track, all parsing against the contract, none
|
|
22
|
+
* repeated. That answers "does it land at all" and nothing about the rate —
|
|
23
|
+
* 8 of 8 bounds the true failure rate at about 31%, which is not evidence of
|
|
24
|
+
* reliability. Raw arguments + the free re-scorer:
|
|
25
|
+
* `examples/experimental-emit/`.
|
|
26
|
+
* 2. **Nobody depends on it.** Neither `compileSkill` nor `compileAgent` emits
|
|
27
|
+
* this instruction; the caller pastes `.instruction` into a skill body and
|
|
28
|
+
* serves `.tool` from their own MCP server by hand. There is no compile-time
|
|
29
|
+
* path, so no skill in any corpus is typed by it yet.
|
|
30
|
+
* 3. **The transport is not part of the contract.** MCP is how the tool reached
|
|
31
|
+
* the model in the measurement. Whether a plugin can hand the model a tool
|
|
32
|
+
* WITHOUT a separate server process is untested, and the answer changes what
|
|
33
|
+
* this surface should look like.
|
|
34
|
+
* 4. **The runtime does not enforce `required`.** Measured: Claude Code accepted a
|
|
35
|
+
* call omitting three declared-required fields (raw proof in `mine`,
|
|
36
|
+
* `vigiles/repro/output-contract-2026-08-13/mcp-arm/schema-probe-emitted.jsonl`).
|
|
37
|
+
* `inputSchema` is DESCRIPTION for the model, not a runtime gate — which is
|
|
38
|
+
* exactly why `experimental_parseEmitted` re-validates on the receiving side
|
|
39
|
+
* and why "the API validates it for you" must not be claimed.
|
|
40
|
+
*
|
|
41
|
+
* ## What would have to be true to drop the prefix
|
|
42
|
+
*
|
|
43
|
+
* - A rate, not an existence proof: ≥30 trials across ≥2 unforked skills and ≥2
|
|
44
|
+
* models, with the emit-landing rate reported and its failure modes named.
|
|
45
|
+
* - One consumer inside vigiles that compiles the instruction from the spec, so
|
|
46
|
+
* the tool name and the shape cannot drift apart by hand.
|
|
47
|
+
* - A measured answer to (3) — plugin-served tool vs external MCP server — since
|
|
48
|
+
* an in-process tool would remove the standing-up cost this surface assumes.
|
|
49
|
+
*
|
|
50
|
+
* Until then: not covered by the stability guarantee, may change or be removed
|
|
51
|
+
* without a major bump. See `docs/../STABILITY.md` and `src/experimental.ts`.
|
|
52
|
+
*
|
|
53
|
+
* @experimental
|
|
54
|
+
* @module
|
|
55
|
+
*/
|
|
56
|
+
import type { OutputContract } from "./core/spec.js";
|
|
57
|
+
import type { ToolCall } from "./core/harness-driver.js";
|
|
58
|
+
import { type ParsedAgentResult } from "./adapters/claude-code/agent-result.js";
|
|
59
|
+
/** A JSON-Schema fragment for one declared field. */
|
|
60
|
+
export type EmitFieldSchema = {
|
|
61
|
+
readonly type: "string";
|
|
62
|
+
} | {
|
|
63
|
+
readonly type: "number";
|
|
64
|
+
} | {
|
|
65
|
+
readonly type: "boolean";
|
|
66
|
+
} | {
|
|
67
|
+
readonly type: "array";
|
|
68
|
+
readonly items: {
|
|
69
|
+
readonly type: "string";
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/** The `track` discriminator's schema — the only enum this surface emits. */
|
|
73
|
+
export interface EmitTrackSchema {
|
|
74
|
+
readonly type: "string";
|
|
75
|
+
readonly enum: readonly ["ok", "err"];
|
|
76
|
+
}
|
|
77
|
+
/** Anything that can sit under `properties`: a field, the discriminator, a track. */
|
|
78
|
+
export type EmitPropertySchema = EmitFieldSchema | EmitTrackSchema | EmitObjectSchema;
|
|
79
|
+
/** A JSON-Schema object node — one track's payload, or the whole argument. */
|
|
80
|
+
export interface EmitObjectSchema {
|
|
81
|
+
readonly type: "object";
|
|
82
|
+
readonly properties: Readonly<Record<string, EmitPropertySchema>>;
|
|
83
|
+
readonly required: readonly string[];
|
|
84
|
+
readonly additionalProperties: false;
|
|
85
|
+
}
|
|
86
|
+
/** An MCP tool definition, in the shape a `tools/list` response carries. */
|
|
87
|
+
export interface EmitToolDefinition {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
readonly description: string;
|
|
90
|
+
readonly inputSchema: EmitObjectSchema;
|
|
91
|
+
}
|
|
92
|
+
/** What `experimental_emitTool` hands back: the tool, and the prose that asks for it. */
|
|
93
|
+
export interface ExperimentalEmitTool {
|
|
94
|
+
/** Serve this from your MCP server's `tools/list`. */
|
|
95
|
+
readonly tool: EmitToolDefinition;
|
|
96
|
+
/**
|
|
97
|
+
* Markdown fragment for the skill body. The SAME contract rendered for the
|
|
98
|
+
* model — kept next to the schema so the two cannot drift when hand-wired.
|
|
99
|
+
*/
|
|
100
|
+
readonly instruction: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* ⚠️ EXPERIMENTAL. Derive an emit TOOL from an `OutputContract` — the same
|
|
104
|
+
* contract the fork rail renders as a `vigiles:ok` / `vigiles:err` fenced block.
|
|
105
|
+
*
|
|
106
|
+
* const emit = experimental_emitTool(contract);
|
|
107
|
+
* // emit.tool → serve from your MCP server
|
|
108
|
+
* // emit.instruction → paste into the (unforked) skill's body
|
|
109
|
+
*
|
|
110
|
+
* The two tracks are NESTED (`{ track, ok? , err? }`), not flattened into one bag
|
|
111
|
+
* of fields. That is deliberate: a flat union cannot say which fields are required
|
|
112
|
+
* on which track, so "success fields mixed with error fields" would be a
|
|
113
|
+
* well-formed call. Nested, it is not expressible.
|
|
114
|
+
*
|
|
115
|
+
* 🔴 `required` in the returned schema is DESCRIPTION, not enforcement — measured,
|
|
116
|
+
* see the module header (4). Validate what arrives with
|
|
117
|
+
* `experimental_parseEmitted`.
|
|
118
|
+
*
|
|
119
|
+
* @experimental
|
|
120
|
+
*/
|
|
121
|
+
export declare function experimental_emitTool(contract: OutputContract, options?: {
|
|
122
|
+
readonly name?: string;
|
|
123
|
+
}): ExperimentalEmitTool;
|
|
124
|
+
/**
|
|
125
|
+
* ⚠️ EXPERIMENTAL. Read the emitted result out of a run's tool calls, validated
|
|
126
|
+
* against the contract. Pure — returns the same `ParsedAgentResult` vocabulary the
|
|
127
|
+
* fenced rail's `parseAgentResult` returns, so an eval `measure` can use it as a
|
|
128
|
+
* metric and the assertion below can wrap it, without one dual-purpose function.
|
|
129
|
+
*
|
|
130
|
+
* Accepts `Trace["toolCalls"]`, `SubagentTrace["toolCalls"]` or an eval
|
|
131
|
+
* `ctx.toolCalls`. Names are matched bare (`emit_result`) or MCP-prefixed
|
|
132
|
+
* (`mcp__<server>__emit_result`).
|
|
133
|
+
*
|
|
134
|
+
* Differs from the fenced rail in ONE deliberate way: **more than one call is
|
|
135
|
+
* `malformed`, not last-one-wins.** The fenced parser takes the LAST block because
|
|
136
|
+
* an earlier block may be illustrative reasoning; a tool call is an action, never
|
|
137
|
+
* illustrative, so "exactly once" is checkable here and is not checkable there.
|
|
138
|
+
*
|
|
139
|
+
* @experimental
|
|
140
|
+
*/
|
|
141
|
+
export declare function experimental_parseEmitted(toolCalls: readonly ToolCall[], contract: OutputContract, options?: {
|
|
142
|
+
readonly name?: string;
|
|
143
|
+
}): ParsedAgentResult;
|
|
144
|
+
/**
|
|
145
|
+
* ⚠️ EXPERIMENTAL. Assert the run emitted a SUCCESS result, and return its value —
|
|
146
|
+
* the emit-channel counterpart of `assertAgentOk`, for a skill that has no return
|
|
147
|
+
* value to assert on.
|
|
148
|
+
*
|
|
149
|
+
* Throws on a missing emit, a repeated emit, an error track, or a payload that
|
|
150
|
+
* does not match the contract. The failure message names every tool the run DID
|
|
151
|
+
* call, because "the skill never emitted" and "the skill emitted the wrong shape"
|
|
152
|
+
* are different bugs and the tool list separates them at a glance.
|
|
153
|
+
*
|
|
154
|
+
* The error track is reachable through `experimental_parseEmitted`; a matching
|
|
155
|
+
* `…EmittedErr` is deliberately NOT shipped while the surface is this young —
|
|
156
|
+
* three exports is the whole prototype.
|
|
157
|
+
*
|
|
158
|
+
* @experimental
|
|
159
|
+
*/
|
|
160
|
+
export declare function experimental_assertEmittedOk(toolCalls: readonly ToolCall[], contract: OutputContract, options?: {
|
|
161
|
+
readonly name?: string;
|
|
162
|
+
}): Record<string, unknown>;
|
|
163
|
+
//# sourceMappingURL=experimental-emit.d.ts.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ⚠️ EXPERIMENTAL — the EMIT delivery for a typed result: the skill CALLS a tool
|
|
4
|
+
* carrying its outcome instead of ENDING its turn with a fenced block.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* A typed `output` (an `OutputContract`) is valid only on a forked skill: compile
|
|
9
|
+
* hard-errors `output-without-fork` on the other 31, because an inline skill is
|
|
10
|
+
* spliced into the conversation, has no call→return boundary, and therefore has
|
|
11
|
+
* no return value to type (`research/spec-syntax-and-railway-scope.md`).
|
|
12
|
+
*
|
|
13
|
+
* A TOOL CALL needs no return boundary. The skill does not return the structure —
|
|
14
|
+
* it EMITS it, mid-conversation, and the call lands in `Trace.toolCalls`. So the
|
|
15
|
+
* objection that grounds the exclusion does not apply to this delivery. Same
|
|
16
|
+
* `OutputContract`; a different way of getting it out.
|
|
17
|
+
*
|
|
18
|
+
* ## What is UNPROVEN (why the `experimental_` prefix is on every runtime export)
|
|
19
|
+
*
|
|
20
|
+
* 1. **N=8, one skill, one model.** Measured 2026-08-13 against `paper-status`
|
|
21
|
+
* (unforked; `allowed-tools: Bash, Read, Grep, Glob`) on sonnet: 8 runs, 8
|
|
22
|
+
* emits, all on the `ok` track, all parsing against the contract, none
|
|
23
|
+
* repeated. That answers "does it land at all" and nothing about the rate —
|
|
24
|
+
* 8 of 8 bounds the true failure rate at about 31%, which is not evidence of
|
|
25
|
+
* reliability. Raw arguments + the free re-scorer:
|
|
26
|
+
* `examples/experimental-emit/`.
|
|
27
|
+
* 2. **Nobody depends on it.** Neither `compileSkill` nor `compileAgent` emits
|
|
28
|
+
* this instruction; the caller pastes `.instruction` into a skill body and
|
|
29
|
+
* serves `.tool` from their own MCP server by hand. There is no compile-time
|
|
30
|
+
* path, so no skill in any corpus is typed by it yet.
|
|
31
|
+
* 3. **The transport is not part of the contract.** MCP is how the tool reached
|
|
32
|
+
* the model in the measurement. Whether a plugin can hand the model a tool
|
|
33
|
+
* WITHOUT a separate server process is untested, and the answer changes what
|
|
34
|
+
* this surface should look like.
|
|
35
|
+
* 4. **The runtime does not enforce `required`.** Measured: Claude Code accepted a
|
|
36
|
+
* call omitting three declared-required fields (raw proof in `mine`,
|
|
37
|
+
* `vigiles/repro/output-contract-2026-08-13/mcp-arm/schema-probe-emitted.jsonl`).
|
|
38
|
+
* `inputSchema` is DESCRIPTION for the model, not a runtime gate — which is
|
|
39
|
+
* exactly why `experimental_parseEmitted` re-validates on the receiving side
|
|
40
|
+
* and why "the API validates it for you" must not be claimed.
|
|
41
|
+
*
|
|
42
|
+
* ## What would have to be true to drop the prefix
|
|
43
|
+
*
|
|
44
|
+
* - A rate, not an existence proof: ≥30 trials across ≥2 unforked skills and ≥2
|
|
45
|
+
* models, with the emit-landing rate reported and its failure modes named.
|
|
46
|
+
* - One consumer inside vigiles that compiles the instruction from the spec, so
|
|
47
|
+
* the tool name and the shape cannot drift apart by hand.
|
|
48
|
+
* - A measured answer to (3) — plugin-served tool vs external MCP server — since
|
|
49
|
+
* an in-process tool would remove the standing-up cost this surface assumes.
|
|
50
|
+
*
|
|
51
|
+
* Until then: not covered by the stability guarantee, may change or be removed
|
|
52
|
+
* without a major bump. See `docs/../STABILITY.md` and `src/experimental.ts`.
|
|
53
|
+
*
|
|
54
|
+
* @experimental
|
|
55
|
+
* @module
|
|
56
|
+
*/
|
|
57
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
+
exports.experimental_emitTool = experimental_emitTool;
|
|
59
|
+
exports.experimental_parseEmitted = experimental_parseEmitted;
|
|
60
|
+
exports.experimental_assertEmittedOk = experimental_assertEmittedOk;
|
|
61
|
+
const agent_result_js_1 = require("./adapters/claude-code/agent-result.js");
|
|
62
|
+
/** The default tool name, when `options.name` is not given. */
|
|
63
|
+
const DEFAULT_EMIT_TOOL = "emit_result";
|
|
64
|
+
function fieldSchema(type) {
|
|
65
|
+
switch (type) {
|
|
66
|
+
case "string":
|
|
67
|
+
return { type: "string" };
|
|
68
|
+
case "number":
|
|
69
|
+
return { type: "number" };
|
|
70
|
+
case "boolean":
|
|
71
|
+
return { type: "boolean" };
|
|
72
|
+
case "string[]":
|
|
73
|
+
return { type: "array", items: { type: "string" } };
|
|
74
|
+
default:
|
|
75
|
+
// 🔴 Unreachable from TypeScript, reachable from JavaScript — and the one
|
|
76
|
+
// shipped example of this API (examples/experimental-emit/run-emit.mjs) is
|
|
77
|
+
// .mjs, so this is the path a real author takes.
|
|
78
|
+
//
|
|
79
|
+
// Without this arm the switch fell through to `undefined`, and every later
|
|
80
|
+
// step read that as a field: `"actions" in properties` is TRUE for an
|
|
81
|
+
// undefined value, so nothing noticed; `JSON.stringify` then DROPPED the
|
|
82
|
+
// key while `required` kept it and `additionalProperties: false` forbade
|
|
83
|
+
// it. The served schema demanded a property it also banned — unsatisfiable,
|
|
84
|
+
// silent, and contradicted by `.instruction`, which still asked the model
|
|
85
|
+
// to send it. Throwing here makes the contradiction impossible to construct
|
|
86
|
+
// instead of merely unlikely.
|
|
87
|
+
throw new TypeError(`experimental_emitTool: unsupported field type ${JSON.stringify(type)}. ` +
|
|
88
|
+
`Supported: "string", "number", "boolean", "string[]". ` +
|
|
89
|
+
`Nested objects and enums are outside this surface's ceiling — flatten the field, ` +
|
|
90
|
+
`or use the fenced fork rail if the shape cannot be flattened.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function trackSchema(shape) {
|
|
94
|
+
const properties = {};
|
|
95
|
+
for (const [field, type] of Object.entries(shape)) {
|
|
96
|
+
properties[field] = fieldSchema(type);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties,
|
|
101
|
+
required: Object.keys(shape),
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Render a declared shape the way the fenced contract renders it, for the prose half. */
|
|
106
|
+
function renderShape(shape) {
|
|
107
|
+
const fields = Object.entries(shape)
|
|
108
|
+
.map(([k, t]) => `"${k}": ${t}`)
|
|
109
|
+
.join(", ");
|
|
110
|
+
return fields ? `{ ${fields} }` : "{}";
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* ⚠️ EXPERIMENTAL. Derive an emit TOOL from an `OutputContract` — the same
|
|
114
|
+
* contract the fork rail renders as a `vigiles:ok` / `vigiles:err` fenced block.
|
|
115
|
+
*
|
|
116
|
+
* const emit = experimental_emitTool(contract);
|
|
117
|
+
* // emit.tool → serve from your MCP server
|
|
118
|
+
* // emit.instruction → paste into the (unforked) skill's body
|
|
119
|
+
*
|
|
120
|
+
* The two tracks are NESTED (`{ track, ok? , err? }`), not flattened into one bag
|
|
121
|
+
* of fields. That is deliberate: a flat union cannot say which fields are required
|
|
122
|
+
* on which track, so "success fields mixed with error fields" would be a
|
|
123
|
+
* well-formed call. Nested, it is not expressible.
|
|
124
|
+
*
|
|
125
|
+
* 🔴 `required` in the returned schema is DESCRIPTION, not enforcement — measured,
|
|
126
|
+
* see the module header (4). Validate what arrives with
|
|
127
|
+
* `experimental_parseEmitted`.
|
|
128
|
+
*
|
|
129
|
+
* @experimental
|
|
130
|
+
*/
|
|
131
|
+
function experimental_emitTool(contract, options = {}) {
|
|
132
|
+
const name = options.name ?? DEFAULT_EMIT_TOOL;
|
|
133
|
+
const tool = {
|
|
134
|
+
name,
|
|
135
|
+
description: "Emit this task's structured result. Call this exactly once. Set " +
|
|
136
|
+
'`track` to "ok" and fill `ok` on success, or "err" and fill `err` on ' +
|
|
137
|
+
"failure. Do not call it twice and do not fill both tracks.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
track: { type: "string", enum: ["ok", "err"] },
|
|
142
|
+
ok: trackSchema(contract.ok),
|
|
143
|
+
err: trackSchema(contract.err),
|
|
144
|
+
},
|
|
145
|
+
required: ["track"],
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const instruction = [
|
|
150
|
+
"## Output contract",
|
|
151
|
+
"",
|
|
152
|
+
`Emit your result by calling the \`${name}\` tool exactly once, at the point`,
|
|
153
|
+
"you have the answer. Do not print it as a code block; the call IS the result.",
|
|
154
|
+
"",
|
|
155
|
+
`On success: \`track: "ok"\`, with \`ok\` =`,
|
|
156
|
+
"",
|
|
157
|
+
"```json",
|
|
158
|
+
renderShape(contract.ok),
|
|
159
|
+
"```",
|
|
160
|
+
"",
|
|
161
|
+
`On failure: \`track: "err"\`, with \`err\` =`,
|
|
162
|
+
"",
|
|
163
|
+
"```json",
|
|
164
|
+
renderShape(contract.err),
|
|
165
|
+
"```",
|
|
166
|
+
].join("\n");
|
|
167
|
+
return { tool, instruction };
|
|
168
|
+
}
|
|
169
|
+
/** Does this observed tool name refer to `name` (bare, or MCP-prefixed)? */
|
|
170
|
+
function isEmitCall(observed, name) {
|
|
171
|
+
return observed === name || observed.endsWith(`__${name}`);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* ⚠️ EXPERIMENTAL. Read the emitted result out of a run's tool calls, validated
|
|
175
|
+
* against the contract. Pure — returns the same `ParsedAgentResult` vocabulary the
|
|
176
|
+
* fenced rail's `parseAgentResult` returns, so an eval `measure` can use it as a
|
|
177
|
+
* metric and the assertion below can wrap it, without one dual-purpose function.
|
|
178
|
+
*
|
|
179
|
+
* Accepts `Trace["toolCalls"]`, `SubagentTrace["toolCalls"]` or an eval
|
|
180
|
+
* `ctx.toolCalls`. Names are matched bare (`emit_result`) or MCP-prefixed
|
|
181
|
+
* (`mcp__<server>__emit_result`).
|
|
182
|
+
*
|
|
183
|
+
* Differs from the fenced rail in ONE deliberate way: **more than one call is
|
|
184
|
+
* `malformed`, not last-one-wins.** The fenced parser takes the LAST block because
|
|
185
|
+
* an earlier block may be illustrative reasoning; a tool call is an action, never
|
|
186
|
+
* illustrative, so "exactly once" is checkable here and is not checkable there.
|
|
187
|
+
*
|
|
188
|
+
* @experimental
|
|
189
|
+
*/
|
|
190
|
+
function experimental_parseEmitted(toolCalls, contract, options = {}) {
|
|
191
|
+
const name = options.name ?? DEFAULT_EMIT_TOOL;
|
|
192
|
+
const calls = toolCalls.filter((c) => isEmitCall(c.name, name));
|
|
193
|
+
if (calls.length === 0) {
|
|
194
|
+
return { kind: "malformed", reason: `no \`${name}\` tool call in the run` };
|
|
195
|
+
}
|
|
196
|
+
if (calls.length > 1) {
|
|
197
|
+
return {
|
|
198
|
+
kind: "malformed",
|
|
199
|
+
reason: `\`${name}\` was called ${String(calls.length)} times; the contract is exactly once`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const input = calls[0].input;
|
|
203
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "malformed",
|
|
206
|
+
reason: `\`${name}\` call carried no object argument`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const args = input;
|
|
210
|
+
const track = args.track;
|
|
211
|
+
if (track !== "ok" && track !== "err") {
|
|
212
|
+
return {
|
|
213
|
+
kind: "malformed",
|
|
214
|
+
reason: `\`${name}\` call has no \`track\` of "ok" or "err"`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const payload = args[track];
|
|
218
|
+
if (typeof payload !== "object" ||
|
|
219
|
+
payload === null ||
|
|
220
|
+
Array.isArray(payload)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: "malformed",
|
|
223
|
+
reason: `\`${name}\` call declared track "${track}" but carried no \`${track}\` object`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const obj = payload;
|
|
227
|
+
const bad = (0, agent_result_js_1.shapeError)(obj, track === "ok" ? contract.ok : contract.err);
|
|
228
|
+
if (bad)
|
|
229
|
+
return { kind: "malformed", reason: `${track} payload: ${bad}` };
|
|
230
|
+
return track === "ok"
|
|
231
|
+
? { kind: "ok", value: obj }
|
|
232
|
+
: { kind: "err", error: obj };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* ⚠️ EXPERIMENTAL. Assert the run emitted a SUCCESS result, and return its value —
|
|
236
|
+
* the emit-channel counterpart of `assertAgentOk`, for a skill that has no return
|
|
237
|
+
* value to assert on.
|
|
238
|
+
*
|
|
239
|
+
* Throws on a missing emit, a repeated emit, an error track, or a payload that
|
|
240
|
+
* does not match the contract. The failure message names every tool the run DID
|
|
241
|
+
* call, because "the skill never emitted" and "the skill emitted the wrong shape"
|
|
242
|
+
* are different bugs and the tool list separates them at a glance.
|
|
243
|
+
*
|
|
244
|
+
* The error track is reachable through `experimental_parseEmitted`; a matching
|
|
245
|
+
* `…EmittedErr` is deliberately NOT shipped while the surface is this young —
|
|
246
|
+
* three exports is the whole prototype.
|
|
247
|
+
*
|
|
248
|
+
* @experimental
|
|
249
|
+
*/
|
|
250
|
+
function experimental_assertEmittedOk(toolCalls, contract, options = {}) {
|
|
251
|
+
const r = experimental_parseEmitted(toolCalls, contract, options);
|
|
252
|
+
if (r.kind === "ok")
|
|
253
|
+
return r.value;
|
|
254
|
+
const why = r.kind === "err"
|
|
255
|
+
? `it emitted an error result: ${JSON.stringify(r.error)}`
|
|
256
|
+
: r.reason;
|
|
257
|
+
const observed = toolCalls.map((c) => c.name).join(", ") || "none";
|
|
258
|
+
throw new Error(`expected an emitted success result, but ${why} (tools called: ${observed})`);
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=experimental-emit.js.map
|
package/dist/experimental.d.ts
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
* NOT covered by the stability guarantee (STABILITY.md): the shape may change or
|
|
11
11
|
* be removed WITHOUT a major-version bump. Do not depend on it in production.
|
|
12
12
|
*
|
|
13
|
-
* Current contents
|
|
14
|
-
*
|
|
13
|
+
* Current contents:
|
|
14
|
+
* - the R3 disposable-service tier (real side-effect testing; see
|
|
15
|
+
* docs/measuring-skills.md § Experimental and src/services.ts);
|
|
16
|
+
* - the EMIT delivery for a typed result (`src/experimental-emit.ts`) — a skill
|
|
17
|
+
* that CALLS a tool with its outcome instead of ending its turn with a fenced
|
|
18
|
+
* block, which is how an UNFORKED skill can carry an `OutputContract` at all.
|
|
19
|
+
* Read that module's header before using it: it lists, by number, what is
|
|
20
|
+
* unproven and what would have to be true to drop the prefix.
|
|
15
21
|
*
|
|
16
22
|
* ⚠️ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
|
|
17
23
|
* the ONLY isolation vigiles provides — it does not confine the skill's filesystem
|
|
@@ -24,4 +30,5 @@
|
|
|
24
30
|
*/
|
|
25
31
|
export { experimental_startServices, experimental_withServices, type ServiceSpec, type ServiceReady, type ServiceReset, type ServiceHandle, type ServiceSession, type ContainerRuntime, } from "./services.js";
|
|
26
32
|
export { experimental_dockerRuntime, makeDockerRuntime, type DockerExec, type NetProbe, } from "./services-docker.js";
|
|
33
|
+
export { experimental_emitTool, experimental_parseEmitted, experimental_assertEmittedOk, type EmitFieldSchema, type EmitObjectSchema, type EmitPropertySchema, type EmitTrackSchema, type EmitToolDefinition, type ExperimentalEmitTool, } from "./experimental-emit.js";
|
|
27
34
|
//# sourceMappingURL=experimental.d.ts.map
|
package/dist/experimental.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = void 0;
|
|
3
|
+
exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* `vigiles/experimental` — ⚠️ EXPERIMENTAL, UNSTABLE public surface.
|
|
6
6
|
*
|
|
@@ -13,8 +13,14 @@ exports.makeDockerRuntime = exports.experimental_dockerRuntime = exports.experim
|
|
|
13
13
|
* NOT covered by the stability guarantee (STABILITY.md): the shape may change or
|
|
14
14
|
* be removed WITHOUT a major-version bump. Do not depend on it in production.
|
|
15
15
|
*
|
|
16
|
-
* Current contents
|
|
17
|
-
*
|
|
16
|
+
* Current contents:
|
|
17
|
+
* - the R3 disposable-service tier (real side-effect testing; see
|
|
18
|
+
* docs/measuring-skills.md § Experimental and src/services.ts);
|
|
19
|
+
* - the EMIT delivery for a typed result (`src/experimental-emit.ts`) — a skill
|
|
20
|
+
* that CALLS a tool with its outcome instead of ending its turn with a fenced
|
|
21
|
+
* block, which is how an UNFORKED skill can carry an `OutputContract` at all.
|
|
22
|
+
* Read that module's header before using it: it lists, by number, what is
|
|
23
|
+
* unproven and what would have to be true to drop the prefix.
|
|
18
24
|
*
|
|
19
25
|
* ⚠️ SAFETY: R3 runs a model-driven skill FOR REAL. The disposable container is
|
|
20
26
|
* the ONLY isolation vigiles provides — it does not confine the skill's filesystem
|
|
@@ -31,4 +37,8 @@ Object.defineProperty(exports, "experimental_withServices", { enumerable: true,
|
|
|
31
37
|
var services_docker_js_1 = require("./services-docker.js");
|
|
32
38
|
Object.defineProperty(exports, "experimental_dockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.experimental_dockerRuntime; } });
|
|
33
39
|
Object.defineProperty(exports, "makeDockerRuntime", { enumerable: true, get: function () { return services_docker_js_1.makeDockerRuntime; } });
|
|
40
|
+
var experimental_emit_js_1 = require("./experimental-emit.js");
|
|
41
|
+
Object.defineProperty(exports, "experimental_emitTool", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_emitTool; } });
|
|
42
|
+
Object.defineProperty(exports, "experimental_parseEmitted", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_parseEmitted; } });
|
|
43
|
+
Object.defineProperty(exports, "experimental_assertEmittedOk", { enumerable: true, get: function () { return experimental_emit_js_1.experimental_assertEmittedOk; } });
|
|
34
44
|
//# sourceMappingURL=experimental.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** One edit: the file, the exact substring to find, and what replaces it. */
|
|
2
|
+
export type MutationEdit = readonly [
|
|
3
|
+
file: string,
|
|
4
|
+
find: string,
|
|
5
|
+
replace: string
|
|
6
|
+
];
|
|
7
|
+
/** One planted defect and the assertion that must catch it. */
|
|
8
|
+
export interface MutationCase {
|
|
9
|
+
/** Short id, printed in the report. */
|
|
10
|
+
readonly name: string;
|
|
11
|
+
/** What the mutation takes away, in words — the column a reader scans. */
|
|
12
|
+
readonly disables: string;
|
|
13
|
+
/**
|
|
14
|
+
* The edits that plant it. A LIST, because some defects are only expressible as more than one:
|
|
15
|
+
* removing a guard AND the vocabulary check that would otherwise throw for an unrelated reason.
|
|
16
|
+
* Every edit must match its `find` EXACTLY ONCE, or the case is reported `not-applied`.
|
|
17
|
+
*/
|
|
18
|
+
readonly edits: readonly MutationEdit[];
|
|
19
|
+
/** The test file that must go red. Its absence is refused, loudly, before anything is touched. */
|
|
20
|
+
readonly test: string;
|
|
21
|
+
/** A substring the test's complaint must contain, so a kill by a NEIGHBOUR is not counted. */
|
|
22
|
+
readonly expect: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* - `killed` — the test went red AND printed `expect`. The only outcome that counts as proof.
|
|
26
|
+
* - `wrong-assertion` — red, but not with this case's message: two defects share one assertion and
|
|
27
|
+
* neither is really watched.
|
|
28
|
+
* - `survived` — the test stayed green. The assertion is vacuous, or absent.
|
|
29
|
+
* - `unjudgeable` — the test was ALREADY red before the run, and `expect` never printed, so "red"
|
|
30
|
+
* carries no information about this mutation. Not a pass and not a failure of the assertion.
|
|
31
|
+
* - `not-applied` — the edit did not land: `find` matched zero or several times, or the
|
|
32
|
+
* replacement equalled the original.
|
|
33
|
+
*/
|
|
34
|
+
export type MutationVerdict = "killed" | "wrong-assertion" | "survived" | "unjudgeable" | "not-applied";
|
|
35
|
+
/** What one case did. */
|
|
36
|
+
export interface MutationOutcome {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly disables: string;
|
|
39
|
+
readonly verdict: MutationVerdict;
|
|
40
|
+
/** Human-readable specifics — which file, how many matches, whether a retry was needed. */
|
|
41
|
+
readonly detail: string;
|
|
42
|
+
}
|
|
43
|
+
/** What a whole run did. */
|
|
44
|
+
export interface MutationReport {
|
|
45
|
+
readonly outcomes: readonly MutationOutcome[];
|
|
46
|
+
/** Cases with verdict `killed`. */
|
|
47
|
+
readonly killed: number;
|
|
48
|
+
/** Test files that were red BEFORE any mutation, so they can testify about nothing. */
|
|
49
|
+
readonly alreadyRed: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Whether every test that was green before the run is green again after it. `false` means the
|
|
52
|
+
* restore failed and the working tree is not what it was — the one outcome worth interrupting for.
|
|
53
|
+
*/
|
|
54
|
+
readonly restored: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface RunMutationsOptions {
|
|
57
|
+
/** Working directory for the test runs; every path in `edits` and `test` is absolute. */
|
|
58
|
+
readonly cwd: string;
|
|
59
|
+
readonly cases: readonly MutationCase[];
|
|
60
|
+
/**
|
|
61
|
+
* Extra environment for each test run, merged over `process.env`.
|
|
62
|
+
* Use it to hand the test the same variables its normal runner would.
|
|
63
|
+
*/
|
|
64
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Plant each case's defect, run the test that owns it, restore, and report what happened.
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const report = runMutations({
|
|
71
|
+
* cwd: repoRoot,
|
|
72
|
+
* cases: [{
|
|
73
|
+
* name: "year",
|
|
74
|
+
* disables: "the year comparison",
|
|
75
|
+
* edits: [[checker, "rec.year !== ourYear", "false"]],
|
|
76
|
+
* test: harness,
|
|
77
|
+
* expect: "a wrong year was not reported",
|
|
78
|
+
* }],
|
|
79
|
+
* });
|
|
80
|
+
* console.log(formatMutationReport(report));
|
|
81
|
+
* process.exit(report.killed === report.outcomes.length && report.restored ? 0 : 1);
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* 🔴 THIS REWRITES THE FILES NAMED IN `edits` AND RESTORES THEM. The restore runs in a `finally`
|
|
85
|
+
* AND on SIGINT/SIGTERM, because a run killed midway would otherwise leave a neutered checker on
|
|
86
|
+
* disk — and the next run would then measure an already-broken repo and call it healthy. Commit
|
|
87
|
+
* before running. (Do not reach for `git stash` to clear the tree: the stash is repository-global,
|
|
88
|
+
* so a second agent working in another worktree of the same repo will pop your entries.)
|
|
89
|
+
*
|
|
90
|
+
* @throws if `cases` is empty, or if any named test file does not exist — both BEFORE any file is
|
|
91
|
+
* touched, so the message arrives with a clean working tree.
|
|
92
|
+
*/
|
|
93
|
+
export declare function runMutations(o: RunMutationsOptions): MutationReport;
|
|
94
|
+
/** Render a report the way the CLI-style runners in this package render theirs. */
|
|
95
|
+
export declare function formatMutationReport(report: MutationReport): string;
|
|
96
|
+
//# sourceMappingURL=mutations.d.ts.map
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runMutations = runMutations;
|
|
4
|
+
exports.formatMutationReport = formatMutationReport;
|
|
5
|
+
/**
|
|
6
|
+
* `runMutations` — prove a test can FAIL, by breaking the thing it watches.
|
|
7
|
+
*
|
|
8
|
+
* A green test says the checker passed. It does not say the test would notice if the check were
|
|
9
|
+
* deleted: an assertion can be vacuous, a fixture can be wrong in the same direction as the code,
|
|
10
|
+
* and both look exactly like a pass. The only way to tell is to plant a defect and require the
|
|
11
|
+
* test to go red — with the message that NAMES that defect, not merely with something red.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this is in vigiles rather than in a repo's own scripts
|
|
14
|
+
*
|
|
15
|
+
* It arrived as ten hand-written copies of one driver in a dogfooding repo (1799 lines), and the
|
|
16
|
+
* copies had DRIFTED. Counted against the committed versions on 2026-08-14:
|
|
17
|
+
*
|
|
18
|
+
* - the NO-OP guard (a replacement equal to the original leaves a green test proving nothing)
|
|
19
|
+
* was in 4 of 10;
|
|
20
|
+
* - the RETRY (a non-kill re-run once before it is believed) was in 1 of 10;
|
|
21
|
+
* - the strict rule — killed by its OWN named assertion, not merely by something going red —
|
|
22
|
+
* was in 1 of 10.
|
|
23
|
+
*
|
|
24
|
+
* Every one of those was written AFTER it caught something, in whichever copy happened to catch
|
|
25
|
+
* it, and never travelled to the other nine. Two copies also named a test file that a refactor had
|
|
26
|
+
* deleted; the runner they used exits 0 on a path matching nothing, so those cases reported
|
|
27
|
+
* SURVIVED on every run for three days. That is the argument for one engine: not less code, but
|
|
28
|
+
* one place where each of those becomes impossible again.
|
|
29
|
+
*
|
|
30
|
+
* ## What this is NOT
|
|
31
|
+
*
|
|
32
|
+
* Not Stryker/mutmut/PIT. Those GENERATE mutants from operators (flip a `<`, drop a `return`) over
|
|
33
|
+
* production code and score a suite by kill ratio. Here the mutations are HAND-AUTHORED and each
|
|
34
|
+
* one names the assertion that must catch it, because the subject is usually not general-purpose
|
|
35
|
+
* code — it is a checker, a hook, an instruction file — where "flip an operator" produces mostly
|
|
36
|
+
* unreachable nonsense and a kill ratio measures nothing. The generated-operator approach is the
|
|
37
|
+
* better tool when it applies; reach for it there.
|
|
38
|
+
*
|
|
39
|
+
* ## The contract, in one sentence
|
|
40
|
+
*
|
|
41
|
+
* Plant one defect, run the test that owns it, and require the test to fail with the message that
|
|
42
|
+
* names it — anything else is reported as a finding, never as a pass.
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
*/
|
|
46
|
+
const node_fs_1 = require("node:fs");
|
|
47
|
+
const node_child_process_1 = require("node:child_process");
|
|
48
|
+
const node_path_1 = require("node:path");
|
|
49
|
+
const run_scripts_js_1 = require("./adapters/claude-code/run-scripts.js");
|
|
50
|
+
function runTest(file, o) {
|
|
51
|
+
const argv = (0, run_scripts_js_1.interpreterArgs)(file, (0, run_scripts_js_1.detectNodeCaps)(o.cwd));
|
|
52
|
+
const r = (0, node_child_process_1.spawnSync)("node", argv, {
|
|
53
|
+
cwd: o.cwd,
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
env: { ...process.env, ...o.env },
|
|
56
|
+
});
|
|
57
|
+
return { failed: r.status !== 0, out: (r.stdout ?? "") + (r.stderr ?? "") };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Plant each case's defect, run the test that owns it, restore, and report what happened.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* const report = runMutations({
|
|
64
|
+
* cwd: repoRoot,
|
|
65
|
+
* cases: [{
|
|
66
|
+
* name: "year",
|
|
67
|
+
* disables: "the year comparison",
|
|
68
|
+
* edits: [[checker, "rec.year !== ourYear", "false"]],
|
|
69
|
+
* test: harness,
|
|
70
|
+
* expect: "a wrong year was not reported",
|
|
71
|
+
* }],
|
|
72
|
+
* });
|
|
73
|
+
* console.log(formatMutationReport(report));
|
|
74
|
+
* process.exit(report.killed === report.outcomes.length && report.restored ? 0 : 1);
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* 🔴 THIS REWRITES THE FILES NAMED IN `edits` AND RESTORES THEM. The restore runs in a `finally`
|
|
78
|
+
* AND on SIGINT/SIGTERM, because a run killed midway would otherwise leave a neutered checker on
|
|
79
|
+
* disk — and the next run would then measure an already-broken repo and call it healthy. Commit
|
|
80
|
+
* before running. (Do not reach for `git stash` to clear the tree: the stash is repository-global,
|
|
81
|
+
* so a second agent working in another worktree of the same repo will pop your entries.)
|
|
82
|
+
*
|
|
83
|
+
* @throws if `cases` is empty, or if any named test file does not exist — both BEFORE any file is
|
|
84
|
+
* touched, so the message arrives with a clean working tree.
|
|
85
|
+
*/
|
|
86
|
+
function runMutations(o) {
|
|
87
|
+
if (o.cases.length === 0) {
|
|
88
|
+
throw new Error("runMutations: no cases. A mutation run with nothing to run reports success, which is the exact claim this API exists to make impossible.");
|
|
89
|
+
}
|
|
90
|
+
const tests = [...new Set(o.cases.map((c) => c.test))];
|
|
91
|
+
// A test path that resolves to nothing is the defect that hid for three days in the corpus this
|
|
92
|
+
// came from: the runner exits 0 on "no files matched", so every case naming it reported SURVIVED
|
|
93
|
+
// and sent the reader hunting for an assertion that was never reached.
|
|
94
|
+
const missing = tests.filter((t) => !(0, node_fs_1.existsSync)(t));
|
|
95
|
+
if (missing.length > 0) {
|
|
96
|
+
throw new Error(`runMutations: test file(s) do not exist, so no case naming them could ever be judged:\n ${missing.join("\n ")}`);
|
|
97
|
+
}
|
|
98
|
+
// Baselined BEFORE anything is touched. Without this, a test that is red on purpose (an open
|
|
99
|
+
// finding it is meant to report) makes every run announce "the restore failed" about a working
|
|
100
|
+
// restore, and makes every case against it look killed.
|
|
101
|
+
const alreadyRed = tests.filter((t) => runTest(t, o).failed);
|
|
102
|
+
const redBefore = new Set(alreadyRed);
|
|
103
|
+
const targets = [...new Set(o.cases.flatMap((c) => c.edits.map(([f]) => f)))];
|
|
104
|
+
const originals = new Map(targets.map((f) => [f, (0, node_fs_1.readFileSync)(f, "utf8")]));
|
|
105
|
+
const restore = () => {
|
|
106
|
+
for (const [f, text] of originals)
|
|
107
|
+
(0, node_fs_1.writeFileSync)(f, text);
|
|
108
|
+
};
|
|
109
|
+
const onSignal = (sig) => {
|
|
110
|
+
restore();
|
|
111
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
112
|
+
};
|
|
113
|
+
process.on("SIGINT", onSignal);
|
|
114
|
+
process.on("SIGTERM", onSignal);
|
|
115
|
+
const outcomes = [];
|
|
116
|
+
try {
|
|
117
|
+
for (const c of o.cases) {
|
|
118
|
+
const applied = apply(c);
|
|
119
|
+
if (applied) {
|
|
120
|
+
outcomes.push({
|
|
121
|
+
name: c.name,
|
|
122
|
+
disables: c.disables,
|
|
123
|
+
verdict: "not-applied",
|
|
124
|
+
detail: applied,
|
|
125
|
+
});
|
|
126
|
+
restore();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// A non-kill is retried ONCE before it is believed. Observed on a real corpus: a row killed
|
|
130
|
+
// as named came back as a neighbour's assertion in a later full run and reproduced as a clean
|
|
131
|
+
// kill when replayed alone. Reporting a flake as a survivor sends the next reader to rewrite
|
|
132
|
+
// a working assertion, which is worse than one extra run.
|
|
133
|
+
let judged = judge(c, o, redBefore);
|
|
134
|
+
if (judged.verdict !== "killed") {
|
|
135
|
+
const second = judge(c, o, redBefore);
|
|
136
|
+
if (second.verdict === "killed")
|
|
137
|
+
judged = {
|
|
138
|
+
verdict: "killed",
|
|
139
|
+
detail: "killed on retry (the first run was a flake)",
|
|
140
|
+
};
|
|
141
|
+
else
|
|
142
|
+
judged = second;
|
|
143
|
+
}
|
|
144
|
+
outcomes.push({ name: c.name, disables: c.disables, ...judged });
|
|
145
|
+
restore();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
restore();
|
|
150
|
+
process.off("SIGINT", onSignal);
|
|
151
|
+
process.off("SIGTERM", onSignal);
|
|
152
|
+
}
|
|
153
|
+
// Only a test that was GREEN before can testify about the restore.
|
|
154
|
+
const restored = tests
|
|
155
|
+
.filter((t) => !redBefore.has(t))
|
|
156
|
+
.every((t) => !runTest(t, o).failed);
|
|
157
|
+
return {
|
|
158
|
+
outcomes,
|
|
159
|
+
killed: outcomes.filter((r) => r.verdict === "killed").length,
|
|
160
|
+
alreadyRed,
|
|
161
|
+
restored,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Plants one case's edits. Returns a reason string when it could not be planted, else null. */
|
|
165
|
+
function apply(c) {
|
|
166
|
+
for (const [file, find, replace] of c.edits) {
|
|
167
|
+
const src = (0, node_fs_1.readFileSync)(file, "utf8");
|
|
168
|
+
const n = src.split(find).length - 1;
|
|
169
|
+
if (n !== 1) {
|
|
170
|
+
return n === 0
|
|
171
|
+
? `"find" matched nothing in ${(0, node_path_1.basename)(file)} — the source moved under the case`
|
|
172
|
+
: `"find" matched ${String(n)} times in ${(0, node_path_1.basename)(file)}; an edit must be unambiguous`;
|
|
173
|
+
}
|
|
174
|
+
const next = src.replace(find, replace);
|
|
175
|
+
// The mutation-that-does-not-mutate, caught against the BYTES rather than the intent: a
|
|
176
|
+
// replacement equal to the original leaves a green test that reads exactly like a kill.
|
|
177
|
+
if (next === src)
|
|
178
|
+
return `the replacement equals the original in ${(0, node_path_1.basename)(file)}`;
|
|
179
|
+
(0, node_fs_1.writeFileSync)(file, next);
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function judge(c, o, redBefore) {
|
|
184
|
+
const { failed, out } = runTest(c.test, o);
|
|
185
|
+
if (!failed)
|
|
186
|
+
return {
|
|
187
|
+
verdict: "survived",
|
|
188
|
+
detail: `${(0, node_path_1.basename)(c.test)} stayed green with the defect planted`,
|
|
189
|
+
};
|
|
190
|
+
if (out.includes(c.expect))
|
|
191
|
+
return { verdict: "killed", detail: `named by "${c.expect}"` };
|
|
192
|
+
// When the test was ALREADY red, "red" carries no information — but the MESSAGE still does,
|
|
193
|
+
// because a runner that aborts at the first failure never reaches a later assertion. Absent it,
|
|
194
|
+
// the two causes are indistinguishable, and calling it a wrong assertion would be a guess.
|
|
195
|
+
return redBefore.has(c.test)
|
|
196
|
+
? {
|
|
197
|
+
verdict: "unjudgeable",
|
|
198
|
+
detail: `${(0, node_path_1.basename)(c.test)} was red before the run and "${c.expect}" never printed`,
|
|
199
|
+
}
|
|
200
|
+
: {
|
|
201
|
+
verdict: "wrong-assertion",
|
|
202
|
+
detail: `red, but "${c.expect}" never printed — a neighbour's assertion caught it`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Render a report the way the CLI-style runners in this package render theirs. */
|
|
206
|
+
function formatMutationReport(report) {
|
|
207
|
+
const lines = [];
|
|
208
|
+
if (report.alreadyRed.length > 0) {
|
|
209
|
+
lines.push(`ℹ️ already red before any mutation: ${report.alreadyRed.map((t) => (0, node_path_1.basename)(t)).join(", ")} — excluded from the restore check; a case naming one is reported unjudgeable.`, "");
|
|
210
|
+
}
|
|
211
|
+
const width = Math.max(...report.outcomes.map((r) => r.name.length));
|
|
212
|
+
const mark = {
|
|
213
|
+
killed: "✓ killed",
|
|
214
|
+
"wrong-assertion": "🔴 wrong assertion",
|
|
215
|
+
survived: "🔴 SURVIVED",
|
|
216
|
+
unjudgeable: "🔴 unjudgeable",
|
|
217
|
+
"not-applied": "🔴 not applied",
|
|
218
|
+
};
|
|
219
|
+
for (const r of report.outcomes) {
|
|
220
|
+
lines.push(`${r.name.padEnd(width)} ${mark[r.verdict].padEnd(20)} ${r.disables}`);
|
|
221
|
+
if (r.verdict !== "killed")
|
|
222
|
+
lines.push(`${" ".repeat(width + 2)} ${r.detail}`);
|
|
223
|
+
}
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push(report.restored
|
|
226
|
+
? "restored: every test that was green before the run is green again"
|
|
227
|
+
: "🔴 RESTORE FAILED — a test that was green before the run is red now. The working tree is not what it was.");
|
|
228
|
+
const bad = report.outcomes.length - report.killed;
|
|
229
|
+
lines.push(bad === 0
|
|
230
|
+
? `✓ all ${String(report.outcomes.length)} mutations killed, each at its own assertion`
|
|
231
|
+
: `🔴 ${String(bad)} of ${String(report.outcomes.length)} not killed as named: ${report.outcomes
|
|
232
|
+
.filter((r) => r.verdict !== "killed")
|
|
233
|
+
.map((r) => r.name)
|
|
234
|
+
.join(", ")}`);
|
|
235
|
+
return lines.join("\n");
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=mutations.js.map
|
package/dist/testing.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* `research/adapter-api-design.md`.
|
|
12
12
|
*/
|
|
13
13
|
export { recordCheck } from "./check-count.js";
|
|
14
|
+
export { runMutations, formatMutationReport, type MutationCase, type MutationEdit, type MutationOutcome, type MutationReport, type MutationVerdict, type RunMutationsOptions, } from "./mutations.js";
|
|
14
15
|
export { runScript } from "./run-script.js";
|
|
15
16
|
export type { RunScriptOptions, ScriptRunResult } from "./run-script.js";
|
|
16
17
|
export { runHook, propertyHook, fileToolEvents } from "./run-hook.js";
|
package/dist/testing.js
CHANGED
|
@@ -30,7 +30,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
30
30
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
31
31
|
};
|
|
32
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
-
exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.fileToolEvents = exports.propertyHook = exports.runHook = exports.runScript = exports.recordCheck = void 0;
|
|
33
|
+
exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.runHarness = exports.runHarnessTest = exports.judge = exports.hookFired = exports.loadHook = exports.stubSkillBody = exports.parseClaudeRun = exports.claudeEvalDriver = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.measureTriggerRate = exports.measureArms = exports.measure = exports.runEval = exports.fileToolEvents = exports.propertyHook = exports.runHook = exports.runScript = exports.formatMutationReport = exports.runMutations = exports.recordCheck = void 0;
|
|
34
34
|
// --- reporting: how much did this script actually do? ---
|
|
35
35
|
// `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
|
|
36
36
|
// prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
|
|
@@ -39,6 +39,14 @@ exports.formatContainment = exports.compareContainment = exports.skillContract =
|
|
|
39
39
|
// vitest's `expect` — so those are visible to the runner too. See check-count.ts.
|
|
40
40
|
var check_count_js_1 = require("./check-count.js");
|
|
41
41
|
Object.defineProperty(exports, "recordCheck", { enumerable: true, get: function () { return check_count_js_1.recordCheck; } });
|
|
42
|
+
// --- the tier above the tiers: is a passing test PROVING anything? ---
|
|
43
|
+
// Every tier below reports that a check passed. None can tell a watched assertion
|
|
44
|
+
// from a vacuous one — both print `✓`. `runMutations` plants a defect, runs the
|
|
45
|
+
// test that owns it, and requires the test to fail with the message that NAMES
|
|
46
|
+
// it, so "green" stops being the strongest claim a suite can make about itself.
|
|
47
|
+
var mutations_js_1 = require("./mutations.js");
|
|
48
|
+
Object.defineProperty(exports, "runMutations", { enumerable: true, get: function () { return mutations_js_1.runMutations; } });
|
|
49
|
+
Object.defineProperty(exports, "formatMutationReport", { enumerable: true, get: function () { return mutations_js_1.formatMutationReport; } });
|
|
42
50
|
// --- unit tier: runScript (the primitive) + runHook (it, plus a decision) ---
|
|
43
51
|
// `runScript` runs any program and reports what it DID (exit, both streams,
|
|
44
52
|
// writes, egress). `runHook` is that plus the hook protocol: event to stdin,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "15.
|
|
3
|
+
"version": "15.3.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",
|