vigiles 2.3.0 → 2.4.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 +166 -193
- package/dist/cli.js +49 -1
- package/dist/eval.d.ts +8 -0
- package/dist/eval.js +3 -2
- package/dist/harness-assert.d.ts +42 -1
- package/dist/harness-assert.js +93 -0
- package/dist/harness-test.d.ts +39 -0
- package/dist/harness-test.js +65 -2
- package/dist/mcp.d.ts +48 -0
- package/dist/mcp.js +247 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -21,6 +21,29 @@
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
<details>
|
|
25
|
+
<summary><b>Contents</b></summary>
|
|
26
|
+
|
|
27
|
+
**Pillar 1 — verify your instruction files** · references your CLAUDE.md makes that a linter, the filesystem, and package.json can prove
|
|
28
|
+
|
|
29
|
+
- Three adoption levels: [inline comments](#level-0--inline-comments-30-seconds-no-new-files) → [YAML frontmatter](#level-1--yaml-frontmatter-editor-autocomplete-still-no-typescript) → [typed spec](#level-2--typed-spec-compiler-grade-guarantees)
|
|
30
|
+
- [What changes with vigiles](#what-changes-with-vigiles)
|
|
31
|
+
- [Quick start](#quick-start)
|
|
32
|
+
- [Three rule types](#three-rule-types) — `enforce` / `guidance` / `guard`
|
|
33
|
+
- [Verified references](#verified-references) — `file` / `cmd` / `symbol` / `ref`
|
|
34
|
+
|
|
35
|
+
**Pillar 2 — [test your Claude Code harness](#test-your-claude-code-harness)** · eval whether your hooks, skills, and CLAUDE.md actually change what the agent does
|
|
36
|
+
|
|
37
|
+
- [Level 1 — unit-test a hook (no AI)](#level-1--test-a-hook-by-itself-no-ai-milliseconds)
|
|
38
|
+
- [Level 2 — does it fire in a real session?](#level-2--does-it-fire-in-a-real-session-free-scripted-ai)
|
|
39
|
+
- [Level 3 — does it change behaviour?](#level-3--does-it-change-what-claude-does-real-ai-occasional)
|
|
40
|
+
- [Test skills for real + assert on actions](#test-your-skills-for-real--and-assert-on-what-claude-did)
|
|
41
|
+
- [Run them in CI](#run-them-in-ci)
|
|
42
|
+
|
|
43
|
+
**More** — [CLI & CI](#cli--ci) · [Skills](#skills) · [Maturity levels](#maturity-levels) · [Related tools](#related-tools)
|
|
44
|
+
|
|
45
|
+
</details>
|
|
46
|
+
|
|
24
47
|
Your CLAUDE.md lies to your agent. Here's the fix.
|
|
25
48
|
|
|
26
49
|
Hand-written CLAUDE.md files rot silently. Here's what a typical one looks like:
|
|
@@ -47,6 +70,8 @@ Reads fine. Four things are wrong:
|
|
|
47
70
|
|
|
48
71
|
The agent reads this, trusts it, and writes code based on stale claims nobody verified. vigiles **verifies the references in your instruction files** — that each linter rule exists and is enabled, that every file path and script is real, and that referenced **code symbols** (functions, classes, constants) actually exist in the files that define them — and meets you at whatever commitment level you want.
|
|
49
72
|
|
|
73
|
+
> **See it in 60 seconds:** `npm run demo` runs `vigiles audit` against a deliberately-broken instruction file and catches a renamed symbol and a missing MCP tool (_"did you mean `purge`?"_), while the truthful references pass silently. [examples/demo →](examples/demo)
|
|
74
|
+
|
|
50
75
|
Three levels. Each is independently useful; adopt as far up as you like.
|
|
51
76
|
|
|
52
77
|
### Level 0 — inline comments (30 seconds, no new files)
|
|
@@ -168,15 +193,7 @@ npx vigiles init
|
|
|
168
193
|
|
|
169
194
|
The wizard auto-detects your project, creates a spec, scans your linters, compiles to markdown, adds a CI step, and installs Claude Code hooks. After install: the agent edits the spec (hooks block direct CLAUDE.md edits), the spec auto-compiles on save, and `vigiles audit` catches drift in CI.
|
|
170
195
|
|
|
171
|
-
Start with `guidance()` rules (zero config). When you're ready, run `/strengthen` to find rules that can be upgraded to compile-verified `enforce()`. Already have a hand-written CLAUDE.md? The wizard detects it and offers migration.
|
|
172
|
-
|
|
173
|
-
| Flag | Effect |
|
|
174
|
-
| -------------------- | ----------------------------------------------------- |
|
|
175
|
-
| `--strict` | Sets require-spec and require-skill-spec to `"error"` |
|
|
176
|
-
| `--target=AGENTS.md` | Creates AGENTS.md spec instead of CLAUDE.md |
|
|
177
|
-
| `--no-gha` | Skip adding CI step to GHA workflow |
|
|
178
|
-
|
|
179
|
-
Works the same for humans and agents — fully non-interactive. [Agent setup guide →](docs/agent-setup.md) | [Agent workflows →](docs/agent-workflows.md)
|
|
196
|
+
Start with `guidance()` rules (zero config). When you're ready, run `/strengthen` to find rules that can be upgraded to compile-verified `enforce()`. Already have a hand-written CLAUDE.md? The wizard detects it and offers migration. Flags (`--strict`, `--target=AGENTS.md`, `--no-gha`) and non-interactive agent usage are in the [CLI reference](docs/cli.md) and [agent setup guide](docs/agent-setup.md).
|
|
180
197
|
|
|
181
198
|
## Three Rule Types
|
|
182
199
|
|
|
@@ -198,20 +215,7 @@ Supports ESLint, Stylelint, Ruff, Clippy, Pylint, RuboCop, and Cedar policies. [
|
|
|
198
215
|
"research-first": guidance("Google unfamiliar APIs first."),
|
|
199
216
|
```
|
|
200
217
|
|
|
201
|
-
**`guard()`** — reactive: runs a command when watched files change. One declaration emits hooks for every supported system (Claude Code PostToolUse, husky pre-commit, etc.)
|
|
202
|
-
|
|
203
|
-
```typescript
|
|
204
|
-
"recompile-specs": guard(
|
|
205
|
-
{ watch: "*.spec.ts", run: "npx vigiles compile" },
|
|
206
|
-
"Recompile instruction files when any spec changes.",
|
|
207
|
-
),
|
|
208
|
-
"regen-types": guard(
|
|
209
|
-
{ watch: ["eslint.config.*", "package.json"], run: "npx vigiles generate-types" },
|
|
210
|
-
"Regenerate types when linter config or deps change.",
|
|
211
|
-
),
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Same monotonicity guarantees as `enforce()` — guards can't be silently removed.
|
|
218
|
+
**`guard()`** — reactive: runs a command when watched files change (e.g. `*.spec.ts` → `npx vigiles compile`). One declaration emits hooks for every supported system (Claude Code PostToolUse, husky pre-commit, etc.) — no copy-pasting the same trigger across `.claude/settings.json`, `.husky/`, and CI. Same monotonicity guarantees as `enforce()`. [Full spec format →](docs/spec-format.md)
|
|
215
219
|
|
|
216
220
|
## Verified References
|
|
217
221
|
|
|
@@ -234,220 +238,193 @@ export default claude({
|
|
|
234
238
|
});
|
|
235
239
|
```
|
|
236
240
|
|
|
237
|
-
|
|
241
|
+
There's a small family of inline **marks** that `audit` checks, each binding a reference to its real source:
|
|
238
242
|
|
|
239
|
-
|
|
243
|
+
- `` `vigiles:symbol file#name` `` — the named file actually **defines** that symbol (function, class, method, constant), parsed with [ast-grep](https://ast-grep.github.io) across **JS/TS, Python, Ruby, Rust, and CSS**. Rename it and `audit` fails; in markdown mode the `refs-hook` **forces the mark**, blocking edits that leave a code reference bare. [Details →](research/symbol-verification.md)
|
|
244
|
+
- `` `vigiles:mcp server#tool` `` — the referenced **MCP tool exists** on its server. `audit` reads `.mcp.json`, starts the server, lists its tools, and flags a renamed/removed one with a "did you mean" — catching e.g. the GitHub MCP server renaming `create_issue` → `issue_write`, which otherwise fails silently.
|
|
240
245
|
|
|
241
|
-
|
|
246
|
+
**Typo-safe at authoring time, too.** `vigiles generate-types` emits a `.vigiles/generated.d.ts` so `enforce("eslint/no-consolee")` red-squiggles in your editor; `generate-schema` gives Level 1 frontmatter the same via your YAML language server. Both have `--check` CI freshness modes. [How it works →](docs/linter-support.md#generate-types)
|
|
242
247
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
## Type-Safe Rule References
|
|
246
|
-
|
|
247
|
-
`vigiles generate-types` scans your linter configs and emits `.vigiles/generated.d.ts`. With this file, `enforce("eslint/no-consolee")` is a red squiggle in your editor — a typo caught at authoring time, not a runtime surprise. Without it, everything falls back to broad types and still works.
|
|
248
|
-
|
|
249
|
-
```bash
|
|
250
|
-
$ npx vigiles generate-types
|
|
251
|
-
eslint: 64 enabled rules | ruff: 12 | npm scripts: 5 | project files: 42
|
|
252
|
-
✓ Generated .vigiles/generated.d.ts
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
Commit the file to git. CI can verify it's fresh: `npx vigiles generate-types --check`. [How it works →](docs/linter-support.md#generate-types)
|
|
256
|
-
|
|
257
|
-
For markdown frontmatter (Level 1), `vigiles generate-schema` gives the same authoring-time feedback without TypeScript: it emits a JSON Schema from your enabled rules, and your editor's YAML language server autocompletes rule names and squiggles typos. CI freshness check: `npx vigiles generate-schema --check`.
|
|
258
|
-
|
|
259
|
-
## CLI
|
|
248
|
+
## Test your Claude Code harness
|
|
260
249
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
npx vigiles refs <file.md> # Check the symbol references in an instruction file
|
|
266
|
-
npx vigiles test [files...] # Run *.harness.mjs deterministic harness tests (no API key)
|
|
267
|
-
npx vigiles eval [files...] # Run *.eval.mjs real-model harness evals (--trials=N)
|
|
268
|
-
npx vigiles generate-types # Emit .d.ts from project state (for spec mode)
|
|
269
|
-
npx vigiles generate-types --check # Verify .d.ts is up to date
|
|
270
|
-
npx vigiles generate-schema # Emit JSON Schema for vigiles: frontmatter (Level 1)
|
|
271
|
-
npx vigiles generate-schema --check # Verify schema.json is up to date
|
|
272
|
-
```
|
|
250
|
+
You wrote hooks, a skill, a CLAUDE.md rule — how do you know they work, beyond
|
|
251
|
+
running Claude and eyeballing it? vigiles ships a library to **test the harness
|
|
252
|
+
itself**, at three levels, cheapest first. It's plain async functions, so it
|
|
253
|
+
drops into **node:test / vitest / jest**, or a zero-setup `vigiles test`.
|
|
273
254
|
|
|
274
|
-
|
|
255
|
+
### Level 1 — test a hook by itself (no AI, milliseconds)
|
|
275
256
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
command: compile # compile specs in CI
|
|
281
|
-
```
|
|
257
|
+
A hook is just a process that's handed a "Claude is about to do X" event and
|
|
258
|
+
answers block/allow. Hand it a fake event and check the answer — no `claude`, no
|
|
259
|
+
model, and **every** event type is reachable (incl. Edit/Write, PreCompact,
|
|
260
|
+
SessionEnd):
|
|
282
261
|
|
|
283
|
-
|
|
262
|
+
```typescript
|
|
263
|
+
import { runHook } from "vigiles/run-hook";
|
|
284
264
|
|
|
285
|
-
|
|
286
|
-
|
|
265
|
+
const r = runHook(guardCommand, {
|
|
266
|
+
hook_event_name: "PreToolUse",
|
|
267
|
+
tool_name: "Bash",
|
|
268
|
+
tool_input: { command: "git commit --no-verify" },
|
|
269
|
+
});
|
|
270
|
+
assert(r.blocked); // exit 2 / decision:"block" / permissionDecision:"deny"
|
|
287
271
|
```
|
|
288
272
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
**Install the plugin.** Without it, you're responsible for manually running `compile` and `generate-types`. With it, the agent works with fresh instruction files automatically.
|
|
273
|
+
The same shape governs **MCP tools** — the dominant real MCP test — with no
|
|
274
|
+
server running, because the hook only sees the tool _name_:
|
|
292
275
|
|
|
293
|
-
```
|
|
294
|
-
|
|
276
|
+
```typescript
|
|
277
|
+
// block the destructive github-MCP tool; read-only ones pass
|
|
278
|
+
runHook(guard, {
|
|
279
|
+
hook_event_name: "PreToolUse",
|
|
280
|
+
tool_name: "mcp__github__merge_pull_request",
|
|
281
|
+
tool_input: { pull_number: 42 },
|
|
282
|
+
}).blocked; // true
|
|
295
283
|
```
|
|
296
284
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
- **PreToolUse** (Edit/Write) — blocks direct edits to compiled `.md` files and redirects the agent to the `.spec.ts` source
|
|
300
|
-
- **PostToolUse** (Edit/Write) — auto-runs `generate-types` on linter config changes, `compile` on `.spec.ts` changes
|
|
301
|
-
|
|
302
|
-
## Validation
|
|
303
|
-
|
|
304
|
-
`vigiles audit` validates instruction files with four rules:
|
|
285
|
+
### Level 2 — does it fire in a real session? (free, scripted "AI")
|
|
305
286
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
| [`coverage`](docs/rules/coverage.md) | `false` | Spec covers enough of the project surface |
|
|
287
|
+
Right logic ≠ wired in correctly. `runHarnessTest` runs the **real** `claude`
|
|
288
|
+
against a **scripted mock model** you control — your hooks fire for real, the
|
|
289
|
+
agent's turns are fixed, no API key, same result every time. Covers the
|
|
290
|
+
governance shapes: SessionStart, Stop, UserPromptSubmit, and Bash **and
|
|
291
|
+
Edit/Write** Pre/PostToolUse.
|
|
312
292
|
|
|
313
|
-
|
|
293
|
+
```typescript
|
|
294
|
+
import { runHarnessTest, scriptModel } from "vigiles/harness-test";
|
|
314
295
|
|
|
315
|
-
|
|
316
|
-
{
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
296
|
+
const r = await runHarnessTest({
|
|
297
|
+
settings: {
|
|
298
|
+
hooks: {
|
|
299
|
+
Stop: [
|
|
300
|
+
{ hooks: [{ type: "command", command: "test -f DONE || exit 2" }] },
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
model: scriptModel([
|
|
305
|
+
{ text: "I'm done" }, // tries to stop → blocked (no DONE)
|
|
306
|
+
{ tool: "Bash", input: { command: "touch DONE" } },
|
|
307
|
+
{ text: "now done" },
|
|
308
|
+
]),
|
|
309
|
+
});
|
|
310
|
+
assert(JSON.parse(r.stdout).num_turns > 1); // the Stop hook forced more work
|
|
323
311
|
```
|
|
324
312
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
## Skills
|
|
328
|
-
|
|
329
|
-
Install with [Vercel Skills](https://github.com/vercel-labs/skills): `npx skills add zernie/vigiles`
|
|
330
|
-
|
|
331
|
-
| Skill | What it does |
|
|
332
|
-
| ---------------------- | ----------------------------------------------------------------------- |
|
|
333
|
-
| `strengthen` | Upgrade `guidance()` → `enforce()` using linter-specific reference docs |
|
|
334
|
-
| `edit-spec` | Edit a spec file — guided workflow with compile step |
|
|
335
|
-
| `migrate-to-spec` | Convert a hand-written CLAUDE.md to a typed `.spec.ts` |
|
|
336
|
-
| `generate-rule` | Add a new `enforce()` / `guidance()` rule to a spec |
|
|
337
|
-
| `pr-to-lint-rule` | Turn a recurring PR review comment into a lint rule + spec entry |
|
|
338
|
-
| `enforce-rules-format` | Validate all rules have enforcement classification |
|
|
339
|
-
| `audit-feedback-loop` | Score your repo's feedback loop maturity |
|
|
340
|
-
|
|
341
|
-
## Test your Claude Code harness
|
|
342
|
-
|
|
343
|
-
vigiles also ships a library for **testing the harness itself** — your hooks,
|
|
344
|
-
settings, skills, and instruction files. `Agent = Model + Harness`; this tests
|
|
345
|
-
the harness, at three levels.
|
|
313
|
+
### Level 3 — does it change what Claude does? (real AI, occasional)
|
|
346
314
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
metric; `runEval` drives the real `claude` CLI N trials per arm and aggregates.
|
|
315
|
+
`runEval` runs the **real** model N times with your change **on vs off** and
|
|
316
|
+
reports the gap. Costs tokens, so you run it now and then — not on every save:
|
|
350
317
|
|
|
351
318
|
```typescript
|
|
352
319
|
import { runEval, formatEvalReport } from "vigiles/eval";
|
|
353
320
|
|
|
354
321
|
const report = await runEval({
|
|
355
|
-
|
|
356
|
-
arms: {
|
|
357
|
-
vanilla: {},
|
|
358
|
-
gated: { settings: { hooks: { PostToolUse: [refsHook] } } },
|
|
359
|
-
},
|
|
322
|
+
arms: { off: {}, on: { settings: { hooks: { PostToolUse: [refsHook] } } } },
|
|
360
323
|
task: "Document chargeCard in SKILL.md, referencing it by name.",
|
|
361
324
|
measure: (ctx) => ({
|
|
362
325
|
marked: ctx.sh("grep -c vigiles:symbol SKILL.md") !== "0",
|
|
363
326
|
}),
|
|
364
327
|
trials: 6,
|
|
365
328
|
});
|
|
366
|
-
console.log(formatEvalReport(report)); //
|
|
329
|
+
console.log(formatEvalReport(report)); // off marked=0.00 on marked=0.50
|
|
367
330
|
```
|
|
368
331
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
332
|
+
### Test your skills for real — and assert on what Claude _did_
|
|
333
|
+
|
|
334
|
+
Install a plugin the way Claude actually does (`pluginDir` → `--plugin-dir`) so
|
|
335
|
+
its **skills genuinely activate**, then assert on the agent's _actions_, not a
|
|
336
|
+
stdout grep:
|
|
372
337
|
|
|
373
338
|
```typescript
|
|
374
|
-
import {
|
|
339
|
+
import { assertSkillResolved, assertToolNotUsed } from "vigiles/harness-assert";
|
|
375
340
|
|
|
376
341
|
const r = await runHarnessTest({
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
{
|
|
381
|
-
hooks: [
|
|
382
|
-
{
|
|
383
|
-
type: "command",
|
|
384
|
-
command: "test -f DONE || { echo 'not done' >&2; exit 2; }",
|
|
385
|
-
},
|
|
386
|
-
],
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
},
|
|
390
|
-
},
|
|
342
|
+
pluginDir: "./my-plugin",
|
|
343
|
+
transcript: true, // populate r.toolCalls
|
|
344
|
+
allowedTools: ["Read", "Write", "Bash", "Skill"],
|
|
391
345
|
model: scriptModel([
|
|
392
|
-
{
|
|
393
|
-
{
|
|
394
|
-
{ text: "now done" },
|
|
346
|
+
{ tool: "Skill", input: { skill: "my-plugin:greet" } },
|
|
347
|
+
{ text: "ok" },
|
|
395
348
|
]),
|
|
396
349
|
});
|
|
397
|
-
|
|
350
|
+
assertSkillResolved(r, "my-plugin:greet"); // the skill fired, no error
|
|
351
|
+
assertToolNotUsed(r, /^mcp__github__merge/); // the safety negative: the scary tool was never called
|
|
398
352
|
```
|
|
399
353
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
354
|
+
`assertToolNotUsed` is how you test a safety rule **honestly** — _proving_ the
|
|
355
|
+
dangerous tool was never used, which "the file looks unchanged" can't. It works
|
|
356
|
+
on **real third-party plugins** too: the suite confirms real `obra/superpowers`
|
|
357
|
+
and `wshobson/agents` skills resolve this way, with no markers injected.
|
|
404
358
|
|
|
405
|
-
|
|
406
|
-
pipes an event JSON to its stdin and reports the block/allow decision —
|
|
407
|
-
milliseconds, and the only tier that reaches **every** event (incl. Edit/Write,
|
|
408
|
-
PreCompact, SessionEnd, which the deterministic mock can't trigger).
|
|
359
|
+
### Run them in CI
|
|
409
360
|
|
|
410
|
-
|
|
411
|
-
|
|
361
|
+
`vigiles test` runs `*.harness.mjs` files (free, no key); `vigiles eval` runs
|
|
362
|
+
`*.eval.mjs` files (real model). Point a test at a whole plugin (or `"./"` for
|
|
363
|
+
your repo) to load **what ships** — hooks (with `${CLAUDE_PLUGIN_ROOT}`
|
|
364
|
+
resolved), CLAUDE.md, skills, subagents, commands — and `loadPlugin().warnings`
|
|
365
|
+
flags anything only a real model can drive, so you never silently test an empty
|
|
366
|
+
machine.
|
|
412
367
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
tool_input: { command: "git commit --no-verify" },
|
|
417
|
-
});
|
|
418
|
-
assert(r.blocked); // exit 2, decision:"block", or permissionDecision:"deny"
|
|
368
|
+
```bash
|
|
369
|
+
npx vigiles test examples/harness/policy-gate.harness.mjs
|
|
370
|
+
npx vigiles eval --trials=6 examples/harness/skill-outcome.eval.mjs
|
|
419
371
|
```
|
|
420
372
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
(
|
|
373
|
+
### What's covered today — surface × tier
|
|
374
|
+
|
|
375
|
+
| Surface | Unit / static | Integration (no API key) | Eval (real model) |
|
|
376
|
+
| ------------------------------------------------------------- | ---------------------------- | --------------------------- | ----------------- |
|
|
377
|
+
| Hooks — Bash / SessionStart / Stop / UserPromptSubmit | ✅ logic | ✅ fires | ✅ |
|
|
378
|
+
| Hooks — Edit / Write | ✅ logic | ✅ fires | ✅ |
|
|
379
|
+
| Hooks — PreCompact / Notification / SessionEnd / SubagentStop | ✅ logic | — (mock can't trigger) | 🟡 |
|
|
380
|
+
| CLAUDE.md / instructions | ✅ refs | 🟡 present, not behaviour | ✅ behaviour |
|
|
381
|
+
| Skills | 🟡 refs | ✅ resolves via `pluginDir` | ✅ activation |
|
|
382
|
+
| Subagents (`agents/`) | 🟡 refs | 🔴 hard | ✅ via Task |
|
|
383
|
+
| Slash commands (`commands/`) | 🟡 refs | 🟡 needs prompt capture | ✅ via `/cmd` |
|
|
384
|
+
| MCP servers | ✅ tool refs (`vigiles:mcp`) | 🔴 | 🔴 |
|
|
385
|
+
| settings.json | 🟡 assert merged | ✅ applied | ✅ |
|
|
424
386
|
|
|
425
|
-
|
|
426
|
-
|
|
387
|
+
✅ shipped · 🟡 partial · 🔴 gap · — n/a. Full detail + roadmap: [`research/harness-testing-coverage-matrix.md`](research/harness-testing-coverage-matrix.md).
|
|
388
|
+
|
|
389
|
+
[Full guide → `docs/harness-testing.md`](docs/harness-testing.md) · [benchmarks](research/benchmarks-runtime-gates.md).
|
|
390
|
+
|
|
391
|
+
## CLI & CI
|
|
427
392
|
|
|
428
393
|
```bash
|
|
429
|
-
npx vigiles
|
|
430
|
-
npx vigiles
|
|
394
|
+
npx vigiles init # Scaffold a spec (full setup wizard)
|
|
395
|
+
npx vigiles compile # Compile .spec.ts → .md
|
|
396
|
+
npx vigiles audit # Verify hashes + inline/frontmatter/spec rules + symbols + coverage
|
|
397
|
+
npx vigiles test # Run *.harness.mjs deterministic harness tests (no API key)
|
|
398
|
+
npx vigiles eval # Run *.eval.mjs real-model harness evals (--trials=N)
|
|
431
399
|
```
|
|
432
400
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
401
|
+
`vigiles audit` enforces four rules — `require-spec`, `require-skill-spec`, `integrity`, `coverage` — configurable in `.vigilesrc.json`. The GitHub Action runs `audit` by default; the Claude Code plugin (`npx skills add zernie/vigiles`) adds the Pre/PostToolUse hooks that block direct `.md` edits and auto-compile specs. [Full CLI, Action, plugin & validation reference →](docs/cli.md)
|
|
402
|
+
|
|
403
|
+
## Skills
|
|
404
|
+
|
|
405
|
+
Install with [Vercel Skills](https://github.com/vercel-labs/skills): `npx skills add zernie/vigiles`
|
|
406
|
+
|
|
407
|
+
<details>
|
|
408
|
+
<summary><b>The 7 skills</b></summary>
|
|
409
|
+
|
|
410
|
+
| Skill | What it does |
|
|
411
|
+
| ---------------------- | ----------------------------------------------------------------------- |
|
|
412
|
+
| `strengthen` | Upgrade `guidance()` → `enforce()` using linter-specific reference docs |
|
|
413
|
+
| `edit-spec` | Edit a spec file — guided workflow with compile step |
|
|
414
|
+
| `migrate-to-spec` | Convert a hand-written CLAUDE.md to a typed `.spec.ts` |
|
|
415
|
+
| `generate-rule` | Add a new `enforce()` / `guidance()` rule to a spec |
|
|
416
|
+
| `pr-to-lint-rule` | Turn a recurring PR review comment into a lint rule + spec entry |
|
|
417
|
+
| `enforce-rules-format` | Validate all rules have enforcement classification |
|
|
418
|
+
| `audit-feedback-loop` | Score your repo's feedback loop maturity |
|
|
419
|
+
|
|
420
|
+
</details>
|
|
447
421
|
|
|
448
422
|
## Maturity Levels
|
|
449
423
|
|
|
450
|
-
From [Feedback Loop Is All You Need](https://zernie.com/blog/feedback-loop-is-all-you-need):
|
|
424
|
+
From [Feedback Loop Is All You Need](https://zernie.com/blog/feedback-loop-is-all-you-need): **Vibes → Guardrails → Architecture as Code → The Organism**.
|
|
425
|
+
|
|
426
|
+
<details>
|
|
427
|
+
<summary>What each level means</summary>
|
|
451
428
|
|
|
452
429
|
| Level | Name | What it means |
|
|
453
430
|
| ----- | -------------------- | ------------------------------------------------------------------- |
|
|
@@ -456,20 +433,16 @@ From [Feedback Loop Is All You Need](https://zernie.com/blog/feedback-loop-is-al
|
|
|
456
433
|
| 2 | Architecture as Code | Custom lint rules + enforced CLAUDE.md |
|
|
457
434
|
| 3 | The Organism | CI + custom rules + visual tests + observability + scheduled agents |
|
|
458
435
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
Specs compile to `CLAUDE.md` by default. Set `target: "AGENTS.md"` or `target: ["CLAUDE.md", "AGENTS.md"]` for multiple outputs from one spec. For non-markdown formats (`.cursorrules`, Copilot), use [rule-porter](https://github.com/nichochar/rule-porter) or [rulesync](https://github.com/dyoshikawa/rulesync) to convert. [Spec format →](docs/spec-format.md)
|
|
436
|
+
</details>
|
|
462
437
|
|
|
463
438
|
## Related Tools
|
|
464
439
|
|
|
465
|
-
vigiles
|
|
440
|
+
vigiles owns one thing: compile-time verification of typed specs against real linter configs, filesystems, and package.json, plus testing the harness those specs describe. Everything else it composes with rather than replaces — architectural linters ([ast-grep](https://ast-grep.github.io/), [Dependency Cruiser](https://github.com/sverweij/dependency-cruiser)) referenced via `enforce()`, file-sync tools ([Ruler](https://github.com/intellectronica/ruler), [rulesync](https://github.com/dyoshikawa/rulesync)) that distribute the compiled output, and markdown/prose linters that check a different layer. [How vigiles composes with each, and why runtime-LLM rule checkers are the opposite paradigm →](docs/related-tools.md)
|
|
441
|
+
|
|
442
|
+
## Documentation
|
|
466
443
|
|
|
467
|
-
- **
|
|
468
|
-
- **
|
|
469
|
-
- **Markdown linting** — [markdownlint](https://github.com/DavidAnson/markdownlint). vigiles generates markdown; structure is correct by construction.
|
|
470
|
-
- **Code-block linting in docs** — [eslint-plugin-markdown](https://github.com/eslint/eslint-plugin-markdown) for syntax, [twoslash](https://shikijs.github.io/twoslash/) for TS type-checking.
|
|
471
|
-
- **Prose quality** — [Vale](https://vale.sh). Different concern.
|
|
472
|
-
- **Runtime LLM rule checking** (e.g. ai-rulez `"AI-Powered Rule Enforcement"`) — opposite paradigm. Those tools send your code to a model on every check, costing tokens and giving non-reproducible verdicts. vigiles compiles once and checks deterministically forever after with `eslint`, `ruff`, `tsc`, Cedar evaluation — tools as deterministic as their inputs.
|
|
444
|
+
- **[docs/](docs/README.md)** — how-to & reference: the adoption ladder, CLI, linter support, the harness-testing guide, skills/agents.
|
|
445
|
+
- **[research/](research/README.md)** — the thinking behind it: design docs, the [harness-testing coverage roadmap](research/harness-testing-coverage-matrix.md), benchmark findings, landscape, and parked ideas.
|
|
473
446
|
|
|
474
447
|
## License
|
|
475
448
|
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ const generate_schema_js_1 = require("./generate-schema.js");
|
|
|
24
24
|
const compile_generator_js_1 = require("./compile-generator.js");
|
|
25
25
|
const action_gate_js_1 = require("./action-gate.js");
|
|
26
26
|
const refs_js_1 = require("./refs.js");
|
|
27
|
+
const mcp_js_1 = require("./mcp.js");
|
|
27
28
|
const skill_runtime_js_1 = require("./skill-runtime.js");
|
|
28
29
|
const linters_js_1 = require("./linters.js");
|
|
29
30
|
const harness_test_js_1 = require("./harness-test.js");
|
|
@@ -433,6 +434,46 @@ function verifyMarkdownSymbols(files, silent) {
|
|
|
433
434
|
}
|
|
434
435
|
return errors;
|
|
435
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Verify `vigiles:mcp server#tool` marks in instruction files against the live
|
|
439
|
+
* MCP servers declared in `.mcp.json` — the referenced tool must exist on the
|
|
440
|
+
* server (it gets started for the check). No `.mcp.json` ⇒ skipped; a server is
|
|
441
|
+
* only started if a mark actually references it. Returns the count of broken
|
|
442
|
+
* references. Async because it speaks to real servers.
|
|
443
|
+
*/
|
|
444
|
+
async function verifyMarkdownMcpRefs(files, silent) {
|
|
445
|
+
const cwd = process.cwd();
|
|
446
|
+
const servers = (0, mcp_js_1.loadMcpServers)(cwd);
|
|
447
|
+
if (files.length === 0 || Object.keys(servers).length === 0)
|
|
448
|
+
return 0;
|
|
449
|
+
let printedHeader = false;
|
|
450
|
+
let errors = 0;
|
|
451
|
+
for (const f of files) {
|
|
452
|
+
let markdown;
|
|
453
|
+
try {
|
|
454
|
+
markdown = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, f), "utf-8");
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const broken = await (0, mcp_js_1.verifyMcpRefs)(markdown, servers);
|
|
460
|
+
if (broken.length === 0)
|
|
461
|
+
continue;
|
|
462
|
+
if (!silent) {
|
|
463
|
+
if (!printedHeader) {
|
|
464
|
+
console.log("\nMCP reference check:\n");
|
|
465
|
+
printedHeader = true;
|
|
466
|
+
}
|
|
467
|
+
for (const b of broken) {
|
|
468
|
+
const msg = (0, mcp_js_1.mcpRefMessage)(b);
|
|
469
|
+
console.log(` ✗ ${f}:${String(b.line)} ${msg}`);
|
|
470
|
+
ghAnnotate("error", msg, f, b.line);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
errors += broken.length;
|
|
474
|
+
}
|
|
475
|
+
return errors;
|
|
476
|
+
}
|
|
436
477
|
/** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
|
|
437
478
|
function auditExitCode(report) {
|
|
438
479
|
if (report.hashErrors > 0 ||
|
|
@@ -441,7 +482,8 @@ function auditExitCode(report) {
|
|
|
441
482
|
report.frontmatterErrors > 0 ||
|
|
442
483
|
report.integrityErrors > 0 ||
|
|
443
484
|
report.coverageErrors > 0 ||
|
|
444
|
-
report.symbolRefErrors > 0
|
|
485
|
+
report.symbolRefErrors > 0 ||
|
|
486
|
+
report.mcpRefErrors > 0)
|
|
445
487
|
return 2;
|
|
446
488
|
if (report.duplicatePairs > 0 ||
|
|
447
489
|
report.orphanCount > 0 ||
|
|
@@ -730,6 +772,9 @@ async function audit(restArgs, flags, config) {
|
|
|
730
772
|
}
|
|
731
773
|
// 9. Verify code-shaped symbol references live (see src/refs.ts).
|
|
732
774
|
const symbolRefErrors = verifyMarkdownSymbols(files, silent);
|
|
775
|
+
// 10. Verify `vigiles:mcp server#tool` marks against live MCP servers
|
|
776
|
+
// (only when a .mcp.json declares them). See src/mcp.ts.
|
|
777
|
+
const mcpRefErrors = await verifyMarkdownMcpRefs(files, silent);
|
|
733
778
|
const report = {
|
|
734
779
|
hashErrors: hashResult.hashErrors,
|
|
735
780
|
validationErrors: hashResult.validationErrors,
|
|
@@ -746,6 +791,7 @@ async function audit(restArgs, flags, config) {
|
|
|
746
791
|
orphanCount: orphanReport.orphans.length,
|
|
747
792
|
docRefErrors: docRefReport.errors.length,
|
|
748
793
|
symbolRefErrors,
|
|
794
|
+
mcpRefErrors,
|
|
749
795
|
files,
|
|
750
796
|
};
|
|
751
797
|
if (summary) {
|
|
@@ -775,6 +821,8 @@ function printAuditSummary(report) {
|
|
|
775
821
|
parts.push(`${String(report.docRefErrors)} broken doc refs`);
|
|
776
822
|
if (report.symbolRefErrors > 0)
|
|
777
823
|
parts.push(`${String(report.symbolRefErrors)} broken symbol refs`);
|
|
824
|
+
if (report.mcpRefErrors > 0)
|
|
825
|
+
parts.push(`${String(report.mcpRefErrors)} broken MCP refs`);
|
|
778
826
|
const undocumented = report.coverageEnabled - report.coverageDocumented;
|
|
779
827
|
if (undocumented > 0)
|
|
780
828
|
parts.push(`${String(undocumented)} undocumented rules`);
|
package/dist/eval.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface EvalArm {
|
|
|
10
10
|
* src/plugin-loader.ts.
|
|
11
11
|
*/
|
|
12
12
|
readonly plugin?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Path to a plugin dir to install NATIVELY (`claude --plugin-dir`) for this
|
|
15
|
+
* arm, so its skills/commands/agents activate the real way — the real model
|
|
16
|
+
* can trigger a skill by its description (vs. `plugin`, which materializes a
|
|
17
|
+
* file subset that does not register skills). Point at a COMPLETE plugin. Lets
|
|
18
|
+
* an arm be "skill installed" vs "off" to measure real activation.
|
|
19
|
+
*/
|
|
20
|
+
readonly pluginDir?: string;
|
|
13
21
|
}
|
|
14
22
|
/** Context handed to `measure` after a run, to compute that run's metrics. */
|
|
15
23
|
export interface RunContext {
|
package/dist/eval.js
CHANGED
|
@@ -39,7 +39,7 @@ function writeFiles(cwd, files) {
|
|
|
39
39
|
(0, node_fs_1.writeFileSync)(full, content);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
function spawnAgent(task, cwd, model, tools, hasSettings, timeoutMs) {
|
|
42
|
+
function spawnAgent(task, cwd, model, tools, hasSettings, pluginDir, timeoutMs) {
|
|
43
43
|
return new Promise((resolvePromise) => {
|
|
44
44
|
const args = [
|
|
45
45
|
"-p",
|
|
@@ -50,6 +50,7 @@ function spawnAgent(task, cwd, model, tools, hasSettings, timeoutMs) {
|
|
|
50
50
|
model,
|
|
51
51
|
"--permission-mode",
|
|
52
52
|
"acceptEdits",
|
|
53
|
+
...(pluginDir !== undefined ? ["--plugin-dir", (0, node_path_1.resolve)(pluginDir)] : []),
|
|
53
54
|
...(hasSettings ? ["--settings", "settings.json"] : []),
|
|
54
55
|
"--allowedTools",
|
|
55
56
|
...tools,
|
|
@@ -173,7 +174,7 @@ async function runEval(spec) {
|
|
|
173
174
|
if (hasSettings) {
|
|
174
175
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
|
|
175
176
|
}
|
|
176
|
-
const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, timeoutMs);
|
|
177
|
+
const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, arm.pluginDir, timeoutMs);
|
|
177
178
|
rows.push(spec.measure(makeContext(cwd, out)));
|
|
178
179
|
}
|
|
179
180
|
finally {
|
package/dist/harness-assert.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `expect(...).toHaveCreated(...)` sugar. The signature is identical for
|
|
14
14
|
* vitest and jest, so the same object supports both.
|
|
15
15
|
*/
|
|
16
|
-
import { type HarnessTestSpec, type HarnessTestResult } from "./harness-test.js";
|
|
16
|
+
import { type HarnessTestSpec, type HarnessTestResult, type ToolCall } from "./harness-test.js";
|
|
17
17
|
import type { EvalReport } from "./eval.js";
|
|
18
18
|
import type { HookRunResult } from "./run-hook.js";
|
|
19
19
|
/**
|
|
@@ -32,6 +32,47 @@ export declare function assertServedTurns(r: HarnessTestResult, n: number): void
|
|
|
32
32
|
export declare function assertHookBlocked(r: HookRunResult): void;
|
|
33
33
|
/** Assert a `runHook` result allowed (did not block). */
|
|
34
34
|
export declare function assertHookAllowed(r: HookRunResult): void;
|
|
35
|
+
/**
|
|
36
|
+
* Assert the agent invoked a tool whose name matches `name` (string = exact,
|
|
37
|
+
* RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
|
|
38
|
+
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
|
|
39
|
+
* skill/MCP/command surfaces are really about.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertToolUsed(r: HarnessTestResult, name: string | RegExp): void;
|
|
42
|
+
/**
|
|
43
|
+
* Assert the agent did NOT invoke any tool matching `name` — the safety negative
|
|
44
|
+
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
|
|
45
|
+
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertToolNotUsed(r: HarnessTestResult, name: string | RegExp): void;
|
|
48
|
+
/**
|
|
49
|
+
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
50
|
+
* without error — the correct skill-activation invariant, vs. grepping the body.
|
|
51
|
+
*/
|
|
52
|
+
export declare function assertSkillResolved(r: HarnessTestResult, skill: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Assert how many tools matching `name` the agent invoked is within bounds — a
|
|
55
|
+
* budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
|
|
56
|
+
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function assertToolCount(r: HarnessTestResult, name: string | RegExp, bounds: {
|
|
59
|
+
min?: number;
|
|
60
|
+
max?: number;
|
|
61
|
+
exactly?: number;
|
|
62
|
+
}): void;
|
|
63
|
+
/**
|
|
64
|
+
* Assert the named tools occurred in this order (as a subsequence — gaps allowed)
|
|
65
|
+
* — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
|
|
66
|
+
* Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
|
|
67
|
+
* Needs `transcript`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function assertToolSequence(r: HarnessTestResult, names: ReadonlyArray<string | RegExp>): void;
|
|
70
|
+
/**
|
|
71
|
+
* The escape hatch: assert any custom invariant over the full list of tool calls
|
|
72
|
+
* the agent made — for rules the helpers above don't express, e.g. "every Edit
|
|
73
|
+
* was preceded by a Read of that file". Needs `transcript`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertToolCalls(r: HarnessTestResult, predicate: (calls: readonly ToolCall[]) => boolean, message?: string): void;
|
|
35
76
|
/** The gap on `metric` between two arms (arm − baseline). */
|
|
36
77
|
export declare function improvement(report: EvalReport, baseline: string, arm: string, metric: string): number;
|
|
37
78
|
/**
|
package/dist/harness-assert.js
CHANGED
|
@@ -7,6 +7,12 @@ exports.assertNotCreated = assertNotCreated;
|
|
|
7
7
|
exports.assertServedTurns = assertServedTurns;
|
|
8
8
|
exports.assertHookBlocked = assertHookBlocked;
|
|
9
9
|
exports.assertHookAllowed = assertHookAllowed;
|
|
10
|
+
exports.assertToolUsed = assertToolUsed;
|
|
11
|
+
exports.assertToolNotUsed = assertToolNotUsed;
|
|
12
|
+
exports.assertSkillResolved = assertSkillResolved;
|
|
13
|
+
exports.assertToolCount = assertToolCount;
|
|
14
|
+
exports.assertToolSequence = assertToolSequence;
|
|
15
|
+
exports.assertToolCalls = assertToolCalls;
|
|
10
16
|
exports.improvement = improvement;
|
|
11
17
|
exports.assertImproves = assertImproves;
|
|
12
18
|
/**
|
|
@@ -71,6 +77,93 @@ function assertHookAllowed(r) {
|
|
|
71
77
|
fail(`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`);
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
function nameMatches(name, pat) {
|
|
81
|
+
return typeof pat === "string" ? name === pat : pat.test(name);
|
|
82
|
+
}
|
|
83
|
+
function toolNames(r) {
|
|
84
|
+
return r.toolCalls.map((c) => c.name).join(", ") || "none";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Assert the agent invoked a tool whose name matches `name` (string = exact,
|
|
88
|
+
* RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
|
|
89
|
+
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
|
|
90
|
+
* skill/MCP/command surfaces are really about.
|
|
91
|
+
*/
|
|
92
|
+
function assertToolUsed(r, name) {
|
|
93
|
+
if (!r.toolCalls.some((c) => nameMatches(c.name, name))) {
|
|
94
|
+
fail(`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(r)}] (did you set transcript:true?)`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Assert the agent did NOT invoke any tool matching `name` — the safety negative
|
|
99
|
+
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
|
|
100
|
+
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
|
|
101
|
+
*/
|
|
102
|
+
function assertToolNotUsed(r, name) {
|
|
103
|
+
const hit = r.toolCalls.find((c) => nameMatches(c.name, name));
|
|
104
|
+
if (hit) {
|
|
105
|
+
fail(`expected no tool matching ${String(name)} to be used, but ${hit.name} was`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
110
|
+
* without error — the correct skill-activation invariant, vs. grepping the body.
|
|
111
|
+
*/
|
|
112
|
+
function assertSkillResolved(r, skill) {
|
|
113
|
+
const call = r.toolCalls.find((c) => c.name === "Skill" && c.input?.skill === skill);
|
|
114
|
+
if (!call) {
|
|
115
|
+
const seen = r.toolCalls
|
|
116
|
+
.filter((c) => c.name === "Skill")
|
|
117
|
+
.map((c) => c.input?.skill ?? "?")
|
|
118
|
+
.join(", ");
|
|
119
|
+
fail(`expected the Skill tool to resolve "${skill}"; Skill calls: [${seen || "none"}]`);
|
|
120
|
+
}
|
|
121
|
+
if (call.isError) {
|
|
122
|
+
fail(`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// --- sequence / budget invariants over the agent's actions -----------------
|
|
126
|
+
/**
|
|
127
|
+
* Assert how many tools matching `name` the agent invoked is within bounds — a
|
|
128
|
+
* budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
|
|
129
|
+
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
|
|
130
|
+
*/
|
|
131
|
+
function assertToolCount(r, name, bounds) {
|
|
132
|
+
const n = r.toolCalls.filter((c) => nameMatches(c.name, name)).length;
|
|
133
|
+
const ok = (bounds.exactly === undefined || n === bounds.exactly) &&
|
|
134
|
+
(bounds.min === undefined || n >= bounds.min) &&
|
|
135
|
+
(bounds.max === undefined || n <= bounds.max);
|
|
136
|
+
if (!ok) {
|
|
137
|
+
fail(`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(r)}])`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Assert the named tools occurred in this order (as a subsequence — gaps allowed)
|
|
142
|
+
* — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
|
|
143
|
+
* Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
|
|
144
|
+
* Needs `transcript`.
|
|
145
|
+
*/
|
|
146
|
+
function assertToolSequence(r, names) {
|
|
147
|
+
let i = 0;
|
|
148
|
+
for (const c of r.toolCalls) {
|
|
149
|
+
const want = names[i];
|
|
150
|
+
if (want !== undefined && nameMatches(c.name, want))
|
|
151
|
+
i++;
|
|
152
|
+
}
|
|
153
|
+
if (i < names.length) {
|
|
154
|
+
fail(`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(r)}]`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The escape hatch: assert any custom invariant over the full list of tool calls
|
|
159
|
+
* the agent made — for rules the helpers above don't express, e.g. "every Edit
|
|
160
|
+
* was preceded by a Read of that file". Needs `transcript`.
|
|
161
|
+
*/
|
|
162
|
+
function assertToolCalls(r, predicate, message = "tool-call invariant failed") {
|
|
163
|
+
if (!predicate(r.toolCalls)) {
|
|
164
|
+
fail(`${message}; tools used: [${toolNames(r)}]`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
74
167
|
/** The gap on `metric` between two arms (arm − baseline). */
|
|
75
168
|
function improvement(report, baseline, arm, metric) {
|
|
76
169
|
const a = report.arms[arm]?.metrics[metric] ?? 0;
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -12,12 +12,29 @@ export interface HarnessTestSpec {
|
|
|
12
12
|
* subset. Inline `settings`/`files` layer on top. See src/plugin-loader.ts.
|
|
13
13
|
*/
|
|
14
14
|
readonly plugin?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Path to a plugin dir to install NATIVELY via `claude --plugin-dir`, so its
|
|
17
|
+
* skills / commands / agents / hooks register and ACTIVATE the real way — a
|
|
18
|
+
* scripted `Skill` tool_use resolves, and the real model can trigger them.
|
|
19
|
+
* Unlike `plugin` (which materializes a file subset that does NOT register
|
|
20
|
+
* skills for the `Skill` tool), this is the real install path, so point it at a
|
|
21
|
+
* COMPLETE plugin (internal references resolve). Inline `settings`/`files` and
|
|
22
|
+
* `plugin` still layer on top. Resolved to an absolute path.
|
|
23
|
+
*/
|
|
24
|
+
readonly pluginDir?: string;
|
|
15
25
|
/** The scripted model turns the agent will take. */
|
|
16
26
|
readonly model: readonly ModelTurn[];
|
|
17
27
|
/** The user prompt. Default: "go". */
|
|
18
28
|
readonly prompt?: string;
|
|
19
29
|
/** Tools the agent may use. Default: Read Edit Write Bash. */
|
|
20
30
|
readonly allowedTools?: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* Capture the full event transcript (`--output-format stream-json`) into
|
|
33
|
+
* `stdout`, instead of just the final result object, so you can assert on what
|
|
34
|
+
* the agent's tools returned — e.g. the body a `Skill` tool_use resolved. With
|
|
35
|
+
* this on, `stdout` is newline-delimited JSON events, not a single object.
|
|
36
|
+
*/
|
|
37
|
+
readonly transcript?: boolean;
|
|
21
38
|
/** Per-run wall-clock timeout in ms. Default 60000. */
|
|
22
39
|
readonly timeoutMs?: number;
|
|
23
40
|
}
|
|
@@ -30,11 +47,33 @@ export interface HarnessTestResult {
|
|
|
30
47
|
readonly cwd: string;
|
|
31
48
|
/** Number of model turns the agent took (mock turns served). */
|
|
32
49
|
readonly turns: number;
|
|
50
|
+
/**
|
|
51
|
+
* The tools the agent invoked, each paired with its result — parsed from the
|
|
52
|
+
* transcript. Empty unless `transcript: true`. Lets a test assert on the
|
|
53
|
+
* agent's *actions* (skills, MCP tools, subagents) instead of grepping stdout.
|
|
54
|
+
*/
|
|
55
|
+
readonly toolCalls: readonly ToolCall[];
|
|
33
56
|
/** Final contents of a file under the working dir, or null if absent. */
|
|
34
57
|
file(path: string): string | null;
|
|
35
58
|
/** Remove the temp working dir. */
|
|
36
59
|
cleanup(): void;
|
|
37
60
|
}
|
|
61
|
+
/** A tool the agent invoked, paired with its result (transcript mode only). */
|
|
62
|
+
export interface ToolCall {
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly input: unknown;
|
|
65
|
+
/** The tool_result text ("" if none / not captured). */
|
|
66
|
+
readonly resultText: string;
|
|
67
|
+
/** Whether the tool_result came back flagged as an error. */
|
|
68
|
+
readonly isError: boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse `--output-format stream-json` (the `transcript: true` output) into the
|
|
72
|
+
* tools the agent invoked, each joined to its result by id. Returns [] for the
|
|
73
|
+
* non-stream `json` output. The seam that lets a test assert on the agent's
|
|
74
|
+
* actions, not a brittle stdout substring.
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseToolCalls(streamJson: string): ToolCall[];
|
|
38
77
|
/** Whether the `claude` CLI is available — harness tests need it. */
|
|
39
78
|
export declare function claudeAvailable(): boolean;
|
|
40
79
|
/**
|
package/dist/harness-test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resolveHarness = exports.loadPlugin = exports.scriptModel = void 0;
|
|
4
|
+
exports.parseToolCalls = parseToolCalls;
|
|
4
5
|
exports.claudeAvailable = claudeAvailable;
|
|
5
6
|
exports.runHarnessTest = runHarnessTest;
|
|
6
7
|
/**
|
|
@@ -43,6 +44,63 @@ Object.defineProperty(exports, "scriptModel", { enumerable: true, get: function
|
|
|
43
44
|
var plugin_loader_js_2 = require("./plugin-loader.js");
|
|
44
45
|
Object.defineProperty(exports, "loadPlugin", { enumerable: true, get: function () { return plugin_loader_js_2.loadPlugin; } });
|
|
45
46
|
Object.defineProperty(exports, "resolveHarness", { enumerable: true, get: function () { return plugin_loader_js_2.resolveHarness; } });
|
|
47
|
+
function contentText(content) {
|
|
48
|
+
if (typeof content === "string")
|
|
49
|
+
return content;
|
|
50
|
+
if (!Array.isArray(content))
|
|
51
|
+
return "";
|
|
52
|
+
return content
|
|
53
|
+
.map((b) => {
|
|
54
|
+
if (typeof b === "string")
|
|
55
|
+
return b;
|
|
56
|
+
const t = b.text;
|
|
57
|
+
return typeof t === "string" ? t : "";
|
|
58
|
+
})
|
|
59
|
+
.join("");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse `--output-format stream-json` (the `transcript: true` output) into the
|
|
63
|
+
* tools the agent invoked, each joined to its result by id. Returns [] for the
|
|
64
|
+
* non-stream `json` output. The seam that lets a test assert on the agent's
|
|
65
|
+
* actions, not a brittle stdout substring.
|
|
66
|
+
*/
|
|
67
|
+
function parseToolCalls(streamJson) {
|
|
68
|
+
const uses = [];
|
|
69
|
+
const results = new Map();
|
|
70
|
+
for (const line of streamJson.split("\n")) {
|
|
71
|
+
if (!line.trim())
|
|
72
|
+
continue;
|
|
73
|
+
let evt;
|
|
74
|
+
try {
|
|
75
|
+
evt = JSON.parse(line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const content = evt.message?.content;
|
|
81
|
+
if (!Array.isArray(content))
|
|
82
|
+
continue;
|
|
83
|
+
for (const b of content) {
|
|
84
|
+
if (b.type === "tool_use" && typeof b.name === "string") {
|
|
85
|
+
const id = typeof b.id === "string" ? b.id : "";
|
|
86
|
+
uses.push({ id, name: b.name, input: b.input });
|
|
87
|
+
}
|
|
88
|
+
else if (b.type === "tool_result") {
|
|
89
|
+
const id = typeof b.tool_use_id === "string" ? b.tool_use_id : "";
|
|
90
|
+
results.set(id, {
|
|
91
|
+
text: contentText(b.content),
|
|
92
|
+
isError: b.is_error === true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return uses.map((u) => ({
|
|
98
|
+
name: u.name,
|
|
99
|
+
input: u.input,
|
|
100
|
+
resultText: results.get(u.id)?.text ?? "",
|
|
101
|
+
isError: results.get(u.id)?.isError ?? false,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
46
104
|
/** Whether the `claude` CLI is available — harness tests need it. */
|
|
47
105
|
function claudeAvailable() {
|
|
48
106
|
try {
|
|
@@ -107,10 +165,14 @@ async function runHarnessTest(spec) {
|
|
|
107
165
|
const args = [
|
|
108
166
|
"-p",
|
|
109
167
|
spec.prompt ?? "go",
|
|
110
|
-
|
|
111
|
-
|
|
168
|
+
...(spec.transcript
|
|
169
|
+
? ["--output-format", "stream-json", "--verbose"]
|
|
170
|
+
: ["--output-format", "json"]),
|
|
112
171
|
"--model",
|
|
113
172
|
"claude-sonnet-4-5",
|
|
173
|
+
...(spec.pluginDir !== undefined
|
|
174
|
+
? ["--plugin-dir", (0, node_path_1.resolve)(spec.pluginDir)]
|
|
175
|
+
: []),
|
|
114
176
|
...(settings !== undefined ? ["--settings", "settings.json"] : []),
|
|
115
177
|
"--allowedTools",
|
|
116
178
|
...tools,
|
|
@@ -122,6 +184,7 @@ async function runHarnessTest(spec) {
|
|
|
122
184
|
stderr: out.stderr,
|
|
123
185
|
cwd,
|
|
124
186
|
turns: mock.count,
|
|
187
|
+
toolCalls: parseToolCalls(out.stdout),
|
|
125
188
|
file: (p) => {
|
|
126
189
|
const f = (0, node_path_1.resolve)(cwd, p);
|
|
127
190
|
return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface McpServerConfig {
|
|
2
|
+
readonly command: string;
|
|
3
|
+
readonly args?: readonly string[];
|
|
4
|
+
readonly env?: Record<string, string>;
|
|
5
|
+
readonly cwd?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface McpToolInfo {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly description?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Start an MCP server over stdio, complete the handshake, and return its tools.
|
|
13
|
+
* Kills the server when done. Throws on spawn/timeout/exit/protocol error.
|
|
14
|
+
*/
|
|
15
|
+
export declare function listMcpTools(server: McpServerConfig, timeoutMs?: number): Promise<McpToolInfo[]>;
|
|
16
|
+
export interface McpRefResult {
|
|
17
|
+
readonly exists: boolean;
|
|
18
|
+
readonly available: string[];
|
|
19
|
+
readonly suggestions: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Verify `toolName` exists on `server`; on a miss, suggest the closest tool names
|
|
23
|
+
* (edit distance) — "did you mean issue_write?".
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifyMcpTool(server: McpServerConfig, toolName: string, timeoutMs?: number): Promise<McpRefResult>;
|
|
26
|
+
export interface McpRef {
|
|
27
|
+
readonly server: string;
|
|
28
|
+
readonly tool: string;
|
|
29
|
+
readonly line: number;
|
|
30
|
+
}
|
|
31
|
+
export type McpRefReason = "server-undeclared" | "server-unreachable" | "tool-missing";
|
|
32
|
+
export interface McpRefError extends McpRef {
|
|
33
|
+
readonly reason: McpRefReason;
|
|
34
|
+
readonly suggestions: string[];
|
|
35
|
+
}
|
|
36
|
+
/** Parse `vigiles:mcp server#tool` marks from a markdown file's inline spans. */
|
|
37
|
+
export declare function parseMcpRefs(markdown: string): McpRef[];
|
|
38
|
+
/** Read `mcpServers` from `.mcp.json` (the stdio-server config map), or `{}`. */
|
|
39
|
+
export declare function loadMcpServers(cwd: string): Record<string, McpServerConfig>;
|
|
40
|
+
/**
|
|
41
|
+
* Verify every `vigiles:mcp server#tool` mark in `markdown` against the live
|
|
42
|
+
* servers in `mcpServers` (each referenced server is started once). A reference
|
|
43
|
+
* to an undeclared server, an unreachable server, or a missing tool is an error.
|
|
44
|
+
*/
|
|
45
|
+
export declare function verifyMcpRefs(markdown: string, mcpServers: Record<string, McpServerConfig>, timeoutMs?: number): Promise<McpRefError[]>;
|
|
46
|
+
/** Human-readable message for an MCP reference error (with "did you mean"). */
|
|
47
|
+
export declare function mcpRefMessage(e: McpRefError): string;
|
|
48
|
+
//# sourceMappingURL=mcp.d.ts.map
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listMcpTools = listMcpTools;
|
|
4
|
+
exports.verifyMcpTool = verifyMcpTool;
|
|
5
|
+
exports.parseMcpRefs = parseMcpRefs;
|
|
6
|
+
exports.loadMcpServers = loadMcpServers;
|
|
7
|
+
exports.verifyMcpRefs = verifyMcpRefs;
|
|
8
|
+
exports.mcpRefMessage = mcpRefMessage;
|
|
9
|
+
/**
|
|
10
|
+
* Minimal MCP client over stdio — start a server, do the JSON-RPC handshake, and
|
|
11
|
+
* list its tools. This lets vigiles VERIFY a referenced `mcp__server__tool`
|
|
12
|
+
* resolves against the real server (the way `enforce()` resolves a linter rule
|
|
13
|
+
* against its catalog), catching a skill/CLAUDE.md that cites an MCP tool that was
|
|
14
|
+
* renamed or removed — e.g. the GitHub MCP server renaming `create_issue` →
|
|
15
|
+
* `issue_write`, which otherwise fails silently at runtime.
|
|
16
|
+
*
|
|
17
|
+
* MCP stdio transport = newline-delimited JSON-RPC 2.0.
|
|
18
|
+
*/
|
|
19
|
+
const node_child_process_1 = require("node:child_process");
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_path_1 = require("node:path");
|
|
22
|
+
const refs_js_1 = require("./refs.js");
|
|
23
|
+
const hash_js_1 = require("./hash.js");
|
|
24
|
+
function dispatch(line, pending) {
|
|
25
|
+
let msg;
|
|
26
|
+
try {
|
|
27
|
+
msg = JSON.parse(line);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (typeof msg.id !== "number")
|
|
33
|
+
return;
|
|
34
|
+
const p = pending.get(msg.id);
|
|
35
|
+
if (p) {
|
|
36
|
+
pending.delete(msg.id);
|
|
37
|
+
p.resolve(msg);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Start an MCP server over stdio, complete the handshake, and return its tools.
|
|
42
|
+
* Kills the server when done. Throws on spawn/timeout/exit/protocol error.
|
|
43
|
+
*/
|
|
44
|
+
async function listMcpTools(server, timeoutMs = 10000) {
|
|
45
|
+
const child = (0, node_child_process_1.spawn)(server.command, [...(server.args ?? [])], {
|
|
46
|
+
env: server.env ? { ...process.env, ...server.env } : process.env,
|
|
47
|
+
cwd: server.cwd,
|
|
48
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
49
|
+
});
|
|
50
|
+
const { stdin, stdout } = child;
|
|
51
|
+
if (!stdin || !stdout) {
|
|
52
|
+
child.kill("SIGKILL");
|
|
53
|
+
throw new Error("failed to open MCP server stdio");
|
|
54
|
+
}
|
|
55
|
+
const pending = new Map();
|
|
56
|
+
const failAll = (err) => {
|
|
57
|
+
for (const [, p] of pending)
|
|
58
|
+
p.reject(err);
|
|
59
|
+
pending.clear();
|
|
60
|
+
};
|
|
61
|
+
let buffer = "";
|
|
62
|
+
stdout.setEncoding("utf-8");
|
|
63
|
+
stdout.on("data", (chunk) => {
|
|
64
|
+
buffer += chunk;
|
|
65
|
+
let nl = buffer.indexOf("\n");
|
|
66
|
+
while (nl >= 0) {
|
|
67
|
+
const line = buffer.slice(0, nl).trim();
|
|
68
|
+
buffer = buffer.slice(nl + 1);
|
|
69
|
+
if (line)
|
|
70
|
+
dispatch(line, pending);
|
|
71
|
+
nl = buffer.indexOf("\n");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
child.on("error", (e) => {
|
|
75
|
+
failAll(e);
|
|
76
|
+
});
|
|
77
|
+
child.on("close", () => {
|
|
78
|
+
failAll(new Error("MCP server exited before responding"));
|
|
79
|
+
});
|
|
80
|
+
const send = (obj) => {
|
|
81
|
+
stdin.write(`${JSON.stringify(obj)}\n`);
|
|
82
|
+
};
|
|
83
|
+
const request = (id, method, params) => new Promise((resolve, reject) => {
|
|
84
|
+
pending.set(id, { resolve, reject });
|
|
85
|
+
send({ jsonrpc: "2.0", id, method, params });
|
|
86
|
+
});
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
failAll(new Error(`MCP server timed out after ${String(timeoutMs)}ms`));
|
|
89
|
+
child.kill("SIGKILL");
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
try {
|
|
92
|
+
await request(1, "initialize", {
|
|
93
|
+
protocolVersion: "2024-11-05",
|
|
94
|
+
capabilities: {},
|
|
95
|
+
clientInfo: { name: "vigiles", version: "0" },
|
|
96
|
+
});
|
|
97
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
98
|
+
const res = await request(2, "tools/list", {});
|
|
99
|
+
if (res.error) {
|
|
100
|
+
throw new Error(`tools/list failed: ${res.error.message ?? "unknown"}`);
|
|
101
|
+
}
|
|
102
|
+
return (res.result?.tools ?? []).map((t) => ({
|
|
103
|
+
name: t.name,
|
|
104
|
+
description: t.description,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Verify `toolName` exists on `server`; on a miss, suggest the closest tool names
|
|
114
|
+
* (edit distance) — "did you mean issue_write?".
|
|
115
|
+
*/
|
|
116
|
+
async function verifyMcpTool(server, toolName, timeoutMs = 10000) {
|
|
117
|
+
const available = (await listMcpTools(server, timeoutMs)).map((t) => t.name);
|
|
118
|
+
const exists = available.includes(toolName);
|
|
119
|
+
return {
|
|
120
|
+
exists,
|
|
121
|
+
available,
|
|
122
|
+
suggestions: exists ? [] : closest(toolName, available),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function editDistance(a, b) {
|
|
126
|
+
if (a === b)
|
|
127
|
+
return 0;
|
|
128
|
+
const m = a.length;
|
|
129
|
+
const n = b.length;
|
|
130
|
+
if (m === 0)
|
|
131
|
+
return n;
|
|
132
|
+
if (n === 0)
|
|
133
|
+
return m;
|
|
134
|
+
const dp = Array.from({ length: n + 1 }, (_, i) => i);
|
|
135
|
+
for (let i = 1; i <= m; i++) {
|
|
136
|
+
let prev = dp[0];
|
|
137
|
+
dp[0] = i;
|
|
138
|
+
for (let j = 1; j <= n; j++) {
|
|
139
|
+
const tmp = dp[j];
|
|
140
|
+
dp[j] =
|
|
141
|
+
a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
|
|
142
|
+
prev = tmp;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return dp[n];
|
|
146
|
+
}
|
|
147
|
+
function closest(target, candidates, max = 4) {
|
|
148
|
+
return candidates
|
|
149
|
+
.map((c) => ({ c, d: editDistance(target, c) }))
|
|
150
|
+
.filter((x) => x.d <= max)
|
|
151
|
+
.sort((a, b) => a.d - b.d)
|
|
152
|
+
.slice(0, 3)
|
|
153
|
+
.map((x) => x.c);
|
|
154
|
+
}
|
|
155
|
+
// --- MCP tool references in instruction files ------------------------------
|
|
156
|
+
// `vigiles:mcp <server>#<tool>` inside an inline code span — the MCP analogue of
|
|
157
|
+
// the `vigiles:symbol path#name` mark. Self-contained (server + tool in one
|
|
158
|
+
// token) so it binds unambiguously.
|
|
159
|
+
const MCP_MARK = /^vigiles:mcp\s+([\w-]+)#([\w.-]+)$/;
|
|
160
|
+
/** Parse `vigiles:mcp server#tool` marks from a markdown file's inline spans. */
|
|
161
|
+
function parseMcpRefs(markdown) {
|
|
162
|
+
const refs = [];
|
|
163
|
+
for (const span of (0, refs_js_1.inlineSpans)(markdown)) {
|
|
164
|
+
const m = MCP_MARK.exec(span.text);
|
|
165
|
+
if (m)
|
|
166
|
+
refs.push({ server: m[1], tool: m[2], line: span.line });
|
|
167
|
+
}
|
|
168
|
+
return refs;
|
|
169
|
+
}
|
|
170
|
+
/** Read `mcpServers` from `.mcp.json` (the stdio-server config map), or `{}`. */
|
|
171
|
+
function loadMcpServers(cwd) {
|
|
172
|
+
const p = (0, node_path_1.join)(cwd, ".mcp.json");
|
|
173
|
+
if (!(0, node_fs_1.existsSync)(p))
|
|
174
|
+
return {};
|
|
175
|
+
try {
|
|
176
|
+
const json = JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
|
|
177
|
+
return json.mcpServers ?? {};
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function verifyOneServer(group, cfg, timeoutMs) {
|
|
184
|
+
if (!cfg) {
|
|
185
|
+
return group.map((r) => ({
|
|
186
|
+
...r,
|
|
187
|
+
reason: "server-undeclared",
|
|
188
|
+
suggestions: [],
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
let available;
|
|
192
|
+
try {
|
|
193
|
+
available = (await listMcpTools(cfg, timeoutMs)).map((t) => t.name);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return group.map((r) => ({
|
|
197
|
+
...r,
|
|
198
|
+
reason: "server-unreachable",
|
|
199
|
+
suggestions: [],
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
const errs = [];
|
|
203
|
+
for (const r of group) {
|
|
204
|
+
if (!available.includes(r.tool)) {
|
|
205
|
+
errs.push({
|
|
206
|
+
...r,
|
|
207
|
+
reason: "tool-missing",
|
|
208
|
+
suggestions: closest(r.tool, available),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return errs;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Verify every `vigiles:mcp server#tool` mark in `markdown` against the live
|
|
216
|
+
* servers in `mcpServers` (each referenced server is started once). A reference
|
|
217
|
+
* to an undeclared server, an unreachable server, or a missing tool is an error.
|
|
218
|
+
*/
|
|
219
|
+
async function verifyMcpRefs(markdown, mcpServers, timeoutMs = 10000) {
|
|
220
|
+
const byServer = new Map();
|
|
221
|
+
for (const r of parseMcpRefs(markdown)) {
|
|
222
|
+
const arr = byServer.get(r.server) ?? [];
|
|
223
|
+
arr.push(r);
|
|
224
|
+
byServer.set(r.server, arr);
|
|
225
|
+
}
|
|
226
|
+
const all = [];
|
|
227
|
+
for (const [server, group] of byServer) {
|
|
228
|
+
all.push(...(await verifyOneServer(group, mcpServers[server], timeoutMs)));
|
|
229
|
+
}
|
|
230
|
+
return all;
|
|
231
|
+
}
|
|
232
|
+
/** Human-readable message for an MCP reference error (with "did you mean"). */
|
|
233
|
+
function mcpRefMessage(e) {
|
|
234
|
+
switch (e.reason) {
|
|
235
|
+
case "server-undeclared":
|
|
236
|
+
return `MCP server "${e.server}" is not declared in .mcp.json`;
|
|
237
|
+
case "server-unreachable":
|
|
238
|
+
return `MCP server "${e.server}" failed to start`;
|
|
239
|
+
case "tool-missing":
|
|
240
|
+
return `MCP tool "${e.server}#${e.tool}" not found${e.suggestions.length > 0
|
|
241
|
+
? ` — did you mean ${e.suggestions.map((s) => `"${s}"`).join(", ")}?`
|
|
242
|
+
: ""}`;
|
|
243
|
+
default:
|
|
244
|
+
return (0, hash_js_1.assertNever)(e.reason);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=mcp.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Compile .spec.ts files to instruction files (CLAUDE.md, AGENTS.md) with linter cross-referencing",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vigiles": "dist/cli.js"
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./harness-assert": "./dist/harness-assert.js",
|
|
18
18
|
"./run-hook": "./dist/run-hook.js",
|
|
19
19
|
"./plugin-loader": "./dist/plugin-loader.js",
|
|
20
|
+
"./mcp": "./dist/mcp.js",
|
|
20
21
|
"./judge": "./dist/judge.js",
|
|
21
22
|
"./mock-model": "./dist/mock-model.js",
|
|
22
23
|
"./vitest": {
|
|
@@ -39,10 +40,11 @@
|
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "tsc",
|
|
42
|
-
"test": "npm run build && node --test dist/spec.test.js dist/validate.test.js dist/cli.test.js dist/proofs.test.js dist/inline.test.js dist/sidecar.test.js dist/coverage.test.js dist/session.test.js dist/orphans.test.js dist/cedar.test.js dist/doc-refs.test.js dist/frontmatter.test.js dist/skill-pipeline.test.js dist/skill-runtime.test.js dist/skill-driver.test.js dist/skill-test.test.js dist/compile-generator.test.js dist/community-skills.test.js dist/action-gate.test.js dist/symbols.test.js dist/refs.test.js dist/harness-test.test.js dist/eval.test.js dist/run-scripts.test.js dist/plugin-loader.test.js dist/harness-assert.test.js dist/judge.test.js dist/run-hook.test.js",
|
|
43
|
+
"test": "npm run build && node --test dist/spec.test.js dist/validate.test.js dist/cli.test.js dist/proofs.test.js dist/inline.test.js dist/sidecar.test.js dist/coverage.test.js dist/session.test.js dist/orphans.test.js dist/cedar.test.js dist/doc-refs.test.js dist/frontmatter.test.js dist/skill-pipeline.test.js dist/skill-runtime.test.js dist/skill-driver.test.js dist/skill-test.test.js dist/compile-generator.test.js dist/community-skills.test.js dist/action-gate.test.js dist/symbols.test.js dist/refs.test.js dist/harness-test.test.js dist/eval.test.js dist/run-scripts.test.js dist/plugin-loader.test.js dist/harness-assert.test.js dist/judge.test.js dist/run-hook.test.js dist/mcp.test.js",
|
|
43
44
|
"lint": "eslint src/",
|
|
44
45
|
"fmt": "prettier --write .",
|
|
45
46
|
"fmt:check": "prettier --check .",
|
|
47
|
+
"demo": "npm run build && bash examples/demo/run.sh",
|
|
46
48
|
"test:e2e": "bash test/e2e/run.sh",
|
|
47
49
|
"test:harness": "npm run build && node dist/cli.js test",
|
|
48
50
|
"test:eval": "npm run build && node dist/cli.js eval",
|