@devrik-tools/claude-gates 0.7.1 → 0.8.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +103 -60
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +79 -143
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +83 -11
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +49 -93
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +97 -48
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +98 -206
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -91
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +104 -136
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -75
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -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.
|
|
14
|
+
"version": "0.8.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.
|
|
20
|
+
"version": "0.8.0"
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
package/README.es.md
CHANGED
|
@@ -30,7 +30,7 @@ npx @devrik-tools/claude-gates init
|
|
|
30
30
|
|
|
31
31
|
Reinicia la sesión de Claude Code (o ejecuta `/plugin`) para que los hooks carguen.
|
|
32
32
|
|
|
33
|
-
> **¿Por qué dos cosas?** El plugin **siempre trae los
|
|
33
|
+
> **¿Por qué dos cosas?** El plugin **siempre trae los 44 gates**; la configuración decide
|
|
34
34
|
> **cuáles se ejecutan**. Así puedes prender uno sin reinstalar: es una línea en un JSON.
|
|
35
35
|
|
|
36
36
|
---
|
|
@@ -67,7 +67,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
67
67
|
|
|
68
68
|
---
|
|
69
69
|
|
|
70
|
-
## Los gates (
|
|
70
|
+
## Los gates (44, en 10 familias)
|
|
71
71
|
|
|
72
72
|
`[on]` = encendidos por defecto; `[off]` = los prendes si los quieres.
|
|
73
73
|
|
|
@@ -117,6 +117,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
117
117
|
| `atomic-commit` | off | Bloquea un `git commit` que no es atómico — que mezcla más de N naturalezas de cambio (código/tests/deps/config…) o stagea más archivos revisables de los que un commit debería llevar. Docs/imágenes/generados no cuentan. Marcador `[wip]` para un commit deliberadamente amplio. |
|
|
118
118
|
| `no-coauthor` | on | Bloquea un `git commit` que lleve un trailer de atribución de IA (`Co-Authored-By`, `Generated with`, un trailer de sesión). Marcador `[allow-coauthor]` para un co-autor legítimo. |
|
|
119
119
|
| `no-lint-suppression` | on | Bloquea una escritura que silencia el linter/type-checker (`eslint-disable`, `@ts-ignore`, una regla en `off`) en vez de arreglar el código. Marcador `lint-ok: <razón>` en la misma línea para un falso positivo documentado. |
|
|
120
|
+
| `no-explanatory-comments` | on | Bloquea una escritura de código que agrega comentarios que narran qué hace el código. Solo pasan comentarios de decisión (el porqué, un trade-off, una limitación), directivas de herramientas, `TODO`/`FIXME` y etiquetas JSDoc con tipo. Juzga solo comentarios nuevos (diff contra disco). `comment-ok: <razón>` para una excepción documentada. |
|
|
120
121
|
|
|
121
122
|
### 🔎 Tool discovery — no reinventar la rueda
|
|
122
123
|
| Gate | | Qué hace |
|
|
@@ -124,6 +125,13 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
124
125
|
| `reuse-before-build` | off | Antes de construir una herramienta, consulta el mapa de herramientas del proyecto; bloquea si no auditaste (local → Context7 → web). |
|
|
125
126
|
| `tool-map` | off | Registra las herramientas descubiertas en `.ai/tool-map.json` para no volver a explorar. |
|
|
126
127
|
|
|
128
|
+
### 🧠 Research flow — la memoria primero, nunca adivinar una librería
|
|
129
|
+
|
|
130
|
+
| Gate | | Qué hace |
|
|
131
|
+
| -------------- | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| `engram-first` | on | Bloquea `WebSearch`, `WebFetch` y context7 hasta que en la sesión haya corrido un `mem_search` ([engram](https://github.com/Gentleman-Programming/engram) es la primera fuente); bloquea el Stop mientras hubo investigación sin un `mem_save` posterior; al arrancar avisa una vez si engram cloud está configurado y el proyecto no está enrolado. |
|
|
133
|
+
| `library-docs` | on | Bloquea una escritura que importa un paquete que el proyecto no usa en ningún lado, salvo que en esta sesión se haya consultado: un hit de engram sobre él, o docs de context7 seguidas de un `mem_save`. Nunca adivinar la API de una librería. |
|
|
134
|
+
|
|
127
135
|
### 🏭 Forge pipeline — obliga a seguir el flujo de forge
|
|
128
136
|
| Gate | | Qué hace |
|
|
129
137
|
|---|---|---|
|
|
@@ -164,7 +172,17 @@ ves y editas cada perilla:
|
|
|
164
172
|
}
|
|
165
173
|
```
|
|
166
174
|
|
|
167
|
-
- **Apagar un gate:** `"enabled": false`. Se apaga al instante, sin reinstalar.
|
|
175
|
+
- **Apagar un gate:** `"enabled": false`. Se apaga al instante, sin reinstalar. O desde el CLI:
|
|
176
|
+
`claude-gates disable <gate>` / `claude-gates enable <gate>` (ids de gate, claves de
|
|
177
|
+
configuración, ids de familia o `all`; `--project` o `--global`), y `claude-gates status`
|
|
178
|
+
para ver qué está prendido en el directorio actual y de dónde sale cada valor.
|
|
179
|
+
- **Volver a correr `init` mergea, nunca pisa.** Un gate que ya está en el archivo solo cambia
|
|
180
|
+
si lo nombras (`--gates`, `--families`, `--all`, `--none`) y, cuando su `enabled` cambiaría,
|
|
181
|
+
lo confirmas gate por gate. Con `--yes` (sin TTY) se conservan los valores existentes y se
|
|
182
|
+
reportan; con `--force` se aplican sin preguntar.
|
|
183
|
+
- **Un parámetro con el tipo equivocado nunca rompe un gate.** Un string donde se espera una
|
|
184
|
+
lista, o un regex mal escrito, cae al default incorporado y se reporta una vez por sesión
|
|
185
|
+
en el propio mensaje del gate (y en el log de decisiones).
|
|
168
186
|
- **Ajustar su comportamiento:** editas sus parámetros (la lista blanca, los patrones, los
|
|
169
187
|
umbrales). Lo que declara el proyecto **reemplaza** el default del gate.
|
|
170
188
|
- Un gate que no aparece en la configuración usa su default del catálogo. Las claves que ya
|
|
@@ -175,8 +193,8 @@ ves y editas cada perilla:
|
|
|
175
193
|
(una frase que no depende de memoria), `[allow-coauthor]` (un co-autor legítimo en un
|
|
176
194
|
commit), `lint-ok: <razón>` (un falso positivo documentado del linter), `[skip-lint]`
|
|
177
195
|
(saltea el chequeo de staged-lint por un commit), `[wip]` (permite un commit
|
|
178
|
-
deliberadamente amplio, no atómico)
|
|
179
|
-
`depsWithoutOwnApi`.
|
|
196
|
+
deliberadamente amplio, no atómico), `comment-ok: <razón>` (un comentario explicativo que
|
|
197
|
+
debe quedarse). `dependency-skills` se exime vía su lista `depsWithoutOwnApi`.
|
|
180
198
|
- **Inyección de capacidades:** `capability-map` (on por defecto) es totalmente ajustable —
|
|
181
199
|
elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
|
|
182
200
|
(`maxClauseChars`, default 120), agregá raíces extra por tipo, regulá cada cuánto se
|
|
@@ -197,6 +215,36 @@ ves y editas cada perilla:
|
|
|
197
215
|
|
|
198
216
|
---
|
|
199
217
|
|
|
218
|
+
## Log de decisiones
|
|
219
|
+
|
|
220
|
+
Cada deny, warn y bloqueo de Stop se agrega como una línea JSON a
|
|
221
|
+
`<raíz>/.ai/gates-log.jsonl` (fecha, gate, clave de configuración, herramienta, un resumen de
|
|
222
|
+
una línea de la acción, el motivo, la sesión). Se lee con:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
claude-gates log # últimas 30 decisiones de este proyecto
|
|
226
|
+
claude-gates log --deny --gate bash-commands --tail 100
|
|
227
|
+
claude-gates log --since 2026-09-01T00:00:00Z --json
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
El archivo rota una vez a los 5 MB (`gates-log.1.jsonl`). `CLAUDE_GATES_LOG=0` lo desactiva.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Tareas: cerrar exige evidencia verificada
|
|
235
|
+
|
|
236
|
+
`task close` rechaza texto libre. Una tarea está hecha solo cuando una verificación pasa:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
claude-gates task close <id> --check "npm test" --expect "fail 0" --note "suite en verde"
|
|
240
|
+
claude-gates task close <id> --exists dist/report.html --contains "All green"
|
|
241
|
+
claude-gates task abandon <id> --reason "obsoleta"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
El resultado verificado (comando, código de salida, cola de la salida, fecha) queda guardado con la tarea.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
200
248
|
## Comandos del CLI
|
|
201
249
|
|
|
202
250
|
```bash
|
|
@@ -206,10 +254,19 @@ npx @devrik-tools/claude-gates init
|
|
|
206
254
|
# Sin menú (para CI o scripts):
|
|
207
255
|
claude-gates init --project|--global --defaults|--all|--none|--families a,b|--gates x,y --yes --dry-run
|
|
208
256
|
claude-gates init --no-install # escribe la configuración pero no instala el plugin
|
|
257
|
+
claude-gates init --force # aplica cambios a gates ya presentes sin preguntar
|
|
258
|
+
|
|
259
|
+
# Prender, apagar e inspeccionar lo que corre aquí:
|
|
260
|
+
claude-gates enable <gate|familia|all> [--project|--global]
|
|
261
|
+
claude-gates disable <gate|familia|all> [--project|--global]
|
|
262
|
+
claude-gates status # on/off efectivo por gate y su origen (project/global/default)
|
|
263
|
+
claude-gates log [--tail N] [--deny] [--gate id] [--since iso] [--json]
|
|
264
|
+
claude-gates doctor # ¿Claude Code corre ESTA versión del paquete?
|
|
209
265
|
|
|
210
266
|
# Inspeccionar el catálogo:
|
|
211
267
|
claude-gates registry --list # lista familias y gates
|
|
212
|
-
claude-gates registry --check # valida registry.json
|
|
268
|
+
claude-gates registry --check # valida registry.json y que hooks.json esté sincronizado
|
|
269
|
+
claude-gates registry --sync-hooks # regenera el hooks.json de cada plugin desde el registry
|
|
213
270
|
```
|
|
214
271
|
|
|
215
272
|
---
|
|
@@ -225,7 +282,7 @@ cli/ El CLI de npm (commander + @clack/prompts + zo
|
|
|
225
282
|
init.mjs · install.mjs Flujo interactivo + instalar el plugin.
|
|
226
283
|
plugins/gates/ El plugin de gates.
|
|
227
284
|
.claude-plugin/plugin.json
|
|
228
|
-
hooks/hooks.json
|
|
285
|
+
hooks/hooks.json Generado desde registry.json (`registry --sync-hooks`). Lo carga Claude Code.
|
|
229
286
|
hooks/lib/ Código compartido de los hooks (Node built-ins only).
|
|
230
287
|
hooks/gates/<id>/ Un gate por carpeta: index.mjs (la regla) + test.mjs (su test).
|
|
231
288
|
plugins/tasks/ El plugin de tareas (en construcción): persiste tareas por proyecto.
|
|
@@ -233,7 +290,9 @@ plugins/tasks/ El plugin de tareas (en construcción): persis
|
|
|
233
290
|
```
|
|
234
291
|
|
|
235
292
|
**Agregar un gate** = una carpeta en `plugins/gates/hooks/gates/<id>/` (con `index.mjs` y
|
|
236
|
-
`test.mjs`) + una entrada en `registry.json
|
|
293
|
+
`test.mjs`) + una entrada en `registry.json`, y luego `claude-gates registry --sync-hooks`.
|
|
294
|
+
Los gates comparten `hooks/lib/` (lectura del payload, normalización de git, estado por
|
|
295
|
+
sesión, vocabulario de delegación, el log de decisiones, el harness de tests): un gate es solo su regla.
|
|
237
296
|
|
|
238
297
|
---
|
|
239
298
|
|
|
@@ -245,7 +304,8 @@ npm run registry:check # valida el catálogo
|
|
|
245
304
|
npm run lint # eslint (boundaries, no-magic-numbers, sonarjs, cspell…)
|
|
246
305
|
```
|
|
247
306
|
|
|
248
|
-
Cada gate se testea aislado: `node --test plugins/gates/hooks/gates/<id>/test.mjs
|
|
307
|
+
Cada gate se testea aislado: `node --test plugins/gates/hooks/gates/<id>/test.mjs`
|
|
308
|
+
(nombra los archivos de test: un glob que también matchee `index.mjs` se cuelga, porque un gate espera stdin).
|
|
249
309
|
|
|
250
310
|
> Este repositorio trae su propio `.ai/config.json` que apaga localmente los gates que
|
|
251
311
|
> darían falso positivo al **editar los gates mismos** (por ejemplo, `audit-before-build`
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ npx @devrik-tools/claude-gates init
|
|
|
29
29
|
|
|
30
30
|
Restart the Claude Code session (or run `/plugin`) so the hooks load.
|
|
31
31
|
|
|
32
|
-
> **Why two things?** The plugin **always ships all
|
|
32
|
+
> **Why two things?** The plugin **always ships all 44 gates**; the config decides **which
|
|
33
33
|
> ones run**. So you can turn one on without reinstalling — it is one line in a JSON file.
|
|
34
34
|
|
|
35
35
|
---
|
|
@@ -119,6 +119,7 @@ works even if you install one on its own.
|
|
|
119
119
|
| `atomic-commit` | off | Blocks a `git commit` that is not atomic — one mixing more than N natures of change (code/tests/deps/config…) or staging more reviewable files than a commit should carry. Docs/assets/generated aren't counted. Add `[wip]` for a deliberately broad commit. |
|
|
120
120
|
| `no-coauthor` | on | Blocks a `git commit` carrying an AI/agent attribution trailer (`Co-Authored-By`, `Generated with`, a session trailer). Add `[allow-coauthor]` for one legitimate co-author. |
|
|
121
121
|
| `no-lint-suppression` | on | Blocks a write that silences the linter/type-checker (`eslint-disable`, `@ts-ignore`, a rule set to `off`) instead of fixing the code. Add `lint-ok: <reason>` on the same line for a documented false positive. |
|
|
122
|
+
| `no-explanatory-comments` | on | Blocks a code write that adds comments narrating what the code does. Only decision comments (why, trade-off, limitation), tool directives, `TODO`/`FIXME` and typed JSDoc tags pass. Judges only new comments (diff against disk). Add `comment-ok: <reason>` for a documented exception. |
|
|
122
123
|
|
|
123
124
|
### 🔎 Tool discovery — don't reinvent the wheel
|
|
124
125
|
|
|
@@ -127,6 +128,13 @@ works even if you install one on its own.
|
|
|
127
128
|
| `reuse-before-build` | off | Before building a tool, consults the project tool map; blocks if you did not audit (local → Context7 → web). |
|
|
128
129
|
| `tool-map` | off | Records discovered tools in `.ai/tool-map.json` so exploration is not repeated. |
|
|
129
130
|
|
|
131
|
+
### 🧠 Research flow — memory first, never guess a library
|
|
132
|
+
|
|
133
|
+
| Gate | | What it does |
|
|
134
|
+
| -------------- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
135
|
+
| `engram-first` | on | Blocks `WebSearch`, `WebFetch` and context7 until a `mem_search` ran in the session ([engram](https://github.com/Gentleman-Programming/engram) is the first source); blocks the Stop event while research happened with no later `mem_save`; at session start warns once when engram cloud is configured but the project is not enrolled. |
|
|
136
|
+
| `library-docs` | on | Blocks a write that imports a package the project does not use anywhere yet unless this session looked it up: an engram hit about it, or context7 docs followed by a `mem_save`. Never guess a library API. |
|
|
137
|
+
|
|
130
138
|
### 🏭 Forge pipeline — enforces the forge workflow
|
|
131
139
|
|
|
132
140
|
| Gate | | What it does |
|
|
@@ -175,7 +183,17 @@ see and edit every knob:
|
|
|
175
183
|
}
|
|
176
184
|
```
|
|
177
185
|
|
|
178
|
-
- **Turn a gate off:** `"enabled": false`. Off instantly, no reinstall.
|
|
186
|
+
- **Turn a gate off:** `"enabled": false`. Off instantly, no reinstall. Or from the CLI:
|
|
187
|
+
`claude-gates disable <gate>` / `claude-gates enable <gate>` (gate ids, config keys,
|
|
188
|
+
family ids or `all`; `--project` or `--global`), and `claude-gates status` to see what is
|
|
189
|
+
on for the current directory and where each value comes from.
|
|
190
|
+
- **Re-running `init` merges, never overwrites.** A gate already in the file only changes
|
|
191
|
+
when you name it (`--gates`, `--families`, `--all`, `--none`) and, when its `enabled`
|
|
192
|
+
would flip, you confirm it gate by gate. With `--yes` (no TTY) the existing values are
|
|
193
|
+
kept and reported; pass `--force` to apply them without asking.
|
|
194
|
+
- **A wrong-typed param never breaks a gate.** A string where a list is expected, or a
|
|
195
|
+
malformed regex, falls back to the built-in default and is reported once per session in
|
|
196
|
+
the gate's own message (and in the decision log).
|
|
179
197
|
- **Tune its behavior:** edit its parameters (the whitelist, the patterns, the thresholds).
|
|
180
198
|
What the project declares **replaces** the gate's default.
|
|
181
199
|
- A gate absent from the config uses its catalog default. Keys you already had in the file
|
|
@@ -188,7 +206,8 @@ see and edit every knob:
|
|
|
188
206
|
non-memory phrase), `[allow-coauthor]` (one legitimate co-author on a commit),
|
|
189
207
|
`lint-ok: <reason>` (a documented linter false positive), `[skip-lint]` (skip the
|
|
190
208
|
staged-lint check for one commit), `[wip]` (allow one deliberately broad,
|
|
191
|
-
non-atomic commit)
|
|
209
|
+
non-atomic commit), `comment-ok: <reason>` (one explanatory comment that must stay).
|
|
210
|
+
`dependency-skills` opts out via its `depsWithoutOwnApi` list.
|
|
192
211
|
- **Capability injection:** `capability-map` (on by default) is fully tunable — pick which
|
|
193
212
|
kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
|
|
194
213
|
(`maxClauseChars`, default 120), add extra roots per kind, throttle how often the full
|
|
@@ -208,6 +227,36 @@ see and edit every knob:
|
|
|
208
227
|
|
|
209
228
|
---
|
|
210
229
|
|
|
230
|
+
## Decision log
|
|
231
|
+
|
|
232
|
+
Every deny, warn and Stop-block is appended as one JSON line to
|
|
233
|
+
`<root>/.ai/gates-log.jsonl` (timestamp, gate, config key, tool, a one-line summary of the
|
|
234
|
+
action, the reason, the session). Read it with:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
claude-gates log # last 30 decisions for this project
|
|
238
|
+
claude-gates log --deny --gate bash-commands --tail 100
|
|
239
|
+
claude-gates log --since 2026-09-01T00:00:00Z --json
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The file rotates once at 5 MB (`gates-log.1.jsonl`). Set `CLAUDE_GATES_LOG=0` to disable it.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Tasks: closing needs verified evidence
|
|
247
|
+
|
|
248
|
+
`task close` refuses free text. A task is done only when a check passes:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
claude-gates task close <id> --check "npm test" --expect "fail 0" --note "suite green"
|
|
252
|
+
claude-gates task close <id> --exists dist/report.html --contains "All green"
|
|
253
|
+
claude-gates task abandon <id> --reason "obsolete"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The verified result (command, exit code, output tail, timestamp) is stored with the task.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
211
260
|
## CLI commands
|
|
212
261
|
|
|
213
262
|
```bash
|
|
@@ -217,10 +266,19 @@ npx @devrik-tools/claude-gates init
|
|
|
217
266
|
# Non-interactive (for CI or scripts):
|
|
218
267
|
claude-gates init --project|--global --defaults|--all|--none|--families a,b|--gates x,y --yes --dry-run
|
|
219
268
|
claude-gates init --no-install # write the config but do not install the plugin
|
|
269
|
+
claude-gates init --force # apply changes to gates already in the file without asking
|
|
270
|
+
|
|
271
|
+
# Toggle and inspect what runs here:
|
|
272
|
+
claude-gates enable <gate|family|all> [--project|--global]
|
|
273
|
+
claude-gates disable <gate|family|all> [--project|--global]
|
|
274
|
+
claude-gates status # effective on/off per gate and its source (project/global/default)
|
|
275
|
+
claude-gates log [--tail N] [--deny] [--gate id] [--since iso] [--json]
|
|
276
|
+
claude-gates doctor # is Claude Code running THIS package version of the plugins?
|
|
220
277
|
|
|
221
278
|
# Inspect the catalog:
|
|
222
279
|
claude-gates registry --list # list families and gates
|
|
223
|
-
claude-gates registry --check # validate registry.json
|
|
280
|
+
claude-gates registry --check # validate registry.json and that hooks.json is in sync
|
|
281
|
+
claude-gates registry --sync-hooks # regenerate each plugin's hooks.json from the registry
|
|
224
282
|
|
|
225
283
|
# Verify the gates actually react (not just that they are wired):
|
|
226
284
|
claude-gates smoke # feed each gate a known violation; exits non-zero if any does not block/warn
|
|
@@ -245,7 +303,7 @@ cli/ The npm CLI (commander + @clack/prompts + zod)
|
|
|
245
303
|
init.mjs · install.mjs Interactive flow + install the plugin.
|
|
246
304
|
plugins/gates/ The gates plugin.
|
|
247
305
|
.claude-plugin/plugin.json
|
|
248
|
-
hooks/hooks.json
|
|
306
|
+
hooks/hooks.json Generated from registry.json (`registry --sync-hooks`). Loaded by Claude Code.
|
|
249
307
|
hooks/lib/ Shared hook code (Node built-ins only).
|
|
250
308
|
hooks/gates/<id>/ One gate per folder: index.mjs (the rule) + test.mjs (its test).
|
|
251
309
|
plugins/tasks/ The tasks plugin (work in progress): persists per-project tasks.
|
|
@@ -253,7 +311,9 @@ plugins/tasks/ The tasks plugin (work in progress): persists
|
|
|
253
311
|
```
|
|
254
312
|
|
|
255
313
|
**Adding a gate** = one folder in `plugins/gates/hooks/gates/<id>/` (with `index.mjs` and
|
|
256
|
-
`test.mjs`) + one entry in `registry.json
|
|
314
|
+
`test.mjs`) + one entry in `registry.json`, then `claude-gates registry --sync-hooks`.
|
|
315
|
+
Gates share `hooks/lib/` (payload readers, git normalization, session state, delegation
|
|
316
|
+
vocabulary, the decision log, the test harness): a gate file is only its rule.
|
|
257
317
|
|
|
258
318
|
---
|
|
259
319
|
|
|
@@ -265,7 +325,8 @@ npm run registry:check # validate the catalog
|
|
|
265
325
|
npm run lint # eslint (boundaries, no-magic-numbers, sonarjs, cspell…)
|
|
266
326
|
```
|
|
267
327
|
|
|
268
|
-
Each gate is tested in isolation: `node --test plugins/gates/hooks/gates/<id>/test.mjs
|
|
328
|
+
Each gate is tested in isolation: `node --test plugins/gates/hooks/gates/<id>/test.mjs`
|
|
329
|
+
(name the test files: a glob that also matches `index.mjs` hangs, because a gate waits on stdin).
|
|
269
330
|
|
|
270
331
|
> This repo ships its own `.ai/config.json` that locally disables the gates that would
|
|
271
332
|
> false-positive when **editing the gates themselves** (e.g. `audit-before-build` thinks
|
package/cli/doctor.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { REPOSITORY_ROOT } from './constants.mjs';
|
|
5
|
+
|
|
6
|
+
const CLAUDE_BIN = 'claude';
|
|
7
|
+
|
|
8
|
+
function runClaudeDefault(commandArguments) {
|
|
9
|
+
return execFileSync(CLAUDE_BIN, commandArguments, {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: 'pipe',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parsePluginList(listing) {
|
|
16
|
+
const plugins = [];
|
|
17
|
+
let current = null;
|
|
18
|
+
for (const line of listing.split(/\r?\n/)) {
|
|
19
|
+
const name = /^\s*(?:❯\s*)?([\w-]+)@([\w-]+)\s*$/.exec(line);
|
|
20
|
+
if (name) {
|
|
21
|
+
current = { plugin: name[1], marketplace: name[2] };
|
|
22
|
+
plugins.push(current);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!current) continue;
|
|
26
|
+
const version = /^\s*Version:\s*(\S+)/.exec(line);
|
|
27
|
+
if (version) current.version = version[1];
|
|
28
|
+
const scope = /^\s*Scope:\s*(\S+)/.exec(line);
|
|
29
|
+
if (scope) current.scope = scope[1];
|
|
30
|
+
}
|
|
31
|
+
return plugins;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseMarketplaceSources(listing) {
|
|
35
|
+
const sources = {};
|
|
36
|
+
let current = null;
|
|
37
|
+
for (const line of listing.split(/\r?\n/)) {
|
|
38
|
+
const name = /^\s*(?:❯\s*)?([\w-]+)\s*$/.exec(line);
|
|
39
|
+
if (name) {
|
|
40
|
+
current = name[1];
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const source = /^\s*Source:\s*(\w+)\s*\(([^)]+)\)/.exec(line);
|
|
44
|
+
if (source && current)
|
|
45
|
+
sources[current] = { kind: source[1], path: source[2] };
|
|
46
|
+
}
|
|
47
|
+
return sources;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function withoutTrailingSlashes(text) {
|
|
51
|
+
let end = text.length;
|
|
52
|
+
while (end > 0 && (text[end - 1] === '/' || text[end - 1] === '\\')) end -= 1;
|
|
53
|
+
return text.slice(0, end).toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compareVersions(a, b) {
|
|
57
|
+
const left = String(a).split('.').map(Number);
|
|
58
|
+
const right = String(b).split('.').map(Number);
|
|
59
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
60
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
61
|
+
if (difference !== 0) return difference;
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function diagnose({ runClaude = runClaudeDefault } = {}) {
|
|
67
|
+
const manifest = JSON.parse(
|
|
68
|
+
readFileSync(
|
|
69
|
+
join(REPOSITORY_ROOT, '.claude-plugin', 'marketplace.json'),
|
|
70
|
+
'utf8',
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
const packageVersion = JSON.parse(
|
|
74
|
+
readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
|
75
|
+
).version;
|
|
76
|
+
const problems = [];
|
|
77
|
+
const facts = [`package: ${packageVersion} at ${REPOSITORY_ROOT}`];
|
|
78
|
+
let plugins;
|
|
79
|
+
let sources;
|
|
80
|
+
try {
|
|
81
|
+
plugins = parsePluginList(runClaude(['plugin', 'list']));
|
|
82
|
+
sources = parseMarketplaceSources(
|
|
83
|
+
runClaude(['plugin', 'marketplace', 'list']),
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
problems.push(
|
|
87
|
+
`could not query Claude Code (${error?.message ?? error}); is \`claude\` on PATH?`,
|
|
88
|
+
);
|
|
89
|
+
return { packageVersion, facts, problems };
|
|
90
|
+
}
|
|
91
|
+
const ours = plugins.filter((plugin) =>
|
|
92
|
+
manifest.plugins.some((declared) => declared.name === plugin.plugin),
|
|
93
|
+
);
|
|
94
|
+
if (ours.length === 0) {
|
|
95
|
+
problems.push(
|
|
96
|
+
'no claude-gates plugin is installed in Claude Code: run `claude-gates init --global --defaults --yes`',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
for (const plugin of ours) {
|
|
100
|
+
facts.push(
|
|
101
|
+
`${plugin.plugin}@${plugin.marketplace} ${plugin.version} (${plugin.scope})`,
|
|
102
|
+
);
|
|
103
|
+
if (compareVersions(plugin.version, packageVersion) < 0) {
|
|
104
|
+
problems.push(
|
|
105
|
+
`${plugin.plugin} (${plugin.scope}) runs ${plugin.version} but this package is ${packageVersion}: ` +
|
|
106
|
+
'run `claude-gates init --global --defaults --yes` from this package to re-point the marketplace and reinstall',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const source = sources[plugin.marketplace];
|
|
110
|
+
if (source?.kind === 'Directory') {
|
|
111
|
+
const same =
|
|
112
|
+
withoutTrailingSlashes(source.path) ===
|
|
113
|
+
withoutTrailingSlashes(REPOSITORY_ROOT);
|
|
114
|
+
if (!same)
|
|
115
|
+
problems.push(
|
|
116
|
+
`marketplace "${plugin.marketplace}" serves from ${source.path}, not from this package (${REPOSITORY_ROOT})`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { packageVersion, facts, problems };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderDiagnosis(report) {
|
|
124
|
+
const lines = report.facts.map((fact) => ` ${fact}`);
|
|
125
|
+
if (report.problems.length === 0) lines.push('\nAll good.');
|
|
126
|
+
else
|
|
127
|
+
lines.push(
|
|
128
|
+
'\nProblems:',
|
|
129
|
+
...report.problems.map((problem) => ` - ${problem}`),
|
|
130
|
+
);
|
|
131
|
+
return `${lines.join('\n')}\n`;
|
|
132
|
+
}
|
package/cli/evidence.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
6
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
7
|
+
|
|
8
|
+
function tail(text) {
|
|
9
|
+
return String(text ?? '')
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.filter((line) => line.length > 0)
|
|
12
|
+
.slice(-OUTPUT_TAIL_LINES)
|
|
13
|
+
.join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function verifyCommand(
|
|
17
|
+
command,
|
|
18
|
+
{ cwd, expect, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
19
|
+
) {
|
|
20
|
+
const result = spawnSync(command, {
|
|
21
|
+
cwd,
|
|
22
|
+
shell: true,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
timeout: timeoutMs,
|
|
25
|
+
});
|
|
26
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
27
|
+
const exitCode = result.error ? null : result.status;
|
|
28
|
+
const failure = commandFailure(result, exitCode, output, expect);
|
|
29
|
+
return {
|
|
30
|
+
kind: 'command',
|
|
31
|
+
command,
|
|
32
|
+
expect: expect ?? null,
|
|
33
|
+
exitCode,
|
|
34
|
+
outputTail: tail(output),
|
|
35
|
+
verified: failure === null,
|
|
36
|
+
verifiedAt: new Date().toISOString(),
|
|
37
|
+
failure,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commandFailure(result, exitCode, output, expect) {
|
|
42
|
+
if (result.error) return `could not run: ${result.error.message}`;
|
|
43
|
+
if (exitCode !== 0) return `exit code ${exitCode}`;
|
|
44
|
+
if (expect && !output.includes(expect))
|
|
45
|
+
return `output does not contain "${expect}"`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function verifyPath(path, { cwd, contains } = {}) {
|
|
50
|
+
const absolute = isAbsolute(path) ? path : join(cwd, path);
|
|
51
|
+
const exists = existsSync(absolute);
|
|
52
|
+
let containsFound = true;
|
|
53
|
+
if (exists && contains) {
|
|
54
|
+
try {
|
|
55
|
+
containsFound =
|
|
56
|
+
statSync(absolute).isFile() &&
|
|
57
|
+
readFileSync(absolute, 'utf8').includes(contains);
|
|
58
|
+
} catch {
|
|
59
|
+
containsFound = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const failure = pathFailure(path, exists, containsFound, contains);
|
|
63
|
+
return {
|
|
64
|
+
kind: 'path',
|
|
65
|
+
path,
|
|
66
|
+
contains: contains ?? null,
|
|
67
|
+
exists,
|
|
68
|
+
verified: failure === null,
|
|
69
|
+
verifiedAt: new Date().toISOString(),
|
|
70
|
+
failure,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pathFailure(path, exists, containsFound, contains) {
|
|
75
|
+
if (!exists) return `"${path}" does not exist`;
|
|
76
|
+
if (!containsFound) return `"${path}" does not contain "${contains}"`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { matcherFor } from '../plugins/gates/hooks/lib/hook-io.mjs';
|
|
4
|
+
import { JSON_INDENT, REPOSITORY_ROOT } from './constants.mjs';
|
|
5
|
+
import { allGates } from './registry.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
8
|
+
const MATCHED_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
|
9
|
+
const PLUGIN_ROOT_VARIABLE = '${CLAUDE_PLUGIN_ROOT}';
|
|
10
|
+
|
|
11
|
+
export function hooksJsonPathFor(pluginName) {
|
|
12
|
+
return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks', 'hooks.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hookEntry(script, timeoutSeconds, tools, event) {
|
|
16
|
+
const entry = {
|
|
17
|
+
hooks: [
|
|
18
|
+
{
|
|
19
|
+
type: 'command',
|
|
20
|
+
command: `node "${PLUGIN_ROOT_VARIABLE}/hooks/${script}"`,
|
|
21
|
+
timeout: timeoutSeconds,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
if (MATCHED_EVENTS.has(event)) entry.matcher = matcherFor(tools);
|
|
26
|
+
return MATCHED_EVENTS.has(event)
|
|
27
|
+
? { matcher: entry.matcher, hooks: entry.hooks }
|
|
28
|
+
: entry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildHooksManifest(registry, pluginName) {
|
|
32
|
+
const hooks = {};
|
|
33
|
+
const pluginGates = allGates(registry).filter(
|
|
34
|
+
(gate) => gate.plugin === pluginName,
|
|
35
|
+
);
|
|
36
|
+
for (const gate of pluginGates) {
|
|
37
|
+
const timeoutSeconds = gate.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
38
|
+
const scripts = [
|
|
39
|
+
{ event: gate.event, script: gate.script, tools: gate.tools },
|
|
40
|
+
...(gate.extraScripts ?? []).map((extra) => ({
|
|
41
|
+
event: extra.event,
|
|
42
|
+
script: extra.script,
|
|
43
|
+
tools: extra.tools ?? gate.tools,
|
|
44
|
+
})),
|
|
45
|
+
];
|
|
46
|
+
for (const { event, script, tools } of scripts) {
|
|
47
|
+
hooks[event] ??= [];
|
|
48
|
+
hooks[event].push(hookEntry(script, timeoutSeconds, tools, event));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { hooks };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function pluginNamesIn(registry) {
|
|
55
|
+
return [...new Set(allGates(registry).map((gate) => gate.plugin))];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readManifestJson(pluginName) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
JSON.parse(readFileSync(hooksJsonPathFor(pluginName), 'utf8')),
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hooksManifestDrift(registry) {
|
|
69
|
+
const drifted = [];
|
|
70
|
+
for (const pluginName of pluginNamesIn(registry)) {
|
|
71
|
+
const expected = JSON.stringify(buildHooksManifest(registry, pluginName));
|
|
72
|
+
if (readManifestJson(pluginName) !== expected) drifted.push(pluginName);
|
|
73
|
+
}
|
|
74
|
+
return drifted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function writeHooksManifests(registry) {
|
|
78
|
+
const written = [];
|
|
79
|
+
for (const pluginName of pluginNamesIn(registry)) {
|
|
80
|
+
const path = hooksJsonPathFor(pluginName);
|
|
81
|
+
writeFileSync(
|
|
82
|
+
path,
|
|
83
|
+
`${JSON.stringify(buildHooksManifest(registry, pluginName), null, JSON_INDENT)}\n`,
|
|
84
|
+
'utf8',
|
|
85
|
+
);
|
|
86
|
+
written.push(path);
|
|
87
|
+
}
|
|
88
|
+
return written;
|
|
89
|
+
}
|