@devrik-tools/claude-gates 0.2.1 → 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 (96) 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/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  16. package/plugins/gates/hooks/hooks.json +19 -0
  17. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  18. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  19. package/registry.json +738 -793
  20. package/cli/__tests__/config.test.mjs +0 -113
  21. package/cli/__tests__/init-flags.test.mjs +0 -111
  22. package/cli/__tests__/install.test.mjs +0 -102
  23. package/cli/__tests__/materialize.test.mjs +0 -95
  24. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  25. package/cli/__tests__/selection.test.mjs +0 -200
  26. package/cli/__tests__/task.test.mjs +0 -124
  27. package/cli/__tests__/version-consistency.test.mjs +0 -44
  28. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  29. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  30. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  31. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  32. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  33. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  34. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  35. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  36. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  37. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  38. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  39. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  40. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  41. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  42. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  43. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  46. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  47. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  48. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  49. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  50. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  51. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  52. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  53. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  54. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  55. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  56. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  57. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  58. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  59. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  60. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  61. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  62. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  63. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  64. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  65. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  66. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  67. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  68. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  69. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  70. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  71. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  72. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  73. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  74. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  75. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  76. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  77. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  78. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  79. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  81. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  82. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  83. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  84. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  85. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  86. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  87. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  88. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  89. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  94. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  95. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  96. 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
+ );
@@ -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.
@@ -11,6 +11,16 @@
11
11
  }
12
12
  ]
13
13
  },
14
+ {
15
+ "matcher": "Bash|run_command|Agent|Task|invoke_subagent|mcp__.*",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/block-remote-publish/index.mjs\"",
20
+ "timeout": 30
21
+ }
22
+ ]
23
+ },
14
24
  {
15
25
  "matcher": "Write|Edit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|mcp__.*",
16
26
  "hooks": [
@@ -311,6 +321,15 @@
311
321
  "timeout": 30
312
322
  }
313
323
  ]
324
+ },
325
+ {
326
+ "hooks": [
327
+ {
328
+ "type": "command",
329
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/autonomous-mode/stop.mjs\"",
330
+ "timeout": 30
331
+ }
332
+ ]
314
333
  }
315
334
  ],
316
335
  "SessionStart": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tasks",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -81,9 +81,13 @@ function readPayload() {
81
81
  // make something OCCUR deterministically; it is depending on the model's judgment for a
82
82
  // judgment call (whether this message describes a new task), same shape as ask-adoption.
83
83
  const CLASSIFY_PROMPT =
84
- '[tasks] If the user just described a new actionable task (not a question, not small talk), ' +
85
- 'register it now: run `claude-gates task add "<title>" [--description <text>] [--size <size>]` ' +
86
- 'from the project root. Skip this for messages that are not a new task.';
84
+ '[tasks] MANDATORY before you respond: judge whether the message you just received describes ' +
85
+ 'a new actionable task (not a question, not small talk, not a follow-up on an already-registered ' +
86
+ 'task). If it does, you MUST register it now, before writing your reply — run ' +
87
+ '`claude-gates task add "<title>" [--description <text>] [--size <size>]` from the project root ' +
88
+ '(or the CLI\'s absolute path if `claude-gates` is not on PATH). Do not defer this, do not decide ' +
89
+ 'to register it "later", do not silently skip it because the answer seems obvious. If it is not a ' +
90
+ 'new task, skip registration — do not fabricate one.';
87
91
 
88
92
  function formatReminder(tasks) {
89
93
  const shown = tasks.slice(0, MAX_TASKS_SHOWN);