@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,238 +1,260 @@
1
- // intent-flow — denies an implementation delegation that has real intent to mutate a
2
- // high-impact area (money, auth, persisted data, public contract, irreversible...)
3
- // without declaring IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies whenever it
4
- // also leaves a high-impact UNKNOWN unresolved.
5
- //
6
- // ── How this differs from brief-before-delegate ─────────────────────────────────────
7
- // brief-before-delegate asks "was the task thought through" (goal+steps+criterion).
8
- // This gate asks a different, complementary question: "was the SCOPE declared — what
9
- // is in, what is explicitly out, and what edge cases were considered — for work that
10
- // really intends to touch a sensitive area?" Both run on the same delegation prompt
11
- // independently; neither depends on the other's verdict.
12
- //
13
- // ── Two-stage intent check (not lexical match) ──────────────────────────────────────
14
- // Stage 1 (cheap prefilter): does the raw prompt contain BOTH an implementation verb
15
- // and a high-impact signal anywhere? If not, there is no candidate — allow immediately.
16
- // Stage 2 (confirmation, only on candidates): strip quoted/templated text, then require
17
- // near co-occurrence between an implementation verb and a high-impact signal, and
18
- // discard a verb whose immediate object is a documentary deliverable (a doc/report/
19
- // README/.md/.html describing the area is not mutating it). Only surviving (a)+(b)+(c)
20
- // counts as real intent.
21
-
22
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
23
-
24
- const GATE_ID = 'intent-flow';
25
- const CONFIG_KEY = 'requireScopeListBeforeDelegating';
26
-
27
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
28
-
29
- const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
30
- const DEFAULT_HIGH_IMPACT_PATTERNS = [
31
- 'dinero|pago|cobro|money|payment',
32
- 'auth|autenticaci[oó]n|authentication|permiso|permission|token|sesi[oó]n|session|seguridad|security|PII',
33
- 'borrar|delete|drop|migraci[oó]n|migration|schema',
34
- 'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
35
- ];
36
-
37
- const MIN_REQUEST_LENGTH = 40;
38
- const CO_OCCURRENCE_WINDOW = 100;
39
- const VERB_OBJECT_WINDOW = 60;
40
- // How much of an over-length prompt to quote back in a denial message.
41
- const PROMPT_EXCERPT_LENGTH = 80;
42
-
43
- function withUnicodeWordBoundary(alternatives) {
44
- return new RegExp(
45
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
46
- 'iu',
47
- );
48
- }
49
-
50
- const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
51
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
52
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
53
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
54
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
55
- 'implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy',
56
- );
57
-
58
- const QUERY_VERBS = withUnicodeWordBoundary(
59
- 'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
60
- 'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
61
- 'explain|what|how|show|analyze|investigate|review|audit|diagnose',
62
- );
63
-
64
- const IN_SCOPE_PATTERN = withUnicodeWordBoundary(
65
- 'qu[eé] s[ií]|qu[eé] s[ií] entra|alcance|in scope|lo pedido|se va a hacer|incluye',
66
- );
67
- const OUT_OF_SCOPE_PATTERN = withUnicodeWordBoundary(
68
- 'qu[eé] no|fuera de alcance|out of scope|no tocar|no modificar|no debe cambiar|' +
69
- 'must not change|no se debe (cambiar|tocar|modificar)',
70
- );
71
- const EDGE_CASES_PATTERN = withUnicodeWordBoundary(
72
- 'edge cases?|edge-cases?|casos? borde|casos? l[ií]mite|caso l[ií]mite|sin edge cases|' +
73
- 'no (hay|aplican) edge cases|no hay casos borde',
74
- );
75
- const UNRESOLVED_UNKNOWN_PATTERN = withUnicodeWordBoundary(
76
- 'unknown|no se sabe|no sabemos|sin resolver|no est[aá] claro|no est[aá] definido|' +
77
- 'a definir|por definir|desconocido|no est[aá] decidido',
78
- );
79
- const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
80
- 'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
81
- 'p[]gina de documentaci[]n|archivo html|p[]gina html|markdown|document|documentation|report',
82
- );
83
- const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
84
-
85
- function isReadOnlySubagent(toolInput, readOnlySubagents) {
86
- const type = String(
87
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
88
- ).toLowerCase();
89
- return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
90
- }
91
-
92
- function isImplementationRequest(prompt) {
93
- return IMPLEMENTATION_VERBS.test(prompt);
94
- }
95
-
96
- function isExemptQuery(prompt) {
97
- if (isImplementationRequest(prompt)) return false; // implementation verb takes priority
98
- return QUERY_VERBS.test(prompt);
99
- }
100
-
101
- function stripQuoted(text) {
102
- return text
103
- .replace(/```[\s\S]*?```/g, ' ')
104
- .replace(/"[^"\n]{0,300}"/g, ' ')
105
- .replace(/'[^'\n]{0,300}'/g, ' ');
106
- }
107
-
108
- function allMatches(pattern, text) {
109
- const flags = pattern.flags.includes('g')
110
- ? pattern.flags
111
- : `${pattern.flags}g`;
112
- return [...text.matchAll(new RegExp(pattern.source, flags))];
113
- }
114
-
115
- function verbProducesDocument(text, verbMatch) {
116
- const from = verbMatch.index;
117
- const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
118
- const objectOfVerb = text.slice(from, to);
119
- return (
120
- DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
121
- DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
122
- );
123
- }
124
-
125
- function hasRealSensitiveMutation(prompt, highImpactPattern) {
126
- const isCandidate =
127
- IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
128
- if (!isCandidate) return false; // stage 1: cheap, no stage 2 spent
129
-
130
- const text = stripQuoted(prompt); // stage 2(a)
131
- const verbs = allMatches(IMPLEMENTATION_VERBS, text);
132
- const signals = allMatches(highImpactPattern, text);
133
-
134
- for (const verb of verbs) {
135
- if (verbProducesDocument(text, verb)) continue; // stage 2(c)
136
- for (const signal of signals) {
137
- if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
138
- return true; // stage 2(b)
139
- }
140
- }
141
- return false;
142
- }
143
-
144
- function missingSignals(prompt) {
145
- const missing = [];
146
- if (!IN_SCOPE_PATTERN.test(prompt))
147
- missing.push('IN SCOPE (what is requested, stated explicitly)');
148
- if (!OUT_OF_SCOPE_PATTERN.test(prompt))
149
- missing.push('OUT OF SCOPE (what must not be touched)');
150
- if (!EDGE_CASES_PATTERN.test(prompt)) {
151
- missing.push(
152
- 'EDGE CASES (considered, or an explicit mark that none apply)',
153
- );
154
- }
155
- return missing;
156
- }
157
-
158
- function denyUnresolvedUnknown() {
159
- deny(
160
- GATE_ID,
161
- 'This request touches a high-impact signal (money/auth/persisted data/public contract/' +
162
- 'irreversible/production) AND declares an unresolved UNKNOWN. Do not relaunch assuming an ' +
163
- 'answer — ask the user what to decide about that UNKNOWN before delegating again.',
164
- );
165
- }
166
-
167
- function denyTooShortForScopeList(prompt) {
168
- const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
169
- const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
170
- deny(
171
- GATE_ID,
172
- `This implementation request ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
173
- 'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
174
- );
175
- }
176
-
177
- function denyMissingSignals(missing) {
178
- deny(
179
- GATE_ID,
180
- `This implementation request does not declare, in recognizable form: ${missing.join('; ')}. ` +
181
- 'Add what is missing to the prompt before relaunching this delegation.',
182
- );
183
- }
184
-
185
- function buildHighImpactPattern(highImpactPatterns) {
186
- return new RegExp(
187
- (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
188
- 'iu',
189
- );
190
- }
191
-
192
- /** True when this delegation is exempt from the whole check: not a recognizable
193
- * implementation request, a read-only subagent, or an exempt query. */
194
- function isExempt(toolInput, prompt, readOnlySubagents) {
195
- if (!prompt.trim()) return true;
196
- if (isReadOnlySubagent(toolInput, readOnlySubagents)) return true;
197
- if (isExemptQuery(prompt)) return true;
198
- return !isImplementationRequest(prompt);
199
- }
200
-
201
- runGate(
202
- {
203
- id: GATE_ID,
204
- configKey: CONFIG_KEY,
205
- enabledByDefault: false,
206
- defaultParams: {
207
- highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
208
- readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
209
- },
210
- },
211
- ({ toolName, toolInput, parameters }) => {
212
- if (!DELEGATION_TOOLS.has(toolName)) return;
213
-
214
- const prompt = String(
215
- toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
216
- );
217
- if (isExempt(toolInput, prompt, parameters.readOnlySubagents)) return;
218
-
219
- const highImpactPattern = buildHighImpactPattern(
220
- parameters.highImpactPatterns,
221
- );
222
- if (!hasRealSensitiveMutation(prompt, highImpactPattern)) return;
223
-
224
- if (UNRESOLVED_UNKNOWN_PATTERN.test(stripQuoted(prompt))) {
225
- denyUnresolvedUnknown();
226
- }
227
-
228
- const trimmed = prompt.trim();
229
- if (trimmed.length < MIN_REQUEST_LENGTH) {
230
- denyTooShortForScopeList(trimmed);
231
- }
232
-
233
- const missing = missingSignals(prompt);
234
- if (missing.length === 0) return;
235
-
236
- denyMissingSignals(missing);
237
- },
238
- );
1
+ // intent-flow — denies an implementation delegation that has real intent to mutate a
2
+ // high-impact area (money, auth, persisted data, public contract, irreversible...)
3
+ // without declaring IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies whenever it
4
+ // also leaves a high-impact UNKNOWN unresolved.
5
+ //
6
+ // ── How this differs from brief-before-delegate ─────────────────────────────────────
7
+ // brief-before-delegate asks "was the task thought through" (goal+steps+criterion).
8
+ // This gate asks a different, complementary question: "was the SCOPE declared — what
9
+ // is in, what is explicitly out, and what edge cases were considered — for work that
10
+ // really intends to touch a sensitive area?" Both run on the same delegation prompt
11
+ // independently; neither depends on the other's verdict.
12
+ //
13
+ // ── Two-stage intent check (not lexical match) ──────────────────────────────────────
14
+ // Stage 1 (cheap prefilter): does the raw prompt contain BOTH an implementation verb
15
+ // and a high-impact signal anywhere? If not, there is no candidate — allow immediately.
16
+ // Stage 2 (confirmation, only on candidates): strip quoted/templated text, then require
17
+ // near co-occurrence between an implementation verb and a high-impact signal, and
18
+ // discard a verb whose immediate object is a documentary deliverable (a doc/report/
19
+ // README/.md/.html describing the area is not mutating it). Only surviving (a)+(b)+(c)
20
+ // counts as real intent.
21
+ //
22
+ // ── readOnlySubagents is a declared label, not a verified capability ────────────────
23
+ // This hook cannot check what tools a named subagent actually has — the whitelist
24
+ // exemption is void whenever the prompt itself carries a mutation-risk signal (money/
25
+ // auth/data/write/deploy): the signal in the text outranks the label on the call.
26
+
27
+ import {
28
+ runGate,
29
+ deny,
30
+ toolInGroups,
31
+ delegationPromptOf,
32
+ } from '../../lib/hook-io.mjs';
33
+ import { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
34
+
35
+ const GATE_ID = 'intent-flow';
36
+ const CONFIG_KEY = 'requireScopeListBeforeDelegating';
37
+
38
+ const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
39
+ const DEFAULT_HIGH_IMPACT_PATTERNS = [
40
+ 'dinero|pago|cobro|money|payment',
41
+ 'auth|autenticaci[oó]n|authentication|permiso|permission|token|sesi[oó]n|session|seguridad|security|PII',
42
+ 'borrar|delete|drop|migraci[oó]n|migration|schema',
43
+ 'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
44
+ ];
45
+
46
+ const MIN_REQUEST_LENGTH = 40;
47
+ const CO_OCCURRENCE_WINDOW = 100;
48
+ const VERB_OBJECT_WINDOW = 60;
49
+ // How much of an over-length prompt to quote back in a denial message.
50
+ const PROMPT_EXCERPT_LENGTH = 80;
51
+
52
+ function withUnicodeWordBoundary(alternatives) {
53
+ return new RegExp(
54
+ `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
55
+ 'iu',
56
+ );
57
+ }
58
+
59
+ // A prompt-level signal that a whitelisted read-only subagent name should NOT be
60
+ // trusted to exempt this call: the name is a declared label, never a verified
61
+ // capability this hook can check, and real mutation risk in the text must win over it.
62
+ // Deliberately broad — over-including only means this gate's check still runs.
63
+ // Centralized in lib/signals.mjs (ES+EN) — see its header for the class this covers.
64
+ const MUTATION_RISK_SIGNAL_PATTERN = MUTATION_RISK_SIGNAL;
65
+
66
+ const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
67
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
68
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
69
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
70
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
71
+ 'implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy',
72
+ );
73
+
74
+ const QUERY_VERBS = withUnicodeWordBoundary(
75
+ 'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
76
+ 'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
77
+ 'explain|what|how|show|analyze|investigate|review|audit|diagnose',
78
+ );
79
+
80
+ const IN_SCOPE_PATTERN = withUnicodeWordBoundary(
81
+ 'qu[] s[]|qu[eé] s[] entra|alcance|in scope|lo pedido|se va a hacer|incluye',
82
+ );
83
+ const OUT_OF_SCOPE_PATTERN = withUnicodeWordBoundary(
84
+ 'qu[eé] no|fuera de alcance|out of scope|no tocar|no modificar|no debe cambiar|' +
85
+ 'must not change|no se debe (cambiar|tocar|modificar)',
86
+ );
87
+ const EDGE_CASES_PATTERN = withUnicodeWordBoundary(
88
+ 'edge cases?|edge-cases?|casos? borde|casos? l[ií]mite|caso l[ií]mite|sin edge cases|' +
89
+ 'no (hay|aplican) edge cases|no hay casos borde',
90
+ );
91
+ const UNRESOLVED_UNKNOWN_PATTERN = withUnicodeWordBoundary(
92
+ 'unknown|no se sabe|no sabemos|sin resolver|no est[aá] claro|no est[aá] definido|' +
93
+ 'a definir|por definir|desconocido|no est[aá] decidido',
94
+ );
95
+ const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
96
+ 'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
97
+ 'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
98
+ );
99
+ const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
100
+
101
+ function isReadOnlySubagentName(toolInput, readOnlySubagents) {
102
+ const type = String(
103
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
104
+ ).toLowerCase();
105
+ return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
106
+ }
107
+
108
+ /** A whitelisted subagent name exempts a call ONLY when the prompt carries no
109
+ * mutation-risk signal. The name is a declared label, never a verified capability this
110
+ * hook can check — a real risk signal in the text must win over it. */
111
+ function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
112
+ if (!isReadOnlySubagentName(toolInput, readOnlySubagents)) return false;
113
+ return !MUTATION_RISK_SIGNAL_PATTERN.test(prompt);
114
+ }
115
+
116
+ function isImplementationRequest(prompt) {
117
+ return IMPLEMENTATION_VERBS.test(prompt);
118
+ }
119
+
120
+ function isExemptQuery(prompt) {
121
+ if (isImplementationRequest(prompt)) return false; // implementation verb takes priority
122
+ return QUERY_VERBS.test(prompt);
123
+ }
124
+
125
+ function stripQuoted(text) {
126
+ return text
127
+ .replace(/```[\s\S]*?```/g, ' ')
128
+ .replace(/"[^"\n]{0,300}"/g, ' ')
129
+ .replace(/'[^'\n]{0,300}'/g, ' ');
130
+ }
131
+
132
+ function allMatches(pattern, text) {
133
+ const flags = pattern.flags.includes('g')
134
+ ? pattern.flags
135
+ : `${pattern.flags}g`;
136
+ return [...text.matchAll(new RegExp(pattern.source, flags))];
137
+ }
138
+
139
+ function verbProducesDocument(text, verbMatch) {
140
+ const from = verbMatch.index;
141
+ const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
142
+ const objectOfVerb = text.slice(from, to);
143
+ return (
144
+ DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
145
+ DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
146
+ );
147
+ }
148
+
149
+ function hasRealSensitiveMutation(prompt, highImpactPattern) {
150
+ const isCandidate =
151
+ IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
152
+ if (!isCandidate) return false; // stage 1: cheap, no stage 2 spent
153
+
154
+ const text = stripQuoted(prompt); // stage 2(a)
155
+ const verbs = allMatches(IMPLEMENTATION_VERBS, text);
156
+ const signals = allMatches(highImpactPattern, text);
157
+
158
+ for (const verb of verbs) {
159
+ if (verbProducesDocument(text, verb)) continue; // stage 2(c)
160
+ for (const signal of signals) {
161
+ if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
162
+ return true; // stage 2(b)
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function missingSignals(prompt) {
169
+ const missing = [];
170
+ if (!IN_SCOPE_PATTERN.test(prompt))
171
+ missing.push('IN SCOPE (what is requested, stated explicitly)');
172
+ if (!OUT_OF_SCOPE_PATTERN.test(prompt))
173
+ missing.push('OUT OF SCOPE (what must not be touched)');
174
+ if (!EDGE_CASES_PATTERN.test(prompt)) {
175
+ missing.push(
176
+ 'EDGE CASES (considered, or an explicit mark that none apply)',
177
+ );
178
+ }
179
+ return missing;
180
+ }
181
+
182
+ function denyUnresolvedUnknown() {
183
+ deny(
184
+ GATE_ID,
185
+ 'This request touches a high-impact signal (money/auth/persisted data/public contract/' +
186
+ 'irreversible/production) AND declares an unresolved UNKNOWN. Do not relaunch assuming an ' +
187
+ 'answer ask the user what to decide about that UNKNOWN before delegating again.',
188
+ );
189
+ }
190
+
191
+ function denyTooShortForScopeList(prompt) {
192
+ const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
193
+ const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
194
+ deny(
195
+ GATE_ID,
196
+ `This implementation request ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
197
+ 'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
198
+ );
199
+ }
200
+
201
+ function denyMissingSignals(missing) {
202
+ deny(
203
+ GATE_ID,
204
+ `This implementation request does not declare, in recognizable form: ${missing.join('; ')}. ` +
205
+ 'Add what is missing to the prompt before relaunching this delegation.',
206
+ );
207
+ }
208
+
209
+ function buildHighImpactPattern(highImpactPatterns) {
210
+ return new RegExp(
211
+ (highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
212
+ 'iu',
213
+ );
214
+ }
215
+
216
+ /** True when this delegation is exempt from the whole check: not a recognizable
217
+ * implementation request, a read-only subagent, or an exempt query. */
218
+ function isExempt(toolInput, prompt, readOnlySubagents) {
219
+ if (!prompt.trim()) return true;
220
+ if (isReadOnlySubagent(toolInput, prompt, readOnlySubagents)) return true;
221
+ if (isExemptQuery(prompt)) return true;
222
+ return !isImplementationRequest(prompt);
223
+ }
224
+
225
+ runGate(
226
+ {
227
+ id: GATE_ID,
228
+ configKey: CONFIG_KEY,
229
+ enabledByDefault: false,
230
+ defaultParams: {
231
+ highImpactPatterns: DEFAULT_HIGH_IMPACT_PATTERNS,
232
+ readOnlySubagents: DEFAULT_READ_ONLY_SUBAGENTS,
233
+ },
234
+ },
235
+ ({ toolName, toolInput, parameters }) => {
236
+ if (!toolInGroups(toolName, ['delegation'])) return;
237
+
238
+ const prompt = delegationPromptOf(toolInput);
239
+ if (isExempt(toolInput, prompt, parameters.readOnlySubagents)) return;
240
+
241
+ const highImpactPattern = buildHighImpactPattern(
242
+ parameters.highImpactPatterns,
243
+ );
244
+ if (!hasRealSensitiveMutation(prompt, highImpactPattern)) return;
245
+
246
+ if (UNRESOLVED_UNKNOWN_PATTERN.test(stripQuoted(prompt))) {
247
+ denyUnresolvedUnknown();
248
+ }
249
+
250
+ const trimmed = prompt.trim();
251
+ if (trimmed.length < MIN_REQUEST_LENGTH) {
252
+ denyTooShortForScopeList(trimmed);
253
+ }
254
+
255
+ const missing = missingSignals(prompt);
256
+ if (missing.length === 0) return;
257
+
258
+ denyMissingSignals(missing);
259
+ },
260
+ );
@@ -0,0 +1,96 @@
1
+ // Edge-case audit for intent-flow. Each test demonstrates a confirmed BUG or an OK.
2
+ // Run: node --test intent-flow.edge.test.mjs
3
+ import assert from 'node:assert/strict';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { test } from 'node:test';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
12
+
13
+ function runGate(payload, { config } = {}) {
14
+ const project = mkdtempSync(join(tmpdir(), 'intent-flow-edge-'));
15
+ mkdirSync(join(project, '.git'));
16
+ if (config) {
17
+ mkdirSync(join(project, '.ai'));
18
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
19
+ }
20
+ const out = execFileSync(process.execPath, [GATE], {
21
+ input: JSON.stringify(payload),
22
+ encoding: 'utf8',
23
+ cwd: project,
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function isDeny(result) {
30
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
31
+ }
32
+
33
+ const ENABLED = { config: { gates: { requireScopeListBeforeDelegating: true } } };
34
+
35
+ const SENSITIVE_NO_SCOPE =
36
+ 'Implementa el cobro del pago con la nueva pasarela de dinero para el checkout.';
37
+
38
+ test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
39
+ const result = runGate(
40
+ { tool_name: 'mcp__orchestrator__spawn_agent', tool_input: { prompt: SENSITIVE_NO_SCOPE } },
41
+ ENABLED,
42
+ );
43
+ assert.ok(isDeny(result), 'gate now denies the sensitive-no-scope prompt under the MCP tool name too');
44
+ });
45
+
46
+ test('FIXED: prompt carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
47
+ const result = runGate(
48
+ { tool_name: 'Agent', tool_input: { instructions: SENSITIVE_NO_SCOPE } },
49
+ ENABLED,
50
+ );
51
+ assert.ok(isDeny(result), 'gate now sees the prompt under "instructions" and denies it for missing scope list');
52
+ });
53
+
54
+ test('FIXED: a mutation-risk signal in the prompt overrides a whitelisted read-only subagent name', () => {
55
+ // isReadOnlySubagent now voids the name-based exemption whenever the prompt itself
56
+ // carries a mutation-risk signal (money/auth/data/write/deploy): a subagent named
57
+ // "explore" cannot exempt a real money-mutation delegation just by using that label.
58
+ const result = runGate(
59
+ { tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_SCOPE, subagent_type: 'explore' } },
60
+ ENABLED,
61
+ );
62
+ assert.ok(
63
+ isDeny(result),
64
+ 'gate no longer exempts a real money-mutation delegation solely because subagent_type says "explore"',
65
+ );
66
+ });
67
+
68
+ test('OK: a whitelisted read-only subagent name with no mutation-risk signal is still exempt', () => {
69
+ assert.equal(
70
+ runGate(
71
+ {
72
+ tool_name: 'Agent',
73
+ tool_input: {
74
+ prompt: 'Arregla el boton que no cambia de color al pasar el mouse.',
75
+ subagent_type: 'explore',
76
+ },
77
+ },
78
+ ENABLED,
79
+ ),
80
+ null,
81
+ );
82
+ });
83
+
84
+ test('OK: same sensitive prompt without a read-only subagent name is denied', () => {
85
+ const result = runGate({ tool_name: 'Agent', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED);
86
+ assert.ok(isDeny(result));
87
+ });
88
+
89
+ test('OK: Task and invoke_subagent tool names are both covered', () => {
90
+ assert.ok(isDeny(runGate({ tool_name: 'Task', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED)));
91
+ assert.ok(
92
+ isDeny(
93
+ runGate({ tool_name: 'invoke_subagent', tool_input: { prompt: SENSITIVE_NO_SCOPE } }, ENABLED),
94
+ ),
95
+ );
96
+ });