@devrik-tools/claude-gates 0.7.1 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  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 +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  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 +103 -60
  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 +79 -143
  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 +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +83 -11
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +49 -93
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +97 -48
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +98 -206
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -91
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +104 -136
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -75
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,41 +1,13 @@
1
- // reuse-before-build — do not reinvent the wheel. Before building a new script, gate,
2
- // verifier, helper, composable, component or generic utility, consult what already solves
3
- // the need: the per-project tool map (a record of past findings), the installed dependencies
4
- // (package.json), and declared by the author a local→cloud audit. This gate is the
5
- // deterministic half: it objects when a build is starting without evidence of a check.
6
- // It never reaches the network: the cloud search (npm, marketplaces) is the assistant's or
7
- // the CLI's job, so the hook stays self-contained.
8
- //
9
- // ── What a project can configure (params) ───────────────────────────────────────────
10
- // toolMapFile path to the per-project tool map, relative to the project root.
11
- // Default .ai/tool-map.json.
12
- // toolFolders folder names that mark a file as a "tool/helper". Replaces the built-in
13
- // list wholesale.
14
- // toolNamePatterns regex sources; a new file whose BASENAME matches any is treated as a
15
- // generic helper regardless of its folder (useFoo, fooHelper, fooUtil...).
16
- // toolExtensions file extensions considered code that can reinvent a wheel.
17
- //
18
- // ── How it decides (deterministic, no judgment) ─────────────────────────────────────
19
- // A write that creates a tool/helper (by folder, by name pattern, or by a helper-ish
20
- // extension), or a delegation prompt that asks to build one, must carry evidence the wheel
21
- // was checked: (a) an audit phrase in the text, OR (b) the tool's own name already recorded
22
- // in the tool map, OR (c) an installed dependency whose name matches the tool's name (there
23
- // is already a package that plausibly covers it — reuse it or justify not to). Absent all
24
- // three, the gate blocks with an actionable message.
25
- //
26
- // ── What changed vs the first version (closed holes) ────────────────────────────────
27
- // · Map match was a lax "any 4+-letter word of the target appears anywhere in the JSON",
28
- // which produced false "already covered" (a `parser.mjs` cleared by any unrelated entry
29
- // mentioning "parser"). Now it matches the tool's own basename as a whole token against
30
- // the recorded tool paths — structural, not substring-of-prose.
31
- // · Scope was only scripts/hooks/tools/gates + executable extensions. Now it also covers
32
- // lib/utils/helpers/composables/components/services AND name patterns (useX, XHelper,
33
- // XUtil, XService) anywhere, which is where a wheel is most often reinvented.
34
- // · Build-intent detection was English-only prose; now it uses the bilingual BUILD_INTENT.
35
- // · Adds an installed-dependency check: a matching package.json dep also clears the build.
36
-
37
- import { existsSync, readFileSync } from 'node:fs';
38
- import { basename, dirname, extname, join } from 'node:path';
1
+ // reuse-before-build — do not reinvent the wheel. A write that creates a tool/helper, or a
2
+ // delegation asking to build one, must carry evidence the wheel was checked: an audit
3
+ // phrase, the tool's basename already recorded in the project's tool map, or an installed
4
+ // dependency of the same name. What counts as a tool and as evidence is shared with
5
+ // tool-map (lib/tools.mjs) so the read and write halves of the pair cannot drift apart.
6
+ // The gate never reaches the network: the cloud search is the assistant's job.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
10
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
39
11
  import {
40
12
  runGate,
41
13
  deny,
@@ -45,199 +17,119 @@ import {
45
17
  delegationPromptOf,
46
18
  } from '../../lib/hook-io.mjs';
47
19
  import { isBuildIntent } from '../../lib/signals.mjs';
20
+ import {
21
+ AUDIT_DONE_PATTERN,
22
+ DEFAULT_TOOL_EXTENSIONS,
23
+ DEFAULT_TOOL_FOLDERS,
24
+ DEFAULT_TOOL_NAME_PATTERNS,
25
+ hasAuditEvidence,
26
+ isToolPath,
27
+ } from '../../lib/tools.mjs';
48
28
 
49
29
  const GATE_ID = 'reuse-before-build';
50
30
  const CONFIG_KEY = 'requireReuseCheckBeforeBuilding';
51
31
 
52
32
  const DEFAULT_TOOL_MAP_FILE = join('.ai', 'tool-map.json');
53
-
54
- // Code that can reinvent a wheel — broad, because a helper is a helper in any language.
55
- const DEFAULT_TOOL_EXTENSIONS = [
56
- '.js',
57
- '.mjs',
58
- '.cjs',
59
- '.ts',
60
- '.tsx',
61
- '.jsx',
62
- '.vue',
63
- '.py',
64
- '.sh',
65
- '.ps1',
66
- '.rb',
67
- '.go',
68
- ];
69
-
70
- // Folders whose files are "tools/helpers" worth a reuse check. Beyond the original four,
71
- // this now includes the places generic reusable code actually lives.
72
- const DEFAULT_TOOL_FOLDERS = [
73
- 'scripts',
74
- 'hooks',
75
- 'tools',
76
- 'gates',
77
- 'lib',
78
- 'libs',
79
- 'utils',
80
- 'util',
81
- 'helpers',
82
- 'helper',
83
- 'composables',
84
- 'components',
85
- 'services',
86
- 'shared',
87
- 'common',
88
- ];
89
-
90
- // A file whose BASENAME matches one of these is a generic helper wherever it lives —
91
- // useDebounce.ts in features/x/ is still a candidate for "does this already exist?".
92
- const DEFAULT_TOOL_NAME_PATTERNS = [
93
- String.raw`^use[A-Z]`, // React/Vue hook/composable: useFoo
94
- String.raw`[-.]?helpers?\.`, // foo.helper.ts, foo-helpers.js
95
- String.raw`[-.]?utils?\.`, // foo.util.ts, string-utils.js
96
- String.raw`[-.]?service\.`, // foo.service.ts
97
- String.raw`[-.]?wrapper\.`, // foo.wrapper.ts
98
- ];
99
-
100
- // Evidence the wheel was already checked: any phrase stating the audit result. Bilingual.
101
- const AUDIT_DONE_PATTERN =
102
- /(already exists|no existing tool|there is no plugin|audited|checked whether|nothing does this|justification:|ya existe|no existe (?:una |la )?herramienta|no hay plugin|audit[eé]|verifiqu[eé] que no)/i;
103
-
104
- function projectRootOf(startDirectory) {
105
- let current = startDirectory;
106
- while (true) {
107
- if (existsSync(join(current, '.git'))) return current;
108
- const parent = dirname(current);
109
- if (parent === current) return null;
110
- current = parent;
111
- }
33
+ const AUDIT_DONE_EXAMPLE_PHRASE = 'no existing tool covers this';
34
+
35
+ // A configured map path that escapes the project root is ignored in favor of the default:
36
+ // the map is project state, never a file elsewhere on the machine.
37
+ function toolMapPathFor(root, toolMapFile) {
38
+ const resolved = resolve(root, String(toolMapFile || DEFAULT_TOOL_MAP_FILE));
39
+ const relativePath = relative(root, resolved);
40
+ const inside =
41
+ relativePath !== '' &&
42
+ !relativePath.startsWith('..') &&
43
+ !isAbsolute(relativePath);
44
+ return inside ? resolved : join(root, DEFAULT_TOOL_MAP_FILE);
112
45
  }
113
46
 
114
- /**
115
- * Whether the tool map already records THIS tool, matched structurally: the new file's
116
- * basename (without extension) is compared as a whole `-`/`.`-delimited token against the
117
- * basenames of the recorded tool paths. This replaces the old "any 4+-letter word appears
118
- * anywhere in the JSON blob", which cleared unrelated builds by coincidental word overlap.
119
- */
120
- function toolAlreadyInMap(startDirectory, toolMapFile, toolBaseName) {
121
- const root = projectRootOf(startDirectory);
122
- if (!root) return false;
123
- const path = join(root, toolMapFile);
124
- if (!existsSync(path)) return false;
125
- let parsed;
126
- try {
127
- parsed = JSON.parse(readFileSync(path, 'utf8'));
128
- } catch {
129
- return false;
130
- }
131
- if (!Array.isArray(parsed.tools)) return false;
132
- const target = toolBaseName.toLowerCase();
133
- return parsed.tools.some((tool) => {
134
- const recorded = basename(String(tool.path ?? ''))
135
- .replace(/\.[^.]+$/, '')
136
- .toLowerCase();
137
- return recorded === target;
138
- });
47
+ function stemOf(path) {
48
+ return basename(String(path ?? ''))
49
+ .replace(/\.[^.]+$/, '')
50
+ .toLowerCase();
139
51
  }
140
52
 
141
- /** Whether an installed dependency's name matches the tool's name (a package likely covers
142
- * it). Normalizes scoped names (@scope/foo → foo) and matches whole, so `parser` is cleared
143
- * by a `parser`/`@x/parser` dep but not by an unrelated `body-parser`. */
144
- function installedDependencyCovers(startDirectory, toolBaseName) {
145
- const root = projectRootOf(startDirectory);
146
- if (!root) return false;
147
- const path = join(root, 'package.json');
148
- if (!existsSync(path)) return false;
149
- let parsed;
150
- try {
151
- parsed = JSON.parse(readFileSync(path, 'utf8'));
152
- } catch {
153
- return false;
154
- }
155
- const names = [
156
- ...Object.keys(parsed.dependencies ?? {}),
157
- ...Object.keys(parsed.devDependencies ?? {}),
158
- ];
159
- const target = toolBaseName.toLowerCase();
160
- return names.some((name) => {
161
- const bare = name.replace(/^@[^/]+\//, '').toLowerCase();
162
- return bare === target;
163
- });
53
+ function toolAlreadyInMap(root, toolMapFile, toolBaseName) {
54
+ const parsed = readJsonOrNull(toolMapPathFor(root, toolMapFile));
55
+ if (!Array.isArray(parsed?.tools)) return false;
56
+ return parsed.tools.some((tool) => stemOf(tool?.path) === toolBaseName);
164
57
  }
165
58
 
166
- function hasToolExtension(filePath, toolExtensions) {
167
- return toolExtensions.includes(extname(filePath).toLowerCase());
168
- }
169
-
170
- /** A real path SEGMENT equal to one of the tool-folder names (not a mere substring, so
171
- * `src/mytools/x` does not match `tools`). */
172
- function inToolFolder(filePath, toolFolders) {
173
- const segments = filePath.replace(/\\/g, '/').split('/');
174
- return segments.some((segment) =>
175
- toolFolders.includes(segment.toLowerCase()),
59
+ function installedDependencyCovers(root, toolBaseName) {
60
+ const parsed = readJsonOrNull(join(root, 'package.json'));
61
+ const names = [];
62
+ for (const field of ['dependencies', 'devDependencies']) {
63
+ const block = parsed?.[field];
64
+ if (block && typeof block === 'object') names.push(...Object.keys(block));
65
+ }
66
+ return names.some(
67
+ (name) => name.replace(/^@[^/]+\//, '').toLowerCase() === toolBaseName,
176
68
  );
177
69
  }
178
70
 
179
- function matchesToolNamePattern(fileName, toolNamePatterns) {
180
- return toolNamePatterns.some((source) => {
181
- try {
182
- return new RegExp(source).test(fileName);
183
- } catch {
184
- return false;
185
- }
186
- });
71
+ function writeDenyMessage(filePath) {
72
+ return (
73
+ `Blocked: '${filePath}' looks like a new tool/helper, and nothing shows the wheel was ` +
74
+ 'checked first. Pick ONE, then retry the exact same write:\n' +
75
+ ` 1. It already exists here or in an installed dep — don't create '${filePath}'; ` +
76
+ 'reuse/import the existing one instead.\n' +
77
+ ' 2. It does not exist anywhere you checked (repo, installed deps, npm/marketplaces) — ' +
78
+ "add ONE line to the file's content stating that, e.g.: " +
79
+ `"// ${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching /${AUDIT_DONE_PATTERN.source}/iu ` +
80
+ 'works, this one is guaranteed to). That line also gets this file auto-recorded into ' +
81
+ 'the tool map — nothing further to run.\n' +
82
+ 'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
83
+ 'in the file or a matching package.json dependency, nothing else.'
84
+ );
187
85
  }
188
86
 
189
- /** Whether a new file at this path should be treated as a reusable tool/helper. */
190
- function isToolLikePath(filePath, parameters) {
191
- if (!hasToolExtension(filePath, parameters.toolExtensions)) return false;
192
- const fileName = basename(filePath);
87
+ function delegationDenyMessage() {
193
88
  return (
194
- inToolFolder(filePath, parameters.toolFolders) ||
195
- matchesToolNamePattern(fileName, parameters.toolNamePatterns)
89
+ "Blocked: this delegation's prompt asks to build a new tool/helper, and nothing in " +
90
+ 'the prompt shows the wheel was checked first. Pick ONE, then relaunch the same ' +
91
+ 'delegation with the prompt updated:\n' +
92
+ ' 1. It already exists (repo, installed dep) — do not delegate a new build; ' +
93
+ 'reference the existing one in the prompt instead.\n' +
94
+ ' 2. It does not exist anywhere you checked — add ONE line to the prompt stating ' +
95
+ `that, e.g.: "${AUDIT_DONE_EXAMPLE_PHRASE}" (any phrase matching ` +
96
+ `/${AUDIT_DONE_PATTERN.source}/iu works, this one is guaranteed to). Once the delegate ` +
97
+ 'actually writes the file, include the same phrase in its content so it also gets ' +
98
+ 'auto-recorded into the tool map.\n' +
99
+ 'No filesystem exploration is required to satisfy this gate — the audit is a sentence ' +
100
+ 'in the prompt, or a matching package.json dependency, nothing else.'
196
101
  );
197
102
  }
198
103
 
199
- const DENY_MESSAGE =
200
- 'Do not reinvent the wheel. Before building this, confirm nothing already covers it, ' +
201
- 'auditing in this order: (1) LOCAL — the repo and installed deps (search by name and ' +
202
- 'usage, read the manifest); (2) CONTEXT7 — resolve the candidate library and read its ' +
203
- 'docs to confirm whether it truly covers the need; (3) WEB — search npm and plugin ' +
204
- 'marketplaces for a maintained package. If it is genuinely absent, record the finding ' +
205
- '(e.g. `claude-gates tool-map add`) and state the audit in the text, so the exploration ' +
206
- 'is not repeated next time.';
207
-
208
- /** A build is cleared when its text declares an audit, its name is already recorded in the
209
- * map, or an installed dependency covers it. `toolBaseName` is used for the last two. */
210
- function buildIsCleared(text, cwd, parameters, toolBaseName) {
211
- if (AUDIT_DONE_PATTERN.test(text)) return true;
212
- if (
213
- toolBaseName &&
214
- toolAlreadyInMap(cwd, parameters.toolMapFile, toolBaseName)
215
- )
216
- return true;
217
- if (toolBaseName && installedDependencyCovers(cwd, toolBaseName)) return true;
218
- return false;
104
+ function buildIsCleared(text, root, parameters, toolBaseName) {
105
+ if (hasAuditEvidence(text)) return true;
106
+ if (!toolBaseName) return false;
107
+ return (
108
+ toolAlreadyInMap(root, parameters.toolMapFile, toolBaseName) ||
109
+ installedDependencyCovers(root, toolBaseName)
110
+ );
219
111
  }
220
112
 
221
- /** Delegation: block a build-a-tool prompt that is not cleared. No file name is known from a
222
- * prompt, so only the audit-phrase evidence applies there. */
223
- function checkDelegation(toolInput, cwd, parameters) {
113
+ function checkDelegation(toolInput, root, parameters) {
224
114
  const prompt = delegationPromptOf(toolInput);
225
115
  if (!isBuildIntent(prompt)) return;
226
- if (buildIsCleared(prompt, cwd, parameters, null)) return;
227
- deny(CONFIG_KEY, DENY_MESSAGE);
116
+ if (buildIsCleared(prompt, root, parameters, null)) return;
117
+ deny(CONFIG_KEY, delegationDenyMessage());
228
118
  }
229
119
 
230
- /** Write: block a new tool-like file that is not cleared by content, map, or an installed dep. */
231
- function checkWrite(toolInput, cwd, parameters) {
120
+ function checkWrite(toolInput, root, parameters) {
232
121
  const rawPath = writtenPathOf(toolInput);
233
122
  const filePath = rawPath.replace(/\\/g, '/');
234
- if (!isToolLikePath(filePath, parameters)) return;
235
- // Editing an EXISTING file is not building a new tool — only creation triggers the audit.
236
- if (rawPath && existsSync(rawPath)) return;
123
+ const isTool = isToolPath(filePath, {
124
+ folders: parameters.toolFolders,
125
+ extensions: parameters.toolExtensions,
126
+ namePatterns: parameters.toolNamePatterns,
127
+ });
128
+ if (!isTool) return;
129
+ if (existsSync(isAbsolute(rawPath) ? rawPath : join(root, rawPath))) return;
237
130
  const content = writtenContentOf(toolInput);
238
- const toolBaseName = basename(filePath).replace(/\.[^.]+$/, '');
239
- if (buildIsCleared(content, cwd, parameters, toolBaseName)) return;
240
- deny(CONFIG_KEY, DENY_MESSAGE);
131
+ if (buildIsCleared(content, root, parameters, stemOf(filePath))) return;
132
+ deny(CONFIG_KEY, writeDenyMessage(filePath));
241
133
  }
242
134
 
243
135
  runGate(
@@ -252,12 +144,12 @@ runGate(
252
144
  toolExtensions: DEFAULT_TOOL_EXTENSIONS,
253
145
  },
254
146
  },
255
- ({ toolName, toolInput, parameters }) => {
256
- const cwd = process.cwd();
147
+ ({ toolName, toolInput, parameters, cwd }) => {
148
+ const root = projectRootOf(cwd) ?? cwd;
257
149
  if (toolInGroups(toolName, ['delegation'])) {
258
- checkDelegation(toolInput, cwd, parameters);
150
+ checkDelegation(toolInput, root, parameters);
259
151
  } else if (toolInGroups(toolName, ['write'])) {
260
- checkWrite(toolInput, cwd, parameters);
152
+ checkWrite(toolInput, root, parameters);
261
153
  }
262
154
  },
263
155
  );
@@ -1,198 +1,45 @@
1
- // risk-level — denies an implementation delegation that does not declare its risk
2
- // LEVEL (QUESTION|MICRO|STANDARD|HIGH-RISK), and denies a declared level that
3
- // contradicts a real high-impact signal in the prompt itself (anything but HIGH-RISK).
1
+ // risk-level — denies an implementation delegation that does not declare its risk LEVEL
2
+ // (QUESTION|MICRO|STANDARD|HIGH-RISK), and denies a declared level that contradicts a real
3
+ // high-impact signal in the prompt (anything but HIGH-RISK).
4
4
  //
5
- // ── What this does and does not decide ──────────────────────────────────────────────
6
- // Classifying a task correctly between QUESTION/MICRO/STANDARD stays the delegator's
7
- // judgment this gate cannot tell whether STANDARD was the "right" level for a task
8
- // with no detectable high-impact signal. The only deterministic thing it can and does
9
- // impose: the declaration EXISTS, and it does not contradict a real, non-quoted signal
10
- // of high risk. The level vocabulary itself (the four tokens) is a fixed base, not a
11
- // project param — only which signals count as high-impact, and which subagents are
12
- // exempt, are configurable.
13
- //
14
- // ── Same two-stage intent pattern as intent-flow ────────────────────────────────────
15
- // Stage 1: cheap lexical prefilter (implementation verb + high-impact signal present
16
- // anywhere). Stage 2: strip quoted/templated text, require near co-occurrence between
17
- // the verb and the signal, discard a verb whose object is a documentary deliverable.
18
- // Over-declaring HIGH-RISK is never penalized — this gate only catches under-declaring.
19
- //
20
- // ── Which declaration governs ────────────────────────────────────────────────────────
21
- // A prompt can mention the LEVEL token more than once (a decoy/reference to a previous
22
- // task, then the real declaration for THIS task). Taking the first match lets an early,
23
- // irrelevant mention govern instead of the operative one. This gate instead takes the
24
- // LAST declaration as operative (a delegator revising a decoy note downward writes the
25
- // real value last); when multiple declarations disagree, it denies and asks for a single
26
- // unambiguous LEVEL rather than guessing which one is real.
27
- //
28
- // ── readOnlySubagents is a declared label, not a verified capability ────────────────
29
- // This hook cannot check what tools a named subagent actually has — the whitelist
30
- // exemption is void whenever the prompt itself carries a mutation-risk signal (money/
31
- // auth/data/write/deploy): the signal in the text outranks the label on the call.
5
+ // Decisions: classifying between QUESTION/MICRO/STANDARD stays the delegator's judgment;
6
+ // the gate only imposes that the declaration EXISTS and does not contradict a real,
7
+ // non-quoted high-impact signal. Over-declaring HIGH-RISK is never penalized. The LAST
8
+ // declaration governs, and contradictory declarations deny rather than guess. A documentary
9
+ // deliverable (a report on the payment flow) is not an implementation and needs no LEVEL.
10
+ // An empty `highImpactPatterns` turns the contradiction check off, not the LEVEL requirement.
32
11
 
12
+ import {
13
+ DEFAULT_HIGH_IMPACT_PATTERNS,
14
+ DEFAULT_READ_ONLY_SUBAGENTS,
15
+ LEVELS,
16
+ allMatches,
17
+ buildHighImpactPattern,
18
+ declaredLevelsOf,
19
+ hasAmbiguousLevel,
20
+ hasRealSensitiveMutation,
21
+ isDocumentaryRequest,
22
+ isExemptQuery,
23
+ isImplementationRequest,
24
+ isReadOnlySubagent,
25
+ operativeLevelOf,
26
+ promptExcerpt,
27
+ stripQuoted,
28
+ } from '../../lib/delegation.mjs';
33
29
  import {
34
30
  runGate,
35
31
  deny,
36
32
  toolInGroups,
37
33
  delegationPromptOf,
38
34
  } from '../../lib/hook-io.mjs';
39
- import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
40
35
 
41
36
  const GATE_ID = 'risk-level';
42
37
  const CONFIG_KEY = 'requireDeclaredRiskLevel';
43
38
 
44
- const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
45
- const DEFAULT_HIGH_IMPACT_PATTERNS = [
46
- 'dinero|pago|cobro|money|payment',
47
- 'auth|autenticaci[oó]n|authentication|permiso|permission|credencial|credential|token|' +
48
- 'sesi[oó]n|session|seguridad|security|PII',
49
- 'borrar|delete|drop|migraci[oó]n|migration|schema',
50
- 'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
51
- ];
52
-
53
- const CO_OCCURRENCE_WINDOW = 100;
54
- const VERB_OBJECT_WINDOW = 60;
55
- // How much of an over-length prompt to quote back in a denial message.
56
- const PROMPT_EXCERPT_LENGTH = 80;
57
-
58
- function withUnicodeWordBoundary(alternatives) {
59
- return new RegExp(
60
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
61
- 'iu',
62
- );
63
- }
64
-
65
- // A prompt-level signal that a whitelisted read-only subagent name should NOT be
66
- // trusted to exempt this call: the name is a declared label, never a verified
67
- // capability this hook can check, and real mutation risk in the text must win over it.
68
- // Centralized in lib/signals.mjs (ES+EN) — see its header for the class this covers.
69
- const MUTATION_RISK_SIGNAL_PATTERN = MUTATION_RISK_SIGNAL;
70
-
71
- const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
72
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
73
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
74
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
75
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
76
- 'escrib(í|e)|escribir|implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy|write',
77
- );
78
-
79
- const QUERY_VERBS = withUnicodeWordBoundary(
80
- 'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
81
- 'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
82
- 'explain|what|how|show|analyze|investigate|review|audit|diagnose',
83
- );
84
-
85
- const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
86
- 'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
87
- 'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
88
- );
89
- const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
90
-
91
- /** Declared LEVEL: one of the four fixed tokens, near the word "level"/"classification"
92
- * (in Spanish or English) so a stray mention elsewhere in the prompt is not mistaken
93
- * for a declaration. */
94
- const DECLARED_LEVEL_PATTERN =
95
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO|STANDARD|HIGH-RISK)\b/iu;
96
-
97
- function isReadOnlySubagentName(toolInput, readOnlySubagents) {
98
- const type = String(
99
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
100
- ).toLowerCase();
101
- return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
102
- }
103
-
104
- /** A whitelisted subagent name exempts a call ONLY when the prompt carries no
105
- * mutation-risk signal. The name is a declared label, never a verified capability this
106
- * hook can check — a real risk signal in the text must win over it. */
107
- function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
108
- if (!isReadOnlySubagentName(toolInput, readOnlySubagents)) return false;
109
- return !MUTATION_RISK_SIGNAL_PATTERN.test(prompt);
110
- }
111
-
112
- function isImplementationRequest(prompt) {
113
- return IMPLEMENTATION_VERBS.test(prompt);
114
- }
115
-
116
- function isExemptQuery(prompt) {
117
- if (isImplementationRequest(prompt)) return false;
118
- return QUERY_VERBS.test(prompt);
119
- }
120
-
121
- function stripQuoted(text) {
122
- return text
123
- .replace(/```[\s\S]*?```/g, ' ')
124
- .replace(/"[^"\n]{0,300}"/g, ' ')
125
- .replace(/'[^'\n]{0,300}'/g, ' ');
126
- }
127
-
128
- function allMatches(pattern, text) {
129
- const flags = pattern.flags.includes('g')
130
- ? pattern.flags
131
- : `${pattern.flags}g`;
132
- return [...text.matchAll(new RegExp(pattern.source, flags))];
133
- }
134
-
135
- function verbProducesDocument(text, verbMatch) {
136
- const from = verbMatch.index;
137
- const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
138
- const objectOfVerb = text.slice(from, to);
139
- return (
140
- DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
141
- DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
142
- );
143
- }
144
-
145
- /** Returns the matched high-impact signal text if real (non-quoted) intent survives
146
- * stage 2, else null. Returns null immediately when stage 1 finds no candidate. */
147
- function realHighImpactSignal(prompt, highImpactPattern) {
148
- const isCandidate =
149
- IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
150
- if (!isCandidate) return null;
151
-
152
- const text = stripQuoted(prompt);
153
- const verbs = allMatches(IMPLEMENTATION_VERBS, text);
154
- const signals = allMatches(highImpactPattern, text);
155
-
156
- for (const verb of verbs) {
157
- if (verbProducesDocument(text, verb)) continue;
158
- for (const signal of signals) {
159
- if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
160
- return signal[0];
161
- }
162
- }
163
- return null;
164
- }
165
-
166
- /** Every LEVEL declaration in the prompt, in order of appearance. */
167
- function declaredLevels(prompt) {
168
- const withGlobal = new RegExp(
169
- DECLARED_LEVEL_PATTERN.source,
170
- `${DECLARED_LEVEL_PATTERN.flags}g`,
171
- );
172
- return [...prompt.matchAll(withGlobal)].map((match) =>
173
- match[2].toUpperCase(),
174
- );
175
- }
176
-
177
- /** The operative LEVEL: the LAST declaration in the prompt (a delegator who corrects an
178
- * earlier decoy/reference mention writes the real value last). Returns `{ level }` when
179
- * every declaration agrees or there is exactly one; returns `{ ambiguous: true }` when
180
- * two or more DIFFERENT levels are declared — the gate cannot know which one governs,
181
- * so it asks for a single unambiguous LEVEL rather than silently picking one. */
182
- function operativeLevel(prompt) {
183
- const levels = declaredLevels(prompt);
184
- if (levels.length === 0) return { level: null };
185
- const distinct = new Set(levels);
186
- if (distinct.size > 1) return { ambiguous: true, levels };
187
- return { level: levels[levels.length - 1] };
188
- }
189
-
190
39
  function denyNoLevelDeclared(prompt) {
191
- const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
192
- const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
193
40
  deny(
194
41
  CONFIG_KEY,
195
- `This implementation delegation ("${excerpt}${ellipsis}") does not declare its risk LEVEL. Add a ` +
42
+ `This implementation delegation ("${promptExcerpt(prompt)}") does not declare its risk LEVEL. Add a ` +
196
43
  'line such as "LEVEL: STANDARD" (or QUESTION/MICRO/HIGH-RISK, whichever fits) before relaunching ' +
197
44
  'this delegation.',
198
45
  );
@@ -216,11 +63,16 @@ function denyLevelContradictsSignal(level, signal) {
216
63
  );
217
64
  }
218
65
 
219
- function buildHighImpactPattern(highImpactPatterns) {
220
- return new RegExp(
221
- (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
222
- 'iu',
223
- );
66
+ function firstSignalText(prompt, highImpactPattern) {
67
+ return allMatches(highImpactPattern, stripQuoted(prompt))[0]?.[0] ?? '';
68
+ }
69
+
70
+ function isExempt(toolInput, prompt, readOnlySubagents) {
71
+ if (!prompt.trim()) return true;
72
+ if (isReadOnlySubagent(toolInput, prompt, readOnlySubagents)) return true;
73
+ if (isExemptQuery(prompt)) return true;
74
+ if (!isImplementationRequest(prompt)) return true;
75
+ return isDocumentaryRequest(prompt);
224
76
  }
225
77
 
226
78
  runGate(
@@ -237,29 +89,24 @@ runGate(
237
89
  if (!toolInGroups(toolName, ['delegation'])) return;
238
90
 
239
91
  const prompt = delegationPromptOf(toolInput);
240
- if (!prompt.trim()) return;
241
- if (isReadOnlySubagent(toolInput, prompt, parameters.readOnlySubagents))
242
- return;
243
- if (isExemptQuery(prompt)) return;
244
- if (!isImplementationRequest(prompt)) return;
92
+ if (isExempt(toolInput, prompt, parameters.readOnlySubagents)) return;
93
+
94
+ if (hasAmbiguousLevel(prompt)) denyAmbiguousLevel(declaredLevelsOf(prompt));
95
+
96
+ const level = operativeLevelOf(prompt);
97
+ if (!level) denyNoLevelDeclared(prompt.trim());
245
98
 
246
99
  const highImpactPattern = buildHighImpactPattern(
247
100
  parameters.highImpactPatterns,
248
101
  );
249
-
250
- const { level, ambiguous, levels } = operativeLevel(prompt);
251
- const signal = realHighImpactSignal(prompt, highImpactPattern);
252
-
253
- if (ambiguous) {
254
- denyAmbiguousLevel(levels);
255
- }
256
-
257
- if (!level) {
258
- denyNoLevelDeclared(prompt.trim());
259
- }
260
-
261
- if (signal && level !== 'HIGH-RISK') {
262
- denyLevelContradictsSignal(level, signal);
102
+ if (
103
+ level !== LEVELS.HIGH_RISK &&
104
+ hasRealSensitiveMutation(prompt, highImpactPattern)
105
+ ) {
106
+ denyLevelContradictsSignal(
107
+ level,
108
+ firstSignalText(prompt, highImpactPattern),
109
+ );
263
110
  }
264
111
  },
265
112
  );