@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.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +103 -60
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +79 -143
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +83 -11
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +49 -93
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +97 -48
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +98 -206
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -91
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +104 -136
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -75
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. 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.7.1"
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.7.1"
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 41 gates**; la configuración decide
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 (30, en 7 familias)
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). `dependency-skills` se exime vía su lista
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 Una entrada por gate (matcher + comando). Lo carga Claude Code.
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`. El resto se deriva solo.
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 41 gates**; the config decides **which
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). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
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 One entry per gate (matcher + command). Loaded by Claude Code.
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`. Everything else derives automatically.
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
+ }
@@ -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
+ }