@devrik-tools/claude-gates 0.2.1 → 0.3.1

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 (97) 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/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/stop-pending/index.mjs +6 -1
  16. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  17. package/plugins/gates/hooks/hooks.json +19 -0
  18. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  19. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  20. package/registry.json +738 -793
  21. package/cli/__tests__/config.test.mjs +0 -113
  22. package/cli/__tests__/init-flags.test.mjs +0 -111
  23. package/cli/__tests__/install.test.mjs +0 -102
  24. package/cli/__tests__/materialize.test.mjs +0 -95
  25. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  26. package/cli/__tests__/selection.test.mjs +0 -200
  27. package/cli/__tests__/task.test.mjs +0 -124
  28. package/cli/__tests__/version-consistency.test.mjs +0 -44
  29. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  30. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  31. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  32. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  33. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  34. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  35. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  36. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  37. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  38. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  39. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  40. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  41. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  42. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  43. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  46. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  47. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  48. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  49. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  50. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  51. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  52. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  53. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  54. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  55. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  56. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  57. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  58. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  59. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  60. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  61. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  62. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  63. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  64. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  65. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  66. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  67. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  68. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  69. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  70. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  71. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  72. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  73. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  74. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  75. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  76. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  77. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  78. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  79. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  81. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  82. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  83. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  84. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  85. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  86. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  87. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  88. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  89. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  90. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  91. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  92. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  93. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  95. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  96. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  97. 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
+ );