@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,143 +1,161 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { randomUUID } from 'node:crypto';
4
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
- import { tmpdir } from 'node:os';
6
- import { dirname, join } from 'node:path';
7
- import { test } from 'node:test';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
- const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
12
-
13
- function runGate(payload, { config } = {}) {
14
- const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-'));
15
- mkdirSync(join(project, '.git'));
16
- if (config) {
17
- mkdirSync(join(project, '.ai'));
18
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
19
- }
20
- const out = execFileSync(process.execPath, [GATE], {
21
- input: JSON.stringify(payload),
22
- encoding: 'utf8',
23
- cwd: project,
24
- // Isolate from the user's real global config: point homedir() at the temp
25
- // project so the global-config fallback finds nothing (registry default).
26
- env: { ...process.env, HOME: project, USERPROFILE: project },
27
- });
28
- return out.trim() ? JSON.parse(out.trim()) : null;
29
- }
30
-
31
- function delegate(prompt, sessionId, extra = {}) {
32
- return {
33
- tool_name: 'Agent',
34
- session_id: sessionId,
35
- tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
36
- };
37
- }
38
- function isDeny(result) {
39
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
- }
41
-
42
- const ENABLED = {
43
- config: { gates: { requireCircuitBreakerOnDelegation: true } },
44
- };
45
-
46
- function freshSession() {
47
- return `test-${randomUUID()}`;
48
- }
49
-
50
- function cleanupSession(sessionId) {
51
- try {
52
- rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
53
- } catch {
54
- // best-effort cleanup
55
- }
56
- }
57
-
58
- test('cuts the same delegation retried without substantial change', () => {
59
- const sessionId = freshSession();
60
- try {
61
- const prompt = [
62
- 'Objetivo: fix the circuit breaker false positive in guard scoring.',
63
- '',
64
- 'QUE SI: update lib/scoring.mjs to fix the Dice threshold.',
65
- ].join('\n');
66
-
67
- assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
68
- assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
69
- assert.ok(isDeny(runGate(delegate(prompt, sessionId), ENABLED)));
70
- } finally {
71
- cleanupSession(sessionId);
72
- }
73
- });
74
-
75
- test('does not accumulate two genuinely different tasks to the same subagent', () => {
76
- const sessionId = freshSession();
77
- try {
78
- const taskA =
79
- 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
80
- const taskB =
81
- 'Objetivo: migrate the billing schema to the new payments table.\n\nQUE SI: update db/migrations/billing.sql.';
82
-
83
- assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
84
- assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
85
- assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
86
- assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
87
- } finally {
88
- cleanupSession(sessionId);
89
- }
90
- });
91
-
92
- test('the retry/force escape hatch allows and resets the counter', () => {
93
- const sessionId = freshSession();
94
- try {
95
- const prompt =
96
- 'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
97
-
98
- assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
99
- assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
100
- assert.equal(
101
- runGate(delegate(`${prompt}\n\nforce it, retry.`, sessionId), ENABLED),
102
- null,
103
- );
104
- // Counter was reset: the same prompt again should not deny immediately.
105
- assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
106
- } finally {
107
- cleanupSession(sessionId);
108
- }
109
- });
110
-
111
- test('disabled by config: the gate does not run', () => {
112
- const sessionId = freshSession();
113
- try {
114
- const prompt =
115
- 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
116
- const disabled = {
117
- config: { gates: { requireCircuitBreakerOnDelegation: false } },
118
- };
119
- assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
120
- assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
121
- assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
122
- assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
123
- } finally {
124
- cleanupSession(sessionId);
125
- }
126
- });
127
-
128
- test('project retryThreshold override cuts sooner', () => {
129
- const sessionId = freshSession();
130
- try {
131
- const config = {
132
- gates: {
133
- requireCircuitBreakerOnDelegation: { enabled: true, retryThreshold: 2 },
134
- },
135
- };
136
- const prompt =
137
- 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
138
- assert.equal(runGate(delegate(prompt, sessionId), { config }), null);
139
- assert.ok(isDeny(runGate(delegate(prompt, sessionId), { config })));
140
- } finally {
141
- cleanupSession(sessionId);
142
- }
143
- });
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
12
+
13
+ function runGate(payload, { config } = {}) {
14
+ const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-'));
15
+ mkdirSync(join(project, '.git'));
16
+ if (config) {
17
+ mkdirSync(join(project, '.ai'));
18
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
19
+ }
20
+ const out = execFileSync(process.execPath, [GATE], {
21
+ input: JSON.stringify(payload),
22
+ encoding: 'utf8',
23
+ cwd: project,
24
+ // Isolate from the user's real global config: point homedir() at the temp
25
+ // project so the global-config fallback finds nothing (registry default).
26
+ env: { ...process.env, HOME: project, USERPROFILE: project },
27
+ });
28
+ return out.trim() ? JSON.parse(out.trim()) : null;
29
+ }
30
+
31
+ function delegate(prompt, sessionId, extra = {}) {
32
+ return {
33
+ tool_name: 'Agent',
34
+ session_id: sessionId,
35
+ tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
36
+ };
37
+ }
38
+ function isDeny(result) {
39
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
+ }
41
+
42
+ const ENABLED = {
43
+ config: { gates: { requireCircuitBreakerOnDelegation: true } },
44
+ };
45
+
46
+ function freshSession() {
47
+ return `test-${randomUUID()}`;
48
+ }
49
+
50
+ function cleanupSession(sessionId) {
51
+ try {
52
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
53
+ } catch {
54
+ // best-effort cleanup
55
+ }
56
+ }
57
+
58
+ test('cuts the same delegation retried without substantial change', () => {
59
+ const sessionId = freshSession();
60
+ try {
61
+ const prompt = [
62
+ 'Objetivo: fix the circuit breaker false positive in guard scoring.',
63
+ '',
64
+ 'QUE SI: update lib/scoring.mjs to fix the Dice threshold.',
65
+ ].join('\n');
66
+
67
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
68
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
69
+ assert.ok(isDeny(runGate(delegate(prompt, sessionId), ENABLED)));
70
+ } finally {
71
+ cleanupSession(sessionId);
72
+ }
73
+ });
74
+
75
+ test('does not accumulate two genuinely different tasks to the same subagent', () => {
76
+ const sessionId = freshSession();
77
+ try {
78
+ const taskA =
79
+ 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
80
+ const taskB =
81
+ 'Objetivo: migrate the billing schema to the new payments table.\n\nQUE SI: update db/migrations/billing.sql.';
82
+
83
+ assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
84
+ assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
85
+ assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
86
+ assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
87
+ } finally {
88
+ cleanupSession(sessionId);
89
+ }
90
+ });
91
+
92
+ test('the retry/force escape hatch allows and resets the counter', () => {
93
+ const sessionId = freshSession();
94
+ try {
95
+ const prompt =
96
+ 'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
97
+
98
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
99
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
100
+ assert.equal(
101
+ runGate(delegate(`${prompt}\n\nforce it, retry.`, sessionId), ENABLED),
102
+ null,
103
+ );
104
+ // Counter was reset: the same prompt again should not deny immediately.
105
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
106
+ } finally {
107
+ cleanupSession(sessionId);
108
+ }
109
+ });
110
+
111
+ test('bilingual control: the Spanish override imperative resets the counter exactly like its English equivalent', () => {
112
+ const sessionId = freshSession();
113
+ try {
114
+ const prompt =
115
+ 'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
116
+
117
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
118
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
119
+ assert.equal(
120
+ runGate(delegate(`${prompt}\n\nreintentalo, forzalo.`, sessionId), ENABLED),
121
+ null,
122
+ );
123
+ assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
124
+ } finally {
125
+ cleanupSession(sessionId);
126
+ }
127
+ });
128
+
129
+ test('disabled by config: the gate does not run', () => {
130
+ const sessionId = freshSession();
131
+ try {
132
+ const prompt =
133
+ 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
134
+ const disabled = {
135
+ config: { gates: { requireCircuitBreakerOnDelegation: false } },
136
+ };
137
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
138
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
139
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
140
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
141
+ } finally {
142
+ cleanupSession(sessionId);
143
+ }
144
+ });
145
+
146
+ test('project retryThreshold override cuts sooner', () => {
147
+ const sessionId = freshSession();
148
+ try {
149
+ const config = {
150
+ gates: {
151
+ requireCircuitBreakerOnDelegation: { enabled: true, retryThreshold: 2 },
152
+ },
153
+ };
154
+ const prompt =
155
+ 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
156
+ assert.equal(runGate(delegate(prompt, sessionId), { config }), null);
157
+ assert.ok(isDeny(runGate(delegate(prompt, sessionId), { config })));
158
+ } finally {
159
+ cleanupSession(sessionId);
160
+ }
161
+ });
@@ -0,0 +1,69 @@
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, skills } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'dependency-skills-edge-'));
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
+ if (skills) {
19
+ for (const name of skills) {
20
+ mkdirSync(join(project, '.claude', 'skills', name), { recursive: true });
21
+ }
22
+ }
23
+ const out = execFileSync(process.execPath, [GATE], {
24
+ input: JSON.stringify(payload),
25
+ encoding: 'utf8',
26
+ cwd: project,
27
+ env: { ...process.env, HOME: project, USERPROFILE: project },
28
+ });
29
+ return out.trim() ? JSON.parse(out.trim()) : null;
30
+ }
31
+
32
+ function isWarn(result) {
33
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
34
+ }
35
+
36
+ // FIXED: skillNameMatches no longer does bidirectional substring matching. A skill dir must
37
+ // EQUAL the dependency or contain it as a whole `-`-segment, so an unrelated short name like
38
+ // "rip" no longer satisfies "stripe" — the gate now correctly warns (no false exemption).
39
+ test('FIXED: an unrelated skill directory name that is a substring of the dependency no longer satisfies the check', () => {
40
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
41
+ const result = runGate(writePackageJson(package_), { skills: ['rip'] });
42
+ assert.ok(isWarn(result), 'unrelated skill dir "rip" must NOT satisfy dependency "stripe"; the gate warns');
43
+
44
+ function writePackageJson(content) {
45
+ return { tool_name: 'Write', tool_input: { file_path: '/repo/package.json', content } };
46
+ }
47
+ });
48
+
49
+ test('OK: a genuinely matching skill segment still exempts (stripe-payments covers stripe)', () => {
50
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
51
+ const result = runGate(writePackageJson(package_), { skills: ['stripe-payments'] });
52
+ assert.equal(result, null, 'a skill whose name contains the dependency as a segment still covers it');
53
+
54
+ function writePackageJson(content) {
55
+ return { tool_name: 'Write', tool_input: { file_path: '/repo/package.json', content } };
56
+ }
57
+ });
58
+
59
+ // FIXED: content is now read through writtenContentOf, which covers write_to_file's `text`
60
+ // field (and every other native/MCP shape). An unreviewed new dependency written via an MCP
61
+ // write tool is no longer silently skipped.
62
+ test('FIXED: write_to_file with a different content field name is now checked', () => {
63
+ const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
64
+ const result = runGate({
65
+ tool_name: 'write_to_file',
66
+ tool_input: { file_path: '/repo/package.json', text: package_ },
67
+ });
68
+ assert.ok(isWarn(result), 'the new dependency written via write_to_file (text field) must now be caught');
69
+ });
@@ -1,118 +1,126 @@
1
- import { readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
4
-
5
- const GATE_ID = 'dependency-skills';
6
- const CONFIG_KEY = 'requireSkillForNewDependency';
7
-
8
- const DEFAULT_DEPS_WITHOUT_OWN_API = [
9
- 'clsx',
10
- 'classnames',
11
- 'lodash.debounce',
12
- 'lodash.throttle',
13
- 'lodash.merge',
14
- 'tslib',
15
- 'core-js',
16
- 'regenerator-runtime',
17
- ];
18
-
19
- function normalize(name) {
20
- return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
21
- }
22
-
23
- function isExempt(dependencyName, dependenciesWithoutOwnApi) {
24
- if (dependencyName.startsWith('@types/')) return true;
25
- return dependenciesWithoutOwnApi.includes(dependencyName);
26
- }
27
-
28
- function listSkillDirectories(skillsDirectoryPath) {
29
- try {
30
- return readdirSync(skillsDirectoryPath, { withFileTypes: true })
31
- .filter((entry) => entry.isDirectory())
32
- .map((entry) => entry.name);
33
- } catch {
34
- // No skills directory in this project — nothing to cross-check against.
35
- return [];
36
- }
37
- }
38
-
39
- function skillNameMatches(normalizedDependency, skillDirectoryName) {
40
- const normalizedSkill = normalize(skillDirectoryName);
41
- return (
42
- normalizedDependency.includes(normalizedSkill) ||
43
- normalizedSkill.includes(normalizedDependency)
44
- );
45
- }
46
-
47
- function hasMatchingSkill(dependencyName, skillDirectories) {
48
- const normalizedDependency = normalize(dependencyName);
49
- return skillDirectories.some((skillDirectoryName) =>
50
- skillNameMatches(normalizedDependency, skillDirectoryName),
51
- );
52
- }
53
-
54
- function needsSkillReview(dependencyName, parameters, skillDirectories) {
55
- if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
56
- return !hasMatchingSkill(dependencyName, skillDirectories);
57
- }
58
-
59
- /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
60
- function dependencyNamesIn(content) {
61
- let parsed;
62
- try {
63
- parsed = JSON.parse(content);
64
- } catch {
65
- // Mid-edit or partially written package.json — nothing reliable to check yet.
66
- return null;
67
- }
68
- const dependencies = {
69
- ...(parsed.dependencies ?? {}),
70
- ...(parsed.devDependencies ?? {}),
71
- };
72
- return Object.keys(dependencies);
73
- }
74
-
75
- runGate(
76
- {
77
- id: GATE_ID,
78
- configKey: CONFIG_KEY,
79
- enabledByDefault: true,
80
- defaultParams: {
81
- depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
82
- projectSkillsDir: '.claude/skills',
83
- },
84
- },
85
- ({ toolName, toolInput, parameters }) => {
86
- if (!TOOL_GROUPS.write.includes(toolName)) return;
87
-
88
- const filePath = toolInput?.file_path ?? '';
89
- if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
90
-
91
- const content = toolInput?.content ?? toolInput?.new_string;
92
- if (typeof content !== 'string') return;
93
-
94
- const dependencyNames = dependencyNamesIn(content);
95
- if (!dependencyNames || dependencyNames.length === 0) return;
96
-
97
- const skillDirectories = listSkillDirectories(
98
- join(process.cwd(), parameters.projectSkillsDir),
99
- );
100
-
101
- const unmatched = dependencyNames.filter((name) =>
102
- needsSkillReview(name, parameters, skillDirectories),
103
- );
104
- if (unmatched.length === 0) return;
105
-
106
- warn(
107
- GATE_ID,
108
- `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.`,
109
- );
110
- },
111
- );
112
-
113
- // Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
114
- // dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
115
- // .ai/skills-baseline.json freeze mechanism — that belongs to a separate
116
- // adopt-stack-and-skills.mjs script that does not exist in this repo. This
117
- // gate only does simple substring matching between dependency name and skill
118
- // directory name, both normalized.
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.