@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,181 +1,187 @@
1
- // test-matrix — denies an implementation delegation whose brief touches a domain that
2
- // makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA; always →
3
- // unit/mutation) without that type declared in the prompt. Migrated from
4
- // ~/.claude/hooks/guard-matriz-de-tests.mjs.
5
- //
6
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // e2eSignals regex sources (matched case-insensitively) that make an E2E row
8
- // mandatory when found in the brief (money, auth, persistence...).
9
- // Replaces the built-in list wholesale.
10
- // visualSignals regex sources that make a visual/QA row mandatory (UI signals).
11
- // 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
- // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
16
- // Only STANDARD/HIGH-RISK implementation delegations on builder subagents, not about
17
- // the harness itself, reach the check. A brief that only describes work never triggers.
18
-
19
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
20
-
21
- const GATE_ID = 'test-matrix';
22
- const CONFIG_KEY = 'requireTestMatrixWhenImplementing';
23
-
24
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
25
-
26
- const DEFAULT_EXEMPT_SUBAGENTS = [
27
- 'scout',
28
- 'explore',
29
- 'plan',
30
- 'revision',
31
- 'contraste',
32
- 'test-planner',
33
- 'qa',
34
- 'ui',
35
- 'ux',
36
- ];
37
-
38
- const DEFAULT_E2E_SIGNALS = [
39
- 'money|amount|payment|charge|invoice|balance',
40
- 'auth|authentication|login|session|token|permission|role',
41
- 'persist|database|migration|transaction',
42
- ];
43
-
44
- const DEFAULT_VISUAL_SIGNALS = [
45
- 'ui|interface|component|screen|view',
46
- 'form|button|modal|layout|style',
47
- 'responsive|mobile|visual|\\.vue|\\.tsx?|\\.jsx?',
48
- ];
49
-
50
- function withWordBoundary(alternation) {
51
- return new RegExp(
52
- `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
53
- 'iu',
54
- );
55
- }
56
-
57
- const IMPLEMENTATION_VERBS = withWordBoundary(
58
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
59
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
60
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
61
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
62
- 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
63
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
64
- );
65
-
66
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
67
- * the plugin-wide convention (see risk-level.mjs). */
68
- const DEMANDING_LEVEL_PATTERN =
69
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
70
- const EXEMPT_LEVEL_PATTERN =
71
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
72
-
73
- const HARNESS_PATTERN =
74
- /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
75
-
76
- const DETECTS_E2E = /\b(e2e|end[- ]to[- ]end|playwright)\b/i;
77
- const DETECTS_VISUAL = /\b(visual|qa\b|screenshot|snapshot)\b/i;
78
- const DETECTS_UNIT_OR_MUTATION =
79
- /\b(unit\b|units\b|vitest|jest|mutation|mutant|stryker)\b/i;
80
- const DETECTS_NEGATIVE_CASES = withWordBoundary(
81
- 'negative|edge|empty|error|no data|zero|count 0|failure|invalid|limit',
82
- );
83
-
84
- function joinSignals(signals) {
85
- return withWordBoundary(signals.join('|'));
86
- }
87
-
88
- function isExempt(toolInput, prompt, exemptSubagents) {
89
- const subagentType = String(
90
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
91
- ).toLowerCase();
92
- if (exemptSubagents.includes(subagentType)) return true;
93
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
94
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
95
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
96
- if (HARNESS_PATTERN.test(prompt)) return true;
97
- return false;
98
- }
99
-
100
- /** Which mandatory test types are missing from the prompt, given the brief text. */
101
- function missingTypes(prompt, e2ePattern, visualPattern) {
102
- const missing = [];
103
-
104
- if (!DETECTS_UNIT_OR_MUTATION.test(prompt)) {
105
- missing.push({
106
- type: 'unit/mutation',
107
- line: '- unit/mutation: yes — <what it covers> (or: not applicable — <reason>)',
108
- });
109
- }
110
-
111
- if (e2ePattern.test(prompt) && !DETECTS_E2E.test(prompt)) {
112
- missing.push({
113
- type: 'E2E',
114
- line: '- E2E: yes — <end-to-end flow verified> (mandatory: the requirement touches money/auth/persistence)',
115
- });
116
- }
117
-
118
- if (visualPattern.test(prompt) && !DETECTS_VISUAL.test(prompt)) {
119
- missing.push({
120
- type: 'visual/QA',
121
- line: '- visual/QA: yes — <screen/state reviewed in the browser> (mandatory: the requirement touches UI)',
122
- });
123
- }
124
-
125
- const declaresAnyType =
126
- DETECTS_UNIT_OR_MUTATION.test(prompt) ||
127
- DETECTS_E2E.test(prompt) ||
128
- DETECTS_VISUAL.test(prompt);
129
- if (declaresAnyType && !DETECTS_NEGATIVE_CASES.test(prompt)) {
130
- missing.push({
131
- type: 'negative/edge cases',
132
- line: '- negative/edge cases: <no data / empty / error / zero value / invalid input tested> (the happy path is not enough)',
133
- });
134
- }
135
-
136
- return missing;
137
- }
138
-
139
- runGate(
140
- {
141
- id: GATE_ID,
142
- configKey: CONFIG_KEY,
143
- enabledByDefault: false,
144
- defaultParams: {
145
- exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
146
- e2eSignals: DEFAULT_E2E_SIGNALS,
147
- visualSignals: DEFAULT_VISUAL_SIGNALS,
148
- },
149
- },
150
- ({ toolName, toolInput, parameters }) => {
151
- if (!DELEGATION_TOOLS.has(toolName)) return;
152
-
153
- const prompt = String(
154
- toolInput.prompt ?? toolInput.Prompt ?? toolInput.task ?? '',
155
- );
156
- if (!prompt.trim()) return;
157
-
158
- const exemptSubagents =
159
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
160
- if (isExempt(toolInput, prompt, exemptSubagents)) return;
161
-
162
- const e2eSignals = parameters.e2eSignals ?? DEFAULT_E2E_SIGNALS;
163
- const visualSignals = parameters.visualSignals ?? DEFAULT_VISUAL_SIGNALS;
164
- const e2ePattern = joinSignals(e2eSignals);
165
- const visualPattern = joinSignals(visualSignals);
166
-
167
- const missing = missingTypes(prompt, e2ePattern, visualPattern);
168
- if (missing.length === 0) return;
169
-
170
- const lines = missing.map((entry) => ` ${entry.line}`).join('\n');
171
- deny(
172
- GATE_ID,
173
- `Missing ${missing.length} test type(s) the requirement makes mandatory. Test types ` +
174
- 'are chosen ACCORDING TO THE REQUIREMENT: E2E if it touches money/auth/persistence, ' +
175
- 'visual/QA if it touches UI, and always unit or mutation. ' +
176
- `PASTE THIS SECTION INTO THE PROMPT (adjust yes/no with judgment) AND RELAUNCH:\n` +
177
- `TEST MATRIX (by type, according to the requirement):\n${lines}\n` +
178
- 'A type that truly does not apply is declared "no — <reason>", never silently omitted.',
179
- );
180
- },
181
- );
1
+ // test-matrix — denies an implementation delegation whose brief touches a domain that
2
+ // makes a test type mandatory (money/auth/persistence → E2E; UI → visual/QA; always →
3
+ // unit/mutation) without that type declared in the prompt. Migrated from
4
+ // ~/.claude/hooks/guard-matriz-de-tests.mjs.
5
+ //
6
+ // ── What a project can configure (params) ───────────────────────────────────────────
7
+ // e2eSignals regex sources (matched case-insensitively) that make an E2E row
8
+ // mandatory when found in the brief (money, auth, persistence...).
9
+ // Replaces the built-in list wholesale.
10
+ // visualSignals regex sources that make a visual/QA row mandatory (UI signals).
11
+ // 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
+ // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
16
+ // Only STANDARD/HIGH-RISK implementation delegations on builder subagents, not about
17
+ // the harness itself, reach the check. A brief that only describes work never triggers.
18
+
19
+ import {
20
+ runGate,
21
+ deny,
22
+ toolInGroups,
23
+ delegationPromptOf,
24
+ } from '../../lib/hook-io.mjs';
25
+ import { RISK_SIGNAL_SOURCES } from '../../lib/signals.mjs';
26
+
27
+ const GATE_ID = 'test-matrix';
28
+ const CONFIG_KEY = 'requireTestMatrixWhenImplementing';
29
+
30
+ const DEFAULT_EXEMPT_SUBAGENTS = [
31
+ 'scout',
32
+ 'explore',
33
+ 'plan',
34
+ 'revision',
35
+ 'contraste',
36
+ 'test-planner',
37
+ 'qa',
38
+ 'ui',
39
+ 'ux',
40
+ ];
41
+
42
+ // Money/auth signals shared with RISK_SIGNAL (lib/signals.mjs, ES+EN), plus this gate's
43
+ // own persistence-domain terms (also bilingual) — a project override still replaces the
44
+ // whole list wholesale, same as before.
45
+ const DEFAULT_E2E_SIGNALS = [
46
+ ...RISK_SIGNAL_SOURCES.slice(0, 2), // money terms, auth terms
47
+ 'login|session|token|permission|role|sesi[oó]n|permiso|rol',
48
+ 'persist|database|migration|transaction|persistencia|base de datos|migraci[oó]n|transacci[oó]n',
49
+ ];
50
+
51
+ const DEFAULT_VISUAL_SIGNALS = [
52
+ 'ui|interface|interfaz|component|componente|screen|pantalla|view|vista',
53
+ 'form|formulario|button|bot[oó]n|modal|layout|style|estilo',
54
+ 'responsive|mobile|m[oó]vil|visual|\\.vue|\\.tsx?|\\.jsx?',
55
+ ];
56
+
57
+ function withWordBoundary(alternation) {
58
+ return new RegExp(
59
+ `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
60
+ 'iu',
61
+ );
62
+ }
63
+
64
+ const IMPLEMENTATION_VERBS = withWordBoundary(
65
+ 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
66
+ 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
67
+ 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
68
+ 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
69
+ 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
70
+ 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
71
+ );
72
+
73
+ /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
74
+ * the plugin-wide convention (see risk-level.mjs). */
75
+ const DEMANDING_LEVEL_PATTERN =
76
+ /(nivel|level|clasificaci[]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
77
+ const EXEMPT_LEVEL_PATTERN =
78
+ /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
79
+
80
+ const HARNESS_PATTERN =
81
+ /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
82
+
83
+ const DETECTS_E2E = /\b(e2e|end[- ]to[- ]end|playwright)\b/i;
84
+ const DETECTS_VISUAL = /\b(visual|qa\b|screenshot|snapshot)\b/i;
85
+ const DETECTS_UNIT_OR_MUTATION =
86
+ /\b(unit\b|units\b|unitari[ao]s?|vitest|jest|mutation|mutaci[oó]n|mutant|stryker)\b/i;
87
+ const DETECTS_NEGATIVE_CASES = withWordBoundary(
88
+ 'negative|edge|empty|error|no data|zero|count 0|failure|invalid|limit|' +
89
+ 'negativ[ao]s?|borde|vac[ií]o|sin datos|cero|falla|inv[aá]lid[ao]|l[ií]mite',
90
+ );
91
+
92
+ function joinSignals(signals) {
93
+ return withWordBoundary(signals.join('|'));
94
+ }
95
+
96
+ function isExempt(toolInput, prompt, exemptSubagents) {
97
+ const subagentType = String(
98
+ toolInput.subagent_type ?? toolInput.subagentType ?? '',
99
+ ).toLowerCase();
100
+ if (exemptSubagents.includes(subagentType)) return true;
101
+ if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
102
+ if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
103
+ if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
104
+ if (HARNESS_PATTERN.test(prompt)) return true;
105
+ return false;
106
+ }
107
+
108
+ /** Which mandatory test types are missing from the prompt, given the brief text. */
109
+ function missingTypes(prompt, e2ePattern, visualPattern) {
110
+ const missing = [];
111
+
112
+ if (!DETECTS_UNIT_OR_MUTATION.test(prompt)) {
113
+ missing.push({
114
+ type: 'unit/mutation',
115
+ line: '- unit/mutation: yes — <what it covers> (or: not applicable — <reason>)',
116
+ });
117
+ }
118
+
119
+ if (e2ePattern.test(prompt) && !DETECTS_E2E.test(prompt)) {
120
+ missing.push({
121
+ type: 'E2E',
122
+ line: '- E2E: yes — <end-to-end flow verified> (mandatory: the requirement touches money/auth/persistence)',
123
+ });
124
+ }
125
+
126
+ if (visualPattern.test(prompt) && !DETECTS_VISUAL.test(prompt)) {
127
+ missing.push({
128
+ type: 'visual/QA',
129
+ line: '- visual/QA: yes — <screen/state reviewed in the browser> (mandatory: the requirement touches UI)',
130
+ });
131
+ }
132
+
133
+ const declaresAnyType =
134
+ DETECTS_UNIT_OR_MUTATION.test(prompt) ||
135
+ DETECTS_E2E.test(prompt) ||
136
+ DETECTS_VISUAL.test(prompt);
137
+ if (declaresAnyType && !DETECTS_NEGATIVE_CASES.test(prompt)) {
138
+ missing.push({
139
+ type: 'negative/edge cases',
140
+ line: '- negative/edge cases: <no data / empty / error / zero value / invalid input tested> (the happy path is not enough)',
141
+ });
142
+ }
143
+
144
+ return missing;
145
+ }
146
+
147
+ runGate(
148
+ {
149
+ id: GATE_ID,
150
+ configKey: CONFIG_KEY,
151
+ enabledByDefault: false,
152
+ defaultParams: {
153
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
154
+ e2eSignals: DEFAULT_E2E_SIGNALS,
155
+ visualSignals: DEFAULT_VISUAL_SIGNALS,
156
+ },
157
+ },
158
+ ({ toolName, toolInput, parameters }) => {
159
+ if (!toolInGroups(toolName, ['delegation'])) return;
160
+
161
+ const prompt = delegationPromptOf(toolInput);
162
+ if (!prompt.trim()) return;
163
+
164
+ const exemptSubagents =
165
+ parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
166
+ if (isExempt(toolInput, prompt, exemptSubagents)) return;
167
+
168
+ const e2eSignals = parameters.e2eSignals ?? DEFAULT_E2E_SIGNALS;
169
+ const visualSignals = parameters.visualSignals ?? DEFAULT_VISUAL_SIGNALS;
170
+ const e2ePattern = joinSignals(e2eSignals);
171
+ const visualPattern = joinSignals(visualSignals);
172
+
173
+ const missing = missingTypes(prompt, e2ePattern, visualPattern);
174
+ if (missing.length === 0) return;
175
+
176
+ const lines = missing.map((entry) => ` ${entry.line}`).join('\n');
177
+ deny(
178
+ GATE_ID,
179
+ `Missing ${missing.length} test type(s) the requirement makes mandatory. Test types ` +
180
+ 'are chosen ACCORDING TO THE REQUIREMENT: E2E if it touches money/auth/persistence, ' +
181
+ 'visual/QA if it touches UI, and always unit or mutation. ' +
182
+ `PASTE THIS SECTION INTO THE PROMPT (adjust yes/no with judgment) AND RELAUNCH:\n` +
183
+ `TEST MATRIX (by type, according to the requirement):\n${lines}\n` +
184
+ 'A type that truly does not apply is declared "no — <reason>", never silently omitted.',
185
+ );
186
+ },
187
+ );
@@ -1,87 +1,114 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function runGate(payload, { config } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'test-matrix-'));
13
- mkdirSync(join(project, '.git'));
14
- if (config) {
15
- mkdirSync(join(project, '.ai'));
16
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
- }
18
- const out = execFileSync(process.execPath, [GATE], {
19
- input: JSON.stringify(payload),
20
- encoding: 'utf8',
21
- cwd: project,
22
- // Isolate from the user's real global config: point homedir() at the temp
23
- // project so the global-config fallback finds nothing (registry default).
24
- env: { ...process.env, HOME: project, USERPROFILE: project },
25
- });
26
- return out.trim() ? JSON.parse(out.trim()) : null;
27
- }
28
-
29
- function delegate(prompt, subagentType = 'backend') {
30
- return {
31
- tool_name: 'Agent',
32
- tool_input: { prompt, subagent_type: subagentType },
33
- };
34
- }
35
-
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- function enabledConfig(extraParameters) {
41
- return {
42
- gates: {
43
- requireTestMatrixWhenImplementing: extraParameters
44
- ? { enabled: true, ...extraParameters }
45
- : true,
46
- },
47
- };
48
- }
49
-
50
- test('disabled by default: a money-touching brief with no test matrix is allowed', () => {
51
- const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
52
- assert.equal(runGate(delegate(prompt)), null);
53
- });
54
-
55
- test('denies missing unit/mutation on a plain brief with no test type declared', () => {
56
- const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
57
- assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
58
- });
59
-
60
- test('denies missing E2E when the brief touches money and only declares unit tests', () => {
61
- const prompt =
62
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
63
- assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
64
- });
65
-
66
- test('allows a brief that declares unit, E2E and negative cases for a money domain', () => {
67
- const prompt =
68
- 'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
69
- 'Verificación: vitest cubre el cálculo. E2E: flujo completo de cobro. ' +
70
- 'Casos negativos: monto cero, sin saldo, error de red.';
71
- assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
72
- });
73
-
74
- test('exempt subagent type (qa) is never held to the matrix requirement', () => {
75
- const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
76
- assert.equal(
77
- runGate(delegate(prompt, 'qa'), { config: enabledConfig() }),
78
- null,
79
- );
80
- });
81
-
82
- test('e2eSignals param override makes a domain word mandatory that the default list omits', () => {
83
- const prompt =
84
- 'Nivel: STANDARD\nImplementá el módulo de inventario. Verificación: vitest cubre el cálculo.';
85
- const config = enabledConfig({ e2eSignals: ['inventario|inventory'] });
86
- assert.ok(isDeny(runGate(delegate(prompt), { config })));
87
- });
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'test-matrix-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function delegate(prompt, subagentType = 'backend') {
30
+ return {
31
+ tool_name: 'Agent',
32
+ tool_input: { prompt, subagent_type: subagentType },
33
+ };
34
+ }
35
+
36
+ function isDeny(result) {
37
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
+ }
39
+
40
+ function enabledConfig(extraParameters) {
41
+ return {
42
+ gates: {
43
+ requireTestMatrixWhenImplementing: extraParameters
44
+ ? { enabled: true, ...extraParameters }
45
+ : true,
46
+ },
47
+ };
48
+ }
49
+
50
+ test('disabled by default: a money-touching brief with no test matrix is allowed', () => {
51
+ const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
52
+ assert.equal(runGate(delegate(prompt)), null);
53
+ });
54
+
55
+ test('denies missing unit/mutation on a plain brief with no test type declared', () => {
56
+ const prompt = 'Nivel: STANDARD\nImplementá el checkout.';
57
+ assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
58
+ });
59
+
60
+ test('denies missing E2E when the brief touches money and only declares unit tests', () => {
61
+ const prompt =
62
+ 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
63
+ assert.ok(isDeny(runGate(delegate(prompt), { config: enabledConfig() })));
64
+ });
65
+
66
+ test('allows a brief that declares unit, E2E and negative cases for a money domain', () => {
67
+ const prompt =
68
+ 'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
69
+ 'Verificación: vitest cubre el cálculo. E2E: flujo completo de cobro. ' +
70
+ 'Casos negativos: monto cero, sin saldo, error de red.';
71
+ assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
72
+ });
73
+
74
+ test('exempt subagent type (qa) is never held to the matrix requirement', () => {
75
+ const prompt = 'Nivel: STANDARD\nImplementá el cobro de la cuota.';
76
+ assert.equal(
77
+ runGate(delegate(prompt, 'qa'), { config: enabledConfig() }),
78
+ null,
79
+ );
80
+ });
81
+
82
+ test('denies missing E2E for a Spanish money brief (cobro/saldo/cuota), not just its English equivalent', () => {
83
+ const prompt =
84
+ 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
85
+ const result = runGate(delegate(prompt), { config: enabledConfig() });
86
+ assert.ok(isDeny(result));
87
+ assert.ok(result.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
88
+ });
89
+
90
+ test('bilingual control: an EN money brief with only unit coverage denies for the same reason as its ES equivalent', () => {
91
+ const es = 'Nivel: STANDARD\nImplementá el cobro de la cuota. Verificación: vitest cubre el cálculo.';
92
+ const en = 'Level: STANDARD\nImplement the fee charge. Verification: vitest covers the calculation.';
93
+ const resultEs = runGate(delegate(es), { config: enabledConfig() });
94
+ const resultEn = runGate(delegate(en), { config: enabledConfig() });
95
+ assert.ok(isDeny(resultEs));
96
+ assert.ok(isDeny(resultEn));
97
+ assert.ok(resultEs.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
98
+ assert.ok(resultEn.hookSpecificOutput.permissionDecisionReason.includes('E2E'));
99
+ });
100
+
101
+ test('allows a Spanish brief that declares unit, E2E and negative cases with Spanish wording', () => {
102
+ const prompt =
103
+ 'Nivel: STANDARD\nImplementá el cobro de la cuota. ' +
104
+ 'Verificación: pruebas unitarias cubren el calculo. E2E: flujo completo de cobro. ' +
105
+ 'Casos negativos: monto cero, sin saldo, entrada invalida.';
106
+ assert.equal(runGate(delegate(prompt), { config: enabledConfig() }), null);
107
+ });
108
+
109
+ test('e2eSignals param override makes a domain word mandatory that the default list omits', () => {
110
+ const prompt =
111
+ 'Nivel: STANDARD\nImplementá el módulo de inventario. Verificación: vitest cubre el cálculo.';
112
+ const config = enabledConfig({ e2eSignals: ['inventario|inventory'] });
113
+ assert.ok(isDeny(runGate(delegate(prompt), { config })));
114
+ });
@@ -0,0 +1,62 @@
1
+ // Edge-case probe for tool-map. Previously the same Edit/new_string content-field gap
2
+ // as feature-catalog/sdd-specs meant a legitimately-audited tool created/edited via
3
+ // Edit's `new_string` was never recorded in .ai/tool-map.json, so reuse-before-build
4
+ // kept re-blocking a tool that was already cleared once. Migrated to
5
+ // writtenContentOf()/writtenPathOf() (hook-io.mjs), which read new_string, so this is
6
+ // now recorded the same as a Write.
7
+ import assert from 'node:assert/strict';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { test } from 'node:test';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
16
+
17
+ function runGate(payload) {
18
+ const project = mkdtempSync(join(tmpdir(), 'tool-map-edge-'));
19
+ mkdirSync(join(project, '.git'));
20
+ mkdirSync(join(project, '.ai'));
21
+ writeFileSync(
22
+ join(project, '.ai', 'config.json'),
23
+ JSON.stringify({ gates: { maintainToolMap: true } }),
24
+ );
25
+ execFileSync(process.execPath, [GATE], {
26
+ input: JSON.stringify(payload),
27
+ encoding: 'utf8',
28
+ cwd: project,
29
+ env: { ...process.env, HOME: project, USERPROFILE: project },
30
+ });
31
+ return { mapPath: join(project, '.ai', 'tool-map.json') };
32
+ }
33
+
34
+ test('FIXED: an audited tool written via Edit (new_string) is now recorded', () => {
35
+ const payload = {
36
+ tool_name: 'Edit',
37
+ tool_input: {
38
+ file_path: 'scripts/new-tool.mjs',
39
+ old_string: '',
40
+ new_string: '// audited: nothing does this\nexport function parse(){}',
41
+ },
42
+ };
43
+ const { mapPath } = runGate(payload);
44
+ assert.ok(
45
+ existsSync(mapPath),
46
+ 'writtenContentOf must read new_string so an Edit-created tool is persisted',
47
+ );
48
+ const map = JSON.parse(readFileSync(mapPath, 'utf8'));
49
+ assert.equal(map.tools[0].path, 'scripts/new-tool.mjs');
50
+ });
51
+
52
+ test('OK: the equivalent Write-tool payload IS recorded (control)', () => {
53
+ const payload = {
54
+ tool_name: 'Write',
55
+ tool_input: {
56
+ file_path: 'scripts/new-tool.mjs',
57
+ content: '// audited: nothing does this\nexport function parse(){}',
58
+ },
59
+ };
60
+ const { mapPath } = runGate(payload);
61
+ assert.ok(existsSync(mapPath), 'control failed');
62
+ });