vigiles 5.0.1 → 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 (72) hide show
  1. package/README.md +15 -9
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +66 -37
  5. package/dist/adapters/claude-code/dialect.js +37 -2
  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/eval.d.ts +94 -0
  13. package/dist/adapters/codex/eval.js +227 -0
  14. package/dist/adapters/codex/layout.js +3 -0
  15. package/dist/adapters/opencode/adapter.js +1 -0
  16. package/dist/adapters/opencode/layout.js +3 -0
  17. package/dist/check.d.ts +8 -0
  18. package/dist/check.js +27 -3
  19. package/dist/cli.js +712 -21
  20. package/dist/codex.d.ts +1 -0
  21. package/dist/codex.js +3 -0
  22. package/dist/core/adapter.d.ts +10 -0
  23. package/dist/core/bash-effects.d.ts +41 -0
  24. package/dist/core/bash-effects.js +405 -0
  25. package/dist/core/compile.d.ts +3 -1
  26. package/dist/core/compile.js +169 -74
  27. package/dist/core/description-overlap.d.ts +27 -0
  28. package/dist/core/description-overlap.js +53 -0
  29. package/dist/core/dialect.d.ts +18 -0
  30. package/dist/core/effects.d.ts +172 -0
  31. package/dist/core/effects.js +245 -0
  32. package/dist/core/frontmatter-read.d.ts +25 -0
  33. package/dist/core/frontmatter-read.js +138 -0
  34. package/dist/core/hook-events.d.ts +34 -0
  35. package/dist/core/hook-events.js +48 -0
  36. package/dist/core/layout.d.ts +6 -0
  37. package/dist/core/mcp-config.d.ts +20 -0
  38. package/dist/core/mcp-config.js +40 -0
  39. package/dist/core/mcp-hook.d.ts +35 -0
  40. package/dist/core/mcp-hook.js +70 -0
  41. package/dist/core/mcp-tool.d.ts +50 -0
  42. package/dist/core/mcp-tool.js +61 -0
  43. package/dist/core/orphans.js +21 -0
  44. package/dist/core/spec.d.ts +142 -3
  45. package/dist/core/spec.js +48 -0
  46. package/dist/core/tool-contract.d.ts +68 -0
  47. package/dist/core/tool-contract.js +113 -0
  48. package/dist/core/types.d.ts +91 -2
  49. package/dist/core/validate.js +23 -1
  50. package/dist/eval.d.ts +69 -13
  51. package/dist/eval.js +106 -51
  52. package/dist/harness-test.d.ts +7 -0
  53. package/dist/harness-test.js +19 -7
  54. package/dist/leaderboard.d.ts +2 -0
  55. package/dist/leaderboard.js +63 -3
  56. package/dist/optimize.d.ts +74 -0
  57. package/dist/optimize.js +94 -0
  58. package/dist/plugin-loader.d.ts +1 -0
  59. package/dist/plugin-loader.js +71 -18
  60. package/dist/scaffold-test.d.ts +30 -0
  61. package/dist/scaffold-test.js +158 -0
  62. package/dist/scan-behavioral.d.ts +73 -0
  63. package/dist/scan-behavioral.js +150 -0
  64. package/dist/scan.d.ts +166 -1
  65. package/dist/scan.js +622 -55
  66. package/dist/score-explainer.d.ts +69 -0
  67. package/dist/score-explainer.js +169 -0
  68. package/dist/test-coverage.d.ts +7 -0
  69. package/dist/test-coverage.js +39 -24
  70. package/package.json +2 -1
  71. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
  72. package/skills/edit-spec/SKILL.md +1 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MCP-tool resolution — the cross-referencing moat ("valid is not true") applied
3
+ * to an MCP tool reference's SERVER. A contract that lists `mcp__linear__search`
4
+ * names a server `linear`; if the plugin declares its own MCP servers (a
5
+ * `.mcp.json` / manifest `mcpServers` block) and `linear` isn't among them, the
6
+ * tool can't resolve — a dead contract entry. This completes the tool moat:
7
+ * `subagent-tool-contract` (tool-contract.ts) verifies BUILT-IN tools but passes
8
+ * ANY `mcp__*` token unchecked; this verifies the MCP half.
9
+ *
10
+ * Calibrated HIGH-PRECISION — three guards, each learned from a real plugin in
11
+ * the mid-2026 sweep (research/plugin-structural-findings.md):
12
+ *
13
+ * 1. GATE on a declared set. Only flag when the plugin SHIPS a `mcpServers`
14
+ * declaration (a non-empty `declaredServers`). A plugin that declares no
15
+ * servers reaches user-global / project-level ones (the normal pattern —
16
+ * ananddtyagi's agents reference `mcp__ide__*` with no `.mcp.json`), so
17
+ * flagging there would cry wolf. No declared set → return nothing.
18
+ * 2. ALLOWLIST built-ins. A harness-provided server (`dialect.knownMcpServers`,
19
+ * e.g. Claude Code's `ide`) is available without a declaration — never flag it.
20
+ * 3. SKIP the plugin-namespaced form. Claude Code rewrites a plugin's own MCP
21
+ * tool to `mcp__plugin_<plugin>_<server>__<tool>` (observed on han's
22
+ * playwright-mcp: `mcp__plugin_playwright-mcp_playwright__…`). The plugin /
23
+ * server segments are joined with single underscores and are ambiguous to
24
+ * split, and the ref is by construction the plugin's OWN server — so we don't
25
+ * interpret it (parsing it would be a false-positive factory).
26
+ *
27
+ * Pure + ONE detector reused by `scan` + the `mcp-tool-resolves` lint rule
28
+ * (one-detector-no-drift). The dialect is injected (core ⊄ adapter).
29
+ */
30
+ import type { HarnessDialect } from "./dialect.js";
31
+ export interface McpToolIssue {
32
+ readonly tool: string;
33
+ readonly server: string;
34
+ readonly message: string;
35
+ }
36
+ /**
37
+ * The server segment of a direct `mcp__<server>__<tool>` reference, or null when
38
+ * the token isn't a direct MCP tool we resolve: a non-MCP tool, or the
39
+ * plugin-namespaced `mcp__plugin_…__…` form (guard 3 — deliberately skipped).
40
+ * A `Tool(restriction)` suffix is stripped first.
41
+ */
42
+ export declare function mcpToolServer(raw: string, dialect: HarnessDialect): string | null;
43
+ /**
44
+ * Verify the MCP tool references in a contract against the plugin's declared MCP
45
+ * servers. Returns one {@link McpToolIssue} per direct `mcp__<server>__<tool>`
46
+ * whose server is neither declared nor a known built-in. Returns `[]` when no
47
+ * servers are declared (guard 1 — we can't know the resolvable set).
48
+ */
49
+ export declare function verifyMcpToolServers(tools: readonly string[], declaredServers: readonly string[], dialect: HarnessDialect): McpToolIssue[];
50
+ //# sourceMappingURL=mcp-tool.d.ts.map
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mcpToolServer = mcpToolServer;
4
+ exports.verifyMcpToolServers = verifyMcpToolServers;
5
+ /**
6
+ * The server segment of a direct `mcp__<server>__<tool>` reference, or null when
7
+ * the token isn't a direct MCP tool we resolve: a non-MCP tool, or the
8
+ * plugin-namespaced `mcp__plugin_…__…` form (guard 3 — deliberately skipped).
9
+ * A `Tool(restriction)` suffix is stripped first.
10
+ */
11
+ function mcpToolServer(raw, dialect) {
12
+ const tool = raw.split("(")[0].trim();
13
+ if (!dialect.mcpToolPattern.test(tool))
14
+ return null;
15
+ // Non-greedy first segment after `mcp__`, up to the next `__`.
16
+ const m = /^mcp__(.+?)__/.exec(tool);
17
+ if (!m)
18
+ return null;
19
+ const server = m[1];
20
+ // Guard 3: the plugin-namespaced form references the plugin's OWN server under
21
+ // an ambiguous single-underscore join — don't try to split it, don't flag it.
22
+ if (server.startsWith("plugin_"))
23
+ return null;
24
+ return server;
25
+ }
26
+ /**
27
+ * Verify the MCP tool references in a contract against the plugin's declared MCP
28
+ * servers. Returns one {@link McpToolIssue} per direct `mcp__<server>__<tool>`
29
+ * whose server is neither declared nor a known built-in. Returns `[]` when no
30
+ * servers are declared (guard 1 — we can't know the resolvable set).
31
+ */
32
+ function verifyMcpToolServers(tools, declaredServers, dialect) {
33
+ // Guard 1: no declared set → the plugin relies on global/project servers; we
34
+ // can't tell a dead reference from a legitimate global one. Flag nothing.
35
+ if (declaredServers.length === 0)
36
+ return [];
37
+ const known = new Set([
38
+ ...declaredServers,
39
+ ...(dialect.knownMcpServers ?? []),
40
+ ]);
41
+ const issues = [];
42
+ const seen = new Set();
43
+ for (const raw of tools) {
44
+ const server = mcpToolServer(raw, dialect);
45
+ if (server === null)
46
+ continue; // not a direct MCP tool / plugin-namespaced
47
+ if (known.has(server))
48
+ continue;
49
+ const tool = raw.split("(")[0].trim();
50
+ if (seen.has(tool))
51
+ continue; // de-dupe a repeated entry
52
+ seen.add(tool);
53
+ issues.push({
54
+ tool,
55
+ server,
56
+ message: `MCP tool "${tool}" references server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the tool can't resolve.`,
57
+ });
58
+ }
59
+ return issues;
60
+ }
61
+ //# sourceMappingURL=mcp-tool.js.map
@@ -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
  *
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tool-contract verification — the cross-referencing moat ("valid is not true")
3
+ * applied to a subagent's declared `tools:` rail. A subagent may only run
4
+ * built-in tools from the harness dialect's catalog or an MCP tool; anything else
5
+ * is a typo or a nonexistent / never-available tool — a guaranteed-dead reference
6
+ * a compiler catches, not a runtime surprise.
7
+ *
8
+ * ONE pure detector (`one-detector-no-drift`), reused by THREE callers so they
9
+ * can't disagree: `compileAgent` (spec authoring), `scan` (read-only audit of a
10
+ * shipped plugin), and the `subagent-tool-contract` lint rule (the severity-gated
11
+ * commit gate). The dialect is injected (core ⊄ adapter) — the composition root
12
+ * passes `claudeCodeDialect` / `codexDialect`.
13
+ *
14
+ * Scope note: this validates a SUBAGENT contract against the SUBAGENT catalog
15
+ * (`builtinAgentTools` / `neverAvailableTools`). A skill's `allowed-tools` is a
16
+ * DIFFERENT namespace (skills legitimately use `AskUserQuestion`, `TaskCreate`,
17
+ * … which are never-available to a subagent), so it is deliberately NOT validated
18
+ * here — doing so against the agent catalog would be a false-positive factory.
19
+ */
20
+ import type { HarnessDialect } from "./dialect.js";
21
+ export type ToolIssueKind = "never-available" | "unknown";
22
+ export interface ToolIssue {
23
+ readonly tool: string;
24
+ readonly kind: ToolIssueKind;
25
+ /** Closest known built-in tool (did-you-mean), or null. */
26
+ readonly suggestion: string | null;
27
+ /** A ready-to-show, actionable message. */
28
+ readonly message: string;
29
+ }
30
+ /**
31
+ * Closest known built-in tool by edit distance (≤ 2), for a "did you mean" hint.
32
+ * The ≤ 2 bound is deliberately tight: a suggestion is a CONFIDENCE signal (this
33
+ * `unknown` is really a typo of a real tool), and a loose bound mis-suggests —
34
+ * `TaskGet → Task?` (distance 3) is a real tool set, not a typo of `Task`.
35
+ */
36
+ export declare function closestTool(tool: string, dialect: HarnessDialect): string | null;
37
+ /**
38
+ * The HIGH-CONFIDENCE subset of a contract's issues — the ones safe to flag when
39
+ * AUDITING a third-party plugin (scan / lint), where the catalog can't know
40
+ * every tool (plugin-/MCP-provided, newer platform tools). Only two are confident:
41
+ * a `never-available` tool (a curated denylist) and an `unknown` with a close
42
+ * typo suggestion (`Edt → Edit`). A bare `unknown` with no near match is NOT
43
+ * flagged here — it is more likely a tool vigiles doesn't know than a defect
44
+ * (sweeping real plugins surfaced a 280★ plugin using `TaskCreate/TaskGet/…`
45
+ * consistently; flagging those would be crying wolf). `compileAgent` stays strict
46
+ * — when you author your OWN spec, every unrecognized tool is worth an error.
47
+ */
48
+ export declare function confidentToolIssues(issues: readonly ToolIssue[]): ToolIssue[];
49
+ /**
50
+ * Verify a subagent's `disallowedTools:` BLOCK-list — the mirror of the allow
51
+ * contract. A typo here is dangerous: you meant to block `Bash` but wrote `Bsh`,
52
+ * so nothing is blocked and the dangerous tool stays available, silently. Returns
53
+ * one {@link ToolIssue} per entry that's a CLOSE TYPO of a real built-in (the
54
+ * high-confidence signal). Deliberately NOT flagged: a real built-in (it IS being
55
+ * blocked — correct), a never-available tool (harmless to block), an MCP tool (a
56
+ * legitimate plugin tool to block), or a bare unknown with no near match (likely
57
+ * a plugin/MCP tool, not a typo — the cry-wolf trap). The block-list inverts the
58
+ * allow check: never-available is fine to list, a typo is the actual defect.
59
+ */
60
+ export declare function disallowedToolIssues(tools: readonly string[], dialect: HarnessDialect): ToolIssue[];
61
+ /**
62
+ * Verify a subagent's `tools:` contract against the dialect catalog. Returns one
63
+ * {@link ToolIssue} per offending entry (empty when every tool is a real built-in
64
+ * or a well-formed MCP tool). A `Tool(restriction)` suffix (e.g. `Bash(git:*)`)
65
+ * is stripped to its base tool before checking.
66
+ */
67
+ export declare function verifyToolContract(tools: readonly string[], dialect: HarnessDialect): ToolIssue[];
68
+ //# sourceMappingURL=tool-contract.d.ts.map
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.closestTool = closestTool;
4
+ exports.confidentToolIssues = confidentToolIssues;
5
+ exports.disallowedToolIssues = disallowedToolIssues;
6
+ exports.verifyToolContract = verifyToolContract;
7
+ const linters_js_1 = require("./linters.js");
8
+ /**
9
+ * Closest known built-in tool by edit distance (≤ 2), for a "did you mean" hint.
10
+ * The ≤ 2 bound is deliberately tight: a suggestion is a CONFIDENCE signal (this
11
+ * `unknown` is really a typo of a real tool), and a loose bound mis-suggests —
12
+ * `TaskGet → Task?` (distance 3) is a real tool set, not a typo of `Task`.
13
+ */
14
+ function closestTool(tool, dialect) {
15
+ let best = null;
16
+ let bestDistance = Infinity;
17
+ for (const known of dialect.builtinAgentTools) {
18
+ const d = (0, linters_js_1.editDistance)(tool.toLowerCase(), known.toLowerCase());
19
+ if (d < bestDistance) {
20
+ bestDistance = d;
21
+ best = known;
22
+ }
23
+ }
24
+ return bestDistance <= 2 ? best : null;
25
+ }
26
+ /**
27
+ * The HIGH-CONFIDENCE subset of a contract's issues — the ones safe to flag when
28
+ * AUDITING a third-party plugin (scan / lint), where the catalog can't know
29
+ * every tool (plugin-/MCP-provided, newer platform tools). Only two are confident:
30
+ * a `never-available` tool (a curated denylist) and an `unknown` with a close
31
+ * typo suggestion (`Edt → Edit`). A bare `unknown` with no near match is NOT
32
+ * flagged here — it is more likely a tool vigiles doesn't know than a defect
33
+ * (sweeping real plugins surfaced a 280★ plugin using `TaskCreate/TaskGet/…`
34
+ * consistently; flagging those would be crying wolf). `compileAgent` stays strict
35
+ * — when you author your OWN spec, every unrecognized tool is worth an error.
36
+ */
37
+ function confidentToolIssues(issues) {
38
+ return issues.filter((i) => i.kind === "never-available" || i.suggestion !== null);
39
+ }
40
+ /**
41
+ * Verify a subagent's `disallowedTools:` BLOCK-list — the mirror of the allow
42
+ * contract. A typo here is dangerous: you meant to block `Bash` but wrote `Bsh`,
43
+ * so nothing is blocked and the dangerous tool stays available, silently. Returns
44
+ * one {@link ToolIssue} per entry that's a CLOSE TYPO of a real built-in (the
45
+ * high-confidence signal). Deliberately NOT flagged: a real built-in (it IS being
46
+ * blocked — correct), a never-available tool (harmless to block), an MCP tool (a
47
+ * legitimate plugin tool to block), or a bare unknown with no near match (likely
48
+ * a plugin/MCP tool, not a typo — the cry-wolf trap). The block-list inverts the
49
+ * allow check: never-available is fine to list, a typo is the actual defect.
50
+ */
51
+ function disallowedToolIssues(tools, dialect) {
52
+ const never = new Set(dialect.neverAvailableTools);
53
+ const issues = [];
54
+ for (const raw of tools) {
55
+ const tool = raw.split("(")[0].trim();
56
+ if (tool === "" || tool === "*")
57
+ continue;
58
+ if (dialect.builtinAgentTools.includes(tool))
59
+ continue; // legitimately blocked
60
+ if (never.has(tool))
61
+ continue; // harmless to list (already unavailable)
62
+ if (dialect.mcpToolPattern.test(tool))
63
+ continue; // a real plugin/MCP tool to block
64
+ const near = closestTool(tool, dialect);
65
+ if (near === null)
66
+ continue; // bare unknown → likely a plugin tool, not a typo
67
+ issues.push({
68
+ tool,
69
+ kind: "unknown",
70
+ suggestion: near,
71
+ message: `disallowedTools entry "${tool}" matches no real tool — it blocks nothing. Did you mean "${near}"?`,
72
+ });
73
+ }
74
+ return issues;
75
+ }
76
+ /**
77
+ * Verify a subagent's `tools:` contract against the dialect catalog. Returns one
78
+ * {@link ToolIssue} per offending entry (empty when every tool is a real built-in
79
+ * or a well-formed MCP tool). A `Tool(restriction)` suffix (e.g. `Bash(git:*)`)
80
+ * is stripped to its base tool before checking.
81
+ */
82
+ function verifyToolContract(tools, dialect) {
83
+ const never = new Set(dialect.neverAvailableTools);
84
+ const issues = [];
85
+ for (const raw of tools) {
86
+ const tool = raw.split("(")[0].trim(); // strip a Tool(restriction) suffix
87
+ if (tool === "" || tool === "*")
88
+ continue; // "" / "*" = wildcard, inherits all
89
+ if (never.has(tool)) {
90
+ issues.push({
91
+ tool,
92
+ kind: "never-available",
93
+ suggestion: null,
94
+ message: `Tool "${tool}" is never available to a subagent — remove it from the tools list.`,
95
+ });
96
+ continue;
97
+ }
98
+ if (dialect.builtinAgentTools.includes(tool))
99
+ continue;
100
+ if (dialect.mcpToolPattern.test(tool))
101
+ continue;
102
+ const near = closestTool(tool, dialect);
103
+ const hint = near ? ` Did you mean "${near}"?` : "";
104
+ issues.push({
105
+ tool,
106
+ kind: "unknown",
107
+ suggestion: near,
108
+ message: `Unknown tool "${tool}" — use a built-in tool (${dialect.builtinAgentTools.join(", ")}) or an MCP tool (mcp__server__tool).${hint}`,
109
+ });
110
+ }
111
+ return issues;
112
+ }
113
+ //# sourceMappingURL=tool-contract.js.map