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
package/dist/core/compile.js
CHANGED
|
@@ -14,6 +14,8 @@ exports.validateFileRef = validateFileRef;
|
|
|
14
14
|
exports.readPackageScripts = readPackageScripts;
|
|
15
15
|
exports.validateCommandRef = validateCommandRef;
|
|
16
16
|
exports.validateSymbolRef = validateSymbolRef;
|
|
17
|
+
exports.validateDirRef = validateDirRef;
|
|
18
|
+
exports.validateGlobRef = validateGlobRef;
|
|
17
19
|
exports.compileClaude = compileClaude;
|
|
18
20
|
exports.compileSkill = compileSkill;
|
|
19
21
|
exports.compileAgent = compileAgent;
|
|
@@ -22,10 +24,13 @@ exports.compileRailway = compileRailway;
|
|
|
22
24
|
exports.checkFileHash = checkFileHash;
|
|
23
25
|
exports.adoptDiff = adoptDiff;
|
|
24
26
|
const node_fs_1 = require("node:fs");
|
|
27
|
+
const glob_1 = require("glob");
|
|
25
28
|
const node_path_1 = require("node:path");
|
|
26
29
|
const hash_js_1 = require("./hash.js");
|
|
27
30
|
const symbols_js_1 = require("./symbols.js");
|
|
28
31
|
const linters_js_1 = require("./linters.js");
|
|
32
|
+
const tool_contract_js_1 = require("./tool-contract.js");
|
|
33
|
+
const effects_js_1 = require("./effects.js");
|
|
29
34
|
// vigiles's default compile target when a spec names none and no dialect is
|
|
30
35
|
// injected — a product convention (vigiles emits CLAUDE.md by default), not a
|
|
31
36
|
// harness dialect. When a dialect IS injected its instructionTargets win.
|
|
@@ -146,45 +151,73 @@ function validateSymbolRef(file, name, basePath) {
|
|
|
146
151
|
}
|
|
147
152
|
return null;
|
|
148
153
|
}
|
|
154
|
+
function validateDirRef(dirPath, basePath) {
|
|
155
|
+
const resolved = (0, node_path_1.resolve)(basePath, dirPath);
|
|
156
|
+
if (!(0, node_fs_1.existsSync)(resolved)) {
|
|
157
|
+
return {
|
|
158
|
+
type: "stale-file",
|
|
159
|
+
message: `Directory not found: "${dirPath}"`,
|
|
160
|
+
path: dirPath,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (!(0, node_fs_1.statSync)(resolved).isDirectory()) {
|
|
164
|
+
return {
|
|
165
|
+
type: "stale-ref",
|
|
166
|
+
message: `Not a directory: "${dirPath}"`,
|
|
167
|
+
path: dirPath,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function validateGlobRef(pattern, basePath) {
|
|
173
|
+
// ≥1 match = the pattern resolves to something real. `dot` so a dotfile path
|
|
174
|
+
// (e.g. `.claude/**`) isn't silently a no-match.
|
|
175
|
+
const matches = (0, glob_1.globSync)(pattern, { cwd: basePath, dot: true });
|
|
176
|
+
if (matches.length === 0) {
|
|
177
|
+
return {
|
|
178
|
+
type: "stale-ref",
|
|
179
|
+
message: `Glob matched no files: "${pattern}"`,
|
|
180
|
+
path: pattern,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
149
185
|
function validateRefs(fragments, basePath) {
|
|
150
186
|
const errors = [];
|
|
151
187
|
for (const fragment of fragments) {
|
|
152
|
-
if (typeof fragment
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (err) {
|
|
171
|
-
errors.push({
|
|
188
|
+
if (typeof fragment !== "string") {
|
|
189
|
+
errors.push(...validateOneRef(fragment, basePath));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return errors;
|
|
193
|
+
}
|
|
194
|
+
const wrap = (e) => (e ? [e] : []);
|
|
195
|
+
/** Validate a single non-string fragment (a `Ref` or `EffectRegion`). */
|
|
196
|
+
function validateOneRef(r, basePath) {
|
|
197
|
+
switch (r._ref) {
|
|
198
|
+
case "file":
|
|
199
|
+
return wrap(validateFileRef(r.path, basePath));
|
|
200
|
+
case "cmd":
|
|
201
|
+
return wrap(validateCommandRef(r.command, basePath));
|
|
202
|
+
case "skill":
|
|
203
|
+
return validateFileRef(r.path, basePath)
|
|
204
|
+
? [
|
|
205
|
+
{
|
|
172
206
|
type: "stale-ref",
|
|
173
207
|
message: `Skill not found: "${r.path}"`,
|
|
174
208
|
path: r.path,
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
},
|
|
210
|
+
]
|
|
211
|
+
: [];
|
|
212
|
+
case "symbol":
|
|
213
|
+
return wrap(validateSymbolRef(r.file, r.symbol, basePath));
|
|
214
|
+
case "dir":
|
|
215
|
+
return wrap(validateDirRef(r.path, basePath));
|
|
216
|
+
case "glob":
|
|
217
|
+
return wrap(validateGlobRef(r.pattern, basePath));
|
|
218
|
+
case "effect":
|
|
219
|
+
return validateRefs(r.body, basePath);
|
|
186
220
|
}
|
|
187
|
-
return errors;
|
|
188
221
|
}
|
|
189
222
|
function renderFragment(fragment) {
|
|
190
223
|
if (typeof fragment === "string")
|
|
@@ -198,6 +231,14 @@ function renderFragment(fragment) {
|
|
|
198
231
|
return `[${(0, node_path_1.basename)((0, node_path_1.dirname)(fragment.path))}](${fragment.path})`;
|
|
199
232
|
case "symbol":
|
|
200
233
|
return `\`vigiles:symbol ${fragment.file}#${fragment.symbol}\``;
|
|
234
|
+
case "dir":
|
|
235
|
+
return `\`${fragment.path}\``;
|
|
236
|
+
case "glob":
|
|
237
|
+
return `\`${fragment.pattern}\``;
|
|
238
|
+
case "effect": {
|
|
239
|
+
const inner = fragment.body.map(renderFragment).join("").trim();
|
|
240
|
+
return `\n<!-- vigiles:effect -->\n\n${inner}\n\n<!-- /vigiles:effect -->\n`;
|
|
241
|
+
}
|
|
201
242
|
default:
|
|
202
243
|
return (0, hash_js_1.assertNever)(fragment);
|
|
203
244
|
}
|
|
@@ -234,6 +275,14 @@ function compileRule(id, rule) {
|
|
|
234
275
|
return (0, hash_js_1.assertNever)(rule);
|
|
235
276
|
}
|
|
236
277
|
}
|
|
278
|
+
// A generous default cap on a single named prose section. TypeScript types
|
|
279
|
+
// cannot bound a string's length (template-literal-type recursion caps out ~463
|
|
280
|
+
// chars; TS #52243 unresolved), so a helper's content is guarded at COMPILE time
|
|
281
|
+
// instead — the ESLint-max-len / Prettier-printWidth precedent. Deliberately
|
|
282
|
+
// generous (don't-cry-wolf): real prose sections are short, so this only trips on
|
|
283
|
+
// an egregious dump (a whole essay pasted into one section / instructions``).
|
|
284
|
+
// Override per spec with `maxSectionLines`; `maxTokens` is the global backstop.
|
|
285
|
+
const DEFAULT_MAX_SECTION_LINES = 200;
|
|
237
286
|
function validateSectionContent(name, text, maxSectionLines) {
|
|
238
287
|
const errors = [];
|
|
239
288
|
const contentLines = text.split("\n");
|
|
@@ -256,10 +305,11 @@ function validateSectionContent(name, text, maxSectionLines) {
|
|
|
256
305
|
break;
|
|
257
306
|
}
|
|
258
307
|
}
|
|
259
|
-
|
|
308
|
+
const max = maxSectionLines ?? DEFAULT_MAX_SECTION_LINES;
|
|
309
|
+
if (contentLines.length > max) {
|
|
260
310
|
errors.push({
|
|
261
311
|
type: "section-too-long",
|
|
262
|
-
message: `Section "${name}" is ${String(contentLines.length)} lines (max ${String(
|
|
312
|
+
message: `Section "${name}" is ${String(contentLines.length)} lines (max ${String(max)}). Split into smaller named sections, move detail into a file() reference, or raise maxSectionLines if it's intentional.`,
|
|
263
313
|
});
|
|
264
314
|
}
|
|
265
315
|
return errors;
|
|
@@ -560,8 +610,8 @@ function collectSkillRefs(spec) {
|
|
|
560
610
|
* Build the SKILL.md YAML frontmatter block under the harness's frontmatter
|
|
561
611
|
* profile. The `"minimal"` profile (Codex/OpenCode) emits ONLY name +
|
|
562
612
|
* description; `"claude-code"` adds the CC-only keys (disable-model-invocation,
|
|
563
|
-
* argument-hint). Default is `"claude-code"` so callers that pass no
|
|
564
|
-
* byte-identical output to before.
|
|
613
|
+
* argument-hint, tools). Default is `"claude-code"` so callers that pass no
|
|
614
|
+
* dialect get byte-identical output to before.
|
|
565
615
|
*/
|
|
566
616
|
function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
567
617
|
const fm = [
|
|
@@ -576,11 +626,16 @@ function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
|
576
626
|
if (spec.disableModelInvocation !== undefined) {
|
|
577
627
|
fm.push(`disable-model-invocation: ${String(spec.disableModelInvocation)}`);
|
|
578
628
|
}
|
|
629
|
+
if (spec.context !== undefined)
|
|
630
|
+
fm.push(`context: ${spec.context}`);
|
|
579
631
|
const argHint = spec.inputs && spec.inputs.length > 0
|
|
580
632
|
? renderArgumentHint(spec.inputs)
|
|
581
633
|
: spec.argumentHint;
|
|
582
634
|
if (argHint)
|
|
583
635
|
fm.push(`argument-hint: ${argHint}`);
|
|
636
|
+
if (spec.tools && spec.tools.length > 0) {
|
|
637
|
+
fm.push(`tools: ${spec.tools.join(", ")}`);
|
|
638
|
+
}
|
|
584
639
|
}
|
|
585
640
|
fm.push("", "---");
|
|
586
641
|
return fm.join("\n");
|
|
@@ -602,6 +657,10 @@ function renderSkillSections(spec) {
|
|
|
602
657
|
}
|
|
603
658
|
if (spec.result)
|
|
604
659
|
sections.push(renderResult(spec.result));
|
|
660
|
+
// A forked skill (context: fork) runs as a subagent, so it may carry the SAME
|
|
661
|
+
// typed Result outcome — reuse the subagent renderer (one-renderer-no-drift).
|
|
662
|
+
if (spec.output)
|
|
663
|
+
sections.push(renderOutputContract(spec.output));
|
|
605
664
|
return sections.join("\n\n");
|
|
606
665
|
}
|
|
607
666
|
const DEFAULT_MAX_INLINE_CODE_LINES = 20;
|
|
@@ -660,9 +719,36 @@ function compileSkill(spec, options = {}) {
|
|
|
660
719
|
}
|
|
661
720
|
}
|
|
662
721
|
errors.push(...validateRefs(collectSkillRefs(spec), basePath));
|
|
722
|
+
// A typed `output` Result contract is valid ONLY for a forked skill: an inline
|
|
723
|
+
// skill has no call→return boundary, so a typed outcome there is a category
|
|
724
|
+
// error (see research/spec-syntax-and-railway-scope.md). Enforce it at compile.
|
|
725
|
+
if (spec.output && spec.context !== "fork") {
|
|
726
|
+
errors.push({
|
|
727
|
+
type: "output-without-fork",
|
|
728
|
+
message: 'A skill `output` (result() contract) requires `context: "fork"` — an ' +
|
|
729
|
+
"inline skill has no return value to type. Add context:'fork' to run it " +
|
|
730
|
+
"as a subagent, or drop `output`.",
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
// purity floor check — the dialect is optional (callers that don't pass one
|
|
734
|
+
// skip the check rather than crash; the CLI always passes it). An absent
|
|
735
|
+
// tools list inherits ALL tools, so it's checked as the "*" wildcard (a
|
|
736
|
+
// violation at the pure/bounded floors), never as the empty set.
|
|
737
|
+
if (spec.purity &&
|
|
738
|
+
spec.purity !== "dangerously-unrestricted" &&
|
|
739
|
+
options.dialect) {
|
|
740
|
+
for (const v of (0, effects_js_1.purityViolations)(spec.tools ?? ["*"], options.dialect, spec.purity)) {
|
|
741
|
+
errors.push({ type: "purity-violation", message: v.message });
|
|
742
|
+
}
|
|
743
|
+
}
|
|
663
744
|
const sections = renderSkillSections(spec);
|
|
664
745
|
errors.push(...checkInlineCode(sections, spec.maxInlineCodeLines ?? DEFAULT_MAX_INLINE_CODE_LINES));
|
|
665
|
-
const
|
|
746
|
+
const marker = purityMarker(spec.purity);
|
|
747
|
+
const content = renderSkillFrontmatter(spec, profile) +
|
|
748
|
+
"\n\n" +
|
|
749
|
+
(marker ? marker + "\n\n" : "") +
|
|
750
|
+
sections.trim() +
|
|
751
|
+
"\n";
|
|
666
752
|
return { markdown: addHash(content, specFile), errors };
|
|
667
753
|
}
|
|
668
754
|
// ---------------------------------------------------------------------------
|
|
@@ -680,43 +766,14 @@ function compileSkill(spec, options = {}) {
|
|
|
680
766
|
// vigiles does not emit a TOML [agents] block. The Codex dialect still verifies
|
|
681
767
|
// an agent()'s tool contract (its built-in catalog) — only the OUTPUT renderer
|
|
682
768
|
// is CC-only here. See research/codex-prototype-findings.md (gaps).
|
|
683
|
-
/**
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
let bestDistance = Infinity;
|
|
687
|
-
for (const known of dialect.builtinAgentTools) {
|
|
688
|
-
const d = (0, linters_js_1.editDistance)(tool.toLowerCase(), known.toLowerCase());
|
|
689
|
-
if (d < bestDistance) {
|
|
690
|
-
bestDistance = d;
|
|
691
|
-
best = known;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
return bestDistance <= 3 ? best : null;
|
|
695
|
-
}
|
|
696
|
-
/** Verify a subagent's allowed-tools contract — the rails are real tools. */
|
|
769
|
+
/** Verify a subagent's allowed-tools contract — the rails are real tools. The
|
|
770
|
+
* detection lives in the shared `verifyToolContract` detector (one-detector-no-
|
|
771
|
+
* drift: compile + scan + the subagent-tool-contract lint rule call the same code). */
|
|
697
772
|
function validateAgentTools(tools, dialect) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
errors.push({
|
|
703
|
-
type: "unknown-tool",
|
|
704
|
-
message: `Tool "${tool}" is never available to a subagent — remove it from the tools list.`,
|
|
705
|
-
});
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
if (dialect.builtinAgentTools.includes(tool))
|
|
709
|
-
continue;
|
|
710
|
-
if (dialect.mcpToolPattern.test(tool))
|
|
711
|
-
continue;
|
|
712
|
-
const near = closestTool(tool, dialect);
|
|
713
|
-
const hint = near ? ` Did you mean "${near}"?` : "";
|
|
714
|
-
errors.push({
|
|
715
|
-
type: "unknown-tool",
|
|
716
|
-
message: `Unknown tool "${tool}" in agent tools — use a built-in tool (${dialect.builtinAgentTools.join(", ")}) or an MCP tool (mcp__server__tool).${hint}`,
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
return errors;
|
|
773
|
+
return (0, tool_contract_js_1.verifyToolContract)(tools, dialect).map((issue) => ({
|
|
774
|
+
type: "unknown-tool",
|
|
775
|
+
message: issue.message,
|
|
776
|
+
}));
|
|
720
777
|
}
|
|
721
778
|
/** Build the subagent YAML frontmatter (name / description / model / tools). */
|
|
722
779
|
function renderAgentFrontmatter(spec) {
|
|
@@ -728,12 +785,30 @@ function renderAgentFrontmatter(spec) {
|
|
|
728
785
|
];
|
|
729
786
|
if (spec.model !== undefined)
|
|
730
787
|
fm.push(`model: ${spec.model}`);
|
|
788
|
+
if (spec.color !== undefined)
|
|
789
|
+
fm.push(`color: ${spec.color}`);
|
|
731
790
|
if (spec.tools && spec.tools.length > 0) {
|
|
732
791
|
fm.push(`tools: ${spec.tools.join(", ")}`);
|
|
733
792
|
}
|
|
793
|
+
if (spec.disallowedTools && spec.disallowedTools.length > 0) {
|
|
794
|
+
fm.push(`disallowedTools: ${spec.disallowedTools.join(", ")}`);
|
|
795
|
+
}
|
|
734
796
|
fm.push("", "---");
|
|
735
797
|
return fm.join("\n");
|
|
736
798
|
}
|
|
799
|
+
/**
|
|
800
|
+
* The `<!-- vigiles:purity:LEVEL -->` marker the runtime PreToolUse gate reads to
|
|
801
|
+
* enforce a unit's declared purity floor against live tool calls (see
|
|
802
|
+
* `decidePurityGate` / `parseAgentPurity`). Returns "" when no purity is
|
|
803
|
+
* declared. The loud authoring word `dangerously-unrestricted` maps to the
|
|
804
|
+
* neutral report/runtime level `unrestricted` (no constraint to enforce).
|
|
805
|
+
*/
|
|
806
|
+
function purityMarker(purity) {
|
|
807
|
+
if (!purity)
|
|
808
|
+
return "";
|
|
809
|
+
const level = purity === "dangerously-unrestricted" ? "unrestricted" : purity;
|
|
810
|
+
return `<!-- vigiles:purity:${level} -->`;
|
|
811
|
+
}
|
|
737
812
|
/** Render the subagent's named `##` system-prompt sections (verified like CLAUDE.md). */
|
|
738
813
|
function renderAgentSections(sections, basePath) {
|
|
739
814
|
const lines = [];
|
|
@@ -823,6 +898,21 @@ function compileAgent(spec, options) {
|
|
|
823
898
|
}
|
|
824
899
|
if (spec.tools)
|
|
825
900
|
errors.push(...validateAgentTools(spec.tools, dialect));
|
|
901
|
+
if (spec.disallowedTools) {
|
|
902
|
+
// A disallowedTools entry that's a close typo of a real tool blocks NOTHING —
|
|
903
|
+
// the same high-precision detector scan + the disallowed-tools-contract rule use.
|
|
904
|
+
for (const issue of (0, tool_contract_js_1.disallowedToolIssues)(spec.disallowedTools, dialect)) {
|
|
905
|
+
errors.push({ type: "unknown-tool", message: issue.message });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (spec.purity && spec.purity !== "dangerously-unrestricted") {
|
|
909
|
+
// Enforce the declared purity floor against the tool contract. An absent
|
|
910
|
+
// tools list inherits ALL tools, so it's checked as the "*" wildcard (a
|
|
911
|
+
// violation at the pure/bounded floors), never as the empty set.
|
|
912
|
+
for (const v of (0, effects_js_1.purityViolations)(spec.tools ?? ["*"], dialect, spec.purity)) {
|
|
913
|
+
errors.push({ type: "purity-violation", message: v.message });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
826
916
|
if (Array.isArray(spec.body)) {
|
|
827
917
|
errors.push(...validateRefs(spec.body, basePath));
|
|
828
918
|
}
|
|
@@ -841,7 +931,12 @@ function compileAgent(spec, options) {
|
|
|
841
931
|
sections.push(renderOutputContract(spec.output));
|
|
842
932
|
const body = sections.join("\n\n");
|
|
843
933
|
errors.push(...checkInlineCode(body, DEFAULT_MAX_INLINE_CODE_LINES));
|
|
844
|
-
const
|
|
934
|
+
const marker = purityMarker(spec.purity);
|
|
935
|
+
const content = renderAgentFrontmatter(spec) +
|
|
936
|
+
"\n\n" +
|
|
937
|
+
(marker ? marker + "\n\n" : "") +
|
|
938
|
+
body.trim() +
|
|
939
|
+
"\n";
|
|
845
940
|
return { markdown: addHash(content, specFile), errors };
|
|
846
941
|
}
|
|
847
942
|
/** Verify a railway: non-empty, bounded recovery, every delegate target real. */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A skill identified by name + its trigger-surface description. */
|
|
2
|
+
export interface DescribedSurface {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly description: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DescriptionOverlap {
|
|
7
|
+
readonly a: string;
|
|
8
|
+
readonly b: string;
|
|
9
|
+
/** 0–1, higher = more alike (1 − NCD), rounded to 2 dp. */
|
|
10
|
+
readonly similarity: number;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The NCD cutoff below which two descriptions count as a near-duplicate. 0.2 sits
|
|
15
|
+
* safely under the sweep's most-similar legitimately-distinct pair (0.25), so
|
|
16
|
+
* only basically-identical text is flagged. Exported so a caller / test can see
|
|
17
|
+
* the calibrated value.
|
|
18
|
+
*/
|
|
19
|
+
export declare const OVERLAP_NCD_CUTOFF = 0.2;
|
|
20
|
+
/**
|
|
21
|
+
* Find near-duplicate description pairs among `surfaces`. Returns one
|
|
22
|
+
* {@link DescriptionOverlap} per pair whose NCD is below `cutoff`, most-similar
|
|
23
|
+
* first. Pure; pass only the surfaces that actually compete for auto-selection
|
|
24
|
+
* (model-invocable, described) so a user-invoked pair isn't a false alarm.
|
|
25
|
+
*/
|
|
26
|
+
export declare function findDescriptionOverlaps(surfaces: readonly DescribedSurface[], cutoff?: number): DescriptionOverlap[];
|
|
27
|
+
//# sourceMappingURL=description-overlap.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OVERLAP_NCD_CUTOFF = void 0;
|
|
4
|
+
exports.findDescriptionOverlaps = findDescriptionOverlaps;
|
|
5
|
+
/**
|
|
6
|
+
* Description-overlap — a DETERMINISTIC proxy for a behavioral risk. Two skills
|
|
7
|
+
* whose descriptions are near-identical can't be told apart by the model's
|
|
8
|
+
* selector, so the wrong one fires (a precision collision). This catches a
|
|
9
|
+
* `--trigger`-class problem with NO model, reusing the NCD engine in proofs.ts
|
|
10
|
+
* (the same one `findSimilarRules` uses) — the bridge between the deterministic
|
|
11
|
+
* and behavioral columns, and a check no other plugin linter has.
|
|
12
|
+
*
|
|
13
|
+
* Calibrated HIGH-PRECISION against the mid-2026 sweep: across 4678 within-plugin
|
|
14
|
+
* skill-description pairs, the MOST-similar legitimately-distinct pair
|
|
15
|
+
* (`create-issue` vs `create-pr`) sits at NCD 0.25, and NOTHING falls below it.
|
|
16
|
+
* So a cutoff of NCD < 0.2 fires only on text that's essentially identical (a
|
|
17
|
+
* copy-pasted description with a word or two changed) — never on a parallel but
|
|
18
|
+
* distinct pair. Warn-level, reports the PAIR (not a unilateral defect).
|
|
19
|
+
*/
|
|
20
|
+
const proofs_js_1 = require("./proofs.js");
|
|
21
|
+
/**
|
|
22
|
+
* The NCD cutoff below which two descriptions count as a near-duplicate. 0.2 sits
|
|
23
|
+
* safely under the sweep's most-similar legitimately-distinct pair (0.25), so
|
|
24
|
+
* only basically-identical text is flagged. Exported so a caller / test can see
|
|
25
|
+
* the calibrated value.
|
|
26
|
+
*/
|
|
27
|
+
exports.OVERLAP_NCD_CUTOFF = 0.2;
|
|
28
|
+
/**
|
|
29
|
+
* Find near-duplicate description pairs among `surfaces`. Returns one
|
|
30
|
+
* {@link DescriptionOverlap} per pair whose NCD is below `cutoff`, most-similar
|
|
31
|
+
* first. Pure; pass only the surfaces that actually compete for auto-selection
|
|
32
|
+
* (model-invocable, described) so a user-invoked pair isn't a false alarm.
|
|
33
|
+
*/
|
|
34
|
+
function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF) {
|
|
35
|
+
const overlaps = [];
|
|
36
|
+
for (let i = 0; i < surfaces.length; i++) {
|
|
37
|
+
for (let j = i + 1; j < surfaces.length; j++) {
|
|
38
|
+
const d = (0, proofs_js_1.ncd)(surfaces[i].description, surfaces[j].description);
|
|
39
|
+
if (d >= cutoff)
|
|
40
|
+
continue;
|
|
41
|
+
const a = surfaces[i].name;
|
|
42
|
+
const b = surfaces[j].name;
|
|
43
|
+
overlaps.push({
|
|
44
|
+
a,
|
|
45
|
+
b,
|
|
46
|
+
similarity: Math.round((1 - d) * 100) / 100,
|
|
47
|
+
message: `skills "${a}" and "${b}" have near-identical descriptions (${String(Math.round((1 - d) * 100))}% alike) — the model can't reliably tell them apart, so the wrong one may fire. Differentiate their descriptions.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return overlaps.sort((x, y) => y.similarity - x.similarity);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=description-overlap.js.map
|
package/dist/core/dialect.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface HarnessDialect {
|
|
|
32
32
|
readonly neverAvailableTools: readonly string[];
|
|
33
33
|
/** Matches an MCP tool reference, e.g. `mcp__server__tool`. */
|
|
34
34
|
readonly mcpToolPattern: RegExp;
|
|
35
|
+
/**
|
|
36
|
+
* MCP servers the harness provides itself, available to a contract WITHOUT the
|
|
37
|
+
* plugin declaring them — e.g. Claude Code's built-in `ide` integration
|
|
38
|
+
* (`mcp__ide__getDiagnostics`). The `mcp-tool-resolves` check allowlists these
|
|
39
|
+
* so a reference to a built-in server is never flagged as an undeclared one.
|
|
40
|
+
* Optional (additive, non-breaking for existing adapters) — defaults to none.
|
|
41
|
+
*/
|
|
42
|
+
readonly knownMcpServers?: readonly string[];
|
|
35
43
|
/** Hook event names the harness fires. */
|
|
36
44
|
readonly hookEvents: readonly string[];
|
|
37
45
|
/** Instruction-file targets the harness reads (also the h1 heading). */
|
|
@@ -47,5 +55,15 @@ export interface HarnessDialect {
|
|
|
47
55
|
* and OpenCode read; CC-only keys are omitted because they'd be inert noise).
|
|
48
56
|
*/
|
|
49
57
|
readonly skillFrontmatter: SkillFrontmatterProfile;
|
|
58
|
+
/**
|
|
59
|
+
* Tools that PRODUCE side effects (write, exec, network, spawn) — the
|
|
60
|
+
* complement of read-only within `builtinAgentTools`. The basis for
|
|
61
|
+
* effect-surface analysis and the `pure:` contract: a tool here is denied to a
|
|
62
|
+
* pure skill and counts toward a harness's side-effect surface. `Bash` is
|
|
63
|
+
* listed (undecidable at the tool-name level → conservatively side-effecting);
|
|
64
|
+
* an MCP tool not classifiable from the name is treated as unknown-effect.
|
|
65
|
+
* Optional (additive, non-breaking) — absent ⇒ no tool is known-side-effecting.
|
|
66
|
+
*/
|
|
67
|
+
readonly sideEffectingTools?: readonly string[];
|
|
50
68
|
}
|
|
51
69
|
//# sourceMappingURL=dialect.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static effect-surface analysis — the `pure:` contract and the purity ladder.
|
|
3
|
+
*
|
|
4
|
+
* An agent's side effects ARE its tool calls. The read-only vs side-effecting
|
|
5
|
+
* split is a PUBLISHED catalog (`dialect.sideEffectingTools`), so this analysis
|
|
6
|
+
* is fully deterministic — no model needed. The result answers "how constrained
|
|
7
|
+
* is this skill/agent's declared tool contract?" at three rungs:
|
|
8
|
+
*
|
|
9
|
+
* **pure** — no side-effecting tools; the skill can only observe, not
|
|
10
|
+
* mutate. Deterministically testable with no mocks.
|
|
11
|
+
* **bounded** — has decidable side-effecting tools (Edit, Write, …) but
|
|
12
|
+
* none that are undecidable (`Bash`) or unknown-effect (MCP).
|
|
13
|
+
* The side-effect surface is finite and enumerable.
|
|
14
|
+
* **unrestricted** — has `Bash` (undecidable) or an unknown-effect tool (MCP
|
|
15
|
+
* or unclassified), or declares `"*"` / inherits-all. The
|
|
16
|
+
* actual effect surface is unbounded from static analysis
|
|
17
|
+
* alone; "unrestricted" is the honest report.
|
|
18
|
+
*
|
|
19
|
+
* SURFACE vs FLOOR — two related but distinct questions, intentionally. The
|
|
20
|
+
* `effectSurface` above is the STATIC surface: it can't see a `Bash` command, so
|
|
21
|
+
* any `Bash` makes the surface `unrestricted` (honest — statically unbounded).
|
|
22
|
+
* The purity FLOOR (`purityViolations` / `decidePurityGate`) is what's
|
|
23
|
+
* ENFORCED, and `bounded` admits `Bash` because the runtime gate refines it by
|
|
24
|
+
* command (`isReadOnlyBash`). So a `bounded`-declared unit with `Bash` reports an
|
|
25
|
+
* `unrestricted` surface yet enforces a `bounded` floor — the runtime gate is
|
|
26
|
+
* exactly what closes that gap.
|
|
27
|
+
*
|
|
28
|
+
* ONE pure detector (`one-detector-no-drift`), dialect injected (core ⊄
|
|
29
|
+
* adapter). The composition root passes `claudeCodeDialect` / `codexDialect`.
|
|
30
|
+
*
|
|
31
|
+
* See `research/side-effect-separation.md` for the full design rationale.
|
|
32
|
+
*/
|
|
33
|
+
import type { HarnessDialect } from "./dialect.js";
|
|
34
|
+
/** The effect class of a single tool from a declared `tools:` contract. */
|
|
35
|
+
export type ToolEffect = "read-only" | "side-effecting" | "unknown";
|
|
36
|
+
/**
|
|
37
|
+
* The three rungs of the purity ladder. See module-level JSDoc for semantics.
|
|
38
|
+
*
|
|
39
|
+
* - `"pure"`: no side-effecting tools, no unknown-effect tools, no wildcard.
|
|
40
|
+
* - `"bounded"`: has side-effecting tools, none of which are `Bash` or unknown.
|
|
41
|
+
* - `"unrestricted"`: has `Bash`, any unknown-effect tool, or a wildcard (`"*"` /
|
|
42
|
+
* inherits-all) that can reach effects.
|
|
43
|
+
*/
|
|
44
|
+
export type PurityLevel = "pure" | "bounded" | "unrestricted";
|
|
45
|
+
/**
|
|
46
|
+
* The aggregated effect surface of a declared `tools:` contract.
|
|
47
|
+
*
|
|
48
|
+
* Fields are de-duplicated: a tool listed twice appears at most once per bucket.
|
|
49
|
+
*/
|
|
50
|
+
export interface EffectSurface {
|
|
51
|
+
/** Built-in read-only tools in the contract (Read, Grep, Glob, …). */
|
|
52
|
+
readonly readOnly: readonly string[];
|
|
53
|
+
/** Built-in tools that produce side effects (Bash, Write, Edit, …). */
|
|
54
|
+
readonly sideEffecting: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* Tools whose effect class cannot be determined statically: MCP tools
|
|
57
|
+
* (`mcp__server__tool`) and any tool name the dialect does not recognize.
|
|
58
|
+
* These make purity `"unrestricted"` because the surface is unknown.
|
|
59
|
+
*/
|
|
60
|
+
readonly unknown: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* The overall purity of the contract:
|
|
63
|
+
* - `"pure"`: `sideEffecting` and `unknown` are both empty, no wildcard.
|
|
64
|
+
* - `"bounded"`: `sideEffecting` is non-empty, `unknown` is empty, no `Bash`.
|
|
65
|
+
* - `"unrestricted"`: `Bash` present, OR `unknown` is non-empty, OR the contract
|
|
66
|
+
* is `"*"` / inherits-all (can reach any tool, including effects).
|
|
67
|
+
*
|
|
68
|
+
* A wildcard (`"*"` or `""`) contract is always `"unrestricted"` — it grants
|
|
69
|
+
* access to all tools including every side-effecting one.
|
|
70
|
+
*/
|
|
71
|
+
readonly purity: PurityLevel;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A violation record for `pureContractViolations` — a single tool in a
|
|
75
|
+
* declared `pure:` contract that is side-effecting or unknown-effect.
|
|
76
|
+
*/
|
|
77
|
+
export interface PureViolation {
|
|
78
|
+
/** The base tool name (restriction suffix already stripped). */
|
|
79
|
+
readonly tool: string;
|
|
80
|
+
/** Why this tool violates a pure contract. */
|
|
81
|
+
readonly effect: ToolEffect;
|
|
82
|
+
/** A ready-to-show, actionable message. */
|
|
83
|
+
readonly message: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Classify the effect of ONE tool name against a dialect's known catalogs.
|
|
87
|
+
*
|
|
88
|
+
* A `Tool(restriction)` suffix (e.g. `Bash(git:*)`) is stripped first — the
|
|
89
|
+
* restriction narrows what the tool can DO but doesn't change its effect class
|
|
90
|
+
* (Bash with any restriction is still conservatively side-effecting).
|
|
91
|
+
*
|
|
92
|
+
* Classification rules (in priority order):
|
|
93
|
+
* 1. In `dialect.sideEffectingTools` → `"side-effecting"`
|
|
94
|
+
* 2. In `dialect.builtinAgentTools` (and NOT side-effecting) → `"read-only"`
|
|
95
|
+
* 3. Matches `dialect.mcpToolPattern` → `"unknown"` (MCP tools are not
|
|
96
|
+
* classifiable from the name alone — treated as unknown-effect)
|
|
97
|
+
* 4. Otherwise → `"unknown"` (unrecognized tool; may be a plugin tool or a typo)
|
|
98
|
+
*/
|
|
99
|
+
export declare function classifyToolEffect(tool: string, dialect: HarnessDialect): ToolEffect;
|
|
100
|
+
/**
|
|
101
|
+
* Compute the static effect surface of a declared `tools:` contract.
|
|
102
|
+
*
|
|
103
|
+
* `"*"` / `""` (inherits-all) entries make purity `"unrestricted"` because the
|
|
104
|
+
* contract grants access to all tools including every side-effecting one — the
|
|
105
|
+
* full surface is unknowable statically. They are NOT listed in any bucket
|
|
106
|
+
* (they represent a wildcard, not a named tool).
|
|
107
|
+
*
|
|
108
|
+
* De-duplication: a tool name that appears more than once in `tools` is counted
|
|
109
|
+
* once in its bucket (base tool after restriction stripping).
|
|
110
|
+
*/
|
|
111
|
+
export declare function effectSurface(tools: readonly string[], dialect: HarnessDialect): EffectSurface;
|
|
112
|
+
/**
|
|
113
|
+
* Returns the violations of a DECLARED purity floor — the tools that make the
|
|
114
|
+
* actual effect surface LOOSER than the declared level. Empty ⇒ the contract
|
|
115
|
+
* honours the declared level. The `message` on each is actionable (names the
|
|
116
|
+
* tool, the effect class, and what to do).
|
|
117
|
+
*
|
|
118
|
+
* What counts as a violation depends on `declared`:
|
|
119
|
+
* - `"pure"`: every side-effecting tool (incl. `Bash`), every
|
|
120
|
+
* unknown-effect tool, and any wildcard (a pure unit may
|
|
121
|
+
* only observe — no `Bash`, no effects, fully static).
|
|
122
|
+
* - `"bounded"`: only the truly UNBOUNDED tools — unknown-effect (MCP /
|
|
123
|
+
* unrecognized) and wildcards. Every decidable side-effecting
|
|
124
|
+
* tool is ALLOWED: Write/Edit confine to the boundary, and
|
|
125
|
+
* `Bash` is admitted because the RUNTIME gate
|
|
126
|
+
* (`decidePurityGate`) refines it by command (read-only Bash
|
|
127
|
+
* is an observation; a mutating command is denied).
|
|
128
|
+
* - `"unrestricted"`: never a violation (the rung carries no constraint).
|
|
129
|
+
*
|
|
130
|
+
* A wildcard (`"*"` / `""`) contract is a violation at every constrained level:
|
|
131
|
+
* "inherits-all" grants every tool, so neither `pure` nor `bounded` can hold.
|
|
132
|
+
*/
|
|
133
|
+
export declare function purityViolations(tools: readonly string[], dialect: HarnessDialect, declared: PurityLevel): PureViolation[];
|
|
134
|
+
/**
|
|
135
|
+
* The violations of a `purity: "pure"` contract — every side-effecting,
|
|
136
|
+
* unknown-effect, or wildcard tool. A thin alias for `purityViolations(…,
|
|
137
|
+
* "pure")` kept for the common pure case.
|
|
138
|
+
*/
|
|
139
|
+
export declare function pureContractViolations(tools: readonly string[], dialect: HarnessDialect): PureViolation[];
|
|
140
|
+
/** A runtime allow/deny decision for the PreToolUse purity gate. */
|
|
141
|
+
export interface PurityGateDecision {
|
|
142
|
+
readonly allow: boolean;
|
|
143
|
+
/** Reason fed back to the model on a deny; empty on allow. */
|
|
144
|
+
readonly message: string;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The RUNTIME half of the purity contract: decide whether a single LIVE tool
|
|
148
|
+
* call is allowed under the active unit's declared purity floor.
|
|
149
|
+
*
|
|
150
|
+
* Unlike `purityViolations` (which checks the DECLARED tools contract
|
|
151
|
+
* statically), this sees the ACTUAL call — including the `Bash` command string —
|
|
152
|
+
* so it refines `Bash` by effect via `isReadOnlyBash`. That command is the whole
|
|
153
|
+
* reason the gate's home is the runtime hook: only here is the concrete command
|
|
154
|
+
* visible (the static surface sees a tool name + a `Bash(git:*)` pattern, never
|
|
155
|
+
* the command).
|
|
156
|
+
*
|
|
157
|
+
* Rules (the ladder, command-refined):
|
|
158
|
+
* - `unrestricted` → always allow (no constraint).
|
|
159
|
+
* - read-only tool → allow at every level.
|
|
160
|
+
* - `Bash` → allow iff the command is provably read-only (an observation);
|
|
161
|
+
* otherwise deny — a mutating/undecidable command's effect must move to a
|
|
162
|
+
* marked boundary. Same at `pure` and `bounded`.
|
|
163
|
+
* - other side-effecting tool (Write, Edit, …) → allow under `bounded`
|
|
164
|
+
* (a decidable, boundary-confined effect), deny under `pure` (observe-only).
|
|
165
|
+
* - unknown-effect (MCP / unrecognized) → deny under `pure`/`bounded`
|
|
166
|
+
* (unbounded from static analysis).
|
|
167
|
+
*
|
|
168
|
+
* Dialect injected (core ⊄ adapter). Reuses `classifyToolEffect` +
|
|
169
|
+
* `isReadOnlyBash` — one-detector-no-drift with compile + scan.
|
|
170
|
+
*/
|
|
171
|
+
export declare function decidePurityGate(declared: PurityLevel, tool: string, command: string | undefined, dialect: HarnessDialect): PurityGateDecision;
|
|
172
|
+
//# sourceMappingURL=effects.d.ts.map
|