@devrik-tools/claude-gates 0.2.1 → 0.3.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 (96) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  16. package/plugins/gates/hooks/hooks.json +19 -0
  17. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  18. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  19. package/registry.json +738 -793
  20. package/cli/__tests__/config.test.mjs +0 -113
  21. package/cli/__tests__/init-flags.test.mjs +0 -111
  22. package/cli/__tests__/install.test.mjs +0 -102
  23. package/cli/__tests__/materialize.test.mjs +0 -95
  24. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  25. package/cli/__tests__/selection.test.mjs +0 -200
  26. package/cli/__tests__/task.test.mjs +0 -124
  27. package/cli/__tests__/version-consistency.test.mjs +0 -44
  28. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  29. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  30. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  31. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  32. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  33. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  34. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  35. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  36. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  37. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  38. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  39. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  40. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  41. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  42. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  43. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  46. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  47. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  48. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  49. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  50. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  51. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  52. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  53. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  54. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  55. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  56. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  57. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  58. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  59. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  60. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  61. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  62. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  63. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  64. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  65. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  66. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  67. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  68. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  69. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  70. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  71. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  72. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  73. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  74. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  75. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  76. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  77. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  78. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  79. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  81. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  82. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  83. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  84. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  85. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  86. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  87. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  88. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  89. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  94. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  95. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  96. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -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.2.1"
14
+ "version": "0.3.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.2.1"
20
+ "version": "0.3.0"
21
21
  }
22
22
  ]
23
23
  }
package/README.es.md CHANGED
@@ -68,9 +68,10 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
68
68
  ### 🔒 Security — bloqueos duros sobre lo destructivo
69
69
  | Gate | | Qué hace |
70
70
  |---|---|---|
71
- | `bash-commands` | on | Bloquea `git reset --hard`, `rm -rf`, force push, matar procesos por nombre, y publicar al remoto sin autorización. |
71
+ | `bash-commands` | on | Bloquea `git reset --hard`, `rm -rf` sobre áreas protegidas, force push, y matar procesos por nombre. |
72
+ | `block-remote-publish` | on | Bloquea `git push`, `gh pr merge`, `gh release create` sin autorización. Poné `blockRemotePublish: false` para permitir que el agente publique por su cuenta. |
72
73
  | `protected-paths` | on | Bloquea escrituras a `.env`, lockfiles y el propio harness. |
73
- | `root-whitelist` | on | Bloquea crear archivos/carpetas nuevos en la raíz fuera de una lista blanca. |
74
+ | `root-whitelist` | on | Bloquea crear archivos/carpetas nuevos en la raíz fuera de la lista blanca. |
74
75
  | `no-blocking` | off | Bloquea `sleep`, `tail -f`, bucles de sondeo y servidores en primer plano. |
75
76
 
76
77
  ### 🤝 Delegation — exigencias sobre el brief al delegar a un subagente
@@ -80,7 +81,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
80
81
  | `intent-flow` | off | Exige secciones QUÉ SÍ / QUÉ NO / EDGE CASES. |
81
82
  | `risk-level` | off | Exige declarar el nivel (QUESTION/MICRO/STANDARD/HIGH-RISK). |
82
83
  | `circuit-breaker` | off | Corta la misma delegación reintentada sin cambios reales. |
83
- | `no-memory-dependency` | off | Avisa si el brief depende de que el subagente "recuerde" la conversación. |
84
+ | `no-memory-dependency` | off | Bloquea un brief que depende de que el subagente "recuerde" la conversación (marcador `memory-not-needed` para un falso positivo). |
84
85
 
85
86
  ### 📋 Spec-driven flow — solo aplican si el proyecto adoptó desarrollo por specs
86
87
  | Gate | | Qué hace |
@@ -94,15 +95,15 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
94
95
  ### ✨ Quality — higiene de código, diagnóstico y lenguaje
95
96
  | Gate | | Qué hace |
96
97
  |---|---|---|
97
- | `dependency-skills` | on | Una dependencia directa nueva exige una skill vigente. |
98
+ | `dependency-skills` | on | Bloquea una dependencia directa nueva sin skill que la cubra (declarala en `depsWithoutOwnApi` si no necesita). |
98
99
  | `root-cause-first` | off | Exige un diagnóstico origen→síntoma antes de un parche. |
99
100
  | `audit-before-build` | off | Antes de un script/gate nuevo, exige declarar que nada existente lo cubre. |
100
101
  | `never-assume` | off | Marca suposiciones sin verificar en briefs y código. |
101
102
  | `rule-skill-autodiscovery` | off | Carga los gates que el proyecto declara en sus `rules/` y `skills/`. |
102
103
  | `recurrence-lock` | on | La segunda aparición de un defecto exige su bloqueo determinista. |
103
- | `test-after-implementation` | off | Avisa cuando se escribe un test después de su implementación. |
104
+ | `test-after-implementation` | off | Bloquea un test escrito después de su implementación pareja (marcador `test-after-impl:allow` para un test de regresión). |
104
105
  | `no-reconfirm` | on | Nunca vuelve a preguntar lo que ya respondiste. |
105
- | `neutral-spanish` | on | Avisa sobre voseo o léxico regional en el texto escrito. |
106
+ | `neutral-spanish` | on | Bloquea voseo o léxico regional en el texto escrito (marcador `neutral-spanish:allow` para una cita/fixture deliberada). |
106
107
  | `diagnosis-before-patch` | on | Avisa cuando se cambian timeouts/reintentos sin evidencia. |
107
108
 
108
109
  ### 🔎 Tool discovery — no reinventar la rueda
package/README.md CHANGED
@@ -60,14 +60,15 @@ works even if you install one on its own.
60
60
 
61
61
  ---
62
62
 
63
- ## The gates (30, in 7 families)
63
+ ## The gates (in families)
64
64
 
65
65
  `[on]` = enabled by default; `[off]` = enable it if you want it.
66
66
 
67
67
  ### 🔒 Security — hard blocks on destructive actions
68
68
  | Gate | | What it does |
69
69
  |---|---|---|
70
- | `bash-commands` | on | Blocks `git reset --hard`, `rm -rf`, force push, killing processes by name, and publishing to a remote without authorization. |
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. |
71
72
  | `protected-paths` | on | Blocks writes to `.env`, lockfiles and the harness itself. |
72
73
  | `root-whitelist` | on | Blocks new root-level files/folders outside a whitelist. |
73
74
  | `no-blocking` | off | Blocks `sleep`, `tail -f`, polling loops and foreground servers. |
@@ -79,7 +80,7 @@ works even if you install one on its own.
79
80
  | `intent-flow` | off | Requires IN SCOPE / OUT OF SCOPE / EDGE CASES sections. |
80
81
  | `risk-level` | off | Requires a declared level (QUESTION/MICRO/STANDARD/HIGH-RISK). |
81
82
  | `circuit-breaker` | off | Cuts the same delegation retried without real changes. |
82
- | `no-memory-dependency` | off | Warns when the brief relies on the subagent "remembering" the chat. |
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). |
83
84
 
84
85
  ### 📋 Spec-driven flow — only relevant if the project adopted spec-driven development
85
86
  | Gate | | What it does |
@@ -93,15 +94,15 @@ works even if you install one on its own.
93
94
  ### ✨ Quality — code hygiene, diagnosis and language
94
95
  | Gate | | What it does |
95
96
  |---|---|---|
96
- | `dependency-skills` | on | A new direct dependency requires a current skill. |
97
+ | `dependency-skills` | on | Blocks a new direct dependency with no matching skill (declare it in `depsWithoutOwnApi` if it needs none). |
97
98
  | `root-cause-first` | off | Requires an origin→symptom diagnosis before a patch. |
98
99
  | `audit-before-build` | off | Before a new script/gate, requires stating that nothing existing covers it. |
99
100
  | `never-assume` | off | Flags unverified assumptions in briefs and code. |
100
101
  | `rule-skill-autodiscovery` | off | Loads gates the project declares in its `rules/` and `skills/`. |
101
102
  | `recurrence-lock` | on | A second occurrence of a defect class requires its deterministic block. |
102
- | `test-after-implementation` | off | Warns when a test is written after its paired implementation. |
103
+ | `test-after-implementation` | off | Blocks a test written after its paired implementation (add `test-after-impl:allow` for a regression test). |
103
104
  | `no-reconfirm` | on | Never re-ask what you already answered. |
104
- | `neutral-spanish` | on | Warns about voseo or regional lexicon in written text. |
105
+ | `neutral-spanish` | on | Blocks voseo or regional lexicon in written text (add `neutral-spanish:allow` for a deliberate quote/fixture). |
105
106
  | `diagnosis-before-patch` | on | Warns when timeouts/retries change without evidence. |
106
107
 
107
108
  ### 🔎 Tool discovery — don't reinvent the wheel
@@ -141,9 +142,11 @@ see and edit every knob:
141
142
  "rmRfProtectedAreas": ["/", "*", "src", "tests"],
142
143
  "denyPatterns": ["git reset --hard", "…"]
143
144
  },
145
+ "blockRemotePublish": { "enabled": false },
144
146
  "warnNonNeutralSpanish": {
145
147
  "enabled": true,
146
- "regionalMarkers": ["tenés", "podés", "…"]
148
+ "regionalMarkers": ["tenés", "podés", "…"],
149
+ "escapeHatch": "neutral-spanish:allow"
147
150
  },
148
151
  "requireBriefBeforeDelegating": { "enabled": false }
149
152
  }
@@ -155,6 +158,12 @@ see and edit every knob:
155
158
  What the project declares **replaces** the gate's default.
156
159
  - A gate absent from the config uses its catalog default. Keys you already had in the file
157
160
  (e.g. `autoCommit`) are kept intact.
161
+ - **Let the agent push:** set `"blockRemotePublish": { "enabled": false }`. Nothing is
162
+ hardcoded — every gate, remote publish included, obeys this flag.
163
+ - **Escape hatches:** a few gates block (deny) but accept an explicit opt-out marker in the
164
+ content/prompt for a legitimate case: `neutral-spanish:allow` (a deliberate regional
165
+ quote), `test-after-impl:allow` (a regression test), `memory-not-needed` (a
166
+ non-memory phrase). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
158
167
 
159
168
  ---
160
169
 
package/cli/index.mjs CHANGED
@@ -78,6 +78,10 @@ program
78
78
  )
79
79
  .option('-y, --yes', 'never prompt; use flags and defaults')
80
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
+ )
81
85
  .option('--dry-run', 'show the selection without writing')
82
86
  .action(async (options) => {
83
87
  const { runInit } = await import('./init.mjs');
package/cli/init.mjs CHANGED
@@ -68,6 +68,7 @@ export function normalizeOptions(options = {}) {
68
68
  yes: Boolean(options.yes),
69
69
  dryRun: Boolean(options.dryRun),
70
70
  install: options.install,
71
+ removePrevious: options.removePrevious,
71
72
  };
72
73
  }
73
74
 
@@ -172,6 +173,15 @@ function renderSummary(registry, gatesMap) {
172
173
  .join('\n');
173
174
  }
174
175
 
176
+ async function askRemovePrevious(io) {
177
+ return bail(
178
+ await io.confirm({
179
+ message: 'Remove the previous plugin version before installing the new one?',
180
+ initialValue: true,
181
+ }),
182
+ );
183
+ }
184
+
175
185
  async function confirmWrite(io, fileExists) {
176
186
  const confirmed = bail(
177
187
  await io.confirm({
@@ -239,7 +249,14 @@ export async function runInit(
239
249
  return { path, config: merged, written: true, installed: false };
240
250
  }
241
251
 
242
- const result = installPlugin(scope, { cwd });
252
+ // Default true: a previous plugin version is removed before installing the new one, unless
253
+ // --no-remove-previous turned it off. Only asked when interactive — --yes or no TTY uses the
254
+ // flag/default without a prompt, so scripted and non-interactive runs never block on input.
255
+ const removePrevious =
256
+ flags.removePrevious !== false &&
257
+ (!interactive || (await askRemovePrevious(io)));
258
+
259
+ const result = installPlugin(scope, { cwd, removePrevious });
243
260
  if (result.installed) {
244
261
  io.outro(
245
262
  `Written ${path} and installed all plugins (${result.scope} scope). ` +
package/cli/install.mjs CHANGED
@@ -88,6 +88,25 @@ function reasonFor(error) {
88
88
  : (error?.stderr || error?.message || String(error)).trim().split('\n')[0];
89
89
  }
90
90
 
91
+ /**
92
+ * Best-effort removal of a previously installed version of each manifest plugin, at the
93
+ * given scope, before the fresh install runs. `claude plugin uninstall <plugin> --scope
94
+ * <scope>` is verified (via `claude plugin uninstall --help`) to take the bare plugin name
95
+ * (no `@marketplace`, unlike install) and the same `--scope` values as install. When there
96
+ * is no previous install, uninstall fails — that is expected and non-fatal, so failures here
97
+ * are swallowed and never stop the install that follows.
98
+ */
99
+ function removePreviousInstalls(targets, scope, runClaude) {
100
+ for (const { plugin } of targets) {
101
+ try {
102
+ runClaude(['plugin', 'uninstall', plugin, '--scope', scope, '--yes']);
103
+ } catch {
104
+ // No previous install (or removal failed for some other reason) — non-fatal either way;
105
+ // the install below is what actually matters.
106
+ }
107
+ }
108
+ }
109
+
91
110
  /**
92
111
  * Registers the marketplace (idempotent: a second add just reports it already exists, which
93
112
  * is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
@@ -102,10 +121,14 @@ function reasonFor(error) {
102
121
  * `runClaude` is an injectable seam (defaults to the real `claude` binary) so tests can
103
122
  * exercise the multi-plugin partial-failure logic without actually invoking the CLI and
104
123
  * installing plugins on the machine running the test.
124
+ *
125
+ * `removePrevious` (default true) uninstalls each plugin's previous version at this scope
126
+ * before installing, so a stale version never lingers alongside the new one. It is best-effort
127
+ * and never fails the overall install.
105
128
  */
106
129
  export function installPlugin(
107
130
  configScope,
108
- { cwd = process.cwd(), runClaude = realClaude } = {},
131
+ { cwd = process.cwd(), runClaude = realClaude, removePrevious = true } = {},
109
132
  ) {
110
133
  const scope = PLUGIN_SCOPE[configScope] ?? 'user';
111
134
 
@@ -120,6 +143,8 @@ export function installPlugin(
120
143
  // module top, means the name reflects the live registration this run just ensured.
121
144
  const targets = marketplaceAndPlugins(runClaude);
122
145
 
146
+ if (removePrevious) removePreviousInstalls(targets, scope, runClaude);
147
+
123
148
  const results = targets.map(({ marketplace, plugin }) => {
124
149
  try {
125
150
  runClaude([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -33,7 +33,10 @@
33
33
  "README.md",
34
34
  "README.es.md",
35
35
  "plugins",
36
- ".claude-plugin"
36
+ ".claude-plugin",
37
+ "!**/*.test.mjs",
38
+ "!**/test.mjs",
39
+ "!**/__tests__/**"
37
40
  ],
38
41
  "scripts": {
39
42
  "test": "node --test",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.2.1",
3
+ "version": "0.3.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"
@@ -0,0 +1,78 @@
1
+ // autonomous-mode (Stop half) — closes the hole its PreToolUse half cannot reach. The
2
+ // PreToolUse gate (index.mjs) blocks the AskUserQuestion TOOL, but nothing stops the
3
+ // assistant from ending its turn with a question in plain PROSE — and when it does, the
4
+ // session just sits there waiting for a human who is supposed to be away. No hook can read
5
+ // chat text to detect that a turn ended on a question. So this Stop hook takes the only
6
+ // deterministic lever available: when autonomous mode is ON and the turn is ending, it
7
+ // re-injects an instruction to NOT wait — decide, delegate, and keep going on everything
8
+ // that does not strictly require the user's own explicit decision.
9
+ //
10
+ // ── What the re-injected message says (per the user's own framing) ───────────────────
11
+ // In autonomous mode work is delegated to the assistant. It must only leave pending the ONE
12
+ // thing that genuinely needs the user's exclusive, explicit decision (irreversible/dangerous
13
+ // — deleting data, money, production). Everything else: proceed. If there are OTHER tasks it
14
+ // can advance, advance them; isolate only the truly blocking one and state it in prose, then
15
+ // stop. It must not end the turn on a prose question just to wait.
16
+ //
17
+ // ── stop_hook_active loop-guard (mandatory) ──────────────────────────────────────────
18
+ // Claude Code re-invokes Stop hooks with stop_hook_active=true after a block. Blocking again
19
+ // would loop forever. So when stop_hook_active === true this hook unconditionally allows the
20
+ // stop: the reminder is injected exactly once per cycle. After that one nudge, if the
21
+ // assistant still ends the turn, it genuinely had nothing to proceed on (or a real blocker),
22
+ // and the session must be allowed to end — a frozen session is worse than one missed nudge.
23
+ //
24
+ // ── Fail-safe (mandatory for a Stop hook) ────────────────────────────────────────────
25
+ // Everything is wrapped so that no config, no project, a corrupt payload, or any unexpected
26
+ // error allows the stop. A broken Stop hook must never hang a session.
27
+
28
+ import { isGateEnabled } from '../../lib/config.mjs';
29
+ import { readHookPayload } from '../../lib/hook-io.mjs';
30
+
31
+ const CONFIG_KEY = 'autonomousMode';
32
+
33
+ const CONTINUE_MESSAGE =
34
+ '[autonomous] Autonomous mode is ON — do not end the turn to wait for the user. Work here ' +
35
+ 'is delegated to you. Leave PENDING only the one thing that genuinely needs the user’s own ' +
36
+ 'explicit, exclusive decision (a truly irreversible or dangerous choice: deleting data, ' +
37
+ 'money, production). For everything else: decide the best goal-aligned option, state the ' +
38
+ 'reversible assumption in one line, and proceed. If there are OTHER tasks you can advance, ' +
39
+ 'advance them now instead of stopping; isolate only the genuinely blocking item, state it ' +
40
+ 'in prose, and stop only for that. Do NOT end the turn on a prose question just to wait. ' +
41
+ 'If you have truly nothing left to do and nothing needs the user, ending is fine — this ' +
42
+ 'reminder fires once, so a real end will go through on the next stop.';
43
+
44
+ function block(reason) {
45
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
46
+ process.exit(0);
47
+ }
48
+
49
+ function allow() {
50
+ process.exit(0);
51
+ }
52
+
53
+ function main() {
54
+ try {
55
+ const rawPayload = readHookPayload();
56
+ if (rawPayload === null) return allow();
57
+
58
+ let payload;
59
+ try {
60
+ payload = JSON.parse(rawPayload);
61
+ } catch {
62
+ return allow(); // unparseable Stop payload: fail-safe, never hang the session
63
+ }
64
+
65
+ // Loop-guard: this hook already nudged once this cycle. Never block again.
66
+ if (payload?.stop_hook_active === true) return allow();
67
+
68
+ // Only act when the project turned autonomous mode ON. Off by default (registry).
69
+ if (!isGateEnabled(CONFIG_KEY, false, process.cwd())) return allow();
70
+
71
+ return block(CONTINUE_MESSAGE);
72
+ } catch {
73
+ // A broken Stop hook must never hang a session.
74
+ return allow();
75
+ }
76
+ }
77
+
78
+ main();
@@ -1,6 +1,11 @@
1
- // bash-commands — denies destructive shell commands, and blocks publishing to a remote
2
- // without fresh authorization. Runs on a real Bash/run_command call and on a delegation
3
- // prompt (a subagent can be told "run git reset --hard" in prose).
1
+ // bash-commands — denies destructive shell commands (rm -rf over protected areas, git
2
+ // reset --hard, force push, kill-by-name). Runs on a real Bash/run_command call and on a
3
+ // delegation prompt (a subagent can be told "run git reset --hard" in prose).
4
+ //
5
+ // Remote-publish blocking (git push / gh pr merge / gh release create) used to live here as
6
+ // a hardcoded, non-configurable block. It was split out into the block-remote-publish gate
7
+ // so it carries its own enabled flag: a project can now allow the agent to push by disabling
8
+ // that gate WITHOUT also disabling the destructive-command protections below.
4
9
  //
5
10
  // ── What a project can configure (params) ───────────────────────────────────────────
6
11
  // denyPatterns regex sources (matched case-insensitively) of destructive
@@ -10,18 +15,6 @@
10
15
  // by default: this harness legitimately uses inline interpreters.
11
16
  // The defaults live here, in the source, so a project reads them and knows exactly what
12
17
  // its override replaces.
13
- //
14
- // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
15
- // Remote-publish detection (`git push`, `gh pr merge`, `gh release create`) is a hard
16
- // block with no config knob and no in-command escape hatch: authorization is something
17
- // the USER states in chat, never a token the agent could write into its own command.
18
- //
19
- // ── Two-stage intent check, only for delegation prompts ─────────────────────────────
20
- // A real command's `command` field IS what the shell runs — a single-stage regex is
21
- // right. A delegation prompt is natural language that may DESCRIBE a command ("I extended
22
- // the guard to deny git push") without asking anyone to run it. There, publish rules run
23
- // a second stage: strip quoted/example text, then require that at least one surviving
24
- // mention is not governed by a reporting verb before denying.
25
18
 
26
19
  import {
27
20
  runGate,
@@ -107,22 +100,6 @@ function defaultDenyPatterns() {
107
100
  ];
108
101
  }
109
102
 
110
- /** Remote-publish rules. Base, non-configurable — see the header. */
111
- const REMOTE_PUBLISH_RULES = [
112
- [
113
- String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
114
- "Publishing to a remote ('git push') needs fresh authorization from the user naming " +
115
- 'commits, local branch, remote and target. This gate cannot verify that from the ' +
116
- 'command itself — ask the user and let them run it.',
117
- ],
118
- [
119
- String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
120
- 'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
121
- 'authorization from the user naming commits, local branch, remote and target. ' +
122
- 'Ask the user and let them run it.',
123
- ],
124
- ];
125
-
126
103
  // Interpreter name, then any intermediate flags (e.g. --input-type=module), then an
127
104
  // eval flag. Intermediate flags are matched loosely (`-\S+`) to keep the pattern simple.
128
105
  const INTERPRETER_EVAL_PATTERN =
@@ -130,14 +107,6 @@ const INTERPRETER_EVAL_PATTERN =
130
107
  const RAW_FILE_OPS_PATTERN =
131
108
  /\b(writeFileSync|readFileSync|appendFileSync|fs\.writeFile|fs\.readFile|fs\.unlink|fs\.mkdir)\b/;
132
109
 
133
- // How far back from a mention to look for a governing verb, and the reporting-verb lexicon
134
- // that marks a mention as description rather than an order. Only the text before a mention
135
- // is inspected: what governs it is what precedes it (appending words after a real command
136
- // would otherwise be a trivial bypass).
137
- const DESCRIPTION_LOOK_BACK = 80;
138
- const REPORTING_VERB_PATTERN =
139
- /(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
140
-
141
110
  function compile(source) {
142
111
  return new RegExp(source, 'i');
143
112
  }
@@ -170,29 +139,6 @@ function normalizeGitOptions(command) {
170
139
  return normalized;
171
140
  }
172
141
 
173
- function stripQuoted(text) {
174
- return text
175
- .replace(/```[\s\S]*?```/g, ' ')
176
- .replace(/"[^"\n]{0,300}"/g, ' ')
177
- .replace(/'[^'\n]{0,300}'/g, ' ');
178
- }
179
-
180
- /** True when a surviving, non-quoted mention is not governed by a reporting verb. */
181
- function hasRealPublishIntent(text, pattern) {
182
- if (!pattern.test(text)) return false;
183
- const cleaned = stripQuoted(text);
184
- const global = new RegExp(
185
- pattern.source,
186
- `${pattern.flags.replace('g', '')}g`,
187
- );
188
- const matches = [...cleaned.matchAll(global)];
189
- if (matches.length === 0) return false;
190
- return matches.some((match) => {
191
- const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
192
- return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
193
- });
194
- }
195
-
196
142
  function checkEmbeddedInterpreter(command) {
197
143
  const match = INTERPRETER_EVAL_PATTERN.exec(command);
198
144
  if (!match) return null;
@@ -247,21 +193,6 @@ function checkDestructive(command, parameters) {
247
193
  }
248
194
  }
249
195
 
250
- /** Remote-publish rules: a real command is checked literally; a delegation prompt by intent. */
251
- function checkRemotePublish(command, isShell) {
252
- // For a real shell command, normalize git's global options first (same bypass as above).
253
- // For a delegation prompt (free text), the intent check runs on the raw text.
254
- const shellCommand = normalizeGitOptions(command);
255
- for (const [source, reason] of REMOTE_PUBLISH_RULES) {
256
- const pattern = compile(source);
257
- if (isShell) {
258
- if (pattern.test(shellCommand)) deny(GATE_ID, reason);
259
- } else if (hasRealPublishIntent(command, pattern)) {
260
- deny(GATE_ID, reason);
261
- }
262
- }
263
- }
264
-
265
196
  runGate(
266
197
  {
267
198
  id: GATE_ID,
@@ -274,12 +205,11 @@ runGate(
274
205
  },
275
206
  },
276
207
  ({ toolName, toolInput, parameters }) => {
277
- const isShell = toolInGroups(toolName, ['shell']);
278
- const isDelegation = toolInGroups(toolName, ['delegation']);
279
- if (!isShell && !isDelegation) return;
208
+ // Destructive-command rules act on a real shell command AND on a delegation prompt (a
209
+ // subagent can be told "run rm -rf" in prose). commandTextFrom picks the right text.
210
+ if (!toolInGroups(toolName, ['shell', 'delegation'])) return;
280
211
 
281
212
  const command = commandTextFrom(toolName, toolInput);
282
213
  checkDestructive(command, parameters);
283
- checkRemotePublish(command, isShell);
284
214
  },
285
215
  );
@@ -0,0 +1,140 @@
1
+ // block-remote-publish — denies publishing to a remote (`git push`, `gh pr merge`,
2
+ // `gh release create`) without fresh human authorization. Split out of bash-commands so it
3
+ // carries its OWN enabled flag: a project can turn remote-publish blocking off in config
4
+ // (blockRemotePublish: false) WITHOUT losing the destructive-command protections (rm -rf,
5
+ // git reset --hard) that live in bash-commands. Nothing is hardcoded any more — this gate
6
+ // obeys its flag like every other, and appears in the CLI's gate selector on its own.
7
+ //
8
+ // Default: enabled. The safe posture is that the human states the authorization in chat,
9
+ // naming commits, branch, remote and target — a project that wants the agent to push on its
10
+ // own opts in explicitly by disabling this gate.
11
+ //
12
+ // ── Two-stage intent check, only for delegation prompts ─────────────────────────────
13
+ // A real command's `command` field IS what the shell runs — a single-stage regex is right.
14
+ // A delegation prompt is natural language that may DESCRIBE a command ("I extended the guard
15
+ // to deny git push") without asking anyone to run it. There, publish rules run a second
16
+ // stage: strip quoted/example text, then require that at least one surviving mention is not
17
+ // governed by a reporting verb before denying.
18
+
19
+ import {
20
+ runGate,
21
+ deny,
22
+ toolInGroups,
23
+ delegationPromptOf,
24
+ } from '../../lib/hook-io.mjs';
25
+
26
+ const GATE_ID = 'block-remote-publish';
27
+ const CONFIG_KEY = 'blockRemotePublish';
28
+
29
+ // Remote-publish rules as `[regexSource, reason]`. Sources (not RegExp) so a project could
30
+ // override them through config if it ever needed to.
31
+ function defaultPublishRules() {
32
+ return [
33
+ [
34
+ String.raw`\bgit\s+(?:-C\s+\S+\s+)?push\b`,
35
+ "Publishing to a remote ('git push') needs fresh authorization from the user naming " +
36
+ 'commits, local branch, remote and target. This gate cannot verify that from the ' +
37
+ 'command itself — ask the user and let them run it. To let the agent push on its ' +
38
+ 'own, set "blockRemotePublish": false in .ai/config.json.',
39
+ ],
40
+ [
41
+ String.raw`\bgh\s+(?:pr\s+merge|release\s+create)\b`,
42
+ 'Publishing via GitHub CLI (merging a PR or creating a release) needs fresh ' +
43
+ 'authorization from the user naming commits, local branch, remote and target. ' +
44
+ 'Ask the user and let them run it. To let the agent publish on its own, set ' +
45
+ '"blockRemotePublish": false in .ai/config.json.',
46
+ ],
47
+ ];
48
+ }
49
+
50
+ // How far back from a mention to look for a governing verb, and the reporting-verb lexicon
51
+ // that marks a mention as description rather than an order. Only the text before a mention
52
+ // is inspected: what governs it is what precedes it (appending words after a real command
53
+ // would otherwise be a trivial bypass).
54
+ const DESCRIPTION_LOOK_BACK = 80;
55
+ const REPORTING_VERB_PATTERN =
56
+ /(describe|explain|summar|mention|added|built|extended|denies?|deny|prohibit|protection|report|documentation|changelog)/i;
57
+
58
+ function compile(source) {
59
+ return new RegExp(source, 'i');
60
+ }
61
+
62
+ // git's GLOBAL options sit between `git` and the subcommand: `git -C <path> push`, etc. A
63
+ // pattern that matches `git push` contiguously is evaded by any of them. Stripping these
64
+ // first — turning `git -C /repo push` back into `git push` — closes that bypass at once.
65
+ const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
66
+ const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
67
+ const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
68
+ String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
69
+ 'i',
70
+ );
71
+
72
+ function normalizeGitOptions(command) {
73
+ let previous;
74
+ let normalized = command;
75
+ do {
76
+ previous = normalized;
77
+ normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
78
+ } while (normalized !== previous);
79
+ return normalized;
80
+ }
81
+
82
+ function stripQuoted(text) {
83
+ return text
84
+ .replace(/```[\s\S]*?```/g, ' ')
85
+ .replace(/"[^"\n]{0,300}"/g, ' ')
86
+ .replace(/'[^'\n]{0,300}'/g, ' ');
87
+ }
88
+
89
+ /** True when a surviving, non-quoted mention is not governed by a reporting verb. */
90
+ function hasRealPublishIntent(text, pattern) {
91
+ if (!pattern.test(text)) return false;
92
+ const cleaned = stripQuoted(text);
93
+ const global = new RegExp(
94
+ pattern.source,
95
+ `${pattern.flags.replace('g', '')}g`,
96
+ );
97
+ const matches = [...cleaned.matchAll(global)];
98
+ if (matches.length === 0) return false;
99
+ return matches.some((match) => {
100
+ const from = Math.max(0, match.index - DESCRIPTION_LOOK_BACK);
101
+ return !REPORTING_VERB_PATTERN.test(cleaned.slice(from, match.index));
102
+ });
103
+ }
104
+
105
+ /** The text to inspect: a real command's command line, or the delegation prompt. */
106
+ function commandTextFrom(toolName, toolInput) {
107
+ if (toolInGroups(toolName, ['shell'])) {
108
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
109
+ }
110
+ return delegationPromptOf(toolInput);
111
+ }
112
+
113
+ runGate(
114
+ {
115
+ id: GATE_ID,
116
+ configKey: CONFIG_KEY,
117
+ enabledByDefault: true,
118
+ defaultParams: {
119
+ publishRules: defaultPublishRules(),
120
+ },
121
+ },
122
+ ({ toolName, toolInput, parameters }) => {
123
+ const isShell = toolInGroups(toolName, ['shell']);
124
+ const isDelegation = toolInGroups(toolName, ['delegation']);
125
+ if (!isShell && !isDelegation) return;
126
+
127
+ const command = commandTextFrom(toolName, toolInput);
128
+ // For a real shell command, normalize git's global options first. For a delegation
129
+ // prompt (free text), the intent check runs on the raw text.
130
+ const shellCommand = normalizeGitOptions(command);
131
+ for (const [source, reason] of parameters.publishRules) {
132
+ const pattern = compile(source);
133
+ if (isShell) {
134
+ if (pattern.test(shellCommand)) deny(GATE_ID, reason);
135
+ } else if (hasRealPublishIntent(command, pattern)) {
136
+ deny(GATE_ID, reason);
137
+ }
138
+ }
139
+ },
140
+ );