@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
@@ -0,0 +1,207 @@
1
+ // Edge-case audit for circuit-breaker. Each test demonstrates a confirmed BUG or an OK.
2
+ // State lives at os.tmpdir()/claude-gates/circuit-breaker/<sessionId>/state.json, keyed by
3
+ // subagent_type. Each test uses a fresh randomUUID session id and cleans up after itself
4
+ // so it cannot contaminate other tests or other gates.
5
+ // Run: node --test circuit-breaker.edge.test.mjs
6
+ import assert from 'node:assert/strict';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { randomUUID } from 'node:crypto';
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+ import { test } from 'node:test';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
23
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
24
+
25
+ function runGate(payload, { config } = {}) {
26
+ const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-edge-'));
27
+ mkdirSync(join(project, '.git'));
28
+ if (config) {
29
+ mkdirSync(join(project, '.ai'));
30
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
31
+ }
32
+ const out = execFileSync(process.execPath, [GATE], {
33
+ input: JSON.stringify(payload),
34
+ encoding: 'utf8',
35
+ cwd: project,
36
+ env: { ...process.env, HOME: project, USERPROFILE: project },
37
+ });
38
+ return out.trim() ? JSON.parse(out.trim()) : null;
39
+ }
40
+
41
+ function delegate(prompt, sessionId, extra = {}) {
42
+ return {
43
+ tool_name: 'Agent',
44
+ session_id: sessionId,
45
+ tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
46
+ };
47
+ }
48
+ function isDeny(result) {
49
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
50
+ }
51
+
52
+ const ENABLED = { config: { gates: { requireCircuitBreakerOnDelegation: true } } };
53
+
54
+ function freshSession() {
55
+ return `edge-${randomUUID()}`;
56
+ }
57
+ function cleanupSession(sessionId) {
58
+ try {
59
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
60
+ } catch {
61
+ // best-effort cleanup
62
+ }
63
+ }
64
+
65
+ // Deliberately avoids any OVERRIDE_PATTERN word (retry/force/insist/reintenta/forz*) --
66
+ // a prompt describing a legitimate task can innocently contain one of those words (see
67
+ // the dedicated test below), which is itself a confirmed bug, so the other tests here
68
+ // use neutral wording to isolate what they are actually probing.
69
+ const SAME_TASK_PROMPT = [
70
+ 'Objetivo: fix the queue backoff cap in the payment worker.',
71
+ '',
72
+ 'QUE SI: update src/workers/payment.js to cap the backoff.',
73
+ ].join('\n');
74
+
75
+ test('FIXED: varying subagent_type per retry no longer resets the counter — the key is derived from task identity', () => {
76
+ // The key is now a hash of the identity signature (identityKey), not subagent_type. The
77
+ // exact same task retried under a different subagent_type string each time lands on the
78
+ // same key every time, so the breaker still trips.
79
+ const sessionId = freshSession();
80
+ try {
81
+ assert.equal(
82
+ runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker' }), ENABLED),
83
+ null,
84
+ );
85
+ assert.equal(
86
+ runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker-senior' }), ENABLED),
87
+ null,
88
+ );
89
+ assert.ok(
90
+ isDeny(runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker2' }), ENABLED)),
91
+ 'third identical attempt now denies even though it used yet another subagent_type: the key follows the task, not the label',
92
+ );
93
+ } finally {
94
+ cleanupSession(sessionId);
95
+ }
96
+ });
97
+
98
+ test('FIXED: forging a count field on disk no longer has any effect — there is no count field to forge', () => {
99
+ // The persisted shape is now a list of { signature, seenAt } occurrences with no
100
+ // `count` field at all. The attempt count is always recomputed by counting how many
101
+ // stored occurrences are similar to the CURRENT signature, so writing an arbitrary
102
+ // number onto the state file (there being no such field) cannot manufacture a deny.
103
+ const sessionId = freshSession();
104
+ const statePath = join(STATE_ROOT, sessionId, 'state.json');
105
+ try {
106
+ runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED); // one real occurrence recorded
107
+ assert.ok(existsSync(statePath), 'sanity: state file exists after first real attempt');
108
+
109
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
110
+ const [key] = Object.keys(state);
111
+ assert.ok(key, 'sanity: a key was recorded');
112
+ assert.equal(
113
+ state[key][0].count,
114
+ undefined,
115
+ 'the stored entry carries no count field to forge',
116
+ );
117
+ // Attempting to inject a forged count field has no effect: the gate never reads it.
118
+ state[key][0].count = 999;
119
+ writeFileSync(statePath, JSON.stringify(state), 'utf8');
120
+
121
+ const result = runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED);
122
+ assert.equal(
123
+ result,
124
+ null,
125
+ 'the forged count field is ignored: this is only the 2nd real occurrence, below the default threshold of 3',
126
+ );
127
+ } finally {
128
+ cleanupSession(sessionId);
129
+ }
130
+ });
131
+
132
+ test('FIXED: no session_id no longer disables the breaker — it falls back to a fixed bucket', () => {
133
+ // A payload with no session_id lands in the fixed NO_SESSION_BUCKET ("no-session")
134
+ // directory; identityKey (a hash of the prompt's own identity) still discriminates
135
+ // this specific task from any other task sharing that bucket.
136
+ const payload = {
137
+ tool_name: 'Agent',
138
+ tool_input: {
139
+ prompt: 'Objetivo: fix a one-off no-session edge case.\n\nQUE SI: update src/edge/no-session.js.',
140
+ subagent_type: 'worker-senior',
141
+ },
142
+ };
143
+ try {
144
+ assert.equal(runGate(payload, ENABLED), null);
145
+ assert.equal(runGate(payload, ENABLED), null);
146
+ assert.ok(
147
+ isDeny(runGate(payload, ENABLED)),
148
+ 'third identical attempt with no session_id now denies: the fallback bucket still tracks it',
149
+ );
150
+ } finally {
151
+ cleanupSession('no-session');
152
+ }
153
+ });
154
+
155
+ test('FIXED: OVERRIDE_PATTERN no longer matches the plain word "retry" inside ordinary task vocabulary', () => {
156
+ // OVERRIDE_PATTERN now requires an imperative phrasing (retry anyway/it/again, force
157
+ // it/this/anyway, reintentalo, insisti, ...), not the bare word "retry"/"force"
158
+ // appearing as normal task vocabulary. A task genuinely about retry logic no longer
159
+ // silently defeats the breaker.
160
+ const sessionId = freshSession();
161
+ try {
162
+ const retryWordingPrompt = [
163
+ 'Objetivo: fix the retry loop in the payment worker.',
164
+ '',
165
+ 'QUE SI: update src/workers/payment.js to cap retries.',
166
+ ].join('\n');
167
+ assert.equal(runGate(delegate(retryWordingPrompt, sessionId), ENABLED), null);
168
+ assert.equal(runGate(delegate(retryWordingPrompt, sessionId), ENABLED), null);
169
+ assert.ok(
170
+ isDeny(runGate(delegate(retryWordingPrompt, sessionId), ENABLED)),
171
+ 'third identical relaunch now denies: "retry" as ordinary task vocabulary is no longer read as an override',
172
+ );
173
+ } finally {
174
+ cleanupSession(sessionId);
175
+ }
176
+ });
177
+
178
+ test('OK: a genuine override imperative ("retry anyway") still allows and resets the counter', () => {
179
+ const sessionId = freshSession();
180
+ try {
181
+ assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
182
+ assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
183
+ assert.equal(
184
+ runGate(delegate(`${SAME_TASK_PROMPT}\n\nretry anyway.`, sessionId), ENABLED),
185
+ null,
186
+ 'genuine override imperative still allows and resets',
187
+ );
188
+ assert.equal(
189
+ runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED),
190
+ null,
191
+ 'counter was reset: the same prompt again does not deny immediately',
192
+ );
193
+ } finally {
194
+ cleanupSession(sessionId);
195
+ }
196
+ });
197
+
198
+ test('OK: identical prompt+subagent_type is tripped at the default retry threshold', () => {
199
+ const sessionId = freshSession();
200
+ try {
201
+ assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
202
+ assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
203
+ assert.ok(isDeny(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED)));
204
+ } finally {
205
+ cleanupSession(sessionId);
206
+ }
207
+ });