@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,154 +1,210 @@
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
- } from '../hook-io.mjs';
12
-
13
- // deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
14
- // killing the test runner. They are exercised here as a child process instead; each
15
- // gate's own tests additionally cover runGate's wiring through its real check function.
16
-
17
- test('toolNamesFor expands a single group', () => {
18
- assert.deepEqual(toolNamesFor(['question']), ['AskUserQuestion']);
19
- });
20
-
21
- test('toolNamesFor expands multiple groups and de-duplicates overlapping names', () => {
22
- const names = toolNamesFor(['write', 'execution']);
23
- // 'execution' repeats every 'write' tool plus Bash/run_command/mcp__ide__executeCode.
24
- assert.equal(names.filter((name) => name === 'Write').length, 1);
25
- assert.ok(names.includes('Write'));
26
- assert.ok(names.includes('Bash'));
27
- assert.ok(names.includes('mcp__ide__executeCode'));
28
- });
29
-
30
- test('toolNamesFor returns an empty list for an empty group set', () => {
31
- assert.deepEqual(toolNamesFor([]), []);
32
- });
33
-
34
- test('toolNamesFor ignores an unknown group name', () => {
35
- assert.deepEqual(toolNamesFor(['not-a-real-group']), []);
36
- });
37
-
38
- test('matcherFor joins tool names with a pipe', () => {
39
- assert.equal(matcherFor(['question']), 'AskUserQuestion');
40
- assert.equal(matcherFor(['shell']), 'Bash|run_command');
41
- });
42
-
43
- test('matcherFor returns an empty string for an empty group set', () => {
44
- assert.equal(matcherFor([]), '');
45
- });
46
-
47
- test('toolNameOf reads tool_name, falls back to name, defaults to empty string', () => {
48
- assert.equal(toolNameOf(JSON.stringify({ tool_name: 'Bash' })), 'Bash');
49
- assert.equal(toolNameOf(JSON.stringify({ name: 'Edit' })), 'Edit');
50
- assert.equal(
51
- toolNameOf(JSON.stringify({ tool_name: 'Bash', name: 'Edit' })),
52
- 'Bash',
53
- );
54
- assert.equal(toolNameOf(JSON.stringify({})), '');
55
- });
56
-
57
- test('toolNameOf returns null for unparseable payloads', () => {
58
- assert.equal(toolNameOf('{not json'), null);
59
- assert.equal(toolNameOf(undefined), null);
60
- });
61
-
62
- test('sessionIdOf reads session_id or returns null when absent/unparseable', () => {
63
- assert.equal(sessionIdOf(JSON.stringify({ session_id: 'abc123' })), 'abc123');
64
- assert.equal(sessionIdOf(JSON.stringify({})), null);
65
- assert.equal(sessionIdOf('{not json'), null);
66
- });
67
-
68
- test('toolInputOf reads tool_input, falls back to input, defaults to {}', () => {
69
- assert.deepEqual(
70
- toolInputOf(JSON.stringify({ tool_input: { command: 'ls' } })),
71
- { command: 'ls' },
72
- );
73
- assert.deepEqual(toolInputOf(JSON.stringify({ input: { file: 'a' } })), {
74
- file: 'a',
75
- });
76
- assert.deepEqual(
77
- toolInputOf(JSON.stringify({ tool_input: { a: 1 }, input: { b: 2 } })),
78
- { a: 1 },
79
- );
80
- assert.deepEqual(toolInputOf(JSON.stringify({})), {});
81
- });
82
-
83
- test('toolInputOf returns {} for unparseable payloads', () => {
84
- assert.deepEqual(toolInputOf('{not json'), {});
85
- assert.deepEqual(toolInputOf(undefined), {});
86
- });
87
-
88
- const hookIoModuleUrl = pathToFileURL(
89
- new URL('../hook-io.mjs', import.meta.url).pathname.replace(
90
- /^\/([A-Za-z]:)/,
91
- '$1',
92
- ),
93
- ).href;
94
-
95
- /** Runs `body` (a callback expression referencing `mod`) in a child process after
96
- * dynamically importing hook-io.mjs there, so process.exit-calling exports can be
97
- * exercised without killing the test runner. */
98
- function runInChildProcess(body, execOptions) {
99
- const script = `import('${hookIoModuleUrl}').then((mod) => { ${body} });`;
100
- return execFileSync(process.execPath, ['--input-type=module', '-e', script], {
101
- encoding: 'utf8',
102
- ...execOptions,
103
- });
104
- }
105
-
106
- test('readHookPayload reads whatever is piped on stdin, including empty input', () => {
107
- const output = runInChildProcess(
108
- 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
109
- { input: '{"tool_name":"Bash"}' },
110
- );
111
- assert.equal(output, JSON.stringify('{"tool_name":"Bash"}'));
112
-
113
- const emptyOutput = runInChildProcess(
114
- 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
115
- { stdio: ['ignore', 'pipe', 'pipe'] },
116
- );
117
- // With no stdin available, readFileSync(0) resolves to an empty string rather than
118
- // throwing on this platform, so readHookPayload returns '' not null. The null
119
- // path (an unreadable fd 0) is exercised indirectly by every gate's own tests via
120
- // runGate, since forcing an EBADF/EAGAIN on fd 0 portably from node:test is not
121
- // reliable across platforms.
122
- assert.equal(emptyOutput, JSON.stringify(''));
123
- });
124
-
125
- test('deny writes a permissionDecision:deny hookSpecificOutput and exits 0', () => {
126
- const output = runInChildProcess("mod.deny('my-gate', 'because reasons')", {
127
- input: '',
128
- });
129
- const parsed = JSON.parse(output);
130
- assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
131
- assert.equal(parsed.hookSpecificOutput.permissionDecision, 'deny');
132
- assert.equal(
133
- parsed.hookSpecificOutput.permissionDecisionReason,
134
- '[my-gate] because reasons',
135
- );
136
- });
137
-
138
- test('warn writes additionalContext and exits 0', () => {
139
- const output = runInChildProcess("mod.warn('my-gate', 'consider this')", {
140
- input: '',
141
- });
142
- const parsed = JSON.parse(output);
143
- assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
144
- assert.equal(
145
- parsed.hookSpecificOutput.additionalContext,
146
- '[my-gate] consider this',
147
- );
148
- assert.equal(parsed.hookSpecificOutput.permissionDecision, undefined);
149
- });
150
-
151
- test('allow exits 0 with no stdout', () => {
152
- const output = runInChildProcess('mod.allow()', { input: '' });
153
- assert.equal(output, '');
154
- });
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
+ } from '../hook-io.mjs';
16
+
17
+ // deny/warn/allow/runGate call process.exit and cannot be invoked in-process without
18
+ // killing the test runner. They are exercised here as a child process instead; each
19
+ // gate's own tests additionally cover runGate's wiring through its real check function.
20
+
21
+ test('toolNamesFor expands a single group', () => {
22
+ assert.deepEqual(toolNamesFor(['question']), ['AskUserQuestion']);
23
+ });
24
+
25
+ test('toolNamesFor expands multiple groups and de-duplicates overlapping names', () => {
26
+ const names = toolNamesFor(['write', 'execution']);
27
+ // 'execution' repeats every 'write' tool plus Bash/run_command/mcp__ide__executeCode.
28
+ assert.equal(names.filter((name) => name === 'Write').length, 1);
29
+ assert.ok(names.includes('Write'));
30
+ assert.ok(names.includes('Bash'));
31
+ assert.ok(names.includes('mcp__ide__executeCode'));
32
+ });
33
+
34
+ test('toolNamesFor returns an empty list for an empty group set', () => {
35
+ assert.deepEqual(toolNamesFor([]), []);
36
+ });
37
+
38
+ test('toolNamesFor ignores an unknown group name', () => {
39
+ assert.deepEqual(toolNamesFor(['not-a-real-group']), []);
40
+ });
41
+
42
+ test('matcherFor joins native tool names with a pipe and always appends the mcp__.* clause', () => {
43
+ // The trailing `mcp__.*` makes Claude Code route EVERY MCP tool call to the hook, so the
44
+ // gate can classify it at runtime with toolInGroups. Without it, the hook is never even
45
+ // invoked for an MCP tool — the deepest layer of the MCP blind spot.
46
+ assert.equal(matcherFor(['question']), 'AskUserQuestion|mcp__.*');
47
+ assert.equal(matcherFor(['shell']), 'Bash|run_command|mcp__.*');
48
+ });
49
+
50
+ test('matcherFor still matches MCP tools even for an empty native group set', () => {
51
+ // An empty group means "no native names", but MCP tools must still reach the hook.
52
+ assert.equal(matcherFor([]), 'mcp__.*');
53
+ });
54
+
55
+ test('toolInGroups matches native names exactly and case-insensitively', () => {
56
+ assert.equal(toolInGroups('Write', ['write']), true);
57
+ assert.equal(toolInGroups('write', ['write']), true); // case-insensitive
58
+ assert.equal(toolInGroups('AskUserQuestion', ['question']), true);
59
+ assert.equal(toolInGroups('Bash', ['write']), false); // wrong group
60
+ assert.equal(toolInGroups('', ['write']), false); // empty tool name
61
+ });
62
+
63
+ test('toolInGroups classifies MCP tools by their action segment', () => {
64
+ assert.equal(toolInGroups('mcp__filesystem__write_file', ['write']), true);
65
+ assert.equal(toolInGroups('mcp__fs__edit_file', ['write']), true);
66
+ assert.equal(toolInGroups('mcp__shell__exec', ['shell']), true);
67
+ assert.equal(toolInGroups('mcp__x__ask_user_confirmation', ['question']), true);
68
+ assert.equal(toolInGroups('mcp__orchestrator__spawn_agent', ['delegation']), true);
69
+ // A read-only MCP tool must NOT match a write/shell group.
70
+ assert.equal(toolInGroups('mcp__fs__read_file', ['write', 'shell']), false);
71
+ // Malformed mcp name (no action segment) does not match.
72
+ assert.equal(toolInGroups('mcp__server', ['write']), false);
73
+ });
74
+
75
+ test('writtenContentOf reads content across native and MCP field shapes', () => {
76
+ assert.equal(writtenContentOf({ content: 'a' }), 'a'); // Write
77
+ assert.equal(writtenContentOf({ new_string: 'b' }), 'b'); // Edit
78
+ assert.equal(writtenContentOf({ new_source: 'c' }), 'c'); // NotebookEdit
79
+ assert.equal(writtenContentOf({ new_content: 'd' }), 'd'); // replace_file_content
80
+ assert.equal(writtenContentOf({ text: 'e' }), 'e'); // an MCP shape
81
+ assert.equal(
82
+ writtenContentOf({ edits: [{ new_string: 'x' }, { new_string: 'y' }] }),
83
+ 'x\ny',
84
+ ); // MultiEdit
85
+ assert.equal(writtenContentOf({}), '');
86
+ assert.equal(writtenContentOf(null), '');
87
+ });
88
+
89
+ test('writtenPathOf reads the target path across field shapes', () => {
90
+ assert.equal(writtenPathOf({ file_path: 'a.js' }), 'a.js');
91
+ assert.equal(writtenPathOf({ path: 'b.js' }), 'b.js');
92
+ assert.equal(writtenPathOf({ notebook_path: 'n.ipynb' }), 'n.ipynb');
93
+ assert.equal(writtenPathOf({}), '');
94
+ });
95
+
96
+ test('delegationPromptOf reads the brief across field shapes', () => {
97
+ assert.equal(delegationPromptOf({ prompt: 'a' }), 'a');
98
+ assert.equal(delegationPromptOf({ description: 'b' }), 'b'); // Task
99
+ assert.equal(delegationPromptOf({ instructions: 'c' }), 'c'); // an MCP shape
100
+ assert.equal(delegationPromptOf({}), '');
101
+ });
102
+
103
+ test('toolNameOf reads tool_name, falls back to name, defaults to empty string', () => {
104
+ assert.equal(toolNameOf(JSON.stringify({ tool_name: 'Bash' })), 'Bash');
105
+ assert.equal(toolNameOf(JSON.stringify({ name: 'Edit' })), 'Edit');
106
+ assert.equal(
107
+ toolNameOf(JSON.stringify({ tool_name: 'Bash', name: 'Edit' })),
108
+ 'Bash',
109
+ );
110
+ assert.equal(toolNameOf(JSON.stringify({})), '');
111
+ });
112
+
113
+ test('toolNameOf returns null for unparseable payloads', () => {
114
+ assert.equal(toolNameOf('{not json'), null);
115
+ assert.equal(toolNameOf(undefined), null);
116
+ });
117
+
118
+ test('sessionIdOf reads session_id or returns null when absent/unparseable', () => {
119
+ assert.equal(sessionIdOf(JSON.stringify({ session_id: 'abc123' })), 'abc123');
120
+ assert.equal(sessionIdOf(JSON.stringify({})), null);
121
+ assert.equal(sessionIdOf('{not json'), null);
122
+ });
123
+
124
+ test('toolInputOf reads tool_input, falls back to input, defaults to {}', () => {
125
+ assert.deepEqual(
126
+ toolInputOf(JSON.stringify({ tool_input: { command: 'ls' } })),
127
+ { command: 'ls' },
128
+ );
129
+ assert.deepEqual(toolInputOf(JSON.stringify({ input: { file: 'a' } })), {
130
+ file: 'a',
131
+ });
132
+ assert.deepEqual(
133
+ toolInputOf(JSON.stringify({ tool_input: { a: 1 }, input: { b: 2 } })),
134
+ { a: 1 },
135
+ );
136
+ assert.deepEqual(toolInputOf(JSON.stringify({})), {});
137
+ });
138
+
139
+ test('toolInputOf returns {} for unparseable payloads', () => {
140
+ assert.deepEqual(toolInputOf('{not json'), {});
141
+ assert.deepEqual(toolInputOf(undefined), {});
142
+ });
143
+
144
+ const hookIoModuleUrl = pathToFileURL(
145
+ new URL('../hook-io.mjs', import.meta.url).pathname.replace(
146
+ /^\/([A-Za-z]:)/,
147
+ '$1',
148
+ ),
149
+ ).href;
150
+
151
+ /** Runs `body` (a callback expression referencing `mod`) in a child process after
152
+ * dynamically importing hook-io.mjs there, so process.exit-calling exports can be
153
+ * exercised without killing the test runner. */
154
+ function runInChildProcess(body, execOptions) {
155
+ const script = `import('${hookIoModuleUrl}').then((mod) => { ${body} });`;
156
+ return execFileSync(process.execPath, ['--input-type=module', '-e', script], {
157
+ encoding: 'utf8',
158
+ ...execOptions,
159
+ });
160
+ }
161
+
162
+ test('readHookPayload reads whatever is piped on stdin, including empty input', () => {
163
+ const output = runInChildProcess(
164
+ 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
165
+ { input: '{"tool_name":"Bash"}' },
166
+ );
167
+ assert.equal(output, JSON.stringify('{"tool_name":"Bash"}'));
168
+
169
+ const emptyOutput = runInChildProcess(
170
+ 'process.stdout.write(JSON.stringify(mod.readHookPayload()))',
171
+ { stdio: ['ignore', 'pipe', 'pipe'] },
172
+ );
173
+ // With no stdin available, readFileSync(0) resolves to an empty string rather than
174
+ // throwing on this platform, so readHookPayload returns '' — not null. The null
175
+ // path (an unreadable fd 0) is exercised indirectly by every gate's own tests via
176
+ // runGate, since forcing an EBADF/EAGAIN on fd 0 portably from node:test is not
177
+ // reliable across platforms.
178
+ assert.equal(emptyOutput, JSON.stringify(''));
179
+ });
180
+
181
+ test('deny writes a permissionDecision:deny hookSpecificOutput and exits 0', () => {
182
+ const output = runInChildProcess("mod.deny('my-gate', 'because reasons')", {
183
+ input: '',
184
+ });
185
+ const parsed = JSON.parse(output);
186
+ assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
187
+ assert.equal(parsed.hookSpecificOutput.permissionDecision, 'deny');
188
+ assert.equal(
189
+ parsed.hookSpecificOutput.permissionDecisionReason,
190
+ '[my-gate] because reasons',
191
+ );
192
+ });
193
+
194
+ test('warn writes additionalContext and exits 0', () => {
195
+ const output = runInChildProcess("mod.warn('my-gate', 'consider this')", {
196
+ input: '',
197
+ });
198
+ const parsed = JSON.parse(output);
199
+ assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreToolUse');
200
+ assert.equal(
201
+ parsed.hookSpecificOutput.additionalContext,
202
+ '[my-gate] consider this',
203
+ );
204
+ assert.equal(parsed.hookSpecificOutput.permissionDecision, undefined);
205
+ });
206
+
207
+ test('allow exits 0 with no stdout', () => {
208
+ const output = runInChildProcess('mod.allow()', { input: '' });
209
+ assert.equal(output, '');
210
+ });
@@ -0,0 +1,114 @@
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
+ });