@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,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.