vigiles 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/adapters/claude-code/adapter.js +1 -0
- package/dist/adapters/claude-code/agent-runtime.d.ts +20 -6
- package/dist/adapters/claude-code/agent-runtime.js +51 -8
- package/dist/adapters/claude-code/dialect.js +19 -0
- 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/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 +323 -88
- 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 +162 -39
- package/dist/core/dialect.d.ts +10 -0
- package/dist/core/effects.d.ts +172 -0
- package/dist/core/effects.js +245 -0
- package/dist/core/layout.d.ts +6 -0
- package/dist/core/mcp-tool.d.ts +1 -1
- 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 +1 -1
- package/dist/core/types.d.ts +6 -6
- package/dist/core/validate.js +4 -4
- 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 +2 -0
- package/dist/optimize.d.ts +74 -0
- package/dist/optimize.js +94 -0
- package/dist/scaffold-test.d.ts +30 -0
- package/dist/scaffold-test.js +158 -0
- package/dist/scan.d.ts +40 -0
- package/dist/scan.js +91 -43
- 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 -4
- 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,11 +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");
|
|
29
32
|
const tool_contract_js_1 = require("./tool-contract.js");
|
|
33
|
+
const effects_js_1 = require("./effects.js");
|
|
30
34
|
// vigiles's default compile target when a spec names none and no dialect is
|
|
31
35
|
// injected — a product convention (vigiles emits CLAUDE.md by default), not a
|
|
32
36
|
// harness dialect. When a dialect IS injected its instructionTargets win.
|
|
@@ -147,45 +151,73 @@ function validateSymbolRef(file, name, basePath) {
|
|
|
147
151
|
}
|
|
148
152
|
return null;
|
|
149
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
|
+
}
|
|
150
185
|
function validateRefs(fragments, basePath) {
|
|
151
186
|
const errors = [];
|
|
152
187
|
for (const fragment of fragments) {
|
|
153
|
-
if (typeof fragment
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (err) {
|
|
172
|
-
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
|
+
{
|
|
173
206
|
type: "stale-ref",
|
|
174
207
|
message: `Skill not found: "${r.path}"`,
|
|
175
208
|
path: r.path,
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
220
|
}
|
|
188
|
-
return errors;
|
|
189
221
|
}
|
|
190
222
|
function renderFragment(fragment) {
|
|
191
223
|
if (typeof fragment === "string")
|
|
@@ -199,6 +231,14 @@ function renderFragment(fragment) {
|
|
|
199
231
|
return `[${(0, node_path_1.basename)((0, node_path_1.dirname)(fragment.path))}](${fragment.path})`;
|
|
200
232
|
case "symbol":
|
|
201
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
|
+
}
|
|
202
242
|
default:
|
|
203
243
|
return (0, hash_js_1.assertNever)(fragment);
|
|
204
244
|
}
|
|
@@ -235,6 +275,14 @@ function compileRule(id, rule) {
|
|
|
235
275
|
return (0, hash_js_1.assertNever)(rule);
|
|
236
276
|
}
|
|
237
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;
|
|
238
286
|
function validateSectionContent(name, text, maxSectionLines) {
|
|
239
287
|
const errors = [];
|
|
240
288
|
const contentLines = text.split("\n");
|
|
@@ -257,10 +305,11 @@ function validateSectionContent(name, text, maxSectionLines) {
|
|
|
257
305
|
break;
|
|
258
306
|
}
|
|
259
307
|
}
|
|
260
|
-
|
|
308
|
+
const max = maxSectionLines ?? DEFAULT_MAX_SECTION_LINES;
|
|
309
|
+
if (contentLines.length > max) {
|
|
261
310
|
errors.push({
|
|
262
311
|
type: "section-too-long",
|
|
263
|
-
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.`,
|
|
264
313
|
});
|
|
265
314
|
}
|
|
266
315
|
return errors;
|
|
@@ -561,8 +610,8 @@ function collectSkillRefs(spec) {
|
|
|
561
610
|
* Build the SKILL.md YAML frontmatter block under the harness's frontmatter
|
|
562
611
|
* profile. The `"minimal"` profile (Codex/OpenCode) emits ONLY name +
|
|
563
612
|
* description; `"claude-code"` adds the CC-only keys (disable-model-invocation,
|
|
564
|
-
* argument-hint). Default is `"claude-code"` so callers that pass no
|
|
565
|
-
* 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.
|
|
566
615
|
*/
|
|
567
616
|
function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
568
617
|
const fm = [
|
|
@@ -577,11 +626,16 @@ function renderSkillFrontmatter(spec, profile = "claude-code") {
|
|
|
577
626
|
if (spec.disableModelInvocation !== undefined) {
|
|
578
627
|
fm.push(`disable-model-invocation: ${String(spec.disableModelInvocation)}`);
|
|
579
628
|
}
|
|
629
|
+
if (spec.context !== undefined)
|
|
630
|
+
fm.push(`context: ${spec.context}`);
|
|
580
631
|
const argHint = spec.inputs && spec.inputs.length > 0
|
|
581
632
|
? renderArgumentHint(spec.inputs)
|
|
582
633
|
: spec.argumentHint;
|
|
583
634
|
if (argHint)
|
|
584
635
|
fm.push(`argument-hint: ${argHint}`);
|
|
636
|
+
if (spec.tools && spec.tools.length > 0) {
|
|
637
|
+
fm.push(`tools: ${spec.tools.join(", ")}`);
|
|
638
|
+
}
|
|
585
639
|
}
|
|
586
640
|
fm.push("", "---");
|
|
587
641
|
return fm.join("\n");
|
|
@@ -603,6 +657,10 @@ function renderSkillSections(spec) {
|
|
|
603
657
|
}
|
|
604
658
|
if (spec.result)
|
|
605
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));
|
|
606
664
|
return sections.join("\n\n");
|
|
607
665
|
}
|
|
608
666
|
const DEFAULT_MAX_INLINE_CODE_LINES = 20;
|
|
@@ -661,9 +719,36 @@ function compileSkill(spec, options = {}) {
|
|
|
661
719
|
}
|
|
662
720
|
}
|
|
663
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
|
+
}
|
|
664
744
|
const sections = renderSkillSections(spec);
|
|
665
745
|
errors.push(...checkInlineCode(sections, spec.maxInlineCodeLines ?? DEFAULT_MAX_INLINE_CODE_LINES));
|
|
666
|
-
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";
|
|
667
752
|
return { markdown: addHash(content, specFile), errors };
|
|
668
753
|
}
|
|
669
754
|
// ---------------------------------------------------------------------------
|
|
@@ -683,7 +768,7 @@ function compileSkill(spec, options = {}) {
|
|
|
683
768
|
// is CC-only here. See research/codex-prototype-findings.md (gaps).
|
|
684
769
|
/** Verify a subagent's allowed-tools contract — the rails are real tools. The
|
|
685
770
|
* detection lives in the shared `verifyToolContract` detector (one-detector-no-
|
|
686
|
-
* drift: compile + scan + the
|
|
771
|
+
* drift: compile + scan + the subagent-tool-contract lint rule call the same code). */
|
|
687
772
|
function validateAgentTools(tools, dialect) {
|
|
688
773
|
return (0, tool_contract_js_1.verifyToolContract)(tools, dialect).map((issue) => ({
|
|
689
774
|
type: "unknown-tool",
|
|
@@ -700,12 +785,30 @@ function renderAgentFrontmatter(spec) {
|
|
|
700
785
|
];
|
|
701
786
|
if (spec.model !== undefined)
|
|
702
787
|
fm.push(`model: ${spec.model}`);
|
|
788
|
+
if (spec.color !== undefined)
|
|
789
|
+
fm.push(`color: ${spec.color}`);
|
|
703
790
|
if (spec.tools && spec.tools.length > 0) {
|
|
704
791
|
fm.push(`tools: ${spec.tools.join(", ")}`);
|
|
705
792
|
}
|
|
793
|
+
if (spec.disallowedTools && spec.disallowedTools.length > 0) {
|
|
794
|
+
fm.push(`disallowedTools: ${spec.disallowedTools.join(", ")}`);
|
|
795
|
+
}
|
|
706
796
|
fm.push("", "---");
|
|
707
797
|
return fm.join("\n");
|
|
708
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
|
+
}
|
|
709
812
|
/** Render the subagent's named `##` system-prompt sections (verified like CLAUDE.md). */
|
|
710
813
|
function renderAgentSections(sections, basePath) {
|
|
711
814
|
const lines = [];
|
|
@@ -795,6 +898,21 @@ function compileAgent(spec, options) {
|
|
|
795
898
|
}
|
|
796
899
|
if (spec.tools)
|
|
797
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
|
+
}
|
|
798
916
|
if (Array.isArray(spec.body)) {
|
|
799
917
|
errors.push(...validateRefs(spec.body, basePath));
|
|
800
918
|
}
|
|
@@ -813,7 +931,12 @@ function compileAgent(spec, options) {
|
|
|
813
931
|
sections.push(renderOutputContract(spec.output));
|
|
814
932
|
const body = sections.join("\n\n");
|
|
815
933
|
errors.push(...checkInlineCode(body, DEFAULT_MAX_INLINE_CODE_LINES));
|
|
816
|
-
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";
|
|
817
940
|
return { markdown: addHash(content, specFile), errors };
|
|
818
941
|
}
|
|
819
942
|
/** Verify a railway: non-empty, bounded recovery, every delegate target real. */
|
package/dist/core/dialect.d.ts
CHANGED
|
@@ -55,5 +55,15 @@ export interface HarnessDialect {
|
|
|
55
55
|
* and OpenCode read; CC-only keys are omitted because they'd be inert noise).
|
|
56
56
|
*/
|
|
57
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[];
|
|
58
68
|
}
|
|
59
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
|