vigiles 5.0.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +15 -9
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +66 -37
  5. package/dist/adapters/claude-code/dialect.js +37 -2
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/eval.d.ts +94 -0
  13. package/dist/adapters/codex/eval.js +227 -0
  14. package/dist/adapters/codex/layout.js +3 -0
  15. package/dist/adapters/opencode/adapter.js +1 -0
  16. package/dist/adapters/opencode/layout.js +3 -0
  17. package/dist/check.d.ts +8 -0
  18. package/dist/check.js +27 -3
  19. package/dist/cli.js +712 -21
  20. package/dist/codex.d.ts +1 -0
  21. package/dist/codex.js +3 -0
  22. package/dist/core/adapter.d.ts +10 -0
  23. package/dist/core/bash-effects.d.ts +41 -0
  24. package/dist/core/bash-effects.js +405 -0
  25. package/dist/core/compile.d.ts +3 -1
  26. package/dist/core/compile.js +169 -74
  27. package/dist/core/description-overlap.d.ts +27 -0
  28. package/dist/core/description-overlap.js +53 -0
  29. package/dist/core/dialect.d.ts +18 -0
  30. package/dist/core/effects.d.ts +172 -0
  31. package/dist/core/effects.js +245 -0
  32. package/dist/core/frontmatter-read.d.ts +25 -0
  33. package/dist/core/frontmatter-read.js +138 -0
  34. package/dist/core/hook-events.d.ts +34 -0
  35. package/dist/core/hook-events.js +48 -0
  36. package/dist/core/layout.d.ts +6 -0
  37. package/dist/core/mcp-config.d.ts +20 -0
  38. package/dist/core/mcp-config.js +40 -0
  39. package/dist/core/mcp-hook.d.ts +35 -0
  40. package/dist/core/mcp-hook.js +70 -0
  41. package/dist/core/mcp-tool.d.ts +50 -0
  42. package/dist/core/mcp-tool.js +61 -0
  43. package/dist/core/orphans.js +21 -0
  44. package/dist/core/spec.d.ts +142 -3
  45. package/dist/core/spec.js +48 -0
  46. package/dist/core/tool-contract.d.ts +68 -0
  47. package/dist/core/tool-contract.js +113 -0
  48. package/dist/core/types.d.ts +91 -2
  49. package/dist/core/validate.js +23 -1
  50. package/dist/eval.d.ts +69 -13
  51. package/dist/eval.js +106 -51
  52. package/dist/harness-test.d.ts +7 -0
  53. package/dist/harness-test.js +19 -7
  54. package/dist/leaderboard.d.ts +2 -0
  55. package/dist/leaderboard.js +63 -3
  56. package/dist/optimize.d.ts +74 -0
  57. package/dist/optimize.js +94 -0
  58. package/dist/plugin-loader.d.ts +1 -0
  59. package/dist/plugin-loader.js +71 -18
  60. package/dist/scaffold-test.d.ts +30 -0
  61. package/dist/scaffold-test.js +158 -0
  62. package/dist/scan-behavioral.d.ts +73 -0
  63. package/dist/scan-behavioral.js +150 -0
  64. package/dist/scan.d.ts +166 -1
  65. package/dist/scan.js +622 -55
  66. package/dist/score-explainer.d.ts +69 -0
  67. package/dist/score-explainer.js +169 -0
  68. package/dist/test-coverage.d.ts +7 -0
  69. package/dist/test-coverage.js +39 -24
  70. package/package.json +2 -1
  71. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
  72. package/skills/edit-spec/SKILL.md +1 -1
@@ -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 === "string")
153
- continue;
154
- const r = fragment;
155
- switch (r._ref) {
156
- case "file": {
157
- const err = validateFileRef(r.path, basePath);
158
- if (err)
159
- errors.push(err);
160
- break;
161
- }
162
- case "cmd": {
163
- const err = validateCommandRef(r.command, basePath);
164
- if (err)
165
- errors.push(err);
166
- break;
167
- }
168
- case "skill": {
169
- const err = validateFileRef(r.path, basePath);
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
- break;
178
- }
179
- case "symbol": {
180
- const err = validateSymbolRef(r.file, r.symbol, basePath);
181
- if (err)
182
- errors.push(err);
183
- break;
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
- if (maxSectionLines && contentLines.length > maxSectionLines) {
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(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.`,
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 dialect get
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 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";
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
- /** Closest known tool by edit distance (≤ 3), for a "did you mean" hint. */
684
- function closestTool(tool, dialect) {
685
- let best = null;
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
- const never = new Set(dialect.neverAvailableTools);
699
- const errors = [];
700
- for (const tool of tools) {
701
- if (never.has(tool)) {
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 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";
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
@@ -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