vigiles 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +20 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +51 -8
  5. package/dist/adapters/claude-code/dialect.js +19 -0
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/layout.js +3 -0
  13. package/dist/adapters/opencode/adapter.js +1 -0
  14. package/dist/adapters/opencode/layout.js +3 -0
  15. package/dist/check.d.ts +8 -0
  16. package/dist/check.js +27 -3
  17. package/dist/cli.js +323 -88
  18. package/dist/core/adapter.d.ts +10 -0
  19. package/dist/core/bash-effects.d.ts +41 -0
  20. package/dist/core/bash-effects.js +405 -0
  21. package/dist/core/compile.d.ts +3 -1
  22. package/dist/core/compile.js +162 -39
  23. package/dist/core/dialect.d.ts +10 -0
  24. package/dist/core/effects.d.ts +172 -0
  25. package/dist/core/effects.js +245 -0
  26. package/dist/core/layout.d.ts +6 -0
  27. package/dist/core/mcp-tool.d.ts +1 -1
  28. package/dist/core/orphans.js +21 -0
  29. package/dist/core/spec.d.ts +142 -3
  30. package/dist/core/spec.js +48 -0
  31. package/dist/core/tool-contract.d.ts +1 -1
  32. package/dist/core/types.d.ts +6 -6
  33. package/dist/core/validate.js +4 -4
  34. package/dist/harness-test.d.ts +7 -0
  35. package/dist/harness-test.js +19 -7
  36. package/dist/leaderboard.d.ts +2 -0
  37. package/dist/leaderboard.js +2 -0
  38. package/dist/optimize.d.ts +74 -0
  39. package/dist/optimize.js +94 -0
  40. package/dist/scaffold-test.d.ts +30 -0
  41. package/dist/scaffold-test.js +158 -0
  42. package/dist/scan.d.ts +40 -0
  43. package/dist/scan.js +91 -43
  44. package/dist/score-explainer.d.ts +69 -0
  45. package/dist/score-explainer.js +169 -0
  46. package/dist/test-coverage.d.ts +7 -0
  47. package/dist/test-coverage.js +39 -24
  48. package/package.json +2 -1
  49. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -4
  50. package/skills/edit-spec/SKILL.md +1 -1
@@ -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 === "string")
154
- continue;
155
- const r = fragment;
156
- switch (r._ref) {
157
- case "file": {
158
- const err = validateFileRef(r.path, basePath);
159
- if (err)
160
- errors.push(err);
161
- break;
162
- }
163
- case "cmd": {
164
- const err = validateCommandRef(r.command, basePath);
165
- if (err)
166
- errors.push(err);
167
- break;
168
- }
169
- case "skill": {
170
- const err = validateFileRef(r.path, basePath);
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
- break;
179
- }
180
- case "symbol": {
181
- const err = validateSymbolRef(r.file, r.symbol, basePath);
182
- if (err)
183
- errors.push(err);
184
- break;
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
- if (maxSectionLines && contentLines.length > maxSectionLines) {
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(maxSectionLines)}). Split into smaller named sections.`,
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 dialect get
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 content = renderSkillFrontmatter(spec, profile) + "\n\n" + sections.trim() + "\n";
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 agent-tool-contract lint rule call the same code). */
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 content = renderAgentFrontmatter(spec) + "\n\n" + body.trim() + "\n";
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. */
@@ -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