@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
@@ -11,7 +11,13 @@
11
11
  "name": "gates",
12
12
  "source": "./plugins/gates",
13
13
  "description": "PreToolUse gates: destructive-command blocks, protected paths, delegation/spec/quality rules, tool-discovery and forge-pipeline enforcement. Which gates run is decided by .ai/config.json (project) or ~/.claude/claude-gates/config.json (global).",
14
- "version": "0.1.0"
14
+ "version": "0.2.0"
15
+ },
16
+ {
17
+ "name": "tasks",
18
+ "source": "./plugins/tasks",
19
+ "description": "Deterministic task tracking: the model registers tasks via the CLI, a UserPromptSubmit hook reminds of open tasks every few messages, and a SessionStart hook lists active tasks when a session opens.",
20
+ "version": "0.2.0"
15
21
  }
16
22
  ]
17
23
  }
@@ -1,101 +1,113 @@
1
- import assert from 'node:assert/strict';
2
- import {
3
- mkdtempSync,
4
- mkdirSync,
5
- writeFileSync,
6
- readFileSync,
7
- existsSync,
8
- } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { test } from 'node:test';
12
- import {
13
- SCOPES,
14
- findProjectRoot,
15
- configPathFor,
16
- readConfig,
17
- mergeConfig,
18
- writeConfig,
19
- } from '../config.mjs';
20
-
21
- function temporaryDirectory() {
22
- return mkdtempSync(join(tmpdir(), 'claude-gates-'));
23
- }
24
-
25
- test('findProjectRoot climbs to the .git directory and falls back to the start', () => {
26
- const root = temporaryDirectory();
27
- mkdirSync(join(root, '.git'));
28
- const nested = join(root, 'src', 'deep');
29
- mkdirSync(nested, { recursive: true });
30
- assert.equal(findProjectRoot(nested), root);
31
-
32
- const loose = temporaryDirectory();
33
- assert.equal(findProjectRoot(loose), loose);
34
- });
35
-
36
- test('findProjectRoot never climbs into the home directory', () => {
37
- const fakeHome = temporaryDirectory();
38
- mkdirSync(join(fakeHome, '.ai'));
39
- const project = join(fakeHome, 'repos', 'app');
40
- mkdirSync(project, { recursive: true });
41
- assert.equal(findProjectRoot(project, { home: fakeHome }), project);
42
- });
43
-
44
- test('configPathFor resolves both scopes', () => {
45
- const home = temporaryDirectory();
46
- const project = temporaryDirectory();
47
- mkdirSync(join(project, '.ai'));
48
- assert.equal(
49
- configPathFor(SCOPES.GLOBAL, { home }),
50
- join(home, '.claude', 'claude-gates', 'config.json'),
51
- );
52
- assert.equal(
53
- configPathFor(SCOPES.PROJECT, { cwd: project }),
54
- join(project, '.ai', 'config.json'),
55
- );
56
- assert.throws(() => configPathFor('nope'), /Unknown scope/);
57
- });
58
-
59
- test('readConfig distinguishes missing, valid and corrupt', () => {
60
- const directory = temporaryDirectory();
61
- const path = join(directory, 'config.json');
62
- assert.deepEqual(readConfig(path), {
63
- exists: false,
64
- data: {},
65
- corrupt: false,
66
- });
67
- writeFileSync(path, '{"autoCommit":true}');
68
- assert.deepEqual(readConfig(path), {
69
- exists: true,
70
- data: { autoCommit: true },
71
- corrupt: false,
72
- });
73
- writeFileSync(path, '{nope');
74
- assert.equal(readConfig(path).corrupt, true);
75
- });
76
-
77
- test('mergeConfig keeps unrelated keys and unknown gates, overrides known ones', () => {
78
- const existing = {
79
- autoCommit: true,
80
- gates: { oldGate: true, blockX: false },
81
- };
82
- const merged = mergeConfig(existing, {
83
- adopted: 'partial',
84
- gates: { blockX: true, blockY: false },
85
- gateVersion: '3.0.0',
86
- });
87
- assert.deepEqual(merged, {
88
- autoCommit: true,
89
- adopted: 'partial',
90
- gateVersion: '3.0.0',
91
- gates: { oldGate: true, blockX: true, blockY: false },
92
- });
93
- });
94
-
95
- test('writeConfig creates parent folders and writes pretty JSON with trailing newline', () => {
96
- const directory = temporaryDirectory();
97
- const path = join(directory, 'a', 'b', 'config.json');
98
- writeConfig(path, { adopted: true });
99
- assert.ok(existsSync(path));
100
- assert.equal(readFileSync(path, 'utf8'), '{\n "adopted": true\n}\n');
101
- });
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ writeFileSync,
6
+ readFileSync,
7
+ existsSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import {
13
+ SCOPES,
14
+ findProjectRoot,
15
+ configPathFor,
16
+ readConfig,
17
+ mergeConfig,
18
+ writeConfig,
19
+ } from '../config.mjs';
20
+
21
+ function temporaryDirectory() {
22
+ return mkdtempSync(join(tmpdir(), 'claude-gates-'));
23
+ }
24
+
25
+ test('findProjectRoot climbs to the .git directory and falls back to the start', () => {
26
+ const root = temporaryDirectory();
27
+ mkdirSync(join(root, '.git'));
28
+ const nested = join(root, 'src', 'deep');
29
+ mkdirSync(nested, { recursive: true });
30
+ assert.equal(findProjectRoot(nested), root);
31
+
32
+ const loose = temporaryDirectory();
33
+ assert.equal(findProjectRoot(loose), loose);
34
+ });
35
+
36
+ test('findProjectRoot never climbs into the home directory', () => {
37
+ const fakeHome = temporaryDirectory();
38
+ mkdirSync(join(fakeHome, '.ai'));
39
+ const project = join(fakeHome, 'repos', 'app');
40
+ mkdirSync(project, { recursive: true });
41
+ assert.equal(findProjectRoot(project, { home: fakeHome }), project);
42
+ });
43
+
44
+ test('configPathFor resolves both scopes', () => {
45
+ const home = temporaryDirectory();
46
+ const project = temporaryDirectory();
47
+ mkdirSync(join(project, '.ai'));
48
+ assert.equal(
49
+ configPathFor(SCOPES.GLOBAL, { home }),
50
+ join(home, '.claude', 'claude-gates', 'config.json'),
51
+ );
52
+ assert.equal(
53
+ configPathFor(SCOPES.PROJECT, { cwd: project }),
54
+ join(project, '.ai', 'config.json'),
55
+ );
56
+ assert.throws(() => configPathFor('nope'), /Unknown scope/);
57
+ });
58
+
59
+ test('readConfig distinguishes missing, valid and corrupt', () => {
60
+ const directory = temporaryDirectory();
61
+ const path = join(directory, 'config.json');
62
+ assert.deepEqual(readConfig(path), {
63
+ exists: false,
64
+ data: {},
65
+ corrupt: false,
66
+ });
67
+ writeFileSync(path, '{"autoCommit":true}');
68
+ assert.deepEqual(readConfig(path), {
69
+ exists: true,
70
+ data: { autoCommit: true },
71
+ corrupt: false,
72
+ });
73
+ writeFileSync(path, '{nope');
74
+ assert.equal(readConfig(path).corrupt, true);
75
+ });
76
+
77
+ test('readConfig strips a leading UTF-8 BOM instead of treating the file as corrupt', () => {
78
+ const directory = temporaryDirectory();
79
+ const path = join(directory, 'config.json');
80
+ const bom = '';
81
+ writeFileSync(path, bom + '{"autoCommit":true}');
82
+ assert.deepEqual(readConfig(path), {
83
+ exists: true,
84
+ data: { autoCommit: true },
85
+ corrupt: false,
86
+ });
87
+ });
88
+
89
+ test('mergeConfig keeps unrelated keys and unknown gates, overrides known ones', () => {
90
+ const existing = {
91
+ autoCommit: true,
92
+ gates: { oldGate: true, blockX: false },
93
+ };
94
+ const merged = mergeConfig(existing, {
95
+ adopted: 'partial',
96
+ gates: { blockX: true, blockY: false },
97
+ gateVersion: '3.0.0',
98
+ });
99
+ assert.deepEqual(merged, {
100
+ autoCommit: true,
101
+ adopted: 'partial',
102
+ gateVersion: '3.0.0',
103
+ gates: { oldGate: true, blockX: true, blockY: false },
104
+ });
105
+ });
106
+
107
+ test('writeConfig creates parent folders and writes pretty JSON with trailing newline', () => {
108
+ const directory = temporaryDirectory();
109
+ const path = join(directory, 'a', 'b', 'config.json');
110
+ writeConfig(path, { adopted: true });
111
+ assert.ok(existsSync(path));
112
+ assert.equal(readFileSync(path, 'utf8'), '{\n "adopted": true\n}\n');
113
+ });
@@ -0,0 +1,102 @@
1
+ // installPlugin used to install only the marketplace's FIRST plugin (`const [plugin] =
2
+ // manifest.plugins`), so a second plugin (like `tasks`) never got installed by `init`. These
3
+ // tests exercise the fixed multi-plugin behavior with a fake `runClaude` — never the real
4
+ // `claude` binary, which would actually install plugins on the machine running the test.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { SCOPES } from '../config.mjs';
9
+ import { installPlugin, pluginInstallCommands } from '../install.mjs';
10
+ import { REPOSITORY_ROOT } from '../constants.mjs';
11
+
12
+ test('pluginInstallCommands lists one command per plugin the marketplace declares (gates AND tasks)', () => {
13
+ const commands = pluginInstallCommands();
14
+ assert.ok(
15
+ commands.some((command) => command.includes('install gates@')),
16
+ 'gates must still be installable',
17
+ );
18
+ assert.ok(
19
+ commands.some((command) => command.includes('install tasks@')),
20
+ 'tasks must now be installable too — this was the whole point of the fix',
21
+ );
22
+ });
23
+
24
+ test('installPlugin installs EVERY plugin, not just the first', () => {
25
+ const invocations = [];
26
+ const runClaude = (args) => {
27
+ invocations.push(args);
28
+ return '';
29
+ };
30
+
31
+ const result = installPlugin(SCOPES.PROJECT, { cwd: REPOSITORY_ROOT, runClaude });
32
+
33
+ assert.equal(result.installed, true);
34
+ const installedPlugins = result.results.map((entry) => entry.plugin);
35
+ assert.ok(installedPlugins.includes('gates'));
36
+ assert.ok(installedPlugins.includes('tasks'));
37
+ assert.equal(result.results.every((entry) => entry.installed), true);
38
+
39
+ // One marketplace add + one install call per plugin.
40
+ const installCalls = invocations.filter((args) => args[1] === 'install');
41
+ assert.equal(installCalls.length, result.results.length);
42
+ });
43
+
44
+ test('installPlugin: one plugin failing does not stop the rest from being attempted', () => {
45
+ const runClaude = (args) => {
46
+ if (args[0] === 'plugin' && args[1] === 'install' && args[2].startsWith('gates@')) {
47
+ throw new Error('boom: gates install failed');
48
+ }
49
+ return '';
50
+ };
51
+
52
+ const result = installPlugin(SCOPES.GLOBAL, { cwd: REPOSITORY_ROOT, runClaude });
53
+
54
+ assert.equal(result.installed, false, 'overall result reflects the partial failure');
55
+ const gatesEntry = result.results.find((entry) => entry.plugin === 'gates');
56
+ const tasksEntry = result.results.find((entry) => entry.plugin === 'tasks');
57
+ assert.equal(gatesEntry.installed, false);
58
+ assert.match(gatesEntry.reason, /boom/);
59
+ assert.equal(
60
+ tasksEntry.installed,
61
+ true,
62
+ 'tasks must still install even though gates failed',
63
+ );
64
+ assert.match(result.reason, /gates/);
65
+ });
66
+
67
+ test('installPlugin uses the marketplace name Claude Code actually registered, not the manifest name', () => {
68
+ // The repo's manifest name is `claude-gates`, but if the user added this same directory
69
+ // earlier under a different name, Claude Code keeps that name. installPlugin must install as
70
+ // plugin@<registered-name>, or `claude` answers "not found in marketplace claude-gates".
71
+ const installCalls = [];
72
+ const runClaude = (args) => {
73
+ if (args[1] === 'marketplace' && args[2] === 'list') {
74
+ // A listing where THIS repo is registered under the name `devrik`.
75
+ return `Configured marketplaces:\n\n ❯ devrik\n Source: Directory (${REPOSITORY_ROOT})\n`;
76
+ }
77
+ if (args[1] === 'install') installCalls.push(args[2]);
78
+ return '';
79
+ };
80
+
81
+ const result = installPlugin(SCOPES.GLOBAL, { cwd: REPOSITORY_ROOT, runClaude });
82
+
83
+ assert.equal(result.installed, true);
84
+ assert.ok(
85
+ installCalls.every((target) => target.endsWith('@devrik')),
86
+ `every install must target @devrik, got: ${installCalls.join(', ')}`,
87
+ );
88
+ });
89
+
90
+ test('installPlugin degrades gracefully when the claude binary is entirely unreachable', () => {
91
+ const runClaude = () => {
92
+ const error = new Error('spawn claude ENOENT');
93
+ error.code = 'ENOENT';
94
+ throw error;
95
+ };
96
+
97
+ const result = installPlugin(SCOPES.PROJECT, { cwd: REPOSITORY_ROOT, runClaude });
98
+
99
+ assert.equal(result.installed, false);
100
+ assert.ok(result.results.every((entry) => !entry.installed));
101
+ assert.ok(result.results.every((entry) => /not found on PATH/.test(entry.reason)));
102
+ });
@@ -0,0 +1,95 @@
1
+ // materializeGates only computes the NEW selection's map; how much of an existing gate
2
+ // entry survives depends on the run's mode. This test exercises that combination the same
3
+ // way init.mjs does — a bug here reads as "materialize wiped my config" even though
4
+ // materializeGates and mergeConfig are individually simple.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { mergeConfig } from '../config.mjs';
9
+ import { materializeGates } from '../materialize.mjs';
10
+ import { loadRegistry, allGates } from '../registry.mjs';
11
+ import { resolveSelection, MODES } from '../selection.mjs';
12
+
13
+ const registry = loadRegistry();
14
+ const gates = allGates(registry);
15
+ const paramGate = gates.find(
16
+ (gate) => Array.isArray(gate.params) && gate.params.length > 0,
17
+ );
18
+
19
+ function mergedGatesAfter(mode, existingGates) {
20
+ const existing = {
21
+ adopted: 'partial',
22
+ gateVersion: registry.gateVersion,
23
+ gates: existingGates,
24
+ };
25
+ const selection = resolveSelection(registry, mode, {
26
+ gates: [paramGate.id],
27
+ families: [paramGate.family],
28
+ });
29
+ const materialized = materializeGates(registry, selection, existing.gates, mode);
30
+ return mergeConfig(existing, {
31
+ adopted: 'partial',
32
+ gates: materialized,
33
+ gateVersion: registry.gateVersion,
34
+ }).gates;
35
+ }
36
+
37
+ test('registry has at least one gate with params, for these tests to exercise', () => {
38
+ assert.ok(paramGate, 'registry must have at least one gate with params');
39
+ });
40
+
41
+ test('DEFAULTS re-run preserves a gate the user already disabled, with its custom params, untouched', () => {
42
+ const merged = mergedGatesAfter(MODES.DEFAULTS, {
43
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
44
+ });
45
+
46
+ assert.equal(
47
+ merged[paramGate.configKey].enabled,
48
+ false,
49
+ 'a gate the user disabled must stay disabled after a defaults re-run',
50
+ );
51
+ assert.equal(
52
+ merged[paramGate.configKey].__userTuned,
53
+ 'kept-value',
54
+ "the user's own param edit must not be replaced by the gate's built-in default",
55
+ );
56
+
57
+ const untouched = gates.find((gate) => gate.configKey !== paramGate.configKey);
58
+ assert.ok(
59
+ Object.prototype.hasOwnProperty.call(merged, untouched.configKey),
60
+ 'a gate absent from the existing config must still appear after materializing',
61
+ );
62
+ });
63
+
64
+ test('an explicit GRANULAR pick overrides a previously disabled gate: the named choice wins', () => {
65
+ const merged = mergedGatesAfter(MODES.GRANULAR, {
66
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
67
+ });
68
+
69
+ assert.equal(
70
+ merged[paramGate.configKey].enabled,
71
+ true,
72
+ 'naming a gate in --gates must re-enable it even if the config had it disabled',
73
+ );
74
+ });
75
+
76
+ test('an explicit GRANULAR pick still preserves the user\'s own params (no new values were passed)', () => {
77
+ const merged = mergedGatesAfter(MODES.GRANULAR, {
78
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
79
+ });
80
+
81
+ assert.equal(
82
+ merged[paramGate.configKey].__userTuned,
83
+ 'kept-value',
84
+ 'params the user tuned by hand survive an explicit pick that only changes enabled',
85
+ );
86
+ });
87
+
88
+ test('FAMILIES mode behaves the same as GRANULAR for an already-configured gate', () => {
89
+ const merged = mergedGatesAfter(MODES.FAMILIES, {
90
+ [paramGate.configKey]: { enabled: false, __userTuned: 'kept-value' },
91
+ });
92
+
93
+ assert.equal(merged[paramGate.configKey].enabled, true);
94
+ assert.equal(merged[paramGate.configKey].__userTuned, 'kept-value');
95
+ });
@@ -1,44 +1,52 @@
1
- // The registry and the gates on disk must agree: every PreToolUse gate the registry
2
- // declares has to have a script, and every gate folder on disk has to be declared. A
3
- // mismatch (a registry entry with no file, like the old `autocommit`, or an orphan folder)
4
- // is what made `init` spawn a non-existent gate — this test blocks it from returning.
5
-
6
- import assert from 'node:assert/strict';
7
- import { existsSync, readdirSync } from 'node:fs';
8
- import { dirname, join } from 'node:path';
9
- import { test } from 'node:test';
10
- import { fileURLToPath } from 'node:url';
11
- import { loadRegistry, allGates } from '../registry.mjs';
12
-
13
- const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
- const GATES_DIR = join(REPO, 'plugins', 'gates', 'hooks', 'gates');
15
-
16
- const registry = loadRegistry();
17
- const preToolUseGates = allGates(registry).filter(
18
- (gate) => gate.event === 'PreToolUse',
19
- );
20
-
21
- test('every PreToolUse gate in the registry has its script on disk', () => {
22
- for (const gate of preToolUseGates) {
23
- const scriptPath = join(REPO, 'plugins', 'gates', 'hooks', gate.script);
24
- assert.ok(
25
- existsSync(scriptPath),
26
- `registry declares gate "${gate.id}" (script ${gate.script}) but the file is missing`,
27
- );
28
- }
29
- });
30
-
31
- test('every gate folder on disk is declared in the registry', () => {
32
- const declaredScripts = new Set(preToolUseGates.map((gate) => gate.script));
33
- const folders = readdirSync(GATES_DIR, { withFileTypes: true })
34
- .filter((entry) => entry.isDirectory())
35
- .map((entry) => entry.name);
36
-
37
- for (const folder of folders) {
38
- const expectedScript = `gates/${folder}/index.mjs`;
39
- assert.ok(
40
- declaredScripts.has(expectedScript),
41
- `gate folder "${folder}" exists on disk but no registry entry points to ${expectedScript}`,
42
- );
43
- }
44
- });
1
+ // The registry and the gates on disk must agree: every PreToolUse gate the registry
2
+ // declares has to have a script, and every gate folder on disk has to be declared. A
3
+ // mismatch (a registry entry with no file, like the old `autocommit`, or an orphan folder)
4
+ // is what made `init` spawn a non-existent gate — this test blocks it from returning.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, readdirSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { loadRegistry, allGates } from '../registry.mjs';
12
+
13
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
+ const GATES_DIR = join(REPO, 'plugins', 'gates', 'hooks', 'gates');
15
+
16
+ const registry = loadRegistry();
17
+ const preToolUseGates = allGates(registry).filter(
18
+ (gate) => gate.event === 'PreToolUse',
19
+ );
20
+ // Gates that live one-per-folder under plugins/gates/hooks/gates/ — regardless of which
21
+ // event they hook (most are PreToolUse, but e.g. stop-pending is Stop). Session-scoped
22
+ // hooks (doctor.mjs, ask-adoption.mjs, ...) are top-level scripts outside this folder and
23
+ // are intentionally excluded, matching the `gates/x.mjs` vs `x.mjs` convention documented
24
+ // in registry.mjs's SCRIPT_PATTERN.
25
+ const folderGates = allGates(registry).filter((gate) =>
26
+ gate.script.startsWith('gates/'),
27
+ );
28
+
29
+ test('every PreToolUse gate in the registry has its script on disk', () => {
30
+ for (const gate of preToolUseGates) {
31
+ const scriptPath = join(REPO, 'plugins', 'gates', 'hooks', gate.script);
32
+ assert.ok(
33
+ existsSync(scriptPath),
34
+ `registry declares gate "${gate.id}" (script ${gate.script}) but the file is missing`,
35
+ );
36
+ }
37
+ });
38
+
39
+ test('every gate folder on disk is declared in the registry', () => {
40
+ const declaredScripts = new Set(folderGates.map((gate) => gate.script));
41
+ const folders = readdirSync(GATES_DIR, { withFileTypes: true })
42
+ .filter((entry) => entry.isDirectory())
43
+ .map((entry) => entry.name);
44
+
45
+ for (const folder of folders) {
46
+ const expectedScript = `gates/${folder}/index.mjs`;
47
+ assert.ok(
48
+ declaredScripts.has(expectedScript),
49
+ `gate folder "${folder}" exists on disk but no registry entry points to ${expectedScript}`,
50
+ );
51
+ }
52
+ });
@@ -0,0 +1,124 @@
1
+ // The `task` CLI subcommands are thin wiring over task-store.mjs; these tests exercise the
2
+ // CLI surface itself (spawning `cli/index.mjs task ...` as a real child process, the way a
3
+ // user or the register-requests hook's advice actually invokes it) against a temp project,
4
+ // never against this repo's own .ai/config.json or task store.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const CLI_ENTRY = join(
15
+ dirname(fileURLToPath(import.meta.url)),
16
+ '..',
17
+ 'index.mjs',
18
+ );
19
+
20
+ function makeProject() {
21
+ const project = mkdtempSync(join(tmpdir(), 'task-cli-'));
22
+ mkdirSync(join(project, '.git'));
23
+ return project;
24
+ }
25
+
26
+ function runCli(args, cwd) {
27
+ try {
28
+ const stdout = execFileSync(process.execPath, [CLI_ENTRY, ...args], {
29
+ cwd,
30
+ encoding: 'utf8',
31
+ });
32
+ return { code: 0, stdout };
33
+ } catch (error) {
34
+ return {
35
+ code: error.status ?? 1,
36
+ stdout: error.stdout ?? '',
37
+ stderr: error.stderr ?? String(error),
38
+ };
39
+ }
40
+ }
41
+
42
+ test('task add persists a new open task under .ai/tasks/active.json', () => {
43
+ const project = makeProject();
44
+ const result = runCli(['task', 'add', 'Fix the thing', '--description', 'details'], project);
45
+ assert.equal(result.code, 0, result.stderr);
46
+
47
+ const activePath = join(project, '.ai', 'tasks', 'active.json');
48
+ assert.ok(existsSync(activePath));
49
+ const active = JSON.parse(readFileSync(activePath, 'utf8'));
50
+ assert.equal(active.tasks.length, 1);
51
+ assert.equal(active.tasks[0].title, 'Fix the thing');
52
+ assert.equal(active.tasks[0].status, 'open');
53
+ });
54
+
55
+ test('task list shows the active task, and --all also reaches history', () => {
56
+ const project = makeProject();
57
+ runCli(['task', 'add', 'Task one', '--id', 't1'], project);
58
+ runCli(['task', 'add', 'Task two', '--id', 't2'], project);
59
+ runCli(['task', 'abandon', 't2', '--reason', 'not needed'], project);
60
+
61
+ const active = runCli(['task', 'list'], project);
62
+ assert.match(active.stdout, /Task one/);
63
+ assert.doesNotMatch(active.stdout, /Task two/);
64
+
65
+ const all = runCli(['task', 'list', '--all'], project);
66
+ assert.match(all.stdout, /Task one/);
67
+ assert.match(all.stdout, /Task two/);
68
+ });
69
+
70
+ test('task close WITHOUT --evidence fails and leaves the task active', () => {
71
+ const project = makeProject();
72
+ runCli(['task', 'add', 'Needs evidence', '--id', 't1'], project);
73
+
74
+ const result = runCli(['task', 'close', 't1'], project);
75
+ assert.notEqual(result.code, 0);
76
+ assert.match(result.stderr, /evidence/i);
77
+
78
+ const activePath = join(project, '.ai', 'tasks', 'active.json');
79
+ const active = JSON.parse(readFileSync(activePath, 'utf8'));
80
+ assert.equal(active.tasks.length, 1, 'the task must still be active');
81
+ });
82
+
83
+ test('task close WITH --evidence succeeds and moves the task to history', () => {
84
+ const project = makeProject();
85
+ runCli(['task', 'add', 'Has evidence', '--id', 't1'], project);
86
+
87
+ const result = runCli(
88
+ ['task', 'close', 't1', '--evidence', 'npm test -> 5 passing'],
89
+ project,
90
+ );
91
+ assert.equal(result.code, 0, result.stderr);
92
+
93
+ const historyPath = join(project, '.ai', 'tasks', 'history.json');
94
+ const history = JSON.parse(readFileSync(historyPath, 'utf8'));
95
+ assert.equal(history.tasks.length, 1);
96
+ assert.equal(history.tasks[0].status, 'done');
97
+ assert.equal(history.tasks[0].evidence, 'npm test -> 5 passing');
98
+ });
99
+
100
+ test('task abandon needs no evidence and moves the task to history', () => {
101
+ const project = makeProject();
102
+ runCli(['task', 'add', 'Drop me', '--id', 't1'], project);
103
+
104
+ const result = runCli(['task', 'abandon', 't1', '--reason', 'obsolete'], project);
105
+ assert.equal(result.code, 0, result.stderr);
106
+
107
+ const historyPath = join(project, '.ai', 'tasks', 'history.json');
108
+ const history = JSON.parse(readFileSync(historyPath, 'utf8'));
109
+ assert.equal(history.tasks[0].status, 'abandoned');
110
+ assert.equal(history.tasks[0].closeReason, 'obsolete');
111
+ });
112
+
113
+ test('task promote links a forge run and keeps the task active as in_forge', () => {
114
+ const project = makeProject();
115
+ runCli(['task', 'add', 'Promote me', '--id', 't1'], project);
116
+
117
+ const result = runCli(['task', 'promote', 't1', 'run-42'], project);
118
+ assert.equal(result.code, 0, result.stderr);
119
+
120
+ const activePath = join(project, '.ai', 'tasks', 'active.json');
121
+ const active = JSON.parse(readFileSync(activePath, 'utf8'));
122
+ assert.equal(active.tasks[0].status, 'in_forge');
123
+ assert.equal(active.tasks[0].forgeRunId, 'run-42');
124
+ });