@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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. 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
- import { readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
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, { withFileTypes: true })
37
- .filter((entry) => entry.isDirectory())
38
- .map((entry) => entry.name);
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, or contains
46
- // it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
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 needsSkillReview(dependencyName, parameters, skillDirectories) {
63
- if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
64
- return !hasMatchingSkill(dependencyName, skillDirectories);
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
- /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
68
- function dependencyNamesIn(content) {
69
- let parsed;
92
+ function parseJsonOrNull(text) {
70
93
  try {
71
- parsed = JSON.parse(content);
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
- const dependencies = {
77
- ...(parsed.dependencies ?? {}),
78
- ...(parsed.devDependencies ?? {}),
79
- };
80
- return Object.keys(dependencies);
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/skills',
184
+ projectSkillsDir: join('.claude', 'skills'),
91
185
  },
92
186
  },
93
- ({ toolName, toolInput, parameters }) => {
94
- if (!toolInGroups(toolName, ['write'])) return;
95
-
96
- const filePath = writtenPathOf(toolInput);
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
- join(process.cwd(), parameters.projectSkillsDir),
193
+ resolveAgainst(root, parameters.projectSkillsDir),
107
194
  );
108
-
109
- const unmatched = dependencyNames.filter((name) =>
110
- needsSkillReview(name, parameters, skillDirectories),
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
- function extractText(toolName, toolInput) {
21
- if (!toolInGroups(toolName, ['write'])) return '';
22
- return writtenContentOf(toolInput);
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
- const text = extractText(toolName, toolInput);
36
- if (!text) return;
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 patterns = parameters.timeoutPatterns.map(
39
- (source) => new RegExp(source, 'i'),
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
- const matched = patterns
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 writing '${matched[0].trim()}' into ${filePath} — a timeout/deadline/retry ` +
50
- 'value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you ' +
51
- 'have the evidence that proves the cause: a log line from the failing provider/' +
52
- "process, not a hypothesis. If you don't have that log line yet, get it before " +
53
- 'writing this change — do not guess a new number. A timeout should measure ' +
54
- 'inactivity, not total time: a process that is still progressing should not be cut ' +
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
+ );