vigiles 2.1.1 → 2.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/README.md +127 -5
- package/dist/action-gate.d.ts +28 -0
- package/dist/action-gate.js +73 -0
- package/dist/cli.js +450 -75
- package/dist/community-skills.d.ts +22 -0
- package/dist/community-skills.js +86 -0
- package/dist/compile-generator.d.ts +48 -0
- package/dist/compile-generator.js +322 -0
- package/dist/compile.d.ts +3 -0
- package/dist/compile.js +217 -26
- package/dist/eval.d.ts +87 -0
- package/dist/eval.js +208 -0
- package/dist/frontmatter.d.ts +24 -6
- package/dist/frontmatter.js +103 -30
- package/dist/generate-schema.js +10 -0
- package/dist/harness-assert.d.ts +68 -0
- package/dist/harness-assert.js +127 -0
- package/dist/harness-test.d.ts +45 -0
- package/dist/harness-test.js +138 -0
- package/dist/inline.d.ts +22 -4
- package/dist/inline.js +60 -13
- package/dist/jest.d.ts +9 -0
- package/dist/jest.js +23 -0
- package/dist/judge.d.ts +29 -0
- package/dist/judge.js +88 -0
- package/dist/linters.js +28 -0
- package/dist/mock-model.d.ts +31 -0
- package/dist/mock-model.js +189 -0
- package/dist/plugin-loader.d.ts +37 -0
- package/dist/plugin-loader.js +195 -0
- package/dist/refs.d.ts +44 -0
- package/dist/refs.js +144 -0
- package/dist/run-hook.d.ts +77 -0
- package/dist/run-hook.js +80 -0
- package/dist/run-scripts.d.ts +20 -0
- package/dist/run-scripts.js +70 -0
- package/dist/skill-driver.d.ts +77 -0
- package/dist/skill-driver.js +76 -0
- package/dist/skill-runtime.d.ts +101 -0
- package/dist/skill-runtime.js +289 -0
- package/dist/skill-test.d.ts +47 -0
- package/dist/skill-test.js +77 -0
- package/dist/spec.d.ts +90 -4
- package/dist/spec.js +29 -0
- package/dist/symbols.d.ts +30 -0
- package/dist/symbols.js +142 -0
- package/dist/vitest.d.mts +9 -0
- package/dist/vitest.mjs +22 -0
- package/package.json +45 -6
package/dist/inline.js
CHANGED
|
@@ -29,6 +29,17 @@ exports.hasInlineRules = hasInlineRules;
|
|
|
29
29
|
* embedded quotes, they can move to spec mode.
|
|
30
30
|
*/
|
|
31
31
|
const ENFORCE_RE = /<!--\s*vigiles:enforce\s+([@A-Za-z0-9_/:.-]+)\s+"([^"\n]*)"\s*-->/;
|
|
32
|
+
/**
|
|
33
|
+
* Match `<!-- vigiles:file <path> -->`. The path is a single whitespace-free
|
|
34
|
+
* token (project-relative); spaces in paths are vanishingly rare and would be
|
|
35
|
+
* ambiguous against the closing `-->`.
|
|
36
|
+
*/
|
|
37
|
+
const FILE_RE = /<!--\s*vigiles:file\s+(\S+)\s*-->/;
|
|
38
|
+
/**
|
|
39
|
+
* Match `<!-- vigiles:cmd "<command>" -->`. The command is quoted because
|
|
40
|
+
* commands contain spaces (e.g. `npm run build`).
|
|
41
|
+
*/
|
|
42
|
+
const CMD_RE = /<!--\s*vigiles:cmd\s+"([^"\n]*)"\s*-->/;
|
|
32
43
|
/**
|
|
33
44
|
* Detects any `<!-- vigiles:<kind> -->` comment (valid or not) so we can
|
|
34
45
|
* surface errors for typos and reserved-but-unrecognized kinds. Uses a
|
|
@@ -36,6 +47,16 @@ const ENFORCE_RE = /<!--\s*vigiles:enforce\s+([@A-Za-z0-9_/:.-]+)\s+"([^"\n]*)"\
|
|
|
36
47
|
* circuit the pattern.
|
|
37
48
|
*/
|
|
38
49
|
const MARKER_RE = /<!--\s*vigiles:([A-Za-z_-]+)[^]*?-->/;
|
|
50
|
+
// vigiles markers handled by other subsystems (skill runtime gates, opt-outs).
|
|
51
|
+
// The inline *rule* parser skips them rather than flagging them as unknown.
|
|
52
|
+
const KNOWN_NON_RULE_MARKERS = new Set([
|
|
53
|
+
"disable",
|
|
54
|
+
"ignore",
|
|
55
|
+
"ignore-file",
|
|
56
|
+
"gate", // skill step gate (src/skill-runtime.ts)
|
|
57
|
+
"result", // skill result gate
|
|
58
|
+
"symbol", // symbol reference mark (src/refs.ts)
|
|
59
|
+
]);
|
|
39
60
|
/**
|
|
40
61
|
* Parse inline vigiles rules out of a markdown file's contents.
|
|
41
62
|
* Does not touch the filesystem and does not verify the rules against
|
|
@@ -48,6 +69,8 @@ const MARKER_RE = /<!--\s*vigiles:([A-Za-z_-]+)[^]*?-->/;
|
|
|
48
69
|
*/
|
|
49
70
|
function parseInlineRules(content) {
|
|
50
71
|
const rules = [];
|
|
72
|
+
const files = [];
|
|
73
|
+
const commands = [];
|
|
51
74
|
const errors = [];
|
|
52
75
|
const lines = content.split("\n");
|
|
53
76
|
let fenceChar = null;
|
|
@@ -97,15 +120,24 @@ function parseInlineRules(content) {
|
|
|
97
120
|
});
|
|
98
121
|
continue;
|
|
99
122
|
}
|
|
123
|
+
const fileMatch = FILE_RE.exec(scannable);
|
|
124
|
+
if (fileMatch) {
|
|
125
|
+
files.push({ path: fileMatch[1], line: i + 1 });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const cmdMatch = CMD_RE.exec(scannable);
|
|
129
|
+
if (cmdMatch) {
|
|
130
|
+
commands.push({ command: cmdMatch[1], line: i + 1 });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
100
133
|
// Skip the compiled-file hash header (`<!-- vigiles:sha256:... -->`)
|
|
101
134
|
// entirely — it's not a rule marker and should not be reported.
|
|
102
135
|
if (/<!--\s*vigiles:sha\d+:/.test(scannable))
|
|
103
136
|
continue;
|
|
104
137
|
const markerMatch = MARKER_RE.exec(scannable);
|
|
105
138
|
if (markerMatch) {
|
|
106
|
-
// Looks like a vigiles marker but didn't parse
|
|
107
|
-
//
|
|
108
|
-
// unquoted why.
|
|
139
|
+
// Looks like a vigiles marker but didn't parse — surface it so users
|
|
140
|
+
// catch typos like "vigile:enforce" or an unquoted why/command.
|
|
109
141
|
const kind = markerMatch[1];
|
|
110
142
|
if (kind === "enforce") {
|
|
111
143
|
errors.push({
|
|
@@ -114,29 +146,44 @@ function parseInlineRules(content) {
|
|
|
114
146
|
raw: line.trim(),
|
|
115
147
|
});
|
|
116
148
|
}
|
|
117
|
-
else if (kind
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
else if (kind === "file") {
|
|
150
|
+
errors.push({
|
|
151
|
+
line: i + 1,
|
|
152
|
+
message: "Malformed vigiles:file — expected `<!-- vigiles:file <path> -->`",
|
|
153
|
+
raw: line.trim(),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
else if (kind === "cmd") {
|
|
157
|
+
errors.push({
|
|
158
|
+
line: i + 1,
|
|
159
|
+
message: 'Malformed vigiles:cmd — expected `<!-- vigiles:cmd "<command>" -->`',
|
|
160
|
+
raw: line.trim(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else if (!KNOWN_NON_RULE_MARKERS.has(kind)) {
|
|
120
164
|
errors.push({
|
|
121
165
|
line: i + 1,
|
|
122
|
-
message: `Unknown vigiles marker "${kind}". Only \`vigiles:enforce\`
|
|
166
|
+
message: `Unknown vigiles marker "${kind}". Only \`vigiles:enforce\`, \`vigiles:file\`, and \`vigiles:cmd\` are supported.`,
|
|
123
167
|
raw: line.trim(),
|
|
124
168
|
});
|
|
125
169
|
}
|
|
126
170
|
}
|
|
127
171
|
}
|
|
128
|
-
return { rules, errors };
|
|
172
|
+
return { rules, files, commands, errors };
|
|
129
173
|
}
|
|
130
174
|
/**
|
|
131
|
-
* True if the content contains at least one parseable vigiles
|
|
132
|
-
* rule
|
|
133
|
-
* `require-spec` validation to
|
|
175
|
+
* True if the content contains at least one parseable vigiles inline marker —
|
|
176
|
+
* an `enforce` rule, a `file` reference, or a `cmd` reference (ignoring fenced
|
|
177
|
+
* code blocks and malformed markers). Used by `require-spec` validation to
|
|
178
|
+
* treat inline mode as spec-equivalent: a file that pins even a single path is
|
|
179
|
+
* meaningfully managed.
|
|
134
180
|
*
|
|
135
181
|
* Deliberately delegates to `parseInlineRules` so a loose prefix regex
|
|
136
182
|
* can't satisfy require-spec with a malformed marker that produces no
|
|
137
|
-
* real
|
|
183
|
+
* real reference.
|
|
138
184
|
*/
|
|
139
185
|
function hasInlineRules(content) {
|
|
140
|
-
|
|
186
|
+
const r = parseInlineRules(content);
|
|
187
|
+
return r.rules.length + r.files.length + r.commands.length > 0;
|
|
141
188
|
}
|
|
142
189
|
//# sourceMappingURL=inline.js.map
|
package/dist/jest.d.ts
ADDED
package/dist/jest.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable max-params --
|
|
4
|
+
The matcher signatures mirror the runtime vigilesMatchers (positional args). */
|
|
5
|
+
/**
|
|
6
|
+
* vigiles — jest integration (opt-in).
|
|
7
|
+
*
|
|
8
|
+
* Importing this entry registers the vigiles matchers AND augments jest's types
|
|
9
|
+
* so `toHaveCreated` / `toBeatBaseline` type-check.
|
|
10
|
+
*
|
|
11
|
+
* // jest.config.js → setupFilesAfterEnv: ["vigiles/jest"]
|
|
12
|
+
* // …or at the top of a test file:
|
|
13
|
+
* import "vigiles/jest";
|
|
14
|
+
*
|
|
15
|
+
* expect(result).toHaveCreated("DONE");
|
|
16
|
+
* expect(report).toBeatBaseline("vanilla", "gated", "caught");
|
|
17
|
+
*
|
|
18
|
+
* jest is an optional peer dependency — only jest users load this entry.
|
|
19
|
+
*/
|
|
20
|
+
const globals_1 = require("@jest/globals");
|
|
21
|
+
const harness_assert_js_1 = require("./harness-assert.js");
|
|
22
|
+
globals_1.expect.extend(harness_assert_js_1.vigilesMatchers);
|
|
23
|
+
//# sourceMappingURL=jest.js.map
|
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface JudgeResult {
|
|
2
|
+
/** Score in [0, 1] (clamped). 0 on any failure to obtain a verdict. */
|
|
3
|
+
readonly score: number;
|
|
4
|
+
/** score ≥ threshold (default 0.5). */
|
|
5
|
+
readonly pass: boolean;
|
|
6
|
+
/** The model's one-line rationale, or an error string. */
|
|
7
|
+
readonly reason: string;
|
|
8
|
+
}
|
|
9
|
+
export interface JudgeOptions {
|
|
10
|
+
/** The text to grade. */
|
|
11
|
+
readonly output: string;
|
|
12
|
+
/** The grading rubric — describe what earns a high vs low score. */
|
|
13
|
+
readonly rubric: string;
|
|
14
|
+
/** Model alias. Default "haiku" (cheap; judging is a simple call). */
|
|
15
|
+
readonly model?: string;
|
|
16
|
+
/** pass = score ≥ threshold. Default 0.5. */
|
|
17
|
+
readonly threshold?: number;
|
|
18
|
+
/** Per-call timeout ms. Default 60000. */
|
|
19
|
+
readonly timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
|
|
22
|
+
export declare function judge(opts: JudgeOptions): JudgeResult;
|
|
23
|
+
/**
|
|
24
|
+
* Parse a verdict out of the grader's stdout — pure, so the parsing is testable
|
|
25
|
+
* without a model. Handles `claude --output-format json` (text wrapped in a
|
|
26
|
+
* `result` field), bare/prose-wrapped JSON, clamping, and the pass threshold.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseJudgeOutput(stdout: string, threshold?: number): JudgeResult;
|
|
29
|
+
//# sourceMappingURL=judge.d.ts.map
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.judge = judge;
|
|
4
|
+
exports.parseJudgeOutput = parseJudgeOutput;
|
|
5
|
+
/**
|
|
6
|
+
* vigiles — a thin LLM-as-judge for the eval tier.
|
|
7
|
+
*
|
|
8
|
+
* Some outcomes aren't a regex: "is this commit message clear?", "did the SKILL
|
|
9
|
+
* produce a sensible plan?". `judge` grades an output against a rubric with a
|
|
10
|
+
* model and returns a numeric score + pass/fail, for use *inside* an eval's
|
|
11
|
+
* `measure` (which is synchronous — so this shells out via the `claude` CLI
|
|
12
|
+
* synchronously, no extra deps):
|
|
13
|
+
*
|
|
14
|
+
* measure: (ctx) => {
|
|
15
|
+
* const v = judge({ output: ctx.file("PLAN.md") ?? "", rubric:
|
|
16
|
+
* "1 if the plan lists concrete, ordered steps; else 0." });
|
|
17
|
+
* return { quality: v.score, ok: v.pass };
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* This is deliberately minimal — for datasets, tracing, and dashboards use a
|
|
21
|
+
* dedicated eval platform (Braintrust, DeepEval). vigiles owns the harness A/B,
|
|
22
|
+
* not the judging platform. Needs the `claude` CLI + model auth.
|
|
23
|
+
*/
|
|
24
|
+
const node_child_process_1 = require("node:child_process");
|
|
25
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
26
|
+
/** Extract the first JSON object from a string (models often wrap it in prose). */
|
|
27
|
+
function firstJsonObject(s) {
|
|
28
|
+
const start = s.indexOf("{");
|
|
29
|
+
const end = s.lastIndexOf("}");
|
|
30
|
+
if (start === -1 || end <= start)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(s.slice(start, end + 1));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Grade `output` against `rubric` with a model. Synchronous (for `measure`). */
|
|
40
|
+
function judge(opts) {
|
|
41
|
+
const threshold = opts.threshold ?? 0.5;
|
|
42
|
+
const prompt = "You are a strict grader. Score the OUTPUT against the RUBRIC. " +
|
|
43
|
+
'Respond with ONLY a JSON object: {"score": <number 0..1>, "reason": "<one line>"}.\n\n' +
|
|
44
|
+
`RUBRIC:\n${opts.rubric}\n\nOUTPUT:\n${opts.output}`;
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = (0, node_child_process_1.spawnSync)("claude", [
|
|
48
|
+
"-p",
|
|
49
|
+
prompt,
|
|
50
|
+
"--model",
|
|
51
|
+
opts.model ?? "haiku",
|
|
52
|
+
"--output-format",
|
|
53
|
+
"json",
|
|
54
|
+
], { encoding: "utf-8", timeout: opts.timeoutMs ?? 60000 });
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
return {
|
|
58
|
+
score: 0,
|
|
59
|
+
pass: false,
|
|
60
|
+
reason: `judge spawn failed: ${String(e)}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (res.status !== 0) {
|
|
64
|
+
return { score: 0, pass: false, reason: "judge: no model output" };
|
|
65
|
+
}
|
|
66
|
+
return parseJudgeOutput(res.stdout ?? "", threshold);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse a verdict out of the grader's stdout — pure, so the parsing is testable
|
|
70
|
+
* without a model. Handles `claude --output-format json` (text wrapped in a
|
|
71
|
+
* `result` field), bare/prose-wrapped JSON, clamping, and the pass threshold.
|
|
72
|
+
*/
|
|
73
|
+
function parseJudgeOutput(stdout, threshold = 0.5) {
|
|
74
|
+
if (!stdout)
|
|
75
|
+
return { score: 0, pass: false, reason: "judge: no model output" };
|
|
76
|
+
// claude --output-format json wraps the model text in a `result` field.
|
|
77
|
+
let text = stdout;
|
|
78
|
+
const wrapper = firstJsonObject(stdout);
|
|
79
|
+
if (wrapper && typeof wrapper.result === "string")
|
|
80
|
+
text = wrapper.result;
|
|
81
|
+
const verdict = firstJsonObject(text);
|
|
82
|
+
if (!verdict || typeof verdict.score !== "number") {
|
|
83
|
+
return { score: 0, pass: false, reason: "judge: unparseable verdict" };
|
|
84
|
+
}
|
|
85
|
+
const score = clamp01(verdict.score);
|
|
86
|
+
return { score, pass: score >= threshold, reason: verdict.reason ?? "" };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=judge.js.map
|
package/dist/linters.js
CHANGED
|
@@ -20,6 +20,34 @@ const node_path_1 = require("node:path");
|
|
|
20
20
|
const node_child_process_1 = require("node:child_process");
|
|
21
21
|
const node_module_1 = require("node:module");
|
|
22
22
|
const glob_1 = require("glob");
|
|
23
|
+
/**
|
|
24
|
+
* Prepend version-manager shim directories (rbenv / asdf / rvm) to PATH so
|
|
25
|
+
* gem/pip-installed linters are found in non-login shells and CI, where the
|
|
26
|
+
* shims dir is often missing from PATH even though the tool is installed.
|
|
27
|
+
* Runs once; only adds directories that exist and aren't already present.
|
|
28
|
+
*/
|
|
29
|
+
function augmentToolPath() {
|
|
30
|
+
const home = process.env.HOME ?? "";
|
|
31
|
+
// rbenv/asdf shims don't resolve without a selected version, so add the
|
|
32
|
+
// concrete per-version `bin` dirs (where the gem executables actually live).
|
|
33
|
+
const candidates = [
|
|
34
|
+
...(0, glob_1.globSync)("/opt/rbenv/versions/*/bin", { nodir: false }),
|
|
35
|
+
...(home
|
|
36
|
+
? (0, glob_1.globSync)(`${home}/.rbenv/versions/*/bin`, { nodir: false })
|
|
37
|
+
: []),
|
|
38
|
+
...(home
|
|
39
|
+
? (0, glob_1.globSync)(`${home}/.asdf/installs/*/*/bin`, { nodir: false })
|
|
40
|
+
: []),
|
|
41
|
+
`${home}/.rvm/bin`,
|
|
42
|
+
`${home}/.local/bin`,
|
|
43
|
+
];
|
|
44
|
+
const current = (process.env.PATH ?? "").split(":");
|
|
45
|
+
const additions = candidates.filter((d) => d && (0, node_fs_1.existsSync)(d) && !current.includes(d));
|
|
46
|
+
if (additions.length > 0) {
|
|
47
|
+
process.env.PATH = [...current, ...additions].join(":");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
augmentToolPath();
|
|
23
51
|
// ---------------------------------------------------------------------------
|
|
24
52
|
// Parsing enforcement references
|
|
25
53
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** One scripted assistant turn: a final text answer, or a tool call. */
|
|
2
|
+
export interface ModelTurn {
|
|
3
|
+
/** Final text answer (stops the turn). */
|
|
4
|
+
readonly text?: string;
|
|
5
|
+
/** A tool to invoke, e.g. "Bash" | "Write" | "Edit". */
|
|
6
|
+
readonly tool?: string;
|
|
7
|
+
/** The tool input, e.g. `{ file_path, content }` or `{ command }`. */
|
|
8
|
+
readonly input?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
/** Build a scripted model from an ordered list of turns. */
|
|
11
|
+
export declare function scriptModel(turns: readonly ModelTurn[]): ModelTurn[];
|
|
12
|
+
export interface TurnInfo {
|
|
13
|
+
readonly n: number;
|
|
14
|
+
readonly stream: boolean;
|
|
15
|
+
readonly hasToolResult: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface MockHandle {
|
|
18
|
+
readonly url: string;
|
|
19
|
+
close(): void;
|
|
20
|
+
/** Number of model turns served so far. */
|
|
21
|
+
readonly count: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
|
|
25
|
+
* next turn (the last turn repeats if the client asks for more). Resolves to a
|
|
26
|
+
* handle with the base `url` and a `close()`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function startMock(script: readonly ModelTurn[], opts?: {
|
|
29
|
+
onTurn?: (info: TurnInfo) => void;
|
|
30
|
+
}): Promise<MockHandle>;
|
|
31
|
+
//# sourceMappingURL=mock-model.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.scriptModel = scriptModel;
|
|
7
|
+
exports.startMock = startMock;
|
|
8
|
+
/**
|
|
9
|
+
* vigiles — a scripted, deterministic Anthropic Messages API mock.
|
|
10
|
+
*
|
|
11
|
+
* Point a Claude Code client at it with `ANTHROPIC_BASE_URL` (and any dummy
|
|
12
|
+
* `ANTHROPIC_API_KEY`) and it serves a fixed *script* of model turns in order —
|
|
13
|
+
* each `POST /v1/messages` returns the next. This is the seam that makes harness
|
|
14
|
+
* testing deterministic: the real `claude` CLI runs your real hooks/settings,
|
|
15
|
+
* but the model's turns are scripted, so the outcome is reproducible and free.
|
|
16
|
+
*
|
|
17
|
+
* scriptModel([
|
|
18
|
+
* { tool: "Write", input: { file_path: "SKILL.md", content: "..." } },
|
|
19
|
+
* { text: "done" },
|
|
20
|
+
* ])
|
|
21
|
+
*
|
|
22
|
+
* Implements the parts a real client needs: SSE streaming (flushed per event),
|
|
23
|
+
* `/v1/messages/count_tokens` (else Claude Code hangs), HEAD/health tolerance,
|
|
24
|
+
* and echoing the requested model.
|
|
25
|
+
*/
|
|
26
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
27
|
+
/** Build a scripted model from an ordered list of turns. */
|
|
28
|
+
function scriptModel(turns) {
|
|
29
|
+
return [...turns];
|
|
30
|
+
}
|
|
31
|
+
function writeEvent(res, event, data) {
|
|
32
|
+
res.write(`event: ${event}\n`);
|
|
33
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
34
|
+
}
|
|
35
|
+
const rid = (p) => `${p}${Math.random().toString(36).slice(2, 10)}`;
|
|
36
|
+
function streamTurn(res, turn, model) {
|
|
37
|
+
res.writeHead(200, {
|
|
38
|
+
"content-type": "text/event-stream",
|
|
39
|
+
"cache-control": "no-cache",
|
|
40
|
+
connection: "keep-alive",
|
|
41
|
+
});
|
|
42
|
+
writeEvent(res, "message_start", {
|
|
43
|
+
type: "message_start",
|
|
44
|
+
message: {
|
|
45
|
+
id: rid("msg_"),
|
|
46
|
+
type: "message",
|
|
47
|
+
role: "assistant",
|
|
48
|
+
model,
|
|
49
|
+
content: [],
|
|
50
|
+
stop_reason: null,
|
|
51
|
+
usage: { input_tokens: 10, output_tokens: 1 },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
if (turn.tool) {
|
|
55
|
+
writeEvent(res, "content_block_start", {
|
|
56
|
+
type: "content_block_start",
|
|
57
|
+
index: 0,
|
|
58
|
+
content_block: {
|
|
59
|
+
type: "tool_use",
|
|
60
|
+
id: rid("toolu_"),
|
|
61
|
+
name: turn.tool,
|
|
62
|
+
input: {},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
writeEvent(res, "content_block_delta", {
|
|
66
|
+
type: "content_block_delta",
|
|
67
|
+
index: 0,
|
|
68
|
+
delta: {
|
|
69
|
+
type: "input_json_delta",
|
|
70
|
+
partial_json: JSON.stringify(turn.input ?? {}),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
writeEvent(res, "content_block_stop", {
|
|
74
|
+
type: "content_block_stop",
|
|
75
|
+
index: 0,
|
|
76
|
+
});
|
|
77
|
+
writeEvent(res, "message_delta", {
|
|
78
|
+
type: "message_delta",
|
|
79
|
+
delta: { stop_reason: "tool_use", stop_sequence: null },
|
|
80
|
+
usage: { output_tokens: 5 },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
writeEvent(res, "content_block_start", {
|
|
85
|
+
type: "content_block_start",
|
|
86
|
+
index: 0,
|
|
87
|
+
content_block: { type: "text", text: "" },
|
|
88
|
+
});
|
|
89
|
+
writeEvent(res, "content_block_delta", {
|
|
90
|
+
type: "content_block_delta",
|
|
91
|
+
index: 0,
|
|
92
|
+
delta: { type: "text_delta", text: turn.text ?? "" },
|
|
93
|
+
});
|
|
94
|
+
writeEvent(res, "content_block_stop", {
|
|
95
|
+
type: "content_block_stop",
|
|
96
|
+
index: 0,
|
|
97
|
+
});
|
|
98
|
+
writeEvent(res, "message_delta", {
|
|
99
|
+
type: "message_delta",
|
|
100
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
101
|
+
usage: { output_tokens: 5 },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
writeEvent(res, "message_stop", { type: "message_stop" });
|
|
105
|
+
res.end();
|
|
106
|
+
}
|
|
107
|
+
function jsonTurn(res, turn, model) {
|
|
108
|
+
const content = turn.tool
|
|
109
|
+
? [
|
|
110
|
+
{
|
|
111
|
+
type: "tool_use",
|
|
112
|
+
id: rid("toolu_"),
|
|
113
|
+
name: turn.tool,
|
|
114
|
+
input: turn.input ?? {},
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
: [{ type: "text", text: turn.text ?? "" }];
|
|
118
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
119
|
+
res.end(JSON.stringify({
|
|
120
|
+
id: rid("msg_"),
|
|
121
|
+
type: "message",
|
|
122
|
+
role: "assistant",
|
|
123
|
+
model,
|
|
124
|
+
stop_reason: turn.tool ? "tool_use" : "end_turn",
|
|
125
|
+
stop_sequence: null,
|
|
126
|
+
content,
|
|
127
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Start the scripted mock on a free port. Each `/v1/messages` POST consumes the
|
|
132
|
+
* next turn (the last turn repeats if the client asks for more). Resolves to a
|
|
133
|
+
* handle with the base `url` and a `close()`.
|
|
134
|
+
*/
|
|
135
|
+
function startMock(script, opts = {}) {
|
|
136
|
+
let i = 0;
|
|
137
|
+
const server = node_http_1.default.createServer((req, res) => {
|
|
138
|
+
let body = "";
|
|
139
|
+
req.on("data", (c) => (body += c));
|
|
140
|
+
req.on("end", () => {
|
|
141
|
+
const url = req.url ?? "";
|
|
142
|
+
const isCount = url.includes("count_tokens");
|
|
143
|
+
const isMessages = url.includes("/v1/messages") && !isCount;
|
|
144
|
+
let reqBody = {};
|
|
145
|
+
try {
|
|
146
|
+
reqBody = JSON.parse(body);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* HEAD / health checks have no JSON body */
|
|
150
|
+
}
|
|
151
|
+
if (req.method === "HEAD" || (!isMessages && !isCount)) {
|
|
152
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
153
|
+
res.end("{}");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (isCount) {
|
|
157
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
158
|
+
res.end(JSON.stringify({ input_tokens: 10 }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const last = JSON.stringify(reqBody.messages?.at(-1)?.content ?? "");
|
|
162
|
+
opts.onTurn?.({
|
|
163
|
+
n: i,
|
|
164
|
+
stream: reqBody.stream === true,
|
|
165
|
+
hasToolResult: last.includes('"tool_result"'),
|
|
166
|
+
});
|
|
167
|
+
const turn = script[Math.min(i, script.length - 1)] ?? { text: "" };
|
|
168
|
+
i++;
|
|
169
|
+
const model = reqBody.model ?? "claude-mock";
|
|
170
|
+
if (reqBody.stream === true)
|
|
171
|
+
streamTurn(res, turn, model);
|
|
172
|
+
else
|
|
173
|
+
jsonTurn(res, turn, model);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
server.listen(0, "127.0.0.1", () => {
|
|
178
|
+
const { port } = server.address();
|
|
179
|
+
resolve({
|
|
180
|
+
url: `http://127.0.0.1:${String(port)}`,
|
|
181
|
+
close: () => server.close(),
|
|
182
|
+
get count() {
|
|
183
|
+
return i;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=mock-model.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface LoadedPlugin {
|
|
2
|
+
/** A `.claude/settings.json`-shaped object with hooks resolved. */
|
|
3
|
+
readonly settings: {
|
|
4
|
+
hooks?: unknown;
|
|
5
|
+
};
|
|
6
|
+
/** Files to materialize in the sandbox (CLAUDE.md, skills, agents, commands). */
|
|
7
|
+
readonly files: Record<string, string>;
|
|
8
|
+
/**
|
|
9
|
+
* Surfaces that are present in the plugin but cannot be exercised at the
|
|
10
|
+
* deterministic tier (subagents and slash commands need a real model; MCP
|
|
11
|
+
* servers aren't wired by the loader). Empty when the plugin is fully
|
|
12
|
+
* covered. Surfaced so "load the whole plugin" never silently tests nothing —
|
|
13
|
+
* read it in a test, or just to know what the deterministic run won't reach.
|
|
14
|
+
*/
|
|
15
|
+
readonly warnings: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Load the real harness at `pluginPath`. Returns the resolved settings (hooks),
|
|
19
|
+
* the files (CLAUDE.md + skills + agents + commands) to write into the test
|
|
20
|
+
* sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
|
|
21
|
+
* `settings` with any inline settings and spread `files` into the fixture.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadPlugin(pluginPath: string): LoadedPlugin;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the effective harness for a test/eval (arm): load the plugin if given,
|
|
26
|
+
* then layer inline settings + files on top. Shared by `runHarnessTest` and
|
|
27
|
+
* `runEval` so both test the assembled machine the same way.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveHarness(opts: {
|
|
30
|
+
plugin?: string;
|
|
31
|
+
settings?: unknown;
|
|
32
|
+
files?: Record<string, string>;
|
|
33
|
+
}): {
|
|
34
|
+
settings: unknown;
|
|
35
|
+
files: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=plugin-loader.d.ts.map
|