@devrik-tools/claude-gates 0.1.1 → 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 (105) 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 -0
  25. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +58 -0
  26. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  27. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  28. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  29. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  30. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  31. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  32. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  33. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  34. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  37. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  38. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  39. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  40. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  41. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  42. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  43. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  44. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  45. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  46. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  47. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  48. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  49. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  52. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  53. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  54. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  55. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  56. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  57. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  58. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  59. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  60. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  61. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  62. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  63. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  64. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  65. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  68. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  69. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  70. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  71. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  72. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  73. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  74. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  79. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  80. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  81. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  82. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  83. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  84. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  85. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  86. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  87. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  88. package/plugins/gates/hooks/hooks.json +346 -266
  89. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  90. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  91. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  92. package/plugins/gates/hooks/lib/config.mjs +172 -165
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  94. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  95. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  96. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  97. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  98. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  99. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  100. package/plugins/tasks/hooks/hooks.json +26 -0
  101. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  102. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  103. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  104. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  105. package/registry.json +793 -668
@@ -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.