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
package/README.md CHANGED
@@ -138,8 +138,8 @@ That's why you can eval your harness on every change, not just once.
138
138
  ## More
139
139
 
140
140
  - **[Plugin health leaderboard →](docs/cli.md#scan-dir)** — point `scan` at a marketplace (e.g. `wshobson/agents`) and it ranks every plugin by structural health (0–100, A–F), worst issues first — still **no key**. Add `--trigger` for the model-gated column: do the skills actually fire?
141
- - **[CLI & GitHub Action →](docs/cli.md)** — every command, the Action (inputs / output / versioning), the Claude Code plugin, and the `lint` rules.
142
- - **[Skills →](docs/skills.md)** — consumer skills installed as a Claude Code plugin: `/plugin marketplace add zernie/vigiles` then `/plugin install vigiles@vigiles` (or let `vigiles init` do it). The model-invocable ones (`test-harness`, `strengthen`, `edit-spec`) fire on their own — ask _"test my skills"_, _"strengthen my rules"_, or _"add a rule to CLAUDE.md"_ and the agent reaches for them; `migrate-to-spec` and `linter-docs` are user-invoked.
141
+ - **[CLI & GitHub Action →](docs/cli.md)** — every command, the Action (inputs / output / versioning), and the Claude Code plugin. The full **[lint rules matrix →](docs/verifying-instruction-files.md#the-validation-rules--the-full-matrix)** lives with the linting guide.
142
+ - **[Skills →](docs/skills.md)** — consumer skills installed as a Claude Code plugin: `/plugin marketplace add zernie/vigiles` then `/plugin install vigiles@vigiles` (or let `vigiles init` do it). The model-invocable ones (`test-harness`, `strengthen`, `edit-spec`) fire on their own — ask _"test my skills"_, _"strengthen my rules"_, or _"add a rule to CLAUDE.md"_ and the agent reaches for them; `adopt-spec` and `linter-docs` are user-invoked.
143
143
  - **[Docs index →](docs/README.md)** · **[Research →](research/README.md)** · **[Related tools →](docs/related-tools.md)** (ast-grep, Dependency Cruiser, Ruler, rulesync).
144
144
  - Companion to [Feedback Loop Is All You Need](https://zernie.com/blog/feedback-loop-is-all-you-need).
145
145
 
@@ -23,6 +23,7 @@ exports.claudeCodeAdapter = {
23
23
  referenceVerification: true,
24
24
  harnessTesting: true,
25
25
  shellHooks: true,
26
+ subagents: true,
26
27
  },
27
28
  dialect: dialect_js_1.claudeCodeDialect,
28
29
  layout: layout_js_1.claudeCodeLayout,
@@ -22,6 +22,7 @@
22
22
  * (mirrors `.vigiles/active-skill.json`). The decision logic below is
23
23
  * harness-agnostic and fully testable.
24
24
  */
25
+ import type { PurityLevel } from "../../core/effects.js";
25
26
  /**
26
27
  * Parse an agent's allowed-tools contract from its compiled markdown.
27
28
  *
@@ -41,6 +42,14 @@ export declare function parseAgentTools(markdown: string): string[] | null;
41
42
  * rail (`tools:`) and the `disallowed-tools-contract` scan/lint.
42
43
  */
43
44
  export declare function parseAgentToolList(markdown: string, key: string): string[] | null;
45
+ /**
46
+ * Parse the declared purity floor from a compiled agent's `.md` — the
47
+ * `<!-- vigiles:purity:LEVEL -->` marker `compile` emits (see `purityMarker`).
48
+ * Returns null when no marker is present (the unit declared no floor, so the
49
+ * purity gate imposes no constraint). The single source of truth the runtime
50
+ * gate reads, exactly like `tools:` for the tool-contract rail.
51
+ */
52
+ export declare function parseAgentPurity(markdown: string): PurityLevel | null;
44
53
  export interface PreToolDecision {
45
54
  /** Whether the tool call is allowed (true) or blocked (false). */
46
55
  readonly allow: boolean;
@@ -64,11 +73,16 @@ export declare function clearActiveAgent(cwd: string): void;
64
73
  /** The path of the active agent's compiled `.md`, or null when none is active. */
65
74
  export declare function readActiveAgent(cwd: string): string | null;
66
75
  /**
67
- * PreToolUse-hook decision. If an agent is active, parse its compiled `.md`
68
- * tool contract and allow the call only when the tool is in the allowlist;
69
- * otherwise block and tell the model which tools it may use. With no active
70
- * agent (or an agent that inherits all tools), always allow the rail only
71
- * constrains agents that declared a contract.
76
+ * PreToolUse-hook decision. If an agent is active, enforce BOTH deterministic
77
+ * rails its compiled `.md` declares, in order:
78
+ *
79
+ * 1. the tool-contract rail (`tools:`) allow only listed tools;
80
+ * 2. the purity gate (`vigiles:purity:`) allow only calls within the declared
81
+ * effect floor, refining `Bash` by the live `command` (`decidePurityGate`).
82
+ *
83
+ * The first to deny wins, feeding its reason back to the model. With no active
84
+ * agent (or one that declared neither contract), always allow — the rails only
85
+ * constrain agents that opted in.
72
86
  */
73
- export declare function evaluatePreToolUse(cwd: string, tool: string): PreToolDecision;
87
+ export declare function evaluatePreToolUse(cwd: string, tool: string, command?: string): PreToolDecision;
74
88
  //# sourceMappingURL=agent-runtime.d.ts.map
@@ -26,6 +26,7 @@
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.parseAgentTools = parseAgentTools;
28
28
  exports.parseAgentToolList = parseAgentToolList;
29
+ exports.parseAgentPurity = parseAgentPurity;
29
30
  exports.decidePreToolUse = decidePreToolUse;
30
31
  exports.setActiveAgent = setActiveAgent;
31
32
  exports.clearActiveAgent = clearActiveAgent;
@@ -34,6 +35,9 @@ exports.evaluatePreToolUse = evaluatePreToolUse;
34
35
  const node_fs_1 = require("node:fs");
35
36
  const node_path_1 = require("node:path");
36
37
  const frontmatter_read_js_1 = require("../../core/frontmatter-read.js");
38
+ const effects_js_1 = require("../../core/effects.js");
39
+ const dialect_js_1 = require("./dialect.js");
40
+ const effect_region_js_1 = require("./effect-region.js");
37
41
  // ---------------------------------------------------------------------------
38
42
  // Parse the tool contract from a compiled agent .md
39
43
  // ---------------------------------------------------------------------------
@@ -60,6 +64,18 @@ function parseAgentTools(markdown) {
60
64
  function parseAgentToolList(markdown, key) {
61
65
  return (0, frontmatter_read_js_1.frontmatterList)((0, frontmatter_read_js_1.readFrontmatter)(markdown), key);
62
66
  }
67
+ const PURITY_RE = /<!--\s*vigiles:purity:(pure|bounded|unrestricted)\s*-->/;
68
+ /**
69
+ * Parse the declared purity floor from a compiled agent's `.md` — the
70
+ * `<!-- vigiles:purity:LEVEL -->` marker `compile` emits (see `purityMarker`).
71
+ * Returns null when no marker is present (the unit declared no floor, so the
72
+ * purity gate imposes no constraint). The single source of truth the runtime
73
+ * gate reads, exactly like `tools:` for the tool-contract rail.
74
+ */
75
+ function parseAgentPurity(markdown) {
76
+ const m = PURITY_RE.exec(markdown);
77
+ return m ? m[1] : null;
78
+ }
63
79
  /**
64
80
  * Decide whether `tool` is allowed under an agent's tool contract. Pure, so the
65
81
  * rail is unit-testable without spawning anything.
@@ -114,20 +130,47 @@ function readActiveAgent(cwd) {
114
130
  // PreToolUse-hook decision
115
131
  // ---------------------------------------------------------------------------
116
132
  /**
117
- * PreToolUse-hook decision. If an agent is active, parse its compiled `.md`
118
- * tool contract and allow the call only when the tool is in the allowlist;
119
- * otherwise block and tell the model which tools it may use. With no active
120
- * agent (or an agent that inherits all tools), always allow the rail only
121
- * constrains agents that declared a contract.
133
+ * PreToolUse-hook decision. If an agent is active, enforce BOTH deterministic
134
+ * rails its compiled `.md` declares, in order:
135
+ *
136
+ * 1. the tool-contract rail (`tools:`) allow only listed tools;
137
+ * 2. the purity gate (`vigiles:purity:`) allow only calls within the declared
138
+ * effect floor, refining `Bash` by the live `command` (`decidePurityGate`).
139
+ *
140
+ * The first to deny wins, feeding its reason back to the model. With no active
141
+ * agent (or one that declared neither contract), always allow — the rails only
142
+ * constrain agents that opted in.
122
143
  */
123
- function evaluatePreToolUse(cwd, tool) {
144
+ function evaluatePreToolUse(cwd, tool, command) {
124
145
  const agentPath = readActiveAgent(cwd);
125
146
  if (!agentPath)
126
147
  return { allow: true, message: "" };
127
148
  const full = (0, node_path_1.resolve)(cwd, agentPath);
128
149
  if (!(0, node_fs_1.existsSync)(full))
129
150
  return { allow: true, message: "" };
130
- const allowed = parseAgentTools((0, node_fs_1.readFileSync)(full, "utf-8"));
131
- return decidePreToolUse(allowed, tool);
151
+ const md = (0, node_fs_1.readFileSync)(full, "utf-8");
152
+ // 1) Tool-contract rail — the declared allowlist.
153
+ const rail = decidePreToolUse(parseAgentTools(md), tool);
154
+ if (!rail.allow)
155
+ return rail;
156
+ // 2) Purity gate — the declared effect floor, refined by the live command.
157
+ // If an effect boundary is declared, tighten to "pure" outside it and apply
158
+ // the declared purity (or "unrestricted") inside.
159
+ const purity = parseAgentPurity(md);
160
+ const boundary = (0, effect_region_js_1.hasEffectBoundary)(md);
161
+ if (boundary) {
162
+ const effective = (0, effect_region_js_1.readEffectActive)(cwd)
163
+ ? (purity ?? "unrestricted")
164
+ : "pure";
165
+ const gate = (0, effects_js_1.decidePurityGate)(effective, tool, command, dialect_js_1.claudeCodeDialect);
166
+ if (!gate.allow)
167
+ return gate;
168
+ }
169
+ else if (purity) {
170
+ const gate = (0, effects_js_1.decidePurityGate)(purity, tool, command, dialect_js_1.claudeCodeDialect);
171
+ if (!gate.allow)
172
+ return gate;
173
+ }
174
+ return { allow: true, message: "" };
132
175
  }
133
176
  //# sourceMappingURL=agent-runtime.js.map
@@ -63,5 +63,24 @@ exports.claudeCodeDialect = {
63
63
  // Claude Code reads the full SKILL.md frontmatter set (description,
64
64
  // disable-model-invocation, argument-hint, …).
65
65
  skillFrontmatter: "claude-code",
66
+ // Tools that produce side effects in Claude Code. The complement — the
67
+ // read-only tools — are: Read, Grep, Glob, LS, ToolSearch (and LSP/Agent
68
+ // which are not in the subagent catalog). Bash is side-effecting because
69
+ // `cat` and `rm -rf` are the same tool at the tool-name level — the
70
+ // sandbox is the only closure for subprocess effects.
71
+ sideEffectingTools: [
72
+ "Bash",
73
+ "BashOutput",
74
+ "KillBash",
75
+ "Edit",
76
+ "MultiEdit",
77
+ "Write",
78
+ "NotebookEdit",
79
+ "WebFetch",
80
+ "WebSearch",
81
+ "Skill",
82
+ "Task",
83
+ "TodoWrite",
84
+ ],
66
85
  };
67
86
  //# sourceMappingURL=dialect.js.map
@@ -0,0 +1,9 @@
1
+ /** Record that the agent has entered an effect boundary. */
2
+ export declare function setEffectActive(cwd: string): void;
3
+ /** Clear the effect-active marker (the agent exited the effect boundary). */
4
+ export declare function clearEffectActive(cwd: string): void;
5
+ /** True iff the agent is currently inside an effect boundary. Tolerates malformed file. */
6
+ export declare function readEffectActive(cwd: string): boolean;
7
+ /** True iff the compiled markdown declares an effect boundary (`<!-- vigiles:effect -->`). */
8
+ export declare function hasEffectBoundary(markdown: string): boolean;
9
+ //# sourceMappingURL=effect-region.d.ts.map
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setEffectActive = setEffectActive;
4
+ exports.clearEffectActive = clearEffectActive;
5
+ exports.readEffectActive = readEffectActive;
6
+ exports.hasEffectBoundary = hasEffectBoundary;
7
+ /**
8
+ * Effect-boundary position-aware state tracking.
9
+ * Mirrors active-unit tracking (.vigiles/active-agent.json / .vigiles/active-skill.json).
10
+ * The PreToolUse hook reads this file to decide whether the agent is inside an
11
+ * effect boundary (set by `vigiles effect-enter`) or outside it.
12
+ */
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = require("node:path");
15
+ const EFFECT_ACTIVE_PATH = ".vigiles/effect-active.json";
16
+ /** Record that the agent has entered an effect boundary. */
17
+ function setEffectActive(cwd) {
18
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
19
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
20
+ (0, node_fs_1.writeFileSync)(p, JSON.stringify({ active: true }) + "\n");
21
+ }
22
+ /** Clear the effect-active marker (the agent exited the effect boundary). */
23
+ function clearEffectActive(cwd) {
24
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
25
+ if ((0, node_fs_1.existsSync)(p))
26
+ (0, node_fs_1.rmSync)(p);
27
+ }
28
+ /** True iff the agent is currently inside an effect boundary. Tolerates malformed file. */
29
+ function readEffectActive(cwd) {
30
+ const p = (0, node_path_1.resolve)(cwd, EFFECT_ACTIVE_PATH);
31
+ if (!(0, node_fs_1.existsSync)(p))
32
+ return false;
33
+ try {
34
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
35
+ return parsed.active === true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /** True iff the compiled markdown declares an effect boundary (`<!-- vigiles:effect -->`). */
42
+ function hasEffectBoundary(markdown) {
43
+ return markdown.includes("<!-- vigiles:effect -->");
44
+ }
45
+ //# sourceMappingURL=effect-region.js.map
@@ -9,6 +9,9 @@ exports.claudeCodeLayout = {
9
9
  settingsFormat: "json",
10
10
  instructionFile: "CLAUDE.md",
11
11
  surfaceDirs: ["skills", "agents", "commands"],
12
+ skillDir: "skills",
13
+ agentDir: "agents",
14
+ commandDir: "commands",
12
15
  materializeRoot: ".claude",
13
16
  pluginRootToken: "${CLAUDE_PLUGIN_ROOT}",
14
17
  mcpConfigFile: ".mcp.json",
@@ -20,6 +20,7 @@
20
20
  * (`vigiles run-skill`). It is not a silent hook and runs nothing the spec
21
21
  * didn't declare as a gate.
22
22
  */
23
+ import type { PurityLevel } from "../../core/effects.js";
23
24
  export type RuntimeGate = {
24
25
  readonly kind: "cmd";
25
26
  readonly command: string;
@@ -86,6 +87,30 @@ export declare function setActiveSkill(cwd: string, skillPath: string): void;
86
87
  export declare function clearActiveSkill(cwd: string): void;
87
88
  /** The path of the active skill, or null when none is in progress. */
88
89
  export declare function readActiveSkill(cwd: string): string | null;
90
+ /**
91
+ * Parse the declared purity floor from a compiled SKILL.md — the
92
+ * `<!-- vigiles:purity:LEVEL -->` marker `compileSkill` emits (see
93
+ * `purityMarker` in compile.ts). Returns null when no marker is present (the
94
+ * skill declared no floor, so the purity gate imposes no constraint). Mirrors
95
+ * `parseAgentPurity` in agent-runtime.ts.
96
+ */
97
+ export declare function parseSkillPurity(markdown: string): PurityLevel | null;
98
+ /** A runtime allow/deny decision (mirrors PreToolDecision in agent-runtime.ts). */
99
+ export interface SkillPreToolDecision {
100
+ /** Whether the tool call is allowed (true) or blocked (false). */
101
+ readonly allow: boolean;
102
+ /** Message fed back to the model on a block; empty on allow. */
103
+ readonly message: string;
104
+ }
105
+ /**
106
+ * PreToolUse purity gate for skills. Mirrors `evaluatePreToolUse` in
107
+ * agent-runtime.ts, but enforces ONLY the purity floor — skills have no
108
+ * tools-allowlist rail (that's a separate, future concern). If a skill is
109
+ * active and declares a `vigiles:purity:` marker, `decidePurityGate` checks
110
+ * the live call (refining `Bash` by the concrete command via `isReadOnlyBash`).
111
+ * With no active skill, a missing `.md`, or no purity marker, always allows.
112
+ */
113
+ export declare function evaluateSkillPreToolUse(cwd: string, tool: string, command?: string): SkillPreToolDecision;
89
114
  export interface StopDecision {
90
115
  /** Whether the agent may stop (true) or must keep working (false). */
91
116
  readonly allow: boolean;
@@ -30,10 +30,15 @@ exports.runSkillGates = runSkillGates;
30
30
  exports.setActiveSkill = setActiveSkill;
31
31
  exports.clearActiveSkill = clearActiveSkill;
32
32
  exports.readActiveSkill = readActiveSkill;
33
+ exports.parseSkillPurity = parseSkillPurity;
34
+ exports.evaluateSkillPreToolUse = evaluateSkillPreToolUse;
33
35
  exports.evaluateStopHook = evaluateStopHook;
34
36
  const node_child_process_1 = require("node:child_process");
35
37
  const node_fs_1 = require("node:fs");
36
38
  const node_path_1 = require("node:path");
39
+ const effects_js_1 = require("../../core/effects.js");
40
+ const dialect_js_1 = require("./dialect.js");
41
+ const effect_region_js_1 = require("./effect-region.js");
37
42
  const STEP_RE = /^###\s+Step\s+(\d+)/;
38
43
  const GATE_CMD_RE = /<!--\s*vigiles:gate\s+"([^"]*)"(?:\s+retry:(\d+))?\s*-->/;
39
44
  const GATE_FILE_RE = /<!--\s*vigiles:gate\s+file:(\S+)\s*-->/;
@@ -257,6 +262,49 @@ function readActiveSkill(cwd) {
257
262
  return null;
258
263
  }
259
264
  }
265
+ // ---------------------------------------------------------------------------
266
+ // Skill purity gate (mirrors agent-runtime.ts parseAgentPurity / evaluatePreToolUse)
267
+ // ---------------------------------------------------------------------------
268
+ const PURITY_RE = /<!--\s*vigiles:purity:(pure|bounded|unrestricted)\s*-->/;
269
+ /**
270
+ * Parse the declared purity floor from a compiled SKILL.md — the
271
+ * `<!-- vigiles:purity:LEVEL -->` marker `compileSkill` emits (see
272
+ * `purityMarker` in compile.ts). Returns null when no marker is present (the
273
+ * skill declared no floor, so the purity gate imposes no constraint). Mirrors
274
+ * `parseAgentPurity` in agent-runtime.ts.
275
+ */
276
+ function parseSkillPurity(markdown) {
277
+ const m = PURITY_RE.exec(markdown);
278
+ return m ? m[1] : null;
279
+ }
280
+ /**
281
+ * PreToolUse purity gate for skills. Mirrors `evaluatePreToolUse` in
282
+ * agent-runtime.ts, but enforces ONLY the purity floor — skills have no
283
+ * tools-allowlist rail (that's a separate, future concern). If a skill is
284
+ * active and declares a `vigiles:purity:` marker, `decidePurityGate` checks
285
+ * the live call (refining `Bash` by the concrete command via `isReadOnlyBash`).
286
+ * With no active skill, a missing `.md`, or no purity marker, always allows.
287
+ */
288
+ function evaluateSkillPreToolUse(cwd, tool, command) {
289
+ const skillPath = readActiveSkill(cwd);
290
+ if (!skillPath)
291
+ return { allow: true, message: "" };
292
+ const full = (0, node_path_1.resolve)(cwd, skillPath);
293
+ if (!(0, node_fs_1.existsSync)(full))
294
+ return { allow: true, message: "" };
295
+ const md = (0, node_fs_1.readFileSync)(full, "utf-8");
296
+ const purity = parseSkillPurity(md);
297
+ const boundary = (0, effect_region_js_1.hasEffectBoundary)(md);
298
+ if (boundary) {
299
+ const effective = (0, effect_region_js_1.readEffectActive)(cwd)
300
+ ? (purity ?? "unrestricted")
301
+ : "pure";
302
+ return (0, effects_js_1.decidePurityGate)(effective, tool, command, dialect_js_1.claudeCodeDialect);
303
+ }
304
+ if (!purity)
305
+ return { allow: true, message: "" };
306
+ return (0, effects_js_1.decidePurityGate)(purity, tool, command, dialect_js_1.claudeCodeDialect);
307
+ }
260
308
  /**
261
309
  * Stop-hook decision. If a skill is active and declares a result gate, run it:
262
310
  * allow the stop only when the gate passes; otherwise block and tell the model
@@ -29,6 +29,9 @@ exports.codexAdapter = {
29
29
  referenceVerification: true,
30
30
  harnessTesting: true,
31
31
  shellHooks: true,
32
+ // Codex `[agents]` is a concurrency table, not a subagent tool-contract file
33
+ // — the subagent-surface rules report n/a here (a deliberate non-goal).
34
+ subagents: false,
32
35
  },
33
36
  dialect: dialect_js_1.codexDialect,
34
37
  layout: layout_js_1.codexLayout,
@@ -9,6 +9,9 @@ exports.codexLayout = {
9
9
  settingsFormat: "toml",
10
10
  instructionFile: "AGENTS.md",
11
11
  surfaceDirs: ["skills", "prompts"],
12
+ skillDir: "skills",
13
+ agentDir: "", // Codex `[agents]` is a TOML concurrency table, not a subagent dir
14
+ commandDir: "prompts",
12
15
  materializeRoot: ".codex",
13
16
  pluginRootToken: "${PLUGIN_ROOT}",
14
17
  mcpConfigFile: ".mcp.json",
@@ -29,6 +29,7 @@ exports.opencodeAdapter = {
29
29
  referenceVerification: true,
30
30
  harnessTesting: true,
31
31
  shellHooks: false,
32
+ subagents: true,
32
33
  },
33
34
  dialect: dialect_js_1.opencodeDialect,
34
35
  layout: layout_js_1.opencodeLayout,
@@ -16,6 +16,9 @@ exports.opencodeLayout = {
16
16
  // `.opencode/` segment. (Contrast Claude Code: root-level `skills/` surfaces
17
17
  // relocated under `.claude`.)
18
18
  surfaceDirs: [".opencode/agent", ".opencode/command"],
19
+ skillDir: ".opencode/skill",
20
+ agentDir: ".opencode/agent",
21
+ commandDir: ".opencode/command",
19
22
  materializeRoot: "",
20
23
  pluginRootToken: "${OPENCODE_PLUGIN_ROOT}",
21
24
  mcpConfigFile: "opencode.json",
package/dist/check.d.ts CHANGED
@@ -104,6 +104,14 @@ export declare function turns(opts: {
104
104
  }): Check<Trace>;
105
105
  /** The agent wrote (or left) a file at this path in the work dir. */
106
106
  export declare function wrote(path: string): Check<Trace>;
107
+ /**
108
+ * The agent did NOT leave a file at this path — the **side-effect boundary**
109
+ * negative: a skill that declares it writes only `out.txt` should leave nothing
110
+ * at `secrets.env`. The symmetric sibling of `wrote()`; pairs with
111
+ * `notTool(...)` to assert a unit stayed inside its declared write surface
112
+ * deterministically (no model judge).
113
+ */
114
+ export declare function didNotWrite(path: string): Check<Trace>;
107
115
  /** The named subagent (`Task` `subagent_type`) ran and passed every nested check. */
108
116
  export declare function subagent(name: string, checks: readonly Check<Trace>[]): Check<Trace>;
109
117
  /** The hook blocked the event (exit 2 / deny / block). */
package/dist/check.js CHANGED
@@ -11,6 +11,7 @@ exports.hookFired = hookFired;
11
11
  exports.received = received;
12
12
  exports.turns = turns;
13
13
  exports.wrote = wrote;
14
+ exports.didNotWrite = didNotWrite;
14
15
  exports.subagent = subagent;
15
16
  exports.blocked = blocked;
16
17
  exports.allowed = allowed;
@@ -236,16 +237,34 @@ function wrote(path) {
236
237
  toJSON: () => ({ kind: "wrote", path }),
237
238
  };
238
239
  }
240
+ /**
241
+ * The agent did NOT leave a file at this path — the **side-effect boundary**
242
+ * negative: a skill that declares it writes only `out.txt` should leave nothing
243
+ * at `secrets.env`. The symmetric sibling of `wrote()`; pairs with
244
+ * `notTool(...)` to assert a unit stayed inside its declared write surface
245
+ * deterministically (no model judge).
246
+ */
247
+ function didNotWrite(path) {
248
+ return {
249
+ kind: "didNotWrite",
250
+ eval: (t) => t.file(path) === null
251
+ ? ok(`file "${path}" was not created`)
252
+ : no(`expected the agent NOT to create "${path}", but it exists`),
253
+ toJSON: () => ({ kind: "didNotWrite", path }),
254
+ };
255
+ }
239
256
  // ---------------------------------------------------------------------------
240
257
  // Subagent — a `Task` run as a nested trace. Run checks over what the SUBAGENT
241
258
  // did, not just that `Task` fired. Composes the whole vocabulary recursively.
242
259
  // ---------------------------------------------------------------------------
243
- /** Wrap a subagent's tool calls as a minimal `Trace` so checks run over it. */
260
+ /** Wrap a subagent's tool calls + returned text as a minimal `Trace` so checks
261
+ * (incl. `output()` over the sub's RETURN — where a result() vigiles:ok/err block
262
+ * lands) run over it. */
244
263
  function subTrace(sub) {
245
264
  return {
246
265
  toolCalls: sub.toolCalls,
247
266
  hooks: [],
248
- output: "",
267
+ output: sub.output,
249
268
  modelRequests: [],
250
269
  turns: 0,
251
270
  subagents: [],
@@ -258,7 +277,12 @@ function subagent(name, checks) {
258
277
  kind: "subagent",
259
278
  eval: (t) => {
260
279
  const subs = t.subagents ?? [];
261
- const sub = subs.find((s) => s.name === name);
280
+ // A `--plugin-dir` agent's `subagent_type` is namespaced `plugin:agent`
281
+ // (e.g. "reviewer-spec:code-reviewer"), but callers pass the bare agent name
282
+ // — so match the full id OR its last `:`-segment. Non-namespaced (harness
283
+ // mock) names match exactly as before.
284
+ const bare = (n) => n.includes(":") ? n.slice(n.lastIndexOf(":") + 1) : n;
285
+ const sub = subs.find((s) => s.name === name || bare(s.name) === name);
262
286
  if (!sub) {
263
287
  return no(`expected subagent "${name}" to run; subagents that ran: ${subs.length > 0 ? `[${subs.map((s) => s.name).join(", ")}]` : "none"}`);
264
288
  }