@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,120 +1,123 @@
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 deterministic persistence instruction nearby (a file, flag, env var, gate)
11
- // "remember to save this to notes.md" does not depend on model memory, it depends on
12
- // a real file. This is a soft signal (warn), not a hard block: whether the brief truly
13
- // needs the data or is just referencing prior agreement needs judgment this gate can't
14
- // supply on its own.
15
-
16
- import { runGate, warn, TOOL_GROUPS } from '../../lib/hook-io.mjs';
17
-
18
- const GATE_ID = 'no-memory-dependency';
19
- const CONFIG_KEY = 'warnMemoryDependencyInBrief';
20
-
21
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
22
-
23
- const DEFAULT_MEMORY_DEPENDENCY_PATTERNS = [
24
- 'acordate de|acu[eé]rdate de',
25
- 'no olvides|no te olvides',
26
- 'record[aá] que|ten[eé] en cuenta que',
27
- 'como te dije|como ya te dije|ya te dije|te lo dije antes|ya te ped[ií]',
28
- "remember to|don'?t forget|keep in mind",
29
- ];
30
-
31
- // Split into two simpler alternations (tested with plain OR at call time) instead of
32
- // one large regex: each half stays well under the complexity/backtracking budget a
33
- // single combined pattern would hit.
34
- const PERSISTENCE_NOUN_PATTERN =
35
- /archivo|file|flag|variable de entorno|env var|gate determinista|deterministic gate|\.md|\.json/i;
36
- // Kept deliberately flat (no nested optional groups) to stay under the regex-complexity
37
- // budget: a handful of plain alternatives rather than one clever pattern with backtracking.
38
- const PERSISTENCE_VERB_PATTERN =
39
- /guardal[oa]|guarda|guardá esto|persisti|persiste|persistir|persistido|escribil[oa] en|escribi en|save it in|save this in|save it to|save this to/i;
40
-
41
- const PERSISTENCE_WINDOW = 80;
42
-
43
- function withUnicodeWordBoundary(alternatives) {
44
- return new RegExp(
45
- `(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
46
- 'iu',
47
- );
48
- }
49
-
50
- function stripQuoted(text) {
51
- return text
52
- .replace(/```[\s\S]*?```/g, ' ')
53
- .replace(/"[^"\n]{0,300}"/g, ' ')
54
- .replace(/'[^'\n]{0,300}'/g, ' ');
55
- }
56
-
57
- function allMatches(pattern, text) {
58
- const flags = pattern.flags.includes('g')
59
- ? pattern.flags
60
- : `${pattern.flags}g`;
61
- return [...text.matchAll(new RegExp(pattern.source, flags))];
62
- }
63
-
64
- function hasPersistenceInstructionNearby(text, match) {
65
- const from = Math.max(0, match.index - PERSISTENCE_WINDOW);
66
- const to = Math.min(
67
- text.length,
68
- match.index + match[0].length + PERSISTENCE_WINDOW,
69
- );
70
- const window = text.slice(from, to);
71
- return (
72
- PERSISTENCE_NOUN_PATTERN.test(window) ||
73
- PERSISTENCE_VERB_PATTERN.test(window)
74
- );
75
- }
76
-
77
- function unresolvedMemoryPhrases(prompt, memoryPattern) {
78
- if (!memoryPattern.test(prompt)) return []; // stage 1: no candidate, allow cheap
79
-
80
- const text = stripQuoted(prompt); // stage 2(a)
81
- const matches = allMatches(memoryPattern, text);
82
-
83
- return matches
84
- .filter((match) => !hasPersistenceInstructionNearby(text, match))
85
- .map((match) => match[0].trim());
86
- }
87
-
88
- runGate(
89
- {
90
- id: GATE_ID,
91
- configKey: CONFIG_KEY,
92
- enabledByDefault: false,
93
- defaultParams: {
94
- memoryDependencyPatterns: DEFAULT_MEMORY_DEPENDENCY_PATTERNS,
95
- },
96
- },
97
- ({ toolName, toolInput, parameters }) => {
98
- if (!DELEGATION_TOOLS.has(toolName)) return;
99
-
100
- const prompt = String(
101
- toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
102
- );
103
- if (!prompt.trim()) return;
104
-
105
- const memoryPattern = withUnicodeWordBoundary(
106
- (parameters.memoryDependencyPatterns ?? []).join('|'),
107
- );
108
-
109
- const phrases = unresolvedMemoryPhrases(prompt, memoryPattern);
110
- if (phrases.length === 0) return;
111
-
112
- const quotedPhrases = phrases.map((phrase) => `"${phrase}"`).join(', ');
113
- warn(
114
- GATE_ID,
115
- `This delegation depends on the model remembering something (${quotedPhrases}). ` +
116
- 'If it matters, it should live in a file, a flag, an environment variable, or a deterministic gate — ' +
117
- "not in the model's memory.",
118
- );
119
- },
120
- );
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
+ );
@@ -0,0 +1,83 @@
1
+ // Edge-case audit for no-memory-dependency. This gate only warns, never denies, so a
2
+ // "bypass" here means the warning silently fails to fire for a prompt that really does
3
+ // depend on the model's memory, not that anything unsafe was blocked.
4
+ // Run: node --test no-memory-dependency.edge.test.mjs
5
+ import assert from 'node:assert/strict';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import { test } from 'node:test';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
14
+
15
+ function runGate(payload, { config } = {}) {
16
+ const project = mkdtempSync(join(tmpdir(), 'no-memory-dependency-edge-'));
17
+ mkdirSync(join(project, '.git'));
18
+ if (config) {
19
+ mkdirSync(join(project, '.ai'));
20
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
21
+ }
22
+ const out = execFileSync(process.execPath, [GATE], {
23
+ input: JSON.stringify(payload),
24
+ encoding: 'utf8',
25
+ cwd: project,
26
+ env: { ...process.env, HOME: project, USERPROFILE: project },
27
+ });
28
+ return out.trim() ? JSON.parse(out.trim()) : null;
29
+ }
30
+
31
+ function isWarn(result) {
32
+ return typeof result?.hookSpecificOutput?.additionalContext === 'string';
33
+ }
34
+
35
+ const ENABLED = { config: { gates: { warnMemoryDependencyInBrief: true } } };
36
+ const MEMORY_PROMPT = 'Acordate de lo que hablamos antes y aplica el mismo criterio.';
37
+
38
+ test('FIXED: a delegation tool name outside the native list is now caught via toolInGroups (MCP delegation signal)', () => {
39
+ const result = runGate(
40
+ { tool_name: 'mcp__orchestrator__spawn_agent', tool_input: { prompt: MEMORY_PROMPT } },
41
+ ENABLED,
42
+ );
43
+ assert.ok(isWarn(result), 'gate now warns for the memory-dependency prompt under the MCP tool name too');
44
+ });
45
+
46
+ test('FIXED: prompt carried in a field other than prompt/description/task is now read via delegationPromptOf', () => {
47
+ const result = runGate(
48
+ { tool_name: 'Agent', tool_input: { instructions: MEMORY_PROMPT } },
49
+ ENABLED,
50
+ );
51
+ assert.ok(isWarn(result), 'gate now sees the prompt under "instructions" and warns');
52
+ });
53
+
54
+ test('FIXED: a persistence noun with no verb no longer suppresses the warning', () => {
55
+ // hasPersistenceInstructionNearby now requires an imperative persistence VERB nearby,
56
+ // not merely a noun. A prompt that mentions an unrelated file's name within the window
57
+ // (with no verb instructing anything be saved) no longer suppresses the warning.
58
+ const prompt =
59
+ 'Acordate de lo que dijimos del login (el reporte esta en incident.md) y aplica el ' +
60
+ 'mismo criterio de siempre.';
61
+ const result = runGate({ tool_name: 'Agent', tool_input: { prompt } }, ENABLED);
62
+ assert.ok(
63
+ isWarn(result),
64
+ 'gate now warns: "incident.md" nearby with no persistence verb is not a real persistence instruction',
65
+ );
66
+ });
67
+
68
+ test('OK: a genuine memory-dependency phrase via Agent is warned', () => {
69
+ assert.ok(isWarn(runGate({ tool_name: 'Agent', tool_input: { prompt: MEMORY_PROMPT } }, ENABLED)));
70
+ });
71
+
72
+ test('OK: a memory phrase paired with a real persistence verb still suppresses the warning', () => {
73
+ const result = runGate(
74
+ {
75
+ tool_name: 'Agent',
76
+ tool_input: {
77
+ prompt: 'No te olvides de guardar la decision en .ai/decision.md antes de continuar.',
78
+ },
79
+ },
80
+ ENABLED,
81
+ );
82
+ assert.equal(result, null);
83
+ });
@@ -0,0 +1,91 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function newProject({ config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'no-reconfirm-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ return project;
19
+ }
20
+
21
+ function writeTranscript(project, events) {
22
+ const transcriptPath = join(project, 'transcript.jsonl');
23
+ writeFileSync(transcriptPath, events.map((e) => JSON.stringify(e)).join('\n'));
24
+ return transcriptPath;
25
+ }
26
+
27
+ function humanTurn(text) {
28
+ return { type: 'user', userType: 'external', message: { content: text } };
29
+ }
30
+
31
+ function askQuestion(transcriptPath, question) {
32
+ return {
33
+ tool_name: 'AskUserQuestion',
34
+ transcript_path: transcriptPath,
35
+ tool_input: { questions: [question] },
36
+ };
37
+ }
38
+
39
+ function runGateIn(project, payload) {
40
+ const out = execFileSync(process.execPath, [GATE], {
41
+ input: JSON.stringify(payload),
42
+ encoding: 'utf8',
43
+ cwd: project,
44
+ env: { ...process.env, HOME: project, USERPROFILE: project },
45
+ });
46
+ return out.trim() ? JSON.parse(out.trim()) : null;
47
+ }
48
+
49
+ function isWarn(result) {
50
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
51
+ }
52
+
53
+ // EDGE CASE (BUG, confirmed via manual calculation before writing this test): the overlap
54
+ // check has no notion of WHICH entity the action targets — it only counts shared
55
+ // significant (>=5 char) words between the approving turn and the new question's topic
56
+ // text (header+question+option labels). Approving "migrate the STAGING database schema"
57
+ // shares 3 of 4 topic words (migrate/database/schema) with an unrelated question about
58
+ // migrating the BILLING database schema — a materially different target — and clears both
59
+ // MIN_SHARED_WORDS (2) and the default overlapThreshold (0.34): shared=3, fraction=0.75.
60
+ test('BUG: approving one migration is treated as approval for an unrelated migration of a different entity (staging vs billing)', () => {
61
+ const project = newProject();
62
+ const transcriptPath = writeTranscript(project, [
63
+ humanTurn('go ahead, migrate the staging database schema now'),
64
+ ]);
65
+ const question = {
66
+ header: '',
67
+ question: 'Should I migrate the billing database schema?',
68
+ options: [],
69
+ };
70
+ const result = runGateIn(project, askQuestion(transcriptPath, question));
71
+ // Documents the false positive: the gate claims this was "already approved" even though
72
+ // the user approved a migration of a DIFFERENT database (staging, not billing).
73
+ assert.ok(isWarn(result), 'expected the (buggy) false-positive warn to fire: no entity/target check, only generic word overlap');
74
+ });
75
+
76
+ // EDGE CASE (BUG candidate): false negative via negation. The APPROVAL_PATTERN matches
77
+ // literal phrases like "go ahead" / "do it" without checking for a preceding negation.
78
+ // "don't go ahead" or "no, don't do it yet" still matches the raw pattern.
79
+ test('BUG: a NEGATED approval phrase ("do not go ahead") is still treated as approval', () => {
80
+ const project = newProject();
81
+ const transcriptPath = writeTranscript(project, [
82
+ humanTurn('do not go ahead with migrating the authentication database schema'),
83
+ ]);
84
+ const question = {
85
+ header: 'Migration',
86
+ question: 'Should I migrate the authentication database schema?',
87
+ options: [],
88
+ };
89
+ const result = runGateIn(project, askQuestion(transcriptPath, question));
90
+ assert.ok(isWarn(result), 'expected the (buggy) warn to fire even though the user said NOT to go ahead — APPROVAL_PATTERN has no negation check');
91
+ });