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