@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +19 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
-
toolNamesFor,
|
|
7
|
-
matcherFor,
|
|
8
|
-
toolNameOf,
|
|
9
|
-
sessionIdOf,
|
|
10
|
-
toolInputOf,
|
|
11
|
-
toolInGroups,
|
|
12
|
-
writtenContentOf,
|
|
13
|
-
writtenPathOf,
|
|
14
|
-
delegationPromptOf,
|
|
15
|
-
shellWrittenPaths,
|
|
16
|
-
} from '../hook-io.mjs';
|
|
17
|
-
|
|
18
|
-
// deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
|
|
19
|
-
// killing the test runner. They are exercised here as a child process instead; each
|
|
20
|
-
// gate's own tests additionally cover runGate's wiring through its real check function.
|
|
21
|
-
|
|
22
|
-
test('toolNamesFor expands a single group', () => {
|
|
23
|
-
assert.deepEqual(toolNamesFor(['question']), ['AskUserQuestion']);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('toolNamesFor expands multiple groups and de-duplicates overlapping names', () => {
|
|
27
|
-
const names = toolNamesFor(['write', 'execution']);
|
|
28
|
-
// 'execution' repeats every 'write' tool plus Bash/run_command/mcp__ide__executeCode.
|
|
29
|
-
assert.equal(names.filter((name) => name === 'Write').length, 1);
|
|
30
|
-
assert.ok(names.includes('Write'));
|
|
31
|
-
assert.ok(names.includes('Bash'));
|
|
32
|
-
assert.ok(names.includes('mcp__ide__executeCode'));
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('toolNamesFor returns an empty list for an empty group set', () => {
|
|
36
|
-
assert.deepEqual(toolNamesFor([]), []);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('toolNamesFor ignores an unknown group name', () => {
|
|
40
|
-
assert.deepEqual(toolNamesFor(['not-a-real-group']), []);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('matcherFor joins native tool names with a pipe and always appends the mcp__.* clause', () => {
|
|
44
|
-
// The trailing `mcp__.*` makes Claude Code route EVERY MCP tool call to the hook, so the
|
|
45
|
-
// gate can classify it at runtime with toolInGroups. Without it, the hook is never even
|
|
46
|
-
// invoked for an MCP tool — the deepest layer of the MCP blind spot.
|
|
47
|
-
assert.equal(matcherFor(['question']), 'AskUserQuestion|mcp__.*');
|
|
48
|
-
assert.equal(matcherFor(['shell']), 'Bash|run_command|mcp__.*');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('matcherFor still matches MCP tools even for an empty native group set', () => {
|
|
52
|
-
// An empty group means "no native names", but MCP tools must still reach the hook.
|
|
53
|
-
assert.equal(matcherFor([]), 'mcp__.*');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('toolInGroups matches native names exactly and case-insensitively', () => {
|
|
57
|
-
assert.equal(toolInGroups('Write', ['write']), true);
|
|
58
|
-
assert.equal(toolInGroups('write', ['write']), true); // case-insensitive
|
|
59
|
-
assert.equal(toolInGroups('AskUserQuestion', ['question']), true);
|
|
60
|
-
assert.equal(toolInGroups('Bash', ['write']), false); // wrong group
|
|
61
|
-
assert.equal(toolInGroups('', ['write']), false); // empty tool name
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('toolInGroups classifies MCP tools by their action segment', () => {
|
|
65
|
-
assert.equal(toolInGroups('mcp__filesystem__write_file', ['write']), true);
|
|
66
|
-
assert.equal(toolInGroups('mcp__fs__edit_file', ['write']), true);
|
|
67
|
-
assert.equal(toolInGroups('mcp__shell__exec', ['shell']), true);
|
|
68
|
-
assert.equal(toolInGroups('mcp__x__ask_user_confirmation', ['question']), true);
|
|
69
|
-
assert.equal(toolInGroups('mcp__orchestrator__spawn_agent', ['delegation']), true);
|
|
70
|
-
// A read-only MCP tool must NOT match a write/shell group.
|
|
71
|
-
assert.equal(toolInGroups('mcp__fs__read_file', ['write', 'shell']), false);
|
|
72
|
-
// Malformed mcp name (no action segment) does not match.
|
|
73
|
-
assert.equal(toolInGroups('mcp__server', ['write']), false);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('writtenContentOf reads content across native and MCP field shapes', () => {
|
|
77
|
-
assert.equal(writtenContentOf({ content: 'a' }), 'a'); // Write
|
|
78
|
-
assert.equal(writtenContentOf({ new_string: 'b' }), 'b'); // Edit
|
|
79
|
-
assert.equal(writtenContentOf({ new_source: 'c' }), 'c'); // NotebookEdit
|
|
80
|
-
assert.equal(writtenContentOf({ new_content: 'd' }), 'd'); // replace_file_content
|
|
81
|
-
assert.equal(writtenContentOf({ text: 'e' }), 'e'); // an MCP shape
|
|
82
|
-
assert.equal(
|
|
83
|
-
writtenContentOf({ edits: [{ new_string: 'x' }, { new_string: 'y' }] }),
|
|
84
|
-
'x\ny',
|
|
85
|
-
); // MultiEdit
|
|
86
|
-
assert.equal(writtenContentOf({}), '');
|
|
87
|
-
assert.equal(writtenContentOf(null), '');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('writtenPathOf reads the target path across field shapes', () => {
|
|
91
|
-
assert.equal(writtenPathOf({ file_path: 'a.js' }), 'a.js');
|
|
92
|
-
assert.equal(writtenPathOf({ path: 'b.js' }), 'b.js');
|
|
93
|
-
assert.equal(writtenPathOf({ notebook_path: 'n.ipynb' }), 'n.ipynb');
|
|
94
|
-
assert.equal(writtenPathOf({}), '');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('delegationPromptOf reads the brief across field shapes', () => {
|
|
98
|
-
assert.equal(delegationPromptOf({ prompt: 'a' }), 'a');
|
|
99
|
-
assert.equal(delegationPromptOf({ description: 'b' }), 'b'); // Task
|
|
100
|
-
assert.equal(delegationPromptOf({ instructions: 'c' }), 'c'); // an MCP shape
|
|
101
|
-
assert.equal(delegationPromptOf({}), '');
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('toolNameOf reads tool_name, falls back to name, defaults to empty string', () => {
|
|
105
|
-
assert.equal(toolNameOf(JSON.stringify({ tool_name: 'Bash' })), 'Bash');
|
|
106
|
-
assert.equal(toolNameOf(JSON.stringify({ name: 'Edit' })), 'Edit');
|
|
107
|
-
assert.equal(
|
|
108
|
-
toolNameOf(JSON.stringify({ tool_name: 'Bash', name: 'Edit' })),
|
|
109
|
-
'Bash',
|
|
110
|
-
);
|
|
111
|
-
assert.equal(toolNameOf(JSON.stringify({})), '');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('toolNameOf returns null for unparseable payloads', () => {
|
|
115
|
-
assert.equal(toolNameOf('{not json'), null);
|
|
116
|
-
assert.equal(toolNameOf(undefined), null);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('sessionIdOf reads session_id or returns null when absent/unparseable', () => {
|
|
120
|
-
assert.equal(sessionIdOf(JSON.stringify({ session_id: 'abc123' })), 'abc123');
|
|
121
|
-
assert.equal(sessionIdOf(JSON.stringify({})), null);
|
|
122
|
-
assert.equal(sessionIdOf('{not json'), null);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('toolInputOf reads tool_input, falls back to input, defaults to {}', () => {
|
|
126
|
-
assert.deepEqual(
|
|
127
|
-
toolInputOf(JSON.stringify({ tool_input: { command: 'ls' } })),
|
|
128
|
-
{ command: 'ls' },
|
|
129
|
-
);
|
|
130
|
-
assert.deepEqual(toolInputOf(JSON.stringify({ input: { file: 'a' } })), {
|
|
131
|
-
file: 'a',
|
|
132
|
-
});
|
|
133
|
-
assert.deepEqual(
|
|
134
|
-
toolInputOf(JSON.stringify({ tool_input: { a: 1 }, input: { b: 2 } })),
|
|
135
|
-
{ a: 1 },
|
|
136
|
-
);
|
|
137
|
-
assert.deepEqual(toolInputOf(JSON.stringify({})), {});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('toolInputOf returns {} for unparseable payloads', () => {
|
|
141
|
-
assert.deepEqual(toolInputOf('{not json'), {});
|
|
142
|
-
assert.deepEqual(toolInputOf(undefined), {});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const hookIoModuleUrl = pathToFileURL(
|
|
146
|
-
new URL('../hook-io.mjs', import.meta.url).pathname.replace(
|
|
147
|
-
/^\/([A-Za-z]:)/,
|
|
148
|
-
'$1',
|
|
149
|
-
),
|
|
150
|
-
).href;
|
|
151
|
-
|
|
152
|
-
/** Runs `body` (a callback expression referencing `mod`) in a child process after
|
|
153
|
-
* dynamically importing hook-io.mjs there, so process.exit-calling exports can be
|
|
154
|
-
* exercised without killing the test runner. */
|
|
155
|
-
function runInChildProcess(body, execOptions) {
|
|
156
|
-
const script = `import('${hookIoModuleUrl}').then((mod) => { ${body} });`;
|
|
157
|
-
return execFileSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
158
|
-
encoding: 'utf8',
|
|
159
|
-
...execOptions,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
test('readHookPayload reads whatever is piped on stdin, including empty input', () => {
|
|
164
|
-
const output = runInChildProcess(
|
|
165
|
-
'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
|
|
166
|
-
{ input: '{"tool_name":"Bash"}' },
|
|
167
|
-
);
|
|
168
|
-
assert.equal(output, JSON.stringify('{"tool_name":"Bash"}'));
|
|
169
|
-
|
|
170
|
-
const emptyOutput = runInChildProcess(
|
|
171
|
-
'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
|
|
172
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
173
|
-
);
|
|
174
|
-
// With no stdin available, readFileSync(0) resolves to an empty string rather than
|
|
175
|
-
// throwing on this platform, so readHookPayload returns '' — not null. The null
|
|
176
|
-
// path (an unreadable fd 0) is exercised indirectly by every gate's own tests via
|
|
177
|
-
// runGate, since forcing an EBADF/EAGAIN on fd 0 portably from node:test is not
|
|
178
|
-
// reliable across platforms.
|
|
179
|
-
assert.equal(emptyOutput, JSON.stringify(''));
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test('deny writes a permissionDecision:deny hookSpecificOutput and exits 0', () => {
|
|
183
|
-
const output = runInChildProcess("mod.deny('my-gate', 'because reasons')", {
|
|
184
|
-
input: '',
|
|
185
|
-
});
|
|
186
|
-
const parsed = JSON.parse(output);
|
|
187
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
188
|
-
assert.equal(parsed.hookSpecificOutput.permissionDecision, 'deny');
|
|
189
|
-
assert.equal(
|
|
190
|
-
parsed.hookSpecificOutput.permissionDecisionReason,
|
|
191
|
-
'[my-gate] because reasons',
|
|
192
|
-
);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('warn writes additionalContext and exits 0', () => {
|
|
196
|
-
const output = runInChildProcess("mod.warn('my-gate', 'consider this')", {
|
|
197
|
-
input: '',
|
|
198
|
-
});
|
|
199
|
-
const parsed = JSON.parse(output);
|
|
200
|
-
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
|
|
201
|
-
assert.equal(
|
|
202
|
-
parsed.hookSpecificOutput.additionalContext,
|
|
203
|
-
'[my-gate] consider this',
|
|
204
|
-
);
|
|
205
|
-
assert.equal(parsed.hookSpecificOutput.permissionDecision, undefined);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('allow exits 0 with no stdout', () => {
|
|
209
|
-
const output = runInChildProcess('mod.allow()', { input: '' });
|
|
210
|
-
assert.equal(output, '');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test('shellWrittenPaths extracts paths a shell command creates', () => {
|
|
214
|
-
assert.deepEqual(shellWrittenPaths('printf x > basura.txt'), ['basura.txt']);
|
|
215
|
-
assert.deepEqual(shellWrittenPaths('echo hi >> log.txt'), ['log.txt']);
|
|
216
|
-
assert.deepEqual(shellWrittenPaths('touch nuevo.js'), ['nuevo.js']);
|
|
217
|
-
assert.deepEqual(shellWrittenPaths('tee out.txt'), ['out.txt']);
|
|
218
|
-
// creates nothing -> empty
|
|
219
|
-
assert.deepEqual(shellWrittenPaths('git status'), []);
|
|
220
|
-
assert.deepEqual(shellWrittenPaths('cat archivo.txt'), []);
|
|
221
|
-
// a dynamically built path is NOT extracted (documented limitation)
|
|
222
|
-
assert.deepEqual(shellWrittenPaths('printf x > "$f"'), []);
|
|
223
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import {
|
|
4
|
-
RISK_SIGNAL,
|
|
5
|
-
MONEY_SIGNAL,
|
|
6
|
-
AUTH_SIGNAL,
|
|
7
|
-
DESTRUCTIVE_DEPLOY_SIGNAL,
|
|
8
|
-
CONJECTURE,
|
|
9
|
-
PERSISTENCE_VERB,
|
|
10
|
-
withUnicodeWordBoundary,
|
|
11
|
-
} from '../signals.mjs';
|
|
12
|
-
|
|
13
|
-
// ── RISK_SIGNAL ──────────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
test('RISK_SIGNAL matches money terms in EN and ES', () => {
|
|
16
|
-
assert.ok(RISK_SIGNAL.test('update the payment flow'));
|
|
17
|
-
assert.ok(RISK_SIGNAL.test('actualizar el flujo de pago'));
|
|
18
|
-
assert.ok(RISK_SIGNAL.test('mover el saldo de una cuenta'));
|
|
19
|
-
assert.ok(RISK_SIGNAL.test('move the account balance'));
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('RISK_SIGNAL matches auth terms in EN and ES', () => {
|
|
23
|
-
assert.ok(RISK_SIGNAL.test('reset the user password'));
|
|
24
|
-
assert.ok(RISK_SIGNAL.test('resetear la contrasena del usuario'));
|
|
25
|
-
assert.ok(RISK_SIGNAL.test('resetear la contraseña del usuario'));
|
|
26
|
-
assert.ok(RISK_SIGNAL.test('cambiar la sesion activa'));
|
|
27
|
-
assert.ok(RISK_SIGNAL.test('cambiar la sesión activa'));
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('RISK_SIGNAL matches destructive/deploy terms in EN and ES', () => {
|
|
31
|
-
assert.ok(RISK_SIGNAL.test('deploy to production'));
|
|
32
|
-
assert.ok(RISK_SIGNAL.test('desplegar a produccion'));
|
|
33
|
-
assert.ok(RISK_SIGNAL.test('truncate the orders table'));
|
|
34
|
-
assert.ok(RISK_SIGNAL.test('eliminar la tabla de pedidos'));
|
|
35
|
-
assert.ok(RISK_SIGNAL.test('run the migration'));
|
|
36
|
-
assert.ok(RISK_SIGNAL.test('correr la migracion'));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('bilingual control: an ES risk brief matches exactly like its EN equivalent', () => {
|
|
40
|
-
const es = 'implementa el endpoint para mover el saldo de una cuenta bancaria';
|
|
41
|
-
const en = 'implement the endpoint to move the balance of a bank account';
|
|
42
|
-
assert.equal(RISK_SIGNAL.test(es), RISK_SIGNAL.test(en));
|
|
43
|
-
assert.ok(RISK_SIGNAL.test(es));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('RISK_SIGNAL does not fire on neutral text', () => {
|
|
47
|
-
assert.equal(RISK_SIGNAL.test('add a tooltip to the button'), false);
|
|
48
|
-
assert.equal(RISK_SIGNAL.test('agregar un tooltip al boton'), false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('RISK_SIGNAL false-positive guard: short terms require a full word', () => {
|
|
52
|
-
// "auth" inside "autor", "prod" inside "producto", "token" inside "tokenizer".
|
|
53
|
-
assert.equal(RISK_SIGNAL.test('the autor of this book'), false);
|
|
54
|
-
assert.equal(RISK_SIGNAL.test('el autor de este libro'), false);
|
|
55
|
-
assert.equal(RISK_SIGNAL.test('compra este producto'), false);
|
|
56
|
-
assert.equal(RISK_SIGNAL.test('buy this product'), false);
|
|
57
|
-
assert.equal(RISK_SIGNAL.test('the tokenizer splits words'), false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('MONEY_SIGNAL / AUTH_SIGNAL / DESTRUCTIVE_DEPLOY_SIGNAL are independently usable', () => {
|
|
61
|
-
assert.ok(MONEY_SIGNAL.test('pago'));
|
|
62
|
-
assert.equal(MONEY_SIGNAL.test('auth'), false);
|
|
63
|
-
assert.ok(AUTH_SIGNAL.test('token'));
|
|
64
|
-
assert.equal(AUTH_SIGNAL.test('pago'), false);
|
|
65
|
-
assert.ok(DESTRUCTIVE_DEPLOY_SIGNAL.test('produccion'));
|
|
66
|
-
assert.equal(DESTRUCTIVE_DEPLOY_SIGNAL.test('pago'), false);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// ── CONJECTURE ───────────────────────────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
test('CONJECTURE matches EN and ES conjecture phrasing', () => {
|
|
72
|
-
assert.ok(CONJECTURE.test('i assume the timezone is UTC'));
|
|
73
|
-
assert.ok(CONJECTURE.test('probably fine to skip this'));
|
|
74
|
-
assert.ok(CONJECTURE.test('supongo que esto funciona'));
|
|
75
|
-
assert.ok(CONJECTURE.test('asumo que el usuario ya esta logueado'));
|
|
76
|
-
assert.ok(CONJECTURE.test('deberia ser suficiente con esto'));
|
|
77
|
-
assert.ok(CONJECTURE.test('debería ser suficiente con esto'));
|
|
78
|
-
assert.ok(CONJECTURE.test('creo que esto anda bien'));
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('bilingual control: ES and EN conjecture brief both trigger', () => {
|
|
82
|
-
const es = 'creo que el usuario ya esta autenticado, no lo verifique';
|
|
83
|
-
const en = 'i assume the user is already authenticated, did not verify it';
|
|
84
|
-
assert.equal(CONJECTURE.test(es), CONJECTURE.test(en));
|
|
85
|
-
assert.ok(CONJECTURE.test(es));
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('CONJECTURE does not fire on neutral confirmed statements', () => {
|
|
89
|
-
assert.equal(CONJECTURE.test('verified: the timezone is UTC per config.json'), false);
|
|
90
|
-
assert.equal(CONJECTURE.test('confirmado: el usuario esta autenticado'), false);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ── PERSISTENCE_VERB ─────────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
test('PERSISTENCE_VERB matches EN and ES persistence instructions', () => {
|
|
96
|
-
assert.ok(PERSISTENCE_VERB.test('save this to state.json'));
|
|
97
|
-
assert.ok(PERSISTENCE_VERB.test('guarda esto en decision.md'));
|
|
98
|
-
assert.ok(PERSISTENCE_VERB.test('persist the result in the database'));
|
|
99
|
-
assert.ok(PERSISTENCE_VERB.test('escribi esto en el archivo'));
|
|
100
|
-
assert.ok(PERSISTENCE_VERB.test('anota el resultado en el log'));
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('PERSISTENCE_VERB does not fire on unrelated prose', () => {
|
|
104
|
-
assert.equal(PERSISTENCE_VERB.test('the button looks nice'), false);
|
|
105
|
-
assert.equal(PERSISTENCE_VERB.test('el boton se ve bien'), false);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ── withUnicodeWordBoundary ──────────────────────────────────────────────────────────
|
|
109
|
-
|
|
110
|
-
test('withUnicodeWordBoundary rejects a substring match', () => {
|
|
111
|
-
const pattern = withUnicodeWordBoundary('auth');
|
|
112
|
-
assert.ok(pattern.test('auth required'));
|
|
113
|
-
assert.equal(pattern.test('autor'), false);
|
|
114
|
-
});
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
// Edge audit for the common scaffolding (hook-io.mjs, config.mjs) that every gate shares.
|
|
2
|
-
// Uses a minimal real gate built on runGate() so behavior is exercised end to end rather
|
|
3
|
-
// than by reading source and guessing.
|
|
4
|
-
//
|
|
5
|
-
// Findings:
|
|
6
|
-
// 1. Config precedence: a project config that EXISTS but omits a given gate key falls
|
|
7
|
-
// through to the REGISTRY DEFAULT passed by the gate (not to the global config) --
|
|
8
|
-
// confirmed by gatesLayerFor's early return the moment projectData is truthy
|
|
9
|
-
// (config.mjs line 80-81: `if (projectData) return projectData.gates ?? {};`). A
|
|
10
|
-
// global config with the key enabled is never consulted once a project config file
|
|
11
|
-
// exists at all, even if it says nothing about this particular gate.
|
|
12
|
-
// 2. A corrupted project config.json does NOT silently disable protection: readJsonOrNull
|
|
13
|
-
// returns null on parse failure, gatesLayerFor then falls through to the global config
|
|
14
|
-
// layer, and if that too is corrupt/absent, isGateEnabled falls back to the gate's own
|
|
15
|
-
// registryDefault. Verified with a gate whose registryDefault is `true` (deny-by-default)
|
|
16
|
-
// to prove corruption doesn't fail open when the default is protective.
|
|
17
|
-
// 3. toolNameOf/runGate coalescing (payload.tool_name ?? payload.name ?? '' -> '' on
|
|
18
|
-
// unparseable JSON) is exercised directly here against the shared helpers, independent
|
|
19
|
-
// of any specific gate's matcher set.
|
|
20
|
-
|
|
21
|
-
import assert from 'node:assert/strict';
|
|
22
|
-
import { execFileSync } from 'node:child_process';
|
|
23
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
24
|
-
import { tmpdir } from 'node:os';
|
|
25
|
-
import { dirname, join } from 'node:path';
|
|
26
|
-
import { test } from 'node:test';
|
|
27
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
|
-
|
|
29
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
30
|
-
|
|
31
|
-
// A minimal probe gate: denies unconditionally whenever it runs and is enabled, with a
|
|
32
|
-
// registryDefault of true (protective/deny-by-default), so we can distinguish "ran and
|
|
33
|
-
// allowed via toolName mismatch" from "did not run because disabled".
|
|
34
|
-
const PROBE_GATE_SOURCE = `
|
|
35
|
-
import { runGate, deny } from ${JSON.stringify(pathToFileURL(join(HERE, 'hook-io.mjs')).href)};
|
|
36
|
-
runGate(
|
|
37
|
-
{ id: 'probe-gate', configKey: 'probeGate', enabledByDefault: true },
|
|
38
|
-
({ toolName }) => {
|
|
39
|
-
deny('probe-gate', 'toolName=' + JSON.stringify(toolName));
|
|
40
|
-
},
|
|
41
|
-
);
|
|
42
|
-
`;
|
|
43
|
-
|
|
44
|
-
function writeProbeGate(directory) {
|
|
45
|
-
const gatePath = join(directory, 'probe-gate.mjs');
|
|
46
|
-
writeFileSync(gatePath, PROBE_GATE_SOURCE);
|
|
47
|
-
return gatePath;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function runProbe(gatePath, rawInput, project) {
|
|
51
|
-
const out = execFileSync(process.execPath, [gatePath], {
|
|
52
|
-
input: rawInput,
|
|
53
|
-
encoding: 'utf8',
|
|
54
|
-
cwd: project,
|
|
55
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
56
|
-
});
|
|
57
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isDeny(result) {
|
|
61
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
test('toolNameOf/runGate: unparseable JSON payload coalesces toolName to empty string, not null', () => {
|
|
65
|
-
const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
|
|
66
|
-
const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
|
|
67
|
-
mkdirSync(join(project, '.git'));
|
|
68
|
-
const gatePath = writeProbeGate(scratch);
|
|
69
|
-
// registryDefault=true means the probe gate is ON with no config at all; it always denies
|
|
70
|
-
// when it runs, and the deny message embeds the resolved toolName so we can inspect it.
|
|
71
|
-
const result = runProbe(gatePath, 'not json at all {{{', project);
|
|
72
|
-
assert.ok(isDeny(result));
|
|
73
|
-
assert.match(
|
|
74
|
-
result.hookSpecificOutput.permissionDecisionReason,
|
|
75
|
-
/toolName=""/,
|
|
76
|
-
'confirms toolNameOf(...) ?? \'\' resolves corrupt payloads to the empty string, ' +
|
|
77
|
-
'the exact value that fails to match any TOOL_GROUPS Set',
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('config precedence: project config exists but omits the gate key -> registryDefault wins, NOT global config', () => {
|
|
82
|
-
const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
|
|
83
|
-
mkdirSync(join(project, '.git'));
|
|
84
|
-
mkdirSync(join(project, '.ai'));
|
|
85
|
-
// Project config exists, and declares gates, but never mentions 'probeGate'.
|
|
86
|
-
writeFileSync(
|
|
87
|
-
join(project, '.ai', 'config.json'),
|
|
88
|
-
JSON.stringify({ gates: { someOtherGate: true } }),
|
|
89
|
-
);
|
|
90
|
-
// Global config (same HOME as project, since HOME=project below) explicitly disables it.
|
|
91
|
-
// If precedence were "most specific explicit setting wins", global's false should apply.
|
|
92
|
-
// If precedence is "project file existing short-circuits global entirely", the probe's
|
|
93
|
-
// own registryDefault (true) should apply instead, ignoring global.
|
|
94
|
-
const home = project; // isolate HOME to a dir we control, distinct from project root marker use
|
|
95
|
-
mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
|
|
96
|
-
writeFileSync(
|
|
97
|
-
join(home, '.claude', 'claude-gates', 'config.json'),
|
|
98
|
-
JSON.stringify({ gates: { probeGate: false } }),
|
|
99
|
-
);
|
|
100
|
-
const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
|
|
101
|
-
const gatePath = writeProbeGate(scratch);
|
|
102
|
-
const out = execFileSync(process.execPath, [gatePath], {
|
|
103
|
-
input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
|
|
104
|
-
encoding: 'utf8',
|
|
105
|
-
cwd: project,
|
|
106
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
107
|
-
});
|
|
108
|
-
const result = out.trim() ? JSON.parse(out.trim()) : null;
|
|
109
|
-
assert.ok(
|
|
110
|
-
isDeny(result),
|
|
111
|
-
'PASA(confirmed): project config existing makes gatesLayerFor return early with {} ' +
|
|
112
|
-
'for gates it does not mention, so registryDefault (true/deny) wins over the global ' +
|
|
113
|
-
'config saying false -- global is never consulted once ANY project config.json exists',
|
|
114
|
-
);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('config precedence: NO project config at all -> global config value is honored', () => {
|
|
118
|
-
const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
|
|
119
|
-
mkdirSync(join(project, '.git'));
|
|
120
|
-
// No .ai/config.json in the project at all.
|
|
121
|
-
const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
|
|
122
|
-
mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
|
|
123
|
-
writeFileSync(
|
|
124
|
-
join(home, '.claude', 'claude-gates', 'config.json'),
|
|
125
|
-
JSON.stringify({ gates: { probeGate: false } }),
|
|
126
|
-
);
|
|
127
|
-
const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
|
|
128
|
-
const gatePath = writeProbeGate(scratch);
|
|
129
|
-
const out = execFileSync(process.execPath, [gatePath], {
|
|
130
|
-
input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
|
|
131
|
-
encoding: 'utf8',
|
|
132
|
-
cwd: project,
|
|
133
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
134
|
-
});
|
|
135
|
-
const result = out.trim() ? JSON.parse(out.trim()) : null;
|
|
136
|
-
assert.equal(
|
|
137
|
-
result,
|
|
138
|
-
null,
|
|
139
|
-
'control: with no project config file present at all, global config IS consulted and ' +
|
|
140
|
-
'its false correctly disables the gate -- confirms the precedence bug above is ' +
|
|
141
|
-
'specifically "project file exists" short-circuiting, not "global never works"',
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('corrupted project config.json does not silently disable a protective (default-on) gate', () => {
|
|
146
|
-
const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
|
|
147
|
-
mkdirSync(join(project, '.git'));
|
|
148
|
-
mkdirSync(join(project, '.ai'));
|
|
149
|
-
writeFileSync(join(project, '.ai', 'config.json'), '{ this is not valid json ][');
|
|
150
|
-
const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
|
|
151
|
-
const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
|
|
152
|
-
const gatePath = writeProbeGate(scratch);
|
|
153
|
-
const out = execFileSync(process.execPath, [gatePath], {
|
|
154
|
-
input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
|
|
155
|
-
encoding: 'utf8',
|
|
156
|
-
cwd: project,
|
|
157
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
158
|
-
});
|
|
159
|
-
const result = out.trim() ? JSON.parse(out.trim()) : null;
|
|
160
|
-
assert.ok(
|
|
161
|
-
isDeny(result),
|
|
162
|
-
'FALLA(ok): corrupt project config treated as absent -> falls through to global (none) ' +
|
|
163
|
-
'-> registryDefault (true) -> still protected, matching config.mjs\'s documented intent',
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('corrupted GLOBAL config.json (no project config) does not silently disable a protective gate', () => {
|
|
168
|
-
const project = mkdtempSync(join(tmpdir(), 'hookio-proj-'));
|
|
169
|
-
mkdirSync(join(project, '.git'));
|
|
170
|
-
const home = mkdtempSync(join(tmpdir(), 'hookio-home-'));
|
|
171
|
-
mkdirSync(join(home, '.claude', 'claude-gates'), { recursive: true });
|
|
172
|
-
writeFileSync(
|
|
173
|
-
join(home, '.claude', 'claude-gates', 'config.json'),
|
|
174
|
-
'{ not valid json at all',
|
|
175
|
-
);
|
|
176
|
-
const scratch = mkdtempSync(join(tmpdir(), 'hookio-edge-'));
|
|
177
|
-
const gatePath = writeProbeGate(scratch);
|
|
178
|
-
const out = execFileSync(process.execPath, [gatePath], {
|
|
179
|
-
input: JSON.stringify({ tool_name: 'Write', tool_input: {} }),
|
|
180
|
-
encoding: 'utf8',
|
|
181
|
-
cwd: project,
|
|
182
|
-
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
183
|
-
});
|
|
184
|
-
const result = out.trim() ? JSON.parse(out.trim()) : null;
|
|
185
|
-
assert.ok(
|
|
186
|
-
isDeny(result),
|
|
187
|
-
'FALLA(ok): corrupt global config also treated as absent -> registryDefault wins',
|
|
188
|
-
);
|
|
189
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// register-requests — UserPromptSubmit hook. Spawned as a real child process, matching how
|
|
2
|
-
// Claude Code invokes it, against a temp project.
|
|
3
|
-
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
-
import { test } from 'node:test';
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { openTaskStore } from '../lib/task-store.mjs';
|
|
12
|
-
|
|
13
|
-
const HOOK_PATH = join(
|
|
14
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
15
|
-
'..',
|
|
16
|
-
'register-requests.mjs',
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
function makeProject() {
|
|
20
|
-
const project = mkdtempSync(join(tmpdir(), 'register-requests-'));
|
|
21
|
-
mkdirSync(join(project, '.git'));
|
|
22
|
-
return project;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function runHook(cwd) {
|
|
26
|
-
return execFileSync(process.execPath, [HOOK_PATH], {
|
|
27
|
-
input: JSON.stringify({ cwd }),
|
|
28
|
-
encoding: 'utf8',
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function writeConfig(project, gates) {
|
|
33
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
34
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify({ gates }));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sampleTask(id) {
|
|
38
|
-
return {
|
|
39
|
-
id,
|
|
40
|
-
title: `task ${id}`,
|
|
41
|
-
description: '',
|
|
42
|
-
status: 'open',
|
|
43
|
-
size: 'trivial',
|
|
44
|
-
createdAt: new Date().toISOString(),
|
|
45
|
-
messages: [],
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
test('always asks the model to classify+register, even with no active tasks yet', () => {
|
|
50
|
-
const project = makeProject();
|
|
51
|
-
const stdout = runHook(project);
|
|
52
|
-
assert.match(stdout, /claude-gates task add/);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('does NOT recite active tasks before remindEveryMessages is reached', () => {
|
|
56
|
-
const project = makeProject();
|
|
57
|
-
const store = openTaskStore(project);
|
|
58
|
-
store.add(sampleTask('t1'));
|
|
59
|
-
writeConfig(project, { remindOpenTasks: { enabled: true, remindEveryMessages: 3 } });
|
|
60
|
-
|
|
61
|
-
const first = runHook(project);
|
|
62
|
-
assert.doesNotMatch(first, /OPEN TASKS/);
|
|
63
|
-
const second = runHook(project);
|
|
64
|
-
assert.doesNotMatch(second, /OPEN TASKS/);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('recites active tasks once remindEveryMessages is reached, then resets the counter', () => {
|
|
68
|
-
const project = makeProject();
|
|
69
|
-
const store = openTaskStore(project);
|
|
70
|
-
store.add(sampleTask('t1'));
|
|
71
|
-
writeConfig(project, { remindOpenTasks: { enabled: true, remindEveryMessages: 2 } });
|
|
72
|
-
|
|
73
|
-
runHook(project); // 1st call: counter -> 1, no reminder
|
|
74
|
-
const forced = runHook(project); // 2nd call: counter hits threshold -> reminder + reset
|
|
75
|
-
assert.match(forced, /OPEN TASKS/);
|
|
76
|
-
assert.match(forced, /t1/);
|
|
77
|
-
|
|
78
|
-
const afterReset = runHook(project); // 3rd call: counter just reset -> no reminder yet
|
|
79
|
-
assert.doesNotMatch(afterReset, /OPEN TASKS/);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('remindOpenTasks:false silences the hook entirely (no classify prompt, no reminder)', () => {
|
|
83
|
-
const project = makeProject();
|
|
84
|
-
const store = openTaskStore(project);
|
|
85
|
-
store.add(sampleTask('t1'));
|
|
86
|
-
writeConfig(project, { remindOpenTasks: false });
|
|
87
|
-
|
|
88
|
-
const stdout = runHook(project);
|
|
89
|
-
assert.equal(stdout, '');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('defaults-dump mode reports remindEveryMessages default without touching stdin', () => {
|
|
93
|
-
const out = execFileSync(process.execPath, [HOOK_PATH], {
|
|
94
|
-
encoding: 'utf8',
|
|
95
|
-
env: { ...process.env, CLAUDE_GATES_DUMP_DEFAULTS: '1' },
|
|
96
|
-
});
|
|
97
|
-
const parsed = JSON.parse(out);
|
|
98
|
-
assert.equal(parsed.configKey, 'remindOpenTasks');
|
|
99
|
-
assert.equal(parsed.defaultParams.remindEveryMessages, 5);
|
|
100
|
-
});
|