@devrik-tools/claude-gates 0.3.0 → 0.4.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.
@@ -11,13 +11,13 @@
11
11
  "name": "gates",
12
12
  "source": "./plugins/gates",
13
13
  "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
- "version": "0.3.0"
14
+ "version": "0.4.0"
15
15
  },
16
16
  {
17
17
  "name": "tasks",
18
18
  "source": "./plugins/tasks",
19
19
  "description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
20
- "version": "0.3.0"
20
+ "version": "0.4.0"
21
21
  }
22
22
  ]
23
23
  }
package/README.md CHANGED
@@ -5,7 +5,7 @@ Claude executes a tool and **block or warn** when something breaks a rule: a des
5
5
  command, a write to a protected file, a delegation with no brief, and more. Everything is
6
6
  **configurable per project** and can be **turned on/off** whenever you want.
7
7
 
8
- The core idea: instead of trusting the model to *remember* the rules, a **deterministic**
8
+ The core idea: instead of trusting the model to _remember_ the rules, a **deterministic**
9
9
  hook enforces them. A `git reset --hard` does not run because the model chose to behave —
10
10
  it is blocked because a gate intercepts it.
11
11
 
@@ -65,58 +65,65 @@ works even if you install one on its own.
65
65
  `[on]` = enabled by default; `[off]` = enable it if you want it.
66
66
 
67
67
  ### 🔒 Security — hard blocks on destructive actions
68
- | Gate | | What it does |
69
- |---|---|---|
70
- | `bash-commands` | on | Blocks `git reset --hard`, `rm -rf` over protected areas, force push, and killing processes by name. |
71
- | `block-remote-publish` | on | Blocks `git push`, `gh pr merge`, `gh release create` without authorization. Set `blockRemotePublish: false` to let the agent publish on its own. |
72
- | `protected-paths` | on | Blocks writes to `.env`, lockfiles and the harness itself. |
73
- | `root-whitelist` | on | Blocks new root-level files/folders outside a whitelist. |
74
- | `no-blocking` | off | Blocks `sleep`, `tail -f`, polling loops and foreground servers. |
68
+
69
+ | Gate | | What it does |
70
+ | ---------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
71
+ | `bash-commands` | on | Blocks `git reset --hard`, `rm -rf` over protected areas, force push, and killing processes by name. |
72
+ | `block-remote-publish` | on | Blocks `git push`, `gh pr merge`, `gh release create` without authorization. Set `blockRemotePublish: false` to let the agent publish on its own. |
73
+ | `protected-paths` | on | Blocks writes to `.env`, lockfiles and the harness itself. |
74
+ | `root-whitelist` | on | Blocks new root-level files/folders outside a whitelist. |
75
+ | `no-blocking` | off | Blocks `sleep`, `tail -f`, polling loops and foreground servers. |
75
76
 
76
77
  ### 🤝 Delegation — requirements on the brief when delegating to a subagent
77
- | Gate | | What it does |
78
- |---|---|---|
79
- | `brief-before-delegate` | off | Requires goal, steps and done-when criteria in the prompt. |
80
- | `intent-flow` | off | Requires IN SCOPE / OUT OF SCOPE / EDGE CASES sections. |
81
- | `risk-level` | off | Requires a declared level (QUESTION/MICRO/STANDARD/HIGH-RISK). |
82
- | `circuit-breaker` | off | Cuts the same delegation retried without real changes. |
83
- | `no-memory-dependency` | off | Blocks a brief that relies on the subagent "remembering" the chat (add `memory-not-needed` to allow a false positive). |
78
+
79
+ | Gate | | What it does |
80
+ | ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------------- |
81
+ | `brief-before-delegate` | off | Requires goal, steps and done-when criteria in the prompt. |
82
+ | `intent-flow` | off | Requires IN SCOPE / OUT OF SCOPE / EDGE CASES sections. |
83
+ | `risk-level` | off | Requires a declared level (QUESTION/MICRO/STANDARD/HIGH-RISK). |
84
+ | `circuit-breaker` | off | Cuts the same delegation retried without real changes. |
85
+ | `no-memory-dependency` | off | Blocks a brief that relies on the subagent "remembering" the chat (add `memory-not-needed` to allow a false positive). |
84
86
 
85
87
  ### 📋 Spec-driven flow — only relevant if the project adopted spec-driven development
86
- | Gate | | What it does |
87
- |---|---|---|
88
- | `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
89
- | `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
88
+
89
+ | Gate | | What it does |
90
+ | ------------------------- | --- | -------------------------------------------------------------------- |
91
+ | `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
92
+ | `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
90
93
  | `implementation-pipeline` | off | Requires declaring definition → writing → validation → QA → closure. |
91
- | `mandatory-flow` | off | Requires an active task with a contract before implementing. |
92
- | `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
94
+ | `mandatory-flow` | off | Requires an active task with a contract before implementing. |
95
+ | `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
93
96
 
94
97
  ### ✨ Quality — code hygiene, diagnosis and language
95
- | Gate | | What it does |
96
- |---|---|---|
97
- | `dependency-skills` | on | Blocks a new direct dependency with no matching skill (declare it in `depsWithoutOwnApi` if it needs none). |
98
- | `root-cause-first` | off | Requires an origin→symptom diagnosis before a patch. |
99
- | `audit-before-build` | off | Before a new script/gate, requires stating that nothing existing covers it. |
100
- | `never-assume` | off | Flags unverified assumptions in briefs and code. |
101
- | `rule-skill-autodiscovery` | off | Loads gates the project declares in its `rules/` and `skills/`. |
102
- | `recurrence-lock` | on | A second occurrence of a defect class requires its deterministic block. |
103
- | `test-after-implementation` | off | Blocks a test written after its paired implementation (add `test-after-impl:allow` for a regression test). |
104
- | `no-reconfirm` | on | Never re-ask what you already answered. |
105
- | `neutral-spanish` | on | Blocks voseo or regional lexicon in written text (add `neutral-spanish:allow` for a deliberate quote/fixture). |
106
- | `diagnosis-before-patch` | on | Warns when timeouts/retries change without evidence. |
98
+
99
+ | Gate | | What it does |
100
+ | --------------------------- | --- | -------------------------------------------------------------------------------------------------------------- |
101
+ | `dependency-skills` | on | Blocks a new direct dependency with no matching skill (declare it in `depsWithoutOwnApi` if it needs none). |
102
+ | `root-cause-first` | off | Requires an origin→symptom diagnosis before a patch. |
103
+ | `audit-before-build` | off | Before a new script/gate, requires stating that nothing existing covers it. |
104
+ | `never-assume` | off | Flags unverified assumptions in briefs and code. |
105
+ | `rule-skill-autodiscovery` | off | Loads gates the project declares in its `rules/` and `skills/`. |
106
+ | `recurrence-lock` | on | A second occurrence of a defect class requires its deterministic block. |
107
+ | `test-after-implementation` | off | Blocks a test written after its paired implementation (add `test-after-impl:allow` for a regression test). |
108
+ | `no-reconfirm` | on | Never re-ask what you already answered. |
109
+ | `neutral-spanish` | on | Blocks voseo or regional lexicon in written text (add `neutral-spanish:allow` for a deliberate quote/fixture). |
110
+ | `diagnosis-before-patch` | on | Warns when timeouts/retries change without evidence. |
107
111
 
108
112
  ### 🔎 Tool discovery — don't reinvent the wheel
109
- | Gate | | What it does |
110
- |---|---|---|
113
+
114
+ | Gate | | What it does |
115
+ | -------------------- | --- | ------------------------------------------------------------------------------------------------------------ |
111
116
  | `reuse-before-build` | off | Before building a tool, consults the project tool map; blocks if you did not audit (local → Context7 → web). |
112
- | `tool-map` | off | Records discovered tools in `.ai/tool-map.json` so exploration is not repeated. |
117
+ | `tool-map` | off | Records discovered tools in `.ai/tool-map.json` so exploration is not repeated. |
113
118
 
114
119
  ### 🏭 Forge pipeline — enforces the forge workflow
115
- | Gate | | What it does |
116
- |---|---|---|
120
+
121
+ | Gate | | What it does |
122
+ | ------------ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
123
  | `forge-flow` | off | In a project that adopted [forge](https://github.com/DevRik99/forge-mcp), blocks editing/running unless an active forge run exists. Closes the hole the MCP cannot: it forces you through the pipeline. |
118
124
 
119
- ### 🩺 Session start — startup checks *(work in progress)*
125
+ ### 🩺 Session start — startup checks _(work in progress)_
126
+
120
127
  `doctor`, `ask-adoption`, `wiring-check` — declared in the catalog; their scripts are being
121
128
  migrated next.
122
129
 
@@ -180,8 +187,17 @@ claude-gates init --no-install # write the config but do not install the
180
187
  # Inspect the catalog:
181
188
  claude-gates registry --list # list families and gates
182
189
  claude-gates registry --check # validate registry.json
190
+
191
+ # Verify the gates actually react (not just that they are wired):
192
+ claude-gates smoke # feed each gate a known violation; exits non-zero if any does not block/warn
183
193
  ```
184
194
 
195
+ `smoke` is the behavioral check `registry --check` (structure) and the doctor hook (files
196
+ exist) do not do: it feeds every gate a known violation and confirms it really denies/warns.
197
+ Gates whose violation needs seeded state (a db, a git repo, cross-call state) report `skip`,
198
+ never a false pass. Run it after install, or in CI, to catch a gate that is wired but silently
199
+ allows.
200
+
185
201
  ---
186
202
 
187
203
  ## Repository layout
package/cli/index.mjs CHANGED
@@ -1,104 +1,154 @@
1
- #!/usr/bin/env node
2
- // Entry point (commander). Commands:
3
- // init interactive (or flag-driven) selection of gates, per project or globally
4
- // registry --check validates registry.json; --list prints the catalog
5
-
6
- import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { Command } from 'commander';
9
- import {
10
- EXIT_CODE,
11
- LIST_SEPARATOR,
12
- REGISTRY_PATH,
13
- REPOSITORY_ROOT,
14
- } from './constants.mjs';
15
- import { loadRegistry, validateRegistry } from './registry.mjs';
16
- import { registerTaskCommand } from './task.mjs';
17
-
18
- const packageManifest = JSON.parse(
19
- readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
20
- );
21
-
22
- function fail(message) {
23
- process.stderr.write(`${message}\n`);
24
- process.exit(EXIT_CODE.FAILURE);
25
- }
26
-
27
- function splitList(value) {
28
- return value
29
- .split(LIST_SEPARATOR)
30
- .map((item) => item.trim())
31
- .filter(Boolean);
32
- }
33
-
34
- function registryCheck() {
35
- const problems = validateRegistry(
36
- JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
37
- );
38
- if (problems.length > 0) {
39
- fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
40
- }
41
- process.stdout.write('registry.json OK\n');
42
- }
43
-
44
- function registryList() {
45
- const registry = loadRegistry();
46
- for (const family of registry.families) {
47
- process.stdout.write(`${family.name} (${family.id})\n`);
48
- for (const gate of family.gates) {
49
- const marker = gate.default ? '*' : ' ';
50
- process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
51
- }
52
- }
53
- process.stdout.write('\n* = recommended default\n');
54
- }
55
-
56
- const program = new Command()
57
- .name(packageManifest.name)
58
- .description(packageManifest.description)
59
- .version(packageManifest.version);
60
-
61
- program
62
- .command('init')
63
- .description('Choose which gates to adopt and write the config.')
64
- .option('--project', 'apply to this project (<root>/.ai/config.json)')
65
- .option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
66
- .option('--defaults', 'enable the recommended defaults')
67
- .option('--all', 'enable every gate')
68
- .option('--none', "record a 'no' so you are not asked again")
69
- .option(
70
- '--families <ids>',
71
- 'enable whole families, comma-separated',
72
- splitList,
73
- )
74
- .option(
75
- '--gates <ids>',
76
- 'enable individual gates, comma-separated',
77
- splitList,
78
- )
79
- .option('-y, --yes', 'never prompt; use flags and defaults')
80
- .option('--no-install', 'write the config but do not install the plugin')
81
- .option(
82
- '--no-remove-previous',
83
- 'keep any previously installed plugin version instead of removing it first',
84
- )
85
- .option('--dry-run', 'show the selection without writing')
86
- .action(async (options) => {
87
- const { runInit } = await import('./init.mjs');
88
- await runInit(options);
89
- });
90
-
91
- program
92
- .command('registry')
93
- .description('Inspect registry.json.')
94
- .option('--check', 'validate the registry')
95
- .option('--list', 'print families and gates')
96
- .action((options) => {
97
- if (options.check) return registryCheck();
98
- if (options.list) return registryList();
99
- return fail('registry needs --check or --list');
100
- });
101
-
102
- registerTaskCommand(program);
103
-
104
- program.parseAsync(process.argv).catch((error) => fail(error.message));
1
+ #!/usr/bin/env node
2
+ // Entry point (commander). Commands:
3
+ // init interactive (or flag-driven) selection of gates, per project or globally
4
+ // registry --check validates registry.json; --list prints the catalog
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { Command } from 'commander';
9
+ import {
10
+ EXIT_CODE,
11
+ LIST_SEPARATOR,
12
+ REGISTRY_PATH,
13
+ REPOSITORY_ROOT,
14
+ } from './constants.mjs';
15
+ import { loadRegistry, validateRegistry } from './registry.mjs';
16
+ import { runSmoke, OUTCOMES } from './smoke.mjs';
17
+ import { registerTaskCommand } from './task.mjs';
18
+
19
+ const packageManifest = JSON.parse(
20
+ readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
21
+ );
22
+
23
+ function fail(message) {
24
+ process.stderr.write(`${message}\n`);
25
+ process.exit(EXIT_CODE.FAILURE);
26
+ }
27
+
28
+ function splitList(value) {
29
+ return value
30
+ .split(LIST_SEPARATOR)
31
+ .map((item) => item.trim())
32
+ .filter(Boolean);
33
+ }
34
+
35
+ function registryCheck() {
36
+ const problems = validateRegistry(
37
+ JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
38
+ );
39
+ if (problems.length > 0) {
40
+ fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
41
+ }
42
+ process.stdout.write('registry.json OK\n');
43
+ }
44
+
45
+ function registryList() {
46
+ const registry = loadRegistry();
47
+ for (const family of registry.families) {
48
+ process.stdout.write(`${family.name} (${family.id})\n`);
49
+ for (const gate of family.gates) {
50
+ const marker = gate.default ? '*' : ' ';
51
+ process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
52
+ }
53
+ }
54
+ process.stdout.write('\n* = recommended default\n');
55
+ }
56
+
57
+ const SMOKE_FIXTURES_PATH = join(REPOSITORY_ROOT, 'cli', 'smoke-fixtures.json');
58
+ // Width the gate id is padded to so the expected/got columns line up in the report.
59
+ const GATE_ID_COLUMN_WIDTH = 28;
60
+ const OUTCOME_MARK = {
61
+ [OUTCOMES.REACTED]: 'ok ',
62
+ [OUTCOMES.NO_REACTION]: 'FAIL',
63
+ [OUTCOMES.SKIPPED]: 'skip',
64
+ [OUTCOMES.ERROR]: 'ERR ',
65
+ };
66
+
67
+ // Runs each gate against a known violation and reports whether it actually reacts. Unlike
68
+ // `registry --check` (structure) and the doctor hook (files exist), this confirms behavior:
69
+ // it is the check that catches a gate silently allowing its own known violation. Exits
70
+ // non-zero if any gate did not react (a real defect) — so CI or a post-install step can gate
71
+ // on it. Gates whose violation needs seeded state are reported `skip`, never counted as pass.
72
+ function smokeGates() {
73
+ const manifest = JSON.parse(
74
+ readFileSync(SMOKE_FIXTURES_PATH, 'utf8'),
75
+ ).fixtures;
76
+ const { results, tally } = runSmoke(manifest);
77
+
78
+ for (const result of results) {
79
+ const detail = result.reason ? ` (${result.reason})` : '';
80
+ process.stdout.write(
81
+ `${OUTCOME_MARK[result.outcome]} ${result.id.padEnd(GATE_ID_COLUMN_WIDTH)} ` +
82
+ `expected=${result.expected} got=${result.got ?? '-'}${detail}\n`,
83
+ );
84
+ }
85
+ process.stdout.write(
86
+ `\nreacted ${tally.reacted} · no-reaction ${tally['no-reaction']} · ` +
87
+ `skipped ${tally.skipped} · error ${tally.error}\n`,
88
+ );
89
+ if (tally['no-reaction'] > 0 || tally.error > 0) {
90
+ process.stdout.write(
91
+ '\nSome gates did not react to their own known violation. This is a defect: ' +
92
+ 'the gate is wired but does not block/warn as declared.\n',
93
+ );
94
+ process.exit(EXIT_CODE.FAILURE);
95
+ }
96
+ }
97
+
98
+ const program = new Command()
99
+ .name(packageManifest.name)
100
+ .description(packageManifest.description)
101
+ .version(packageManifest.version);
102
+
103
+ program
104
+ .command('init')
105
+ .description('Choose which gates to adopt and write the config.')
106
+ .option('--project', 'apply to this project (<root>/.ai/config.json)')
107
+ .option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
108
+ .option('--defaults', 'enable the recommended defaults')
109
+ .option('--all', 'enable every gate')
110
+ .option('--none', "record a 'no' so you are not asked again")
111
+ .option(
112
+ '--families <ids>',
113
+ 'enable whole families, comma-separated',
114
+ splitList,
115
+ )
116
+ .option(
117
+ '--gates <ids>',
118
+ 'enable individual gates, comma-separated',
119
+ splitList,
120
+ )
121
+ .option('-y, --yes', 'never prompt; use flags and defaults')
122
+ .option('--no-install', 'write the config but do not install the plugin')
123
+ .option(
124
+ '--no-remove-previous',
125
+ 'keep any previously installed plugin version instead of removing it first',
126
+ )
127
+ .option('--dry-run', 'show the selection without writing')
128
+ .action(async (options) => {
129
+ const { runInit } = await import('./init.mjs');
130
+ await runInit(options);
131
+ });
132
+
133
+ program
134
+ .command('registry')
135
+ .description('Inspect registry.json.')
136
+ .option('--check', 'validate the registry')
137
+ .option('--list', 'print families and gates')
138
+ .action((options) => {
139
+ if (options.check) return registryCheck();
140
+ if (options.list) return registryList();
141
+ return fail('registry needs --check or --list');
142
+ });
143
+
144
+ program
145
+ .command('smoke')
146
+ .description(
147
+ 'Feed each gate a known violation and confirm it actually reacts (deny/warn). ' +
148
+ 'Catches a gate that is wired but silently allows. Exits non-zero on any no-reaction.',
149
+ )
150
+ .action(() => smokeGates());
151
+
152
+ registerTaskCommand(program);
153
+
154
+ program.parseAsync(process.argv).catch((error) => fail(error.message));
@@ -0,0 +1,390 @@
1
+ {
2
+ "_comment": "One known-violation payload per gate, derived from each gate's own test.mjs, for the `smoke` command. The runner (cli/smoke.mjs) feeds each payload to the gate and confirms it reacts (deny/warn) as declared. `needsState: true` marks a gate whose violation cannot be reproduced with a single stdin payload alone (it reads a seeded file, a sqlite db, or accumulates state across calls) — the runner SKIPS those honestly instead of miscounting them as a defect. `type` is deny/warn/none. When enabledByDefault is false the runner enables the gate via config before running it. This file is test-fixture data (it embeds regional-Spanish and destructive-command strings on purpose): neutral-spanish:allow",
3
+ "fixtures": [
4
+ {
5
+ "id": "bash-commands",
6
+ "configKey": "blockDestructiveShellCommands",
7
+ "enabledByDefault": true,
8
+ "type": "deny",
9
+ "payload": {
10
+ "tool_name": "Bash",
11
+ "tool_input": { "command": "git reset --hard HEAD~3" }
12
+ },
13
+ "needsState": false
14
+ },
15
+ {
16
+ "id": "block-remote-publish",
17
+ "configKey": "blockRemotePublish",
18
+ "enabledByDefault": true,
19
+ "type": "deny",
20
+ "payload": {
21
+ "tool_name": "Bash",
22
+ "tool_input": { "command": "git push origin main" }
23
+ },
24
+ "needsState": false
25
+ },
26
+ {
27
+ "id": "protected-paths",
28
+ "configKey": "blockWritesToProtectedPaths",
29
+ "enabledByDefault": true,
30
+ "type": "deny",
31
+ "payload": {
32
+ "tool_name": "Write",
33
+ "tool_input": { "file_path": "/repo/.env" }
34
+ },
35
+ "needsState": false
36
+ },
37
+ {
38
+ "id": "root-whitelist",
39
+ "configKey": "blockPathsOutsideRootWhitelist",
40
+ "enabledByDefault": true,
41
+ "type": "deny",
42
+ "payload": {
43
+ "tool_name": "Write",
44
+ "tool_input": { "file_path": "random-orphan.txt" }
45
+ },
46
+ "needsState": true,
47
+ "note": "file_path must resolve against the scratch project root; a literal alone does not reproduce root detection."
48
+ },
49
+ {
50
+ "id": "no-blocking",
51
+ "configKey": "blockWaitingCommands",
52
+ "enabledByDefault": false,
53
+ "type": "deny",
54
+ "payload": {
55
+ "tool_name": "Bash",
56
+ "tool_input": { "command": "sleep 30" }
57
+ },
58
+ "needsState": false
59
+ },
60
+ {
61
+ "id": "brief-before-delegate",
62
+ "configKey": "requireBriefBeforeDelegating",
63
+ "enabledByDefault": false,
64
+ "type": "deny",
65
+ "payload": {
66
+ "tool_name": "Agent",
67
+ "tool_input": { "prompt": "Fix the login bug please, thanks." }
68
+ },
69
+ "needsState": false
70
+ },
71
+ {
72
+ "id": "intent-flow",
73
+ "configKey": "requireScopeListBeforeDelegating",
74
+ "enabledByDefault": false,
75
+ "type": "deny",
76
+ "payload": {
77
+ "tool_name": "Agent",
78
+ "tool_input": {
79
+ "prompt": "Implementa el cobro del pago con la nueva pasarela de dinero para el checkout."
80
+ }
81
+ },
82
+ "needsState": false
83
+ },
84
+ {
85
+ "id": "risk-level",
86
+ "configKey": "requireDeclaredRiskLevel",
87
+ "enabledByDefault": false,
88
+ "type": "deny",
89
+ "payload": {
90
+ "tool_name": "Agent",
91
+ "tool_input": {
92
+ "prompt": "Arregla el boton que no cambia de color en la pagina."
93
+ }
94
+ },
95
+ "needsState": false
96
+ },
97
+ {
98
+ "id": "circuit-breaker",
99
+ "configKey": "requireCircuitBreakerOnDelegation",
100
+ "enabledByDefault": false,
101
+ "type": "deny",
102
+ "payload": {
103
+ "tool_name": "Agent",
104
+ "tool_input": { "prompt": "fix the scoring threshold" }
105
+ },
106
+ "needsState": true,
107
+ "note": "stateful: denies only on the 3rd identical retry under the same session id."
108
+ },
109
+ {
110
+ "id": "no-memory-dependency",
111
+ "configKey": "warnMemoryDependencyInBrief",
112
+ "enabledByDefault": false,
113
+ "type": "deny",
114
+ "payload": {
115
+ "tool_name": "Agent",
116
+ "tool_input": {
117
+ "prompt": "Acordate de lo que hablamos antes y aplica el mismo criterio."
118
+ }
119
+ },
120
+ "needsState": false
121
+ },
122
+ {
123
+ "id": "force-parallel",
124
+ "configKey": "warnSequentialDelegations",
125
+ "enabledByDefault": false,
126
+ "type": "warn",
127
+ "payload": { "tool_name": "Task", "tool_input": { "prompt": "do work" } },
128
+ "needsState": true,
129
+ "note": "stateful: warns only on the 3rd consecutive delegation under one session."
130
+ },
131
+ {
132
+ "id": "feature-catalog",
133
+ "configKey": "requireFeatureCatalog",
134
+ "enabledByDefault": true,
135
+ "type": "deny",
136
+ "payload": {
137
+ "tool_name": "Write",
138
+ "tool_input": {
139
+ "file_path": "feature_list.json",
140
+ "content": "{\"features\":[{\"name\":\"a\",\"status\":\"in_progress\"},{\"name\":\"b\",\"status\":\"in_progress\"}]}"
141
+ }
142
+ },
143
+ "needsState": true,
144
+ "note": "denies >maxInProgress; needs the catalog shape and threshold to line up."
145
+ },
146
+ {
147
+ "id": "sdd-specs",
148
+ "configKey": "requireSpecBeforeImplementing",
149
+ "enabledByDefault": false,
150
+ "type": "deny",
151
+ "payload": {
152
+ "tool_name": "Agent",
153
+ "tool_input": {
154
+ "prompt": "Nivel: STANDARD\nImplementa el flujo de pago."
155
+ }
156
+ },
157
+ "needsState": true,
158
+ "note": "auto-off unless a features tree exists; needs seeded .ai/feature_list.json + features dir."
159
+ },
160
+ {
161
+ "id": "implementation-pipeline",
162
+ "configKey": "requireImplementationPipeline",
163
+ "enabledByDefault": false,
164
+ "type": "deny",
165
+ "payload": {
166
+ "tool_name": "Agent",
167
+ "tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
168
+ },
169
+ "needsState": false
170
+ },
171
+ {
172
+ "id": "mandatory-flow",
173
+ "configKey": "requireLiveTaskWhenImplementing",
174
+ "enabledByDefault": false,
175
+ "type": "deny",
176
+ "payload": {
177
+ "tool_name": "Agent",
178
+ "tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
179
+ },
180
+ "needsState": false
181
+ },
182
+ {
183
+ "id": "test-matrix",
184
+ "configKey": "requireTestMatrixWhenImplementing",
185
+ "enabledByDefault": false,
186
+ "type": "deny",
187
+ "payload": {
188
+ "tool_name": "Agent",
189
+ "tool_input": {
190
+ "prompt": "Nivel: STANDARD\nImplementa el checkout.",
191
+ "subagent_type": "backend"
192
+ }
193
+ },
194
+ "needsState": false
195
+ },
196
+ {
197
+ "id": "dependency-skills",
198
+ "configKey": "requireSkillForNewDependency",
199
+ "enabledByDefault": true,
200
+ "type": "deny",
201
+ "payload": {
202
+ "tool_name": "Write",
203
+ "tool_input": {
204
+ "file_path": "/repo/package.json",
205
+ "content": "{\"dependencies\":{\"stripe\":\"1.0.0\"}}"
206
+ }
207
+ },
208
+ "needsState": false
209
+ },
210
+ {
211
+ "id": "root-cause-first",
212
+ "configKey": "requireRootCauseBeforePatch",
213
+ "enabledByDefault": false,
214
+ "type": "deny",
215
+ "payload": {
216
+ "tool_name": "Write",
217
+ "tool_input": {
218
+ "file_path": "/repo/src/x.js",
219
+ "content": "// TODO fix later patch"
220
+ }
221
+ },
222
+ "needsState": false
223
+ },
224
+ {
225
+ "id": "audit-before-build",
226
+ "configKey": "requireAuditBeforeBuilding",
227
+ "enabledByDefault": false,
228
+ "type": "deny",
229
+ "payload": {
230
+ "tool_name": "Agent",
231
+ "tool_input": { "prompt": "create a new script that checks X" }
232
+ },
233
+ "needsState": false
234
+ },
235
+ {
236
+ "id": "never-assume",
237
+ "configKey": "requireVerificationBeforeAssuming",
238
+ "enabledByDefault": false,
239
+ "type": "warn",
240
+ "payload": {
241
+ "tool_name": "Write",
242
+ "tool_input": {
243
+ "file_path": "/repo/src/x.js",
244
+ "content": "// i assume the timezone is UTC"
245
+ }
246
+ },
247
+ "needsState": false
248
+ },
249
+ {
250
+ "id": "rule-skill-autodiscovery",
251
+ "configKey": "autodiscoverRulesAndSkills",
252
+ "enabledByDefault": false,
253
+ "type": "deny",
254
+ "payload": {
255
+ "tool_name": "Bash",
256
+ "tool_input": { "command": "echo hi" }
257
+ },
258
+ "needsState": true,
259
+ "note": "needs a project rules/ or .claude/skills gate script present that exits non-zero."
260
+ },
261
+ {
262
+ "id": "recurrence-lock",
263
+ "configKey": "blockRegisteredRecurrences",
264
+ "enabledByDefault": true,
265
+ "type": "deny",
266
+ "payload": {
267
+ "tool_name": "Bash",
268
+ "tool_input": { "command": "echo hi" }
269
+ },
270
+ "needsState": true,
271
+ "note": "needs .ai/reincidencias.json with an open class at/above threshold."
272
+ },
273
+ {
274
+ "id": "test-after-implementation",
275
+ "configKey": "warnTestWrittenAfterImplementation",
276
+ "enabledByDefault": false,
277
+ "type": "deny",
278
+ "payload": {
279
+ "tool_name": "Write",
280
+ "tool_input": {
281
+ "file_path": "thing.test.js",
282
+ "content": "test content"
283
+ }
284
+ },
285
+ "needsState": true,
286
+ "note": "needs a git repo with an uncommitted paired implementation file."
287
+ },
288
+ {
289
+ "id": "no-reconfirm",
290
+ "configKey": "requireNoReconfirmOfApproved",
291
+ "enabledByDefault": true,
292
+ "type": "warn",
293
+ "payload": {
294
+ "tool_name": "AskUserQuestion",
295
+ "tool_input": {
296
+ "questions": [{ "question": "Should I migrate the auth schema?" }]
297
+ }
298
+ },
299
+ "needsState": true,
300
+ "note": "needs a transcript file with a prior approval of the same topic."
301
+ },
302
+ {
303
+ "id": "neutral-spanish",
304
+ "configKey": "warnNonNeutralSpanish",
305
+ "enabledByDefault": true,
306
+ "type": "deny",
307
+ "payload": {
308
+ "tool_name": "Write",
309
+ "tool_input": {
310
+ "file_path": "/repo/notes.md",
311
+ "content": "che, mira el laburo que hicimos"
312
+ }
313
+ },
314
+ "needsState": false,
315
+ "note": "neutral-spanish:allow (fixture data uses regional markers on purpose)"
316
+ },
317
+ {
318
+ "id": "diagnosis-before-patch",
319
+ "configKey": "warnTimeoutChangeWithoutDiagnosis",
320
+ "enabledByDefault": true,
321
+ "type": "warn",
322
+ "payload": {
323
+ "tool_name": "Write",
324
+ "tool_input": {
325
+ "file_path": "/repo/src/config.js",
326
+ "content": "const REQUEST_TIMEOUT_MS = 30000;"
327
+ }
328
+ },
329
+ "needsState": false
330
+ },
331
+ {
332
+ "id": "lint-commit",
333
+ "configKey": "blockCommitWithFailingLint",
334
+ "enabledByDefault": false,
335
+ "type": "deny",
336
+ "payload": {
337
+ "tool_name": "Bash",
338
+ "tool_input": { "command": "git commit -m x" }
339
+ },
340
+ "needsState": true,
341
+ "note": "needs a project package.json whose lint script exits non-zero."
342
+ },
343
+ {
344
+ "id": "reuse-before-build",
345
+ "configKey": "requireReuseCheckBeforeBuilding",
346
+ "enabledByDefault": false,
347
+ "type": "deny",
348
+ "payload": {
349
+ "tool_name": "Write",
350
+ "tool_input": {
351
+ "file_path": "scripts/csv-parser.mjs",
352
+ "content": "export function parse() {}"
353
+ }
354
+ },
355
+ "needsState": false
356
+ },
357
+ {
358
+ "id": "tool-map",
359
+ "configKey": "maintainToolMap",
360
+ "enabledByDefault": false,
361
+ "type": "none",
362
+ "payload": null,
363
+ "needsState": true,
364
+ "note": "side-effect only: records to .ai/tool-map.json, never denies/warns."
365
+ },
366
+ {
367
+ "id": "forge-flow",
368
+ "configKey": "requireForgeRunToEdit",
369
+ "enabledByDefault": true,
370
+ "type": "deny",
371
+ "payload": {
372
+ "tool_name": "Write",
373
+ "tool_input": { "file_path": "src/x.js", "content": "x" }
374
+ },
375
+ "needsState": true,
376
+ "note": "needs a forge adoption marker + sqlite db with no active run."
377
+ },
378
+ {
379
+ "id": "autonomous-mode",
380
+ "configKey": "autonomousMode",
381
+ "enabledByDefault": false,
382
+ "type": "deny",
383
+ "payload": {
384
+ "tool_name": "AskUserQuestion",
385
+ "tool_input": { "questions": [{ "question": "A or B?" }] }
386
+ },
387
+ "needsState": false
388
+ }
389
+ ]
390
+ }
package/cli/smoke.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Gate smoke test: for each gate, feed it a KNOWN violation and confirm it actually reacts
2
+ // (denies / warns), not just that its file exists on disk (that is doctor.mjs's job). This
3
+ // is the check that would have caught neutral-spanish silently warning when the user expected
4
+ // a block, and stop-pending never firing on a fresh install.
5
+ //
6
+ // A gate is run as its own process, exactly as Claude Code runs it: the violation payload on
7
+ // stdin, in a scratch project so its config lookup is isolated. The gate declares in the
8
+ // fixture manifest whether it is expected to DENY or WARN, and whether it needs its config key
9
+ // enabled (gates that are off by default) — the runner writes that config into the scratch
10
+ // project before invoking.
11
+ //
12
+ // Pure of I/O policy: this module runs gates and classifies; the CLI command (index.mjs)
13
+ // owns printing. `runSmoke` returns structured results so it is testable without a terminal.
14
+
15
+ import { execFileSync } from 'node:child_process';
16
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const GATES_DIR = join(HERE, '..', 'plugins', 'gates', 'hooks', 'gates');
23
+
24
+ // Max characters of a gate's error message to keep in the report — enough to identify the
25
+ // failure, short enough to keep the per-gate line readable.
26
+ const ERROR_MESSAGE_MAX_LENGTH = 120;
27
+ // A gate that hangs is a defect too; cap how long the smoke test waits for one to respond.
28
+ const GATE_RUN_TIMEOUT_MS = 10000;
29
+
30
+ // Outcomes the runner reports per gate.
31
+ export const OUTCOMES = Object.freeze({
32
+ REACTED: 'reacted', // gate produced the expected deny/warn — the protection works
33
+ NO_REACTION: 'no-reaction', // gate ran but did not react to its own known violation — DEFECT
34
+ SKIPPED: 'skipped', // fixture needs seeded state we do not set up, or has no violation case
35
+ ERROR: 'error', // gate threw / could not be run
36
+ });
37
+
38
+ /** A scratch project (git marker) with an optional gate config, so the run is isolated. */
39
+ function makeScratchProject(configKey, enable) {
40
+ const project = mkdtempSync(join(tmpdir(), 'gate-smoke-'));
41
+ mkdirSync(join(project, '.git'));
42
+ if (enable && configKey) {
43
+ mkdirSync(join(project, '.ai'));
44
+ writeFileSync(
45
+ join(project, '.ai', 'config.json'),
46
+ JSON.stringify({ gates: { [configKey]: { enabled: true } } }),
47
+ );
48
+ }
49
+ return project;
50
+ }
51
+
52
+ /** Reads a gate's decision from its stdout. null when it allowed (no output). */
53
+ function decisionOf(stdout) {
54
+ const trimmed = stdout.trim();
55
+ if (!trimmed) return null;
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(trimmed);
59
+ } catch {
60
+ return null;
61
+ }
62
+ if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return 'deny';
63
+ if (parsed?.decision === 'block') return 'deny'; // Stop-hook shape
64
+ if (typeof parsed?.hookSpecificOutput?.additionalContext === 'string')
65
+ return 'warn';
66
+ return null;
67
+ }
68
+
69
+ /** Runs one gate against its fixture and classifies the outcome. */
70
+ export function runGateFixture(fixture, { gatesDirectory = GATES_DIR } = {}) {
71
+ const { id, configKey, enabledByDefault, type, payload, needsState } =
72
+ fixture;
73
+
74
+ if (needsState || type === 'none' || payload == null) {
75
+ return {
76
+ id,
77
+ outcome: OUTCOMES.SKIPPED,
78
+ expected: type,
79
+ got: null,
80
+ reason: needsState
81
+ ? 'needs seeded state (db/file) not set up by the smoke test'
82
+ : 'gate has no violation case to plant (side-effect or judgment-only)',
83
+ };
84
+ }
85
+
86
+ const scriptPath = join(gatesDirectory, id, 'index.mjs');
87
+ const project = makeScratchProject(configKey, !enabledByDefault);
88
+
89
+ let stdout;
90
+ try {
91
+ stdout = execFileSync(process.execPath, [scriptPath], {
92
+ input: JSON.stringify(payload),
93
+ encoding: 'utf8',
94
+ cwd: project,
95
+ env: { ...process.env, HOME: project, USERPROFILE: project },
96
+ timeout: GATE_RUN_TIMEOUT_MS,
97
+ });
98
+ } catch (error) {
99
+ return {
100
+ id,
101
+ outcome: OUTCOMES.ERROR,
102
+ expected: type,
103
+ got: null,
104
+ reason: String(error?.message ?? error).slice(
105
+ 0,
106
+ ERROR_MESSAGE_MAX_LENGTH,
107
+ ),
108
+ };
109
+ }
110
+
111
+ const got = decisionOf(stdout);
112
+ // A gate expected to deny must deny; a gate expected to warn must warn. Anything else
113
+ // (allowed silently, or wrong reaction kind) is a no-reaction defect worth surfacing.
114
+ const reacted = got === type;
115
+ return {
116
+ id,
117
+ outcome: reacted ? OUTCOMES.REACTED : OUTCOMES.NO_REACTION,
118
+ expected: type,
119
+ got: got ?? 'allow',
120
+ };
121
+ }
122
+
123
+ /** Runs the whole manifest and returns per-gate results plus a summary tally. */
124
+ export function runSmoke(manifest, options = {}) {
125
+ const results = manifest.map((fixture) => runGateFixture(fixture, options));
126
+ const tally = { reacted: 0, 'no-reaction': 0, skipped: 0, error: 0 };
127
+ for (const result of results) tally[result.outcome] += 1;
128
+ return { results, tally };
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -130,7 +130,12 @@ function main() {
130
130
  if (payload?.stop_hook_active === true) return allow();
131
131
 
132
132
  const cwd = process.cwd();
133
- if (!isGateEnabled(CONFIG_KEY, false, cwd)) return allow();
133
+ // registryDefault MUST match this gate's `default` in registry.json (true). The gate
134
+ // does not read the registry — this literal IS its default when a project config is
135
+ // silent about the key. It was false while the registry said false; both moved to true
136
+ // so the pending-task reminder actually fires on a fresh install, not only when the
137
+ // user's config names the key explicitly.
138
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) return allow();
134
139
 
135
140
  const root = projectRootOf(cwd);
136
141
  if (!root) return allow(); // no project: nothing to check
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tasks",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
5
5
  "author": {
6
6
  "name": "Devrik"