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 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.). Eliminates copy-pasting the same trigger across `.claude/settings.json`, `.husky/`, and CI configs.
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
- Skill specs use the same helpers for verified references inside instructions. [Full spec format →](docs/spec-format.md)
241
+ There's a small family of inline **marks** that `audit` checks, each binding a reference to its real source:
238
242
 
239
- ### Symbol references (cross-language)
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
- `symbol("file", "name")` (and the markdown mark `` `vigiles:symbol file#name` ``) verify that the named file actually **defines** the symbol — a function, class, method, or constant — parsed with [ast-grep](https://ast-grep.github.io) across **JS/TS, Python, Ruby, Rust, and CSS**. Rename the function and `audit` fails; no project-wide index, no autoloader guessing it parses the one named file.
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
- In markdown mode the `refs-hook` (PostToolUse) **forces the mark**: it blocks an edit that leaves a code reference bare, telling the agent to write `` `vigiles:symbol path#name` `` or opt out with `<!-- vigiles:ignore -->`. The harness makes the agent mark its references at write time, with full context; `audit` re-verifies them. [Symbol verification →](research/symbol-verification.md)
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
- ```bash
262
- npx vigiles init [--target=X.md] # Scaffold a spec (runs full setup wizard by default)
263
- npx vigiles compile [files...] # Compile .spec.ts .md
264
- npx vigiles audit [files...] # Verify hashes + inline/frontmatter/spec rules + symbols + coverage
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
- ## GitHub Action
255
+ ### Level 1 — test a hook by itself (no AI, milliseconds)
275
256
 
276
- ```yaml
277
- - uses: zernie/vigiles@main # runs `audit` by default
278
- - uses: zernie/vigiles@main
279
- with:
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
- To verify generated types are fresh in CI:
262
+ ```typescript
263
+ import { runHook } from "vigiles/run-hook";
284
264
 
285
- ```yaml
286
- - run: npx vigiles generate-types --check
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
- ## Claude Code Plugin
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
- ```bash
294
- npx skills add zernie/vigiles
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
- The plugin provides two hooks:
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
- | Rule | Default | What it checks |
307
- | -------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- |
308
- | [`require-spec`](docs/rules/require-spec.md) | `"warn"` | Every CLAUDE.md/AGENTS.md has a spec, inline rule, or `vigiles:` frontmatter |
309
- | [`require-skill-spec`](docs/rules/require-skill-spec.md) | `"warn"` | Every SKILL.md has a `.spec.ts` |
310
- | [`integrity`](docs/rules/integrity.md) | `"warn"` | Compiled markdown wasn't hand-edited (SHA-256 check) |
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
- Configure in `.vigilesrc.json`:
293
+ ```typescript
294
+ import { runHarnessTest, scriptModel } from "vigiles/harness-test";
314
295
 
315
- ```json
316
- {
317
- "rules": {
318
- "require-spec": "error",
319
- "integrity": "error",
320
- "coverage": ["warn", { "scripts": 50, "linterRules": 5 }]
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
- Disable per-file with `<!-- vigiles-disable require-spec -->` at the top of the markdown.
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
- **Evals does my change actually move agent behaviour?** Define a fixture, a set
348
- of **arms** (a hook on vs off, with/without a CLAUDE.md rule), a task, and a
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
- fixture: { "src/billing.ts": "export function chargeCard() {}" },
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)); // vanilla marked=0.00 gated marked=0.50
329
+ console.log(formatEvalReport(report)); // off marked=0.00 on marked=0.50
367
330
  ```
368
331
 
369
- **Deterministic tests does my hook fire correctly?** No API key, no cost.
370
- `runHarnessTest` runs real `claude` against a **scripted mock model**
371
- (`vigiles/mock-model`), so your real hooks fire but the agent's turns are fixed.
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 { runHarnessTest, scriptModel } from "vigiles/harness-test";
339
+ import { assertSkillResolved, assertToolNotUsed } from "vigiles/harness-assert";
375
340
 
376
341
  const r = await runHarnessTest({
377
- settings: {
378
- hooks: {
379
- Stop: [
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
- { text: "I'm done" }, // tries to stop blocked
393
- { tool: "Bash", input: { command: "touch DONE" } },
394
- { text: "now done" },
346
+ { tool: "Skill", input: { skill: "my-plugin:greet" } },
347
+ { text: "ok" },
395
348
  ]),
396
349
  });
397
- assert(JSON.parse(r.stdout).num_turns > 1); // the Stop hook forced more work
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
- The deterministic tier is reliable for **SessionStart, Stop, UserPromptSubmit,
401
- and Bash PreToolUse/PostToolUse** hooks the governance/policy shapes most real
402
- plugins use; Edit/Write tool-event hooks are headless-gated, so test those at the
403
- unit tier or via the eval tier.
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
- **Unit-test a hook no `claude` at all.** A hook is just a process: `runHook`
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
- ```typescript
411
- import { runHook } from "vigiles/run-hook";
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
- const r = runHook(guardCommand, {
414
- hook_event_name: "PreToolUse",
415
- tool_name: "Bash",
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
- **Run them as a CI command.** `vigiles test` discovers `*.harness.mjs` files
422
- (deterministic, no API key) and `vigiles eval` discovers `*.eval.mjs` files
423
- (real model). Canonical, real-plugin-shaped examples to copy:
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
- - [`examples/harness/policy-gate.harness.mjs`](examples/harness/policy-gate.harness.mjs) a `PreToolUse` Bash policy gate (block `git commit --no-verify`) and a `SessionStart` setup hook, deterministic.
426
- - [`examples/harness/skill-outcome.eval.mjs`](examples/harness/skill-outcome.eval.mjs) — does a skill change the agent's output? (the question you ask of any `SKILL.md`).
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 test examples/harness/policy-gate.harness.mjs
430
- npx vigiles eval --trials=6 examples/harness/skill-outcome.eval.mjs
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
- **Test the whole machine.** Point `plugin` at a plugin (or `"./"` for your repo)
434
- and the real harness — hooks (with `${CLAUDE_PLUGIN_ROOT}` resolved), CLAUDE.md,
435
- skills, subagents and commands — is loaded into the sandbox, so you test what
436
- ships, not a retyped subset. `loadPlugin(...).warnings` flags surfaces only a
437
- real model can drive (subagents, slash commands, MCP), so loading a whole plugin
438
- never silently tests an empty machine. The library is plain async functions, so
439
- it runs in **node:test, vitest, or jest** unchanged (shared `expect.extend`
440
- matchers for the latter two).
441
-
442
- [Full guide `docs/harness-testing.md`](docs/harness-testing.md). The design
443
- rationale and a coverage assessment against real plugins (protect-mcp,
444
- obra/superpowers, block-no-verify, 156 wshobson skills) are in
445
- [`research/harness-testing.md`](research/harness-testing.md); findings from
446
- running this harness in anger live in [`research/benchmarks-runtime-gates.md`](research/benchmarks-runtime-gates.md).
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
- ## Output Targets
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 doesn't try to do everything. It owns one thing: compile-time verification of typed specs against real linter configs, filesystems, and package.json. Everything else, compose:
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
- - **Architectural linting** — [ast-grep](https://ast-grep.github.io/), [Dependency Cruiser](https://github.com/sverweij/dependency-cruiser), [Steiger](https://github.com/feature-sliced/steiger). Reference their rules via `enforce()`.
468
- - **File sync** across agents — [Ruler](https://github.com/intellectronica/ruler), [rulesync](https://github.com/dyoshikawa/rulesync), [block/ai-rules](https://github.com/block/ai-rules). vigiles compiles the source; sync tools distribute.
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 {
@@ -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
  /**
@@ -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;
@@ -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
  /**
@@ -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
- "--output-format",
111
- "json",
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.0",
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",