@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  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 +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  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 +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,33 +1,29 @@
1
- // no-memory-dependency — denies when a delegation prompt leans on the subagent
2
- // "remembering" something said earlier in conversation instead of carrying the data itself.
3
- // A fresh subagent has no access to the delegator's conversation history: if the data
4
- // matters, it must travel IN the prompt, in a file the subagent reads, or in an
5
- // already-persisted decision. The dependency on unavailable memory is a complete, objective
6
- // fact once stage 2 has ruled out a real persistence instruction — so this blocks.
1
+ // no-memory-dependency — denies a delegation whose prompt refers the subagent to something
2
+ // said earlier in the conversation ("como te dije", "as we discussed") instead of carrying
3
+ // the data itself: a fresh subagent has none of the delegator's history.
7
4
  //
8
- // ── Two stages, same pattern as intent-flow ─────────────────────────────────────────
9
- // Stage 1 (cheap): does any memory-dependency phrase appear anywhere? If not, allow.
10
- // Stage 2 (confirmation, only on candidates): strip quoted text, then discard a phrase
11
- // that has a REAL deterministic persistence INSTRUCTION nearby (not just a noun/verb
12
- // mentioned in passing) "no te olvides de guardar la decision en .ai/decision.md"
13
- // genuinely depends on a file, not on model memory. A persistence noun/verb that merely
14
- // co-occurs in the window without forming an instruction TO persist THIS remembered
15
- // thing (e.g. an unrelated file named elsewhere in the same sentence) does not suppress
16
- // the deny.
17
- //
18
- // ── Escape hatch ─────────────────────────────────────────────────────────────────────
19
- // A memory phrase can be a false positive: "no te olvides de cerrar el server al final"
20
- // directs the SUBAGENT'S own future action, not data it must recall from the delegator. For
21
- // that case the author adds the escapeHatch marker to the prompt to state, explicitly, that
22
- // no cross-conversation memory is actually required.
5
+ // Decisions: the default phrases are references to PRIOR CONVERSATION only — "don't forget
6
+ // to X", "keep in mind that <fact>", "remember to run the tests" carry their own content and
7
+ // are allowed. A reference next to a real persistence instruction ("como quedamos, guardá la
8
+ // decisión en .ai/decision.md") depends on a file, not on memory, and is allowed. The escape
9
+ // hatch marker states that no recalled data is actually needed.
23
10
 
11
+ import {
12
+ allMatches,
13
+ promptExcerpt,
14
+ stripQuoted,
15
+ } from '../../lib/delegation.mjs';
24
16
  import {
25
17
  runGate,
26
18
  deny,
19
+ compileRegexList,
27
20
  toolInGroups,
28
21
  delegationPromptOf,
29
22
  } from '../../lib/hook-io.mjs';
30
- import { PERSISTENCE_VERB } from '../../lib/signals.mjs';
23
+ import {
24
+ PERSISTENCE_VERB,
25
+ withUnicodeWordBoundary,
26
+ } from '../../lib/signals.mjs';
31
27
 
32
28
  const GATE_ID = 'no-memory-dependency';
33
29
  const CONFIG_KEY = 'warnMemoryDependencyInBrief';
@@ -35,64 +31,39 @@ const CONFIG_KEY = 'warnMemoryDependencyInBrief';
35
31
  const DEFAULT_ESCAPE_HATCH = 'memory-not-needed';
36
32
 
37
33
  const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
38
- 'acordate de|acu[eé]rdate de',
39
- 'no olvides|no te olvides',
40
- 'record[aá] que|ten[] en cuenta que',
41
- 'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
42
- "remember to|don'?t forget|keep in mind",
34
+ 'recuerda que|acu[eé]rdate de (?:lo )?que|acordate de (?:lo )?que|como te dije',
35
+ 'como (vimos|hablamos|dijimos|quedamos)|lo de antes',
36
+ 'lo que te (dije|comenté|pasé)|ya sabes cu[]l',
37
+ 'el (archivo|cambio|tema) de antes',
38
+ 'remember that we|as (i|we) (said|discussed|mentioned|agreed)|as discussed',
39
+ 'as mentioned (earlier|before|above)|the one from (before|earlier)',
40
+ 'what we (discussed|talked about|decided)|you know which',
41
+ 'like (before|last time)|the previous (one|file|change) i mentioned',
43
42
  ];
44
43
 
45
- // A real persistence INSTRUCTION requires an imperative persistence VERB (an actual
46
- // directive to save/persist/write THIS remembered thing), not merely a persistence-
47
- // related NOUN mentioned somewhere nearby (a stray "incident.md" in the same sentence
48
- // names a file without instructing anything be saved to it). Centralized in
49
- // lib/signals.mjs (ES+EN) so this class of signal is not duplicated per gate.
50
- const PERSISTENCE_VERB_PATTERN = PERSISTENCE_VERB;
51
-
52
44
  const PERSISTENCE_WINDOW = 80;
53
45
 
54
- function withUnicodeWordBoundary(alternatives) {
55
- return new RegExp(
56
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
57
- 'iu',
58
- );
59
- }
60
-
61
- function stripQuoted(text) {
62
- return text
63
- .replace(/```[\s\S]*?```/g, ' ')
64
- .replace(/"[^"\n]{0,300}"/g, ' ')
65
- .replace(/'[^'\n]{0,300}'/g, ' ');
66
- }
67
-
68
- function allMatches(pattern, text) {
69
- const flags = pattern.flags.includes('g')
70
- ? pattern.flags
71
- : `${pattern.flags}g`;
72
- return [...text.matchAll(new RegExp(pattern.source, flags))];
73
- }
74
-
75
- /** True only when the window around a memory phrase carries a real persistence
76
- * INSTRUCTION — an imperative persistence verb — not merely a persistence-related noun
77
- * mentioned in passing. A noun alone ("el reporte esta en incident.md") names a file
78
- * without instructing anything be saved to it, so it must NOT suppress the warning. */
79
46
  function hasPersistenceInstructionNearby(text, match) {
80
47
  const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
81
48
  const to = Math.min(
82
49
  text.length,
83
50
  match.index + match[0].length + PERSISTENCE_WINDOW,
84
51
  );
85
- const window = text.slice(from, to);
86
- return PERSISTENCE_VERB_PATTERN.test(window);
52
+ return PERSISTENCE_VERB.test(text.slice(from, to));
87
53
  }
88
54
 
89
- function unresolvedMemoryPhrases(prompt, memoryPattern) {
90
- if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
91
-
92
- const text = stripQuoted(prompt); // stage 2(a)
93
- const matches = allMatches(memoryPattern, text);
55
+ function memoryPatternFrom(sources) {
56
+ const { patterns } = compileRegexList(sources, 'iu');
57
+ if (patterns.length === 0) return null;
58
+ return withUnicodeWordBoundary(
59
+ patterns.map((pattern) => `(?:${pattern.source})`).join('|'),
60
+ );
61
+ }
94
62
 
95
- return matches
63
+ function unresolvedMemoryPhrases(prompt, memoryPattern) {
64
+ if (!memoryPattern.test(prompt)) return [];
65
+ const text = stripQuoted(prompt);
66
+ return allMatches(memoryPattern, text)
96
67
  .filter((match) => !hasPersistenceInstructionNearby(text, match))
97
68
  .map((match) => match[0].trim());
98
69
  }
@@ -113,15 +84,13 @@ runGate(
113
84
  const prompt = delegationPromptOf(toolInput);
114
85
  if (!prompt.trim()) return;
115
86
 
116
- // Explicit opt-out: the author states no cross-conversation memory is actually required.
117
- const escapeHatch = (
118
- parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH
119
- ).toLowerCase();
87
+ const escapeHatch = String(parameters.escapeHatch ?? '').toLowerCase();
120
88
  if (escapeHatch && prompt.toLowerCase().includes(escapeHatch)) return;
121
89
 
122
- const memoryPattern = withUnicodeWordBoundary(
123
- (parameters.memoryDependencyPatterns ?? []).join('|'),
90
+ const memoryPattern = memoryPatternFrom(
91
+ parameters.memoryDependencyPatterns,
124
92
  );
93
+ if (!memoryPattern) return;
125
94
 
126
95
  const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
127
96
  if (phrases.length === 0) return;
@@ -129,10 +98,10 @@ runGate(
129
98
  const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
130
99
  deny(
131
100
  CONFIG_KEY,
132
- `This delegation depends on the subagent remembering something (${quotedPhrases}), but a ` +
133
- 'fresh subagent has none of this conversation. Put the data IN the prompt, in a file it ' +
134
- 'reads, a flag, or an already-persisted decision. If the phrase directs the subagent’s ' +
135
- `own future action and needs no recalled data, add the marker "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" to the prompt.`,
101
+ `This delegation ("${promptExcerpt(prompt)}") depends on the subagent remembering something ` +
102
+ `(${quotedPhrases}), but a fresh subagent has none of this conversation. Put the data IN the ` +
103
+ 'prompt, in a file it reads, a flag, or an already-persisted decision. If the phrase needs no ' +
104
+ `recalled data, add the marker "${escapeHatch || DEFAULT_ESCAPE_HATCH}" to the prompt.`,
136
105
  );
137
106
  },
138
107
  );
@@ -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
  },
@@ -0,0 +1,114 @@
1
+ // no-trivial-scripts — denies inline interpreter scripts (node -e, python -c, sed -i,
2
+ // perl -e, PowerShell one-liners with [IO.File]/Set-Content/Add-Content) when they perform
3
+ // file operations that the Edit or Write tool would handle directly: inserting a line,
4
+ // replacing text, creating a file, appending content. Legitimate uses of inline scripts
5
+ // (JSON processing, running a test harness, computing a value) are not caught because they
6
+ // don't touch the filesystem.
7
+ //
8
+ // Deterministic: pattern match on the command, unconditional deny. No model judgment.
9
+
10
+ import {
11
+ deny,
12
+ runGate,
13
+ shellCommandOf,
14
+ toolInGroups,
15
+ } from '../../lib/hook-io.mjs';
16
+
17
+ const GATE_ID = 'no-trivial-scripts';
18
+ const CONFIG_KEY = 'blockTrivialInlineScripts';
19
+
20
+ // Node/Deno/Bun inline eval doing file operations
21
+ const NODE_EVAL_PATTERN =
22
+ /\b(?:node|deno|bun)\b(?:\s+-\S+)*?\s+(?:-e|--eval|-p|--print)\b/i;
23
+ const NODE_FILE_OPS =
24
+ /\b(?:writeFileSync|appendFileSync|mkdirSync|unlinkSync|renameSync|copyFileSync|fs\.writeFile|fs\.appendFile|fs\.unlink|fs\.rename|fs\.mkdir|fs\.copyFile|createWriteStream)\b/;
25
+
26
+ // Python inline doing file operations
27
+ const PYTHON_EVAL_PATTERN = /\bpython3?\b(?:\s+-\S+)*?\s+-c\b/i;
28
+ const PYTHON_OPEN_FOR_WRITING = /\bopen\s*\([^)]*,\s*['"][wax]/;
29
+ const PYTHON_FILE_OPS =
30
+ /\.write\s*\(|\bshutil\.|\bos\.(?:rename|remove|unlink|makedirs)\b|\bpathlib\.Path\b[^)]*\.write_text/;
31
+
32
+ // sed/awk/perl inline edits — these are always file edits
33
+ const SED_INLINE_PATTERN = /\bsed\s+(?:-[^;\s]*\s+)*-i/;
34
+ const PERL_INLINE_PATTERN = /\bperl\s+(?:-[^;\s]*\s+)*-[ip]/;
35
+ const AWK_INLINE_PATTERN = /\bawk\s+(?:-[^;\s]*\s+)*-i\s+inplace\b/;
36
+
37
+ // PowerShell file manipulation one-liners
38
+ const PS_FILE_OPS_PATTERN =
39
+ /\b(?:Set-Content|Add-Content|Out-File|\[System\.IO\.File\]::(?:WriteAll|AppendAll)|New-Item\s[^|;]*-ItemType\s+File)\b/i;
40
+
41
+ const REMEDY =
42
+ 'This command uses an inline script for a file operation that the Edit or Write tool ' +
43
+ 'handles directly. Use Edit to modify existing files (insert, replace, delete lines) ' +
44
+ 'or Write to create new files. Inline scripts are for computation, not file manipulation.';
45
+
46
+ function checkCommand(command) {
47
+ if (!command) return;
48
+
49
+ // Node/Deno/Bun -e with file operations
50
+ const nodeMatch = NODE_EVAL_PATTERN.exec(command);
51
+ if (nodeMatch) {
52
+ const afterEval = command.slice(nodeMatch.index + nodeMatch[0].length);
53
+ if (NODE_FILE_OPS.test(afterEval)) {
54
+ deny(CONFIG_KEY, `${REMEDY} (detected: inline Node.js file operation)`);
55
+ }
56
+ }
57
+
58
+ // Python -c with file operations
59
+ const pythonMatch = PYTHON_EVAL_PATTERN.exec(command);
60
+ if (pythonMatch) {
61
+ const afterEval = command.slice(pythonMatch.index + pythonMatch[0].length);
62
+ if (
63
+ PYTHON_OPEN_FOR_WRITING.test(afterEval) ||
64
+ PYTHON_FILE_OPS.test(afterEval)
65
+ ) {
66
+ deny(CONFIG_KEY, `${REMEDY} (detected: inline Python file operation)`);
67
+ }
68
+ }
69
+
70
+ // sed -i is always an in-place file edit
71
+ if (SED_INLINE_PATTERN.test(command)) {
72
+ deny(
73
+ CONFIG_KEY,
74
+ `${REMEDY} (detected: sed -i in-place edit — use Edit tool instead)`,
75
+ );
76
+ }
77
+
78
+ // perl -i/-p is always an in-place file edit
79
+ if (PERL_INLINE_PATTERN.test(command)) {
80
+ deny(
81
+ CONFIG_KEY,
82
+ `${REMEDY} (detected: perl -i/-p in-place edit — use Edit tool instead)`,
83
+ );
84
+ }
85
+
86
+ // awk -i inplace
87
+ if (AWK_INLINE_PATTERN.test(command)) {
88
+ deny(
89
+ CONFIG_KEY,
90
+ `${REMEDY} (detected: awk -i inplace — use Edit tool instead)`,
91
+ );
92
+ }
93
+
94
+ // PowerShell file write one-liners
95
+ if (PS_FILE_OPS_PATTERN.test(command)) {
96
+ deny(
97
+ CONFIG_KEY,
98
+ `${REMEDY} (detected: PowerShell file write cmdlet — use Edit or Write tool instead)`,
99
+ );
100
+ }
101
+ }
102
+
103
+ runGate(
104
+ {
105
+ id: GATE_ID,
106
+ configKey: CONFIG_KEY,
107
+ enabledByDefault: true,
108
+ defaultParams: {},
109
+ },
110
+ ({ toolName, toolInput }) => {
111
+ if (!toolInGroups(toolName, ['shell'])) return;
112
+ checkCommand(shellCommandOf(toolInput));
113
+ },
114
+ );