@devrik-tools/claude-gates 0.4.0 → 0.7.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 +2 -2
- package/README.es.md +39 -4
- package/README.md +34 -5
- package/cli/config.mjs +126 -124
- package/cli/init.mjs +303 -276
- package/cli/install.mjs +281 -175
- package/cli/materialize.mjs +103 -102
- package/cli/registry.mjs +139 -136
- package/cli/smoke-fixtures.json +65 -0
- package/cli/task.mjs +140 -140
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/ask-adoption.mjs +147 -147
- package/plugins/gates/hooks/doctor.mjs +207 -207
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
- package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
- package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
- package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
- package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
- package/plugins/gates/hooks/hooks.json +61 -0
- package/plugins/gates/hooks/lib/config.mjs +179 -172
- package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
- package/plugins/gates/hooks/lib/signals.mjs +172 -127
- package/plugins/gates/hooks/wiring-check.mjs +227 -227
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/hooks.json +26 -26
- package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
- package/plugins/tasks/hooks/register-requests.mjs +145 -145
- package/plugins/tasks/hooks/session-tasks.mjs +108 -108
- package/registry.json +192 -1
package/cli/materialize.mjs
CHANGED
|
@@ -1,102 +1,103 @@
|
|
|
1
|
-
// Materializes each gate's default params into the config that `init` writes, so every
|
|
2
|
-
// configurable value (a whitelist, a pattern list, a threshold) lands in the user's
|
|
3
|
-
// .ai/config.json ready to edit. The values come from the gates themselves — each gate,
|
|
4
|
-
// run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
|
|
5
|
-
// source of truth (the gate) and nothing is duplicated in the registry.
|
|
6
|
-
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
-
import { allGates } from './registry.mjs';
|
|
11
|
-
import { MODES } from './selection.mjs';
|
|
12
|
-
|
|
13
|
-
const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
|
|
14
|
-
|
|
15
|
-
/** A gate's `hooks/` directory: each plugin (registry's per-family `plugin`) owns its own. */
|
|
16
|
-
function
|
|
17
|
-
return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Runs one gate in defaults-dump mode and returns its built-in params. Not every gate
|
|
22
|
-
* supports the defaults-dump protocol (only the `gates` plugin's PreToolUse gates do, via
|
|
23
|
-
* runGate in hook-io.mjs); a script that does not recognize the env var, does not exist, or
|
|
24
|
-
* errors for any other reason yields {} rather than aborting the whole init — the same
|
|
25
|
-
* fallback already relied on before multi-plugin support.
|
|
26
|
-
*/
|
|
27
|
-
function defaultParametersOf(gate) {
|
|
28
|
-
try {
|
|
29
|
-
const out = execFileSync(
|
|
30
|
-
process.execPath,
|
|
31
|
-
[join(
|
|
32
|
-
{ encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
|
|
33
|
-
);
|
|
34
|
-
return JSON.parse(out).defaultParams ?? {};
|
|
35
|
-
} catch {
|
|
36
|
-
return {};
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The existing entry's params, stripped of `enabled` — {} for a boolean or missing entry. */
|
|
41
|
-
function
|
|
42
|
-
if (!existingEntry || typeof existingEntry !== 'object') return {};
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1
|
+
// Materializes each gate's default params into the config that `init` writes, so every
|
|
2
|
+
// configurable value (a whitelist, a pattern list, a threshold) lands in the user's
|
|
3
|
+
// .ai/config.json ready to edit. The values come from the gates themselves — each gate,
|
|
4
|
+
// run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
|
|
5
|
+
// source of truth (the gate) and nothing is duplicated in the registry.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
+
import { allGates } from './registry.mjs';
|
|
11
|
+
import { MODES } from './selection.mjs';
|
|
12
|
+
|
|
13
|
+
const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
|
|
14
|
+
|
|
15
|
+
/** A gate's `hooks/` directory: each plugin (registry's per-family `plugin`) owns its own. */
|
|
16
|
+
function pluginHooksDirectory(pluginName) {
|
|
17
|
+
return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs one gate in defaults-dump mode and returns its built-in params. Not every gate
|
|
22
|
+
* supports the defaults-dump protocol (only the `gates` plugin's PreToolUse gates do, via
|
|
23
|
+
* runGate in hook-io.mjs); a script that does not recognize the env var, does not exist, or
|
|
24
|
+
* errors for any other reason yields {} rather than aborting the whole init — the same
|
|
25
|
+
* fallback already relied on before multi-plugin support.
|
|
26
|
+
*/
|
|
27
|
+
function defaultParametersOf(gate) {
|
|
28
|
+
try {
|
|
29
|
+
const out = execFileSync(
|
|
30
|
+
process.execPath,
|
|
31
|
+
[join(pluginHooksDirectory(gate.plugin), gate.script)],
|
|
32
|
+
{ encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
|
|
33
|
+
);
|
|
34
|
+
return JSON.parse(out).defaultParams ?? {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The existing entry's params, stripped of `enabled` — {} for a boolean or missing entry. */
|
|
41
|
+
function existingParametersOf(existingEntry) {
|
|
42
|
+
if (!existingEntry || typeof existingEntry !== 'object') return {};
|
|
43
|
+
const parameters = { ...existingEntry };
|
|
44
|
+
delete parameters.enabled;
|
|
45
|
+
return parameters;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Turns the flat `{ configKey: enabled }` selection into the config's gates map. A gate
|
|
50
|
+
* that declares params (per the registry) becomes `{ enabled, ...defaults }` so its knobs
|
|
51
|
+
* are visible and editable; a gate with no params stays a plain boolean, keeping the file
|
|
52
|
+
* compact. Only gates with params are spawned, so paramless selections cost nothing.
|
|
53
|
+
*
|
|
54
|
+
* `existingGates` (the project's current config, if any) and `mode` (the selection mode
|
|
55
|
+
* this run used) together decide how much of an already-present gate survives:
|
|
56
|
+
*
|
|
57
|
+
* - mode === DEFAULTS: a "fill the gaps" sweep, not a directed choice. A gate already in
|
|
58
|
+
* `existingGates` is carried over VERBATIM (enabled + every param) — materializing
|
|
59
|
+
* defaults must never be what resets a disablement or a custom pattern list the user
|
|
60
|
+
* deliberately set.
|
|
61
|
+
* - any other mode (ALL / FAMILIES / GRANULAR): the user named this gate (directly, or
|
|
62
|
+
* via its family) this run, so `enabled` follows the new selection — that is the whole
|
|
63
|
+
* point of picking it. Its PARAMS still survive from the existing entry, since there is
|
|
64
|
+
* no per-run way to pass new param values through `init`'s flags today; only `enabled`
|
|
65
|
+
* is something this run actually decided.
|
|
66
|
+
*
|
|
67
|
+
* A gate absent from `existingGates` always gets a freshly computed value regardless of
|
|
68
|
+
* mode. `mergeConfig` still does the key-by-key merge against the rest of the file
|
|
69
|
+
* (unrelated top-level keys, gates the new registry dropped).
|
|
70
|
+
*/
|
|
71
|
+
export function materializeGates(
|
|
72
|
+
registry,
|
|
73
|
+
enabledMap,
|
|
74
|
+
existingGates = {},
|
|
75
|
+
mode = MODES.DEFAULTS,
|
|
76
|
+
) {
|
|
77
|
+
const gates = {};
|
|
78
|
+
for (const gate of allGates(registry)) {
|
|
79
|
+
const hasExisting = Object.prototype.hasOwnProperty.call(
|
|
80
|
+
existingGates,
|
|
81
|
+
gate.configKey,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (hasExisting && mode === MODES.DEFAULTS) {
|
|
85
|
+
gates[gate.configKey] = existingGates[gate.configKey];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const enabled = enabledMap[gate.configKey] === true;
|
|
90
|
+
const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
|
|
91
|
+
if (!hasParameters) {
|
|
92
|
+
gates[gate.configKey] = enabled;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaults = defaultParametersOf(gate);
|
|
97
|
+
const parameters = hasExisting
|
|
98
|
+
? { ...defaults, ...existingParametersOf(existingGates[gate.configKey]) }
|
|
99
|
+
: defaults;
|
|
100
|
+
gates[gate.configKey] = { enabled, ...parameters };
|
|
101
|
+
}
|
|
102
|
+
return gates;
|
|
103
|
+
}
|
package/cli/registry.mjs
CHANGED
|
@@ -1,136 +1,139 @@
|
|
|
1
|
-
// Loads and validates registry.json — the catalog that menus, config and hooks derive from.
|
|
2
|
-
// Validation is a zod schema so the shape is declared once and errors are uniform.
|
|
3
|
-
// Fails loudly on a corrupt catalog: a menu built on invalid data would write a
|
|
4
|
-
// configuration that no gate recognizes.
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { z } from 'zod';
|
|
8
|
-
import { REGISTRY_PATH } from './constants.mjs';
|
|
9
|
-
|
|
10
|
-
export const HOOK_EVENTS = [
|
|
11
|
-
'PreToolUse',
|
|
12
|
-
'SessionStart',
|
|
13
|
-
'PostToolUse',
|
|
14
|
-
'Stop',
|
|
15
|
-
'UserPromptSubmit',
|
|
16
|
-
];
|
|
17
|
-
export const TOOL_GROUPS = [
|
|
18
|
-
'write',
|
|
19
|
-
'shell',
|
|
20
|
-
'delegation',
|
|
21
|
-
'execution',
|
|
22
|
-
'question',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
/** The kinds of value a gate param can take, so the CLI can describe and validate it. */
|
|
26
|
-
export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
|
|
27
|
-
|
|
28
|
-
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
-
const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
|
|
30
|
-
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
31
|
-
// A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
|
|
32
|
-
// (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
|
|
33
|
-
const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A configurable param a project may override. The registry declares that the param
|
|
37
|
-
* EXISTS and its type; the default VALUE lives in the gate's own source, so the user
|
|
38
|
-
* reads it there and knows exactly what a project override replaces.
|
|
39
|
-
*/
|
|
40
|
-
const parameterSchema = z.object({
|
|
41
|
-
name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
|
|
42
|
-
type: z.enum(PARAM_TYPES),
|
|
43
|
-
description: z.string().min(1),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const gateSchema = z.object({
|
|
47
|
-
id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
|
|
48
|
-
configKey: z
|
|
49
|
-
.string()
|
|
50
|
-
.regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
|
|
51
|
-
default: z.boolean(),
|
|
52
|
-
event: z.enum(HOOK_EVENTS),
|
|
53
|
-
tools: z.array(z.enum(TOOL_GROUPS)),
|
|
54
|
-
script: z
|
|
55
|
-
.string()
|
|
56
|
-
.regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
|
|
57
|
-
description: z.string().min(1),
|
|
58
|
-
// Optional: a gate with no configurable params omits it.
|
|
59
|
-
params: z.array(parameterSchema).optional(),
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const familySchema = z.object({
|
|
63
|
-
id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
|
|
64
|
-
name: z.string().min(1),
|
|
65
|
-
description: z.string().min(1),
|
|
66
|
-
// Which plugin's hooks/ directory the family's gate scripts resolve against. Optional so
|
|
67
|
-
// existing families need no change: absent means the original 'gates' plugin.
|
|
68
|
-
plugin: z
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
...duplicatesIn(
|
|
93
|
-
(id) => `duplicate
|
|
94
|
-
),
|
|
95
|
-
...duplicatesIn(gates.map((gate) => gate?.
|
|
96
|
-
(
|
|
97
|
-
),
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
1
|
+
// Loads and validates registry.json — the catalog that menus, config and hooks derive from.
|
|
2
|
+
// Validation is a zod schema so the shape is declared once and errors are uniform.
|
|
3
|
+
// Fails loudly on a corrupt catalog: a menu built on invalid data would write a
|
|
4
|
+
// configuration that no gate recognizes.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { REGISTRY_PATH } from './constants.mjs';
|
|
9
|
+
|
|
10
|
+
export const HOOK_EVENTS = [
|
|
11
|
+
'PreToolUse',
|
|
12
|
+
'SessionStart',
|
|
13
|
+
'PostToolUse',
|
|
14
|
+
'Stop',
|
|
15
|
+
'UserPromptSubmit',
|
|
16
|
+
];
|
|
17
|
+
export const TOOL_GROUPS = [
|
|
18
|
+
'write',
|
|
19
|
+
'shell',
|
|
20
|
+
'delegation',
|
|
21
|
+
'execution',
|
|
22
|
+
'question',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** The kinds of value a gate param can take, so the CLI can describe and validate it. */
|
|
26
|
+
export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
|
|
27
|
+
|
|
28
|
+
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
+
const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
|
|
30
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
31
|
+
// A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
|
|
32
|
+
// (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
|
|
33
|
+
const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A configurable param a project may override. The registry declares that the param
|
|
37
|
+
* EXISTS and its type; the default VALUE lives in the gate's own source, so the user
|
|
38
|
+
* reads it there and knows exactly what a project override replaces.
|
|
39
|
+
*/
|
|
40
|
+
const parameterSchema = z.object({
|
|
41
|
+
name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
|
|
42
|
+
type: z.enum(PARAM_TYPES),
|
|
43
|
+
description: z.string().min(1),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const gateSchema = z.object({
|
|
47
|
+
id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
|
|
48
|
+
configKey: z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
|
|
51
|
+
default: z.boolean(),
|
|
52
|
+
event: z.enum(HOOK_EVENTS),
|
|
53
|
+
tools: z.array(z.enum(TOOL_GROUPS)),
|
|
54
|
+
script: z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
|
|
57
|
+
description: z.string().min(1),
|
|
58
|
+
// Optional: a gate with no configurable params omits it.
|
|
59
|
+
params: z.array(parameterSchema).optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const familySchema = z.object({
|
|
63
|
+
id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
|
|
64
|
+
name: z.string().min(1),
|
|
65
|
+
description: z.string().min(1),
|
|
66
|
+
// Which plugin's hooks/ directory the family's gate scripts resolve against. Optional so
|
|
67
|
+
// existing families need no change: absent means the original 'gates' plugin.
|
|
68
|
+
plugin: z
|
|
69
|
+
.string()
|
|
70
|
+
.regex(ID_PATTERN, 'plugin id must be kebab-case')
|
|
71
|
+
.optional(),
|
|
72
|
+
gates: z.array(gateSchema).min(1),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function duplicatesIn(items) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const duplicates = new Set();
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
if (seen.has(item)) duplicates.add(item);
|
|
80
|
+
seen.add(item);
|
|
81
|
+
}
|
|
82
|
+
return [...duplicates];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Uniqueness across families is not expressible per-field, so it is a second pass. */
|
|
86
|
+
function duplicateProblems(candidate) {
|
|
87
|
+
const families = Array.isArray(candidate?.families) ? candidate.families : [];
|
|
88
|
+
const gates = families.flatMap((family) =>
|
|
89
|
+
Array.isArray(family?.gates) ? family.gates : [],
|
|
90
|
+
);
|
|
91
|
+
return [
|
|
92
|
+
...duplicatesIn(families.map((family) => family?.id)).map(
|
|
93
|
+
(id) => `duplicate family id: ${id}`,
|
|
94
|
+
),
|
|
95
|
+
...duplicatesIn(gates.map((gate) => gate?.id)).map(
|
|
96
|
+
(id) => `duplicate gate id: ${id}`,
|
|
97
|
+
),
|
|
98
|
+
...duplicatesIn(gates.map((gate) => gate?.configKey)).map(
|
|
99
|
+
(key) => `duplicate configKey: ${key}`,
|
|
100
|
+
),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const registrySchema = z.object({
|
|
105
|
+
gateVersion: z.string().regex(VERSION_PATTERN, 'gateVersion must be x.y.z'),
|
|
106
|
+
families: z.array(familySchema).min(1),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Returns a list of human-readable problems; empty when the registry is valid. */
|
|
110
|
+
export function validateRegistry(candidate) {
|
|
111
|
+
const result = registrySchema.safeParse(candidate);
|
|
112
|
+
const schemaProblems = result.success
|
|
113
|
+
? []
|
|
114
|
+
: result.error.issues.map(
|
|
115
|
+
(issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
|
|
116
|
+
);
|
|
117
|
+
return [...schemaProblems, ...duplicateProblems(candidate)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadRegistry(path = REGISTRY_PATH) {
|
|
121
|
+
const candidate = JSON.parse(readFileSync(path, 'utf8'));
|
|
122
|
+
const problems = validateRegistry(candidate);
|
|
123
|
+
if (problems.length > 0) {
|
|
124
|
+
throw new Error(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
|
|
125
|
+
}
|
|
126
|
+
return candidate;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const DEFAULT_PLUGIN = 'gates';
|
|
130
|
+
|
|
131
|
+
export function allGates(registry) {
|
|
132
|
+
return registry.families.flatMap((family) =>
|
|
133
|
+
family.gates.map((gate) => ({
|
|
134
|
+
...gate,
|
|
135
|
+
family: family.id,
|
|
136
|
+
plugin: family.plugin ?? DEFAULT_PLUGIN,
|
|
137
|
+
})),
|
|
138
|
+
);
|
|
139
|
+
}
|
package/cli/smoke-fixtures.json
CHANGED
|
@@ -157,6 +157,20 @@
|
|
|
157
157
|
"needsState": true,
|
|
158
158
|
"note": "auto-off unless a features tree exists; needs seeded .ai/feature_list.json + features dir."
|
|
159
159
|
},
|
|
160
|
+
{
|
|
161
|
+
"id": "brief-approved",
|
|
162
|
+
"configKey": "requireApprovedBriefBeforeImplementing",
|
|
163
|
+
"enabledByDefault": false,
|
|
164
|
+
"type": "deny",
|
|
165
|
+
"payload": {
|
|
166
|
+
"tool_name": "Agent",
|
|
167
|
+
"tool_input": {
|
|
168
|
+
"prompt": "Nivel: STANDARD\nImplementa .ai/features/checkout/brief.md."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"needsState": true,
|
|
172
|
+
"note": "auto-off unless a features tree exists; needs a seeded .ai/features/<name>/brief.md with no approved frontmatter."
|
|
173
|
+
},
|
|
160
174
|
{
|
|
161
175
|
"id": "implementation-pipeline",
|
|
162
176
|
"configKey": "requireImplementationPipeline",
|
|
@@ -385,6 +399,57 @@
|
|
|
385
399
|
"tool_input": { "questions": [{ "question": "A or B?" }] }
|
|
386
400
|
},
|
|
387
401
|
"needsState": false
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"id": "no-coauthor",
|
|
405
|
+
"configKey": "blockCoauthorTrailers",
|
|
406
|
+
"enabledByDefault": true,
|
|
407
|
+
"type": "deny",
|
|
408
|
+
"payload": {
|
|
409
|
+
"tool_name": "Bash",
|
|
410
|
+
"tool_input": {
|
|
411
|
+
"command": "git commit -m \"fix\n\nCo-Authored-By: Claude <noreply@anthropic.com>\""
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
"needsState": false
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
"id": "no-lint-suppression",
|
|
418
|
+
"configKey": "blockLintSuppression",
|
|
419
|
+
"enabledByDefault": true,
|
|
420
|
+
"type": "deny",
|
|
421
|
+
"payload": {
|
|
422
|
+
"tool_name": "Write",
|
|
423
|
+
"tool_input": {
|
|
424
|
+
"file_path": "/repo/a.js",
|
|
425
|
+
"content": "const x = 1; // eslint-disable-next-line no-unused-vars"
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
"needsState": false
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
"id": "staged-lint",
|
|
432
|
+
"configKey": "blockCommitWithStagedLintErrors",
|
|
433
|
+
"enabledByDefault": false,
|
|
434
|
+
"type": "deny",
|
|
435
|
+
"payload": {
|
|
436
|
+
"tool_name": "Bash",
|
|
437
|
+
"tool_input": { "command": "git commit -m \"wip\"" }
|
|
438
|
+
},
|
|
439
|
+
"needsState": true,
|
|
440
|
+
"note": "needs a git repo with a staged lintable file that fails lint and a resolvable linter; a bare payload cannot reproduce the staged-file scan."
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"id": "atomic-commit",
|
|
444
|
+
"configKey": "blockNonAtomicCommits",
|
|
445
|
+
"enabledByDefault": false,
|
|
446
|
+
"type": "deny",
|
|
447
|
+
"payload": {
|
|
448
|
+
"tool_name": "Bash",
|
|
449
|
+
"tool_input": { "command": "git commit -m \"stuff\"" }
|
|
450
|
+
},
|
|
451
|
+
"needsState": true,
|
|
452
|
+
"note": "needs a git repo with a staged set that mixes >maxNatures natures or exceeds maxFiles; a bare payload cannot reproduce the staged-file scan."
|
|
388
453
|
}
|
|
389
454
|
]
|
|
390
455
|
}
|