@devrik-tools/claude-gates 0.2.1 → 0.3.1

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 (97) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +7 -6
  3. package/README.md +16 -7
  4. package/cli/index.mjs +4 -0
  5. package/cli/init.mjs +18 -1
  6. package/cli/install.mjs +26 -1
  7. package/package.json +5 -2
  8. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  9. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
  10. package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
  11. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
  12. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
  13. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
  14. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
  15. package/plugins/gates/hooks/gates/stop-pending/index.mjs +6 -1
  16. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
  17. package/plugins/gates/hooks/hooks.json +19 -0
  18. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  19. package/plugins/tasks/hooks/register-requests.mjs +7 -3
  20. package/registry.json +738 -793
  21. package/cli/__tests__/config.test.mjs +0 -113
  22. package/cli/__tests__/init-flags.test.mjs +0 -111
  23. package/cli/__tests__/install.test.mjs +0 -102
  24. package/cli/__tests__/materialize.test.mjs +0 -95
  25. package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
  26. package/cli/__tests__/selection.test.mjs +0 -200
  27. package/cli/__tests__/task.test.mjs +0 -124
  28. package/cli/__tests__/version-consistency.test.mjs +0 -44
  29. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
  30. package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
  31. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
  32. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
  33. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
  34. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
  35. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
  36. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
  37. package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
  38. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
  39. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
  40. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
  41. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
  42. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
  43. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
  44. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
  45. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
  46. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
  47. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
  48. package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
  49. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
  50. package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
  51. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
  52. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
  53. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
  54. package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
  55. package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
  56. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
  57. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
  58. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
  59. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
  60. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
  61. package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
  62. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
  63. package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
  64. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
  65. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
  66. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
  67. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
  68. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
  69. package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
  70. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
  71. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
  72. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
  73. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
  74. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
  75. package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
  76. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
  77. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
  78. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
  79. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
  80. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
  81. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
  82. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
  83. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
  84. package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
  85. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
  86. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
  87. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
  88. package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
  89. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
  90. package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
  91. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
  92. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
  93. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
  95. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
  96. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
  97. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
@@ -1,78 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function runGateIn(project, payload) {
12
- const out = execFileSync(process.execPath, [GATE], {
13
- input: JSON.stringify(payload),
14
- encoding: 'utf8',
15
- cwd: project,
16
- // Isolate from the user's real global config: point homedir() at the temp
17
- // project so the global-config fallback finds nothing (registry default).
18
- env: { ...process.env, HOME: project, USERPROFILE: project },
19
- });
20
- return out.trim() ? JSON.parse(out.trim()) : null;
21
- }
22
-
23
- function newProject({ config } = {}) {
24
- const project = mkdtempSync(join(tmpdir(), 'rule-skill-autodiscovery-'));
25
- mkdirSync(join(project, '.git'));
26
- if (config) {
27
- mkdirSync(join(project, '.ai'));
28
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
29
- }
30
- return project;
31
- }
32
-
33
- function exec() {
34
- return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
35
- }
36
- function isDeny(result) {
37
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
- }
39
-
40
- const ENABLE = { gates: { ['autodiscoverRulesAndSkills']: true } };
41
-
42
- test('denies when a discovered rule script fails', () => {
43
- const project = newProject({ config: ENABLE });
44
- mkdirSync(join(project, 'rules'));
45
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
46
- assert.ok(isDeny(runGateIn(project, exec())));
47
- });
48
-
49
- test('allows when discovered rule scripts succeed', () => {
50
- const project = newProject({ config: ENABLE });
51
- mkdirSync(join(project, 'rules'));
52
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(0);');
53
- assert.equal(runGateIn(project, exec()), null);
54
- });
55
-
56
- test('discovers gate files inside .claude/skills subfolders', () => {
57
- const project = newProject({ config: ENABLE });
58
- mkdirSync(join(project, '.claude', 'skills', 'my-skill'), {
59
- recursive: true,
60
- });
61
- writeFileSync(
62
- join(project, '.claude', 'skills', 'my-skill', 'check.mjs'),
63
- 'process.exit(1);',
64
- );
65
- assert.ok(isDeny(runGateIn(project, exec())));
66
- });
67
-
68
- test('degrades to allow when no rules dir or skills dir exists', () => {
69
- const project = newProject({ config: ENABLE });
70
- assert.equal(runGateIn(project, exec()), null);
71
- });
72
-
73
- test('disabled by default (registry default is false)', () => {
74
- const project = newProject();
75
- mkdirSync(join(project, 'rules'));
76
- writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
77
- assert.equal(runGateIn(project, exec()), null);
78
- });
@@ -1,130 +0,0 @@
1
- // Edge-case probes for sdd-specs. Previously: (1) an Edit (new_string) bypassed the
2
- // content check the same way as feature-catalog; (2) malformed JSON content failed
3
- // open (parseJsonOrNull -> null -> features=[] -> nothing to check); (3) the catalog
4
- // lookup was relative to process.cwd() only, so a monorepo subpackage's own
5
- // .ai/feature_list.json living outside cwd was invisible to findCatalog. All three are
6
- // now fixed: writtenContentOf()/writtenPathOf() read every field shape, invalid JSON on
7
- // a catalog write now denies (fail-closed) instead of silently allowing, and
8
- // findCatalog also resolves catalogLocations relative to the written file's own
9
- // directory.
10
- import assert from 'node:assert/strict';
11
- import { execFileSync } from 'node:child_process';
12
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
14
- import { dirname, join } from 'node:path';
15
- import { test } from 'node:test';
16
- import { fileURLToPath } from 'node:url';
17
-
18
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
19
-
20
- function makeProject() {
21
- const project = mkdtempSync(join(tmpdir(), 'sdd-specs-edge-'));
22
- mkdirSync(join(project, '.git'));
23
- return project;
24
- }
25
-
26
- function writeProjectConfig(project, config) {
27
- mkdirSync(join(project, '.ai'), { recursive: true });
28
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
29
- }
30
-
31
- function runGate(project, payload) {
32
- const out = execFileSync(process.execPath, [GATE], {
33
- input: JSON.stringify(payload),
34
- encoding: 'utf8',
35
- cwd: project,
36
- env: { ...process.env, HOME: project, USERPROFILE: project },
37
- });
38
- return out.trim() ? JSON.parse(out.trim()) : null;
39
- }
40
-
41
- function isDeny(result) {
42
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
43
- }
44
-
45
- function enableGate(project) {
46
- writeProjectConfig(project, { gates: { requireSpecBeforeImplementing: true } });
47
- }
48
-
49
- test('FIXED: an Edit (new_string) moving a feature to spec_ready with no contract is now detected', () => {
50
- const project = makeProject();
51
- enableGate(project);
52
- writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
53
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
54
- const payload = {
55
- tool_name: 'Edit',
56
- tool_input: {
57
- file_path: join(project, '.ai', 'feature_list.json'),
58
- old_string: 'x',
59
- new_string: JSON.stringify({
60
- features: [{ name: 'checkout', status: 'spec_ready' }],
61
- }),
62
- },
63
- };
64
- assert.ok(
65
- isDeny(runGate(project, payload)),
66
- 'writtenContentOf reads new_string and catches the spec_ready-with-no-contract write',
67
- );
68
- });
69
-
70
- test('FIXED: malformed/invalid JSON content on a catalog write now denies (fail-closed)', () => {
71
- const project = makeProject();
72
- enableGate(project);
73
- writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
74
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
75
- const payload = {
76
- tool_name: 'Write',
77
- tool_input: {
78
- file_path: join(project, '.ai', 'feature_list.json'),
79
- // deliberately invalid JSON (unquoted keys): the gate can no longer verify the
80
- // spec-contract invariant for this write, so it denies instead of allowing.
81
- content:
82
- '{features: [{name: "checkout", status: "spec_ready"}]}',
83
- },
84
- };
85
- assert.ok(
86
- isDeny(runGate(project, payload)),
87
- 'malformed JSON targeting the catalog must deny, not fail open',
88
- );
89
- });
90
-
91
- test('FIXED: a catalog living outside process.cwd() is now found via the written path', () => {
92
- const project = makeProject();
93
- enableGate(project);
94
- // The catalog lives under sub/.ai/feature_list.json, not the project root's .ai/ --
95
- // findCatalog now also resolves catalogLocations relative to dirname(writtenPath).
96
- mkdirSync(join(project, 'sub', '.ai', 'features', 'checkout'), { recursive: true });
97
- writeFileSync(
98
- join(project, 'sub', '.ai', 'feature_list.json'),
99
- JSON.stringify({ features: [{ name: 'checkout', status: 'spec_ready' }] }),
100
- );
101
- const payload = {
102
- tool_name: 'Write',
103
- tool_input: {
104
- file_path: join(project, 'sub', '.ai', 'feature_list.json'),
105
- content: JSON.stringify({
106
- features: [{ name: 'checkout', status: 'spec_ready' }],
107
- }),
108
- },
109
- };
110
- assert.ok(
111
- isDeny(runGate(project, payload)),
112
- 'a subpackage catalog reached via its own written path must now be inspected',
113
- );
114
- });
115
-
116
- test('OK: the equivalent Write-tool payload at the project root IS caught (control)', () => {
117
- const project = makeProject();
118
- enableGate(project);
119
- writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
120
- const payload = {
121
- tool_name: 'Write',
122
- tool_input: {
123
- file_path: join(project, '.ai', 'feature_list.json'),
124
- content: JSON.stringify({
125
- features: [{ name: 'checkout', status: 'spec_ready' }],
126
- }),
127
- },
128
- };
129
- assert.ok(isDeny(runGate(project, payload)), 'control failed');
130
- });
@@ -1,163 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function makeProject() {
12
- const project = mkdtempSync(join(tmpdir(), 'sdd-specs-'));
13
- mkdirSync(join(project, '.git'));
14
- return project;
15
- }
16
-
17
- function writeProjectConfig(project, config) {
18
- mkdirSync(join(project, '.ai'), { recursive: true });
19
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
20
- }
21
-
22
- function runGate(project, payload) {
23
- const out = execFileSync(process.execPath, [GATE], {
24
- input: JSON.stringify(payload),
25
- encoding: 'utf8',
26
- cwd: project,
27
- // Isolate from the user's real global config: point homedir() at the temp
28
- // project so the global-config fallback finds nothing (registry default).
29
- env: { ...process.env, HOME: project, USERPROFILE: project },
30
- });
31
- return out.trim() ? JSON.parse(out.trim()) : null;
32
- }
33
-
34
- function delegate(prompt, subagentType = 'backend') {
35
- return {
36
- tool_name: 'Agent',
37
- tool_input: { prompt, subagent_type: subagentType },
38
- };
39
- }
40
-
41
- function isDeny(result) {
42
- return result?.hookSpecificOutput?.permissionDecision === 'deny';
43
- }
44
-
45
- function enableGate(project) {
46
- writeProjectConfig(project, {
47
- gates: { requireSpecBeforeImplementing: true },
48
- });
49
- }
50
-
51
- test('auto-off: no catalog anywhere allows implementation delegation', () => {
52
- const project = makeProject();
53
- enableGate(project);
54
- const prompt =
55
- 'Nivel: STANDARD\nImplementá el checkout citando .ai/features/checkout/tasks/t1/';
56
- assert.equal(runGate(project, delegate(prompt)), null);
57
- });
58
-
59
- test('denies an implementation delegation citing a feature with no contract on disk', () => {
60
- const project = makeProject();
61
- enableGate(project);
62
- mkdirSync(join(project, '.ai'), { recursive: true });
63
- writeFileSync(
64
- join(project, '.ai', 'feature_list.json'),
65
- JSON.stringify({ features: [] }),
66
- );
67
- // features tree exists but the cited feature has no contract file
68
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
69
- const prompt =
70
- 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
71
- assert.ok(isDeny(runGate(project, delegate(prompt))));
72
- });
73
-
74
- test('allows an implementation delegation citing a feature with a non-empty contract', () => {
75
- const project = makeProject();
76
- enableGate(project);
77
- mkdirSync(join(project, '.ai'), { recursive: true });
78
- writeFileSync(
79
- join(project, '.ai', 'feature_list.json'),
80
- JSON.stringify({ features: [] }),
81
- );
82
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
83
- writeFileSync(
84
- join(project, '.ai', 'features', 'checkout', 'brief.md'),
85
- '# Checkout\n\nObjetivo: cobrar.',
86
- );
87
- const prompt =
88
- 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
89
- assert.equal(runGate(project, delegate(prompt)), null);
90
- });
91
-
92
- test('disabled by config: the gate does not run', () => {
93
- const project = makeProject();
94
- writeProjectConfig(project, {
95
- gates: { requireSpecBeforeImplementing: false },
96
- });
97
- mkdirSync(join(project, '.ai'), { recursive: true });
98
- writeFileSync(
99
- join(project, '.ai', 'feature_list.json'),
100
- JSON.stringify({ features: [] }),
101
- );
102
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
103
- const prompt =
104
- 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
105
- assert.equal(runGate(project, delegate(prompt)), null);
106
- });
107
-
108
- test('a prompt that only describes, does not order, is exempt via QUESTION level', () => {
109
- const project = makeProject();
110
- enableGate(project);
111
- mkdirSync(join(project, '.ai'), { recursive: true });
112
- writeFileSync(
113
- join(project, '.ai', 'feature_list.json'),
114
- JSON.stringify({ features: [] }),
115
- );
116
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
117
- const prompt =
118
- 'Nivel: QUESTION\nExplicá cómo funciona .ai/features/checkout/ hoy.';
119
- assert.equal(runGate(project, delegate(prompt)), null);
120
- });
121
-
122
- test('exemptSubagents param override exempts a custom subagent type', () => {
123
- const project = makeProject();
124
- writeProjectConfig(project, {
125
- gates: {
126
- requireSpecBeforeImplementing: {
127
- enabled: true,
128
- exemptSubagents: ['worker'],
129
- },
130
- },
131
- });
132
- mkdirSync(join(project, '.ai'), { recursive: true });
133
- writeFileSync(
134
- join(project, '.ai', 'feature_list.json'),
135
- JSON.stringify({ features: [] }),
136
- );
137
- mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
138
- const prompt =
139
- 'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
140
- assert.equal(runGate(project, delegate(prompt, 'worker')), null);
141
- });
142
-
143
- test('a catalog write moving a feature to spec_ready without contract is denied', () => {
144
- const project = makeProject();
145
- enableGate(project);
146
- mkdirSync(join(project, '.ai', 'features'), { recursive: true });
147
- // The catalog already exists on disk (an Edit to an existing file); the write below
148
- // is the new content about to replace it.
149
- writeFileSync(
150
- join(project, '.ai', 'feature_list.json'),
151
- JSON.stringify({ features: [] }),
152
- );
153
- const write = {
154
- tool_name: 'Write',
155
- tool_input: {
156
- file_path: join(project, '.ai', 'feature_list.json'),
157
- content: JSON.stringify({
158
- features: [{ name: 'checkout', status: 'spec_ready' }],
159
- }),
160
- },
161
- };
162
- assert.ok(isDeny(runGate(project, write)));
163
- });
@@ -1,114 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function setupProject({ enabled = true, tasks, allowStopWithBlockedTasks } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'stop-pending-'));
13
- mkdirSync(join(project, '.git'), { recursive: true });
14
- mkdirSync(join(project, '.ai'), { recursive: true });
15
-
16
- const gateEntry = { enabled };
17
- if (allowStopWithBlockedTasks !== undefined) {
18
- gateEntry.allowStopWithBlockedTasks = allowStopWithBlockedTasks;
19
- }
20
- writeFileSync(
21
- join(project, '.ai', 'config.json'),
22
- JSON.stringify({ gates: { blockStopWithPendingTasks: gateEntry } }),
23
- );
24
-
25
- if (tasks) {
26
- mkdirSync(join(project, '.ai', 'tasks'), { recursive: true });
27
- writeFileSync(
28
- join(project, '.ai', 'tasks', 'active.json'),
29
- JSON.stringify({ tasks }),
30
- );
31
- }
32
- return project;
33
- }
34
-
35
- function runGate(project, payload) {
36
- const out = execFileSync(process.execPath, [GATE], {
37
- input: JSON.stringify(payload),
38
- encoding: 'utf8',
39
- cwd: project,
40
- env: { ...process.env, HOME: project, USERPROFILE: project },
41
- });
42
- return out.trim() ? JSON.parse(out.trim()) : null;
43
- }
44
-
45
- test('an open active task blocks the stop', () => {
46
- const project = setupProject({
47
- tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
48
- });
49
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
50
- assert.equal(result.decision, 'block');
51
- assert.match(result.reason, /t-1/);
52
- });
53
-
54
- test('no active tasks: stop is allowed', () => {
55
- const project = setupProject({ tasks: [] });
56
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
57
- assert.equal(result, null);
58
- });
59
-
60
- test('no .ai/tasks directory at all: stop is allowed', () => {
61
- const project = setupProject({});
62
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
63
- assert.equal(result, null);
64
- });
65
-
66
- test('stop_hook_active=true: always allow, even with an open task (loop guard)', () => {
67
- const project = setupProject({
68
- tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
69
- });
70
- const result = runGate(project, { session_id: 's1', stop_hook_active: true });
71
- assert.equal(result, null);
72
- });
73
-
74
- test('a blocked task with allowStopWithBlockedTasks default (true): stop is allowed', () => {
75
- const project = setupProject({
76
- tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
77
- });
78
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
79
- assert.equal(result, null);
80
- });
81
-
82
- test('a blocked task with allowStopWithBlockedTasks:false: stop is blocked', () => {
83
- const project = setupProject({
84
- tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
85
- allowStopWithBlockedTasks: false,
86
- });
87
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
88
- assert.equal(result.decision, 'block');
89
- assert.match(result.reason, /t-2/);
90
- });
91
-
92
- test('gate disabled: stop is allowed even with an open task', () => {
93
- const project = setupProject({
94
- tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
95
- enabled: false,
96
- });
97
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
98
- assert.equal(result, null);
99
- });
100
-
101
- test('a done task in active.json (should not normally happen) never blocks', () => {
102
- const project = setupProject({
103
- tasks: [{ id: 't-3', title: 'already closed', status: 'done' }],
104
- });
105
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
106
- assert.equal(result, null);
107
- });
108
-
109
- test('corrupt active.json: fail-safe allow', () => {
110
- const project = setupProject({ tasks: [] });
111
- writeFileSync(join(project, '.ai', 'tasks', 'active.json'), '{not json');
112
- const result = runGate(project, { session_id: 's1', stop_hook_active: false });
113
- assert.equal(result, null);
114
- });
@@ -1,72 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function newProject({ config, git = true } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-edge-'));
13
- if (git) {
14
- execFileSync('git', ['init', '-q'], { cwd: project });
15
- execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
16
- execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
17
- }
18
- if (config) {
19
- mkdirSync(join(project, '.ai'));
20
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
21
- }
22
- return project;
23
- }
24
-
25
- function runGateIn(project, payload) {
26
- const out = execFileSync(process.execPath, [GATE], {
27
- input: JSON.stringify(payload),
28
- encoding: 'utf8',
29
- cwd: project,
30
- env: { ...process.env, HOME: project, USERPROFILE: project },
31
- });
32
- return out.trim() ? JSON.parse(out.trim()) : null;
33
- }
34
-
35
- function writeTest(filePath) {
36
- return { tool_name: 'Write', tool_input: { file_path: filePath, content: 'test content' } };
37
- }
38
- function isWarn(result) {
39
- return result?.hookSpecificOutput?.additionalContext !== undefined;
40
- }
41
-
42
- const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
43
-
44
- // EDGE CASE (BUG): the paired-implementation match requires the changed file's directory
45
- // (relative to cwd) to equal the test file's directory exactly (index.mjs line 83-86). A
46
- // common layout keeps tests in a sibling __tests__/ directory next to the implementation —
47
- // this gate then never pairs them, silently missing the exact case (tests-after-impl) it
48
- // exists to catch.
49
- test('BUG: false negative — implementation and test in different conventional directories (src/ vs __tests__/) are never paired', () => {
50
- const project = newProject({ config: ENABLE });
51
- mkdirSync(join(project, 'src'));
52
- mkdirSync(join(project, '__tests__'));
53
- writeFileSync(join(project, 'src', 'thing.js'), 'export const thing = 1;');
54
- const testPath = join(project, '__tests__', 'thing.test.js');
55
- const result = runGateIn(project, writeTest(testPath));
56
- assert.equal(result, null, 'gate misses the exact case it targets because dirname(test) !== dirname(impl) under a __tests__/ layout');
57
- });
58
-
59
- // EDGE CASE (BUG): only `git status --porcelain` (uncommitted changes) is checked. If the
60
- // implementation was written AND COMMITTED before writing the test (a slightly worse
61
- // practice: commit-then-test rather than just write-then-test), git status shows nothing
62
- // changed and the gate never warns — the temporal violation it targets still happened but
63
- // leaves no trace once committed.
64
- test('BUG: false negative — once the paired implementation change is committed, the warning never fires even though tests were still written strictly after', () => {
65
- const project = newProject({ config: ENABLE });
66
- writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
67
- execFileSync('git', ['add', '-A'], { cwd: project });
68
- execFileSync('git', ['commit', '-q', '-m', 'implement thing'], { cwd: project });
69
- const testPath = join(project, 'thing.test.js');
70
- const result = runGateIn(project, writeTest(testPath));
71
- assert.equal(result, null, 'committing the implementation first launders the same temporal violation past a git-status-based check');
72
- });
@@ -1,81 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { test } from 'node:test';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
-
11
- function newProject({ config, git = true } = {}) {
12
- const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-'));
13
- if (git) {
14
- execFileSync('git', ['init', '-q'], { cwd: project });
15
- execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
16
- execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
17
- }
18
- if (config) {
19
- mkdirSync(join(project, '.ai'));
20
- writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
21
- }
22
- return project;
23
- }
24
-
25
- function runGateIn(project, payload) {
26
- const out = execFileSync(process.execPath, [GATE], {
27
- input: JSON.stringify(payload),
28
- encoding: 'utf8',
29
- cwd: project,
30
- // Isolate from the user's real global config: point homedir() at the temp
31
- // project so the global-config fallback finds nothing (registry default).
32
- env: { ...process.env, HOME: project, USERPROFILE: project },
33
- });
34
- return out.trim() ? JSON.parse(out.trim()) : null;
35
- }
36
-
37
- function writeTest(filePath) {
38
- return {
39
- tool_name: 'Write',
40
- tool_input: { file_path: filePath, content: 'test content' },
41
- };
42
- }
43
- function isWarn(result) {
44
- return result?.hookSpecificOutput?.additionalContext !== undefined;
45
- }
46
-
47
- const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
48
-
49
- test('warns when creating a test whose paired implementation was just modified (uncommitted)', () => {
50
- const project = newProject({ config: ENABLE });
51
- writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
52
- const testPath = join(project, 'thing.test.js');
53
- assert.ok(isWarn(runGateIn(project, writeTest(testPath))));
54
- });
55
-
56
- test('allows creating a test with no paired uncommitted implementation', () => {
57
- const project = newProject({ config: ENABLE });
58
- const testPath = join(project, 'unrelated.test.js');
59
- assert.equal(runGateIn(project, writeTest(testPath)), null);
60
- });
61
-
62
- test('allows editing (not creating) an existing test file', () => {
63
- const project = newProject({ config: ENABLE });
64
- writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
65
- const testPath = join(project, 'thing.test.js');
66
- writeFileSync(testPath, 'existing test');
67
- assert.equal(runGateIn(project, writeTest(testPath)), null);
68
- });
69
-
70
- test('degrades to allow when not a git repo', () => {
71
- const project = newProject({ config: ENABLE, git: false });
72
- const testPath = join(project, 'thing.test.js');
73
- assert.equal(runGateIn(project, writeTest(testPath)), null);
74
- });
75
-
76
- test('disabled by default (registry default is false)', () => {
77
- const project = newProject();
78
- writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
79
- const testPath = join(project, 'thing.test.js');
80
- assert.equal(runGateIn(project, writeTest(testPath)), null);
81
- });