@devrik-tools/claude-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,251 +1,256 @@
1
- // sdd-specs — denies implementation (a write to the catalog moving a feature past
2
- // `spec_ready`, or a delegation prompt that implements) when the feature/task it targets
3
- // has no non-empty contract on disk (requirements/design/tasks or brief/asserts).
4
- // Migrated from ~/.claude/hooks/guard-sdd-specs.mjs.
5
- //
6
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // catalogLocations paths (relative to the project root) searched for the feature
8
- // catalog. Replaces the built-in list wholesale.
9
- // exemptSubagents subagent types exempt from the spec requirement (read-only
10
- // stages of the pipeline that cannot require a contract of
11
- // themselves). 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
- // ── Auto-off when there is no SDD harness ───────────────────────────────────────────
16
- // This gate only acts once a catalog is found at one of `catalogLocations` (or the
17
- // project's own `.ai/config.json` declares adoption). A project that never adopted the
18
- // spec-driven harness has no catalog and no declared adoption, so every check below is
19
- // skipped and the write/delegation is allowed — imposing phases on a project that never
20
- // asked for them is the false positive that gets a gate disabled.
21
- //
22
- // ── Two surfaces inspected ───────────────────────────────────────────────────────────
23
- // A write to the catalog is checked against the catalog's own declared statuses: a
24
- // feature marked `spec_ready`/`in_progress`/`done` needs its contract tree in place. A
25
- // delegation prompt is checked when it declares a STANDARD/HIGH-RISK level and an
26
- // implementation verb: the contract tree must have non-empty requirements/design/tasks
27
- // (or brief/asserts) for the feature it targets.
28
-
29
- import { existsSync, statSync } from 'node:fs';
30
- import { dirname, join } from 'node:path';
31
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
32
-
33
- const GATE_ID = 'sdd-specs';
34
- const CONFIG_KEY = 'requireSpecBeforeImplementing';
35
-
36
- const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
37
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
38
-
39
- const CATALOG_FILE_NAME = 'feature_list.json';
40
-
41
- const DEFAULT_CATALOG_LOCATIONS = [
42
- join('.ai', CATALOG_FILE_NAME),
43
- CATALOG_FILE_NAME,
44
- ];
45
-
46
- const DEFAULT_EXEMPT_SUBAGENTS = [
47
- 'explore',
48
- 'plan',
49
- 'scout',
50
- 'revision',
51
- 'contraste',
52
- 'test-planner',
53
- 'qa',
54
- 'ui',
55
- 'ux',
56
- ];
57
-
58
- const ADVANCED_STATUSES = new Set(['spec_ready', 'in_progress', 'done']);
59
-
60
- /** Files that count as a feature's contract; at least one must be non-empty. */
61
- const CONTRACT_FILES = [
62
- 'requirements.md',
63
- 'design.md',
64
- 'tasks.md',
65
- 'brief.md',
66
- 'asserts.md',
67
- ];
68
-
69
- function withWordBoundary(alternation) {
70
- return new RegExp(
71
- `(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
72
- 'iu',
73
- );
74
- }
75
-
76
- const IMPLEMENTATION_VERBS = withWordBoundary(
77
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
78
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
79
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
80
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
81
- 'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
82
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
83
- );
84
-
85
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
86
- * the plugin-wide convention (see risk-level.mjs). */
87
- const DEMANDING_LEVEL_PATTERN =
88
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
89
- const EXEMPT_LEVEL_PATTERN =
90
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
91
-
92
- function fileExistsNonEmpty(path) {
93
- try {
94
- return existsSync(path) && statSync(path).size > 0;
95
- } catch {
96
- return false;
97
- }
98
- }
99
-
100
- function findCatalog(catalogLocations) {
101
- for (const relative of catalogLocations) {
102
- const path = join(process.cwd(), relative);
103
- if (existsSync(path)) return path;
104
- }
105
- return null;
106
- }
107
-
108
- /** Discovers the contract tree root next to wherever the catalog lives, or `.ai/features`. */
109
- function contractTreeRootFor(catalogPath) {
110
- const candidates = catalogPath
111
- ? [join(dirname(catalogPath), 'features')]
112
- : [];
113
- candidates.push(join(process.cwd(), '.ai', 'features'));
114
- candidates.push(join(process.cwd(), 'features'));
115
- for (const candidate of candidates) {
116
- if (existsSync(candidate)) return candidate;
117
- }
118
- return null;
119
- }
120
-
121
- function contractExistsFor(treeRoot, featureName) {
122
- if (!treeRoot) return false;
123
- const featureDirectory = join(treeRoot, featureName);
124
- if (!existsSync(featureDirectory)) return false;
125
- return CONTRACT_FILES.some((file) =>
126
- fileExistsNonEmpty(join(featureDirectory, file)),
127
- );
128
- }
129
-
130
- function parseJsonOrNull(text) {
131
- try {
132
- return JSON.parse(text);
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
- function writeTargetFrom(toolInput) {
139
- return String(
140
- toolInput.TargetFile ??
141
- toolInput.target_file ??
142
- toolInput.file_path ??
143
- toolInput.path ??
144
- '',
145
- );
146
- }
147
-
148
- function writeContentFrom(toolInput) {
149
- return String(
150
- toolInput.CodeContent ??
151
- toolInput.ReplacementContent ??
152
- toolInput.content ??
153
- '',
154
- );
155
- }
156
-
157
- /** Denies the single first advanced-status feature in the new catalog content that has
158
- * no contract on disk, or does nothing when every advanced feature has one. */
159
- function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
160
- for (const feature of features) {
161
- if (!feature?.name || !ADVANCED_STATUSES.has(feature.status)) continue;
162
- if (contractExistsFor(treeRoot, feature.name)) continue;
163
- deny(
164
- GATE_ID,
165
- `Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
166
- `contract (${CONTRACT_FILES.join(', ')}) under the discovered contract tree.`,
167
- );
168
- }
169
- }
170
-
171
- function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
172
- const target = writeTargetFrom(toolInput);
173
- if (!catalogPath || !target.includes(CATALOG_FILE_NAME)) return;
174
-
175
- const parsed = parseJsonOrNull(writeContentFrom(toolInput));
176
- const features = Array.isArray(parsed?.features) ? parsed.features : [];
177
- denyIfAdvancedFeatureLacksContract(features, treeRoot);
178
- }
179
-
180
- function isExemptDelegation(toolInput, prompt, exemptSubagents) {
181
- const subagentType = String(
182
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
183
- ).toLowerCase();
184
- if (exemptSubagents.includes(subagentType)) return true;
185
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
186
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
187
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
188
- return false;
189
- }
190
-
191
- function featureNamesCitedIn(prompt) {
192
- const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
193
- const names = [];
194
- let match;
195
- while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
196
- return names;
197
- }
198
-
199
- function checkDelegation(toolInput, treeRoot, exemptSubagents) {
200
- const prompt = String(
201
- toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
202
- );
203
- if (!prompt.trim()) return;
204
- if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
205
-
206
- const citedFeatures = featureNamesCitedIn(prompt);
207
- if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
208
-
209
- const missing = citedFeatures.filter(
210
- (feature) => !contractExistsFor(treeRoot, feature),
211
- );
212
- if (missing.length === citedFeatures.length) {
213
- deny(
214
- GATE_ID,
215
- `This implementation delegation cites feature(s) [${missing.join(', ')}] with no ` +
216
- `non-empty contract (${CONTRACT_FILES.join(', ')}) on disk. Write the contract ` +
217
- 'before implementing.',
218
- );
219
- }
220
- }
221
-
222
- runGate(
223
- {
224
- id: GATE_ID,
225
- configKey: CONFIG_KEY,
226
- enabledByDefault: false,
227
- defaultParams: {
228
- catalogLocations: DEFAULT_CATALOG_LOCATIONS,
229
- exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
230
- },
231
- },
232
- ({ toolName, toolInput, parameters }) => {
233
- const isWrite = WRITE_TOOLS.has(toolName);
234
- const isDelegation = DELEGATION_TOOLS.has(toolName);
235
- if (!isWrite && !isDelegation) return;
236
-
237
- const catalogLocations =
238
- parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
239
- const catalogPath = findCatalog(catalogLocations);
240
- if (!catalogPath) return; // no SDD harness adopted: stay silent
241
-
242
- const treeRoot = contractTreeRootFor(catalogPath);
243
-
244
- if (isWrite) checkCatalogWrite(toolInput, catalogPath, treeRoot);
245
- if (isDelegation) {
246
- const exemptSubagents =
247
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
248
- checkDelegation(toolInput, treeRoot, exemptSubagents);
249
- }
250
- },
251
- );
1
+ // sdd-specs — denies implementation (a write to the catalog moving a feature past
2
+ // `spec_ready`, or a delegation prompt that implements) when the feature/task it targets
3
+ // has no non-empty contract on disk (requirements/design/tasks or brief/asserts).
4
+ // Migrated from ~/.claude/hooks/guard-sdd-specs.mjs.
5
+ //
6
+ // ── What a project can configure (params) ───────────────────────────────────────────
7
+ // catalogLocations paths (relative to the project root) searched for the feature
8
+ // catalog. Replaces the built-in list wholesale.
9
+ // exemptSubagents subagent types exempt from the spec requirement (read-only
10
+ // stages of the pipeline that cannot require a contract of
11
+ // themselves). 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
+ // ── Auto-off when there is no SDD harness ───────────────────────────────────────────
16
+ // This gate only acts once a catalog is found at one of `catalogLocations` (or the
17
+ // project's own `.ai/config.json` declares adoption). A project that never adopted the
18
+ // spec-driven harness has no catalog and no declared adoption, so every check below is
19
+ // skipped and the write/delegation is allowed — imposing phases on a project that never
20
+ // asked for them is the false positive that gets a gate disabled.
21
+ //
22
+ // ── Two surfaces inspected ───────────────────────────────────────────────────────────
23
+ // A write to the catalog is checked against the catalog's own declared statuses: a
24
+ // feature marked `spec_ready`/`in_progress`/`done` needs its contract tree in place. A
25
+ // delegation prompt is checked when it declares a STANDARD/HIGH-RISK level and an
26
+ // implementation verb: the contract tree must have non-empty requirements/design/tasks
27
+ // (or brief/asserts) for the feature it targets.
28
+
29
+ import { existsSync, statSync } from 'node:fs';
30
+ import { dirname, join } from 'node:path';
31
+ import {
32
+ runGate,
33
+ deny,
34
+ toolInGroups,
35
+ writtenContentOf,
36
+ writtenPathOf,
37
+ delegationPromptOf,
38
+ } from '../../lib/hook-io.mjs';
39
+
40
+ const GATE_ID = 'sdd-specs';
41
+ const CONFIG_KEY = 'requireSpecBeforeImplementing';
42
+
43
+ const CATALOG_FILE_NAME = 'feature_list.json';
44
+
45
+ const DEFAULT_CATALOG_LOCATIONS = [
46
+ join('.ai', CATALOG_FILE_NAME),
47
+ CATALOG_FILE_NAME,
48
+ ];
49
+
50
+ const DEFAULT_EXEMPT_SUBAGENTS = [
51
+ 'explore',
52
+ 'plan',
53
+ 'scout',
54
+ 'revision',
55
+ 'contraste',
56
+ 'test-planner',
57
+ 'qa',
58
+ 'ui',
59
+ 'ux',
60
+ ];
61
+
62
+ const ADVANCED_STATUSES = new Set(['spec_ready', 'in_progress', 'done']);
63
+
64
+ /** Files that count as a feature's contract; at least one must be non-empty. */
65
+ const CONTRACT_FILES = [
66
+ 'requirements.md',
67
+ 'design.md',
68
+ 'tasks.md',
69
+ 'brief.md',
70
+ 'asserts.md',
71
+ ];
72
+
73
+ function withWordBoundary(alternation) {
74
+ return new RegExp(
75
+ `(?<![\\p{L}\\p{N}_])(${alternation})(?![\\p{L}\\p{N}_])`,
76
+ 'iu',
77
+ );
78
+ }
79
+
80
+ const IMPLEMENTATION_VERBS = withWordBoundary(
81
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
82
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
83
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
84
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
85
+ 'escrib(í|e)|escribir|implement\\w*|writ(e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
86
+ 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
87
+ );
88
+
89
+ /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
90
+ * the plugin-wide convention (see risk-level.mjs). */
91
+ const DEMANDING_LEVEL_PATTERN =
92
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
93
+ const EXEMPT_LEVEL_PATTERN =
94
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
95
+
96
+ function fileExistsNonEmpty(path) {
97
+ try {
98
+ return existsSync(path) && statSync(path).size > 0;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function findCatalog(catalogLocations, writtenPath) {
105
+ const roots = [process.cwd()];
106
+ // A write can target a catalog that lives elsewhere than cwd (a monorepo subpackage's
107
+ // own .ai/feature_list.json): also resolve catalogLocations relative to the directory
108
+ // of the file actually being written, not only the process cwd.
109
+ if (writtenPath) roots.push(dirname(writtenPath));
110
+ for (const root of roots) {
111
+ for (const relative of catalogLocations) {
112
+ const path = join(root, relative);
113
+ if (existsSync(path)) return path;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** Discovers the contract tree root next to wherever the catalog lives, or `.ai/features`. */
120
+ function contractTreeRootFor(catalogPath) {
121
+ const candidates = catalogPath
122
+ ? [join(dirname(catalogPath), 'features')]
123
+ : [];
124
+ candidates.push(join(process.cwd(), '.ai', 'features'));
125
+ candidates.push(join(process.cwd(), 'features'));
126
+ for (const candidate of candidates) {
127
+ if (existsSync(candidate)) return candidate;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function contractExistsFor(treeRoot, featureName) {
133
+ if (!treeRoot) return false;
134
+ const featureDirectory = join(treeRoot, featureName);
135
+ if (!existsSync(featureDirectory)) return false;
136
+ return CONTRACT_FILES.some((file) =>
137
+ fileExistsNonEmpty(join(featureDirectory, file)),
138
+ );
139
+ }
140
+
141
+ function parseJsonOrNull(text) {
142
+ try {
143
+ return JSON.parse(text);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /** Denies the single first advanced-status feature in the new catalog content that has
150
+ * no contract on disk, or does nothing when every advanced feature has one. */
151
+ function denyIfAdvancedFeatureLacksContract(features, treeRoot) {
152
+ for (const feature of features) {
153
+ if (!feature?.name || !ADVANCED_STATUSES.has(feature.status)) continue;
154
+ if (contractExistsFor(treeRoot, feature.name)) continue;
155
+ deny(
156
+ GATE_ID,
157
+ `Feature '${feature.name}' is set to '${feature.status}' but has no non-empty ` +
158
+ `contract (${CONTRACT_FILES.join(', ')}) under the discovered contract tree.`,
159
+ );
160
+ }
161
+ }
162
+
163
+ function checkCatalogWrite(toolInput, catalogPath, treeRoot) {
164
+ const target = writtenPathOf(toolInput);
165
+ if (!catalogPath || !target.includes(CATALOG_FILE_NAME)) return;
166
+
167
+ const rawContent = writtenContentOf(toolInput);
168
+ const parsed = parseJsonOrNull(rawContent);
169
+ if (parsed === null) {
170
+ // Fail-closed, not fail-open: a write whose content does not parse as JSON but
171
+ // still targets the catalog is suspicious on its own -- a spec_ready/done transition
172
+ // hidden behind a malformed payload must not be silently allowed through.
173
+ deny(
174
+ GATE_ID,
175
+ `The write to ${CATALOG_FILE_NAME} does not parse as JSON. A catalog write that ` +
176
+ 'cannot be verified for the spec-contract invariant is not allowed; fix the JSON ' +
177
+ 'or write valid content.',
178
+ );
179
+ }
180
+ const features = Array.isArray(parsed?.features) ? parsed.features : [];
181
+ denyIfAdvancedFeatureLacksContract(features, treeRoot);
182
+ }
183
+
184
+ function isExemptDelegation(toolInput, prompt, exemptSubagents) {
185
+ const subagentType = String(
186
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
187
+ ).toLowerCase();
188
+ if (exemptSubagents.includes(subagentType)) return true;
189
+ if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
190
+ if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
191
+ if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
192
+ return false;
193
+ }
194
+
195
+ function featureNamesCitedIn(prompt) {
196
+ const pattern = /\.(?:ai)[\\/]features[\\/]([\w.@-]+)/gi;
197
+ const names = [];
198
+ let match;
199
+ while ((match = pattern.exec(prompt)) !== null) names.push(match[1]);
200
+ return names;
201
+ }
202
+
203
+ function checkDelegation(toolInput, treeRoot, exemptSubagents) {
204
+ const prompt = delegationPromptOf(toolInput);
205
+ if (!prompt.trim()) return;
206
+ if (isExemptDelegation(toolInput, prompt, exemptSubagents)) return;
207
+
208
+ const citedFeatures = featureNamesCitedIn(prompt);
209
+ if (citedFeatures.length === 0) return; // no citation: nothing this gate can check
210
+
211
+ const missing = citedFeatures.filter(
212
+ (feature) => !contractExistsFor(treeRoot, feature),
213
+ );
214
+ if (missing.length === citedFeatures.length) {
215
+ deny(
216
+ GATE_ID,
217
+ `This implementation delegation cites feature(s) [${missing.join(', ')}] with no ` +
218
+ `non-empty contract (${CONTRACT_FILES.join(', ')}) on disk. Write the contract ` +
219
+ 'before implementing.',
220
+ );
221
+ }
222
+ }
223
+
224
+ runGate(
225
+ {
226
+ id: GATE_ID,
227
+ configKey: CONFIG_KEY,
228
+ enabledByDefault: false,
229
+ defaultParams: {
230
+ catalogLocations: DEFAULT_CATALOG_LOCATIONS,
231
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
232
+ },
233
+ },
234
+ ({ toolName, toolInput, parameters }) => {
235
+ const isWrite = toolInGroups(toolName, ['write']);
236
+ const isDelegation = toolInGroups(toolName, ['delegation']);
237
+ if (!isWrite && !isDelegation) return;
238
+
239
+ const catalogLocations =
240
+ parameters.catalogLocations ?? DEFAULT_CATALOG_LOCATIONS;
241
+ const catalogPath = findCatalog(
242
+ catalogLocations,
243
+ isWrite ? writtenPathOf(toolInput) : null,
244
+ );
245
+ if (!catalogPath) return; // no SDD harness adopted: stay silent
246
+
247
+ const treeRoot = contractTreeRootFor(catalogPath);
248
+
249
+ if (isWrite) checkCatalogWrite(toolInput, catalogPath, treeRoot);
250
+ if (isDelegation) {
251
+ const exemptSubagents =
252
+ parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
253
+ checkDelegation(toolInput, treeRoot, exemptSubagents);
254
+ }
255
+ },
256
+ );