@devrik-tools/claude-gates 0.1.2 → 0.1.3

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