@devrik-tools/claude-gates 0.4.0 → 0.7.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -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.4.0"
14
+ "version": "0.7.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.4.0"
20
+ "version": "0.7.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 30 gates**; la configuración decide
33
+ > **¿Por qué dos cosas?** El plugin **siempre trae los 41 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
  ---
@@ -88,6 +88,7 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
88
88
  |---|---|---|
89
89
  | `feature-catalog` | on | Una sola feature en progreso; cerrar exige asserts y revisión. |
90
90
  | `sdd-specs` | off | Exige requirements/design/tasks no vacíos antes de implementar. |
91
+ | `brief-approved` | on | Exige una aprobación del usuario registrada (`status: approved` + cita) en el brief de la feature citada antes de implementar. |
91
92
  | `implementation-pipeline` | off | Exige declarar definición → escritura → validación → QA → cierre. |
92
93
  | `mandatory-flow` | off | Exige una tarea activa con contrato antes de implementar. |
93
94
  | `test-matrix` | off | Exige una matriz de tests (los tipos que el requerimiento vuelve obligatorios). |
@@ -105,6 +106,11 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
105
106
  | `no-reconfirm` | on | Nunca vuelve a preguntar lo que ya respondiste. |
106
107
  | `neutral-spanish` | on | Bloquea voseo o léxico regional en el texto escrito (marcador `neutral-spanish:allow` para una cita/fixture deliberada). |
107
108
  | `diagnosis-before-patch` | on | Avisa cuando se cambian timeouts/reintentos sin evidencia. |
109
+ | `lint-commit` | off | Bloquea `git commit` mientras el script de lint del proyecto falla (autodetecta `npm run lint`; silencioso si no hay). |
110
+ | `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. |
111
+ | `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. |
112
+ | `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. |
113
+ | `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
114
 
109
115
  ### 🔎 Tool discovery — no reinventar la rueda
110
116
  | Gate | | Qué hace |
@@ -117,9 +123,13 @@ ejecución), así que funciona aunque instales uno suelto por fuera.
117
123
  |---|---|---|
118
124
  | `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
125
 
120
- ### 🩺 Session start — validaciones al arrancar la sesión *(en construcción)*
121
- `doctor`, `ask-adoption`, `wiring-check` declarados en el catálogo; sus scripts se migran
122
- a continuación.
126
+ ### 🩺 Sesión y contexto — validaciones al arrancar e inyección de capacidades
127
+ | Gate | | Qué hace |
128
+ |---|---|---|
129
+ | `doctor` | on | Al iniciar la sesión, corre el validador de entorno y solo habla si algo falla. |
130
+ | `ask-adoption` | on | En un proyecto que nunca respondió, hace que el asistente pregunte qué adoptar. |
131
+ | `wiring-check` | on | Avisa cuando un hook registrado falta o un script quedó huérfano. |
132
+ | `capability-map` | on | Cada `injectEveryMessages` mensajes (default 10; siempre en la primera corrida y cuando se agrega/borra una capacidad), 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
133
 
124
134
  ---
125
135
 
@@ -153,6 +163,31 @@ ves y editas cada perilla:
153
163
  umbrales). Lo que declara el proyecto **reemplaza** el default del gate.
154
164
  - Un gate que no aparece en la configuración usa su default del catálogo. Las claves que ya
155
165
  tuvieras en el archivo (por ejemplo `autoCommit`) se conservan intactas.
166
+ - **Válvulas de escape:** algunos gates bloquean (deny) pero aceptan un marcador explícito
167
+ de exención en el contenido/prompt para un caso legítimo: `neutral-spanish:allow` (una cita
168
+ regional deliberada), `test-after-impl:allow` (un test de regresión), `memory-not-needed`
169
+ (una frase que no depende de memoria), `[allow-coauthor]` (un co-autor legítimo en un
170
+ commit), `lint-ok: <razón>` (un falso positivo documentado del linter), `[skip-lint]`
171
+ (saltea el chequeo de staged-lint por un commit), `[wip]` (permite un commit
172
+ deliberadamente amplio, no atómico). `dependency-skills` se exime vía su lista
173
+ `depsWithoutOwnApi`.
174
+ - **Inyección de capacidades:** `capability-map` (on por defecto) es totalmente ajustable —
175
+ elegí qué tipos exponer (`"kinds": ["skills", "agents", "commands"]`), limitá cada blurb
176
+ (`maxClauseChars`, default 120), agregá raíces extra por tipo, regulá cada cuánto se
177
+ re-inyecta el catálogo completo (`injectEveryMessages`, default 10 — el archivo persistido
178
+ se refresca igual en cada mensaje), o apagá la persistencia (`"persist": false`) y apuntá
179
+ el mapa a otro archivo (`mapFile`). Las skills también se escanean por defecto en
180
+ `~/.agents/skills`, `<proyecto>/.agents/skills`, `~/.ai/skills` y `<proyecto>/.ai/skills`
181
+ (raíces exclusivas de skills que usan otros instaladores además de `.claude/skills` — sin
182
+ necesidad de configurar nada), sumadas a cualquier `extraSkillsDirs` que el proyecto
183
+ declare. Una descripción que no entra en `maxClauseChars` cae al truncado mecánico por
184
+ palabra completa, pero podés escribir a mano un resumen mejor por capacidad en
185
+ `~/.claude/blurb-overrides.json` (global) o `<proyecto>/<blurbOverridesFile>` (default
186
+ `.ai/blurb-overrides.json`, el proyecto gana por clave) — un mapa
187
+ `{ "nombre-skill": "resumen corto" }`, usado tal cual en vez del corte mecánico. El
188
+ re-escaneo de una capacidad se saltea (se reusa su blurb tal cual) cuando el disco no
189
+ cambió desde el último escaneo (mismos archivos fuente, mismos mtimes); borrar una
190
+ skill/agente/comando saca su entrada del mapa en la corrida siguiente.
156
191
 
157
192
  ---
158
193
 
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 30 gates**; the config decides **which
32
+ > **Why two things?** The plugin **always ships all 41 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
  ---
@@ -90,6 +90,7 @@ works even if you install one on its own.
90
90
  | ------------------------- | --- | -------------------------------------------------------------------- |
91
91
  | `feature-catalog` | on | A single feature in progress; closing requires asserts and review. |
92
92
  | `sdd-specs` | off | Requires non-empty requirements/design/tasks before implementing. |
93
+ | `brief-approved` | on | Requires a recorded user approval (`status: approved` + quote) on the cited feature's brief before implementing. |
93
94
  | `implementation-pipeline` | off | Requires declaring definition → writing → validation → QA → closure. |
94
95
  | `mandatory-flow` | off | Requires an active task with a contract before implementing. |
95
96
  | `test-matrix` | off | Requires a test matrix (the types the requirement makes mandatory). |
@@ -108,6 +109,11 @@ works even if you install one on its own.
108
109
  | `no-reconfirm` | on | Never re-ask what you already answered. |
109
110
  | `neutral-spanish` | on | Blocks voseo or regional lexicon in written text (add `neutral-spanish:allow` for a deliberate quote/fixture). |
110
111
  | `diagnosis-before-patch` | on | Warns when timeouts/retries change without evidence. |
112
+ | `lint-commit` | off | Blocks `git commit` while the project's lint script fails (autodetects `npm run lint`; silent if none). |
113
+ | `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. |
114
+ | `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. |
115
+ | `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. |
116
+ | `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. |
111
117
 
112
118
  ### 🔎 Tool discovery — don't reinvent the wheel
113
119
 
@@ -122,10 +128,14 @@ works even if you install one on its own.
122
128
  | ------------ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
129
  | `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. |
124
130
 
125
- ### 🩺 Session start — startup checks _(work in progress)_
131
+ ### 🩺 Session & context — startup checks and capability injection
126
132
 
127
- `doctor`, `ask-adoption`, `wiring-check` declared in the catalog; their scripts are being
128
- migrated next.
133
+ | Gate | | What it does |
134
+ | ---------------- | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
135
+ | `doctor` | on | On session start, runs the environment validator and only speaks on failure. |
136
+ | `ask-adoption` | on | In a project that never answered, makes the assistant ask what to adopt. |
137
+ | `wiring-check` | on | Warns when a registered hook is missing or a script is orphaned. |
138
+ | `capability-map` | on | Every `injectEveryMessages` messages (default 10; always on the first run and whenever a capability is added/removed), 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. |
129
139
 
130
140
  ---
131
141
 
@@ -170,7 +180,26 @@ see and edit every knob:
170
180
  - **Escape hatches:** a few gates block (deny) but accept an explicit opt-out marker in the
171
181
  content/prompt for a legitimate case: `neutral-spanish:allow` (a deliberate regional
172
182
  quote), `test-after-impl:allow` (a regression test), `memory-not-needed` (a
173
- non-memory phrase). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
183
+ non-memory phrase), `[allow-coauthor]` (one legitimate co-author on a commit),
184
+ `lint-ok: <reason>` (a documented linter false positive), `[skip-lint]` (skip the
185
+ staged-lint check for one commit), `[wip]` (allow one deliberately broad,
186
+ non-atomic commit). `dependency-skills` opts out via its `depsWithoutOwnApi` list.
187
+ - **Capability injection:** `capability-map` (on by default) is fully tunable — pick which
188
+ kinds to surface (`"kinds": ["skills", "agents", "commands"]`), cap each blurb
189
+ (`maxClauseChars`, default 120), add extra roots per kind, throttle how often the full
190
+ catalog is re-injected (`injectEveryMessages`, default 10 — the persisted map file itself
191
+ still refreshes every message), or turn off persistence (`"persist": false`) and point the
192
+ map file elsewhere (`mapFile`). Skills are also scanned by default under `~/.agents/skills`,
193
+ `<project>/.agents/skills`, `~/.ai/skills` and `<project>/.ai/skills` (skill-only roots some
194
+ installers use besides `.claude/skills` — no config needed), in addition to any
195
+ `extraSkillsDirs` the project declares. A description too long to fit `maxClauseChars`
196
+ falls back to mechanical word-boundary truncation, but you can hand-write a better one-line
197
+ summary per capability in `~/.claude/blurb-overrides.json` (global) or
198
+ `<project>/<blurbOverridesFile>` (default `.ai/blurb-overrides.json`, project wins per
199
+ key) — a `{ "skill-name": "short summary" }` map, used verbatim instead of the mechanical
200
+ cut. A capability's re-scan is skipped (its blurb reused verbatim) whenever disk is
201
+ unchanged since the last scan (same set of source files, same mtimes); removing a
202
+ skill/agent/command drops its entry from the map on the very next run.
174
203
 
175
204
  ---
176
205
 
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
- function stripBom(text) {
76
- return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
77
- }
78
-
79
- export function readConfig(path) {
80
- if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
81
- try {
82
- return {
83
- exists: true,
84
- data: JSON.parse(stripBom(readFileSync(path, 'utf8'))),
85
- corrupt: false,
86
- };
87
- } catch {
88
- return { exists: true, data: {}, corrupt: true };
89
- }
90
- }
91
-
92
- /**
93
- * Merges one gate's new value onto whatever the project already had, preserving the user's
94
- * edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
95
- * tuned those params, their values win and only `enabled` follows the new selection. A
96
- * paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
97
- * never overwrites a whitelist or pattern list the user changed by hand.
98
- */
99
- function mergeGate(existingValue, newValue) {
100
- if (typeof newValue === 'boolean') return newValue;
101
- if (existingValue && typeof existingValue === 'object') {
102
- return { ...newValue, ...existingValue, enabled: newValue.enabled };
103
- }
104
- return newValue;
105
- }
106
-
107
- /**
108
- * Merges the new selection into an existing config without touching unrelated keys. Gates
109
- * are merged key by key: a gate absent from the new map keeps its old value (a registry
110
- * that dropped a gate must not silently flip it), and a gate the user configured keeps its
111
- * params (see mergeGate).
112
- */
113
- export function mergeConfig(existing, { adopted, gates, gateVersion }) {
114
- const mergedGates = { ...(existing.gates ?? {}) };
115
- for (const [key, value] of Object.entries(gates)) {
116
- mergedGates[key] = mergeGate(mergedGates[key], value);
117
- }
118
- return { ...existing, adopted, gateVersion, gates: mergedGates };
119
- }
120
-
121
- export function writeConfig(path, data) {
122
- mkdirSync(dirname(path), { recursive: true });
123
- writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
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
+ }