@devrik-tools/claude-gates 0.7.2 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,5 +1,10 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
1
+ // no-reconfirm asking again what the user already approved gets a reminder to proceed.
2
+ // Advisory only. An approval is a human turn that STARTS with an affirmative; a turn that
3
+ // merely contains "proceed" ("how should I proceed?") is a question, not an approval. Only
4
+ // the tail of the transcript is read: an approval given hundreds of turns ago is stale.
5
+
6
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
7
+ import { runGate, warn, toolInGroups } from '../../lib/hook-io.mjs';
3
8
 
4
9
  const GATE_ID = 'no-reconfirm';
5
10
  const CONFIG_KEY = 'requireNoReconfirmOfApproved';
@@ -9,17 +14,33 @@ const MIN_SHARED_WORDS = 2;
9
14
  const MIN_SIGNIFICANT_WORD_LENGTH = 5;
10
15
  const QUESTION_PREVIEW_LENGTH = 100;
11
16
  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)',
17
+ const TRANSCRIPT_TAIL_LINES = 400;
18
+ const KIB = 1024;
19
+ const TRANSCRIPT_TAIL_BYTES = KIB * KIB;
20
+
21
+ const AFFIRMATIVES = [
22
+ 'yes',
23
+ 'yeah',
24
+ 'yep',
25
+ 'ok',
26
+ 'okay',
27
+ 'sure',
28
+ 'go ahead',
29
+ 'proceed',
30
+ 'approved',
31
+ 'do it',
32
+ 's[ií]',
33
+ 'de acuerdo',
34
+ 'adelante',
35
+ 'procede',
36
+ 'hazlo',
37
+ 'correcto',
38
+ 'confirmo',
39
+ 'aprobado',
40
+ ];
41
+ const APPROVAL_START_PATTERN = new RegExp(
42
+ String.raw`^\s*(?:${AFFIRMATIVES.join('|')})(?![\p{L}\p{N}_])`,
43
+ 'iu',
23
44
  );
24
45
 
25
46
  const STOP_WORDS = new Set([
@@ -67,18 +88,29 @@ function textOf(event) {
67
88
  return content.map((block) => block.text || '').join(' ');
68
89
  }
69
90
 
70
- function humanTurnsFrom(transcriptPath) {
71
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
72
-
73
- let raw;
91
+ function tailLinesOf(path) {
92
+ let descriptor;
74
93
  try {
75
- raw = readFileSync(transcriptPath, 'utf8');
94
+ descriptor = openSync(path, 'r');
95
+ const { size } = fstatSync(descriptor);
96
+ const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
97
+ const buffer = Buffer.alloc(length);
98
+ readSync(descriptor, buffer, 0, length, size - length);
99
+ const lines = buffer.toString('utf8').split('\n');
100
+ if (length < size) lines.shift();
101
+ return lines.slice(-TRANSCRIPT_TAIL_LINES);
76
102
  } catch {
77
103
  return null;
104
+ } finally {
105
+ if (descriptor !== undefined) closeSync(descriptor);
78
106
  }
107
+ }
79
108
 
109
+ function humanTurnsFrom(transcriptPath) {
110
+ const lines = tailLinesOf(transcriptPath);
111
+ if (lines === null) return null;
80
112
  const turns = [];
81
- for (const line of raw.split('\n')) {
113
+ for (const line of lines) {
82
114
  if (!line.trim()) continue;
83
115
  let event;
84
116
  try {
@@ -105,10 +137,14 @@ function significantWords(text) {
105
137
  );
106
138
  }
107
139
 
140
+ function optionsOf(question) {
141
+ return Array.isArray(question?.options) ? question.options : [];
142
+ }
143
+
108
144
  function topicOfQuestion(question) {
109
145
  const header = String(question?.header || '');
110
146
  const body = String(question?.question || '');
111
- const optionLabels = (question?.options || [])
147
+ const optionLabels = optionsOf(question)
112
148
  .map((option) => String(option?.label || ''))
113
149
  .join(' ');
114
150
  return `${header} ${body} ${optionLabels}`;
@@ -123,8 +159,7 @@ function turnThatAlreadyApprovedThisTopic(
123
159
  if (topicWords.size === 0) return null;
124
160
 
125
161
  for (const turn of humanTurns) {
126
- if (!APPROVAL_PATTERN.test(turn)) continue;
127
-
162
+ if (!APPROVAL_START_PATTERN.test(turn)) continue;
128
163
  const turnWords = significantWords(turn);
129
164
  let shared = 0;
130
165
  for (const word of topicWords) {
@@ -144,6 +179,17 @@ function transcriptPathOf(rawPayload) {
144
179
  }
145
180
  }
146
181
 
182
+ function questionsOf(toolInput) {
183
+ if (Array.isArray(toolInput?.questions)) {
184
+ return toolInput.questions.filter(
185
+ (question) => question && typeof question === 'object',
186
+ );
187
+ }
188
+ if (typeof toolInput?.question === 'string')
189
+ return [{ question: toolInput.question }];
190
+ return [];
191
+ }
192
+
147
193
  function noticeFor(question, approvingTurn) {
148
194
  const questionPreview = String(question?.question || '').slice(
149
195
  0,
@@ -155,47 +201,39 @@ function noticeFor(question, approvingTurn) {
155
201
  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
202
  }
157
203
 
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
204
  runGate(
172
205
  {
173
206
  id: GATE_ID,
174
207
  configKey: CONFIG_KEY,
175
208
  enabledByDefault: true,
209
+ severity: 'warn',
176
210
  defaultParams: {
177
211
  overlapThreshold: DEFAULT_OVERLAP_THRESHOLD,
178
212
  },
179
213
  },
180
214
  ({ toolName, toolInput, parameters, rawPayload }) => {
181
- if (!TOOL_GROUPS.question.includes(toolName)) return;
215
+ if (!toolInGroups(toolName, ['question'])) return;
182
216
 
183
- const questions = Array.isArray(toolInput?.questions)
184
- ? toolInput.questions
185
- : [];
217
+ const questions = questionsOf(toolInput);
186
218
  if (questions.length === 0) return;
187
219
 
188
220
  const transcriptPath = transcriptPathOf(rawPayload);
189
- if (!transcriptPath) return;
221
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return;
190
222
 
191
223
  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
- );
224
+ if (!humanTurns || humanTurns.length === 0) return;
225
+
226
+ const notices = questions
227
+ .map((question) => ({
228
+ question,
229
+ approvingTurn: turnThatAlreadyApprovedThisTopic(
230
+ humanTurns,
231
+ question,
232
+ parameters.overlapThreshold,
233
+ ),
234
+ }))
235
+ .filter((entry) => entry.approvingTurn)
236
+ .map((entry) => noticeFor(entry.question, entry.approvingTurn));
199
237
  if (notices.length === 0) return;
200
238
  warn(CONFIG_KEY, notices.join('\n\n'));
201
239
  },
@@ -1,22 +1,18 @@
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
-
1
+ // protected-paths — denies a write, a shell redirection or a mutating shell command that
2
+ // targets a protected path (.env files, lockfiles, the harness's own hooks). Deliberate
3
+ // semantics: a fragment without a slash names a file by its exact basename (`.env` is not
4
+ // `.env.example`), a fragment ending in a slash names a contiguous directory sequence
5
+ // (`.claude/hooks/` is not `src/hooks/`). Only the command segment that RUNS a mutating
6
+ // command is judged, so `npm install; cat .env` reads and is allowed. A path built from a
7
+ // variable is not resolved.
8
+
9
+ import { posix } from 'node:path';
17
10
  import {
18
- runGate,
11
+ compileRegexList,
19
12
  deny,
13
+ runGate,
14
+ shellCommandOf,
15
+ shellWrittenPaths,
20
16
  toolInGroups,
21
17
  writtenPathOf,
22
18
  } from '../../lib/hook-io.mjs';
@@ -26,94 +22,145 @@ const CONFIG_KEY = 'blockWritesToProtectedPaths';
26
22
 
27
23
  const DEFAULT_PROTECTED_PATHS = [
28
24
  '.env',
25
+ '.env.local',
26
+ '.env.production',
27
+ '.env.development',
29
28
  'package-lock.json',
30
29
  'yarn.lock',
31
30
  'pnpm-lock.yaml',
32
- 'hooks/',
31
+ '.claude/hooks/',
33
32
  ];
34
33
 
34
+ // Regex sources matched at the start of a command segment. A command is mutating when its
35
+ // ARGUMENTS name the protected path; redirections are covered separately by shellWrittenPaths.
35
36
  const DEFAULT_MUTATING_COMMANDS = [
36
37
  'touch',
37
38
  'rm',
38
39
  'cp',
39
40
  'mv',
40
- 'sed\\s+-i',
41
+ String.raw`sed\s+(?:-i|--in-place)`,
41
42
  'tee',
42
43
  'install',
43
44
  'chmod',
44
45
  'chown',
45
46
  'truncate',
47
+ 'unlink',
48
+ 'dd',
49
+ String.raw`perl\s+-\w*i`,
50
+ String.raw`git\s+checkout\s+--`,
51
+ String.raw`git\s+restore`,
52
+ 'Set-Content',
53
+ 'Add-Content',
54
+ 'Out-File',
55
+ 'Remove-Item',
56
+ 'del',
57
+ 'erase',
58
+ 'Copy-Item',
59
+ 'Move-Item',
60
+ 'New-Item',
61
+ 'Rename-Item',
62
+ 'copy',
63
+ 'move',
64
+ 'ren',
46
65
  ];
47
66
 
48
- function escapeRegExp(fragment) {
49
- return fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ // ── Path matching ───────────────────────────────────────────────────────────────────
68
+ function pathSegmentsOf(path) {
69
+ const normalized = posix.normalize(String(path).replace(/\\/g, '/'));
70
+ return normalized.toLowerCase().split('/').filter(Boolean);
50
71
  }
51
72
 
52
- // Windows delivers absolute paths with backslashes ('C:\repo\hooks\gates\x.mjs'), but a
53
- // protected fragment like 'hooks/' is written with a forward slash. Normalizing the
54
- // separator before comparing means the fragment matches regardless of which OS produced
55
- // the path, without weakening the existing case-insensitive comparison.
56
- function toForwardSlashes(path) {
57
- return path.replace(/\\/g, '/');
73
+ function fragmentRule(fragment) {
74
+ const segments = pathSegmentsOf(fragment);
75
+ return {
76
+ fragment,
77
+ segments,
78
+ anchoredAtEnd: !String(fragment).endsWith('/'),
79
+ };
58
80
  }
59
81
 
60
- function isProtectedPath(path, protectedPaths) {
61
- const normalized = toForwardSlashes(path).toLowerCase();
62
- return protectedPaths.some((fragment) =>
63
- normalized.includes(fragment.toLowerCase()),
82
+ function segmentsMatchAt(pathSegments, ruleSegments, offset) {
83
+ return ruleSegments.every(
84
+ (segment, index) => pathSegments[offset + index] === segment,
64
85
  );
65
86
  }
66
87
 
67
- function buildCommandPatterns(protectedPaths, mutatingCommands) {
68
- const pathAlternation = protectedPaths.map(escapeRegExp).join('|');
69
- const mutatingAlternation = mutatingCommands.join('|');
70
- const wordBoundary = String.raw`\b`;
71
- const redirectPrefix = String.raw`>>?\s*\S*`;
72
- return {
73
- mutatingCommand: new RegExp(
74
- `${wordBoundary}(${mutatingAlternation})${wordBoundary}`,
75
- 'i',
76
- ),
77
- protectedTarget: new RegExp(`(${pathAlternation})`, 'i'),
78
- redirectToProtected: new RegExp(
79
- `${redirectPrefix}(${pathAlternation})`,
80
- 'i',
81
- ),
82
- };
88
+ function ruleMatches(rule, pathSegments) {
89
+ const { segments, anchoredAtEnd } = rule;
90
+ if (segments.length === 0 || pathSegments.length < segments.length)
91
+ return false;
92
+ if (anchoredAtEnd) {
93
+ return segmentsMatchAt(
94
+ pathSegments,
95
+ segments,
96
+ pathSegments.length - segments.length,
97
+ );
98
+ }
99
+ const lastOffset = pathSegments.length - segments.length;
100
+ for (let offset = 0; offset <= lastOffset; offset += 1) {
101
+ if (segmentsMatchAt(pathSegments, segments, offset)) return true;
102
+ }
103
+ return false;
83
104
  }
84
105
 
85
- /** The write-tool branch: deny when the target path matches a protected fragment. */
86
- function checkWrite(toolInput, protectedPaths) {
87
- const target = writtenPathOf(toolInput);
88
- if (target && isProtectedPath(target, protectedPaths)) {
89
- deny(
90
- CONFIG_KEY,
91
- `Writing to '${target}' is not allowed: it matches a protected path (${protectedPaths.join(', ')}).`,
92
- );
106
+ function protectedFragmentOf(path, rules) {
107
+ const pathSegments = pathSegmentsOf(path);
108
+ return rules.find((rule) => ruleMatches(rule, pathSegments))?.fragment;
109
+ }
110
+
111
+ // ── Shell command arguments ─────────────────────────────────────────────────────────
112
+ const SEGMENT_SEPARATOR = /;|&&|\|\||\||\n/;
113
+ const ARGUMENT_PATTERN = /"([^"]*)"|'([^']*)'|(\S+)/g;
114
+ const KEY_VALUE_PREFIX = /^-{0,2}[\w-]+=/;
115
+
116
+ function stripLeadingWrappers(segment) {
117
+ const tokens = segment.trim().split(/\s+/);
118
+ while (tokens.length > 0 && /^(?:sudo|command|env|\w+=\S*)$/i.test(tokens[0]))
119
+ tokens.shift();
120
+ return tokens.join(' ');
121
+ }
122
+
123
+ function argumentsOf(text) {
124
+ const found = [];
125
+ for (const match of text.matchAll(ARGUMENT_PATTERN)) {
126
+ const token =
127
+ match[1] ?? match[2] ?? match[3].replace(KEY_VALUE_PREFIX, '');
128
+ if (token && !/[$`%]/.test(token)) found.push(token);
93
129
  }
130
+ return found;
94
131
  }
95
132
 
96
- /** The shell-tool branch: deny a mutating command whose target matches a protected path. */
97
- function checkShellCommand(toolInput, protectedPaths, mutatingCommands) {
98
- const rawCommand = String(toolInput.CommandLine ?? toolInput.command ?? '');
99
- if (!rawCommand.trim()) return;
100
- // Same separator normalization as the write branch: a command embedding a Windows
101
- // path ('rm C:\repo\hooks\gates\evil.mjs') must still match a 'hooks/' fragment.
102
- const command = toForwardSlashes(rawCommand);
103
-
104
- const { mutatingCommand, protectedTarget, redirectToProtected } =
105
- buildCommandPatterns(protectedPaths, mutatingCommands);
106
-
107
- const targetsProtected =
108
- redirectToProtected.test(command) ||
109
- (mutatingCommand.test(command) && protectedTarget.test(command));
110
-
111
- if (targetsProtected) {
112
- deny(
113
- CONFIG_KEY,
114
- `This command targets a protected path (${protectedPaths.join(', ')}) with a mutating operation. ` +
115
- 'Reading (echo/cat/grep) is fine; modifying it is not.',
116
- );
133
+ function mutatingCommandPatterns(sources) {
134
+ return compileRegexList(
135
+ sources.map((source) => String.raw`^(?:${source})(?=\s|$)`),
136
+ ).patterns;
137
+ }
138
+
139
+ function mutatedArguments(command, patterns) {
140
+ const found = [];
141
+ for (const rawSegment of String(command).split(SEGMENT_SEPARATOR)) {
142
+ const segment = stripLeadingWrappers(rawSegment);
143
+ const matched = patterns
144
+ .map((pattern) => pattern.exec(segment))
145
+ .find(Boolean);
146
+ if (matched) found.push(...argumentsOf(segment.slice(matched[0].length)));
147
+ }
148
+ return found;
149
+ }
150
+
151
+ function denyProtected(target, fragment) {
152
+ deny(
153
+ CONFIG_KEY,
154
+ `Writing to '${target}' is not allowed: it matches the protected path '${fragment}'. ` +
155
+ 'Reading it is fine; to change it, ask the user, or edit protectedPaths under ' +
156
+ `${CONFIG_KEY} in .ai/config.json.`,
157
+ );
158
+ }
159
+
160
+ function checkTargets(targets, rules) {
161
+ for (const target of targets) {
162
+ const fragment = protectedFragmentOf(target, rules);
163
+ if (fragment) denyProtected(target, fragment);
117
164
  }
118
165
  }
119
166
 
@@ -128,22 +175,22 @@ runGate(
128
175
  },
129
176
  },
130
177
  ({ toolName, toolInput, parameters }) => {
131
- const isWrite = toolInGroups(toolName, ['write']);
132
- const isShell = toolInGroups(toolName, ['shell']);
133
- if (!isWrite && !isShell) return;
178
+ const rules = parameters.protectedPaths
179
+ .map(fragmentRule)
180
+ .filter((rule) => rule.segments.length > 0);
181
+ if (rules.length === 0) return;
134
182
 
135
- const protectedPaths = parameters.protectedPaths ?? [];
136
- if (protectedPaths.length === 0) return;
137
-
138
- if (isWrite) {
139
- checkWrite(toolInput, protectedPaths);
183
+ if (toolInGroups(toolName, ['write'])) {
184
+ checkTargets([writtenPathOf(toolInput)].filter(Boolean), rules);
140
185
  return;
141
186
  }
142
-
143
- checkShellCommand(
144
- toolInput,
145
- protectedPaths,
146
- parameters.mutatingCommands ?? [],
147
- );
187
+ if (!toolInGroups(toolName, ['shell'])) return;
188
+
189
+ const command = shellCommandOf(toolInput);
190
+ if (!command.trim()) return;
191
+ checkTargets(shellWrittenPaths(command), rules);
192
+ const patterns = mutatingCommandPatterns(parameters.mutatingCommands);
193
+ if (patterns.length > 0)
194
+ checkTargets(mutatedArguments(command, patterns), rules);
148
195
  },
149
196
  );
@@ -1,66 +1,108 @@
1
- import { readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { runGate, deny, toolInGroups } from '../../lib/hook-io.mjs';
1
+ // recurrence-lock while a registered defect class is open and at/above the threshold,
2
+ // mutating work is denied until its root cause is closed. Whatever is needed to CLOSE the
3
+ // class stays allowed (editing the recurrence file, read-only inspection, read-only
4
+ // delegation), otherwise the lock would deadlock on its own remedy.
5
+
6
+ import { basename, join } from 'node:path';
7
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
8
+ import { isExemptQuery } from '../../lib/delegation.mjs';
9
+ import {
10
+ runGate,
11
+ deny,
12
+ toolInGroups,
13
+ writtenPathOf,
14
+ shellCommandOf,
15
+ shellWrittenPaths,
16
+ delegationPromptOf,
17
+ } from '../../lib/hook-io.mjs';
4
18
 
5
19
  const GATE_ID = 'recurrence-lock';
6
20
  const CONFIG_KEY = 'blockRegisteredRecurrences';
7
21
 
8
- /**
9
- * De-duplicates occurrences before counting them against the threshold. An occurrence is
10
- * identified by its `id`/`hash` field when present (the intended identity for a logged
11
- * occurrence); a bare-scalar entry (number/string, as in the plain fixture shape used by
12
- * tests) is deduplicated by its own value instead, since it carries no other identity.
13
- * Without this, the same occurrence logged twice (a race or a bug in the writer) inflates
14
- * the count and trips the lock as if two distinct occurrences had happened.
15
- */
22
+ const RECURRENCES_FILE = 'reincidencias.json';
23
+ const RECURRENCES_RELATIVE_PATH = join('.ai', RECURRENCES_FILE);
24
+ const DEFAULT_THRESHOLD = 2;
25
+
26
+ const READ_ONLY_COMMANDS = [
27
+ 'git status',
28
+ 'git log',
29
+ 'git diff',
30
+ 'git show',
31
+ 'git branch',
32
+ 'cat',
33
+ 'ls',
34
+ 'dir',
35
+ 'pwd',
36
+ 'echo',
37
+ 'grep',
38
+ 'rg',
39
+ 'find',
40
+ 'head',
41
+ 'tail',
42
+ 'wc',
43
+ 'type',
44
+ 'get-content',
45
+ 'get-childitem',
46
+ 'node --test',
47
+ 'npm test',
48
+ 'npm run lint',
49
+ ];
50
+ const SEGMENT_SEPARATOR = /&&|\|\||[;|\n]/;
51
+
52
+ function segmentIsReadOnly(segment) {
53
+ const words = segment.trim().toLowerCase().split(/\s+/);
54
+ return READ_ONLY_COMMANDS.some((command) => {
55
+ const expected = command.split(' ');
56
+ return expected.every((word, index) => words[index] === word);
57
+ });
58
+ }
59
+
60
+ function isReadOnlyCommand(command) {
61
+ if (!command.trim()) return false;
62
+ if (shellWrittenPaths(command).length > 0) return false;
63
+ return command.split(SEGMENT_SEPARATOR).every(segmentIsReadOnly);
64
+ }
65
+
66
+ function isExemptCall(toolName, toolInput) {
67
+ if (toolInGroups(toolName, ['write']))
68
+ return basename(writtenPathOf(toolInput)) === RECURRENCES_FILE;
69
+ if (toolInGroups(toolName, ['shell']))
70
+ return isReadOnlyCommand(shellCommandOf(toolInput));
71
+ if (toolInGroups(toolName, ['delegation']))
72
+ return isExemptQuery(delegationPromptOf(toolInput));
73
+ return false;
74
+ }
75
+
76
+ // Identity is id/hash when present, else the value itself: the same occurrence logged
77
+ // twice must not count as two.
16
78
  function dedupOccurrences(occurrences) {
17
79
  const seen = new Set();
18
- const deduped = [];
19
- for (const occurrence of occurrences) {
80
+ return occurrences.filter((occurrence) => {
20
81
  const identity =
21
82
  occurrence && typeof occurrence === 'object'
22
83
  ? String(occurrence.id ?? occurrence.hash ?? JSON.stringify(occurrence))
23
84
  : String(occurrence);
24
- if (seen.has(identity)) continue;
85
+ if (seen.has(identity)) return false;
25
86
  seen.add(identity);
26
- deduped.push(occurrence);
27
- }
28
- return deduped;
87
+ return true;
88
+ });
29
89
  }
30
90
 
31
- // The source guard (guard-reincidence-lock.mjs) reads pending recurrences
32
- // from a full tracking module (../scripts/memory/recurrences.mjs) with
33
- // close/decide/pending subcommands. That infrastructure belongs to another
34
- // project and does not exist here. This gate only reads a plain state file
35
- // at .ai/reincidencias.json if present, with the shape:
36
- // { classes: [{ class, occurrences: [...], status }] }
37
- // If neither the module nor the file exists, it allows silently — there is
38
- // nothing to enforce without a recurrence record.
39
- function loadOpenRecurrences(projectRoot, thresholdAppearances) {
40
- const filePath = join(projectRoot, '.ai', 'reincidencias.json');
41
- if (!existsSync(filePath)) return [];
42
-
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(readFileSync(filePath, 'utf8'));
46
- } catch {
47
- return [];
48
- }
91
+ function isClosed(entry) {
92
+ const status = String(entry?.status ?? '')
93
+ .trim()
94
+ .toLowerCase();
95
+ return status === 'closed' || status === 'cerrada';
96
+ }
49
97
 
98
+ function loadOpenRecurrences(root, thresholdAppearances) {
99
+ const parsed = readJsonOrNull(join(root, RECURRENCES_RELATIVE_PATH));
50
100
  const classes = Array.isArray(parsed?.classes) ? parsed.classes : [];
51
101
  return classes.filter((entry) => {
52
102
  const occurrences = Array.isArray(entry?.occurrences)
53
103
  ? dedupOccurrences(entry.occurrences)
54
104
  : [];
55
- const occurrenceCount = occurrences.length;
56
- // Trim before lowercasing so a padded status ("Closed ", "cerrada\n") is recognized —
57
- // a status compared without trimming left a validly-closed (but padded) recurrence
58
- // stuck as still-open, blocking unrelated work indefinitely.
59
- const status = String(entry?.status ?? '')
60
- .trim()
61
- .toLowerCase();
62
- const isClosed = status === 'closed' || status === 'cerrada';
63
- return occurrenceCount >= thresholdAppearances && !isClosed;
105
+ return occurrences.length >= thresholdAppearances && !isClosed(entry);
64
106
  });
65
107
  }
66
108
 
@@ -70,15 +112,16 @@ runGate(
70
112
  configKey: CONFIG_KEY,
71
113
  enabledByDefault: true,
72
114
  defaultParams: {
73
- thresholdAppearances: 2,
115
+ thresholdAppearances: DEFAULT_THRESHOLD,
74
116
  },
75
117
  },
76
- ({ toolName, parameters }) => {
118
+ ({ toolName, toolInput, parameters, cwd }) => {
77
119
  if (!toolInGroups(toolName, ['execution', 'delegation'])) return;
120
+ if (isExemptCall(toolName, toolInput)) return;
78
121
 
79
- const recurrencesFile = join('.ai', 'reincidencias.json');
122
+ const root = projectRootOf(cwd) ?? cwd;
80
123
  const openRecurrences = loadOpenRecurrences(
81
- process.cwd(),
124
+ root,
82
125
  parameters.thresholdAppearances,
83
126
  );
84
127
  if (openRecurrences.length === 0) return;
@@ -88,9 +131,10 @@ runGate(
88
131
  CONFIG_KEY,
89
132
  `Registered recurring issue class(es) still open and at/above the ` +
90
133
  `${parameters.thresholdAppearances}-occurrence threshold: ${names}. Fix the root ` +
91
- `cause of the class (not this one instance), then in ${recurrencesFile} set that ` +
92
- 'class\'s "status" to "closed" (or "cerrada") before proceeding — no other file to ' +
93
- 'find, this is the only source this gate reads.',
134
+ `cause of the class (not this one instance), then in ${RECURRENCES_RELATIVE_PATH} set that ` +
135
+ 'class\'s "status" to "closed" (or "cerrada") before proceeding — editing that file, ' +
136
+ 'read-only commands (git status/log/diff, cat, grep, tests) and read-only delegations ' +
137
+ 'stay allowed so you can do exactly that.',
94
138
  );
95
139
  },
96
140
  );