@devrik-tools/claude-gates 0.7.2 → 0.8.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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
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 problems = validateRegistry(
37
- JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
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
- process.stdout.write('registry.json OK\n');
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
- return fail('registry needs --check or --list');
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 { materializeGates } from './materialize.mjs';
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: adoptionOf(gates),
278
- gates: materializeGates(registry, gates, existing.data.gates ?? {}, mode),
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
+ }
@@ -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 && mode === MODES.DEFAULTS) {
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,7 @@ export const TOOL_GROUPS = [
20
20
  'delegation',
21
21
  'execution',
22
22
  'question',
23
+ 'research',
23
24
  ];
24
25
 
25
26
  /** The kinds of value a gate param can take, so the CLI can describe and validate it. */
@@ -55,8 +56,17 @@ const gateSchema = z.object({
55
56
  .string()
56
57
  .regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
57
58
  description: z.string().min(1),
58
- // Optional: a gate with no configurable params omits it.
59
59
  params: z.array(parameterSchema).optional(),
60
+ timeoutSeconds: z.number().int().positive().optional(),
61
+ extraScripts: z
62
+ .array(
63
+ z.object({
64
+ event: z.enum(HOOK_EVENTS),
65
+ script: z.string().regex(SCRIPT_PATTERN),
66
+ tools: z.array(z.enum(TOOL_GROUPS)).optional(),
67
+ }),
68
+ )
69
+ .optional(),
60
70
  });
61
71
 
62
72
  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
+ }