@devrik-tools/claude-gates 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -4
  3. package/README.md +21 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +51 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  20. package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
  21. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  22. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  23. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  24. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  25. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  26. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  27. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  28. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  29. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  30. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  31. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  32. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  33. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  34. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  35. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  36. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  37. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  38. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  39. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  40. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  41. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  42. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  43. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  44. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  45. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  46. package/plugins/gates/hooks/hooks.json +51 -0
  47. package/plugins/gates/hooks/lib/config.mjs +179 -172
  48. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  49. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  50. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  51. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  52. package/plugins/tasks/hooks/hooks.json +26 -26
  53. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  54. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  55. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  56. package/registry.json +171 -1
package/cli/init.mjs CHANGED
@@ -1,276 +1,303 @@
1
- // `claude-gates init` — the interactive flow: scope → mode → picks → confirm → write.
2
- // Every decision can also be passed as a flag so CI and scripts can run it without a TTY.
3
-
4
- import * as prompts from '@clack/prompts';
5
- import {
6
- SCOPES,
7
- configPathFor,
8
- readConfig,
9
- mergeConfig,
10
- writeConfig,
11
- } from './config.mjs';
12
- import { EXIT_CODE } from './constants.mjs';
13
- import { installPlugin, pluginInstallCommands } from './install.mjs';
14
- import { materializeGates } from './materialize.mjs';
15
- import { loadRegistry, allGates } from './registry.mjs';
16
- import {
17
- MODES,
18
- resolveSelection,
19
- adoptionOf,
20
- summarize,
21
- } from './selection.mjs';
22
-
23
- const MODE_OPTIONS = [
24
- {
25
- value: MODES.DEFAULTS,
26
- label: 'Recommended defaults',
27
- hint: 'gates marked default in the registry',
28
- },
29
- { value: MODES.ALL, label: 'Everything', hint: 'every gate in every family' },
30
- { value: MODES.FAMILIES, label: 'By family', hint: 'pick whole families' },
31
- { value: MODES.GRANULAR, label: 'Granular', hint: 'pick individual gates' },
32
- {
33
- value: MODES.NONE,
34
- label: 'Nothing',
35
- hint: "record a 'no' so you are not asked again",
36
- },
37
- ];
38
-
39
- /** commander option name → selection mode */
40
- const MODE_BY_OPTION = {
41
- defaults: MODES.DEFAULTS,
42
- all: MODES.ALL,
43
- none: MODES.NONE,
44
- families: MODES.FAMILIES,
45
- gates: MODES.GRANULAR,
46
- };
47
-
48
- /**
49
- * Turns commander's options object into the decisions `runInit` needs.
50
- * `scope`/`mode` stay `null` when no flag decided them (prompted later).
51
- */
52
- export function normalizeOptions(options = {}) {
53
- const modeFlags = Object.keys(MODE_BY_OPTION).filter((name) => options[name]);
54
- if (modeFlags.length > 1)
55
- throw new Error(`Pick one of --${modeFlags.join(', --')}`);
56
- if (options.project && options.global)
57
- throw new Error('Pick one of --project, --global');
58
-
59
- let scope = null;
60
- if (options.project) scope = SCOPES.PROJECT;
61
- if (options.global) scope = SCOPES.GLOBAL;
62
-
63
- return {
64
- scope,
65
- mode: modeFlags.length === 1 ? MODE_BY_OPTION[modeFlags[0]] : null,
66
- families: Array.isArray(options.families) ? options.families : [],
67
- gates: Array.isArray(options.gates) ? options.gates : [],
68
- yes: Boolean(options.yes),
69
- dryRun: Boolean(options.dryRun),
70
- install: options.install,
71
- removePrevious: options.removePrevious,
72
- };
73
- }
74
-
75
- function bail(value) {
76
- if (prompts.isCancel(value)) {
77
- prompts.cancel('Nothing written.');
78
- process.exit(EXIT_CODE.FAILURE);
79
- }
80
- return value;
81
- }
82
-
83
- async function askScope(cwd) {
84
- return bail(
85
- await prompts.select({
86
- message: 'Where should these gates apply?',
87
- options: [
88
- {
89
- value: SCOPES.PROJECT,
90
- label: 'This project',
91
- hint: configPathFor(SCOPES.PROJECT, { cwd }),
92
- },
93
- {
94
- value: SCOPES.GLOBAL,
95
- label: 'Globally (fallback for every project)',
96
- hint: configPathFor(SCOPES.GLOBAL),
97
- },
98
- ],
99
- }),
100
- );
101
- }
102
-
103
- async function askMode() {
104
- return bail(
105
- await prompts.select({
106
- message: 'What do you want to adopt?',
107
- options: MODE_OPTIONS,
108
- }),
109
- );
110
- }
111
-
112
- async function askFamilies(registry) {
113
- return bail(
114
- await prompts.multiselect({
115
- message: 'Pick families (space to toggle, enter to confirm)',
116
- options: registry.families.map((family) => ({
117
- value: family.id,
118
- label: family.name,
119
- hint: family.description,
120
- })),
121
- initialValues: registry.families
122
- .filter((family) => family.gates.some((gate) => gate.default))
123
- .map((family) => family.id),
124
- required: false,
125
- }),
126
- );
127
- }
128
-
129
- async function askGates(registry) {
130
- const options = {};
131
- for (const family of registry.families) {
132
- options[family.name] = family.gates.map((gate) => ({
133
- value: gate.id,
134
- label: gate.id,
135
- hint: gate.description,
136
- }));
137
- }
138
- return bail(
139
- await prompts.groupMultiselect({
140
- message: 'Pick gates (space to toggle, enter to confirm)',
141
- options,
142
- initialValues: allGates(registry)
143
- .filter((gate) => gate.default)
144
- .map((gate) => gate.id),
145
- required: false,
146
- }),
147
- );
148
- }
149
-
150
- /** `claude plugin install <plugin>@<marketplace>`, read from the marketplace manifest, never hard-coded. */
151
-
152
- /** Fills in whatever the flags left undecided, asking only when there is a TTY. */
153
- async function decide(flags, registry, cwd, interactive) {
154
- const scope =
155
- flags.scope ?? (interactive ? await askScope(cwd) : SCOPES.PROJECT);
156
- const mode = flags.mode ?? (interactive ? await askMode() : MODES.DEFAULTS);
157
- const picks = { families: flags.families, gates: flags.gates };
158
- if (interactive && mode === MODES.FAMILIES && picks.families.length === 0) {
159
- picks.families = await askFamilies(registry);
160
- }
161
- if (interactive && mode === MODES.GRANULAR && picks.gates.length === 0) {
162
- picks.gates = await askGates(registry);
163
- }
164
- return { scope, mode, picks };
165
- }
166
-
167
- function renderSummary(registry, gatesMap) {
168
- return summarize(registry, gatesMap)
169
- .map((row) => {
170
- const list = row.enabled.length > 0 ? ` — ${row.enabled.join(', ')}` : '';
171
- return `${row.name}: ${row.enabled.length}/${row.total}${list}`;
172
- })
173
- .join('\n');
174
- }
175
-
176
- async function askRemovePrevious(io) {
177
- return bail(
178
- await io.confirm({
179
- message: 'Remove the previous plugin version before installing the new one?',
180
- initialValue: true,
181
- }),
182
- );
183
- }
184
-
185
- async function confirmWrite(io, fileExists) {
186
- const confirmed = bail(
187
- await io.confirm({
188
- message: fileExists
189
- ? 'File exists. Merge this selection into it?'
190
- : 'Write this file?',
191
- }),
192
- );
193
- if (!confirmed) {
194
- io.cancel('Nothing written.');
195
- process.exit(EXIT_CODE.FAILURE);
196
- }
197
- }
198
-
199
- export async function runInit(
200
- options,
201
- { cwd = process.cwd(), io = prompts } = {},
202
- ) {
203
- const flags = normalizeOptions(options);
204
- const registry = loadRegistry();
205
- const interactive = !flags.yes && Boolean(process.stdin.isTTY);
206
-
207
- if (interactive) io.intro('claude-gates');
208
-
209
- const { scope, mode, picks } = await decide(
210
- flags,
211
- registry,
212
- cwd,
213
- interactive,
214
- );
215
- const gates = resolveSelection(registry, mode, picks);
216
- const path = configPathFor(scope, { cwd });
217
- const existing = readConfig(path);
218
-
219
- if (existing.corrupt) {
220
- io.log.error(
221
- `${path} exists but is not valid JSON. Fix or remove it first; nothing written.`,
222
- );
223
- process.exit(EXIT_CODE.FAILURE);
224
- }
225
-
226
- const merged = mergeConfig(existing.data, {
227
- adopted: adoptionOf(gates),
228
- gates: materializeGates(registry, gates, existing.data.gates ?? {}, mode),
229
- gateVersion: registry.gateVersion,
230
- });
231
-
232
- io.note(renderSummary(registry, gates), `Selection (${scope}) → ${path}`);
233
-
234
- if (flags.dryRun) {
235
- io.outro('Dry run: nothing written.');
236
- return { path, config: merged, written: false };
237
- }
238
-
239
- if (interactive) await confirmWrite(io, existing.exists);
240
-
241
- writeConfig(path, merged);
242
-
243
- // Install based on the selection, unless the user turned everything off or opted out.
244
- // Additive: `claude plugin install` merges the gate hooks next to whatever is already
245
- // configured. If it cannot run, we fall back to printing the manual command.
246
- const wantsInstall = flags.install !== false && adoptionOf(gates) !== false;
247
- if (!wantsInstall) {
248
- io.outro(`Written ${path}.`);
249
- return { path, config: merged, written: true, installed: false };
250
- }
251
-
252
- // Default true: a previous plugin version is removed before installing the new one, unless
253
- // --no-remove-previous turned it off. Only asked when interactive — --yes or no TTY uses the
254
- // flag/default without a prompt, so scripted and non-interactive runs never block on input.
255
- const removePrevious =
256
- flags.removePrevious !== false &&
257
- (!interactive || (await askRemovePrevious(io)));
258
-
259
- const result = installPlugin(scope, { cwd, removePrevious });
260
- if (result.installed) {
261
- io.outro(
262
- `Written ${path} and installed all plugins (${result.scope} scope). ` +
263
- 'Restart the session (or run /plugin) for the gates to load.',
264
- );
265
- } else {
266
- const manualCommands = pluginInstallCommands()
267
- .filter((_command, index) => !result.results[index].installed)
268
- .join('\n ');
269
- io.log.warn(
270
- `Config written, but not every plugin installed automatically (${result.reason}). ` +
271
- `Install the rest yourself with:\n ${manualCommands}`,
272
- );
273
- io.outro(`Written ${path}.`);
274
- }
275
- return { path, config: merged, written: true, installed: result.installed };
276
- }
1
+ // `claude-gates init` — the interactive flow: scope → mode → picks → confirm → write.
2
+ // Every decision can also be passed as a flag so CI and scripts can run it without a TTY.
3
+
4
+ import * as prompts from '@clack/prompts';
5
+ import {
6
+ SCOPES,
7
+ configPathFor,
8
+ readConfig,
9
+ mergeConfig,
10
+ writeConfig,
11
+ } from './config.mjs';
12
+ import { EXIT_CODE } from './constants.mjs';
13
+ import { installPlugin, pluginInstallCommands } from './install.mjs';
14
+ import { materializeGates } from './materialize.mjs';
15
+ import { loadRegistry, allGates } from './registry.mjs';
16
+ import {
17
+ MODES,
18
+ resolveSelection,
19
+ adoptionOf,
20
+ summarize,
21
+ } from './selection.mjs';
22
+
23
+ const MODE_OPTIONS = [
24
+ {
25
+ value: MODES.DEFAULTS,
26
+ label: 'Recommended defaults',
27
+ hint: 'gates marked default in the registry',
28
+ },
29
+ { value: MODES.ALL, label: 'Everything', hint: 'every gate in every family' },
30
+ { value: MODES.FAMILIES, label: 'By family', hint: 'pick whole families' },
31
+ { value: MODES.GRANULAR, label: 'Granular', hint: 'pick individual gates' },
32
+ {
33
+ value: MODES.NONE,
34
+ label: 'Nothing',
35
+ hint: "record a 'no' so you are not asked again",
36
+ },
37
+ ];
38
+
39
+ /** commander option name → selection mode */
40
+ const MODE_BY_OPTION = {
41
+ defaults: MODES.DEFAULTS,
42
+ all: MODES.ALL,
43
+ none: MODES.NONE,
44
+ families: MODES.FAMILIES,
45
+ gates: MODES.GRANULAR,
46
+ };
47
+
48
+ /**
49
+ * Turns commander's options object into the decisions `runInit` needs.
50
+ * `scope`/`mode` stay `null` when no flag decided them (prompted later).
51
+ */
52
+ export function normalizeOptions(options = {}) {
53
+ const modeFlags = Object.keys(MODE_BY_OPTION).filter((name) => options[name]);
54
+ if (modeFlags.length > 1)
55
+ throw new Error(`Pick one of --${modeFlags.join(', --')}`);
56
+ if (options.project && options.global)
57
+ throw new Error('Pick one of --project, --global');
58
+
59
+ let scope = null;
60
+ if (options.project) scope = SCOPES.PROJECT;
61
+ if (options.global) scope = SCOPES.GLOBAL;
62
+
63
+ return {
64
+ scope,
65
+ mode: modeFlags.length === 1 ? MODE_BY_OPTION[modeFlags[0]] : null,
66
+ families: Array.isArray(options.families) ? options.families : [],
67
+ gates: Array.isArray(options.gates) ? options.gates : [],
68
+ yes: Boolean(options.yes),
69
+ dryRun: Boolean(options.dryRun),
70
+ install: options.install,
71
+ removePrevious: options.removePrevious,
72
+ };
73
+ }
74
+
75
+ function bail(value) {
76
+ if (prompts.isCancel(value)) {
77
+ prompts.cancel('Nothing written.');
78
+ process.exit(EXIT_CODE.FAILURE);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ async function askScope(cwd) {
84
+ return bail(
85
+ await prompts.select({
86
+ message: 'Where should these gates apply?',
87
+ options: [
88
+ {
89
+ value: SCOPES.PROJECT,
90
+ label: 'This project',
91
+ hint: configPathFor(SCOPES.PROJECT, { cwd }),
92
+ },
93
+ {
94
+ value: SCOPES.GLOBAL,
95
+ label: 'Globally (fallback for every project)',
96
+ hint: configPathFor(SCOPES.GLOBAL),
97
+ },
98
+ ],
99
+ }),
100
+ );
101
+ }
102
+
103
+ async function askMode() {
104
+ return bail(
105
+ await prompts.select({
106
+ message: 'What do you want to adopt?',
107
+ options: MODE_OPTIONS,
108
+ }),
109
+ );
110
+ }
111
+
112
+ async function askFamilies(registry) {
113
+ return bail(
114
+ await prompts.multiselect({
115
+ message: 'Pick families (space to toggle, enter to confirm)',
116
+ options: registry.families.map((family) => ({
117
+ value: family.id,
118
+ label: family.name,
119
+ hint: family.description,
120
+ })),
121
+ initialValues: registry.families
122
+ .filter((family) => family.gates.some((gate) => gate.default))
123
+ .map((family) => family.id),
124
+ required: false,
125
+ }),
126
+ );
127
+ }
128
+
129
+ async function askGates(registry) {
130
+ const options = {};
131
+ for (const family of registry.families) {
132
+ options[family.name] = family.gates.map((gate) => ({
133
+ value: gate.id,
134
+ label: gate.id,
135
+ hint: gate.description,
136
+ }));
137
+ }
138
+ return bail(
139
+ await prompts.groupMultiselect({
140
+ message: 'Pick gates (space to toggle, enter to confirm)',
141
+ options,
142
+ initialValues: allGates(registry)
143
+ .filter((gate) => gate.default)
144
+ .map((gate) => gate.id),
145
+ required: false,
146
+ }),
147
+ );
148
+ }
149
+
150
+ /** `claude plugin install <plugin>@<marketplace>`, read from the marketplace manifest, never hard-coded. */
151
+
152
+ /** Fills in whatever the flags left undecided, asking only when there is a TTY. */
153
+ async function decide(flags, registry, cwd, interactive) {
154
+ const scope =
155
+ flags.scope ?? (interactive ? await askScope(cwd) : SCOPES.PROJECT);
156
+ const mode = flags.mode ?? (interactive ? await askMode() : MODES.DEFAULTS);
157
+ const picks = { families: flags.families, gates: flags.gates };
158
+ if (interactive && mode === MODES.FAMILIES && picks.families.length === 0) {
159
+ picks.families = await askFamilies(registry);
160
+ }
161
+ if (interactive && mode === MODES.GRANULAR && picks.gates.length === 0) {
162
+ picks.gates = await askGates(registry);
163
+ }
164
+ return { scope, mode, picks };
165
+ }
166
+
167
+ function renderSummary(registry, gatesMap) {
168
+ return summarize(registry, gatesMap)
169
+ .map((row) => {
170
+ const list = row.enabled.length > 0 ? ` — ${row.enabled.join(', ')}` : '';
171
+ return `${row.name}: ${row.enabled.length}/${row.total}${list}`;
172
+ })
173
+ .join('\n');
174
+ }
175
+
176
+ async function askRemovePrevious(io) {
177
+ return bail(
178
+ await io.confirm({
179
+ message:
180
+ 'Remove the previous plugin version before installing the new one?',
181
+ initialValue: true,
182
+ }),
183
+ );
184
+ }
185
+
186
+ async function confirmWrite(io, fileExists) {
187
+ const confirmed = bail(
188
+ await io.confirm({
189
+ message: fileExists
190
+ ? 'File exists. Merge this selection into it?'
191
+ : 'Write this file?',
192
+ }),
193
+ );
194
+ if (!confirmed) {
195
+ io.cancel('Nothing written.');
196
+ process.exit(EXIT_CODE.FAILURE);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Installs the gate plugins after the config has been written, unless the user turned
202
+ * everything off or opted out with `--no-install`. Additive: `claude plugin install`
203
+ * merges the gate hooks next to whatever is already configured. If it cannot run, this
204
+ * falls back to printing the manual command. Extracted from `runInit` purely to keep that
205
+ * function's branching within the project's complexity budget — same behavior, same order.
206
+ */
207
+ async function installGatesAfterWrite({
208
+ flags,
209
+ gates,
210
+ scope,
211
+ cwd,
212
+ interactive,
213
+ io,
214
+ path,
215
+ merged,
216
+ }) {
217
+ const wantsInstall = flags.install !== false && adoptionOf(gates) !== false;
218
+ if (!wantsInstall) {
219
+ io.outro(`Written ${path}.`);
220
+ return { path, config: merged, written: true, installed: false };
221
+ }
222
+
223
+ // Default true: a previous plugin version is removed before installing the new one, unless
224
+ // --no-remove-previous turned it off. Only asked when interactive — --yes or no TTY uses the
225
+ // flag/default without a prompt, so scripted and non-interactive runs never block on input.
226
+ const removePrevious =
227
+ flags.removePrevious !== false &&
228
+ (!interactive || (await askRemovePrevious(io)));
229
+
230
+ const result = installPlugin(scope, { cwd, removePrevious });
231
+ if (result.installed) {
232
+ io.outro(
233
+ `Written ${path} and installed all plugins (${result.scope} scope). ` +
234
+ 'Restart the session (or run /plugin) for the gates to load.',
235
+ );
236
+ } else {
237
+ const manualCommands = pluginInstallCommands()
238
+ .filter((_command, index) => !result.results[index].installed)
239
+ .join('\n ');
240
+ io.log.warn(
241
+ `Config written, but not every plugin installed automatically (${result.reason}). ` +
242
+ `Install the rest yourself with:\n ${manualCommands}`,
243
+ );
244
+ io.outro(`Written ${path}.`);
245
+ }
246
+ return { path, config: merged, written: true, installed: result.installed };
247
+ }
248
+
249
+ export async function runInit(
250
+ options,
251
+ { cwd = process.cwd(), io = prompts } = {},
252
+ ) {
253
+ const flags = normalizeOptions(options);
254
+ const registry = loadRegistry();
255
+ const interactive = !flags.yes && Boolean(process.stdin.isTTY);
256
+
257
+ if (interactive) io.intro('claude-gates');
258
+
259
+ const { scope, mode, picks } = await decide(
260
+ flags,
261
+ registry,
262
+ cwd,
263
+ interactive,
264
+ );
265
+ const gates = resolveSelection(registry, mode, picks);
266
+ const path = configPathFor(scope, { cwd });
267
+ const existing = readConfig(path);
268
+
269
+ if (existing.corrupt) {
270
+ io.log.error(
271
+ `${path} exists but is not valid JSON. Fix or remove it first; nothing written.`,
272
+ );
273
+ process.exit(EXIT_CODE.FAILURE);
274
+ }
275
+
276
+ const merged = mergeConfig(existing.data, {
277
+ adopted: adoptionOf(gates),
278
+ gates: materializeGates(registry, gates, existing.data.gates ?? {}, mode),
279
+ gateVersion: registry.gateVersion,
280
+ });
281
+
282
+ io.note(renderSummary(registry, gates), `Selection (${scope}) → ${path}`);
283
+
284
+ if (flags.dryRun) {
285
+ io.outro('Dry run: nothing written.');
286
+ return { path, config: merged, written: false };
287
+ }
288
+
289
+ if (interactive) await confirmWrite(io, existing.exists);
290
+
291
+ writeConfig(path, merged);
292
+
293
+ return installGatesAfterWrite({
294
+ flags,
295
+ gates,
296
+ scope,
297
+ cwd,
298
+ interactive,
299
+ io,
300
+ path,
301
+ merged,
302
+ });
303
+ }