@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
@@ -8,7 +8,9 @@
8
8
  "type": "deny",
9
9
  "payload": {
10
10
  "tool_name": "Bash",
11
- "tool_input": { "command": "git reset --hard HEAD~3" }
11
+ "tool_input": {
12
+ "command": "git reset --hard HEAD~3"
13
+ }
12
14
  },
13
15
  "needsState": false
14
16
  },
@@ -19,7 +21,9 @@
19
21
  "type": "deny",
20
22
  "payload": {
21
23
  "tool_name": "Bash",
22
- "tool_input": { "command": "git push origin main" }
24
+ "tool_input": {
25
+ "command": "git push origin main"
26
+ }
23
27
  },
24
28
  "needsState": false
25
29
  },
@@ -30,7 +34,9 @@
30
34
  "type": "deny",
31
35
  "payload": {
32
36
  "tool_name": "Write",
33
- "tool_input": { "file_path": "/repo/.env" }
37
+ "tool_input": {
38
+ "file_path": "/repo/.env"
39
+ }
34
40
  },
35
41
  "needsState": false
36
42
  },
@@ -41,7 +47,9 @@
41
47
  "type": "deny",
42
48
  "payload": {
43
49
  "tool_name": "Write",
44
- "tool_input": { "file_path": "random-orphan.txt" }
50
+ "tool_input": {
51
+ "file_path": "random-orphan.txt"
52
+ }
45
53
  },
46
54
  "needsState": true,
47
55
  "note": "file_path must resolve against the scratch project root; a literal alone does not reproduce root detection."
@@ -53,7 +61,9 @@
53
61
  "type": "deny",
54
62
  "payload": {
55
63
  "tool_name": "Bash",
56
- "tool_input": { "command": "sleep 30" }
64
+ "tool_input": {
65
+ "command": "sleep 30"
66
+ }
57
67
  },
58
68
  "needsState": false
59
69
  },
@@ -64,7 +74,9 @@
64
74
  "type": "deny",
65
75
  "payload": {
66
76
  "tool_name": "Agent",
67
- "tool_input": { "prompt": "Fix the login bug please, thanks." }
77
+ "tool_input": {
78
+ "prompt": "Fix the login bug please, thanks."
79
+ }
68
80
  },
69
81
  "needsState": false
70
82
  },
@@ -101,7 +113,9 @@
101
113
  "type": "deny",
102
114
  "payload": {
103
115
  "tool_name": "Agent",
104
- "tool_input": { "prompt": "fix the scoring threshold" }
116
+ "tool_input": {
117
+ "prompt": "fix the scoring threshold"
118
+ }
105
119
  },
106
120
  "needsState": true,
107
121
  "note": "stateful: denies only on the 3rd identical retry under the same session id."
@@ -114,7 +128,7 @@
114
128
  "payload": {
115
129
  "tool_name": "Agent",
116
130
  "tool_input": {
117
- "prompt": "Acordate de lo que hablamos antes y aplica el mismo criterio."
131
+ "prompt": "Como te dije antes, aplica el mismo criterio al nuevo archivo."
118
132
  }
119
133
  },
120
134
  "needsState": false
@@ -124,7 +138,12 @@
124
138
  "configKey": "warnSequentialDelegations",
125
139
  "enabledByDefault": false,
126
140
  "type": "warn",
127
- "payload": { "tool_name": "Task", "tool_input": { "prompt": "do work" } },
141
+ "payload": {
142
+ "tool_name": "Task",
143
+ "tool_input": {
144
+ "prompt": "do work"
145
+ }
146
+ },
128
147
  "needsState": true,
129
148
  "note": "stateful: warns only on the 3rd consecutive delegation under one session."
130
149
  },
@@ -178,7 +197,9 @@
178
197
  "type": "deny",
179
198
  "payload": {
180
199
  "tool_name": "Agent",
181
- "tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
200
+ "tool_input": {
201
+ "prompt": "Nivel: STANDARD\nImplementa el checkout."
202
+ }
182
203
  },
183
204
  "needsState": false
184
205
  },
@@ -189,7 +210,9 @@
189
210
  "type": "deny",
190
211
  "payload": {
191
212
  "tool_name": "Agent",
192
- "tool_input": { "prompt": "Nivel: STANDARD\nImplementa el checkout." }
213
+ "tool_input": {
214
+ "prompt": "Nivel: STANDARD\nImplementa el checkout."
215
+ }
193
216
  },
194
217
  "needsState": false
195
218
  },
@@ -242,7 +265,9 @@
242
265
  "type": "deny",
243
266
  "payload": {
244
267
  "tool_name": "Agent",
245
- "tool_input": { "prompt": "create a new script that checks X" }
268
+ "tool_input": {
269
+ "prompt": "create a new script that checks X"
270
+ }
246
271
  },
247
272
  "needsState": false
248
273
  },
@@ -267,7 +292,9 @@
267
292
  "type": "deny",
268
293
  "payload": {
269
294
  "tool_name": "Bash",
270
- "tool_input": { "command": "echo hi" }
295
+ "tool_input": {
296
+ "command": "echo hi"
297
+ }
271
298
  },
272
299
  "needsState": true,
273
300
  "note": "needs a project rules/ or .claude/skills gate script present that exits non-zero."
@@ -279,7 +306,9 @@
279
306
  "type": "deny",
280
307
  "payload": {
281
308
  "tool_name": "Bash",
282
- "tool_input": { "command": "echo hi" }
309
+ "tool_input": {
310
+ "command": "echo hi"
311
+ }
283
312
  },
284
313
  "needsState": true,
285
314
  "note": "needs .ai/reincidencias.json with an open class at/above threshold."
@@ -307,7 +336,11 @@
307
336
  "payload": {
308
337
  "tool_name": "AskUserQuestion",
309
338
  "tool_input": {
310
- "questions": [{ "question": "Should I migrate the auth schema?" }]
339
+ "questions": [
340
+ {
341
+ "question": "Should I migrate the auth schema?"
342
+ }
343
+ ]
311
344
  }
312
345
  },
313
346
  "needsState": true,
@@ -334,10 +367,11 @@
334
367
  "enabledByDefault": true,
335
368
  "type": "warn",
336
369
  "payload": {
337
- "tool_name": "Write",
370
+ "tool_name": "Edit",
338
371
  "tool_input": {
339
372
  "file_path": "/repo/src/config.js",
340
- "content": "const REQUEST_TIMEOUT_MS = 30000;"
373
+ "old_string": "const REQUEST_TIMEOUT_MS = 30000;",
374
+ "new_string": "const REQUEST_TIMEOUT_MS = 60000;"
341
375
  }
342
376
  },
343
377
  "needsState": false
@@ -349,7 +383,9 @@
349
383
  "type": "deny",
350
384
  "payload": {
351
385
  "tool_name": "Bash",
352
- "tool_input": { "command": "git commit -m x" }
386
+ "tool_input": {
387
+ "command": "git commit -m x"
388
+ }
353
389
  },
354
390
  "needsState": true,
355
391
  "note": "needs a project package.json whose lint script exits non-zero."
@@ -384,7 +420,10 @@
384
420
  "type": "deny",
385
421
  "payload": {
386
422
  "tool_name": "Write",
387
- "tool_input": { "file_path": "src/x.js", "content": "x" }
423
+ "tool_input": {
424
+ "file_path": "src/x.js",
425
+ "content": "x"
426
+ }
388
427
  },
389
428
  "needsState": true,
390
429
  "note": "needs a forge adoption marker + sqlite db with no active run."
@@ -396,7 +435,13 @@
396
435
  "type": "deny",
397
436
  "payload": {
398
437
  "tool_name": "AskUserQuestion",
399
- "tool_input": { "questions": [{ "question": "A or B?" }] }
438
+ "tool_input": {
439
+ "questions": [
440
+ {
441
+ "question": "A or B?"
442
+ }
443
+ ]
444
+ }
400
445
  },
401
446
  "needsState": false
402
447
  },
@@ -434,7 +479,9 @@
434
479
  "type": "deny",
435
480
  "payload": {
436
481
  "tool_name": "Bash",
437
- "tool_input": { "command": "git commit -m \"wip\"" }
482
+ "tool_input": {
483
+ "command": "git commit -m \"wip\""
484
+ }
438
485
  },
439
486
  "needsState": true,
440
487
  "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."
@@ -446,10 +493,55 @@
446
493
  "type": "deny",
447
494
  "payload": {
448
495
  "tool_name": "Bash",
449
- "tool_input": { "command": "git commit -m \"stuff\"" }
496
+ "tool_input": {
497
+ "command": "git commit -m \"stuff\""
498
+ }
450
499
  },
451
500
  "needsState": true,
452
501
  "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."
502
+ },
503
+ {
504
+ "id": "engram-first",
505
+ "configKey": "requireEngramBeforeResearch",
506
+ "enabledByDefault": true,
507
+ "type": "deny",
508
+ "payload": {
509
+ "tool_name": "WebSearch",
510
+ "tool_input": {
511
+ "query": "how to use zod refine"
512
+ },
513
+ "session_id": "smoke-engram-first"
514
+ },
515
+ "needsState": false
516
+ },
517
+ {
518
+ "id": "library-docs",
519
+ "configKey": "requireDocsBeforeUsingNewLibrary",
520
+ "enabledByDefault": true,
521
+ "type": "deny",
522
+ "payload": {
523
+ "tool_name": "Write",
524
+ "tool_input": {
525
+ "file_path": "src/schema.ts",
526
+ "content": "import { z } from 'zod';\n"
527
+ },
528
+ "session_id": "smoke-library-docs"
529
+ },
530
+ "needsState": false
531
+ },
532
+ {
533
+ "id": "no-explanatory-comments",
534
+ "configKey": "blockExplanatoryComments",
535
+ "enabledByDefault": true,
536
+ "type": "deny",
537
+ "payload": {
538
+ "tool_name": "Write",
539
+ "tool_input": {
540
+ "file_path": "src/a.mjs",
541
+ "content": "// reads the config and returns it\nexport const x = 1;\n"
542
+ }
543
+ },
544
+ "needsState": false
453
545
  }
454
546
  ]
455
547
  }
package/cli/task.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  STATUS,
13
13
  } from '../plugins/tasks/hooks/lib/task-store.mjs';
14
14
  import { EXIT_CODE } from './constants.mjs';
15
+ import { verifyCommand, verifyPath } from './evidence.mjs';
15
16
 
16
17
  const DEFAULT_SIZE = 'unspecified';
17
18
 
@@ -64,19 +65,36 @@ function reportCloseResult(result) {
64
65
  process.stdout.write(`${JSON.stringify(result.task, null, 2)}\n`);
65
66
  }
66
67
 
68
+ const EVIDENCE_USAGE =
69
+ 'closing a task as done requires verified evidence: --check "<command>" ' +
70
+ '[--expect <text>] (the command must exit 0) or --exists <path> [--contains <text>]. ' +
71
+ 'Free text (--evidence/--note) is only a note. Use `task abandon` if it will not be finished.';
72
+
73
+ function collectEvidence(options, cwd) {
74
+ if (options.check) {
75
+ return verifyCommand(options.check, { cwd, expect: options.expect });
76
+ }
77
+ if (options.exists) {
78
+ return verifyPath(options.exists, { cwd, contains: options.contains });
79
+ }
80
+ return null;
81
+ }
82
+
67
83
  function taskClose(id, options, { cwd = process.cwd() } = {}) {
68
- if (!options.evidence || !options.evidence.trim()) {
84
+ const store = openStoreOrFail(cwd);
85
+ const evidence = collectEvidence(options, store.root);
86
+ if (!evidence) fail(EVIDENCE_USAGE);
87
+ if (!evidence.verified) {
88
+ const output = evidence.outputTail ? `\n${evidence.outputTail}` : '';
69
89
  fail(
70
- 'closing a task as done requires evidence that it was attended and resolved ' +
71
- '(test output, a diff, a verification note). Pass --evidence, or use ' +
72
- '`task abandon` if it will not be finished.',
90
+ `evidence did not verify (${evidence.failure}); the task stays open.${output}`,
73
91
  );
74
92
  }
75
- const store = openStoreOrFail(cwd);
93
+ const note = options.note ?? options.evidence ?? '';
76
94
  reportCloseResult(
77
95
  store.close(id, STATUS.DONE, {
78
96
  reason: options.reason ?? '',
79
- evidence: options.evidence,
97
+ evidence: note ? { ...evidence, note } : evidence,
80
98
  }),
81
99
  );
82
100
  }
@@ -117,10 +135,20 @@ export function registerTaskCommand(program) {
117
135
 
118
136
  task
119
137
  .command('close <id>')
120
- .description('Close a task as done. Requires --evidence.')
138
+ .description(
139
+ 'Close a task as done. Requires verified evidence: --check or --exists.',
140
+ )
141
+ .option(
142
+ '--check <command>',
143
+ 'command that must exit 0 (run at the project root)',
144
+ )
145
+ .option('--expect <text>', 'text the --check output must contain')
146
+ .option('--exists <path>', 'file or directory that must exist')
147
+ .option('--contains <text>', 'text the --exists file must contain')
148
+ .option('--note <text>', 'free-text context stored with the evidence')
121
149
  .option(
122
150
  '--evidence <text>',
123
- 'proof the task was attended and resolved (required)',
151
+ 'alias of --note (free text alone is not evidence)',
124
152
  )
125
153
  .option('--reason <text>', 'closing note')
126
154
  .action((id, options) => taskClose(id, options));
package/cli/toggle.mjs ADDED
@@ -0,0 +1,125 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readJsonOrNull } from '../plugins/gates/hooks/lib/config.mjs';
3
+ import { SCOPES, configPathFor, readConfig, writeConfig } from './config.mjs';
4
+ import { defaultParametersOf } from './materialize.mjs';
5
+ import { allGates } from './registry.mjs';
6
+ import { adoptionOf } from './selection.mjs';
7
+
8
+ const ALL_TARGET = 'all';
9
+
10
+ export function resolveTargets(registry, ids) {
11
+ const gates = allGates(registry);
12
+ const families = new Set(registry.families.map((family) => family.id));
13
+ const keys = new Set();
14
+ const unknown = [];
15
+ for (const id of ids) {
16
+ if (id === ALL_TARGET) {
17
+ for (const gate of gates) keys.add(gate.configKey);
18
+ continue;
19
+ }
20
+ if (families.has(id)) {
21
+ for (const gate of gates.filter((entry) => entry.family === id))
22
+ keys.add(gate.configKey);
23
+ continue;
24
+ }
25
+ const gate = gates.find(
26
+ (entry) => entry.id === id || entry.configKey === id,
27
+ );
28
+ if (gate) keys.add(gate.configKey);
29
+ else unknown.push(id);
30
+ }
31
+ if (unknown.length > 0)
32
+ throw new Error(
33
+ `Unknown gate, family or config key: ${unknown.join(', ')}. Run \`claude-gates registry --list\` to see the ids.`,
34
+ );
35
+ return [...keys];
36
+ }
37
+
38
+ export function enabledOf(entry, fallback) {
39
+ if (typeof entry === 'boolean') return entry;
40
+ if (entry && typeof entry === 'object')
41
+ return entry.enabled === undefined ? fallback : entry.enabled !== false;
42
+ return fallback;
43
+ }
44
+
45
+ function entryWithEnabled(gate, existing, enabled) {
46
+ if (existing && typeof existing === 'object') return { ...existing, enabled };
47
+ const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
48
+ if (!hasParameters) return enabled;
49
+ return { enabled, ...defaultParametersOf(gate) };
50
+ }
51
+
52
+ function adoptionFromGates(registry, gates) {
53
+ const map = {};
54
+ for (const gate of allGates(registry))
55
+ map[gate.configKey] = enabledOf(gates[gate.configKey], gate.default);
56
+ return adoptionOf(map);
57
+ }
58
+
59
+ export function toggleGates(path, registry, configKeys, enabled) {
60
+ const existing = readConfig(path);
61
+ if (existing.corrupt)
62
+ throw new Error(`${path} exists but is not valid JSON. Fix it first.`);
63
+ const gates = { ...(existing.data.gates ?? {}) };
64
+ const changed = [];
65
+ for (const gate of allGates(registry)) {
66
+ if (!configKeys.includes(gate.configKey)) continue;
67
+ const before = enabledOf(gates[gate.configKey], gate.default);
68
+ gates[gate.configKey] = entryWithEnabled(
69
+ gate,
70
+ gates[gate.configKey],
71
+ enabled,
72
+ );
73
+ if (before !== enabled) changed.push(gate.configKey);
74
+ }
75
+ const next = {
76
+ ...existing.data,
77
+ adopted: adoptionFromGates(registry, gates),
78
+ gateVersion: registry.gateVersion,
79
+ gates,
80
+ };
81
+ writeConfig(path, next);
82
+ return { path, changed, unchanged: configKeys.length - changed.length };
83
+ }
84
+
85
+ export function defaultScopeFor(cwd) {
86
+ if (existsSync(configPathFor(SCOPES.PROJECT, { cwd }))) return SCOPES.PROJECT;
87
+ if (existsSync(configPathFor(SCOPES.GLOBAL))) return SCOPES.GLOBAL;
88
+ return SCOPES.PROJECT;
89
+ }
90
+
91
+ function effectiveLayer(projectConfig, globalConfig) {
92
+ if (projectConfig)
93
+ return { name: SCOPES.PROJECT, gates: projectConfig.gates ?? {} };
94
+ if (globalConfig)
95
+ return { name: SCOPES.GLOBAL, gates: globalConfig.gates ?? {} };
96
+ return { name: 'default', gates: {} };
97
+ }
98
+
99
+ export function statusRows(registry, { cwd = process.cwd() } = {}) {
100
+ const projectPath = configPathFor(SCOPES.PROJECT, { cwd });
101
+ const globalPath = configPathFor(SCOPES.GLOBAL);
102
+ const projectConfig = readJsonOrNull(projectPath);
103
+ const globalConfig = readJsonOrNull(globalPath);
104
+ const layer = effectiveLayer(projectConfig, globalConfig);
105
+ return allGates(registry).map((gate) => {
106
+ const entry = layer.gates[gate.configKey];
107
+ const declared = entry !== undefined;
108
+ return {
109
+ id: gate.id,
110
+ configKey: gate.configKey,
111
+ family: gate.family,
112
+ enabled: enabledOf(entry, gate.default),
113
+ source: declared ? layer.name : 'default',
114
+ };
115
+ });
116
+ }
117
+
118
+ export function renderStatus(rows, layerLabel) {
119
+ const width = Math.max(...rows.map((row) => row.id.length));
120
+ const lines = rows.map(
121
+ (row) =>
122
+ `${row.enabled ? 'on ' : 'off'} ${row.id.padEnd(width)} ${row.configKey} (${row.source})`,
123
+ );
124
+ return `${layerLabel}\n${lines.join('\n')}`;
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -15,7 +15,7 @@
15
15
  // Fail-safe: every check is wrapped so a doctor bug never blocks a session from
16
16
  // starting — worst case it silently skips a check rather than throwing.
17
17
 
18
- import { existsSync, readFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
@@ -29,6 +29,14 @@ const HOOKS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
29
  const REPOSITORY_ROOT = join(HOOKS_DIRECTORY, '..', '..', '..');
30
30
  const PACKAGE_JSON_PATH = join(REPOSITORY_ROOT, 'package.json');
31
31
  const REGISTRY_PATH = join(REPOSITORY_ROOT, 'registry.json');
32
+ const PLUGIN_MANIFEST_PATH = join(
33
+ HOOKS_DIRECTORY,
34
+ '..',
35
+ '.claude-plugin',
36
+ 'plugin.json',
37
+ );
38
+ const PLUGIN_CACHE = join(homedir(), '.claude', 'plugins', 'cache');
39
+ const PLUGIN_NAME = 'gates';
32
40
 
33
41
  // Same config lookup as the other session hooks (see session-tasks.mjs / register-requests.mjs):
34
42
  // kept local and dependency-free so this plugin works standalone.
@@ -126,6 +134,41 @@ function minNodeVersionRequired(configuredMin) {
126
134
  return token ?? DEFAULT_MIN_NODE_VERSION;
127
135
  }
128
136
 
137
+ function directoriesIn(path) {
138
+ try {
139
+ return readdirSync(path, { withFileTypes: true })
140
+ .filter((entry) => entry.isDirectory())
141
+ .map((entry) => entry.name);
142
+ } catch {
143
+ return [];
144
+ }
145
+ }
146
+
147
+ /** A newer gates version sitting in Claude Code's plugin cache means this session runs a
148
+ * stale install (the marketplace still points at an old package path). */
149
+ function staleInstallProblem() {
150
+ const running = readJson(PLUGIN_MANIFEST_PATH)?.version;
151
+ if (!parseVersion(running)) return null;
152
+ let newest = running;
153
+ let marketplace = null;
154
+ for (const marketplaceName of directoriesIn(PLUGIN_CACHE)) {
155
+ for (const version of directoriesIn(
156
+ join(PLUGIN_CACHE, marketplaceName, PLUGIN_NAME),
157
+ )) {
158
+ if (parseVersion(version) && !versionAtLeast(newest, version)) {
159
+ newest = version;
160
+ marketplace = marketplaceName;
161
+ }
162
+ }
163
+ }
164
+ if (newest === running) return null;
165
+ return (
166
+ `the gates plugin running in this session is ${running} but ${newest} is installed in the ` +
167
+ `plugin cache (${marketplace}). Run \`claude plugin update ${PLUGIN_NAME}@${marketplace}\` or ` +
168
+ '`claude-gates init --global --defaults --yes` from the newer package, then restart the session.'
169
+ );
170
+ }
171
+
129
172
  function checkNodeVersion(minVersion) {
130
173
  if (versionAtLeast(process.version, minVersion)) return null;
131
174
  return (
@@ -184,6 +227,9 @@ function main() {
184
227
  const versionProblem = checkNodeVersion(minVersion);
185
228
  if (versionProblem) problems.push(versionProblem);
186
229
 
230
+ const staleInstall = staleInstallProblem();
231
+ if (staleInstall) problems.push(staleInstall);
232
+
187
233
  const missingScripts = missingFolderGateScripts();
188
234
  if (missingScripts.length > 0) {
189
235
  problems.push(