@devrik-tools/claude-gates 0.1.1 → 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 (105) 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 -0
  25. package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +58 -0
  26. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  27. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  28. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  29. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  30. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  31. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  32. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  33. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  34. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  35. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  37. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  38. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  39. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  40. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  41. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  42. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  43. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  44. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  45. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  46. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  47. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  48. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  49. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  50. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  52. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  53. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  54. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  55. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  56. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  57. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  58. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  59. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  60. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  61. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  62. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  63. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  64. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  65. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  66. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  68. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  69. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  70. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  71. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  72. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  73. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  74. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  75. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  77. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  79. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  80. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  81. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  82. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  83. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  84. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  85. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  86. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  87. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  88. package/plugins/gates/hooks/hooks.json +346 -266
  89. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  90. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  91. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  92. package/plugins/gates/hooks/lib/config.mjs +172 -165
  93. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  94. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  95. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  96. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  97. package/plugins/tasks/.claude-plugin/plugin.json +9 -0
  98. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  99. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  100. package/plugins/tasks/hooks/hooks.json +26 -0
  101. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  102. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  103. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  104. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  105. package/registry.json +793 -668
@@ -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));