@devrik-tools/claude-gates 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
@@ -1,263 +1,265 @@
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).
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.
32
-
33
- import {
34
- runGate,
35
- deny,
36
- toolInGroups,
37
- delegationPromptOf,
38
- } from '../../lib/hook-io.mjs';
39
- import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
40
-
41
- const GATE_ID = 'risk-level';
42
- const CONFIG_KEY = 'requireDeclaredRiskLevel';
43
-
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) => match[2].toUpperCase());
173
- }
174
-
175
- /** The operative LEVEL: the LAST declaration in the prompt (a delegator who corrects an
176
- * earlier decoy/reference mention writes the real value last). Returns `{ level }` when
177
- * every declaration agrees or there is exactly one; returns `{ ambiguous: true }` when
178
- * two or more DIFFERENT levels are declared the gate cannot know which one governs,
179
- * so it asks for a single unambiguous LEVEL rather than silently picking one. */
180
- function operativeLevel(prompt) {
181
- const levels = declaredLevels(prompt);
182
- if (levels.length === 0) return { level: null };
183
- const distinct = new Set(levels);
184
- if (distinct.size > 1) return { ambiguous: true, levels };
185
- return { level: levels[levels.length - 1] };
186
- }
187
-
188
- function denyNoLevelDeclared(prompt) {
189
- const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
190
- const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
191
- deny(
192
- GATE_ID,
193
- `This implementation delegation ("${excerpt}${ellipsis}") does not declare its risk LEVEL. Add a ` +
194
- 'line such as "LEVEL: STANDARD" (or QUESTION/MICRO/HIGH-RISK, whichever fits) before relaunching ' +
195
- 'this delegation.',
196
- );
197
- }
198
-
199
- function denyAmbiguousLevel(levels) {
200
- deny(
201
- GATE_ID,
202
- `This delegation declares multiple different risk levels (${[...new Set(levels)].join(', ')}) — ` +
203
- 'it is not clear which one governs this task. Declare a single, unambiguous LEVEL for this ' +
204
- 'delegation (remove any decoy/reference mention of a different level) before relaunching.',
205
- );
206
- }
207
-
208
- function denyLevelContradictsSignal(level, signal) {
209
- deny(
210
- GATE_ID,
211
- `This delegation declares LEVEL: ${level}, but the request touches the risk signal "${signal}" near ` +
212
- 'an implementation verb (not quoted, not the topic of a documentary deliverable) — that requires ' +
213
- `LEVEL: HIGH-RISK, not ${level}. Raise the declaration to HIGH-RISK before relaunching.`,
214
- );
215
- }
216
-
217
- function buildHighImpactPattern(highImpactPatterns) {
218
- return new RegExp(
219
- (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
220
- 'iu',
221
- );
222
- }
223
-
224
- runGate(
225
- {
226
- id: GATE_ID,
227
- configKey: CONFIG_KEY,
228
- enabledByDefault: false,
229
- defaultParams: {
230
- highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
231
- readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
232
- },
233
- },
234
- ({ toolName, toolInput, parameters }) => {
235
- if (!toolInGroups(toolName, ['delegation'])) return;
236
-
237
- const prompt = delegationPromptOf(toolInput);
238
- if (!prompt.trim()) return;
239
- if (isReadOnlySubagent(toolInput, prompt, parameters.readOnlySubagents))
240
- return;
241
- if (isExemptQuery(prompt)) return;
242
- if (!isImplementationRequest(prompt)) return;
243
-
244
- const highImpactPattern = buildHighImpactPattern(
245
- parameters.highImpactPatterns,
246
- );
247
-
248
- const { level, ambiguous, levels } = operativeLevel(prompt);
249
- const signal = realHighImpactSignal(prompt, highImpactPattern);
250
-
251
- if (ambiguous) {
252
- denyAmbiguousLevel(levels);
253
- }
254
-
255
- if (!level) {
256
- denyNoLevelDeclared(prompt.trim());
257
- }
258
-
259
- if (signal && level !== 'HIGH-RISK') {
260
- denyLevelContradictsSignal(level, signal);
261
- }
262
- },
263
- );
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).
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.
32
+
33
+ import {
34
+ runGate,
35
+ deny,
36
+ toolInGroups,
37
+ delegationPromptOf,
38
+ } from '../../lib/hook-io.mjs';
39
+ import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
40
+
41
+ const GATE_ID = 'risk-level';
42
+ const CONFIG_KEY = 'requireDeclaredRiskLevel';
43
+
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
+ function denyNoLevelDeclared(prompt) {
191
+ const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
192
+ const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
193
+ deny(
194
+ GATE_ID,
195
+ `This implementation delegation ("${excerpt}${ellipsis}") does not declare its risk LEVEL. Add a ` +
196
+ 'line such as "LEVEL: STANDARD" (or QUESTION/MICRO/HIGH-RISK, whichever fits) before relaunching ' +
197
+ 'this delegation.',
198
+ );
199
+ }
200
+
201
+ function denyAmbiguousLevel(levels) {
202
+ deny(
203
+ GATE_ID,
204
+ `This delegation declares multiple different risk levels (${[...new Set(levels)].join(', ')}) ` +
205
+ 'it is not clear which one governs this task. Declare a single, unambiguous LEVEL for this ' +
206
+ 'delegation (remove any decoy/reference mention of a different level) before relaunching.',
207
+ );
208
+ }
209
+
210
+ function denyLevelContradictsSignal(level, signal) {
211
+ deny(
212
+ GATE_ID,
213
+ `This delegation declares LEVEL: ${level}, but the request touches the risk signal "${signal}" near ` +
214
+ 'an implementation verb (not quoted, not the topic of a documentary deliverable) — that requires ' +
215
+ `LEVEL: HIGH-RISK, not ${level}. Raise the declaration to HIGH-RISK before relaunching.`,
216
+ );
217
+ }
218
+
219
+ function buildHighImpactPattern(highImpactPatterns) {
220
+ return new RegExp(
221
+ (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
222
+ 'iu',
223
+ );
224
+ }
225
+
226
+ runGate(
227
+ {
228
+ id: GATE_ID,
229
+ configKey: CONFIG_KEY,
230
+ enabledByDefault: false,
231
+ defaultParams: {
232
+ highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
233
+ readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
234
+ },
235
+ },
236
+ ({ toolName, toolInput, parameters }) => {
237
+ if (!toolInGroups(toolName, ['delegation'])) return;
238
+
239
+ 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;
245
+
246
+ const highImpactPattern = buildHighImpactPattern(
247
+ parameters.highImpactPatterns,
248
+ );
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);
263
+ }
264
+ },
265
+ );
@@ -1,56 +1,57 @@
1
- import {
2
- runGate,
3
- deny,
4
- toolInGroups,
5
- writtenContentOf,
6
- delegationPromptOf,
7
- } from '../../lib/hook-io.mjs';
8
-
9
- const GATE_ID = 'root-cause-first';
10
- const CONFIG_KEY = 'requireRootCauseBeforePatch';
11
-
12
- const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
13
-
14
- // The text to scan: a delegation's brief, or the content a write puts on disk. writtenContentOf
15
- // covers every native and MCP write shape (Write's content, Edit's new_string, NotebookEdit's
16
- // new_source, replace_file_content's new_content) — the old reader missed new_source, so a
17
- // deferral marker written via NotebookEdit was never caught by this DENY gate.
18
- function textToScan(toolName, toolInput) {
19
- if (toolInGroups(toolName, ['delegation'])) return delegationPromptOf(toolInput);
20
- return writtenContentOf(toolInput);
21
- }
22
-
23
- runGate(
24
- {
25
- id: GATE_ID,
26
- configKey: CONFIG_KEY,
27
- enabledByDefault: false,
28
- defaultParams: {
29
- patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
30
- },
31
- },
32
- ({ toolName, toolInput, parameters }) => {
33
- if (!toolInGroups(toolName, ['write', 'delegation'])) return;
34
-
35
- const content = textToScan(toolName, toolInput);
36
- if (!content) return;
37
-
38
- const patterns = parameters.patchMarkerPatterns.map(
39
- (source) => new RegExp(source, 'i'),
40
- );
41
- const matched = patterns.find((pattern) => pattern.test(content));
42
- if (!matched) return;
43
-
44
- deny(
45
- GATE_ID,
46
- `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
47
- );
48
- },
49
- );
50
-
51
- // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
52
- // used an auxiliary lib/embedded-content-detection.mjs module to tell real
53
- // file content apart from a quoted example inside markdown. That module does
54
- // not exist in this repo, so this gate matches directly against the new
55
- // content/prompt. Distinguishing markdown quotes from real code is future
56
- // work if false positives on quoted examples become a problem.
1
+ import {
2
+ runGate,
3
+ deny,
4
+ toolInGroups,
5
+ writtenContentOf,
6
+ delegationPromptOf,
7
+ } from '../../lib/hook-io.mjs';
8
+
9
+ const GATE_ID = 'root-cause-first';
10
+ const CONFIG_KEY = 'requireRootCauseBeforePatch';
11
+
12
+ const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
13
+
14
+ // The text to scan: a delegation's brief, or the content a write puts on disk. writtenContentOf
15
+ // covers every native and MCP write shape (Write's content, Edit's new_string, NotebookEdit's
16
+ // new_source, replace_file_content's new_content) — the old reader missed new_source, so a
17
+ // deferral marker written via NotebookEdit was never caught by this DENY gate.
18
+ function textToScan(toolName, toolInput) {
19
+ if (toolInGroups(toolName, ['delegation']))
20
+ return delegationPromptOf(toolInput);
21
+ return writtenContentOf(toolInput);
22
+ }
23
+
24
+ runGate(
25
+ {
26
+ id: GATE_ID,
27
+ configKey: CONFIG_KEY,
28
+ enabledByDefault: false,
29
+ defaultParams: {
30
+ patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
31
+ },
32
+ },
33
+ ({ toolName, toolInput, parameters }) => {
34
+ if (!toolInGroups(toolName, ['write', 'delegation'])) return;
35
+
36
+ const content = textToScan(toolName, toolInput);
37
+ if (!content) return;
38
+
39
+ const patterns = parameters.patchMarkerPatterns.map(
40
+ (source) => new RegExp(source, 'i'),
41
+ );
42
+ const matched = patterns.find((pattern) => pattern.test(content));
43
+ if (!matched) return;
44
+
45
+ deny(
46
+ GATE_ID,
47
+ `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
48
+ );
49
+ },
50
+ );
51
+
52
+ // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
53
+ // used an auxiliary lib/embedded-content-detection.mjs module to tell real
54
+ // file content apart from a quoted example inside markdown. That module does
55
+ // not exist in this repo, so this gate matches directly against the new
56
+ // content/prompt. Distinguishing markdown quotes from real code is future
57
+ // work if false positives on quoted examples become a problem.