vigiles 5.1.0 → 6.0.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.
Files changed (57) hide show
  1. package/README.md +59 -18
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +45 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +94 -8
  5. package/dist/adapters/claude-code/dialect.d.ts +34 -0
  6. package/dist/adapters/claude-code/dialect.js +51 -19
  7. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  8. package/dist/adapters/claude-code/effect-region.js +45 -0
  9. package/dist/adapters/claude-code/layout.js +3 -0
  10. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  11. package/dist/adapters/claude-code/skill-runtime.js +40 -0
  12. package/dist/adapters/claude-code/typed-spec.d.ts +58 -0
  13. package/dist/adapters/claude-code/typed-spec.js +55 -0
  14. package/dist/adapters/codex/adapter.js +3 -0
  15. package/dist/adapters/codex/layout.js +3 -0
  16. package/dist/adapters/opencode/adapter.js +1 -0
  17. package/dist/adapters/opencode/layout.js +3 -0
  18. package/dist/check.d.ts +8 -0
  19. package/dist/check.js +27 -3
  20. package/dist/claude-code.d.ts +1 -0
  21. package/dist/claude-code.js +8 -1
  22. package/dist/cli.js +469 -88
  23. package/dist/core/adapter.d.ts +10 -0
  24. package/dist/core/bash-effects.d.ts +41 -0
  25. package/dist/core/bash-effects.js +405 -0
  26. package/dist/core/compile.d.ts +3 -1
  27. package/dist/core/compile.js +176 -39
  28. package/dist/core/dialect.d.ts +10 -0
  29. package/dist/core/effects.d.ts +172 -0
  30. package/dist/core/effects.js +245 -0
  31. package/dist/core/generate-harness.d.ts +187 -0
  32. package/dist/core/generate-harness.js +337 -0
  33. package/dist/core/layout.d.ts +6 -0
  34. package/dist/core/mcp-tool.d.ts +1 -1
  35. package/dist/core/orphans.js +21 -0
  36. package/dist/core/spec.d.ts +432 -11
  37. package/dist/core/spec.js +166 -3
  38. package/dist/core/tool-contract.d.ts +1 -1
  39. package/dist/core/types.d.ts +6 -6
  40. package/dist/core/validate.js +4 -4
  41. package/dist/harness-test.d.ts +7 -0
  42. package/dist/harness-test.js +19 -7
  43. package/dist/leaderboard.d.ts +2 -0
  44. package/dist/leaderboard.js +2 -0
  45. package/dist/optimize.d.ts +74 -0
  46. package/dist/optimize.js +94 -0
  47. package/dist/scaffold-test.d.ts +58 -0
  48. package/dist/scaffold-test.js +263 -0
  49. package/dist/scan.d.ts +40 -0
  50. package/dist/scan.js +91 -43
  51. package/dist/score-explainer.d.ts +69 -0
  52. package/dist/score-explainer.js +169 -0
  53. package/dist/test-coverage.d.ts +7 -0
  54. package/dist/test-coverage.js +39 -24
  55. package/package.json +2 -1
  56. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -4
  57. package/skills/edit-spec/SKILL.md +1 -1
package/README.md CHANGED
@@ -5,11 +5,11 @@
5
5
  <h1 align="center">vigiles</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Lint &amp; test the harness your AI agent runs on.</strong>
8
+ <strong>Lint, test &amp; measure the harness your AI agent runs on.</strong>
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- Your CLAUDE.md, hooks, and skills steer the agent — but nothing checks they're <em>true</em>, and nothing tests they <em>work</em>. vigiles does both.
12
+ Your CLAUDE.md, hooks, and skills steer the agent — but nothing checks they're <em>true</em>, nothing tests they <em>work</em>, and nothing measures whether they actually <em>help</em>. vigiles does all three.
13
13
  </p>
14
14
 
15
15
  <p align="center">
@@ -20,14 +20,17 @@
20
20
 
21
21
  ---
22
22
 
23
- `Agent = Model + Harness`. You'd never ship an app without a linter and a test
24
- suite — yet the harness steering your agent runs on vibes. vigiles[^name] is the
25
- deterministic layer for it, and does two independent things adopt either, or both:
23
+ `Agent = Model + Harness`. You'd never ship an app without a linter, a test suite,
24
+ and a benchmark — yet the harness steering your agent runs on vibes. vigiles[^name]
25
+ is the deterministic layer for it and the only one that can **A/B-test what
26
+ actually moves the needle on your Claude subscription**, not metered API. Three
27
+ things, adopt any:
26
28
 
27
- | | |
28
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
29
- | **🔎 Lint** | Every file path, script, code symbol, and linter rule your CLAUDE.md cites is checked against reality — so a renamed file or a disabled rule can't silently mislead the agent. **[→](docs/verifying-instruction-files.md)** |
30
- | **🧪 Test** | Hooks, skills, and subagents are code. vigiles tests they _do their job_ — and almost all of it is **deterministic, no API key**; the real-model evals run on your **Claude subscription**, not metered tokens. **[→](docs/harness-testing.md)** |
29
+ | | |
30
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | **🔎 Lint** | Every file path, script, code symbol, and linter rule your CLAUDE.md cites is checked against reality — so a renamed file or a disabled rule can't silently mislead the agent. **[→](docs/verifying-instruction-files.md)** |
32
+ | **🧪 Test** | Hooks, skills, and subagents are code. vigiles tests they _do their job_ — and almost all of it is **deterministic, no API key**; the real-model evals run on your **Claude subscription**, not metered tokens. **[→](docs/harness-testing.md)** |
33
+ | **📊 Measure** | Does that skill or plugin actually help — or just add cost? **A/B it on real tasks** and read the bill + correctness, not the vendor's headline. The same engine ranks the hyped ecosystem: **what works vs hype**. **[→](docs/measuring-skills.md)** |
31
34
 
32
35
  Pick the one that hurts today. **Works with Claude Code and Codex**
33
36
  ([`vigiles/codex`](docs/harnesses.md)), and you can
@@ -92,7 +95,16 @@ CLAUDE.md:
92
95
  File paths, scripts, and code symbols — plus linter rules across **7 catalogs**
93
96
  (the rule exists **and is enabled**). Start with one inline comment, no new files;
94
97
  step up to a typed `.spec.ts` (compiled to CLAUDE.md, compiler-grade) when you want
95
- it. **[Full guide →](docs/verifying-instruction-files.md)**
98
+ it and that same spec is what makes your skills & subagents deterministically
99
+ **testable** (a `result()` contract → an assert, no LLM judge — see ② below), not
100
+ just lint-able. **[Full guide →](docs/verifying-instruction-files.md)**
101
+
102
+ > **Markdown is prose; a typed spec is a _program_.** Opt in and an agent that
103
+ > leaks, exceeds its effect floor, or hands off mismatched data is a **type
104
+ > error** — your **multi-agent pipeline won't compile if the handoffs don't line
105
+ > up**. Others lint prose after the fact; vigiles is a **compiler for harnesses**.
106
+ > Graduated like TypeScript's `strict` — markdown still works, type-safety is
107
+ > opt-in. **[The moat →](research/typed-spec-moat.md)**
96
108
 
97
109
  **Same cross-reference, any plugin.** `npx vigiles scan` checks a plugin's
98
110
  _contracts_ — every subagent tool, `mcp__server__tool`, `mcp_tool` hook, hook
@@ -121,25 +133,54 @@ It goes well past _"did it fire?"_:
121
133
 
122
134
  - **Hooks block** what they must — `runHook`, or the real agent CLI via `runHarnessTest`.
123
135
  - **Skills trigger** on the right prompts and stay quiet on the wrong ones — recall _and_ precision (`measureTriggerRate`).
124
- - **Behaviour is good** — score a skill's output directly, or A/B it on-vs-off for the real lift over no-skill (`measure` / `runEval`, with significance testing).
136
+ - **Behaviour is good** — score a skill's output directly, or A/B it on-vs-off for the real lift (`measure` / `runEval`, with significance testing).
125
137
  - **Safety holds** — the agent _didn't_ push to the wrong branch or hit a paid API; `interceptTools` catches the attempt so the side effect never happens.
126
138
 
127
- **The eval you can actually afford.** Almost every tier runs with **no model and
128
- no API key** milliseconds, on every commit. The rest drive your own `claude` CLI:
139
+ Almost every tier runs with **no model and no API key** — milliseconds, on every
140
+ commit. Only the real-model evals need a model, and they run on your own `claude`
141
+ CLI. **[How it works →](docs/harness-testing.md)**
142
+
143
+ ## ③ Measure — does it actually help, or just cost more?
144
+
145
+ A skill claims "65% fewer tokens." A plugin promises "3× faster." Stars and
146
+ vibes — **zero measurement**. vigiles A/Bs the claim on real coding tasks, the
147
+ harness loaded exactly as it ships, and reports the **metric triple**:
148
+
149
+ ```typescript
150
+ import { measureArms } from "vigiles/testing";
151
+
152
+ const r = await measureArms({
153
+ fixture: { "in.txt": "Implement a slug helper." },
154
+ task: "Read in.txt, write slugify() to slug.js, explain. Stop.",
155
+ arms: { baseline: {}, skill: { files: { "SKILL.md": THE_SKILL } } },
156
+ measure: (ctx) => ({ cost: ctx.usage.costUsd, correct: check(ctx) }),
157
+ });
158
+ // → is the bill lower? did correctness survive? (not just "fewer output tokens")
159
+ ```
160
+
161
+ - **The bill (`costUsd`)** — the honest number; it weights cache ~0.1× and output 1×, so a "saved tokens" headline can't hide behind cheap cache.
162
+ - **The optimization target** — whatever the skill claims to move (output tokens, latency, tool calls). Verified on its own terms.
163
+ - **The blast radius** — correctness, a deterministic 1/0. A token win that breaks the code is **not a win**.
164
+
165
+ The kicker: every run is **your own `claude` CLI on your Pro/Max subscription** —
166
+ so you can measure on every change, and the same engine powers the **ecosystem
167
+ benchmark** ("we A/B-tested the most-hyped skills — what works vs hype"). That's
168
+ the moat: continuous, sub-affordable measurement nobody metered-by-the-token can
169
+ match. **[Measure a skill →](docs/measuring-skills.md)** · **[Why it's affordable →](docs/eval-architecture.md)**
170
+
171
+ **The eval you can actually afford.** Most of vigiles needs no model at all; the
172
+ rest drives your subscription:
129
173
 
130
174
  | | Runs on | Cost |
131
175
  | ---------------------- | ----------------------- | ------------------------------------------- |
132
176
  | promptfoo, DeepEval, … | metered API SDK | billed **per token, every run** |
133
177
  | **vigiles** | your Claude Pro/Max sub | **$0 extra** — and most tiers need no model |
134
178
 
135
- That's why you can eval your harness on every change, not just once.
136
- **[How it works →](docs/harness-testing.md)** · **[Why it's affordable →](docs/eval-architecture.md)** · **[Safety model →](docs/safety.md)**
137
-
138
179
  ## More
139
180
 
140
181
  - **[Plugin health leaderboard →](docs/cli.md#scan-dir)** — point `scan` at a marketplace (e.g. `wshobson/agents`) and it ranks every plugin by structural health (0–100, A–F), worst issues first — still **no key**. Add `--trigger` for the model-gated column: do the skills actually fire?
141
- - **[CLI & GitHub Action →](docs/cli.md)** — every command, the Action (inputs / output / versioning), the Claude Code plugin, and the `lint` rules.
142
- - **[Skills →](docs/skills.md)** — consumer skills installed as a Claude Code plugin: `/plugin marketplace add zernie/vigiles` then `/plugin install vigiles@vigiles` (or let `vigiles init` do it). The model-invocable ones (`test-harness`, `strengthen`, `edit-spec`) fire on their own — ask _"test my skills"_, _"strengthen my rules"_, or _"add a rule to CLAUDE.md"_ and the agent reaches for them; `migrate-to-spec` and `linter-docs` are user-invoked.
182
+ - **[CLI & GitHub Action →](docs/cli.md)** — every command, the Action (inputs / output / versioning), and the Claude Code plugin. The full **[lint rules matrix →](docs/verifying-instruction-files.md#the-validation-rules--the-full-matrix)** lives with the linting guide.
183
+ - **[Skills →](docs/skills.md)** — consumer skills installed as a Claude Code plugin: `/plugin marketplace add zernie/vigiles` then `/plugin install vigiles@vigiles` (or let `vigiles init` do it). The model-invocable ones (`test-harness`, `strengthen`, `edit-spec`) fire on their own — ask _"test my skills"_, _"strengthen my rules"_, or _"add a rule to CLAUDE.md"_ and the agent reaches for them; `adopt-spec` and `linter-docs` are user-invoked.
143
184
  - **[Docs index →](docs/README.md)** · **[Research →](research/README.md)** · **[Related tools →](docs/related-tools.md)** (ast-grep, Dependency Cruiser, Ruler, rulesync).
144
185
  - Companion to [Feedback Loop Is All You Need](https://zernie.com/blog/feedback-loop-is-all-you-need).
145
186
 
@@ -23,6 +23,7 @@ exports.claudeCodeAdapter = {
23
23
  referenceVerification: true,
24
24
  harnessTesting: true,
25
25
  shellHooks: true,
26
+ subagents: true,
26
27
  },
27
28
  dialect: dialect_js_1.claudeCodeDialect,
28
29
  layout: layout_js_1.claudeCodeLayout,
@@ -22,6 +22,7 @@
22
22
  * (mirrors `.vigiles/active-skill.json`). The decision logic below is
23
23
  * harness-agnostic and fully testable.
24
24
  */
25
+ import type { PurityLevel } from "../../core/effects.js";
25
26
  /**
26
27
  * Parse an agent's allowed-tools contract from its compiled markdown.
27
28
  *
@@ -41,6 +42,14 @@ export declare function parseAgentTools(markdown: string): string[] | null;
41
42
  * rail (`tools:`) and the `disallowed-tools-contract` scan/lint.
42
43
  */
43
44
  export declare function parseAgentToolList(markdown: string, key: string): string[] | null;
45
+ /**
46
+ * Parse the declared purity floor from a compiled agent's `.md` — the
47
+ * `<!-- vigiles:purity:LEVEL -->` marker `compile` emits (see `purityMarker`).
48
+ * Returns null when no marker is present (the unit declared no floor, so the
49
+ * purity gate imposes no constraint). The single source of truth the runtime
50
+ * gate reads, exactly like `tools:` for the tool-contract rail.
51
+ */
52
+ export declare function parseAgentPurity(markdown: string): PurityLevel | null;
44
53
  export interface PreToolDecision {
45
54
  /** Whether the tool call is allowed (true) or blocked (false). */
46
55
  readonly allow: boolean;
@@ -64,11 +73,41 @@ export declare function clearActiveAgent(cwd: string): void;
64
73
  /** The path of the active agent's compiled `.md`, or null when none is active. */
65
74
  export declare function readActiveAgent(cwd: string): string | null;
66
75
  /**
67
- * PreToolUse-hook decision. If an agent is active, parse its compiled `.md`
68
- * tool contract and allow the call only when the tool is in the allowlist;
69
- * otherwise block and tell the model which tools it may use. With no active
70
- * agent (or an agent that inherits all tools), always allow — the rail only
71
- * constrains agents that declared a contract.
76
+ * EXPERIMENTAL parked (P3), flat-only, do NOT auto-wire. This deterministic
77
+ * subagent-window tracking assumes FLAT dispatch (one active subagent at a time);
78
+ * it is NOT nesting-safe Claude Code v2.1.172 added depth-5 nested subagents, so
79
+ * correct tracking needs a depth-aware STACK (push on dispatch, pop on SubagentStop)
80
+ * + verifying the spawn tool name (`Agent` vs `Task`). See
81
+ * research/effect-boundary-design.md ("Why dropped").
82
+ *
83
+ * Resolve a `Task` tool's `subagent_type` to the compiled agent `.md` to
84
+ * activate, or null when none is found. The DETERMINISTIC open signal that
85
+ * replaces the model-invoked `agent-start`: Claude Code fires `PreToolUse` for
86
+ * the parent's `Task` dispatch (and `SubagentStop` when it returns), so the
87
+ * harness — not the model — brackets the subagent's active window. The name is
88
+ * the last ":"-segment (a `--plugin-dir` subagent_type is namespaced
89
+ * "plugin:name"); searched in `agents/` under the cwd then the plugin root. A
90
+ * path under the cwd is returned relative (readActiveAgent resolves vs cwd); a
91
+ * plugin-root hit is absolute. Returns null on an unknown agent (fail-open: an
92
+ * unresolved subagent is simply not gated, exactly as before agent-start ran).
93
+ */
94
+ export declare function resolveDispatchedAgent(subagentType: string, cwd: string, pluginRoot?: string): string | null;
95
+ /**
96
+ * The agent `.md` to activate for a `PreToolUse(Task)` event, or null. Pure
97
+ * (reads `tool_input.subagent_type`, resolves via {@link resolveDispatchedAgent}).
98
+ */
99
+ export declare function decideTaskDispatch(toolInput: unknown, cwd: string, pluginRoot?: string): string | null;
100
+ /**
101
+ * PreToolUse-hook decision. If an agent is active, enforce BOTH deterministic
102
+ * rails its compiled `.md` declares, in order:
103
+ *
104
+ * 1. the tool-contract rail (`tools:`) — allow only listed tools;
105
+ * 2. the purity gate (`vigiles:purity:`) — allow only calls within the declared
106
+ * effect floor, refining `Bash` by the live `command` (`decidePurityGate`).
107
+ *
108
+ * The first to deny wins, feeding its reason back to the model. With no active
109
+ * agent (or one that declared neither contract), always allow — the rails only
110
+ * constrain agents that opted in.
72
111
  */
73
- export declare function evaluatePreToolUse(cwd: string, tool: string): PreToolDecision;
112
+ export declare function evaluatePreToolUse(cwd: string, tool: string, command?: string): PreToolDecision;
74
113
  //# sourceMappingURL=agent-runtime.d.ts.map
@@ -26,14 +26,20 @@
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.parseAgentTools = parseAgentTools;
28
28
  exports.parseAgentToolList = parseAgentToolList;
29
+ exports.parseAgentPurity = parseAgentPurity;
29
30
  exports.decidePreToolUse = decidePreToolUse;
30
31
  exports.setActiveAgent = setActiveAgent;
31
32
  exports.clearActiveAgent = clearActiveAgent;
32
33
  exports.readActiveAgent = readActiveAgent;
34
+ exports.resolveDispatchedAgent = resolveDispatchedAgent;
35
+ exports.decideTaskDispatch = decideTaskDispatch;
33
36
  exports.evaluatePreToolUse = evaluatePreToolUse;
34
37
  const node_fs_1 = require("node:fs");
35
38
  const node_path_1 = require("node:path");
36
39
  const frontmatter_read_js_1 = require("../../core/frontmatter-read.js");
40
+ const effects_js_1 = require("../../core/effects.js");
41
+ const dialect_js_1 = require("./dialect.js");
42
+ const effect_region_js_1 = require("./effect-region.js");
37
43
  // ---------------------------------------------------------------------------
38
44
  // Parse the tool contract from a compiled agent .md
39
45
  // ---------------------------------------------------------------------------
@@ -60,6 +66,18 @@ function parseAgentTools(markdown) {
60
66
  function parseAgentToolList(markdown, key) {
61
67
  return (0, frontmatter_read_js_1.frontmatterList)((0, frontmatter_read_js_1.readFrontmatter)(markdown), key);
62
68
  }
69
+ const PURITY_RE = /<!--\s*vigiles:purity:(pure|bounded|unrestricted)\s*-->/;
70
+ /**
71
+ * Parse the declared purity floor from a compiled agent's `.md` — the
72
+ * `<!-- vigiles:purity:LEVEL -->` marker `compile` emits (see `purityMarker`).
73
+ * Returns null when no marker is present (the unit declared no floor, so the
74
+ * purity gate imposes no constraint). The single source of truth the runtime
75
+ * gate reads, exactly like `tools:` for the tool-contract rail.
76
+ */
77
+ function parseAgentPurity(markdown) {
78
+ const m = PURITY_RE.exec(markdown);
79
+ return m ? m[1] : null;
80
+ }
63
81
  /**
64
82
  * Decide whether `tool` is allowed under an agent's tool contract. Pure, so the
65
83
  * rail is unit-testable without spawning anything.
@@ -114,20 +132,88 @@ function readActiveAgent(cwd) {
114
132
  // PreToolUse-hook decision
115
133
  // ---------------------------------------------------------------------------
116
134
  /**
117
- * PreToolUse-hook decision. If an agent is active, parse its compiled `.md`
118
- * tool contract and allow the call only when the tool is in the allowlist;
119
- * otherwise block and tell the model which tools it may use. With no active
120
- * agent (or an agent that inherits all tools), always allow — the rail only
121
- * constrains agents that declared a contract.
135
+ * EXPERIMENTAL parked (P3), flat-only, do NOT auto-wire. This deterministic
136
+ * subagent-window tracking assumes FLAT dispatch (one active subagent at a time);
137
+ * it is NOT nesting-safe Claude Code v2.1.172 added depth-5 nested subagents, so
138
+ * correct tracking needs a depth-aware STACK (push on dispatch, pop on SubagentStop)
139
+ * + verifying the spawn tool name (`Agent` vs `Task`). See
140
+ * research/effect-boundary-design.md ("Why dropped").
141
+ *
142
+ * Resolve a `Task` tool's `subagent_type` to the compiled agent `.md` to
143
+ * activate, or null when none is found. The DETERMINISTIC open signal that
144
+ * replaces the model-invoked `agent-start`: Claude Code fires `PreToolUse` for
145
+ * the parent's `Task` dispatch (and `SubagentStop` when it returns), so the
146
+ * harness — not the model — brackets the subagent's active window. The name is
147
+ * the last ":"-segment (a `--plugin-dir` subagent_type is namespaced
148
+ * "plugin:name"); searched in `agents/` under the cwd then the plugin root. A
149
+ * path under the cwd is returned relative (readActiveAgent resolves vs cwd); a
150
+ * plugin-root hit is absolute. Returns null on an unknown agent (fail-open: an
151
+ * unresolved subagent is simply not gated, exactly as before agent-start ran).
152
+ */
153
+ function resolveDispatchedAgent(subagentType, cwd, pluginRoot) {
154
+ const name = subagentType.split(":").pop()?.trim();
155
+ if (!name)
156
+ return null;
157
+ const rel = (0, node_path_1.join)("agents", `${name}.md`);
158
+ if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(cwd, rel)))
159
+ return rel;
160
+ if (pluginRoot && (0, node_fs_1.existsSync)((0, node_path_1.resolve)(pluginRoot, rel))) {
161
+ return (0, node_path_1.resolve)(pluginRoot, rel);
162
+ }
163
+ return null;
164
+ }
165
+ /**
166
+ * The agent `.md` to activate for a `PreToolUse(Task)` event, or null. Pure
167
+ * (reads `tool_input.subagent_type`, resolves via {@link resolveDispatchedAgent}).
122
168
  */
123
- function evaluatePreToolUse(cwd, tool) {
169
+ function decideTaskDispatch(toolInput, cwd, pluginRoot) {
170
+ const st = toolInput?.subagent_type;
171
+ if (typeof st !== "string" || !st)
172
+ return null;
173
+ return resolveDispatchedAgent(st, cwd, pluginRoot);
174
+ }
175
+ /**
176
+ * PreToolUse-hook decision. If an agent is active, enforce BOTH deterministic
177
+ * rails its compiled `.md` declares, in order:
178
+ *
179
+ * 1. the tool-contract rail (`tools:`) — allow only listed tools;
180
+ * 2. the purity gate (`vigiles:purity:`) — allow only calls within the declared
181
+ * effect floor, refining `Bash` by the live `command` (`decidePurityGate`).
182
+ *
183
+ * The first to deny wins, feeding its reason back to the model. With no active
184
+ * agent (or one that declared neither contract), always allow — the rails only
185
+ * constrain agents that opted in.
186
+ */
187
+ function evaluatePreToolUse(cwd, tool, command) {
124
188
  const agentPath = readActiveAgent(cwd);
125
189
  if (!agentPath)
126
190
  return { allow: true, message: "" };
127
191
  const full = (0, node_path_1.resolve)(cwd, agentPath);
128
192
  if (!(0, node_fs_1.existsSync)(full))
129
193
  return { allow: true, message: "" };
130
- const allowed = parseAgentTools((0, node_fs_1.readFileSync)(full, "utf-8"));
131
- return decidePreToolUse(allowed, tool);
194
+ const md = (0, node_fs_1.readFileSync)(full, "utf-8");
195
+ // 1) Tool-contract rail — the declared allowlist.
196
+ const rail = decidePreToolUse(parseAgentTools(md), tool);
197
+ if (!rail.allow)
198
+ return rail;
199
+ // 2) Purity gate — the declared effect floor, refined by the live command.
200
+ // If an effect boundary is declared, tighten to "pure" outside it and apply
201
+ // the declared purity (or "unrestricted") inside.
202
+ const purity = parseAgentPurity(md);
203
+ const boundary = (0, effect_region_js_1.hasEffectBoundary)(md);
204
+ if (boundary) {
205
+ const effective = (0, effect_region_js_1.readEffectActive)(cwd)
206
+ ? (purity ?? "unrestricted")
207
+ : "pure";
208
+ const gate = (0, effects_js_1.decidePurityGate)(effective, tool, command, dialect_js_1.claudeCodeDialect);
209
+ if (!gate.allow)
210
+ return gate;
211
+ }
212
+ else if (purity) {
213
+ const gate = (0, effects_js_1.decidePurityGate)(purity, tool, command, dialect_js_1.claudeCodeDialect);
214
+ if (!gate.allow)
215
+ return gate;
216
+ }
217
+ return { allow: true, message: "" };
132
218
  }
133
219
  //# sourceMappingURL=agent-runtime.js.map
@@ -8,6 +8,40 @@
8
8
  * in its adapter (e.g. `src/adapters/codex/dialect.ts` exporting `codexDialect`).
9
9
  */
10
10
  import type { HarnessDialect } from "../../core/dialect.js";
11
+ /**
12
+ * The Claude Code built-in subagent tool catalog as a `const` tuple, so a typed
13
+ * authoring surface can derive a LITERAL union (`ClaudeCodeBuiltinTool`) from it.
14
+ * `claudeCodeDialect.builtinAgentTools` references this same array — one source
15
+ * of truth for the runtime catalog AND the compile-time tool vocabulary.
16
+ */
17
+ export declare const claudeCodeBuiltinAgentTools: readonly ["Read", "Write", "Edit", "MultiEdit", "Bash", "BashOutput", "KillBash", "Grep", "Glob", "LS", "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", "Task", "Skill"];
18
+ /**
19
+ * The Claude Code side-effecting tools as a `const` tuple (the complement of
20
+ * read-only within `builtinAgentTools`). `claudeCodeDialect.sideEffectingTools`
21
+ * references this; the typed vocabulary derives the read-only / bounded splits.
22
+ */
23
+ export declare const claudeCodeSideEffectingTools: readonly ["Bash", "BashOutput", "KillBash", "Edit", "MultiEdit", "Write", "NotebookEdit", "WebFetch", "WebSearch", "Skill", "Task", "TodoWrite"];
11
24
  export declare const claudeCodeDialect: HarnessDialect;
25
+ /** Every Claude Code built-in subagent tool (literal union). */
26
+ export type ClaudeCodeBuiltinTool = (typeof claudeCodeBuiltinAgentTools)[number];
27
+ /** The side-effecting subset (literal union). */
28
+ export type ClaudeCodeSideEffectingTool = (typeof claudeCodeSideEffectingTools)[number];
29
+ /**
30
+ * Read-only built-in tools — the complement of the side-effecting set within
31
+ * the built-in catalog. The tools a `pure` CC unit may declare.
32
+ */
33
+ export type ClaudeCodeReadOnlyTool = Exclude<ClaudeCodeBuiltinTool, ClaudeCodeSideEffectingTool>;
34
+ /**
35
+ * Tools a `bounded` CC unit may declare: read-only ∪ the decidable
36
+ * side-effecting tools (Write/Edit/MultiEdit/NotebookEdit) ∪ `Bash` (its
37
+ * command is decided at RUNTIME by the gate). Bars MCP / unknown / wildcard —
38
+ * those are simply not in the built-in union, so listing one is a `tsc` error.
39
+ *
40
+ * NOTE: `BashOutput`/`KillBash` are read-only-ish helpers tied to a running
41
+ * Bash; `Bash` is the admitting tool, so they're included via the read-only
42
+ * exclusion path only if read-only — here they stay side-effecting, hence the
43
+ * explicit add of the bounded-decidable set plus `Bash`.
44
+ */
45
+ export type ClaudeCodeBoundedTool = ClaudeCodeReadOnlyTool | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" | "Bash";
12
46
  export type { HarnessDialect } from "../../core/dialect.js";
13
47
  //# sourceMappingURL=dialect.d.ts.map
@@ -1,28 +1,54 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.claudeCodeDialect = void 0;
3
+ exports.claudeCodeDialect = exports.claudeCodeSideEffectingTools = exports.claudeCodeBuiltinAgentTools = void 0;
4
+ /**
5
+ * The Claude Code built-in subagent tool catalog as a `const` tuple, so a typed
6
+ * authoring surface can derive a LITERAL union (`ClaudeCodeBuiltinTool`) from it.
7
+ * `claudeCodeDialect.builtinAgentTools` references this same array — one source
8
+ * of truth for the runtime catalog AND the compile-time tool vocabulary.
9
+ */
10
+ exports.claudeCodeBuiltinAgentTools = [
11
+ "Read",
12
+ "Write",
13
+ "Edit",
14
+ "MultiEdit",
15
+ "Bash",
16
+ "BashOutput",
17
+ "KillBash",
18
+ "Grep",
19
+ "Glob",
20
+ "LS",
21
+ "WebSearch",
22
+ "WebFetch",
23
+ "NotebookEdit",
24
+ "TodoWrite",
25
+ "Task",
26
+ "Skill",
27
+ ];
28
+ /**
29
+ * The Claude Code side-effecting tools as a `const` tuple (the complement of
30
+ * read-only within `builtinAgentTools`). `claudeCodeDialect.sideEffectingTools`
31
+ * references this; the typed vocabulary derives the read-only / bounded splits.
32
+ */
33
+ exports.claudeCodeSideEffectingTools = [
34
+ "Bash",
35
+ "BashOutput",
36
+ "KillBash",
37
+ "Edit",
38
+ "MultiEdit",
39
+ "Write",
40
+ "NotebookEdit",
41
+ "WebFetch",
42
+ "WebSearch",
43
+ "Skill",
44
+ "Task",
45
+ "TodoWrite",
46
+ ];
4
47
  exports.claudeCodeDialect = {
5
48
  name: "claude-code",
6
49
  // The tool contract a subagent may declare — the rails it runs on. Anything
7
50
  // else must be an MCP tool, else it's a typo / nonexistent tool.
8
- builtinAgentTools: [
9
- "Read",
10
- "Write",
11
- "Edit",
12
- "MultiEdit",
13
- "Bash",
14
- "BashOutput",
15
- "KillBash",
16
- "Grep",
17
- "Glob",
18
- "LS",
19
- "WebSearch",
20
- "WebFetch",
21
- "NotebookEdit",
22
- "TodoWrite",
23
- "Task",
24
- "Skill",
25
- ],
51
+ builtinAgentTools: exports.claudeCodeBuiltinAgentTools,
26
52
  // Tools the platform never exposes to a subagent, whatever the list says — so
27
53
  // a subagent listing one is a guaranteed-dead reference only a compiler catches.
28
54
  neverAvailableTools: [
@@ -63,5 +89,11 @@ exports.claudeCodeDialect = {
63
89
  // Claude Code reads the full SKILL.md frontmatter set (description,
64
90
  // disable-model-invocation, argument-hint, …).
65
91
  skillFrontmatter: "claude-code",
92
+ // Tools that produce side effects in Claude Code. The complement — the
93
+ // read-only tools — are: Read, Grep, Glob, LS, ToolSearch (and LSP/Agent
94
+ // which are not in the subagent catalog). Bash is side-effecting because
95
+ // `cat` and `rm -rf` are the same tool at the tool-name level — the
96
+ // sandbox is the only closure for subprocess effects.
97
+ sideEffectingTools: exports.claudeCodeSideEffectingTools,
66
98
  };
67
99
  //# sourceMappingURL=dialect.js.map
@@ -0,0 +1,9 @@
1
+ /** Record that the agent has entered an effect boundary. */
2
+ export declare function setEffectActive(cwd: string): void;
3
+ /** Clear the effect-active marker (the agent exited the effect boundary). */
4
+ export declare function clearEffectActive(cwd: string): void;
5
+ /** True iff the agent is currently inside an effect boundary. Tolerates malformed file. */
6
+ export declare function readEffectActive(cwd: string): boolean;
7
+ /** True iff the compiled markdown declares an effect boundary (`<!-- vigiles:effect -->`). */
8
+ export declare function hasEffectBoundary(markdown: string): boolean;
9
+ //# sourceMappingURL=effect-region.d.ts.map
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setEffectActive = setEffectActive;
4
+ exports.clearEffectActive = clearEffectActive;
5
+ exports.readEffectActive = readEffectActive;
6
+ exports.hasEffectBoundary = hasEffectBoundary;
7
+ /**
8
+ * Effect-boundary position-aware state tracking.
9
+ * Mirrors active-unit tracking (.vigiles/active-agent.json / .vigiles/active-skill.json).
10
+ * The PreToolUse hook reads this file to decide whether the agent is inside an
11
+ * effect boundary (set by `vigiles effect-enter`) or outside it.
12
+ */
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = require("node:path");
15
+ const EFFECT_ACTIVE_PATH = ".vigiles/effect-active.json";
16
+ /** Record that the agent has entered an effect boundary. */
17
+ function setEffectActive(cwd) {
18
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
19
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
20
+ (0, node_fs_1.writeFileSync)(p, JSON.stringify({ active: true }) + "\n");
21
+ }
22
+ /** Clear the effect-active marker (the agent exited the effect boundary). */
23
+ function clearEffectActive(cwd) {
24
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
25
+ if ((0, node_fs_1.existsSync)(p))
26
+ (0, node_fs_1.rmSync)(p);
27
+ }
28
+ /** True iff the agent is currently inside an effect boundary. Tolerates malformed file. */
29
+ function readEffectActive(cwd) {
30
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
31
+ if (!(0, node_fs_1.existsSync)(p))
32
+ return false;
33
+ try {
34
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
35
+ return parsed.active === true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** True iff the compiled markdown declares an effect boundary (`<!-- vigiles:effect -->`). */
42
+ function hasEffectBoundary(markdown) {
43
+ return markdown.includes("<!-- vigiles:effect -->");
44
+ }
45
+ //# sourceMappingURL=effect-region.js.map
@@ -9,6 +9,9 @@ exports.claudeCodeLayout = {
9
9
  settingsFormat: "json",
10
10
  instructionFile: "CLAUDE.md",
11
11
  surfaceDirs: ["skills", "agents", "commands"],
12
+ skillDir: "skills",
13
+ agentDir: "agents",
14
+ commandDir: "commands",
12
15
  materializeRoot: ".claude",
13
16
  pluginRootToken: "${CLAUDE_PLUGIN_ROOT}",
14
17
  mcpConfigFile: ".mcp.json",
@@ -20,6 +20,7 @@
20
20
  * (`vigiles run-skill`). It is not a silent hook and runs nothing the spec
21
21
  * didn't declare as a gate.
22
22
  */
23
+ import type { PurityLevel } from "../../core/effects.js";
23
24
  export type RuntimeGate = {
24
25
  readonly kind: "cmd";
25
26
  readonly command: string;
@@ -86,6 +87,30 @@ export declare function setActiveSkill(cwd: string, skillPath: string): void;
86
87
  export declare function clearActiveSkill(cwd: string): void;
87
88
  /** The path of the active skill, or null when none is in progress. */
88
89
  export declare function readActiveSkill(cwd: string): string | null;
90
+ /**
91
+ * Parse the declared purity floor from a compiled SKILL.md — the
92
+ * `<!-- vigiles:purity:LEVEL -->` marker `compileSkill` emits (see
93
+ * `purityMarker` in compile.ts). Returns null when no marker is present (the
94
+ * skill declared no floor, so the purity gate imposes no constraint). Mirrors
95
+ * `parseAgentPurity` in agent-runtime.ts.
96
+ */
97
+ export declare function parseSkillPurity(markdown: string): PurityLevel | null;
98
+ /** A runtime allow/deny decision (mirrors PreToolDecision in agent-runtime.ts). */
99
+ export interface SkillPreToolDecision {
100
+ /** Whether the tool call is allowed (true) or blocked (false). */
101
+ readonly allow: boolean;
102
+ /** Message fed back to the model on a block; empty on allow. */
103
+ readonly message: string;
104
+ }
105
+ /**
106
+ * PreToolUse purity gate for skills. Mirrors `evaluatePreToolUse` in
107
+ * agent-runtime.ts, but enforces ONLY the purity floor — skills have no
108
+ * tools-allowlist rail (that's a separate, future concern). If a skill is
109
+ * active and declares a `vigiles:purity:` marker, `decidePurityGate` checks
110
+ * the live call (refining `Bash` by the concrete command via `isReadOnlyBash`).
111
+ * With no active skill, a missing `.md`, or no purity marker, always allows.
112
+ */
113
+ export declare function evaluateSkillPreToolUse(cwd: string, tool: string, command?: string): SkillPreToolDecision;
89
114
  export interface StopDecision {
90
115
  /** Whether the agent may stop (true) or must keep working (false). */
91
116
  readonly allow: boolean;
@@ -30,10 +30,14 @@ exports.runSkillGates = runSkillGates;
30
30
  exports.setActiveSkill = setActiveSkill;
31
31
  exports.clearActiveSkill = clearActiveSkill;
32
32
  exports.readActiveSkill = readActiveSkill;
33
+ exports.parseSkillPurity = parseSkillPurity;
34
+ exports.evaluateSkillPreToolUse = evaluateSkillPreToolUse;
33
35
  exports.evaluateStopHook = evaluateStopHook;
34
36
  const node_child_process_1 = require("node:child_process");
35
37
  const node_fs_1 = require("node:fs");
36
38
  const node_path_1 = require("node:path");
39
+ const effects_js_1 = require("../../core/effects.js");
40
+ const dialect_js_1 = require("./dialect.js");
37
41
  const STEP_RE = /^###\s+Step\s+(\d+)/;
38
42
  const GATE_CMD_RE = /<!--\s*vigiles:gate\s+"([^"]*)"(?:\s+retry:(\d+))?\s*-->/;
39
43
  const GATE_FILE_RE = /<!--\s*vigiles:gate\s+file:(\S+)\s*-->/;
@@ -257,6 +261,42 @@ function readActiveSkill(cwd) {
257
261
  return null;
258
262
  }
259
263
  }
264
+ // ---------------------------------------------------------------------------
265
+ // Skill purity gate (mirrors agent-runtime.ts parseAgentPurity / evaluatePreToolUse)
266
+ // ---------------------------------------------------------------------------
267
+ const PURITY_RE = /<!--\s*vigiles:purity:(pure|bounded|unrestricted)\s*-->/;
268
+ /**
269
+ * Parse the declared purity floor from a compiled SKILL.md — the
270
+ * `<!-- vigiles:purity:LEVEL -->` marker `compileSkill` emits (see
271
+ * `purityMarker` in compile.ts). Returns null when no marker is present (the
272
+ * skill declared no floor, so the purity gate imposes no constraint). Mirrors
273
+ * `parseAgentPurity` in agent-runtime.ts.
274
+ */
275
+ function parseSkillPurity(markdown) {
276
+ const m = PURITY_RE.exec(markdown);
277
+ return m ? m[1] : null;
278
+ }
279
+ /**
280
+ * PreToolUse purity gate for skills. Mirrors `evaluatePreToolUse` in
281
+ * agent-runtime.ts, but enforces ONLY the purity floor — skills have no
282
+ * tools-allowlist rail (that's a separate, future concern). If a skill is
283
+ * active and declares a `vigiles:purity:` marker, `decidePurityGate` checks
284
+ * the live call (refining `Bash` by the concrete command via `isReadOnlyBash`).
285
+ * With no active skill, a missing `.md`, or no purity marker, always allows.
286
+ */
287
+ function evaluateSkillPreToolUse(cwd, tool, command) {
288
+ const skillPath = readActiveSkill(cwd);
289
+ if (!skillPath)
290
+ return { allow: true, message: "" };
291
+ const full = (0, node_path_1.resolve)(cwd, skillPath);
292
+ if (!(0, node_fs_1.existsSync)(full))
293
+ return { allow: true, message: "" };
294
+ const md = (0, node_fs_1.readFileSync)(full, "utf-8");
295
+ const purity = parseSkillPurity(md);
296
+ if (!purity)
297
+ return { allow: true, message: "" };
298
+ return (0, effects_js_1.decidePurityGate)(purity, tool, command, dialect_js_1.claudeCodeDialect);
299
+ }
260
300
  /**
261
301
  * Stop-hook decision. If a skill is active and declares a result gate, run it:
262
302
  * allow the stop only when the gate passes; otherwise block and tell the model