@devrik-tools/claude-gates 0.7.2 → 0.9.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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
package/cli/evidence.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
6
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
7
|
+
|
|
8
|
+
function tail(text) {
|
|
9
|
+
return String(text ?? '')
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.filter((line) => line.length > 0)
|
|
12
|
+
.slice(-OUTPUT_TAIL_LINES)
|
|
13
|
+
.join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function verifyCommand(
|
|
17
|
+
command,
|
|
18
|
+
{ cwd, expect, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
19
|
+
) {
|
|
20
|
+
const result = spawnSync(command, {
|
|
21
|
+
cwd,
|
|
22
|
+
shell: true,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
timeout: timeoutMs,
|
|
25
|
+
});
|
|
26
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
27
|
+
const exitCode = result.error ? null : result.status;
|
|
28
|
+
const failure = commandFailure(result, exitCode, output, expect);
|
|
29
|
+
return {
|
|
30
|
+
kind: 'command',
|
|
31
|
+
command,
|
|
32
|
+
expect: expect ?? null,
|
|
33
|
+
exitCode,
|
|
34
|
+
outputTail: tail(output),
|
|
35
|
+
verified: failure === null,
|
|
36
|
+
verifiedAt: new Date().toISOString(),
|
|
37
|
+
failure,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commandFailure(result, exitCode, output, expect) {
|
|
42
|
+
if (result.error) return `could not run: ${result.error.message}`;
|
|
43
|
+
if (exitCode !== 0) return `exit code ${exitCode}`;
|
|
44
|
+
if (expect && !output.includes(expect))
|
|
45
|
+
return `output does not contain "${expect}"`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function verifyPath(path, { cwd, contains } = {}) {
|
|
50
|
+
const absolute = isAbsolute(path) ? path : join(cwd, path);
|
|
51
|
+
const exists = existsSync(absolute);
|
|
52
|
+
let containsFound = true;
|
|
53
|
+
if (exists && contains) {
|
|
54
|
+
try {
|
|
55
|
+
containsFound =
|
|
56
|
+
statSync(absolute).isFile() &&
|
|
57
|
+
readFileSync(absolute, 'utf8').includes(contains);
|
|
58
|
+
} catch {
|
|
59
|
+
containsFound = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const failure = pathFailure(path, exists, containsFound, contains);
|
|
63
|
+
return {
|
|
64
|
+
kind: 'path',
|
|
65
|
+
path,
|
|
66
|
+
contains: contains ?? null,
|
|
67
|
+
exists,
|
|
68
|
+
verified: failure === null,
|
|
69
|
+
verifiedAt: new Date().toISOString(),
|
|
70
|
+
failure,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pathFailure(path, exists, containsFound, contains) {
|
|
75
|
+
if (!exists) return `"${path}" does not exist`;
|
|
76
|
+
if (!containsFound) return `"${path}" does not contain "${contains}"`;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { matcherFor } from '../plugins/gates/hooks/lib/hook-io.mjs';
|
|
4
|
+
import { JSON_INDENT, REPOSITORY_ROOT } from './constants.mjs';
|
|
5
|
+
import { allGates } from './registry.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
8
|
+
const MATCHED_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
|
9
|
+
const PLUGIN_ROOT_VARIABLE = '${CLAUDE_PLUGIN_ROOT}';
|
|
10
|
+
|
|
11
|
+
export function hooksJsonPathFor(pluginName) {
|
|
12
|
+
return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks', 'hooks.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hookEntry(script, timeoutSeconds, tools, event) {
|
|
16
|
+
const entry = {
|
|
17
|
+
hooks: [
|
|
18
|
+
{
|
|
19
|
+
type: 'command',
|
|
20
|
+
command: `node "${PLUGIN_ROOT_VARIABLE}/hooks/${script}"`,
|
|
21
|
+
timeout: timeoutSeconds,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
if (MATCHED_EVENTS.has(event)) entry.matcher = matcherFor(tools);
|
|
26
|
+
return MATCHED_EVENTS.has(event)
|
|
27
|
+
? { matcher: entry.matcher, hooks: entry.hooks }
|
|
28
|
+
: entry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildHooksManifest(registry, pluginName) {
|
|
32
|
+
const hooks = {};
|
|
33
|
+
const pluginGates = allGates(registry).filter(
|
|
34
|
+
(gate) => gate.plugin === pluginName,
|
|
35
|
+
);
|
|
36
|
+
for (const gate of pluginGates) {
|
|
37
|
+
const timeoutSeconds = gate.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
38
|
+
const scripts = [
|
|
39
|
+
{ event: gate.event, script: gate.script, tools: gate.tools },
|
|
40
|
+
...(gate.extraScripts ?? []).map((extra) => ({
|
|
41
|
+
event: extra.event,
|
|
42
|
+
script: extra.script,
|
|
43
|
+
tools: extra.tools ?? gate.tools,
|
|
44
|
+
})),
|
|
45
|
+
];
|
|
46
|
+
for (const { event, script, tools } of scripts) {
|
|
47
|
+
hooks[event] ??= [];
|
|
48
|
+
hooks[event].push(hookEntry(script, timeoutSeconds, tools, event));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { hooks };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function pluginNamesIn(registry) {
|
|
55
|
+
return [...new Set(allGates(registry).map((gate) => gate.plugin))];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readManifestJson(pluginName) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
JSON.parse(readFileSync(hooksJsonPathFor(pluginName), 'utf8')),
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hooksManifestDrift(registry) {
|
|
69
|
+
const drifted = [];
|
|
70
|
+
for (const pluginName of pluginNamesIn(registry)) {
|
|
71
|
+
const expected = JSON.stringify(buildHooksManifest(registry, pluginName));
|
|
72
|
+
if (readManifestJson(pluginName) !== expected) drifted.push(pluginName);
|
|
73
|
+
}
|
|
74
|
+
return drifted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function writeHooksManifests(registry) {
|
|
78
|
+
const written = [];
|
|
79
|
+
for (const pluginName of pluginNamesIn(registry)) {
|
|
80
|
+
const path = hooksJsonPathFor(pluginName);
|
|
81
|
+
writeFileSync(
|
|
82
|
+
path,
|
|
83
|
+
`${JSON.stringify(buildHooksManifest(registry, pluginName), null, JSON_INDENT)}\n`,
|
|
84
|
+
'utf8',
|
|
85
|
+
);
|
|
86
|
+
written.push(path);
|
|
87
|
+
}
|
|
88
|
+
return written;
|
|
89
|
+
}
|
package/cli/index.mjs
CHANGED
|
@@ -6,15 +6,26 @@
|
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { Command } from 'commander';
|
|
9
|
+
import { SCOPES, configPathFor } from './config.mjs';
|
|
9
10
|
import {
|
|
10
11
|
EXIT_CODE,
|
|
11
12
|
LIST_SEPARATOR,
|
|
12
13
|
REGISTRY_PATH,
|
|
13
14
|
REPOSITORY_ROOT,
|
|
14
15
|
} from './constants.mjs';
|
|
16
|
+
import { diagnose, renderDiagnosis } from './doctor.mjs';
|
|
17
|
+
import { hooksManifestDrift, writeHooksManifests } from './hooks-manifest.mjs';
|
|
18
|
+
import { runLog } from './log.mjs';
|
|
15
19
|
import { loadRegistry, validateRegistry } from './registry.mjs';
|
|
16
20
|
import { runSmoke, OUTCOMES } from './smoke.mjs';
|
|
17
21
|
import { registerTaskCommand } from './task.mjs';
|
|
22
|
+
import {
|
|
23
|
+
defaultScopeFor,
|
|
24
|
+
renderStatus,
|
|
25
|
+
resolveTargets,
|
|
26
|
+
statusRows,
|
|
27
|
+
toggleGates,
|
|
28
|
+
} from './toggle.mjs';
|
|
18
29
|
|
|
19
30
|
const packageManifest = JSON.parse(
|
|
20
31
|
readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
|
@@ -33,13 +44,68 @@ function splitList(value) {
|
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
function registryCheck() {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
);
|
|
47
|
+
const candidate = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
|
|
48
|
+
const problems = validateRegistry(candidate);
|
|
39
49
|
if (problems.length > 0) {
|
|
40
50
|
fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
|
|
41
51
|
}
|
|
42
|
-
|
|
52
|
+
const drifted = hooksManifestDrift(candidate);
|
|
53
|
+
if (drifted.length > 0) {
|
|
54
|
+
fail(
|
|
55
|
+
`hooks.json is out of date for plugin(s): ${drifted.join(', ')}. ` +
|
|
56
|
+
'Run `claude-gates registry --sync-hooks`.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write('registry.json OK, hooks.json in sync\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function registrySyncHooks() {
|
|
63
|
+
const written = writeHooksManifests(loadRegistry());
|
|
64
|
+
process.stdout.write(`Wrote:\n ${written.join('\n ')}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scopeFromOptions(options) {
|
|
68
|
+
if (options.project && options.global)
|
|
69
|
+
fail('Pick one of --project, --global');
|
|
70
|
+
if (options.project) return SCOPES.PROJECT;
|
|
71
|
+
if (options.global) return SCOPES.GLOBAL;
|
|
72
|
+
return defaultScopeFor(process.cwd());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describeToggle(result, enabled) {
|
|
76
|
+
const verb = enabled ? 'Enabled' : 'Disabled';
|
|
77
|
+
const state = enabled ? 'on' : 'off';
|
|
78
|
+
const changed =
|
|
79
|
+
result.changed.length > 0 ? `: ${result.changed.join(', ')}` : '';
|
|
80
|
+
const unchanged =
|
|
81
|
+
result.unchanged > 0 ? ` (${result.unchanged} already ${state})` : '';
|
|
82
|
+
return `${verb} ${result.changed.length} gate(s) in ${result.path}${changed}${unchanged}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toggle(ids, options, enabled) {
|
|
86
|
+
const registry = loadRegistry();
|
|
87
|
+
const scope = scopeFromOptions(options);
|
|
88
|
+
const path = configPathFor(scope);
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = toggleGates(
|
|
92
|
+
path,
|
|
93
|
+
registry,
|
|
94
|
+
resolveTargets(registry, ids),
|
|
95
|
+
enabled,
|
|
96
|
+
);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return fail(error.message);
|
|
99
|
+
}
|
|
100
|
+
process.stdout.write(describeToggle(result, enabled));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function status() {
|
|
104
|
+
const rows = statusRows(loadRegistry());
|
|
105
|
+
const source =
|
|
106
|
+
rows.find((row) => row.source !== 'default')?.source ?? 'default';
|
|
107
|
+
const label = `Effective gates (layer: ${source}, cwd ${process.cwd()})`;
|
|
108
|
+
process.stdout.write(`${renderStatus(rows, label)}\n`);
|
|
43
109
|
}
|
|
44
110
|
|
|
45
111
|
function registryList() {
|
|
@@ -125,6 +191,10 @@ program
|
|
|
125
191
|
'keep any previously installed plugin version instead of removing it first',
|
|
126
192
|
)
|
|
127
193
|
.option('--dry-run', 'show the selection without writing')
|
|
194
|
+
.option(
|
|
195
|
+
'--force',
|
|
196
|
+
'apply changes to gates already in the file without asking (default: keep them)',
|
|
197
|
+
)
|
|
128
198
|
.action(async (options) => {
|
|
129
199
|
const { runInit } = await import('./init.mjs');
|
|
130
200
|
await runInit(options);
|
|
@@ -133,12 +203,60 @@ program
|
|
|
133
203
|
program
|
|
134
204
|
.command('registry')
|
|
135
205
|
.description('Inspect registry.json.')
|
|
136
|
-
.option('--check', 'validate the registry')
|
|
206
|
+
.option('--check', 'validate the registry and hooks.json sync')
|
|
137
207
|
.option('--list', 'print families and gates')
|
|
208
|
+
.option('--sync-hooks', 'regenerate each plugin hooks.json from the registry')
|
|
138
209
|
.action((options) => {
|
|
139
210
|
if (options.check) return registryCheck();
|
|
140
211
|
if (options.list) return registryList();
|
|
141
|
-
|
|
212
|
+
if (options.syncHooks) return registrySyncHooks();
|
|
213
|
+
return fail('registry needs --check, --list or --sync-hooks');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
program
|
|
217
|
+
.command('enable <ids...>')
|
|
218
|
+
.description('Turn gates on: gate ids, config keys, family ids, or "all".')
|
|
219
|
+
.option('--project', 'edit this project config')
|
|
220
|
+
.option('--global', 'edit the global config')
|
|
221
|
+
.action((ids, options) => toggle(ids, options, true));
|
|
222
|
+
|
|
223
|
+
program
|
|
224
|
+
.command('disable <ids...>')
|
|
225
|
+
.description('Turn gates off: gate ids, config keys, family ids, or "all".')
|
|
226
|
+
.option('--project', 'edit this project config')
|
|
227
|
+
.option('--global', 'edit the global config')
|
|
228
|
+
.action((ids, options) => toggle(ids, options, false));
|
|
229
|
+
|
|
230
|
+
program
|
|
231
|
+
.command('status')
|
|
232
|
+
.description(
|
|
233
|
+
'Show which gates are on for this directory and where that comes from.',
|
|
234
|
+
)
|
|
235
|
+
.action(() => status());
|
|
236
|
+
|
|
237
|
+
program
|
|
238
|
+
.command('log')
|
|
239
|
+
.description(
|
|
240
|
+
'Show the decisions gates recorded for this project (.ai/gates-log.jsonl).',
|
|
241
|
+
)
|
|
242
|
+
.option('--tail <n>', 'how many of the latest entries to show (default 30)')
|
|
243
|
+
.option('--all', 'show every entry')
|
|
244
|
+
.option('--gate <id>', 'only this gate id or config key')
|
|
245
|
+
.option('--deny', 'only denials')
|
|
246
|
+
.option('--decision <kind>', 'deny | warn | block | config')
|
|
247
|
+
.option('--since <iso>', 'only entries after this ISO timestamp')
|
|
248
|
+
.option('--json', 'print JSON')
|
|
249
|
+
.action((options) => runLog(options));
|
|
250
|
+
|
|
251
|
+
program
|
|
252
|
+
.command('doctor')
|
|
253
|
+
.description(
|
|
254
|
+
'Check that Claude Code runs this package version of the plugins.',
|
|
255
|
+
)
|
|
256
|
+
.action(() => {
|
|
257
|
+
const report = diagnose();
|
|
258
|
+
process.stdout.write(renderDiagnosis(report));
|
|
259
|
+
if (report.problems.length > 0) process.exit(EXIT_CODE.FAILURE);
|
|
142
260
|
});
|
|
143
261
|
|
|
144
262
|
program
|
package/cli/init.mjs
CHANGED
|
@@ -11,12 +11,17 @@ import {
|
|
|
11
11
|
} from './config.mjs';
|
|
12
12
|
import { EXIT_CODE } from './constants.mjs';
|
|
13
13
|
import { installPlugin, pluginInstallCommands } from './install.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
materializeGates,
|
|
16
|
+
plannedChanges,
|
|
17
|
+
revertChanges,
|
|
18
|
+
} from './materialize.mjs';
|
|
15
19
|
import { loadRegistry, allGates } from './registry.mjs';
|
|
16
20
|
import {
|
|
17
21
|
MODES,
|
|
18
22
|
resolveSelection,
|
|
19
23
|
adoptionOf,
|
|
24
|
+
namedGatesFor,
|
|
20
25
|
summarize,
|
|
21
26
|
} from './selection.mjs';
|
|
22
27
|
|
|
@@ -69,6 +74,7 @@ export function normalizeOptions(options = {}) {
|
|
|
69
74
|
dryRun: Boolean(options.dryRun),
|
|
70
75
|
install: options.install,
|
|
71
76
|
removePrevious: options.removePrevious,
|
|
77
|
+
force: Boolean(options.force),
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -183,6 +189,58 @@ async function askRemovePrevious(io) {
|
|
|
183
189
|
);
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
function stateLabel(value) {
|
|
193
|
+
if (value === 'default') return 'default';
|
|
194
|
+
return value ? 'on' : 'off';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function describeChange(change) {
|
|
198
|
+
return `${change.configKey}: ${stateLabel(change.from)} → ${stateLabel(change.to)}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function askChanges(io, changes) {
|
|
202
|
+
const approved = bail(
|
|
203
|
+
await io.multiselect({
|
|
204
|
+
message:
|
|
205
|
+
'These gates are already in the file and would change. Keep checked the ones to apply:',
|
|
206
|
+
options: changes.map((change) => ({
|
|
207
|
+
value: change.configKey,
|
|
208
|
+
label: describeChange(change),
|
|
209
|
+
})),
|
|
210
|
+
initialValues: changes.map((change) => change.configKey),
|
|
211
|
+
required: false,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
return new Set(approved);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Existing gates only change when the user confirms (or passes --force); never silently. */
|
|
218
|
+
async function settleChanges({ io, flags, interactive, existingGates, gates }) {
|
|
219
|
+
const changes = plannedChanges(existingGates, gates);
|
|
220
|
+
if (changes.length === 0) return { gates, kept: [] };
|
|
221
|
+
if (flags.force) return { gates, kept: [] };
|
|
222
|
+
if (interactive) {
|
|
223
|
+
const approved = await askChanges(io, changes);
|
|
224
|
+
const kept = changes.filter((change) => !approved.has(change.configKey));
|
|
225
|
+
return {
|
|
226
|
+
gates: revertChanges(
|
|
227
|
+
gates,
|
|
228
|
+
existingGates,
|
|
229
|
+
kept.map((change) => change.configKey),
|
|
230
|
+
),
|
|
231
|
+
kept,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
gates: revertChanges(
|
|
236
|
+
gates,
|
|
237
|
+
existingGates,
|
|
238
|
+
changes.map((change) => change.configKey),
|
|
239
|
+
),
|
|
240
|
+
kept: changes,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
186
244
|
async function confirmWrite(io, fileExists) {
|
|
187
245
|
const confirmed = bail(
|
|
188
246
|
await io.confirm({
|
|
@@ -246,6 +304,22 @@ async function installGatesAfterWrite({
|
|
|
246
304
|
return { path, config: merged, written: true, installed: result.installed };
|
|
247
305
|
}
|
|
248
306
|
|
|
307
|
+
function enabledOfEntry(entry, fallback) {
|
|
308
|
+
if (typeof entry === 'boolean') return entry;
|
|
309
|
+
if (entry && typeof entry === 'object') return entry.enabled !== false;
|
|
310
|
+
return fallback;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function adoptionOfEntries(registry, gateEntries) {
|
|
314
|
+
const map = {};
|
|
315
|
+
for (const gate of allGates(registry))
|
|
316
|
+
map[gate.configKey] = enabledOfEntry(
|
|
317
|
+
gateEntries[gate.configKey],
|
|
318
|
+
gate.default,
|
|
319
|
+
);
|
|
320
|
+
return adoptionOf(map);
|
|
321
|
+
}
|
|
322
|
+
|
|
249
323
|
export async function runInit(
|
|
250
324
|
options,
|
|
251
325
|
{ cwd = process.cwd(), io = prompts } = {},
|
|
@@ -273,13 +347,33 @@ export async function runInit(
|
|
|
273
347
|
process.exit(EXIT_CODE.FAILURE);
|
|
274
348
|
}
|
|
275
349
|
|
|
350
|
+
const existingGates = existing.data.gates ?? {};
|
|
351
|
+
const settled = await settleChanges({
|
|
352
|
+
io,
|
|
353
|
+
flags,
|
|
354
|
+
interactive,
|
|
355
|
+
existingGates,
|
|
356
|
+
gates: materializeGates(
|
|
357
|
+
registry,
|
|
358
|
+
gates,
|
|
359
|
+
existingGates,
|
|
360
|
+
mode,
|
|
361
|
+
namedGatesFor(registry, mode, picks),
|
|
362
|
+
),
|
|
363
|
+
});
|
|
276
364
|
const merged = mergeConfig(existing.data, {
|
|
277
|
-
adopted:
|
|
278
|
-
gates:
|
|
365
|
+
adopted: adoptionOfEntries(registry, settled.gates),
|
|
366
|
+
gates: settled.gates,
|
|
279
367
|
gateVersion: registry.gateVersion,
|
|
280
368
|
});
|
|
281
369
|
|
|
282
370
|
io.note(renderSummary(registry, gates), `Selection (${scope}) → ${path}`);
|
|
371
|
+
if (settled.kept.length > 0) {
|
|
372
|
+
io.log.warn(
|
|
373
|
+
`Kept as they were (not confirmed): ${settled.kept.map(describeChange).join('; ')}. ` +
|
|
374
|
+
'Use `claude-gates enable|disable <gate>` or re-run with --force to apply.',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
283
377
|
|
|
284
378
|
if (flags.dryRun) {
|
|
285
379
|
io.outro('Dry run: nothing written.');
|
package/cli/log.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { logPathFor } from '../plugins/gates/hooks/lib/gate-log.mjs';
|
|
3
|
+
import { findProjectRoot } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TAIL = 30;
|
|
6
|
+
const TIMESTAMP_LENGTH = 'YYYY-MM-DD HH:MM:SS'.length;
|
|
7
|
+
const DECISION_COLUMN_WIDTH = 6;
|
|
8
|
+
|
|
9
|
+
export function readLogEntries(cwd) {
|
|
10
|
+
const path = logPathFor(findProjectRoot(cwd));
|
|
11
|
+
if (!existsSync(path)) return { path, entries: [] };
|
|
12
|
+
const entries = readFileSync(path, 'utf8')
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.filter((line) => line.trim().length > 0)
|
|
15
|
+
.map((line) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(line);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
return { path, entries };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function filterEntries(entries, { gate, decision, since } = {}) {
|
|
27
|
+
const sinceTime = since ? Date.parse(since) : Number.NaN;
|
|
28
|
+
return entries.filter((entry) => {
|
|
29
|
+
if (gate && entry.gate !== gate && entry.configKey !== gate) return false;
|
|
30
|
+
if (decision && entry.decision !== decision) return false;
|
|
31
|
+
if (!Number.isNaN(sinceTime) && Date.parse(entry.ts) < sinceTime)
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderEntry(entry) {
|
|
38
|
+
const stamp = String(entry.ts ?? '')
|
|
39
|
+
.replace('T', ' ')
|
|
40
|
+
.slice(0, TIMESTAMP_LENGTH);
|
|
41
|
+
const decision = String(entry.decision ?? '').padEnd(DECISION_COLUMN_WIDTH);
|
|
42
|
+
const tool = entry.tool ? ` ${entry.tool}` : '';
|
|
43
|
+
return `${stamp} ${decision} ${entry.gate}${tool}\n ${entry.summary ?? ''}\n → ${entry.reason ?? ''}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function runLog(
|
|
47
|
+
options,
|
|
48
|
+
{ cwd = process.cwd(), out = process.stdout } = {},
|
|
49
|
+
) {
|
|
50
|
+
const { path, entries } = readLogEntries(cwd);
|
|
51
|
+
const filtered = filterEntries(entries, {
|
|
52
|
+
gate: options.gate,
|
|
53
|
+
decision: options.deny ? 'deny' : options.decision,
|
|
54
|
+
since: options.since,
|
|
55
|
+
});
|
|
56
|
+
const tail = Number(options.tail) > 0 ? Number(options.tail) : DEFAULT_TAIL;
|
|
57
|
+
const shown = options.all ? filtered : filtered.slice(-tail);
|
|
58
|
+
if (options.json) {
|
|
59
|
+
out.write(`${JSON.stringify(shown, null, 2)}\n`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (shown.length === 0) {
|
|
63
|
+
out.write(`No gate decisions recorded (${path}).\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
out.write(`${shown.map(renderEntry).join('\n')}\n`);
|
|
67
|
+
out.write(
|
|
68
|
+
`\n${shown.length} of ${filtered.length} decision(s) shown from ${path}\n`,
|
|
69
|
+
);
|
|
70
|
+
}
|
package/cli/materialize.mjs
CHANGED
|
@@ -24,7 +24,7 @@ function pluginHooksDirectory(pluginName) {
|
|
|
24
24
|
* errors for any other reason yields {} rather than aborting the whole init — the same
|
|
25
25
|
* fallback already relied on before multi-plugin support.
|
|
26
26
|
*/
|
|
27
|
-
function defaultParametersOf(gate) {
|
|
27
|
+
export function defaultParametersOf(gate) {
|
|
28
28
|
try {
|
|
29
29
|
const out = execFileSync(
|
|
30
30
|
process.execPath,
|
|
@@ -68,11 +68,17 @@ function existingParametersOf(existingEntry) {
|
|
|
68
68
|
* mode. `mergeConfig` still does the key-by-key merge against the rest of the file
|
|
69
69
|
* (unrelated top-level keys, gates the new registry dropped).
|
|
70
70
|
*/
|
|
71
|
+
function namedByMode(registry, mode) {
|
|
72
|
+
if (mode === MODES.DEFAULTS) return new Set();
|
|
73
|
+
return new Set(allGates(registry).map((gate) => gate.configKey));
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
export function materializeGates(
|
|
72
77
|
registry,
|
|
73
78
|
enabledMap,
|
|
74
79
|
existingGates = {},
|
|
75
80
|
mode = MODES.DEFAULTS,
|
|
81
|
+
named = namedByMode(registry, mode),
|
|
76
82
|
) {
|
|
77
83
|
const gates = {};
|
|
78
84
|
for (const gate of allGates(registry)) {
|
|
@@ -81,7 +87,7 @@ export function materializeGates(
|
|
|
81
87
|
gate.configKey,
|
|
82
88
|
);
|
|
83
89
|
|
|
84
|
-
if (hasExisting &&
|
|
90
|
+
if (hasExisting && !named.has(gate.configKey)) {
|
|
85
91
|
gates[gate.configKey] = existingGates[gate.configKey];
|
|
86
92
|
continue;
|
|
87
93
|
}
|
|
@@ -101,3 +107,31 @@ export function materializeGates(
|
|
|
101
107
|
}
|
|
102
108
|
return gates;
|
|
103
109
|
}
|
|
110
|
+
|
|
111
|
+
function enabledStateOf(entry) {
|
|
112
|
+
if (typeof entry === 'boolean') return entry;
|
|
113
|
+
if (entry && typeof entry === 'object') {
|
|
114
|
+
return entry.enabled === undefined ? 'default' : entry.enabled !== false;
|
|
115
|
+
}
|
|
116
|
+
return 'default';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Gates whose `enabled` this run would flip relative to what the file already has. */
|
|
120
|
+
export function plannedChanges(existingGates, materializedGates) {
|
|
121
|
+
const changes = [];
|
|
122
|
+
for (const [configKey, next] of Object.entries(materializedGates)) {
|
|
123
|
+
if (!Object.prototype.hasOwnProperty.call(existingGates, configKey))
|
|
124
|
+
continue;
|
|
125
|
+
const from = enabledStateOf(existingGates[configKey]);
|
|
126
|
+
const to = enabledStateOf(next);
|
|
127
|
+
if (from !== to) changes.push({ configKey, from, to });
|
|
128
|
+
}
|
|
129
|
+
return changes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function revertChanges(materializedGates, existingGates, configKeys) {
|
|
133
|
+
const reverted = { ...materializedGates };
|
|
134
|
+
for (const configKey of configKeys)
|
|
135
|
+
reverted[configKey] = existingGates[configKey];
|
|
136
|
+
return reverted;
|
|
137
|
+
}
|
package/cli/registry.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export const TOOL_GROUPS = [
|
|
|
20
20
|
'delegation',
|
|
21
21
|
'execution',
|
|
22
22
|
'question',
|
|
23
|
+
'research',
|
|
24
|
+
'monitor',
|
|
23
25
|
];
|
|
24
26
|
|
|
25
27
|
/** The kinds of value a gate param can take, so the CLI can describe and validate it. */
|
|
@@ -55,8 +57,17 @@ const gateSchema = z.object({
|
|
|
55
57
|
.string()
|
|
56
58
|
.regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
|
|
57
59
|
description: z.string().min(1),
|
|
58
|
-
// Optional: a gate with no configurable params omits it.
|
|
59
60
|
params: z.array(parameterSchema).optional(),
|
|
61
|
+
timeoutSeconds: z.number().int().positive().optional(),
|
|
62
|
+
extraScripts: z
|
|
63
|
+
.array(
|
|
64
|
+
z.object({
|
|
65
|
+
event: z.enum(HOOK_EVENTS),
|
|
66
|
+
script: z.string().regex(SCRIPT_PATTERN),
|
|
67
|
+
tools: z.array(z.enum(TOOL_GROUPS)).optional(),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
.optional(),
|
|
60
71
|
});
|
|
61
72
|
|
|
62
73
|
const familySchema = z.object({
|
package/cli/selection.mjs
CHANGED
|
@@ -81,3 +81,21 @@ export function summarize(registry, gatesMap) {
|
|
|
81
81
|
};
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/** Config keys a run explicitly decided about: only these may change an existing entry. */
|
|
86
|
+
export function namedGatesFor(registry, mode, picks = {}) {
|
|
87
|
+
const gates = allGates(registry);
|
|
88
|
+
const keysOf = (predicate) =>
|
|
89
|
+
new Set(gates.filter(predicate).map((gate) => gate.configKey));
|
|
90
|
+
if (mode === MODES.ALL || mode === MODES.NONE) return keysOf(() => true);
|
|
91
|
+
if (mode === MODES.DEFAULTS) return new Set();
|
|
92
|
+
if (mode === MODES.FAMILIES) {
|
|
93
|
+
const chosen = new Set(picks.families ?? []);
|
|
94
|
+
return keysOf((gate) => chosen.has(gate.family));
|
|
95
|
+
}
|
|
96
|
+
if (mode === MODES.GRANULAR) {
|
|
97
|
+
const chosen = new Set(picks.gates ?? []);
|
|
98
|
+
return keysOf((gate) => chosen.has(gate.id));
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Unknown selection mode: ${mode}`);
|
|
101
|
+
}
|