@devrik-tools/claude-gates 0.1.1 → 0.1.3

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +23 -17
  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/config.mjs +124 -113
  8. package/cli/index.mjs +100 -97
  9. package/cli/init.mjs +259 -256
  10. package/cli/install.mjs +150 -80
  11. package/cli/materialize.mjs +102 -51
  12. package/cli/registry.mjs +136 -127
  13. package/cli/task.mjs +140 -0
  14. package/package.json +74 -73
  15. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  16. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  17. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  18. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  19. package/plugins/gates/hooks/doctor.mjs +207 -0
  20. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  21. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  22. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  23. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  24. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -0
  25. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +58 -0
  26. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  27. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  28. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  29. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  30. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  31. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  32. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  33. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  34. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  37. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  38. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  39. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  40. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  41. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  42. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  43. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  44. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  45. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  46. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  47. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  48. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  49. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  52. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  53. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  54. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  55. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  56. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  57. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  58. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  59. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  60. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  61. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  62. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  63. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  64. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  65. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  68. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  69. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  70. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  71. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  72. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  73. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  74. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  79. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  80. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  81. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  82. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  83. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  84. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  85. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  86. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  87. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  88. package/plugins/gates/hooks/hooks.json +346 -266
  89. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  90. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  91. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  92. package/plugins/gates/hooks/lib/config.mjs +172 -165
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  94. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  95. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  96. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  97. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  98. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  99. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  100. package/plugins/tasks/hooks/hooks.json +26 -0
  101. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  102. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  103. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  104. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  105. package/registry.json +793 -668
@@ -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
+ });