@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'
|
|
22
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let raw;
|
|
91
|
+
function tailLinesOf(path) {
|
|
92
|
+
let descriptor;
|
|
74
93
|
try {
|
|
75
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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 (!
|
|
215
|
+
if (!toolInGroups(toolName, ['question'])) return;
|
|
182
216
|
|
|
183
|
-
const 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
|
|
193
|
-
|
|
194
|
-
const notices =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
const rules = parameters.protectedPaths
|
|
179
|
+
.map(fragmentRule)
|
|
180
|
+
.filter((rule) => rule.segments.length > 0);
|
|
181
|
+
if (rules.length === 0) return;
|
|
134
182
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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))
|
|
85
|
+
if (seen.has(identity)) return false;
|
|
25
86
|
seen.add(identity);
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
return deduped;
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
29
89
|
}
|
|
30
90
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
122
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
80
123
|
const openRecurrences = loadOpenRecurrences(
|
|
81
|
-
|
|
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 ${
|
|
92
|
-
'class\'s "status" to "closed" (or "cerrada") before proceeding —
|
|
93
|
-
'
|
|
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
|
);
|