@devrik-tools/claude-gates 0.1.2 → 0.1.3

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +23 -17
  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/config.mjs +124 -113
  8. package/cli/index.mjs +100 -97
  9. package/cli/init.mjs +259 -256
  10. package/cli/install.mjs +150 -80
  11. package/cli/materialize.mjs +102 -51
  12. package/cli/registry.mjs +136 -127
  13. package/cli/task.mjs +140 -0
  14. package/package.json +74 -73
  15. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  16. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  17. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  18. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  19. package/plugins/gates/hooks/doctor.mjs +207 -0
  20. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  21. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  22. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  23. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  24. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  25. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  26. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  27. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  28. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  29. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  30. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  31. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  32. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  33. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  34. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  36. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  39. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  40. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  41. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  42. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  43. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  44. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  45. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  46. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  47. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  48. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  49. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  51. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  52. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  53. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  54. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  55. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  56. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  57. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  58. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  59. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  60. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  61. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  62. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  63. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  64. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  65. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  67. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  68. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  69. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  70. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  71. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  72. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  73. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  74. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  76. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  78. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  79. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  80. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  81. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  82. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  83. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  84. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  85. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  86. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  87. package/plugins/gates/hooks/hooks.json +346 -286
  88. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  89. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  90. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  91. package/plugins/gates/hooks/lib/config.mjs +172 -165
  92. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  93. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  94. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  95. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  96. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  97. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  98. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  99. package/plugins/tasks/hooks/hooks.json +26 -0
  100. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  101. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  102. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  103. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  104. package/registry.json +793 -686
@@ -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
+ });
package/cli/config.mjs CHANGED
@@ -1,113 +1,124 @@
1
- // Where the selection is persisted and how it merges with what is already there.
2
- //
3
- // Scope "project": `<project root>/.ai/config.json` — the file the hooks read
4
- // per project. Other keys the project already has (autoCommit, etc.) are kept.
5
- // Scope "global": `~/.claude/claude-gates/config.json` — the fallback the hooks
6
- // use when a project has no answer of its own.
7
-
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
- import { dirname, join } from 'node:path';
10
- import { findUpSync } from 'find-up';
11
- import {
12
- CLAUDE_USER_DIRECTORY,
13
- CONFIG_FILE,
14
- GLOBAL_STATE_DIRECTORY,
15
- HOME_DIRECTORY,
16
- JSON_INDENT,
17
- PROJECT_ROOT_MARKERS,
18
- PROJECT_STATE_DIRECTORY,
19
- } from './constants.mjs';
20
-
21
- export const SCOPES = Object.freeze({ GLOBAL: 'global', PROJECT: 'project' });
22
-
23
- /**
24
- * Climbs from `startDirectory` to the nearest project marker (`.git`/`.ai`).
25
- * The home directory (and anything above it) is never a project root: a stray
26
- * `~/.ai` would otherwise turn every folder under home into "the project" and the
27
- * config would land in the wrong place silently. `.git` may be a file (worktrees),
28
- * so the matcher checks existence, not type.
29
- */
30
- export function findProjectRoot(
31
- startDirectory,
32
- { home = HOME_DIRECTORY } = {},
33
- ) {
34
- if (startDirectory === home) return startDirectory;
35
- const markerDirectory = findUpSync(
36
- (directory) => {
37
- if (directory === home) return findUpSync.stop;
38
- const hasMarker = PROJECT_ROOT_MARKERS.some((marker) =>
39
- existsSync(join(directory, marker)),
40
- );
41
- return hasMarker ? directory : undefined;
42
- },
43
- { cwd: startDirectory, stopAt: home, type: 'directory' },
44
- );
45
- return markerDirectory ?? startDirectory;
46
- }
47
-
48
- export function configPathFor(
49
- scope,
50
- { cwd = process.cwd(), home = HOME_DIRECTORY } = {},
51
- ) {
52
- if (scope === SCOPES.GLOBAL)
53
- return join(
54
- home,
55
- CLAUDE_USER_DIRECTORY,
56
- GLOBAL_STATE_DIRECTORY,
57
- CONFIG_FILE,
58
- );
59
- if (scope === SCOPES.PROJECT)
60
- return join(
61
- findProjectRoot(cwd, { home }),
62
- PROJECT_STATE_DIRECTORY,
63
- CONFIG_FILE,
64
- );
65
- throw new Error(`Unknown scope: ${scope}`);
66
- }
67
-
68
- export function readConfig(path) {
69
- if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
70
- try {
71
- return {
72
- exists: true,
73
- data: JSON.parse(readFileSync(path, 'utf8')),
74
- corrupt: false,
75
- };
76
- } catch {
77
- return { exists: true, data: {}, corrupt: true };
78
- }
79
- }
80
-
81
- /**
82
- * Merges one gate's new value onto whatever the project already had, preserving the user's
83
- * edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
84
- * tuned those params, their values win and only `enabled` follows the new selection. A
85
- * paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
86
- * never overwrites a whitelist or pattern list the user changed by hand.
87
- */
88
- function mergeGate(existingValue, newValue) {
89
- if (typeof newValue === 'boolean') return newValue;
90
- if (existingValue && typeof existingValue === 'object') {
91
- return { ...newValue, ...existingValue, enabled: newValue.enabled };
92
- }
93
- return newValue;
94
- }
95
-
96
- /**
97
- * Merges the new selection into an existing config without touching unrelated keys. Gates
98
- * are merged key by key: a gate absent from the new map keeps its old value (a registry
99
- * that dropped a gate must not silently flip it), and a gate the user configured keeps its
100
- * params (see mergeGate).
101
- */
102
- export function mergeConfig(existing, { adopted, gates, gateVersion }) {
103
- const mergedGates = { ...(existing.gates ?? {}) };
104
- for (const [key, value] of Object.entries(gates)) {
105
- mergedGates[key] = mergeGate(mergedGates[key], value);
106
- }
107
- return { ...existing, adopted, gateVersion, gates: mergedGates };
108
- }
109
-
110
- export function writeConfig(path, data) {
111
- mkdirSync(dirname(path), { recursive: true });
112
- writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
113
- }
1
+ // Where the selection is persisted and how it merges with what is already there.
2
+ //
3
+ // Scope "project": `<project root>/.ai/config.json` — the file the hooks read
4
+ // per project. Other keys the project already has (autoCommit, etc.) are kept.
5
+ // Scope "global": `~/.claude/claude-gates/config.json` — the fallback the hooks
6
+ // use when a project has no answer of its own.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { findUpSync } from 'find-up';
11
+ import {
12
+ CLAUDE_USER_DIRECTORY,
13
+ CONFIG_FILE,
14
+ GLOBAL_STATE_DIRECTORY,
15
+ HOME_DIRECTORY,
16
+ JSON_INDENT,
17
+ PROJECT_ROOT_MARKERS,
18
+ PROJECT_STATE_DIRECTORY,
19
+ } from './constants.mjs';
20
+
21
+ export const SCOPES = Object.freeze({ GLOBAL: 'global', PROJECT: 'project' });
22
+
23
+ /**
24
+ * Climbs from `startDirectory` to the nearest project marker (`.git`/`.ai`).
25
+ * The home directory (and anything above it) is never a project root: a stray
26
+ * `~/.ai` would otherwise turn every folder under home into "the project" and the
27
+ * config would land in the wrong place silently. `.git` may be a file (worktrees),
28
+ * so the matcher checks existence, not type.
29
+ */
30
+ export function findProjectRoot(
31
+ startDirectory,
32
+ { home = HOME_DIRECTORY } = {},
33
+ ) {
34
+ if (startDirectory === home) return startDirectory;
35
+ const markerDirectory = findUpSync(
36
+ (directory) => {
37
+ if (directory === home) return findUpSync.stop;
38
+ const hasMarker = PROJECT_ROOT_MARKERS.some((marker) =>
39
+ existsSync(join(directory, marker)),
40
+ );
41
+ return hasMarker ? directory : undefined;
42
+ },
43
+ { cwd: startDirectory, stopAt: home, type: 'directory' },
44
+ );
45
+ return markerDirectory ?? startDirectory;
46
+ }
47
+
48
+ export function configPathFor(
49
+ scope,
50
+ { cwd = process.cwd(), home = HOME_DIRECTORY } = {},
51
+ ) {
52
+ if (scope === SCOPES.GLOBAL)
53
+ return join(
54
+ home,
55
+ CLAUDE_USER_DIRECTORY,
56
+ GLOBAL_STATE_DIRECTORY,
57
+ CONFIG_FILE,
58
+ );
59
+ if (scope === SCOPES.PROJECT)
60
+ return join(
61
+ findProjectRoot(cwd, { home }),
62
+ PROJECT_STATE_DIRECTORY,
63
+ CONFIG_FILE,
64
+ );
65
+ throw new Error(`Unknown scope: ${scope}`);
66
+ }
67
+
68
+ // A leading UTF-8 BOM (EF BB BF, decoded as U+FEFF) is not stripped by readFileSync('utf8'),
69
+ // and JSON.parse rejects a string that starts with it. Without stripping it, a config saved
70
+ // by a BOM-adding editor or `PowerShell Set-Content -Encoding utf8` would read back as
71
+ // `corrupt: true` — a valid, user-edited config (with its gate overrides) mistaken for
72
+ // unreadable. init.mjs bails loudly on `corrupt`, but a caller relying on `data` alone (as
73
+ // `mergeConfig` does) would otherwise merge onto `{}` and silently drop every existing gate
74
+ // override the user made. This is the same failure mode fixed in the gates' own config.mjs.
75
+ function stripBom(text) {
76
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
77
+ }
78
+
79
+ export function readConfig(path) {
80
+ if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
81
+ try {
82
+ return {
83
+ exists: true,
84
+ data: JSON.parse(stripBom(readFileSync(path, 'utf8'))),
85
+ corrupt: false,
86
+ };
87
+ } catch {
88
+ return { exists: true, data: {}, corrupt: true };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Merges one gate's new value onto whatever the project already had, preserving the user's
94
+ * edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
95
+ * tuned those params, their values win and only `enabled` follows the new selection. A
96
+ * paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
97
+ * never overwrites a whitelist or pattern list the user changed by hand.
98
+ */
99
+ function mergeGate(existingValue, newValue) {
100
+ if (typeof newValue === 'boolean') return newValue;
101
+ if (existingValue && typeof existingValue === 'object') {
102
+ return { ...newValue, ...existingValue, enabled: newValue.enabled };
103
+ }
104
+ return newValue;
105
+ }
106
+
107
+ /**
108
+ * Merges the new selection into an existing config without touching unrelated keys. Gates
109
+ * are merged key by key: a gate absent from the new map keeps its old value (a registry
110
+ * that dropped a gate must not silently flip it), and a gate the user configured keeps its
111
+ * params (see mergeGate).
112
+ */
113
+ export function mergeConfig(existing, { adopted, gates, gateVersion }) {
114
+ const mergedGates = { ...(existing.gates ?? {}) };
115
+ for (const [key, value] of Object.entries(gates)) {
116
+ mergedGates[key] = mergeGate(mergedGates[key], value);
117
+ }
118
+ return { ...existing, adopted, gateVersion, gates: mergedGates };
119
+ }
120
+
121
+ export function writeConfig(path, data) {
122
+ mkdirSync(dirname(path), { recursive: true });
123
+ writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
124
+ }
package/cli/index.mjs CHANGED
@@ -1,97 +1,100 @@
1
- #!/usr/bin/env node
2
- // Entry point (commander). Commands:
3
- // init interactive (or flag-driven) selection of gates, per project or globally
4
- // registry --check validates registry.json; --list prints the catalog
5
-
6
- import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { Command } from 'commander';
9
- import {
10
- EXIT_CODE,
11
- LIST_SEPARATOR,
12
- REGISTRY_PATH,
13
- REPOSITORY_ROOT,
14
- } from './constants.mjs';
15
- import { loadRegistry, validateRegistry } from './registry.mjs';
16
-
17
- const packageManifest = JSON.parse(
18
- readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
19
- );
20
-
21
- function fail(message) {
22
- process.stderr.write(`${message}\n`);
23
- process.exit(EXIT_CODE.FAILURE);
24
- }
25
-
26
- function splitList(value) {
27
- return value
28
- .split(LIST_SEPARATOR)
29
- .map((item) => item.trim())
30
- .filter(Boolean);
31
- }
32
-
33
- function registryCheck() {
34
- const problems = validateRegistry(
35
- JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
36
- );
37
- if (problems.length > 0) {
38
- fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
39
- }
40
- process.stdout.write('registry.json OK\n');
41
- }
42
-
43
- function registryList() {
44
- const registry = loadRegistry();
45
- for (const family of registry.families) {
46
- process.stdout.write(`${family.name} (${family.id})\n`);
47
- for (const gate of family.gates) {
48
- const marker = gate.default ? '*' : ' ';
49
- process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
50
- }
51
- }
52
- process.stdout.write('\n* = recommended default\n');
53
- }
54
-
55
- const program = new Command()
56
- .name(packageManifest.name)
57
- .description(packageManifest.description)
58
- .version(packageManifest.version);
59
-
60
- program
61
- .command('init')
62
- .description('Choose which gates to adopt and write the config.')
63
- .option('--project', 'apply to this project (<root>/.ai/config.json)')
64
- .option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
65
- .option('--defaults', 'enable the recommended defaults')
66
- .option('--all', 'enable every gate')
67
- .option('--none', "record a 'no' so you are not asked again")
68
- .option(
69
- '--families <ids>',
70
- 'enable whole families, comma-separated',
71
- splitList,
72
- )
73
- .option(
74
- '--gates <ids>',
75
- 'enable individual gates, comma-separated',
76
- splitList,
77
- )
78
- .option('-y, --yes', 'never prompt; use flags and defaults')
79
- .option('--no-install', 'write the config but do not install the plugin')
80
- .option('--dry-run', 'show the selection without writing')
81
- .action(async (options) => {
82
- const { runInit } = await import('./init.mjs');
83
- await runInit(options);
84
- });
85
-
86
- program
87
- .command('registry')
88
- .description('Inspect registry.json.')
89
- .option('--check', 'validate the registry')
90
- .option('--list', 'print families and gates')
91
- .action((options) => {
92
- if (options.check) return registryCheck();
93
- if (options.list) return registryList();
94
- return fail('registry needs --check or --list');
95
- });
96
-
97
- program.parseAsync(process.argv).catch((error) => fail(error.message));
1
+ #!/usr/bin/env node
2
+ // Entry point (commander). Commands:
3
+ // init interactive (or flag-driven) selection of gates, per project or globally
4
+ // registry --check validates registry.json; --list prints the catalog
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { Command } from 'commander';
9
+ import {
10
+ EXIT_CODE,
11
+ LIST_SEPARATOR,
12
+ REGISTRY_PATH,
13
+ REPOSITORY_ROOT,
14
+ } from './constants.mjs';
15
+ import { loadRegistry, validateRegistry } from './registry.mjs';
16
+ import { registerTaskCommand } from './task.mjs';
17
+
18
+ const packageManifest = JSON.parse(
19
+ readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
20
+ );
21
+
22
+ function fail(message) {
23
+ process.stderr.write(`${message}\n`);
24
+ process.exit(EXIT_CODE.FAILURE);
25
+ }
26
+
27
+ function splitList(value) {
28
+ return value
29
+ .split(LIST_SEPARATOR)
30
+ .map((item) => item.trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ function registryCheck() {
35
+ const problems = validateRegistry(
36
+ JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
37
+ );
38
+ if (problems.length > 0) {
39
+ fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
40
+ }
41
+ process.stdout.write('registry.json OK\n');
42
+ }
43
+
44
+ function registryList() {
45
+ const registry = loadRegistry();
46
+ for (const family of registry.families) {
47
+ process.stdout.write(`${family.name} (${family.id})\n`);
48
+ for (const gate of family.gates) {
49
+ const marker = gate.default ? '*' : ' ';
50
+ process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
51
+ }
52
+ }
53
+ process.stdout.write('\n* = recommended default\n');
54
+ }
55
+
56
+ const program = new Command()
57
+ .name(packageManifest.name)
58
+ .description(packageManifest.description)
59
+ .version(packageManifest.version);
60
+
61
+ program
62
+ .command('init')
63
+ .description('Choose which gates to adopt and write the config.')
64
+ .option('--project', 'apply to this project (<root>/.ai/config.json)')
65
+ .option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
66
+ .option('--defaults', 'enable the recommended defaults')
67
+ .option('--all', 'enable every gate')
68
+ .option('--none', "record a 'no' so you are not asked again")
69
+ .option(
70
+ '--families <ids>',
71
+ 'enable whole families, comma-separated',
72
+ splitList,
73
+ )
74
+ .option(
75
+ '--gates <ids>',
76
+ 'enable individual gates, comma-separated',
77
+ splitList,
78
+ )
79
+ .option('-y, --yes', 'never prompt; use flags and defaults')
80
+ .option('--no-install', 'write the config but do not install the plugin')
81
+ .option('--dry-run', 'show the selection without writing')
82
+ .action(async (options) => {
83
+ const { runInit } = await import('./init.mjs');
84
+ await runInit(options);
85
+ });
86
+
87
+ program
88
+ .command('registry')
89
+ .description('Inspect registry.json.')
90
+ .option('--check', 'validate the registry')
91
+ .option('--list', 'print families and gates')
92
+ .action((options) => {
93
+ if (options.check) return registryCheck();
94
+ if (options.list) return registryList();
95
+ return fail('registry needs --check or --list');
96
+ });
97
+
98
+ registerTaskCommand(program);
99
+
100
+ program.parseAsync(process.argv).catch((error) => fail(error.message));