@devrik-tools/claude-gates 0.7.2 → 0.9.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 +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -1,33 +1,23 @@
|
|
|
1
|
-
// sdd-specs — denies implementation (a write
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Migrated from ~/.claude/hooks/guard-sdd-specs.mjs.
|
|
1
|
+
// sdd-specs — denies implementation (a catalog write moving a feature past `spec_ready`,
|
|
2
|
+
// or a STANDARD/HIGH-RISK implementation delegation citing a feature) when the feature has
|
|
3
|
+
// no non-empty contract on disk (requirements/design/tasks or brief/asserts).
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// stages of the pipeline that cannot require a contract of
|
|
11
|
-
// themselves). Replaces the built-in list wholesale.
|
|
12
|
-
// The defaults live here, in the source, so a project reads them and knows exactly what
|
|
13
|
-
// its override replaces.
|
|
14
|
-
//
|
|
15
|
-
// ── Auto-off when there is no SDD harness ───────────────────────────────────────────
|
|
16
|
-
// This gate only acts once a catalog is found at one of `catalogLocations` (or the
|
|
17
|
-
// project's own `.ai/config.json` declares adoption). A project that never adopted the
|
|
18
|
-
// spec-driven harness has no catalog and no declared adoption, so every check below is
|
|
19
|
-
// skipped and the write/delegation is allowed — imposing phases on a project that never
|
|
20
|
-
// asked for them is the false positive that gets a gate disabled.
|
|
21
|
-
//
|
|
22
|
-
// ── Two surfaces inspected ───────────────────────────────────────────────────────────
|
|
23
|
-
// A write to the catalog is checked against the catalog's own declared statuses: a
|
|
24
|
-
// feature marked `spec_ready`/`in_progress`/`done` needs its contract tree in place. A
|
|
25
|
-
// delegation prompt is checked when it declares a STANDARD/HIGH-RISK level and an
|
|
26
|
-
// implementation verb: the contract tree must have non-empty requirements/design/tasks
|
|
27
|
-
// (or brief/asserts) for the feature it targets.
|
|
5
|
+
// Decisions: the gate is inert until a catalog exists at one of `catalogLocations` — a
|
|
6
|
+
// project that never adopted the spec-driven harness must not be held to it. A delegation
|
|
7
|
+
// citing several features is denied when ANY of them lacks a contract. The LAST declared
|
|
8
|
+
// LEVEL governs, so a decoy MICRO before HIGH-RISK exempts nothing.
|
|
28
9
|
|
|
29
10
|
import { existsSync, statSync } from 'node:fs';
|
|
30
|
-
import { dirname, join } from 'node:path';
|
|
11
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
12
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EXEMPT_SUBAGENTS,
|
|
15
|
+
DEMANDING_LEVELS,
|
|
16
|
+
featureNamesCitedIn,
|
|
17
|
+
isImplementationRequest,
|
|
18
|
+
isSubagentNamedIn,
|
|
19
|
+
operativeLevelOf,
|
|
20
|
+
} from '../../lib/delegation.mjs';
|
|
31
21
|
import {
|
|
32
22
|
runGate,
|
|
33
23
|
deny,
|
|
@@ -41,27 +31,15 @@ const GATE_ID = 'sdd-specs';
|
|
|
41
31
|
const CONFIG_KEY = 'requireSpecBeforeImplementing';
|
|
42
32
|
|
|
43
33
|
const CATALOG_FILE_NAME = 'feature_list.json';
|
|
34
|
+
const FEATURES_DIRECTORY = 'features';
|
|
44
35
|
|
|
45
36
|
const DEFAULT_CATALOG_LOCATIONS = [
|
|
46
37
|
join('.ai', CATALOG_FILE_NAME),
|
|
47
38
|
CATALOG_FILE_NAME,
|
|
48
39
|
];
|
|
49
40
|
|
|
50
|
-
const DEFAULT_EXEMPT_SUBAGENTS = [
|
|
51
|
-
'explore',
|
|
52
|
-
'plan',
|
|
53
|
-
'scout',
|
|
54
|
-
'revision',
|
|
55
|
-
'contraste',
|
|
56
|
-
'test-planner',
|
|
57
|
-
'qa',
|
|
58
|
-
'ui',
|
|
59
|
-
'ux',
|
|
60
|
-
];
|
|
61
|
-
|
|
62
41
|
const ADVANCED_STATUSES = new Set(['spec_ready', 'in_progress', 'done']);
|
|
63
42
|
|
|
64
|
-
/** Files that count as a feature's contract; at least one must be non-empty. */
|
|
65
43
|
const CONTRACT_FILES = [
|
|
66
44
|
'requirements.md',
|
|
67
45
|
'design.md',
|
|
@@ -70,29 +48,6 @@ const CONTRACT_FILES = [
|
|
|
70
48
|
'asserts.md',
|
|
71
49
|
];
|
|
72
50
|
|
|
73
|
-
function withWordBoundary(alternation) {
|
|
74
|
-
return new RegExp(
|
|
75
|
-
`(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
|
|
76
|
-
'iu',
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const IMPLEMENTATION_VERBS = withWordBoundary(
|
|
81
|
-
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
82
|
-
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
|
|
83
|
-
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
84
|
-
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
85
|
-
'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
|
|
86
|
-
'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
/** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
|
|
90
|
-
* the plugin-wide convention (see risk-level.mjs). */
|
|
91
|
-
const DEMANDING_LEVEL_PATTERN =
|
|
92
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
|
|
93
|
-
const EXEMPT_LEVEL_PATTERN =
|
|
94
|
-
/(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
|
|
95
|
-
|
|
96
51
|
function fileExistsNonEmpty(path) {
|
|
97
52
|
try {
|
|
98
53
|
return existsSync(path) && statSync(path).size > 0;
|
|
@@ -101,32 +56,30 @@ function fileExistsNonEmpty(path) {
|
|
|
101
56
|
}
|
|
102
57
|
}
|
|
103
58
|
|
|
104
|
-
function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
59
|
+
function resolveFrom(root, path) {
|
|
60
|
+
return isAbsolute(path) ? path : join(root, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function findCatalog(root, catalogLocations, writtenPath) {
|
|
64
|
+
const roots = [root];
|
|
65
|
+
// A monorepo subpackage keeps its own catalog next to the file being written.
|
|
66
|
+
if (writtenPath) roots.push(dirname(resolveFrom(root, writtenPath)));
|
|
67
|
+
for (const base of roots) {
|
|
111
68
|
for (const relative of catalogLocations) {
|
|
112
|
-
const path =
|
|
69
|
+
const path = resolveFrom(base, relative);
|
|
113
70
|
if (existsSync(path)) return path;
|
|
114
71
|
}
|
|
115
72
|
}
|
|
116
73
|
return null;
|
|
117
74
|
}
|
|
118
75
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
candidates.
|
|
126
|
-
for (const candidate of candidates) {
|
|
127
|
-
if (existsSync(candidate)) return candidate;
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
76
|
+
function contractTreeRootFor(root, catalogPath) {
|
|
77
|
+
const candidates = [
|
|
78
|
+
join(dirname(catalogPath), FEATURES_DIRECTORY),
|
|
79
|
+
join(root, '.ai', FEATURES_DIRECTORY),
|
|
80
|
+
join(root, FEATURES_DIRECTORY),
|
|
81
|
+
];
|
|
82
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
130
83
|
}
|
|
131
84
|
|
|
132
85
|
function contractExistsFor(treeRoot, featureName) {
|
|
@@ -146,11 +99,7 @@ function parseJsonOrNull(text) {
|
|
|
146
99
|
}
|
|
147
100
|
}
|
|
148
101
|
|
|
149
|
-
|
|
150
|
-
* no contract on disk, or does nothing when every advanced feature has one. A feature
|
|
151
|
-
* object missing its `name` field is denied too (not silently skipped) — it is malformed
|
|
152
|
-
* catalog data, not a feature this gate has decided has no obligations. */
|
|
153
|
-
function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
|
|
102
|
+
function denyIfAdvancedFeatureLacksContract(features, treeRoot, fallbackRoot) {
|
|
154
103
|
for (const feature of features) {
|
|
155
104
|
if (!ADVANCED_STATUSES.has(feature?.status)) continue;
|
|
156
105
|
if (!feature?.name) {
|
|
@@ -160,13 +109,9 @@ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
|
|
|
160
109
|
`${JSON.stringify(feature)}. Every feature needs a 'name' so its contract tree ` +
|
|
161
110
|
'(.ai/features/<name>/) can be located — add it before writing this status.',
|
|
162
111
|
);
|
|
163
|
-
continue;
|
|
164
112
|
}
|
|
165
113
|
if (contractExistsFor(treeRoot, feature.name)) continue;
|
|
166
|
-
const featureDirectory = join(
|
|
167
|
-
treeRoot ?? join(process.cwd(), '.ai', 'features'),
|
|
168
|
-
feature.name,
|
|
169
|
-
);
|
|
114
|
+
const featureDirectory = join(treeRoot ?? fallbackRoot, feature.name);
|
|
170
115
|
deny(
|
|
171
116
|
CONFIG_KEY,
|
|
172
117
|
`Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
|
|
@@ -176,70 +121,64 @@ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
|
|
|
176
121
|
}
|
|
177
122
|
}
|
|
178
123
|
|
|
179
|
-
function
|
|
124
|
+
function targetsCatalog(target, catalogLocations) {
|
|
125
|
+
const name = basename(target);
|
|
126
|
+
return catalogLocations.some((location) => basename(location) === name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkCatalogWrite(
|
|
130
|
+
toolInput,
|
|
131
|
+
catalogLocations,
|
|
132
|
+
treeRoot,
|
|
133
|
+
fallbackRoot,
|
|
134
|
+
) {
|
|
180
135
|
const target = writtenPathOf(toolInput);
|
|
181
|
-
if (!
|
|
136
|
+
if (!target || !targetsCatalog(target, catalogLocations)) return;
|
|
182
137
|
|
|
183
|
-
const
|
|
184
|
-
const parsed = parseJsonOrNull(rawContent);
|
|
138
|
+
const parsed = parseJsonOrNull(writtenContentOf(toolInput));
|
|
185
139
|
if (parsed === null) {
|
|
186
|
-
// Fail
|
|
187
|
-
// still targets the catalog is suspicious on its own -- a spec_ready/done transition
|
|
188
|
-
// hidden behind a malformed payload must not be silently allowed through.
|
|
140
|
+
// Fail closed: a status transition hidden behind malformed JSON must not slip through.
|
|
189
141
|
deny(
|
|
190
142
|
CONFIG_KEY,
|
|
191
|
-
`The write to ${
|
|
143
|
+
`The write to ${basename(target)} does not parse as JSON (JSON.parse threw). This ` +
|
|
192
144
|
'is a syntax problem in the content being written, not a missing-field one — check ' +
|
|
193
145
|
'for a trailing comma, unquoted key, or unclosed bracket in what you are about to ' +
|
|
194
146
|
'write. No filesystem exploration needed; the payload itself is the thing to fix.',
|
|
195
147
|
);
|
|
196
148
|
}
|
|
197
149
|
const features = Array.isArray(parsed?.features) ? parsed.features : [];
|
|
198
|
-
denyIfAdvancedFeatureLacksContract(features, treeRoot);
|
|
150
|
+
denyIfAdvancedFeatureLacksContract(features, treeRoot, fallbackRoot);
|
|
199
151
|
}
|
|
200
152
|
|
|
201
153
|
function isExemptDelegation(toolInput, prompt, exemptSubagents) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (exemptSubagents.includes(subagentType)) return true;
|
|
206
|
-
if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
|
|
207
|
-
if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
|
|
208
|
-
if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
|
|
209
|
-
return false;
|
|
154
|
+
if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
|
|
155
|
+
if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
|
|
156
|
+
return !isImplementationRequest(prompt);
|
|
210
157
|
}
|
|
211
158
|
|
|
212
|
-
function
|
|
213
|
-
const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
|
|
214
|
-
const names = [];
|
|
215
|
-
let match;
|
|
216
|
-
while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
|
|
217
|
-
return names;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function checkDelegation(toolInput, treeRoot, exemptSubagents) {
|
|
159
|
+
function checkDelegation(toolInput, treeRoot, fallbackRoot, exemptSubagents) {
|
|
221
160
|
const prompt = delegationPromptOf(toolInput);
|
|
222
161
|
if (!prompt.trim()) return;
|
|
223
162
|
if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
|
|
224
163
|
|
|
225
164
|
const citedFeatures = featureNamesCitedIn(prompt);
|
|
226
|
-
if (citedFeatures.length === 0) return;
|
|
165
|
+
if (citedFeatures.length === 0) return;
|
|
227
166
|
|
|
228
167
|
const missing = citedFeatures.filter(
|
|
229
168
|
(feature) => !contractExistsFor(treeRoot, feature),
|
|
230
169
|
);
|
|
231
|
-
if (missing.length ===
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
170
|
+
if (missing.length === 0) return;
|
|
171
|
+
|
|
172
|
+
const root = treeRoot ?? fallbackRoot;
|
|
173
|
+
const fileList = missing
|
|
174
|
+
.map((feature) => `${feature} -> ${join(root, feature)}/`)
|
|
175
|
+
.join('\n ');
|
|
176
|
+
deny(
|
|
177
|
+
CONFIG_KEY,
|
|
178
|
+
`This implementation delegation cites feature(s) with no contract on disk:\n ${fileList}\n` +
|
|
179
|
+
`Write ONE of these files in each directory above (any one is enough): ` +
|
|
180
|
+
`${CONTRACT_FILES.join(', ')}. Then relaunch.`,
|
|
181
|
+
);
|
|
243
182
|
}
|
|
244
183
|
|
|
245
184
|
runGate(
|
|
@@ -252,26 +191,34 @@ runGate(
|
|
|
252
191
|
exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
|
|
253
192
|
},
|
|
254
193
|
},
|
|
255
|
-
({ toolName, toolInput, parameters }) => {
|
|
194
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
256
195
|
const isWrite = toolInGroups(toolName, ['write']);
|
|
257
196
|
const isDelegation = toolInGroups(toolName, ['delegation']);
|
|
258
197
|
if (!isWrite && !isDelegation) return;
|
|
259
198
|
|
|
199
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
260
200
|
const catalogLocations =
|
|
261
|
-
parameters.catalogLocations
|
|
201
|
+
parameters.catalogLocations.length > 0
|
|
202
|
+
? parameters.catalogLocations
|
|
203
|
+
: DEFAULT_CATALOG_LOCATIONS;
|
|
262
204
|
const catalogPath = findCatalog(
|
|
205
|
+
root,
|
|
263
206
|
catalogLocations,
|
|
264
207
|
isWrite ? writtenPathOf(toolInput) : null,
|
|
265
208
|
);
|
|
266
|
-
if (!catalogPath) return;
|
|
267
|
-
|
|
268
|
-
const treeRoot = contractTreeRootFor(catalogPath);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
checkDelegation(
|
|
275
|
-
|
|
209
|
+
if (!catalogPath) return;
|
|
210
|
+
|
|
211
|
+
const treeRoot = contractTreeRootFor(root, catalogPath);
|
|
212
|
+
const fallbackRoot = join(root, '.ai', FEATURES_DIRECTORY);
|
|
213
|
+
|
|
214
|
+
if (isWrite)
|
|
215
|
+
checkCatalogWrite(toolInput, catalogLocations, treeRoot, fallbackRoot);
|
|
216
|
+
if (isDelegation)
|
|
217
|
+
checkDelegation(
|
|
218
|
+
toolInput,
|
|
219
|
+
treeRoot,
|
|
220
|
+
fallbackRoot,
|
|
221
|
+
parameters.exemptSubagents,
|
|
222
|
+
);
|
|
276
223
|
},
|
|
277
224
|
);
|
|
@@ -1,51 +1,53 @@
|
|
|
1
|
-
// staged-lint — denies a `git commit` when the files
|
|
2
|
-
// whole repo (
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the WHOLE project and blocks if anything fails — too broad: a repo with pre-existing debt can
|
|
9
|
-
// never commit. This gate scopes the check to the staged set, so it enforces "your change is
|
|
10
|
-
// clean" without demanding "the whole repo is clean".
|
|
11
|
-
//
|
|
12
|
-
// ── How it works ─────────────────────────────────────────────────────────────────────
|
|
13
|
-
// On a real `git commit`, it asks git for the staged files (`git diff --cached --name-only
|
|
14
|
-
// --diff-filter=ACM`), keeps the ones with a lintable extension, and runs the project's lint
|
|
15
|
-
// command over exactly those paths. Non-zero exit → deny with the lint output. No staged
|
|
16
|
-
// lintable file, or no lint command declared → allow (nothing to enforce, never invented).
|
|
17
|
-
//
|
|
18
|
-
// ── What a project can configure (params) ───────────────────────────────────────────
|
|
19
|
-
// lintCommand the command to run; the staged paths are appended as arguments. Default
|
|
20
|
-
// null → autodetect eslint (`npx eslint`) when the project has an eslint
|
|
21
|
-
// config, else allow (never invent a linter).
|
|
22
|
-
// lintExtensions which staged file extensions are linted. Default js/mjs/cjs/ts/tsx/jsx.
|
|
23
|
-
// lintTimeoutMs how long the lint run may take before it counts as a failure.
|
|
24
|
-
// escapeHatch substring in the command that skips the check for one commit. Default
|
|
25
|
-
// '[skip-lint]'.
|
|
26
|
-
//
|
|
27
|
-
// ── Fail-safe shape ──────────────────────────────────────────────────────────────────
|
|
28
|
-
// Not a commit: allow (silent). No staged lintable files, or no linter: allow. The lint run
|
|
29
|
-
// cannot spawn: deny (a commit gate that cannot evaluate must not silently permit). Lint exits
|
|
30
|
-
// non-zero: deny with the tail of its output. Lint exits zero: allow.
|
|
1
|
+
// staged-lint — denies a `git commit` when the files THIS commit carries fail lint. It never
|
|
2
|
+
// lints the whole repo (pre-existing debt in untouched files must not block you); it lints
|
|
3
|
+
// what the commit will contain: the staged set plus whatever a `git add …` earlier in the
|
|
4
|
+
// same command line or a `-a` flag stages before the commit runs. Only a command segment that
|
|
5
|
+
// RUNS git commit counts; a `--dry-run` writes nothing. No lintable file, or no linter
|
|
6
|
+
// declared or detected: allow — this gate never invents a linter. A lint run that cannot
|
|
7
|
+
// spawn is denied: a commit gate must not silently permit.
|
|
31
8
|
|
|
32
9
|
import { spawnSync } from 'node:child_process';
|
|
33
10
|
import { existsSync } from 'node:fs';
|
|
34
11
|
import { extname, join } from 'node:path';
|
|
35
|
-
import {
|
|
12
|
+
import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
|
|
13
|
+
import {
|
|
14
|
+
effectiveCommitFiles,
|
|
15
|
+
isDryRunCommit,
|
|
16
|
+
isGitCommit,
|
|
17
|
+
normalizeGitCommand,
|
|
18
|
+
} from '../../lib/git.mjs';
|
|
19
|
+
import {
|
|
20
|
+
deny,
|
|
21
|
+
runGate,
|
|
22
|
+
shellCommandOf,
|
|
23
|
+
toolInGroups,
|
|
24
|
+
} from '../../lib/hook-io.mjs';
|
|
36
25
|
|
|
37
26
|
const GATE_ID = 'staged-lint';
|
|
38
27
|
const CONFIG_KEY = 'blockCommitWithStagedLintErrors';
|
|
39
28
|
|
|
40
|
-
const SHELL_GROUPS = ['shell'];
|
|
41
29
|
const DEFAULT_LINT_TIMEOUT_MS = 60000;
|
|
42
30
|
const OUTPUT_TAIL_LINES = 20;
|
|
43
31
|
const DEFAULT_ESCAPE_HATCH = '[skip-lint]';
|
|
44
|
-
const DEFAULT_LINT_EXTENSIONS = [
|
|
32
|
+
const DEFAULT_LINT_EXTENSIONS = [
|
|
33
|
+
'.js',
|
|
34
|
+
'.mjs',
|
|
35
|
+
'.cjs',
|
|
36
|
+
'.ts',
|
|
37
|
+
'.mts',
|
|
38
|
+
'.cts',
|
|
39
|
+
'.tsx',
|
|
40
|
+
'.jsx',
|
|
41
|
+
'.vue',
|
|
42
|
+
'.svelte',
|
|
43
|
+
];
|
|
45
44
|
const ESLINT_CONFIG_FILES = [
|
|
46
45
|
'eslint.config.mjs',
|
|
47
46
|
'eslint.config.js',
|
|
48
47
|
'eslint.config.cjs',
|
|
48
|
+
'eslint.config.ts',
|
|
49
|
+
'eslint.config.mts',
|
|
50
|
+
'eslint.config.cts',
|
|
49
51
|
'.eslintrc',
|
|
50
52
|
'.eslintrc.js',
|
|
51
53
|
'.eslintrc.cjs',
|
|
@@ -54,74 +56,57 @@ const ESLINT_CONFIG_FILES = [
|
|
|
54
56
|
'.eslintrc.yaml',
|
|
55
57
|
];
|
|
56
58
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
let normalized = command;
|
|
70
|
-
do {
|
|
71
|
-
previous = normalized;
|
|
72
|
-
normalized = normalized.replace(GIT_GLOBAL_OPTION_PATTERN, 'git ');
|
|
73
|
-
} while (normalized !== previous);
|
|
74
|
-
return normalized;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isGitCommit(command) {
|
|
78
|
-
return GIT_COMMIT_PATTERN.test(normalizeGitOptions(command));
|
|
59
|
+
// ── The commit segment ──────────────────────────────────────────────────────────────
|
|
60
|
+
const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
|
|
61
|
+
const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
62
|
+
const GIT_BINARY_TOKEN = /(?:^|[\\/])git(?:\.exe)?$/i;
|
|
63
|
+
const WRAPPER_TOKEN = /^(?:\w+=\S*|command|sudo|env)$/i;
|
|
64
|
+
|
|
65
|
+
function runsGit(segment) {
|
|
66
|
+
const tokens = [];
|
|
67
|
+
for (const match of segment.matchAll(ARGUMENT_PATTERN))
|
|
68
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
69
|
+
while (tokens.length > 0 && WRAPPER_TOKEN.test(tokens[0])) tokens.shift();
|
|
70
|
+
return tokens.length > 0 && GIT_BINARY_TOKEN.test(tokens[0]);
|
|
79
71
|
}
|
|
80
72
|
|
|
81
|
-
function
|
|
82
|
-
return String(
|
|
73
|
+
function runsRealCommit(command) {
|
|
74
|
+
return String(command)
|
|
75
|
+
.split(SEGMENT_SEPARATOR)
|
|
76
|
+
.some((segment) => {
|
|
77
|
+
if (!runsGit(segment)) return false;
|
|
78
|
+
const normalized = normalizeGitCommand(segment);
|
|
79
|
+
return isGitCommit(normalized) && !isDryRunCommit(normalized);
|
|
80
|
+
});
|
|
83
81
|
}
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
function
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
['diff', '--cached', '--name-only', '--diff-filter=ACM'],
|
|
90
|
-
{ cwd, encoding: 'utf8' },
|
|
91
|
-
);
|
|
92
|
-
if (result.status !== 0 || !result.stdout) return [];
|
|
93
|
-
return result.stdout
|
|
94
|
-
.split(/\r?\n/)
|
|
95
|
-
.map((line) => line.trim())
|
|
96
|
-
.filter((line) => line.length > 0)
|
|
97
|
-
.filter((file) => lintExtensions.includes(extname(file).toLowerCase()));
|
|
83
|
+
// ── Lint ────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
function normalizeExtension(extension) {
|
|
85
|
+
const text = String(extension).trim().toLowerCase();
|
|
86
|
+
return text.startsWith('.') ? text : `.${text}`;
|
|
98
87
|
}
|
|
99
88
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
existsSync(join(
|
|
89
|
+
function resolveLintCommand(root, lintCommandOverride) {
|
|
90
|
+
if (typeof lintCommandOverride === 'string' && lintCommandOverride.trim())
|
|
91
|
+
return lintCommandOverride;
|
|
92
|
+
const hasEslintFile = ESLINT_CONFIG_FILES.some((name) =>
|
|
93
|
+
existsSync(join(root, name)),
|
|
105
94
|
);
|
|
106
|
-
|
|
95
|
+
const packageJson = readJsonOrNull(join(root, 'package.json'));
|
|
96
|
+
return hasEslintFile || packageJson?.eslintConfig ? 'npx eslint' : null;
|
|
107
97
|
}
|
|
108
98
|
|
|
109
99
|
function tailLines(text, count) {
|
|
110
|
-
|
|
100
|
+
return String(text)
|
|
111
101
|
.split(/\r?\n/)
|
|
112
|
-
.filter((line) => line.length > 0)
|
|
113
|
-
|
|
102
|
+
.filter((line) => line.length > 0)
|
|
103
|
+
.slice(-count)
|
|
104
|
+
.join('\n');
|
|
114
105
|
}
|
|
115
106
|
|
|
116
|
-
|
|
117
|
-
function lintCommandForFiles(lintCommand, files) {
|
|
107
|
+
function runLintOverFiles(lintCommand, files, cwd, timeoutMs) {
|
|
118
108
|
const quotedPaths = files.map((file) => `"${file}"`).join(' ');
|
|
119
|
-
return `${lintCommand} ${quotedPaths}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Runs the lint command over the staged files; returns the spawnSync result. */
|
|
123
|
-
function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
|
|
124
|
-
return spawnSync(lintCommandForFiles(lintCommand, files), {
|
|
109
|
+
return spawnSync(`${lintCommand} ${quotedPaths}`, {
|
|
125
110
|
cwd,
|
|
126
111
|
shell: true,
|
|
127
112
|
encoding: 'utf8',
|
|
@@ -129,25 +114,23 @@ function runLintOverStaged(lintCommand, files, cwd, timeoutMs) {
|
|
|
129
114
|
});
|
|
130
115
|
}
|
|
131
116
|
|
|
132
|
-
/** Denies when the lint run could not spawn or reported failures; otherwise returns. */
|
|
133
117
|
function denyIfLintFailed(result, lintCommand, escapeHatch) {
|
|
134
118
|
if (result.error) {
|
|
135
119
|
deny(
|
|
136
120
|
CONFIG_KEY,
|
|
137
|
-
`Could not run the lint command ("${lintCommand}") over the
|
|
138
|
-
`${result.error.message}. Fix the lint setup or set lintCommand
|
|
121
|
+
`Could not run the lint command ("${lintCommand}") over the commit's files: ` +
|
|
122
|
+
`${result.error.message}. Fix the lint setup or set lintCommand under ` +
|
|
139
123
|
`${CONFIG_KEY} in .ai/config.json.`,
|
|
140
124
|
);
|
|
141
125
|
}
|
|
142
126
|
if (result.status !== 0) {
|
|
143
127
|
const combinedOutput =
|
|
144
128
|
`${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
145
|
-
const outputTail = tailLines(combinedOutput, OUTPUT_TAIL_LINES);
|
|
146
129
|
deny(
|
|
147
130
|
CONFIG_KEY,
|
|
148
|
-
`The files
|
|
149
|
-
`(the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to
|
|
150
|
-
`commit command for a deliberate exception:\n${
|
|
131
|
+
`The files this commit carries fail lint — commit blocked until your own changes are ` +
|
|
132
|
+
`clean (the rest of the repo is not checked). Fix them, or add "${escapeHatch}" to ` +
|
|
133
|
+
`the commit command for a deliberate exception:\n${tailLines(combinedOutput, OUTPUT_TAIL_LINES)}`,
|
|
151
134
|
);
|
|
152
135
|
}
|
|
153
136
|
}
|
|
@@ -164,24 +147,33 @@ runGate(
|
|
|
164
147
|
escapeHatch: DEFAULT_ESCAPE_HATCH,
|
|
165
148
|
},
|
|
166
149
|
},
|
|
167
|
-
({ toolName, toolInput, parameters }) => {
|
|
168
|
-
if (!toolInGroups(toolName,
|
|
150
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
151
|
+
if (!toolInGroups(toolName, ['shell'])) return;
|
|
169
152
|
|
|
170
|
-
const command =
|
|
171
|
-
if (!
|
|
153
|
+
const command = shellCommandOf(toolInput);
|
|
154
|
+
if (!runsRealCommit(command)) return;
|
|
172
155
|
|
|
173
|
-
const escapeHatch = parameters.escapeHatch ??
|
|
156
|
+
const escapeHatch = String(parameters.escapeHatch ?? '');
|
|
174
157
|
if (escapeHatch && command.includes(escapeHatch)) return;
|
|
175
158
|
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
159
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
160
|
+
const extensions = new Set(
|
|
161
|
+
parameters.lintExtensions.map(normalizeExtension),
|
|
162
|
+
);
|
|
163
|
+
const files = (effectiveCommitFiles(root, command) ?? []).filter((file) =>
|
|
164
|
+
extensions.has(extname(file).toLowerCase()),
|
|
165
|
+
);
|
|
166
|
+
if (files.length === 0) return;
|
|
179
167
|
|
|
180
|
-
const lintCommand = resolveLintCommand(
|
|
181
|
-
if (!lintCommand) return;
|
|
168
|
+
const lintCommand = resolveLintCommand(root, parameters.lintCommand);
|
|
169
|
+
if (!lintCommand) return;
|
|
182
170
|
|
|
183
|
-
const
|
|
184
|
-
|
|
171
|
+
const result = runLintOverFiles(
|
|
172
|
+
lintCommand,
|
|
173
|
+
files,
|
|
174
|
+
root,
|
|
175
|
+
parameters.lintTimeoutMs,
|
|
176
|
+
);
|
|
185
177
|
denyIfLintFailed(result, lintCommand, escapeHatch);
|
|
186
178
|
},
|
|
187
179
|
);
|