vigiles 5.1.0 → 5.2.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 (50) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +20 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +51 -8
  5. package/dist/adapters/claude-code/dialect.js +19 -0
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/layout.js +3 -0
  13. package/dist/adapters/opencode/adapter.js +1 -0
  14. package/dist/adapters/opencode/layout.js +3 -0
  15. package/dist/check.d.ts +8 -0
  16. package/dist/check.js +27 -3
  17. package/dist/cli.js +323 -88
  18. package/dist/core/adapter.d.ts +10 -0
  19. package/dist/core/bash-effects.d.ts +41 -0
  20. package/dist/core/bash-effects.js +405 -0
  21. package/dist/core/compile.d.ts +3 -1
  22. package/dist/core/compile.js +162 -39
  23. package/dist/core/dialect.d.ts +10 -0
  24. package/dist/core/effects.d.ts +172 -0
  25. package/dist/core/effects.js +245 -0
  26. package/dist/core/layout.d.ts +6 -0
  27. package/dist/core/mcp-tool.d.ts +1 -1
  28. package/dist/core/orphans.js +21 -0
  29. package/dist/core/spec.d.ts +142 -3
  30. package/dist/core/spec.js +48 -0
  31. package/dist/core/tool-contract.d.ts +1 -1
  32. package/dist/core/types.d.ts +6 -6
  33. package/dist/core/validate.js +4 -4
  34. package/dist/harness-test.d.ts +7 -0
  35. package/dist/harness-test.js +19 -7
  36. package/dist/leaderboard.d.ts +2 -0
  37. package/dist/leaderboard.js +2 -0
  38. package/dist/optimize.d.ts +74 -0
  39. package/dist/optimize.js +94 -0
  40. package/dist/scaffold-test.d.ts +30 -0
  41. package/dist/scaffold-test.js +158 -0
  42. package/dist/scan.d.ts +40 -0
  43. package/dist/scan.js +91 -43
  44. package/dist/score-explainer.d.ts +69 -0
  45. package/dist/score-explainer.js +169 -0
  46. package/dist/test-coverage.d.ts +7 -0
  47. package/dist/test-coverage.js +39 -24
  48. package/package.json +2 -1
  49. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -4
  50. package/skills/edit-spec/SKILL.md +1 -1
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyToolEffect = classifyToolEffect;
4
+ exports.effectSurface = effectSurface;
5
+ exports.purityViolations = purityViolations;
6
+ exports.pureContractViolations = pureContractViolations;
7
+ exports.decidePurityGate = decidePurityGate;
8
+ const hash_js_1 = require("./hash.js");
9
+ const bash_effects_js_1 = require("./bash-effects.js");
10
+ // ---------------------------------------------------------------------------
11
+ // Internal helpers
12
+ // ---------------------------------------------------------------------------
13
+ /** Strips a `Tool(restriction)` suffix and returns the base tool name. */
14
+ function baseTool(raw) {
15
+ return raw.split("(")[0].trim();
16
+ }
17
+ /** Returns true for the wildcard sentinels that mean "inherits-all". */
18
+ function isWildcard(tool) {
19
+ return tool === "" || tool === "*";
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Public API
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Classify the effect of ONE tool name against a dialect's known catalogs.
26
+ *
27
+ * A `Tool(restriction)` suffix (e.g. `Bash(git:*)`) is stripped first — the
28
+ * restriction narrows what the tool can DO but doesn't change its effect class
29
+ * (Bash with any restriction is still conservatively side-effecting).
30
+ *
31
+ * Classification rules (in priority order):
32
+ * 1. In `dialect.sideEffectingTools` → `"side-effecting"`
33
+ * 2. In `dialect.builtinAgentTools` (and NOT side-effecting) → `"read-only"`
34
+ * 3. Matches `dialect.mcpToolPattern` → `"unknown"` (MCP tools are not
35
+ * classifiable from the name alone — treated as unknown-effect)
36
+ * 4. Otherwise → `"unknown"` (unrecognized tool; may be a plugin tool or a typo)
37
+ */
38
+ function classifyToolEffect(tool, dialect) {
39
+ const base = baseTool(tool);
40
+ const sideEffecting = dialect.sideEffectingTools ?? [];
41
+ if (sideEffecting.includes(base))
42
+ return "side-effecting";
43
+ if (dialect.builtinAgentTools.includes(base))
44
+ return "read-only";
45
+ if (dialect.mcpToolPattern.test(base))
46
+ return "unknown";
47
+ return "unknown";
48
+ }
49
+ /**
50
+ * Compute the static effect surface of a declared `tools:` contract.
51
+ *
52
+ * `"*"` / `""` (inherits-all) entries make purity `"unrestricted"` because the
53
+ * contract grants access to all tools including every side-effecting one — the
54
+ * full surface is unknowable statically. They are NOT listed in any bucket
55
+ * (they represent a wildcard, not a named tool).
56
+ *
57
+ * De-duplication: a tool name that appears more than once in `tools` is counted
58
+ * once in its bucket (base tool after restriction stripping).
59
+ */
60
+ function effectSurface(tools, dialect) {
61
+ const readOnly = new Set();
62
+ const sideEffecting = new Set();
63
+ const unknown = new Set();
64
+ let hasWildcard = false;
65
+ let hasBash = false;
66
+ for (const raw of tools) {
67
+ const base = baseTool(raw);
68
+ if (isWildcard(base)) {
69
+ hasWildcard = true;
70
+ continue; // wildcards don't go into any named bucket
71
+ }
72
+ const effect = classifyToolEffect(raw, dialect);
73
+ switch (effect) {
74
+ case "read-only":
75
+ readOnly.add(base);
76
+ break;
77
+ case "side-effecting":
78
+ sideEffecting.add(base);
79
+ if (base === "Bash")
80
+ hasBash = true;
81
+ break;
82
+ case "unknown":
83
+ unknown.add(base);
84
+ break;
85
+ default:
86
+ (0, hash_js_1.assertNever)(effect);
87
+ }
88
+ }
89
+ const purity = hasWildcard || hasBash || unknown.size > 0
90
+ ? "unrestricted"
91
+ : sideEffecting.size > 0
92
+ ? "bounded"
93
+ : "pure";
94
+ return {
95
+ readOnly: [...readOnly],
96
+ sideEffecting: [...sideEffecting],
97
+ unknown: [...unknown],
98
+ purity,
99
+ };
100
+ }
101
+ /**
102
+ * Returns the violations of a DECLARED purity floor — the tools that make the
103
+ * actual effect surface LOOSER than the declared level. Empty ⇒ the contract
104
+ * honours the declared level. The `message` on each is actionable (names the
105
+ * tool, the effect class, and what to do).
106
+ *
107
+ * What counts as a violation depends on `declared`:
108
+ * - `"pure"`: every side-effecting tool (incl. `Bash`), every
109
+ * unknown-effect tool, and any wildcard (a pure unit may
110
+ * only observe — no `Bash`, no effects, fully static).
111
+ * - `"bounded"`: only the truly UNBOUNDED tools — unknown-effect (MCP /
112
+ * unrecognized) and wildcards. Every decidable side-effecting
113
+ * tool is ALLOWED: Write/Edit confine to the boundary, and
114
+ * `Bash` is admitted because the RUNTIME gate
115
+ * (`decidePurityGate`) refines it by command (read-only Bash
116
+ * is an observation; a mutating command is denied).
117
+ * - `"unrestricted"`: never a violation (the rung carries no constraint).
118
+ *
119
+ * A wildcard (`"*"` / `""`) contract is a violation at every constrained level:
120
+ * "inherits-all" grants every tool, so neither `pure` nor `bounded` can hold.
121
+ */
122
+ function purityViolations(tools, dialect, declared) {
123
+ if (declared === "unrestricted")
124
+ return []; // no constraint to violate
125
+ const violations = [];
126
+ const seen = new Set();
127
+ for (const raw of tools) {
128
+ const base = baseTool(raw);
129
+ if (isWildcard(base)) {
130
+ const key = base === "" ? '""' : '"*"';
131
+ if (!seen.has(key)) {
132
+ seen.add(key);
133
+ violations.push({
134
+ tool: base,
135
+ effect: "side-effecting",
136
+ message: `${key} (inherits-all) is not allowed in a ${declared} contract — it grants access to every tool, including side-effecting ones. Declare explicit tools instead.`,
137
+ });
138
+ }
139
+ continue;
140
+ }
141
+ if (seen.has(base))
142
+ continue;
143
+ const effect = classifyToolEffect(raw, dialect);
144
+ switch (effect) {
145
+ case "read-only":
146
+ break; // allowed at every level
147
+ case "unknown":
148
+ seen.add(base);
149
+ violations.push({
150
+ tool: base,
151
+ effect,
152
+ message: `"${base}" has unknown effect class (MCP or unrecognized tool); a ${declared} contract cannot declare it — its effects are unbounded from static analysis. Remove it or declare the unit dangerously-unrestricted.`,
153
+ });
154
+ break;
155
+ case "side-effecting":
156
+ // In a BOUNDED unit every decidable side-effecting tool is allowed:
157
+ // Write/Edit confine to the boundary, and `Bash` is admitted because the
158
+ // RUNTIME gate (`decidePurityGate`) refines it by command — a read-only
159
+ // Bash is an observation, a mutating command is denied. Only `pure` bars
160
+ // them (a pure unit may only observe; no `Bash`, no effects).
161
+ if (declared === "bounded")
162
+ break;
163
+ seen.add(base);
164
+ violations.push({
165
+ tool: base,
166
+ effect,
167
+ message: base === "Bash"
168
+ ? `"Bash" is undecidable at the tool-name level; a pure unit cannot declare it. A read-only Bash belongs in a bounded unit (the runtime gate confines it by command) — declare the unit bounded or dangerously-unrestricted.`
169
+ : `"${base}" is side-effecting; a pure unit cannot declare it. Remove it or declare the unit bounded.`,
170
+ });
171
+ break;
172
+ default:
173
+ (0, hash_js_1.assertNever)(effect);
174
+ }
175
+ }
176
+ return violations;
177
+ }
178
+ /**
179
+ * The violations of a `purity: "pure"` contract — every side-effecting,
180
+ * unknown-effect, or wildcard tool. A thin alias for `purityViolations(…,
181
+ * "pure")` kept for the common pure case.
182
+ */
183
+ function pureContractViolations(tools, dialect) {
184
+ return purityViolations(tools, dialect, "pure");
185
+ }
186
+ /**
187
+ * The RUNTIME half of the purity contract: decide whether a single LIVE tool
188
+ * call is allowed under the active unit's declared purity floor.
189
+ *
190
+ * Unlike `purityViolations` (which checks the DECLARED tools contract
191
+ * statically), this sees the ACTUAL call — including the `Bash` command string —
192
+ * so it refines `Bash` by effect via `isReadOnlyBash`. That command is the whole
193
+ * reason the gate's home is the runtime hook: only here is the concrete command
194
+ * visible (the static surface sees a tool name + a `Bash(git:*)` pattern, never
195
+ * the command).
196
+ *
197
+ * Rules (the ladder, command-refined):
198
+ * - `unrestricted` → always allow (no constraint).
199
+ * - read-only tool → allow at every level.
200
+ * - `Bash` → allow iff the command is provably read-only (an observation);
201
+ * otherwise deny — a mutating/undecidable command's effect must move to a
202
+ * marked boundary. Same at `pure` and `bounded`.
203
+ * - other side-effecting tool (Write, Edit, …) → allow under `bounded`
204
+ * (a decidable, boundary-confined effect), deny under `pure` (observe-only).
205
+ * - unknown-effect (MCP / unrecognized) → deny under `pure`/`bounded`
206
+ * (unbounded from static analysis).
207
+ *
208
+ * Dialect injected (core ⊄ adapter). Reuses `classifyToolEffect` +
209
+ * `isReadOnlyBash` — one-detector-no-drift with compile + scan.
210
+ */
211
+ function decidePurityGate(declared, tool, command, dialect) {
212
+ if (declared === "unrestricted")
213
+ return { allow: true, message: "" };
214
+ const base = baseTool(tool);
215
+ const effect = classifyToolEffect(tool, dialect);
216
+ if (effect === "read-only")
217
+ return { allow: true, message: "" };
218
+ if (base === "Bash") {
219
+ if (command !== undefined && (0, bash_effects_js_1.isReadOnlyBash)(command)) {
220
+ return { allow: true, message: "" };
221
+ }
222
+ const shown = command ? `"${command}" ` : "";
223
+ return {
224
+ allow: false,
225
+ message: `Bash command ${shown}is not provably read-only; a ${declared} unit may run only read-only Bash ` +
226
+ `(observation). Move the side effect into a marked boundary, or declare the unit dangerously-unrestricted.`,
227
+ };
228
+ }
229
+ if (effect === "side-effecting") {
230
+ if (declared === "bounded")
231
+ return { allow: true, message: "" };
232
+ return {
233
+ allow: false,
234
+ message: `"${base}" is side-effecting; a pure unit may only observe. ` +
235
+ `Declare the unit bounded (or dangerously-unrestricted) to use it.`,
236
+ };
237
+ }
238
+ // unknown — MCP / unrecognized tool
239
+ return {
240
+ allow: false,
241
+ message: `"${base}" has unknown effect class; a ${declared} unit cannot use it — its effects are unbounded. ` +
242
+ `Declare the unit dangerously-unrestricted to allow it.`,
243
+ };
244
+ }
245
+ //# sourceMappingURL=effects.js.map
@@ -29,6 +29,12 @@ export interface PluginLayout {
29
29
  readonly instructionFile: string;
30
30
  /** Surface dirs materialized into the sandbox, e.g. skills/agents/commands. */
31
31
  readonly surfaceDirs: readonly string[];
32
+ /** Skills dir, holding the nested `<dir>/<name>/SKILL.md`, e.g. `skills`. */
33
+ readonly skillDir: string;
34
+ /** Subagents dir, holding flat `<dir>/<name>.md`, e.g. `agents` (`""` = none). */
35
+ readonly agentDir: string;
36
+ /** Slash-commands dir, holding flat `<dir>/<name>.md`, e.g. `commands`. */
37
+ readonly commandDir: string;
32
38
  /** Dir the surfaces are materialized under, e.g. `.claude`. */
33
39
  readonly materializeRoot: string;
34
40
  /** Env token expanded to the plugin's absolute root in hook commands. */
@@ -4,7 +4,7 @@
4
4
  * names a server `linear`; if the plugin declares its own MCP servers (a
5
5
  * `.mcp.json` / manifest `mcpServers` block) and `linear` isn't among them, the
6
6
  * tool can't resolve — a dead contract entry. This completes the tool moat:
7
- * `agent-tool-contract` (tool-contract.ts) verifies BUILT-IN tools but passes
7
+ * `subagent-tool-contract` (tool-contract.ts) verifies BUILT-IN tools but passes
8
8
  * ANY `mcp__*` token unchecked; this verifies the MCP half.
9
9
  *
10
10
  * Calibrated HIGH-PRECISION — three guards, each learned from a real plugin in
@@ -35,6 +35,25 @@ const DEFAULT_IGNORE = [
35
35
  * that nothing else links to but is not rot.
36
36
  */
37
37
  const DISABLE_RE = /<!--\s*vigiles-disable\s+orphan-docs\s*-->/;
38
+ /**
39
+ * Files the HARNESS loads directly — an instruction file (`CLAUDE.md` /
40
+ * `AGENTS.md`), a skill (`SKILL.md`), a subagent (`agents/*.md`), or a slash
41
+ * command (`commands/*.md`) — are load-bearing by their NAME/LOCATION, not
42
+ * because another `.md` links to them. They are categorically NOT docs, so they
43
+ * are never orphans, even if a project broadens `orphans.include` to scan the
44
+ * whole repo. (They are still scanned as REFERENCERS, so a real doc that only
45
+ * a CLAUDE.md links to is still credited — this exemption only removes them from
46
+ * the orphan-CANDIDATE set.)
47
+ */
48
+ function isHarnessLoadedFile(path) {
49
+ const norm = normalizePath(path);
50
+ const base = norm.slice(norm.lastIndexOf("/") + 1);
51
+ if (base === "CLAUDE.md" || base === "AGENTS.md" || base === "SKILL.md") {
52
+ return true;
53
+ }
54
+ // Subagent / slash-command surfaces the harness enumerates by directory.
55
+ return /(^|\/)(agents|commands)\//.test(norm);
56
+ }
38
57
  // Match markdown links ](path.md) or ](path.md#anchor)
39
58
  const LINK_RE = /\]\(([^)\s]+\.md)(?:#[^)]*)?\)/g;
40
59
  // Match backtick code spans wrapping a path ending in .md
@@ -56,6 +75,8 @@ function collectDocs(basePath, include, ignore) {
56
75
  const docs = new Set();
57
76
  for (const pattern of include) {
58
77
  for (const p of (0, glob_1.globSync)(pattern, { cwd: basePath, ignore: [...ignore] })) {
78
+ if (isHarnessLoadedFile(p))
79
+ continue; // instruction files are never orphans
59
80
  if (isOrphanExempt((0, node_path_1.resolve)(basePath, p)))
60
81
  continue;
61
82
  docs.add(normalizePath(p));
@@ -120,6 +120,12 @@ export type VerifiedCmd = string & {
120
120
  export type VerifiedRef = string & {
121
121
  readonly [__brand]: "VerifiedRef";
122
122
  };
123
+ export type VerifiedDir = string & {
124
+ readonly [__brand]: "VerifiedDir";
125
+ };
126
+ export type VerifiedGlob = string & {
127
+ readonly [__brand]: "VerifiedGlob";
128
+ };
123
129
  /** A typed file reference — verified at compile time. */
124
130
  export interface FileRef {
125
131
  readonly _ref: "file";
@@ -141,7 +147,17 @@ export interface SymbolRef {
141
147
  readonly file: VerifiedPath;
142
148
  readonly symbol: string;
143
149
  }
144
- export type Ref = FileRef | CmdRef | SkillRef | SymbolRef;
150
+ /** A typed directory reference verified to exist AND be a directory. */
151
+ export interface DirRef {
152
+ readonly _ref: "dir";
153
+ readonly path: VerifiedDir;
154
+ }
155
+ /** A typed glob reference — verified to match at least one path. */
156
+ export interface GlobRef {
157
+ readonly _ref: "glob";
158
+ readonly pattern: VerifiedGlob;
159
+ }
160
+ export type Ref = FileRef | CmdRef | SkillRef | SymbolRef | DirRef | GlobRef;
145
161
  /**
146
162
  * Reference a file path — verified to exist at compile time.
147
163
  * When generated types are present, narrowed to known project files.
@@ -164,7 +180,33 @@ export declare function symbol(file: NoInfer<StrictFile>, name: string): SymbolR
164
180
  * Compiles to a markdown link: [skill name](path)
165
181
  */
166
182
  export declare function ref(path: string): SkillRef;
167
- export type InstructionFragment = string | Ref;
183
+ /**
184
+ * Reference a directory — verified at compile time to exist AND be a directory
185
+ * (not a file). The "architecture floats free" fix: a spec that names `src/core/`
186
+ * proves the directory is really there, where a plain string in prose rots
187
+ * silently. Compiles to the inline form `` `path` ``.
188
+ */
189
+ export declare function dir(path: string): DirRef;
190
+ /**
191
+ * Reference a glob pattern — verified at compile time to match at least one path,
192
+ * so `glob("src/*.test.ts")` proves tests actually exist where the instructions
193
+ * claim (the pattern supports the usual `*` / `**` syntax). Compiles to the
194
+ * inline form `` `pattern` ``.
195
+ */
196
+ export declare function glob(pattern: string): GlobRef;
197
+ /**
198
+ * A marked side-effect BOUNDARY inside a skill/agent body — "side effects are
199
+ * allowed ONLY inside this block." Compiles to `<!-- vigiles:effect -->` …
200
+ * `<!-- /vigiles:effect -->` markers the runtime PreToolUse gate keys on: outside
201
+ * the region the unit is treated as read-only (the `"pure"` effective floor),
202
+ * inside it the declared purity floor applies. The position-aware companion to
203
+ * the per-call `purity` floor. See `research/effect-boundary-design.md`.
204
+ */
205
+ export interface EffectRegion {
206
+ readonly _ref: "effect";
207
+ readonly body: InstructionFragment[];
208
+ }
209
+ export type InstructionFragment = string | Ref | EffectRegion;
168
210
  /**
169
211
  * Tagged template literal for skill instructions with typed references.
170
212
  *
@@ -175,6 +217,39 @@ export type InstructionFragment = string | Ref;
175
217
  * `
176
218
  */
177
219
  export declare function instructions(strings: TemplateStringsArray, ...values: InstructionFragment[]): InstructionFragment[];
220
+ /**
221
+ * Tagged template literal marking a side-effect boundary — usable as an
222
+ * interpolated fragment inside a body / `instructions\`\``:
223
+ *
224
+ * instructions`
225
+ * ## Apply
226
+ * ${effect`
227
+ * Side effects are allowed ONLY here:
228
+ * - write ${file("CHANGELOG.md")}
229
+ * - ${cmd("npm publish")}
230
+ * `}
231
+ * `
232
+ *
233
+ * Returns an `EffectRegion` fragment; `compile` wraps its rendered body in
234
+ * `<!-- vigiles:effect -->` markers. Independent of the `doc()` authoring
235
+ * surface — it does not block on it.
236
+ */
237
+ export declare function effect(strings: TemplateStringsArray, ...values: InstructionFragment[]): EffectRegion;
238
+ /**
239
+ * The purity an author DECLARES for a skill/agent — the floor `compile`
240
+ * enforces against the tool contract (see `purityViolations` in
241
+ * `core/effects.ts`). Mirrors the analysis `PurityLevel` for the two meaningful
242
+ * rungs, so what you DECLARE and what `scan` REPORTS share one vocabulary:
243
+ * - `"pure"`: only read-only tools — no side effects at all.
244
+ * - `"bounded"`: decidable side-effecting tools (Write, Edit, …) are allowed,
245
+ * but not `Bash` / unknown-effect / inherits-all (the unbounded cells).
246
+ * - `"dangerously-unrestricted"`: the explicit escape hatch — no enforcement.
247
+ * Deliberately loud (cf. React's `dangerouslySetInnerHTML`) so opting OUT of
248
+ * the guardrail stands out in review. Omitting `purity` is the same
249
+ * (unenforced) default WITHOUT typing the loud word — you write it only when
250
+ * you mean to override a stricter level.
251
+ */
252
+ export type AuthoredPurity = "pure" | "bounded" | "dangerously-unrestricted";
178
253
  /** Known markdown instruction file targets. */
179
254
  export type InstructionTarget = "CLAUDE.md" | "AGENTS.md" | (string & {});
180
255
  export interface ClaudeSpec {
@@ -190,7 +265,12 @@ export interface ClaudeSpec {
190
265
  readonly keyFiles?: Record<string, string>;
191
266
  /** Named prose sections — plain strings or tagged templates with file()/cmd()/ref(). */
192
267
  readonly sections?: Record<string, string | InstructionFragment[]>;
193
- /** Maximum lines per prose section (per-spec override). */
268
+ /**
269
+ * Maximum lines for a single named prose section. Overrides the generous
270
+ * compile-time default (200 lines) that guards every section + agent section
271
+ * against an egregious content dump — set a tighter number to enforce your own
272
+ * house limit, or a larger one for an intentionally long section.
273
+ */
194
274
  readonly maxSectionLines?: number;
195
275
  /**
196
276
  * Maximum estimated tokens for the compiled output (~4 chars per token).
@@ -293,6 +373,31 @@ export interface SkillSpec {
293
373
  readonly inputs?: readonly SkillInput[];
294
374
  /** Whether to disable model invocation (frontmatter flag). */
295
375
  readonly disableModelInvocation?: boolean;
376
+ /**
377
+ * Execution context. `"fork"` runs the skill's body as the task inside a
378
+ * forked SUBAGENT (its own context window) instead of inline in the main
379
+ * conversation (Anthropic's `context: fork` frontmatter). This is the ONLY
380
+ * setting under which a skill gains a real call→return boundary — so it's the
381
+ * prerequisite for declaring an `output` Result contract (see `output`). Omit
382
+ * for the default inline execution.
383
+ */
384
+ readonly context?: "fork";
385
+ /**
386
+ * The allowed-tools contract for this skill. Each entry must be a known
387
+ * built-in tool or an MCP tool (`mcp__server__tool`). Omit to inherit all
388
+ * tools. When `purity` is `"pure"`/`"bounded"`, the declared tools are checked
389
+ * against that floor — compile rejects a tool looser than the declared level.
390
+ */
391
+ readonly tools?: readonly string[];
392
+ /**
393
+ * Declare this skill's purity floor — compile rejects a tool contract looser
394
+ * than it. `"pure"` allows only read-only tools; `"bounded"` also allows
395
+ * decidable side-effecting tools (Write, Edit, …) but bars `Bash` /
396
+ * unknown-effect / inherits-all; `"dangerously-unrestricted"` (or omitting it)
397
+ * enforces nothing. NOTE: `"pure"`/`"bounded"` require an explicit read-only
398
+ * `tools` list — an absent list inherits ALL tools and is a violation.
399
+ */
400
+ readonly purity?: AuthoredPurity;
296
401
  /**
297
402
  * Gated pipeline steps. When set, the skill compiles to a `## Steps`
298
403
  * checklist with a deterministic gate per step. Use this OR `body`.
@@ -303,6 +408,18 @@ export interface SkillSpec {
303
408
  * Compiles to a `## Result` section + a `vigiles:result` marker.
304
409
  */
305
410
  readonly result?: Gate;
411
+ /**
412
+ * The skill's typed railway outcome — the SAME `Result<ok, err>` contract a
413
+ * subagent declares with `result(okShape, errShape)`. Valid ONLY with
414
+ * `context: "fork"`: a forked skill runs as a subagent, so it has the
415
+ * call→return boundary a typed outcome needs (compile errors if `output` is set
416
+ * without `context: "fork"`). When valid, compiles to a `## Output contract`
417
+ * with a `vigiles:ok` / `vigiles:err` block — parseable (`parseAgentResult`) and
418
+ * testable (`assertAgentOk`) via the existing subagent rail. An INLINE skill has
419
+ * no return, so a typed outcome there is a category error — hence the gate. See
420
+ * `research/spec-syntax-and-railway-scope.md`.
421
+ */
422
+ readonly output?: OutputContract;
306
423
  /** Freeform instruction body (linear/unstructured skills). Use this OR `steps`. */
307
424
  readonly body?: string | InstructionFragment[];
308
425
  /**
@@ -338,6 +455,8 @@ export interface AgentSpec {
338
455
  readonly description: string;
339
456
  /** Model alias (e.g. "sonnet", "opus", "haiku", "inherit"). Optional. */
340
457
  readonly model?: string;
458
+ /** Subagent UI colour (Claude Code frontmatter, e.g. "pink", "blue"). Optional. */
459
+ readonly color?: string;
341
460
  /**
342
461
  * The allowed-tools contract — the rails the worker runs on. Each entry must be
343
462
  * a known built-in tool (Read/Write/Edit/Bash/Grep/Glob/WebSearch/WebFetch/
@@ -345,6 +464,17 @@ export interface AgentSpec {
345
464
  * Omit to inherit all tools. Verified at compile time.
346
465
  */
347
466
  readonly tools?: readonly string[];
467
+ /**
468
+ * The DENY-side contract — tools the worker may NOT use. Use this INSTEAD OF
469
+ * `tools`, not with it: `tools` is an allowlist (only these), so a tool not
470
+ * listed is already unavailable and a `disallowedTools` entry would be
471
+ * redundant. `disallowedTools` earns its place only when there's NO allowlist
472
+ * (the agent inherits ALL tools) and you want to subtract a few — e.g.
473
+ * `disallowedTools: ["Bash"]` on an otherwise-unrestricted worker. Rendered to
474
+ * the `disallowedTools:` frontmatter; close-typos are flagged (a typo'd entry
475
+ * blocks nothing). For a read-only floor prefer a tight `tools` list + `purity`.
476
+ */
477
+ readonly disallowedTools?: readonly string[];
348
478
  /**
349
479
  * The lead/intro prose of the system prompt (the "You are…" opener), before any
350
480
  * sections. Carries verified `file()`/`cmd()`/`symbol()`/`ref()` marks. No
@@ -367,6 +497,15 @@ export interface AgentSpec {
367
497
  * and testable (see `result()`, `parseAgentResult`, `assertAgentOk`).
368
498
  */
369
499
  readonly output?: OutputContract;
500
+ /**
501
+ * Declare this agent's purity floor — compile rejects a tool contract looser
502
+ * than it. `"pure"` allows only read-only tools; `"bounded"` also allows
503
+ * decidable side-effecting tools (Write, Edit, …) but bars `Bash` /
504
+ * unknown-effect / inherits-all; `"dangerously-unrestricted"` (or omitting it)
505
+ * enforces nothing. `"pure"`/`"bounded"` require an explicit `tools` list — a
506
+ * wildcard or absent-tools (inherits-all) is always a violation.
507
+ */
508
+ readonly purity?: AuthoredPurity;
370
509
  }
371
510
  /**
372
511
  * Define a subagent specification (compiles to `agents/<name>.md`).
package/dist/core/spec.js CHANGED
@@ -17,7 +17,10 @@ exports.file = file;
17
17
  exports.cmd = cmd;
18
18
  exports.symbol = symbol;
19
19
  exports.ref = ref;
20
+ exports.dir = dir;
21
+ exports.glob = glob;
20
22
  exports.instructions = instructions;
23
+ exports.effect = effect;
21
24
  exports.claude = claude;
22
25
  exports.project = project;
23
26
  exports.input = input;
@@ -105,6 +108,24 @@ function symbol(file, name) {
105
108
  function ref(path) {
106
109
  return { _ref: "skill", path: path };
107
110
  }
111
+ /**
112
+ * Reference a directory — verified at compile time to exist AND be a directory
113
+ * (not a file). The "architecture floats free" fix: a spec that names `src/core/`
114
+ * proves the directory is really there, where a plain string in prose rots
115
+ * silently. Compiles to the inline form `` `path` ``.
116
+ */
117
+ function dir(path) {
118
+ return { _ref: "dir", path: path };
119
+ }
120
+ /**
121
+ * Reference a glob pattern — verified at compile time to match at least one path,
122
+ * so `glob("src/*.test.ts")` proves tests actually exist where the instructions
123
+ * claim (the pattern supports the usual `*` / `**` syntax). Compiles to the
124
+ * inline form `` `pattern` ``.
125
+ */
126
+ function glob(pattern) {
127
+ return { _ref: "glob", pattern: pattern };
128
+ }
108
129
  /**
109
130
  * Tagged template literal for skill instructions with typed references.
110
131
  *
@@ -124,6 +145,33 @@ function instructions(strings, ...values) {
124
145
  }
125
146
  return result;
126
147
  }
148
+ /**
149
+ * Tagged template literal marking a side-effect boundary — usable as an
150
+ * interpolated fragment inside a body / `instructions\`\``:
151
+ *
152
+ * instructions`
153
+ * ## Apply
154
+ * ${effect`
155
+ * Side effects are allowed ONLY here:
156
+ * - write ${file("CHANGELOG.md")}
157
+ * - ${cmd("npm publish")}
158
+ * `}
159
+ * `
160
+ *
161
+ * Returns an `EffectRegion` fragment; `compile` wraps its rendered body in
162
+ * `<!-- vigiles:effect -->` markers. Independent of the `doc()` authoring
163
+ * surface — it does not block on it.
164
+ */
165
+ function effect(strings, ...values) {
166
+ const body = [];
167
+ for (let i = 0; i < strings.length; i++) {
168
+ if (strings[i])
169
+ body.push(strings[i]);
170
+ if (i < values.length)
171
+ body.push(values[i]);
172
+ }
173
+ return { _ref: "effect", body };
174
+ }
127
175
  /**
128
176
  * Define a CLAUDE.md specification.
129
177
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * ONE pure detector (`one-detector-no-drift`), reused by THREE callers so they
9
9
  * can't disagree: `compileAgent` (spec authoring), `scan` (read-only audit of a
10
- * shipped plugin), and the `agent-tool-contract` lint rule (the severity-gated
10
+ * shipped plugin), and the `subagent-tool-contract` lint rule (the severity-gated
11
11
  * commit gate). The dialect is injected (core ⊄ adapter) — the composition root
12
12
  * passes `claudeCodeDialect` / `codexDialect`.
13
13
  *
@@ -71,7 +71,7 @@ export interface OrphansConfig {
71
71
  }
72
72
  /**
73
73
  * Shared options for the per-kind untested-* rules (`untested-skill` /
74
- * `untested-agent` / `untested-hook`). Which kinds are scanned is controlled by
74
+ * `untested-subagent` / `untested-hook`). Which kinds are scanned is controlled by
75
75
  * each rule's severity (set a rule to `false` to skip that kind), so only the
76
76
  * test-discovery knobs live here.
77
77
  */
@@ -97,7 +97,7 @@ export interface RulesConfig {
97
97
  /** Flag a skill (SKILL.md) that ships with no test or eval. Default: "warn". */
98
98
  "untested-skill"?: RuleWithOptions<TestCoverageConfig>;
99
99
  /** Flag a subagent (agents/*.md) that ships with no test or eval. Default: "warn". */
100
- "untested-agent"?: RuleWithOptions<TestCoverageConfig>;
100
+ "untested-subagent"?: RuleWithOptions<TestCoverageConfig>;
101
101
  /** Flag a hook script that ships with no test or eval. Default: "warn". */
102
102
  "untested-hook"?: RuleWithOptions<TestCoverageConfig>;
103
103
  /**
@@ -115,7 +115,7 @@ export interface RulesConfig {
115
115
  * plugin/MCP-provided, never flagged). Off unless set; "warn" surfaces,
116
116
  * "error" gates CI. Same detector as `scan` + `compileAgent`.
117
117
  */
118
- "agent-tool-contract"?: RuleSeverity;
118
+ "subagent-tool-contract"?: RuleSeverity;
119
119
  /**
120
120
  * Flag a hook registered under an event name the harness doesn't define (a
121
121
  * typo → the hook never fires). High-precision: close typos only, never a
@@ -128,7 +128,7 @@ export interface RulesConfig {
128
128
  * `name` (to load), an agent needs `name` + `description`. A broken surface
129
129
  * that won't register. Default "warn"; "error" gates CI. Same detector as `scan`.
130
130
  */
131
- "agent-frontmatter"?: RuleSeverity;
131
+ "subagent-frontmatter"?: RuleSeverity;
132
132
  /**
133
133
  * Flag a declared MCP server that can't start — neither a `command` (stdio)
134
134
  * nor a `url` (http/sse). Default "warn"; "error" gates CI. Same detector as
@@ -146,7 +146,7 @@ export interface RulesConfig {
146
146
  /**
147
147
  * Cross-reference an `mcp__server__tool` in a subagent's contract against the
148
148
  * plugin's declared `mcpServers` — flag a server the plugin doesn't declare
149
- * (the MCP half of the tool moat; `agent-tool-contract` checks the built-in
149
+ * (the MCP half of the tool moat; `subagent-tool-contract` checks the built-in
150
150
  * half). High-precision: only flags when the plugin SHIPS a declared set,
151
151
  * allowlists harness built-ins (`ide`), and skips the plugin-namespaced
152
152
  * `mcp__plugin_…` form. Default "warn"; "error" gates CI. Same detector as
@@ -163,7 +163,7 @@ export interface RulesConfig {
163
163
  "hook-script-exists"?: RuleSeverity;
164
164
  /**
165
165
  * Cross-reference a subagent's `disallowedTools:` block-list against the
166
- * catalog — the deny-side mirror of `agent-tool-contract`. A close typo there
166
+ * catalog — the deny-side mirror of `subagent-tool-contract`. A close typo there
167
167
  * blocks NOTHING (you meant to deny `Bash`, wrote `Bsh`), leaving the tool
168
168
  * available. High-precision: close-typo only (a never-available tool is
169
169
  * harmless to list, a bare unknown is likely a plugin tool). Default "warn";