@devrik-tools/claude-gates 0.2.0 → 0.3.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 (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
  10. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  11. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  12. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  13. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  14. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  15. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  16. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
  17. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  18. package/plugins/gates/hooks/hooks.json +365 -346
  19. package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
  20. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  21. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  22. package/registry.json +738 -793
  23. package/cli/__tests__/config.test.mjs +0 -113
  24. package/cli/__tests__/init-flags.test.mjs +0 -111
  25. package/cli/__tests__/install.test.mjs +0 -102
  26. package/cli/__tests__/materialize.test.mjs +0 -95
  27. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  28. package/cli/__tests__/selection.test.mjs +0 -200
  29. package/cli/__tests__/task.test.mjs +0 -124
  30. package/cli/__tests__/version-consistency.test.mjs +0 -44
  31. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  32. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  33. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  34. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  35. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  36. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  37. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  38. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  39. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  40. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  41. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  42. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  43. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  44. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  45. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  46. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  47. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  48. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  49. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  50. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  51. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  52. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  53. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  54. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  55. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  56. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  57. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  58. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  59. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  60. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  61. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  62. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  63. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  64. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  65. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  66. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  67. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  68. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  69. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  70. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  71. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  72. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  73. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  74. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  75. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  76. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  77. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  78. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  79. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  80. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
  81. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  82. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  83. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  84. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  85. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  86. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  87. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  88. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  89. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  90. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  91. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  92. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  93. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  94. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
  95. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  96. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  99. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,123 +1,138 @@
1
- // no-memory-dependency — warns (never denies) when a delegation prompt leans on the
2
- // subagent "remembering" something said earlier in conversation instead of carrying
3
- // the data itself. A fresh subagent has no access to the delegator's conversation
4
- // history: if the data matters, it must travel IN the prompt, in a file the subagent
5
- // reads, or in an already-persisted decision.
6
- //
7
- // ── Two stages, same pattern as intent-flow ─────────────────────────────────────────
8
- // Stage 1 (cheap): does any memory-dependency phrase appear anywhere? If not, allow.
9
- // Stage 2 (confirmation, only on candidates): strip quoted text, then discard a phrase
10
- // that has a REAL deterministic persistence INSTRUCTION nearby (not just a noun/verb
11
- // mentioned in passing) "no te olvides de guardar la decision en .ai/decision.md"
12
- // genuinely depends on a file, not on model memory. A persistence noun/verb that merely
13
- // co-occurs in the window without forming an instruction TO persist THIS remembered
14
- // thing (e.g. an unrelated file named elsewhere in the same sentence) does not suppress
15
- // the warning. This stays a soft signal (warn), never a hard block: whether the brief
16
- // truly needs the data or is just referencing prior agreement needs judgment this gate
17
- // can't supply on its own.
18
-
19
- import {
20
- runGate,
21
- warn,
22
- toolInGroups,
23
- delegationPromptOf,
24
- } from '../../lib/hook-io.mjs';
25
- import { PERSISTENCE_VERB } from '../../lib/signals.mjs';
26
-
27
- const GATE_ID = 'no-memory-dependency';
28
- const CONFIG_KEY = 'warnMemoryDependencyInBrief';
29
-
30
- const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
31
- 'acordate de|acu[eé]rdate de',
32
- 'no olvides|no te olvides',
33
- 'record[aá] que|ten[eé] en cuenta que',
34
- 'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
35
- "remember to|don'?t forget|keep in mind",
36
- ];
37
-
38
- // A real persistence INSTRUCTION requires an imperative persistence VERB (an actual
39
- // directive to save/persist/write THIS remembered thing), not merely a persistence-
40
- // related NOUN mentioned somewhere nearby (a stray "incident.md" in the same sentence
41
- // names a file without instructing anything be saved to it). Centralized in
42
- // lib/signals.mjs (ES+EN) so this class of signal is not duplicated per gate.
43
- const PERSISTENCE_VERB_PATTERN = PERSISTENCE_VERB;
44
-
45
- const PERSISTENCE_WINDOW = 80;
46
-
47
- function withUnicodeWordBoundary(alternatives) {
48
- return new RegExp(
49
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
50
- 'iu',
51
- );
52
- }
53
-
54
- function stripQuoted(text) {
55
- return text
56
- .replace(/```[\s\S]*?```/g, ' ')
57
- .replace(/"[^"\n]{0,300}"/g, ' ')
58
- .replace(/'[^'\n]{0,300}'/g, ' ');
59
- }
60
-
61
- function allMatches(pattern, text) {
62
- const flags = pattern.flags.includes('g')
63
- ? pattern.flags
64
- : `${pattern.flags}g`;
65
- return [...text.matchAll(new RegExp(pattern.source, flags))];
66
- }
67
-
68
- /** True only when the window around a memory phrase carries a real persistence
69
- * INSTRUCTION — an imperative persistence verb — not merely a persistence-related noun
70
- * mentioned in passing. A noun alone ("el reporte esta en incident.md") names a file
71
- * without instructing anything be saved to it, so it must NOT suppress the warning. */
72
- function hasPersistenceInstructionNearby(text, match) {
73
- const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
74
- const to = Math.min(
75
- text.length,
76
- match.index + match[0].length + PERSISTENCE_WINDOW,
77
- );
78
- const window = text.slice(from, to);
79
- return PERSISTENCE_VERB_PATTERN.test(window);
80
- }
81
-
82
- function unresolvedMemoryPhrases(prompt, memoryPattern) {
83
- if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
84
-
85
- const text = stripQuoted(prompt); // stage 2(a)
86
- const matches = allMatches(memoryPattern, text);
87
-
88
- return matches
89
- .filter((match) => !hasPersistenceInstructionNearby(text, match))
90
- .map((match) => match[0].trim());
91
- }
92
-
93
- runGate(
94
- {
95
- id: GATE_ID,
96
- configKey: CONFIG_KEY,
97
- enabledByDefault: false,
98
- defaultParams: {
99
- memoryDependencyPatterns: DEFAULT_MEMORY_DEPENDENCY_PATTERNS,
100
- },
101
- },
102
- ({ toolName, toolInput, parameters }) => {
103
- if (!toolInGroups(toolName, ['delegation'])) return;
104
-
105
- const prompt = delegationPromptOf(toolInput);
106
- if (!prompt.trim()) return;
107
-
108
- const memoryPattern = withUnicodeWordBoundary(
109
- (parameters.memoryDependencyPatterns ?? []).join('|'),
110
- );
111
-
112
- const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
113
- if (phrases.length === 0) return;
114
-
115
- const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
116
- warn(
117
- GATE_ID,
118
- `This delegation depends on the model remembering something (${quotedPhrases}). ` +
119
- 'If it matters, it should live in a file, a flag, an environment variable, or a deterministic gate — ' +
120
- "not in the model's memory.",
121
- );
122
- },
123
- );
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.
7
+ //
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.
23
+
24
+ import {
25
+ runGate,
26
+ deny,
27
+ toolInGroups,
28
+ delegationPromptOf,
29
+ } from '../../lib/hook-io.mjs';
30
+ import { PERSISTENCE_VERB } from '../../lib/signals.mjs';
31
+
32
+ const GATE_ID = 'no-memory-dependency';
33
+ const CONFIG_KEY = 'warnMemoryDependencyInBrief';
34
+
35
+ const DEFAULT_ESCAPE_HATCH = 'memory-not-needed';
36
+
37
+ const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
38
+ 'acordate de|acu[eé]rdate de',
39
+ 'no olvides|no te olvides',
40
+ 'record[aá] que|ten[eé] 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",
43
+ ];
44
+
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
+ const PERSISTENCE_WINDOW = 80;
53
+
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
+ function hasPersistenceInstructionNearby(text, match) {
80
+ const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
81
+ const to = Math.min(
82
+ text.length,
83
+ match.index + match[0].length + PERSISTENCE_WINDOW,
84
+ );
85
+ const window = text.slice(from, to);
86
+ return PERSISTENCE_VERB_PATTERN.test(window);
87
+ }
88
+
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);
94
+
95
+ return matches
96
+ .filter((match) => !hasPersistenceInstructionNearby(text, match))
97
+ .map((match) => match[0].trim());
98
+ }
99
+
100
+ runGate(
101
+ {
102
+ id: GATE_ID,
103
+ configKey: CONFIG_KEY,
104
+ enabledByDefault: false,
105
+ defaultParams: {
106
+ memoryDependencyPatterns: DEFAULT_MEMORY_DEPENDENCY_PATTERNS,
107
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
108
+ },
109
+ },
110
+ ({ toolName, toolInput, parameters }) => {
111
+ if (!toolInGroups(toolName, ['delegation'])) return;
112
+
113
+ const prompt = delegationPromptOf(toolInput);
114
+ if (!prompt.trim()) return;
115
+
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();
120
+ if (escapeHatch && prompt.toLowerCase().includes(escapeHatch)) return;
121
+
122
+ const memoryPattern = withUnicodeWordBoundary(
123
+ (parameters.memoryDependencyPatterns ?? []).join('|'),
124
+ );
125
+
126
+ const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
127
+ if (phrases.length === 0) return;
128
+
129
+ const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
130
+ deny(
131
+ GATE_ID,
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.`,
136
+ );
137
+ },
138
+ );
@@ -14,7 +14,13 @@
14
14
  // dotfiles are a different, well-known convention this gate does not police.
15
15
 
16
16
  import { basename, isAbsolute, resolve, sep } from 'node:path';
17
- import { runGate, deny, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
17
+ import {
18
+ runGate,
19
+ deny,
20
+ toolInGroups,
21
+ writtenPathOf,
22
+ shellWrittenPaths,
23
+ } from '../../lib/hook-io.mjs';
18
24
 
19
25
  const GATE_ID = 'root-whitelist';
20
26
  const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
@@ -49,6 +55,40 @@ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
49
55
  'plugins',
50
56
  ];
51
57
 
58
+ // The whitelist verdict for one target path. Returns a deny reason string when the path is a
59
+ // non-whitelisted new thing at the project root, or null when it is allowed (outside the root,
60
+ // a dotfile, or whitelisted). Shared by the write-tool path and every shell-created path, so a
61
+ // `printf x > basura.txt` is judged by the same rule as a Write to basura.txt.
62
+ function rootWhitelistViolation(target, filesWhitelist, foldersWhitelist) {
63
+ if (!target) return null;
64
+
65
+ // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
66
+ // already-absolute path merely normalized ('..' collapsed, separators fixed), and resolves
67
+ // a relative one against process.cwd() — so either shape lands on the same absolute path.
68
+ const normalized = isAbsolute(target)
69
+ ? resolve(target)
70
+ : resolve(process.cwd(), target);
71
+ const projectRoot = process.cwd() + sep;
72
+
73
+ // A path outside the project (scratchpad, temp, another repo) is not at its root — not this
74
+ // rule's business. Judged AFTER resolving, so a relative path is judged by where it lands.
75
+ if (!normalized.startsWith(projectRoot)) return null;
76
+
77
+ const relativePath = normalized.slice(projectRoot.length);
78
+
79
+ if (!relativePath.includes(sep)) {
80
+ const fileName = basename(relativePath);
81
+ if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase())) return null;
82
+ return `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`;
83
+ }
84
+
85
+ const topDirectory = relativePath.split(sep)[0];
86
+ if (topDirectory.startsWith('.') || foldersWhitelist.has(topDirectory.toLowerCase())) {
87
+ return null;
88
+ }
89
+ return `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`;
90
+ }
91
+
52
92
  runGate(
53
93
  {
54
94
  id: GATE_ID,
@@ -60,30 +100,12 @@ runGate(
60
100
  },
61
101
  },
62
102
  ({ toolName, toolInput, parameters }) => {
63
- if (!toolInGroups(toolName, ['write'])) return;
103
+ const isWrite = toolInGroups(toolName, ['write']);
104
+ const isShell = toolInGroups(toolName, ['shell']);
105
+ if (!isWrite && !isShell) return;
64
106
 
65
- const target = writtenPathOf(toolInput);
66
- if (!target) return;
67
-
68
- // Some tools send a path relative to cwd rather than absolute. resolve() leaves an
69
- // already-absolute path merely normalized ('..' collapsed, separators fixed), and
70
- // resolves a relative one against process.cwd() — so either shape lands on the same
71
- // absolute path the root check below expects.
72
- const normalized = isAbsolute(target)
73
- ? resolve(target)
74
- : resolve(process.cwd(), target);
75
- const projectRoot = process.cwd() + sep;
76
-
77
- // The whitelist describes the project's ROOT. A file outside the project (session
78
- // scratchpad, system temp, another repo) is not at its root, and this rule does not
79
- // govern it: letting it through is correct, not an exception. This check runs AFTER
80
- // resolving relative paths, so a relative path is judged by where it actually lands.
81
- if (!normalized.startsWith(projectRoot)) return;
82
-
83
- const relativePath = normalized.slice(projectRoot.length);
84
- // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the
85
- // same file. Comparing lowercase on both sides avoids denying a whitelisted name that
86
- // merely differs in case (a false positive, not a real gap).
107
+ // Windows filesystems are case-insensitive: 'Package.json' and 'package.json' are the same
108
+ // file. Comparing lowercase on both sides avoids a false positive on a case difference.
87
109
  const filesWhitelist = new Set(
88
110
  (parameters.rootFilesWhitelist ?? []).map((name) => name.toLowerCase()),
89
111
  );
@@ -91,26 +113,19 @@ runGate(
91
113
  (parameters.rootFoldersWhitelist ?? []).map((name) => name.toLowerCase()),
92
114
  );
93
115
 
94
- if (!relativePath.includes(sep)) {
95
- const fileName = basename(relativePath);
96
- if (fileName.startsWith('.') || filesWhitelist.has(fileName.toLowerCase()))
97
- return;
98
- deny(
99
- GATE_ID,
100
- `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`,
101
- );
102
- return;
103
- }
116
+ // The paths to judge: a write tool's target, OR every path a shell command creates via a
117
+ // redirection / touch / tee / cp / mv. The latter closes the hole where `> basura.txt`
118
+ // created a root file that a Write to the same name would have been denied. This does not
119
+ // resolve a dynamically built path (`> "$f"`) — that honest limitation is the boundary of
120
+ // a regex, and is why the write-tool path (which carries a concrete file_path) stays the
121
+ // primary, most reliable surface.
122
+ const targets = isShell
123
+ ? shellWrittenPaths(String(toolInput?.command ?? toolInput?.CommandLine ?? ''))
124
+ : [writtenPathOf(toolInput)];
104
125
 
105
- const topDirectory = relativePath.split(sep)[0];
106
- if (
107
- topDirectory.startsWith('.') ||
108
- foldersWhitelist.has(topDirectory.toLowerCase())
109
- )
110
- return;
111
- deny(
112
- GATE_ID,
113
- `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
114
- );
126
+ for (const target of targets) {
127
+ const reason = rootWhitelistViolation(target, filesWhitelist, foldersWhitelist);
128
+ if (reason) deny(GATE_ID, reason);
129
+ }
115
130
  },
116
131
  );
@@ -1,101 +1,124 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { basename, dirname, relative } from 'node:path';
4
- import { runGate, warn, toolInGroups, writtenPathOf } from '../../lib/hook-io.mjs';
5
-
6
- const GATE_ID = 'test-after-implementation';
7
- const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
8
-
9
- const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
10
- '.ts',
11
- '.tsx',
12
- '.js',
13
- '.jsx',
14
- '.mjs',
15
- '.vue',
16
- ];
17
- const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
18
- // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
19
- const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
20
-
21
- function baseNameWithoutTestSuffix(fileName) {
22
- return fileName.replace(TEST_FILE_PATTERN, '');
23
- }
24
-
25
- function gitStatusPorcelain(cwd) {
26
- try {
27
- const output = execFileSync('git', ['status', '--porcelain'], {
28
- cwd,
29
- timeout: 5000,
30
- }).toString();
31
- return output
32
- .split('\n')
33
- .map((line) => line.trim())
34
- .filter(Boolean);
35
- } catch {
36
- // Not a git repo, or git unavailable — nothing to cross-check against.
37
- return [];
38
- }
39
- }
40
-
41
- runGate(
42
- {
43
- id: GATE_ID,
44
- configKey: CONFIG_KEY,
45
- enabledByDefault: false,
46
- defaultParams: {
47
- implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
48
- },
49
- },
50
- ({ toolName, toolInput, parameters }) => {
51
- if (!toolInGroups(toolName, ['write'])) return;
52
-
53
- const filePath = writtenPathOf(toolInput);
54
- if (!TEST_FILE_PATTERN.test(filePath)) return;
55
- if (existsSync(filePath)) return; // only new test file creation, not edits
56
-
57
- const cwd = process.cwd();
58
- const statusLines = gitStatusPorcelain(cwd);
59
- if (statusLines.length === 0) return;
60
-
61
- const testFileName = basename(filePath);
62
- const stem = baseNameWithoutTestSuffix(testFileName);
63
- // `git status --porcelain` reports paths relative to `cwd`; the test's own
64
- // directory (relative to `cwd`) is the fair comparison, not its absolute path.
65
- // `relative()` returns '' for the repo root itself, while `dirname()` on a
66
- // bare file name returns '.' — normalize both to '.' so root-level files compare equal.
67
- const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
68
-
69
- const pairedImplementationChanged = statusLines.some((line) => {
70
- const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
71
- if (
72
- !parameters.implementationExtensions.some((extension) =>
73
- changedPath.endsWith(extension),
74
- )
75
- )
76
- return false;
77
- if (TEST_FILE_PATTERN.test(changedPath)) return false;
78
- const lastDot = changedPath.lastIndexOf('.');
79
- const changedBase = basename(
80
- changedPath,
81
- lastDot >= 0 ? changedPath.slice(lastDot) : '',
82
- );
83
- return (
84
- changedBase === stem &&
85
- (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
86
- );
87
- });
88
-
89
- if (!pairedImplementationChanged) return;
90
-
91
- warn(
92
- GATE_ID,
93
- `Creating test ${testFileName} after its paired implementation was already modified (uncommitted). Consider writing tests before or alongside the implementation, not after.`,
94
- );
95
- },
96
- );
97
-
98
- // Note: the source guard (guard-test-despues-de-implementar.mjs) denies this
99
- // case outright. This gate warns instead, because registry.json names the
100
- // config key warnTestWrittenAfterImplementation, which declares warn as the
101
- // intended severity for this migration.
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, dirname, relative } from 'node:path';
4
+ import {
5
+ runGate,
6
+ deny,
7
+ toolInGroups,
8
+ writtenPathOf,
9
+ writtenContentOf,
10
+ } from '../../lib/hook-io.mjs';
11
+
12
+ const GATE_ID = 'test-after-implementation';
13
+ const CONFIG_KEY = 'warnTestWrittenAfterImplementation';
14
+
15
+ // Escape hatch: a regression test written AFTER reproducing a bug is a legitimate
16
+ // test-after-implementation case (the project's own rule: reproduce the bug, then write the
17
+ // test that pins it). A deny cannot advise-and-pass, so that legitimate case needs an
18
+ // explicit opt-out this marker anywhere in the test file's content lets it through.
19
+ const DEFAULT_ESCAPE_HATCH = 'test-after-impl:allow';
20
+
21
+ const DEFAULT_IMPLEMENTATION_EXTENSIONS = [
22
+ '.ts',
23
+ '.tsx',
24
+ '.js',
25
+ '.jsx',
26
+ '.mjs',
27
+ '.vue',
28
+ ];
29
+ const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?[jt]sx?$/i;
30
+ // `git status --porcelain` prefixes each line with a 2-character status code plus a space.
31
+ const PORCELAIN_STATUS_PREFIX_LENGTH = 3;
32
+
33
+ function baseNameWithoutTestSuffix(fileName) {
34
+ return fileName.replace(TEST_FILE_PATTERN, '');
35
+ }
36
+
37
+ function gitStatusPorcelain(cwd) {
38
+ try {
39
+ const output = execFileSync('git', ['status', '--porcelain'], {
40
+ cwd,
41
+ timeout: 5000,
42
+ }).toString();
43
+ return output
44
+ .split('\n')
45
+ .map((line) => line.trim())
46
+ .filter(Boolean);
47
+ } catch {
48
+ // Not a git repo, or git unavailable — nothing to cross-check against.
49
+ return [];
50
+ }
51
+ }
52
+
53
+ runGate(
54
+ {
55
+ id: GATE_ID,
56
+ configKey: CONFIG_KEY,
57
+ enabledByDefault: false,
58
+ defaultParams: {
59
+ implementationExtensions: DEFAULT_IMPLEMENTATION_EXTENSIONS,
60
+ escapeHatch: DEFAULT_ESCAPE_HATCH,
61
+ },
62
+ },
63
+ ({ toolName, toolInput, parameters }) => {
64
+ if (!toolInGroups(toolName, ['write'])) return;
65
+
66
+ const filePath = writtenPathOf(toolInput);
67
+ if (!TEST_FILE_PATTERN.test(filePath)) return;
68
+ if (existsSync(filePath)) return; // only new test file creation, not edits
69
+
70
+ // Explicit opt-out for a legitimate regression test (bug reproduced first, then pinned).
71
+ const escapeHatch = parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH;
72
+ if (escapeHatch && writtenContentOf(toolInput).includes(escapeHatch))
73
+ return;
74
+
75
+ const cwd = process.cwd();
76
+ const statusLines = gitStatusPorcelain(cwd);
77
+ if (statusLines.length === 0) return;
78
+
79
+ const testFileName = basename(filePath);
80
+ const stem = baseNameWithoutTestSuffix(testFileName);
81
+ // `git status --porcelain` reports paths relative to `cwd`; the test's own
82
+ // directory (relative to `cwd`) is the fair comparison, not its absolute path.
83
+ // `relative()` returns '' for the repo root itself, while `dirname()` on a
84
+ // bare file name returns '.' — normalize both to '.' so root-level files compare equal.
85
+ const testDirectoryRelativeToRepo = relative(cwd, dirname(filePath)) || '.';
86
+
87
+ const pairedImplementationChanged = statusLines.some((line) => {
88
+ const changedPath = line.slice(PORCELAIN_STATUS_PREFIX_LENGTH).trim();
89
+ if (
90
+ !parameters.implementationExtensions.some((extension) =>
91
+ changedPath.endsWith(extension),
92
+ )
93
+ )
94
+ return false;
95
+ if (TEST_FILE_PATTERN.test(changedPath)) return false;
96
+ const lastDot = changedPath.lastIndexOf('.');
97
+ const changedBase = basename(
98
+ changedPath,
99
+ lastDot >= 0 ? changedPath.slice(lastDot) : '',
100
+ );
101
+ return (
102
+ changedBase === stem &&
103
+ (dirname(changedPath) || '.') === testDirectoryRelativeToRepo
104
+ );
105
+ });
106
+
107
+ if (!pairedImplementationChanged) return;
108
+
109
+ deny(
110
+ GATE_ID,
111
+ `Creating test ${testFileName} after its paired implementation was already modified ` +
112
+ '(uncommitted). Write the test before or alongside the implementation, not after — a ' +
113
+ 'test that passes the first time it runs proves nothing about the change. If this is a ' +
114
+ 'regression test for a bug you already reproduced (reproduce first, then pin it), add ' +
115
+ `the marker "${parameters.escapeHatch ?? DEFAULT_ESCAPE_HATCH}" in the test content to allow it.`,
116
+ );
117
+ },
118
+ );
119
+
120
+ // Note: this restores the source guard's (guard-test-despues-de-implementar.mjs) original
121
+ // deny severity. It was warn during the config-key migration; the user's directive is that a
122
+ // gate stays warn only when the defect is genuinely invisible to a hook. Here the defect is a
123
+ // complete, git-verifiable fact, so it denies — with an escape hatch for the legitimate
124
+ // regression-test case. The config key still reads warn* for backward compatibility.