@devrik-tools/claude-gates 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
  10. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  11. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  12. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  13. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  14. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  15. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  16. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
  17. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  18. package/plugins/gates/hooks/hooks.json +365 -346
  19. package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
  20. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  21. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  22. package/registry.json +738 -793
  23. package/cli/__tests__/config.test.mjs +0 -113
  24. package/cli/__tests__/init-flags.test.mjs +0 -111
  25. package/cli/__tests__/install.test.mjs +0 -102
  26. package/cli/__tests__/materialize.test.mjs +0 -95
  27. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  28. package/cli/__tests__/selection.test.mjs +0 -200
  29. package/cli/__tests__/task.test.mjs +0 -124
  30. package/cli/__tests__/version-consistency.test.mjs +0 -44
  31. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  32. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  33. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  34. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  35. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  36. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  37. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  38. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  39. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  40. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  41. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  42. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  43. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  44. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  45. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  46. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  47. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  48. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  49. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  50. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  51. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  52. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  53. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  54. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  55. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  56. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  57. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  58. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  59. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  60. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  61. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  62. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  63. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  64. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  65. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  66. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  67. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  68. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  69. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  70. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  71. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  72. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  73. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  74. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  75. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  76. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  77. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  78. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  79. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  80. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
  81. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  82. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  83. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  84. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  85. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  86. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  87. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  88. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  89. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  90. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  91. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  92. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  93. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  94. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
  95. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  96. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  99. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,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
- });
@@ -1,95 +0,0 @@
1
- // session-tasks — SessionStart hook. Spawned as a real child process with a JSON payload on
2
- // stdin, exactly as Claude Code invokes it, against a temp project (never this repo's own
3
- // .ai/ state).
4
-
5
- import assert from 'node:assert/strict';
6
- import { execFileSync } from 'node:child_process';
7
- import { mkdirSync, mkdtempSync, 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
- import { openTaskStore, STATUS } from '../lib/task-store.mjs';
13
-
14
- const HOOK_PATH = join(
15
- dirname(fileURLToPath(import.meta.url)),
16
- '..',
17
- 'session-tasks.mjs',
18
- );
19
-
20
- function makeProject() {
21
- const project = mkdtempSync(join(tmpdir(), 'session-tasks-'));
22
- mkdirSync(join(project, '.git'));
23
- return project;
24
- }
25
-
26
- function runHook(cwd) {
27
- return execFileSync(process.execPath, [HOOK_PATH], {
28
- input: JSON.stringify({ cwd }),
29
- encoding: 'utf8',
30
- });
31
- }
32
-
33
- function sampleTask(id, status = STATUS.OPEN) {
34
- return {
35
- id,
36
- title: `task ${id}`,
37
- description: '',
38
- status,
39
- size: 'trivial',
40
- createdAt: new Date().toISOString(),
41
- messages: [],
42
- };
43
- }
44
-
45
- test('stays silent (empty stdout) when there are no active tasks', () => {
46
- const project = makeProject();
47
- const stdout = runHook(project);
48
- assert.equal(stdout, '');
49
- });
50
-
51
- test('lists active tasks (including in_forge, marked) when some exist', () => {
52
- const project = makeProject();
53
- const store = openTaskStore(project);
54
- store.add(sampleTask('t1'));
55
- store.add(sampleTask('t2', STATUS.IN_FORGE));
56
-
57
- const stdout = runHook(project);
58
- assert.notEqual(stdout, '');
59
- const payload = JSON.parse(stdout);
60
- const context = payload.hookSpecificOutput.additionalContext;
61
- assert.match(context, /t1/);
62
- assert.match(context, /t2/);
63
- assert.match(context, /in_forge/);
64
- assert.equal(payload.hookSpecificOutput.hookEventName, 'SessionStart');
65
- });
66
-
67
- test('a task closed (done/abandoned) does not appear in the session-start list', () => {
68
- const project = makeProject();
69
- const store = openTaskStore(project);
70
- store.add(sampleTask('t1'));
71
- store.add(sampleTask('t2'));
72
- store.close('t2', STATUS.ABANDONED, { reason: 'dropped' });
73
-
74
- const stdout = runHook(project);
75
- const context = JSON.parse(stdout).hookSpecificOutput.additionalContext;
76
- assert.match(context, /t1/);
77
- assert.doesNotMatch(context, /t2/);
78
- });
79
-
80
- test('listTasksOnSessionStart:false in project config silences the hook even with active tasks', () => {
81
- const project = makeProject();
82
- const store = openTaskStore(project);
83
- store.add(sampleTask('t1'));
84
-
85
- mkdirSync(join(project, '.ai'), { recursive: true });
86
- writeFileSync(
87
- join(project, '.ai', 'config.json'),
88
- JSON.stringify({
89
- gates: { listTasksOnSessionStart: false },
90
- }),
91
- );
92
-
93
- const stdout = runHook(project);
94
- assert.equal(stdout, '');
95
- });
@@ -1,180 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import {
3
- mkdtempSync,
4
- mkdirSync,
5
- existsSync,
6
- readFileSync,
7
- writeFileSync,
8
- } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { test } from 'node:test';
12
- import { openTaskStore, STATUS } from '../task-store.mjs';
13
-
14
- // A temp project with a .git marker, so the store anchors its .ai/tasks/ there.
15
- function makeProject() {
16
- const project = mkdtempSync(join(tmpdir(), 'task-store-'));
17
- mkdirSync(join(project, '.git'));
18
- return project;
19
- }
20
-
21
- function sampleTask(id) {
22
- return {
23
- id,
24
- title: `task ${id}`,
25
- description: 'desc',
26
- status: STATUS.OPEN,
27
- size: 'trivial',
28
- createdAt: new Date().toISOString(),
29
- messages: [],
30
- };
31
- }
32
-
33
- test('openTaskStore anchors at the nearest project marker (.ai/ counts, not only .git)', () => {
34
- // A project rooted only by .ai/ (no .git) must still resolve — the repo-less case.
35
- const project = mkdtempSync(join(tmpdir(), 'ai-only-'));
36
- mkdirSync(join(project, '.ai'));
37
- const nested = join(project, 'src', 'deep');
38
- mkdirSync(nested, { recursive: true });
39
- const store = openTaskStore(nested);
40
- assert.ok(
41
- store,
42
- 'store resolves from a nested dir under an .ai/-rooted project',
43
- );
44
- assert.equal(store.root, project);
45
- });
46
-
47
- test('add and active: a new task shows up in active', () => {
48
- const store = openTaskStore(makeProject());
49
- store.add(sampleTask('t1'));
50
- const active = store.active();
51
- assert.equal(active.length, 1);
52
- assert.equal(active[0].id, 't1');
53
- assert.deepEqual(store.history(), []);
54
- });
55
-
56
- test('update merges fields into the active task', () => {
57
- const store = openTaskStore(makeProject());
58
- store.add(sampleTask('t1'));
59
- store.update('t1', { status: STATUS.BLOCKED, note: 'waiting' });
60
- const task = store.active()[0];
61
- assert.equal(task.status, STATUS.BLOCKED);
62
- assert.equal(task.note, 'waiting');
63
- assert.equal(store.update('missing', {}), null);
64
- });
65
-
66
- test('close MOVES a task from active to history, never deletes', () => {
67
- const store = openTaskStore(makeProject());
68
- store.add(sampleTask('t1'));
69
- store.add(sampleTask('t2'));
70
-
71
- const { task: closed } = store.close('t1', STATUS.DONE, {
72
- reason: 'finished',
73
- evidence: 'npm test -> 12 passing',
74
- });
75
- assert.equal(closed.status, STATUS.DONE);
76
- assert.equal(closed.closeReason, 'finished');
77
- assert.equal(closed.evidence, 'npm test -> 12 passing');
78
- assert.ok(closed.closedAt);
79
-
80
- assert.deepEqual(
81
- store.active().map((task) => task.id),
82
- ['t2'],
83
- );
84
- assert.deepEqual(
85
- store.history().map((task) => task.id),
86
- ['t1'],
87
- );
88
- });
89
-
90
- test('closing as done WITHOUT evidence is refused; the task stays active', () => {
91
- const store = openTaskStore(makeProject());
92
- store.add(sampleTask('t1'));
93
- const result = store.close('t1', STATUS.DONE, { reason: 'trust me' });
94
- assert.ok(result.error, 'done without evidence must return an error');
95
- assert.match(result.error, /evidence/i);
96
- assert.equal(store.active().length, 1, 'the task is not moved to history');
97
- });
98
-
99
- test('abandon is a terminal close: moves to history with reason, no evidence needed', () => {
100
- const store = openTaskStore(makeProject());
101
- store.add(sampleTask('t1'));
102
- const { task } = store.close('t1', STATUS.ABANDONED, { reason: 'no longer needed' });
103
- assert.equal(task.status, STATUS.ABANDONED);
104
- assert.equal(store.active().length, 0);
105
- assert.equal(store.history()[0].status, STATUS.ABANDONED);
106
- assert.equal(store.history()[0].closeReason, 'no longer needed');
107
- });
108
-
109
- test('promoteToForge links a run and keeps the task active as in_forge', () => {
110
- const store = openTaskStore(makeProject());
111
- store.add(sampleTask('t1'));
112
- const promoted = store.promoteToForge('t1', 'run-42');
113
- assert.equal(promoted.status, STATUS.IN_FORGE);
114
- assert.equal(promoted.forgeRunId, 'run-42');
115
- assert.equal(store.active().length, 1, 'in_forge is still active, not closed');
116
- });
117
-
118
- test('close rejects a non-terminal status and an unknown id', () => {
119
- const store = openTaskStore(makeProject());
120
- store.add(sampleTask('t1'));
121
- assert.ok(store.close('t1', STATUS.OPEN, { reason: 'x' }).error);
122
- assert.ok(store.close('missing', STATUS.DONE, { evidence: 'x' }).error);
123
- assert.equal(store.active().length, 1);
124
- });
125
-
126
- test('counter persists and reads back', () => {
127
- const store = openTaskStore(makeProject());
128
- assert.equal(store.counter(), 0);
129
- store.setCounter(7);
130
- assert.equal(store.counter(), 7);
131
- });
132
-
133
- test('history is append-only across multiple closes', () => {
134
- const store = openTaskStore(makeProject());
135
- store.add(sampleTask('t1'));
136
- store.add(sampleTask('t2'));
137
- store.close('t1', STATUS.DONE, { reason: 'a', evidence: 'tests green' });
138
- store.close('t2', STATUS.ABANDONED, { reason: 'b' });
139
- assert.deepEqual(
140
- store.history().map((task) => task.id),
141
- ['t1', 't2'],
142
- );
143
- });
144
-
145
- test('a corrupt active.json is treated as empty, then healed by a write', () => {
146
- const project = makeProject();
147
- const store = openTaskStore(project);
148
- mkdirSync(join(project, '.ai', 'tasks'), { recursive: true });
149
- const path = join(project, '.ai', 'tasks', 'active.json');
150
- writeFileSync(path, '{broken');
151
- assert.deepEqual(store.active(), []);
152
- store.add(sampleTask('t1'));
153
- assert.ok(existsSync(path));
154
- assert.equal(JSON.parse(readFileSync(path, 'utf8')).tasks.length, 1);
155
- });
156
-
157
- test('a UTF-8 BOM on active.json/counter.json does not make the store treat them as empty/corrupt', () => {
158
- const project = makeProject();
159
- const tasksDir = join(project, '.ai', 'tasks');
160
- mkdirSync(tasksDir, { recursive: true });
161
-
162
- const bom = '';
163
- writeFileSync(
164
- join(tasksDir, 'active.json'),
165
- bom + JSON.stringify({ tasks: [sampleTask('t1')] }),
166
- );
167
- writeFileSync(join(tasksDir, 'counter.json'), bom + JSON.stringify({ count: 3 }));
168
-
169
- const store = openTaskStore(project);
170
- assert.deepEqual(
171
- store.active().map((task) => task.id),
172
- ['t1'],
173
- 'a BOM-prefixed active.json must still be read as its real content, not as empty',
174
- );
175
- assert.equal(
176
- store.counter(),
177
- 3,
178
- 'a BOM-prefixed counter.json must still be read as its real value, not 0',
179
- );
180
- });