@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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// circuit-breaker PostToolUse tracker — records a delegation attempt AFTER it was allowed
|
|
2
|
+
// by all PreToolUse hooks (including this gate's own). A delegation rejected by another
|
|
3
|
+
// gate (intent-flow, brief-before-delegate, etc.) never reaches PostToolUse, so it does
|
|
4
|
+
// not inflate the retry counter. Without this split, the PreToolUse gate counted rejected
|
|
5
|
+
// delegations as retries and tripped the breaker on the corrected second attempt.
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { isGateEnabled } from '../../lib/config.mjs';
|
|
9
|
+
import {
|
|
10
|
+
allow,
|
|
11
|
+
delegationPromptOf,
|
|
12
|
+
readHookPayload,
|
|
13
|
+
sessionIdOf,
|
|
14
|
+
toolInputOf,
|
|
15
|
+
toolNameOf,
|
|
16
|
+
toolInGroups,
|
|
17
|
+
} from '../../lib/hook-io.mjs';
|
|
18
|
+
import {
|
|
19
|
+
readSessionState,
|
|
20
|
+
writeSessionState,
|
|
21
|
+
} from '../../lib/session-state.mjs';
|
|
22
|
+
|
|
23
|
+
const GATE_ID = 'circuit-breaker';
|
|
24
|
+
const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
|
|
25
|
+
|
|
26
|
+
const MAX_ENTRIES_PER_KEY = 12;
|
|
27
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
28
|
+
|
|
29
|
+
const SECTION_HEADING_NAMES = [
|
|
30
|
+
'scope',
|
|
31
|
+
'steps',
|
|
32
|
+
'haceres',
|
|
33
|
+
'output',
|
|
34
|
+
'criteria',
|
|
35
|
+
'criterio',
|
|
36
|
+
'handoff',
|
|
37
|
+
'out of scope',
|
|
38
|
+
'in scope',
|
|
39
|
+
'que no',
|
|
40
|
+
'que si',
|
|
41
|
+
'que sí',
|
|
42
|
+
'edge cases',
|
|
43
|
+
'casos borde',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function headingNamePattern(name) {
|
|
47
|
+
return new RegExp(
|
|
48
|
+
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+'),
|
|
49
|
+
'i',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
|
|
54
|
+
|
|
55
|
+
function indexOfSectionMarker(text) {
|
|
56
|
+
let earliest = -1;
|
|
57
|
+
for (const namePattern of HEADING_NAME_PATTERNS) {
|
|
58
|
+
const match = namePattern.exec(text);
|
|
59
|
+
if (!match) continue;
|
|
60
|
+
const after = text.slice(match.index + match[0].length);
|
|
61
|
+
if (!/^\s*:/.test(after)) continue;
|
|
62
|
+
if (earliest === -1 || match.index < earliest) earliest = match.index;
|
|
63
|
+
}
|
|
64
|
+
return earliest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LEVEL_LINE_PATTERNS = [
|
|
68
|
+
new RegExp('^level\\s*:?$', 'i'),
|
|
69
|
+
new RegExp('^nivel\\s*:?$', 'i'),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function stripLeadingMarkup(line) {
|
|
73
|
+
return line
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^#{1,4}\s*/, '')
|
|
76
|
+
.replace(/^[-*]\s*/, '')
|
|
77
|
+
.replace(/^\*\*/, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isScaffoldingLine(line) {
|
|
81
|
+
const stripped = stripLeadingMarkup(line);
|
|
82
|
+
if (LEVEL_LINE_PATTERNS.some((pattern) => pattern.test(stripped)))
|
|
83
|
+
return true;
|
|
84
|
+
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
85
|
+
const match = namePattern.exec(stripped);
|
|
86
|
+
return (
|
|
87
|
+
match &&
|
|
88
|
+
match.index === 0 &&
|
|
89
|
+
/^\s*:?$/.test(stripped.slice(match[0].length))
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
|
|
95
|
+
const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
|
|
96
|
+
|
|
97
|
+
function isEndOfInScope(line) {
|
|
98
|
+
const stripped = stripLeadingMarkup(line);
|
|
99
|
+
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
100
|
+
const match = namePattern.exec(stripped);
|
|
101
|
+
return match && match.index === 0;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function contentAfterHeading(line) {
|
|
106
|
+
const colon = line.indexOf(':');
|
|
107
|
+
return colon === -1 ? '' : line.slice(colon + 1).trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const STOP_WORDS = new Set(
|
|
111
|
+
(
|
|
112
|
+
'de la el los las un una unos unas y o a en del al lo que se su sus por para con como es son ser este esta ' +
|
|
113
|
+
'esto ese esa eso mas si no ni pero cuando donde cual cuales hay hace hacer debe deben debes puede pueden ' +
|
|
114
|
+
'the of to and in for on with a an is are be it its this that'
|
|
115
|
+
).split(' '),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
function stripDiacritics(text) {
|
|
119
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function tokenize(text) {
|
|
123
|
+
return stripDiacritics(String(text).toLowerCase())
|
|
124
|
+
.split(/[^a-z0-9]+/)
|
|
125
|
+
.filter(
|
|
126
|
+
(token) => token.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(token),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
|
|
131
|
+
const KNOWN_EXTENSIONS = [
|
|
132
|
+
'mjs',
|
|
133
|
+
'cjs',
|
|
134
|
+
'json',
|
|
135
|
+
'jsx',
|
|
136
|
+
'js',
|
|
137
|
+
'mts',
|
|
138
|
+
'cts',
|
|
139
|
+
'tsx',
|
|
140
|
+
'ts',
|
|
141
|
+
'vue',
|
|
142
|
+
'md',
|
|
143
|
+
'py',
|
|
144
|
+
'sh',
|
|
145
|
+
'ps1',
|
|
146
|
+
'yaml',
|
|
147
|
+
'yml',
|
|
148
|
+
'toml',
|
|
149
|
+
'sql',
|
|
150
|
+
'css',
|
|
151
|
+
'html',
|
|
152
|
+
];
|
|
153
|
+
const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
|
|
154
|
+
`[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
|
|
155
|
+
'g',
|
|
156
|
+
);
|
|
157
|
+
const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
|
|
158
|
+
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
|
|
159
|
+
|
|
160
|
+
function looksLikeRelativePath(run) {
|
|
161
|
+
return /[\\/]/.test(run) && !/^[\\/]+$/.test(run);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stripTrailingPunctuation(text) {
|
|
165
|
+
let end = text.length;
|
|
166
|
+
while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
|
|
167
|
+
return text.slice(0, end);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function mentionedPaths(prompt) {
|
|
171
|
+
const found = new Set();
|
|
172
|
+
const text = String(prompt);
|
|
173
|
+
const fixedShapeMatches = [
|
|
174
|
+
WINDOWS_DRIVE_PATH_PATTERN,
|
|
175
|
+
KNOWN_EXTENSION_FILENAME_PATTERN,
|
|
176
|
+
].flatMap((pattern) => text.match(pattern) ?? []);
|
|
177
|
+
const relativePathMatches = (text.match(PATH_LIKE_RUN_PATTERN) ?? []).filter(
|
|
178
|
+
looksLikeRelativePath,
|
|
179
|
+
);
|
|
180
|
+
for (const raw of [...fixedShapeMatches, ...relativePathMatches]) {
|
|
181
|
+
const normalized = stripTrailingPunctuation(
|
|
182
|
+
stripDiacritics(raw.toLowerCase()).replace(/\\/g, '/'),
|
|
183
|
+
);
|
|
184
|
+
const base = normalized.split('/').filter(Boolean).pop();
|
|
185
|
+
if (base && /[a-z0-9]/.test(base)) found.add(base);
|
|
186
|
+
}
|
|
187
|
+
return [...found].sort();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function identityText(prompt) {
|
|
191
|
+
const lines = String(prompt).split(/\r?\n/);
|
|
192
|
+
const parts = [];
|
|
193
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
194
|
+
const strippedLine = stripLeadingMarkup(lines[index])
|
|
195
|
+
.replace(/\*\*\s*/g, '')
|
|
196
|
+
.replace(' :', ':');
|
|
197
|
+
const goal = strippedLine.match(GOAL_HEADING_PATTERN);
|
|
198
|
+
if (goal) {
|
|
199
|
+
const goalText = goal[2].trim();
|
|
200
|
+
const cut = indexOfSectionMarker(goalText);
|
|
201
|
+
parts.push(cut > 0 ? goalText.slice(0, cut) : goalText);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
|
|
205
|
+
parts.push(contentAfterHeading(strippedLine));
|
|
206
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
207
|
+
if (isEndOfInScope(lines[cursor])) break;
|
|
208
|
+
parts.push(lines[cursor]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return parts.join(' ').trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function textWithoutScaffolding(prompt) {
|
|
216
|
+
return String(prompt)
|
|
217
|
+
.split(/\r?\n/)
|
|
218
|
+
.filter((line) => !isScaffoldingLine(line))
|
|
219
|
+
.join(' ')
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function identitySignature(prompt) {
|
|
224
|
+
const identity =
|
|
225
|
+
identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
|
|
226
|
+
const tokens = tokenize(identity);
|
|
227
|
+
const features = [];
|
|
228
|
+
if (tokens.length >= 2) {
|
|
229
|
+
for (let index = 0; index < tokens.length - 1; index += 1)
|
|
230
|
+
features.push(`${tokens[index]} ${tokens[index + 1]}`);
|
|
231
|
+
} else {
|
|
232
|
+
features.push(...tokens);
|
|
233
|
+
}
|
|
234
|
+
for (const path of mentionedPaths(prompt))
|
|
235
|
+
features.push(`path:${path}`, `path:${path}`);
|
|
236
|
+
return features;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function identityKey(signature) {
|
|
240
|
+
return createHash('sha256').update(signature.join(' ')).digest('hex');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function occurrencesFor(state, key) {
|
|
244
|
+
const value = state[key];
|
|
245
|
+
if (!Array.isArray(value)) return [];
|
|
246
|
+
return value.filter((entry) => entry && Array.isArray(entry.signature));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main() {
|
|
250
|
+
const rawPayload = readHookPayload();
|
|
251
|
+
if (rawPayload === null) allow();
|
|
252
|
+
const toolName = toolNameOf(rawPayload) ?? '';
|
|
253
|
+
if (!toolInGroups(toolName, ['delegation'])) allow();
|
|
254
|
+
|
|
255
|
+
const cwd = process.cwd();
|
|
256
|
+
if (!isGateEnabled(CONFIG_KEY, false, cwd)) allow();
|
|
257
|
+
|
|
258
|
+
const toolInput = toolInputOf(rawPayload);
|
|
259
|
+
const prompt = delegationPromptOf(toolInput);
|
|
260
|
+
if (!prompt.trim()) allow();
|
|
261
|
+
|
|
262
|
+
const sessionId = sessionIdOf(rawPayload);
|
|
263
|
+
const stateOptions = { cwd };
|
|
264
|
+
const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
|
|
265
|
+
const signature = identitySignature(prompt);
|
|
266
|
+
const key = identityKey(signature);
|
|
267
|
+
|
|
268
|
+
const occurrences = [
|
|
269
|
+
...occurrencesFor(state, key),
|
|
270
|
+
{ signature, seenAt: Date.now() },
|
|
271
|
+
].slice(-MAX_ENTRIES_PER_KEY);
|
|
272
|
+
writeSessionState(
|
|
273
|
+
GATE_ID,
|
|
274
|
+
sessionId,
|
|
275
|
+
{ ...state, [key]: occurrences },
|
|
276
|
+
stateOptions,
|
|
277
|
+
);
|
|
278
|
+
allow();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
main();
|
|
283
|
+
} catch {
|
|
284
|
+
allow();
|
|
285
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// dependency-skills — a dependency that is NEW to the project must be covered by a skill.
|
|
2
|
+
// Only the delta counts: a rewrite of package.json that keeps every existing dependency is
|
|
3
|
+
// not "adding" anything, so the gate compares what will be written (or installed from the
|
|
4
|
+
// shell) against the package.json already on disk. A version bump is never a new dependency.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
8
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
3
9
|
import {
|
|
4
10
|
runGate,
|
|
5
11
|
deny,
|
|
6
12
|
toolInGroups,
|
|
7
13
|
writtenPathOf,
|
|
8
14
|
writtenContentOf,
|
|
15
|
+
shellCommandOf,
|
|
9
16
|
} from '../../lib/hook-io.mjs';
|
|
10
17
|
|
|
11
18
|
const GATE_ID = 'dependency-skills';
|
|
12
19
|
const CONFIG_KEY = 'requireSkillForNewDependency';
|
|
20
|
+
const PACKAGE_FILE = 'package.json';
|
|
13
21
|
|
|
14
22
|
const DEFAULT_DEPS_WITHOUT_OWN_API = [
|
|
15
23
|
'clsx',
|
|
@@ -22,6 +30,15 @@ const DEFAULT_DEPS_WITHOUT_OWN_API = [
|
|
|
22
30
|
'regenerator-runtime',
|
|
23
31
|
];
|
|
24
32
|
|
|
33
|
+
const INSTALL_COMMANDS = [
|
|
34
|
+
{ manager: 'npm', verbs: ['i', 'install', 'add'] },
|
|
35
|
+
{ manager: 'pnpm', verbs: ['add'] },
|
|
36
|
+
{ manager: 'yarn', verbs: ['add'] },
|
|
37
|
+
{ manager: 'bun', verbs: ['add'] },
|
|
38
|
+
];
|
|
39
|
+
const REGISTRY_NAME_PATTERN = /^(?:@[\w.-]+\/)?[\w.-]+$/;
|
|
40
|
+
const SEGMENT_SEPARATOR = /&&|\|\||[;|\n]/;
|
|
41
|
+
|
|
25
42
|
function normalize(name) {
|
|
26
43
|
return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
|
|
27
44
|
}
|
|
@@ -31,21 +48,23 @@ function isExempt(dependencyName, dependenciesWithoutOwnApi) {
|
|
|
31
48
|
return dependenciesWithoutOwnApi.includes(dependencyName);
|
|
32
49
|
}
|
|
33
50
|
|
|
51
|
+
// statSync (not Dirent.isDirectory) so a skill installed as a junction/symlink counts.
|
|
34
52
|
function listSkillDirectories(skillsDirectoryPath) {
|
|
35
53
|
try {
|
|
36
|
-
return readdirSync(skillsDirectoryPath
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
return readdirSync(skillsDirectoryPath).filter((name) => {
|
|
55
|
+
try {
|
|
56
|
+
return statSync(join(skillsDirectoryPath, name)).isDirectory();
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
39
61
|
} catch {
|
|
40
|
-
// No skills directory in this project — nothing to cross-check against.
|
|
41
62
|
return [];
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
// A skill covers a dependency when its normalized name equals the dependency's
|
|
46
|
-
// it as a whole
|
|
47
|
-
// bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
|
|
48
|
-
// exemption that silenced the warning for an unreviewed dependency.
|
|
66
|
+
// A skill covers a dependency when its normalized name equals the dependency's or contains
|
|
67
|
+
// it as a whole `-` segment; bidirectional substring matching let `rip` cover `stripe`.
|
|
49
68
|
function skillNameMatches(normalizedDependency, skillDirectoryName) {
|
|
50
69
|
const normalizedSkill = normalize(skillDirectoryName);
|
|
51
70
|
if (normalizedSkill === normalizedDependency) return true;
|
|
@@ -59,25 +78,100 @@ function hasMatchingSkill(dependencyName, skillDirectories) {
|
|
|
59
78
|
);
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
function
|
|
63
|
-
if (
|
|
64
|
-
|
|
81
|
+
function dependencyNamesOf(parsed) {
|
|
82
|
+
if (!parsed || typeof parsed !== 'object') return [];
|
|
83
|
+
const names = new Set();
|
|
84
|
+
for (const field of ['dependencies', 'devDependencies']) {
|
|
85
|
+
const block = parsed[field];
|
|
86
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
|
|
87
|
+
for (const name of Object.keys(block)) names.add(name);
|
|
88
|
+
}
|
|
89
|
+
return [...names];
|
|
65
90
|
}
|
|
66
91
|
|
|
67
|
-
|
|
68
|
-
function dependencyNamesIn(content) {
|
|
69
|
-
let parsed;
|
|
92
|
+
function parseJsonOrNull(text) {
|
|
70
93
|
try {
|
|
71
|
-
|
|
94
|
+
return JSON.parse(text);
|
|
72
95
|
} catch {
|
|
73
|
-
// Mid-edit or partially written package.json — nothing reliable to check yet.
|
|
74
96
|
return null;
|
|
75
97
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readTextOrEmpty(path) {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(path, 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyEdit(base, edit) {
|
|
109
|
+
const oldString = String(edit.old_string ?? '');
|
|
110
|
+
const newString = String(edit.new_string ?? '');
|
|
111
|
+
if (!oldString) return base || newString;
|
|
112
|
+
if (edit.replace_all) return base.split(oldString).join(newString);
|
|
113
|
+
const at = base.indexOf(oldString);
|
|
114
|
+
if (at === -1) return base;
|
|
115
|
+
return base.slice(0, at) + newString + base.slice(at + oldString.length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writtenPackageText(toolInput, diskText) {
|
|
119
|
+
if (Array.isArray(toolInput.edits)) {
|
|
120
|
+
return toolInput.edits.reduce(
|
|
121
|
+
(text, edit) => applyEdit(text, edit ?? {}),
|
|
122
|
+
diskText,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (typeof toolInput.old_string === 'string')
|
|
126
|
+
return applyEdit(diskText, toolInput);
|
|
127
|
+
return writtenContentOf(toolInput);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function packageNameOf(token) {
|
|
131
|
+
const versionAt = token.indexOf('@', token.startsWith('@') ? 1 : 0);
|
|
132
|
+
const name = versionAt === -1 ? token : token.slice(0, versionAt);
|
|
133
|
+
return REGISTRY_NAME_PATTERN.test(name) ? name : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function installedPackagesIn(command) {
|
|
137
|
+
const names = [];
|
|
138
|
+
for (const segment of command.split(SEGMENT_SEPARATOR)) {
|
|
139
|
+
const [manager = '', verb = '', ...rest] = segment.trim().split(/\s+/);
|
|
140
|
+
const spec = INSTALL_COMMANDS.find(
|
|
141
|
+
(candidate) => candidate.manager === manager.toLowerCase(),
|
|
142
|
+
);
|
|
143
|
+
if (!spec || !spec.verbs.includes(verb)) continue;
|
|
144
|
+
for (const token of rest) {
|
|
145
|
+
if (token.startsWith('-')) continue;
|
|
146
|
+
const name = packageNameOf(token);
|
|
147
|
+
if (name) names.push(name);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return names;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveAgainst(root, path) {
|
|
154
|
+
return isAbsolute(path) ? path : join(root, path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Dependencies the call introduces, or null when the written JSON is not parseable yet. */
|
|
158
|
+
function newDependenciesOf(toolName, toolInput, root) {
|
|
159
|
+
if (toolInGroups(toolName, ['shell'])) {
|
|
160
|
+
const names = installedPackagesIn(shellCommandOf(toolInput));
|
|
161
|
+
if (names.length === 0) return [];
|
|
162
|
+
const disk = dependencyNamesOf(
|
|
163
|
+
parseJsonOrNull(readTextOrEmpty(join(root, PACKAGE_FILE))),
|
|
164
|
+
);
|
|
165
|
+
return names.filter((name) => !disk.includes(name));
|
|
166
|
+
}
|
|
167
|
+
if (!toolInGroups(toolName, ['write'])) return [];
|
|
168
|
+
const filePath = writtenPathOf(toolInput);
|
|
169
|
+
if (basename(filePath) !== PACKAGE_FILE) return [];
|
|
170
|
+
const diskText = readTextOrEmpty(resolveAgainst(root, filePath));
|
|
171
|
+
const written = parseJsonOrNull(writtenPackageText(toolInput, diskText));
|
|
172
|
+
if (written === null) return null;
|
|
173
|
+
const disk = dependencyNamesOf(parseJsonOrNull(diskText));
|
|
174
|
+
return dependencyNamesOf(written).filter((name) => !disk.includes(name));
|
|
81
175
|
}
|
|
82
176
|
|
|
83
177
|
runGate(
|
|
@@ -87,27 +181,21 @@ runGate(
|
|
|
87
181
|
enabledByDefault: true,
|
|
88
182
|
defaultParams: {
|
|
89
183
|
depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
|
|
90
|
-
projectSkillsDir: '.claude
|
|
184
|
+
projectSkillsDir: join('.claude', 'skills'),
|
|
91
185
|
},
|
|
92
186
|
},
|
|
93
|
-
({ toolName, toolInput, parameters }) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
|
|
98
|
-
|
|
99
|
-
const content = writtenContentOf(toolInput);
|
|
100
|
-
if (!content) return;
|
|
101
|
-
|
|
102
|
-
const dependencyNames = dependencyNamesIn(content);
|
|
103
|
-
if (!dependencyNames || dependencyNames.length === 0) return;
|
|
187
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
188
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
189
|
+
const newDependencies = newDependenciesOf(toolName, toolInput, root);
|
|
190
|
+
if (!newDependencies || newDependencies.length === 0) return;
|
|
104
191
|
|
|
105
192
|
const skillDirectories = listSkillDirectories(
|
|
106
|
-
|
|
193
|
+
resolveAgainst(root, parameters.projectSkillsDir),
|
|
107
194
|
);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
195
|
+
const unmatched = newDependencies.filter(
|
|
196
|
+
(name) =>
|
|
197
|
+
!isExempt(name, parameters.depsWithoutOwnApi) &&
|
|
198
|
+
!hasMatchingSkill(name, skillDirectories),
|
|
111
199
|
);
|
|
112
200
|
if (unmatched.length === 0) return;
|
|
113
201
|
|
|
@@ -121,10 +209,3 @@ runGate(
|
|
|
121
209
|
);
|
|
122
210
|
},
|
|
123
211
|
);
|
|
124
|
-
|
|
125
|
-
// Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
|
|
126
|
-
// dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
|
|
127
|
-
// .ai/skills-baseline.json freeze mechanism — that belongs to a separate
|
|
128
|
-
// adopt-stack-and-skills.mjs script that does not exist in this repo. This
|
|
129
|
-
// gate only does simple substring matching between dependency name and skill
|
|
130
|
-
// directory name, both normalized.
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
// diagnosis-before-patch — a timeout/deadline/retry value that CHANGES gets a reminder to
|
|
2
|
+
// bring the evidence first. Only a change counts: a new file, or a value that stays as it
|
|
3
|
+
// is on disk, is not a symptom being patched. Advisory only, never a block.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
1
8
|
import {
|
|
2
9
|
runGate,
|
|
3
10
|
warn,
|
|
4
11
|
toolInGroups,
|
|
5
12
|
writtenContentOf,
|
|
6
13
|
writtenPathOf,
|
|
14
|
+
compileRegexList,
|
|
7
15
|
} from '../../lib/hook-io.mjs';
|
|
8
16
|
|
|
9
17
|
const GATE_ID = 'diagnosis-before-patch';
|
|
@@ -17,9 +25,57 @@ const DEFAULT_TIMEOUT_PATTERNS = [
|
|
|
17
25
|
String.raw`\b(query_?timeout|hard_?deadline)\b`,
|
|
18
26
|
];
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
// From a pattern hit, the key and the numeric value that follows it (value may be empty).
|
|
29
|
+
const ASSIGNMENT_PATTERN = /^([\w.$-]+)\s*[:=]?\s*['"]?([\d.]*)/;
|
|
30
|
+
const ASSIGNMENT_WINDOW = 200;
|
|
31
|
+
|
|
32
|
+
function assignmentsIn(text, patterns) {
|
|
33
|
+
const values = new Map();
|
|
34
|
+
for (const pattern of patterns) {
|
|
35
|
+
const global = new RegExp(pattern.source, `${pattern.flags}g`);
|
|
36
|
+
for (const match of text.matchAll(global)) {
|
|
37
|
+
const slice = text.slice(match.index, match.index + ASSIGNMENT_WINDOW);
|
|
38
|
+
const assignment = ASSIGNMENT_PATTERN.exec(slice);
|
|
39
|
+
if (!assignment) continue;
|
|
40
|
+
const [, key, value] = assignment;
|
|
41
|
+
if (!values.has(key)) values.set(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function changedKeys(before, after) {
|
|
48
|
+
const changes = [];
|
|
49
|
+
for (const [key, value] of after) {
|
|
50
|
+
if (before.has(key) && before.get(key) !== value)
|
|
51
|
+
changes.push({ key, from: before.get(key), to: value });
|
|
52
|
+
}
|
|
53
|
+
return changes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readTextOrNull(path) {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(path, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Edit-style tools carry old/new pairs; anything else is compared with the file on disk.
|
|
65
|
+
function beforeAfterPairs(toolInput, root) {
|
|
66
|
+
if (Array.isArray(toolInput.edits)) {
|
|
67
|
+
return toolInput.edits.map((edit) => [
|
|
68
|
+
String(edit?.old_string ?? ''),
|
|
69
|
+
String(edit?.new_string ?? ''),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
if (typeof toolInput.old_string === 'string') {
|
|
73
|
+
return [[toolInput.old_string, writtenContentOf(toolInput)]];
|
|
74
|
+
}
|
|
75
|
+
const path = writtenPathOf(toolInput);
|
|
76
|
+
if (!path) return [];
|
|
77
|
+
const onDisk = readTextOrNull(isAbsolute(path) ? path : join(root, path));
|
|
78
|
+
return onDisk === null ? [] : [[onDisk, writtenContentOf(toolInput)]];
|
|
23
79
|
}
|
|
24
80
|
|
|
25
81
|
runGate(
|
|
@@ -27,32 +83,38 @@ runGate(
|
|
|
27
83
|
id: GATE_ID,
|
|
28
84
|
configKey: CONFIG_KEY,
|
|
29
85
|
enabledByDefault: true,
|
|
86
|
+
severity: 'warn',
|
|
30
87
|
defaultParams: {
|
|
31
88
|
timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
|
|
32
89
|
},
|
|
33
90
|
},
|
|
34
|
-
({ toolName, toolInput, parameters }) => {
|
|
35
|
-
|
|
36
|
-
|
|
91
|
+
({ toolName, toolInput, parameters, cwd }) => {
|
|
92
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
93
|
+
const { patterns } = compileRegexList(parameters.timeoutPatterns);
|
|
94
|
+
if (patterns.length === 0) return;
|
|
37
95
|
|
|
38
|
-
const
|
|
39
|
-
|
|
96
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
97
|
+
const changes = beforeAfterPairs(toolInput, root).flatMap(
|
|
98
|
+
([before, after]) =>
|
|
99
|
+
changedKeys(
|
|
100
|
+
assignmentsIn(before, patterns),
|
|
101
|
+
assignmentsIn(after, patterns),
|
|
102
|
+
),
|
|
40
103
|
);
|
|
41
|
-
|
|
42
|
-
.map((pattern) => pattern.exec(text))
|
|
43
|
-
.find((match) => match !== null);
|
|
44
|
-
if (!matched) return;
|
|
104
|
+
if (changes.length === 0) return;
|
|
45
105
|
|
|
46
106
|
const filePath = writtenPathOf(toolInput) || '(unknown path)';
|
|
107
|
+
const described = changes
|
|
108
|
+
.map((change) => `${change.key}: ${change.from} -> ${change.to}`)
|
|
109
|
+
.join(', ');
|
|
47
110
|
warn(
|
|
48
111
|
CONFIG_KEY,
|
|
49
|
-
`You are
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
"
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'off. This is a warning, not a block — the write proceeds either way.',
|
|
112
|
+
`You are changing a timeout/deadline/retry value in ${filePath} (${described}). ` +
|
|
113
|
+
'Before changing a value to fix a symptom ("X is slow/fails"), confirm you have ' +
|
|
114
|
+
'the evidence that proves the cause: a log line from the failing provider/process, ' +
|
|
115
|
+
"not a hypothesis. If you don't have that log line yet, get it before writing this " +
|
|
116
|
+
'change — do not guess a new number. A timeout should measure inactivity, not total ' +
|
|
117
|
+
'time. This is a warning, not a block — the write proceeds either way.',
|
|
56
118
|
);
|
|
57
119
|
},
|
|
58
120
|
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { deny, runGate } from '../../lib/hook-io.mjs';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PARAMS,
|
|
4
|
+
GATE_ID,
|
|
5
|
+
isResearchTool,
|
|
6
|
+
queryOf,
|
|
7
|
+
readState,
|
|
8
|
+
} from './shared.mjs';
|
|
9
|
+
|
|
10
|
+
const CONFIG_KEY = 'requireEngramBeforeResearch';
|
|
11
|
+
|
|
12
|
+
const TOPIC_EXCERPT_LENGTH = 80;
|
|
13
|
+
|
|
14
|
+
function denyResearchWithoutMemory(toolName, toolInput) {
|
|
15
|
+
const topic = queryOf(toolInput);
|
|
16
|
+
const suggestion = topic
|
|
17
|
+
? ` (e.g. mem_search "${topic.slice(0, TOPIC_EXCERPT_LENGTH)}")`
|
|
18
|
+
: '';
|
|
19
|
+
deny(
|
|
20
|
+
CONFIG_KEY,
|
|
21
|
+
`${toolName} was called before any engram lookup in this session. Persistent memory is the ` +
|
|
22
|
+
`first source: call mem_search with the topic you are about to research${suggestion}. ` +
|
|
23
|
+
'If it returns nothing, research with context7 (libraries) or the web, then mem_save what ' +
|
|
24
|
+
'you learned before ending the turn.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
runGate(
|
|
29
|
+
{
|
|
30
|
+
id: GATE_ID,
|
|
31
|
+
configKey: CONFIG_KEY,
|
|
32
|
+
enabledByDefault: true,
|
|
33
|
+
defaultParams: { ...DEFAULT_PARAMS },
|
|
34
|
+
},
|
|
35
|
+
({ toolName, toolInput, sessionId, parameters, cwd }) => {
|
|
36
|
+
if (!isResearchTool(toolName, parameters)) return;
|
|
37
|
+
const state = readState(sessionId, cwd);
|
|
38
|
+
if (state.memSearchCount > 0) return;
|
|
39
|
+
denyResearchWithoutMemory(toolName, toolInput);
|
|
40
|
+
},
|
|
41
|
+
);
|