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.
- package/README.md +15 -9
- package/dist/adapters/claude-code/adapter.js +1 -0
- package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
- package/dist/adapters/claude-code/agent-runtime.js +66 -37
- package/dist/adapters/claude-code/dialect.js +37 -2
- package/dist/adapters/claude-code/effect-region.d.ts +9 -0
- package/dist/adapters/claude-code/effect-region.js +45 -0
- package/dist/adapters/claude-code/layout.js +3 -0
- package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
- package/dist/adapters/claude-code/skill-runtime.js +48 -0
- package/dist/adapters/codex/adapter.js +3 -0
- package/dist/adapters/codex/eval.d.ts +94 -0
- package/dist/adapters/codex/eval.js +227 -0
- package/dist/adapters/codex/layout.js +3 -0
- package/dist/adapters/opencode/adapter.js +1 -0
- package/dist/adapters/opencode/layout.js +3 -0
- package/dist/check.d.ts +8 -0
- package/dist/check.js +27 -3
- package/dist/cli.js +712 -21
- package/dist/codex.d.ts +1 -0
- package/dist/codex.js +3 -0
- package/dist/core/adapter.d.ts +10 -0
- package/dist/core/bash-effects.d.ts +41 -0
- package/dist/core/bash-effects.js +405 -0
- package/dist/core/compile.d.ts +3 -1
- package/dist/core/compile.js +169 -74
- package/dist/core/description-overlap.d.ts +27 -0
- package/dist/core/description-overlap.js +53 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/effects.d.ts +172 -0
- package/dist/core/effects.js +245 -0
- package/dist/core/frontmatter-read.d.ts +25 -0
- package/dist/core/frontmatter-read.js +138 -0
- package/dist/core/hook-events.d.ts +34 -0
- package/dist/core/hook-events.js +48 -0
- package/dist/core/layout.d.ts +6 -0
- package/dist/core/mcp-config.d.ts +20 -0
- package/dist/core/mcp-config.js +40 -0
- package/dist/core/mcp-hook.d.ts +35 -0
- package/dist/core/mcp-hook.js +70 -0
- package/dist/core/mcp-tool.d.ts +50 -0
- package/dist/core/mcp-tool.js +61 -0
- package/dist/core/orphans.js +21 -0
- package/dist/core/spec.d.ts +142 -3
- package/dist/core/spec.js +48 -0
- package/dist/core/tool-contract.d.ts +68 -0
- package/dist/core/tool-contract.js +113 -0
- package/dist/core/types.d.ts +91 -2
- package/dist/core/validate.js +23 -1
- package/dist/eval.d.ts +69 -13
- package/dist/eval.js +106 -51
- package/dist/harness-test.d.ts +7 -0
- package/dist/harness-test.js +19 -7
- package/dist/leaderboard.d.ts +2 -0
- package/dist/leaderboard.js +63 -3
- package/dist/optimize.d.ts +74 -0
- package/dist/optimize.js +94 -0
- package/dist/plugin-loader.d.ts +1 -0
- package/dist/plugin-loader.js +71 -18
- package/dist/scaffold-test.d.ts +30 -0
- package/dist/scaffold-test.js +158 -0
- package/dist/scan-behavioral.d.ts +73 -0
- package/dist/scan-behavioral.js +150 -0
- package/dist/scan.d.ts +166 -1
- package/dist/scan.js +622 -55
- package/dist/score-explainer.d.ts +69 -0
- package/dist/score-explainer.js +169 -0
- package/dist/test-coverage.d.ts +7 -0
- package/dist/test-coverage.js +39 -24
- package/package.json +2 -1
- package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
- 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
|
package/dist/core/orphans.js
CHANGED
|
@@ -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));
|
package/dist/core/spec.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|