@devrik-tools/claude-gates 0.3.1 → 0.6.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 +25 -4
- package/README.md +75 -43
- package/cli/config.mjs +126 -124
- package/cli/index.mjs +154 -104
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +441 -0
- package/cli/smoke.mjs +129 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +51 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- package/registry.json +171 -1
|
@@ -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.6.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.6.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 40 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
|
---
|
|
@@ -105,6 +105,11 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
105
105
|
| `no-reconfirm` | on | Nunca vuelve a preguntar lo que ya respondiste. |
|
|
106
106
|
| `neutral-spanish` | on | Bloquea voseo o léxico regional en el texto escrito (marcador `neutral-spanish:allow` para una cita/fixture deliberada). |
|
|
107
107
|
| `diagnosis-before-patch` | on | Avisa cuando se cambian timeouts/reintentos sin evidencia. |
|
|
108
|
+
| `lint-commit` | off | Bloquea `git commit` mientras el script de lint del proyecto falla (autodetecta `npm run lint`; silencioso si no hay). |
|
|
109
|
+
| `staged-lint` | off | Bloquea `git commit` cuando los archivos **en el stage** fallan lint — lintea solo lo que agregaste al stage, así tu cambio no puede meter deuda de lint nueva y la deuda preexistente en archivos que no tocaste nunca te bloquea. Marcador `[skip-lint]` para una excepción deliberada. |
|
|
110
|
+
| `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. |
|
|
111
|
+
| `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. |
|
|
112
|
+
| `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. |
|
|
108
113
|
|
|
109
114
|
### 🔎 Tool discovery — no reinventar la rueda
|
|
110
115
|
| Gate | | Qué hace |
|
|
@@ -117,9 +122,13 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
|
|
|
117
122
|
|---|---|---|
|
|
118
123
|
| `forge-flow` | off | En un proyecto que adoptó [forge](https://github.com/DevRik99/forge-mcp), bloquea editar/ejecutar si no hay un run de forge activo. Cierra el hueco que el MCP no puede: te obliga a pasar por el pipeline. |
|
|
119
124
|
|
|
120
|
-
### 🩺
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
### 🩺 Sesión y contexto — validaciones al arrancar e inyección de capacidades
|
|
126
|
+
| Gate | | Qué hace |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `doctor` | on | Al iniciar la sesión, corre el validador de entorno y solo habla si algo falla. |
|
|
129
|
+
| `ask-adoption` | on | En un proyecto que nunca respondió, hace que el asistente pregunte qué adoptar. |
|
|
130
|
+
| `wiring-check` | on | Avisa cuando un hook registrado falta o un script quedó huérfano. |
|
|
131
|
+
| `capability-map` | off | En cada mensaje, inyecta el catálogo de capacidades del proyecto — skills, agents/subagents, comandos — como dato compacto, y lo persiste en `.ai/capability-map.json` (como el mapa de herramientas). Autosincronizado desde el disco. Nunca bloquea. |
|
|
123
132
|
|
|
124
133
|
---
|
|
125
134
|
|
|
@@ -153,6 +162,18 @@ ves y editas cada perilla:
|
|
|
153
162
|
umbrales). Lo que declara el proyecto **reemplaza** el default del gate.
|
|
154
163
|
- Un gate que no aparece en la configuración usa su default del catálogo. Las claves que ya
|
|
155
164
|
tuvieras en el archivo (por ejemplo `autoCommit`) se conservan intactas.
|
|
165
|
+
- **Válvulas de escape:** algunos gates bloquean (deny) pero aceptan un marcador explícito
|
|
166
|
+
de exención en el contenido/prompt para un caso legítimo: `neutral-spanish:allow` (una cita
|
|
167
|
+
regional deliberada), `test-after-impl:allow` (un test de regresión), `memory-not-needed`
|
|
168
|
+
(una frase que no depende de memoria), `[allow-coauthor]` (un co-autor legítimo en un
|
|
169
|
+
commit), `lint-ok: <razón>` (un falso positivo documentado del linter), `[skip-lint]`
|
|
170
|
+
(saltea el chequeo de staged-lint por un commit), `[wip]` (permite un commit
|
|
171
|
+
deliberadamente amplio, no atómico). `dependency-skills` se exime vía su lista
|
|
172
|
+
`depsWithoutOwnApi`.
|
|
173
|
+
- **Inyección de capacidades:** `capability-map` (off por defecto) es totalmente ajustable —
|
|
174
|
+
elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
|
|
175
|
+
(`maxClauseChars`), agregá raíces extra por tipo, o apagá la persistencia
|
|
176
|
+
(`"persist": false`) y apuntá el mapa a otro archivo (`mapFile`).
|
|
156
177
|
|
|
157
178
|
---
|
|
158
179
|
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Claude executes a tool and **block or warn** when something breaks a rule: a des
|
|
|
5
5
|
command, a write to a protected file, a delegation with no brief, and more. Everything is
|
|
6
6
|
**configurable per project** and can be **turned on/off** whenever you want.
|
|
7
7
|
|
|
8
|
-
The core idea: instead of trusting the model to
|
|
8
|
+
The core idea: instead of trusting the model to _remember_ the rules, a **deterministic**
|
|
9
9
|
hook enforces them. A `git reset --hard` does not run because the model chose to behave —
|
|
10
10
|
it is blocked because a gate intercepts it.
|
|
11
11
|
|
|
@@ -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 40 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
|
---
|
|
@@ -65,60 +65,76 @@ works even if you install one on its own.
|
|
|
65
65
|
`[on]` = enabled by default; `[off]` = enable it if you want it.
|
|
66
66
|
|
|
67
67
|
### 🔒 Security — hard blocks on destructive actions
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
68
|
+
|
|
69
|
+
| Gate | | What it does |
|
|
70
|
+
| ---------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
71
|
+
| `bash-commands` | on | Blocks `git reset --hard`, `rm -rf` over protected areas, force push, and killing processes by name. |
|
|
72
|
+
| `block-remote-publish` | on | Blocks `git push`, `gh pr merge`, `gh release create` without authorization. Set `blockRemotePublish: false` to let the agent publish on its own. |
|
|
73
|
+
| `protected-paths` | on | Blocks writes to `.env`, lockfiles and the harness itself. |
|
|
74
|
+
| `root-whitelist` | on | Blocks new root-level files/folders outside a whitelist. |
|
|
75
|
+
| `no-blocking` | off | Blocks `sleep`, `tail -f`, polling loops and foreground servers. |
|
|
75
76
|
|
|
76
77
|
### 🤝 Delegation — requirements on the brief when delegating to a subagent
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
78
|
+
|
|
79
|
+
| Gate | | What it does |
|
|
80
|
+
| ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `brief-before-delegate` | off | Requires goal, steps and done-when criteria in the prompt. |
|
|
82
|
+
| `intent-flow` | off | Requires IN SCOPE / OUT OF SCOPE / EDGE CASES sections. |
|
|
83
|
+
| `risk-level` | off | Requires a declared level (QUESTION/MICRO/STANDARD/HIGH-RISK). |
|
|
84
|
+
| `circuit-breaker` | off | Cuts the same delegation retried without real changes. |
|
|
85
|
+
| `no-memory-dependency` | off | Blocks a brief that relies on the subagent "remembering" the chat (add `memory-not-needed` to allow a false positive). |
|
|
84
86
|
|
|
85
87
|
### 📋 Spec-driven flow — only relevant if the project adopted spec-driven development
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
|
89
|
-
| `
|
|
88
|
+
|
|
89
|
+
| Gate | | What it does |
|
|
90
|
+
| ------------------------- | --- | -------------------------------------------------------------------- |
|
|
91
|
+
| `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
|
|
92
|
+
| `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
|
|
90
93
|
| `implementation-pipeline` | off | Requires declaring definition → writing → validation → QA → closure. |
|
|
91
|
-
| `mandatory-flow`
|
|
92
|
-
| `test-matrix`
|
|
94
|
+
| `mandatory-flow` | off | Requires an active task with a contract before implementing. |
|
|
95
|
+
| `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
|
|
93
96
|
|
|
94
97
|
### ✨ Quality — code hygiene, diagnosis and language
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
98
|
+
|
|
99
|
+
| Gate | | What it does |
|
|
100
|
+
| --------------------------- | --- | -------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `dependency-skills` | on | Blocks a new direct dependency with no matching skill (declare it in `depsWithoutOwnApi` if it needs none). |
|
|
102
|
+
| `root-cause-first` | off | Requires an origin→symptom diagnosis before a patch. |
|
|
103
|
+
| `audit-before-build` | off | Before a new script/gate, requires stating that nothing existing covers it. |
|
|
104
|
+
| `never-assume` | off | Flags unverified assumptions in briefs and code. |
|
|
105
|
+
| `rule-skill-autodiscovery` | off | Loads gates the project declares in its `rules/` and `skills/`. |
|
|
106
|
+
| `recurrence-lock` | on | A second occurrence of a defect class requires its deterministic block. |
|
|
107
|
+
| `test-after-implementation` | off | Blocks a test written after its paired implementation (add `test-after-impl:allow` for a regression test). |
|
|
108
|
+
| `no-reconfirm` | on | Never re-ask what you already answered. |
|
|
109
|
+
| `neutral-spanish` | on | Blocks voseo or regional lexicon in written text (add `neutral-spanish:allow` for a deliberate quote/fixture). |
|
|
110
|
+
| `diagnosis-before-patch` | on | Warns when timeouts/retries change without evidence. |
|
|
111
|
+
| `lint-commit` | off | Blocks `git commit` while the project's lint script fails (autodetects `npm run lint`; silent if none). |
|
|
112
|
+
| `staged-lint` | off | Blocks `git commit` when the **staged** files fail lint — lints only what you staged, so your change can't add new lint debt while pre-existing debt in untouched files never blocks you. Add `[skip-lint]` for a deliberate exception. |
|
|
113
|
+
| `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. |
|
|
114
|
+
| `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. |
|
|
115
|
+
| `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. |
|
|
107
116
|
|
|
108
117
|
### 🔎 Tool discovery — don't reinvent the wheel
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
|
|
119
|
+
| Gate | | What it does |
|
|
120
|
+
| -------------------- | --- | ------------------------------------------------------------------------------------------------------------ |
|
|
111
121
|
| `reuse-before-build` | off | Before building a tool, consults the project tool map; blocks if you did not audit (local → Context7 → web). |
|
|
112
|
-
| `tool-map`
|
|
122
|
+
| `tool-map` | off | Records discovered tools in `.ai/tool-map.json` so exploration is not repeated. |
|
|
113
123
|
|
|
114
124
|
### 🏭 Forge pipeline — enforces the forge workflow
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
|
|
126
|
+
| Gate | | What it does |
|
|
127
|
+
| ------------ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
128
|
| `forge-flow` | off | In a project that adopted [forge](https://github.com/DevRik99/forge-mcp), blocks editing/running unless an active forge run exists. Closes the hole the MCP cannot: it forces you through the pipeline. |
|
|
118
129
|
|
|
119
|
-
### 🩺 Session
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
### 🩺 Session & context — startup checks and capability injection
|
|
131
|
+
|
|
132
|
+
| Gate | | What it does |
|
|
133
|
+
| ---------------- | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
134
|
+
| `doctor` | on | On session start, runs the environment validator and only speaks on failure. |
|
|
135
|
+
| `ask-adoption` | on | In a project that never answered, makes the assistant ask what to adopt. |
|
|
136
|
+
| `wiring-check` | on | Warns when a registered hook is missing or a script is orphaned. |
|
|
137
|
+
| `capability-map` | off | On every message, injects the project's capability catalog — skills, agents/subagents, commands — as compact data, and persists it to `.ai/capability-map.json` (like the tool map). Autosynced from disk. Never blocks. |
|
|
122
138
|
|
|
123
139
|
---
|
|
124
140
|
|
|
@@ -163,7 +179,14 @@ see and edit every knob:
|
|
|
163
179
|
- **Escape hatches:** a few gates block (deny) but accept an explicit opt-out marker in the
|
|
164
180
|
content/prompt for a legitimate case: `neutral-spanish:allow` (a deliberate regional
|
|
165
181
|
quote), `test-after-impl:allow` (a regression test), `memory-not-needed` (a
|
|
166
|
-
non-memory phrase)
|
|
182
|
+
non-memory phrase), `[allow-coauthor]` (one legitimate co-author on a commit),
|
|
183
|
+
`lint-ok: <reason>` (a documented linter false positive), `[skip-lint]` (skip the
|
|
184
|
+
staged-lint check for one commit), `[wip]` (allow one deliberately broad,
|
|
185
|
+
non-atomic commit). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
|
|
186
|
+
- **Capability injection:** `capability-map` (off by default) is fully tunable — pick which
|
|
187
|
+
kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
|
|
188
|
+
(`maxClauseChars`), add extra roots per kind, or turn off persistence (`"persist": false`)
|
|
189
|
+
and point the map file elsewhere (`mapFile`).
|
|
167
190
|
|
|
168
191
|
---
|
|
169
192
|
|
|
@@ -180,8 +203,17 @@ claude-gates init --no-install # write the config but do not install the
|
|
|
180
203
|
# Inspect the catalog:
|
|
181
204
|
claude-gates registry --list # list families and gates
|
|
182
205
|
claude-gates registry --check # validate registry.json
|
|
206
|
+
|
|
207
|
+
# Verify the gates actually react (not just that they are wired):
|
|
208
|
+
claude-gates smoke # feed each gate a known violation; exits non-zero if any does not block/warn
|
|
183
209
|
```
|
|
184
210
|
|
|
211
|
+
`smoke` is the behavioral check `registry --check` (structure) and the doctor hook (files
|
|
212
|
+
exist) do not do: it feeds every gate a known violation and confirms it really denies/warns.
|
|
213
|
+
Gates whose violation needs seeded state (a db, a git repo, cross-call state) report `skip`,
|
|
214
|
+
never a false pass. Run it after install, or in CI, to catch a gate that is wired but silently
|
|
215
|
+
allows.
|
|
216
|
+
|
|
185
217
|
---
|
|
186
218
|
|
|
187
219
|
## Repository layout
|
package/cli/config.mjs
CHANGED
|
@@ -1,124 +1,126 @@
|
|
|
1
|
-
// Where the selection is persisted and how it merges with what is already there.
|
|
2
|
-
//
|
|
3
|
-
// Scope "project": `<project root>/.ai/config.json` — the file the hooks read
|
|
4
|
-
// per project. Other keys the project already has (autoCommit, etc.) are kept.
|
|
5
|
-
// Scope "global": `~/.claude/claude-gates/config.json` — the fallback the hooks
|
|
6
|
-
// use when a project has no answer of its own.
|
|
7
|
-
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
10
|
-
import { findUpSync } from 'find-up';
|
|
11
|
-
import {
|
|
12
|
-
CLAUDE_USER_DIRECTORY,
|
|
13
|
-
CONFIG_FILE,
|
|
14
|
-
GLOBAL_STATE_DIRECTORY,
|
|
15
|
-
HOME_DIRECTORY,
|
|
16
|
-
JSON_INDENT,
|
|
17
|
-
PROJECT_ROOT_MARKERS,
|
|
18
|
-
PROJECT_STATE_DIRECTORY,
|
|
19
|
-
} from './constants.mjs';
|
|
20
|
-
|
|
21
|
-
export const SCOPES = Object.freeze({ GLOBAL: 'global', PROJECT: 'project' });
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Climbs from `startDirectory` to the nearest project marker (`.git`/`.ai`).
|
|
25
|
-
* The home directory (and anything above it) is never a project root: a stray
|
|
26
|
-
* `~/.ai` would otherwise turn every folder under home into "the project" and the
|
|
27
|
-
* config would land in the wrong place silently. `.git` may be a file (worktrees),
|
|
28
|
-
* so the matcher checks existence, not type.
|
|
29
|
-
*/
|
|
30
|
-
export function findProjectRoot(
|
|
31
|
-
startDirectory,
|
|
32
|
-
{ home = HOME_DIRECTORY } = {},
|
|
33
|
-
) {
|
|
34
|
-
if (startDirectory === home) return startDirectory;
|
|
35
|
-
const markerDirectory = findUpSync(
|
|
36
|
-
(directory) => {
|
|
37
|
-
if (directory === home) return findUpSync.stop;
|
|
38
|
-
const hasMarker = PROJECT_ROOT_MARKERS.some((marker) =>
|
|
39
|
-
existsSync(join(directory, marker)),
|
|
40
|
-
);
|
|
41
|
-
return hasMarker ? directory : undefined;
|
|
42
|
-
},
|
|
43
|
-
{ cwd: startDirectory, stopAt: home, type: 'directory' },
|
|
44
|
-
);
|
|
45
|
-
return markerDirectory ?? startDirectory;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function configPathFor(
|
|
49
|
-
scope,
|
|
50
|
-
{ cwd = process.cwd(), home = HOME_DIRECTORY } = {},
|
|
51
|
-
) {
|
|
52
|
-
if (scope === SCOPES.GLOBAL)
|
|
53
|
-
return join(
|
|
54
|
-
home,
|
|
55
|
-
CLAUDE_USER_DIRECTORY,
|
|
56
|
-
GLOBAL_STATE_DIRECTORY,
|
|
57
|
-
CONFIG_FILE,
|
|
58
|
-
);
|
|
59
|
-
if (scope === SCOPES.PROJECT)
|
|
60
|
-
return join(
|
|
61
|
-
findProjectRoot(cwd, { home }),
|
|
62
|
-
PROJECT_STATE_DIRECTORY,
|
|
63
|
-
CONFIG_FILE,
|
|
64
|
-
);
|
|
65
|
-
throw new Error(`Unknown scope: ${scope}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// A leading UTF-8 BOM (EF BB BF, decoded as U+FEFF) is not stripped by readFileSync('utf8'),
|
|
69
|
-
// and JSON.parse rejects a string that starts with it. Without stripping it, a config saved
|
|
70
|
-
// by a BOM-adding editor or `PowerShell Set-Content -Encoding utf8` would read back as
|
|
71
|
-
// `corrupt: true` — a valid, user-edited config (with its gate overrides) mistaken for
|
|
72
|
-
// unreadable. init.mjs bails loudly on `corrupt`, but a caller relying on `data` alone (as
|
|
73
|
-
// `mergeConfig` does) would otherwise merge onto `{}` and silently drop every existing gate
|
|
74
|
-
// override the user made. This is the same failure mode fixed in the gates' own config.mjs.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
1
|
+
// Where the selection is persisted and how it merges with what is already there.
|
|
2
|
+
//
|
|
3
|
+
// Scope "project": `<project root>/.ai/config.json` — the file the hooks read
|
|
4
|
+
// per project. Other keys the project already has (autoCommit, etc.) are kept.
|
|
5
|
+
// Scope "global": `~/.claude/claude-gates/config.json` — the fallback the hooks
|
|
6
|
+
// use when a project has no answer of its own.
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { findUpSync } from 'find-up';
|
|
11
|
+
import {
|
|
12
|
+
CLAUDE_USER_DIRECTORY,
|
|
13
|
+
CONFIG_FILE,
|
|
14
|
+
GLOBAL_STATE_DIRECTORY,
|
|
15
|
+
HOME_DIRECTORY,
|
|
16
|
+
JSON_INDENT,
|
|
17
|
+
PROJECT_ROOT_MARKERS,
|
|
18
|
+
PROJECT_STATE_DIRECTORY,
|
|
19
|
+
} from './constants.mjs';
|
|
20
|
+
|
|
21
|
+
export const SCOPES = Object.freeze({ GLOBAL: 'global', PROJECT: 'project' });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Climbs from `startDirectory` to the nearest project marker (`.git`/`.ai`).
|
|
25
|
+
* The home directory (and anything above it) is never a project root: a stray
|
|
26
|
+
* `~/.ai` would otherwise turn every folder under home into "the project" and the
|
|
27
|
+
* config would land in the wrong place silently. `.git` may be a file (worktrees),
|
|
28
|
+
* so the matcher checks existence, not type.
|
|
29
|
+
*/
|
|
30
|
+
export function findProjectRoot(
|
|
31
|
+
startDirectory,
|
|
32
|
+
{ home = HOME_DIRECTORY } = {},
|
|
33
|
+
) {
|
|
34
|
+
if (startDirectory === home) return startDirectory;
|
|
35
|
+
const markerDirectory = findUpSync(
|
|
36
|
+
(directory) => {
|
|
37
|
+
if (directory === home) return findUpSync.stop;
|
|
38
|
+
const hasMarker = PROJECT_ROOT_MARKERS.some((marker) =>
|
|
39
|
+
existsSync(join(directory, marker)),
|
|
40
|
+
);
|
|
41
|
+
return hasMarker ? directory : undefined;
|
|
42
|
+
},
|
|
43
|
+
{ cwd: startDirectory, stopAt: home, type: 'directory' },
|
|
44
|
+
);
|
|
45
|
+
return markerDirectory ?? startDirectory;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function configPathFor(
|
|
49
|
+
scope,
|
|
50
|
+
{ cwd = process.cwd(), home = HOME_DIRECTORY } = {},
|
|
51
|
+
) {
|
|
52
|
+
if (scope === SCOPES.GLOBAL)
|
|
53
|
+
return join(
|
|
54
|
+
home,
|
|
55
|
+
CLAUDE_USER_DIRECTORY,
|
|
56
|
+
GLOBAL_STATE_DIRECTORY,
|
|
57
|
+
CONFIG_FILE,
|
|
58
|
+
);
|
|
59
|
+
if (scope === SCOPES.PROJECT)
|
|
60
|
+
return join(
|
|
61
|
+
findProjectRoot(cwd, { home }),
|
|
62
|
+
PROJECT_STATE_DIRECTORY,
|
|
63
|
+
CONFIG_FILE,
|
|
64
|
+
);
|
|
65
|
+
throw new Error(`Unknown scope: ${scope}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A leading UTF-8 BOM (EF BB BF, decoded as U+FEFF) is not stripped by readFileSync('utf8'),
|
|
69
|
+
// and JSON.parse rejects a string that starts with it. Without stripping it, a config saved
|
|
70
|
+
// by a BOM-adding editor or `PowerShell Set-Content -Encoding utf8` would read back as
|
|
71
|
+
// `corrupt: true` — a valid, user-edited config (with its gate overrides) mistaken for
|
|
72
|
+
// unreadable. init.mjs bails loudly on `corrupt`, but a caller relying on `data` alone (as
|
|
73
|
+
// `mergeConfig` does) would otherwise merge onto `{}` and silently drop every existing gate
|
|
74
|
+
// override the user made. This is the same failure mode fixed in the gates' own config.mjs.
|
|
75
|
+
const BOM_CODE_POINT = 0xfeff;
|
|
76
|
+
|
|
77
|
+
function stripBom(text) {
|
|
78
|
+
return text.charCodeAt(0) === BOM_CODE_POINT ? text.slice(1) : text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readConfig(path) {
|
|
82
|
+
if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
|
|
83
|
+
try {
|
|
84
|
+
return {
|
|
85
|
+
exists: true,
|
|
86
|
+
data: JSON.parse(stripBom(readFileSync(path, 'utf8'))),
|
|
87
|
+
corrupt: false,
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return { exists: true, data: {}, corrupt: true };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merges one gate's new value onto whatever the project already had, preserving the user's
|
|
96
|
+
* edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
|
|
97
|
+
* tuned those params, their values win and only `enabled` follows the new selection. A
|
|
98
|
+
* paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
|
|
99
|
+
* never overwrites a whitelist or pattern list the user changed by hand.
|
|
100
|
+
*/
|
|
101
|
+
function mergeGate(existingValue, newValue) {
|
|
102
|
+
if (typeof newValue === 'boolean') return newValue;
|
|
103
|
+
if (existingValue && typeof existingValue === 'object') {
|
|
104
|
+
return { ...newValue, ...existingValue, enabled: newValue.enabled };
|
|
105
|
+
}
|
|
106
|
+
return newValue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merges the new selection into an existing config without touching unrelated keys. Gates
|
|
111
|
+
* are merged key by key: a gate absent from the new map keeps its old value (a registry
|
|
112
|
+
* that dropped a gate must not silently flip it), and a gate the user configured keeps its
|
|
113
|
+
* params (see mergeGate).
|
|
114
|
+
*/
|
|
115
|
+
export function mergeConfig(existing, { adopted, gates, gateVersion }) {
|
|
116
|
+
const mergedGates = { ...(existing.gates ?? {}) };
|
|
117
|
+
for (const [key, value] of Object.entries(gates)) {
|
|
118
|
+
mergedGates[key] = mergeGate(mergedGates[key], value);
|
|
119
|
+
}
|
|
120
|
+
return { ...existing, adopted, gateVersion, gates: mergedGates };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function writeConfig(path, data) {
|
|
124
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
125
|
+
writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
|
|
126
|
+
}
|