@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
@@ -1,207 +1,207 @@
1
- // doctor — SessionStart hook. Validates the environment claude-gates needs and speaks
2
- // ONLY on failure: node's version (node:sqlite and other built-ins the plugin relies on
3
- // need >=22.5.0, read from this package's own engines.node when reachable, else a
4
- // hard-coded fallback) and that every gate script the registry declares actually exists
5
- // on disk (a plugin installed half — e.g. copied without a file, or a stale global
6
- // install) fails silently otherwise: hooks.json references a script that is not there,
7
- // and Claude Code just skips it without telling anyone).
8
- //
9
- // justification: no existing tool covers this. wiring-check.mjs (same family) checks
10
- // registry vs hooks.json vs disk cross-consistency; doctor.mjs checks the runtime
11
- // environment itself (node version) plus the same "script exists" fact from the angle
12
- // of "can this plugin even run", so a broken environment is caught before any gate is
13
- // invoked and blocks something for the wrong reason.
14
- //
15
- // Fail-safe: every check is wrapped so a doctor bug never blocks a session from
16
- // starting — worst case it silently skips a check rather than throwing.
17
-
18
- import { existsSync, readFileSync } from 'node:fs';
19
- import { homedir } from 'node:os';
20
- import { dirname, join } from 'node:path';
21
- import { fileURLToPath } from 'node:url';
22
-
23
- const STDIN_FILE_DESCRIPTOR = 0;
24
- const SESSION_START_EVENT = 'SessionStart';
25
- const CONFIG_KEY = 'validateEnvironmentOnStart';
26
- const DEFAULT_MIN_NODE_VERSION = '22.5.0';
27
-
28
- const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
- const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
30
- const PACKAGE_JSON_PATH = join(REPOSITORY_ROOT, 'package.json');
31
- const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
32
-
33
- // Same config lookup as the other session hooks (see session-tasks.mjs / register-requests.mjs):
34
- // kept local and dependency-free so this plugin works standalone.
35
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
36
- const PROJECT_CONFIG = join('.ai', 'config.json');
37
- const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
38
-
39
- const BOM_CHAR_CODE = 0xfeff;
40
-
41
- /** Strips a UTF-8 BOM a Windows editor/tool may have written before the JSON. */
42
- function stripBom(text) {
43
- return text.charCodeAt(0) === BOM_CHAR_CODE ? text.slice(1) : text;
44
- }
45
-
46
- function readJson(path) {
47
- try {
48
- return JSON.parse(stripBom(readFileSync(path, 'utf8')));
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- function projectRootOf(startDirectory) {
55
- let current = startDirectory;
56
- while (true) {
57
- if (
58
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
59
- ) {
60
- return current;
61
- }
62
- const parent = dirname(current);
63
- if (parent === current) return null;
64
- current = parent;
65
- }
66
- }
67
-
68
- /** The doctor gate config entry (project overrides global), or null. */
69
- function doctorConfig(startDirectory) {
70
- const root = projectRootOf(startDirectory);
71
- const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
72
- const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
73
- const layer = projectData?.gates ?? globalData?.gates ?? {};
74
- const entry = layer[CONFIG_KEY];
75
- if (typeof entry === 'boolean') return { enabled: entry };
76
- if (entry && typeof entry === 'object') return entry;
77
- return null;
78
- }
79
-
80
- function readPayload() {
81
- try {
82
- return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
83
- } catch {
84
- return {};
85
- }
86
- }
87
-
88
- const VERSION_SEGMENT_COUNT = 3; // major, minor, patch
89
-
90
- /** Parses "22.5.0" (or "v22.5.0") into [22, 5, 0]; null when unparseable. */
91
- function parseVersion(raw) {
92
- const withoutPrefix = String(raw ?? '').replace(/^v/, '');
93
- const segments = withoutPrefix.split('.').slice(0, VERSION_SEGMENT_COUNT);
94
- if (segments.length < VERSION_SEGMENT_COUNT) return null;
95
- const numbers = segments.map((segment) => Number(/^\d+/.exec(segment)?.[0]));
96
- return numbers.some((n) => Number.isNaN(n)) ? null : numbers;
97
- }
98
-
99
- /** True when `actual` >= `required`, comparing major.minor.patch lexicographically. */
100
- function versionAtLeast(actual, required) {
101
- const a = parseVersion(actual);
102
- const r = parseVersion(required);
103
- if (!a || !r) return true; // can't parse: don't fail the session over a doctor bug
104
- for (let index = 0; index < VERSION_SEGMENT_COUNT; index += 1) {
105
- if (a[index] > r[index]) return true;
106
- if (a[index] < r[index]) return false;
107
- }
108
- return true;
109
- }
110
-
111
- /** The first "x.y.z"-shaped token inside an engines.node range string (">=22.5.0" -> "22.5.0"). */
112
- function firstVersionToken(engineRange) {
113
- for (const token of engineRange.split(/\s+/)) {
114
- const digits = token.replace(/[^\d.]/g, '');
115
- if (parseVersion(digits)) return digits;
116
- }
117
- return null;
118
- }
119
-
120
- /** The minimum node version this install requires: package.json's engines.node, else the fallback. */
121
- function minNodeVersionRequired(configuredMin) {
122
- if (configuredMin) return configuredMin;
123
- const packageManifest = readJson(PACKAGE_JSON_PATH);
124
- const engineRange = packageManifest?.engines?.node;
125
- const token = engineRange ? firstVersionToken(engineRange) : null;
126
- return token ?? DEFAULT_MIN_NODE_VERSION;
127
- }
128
-
129
- function checkNodeVersion(minVersion) {
130
- if (versionAtLeast(process.version, minVersion)) return null;
131
- return (
132
- `node ${process.version} is below the required >=${minVersion}. ` +
133
- 'Some gates (e.g. node:sqlite-backed task tracking) will not work until node is upgraded.'
134
- );
135
- }
136
-
137
- /** Every script the registry's PreToolUse/Stop gates (folder-style: gates/<id>/index.mjs) declare. */
138
- function missingFolderGateScripts() {
139
- const registry = readJson(REGISTRY_PATH);
140
- if (!registry?.families) return []; // can't read the registry: nothing to report
141
-
142
- const problems = [];
143
- for (const family of registry.families) {
144
- const pluginName = family.plugin ?? 'gates';
145
- const pluginHooksDirectory = join(
146
- REPOSITORY_ROOT,
147
- 'plugins',
148
- pluginName,
149
- 'hooks',
150
- );
151
- for (const gate of family.gates ?? []) {
152
- const scriptPath = join(pluginHooksDirectory, gate.script);
153
- if (!existsSync(scriptPath)) {
154
- problems.push(
155
- `${gate.id} (expected ${join('plugins', pluginName, 'hooks', gate.script)})`,
156
- );
157
- }
158
- }
159
- }
160
- return problems;
161
- }
162
-
163
- function speak(context) {
164
- process.stdout.write(
165
- JSON.stringify({
166
- hookSpecificOutput: {
167
- hookEventName: SESSION_START_EVENT,
168
- additionalContext: context,
169
- },
170
- }),
171
- );
172
- }
173
-
174
- function main() {
175
- const payload = readPayload();
176
- const cwd = payload.cwd || process.cwd();
177
-
178
- const config = doctorConfig(cwd);
179
- if (config && config.enabled === false) return; // gate turned off for this project
180
-
181
- const problems = [];
182
-
183
- const minVersion = minNodeVersionRequired(config?.minNodeVersion);
184
- const versionProblem = checkNodeVersion(minVersion);
185
- if (versionProblem) problems.push(versionProblem);
186
-
187
- const missingScripts = missingFolderGateScripts();
188
- if (missingScripts.length > 0) {
189
- problems.push(
190
- `${missingScripts.length} gate script(s) declared in registry.json are missing on disk: ${missingScripts.join(
191
- ', ',
192
- )}`,
193
- );
194
- }
195
-
196
- if (problems.length === 0) return; // environment is healthy: stay silent
197
-
198
- speak(
199
- `[doctor] claude-gates environment check failed:\n- ${problems.join('\n- ')}`,
200
- );
201
- }
202
-
203
- try {
204
- main();
205
- } catch {
206
- // A doctor bug must never block session start.
207
- }
1
+ // doctor — SessionStart hook. Validates the environment claude-gates needs and speaks
2
+ // ONLY on failure: node's version (node:sqlite and other built-ins the plugin relies on
3
+ // need >=22.5.0, read from this package's own engines.node when reachable, else a
4
+ // hard-coded fallback) and that every gate script the registry declares actually exists
5
+ // on disk (a plugin installed half — e.g. copied without a file, or a stale global
6
+ // install) fails silently otherwise: hooks.json references a script that is not there,
7
+ // and Claude Code just skips it without telling anyone).
8
+ //
9
+ // justification: no existing tool covers this. wiring-check.mjs (same family) checks
10
+ // registry vs hooks.json vs disk cross-consistency; doctor.mjs checks the runtime
11
+ // environment itself (node version) plus the same "script exists" fact from the angle
12
+ // of "can this plugin even run", so a broken environment is caught before any gate is
13
+ // invoked and blocks something for the wrong reason.
14
+ //
15
+ // Fail-safe: every check is wrapped so a doctor bug never blocks a session from
16
+ // starting — worst case it silently skips a check rather than throwing.
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const STDIN_FILE_DESCRIPTOR = 0;
24
+ const SESSION_START_EVENT = 'SessionStart';
25
+ const CONFIG_KEY = 'validateEnvironmentOnStart';
26
+ const DEFAULT_MIN_NODE_VERSION = '22.5.0';
27
+
28
+ const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
+ const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
30
+ const PACKAGE_JSON_PATH = join(REPOSITORY_ROOT, 'package.json');
31
+ const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
32
+
33
+ // Same config lookup as the other session hooks (see session-tasks.mjs / register-requests.mjs):
34
+ // kept local and dependency-free so this plugin works standalone.
35
+ const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
36
+ const PROJECT_CONFIG = join('.ai', 'config.json');
37
+ const GLOBAL_CONFIG = join('.claude', 'claude-gates', 'config.json');
38
+
39
+ const BOM_CHAR_CODE = 0xfeff;
40
+
41
+ /** Strips a UTF-8 BOM a Windows editor/tool may have written before the JSON. */
42
+ function stripBom(text) {
43
+ return text.charCodeAt(0) === BOM_CHAR_CODE ? text.slice(1) : text;
44
+ }
45
+
46
+ function readJson(path) {
47
+ try {
48
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function projectRootOf(startDirectory) {
55
+ let current = startDirectory;
56
+ while (true) {
57
+ if (
58
+ PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
59
+ ) {
60
+ return current;
61
+ }
62
+ const parent = dirname(current);
63
+ if (parent === current) return null;
64
+ current = parent;
65
+ }
66
+ }
67
+
68
+ /** The doctor gate config entry (project overrides global), or null. */
69
+ function doctorConfig(startDirectory) {
70
+ const root = projectRootOf(startDirectory);
71
+ const projectData = root ? readJson(join(root, PROJECT_CONFIG)) : null;
72
+ const globalData = readJson(join(homedir(), GLOBAL_CONFIG));
73
+ const layer = projectData?.gates ?? globalData?.gates ?? {};
74
+ const entry = layer[CONFIG_KEY];
75
+ if (typeof entry === 'boolean') return { enabled: entry };
76
+ if (entry && typeof entry === 'object') return entry;
77
+ return null;
78
+ }
79
+
80
+ function readPayload() {
81
+ try {
82
+ return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ const VERSION_SEGMENT_COUNT = 3; // major, minor, patch
89
+
90
+ /** Parses "22.5.0" (or "v22.5.0") into [22, 5, 0]; null when unparseable. */
91
+ function parseVersion(raw) {
92
+ const withoutPrefix = String(raw ?? '').replace(/^v/, '');
93
+ const segments = withoutPrefix.split('.').slice(0, VERSION_SEGMENT_COUNT);
94
+ if (segments.length < VERSION_SEGMENT_COUNT) return null;
95
+ const numbers = segments.map((segment) => Number(/^\d+/.exec(segment)?.[0]));
96
+ return numbers.some((n) => Number.isNaN(n)) ? null : numbers;
97
+ }
98
+
99
+ /** True when `actual` >= `required`, comparing major.minor.patch lexicographically. */
100
+ function versionAtLeast(actual, required) {
101
+ const a = parseVersion(actual);
102
+ const r = parseVersion(required);
103
+ if (!a || !r) return true; // can't parse: don't fail the session over a doctor bug
104
+ for (let index = 0; index < VERSION_SEGMENT_COUNT; index += 1) {
105
+ if (a[index] > r[index]) return true;
106
+ if (a[index] < r[index]) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /** The first "x.y.z"-shaped token inside an engines.node range string (">=22.5.0" -> "22.5.0"). */
112
+ function firstVersionToken(engineRange) {
113
+ for (const token of engineRange.split(/\s+/)) {
114
+ const digits = token.replace(/[^\d.]/g, '');
115
+ if (parseVersion(digits)) return digits;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** The minimum node version this install requires: package.json's engines.node, else the fallback. */
121
+ function minNodeVersionRequired(configuredMin) {
122
+ if (configuredMin) return configuredMin;
123
+ const packageManifest = readJson(PACKAGE_JSON_PATH);
124
+ const engineRange = packageManifest?.engines?.node;
125
+ const token = engineRange ? firstVersionToken(engineRange) : null;
126
+ return token ?? DEFAULT_MIN_NODE_VERSION;
127
+ }
128
+
129
+ function checkNodeVersion(minVersion) {
130
+ if (versionAtLeast(process.version, minVersion)) return null;
131
+ return (
132
+ `node ${process.version} is below the required >=${minVersion}. ` +
133
+ 'Some gates (e.g. node:sqlite-backed task tracking) will not work until node is upgraded.'
134
+ );
135
+ }
136
+
137
+ /** Every script the registry's PreToolUse/Stop gates (folder-style: gates/<id>/index.mjs) declare. */
138
+ function missingFolderGateScripts() {
139
+ const registry = readJson(REGISTRY_PATH);
140
+ if (!registry?.families) return []; // can't read the registry: nothing to report
141
+
142
+ const problems = [];
143
+ for (const family of registry.families) {
144
+ const pluginName = family.plugin ?? 'gates';
145
+ const pluginHooksDirectory = join(
146
+ REPOSITORY_ROOT,
147
+ 'plugins',
148
+ pluginName,
149
+ 'hooks',
150
+ );
151
+ for (const gate of family.gates ?? []) {
152
+ const scriptPath = join(pluginHooksDirectory, gate.script);
153
+ if (!existsSync(scriptPath)) {
154
+ problems.push(
155
+ `${gate.id} (expected ${join('plugins', pluginName, 'hooks', gate.script)})`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+ return problems;
161
+ }
162
+
163
+ function speak(context) {
164
+ process.stdout.write(
165
+ JSON.stringify({
166
+ hookSpecificOutput: {
167
+ hookEventName: SESSION_START_EVENT,
168
+ additionalContext: context,
169
+ },
170
+ }),
171
+ );
172
+ }
173
+
174
+ function main() {
175
+ const payload = readPayload();
176
+ const cwd = payload.cwd || process.cwd();
177
+
178
+ const config = doctorConfig(cwd);
179
+ if (config && config.enabled === false) return; // gate turned off for this project
180
+
181
+ const problems = [];
182
+
183
+ const minVersion = minNodeVersionRequired(config?.minNodeVersion);
184
+ const versionProblem = checkNodeVersion(minVersion);
185
+ if (versionProblem) problems.push(versionProblem);
186
+
187
+ const missingScripts = missingFolderGateScripts();
188
+ if (missingScripts.length > 0) {
189
+ problems.push(
190
+ `${missingScripts.length} gate script(s) declared in registry.json are missing on disk: ${missingScripts.join(
191
+ ', ',
192
+ )}`,
193
+ );
194
+ }
195
+
196
+ if (problems.length === 0) return; // environment is healthy: stay silent
197
+
198
+ speak(
199
+ `[doctor] claude-gates environment check failed:\n- ${problems.join('\n- ')}`,
200
+ );
201
+ }
202
+
203
+ try {
204
+ main();
205
+ } catch {
206
+ // A doctor bug must never block session start.
207
+ }
@@ -0,0 +1,229 @@
1
+ // atomic-commit — denies a `git commit` that is not atomic: one that mixes too many distinct
2
+ // NATURES of change (code + tests + deps + config in one shot) or stages more reviewable files
3
+ // than a commit should carry. It never commits or groups for you (that is a tool, not a gate,
4
+ // and the user already has guard-autocommit for it); it only OBJECTS when the staged set is not
5
+ // a cohesive, reviewable unit, so the split happens before the commit lands.
6
+ //
7
+ // justification: no existing gate covers this. lint-commit/staged-lint run the linter on a
8
+ // commit; no-coauthor reads the message; none look at the SHAPE of the staged set. This is the
9
+ // first gate that judges whether a commit is atomic.
10
+ //
11
+ // ── How it decides (deterministic) ───────────────────────────────────────────────────
12
+ // On a real `git commit`, it lists the staged files and classifies each by nature (deps,
13
+ // generated, assets, docs, config, types, tests, tooling, or code). Docs/assets/generated do
14
+ // NOT count toward the size or the mix — they are legitimately committed alongside anything and
15
+ // are not reviewed line by line (same rule guard-autocommit uses). Among the files that DO
16
+ // count, it blocks when either:
17
+ // · the number of distinct counted natures exceeds `maxNatures` (default 2 — e.g. code+tests
18
+ // is fine, but code+tests+deps+config in one commit is not atomic), or
19
+ // · the number of counted files exceeds `maxFiles` (default 12) — too large to review.
20
+ // A commit that is already scoped (`--amend`, a merge, or the escape hatch) is left alone.
21
+ //
22
+ // ── What a project can configure (params) ───────────────────────────────────────────
23
+ // maxFiles max counted files in one commit. Default 12.
24
+ // maxNatures max distinct counted natures in one commit. Default 2.
25
+ // escapeHatch substring in the command that allows one deliberately broad commit.
26
+ // Default '[wip]'.
27
+ // natures the classification table (name + regex source + counts flag). Replaces the
28
+ // built-in list wholesale.
29
+ //
30
+ // ── Fail-safe shape ──────────────────────────────────────────────────────────────────
31
+ // Not a commit, an --amend, or nothing staged: allow (silent). git not queryable: allow (the
32
+ // gate cannot judge and must not block a legitimate commit on a git error). Otherwise: deny
33
+ // with the offending mix/size and how to split.
34
+
35
+ import { spawnSync } from 'node:child_process';
36
+ import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
37
+
38
+ const GATE_ID = 'atomic-commit';
39
+ const CONFIG_KEY = 'blockNonAtomicCommits';
40
+
41
+ const SHELL_GROUPS = ['shell'];
42
+ const DEFAULT_MAX_FILES = 12;
43
+ const DEFAULT_MAX_NATURES = 2;
44
+ const DEFAULT_ESCAPE_HATCH = '[wip]';
45
+
46
+ // Nature table, evaluated in order (first match wins), mirroring guard-autocommit's catalog so
47
+ // a project that uses both sees the same grouping. `counts:false` = committed alongside anything
48
+ // and not counted toward size/mix (docs, assets, generated artifacts).
49
+ const DEFAULT_NATURES = [
50
+ {
51
+ name: 'deps',
52
+ source: String.raw`(^|/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|requirements\.txt|Cargo\.lock|go\.sum)$`,
53
+ counts: true,
54
+ },
55
+ {
56
+ name: 'generated',
57
+ source: String.raw`(^|/)(dist|build|coverage|__snapshots__)/|\.snap$|baseline.*\.json$`,
58
+ counts: false,
59
+ },
60
+ {
61
+ name: 'assets',
62
+ source: String.raw`\.(png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|eot|mp[34]|wav|webm|pdf)$`,
63
+ counts: false,
64
+ },
65
+ {
66
+ name: 'docs',
67
+ source: String.raw`\.(md|mdx|txt|adoc|rst|org|log|csv|tsv)$`,
68
+ counts: false,
69
+ },
70
+ {
71
+ name: 'config',
72
+ source: String.raw`(^|/)(tsconfig.*\.json|.*\.config\.[cm]?[jt]s|\.eslintrc.*|\.prettierrc.*|\.editorconfig|Dockerfile|docker-compose\.ya?ml|\.gitattributes|\.gitignore)$`,
73
+ counts: true,
74
+ },
75
+ {
76
+ name: 'types',
77
+ source: String.raw`(^|/)(types?|interfaces?|models?|schemas?)/|\.d\.ts$`,
78
+ counts: true,
79
+ },
80
+ {
81
+ name: 'tests',
82
+ source: String.raw`(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[cm]?[jt]sx?$`,
83
+ counts: true,
84
+ },
85
+ {
86
+ name: 'tooling',
87
+ source: String.raw`(^|/)(scripts|hooks|\.ai|\.claude|\.github)/`,
88
+ counts: true,
89
+ },
90
+ ];
91
+ const DEFAULT_NATURE = { name: 'code', counts: true };
92
+
93
+ // git-global-option normalization, shared with the other commit gates.
94
+ const GIT_OPTION_WITH_VALUE = String.raw`(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:\s+|=)\S+`;
95
+ const GIT_FLAG_OPTION = String.raw`--(?:paginate|no-pager|bare|no-optional-locks)|-p`;
96
+ const GIT_GLOBAL_OPTION_PATTERN = new RegExp(
97
+ String.raw`\bgit\s+(?:${GIT_OPTION_WITH_VALUE}|${GIT_FLAG_OPTION})\s+`,
98
+ 'i',
99
+ );
100
+ const GIT_COMMIT_PATTERN = /\bgit\s+commit\b/i;
101
+ // An --amend or a merge commit is a deliberate, already-scoped operation — not this gate's.
102
+ const EXEMPT_COMMIT_PATTERN = /--amend\b|\bgit\s+merge\b/i;
103
+
104
+ function normalizeGitOptions(command) {
105
+ let previous;
106
+ let normalized = command;
107
+ do {
108
+ previous = normalized;
109
+ normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
110
+ } while (normalized !== previous);
111
+ return normalized;
112
+ }
113
+
114
+ function isPlainCommit(command) {
115
+ const normalized = normalizeGitOptions(command);
116
+ return (
117
+ GIT_COMMIT_PATTERN.test(normalized) &&
118
+ !EXEMPT_COMMIT_PATTERN.test(normalized)
119
+ );
120
+ }
121
+
122
+ function commandTextFrom(toolInput) {
123
+ return String(toolInput.CommandLine ?? toolInput.command ?? '');
124
+ }
125
+
126
+ /** The staged files (added/copied/modified/renamed), or [] when git cannot be queried. */
127
+ function stagedFiles(cwd) {
128
+ const result = spawnSync(
129
+ 'git',
130
+ ['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
131
+ { cwd, encoding: 'utf8' },
132
+ );
133
+ if (result.status !== 0 || !result.stdout) return [];
134
+ return result.stdout
135
+ .split(/\r?\n/)
136
+ .map((line) => line.trim())
137
+ .filter((line) => line.length > 0)
138
+ .map((line) => line.replace(/\\/g, '/'));
139
+ }
140
+
141
+ function compileNatures(natures) {
142
+ return natures.map((nature) => ({
143
+ name: nature.name,
144
+ counts: nature.counts !== false,
145
+ pattern: (() => {
146
+ try {
147
+ return new RegExp(nature.source, 'i');
148
+ } catch {
149
+ return null;
150
+ }
151
+ })(),
152
+ }));
153
+ }
154
+
155
+ function natureOf(filePath, compiledNatures) {
156
+ for (const nature of compiledNatures) {
157
+ if (nature.pattern && nature.pattern.test(filePath)) return nature;
158
+ }
159
+ return DEFAULT_NATURE;
160
+ }
161
+
162
+ /** Classifies the staged files: the counted (reviewable) ones and the distinct natures among
163
+ * them. Docs/assets/generated (counts:false) are excluded from both. */
164
+ function classifyStaged(files, compiledNatures) {
165
+ const counted = [];
166
+ const countedNatures = new Set();
167
+ for (const file of files) {
168
+ const nature = natureOf(file, compiledNatures);
169
+ if (!nature.counts) continue;
170
+ counted.push(file);
171
+ countedNatures.add(nature.name);
172
+ }
173
+ return { counted, countedNatures };
174
+ }
175
+
176
+ runGate(
177
+ {
178
+ id: GATE_ID,
179
+ configKey: CONFIG_KEY,
180
+ enabledByDefault: false,
181
+ defaultParams: {
182
+ maxFiles: DEFAULT_MAX_FILES,
183
+ maxNatures: DEFAULT_MAX_NATURES,
184
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
185
+ natures: DEFAULT_NATURES,
186
+ },
187
+ },
188
+ ({ toolName, toolInput, parameters }) => {
189
+ if (!toolInGroups(toolName, SHELL_GROUPS)) return;
190
+
191
+ const command = commandTextFrom(toolInput);
192
+ if (!isPlainCommit(command)) return;
193
+
194
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
195
+ if (escapeHatch && command.includes(escapeHatch)) return;
196
+
197
+ const cwd = process.cwd();
198
+ const files = stagedFiles(cwd);
199
+ if (files.length === 0) return; // nothing staged (or git unqueryable): nothing to judge
200
+
201
+ const compiledNatures = compileNatures(
202
+ parameters.natures ?? DEFAULT_NATURES,
203
+ );
204
+ const { counted, countedNatures } = classifyStaged(files, compiledNatures);
205
+
206
+ const maxFiles = parameters.maxFiles ?? DEFAULT_MAX_FILES;
207
+ const maxNatures = parameters.maxNatures ?? DEFAULT_MAX_NATURES;
208
+
209
+ if (countedNatures.size > maxNatures) {
210
+ deny(
211
+ GATE_ID,
212
+ `This commit mixes ${countedNatures.size} kinds of change ` +
213
+ `(${[...countedNatures].join(', ')}) — a commit should be one cohesive change ` +
214
+ `(max ${maxNatures}). Split it: stage and commit one nature at a time (e.g. the ` +
215
+ `code, then its tests, then deps). Docs/assets/generated files do not count. ` +
216
+ `Add "${escapeHatch}" to the command for one deliberately broad commit.`,
217
+ );
218
+ }
219
+
220
+ if (counted.length > maxFiles) {
221
+ deny(
222
+ GATE_ID,
223
+ `This commit stages ${counted.length} reviewable files (max ${maxFiles}) — too large ` +
224
+ `to review as one unit. Split it into smaller, cohesive commits (docs/assets/` +
225
+ `generated files are not counted). Add "${escapeHatch}" for one deliberately broad commit.`,
226
+ );
227
+ }
228
+ },
229
+ );