@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,166 +1,180 @@
1
- // mandatory-flow — denies an implementation delegation when there is no live task
2
- // pointer on disk backed by a contract file. Declaring pipeline stages in prose (what
3
- // implementation-pipeline.mjs checks) is not the same as a live task actually existing
4
- // on disk; this gate checks the disk fact, not the prompt's wording. Migrated from
5
- // ~/.claude/hooks/guard-flujo-obligatorio.mjs.
6
- //
7
- // ── What a project can configure (params) ───────────────────────────────────────────
8
- // activePointerPath path (relative to the project root) to the file naming the
9
- // active task/pipeline slug.
10
- // exemptSubagents subagent types exempt outright (read-only pipeline stages).
11
- // Replaces the built-in list wholesale.
12
- // The defaults live here, in the source, so a project reads them and knows exactly what
13
- // its override replaces.
14
- //
15
- // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
16
- // Only STANDARD/HIGH-RISK implementation delegations, on non-exempt subagents, not
17
- // about the harness itself, reach the disk check.
18
-
19
- import { existsSync, readFileSync } from 'node:fs';
20
- import { join } from 'node:path';
21
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
22
-
23
- const GATE_ID = 'mandatory-flow';
24
- const CONFIG_KEY = 'requireLiveTaskWhenImplementing';
25
-
26
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
27
-
28
- const DEFAULT_ACTIVE_POINTER_PATH = join('.ai', 'pipeline', 'ACTIVA');
29
-
30
- const DEFAULT_EXEMPT_SUBAGENTS = [
31
- 'explore',
32
- 'plan',
33
- 'scout',
34
- 'revision',
35
- 'contraste',
36
- 'test-planner',
37
- 'qa',
38
- 'ui',
39
- 'ux',
40
- ];
41
-
42
- /** Contract files that count as evidence a task's contract exists on disk. */
43
- const TASK_CONTRACT_FILES = [
44
- 'asserts.md',
45
- 'task.json',
46
- 'spec.md',
47
- 'requirements.md',
48
- 'acceptance.md',
49
- ];
50
-
51
- function withWordBoundary(alternation) {
52
- return new RegExp(
53
- `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
54
- 'iu',
55
- );
56
- }
57
-
58
- const IMPLEMENTATION_VERBS = withWordBoundary(
59
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
60
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
61
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
62
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
63
- 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
64
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
65
- );
66
-
67
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
68
- * the plugin-wide convention (see risk-level.mjs). */
69
- const DEMANDING_LEVEL_PATTERN =
70
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
71
- const EXEMPT_LEVEL_PATTERN =
72
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
73
-
74
- const HARNESS_PATTERN =
75
- /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
76
-
77
- function isExempt(toolInput, prompt, exemptSubagents) {
78
- const subagentType = String(
79
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
80
- ).toLowerCase();
81
- if (exemptSubagents.includes(subagentType)) return true;
82
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
83
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
84
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
85
- if (HARNESS_PATTERN.test(prompt)) return true;
86
- return false;
87
- }
88
-
89
- function hasTaskContract(taskDirectory) {
90
- return TASK_CONTRACT_FILES.some((file) => {
91
- try {
92
- return existsSync(join(taskDirectory, file));
93
- } catch {
94
- return false;
95
- }
96
- });
97
- }
98
-
99
- /** The pointer file's trimmed content, or '' when it cannot be read. */
100
- function readSlug(pointerPath) {
101
- try {
102
- return readFileSync(pointerPath, 'utf8').trim();
103
- } catch {
104
- return '';
105
- }
106
- }
107
-
108
- /** Denies for whichever of the three live-task facts is missing, or does nothing. */
109
- function checkLiveTask(pointerPath) {
110
- if (!existsSync(pointerPath)) {
111
- deny(
112
- GATE_ID,
113
- `This delegation is going to implement, but there is no live task: ${pointerPath} ` +
114
- 'does not exist. Start a task pointing to it before delegating, or declare ' +
115
- 'LEVEL: QUESTION/MICRO if this is not implementation.',
116
- );
117
- }
118
-
119
- const slug = readSlug(pointerPath);
120
- if (!slug) {
121
- deny(
122
- GATE_ID,
123
- `${pointerPath} exists but is empty. An active-task pointer with no slug is not ` +
124
- 'a live task. Write the task slug into it before delegating.',
125
- );
126
- }
127
-
128
- const taskDirectory = join(process.cwd(), '.ai', 'pipeline', slug);
129
- if (!hasTaskContract(taskDirectory)) {
130
- deny(
131
- GATE_ID,
132
- `The active task '${slug}' has no contract on disk: none of ` +
133
- `${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
134
- 'Write the contract before implementing.',
135
- );
136
- }
137
- }
138
-
139
- runGate(
140
- {
141
- id: GATE_ID,
142
- configKey: CONFIG_KEY,
143
- enabledByDefault: false,
144
- defaultParams: {
145
- activePointerPath: DEFAULT_ACTIVE_POINTER_PATH,
146
- exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
147
- },
148
- },
149
- ({ toolName, toolInput, parameters }) => {
150
- if (!DELEGATION_TOOLS.has(toolName)) return;
151
-
152
- const prompt = String(
153
- toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
154
- );
155
- if (!prompt.trim()) return;
156
-
157
- const exemptSubagents =
158
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
159
- if (isExempt(toolInput, prompt, exemptSubagents)) return;
160
-
161
- const activePointerPath = String(
162
- parameters.activePointerPath ?? DEFAULT_ACTIVE_POINTER_PATH,
163
- );
164
- checkLiveTask(join(process.cwd(), activePointerPath));
165
- },
166
- );
1
+ // mandatory-flow — denies an implementation delegation when there is no live task
2
+ // pointer on disk backed by a contract file. Declaring pipeline stages in prose (what
3
+ // implementation-pipeline.mjs checks) is not the same as a live task actually existing
4
+ // on disk; this gate checks the disk fact, not the prompt's wording. Migrated from
5
+ // ~/.claude/hooks/guard-flujo-obligatorio.mjs.
6
+ //
7
+ // ── What a project can configure (params) ───────────────────────────────────────────
8
+ // activePointerPath path (relative to the project root) to the file naming the
9
+ // active task/pipeline slug.
10
+ // exemptSubagents subagent types exempt outright (read-only pipeline stages).
11
+ // Replaces the built-in list wholesale.
12
+ // The defaults live here, in the source, so a project reads them and knows exactly what
13
+ // its override replaces.
14
+ //
15
+ // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
16
+ // Only STANDARD/HIGH-RISK implementation delegations, on non-exempt subagents, not
17
+ // about the harness itself, reach the disk check.
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join, normalize, sep } from 'node:path';
21
+ import {
22
+ runGate,
23
+ deny,
24
+ toolInGroups,
25
+ delegationPromptOf,
26
+ } from '../../lib/hook-io.mjs';
27
+
28
+ const GATE_ID = 'mandatory-flow';
29
+ const CONFIG_KEY = 'requireLiveTaskWhenImplementing';
30
+
31
+ const DEFAULT_ACTIVE_POINTER_PATH = join('.ai', 'pipeline', 'ACTIVA');
32
+
33
+ const DEFAULT_EXEMPT_SUBAGENTS = [
34
+ 'explore',
35
+ 'plan',
36
+ 'scout',
37
+ 'revision',
38
+ 'contraste',
39
+ 'test-planner',
40
+ 'qa',
41
+ 'ui',
42
+ 'ux',
43
+ ];
44
+
45
+ /** Contract files that count as evidence a task's contract exists on disk. */
46
+ const TASK_CONTRACT_FILES = [
47
+ 'asserts.md',
48
+ 'task.json',
49
+ 'spec.md',
50
+ 'requirements.md',
51
+ 'acceptance.md',
52
+ ];
53
+
54
+ function withWordBoundary(alternation) {
55
+ return new RegExp(
56
+ `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
57
+ 'iu',
58
+ );
59
+ }
60
+
61
+ const IMPLEMENTATION_VERBS = withWordBoundary(
62
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
63
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
64
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
65
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
66
+ 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
67
+ 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
68
+ );
69
+
70
+ /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
71
+ * the plugin-wide convention (see risk-level.mjs). */
72
+ const DEMANDING_LEVEL_PATTERN =
73
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
74
+ const EXEMPT_LEVEL_PATTERN =
75
+ /(nivel|level|clasificaci[]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
76
+
77
+ const HARNESS_PATTERN =
78
+ /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
79
+
80
+ function isExempt(toolInput, prompt, exemptSubagents) {
81
+ const subagentType = String(
82
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
83
+ ).toLowerCase();
84
+ if (exemptSubagents.includes(subagentType)) return true;
85
+ if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
86
+ if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
87
+ if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
88
+ if (HARNESS_PATTERN.test(prompt)) return true;
89
+ return false;
90
+ }
91
+
92
+ function hasTaskContract(taskDirectory) {
93
+ return TASK_CONTRACT_FILES.some((file) => {
94
+ try {
95
+ return existsSync(join(taskDirectory, file));
96
+ } catch {
97
+ return false;
98
+ }
99
+ });
100
+ }
101
+
102
+ /** The pointer file's trimmed content, or '' when it cannot be read or the slug
103
+ * attempts path traversal. A slug is a directory name, not a path: rejecting any
104
+ * segment separator or '..' closes off `join(cwd, '.ai', 'pipeline', slug)` escaping
105
+ * that directory to accept an unrelated file elsewhere on disk as the task contract. */
106
+ function readSlug(pointerPath) {
107
+ let raw;
108
+ try {
109
+ raw = readFileSync(pointerPath, 'utf8').trim();
110
+ } catch {
111
+ return '';
112
+ }
113
+ if (!raw) return '';
114
+ const normalized = normalize(raw);
115
+ const hasTraversal = normalized
116
+ .split(/[\\/]/)
117
+ .some((segment) => segment === '..' || segment === '.');
118
+ if (hasTraversal || normalized.includes(sep) || normalized.includes('/')) {
119
+ return '';
120
+ }
121
+ return normalized;
122
+ }
123
+
124
+ /** Denies for whichever of the three live-task facts is missing, or does nothing. */
125
+ function checkLiveTask(pointerPath) {
126
+ if (!existsSync(pointerPath)) {
127
+ deny(
128
+ GATE_ID,
129
+ `This delegation is going to implement, but there is no live task: ${pointerPath} ` +
130
+ 'does not exist. Start a task pointing to it before delegating, or declare ' +
131
+ 'LEVEL: QUESTION/MICRO if this is not implementation.',
132
+ );
133
+ }
134
+
135
+ const slug = readSlug(pointerPath);
136
+ if (!slug) {
137
+ deny(
138
+ GATE_ID,
139
+ `${pointerPath} exists but is empty. An active-task pointer with no slug is not ` +
140
+ 'a live task. Write the task slug into it before delegating.',
141
+ );
142
+ }
143
+
144
+ const taskDirectory = join(process.cwd(), '.ai', 'pipeline', slug);
145
+ if (!hasTaskContract(taskDirectory)) {
146
+ deny(
147
+ GATE_ID,
148
+ `The active task '${slug}' has no contract on disk: none of ` +
149
+ `${TASK_CONTRACT_FILES.join(', ')} exists under ${taskDirectory}. ` +
150
+ 'Write the contract before implementing.',
151
+ );
152
+ }
153
+ }
154
+
155
+ runGate(
156
+ {
157
+ id: GATE_ID,
158
+ configKey: CONFIG_KEY,
159
+ enabledByDefault: false,
160
+ defaultParams: {
161
+ activePointerPath: DEFAULT_ACTIVE_POINTER_PATH,
162
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
163
+ },
164
+ },
165
+ ({ toolName, toolInput, parameters }) => {
166
+ if (!toolInGroups(toolName, ['delegation'])) return;
167
+
168
+ const prompt = delegationPromptOf(toolInput);
169
+ if (!prompt.trim()) return;
170
+
171
+ const exemptSubagents =
172
+ parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
173
+ if (isExempt(toolInput, prompt, exemptSubagents)) return;
174
+
175
+ const activePointerPath = String(
176
+ parameters.activePointerPath ?? DEFAULT_ACTIVE_POINTER_PATH,
177
+ );
178
+ checkLiveTask(join(process.cwd(), activePointerPath));
179
+ },
180
+ );
@@ -1,85 +1,79 @@
1
- import { runGate, warn } from '../../lib/hook-io.mjs';
2
-
3
- const GATE_ID = 'neutral-spanish';
4
- const CONFIG_KEY = 'warnNonNeutralSpanish';
5
-
6
- const MAX_REPORTED_MARKERS = 8;
7
-
8
- // Data strings, not identifiers — never add these to a cSpell dictionary.
9
- const DEFAULT_REGIONAL_MARKERS = [
10
- 'tenés',
11
- 'podés',
12
- 'querés',
13
- 'sabés',
14
- 'hacés',
15
- 'decís',
16
- 'venís',
17
- 'sos',
18
- 'fijate',
19
- 'mirá',
20
- 'pará',
21
- 'esperá',
22
- 'dale',
23
- 'mandale',
24
- 'contame',
25
- 'fíjate vos',
26
- 'acá',
27
- 'allá',
28
- 'laburo',
29
- 'laburar',
30
- 'quilombo',
31
- 'posta',
32
- 'che',
33
- 'boludo',
34
- 'pibe',
35
- 'guita',
36
- 'al pedo',
37
- 'un toque',
38
- 'capaz que',
39
- 'de una',
40
- ];
41
-
42
- function extractText(toolName, toolInput) {
43
- if (toolName === 'Write') return String(toolInput?.content ?? '');
44
- if (toolName === 'Edit') return String(toolInput?.new_string ?? '');
45
- if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
46
- return toolInput.edits
47
- .map((edit) => String(edit?.new_string ?? ''))
48
- .join('\n');
49
- }
50
- return '';
51
- }
52
-
53
- runGate(
54
- {
55
- id: GATE_ID,
56
- configKey: CONFIG_KEY,
57
- enabledByDefault: true,
58
- defaultParams: {
59
- regionalMarkers: DEFAULT_REGIONAL_MARKERS,
60
- },
61
- },
62
- ({ toolName, toolInput, parameters }) => {
63
- const text = extractText(toolName, toolInput).toLowerCase();
64
- if (!text) return;
65
-
66
- const hits = [];
67
- for (const marker of parameters.regionalMarkers) {
68
- const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
- // \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
70
- // accented-character set, so accented word boundaries are handled correctly without
71
- // spelling out literal accented characters in the source (the project keeps its
72
- // spell-check dictionary untouched, so no word list may lean on this being a string).
73
- const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
74
- if (pattern.test(text)) hits.push(marker);
75
- }
76
-
77
- if (hits.length === 0) return;
78
-
79
- const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
80
- warn(
81
- GATE_ID,
82
- `Text being written contains regional Spanish markers: ${unique.join(', ')}. Prefer neutral Spanish unless this is a literal quote or data.`,
83
- );
84
- },
85
- );
1
+ import { runGate, warn, toolInGroups, writtenContentOf } from '../../lib/hook-io.mjs';
2
+
3
+ const GATE_ID = 'neutral-spanish';
4
+ const CONFIG_KEY = 'warnNonNeutralSpanish';
5
+
6
+ const MAX_REPORTED_MARKERS = 8;
7
+
8
+ // Data strings, not identifiers — never add these to a cSpell dictionary.
9
+ const DEFAULT_REGIONAL_MARKERS = [
10
+ 'tenés',
11
+ 'podés',
12
+ 'querés',
13
+ 'sabés',
14
+ 'hacés',
15
+ 'decís',
16
+ 'venís',
17
+ 'sos',
18
+ 'fijate',
19
+ 'mirá',
20
+ 'pará',
21
+ 'esperá',
22
+ 'dale',
23
+ 'mandale',
24
+ 'contame',
25
+ 'fíjate vos',
26
+ 'acá',
27
+ 'allá',
28
+ 'laburo',
29
+ 'laburar',
30
+ 'quilombo',
31
+ 'posta',
32
+ 'che',
33
+ 'boludo',
34
+ 'pibe',
35
+ 'guita',
36
+ 'al pedo',
37
+ 'un toque',
38
+ 'capaz que',
39
+ 'de una',
40
+ ];
41
+
42
+ function extractText(toolName, toolInput) {
43
+ if (!toolInGroups(toolName, ['write'])) return '';
44
+ return writtenContentOf(toolInput);
45
+ }
46
+
47
+ runGate(
48
+ {
49
+ id: GATE_ID,
50
+ configKey: CONFIG_KEY,
51
+ enabledByDefault: true,
52
+ defaultParams: {
53
+ regionalMarkers: DEFAULT_REGIONAL_MARKERS,
54
+ },
55
+ },
56
+ ({ toolName, toolInput, parameters }) => {
57
+ const text = extractText(toolName, toolInput).toLowerCase();
58
+ if (!text) return;
59
+
60
+ const hits = [];
61
+ for (const marker of parameters.regionalMarkers) {
62
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
63
+ // \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
64
+ // accented-character set, so accented word boundaries are handled correctly without
65
+ // spelling out literal accented characters in the source (the project keeps its
66
+ // spell-check dictionary untouched, so no word list may lean on this being a string).
67
+ const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
68
+ if (pattern.test(text)) hits.push(marker);
69
+ }
70
+
71
+ if (hits.length === 0) return;
72
+
73
+ const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
74
+ warn(
75
+ GATE_ID,
76
+ `Text being written contains regional Spanish markers: ${unique.join(', ')}. Prefer neutral Spanish unless this is a literal quote or data.`,
77
+ );
78
+ },
79
+ );
@@ -0,0 +1,80 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'neutral-spanish-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ env: { ...process.env, HOME: project, USERPROFILE: project },
23
+ });
24
+ return out.trim() ? JSON.parse(out.trim()) : null;
25
+ }
26
+
27
+ function isWarn(result) {
28
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
29
+ }
30
+
31
+ // FIXED: extractText() now gates on toolInGroups(toolName, ['write']) and reads through
32
+ // writtenContentOf() (lib/hook-io.mjs), which covers NotebookEdit's new_source field.
33
+ test('FIXED: NotebookEdit carrying regional Spanish is detected', () => {
34
+ const payload = {
35
+ tool_name: 'NotebookEdit',
36
+ tool_input: {
37
+ notebook_path: '/repo/notebook.ipynb',
38
+ cell_id: 'abc',
39
+ new_source: 'vos tenés que revisar esto, dale',
40
+ cell_type: 'markdown',
41
+ },
42
+ };
43
+ assert.ok(isWarn(runGate(payload)), 'gate now detects regional Spanish written via NotebookEdit');
44
+ });
45
+
46
+ // FIXED: write_to_file is recognized by toolInGroups(toolName, ['write']) (native name in
47
+ // TOOL_GROUPS.write) and its `content` field is read through writtenContentOf().
48
+ test('FIXED: write_to_file with regional Spanish content is detected', () => {
49
+ const payload = {
50
+ tool_name: 'write_to_file',
51
+ tool_input: {
52
+ file_path: '/repo/notes.md',
53
+ content: 'che, mirá el laburo que hicimos acá',
54
+ },
55
+ };
56
+ assert.ok(isWarn(runGate(payload)), 'gate now detects regional Spanish written via write_to_file');
57
+ });
58
+
59
+ // EDGE CASE 3: false-positive check on legitimate neutral words that are substrings-adjacent
60
+ // to markers. "sos" (voseo for "you are") is in the marker list; verify a word that merely
61
+ // CONTAINS "sos" as a substring (e.g. "sospecha", "hermosos") is NOT flagged, since the
62
+ // pattern uses \p{L} boundaries.
63
+ test('OK: word boundary prevents false positive on words containing a marker as substring', () => {
64
+ const result = runGate({
65
+ tool_name: 'Write',
66
+ tool_input: { file_path: '/repo/notes.md', content: 'Tengo una sospecha sobre estos hermosos resultados.' },
67
+ });
68
+ assert.equal(result, null, 'false positive: "sos" matched inside sospecha/hermosos');
69
+ });
70
+
71
+ // EDGE CASE 4: "dale" is in the marker list as Rioplatense slang, but is this over-broad?
72
+ // "dale" only appears as that exact word — check it correctly fires only for that token,
73
+ // not, e.g., as part of "dalear" or similar. This confirms no over-matching regression.
74
+ test('OK: marker "dale" does not fire on a word merely containing it', () => {
75
+ const result = runGate({
76
+ tool_name: 'Write',
77
+ tool_input: { file_path: '/repo/notes.md', content: 'Actualiza el dataleer o el dalext.' },
78
+ });
79
+ assert.equal(result, null, 'false positive: "dale" matched inside a longer word');
80
+ });