@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- 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 +65 -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-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -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 +61 -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 +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
|
+
);
|