@devrik-tools/claude-gates 0.2.0 → 0.3.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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
  10. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  11. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  12. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  13. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  14. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  15. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  16. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
  17. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  18. package/plugins/gates/hooks/hooks.json +365 -346
  19. package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
  20. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  21. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  22. package/registry.json +738 -793
  23. package/cli/__tests__/config.test.mjs +0 -113
  24. package/cli/__tests__/init-flags.test.mjs +0 -111
  25. package/cli/__tests__/install.test.mjs +0 -102
  26. package/cli/__tests__/materialize.test.mjs +0 -95
  27. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  28. package/cli/__tests__/selection.test.mjs +0 -200
  29. package/cli/__tests__/task.test.mjs +0 -124
  30. package/cli/__tests__/version-consistency.test.mjs +0 -44
  31. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  32. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  33. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  34. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  35. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  36. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  37. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  38. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  39. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  40. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  41. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  42. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  43. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  44. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  45. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  46. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  47. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  48. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  49. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  50. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  51. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  52. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  53. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  54. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  55. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  56. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  57. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  58. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  59. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  60. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  61. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  62. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  63. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  64. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  65. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  66. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  67. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  68. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  69. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  70. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  71. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  72. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  73. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  74. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  75. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  76. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  77. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  78. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  79. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  80. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
  81. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  82. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  83. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  84. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  85. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  86. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  87. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  88. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  89. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  90. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  91. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  92. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  93. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  94. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
  95. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  96. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  99. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,126 +1,130 @@
1
- import { readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import {
4
- runGate,
5
- warn,
6
- toolInGroups,
7
- writtenPathOf,
8
- writtenContentOf,
9
- } from '../../lib/hook-io.mjs';
10
-
11
- const GATE_ID = 'dependency-skills';
12
- const CONFIG_KEY = 'requireSkillForNewDependency';
13
-
14
- const DEFAULT_DEPS_WITHOUT_OWN_API = [
15
- 'clsx',
16
- 'classnames',
17
- 'lodash.debounce',
18
- 'lodash.throttle',
19
- 'lodash.merge',
20
- 'tslib',
21
- 'core-js',
22
- 'regenerator-runtime',
23
- ];
24
-
25
- function normalize(name) {
26
- return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
27
- }
28
-
29
- function isExempt(dependencyName, dependenciesWithoutOwnApi) {
30
- if (dependencyName.startsWith('@types/')) return true;
31
- return dependenciesWithoutOwnApi.includes(dependencyName);
32
- }
33
-
34
- function listSkillDirectories(skillsDirectoryPath) {
35
- try {
36
- return readdirSync(skillsDirectoryPath, { withFileTypes: true })
37
- .filter((entry) => entry.isDirectory())
38
- .map((entry) => entry.name);
39
- } catch {
40
- // No skills directory in this project — nothing to cross-check against.
41
- return [];
42
- }
43
- }
44
-
45
- // A skill covers a dependency when its normalized name equals the dependency's, or contains
46
- // it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
47
- // bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
48
- // exemption that silenced the warning for an unreviewed dependency.
49
- function skillNameMatches(normalizedDependency, skillDirectoryName) {
50
- const normalizedSkill = normalize(skillDirectoryName);
51
- if (normalizedSkill === normalizedDependency) return true;
52
- return normalizedSkill.split('-').includes(normalizedDependency);
53
- }
54
-
55
- function hasMatchingSkill(dependencyName, skillDirectories) {
56
- const normalizedDependency = normalize(dependencyName);
57
- return skillDirectories.some((skillDirectoryName) =>
58
- skillNameMatches(normalizedDependency, skillDirectoryName),
59
- );
60
- }
61
-
62
- function needsSkillReview(dependencyName, parameters, skillDirectories) {
63
- if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
64
- return !hasMatchingSkill(dependencyName, skillDirectories);
65
- }
66
-
67
- /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
68
- function dependencyNamesIn(content) {
69
- let parsed;
70
- try {
71
- parsed = JSON.parse(content);
72
- } catch {
73
- // Mid-edit or partially written package.json — nothing reliable to check yet.
74
- return null;
75
- }
76
- const dependencies = {
77
- ...(parsed.dependencies ?? {}),
78
- ...(parsed.devDependencies ?? {}),
79
- };
80
- return Object.keys(dependencies);
81
- }
82
-
83
- runGate(
84
- {
85
- id: GATE_ID,
86
- configKey: CONFIG_KEY,
87
- enabledByDefault: true,
88
- defaultParams: {
89
- depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
90
- projectSkillsDir: '.claude/skills',
91
- },
92
- },
93
- ({ toolName, toolInput, parameters }) => {
94
- if (!toolInGroups(toolName, ['write'])) return;
95
-
96
- const filePath = writtenPathOf(toolInput);
97
- if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
98
-
99
- const content = writtenContentOf(toolInput);
100
- if (!content) return;
101
-
102
- const dependencyNames = dependencyNamesIn(content);
103
- if (!dependencyNames || dependencyNames.length === 0) return;
104
-
105
- const skillDirectories = listSkillDirectories(
106
- join(process.cwd(), parameters.projectSkillsDir),
107
- );
108
-
109
- const unmatched = dependencyNames.filter((name) =>
110
- needsSkillReview(name, parameters, skillDirectories),
111
- );
112
- if (unmatched.length === 0) return;
113
-
114
- warn(
115
- GATE_ID,
116
- `New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. Consider adding a skill documenting how to use them, or add them to depsWithoutOwnApi if they need none.`,
117
- );
118
- },
119
- );
120
-
121
- // Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
122
- // dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
123
- // .ai/skills-baseline.json freeze mechanism — that belongs to a separate
124
- // adopt-stack-and-skills.mjs script that does not exist in this repo. This
125
- // gate only does simple substring matching between dependency name and skill
126
- // directory name, both normalized.
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ runGate,
5
+ deny,
6
+ toolInGroups,
7
+ writtenPathOf,
8
+ writtenContentOf,
9
+ } from '../../lib/hook-io.mjs';
10
+
11
+ const GATE_ID = 'dependency-skills';
12
+ const CONFIG_KEY = 'requireSkillForNewDependency';
13
+
14
+ const DEFAULT_DEPS_WITHOUT_OWN_API = [
15
+ 'clsx',
16
+ 'classnames',
17
+ 'lodash.debounce',
18
+ 'lodash.throttle',
19
+ 'lodash.merge',
20
+ 'tslib',
21
+ 'core-js',
22
+ 'regenerator-runtime',
23
+ ];
24
+
25
+ function normalize(name) {
26
+ return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
27
+ }
28
+
29
+ function isExempt(dependencyName, dependenciesWithoutOwnApi) {
30
+ if (dependencyName.startsWith('@types/')) return true;
31
+ return dependenciesWithoutOwnApi.includes(dependencyName);
32
+ }
33
+
34
+ function listSkillDirectories(skillsDirectoryPath) {
35
+ try {
36
+ return readdirSync(skillsDirectoryPath, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory())
38
+ .map((entry) => entry.name);
39
+ } catch {
40
+ // No skills directory in this project — nothing to cross-check against.
41
+ return [];
42
+ }
43
+ }
44
+
45
+ // A skill covers a dependency when its normalized name equals the dependency's, or contains
46
+ // it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
47
+ // bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
48
+ // exemption that silenced the warning for an unreviewed dependency.
49
+ function skillNameMatches(normalizedDependency, skillDirectoryName) {
50
+ const normalizedSkill = normalize(skillDirectoryName);
51
+ if (normalizedSkill === normalizedDependency) return true;
52
+ return normalizedSkill.split('-').includes(normalizedDependency);
53
+ }
54
+
55
+ function hasMatchingSkill(dependencyName, skillDirectories) {
56
+ const normalizedDependency = normalize(dependencyName);
57
+ return skillDirectories.some((skillDirectoryName) =>
58
+ skillNameMatches(normalizedDependency, skillDirectoryName),
59
+ );
60
+ }
61
+
62
+ function needsSkillReview(dependencyName, parameters, skillDirectories) {
63
+ if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
64
+ return !hasMatchingSkill(dependencyName, skillDirectories);
65
+ }
66
+
67
+ /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
68
+ function dependencyNamesIn(content) {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(content);
72
+ } catch {
73
+ // Mid-edit or partially written package.json — nothing reliable to check yet.
74
+ return null;
75
+ }
76
+ const dependencies = {
77
+ ...(parsed.dependencies ?? {}),
78
+ ...(parsed.devDependencies ?? {}),
79
+ };
80
+ return Object.keys(dependencies);
81
+ }
82
+
83
+ runGate(
84
+ {
85
+ id: GATE_ID,
86
+ configKey: CONFIG_KEY,
87
+ enabledByDefault: true,
88
+ defaultParams: {
89
+ depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
90
+ projectSkillsDir: '.claude/skills',
91
+ },
92
+ },
93
+ ({ toolName, toolInput, parameters }) => {
94
+ if (!toolInGroups(toolName, ['write'])) return;
95
+
96
+ const filePath = writtenPathOf(toolInput);
97
+ if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
98
+
99
+ const content = writtenContentOf(toolInput);
100
+ if (!content) return;
101
+
102
+ const dependencyNames = dependencyNamesIn(content);
103
+ if (!dependencyNames || dependencyNames.length === 0) return;
104
+
105
+ const skillDirectories = listSkillDirectories(
106
+ join(process.cwd(), parameters.projectSkillsDir),
107
+ );
108
+
109
+ const unmatched = dependencyNames.filter((name) =>
110
+ needsSkillReview(name, parameters, skillDirectories),
111
+ );
112
+ if (unmatched.length === 0) return;
113
+
114
+ deny(
115
+ GATE_ID,
116
+ `New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. ` +
117
+ 'Add a skill documenting how to use each (directory name matching or containing the ' +
118
+ 'package name), OR, if a dependency genuinely needs no skill, declare it in ' +
119
+ '`depsWithoutOwnApi` in .ai/config.json — that is the explicit opt-out, edited in the ' +
120
+ 'same change. This blocks so an unreviewed dependency is a deliberate choice, not a default.',
121
+ );
122
+ },
123
+ );
124
+
125
+ // Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
126
+ // dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
127
+ // .ai/skills-baseline.json freeze mechanism — that belongs to a separate
128
+ // adopt-stack-and-skills.mjs script that does not exist in this repo. This
129
+ // gate only does simple substring matching between dependency name and skill
130
+ // directory name, both normalized.
@@ -1,79 +1,104 @@
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
- );
1
+ import {
2
+ runGate,
3
+ deny,
4
+ toolInGroups,
5
+ writtenContentOf,
6
+ } from '../../lib/hook-io.mjs';
7
+
8
+ const GATE_ID = 'neutral-spanish';
9
+ const CONFIG_KEY = 'warnNonNeutralSpanish';
10
+
11
+ const MAX_REPORTED_MARKERS = 8;
12
+
13
+ // Escape hatch: when a written file legitimately needs regional text (a literal quote, a
14
+ // test fixture, a captured log, a data sample), placing this marker anywhere in the content
15
+ // tells the gate the regional wording is intentional and lets the write through. A deny (as
16
+ // opposed to the old warn) cannot "advise and let pass", so a legitimate case needs an
17
+ // explicit, greppable opt-out — a marker the author writes on purpose, never one the agent
18
+ // could infer. A project can override it via config (escapeHatch).
19
+ const DEFAULT_ESCAPE_HATCH = 'neutral-spanish:allow';
20
+
21
+ // Data strings, not identifiers — never add these to a cSpell dictionary.
22
+ // neutral-spanish:allow — this list IS the marker data; the gate must not deny its own source.
23
+ // Now that this gate DENIES (not warns), a marker must be unambiguously regional. Tokens that
24
+ // also occur in neutral Spanish were removed: "de una" (matches "de una lista/vez"), "allá"
25
+ // (standard across the whole language) — a false positive here blocks a legitimate write.
26
+ const DEFAULT_REGIONAL_MARKERS = [
27
+ 'tenés',
28
+ 'podés',
29
+ 'querés',
30
+ 'sabés',
31
+ 'hacés',
32
+ 'decís',
33
+ 'venís',
34
+ 'sos',
35
+ 'fijate',
36
+ 'mirá',
37
+ 'pará',
38
+ 'esperá',
39
+ 'dale',
40
+ 'mandale',
41
+ 'contame',
42
+ 'fíjate vos',
43
+ 'acá',
44
+ 'laburo',
45
+ 'laburar',
46
+ 'quilombo',
47
+ 'posta',
48
+ 'che',
49
+ 'boludo',
50
+ 'pibe',
51
+ 'guita',
52
+ 'al pedo',
53
+ 'un toque',
54
+ 'capaz que', // neutral-spanish:allow (marker data, not prose)
55
+ ];
56
+
57
+ function extractText(toolName, toolInput) {
58
+ if (!toolInGroups(toolName, ['write'])) return '';
59
+ return writtenContentOf(toolInput);
60
+ }
61
+
62
+ runGate(
63
+ {
64
+ id: GATE_ID,
65
+ configKey: CONFIG_KEY,
66
+ enabledByDefault: true,
67
+ defaultParams: {
68
+ regionalMarkers: DEFAULT_REGIONAL_MARKERS,
69
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
70
+ },
71
+ },
72
+ ({ toolName, toolInput, parameters }) => {
73
+ const text = extractText(toolName, toolInput).toLowerCase();
74
+ if (!text) return;
75
+
76
+ // Explicit opt-out for legitimate regional text (quote/fixture/log/data sample).
77
+ const escapeHatch = (
78
+ parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
79
+ ).toLowerCase();
80
+ if (escapeHatch && text.includes(escapeHatch)) return;
81
+
82
+ const hits = [];
83
+ for (const marker of parameters.regionalMarkers) {
84
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ // \p{L} (any Unicode letter) as the non-boundary class, instead of a hand-written
86
+ // accented-character set, so accented word boundaries are handled correctly without
87
+ // spelling out literal accented characters in the source (the project keeps its
88
+ // spell-check dictionary untouched, so no word list may lean on this being a string).
89
+ const pattern = new RegExp(`(^|[^\\p{L}])${escaped}([^\\p{L}]|$)`, 'iu');
90
+ if (pattern.test(text)) hits.push(marker);
91
+ }
92
+
93
+ if (hits.length === 0) return;
94
+
95
+ const unique = [...new Set(hits)].slice(0, MAX_REPORTED_MARKERS);
96
+ deny(
97
+ GATE_ID,
98
+ `Text being written contains regional Spanish markers: ${unique.join(', ')}. ` +
99
+ 'Rewrite in neutral Spanish before writing. If the regional wording is intentional ' +
100
+ `(a literal quote, a test fixture, a captured log, a data sample), add the marker ` +
101
+ `"${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" somewhere in the content to allow it.`,
102
+ );
103
+ },
104
+ );