@devrik-tools/claude-gates 0.1.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,202 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
3
+
4
+ const GATE_ID = 'no-reconfirm';
5
+ const CONFIG_KEY = 'requireNoReconfirmOfApproved';
6
+
7
+ const DEFAULT_OVERLAP_THRESHOLD = 0.34;
8
+ const MIN_SHARED_WORDS = 2;
9
+ const MIN_SIGNIFICANT_WORD_LENGTH = 5;
10
+ const QUESTION_PREVIEW_LENGTH = 100;
11
+ const APPROVING_TURN_PREVIEW_LENGTH = 140;
12
+
13
+ function wordBoundary(alternatives) {
14
+ return new RegExp(
15
+ `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
16
+ 'iu',
17
+ );
18
+ }
19
+
20
+ const APPROVAL_PATTERN = wordBoundary(
21
+ 'go ahead|approved|i approve|authorized|proceed|do it|confirmed|i confirm|' +
22
+ 'sounds good,? do it|yes,? (do it|go ahead|proceed)|ok,? (do it|go ahead|proceed)',
23
+ );
24
+
25
+ const STOP_WORDS = new Set([
26
+ 'about',
27
+ 'above',
28
+ 'after',
29
+ 'again',
30
+ 'against',
31
+ 'before',
32
+ 'being',
33
+ 'below',
34
+ 'between',
35
+ 'could',
36
+ 'during',
37
+ 'having',
38
+ 'other',
39
+ 'people',
40
+ 'should',
41
+ 'system',
42
+ 'their',
43
+ 'there',
44
+ 'these',
45
+ 'those',
46
+ 'through',
47
+ 'under',
48
+ 'until',
49
+ 'where',
50
+ 'which',
51
+ 'while',
52
+ 'would',
53
+ ]);
54
+
55
+ function isHumanTurn(event) {
56
+ if (event?.type !== 'user' || event?.userType !== 'external') return false;
57
+ const content = event?.message?.content;
58
+ if (typeof content === 'string') return true;
59
+ if (Array.isArray(content))
60
+ return content.every((block) => block?.type === 'text');
61
+ return false;
62
+ }
63
+
64
+ function textOf(event) {
65
+ const content = event.message.content;
66
+ if (typeof content === 'string') return content;
67
+ return content.map((block) => block.text || '').join(' ');
68
+ }
69
+
70
+ function humanTurnsFrom(transcriptPath) {
71
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
72
+
73
+ let raw;
74
+ try {
75
+ raw = readFileSync(transcriptPath, 'utf8');
76
+ } catch {
77
+ return null;
78
+ }
79
+
80
+ const turns = [];
81
+ for (const line of raw.split('\n')) {
82
+ if (!line.trim()) continue;
83
+ let event;
84
+ try {
85
+ event = JSON.parse(line);
86
+ } catch {
87
+ continue;
88
+ }
89
+ if (isHumanTurn(event)) turns.push(textOf(event));
90
+ }
91
+ return turns;
92
+ }
93
+
94
+ function significantWords(text) {
95
+ const normalized = String(text)
96
+ .toLowerCase()
97
+ .normalize('NFD')
98
+ .replace(/[̀-ͯ]/g, '');
99
+ const words = normalized.match(/[a-z]+/g) || [];
100
+ return new Set(
101
+ words.filter(
102
+ (word) =>
103
+ word.length >= MIN_SIGNIFICANT_WORD_LENGTH && !STOP_WORDS.has(word),
104
+ ),
105
+ );
106
+ }
107
+
108
+ function topicOfQuestion(question) {
109
+ const header = String(question?.header || '');
110
+ const body = String(question?.question || '');
111
+ const optionLabels = (question?.options || [])
112
+ .map((option) => String(option?.label || ''))
113
+ .join(' ');
114
+ return `${header} ${body} ${optionLabels}`;
115
+ }
116
+
117
+ function turnThatAlreadyApprovedThisTopic(
118
+ humanTurns,
119
+ question,
120
+ overlapThreshold,
121
+ ) {
122
+ const topicWords = significantWords(topicOfQuestion(question));
123
+ if (topicWords.size === 0) return null;
124
+
125
+ for (const turn of humanTurns) {
126
+ if (!APPROVAL_PATTERN.test(turn)) continue;
127
+
128
+ const turnWords = significantWords(turn);
129
+ let shared = 0;
130
+ for (const word of topicWords) {
131
+ if (turnWords.has(word)) shared++;
132
+ }
133
+ const fraction = shared / topicWords.size;
134
+ if (shared >= MIN_SHARED_WORDS && fraction >= overlapThreshold) return turn;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function transcriptPathOf(rawPayload) {
140
+ try {
141
+ return JSON.parse(rawPayload)?.transcript_path;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function noticeFor(question, approvingTurn) {
148
+ const questionPreview = String(question?.question || '').slice(
149
+ 0,
150
+ QUESTION_PREVIEW_LENGTH,
151
+ );
152
+ const approvingTurnPreview = approvingTurn
153
+ .replace(/\s+/g, ' ')
154
+ .slice(0, APPROVING_TURN_PREVIEW_LENGTH);
155
+ return `Question "${questionPreview}" shares its topic with a turn where the user already gave explicit approval: "${approvingTurnPreview}". If it is the same decision, do not re-ask — proceed.`;
156
+ }
157
+
158
+ function collectReconfirmationNotices(questions, humanTurns, overlapThreshold) {
159
+ const notices = [];
160
+ for (const question of questions) {
161
+ const approvingTurn = turnThatAlreadyApprovedThisTopic(
162
+ humanTurns,
163
+ question,
164
+ overlapThreshold,
165
+ );
166
+ if (approvingTurn) notices.push(noticeFor(question, approvingTurn));
167
+ }
168
+ return notices;
169
+ }
170
+
171
+ runGate(
172
+ {
173
+ id: GATE_ID,
174
+ configKey: CONFIG_KEY,
175
+ enabledByDefault: true,
176
+ defaultParams: {
177
+ overlapThreshold: DEFAULT_OVERLAP_THRESHOLD,
178
+ },
179
+ },
180
+ ({ toolName, toolInput, parameters, rawPayload }) => {
181
+ if (!TOOL_GROUPS.question.includes(toolName)) return;
182
+
183
+ const questions = Array.isArray(toolInput?.questions)
184
+ ? toolInput.questions
185
+ : [];
186
+ if (questions.length === 0) return;
187
+
188
+ const transcriptPath = transcriptPathOf(rawPayload);
189
+ if (!transcriptPath) return;
190
+
191
+ const humanTurns = humanTurnsFrom(transcriptPath);
192
+ if (humanTurns === null || humanTurns.length === 0) return; // no readable transcript: cannot verify
193
+
194
+ const notices = collectReconfirmationNotices(
195
+ questions,
196
+ humanTurns,
197
+ parameters.overlapThreshold,
198
+ );
199
+ if (notices.length === 0) return;
200
+ warn(GATE_ID, notices.join('\n\n'));
201
+ },
202
+ );
@@ -0,0 +1,131 @@
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 newProject({ config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'no-reconfirm-'));
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
+ return project;
19
+ }
20
+
21
+ function writeTranscript(project, events) {
22
+ const transcriptPath = join(project, 'transcript.jsonl');
23
+ writeFileSync(
24
+ transcriptPath,
25
+ events.map((event) => JSON.stringify(event)).join('\n'),
26
+ );
27
+ return transcriptPath;
28
+ }
29
+
30
+ function humanTurn(text) {
31
+ return { type: 'user', userType: 'external', message: { content: text } };
32
+ }
33
+
34
+ function askQuestion(transcriptPath, question) {
35
+ return {
36
+ tool_name: 'AskUserQuestion',
37
+ transcript_path: transcriptPath,
38
+ tool_input: { questions: [question] },
39
+ };
40
+ }
41
+
42
+ function runGateIn(project, payload) {
43
+ const out = execFileSync(process.execPath, [GATE], {
44
+ input: JSON.stringify(payload),
45
+ encoding: 'utf8',
46
+ cwd: project,
47
+ // Isolate from the user's real global config: point homedir() at the temp
48
+ // project so the global-config fallback finds nothing (registry default).
49
+ env: { ...process.env, HOME: project, USERPROFILE: project },
50
+ });
51
+ return out.trim() ? JSON.parse(out.trim()) : null;
52
+ }
53
+
54
+ function isWarn(result) {
55
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
56
+ }
57
+
58
+ test('warns when re-asking a topic the user already approved', () => {
59
+ const project = newProject();
60
+ const transcriptPath = writeTranscript(project, [
61
+ humanTurn('go ahead, migrate the authentication database schema now'),
62
+ ]);
63
+ const question = {
64
+ header: 'Migration',
65
+ question: 'Should I migrate the authentication database schema?',
66
+ options: [],
67
+ };
68
+ assert.ok(isWarn(runGateIn(project, askQuestion(transcriptPath, question))));
69
+ });
70
+
71
+ test('allows a genuinely new question with no prior approval overlap', () => {
72
+ const project = newProject();
73
+ const transcriptPath = writeTranscript(project, [humanTurn('hello there')]);
74
+ const question = {
75
+ header: 'Deploy',
76
+ question: 'Should I deploy to production now?',
77
+ options: [],
78
+ };
79
+ assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
80
+ });
81
+
82
+ test('disabled by config: the gate does not run', () => {
83
+ const project = newProject({
84
+ config: { gates: { requireNoReconfirmOfApproved: false } },
85
+ });
86
+ const transcriptPath = writeTranscript(project, [
87
+ humanTurn('go ahead, migrate the authentication database schema now'),
88
+ ]);
89
+ const question = {
90
+ header: 'Migration',
91
+ question: 'Should I migrate the authentication database schema?',
92
+ options: [],
93
+ };
94
+ assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
95
+ });
96
+
97
+ test('degrades to allow when transcript_path is missing or unreadable', () => {
98
+ const project = newProject();
99
+ const question = {
100
+ header: 'Migration',
101
+ question: 'Should I migrate now?',
102
+ options: [],
103
+ };
104
+ assert.equal(
105
+ runGateIn(project, {
106
+ tool_name: 'AskUserQuestion',
107
+ tool_input: { questions: [question] },
108
+ }),
109
+ null,
110
+ );
111
+ });
112
+
113
+ test('overlapThreshold override changes sensitivity', () => {
114
+ const project = newProject({
115
+ config: {
116
+ gates: {
117
+ requireNoReconfirmOfApproved: { enabled: true, overlapThreshold: 0.99 },
118
+ },
119
+ },
120
+ });
121
+ const transcriptPath = writeTranscript(project, [
122
+ humanTurn('go ahead, migrate the authentication schema'),
123
+ ]);
124
+ const question = {
125
+ header: 'Migration',
126
+ question:
127
+ 'Should I migrate the authentication database schema now with backups?',
128
+ options: [],
129
+ };
130
+ assert.equal(runGateIn(project, askQuestion(transcriptPath, question)), null);
131
+ });
@@ -0,0 +1,147 @@
1
+ // protected-paths — denies writes and mutating shell commands that target a path
2
+ // containing one of the project's protected fragments (.env, lockfiles, the harness
3
+ // itself). Migrated from ~/.claude/hooks/guard-protected-paths.mjs: that guard protected
4
+ // hardcoded "code directories" (src, tests) to force delegation through a Leader role.
5
+ // This gate keeps only the path-protection mechanism — sensitive files no write should
6
+ // ever touch — and drops the Leader-role/delegation policy, which is a different concern.
7
+ //
8
+ // ── What a project can configure (params) ───────────────────────────────────────────
9
+ // protectedPaths path fragments (case-insensitive) that no write or mutating
10
+ // command may target. Replaces the built-in list wholesale.
11
+ // mutatingCommands shell command names treated as mutating, so a merely-reading
12
+ // command that mentions a protected path (echo, cat, grep) is
13
+ // never denied. Replaces the built-in list wholesale.
14
+ // The defaults live here, in the source, so a project reads them and knows exactly what
15
+ // its override replaces.
16
+
17
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
18
+
19
+ const GATE_ID = 'protected-paths';
20
+ const CONFIG_KEY = 'blockWritesToProtectedPaths';
21
+
22
+ const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
23
+ const SHELL_TOOLS = new Set(TOOL_GROUPS.shell);
24
+
25
+ const DEFAULT_PROTECTED_PATHS = [
26
+ '.env',
27
+ 'package-lock.json',
28
+ 'yarn.lock',
29
+ 'pnpm-lock.yaml',
30
+ 'hooks/',
31
+ ];
32
+
33
+ const DEFAULT_MUTATING_COMMANDS = [
34
+ 'touch',
35
+ 'rm',
36
+ 'cp',
37
+ 'mv',
38
+ 'sed\\s+-i',
39
+ 'tee',
40
+ 'install',
41
+ 'chmod',
42
+ 'chown',
43
+ 'truncate',
44
+ ];
45
+
46
+ function escapeRegExp(fragment) {
47
+ return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ }
49
+
50
+ /** The path a write tool targets, across the field names different tools use. */
51
+ function writeTargetFrom(toolInput) {
52
+ return String(
53
+ toolInput.TargetFile ??
54
+ toolInput.target_file ??
55
+ toolInput.file_path ??
56
+ toolInput.path ??
57
+ '',
58
+ );
59
+ }
60
+
61
+ function isProtectedPath(path, protectedPaths) {
62
+ const normalized = path.toLowerCase();
63
+ return protectedPaths.some((fragment) =>
64
+ normalized.includes(fragment.toLowerCase()),
65
+ );
66
+ }
67
+
68
+ function buildCommandPatterns(protectedPaths, mutatingCommands) {
69
+ const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
70
+ const mutatingAlternation = mutatingCommands.join('|');
71
+ const wordBoundary = String.raw`\b`;
72
+ const redirectPrefix = String.raw`>>?\s*\S*`;
73
+ return {
74
+ mutatingCommand: new RegExp(
75
+ `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
76
+ 'i',
77
+ ),
78
+ protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
79
+ redirectToProtected: new RegExp(
80
+ `${redirectPrefix}(${pathAlternation})`,
81
+ 'i',
82
+ ),
83
+ };
84
+ }
85
+
86
+ /** The write-tool branch: deny when the target path matches a protected fragment. */
87
+ function checkWrite(toolInput, protectedPaths) {
88
+ const target = writeTargetFrom(toolInput);
89
+ if (target && isProtectedPath(target, protectedPaths)) {
90
+ deny(
91
+ GATE_ID,
92
+ `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
93
+ );
94
+ }
95
+ }
96
+
97
+ /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
98
+ function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
99
+ const command = String(toolInput.CommandLine ?? toolInput.command ?? '');
100
+ if (!command.trim()) return;
101
+
102
+ const { mutatingCommand, protectedTarget, redirectToProtected } =
103
+ buildCommandPatterns(protectedPaths, mutatingCommands);
104
+
105
+ const targetsProtected =
106
+ redirectToProtected.test(command) ||
107
+ (mutatingCommand.test(command) && protectedTarget.test(command));
108
+
109
+ if (targetsProtected) {
110
+ deny(
111
+ GATE_ID,
112
+ `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
113
+ 'Reading (echo/cat/grep) is fine; modifying it is not.',
114
+ );
115
+ }
116
+ }
117
+
118
+ runGate(
119
+ {
120
+ id: GATE_ID,
121
+ configKey: CONFIG_KEY,
122
+ enabledByDefault: true,
123
+ defaultParams: {
124
+ protectedPaths: DEFAULT_PROTECTED_PATHS,
125
+ mutatingCommands: DEFAULT_MUTATING_COMMANDS,
126
+ },
127
+ },
128
+ ({ toolName, toolInput, parameters }) => {
129
+ const isWrite = WRITE_TOOLS.has(toolName);
130
+ const isShell = SHELL_TOOLS.has(toolName);
131
+ if (!isWrite && !isShell) return;
132
+
133
+ const protectedPaths = parameters.protectedPaths ?? [];
134
+ if (protectedPaths.length === 0) return;
135
+
136
+ if (isWrite) {
137
+ checkWrite(toolInput, protectedPaths);
138
+ return;
139
+ }
140
+
141
+ checkShellCommand(
142
+ toolInput,
143
+ protectedPaths,
144
+ parameters.mutatingCommands ?? [],
145
+ );
146
+ },
147
+ );
@@ -0,0 +1,75 @@
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
+ // Runs the gate as its own process (it calls process.exit), feeding a payload on stdin
12
+ // from inside a temp project so the gate's config lookup is isolated. Returns the parsed
13
+ // stdout, or null when the gate allowed (no output).
14
+ function runGate(payload, { config } = {}) {
15
+ const project = mkdtempSync(join(tmpdir(), 'protected-paths-'));
16
+ mkdirSync(join(project, '.git'));
17
+ if (config) {
18
+ mkdirSync(join(project, '.ai'));
19
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
20
+ }
21
+ const out = execFileSync(process.execPath, [GATE], {
22
+ input: JSON.stringify(payload),
23
+ encoding: 'utf8',
24
+ cwd: project,
25
+ // Isolate from the user's real global config: point homedir() at the temp
26
+ // project so the global-config fallback finds nothing (registry default).
27
+ env: { ...process.env, HOME: project, USERPROFILE: project },
28
+ });
29
+ return out.trim() ? JSON.parse(out.trim()) : null;
30
+ }
31
+
32
+ function write(filePath) {
33
+ return { tool_name: 'Write', tool_input: { file_path: filePath } };
34
+ }
35
+ function bash(command) {
36
+ return { tool_name: 'Bash', tool_input: { command } };
37
+ }
38
+ function isDeny(result) {
39
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
+ }
41
+
42
+ test('denies writing to .env and a mutating shell command targeting it', () => {
43
+ assert.ok(isDeny(runGate(write('/repo/.env'))));
44
+ assert.ok(isDeny(runGate(bash('rm .env'))));
45
+ assert.ok(isDeny(runGate(bash('touch package-lock.json'))));
46
+ });
47
+
48
+ test('allows an innocuous write and a read-only command mentioning a protected path', () => {
49
+ assert.equal(runGate(write('/repo/src/index.js')), null);
50
+ assert.equal(runGate(bash('echo "reading .env for debugging"')), null);
51
+ assert.equal(runGate(bash('cat .env')), null);
52
+ });
53
+
54
+ test('disabled by config: the gate does not run', () => {
55
+ assert.equal(
56
+ runGate(write('/repo/.env'), {
57
+ config: { gates: { blockWritesToProtectedPaths: false } },
58
+ }),
59
+ null,
60
+ );
61
+ });
62
+
63
+ test('project protectedPaths replaces the built-in list', () => {
64
+ const config = {
65
+ gates: {
66
+ blockWritesToProtectedPaths: {
67
+ enabled: true,
68
+ protectedPaths: ['secrets.yaml'],
69
+ },
70
+ },
71
+ };
72
+ // .env no longer protected under the override
73
+ assert.equal(runGate(write('/repo/.env'), { config }), null);
74
+ assert.ok(isDeny(runGate(write('/repo/secrets.yaml'), { config })));
75
+ });
@@ -0,0 +1,64 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
4
+
5
+ const GATE_ID = 'recurrence-lock';
6
+ const CONFIG_KEY = 'blockRegisteredRecurrences';
7
+
8
+ // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
9
+ // from a full tracking module (../scripts/memory/recurrences.mjs) with
10
+ // close/decide/pending subcommands. That infrastructure belongs to another
11
+ // project and does not exist here. This gate only reads a plain state file
12
+ // at .ai/reincidencias.json if present, with the shape:
13
+ // { classes: [{ class, occurrences: [...], status }] }
14
+ // If neither the module nor the file exists, it allows silently — there is
15
+ // nothing to enforce without a recurrence record.
16
+ function loadOpenRecurrences(projectRoot, thresholdAppearances) {
17
+ const filePath = join(projectRoot, '.ai', 'reincidencias.json');
18
+ if (!existsSync(filePath)) return [];
19
+
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(readFileSync(filePath, 'utf8'));
23
+ } catch {
24
+ return [];
25
+ }
26
+
27
+ const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
28
+ return classes.filter((entry) => {
29
+ const occurrenceCount = Array.isArray(entry?.occurrences)
30
+ ? entry.occurrences.length
31
+ : 0;
32
+ const status = String(entry?.status ?? '').toLowerCase();
33
+ const isClosed = status === 'closed' || status === 'cerrada';
34
+ return occurrenceCount >= thresholdAppearances && !isClosed;
35
+ });
36
+ }
37
+
38
+ runGate(
39
+ {
40
+ id: GATE_ID,
41
+ configKey: CONFIG_KEY,
42
+ enabledByDefault: true,
43
+ defaultParams: {
44
+ thresholdAppearances: 2,
45
+ },
46
+ },
47
+ ({ toolName, parameters }) => {
48
+ const isExecution = TOOL_GROUPS.execution.includes(toolName);
49
+ const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
50
+ if (!isExecution && !isDelegation) return;
51
+
52
+ const openRecurrences = loadOpenRecurrences(
53
+ process.cwd(),
54
+ parameters.thresholdAppearances,
55
+ );
56
+ if (openRecurrences.length === 0) return;
57
+
58
+ const names = openRecurrences.map((entry) => entry.class).join(', ');
59
+ deny(
60
+ GATE_ID,
61
+ `Registered recurring issue classes are still open and at/above threshold: ${names}. Resolve or close them before proceeding.`,
62
+ );
63
+ },
64
+ );