@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'diagnosis-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ env: { ...process.env, HOME: project, USERPROFILE: project },
23
+ });
24
+ return out.trim() ? JSON.parse(out.trim()) : null;
25
+ }
26
+
27
+ function isWarn(result) {
28
+ return result?.hookSpecificOutput?.additionalContext !== undefined;
29
+ }
30
+
31
+ // FIXED: extractText() now gates on toolInGroups(toolName, ['write']) and reads through
32
+ // writtenContentOf() (lib/hook-io.mjs), which covers NotebookEdit's new_source field.
33
+ test('FIXED: NotebookEdit changing a timeout value is detected', () => {
34
+ const payload = {
35
+ tool_name: 'NotebookEdit',
36
+ tool_input: {
37
+ notebook_path: '/repo/notebook.ipynb',
38
+ cell_id: 'abc',
39
+ new_source: 'REQUEST_TIMEOUT_MS = 30000',
40
+ cell_type: 'code',
41
+ },
42
+ };
43
+ assert.ok(isWarn(runGate(payload)), 'gate now detects a timeout change via NotebookEdit');
44
+ });
45
+
46
+ // FIXED: replace_file_content is recognized by toolInGroups(toolName, ['write']) (native
47
+ // name in TOOL_GROUPS.write) and its `content` field is read through writtenContentOf().
48
+ test('FIXED: replace_file_content changing a timeout value is detected', () => {
49
+ const payload = {
50
+ tool_name: 'replace_file_content',
51
+ tool_input: {
52
+ file_path: '/repo/src/config.js',
53
+ content: 'const REQUEST_TIMEOUT_MS = 30000;',
54
+ },
55
+ };
56
+ assert.ok(isWarn(runGate(payload)), 'gate now detects a timeout change via replace_file_content');
57
+ });
58
+
59
+ // EDGE CASE: confirm severity really is warn-only, never deny, for a real match (per WARN
60
+ // family classification). This should hold given index.mjs only calls warn(), never deny.
61
+ test('OK: matched content produces warn, never deny', () => {
62
+ const result = runGate({
63
+ tool_name: 'Write',
64
+ tool_input: { file_path: '/repo/src/config.js', content: 'const REQUEST_TIMEOUT_MS = 30000;' },
65
+ });
66
+ assert.ok(isWarn(result));
67
+ assert.notEqual(result?.hookSpecificOutput?.permissionDecision, 'deny');
68
+ });
@@ -1,49 +1,43 @@
1
- import { runGate, warn } from '../../lib/hook-io.mjs';
2
-
3
- const GATE_ID = 'diagnosis-before-patch';
4
- const CONFIG_KEY = 'warnTimeoutChangeWithoutDiagnosis';
5
-
6
- const DEFAULT_TIMEOUT_PATTERNS = [
7
- String.raw`\b[A-Z_]*TIMEOUT[A-Z_]*\s*[:=]\s*['"]?\d`,
8
- String.raw`\b[A-Z_]*DEADLINE[A-Z_]*\s*[:=]\s*['"]?\d`,
9
- String.raw`\b[A-Z_]*IDLE[A-Z_]*\s*[:=]\s*['"]?\d`,
10
- String.raw`\b(max_?retry|retries|backoff)\b\s*[:=]\s*['"]?\d`,
11
- String.raw`\b(query_?timeout|hard_?deadline)\b`,
12
- ];
13
-
14
- function extractText(toolName, toolInput) {
15
- if (toolName === 'Write') return String(toolInput?.content ?? '');
16
- if (toolName === 'Edit') return String(toolInput?.new_string ?? '');
17
- if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
18
- return toolInput.edits
19
- .map((edit) => String(edit?.new_string ?? ''))
20
- .join('\n');
21
- }
22
- return '';
23
- }
24
-
25
- runGate(
26
- {
27
- id: GATE_ID,
28
- configKey: CONFIG_KEY,
29
- enabledByDefault: true,
30
- defaultParams: {
31
- timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
32
- },
33
- },
34
- ({ toolName, toolInput, parameters }) => {
35
- const text = extractText(toolName, toolInput);
36
- if (!text) return;
37
-
38
- const patterns = parameters.timeoutPatterns.map(
39
- (source) => new RegExp(source, 'i'),
40
- );
41
- const touchesTimeout = patterns.some((pattern) => pattern.test(text));
42
- if (!touchesTimeout) return;
43
-
44
- warn(
45
- GATE_ID,
46
- 'Diagnosis before patch: you are adjusting a timeout/deadline/retry value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you read the evidence that proves the cause (a log line from the failing provider/process, not a hypothesis). A timeout should measure inactivity, not total time: a process that is progressing should not be cut off.',
47
- );
48
- },
49
- );
1
+ import { runGate, warn, toolInGroups, writtenContentOf } from '../../lib/hook-io.mjs';
2
+
3
+ const GATE_ID = 'diagnosis-before-patch';
4
+ const CONFIG_KEY = 'warnTimeoutChangeWithoutDiagnosis';
5
+
6
+ const DEFAULT_TIMEOUT_PATTERNS = [
7
+ String.raw`\b[A-Z_]*TIMEOUT[A-Z_]*\s*[:=]\s*['"]?\d`,
8
+ String.raw`\b[A-Z_]*DEADLINE[A-Z_]*\s*[:=]\s*['"]?\d`,
9
+ String.raw`\b[A-Z_]*IDLE[A-Z_]*\s*[:=]\s*['"]?\d`,
10
+ String.raw`\b(max_?retry|retries|backoff)\b\s*[:=]\s*['"]?\d`,
11
+ String.raw`\b(query_?timeout|hard_?deadline)\b`,
12
+ ];
13
+
14
+ function extractText(toolName, toolInput) {
15
+ if (!toolInGroups(toolName, ['write'])) return '';
16
+ return writtenContentOf(toolInput);
17
+ }
18
+
19
+ runGate(
20
+ {
21
+ id: GATE_ID,
22
+ configKey: CONFIG_KEY,
23
+ enabledByDefault: true,
24
+ defaultParams: {
25
+ timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
26
+ },
27
+ },
28
+ ({ toolName, toolInput, parameters }) => {
29
+ const text = extractText(toolName, toolInput);
30
+ if (!text) return;
31
+
32
+ const patterns = parameters.timeoutPatterns.map(
33
+ (source) => new RegExp(source, 'i'),
34
+ );
35
+ const touchesTimeout = patterns.some((pattern) => pattern.test(text));
36
+ if (!touchesTimeout) return;
37
+
38
+ warn(
39
+ GATE_ID,
40
+ 'Diagnosis before patch: you are adjusting a timeout/deadline/retry value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you read the evidence that proves the cause (a log line from the failing provider/process, not a hypothesis). A timeout should measure inactivity, not total time: a process that is progressing should not be cut off.',
41
+ );
42
+ },
43
+ );
@@ -0,0 +1,83 @@
1
+ // Edge-case probes for feature-catalog. Previously an Edit (new_string) writing
2
+ // status:done or an in_progress overrun to the catalog bypassed this gate because
3
+ // writeContentFrom() read only CodeContent/ReplacementContent/content, never
4
+ // new_string. Migrated to writtenContentOf()/writtenPathOf() (hook-io.mjs), which read
5
+ // through every known field shape, so these are now caught the same as a Write.
6
+ import assert from 'node:assert/strict';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { test } from 'node:test';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
15
+
16
+ function runGate(payload) {
17
+ const project = mkdtempSync(join(tmpdir(), 'feature-catalog-edge-'));
18
+ mkdirSync(join(project, '.git'));
19
+ const out = execFileSync(process.execPath, [GATE], {
20
+ input: JSON.stringify(payload),
21
+ encoding: 'utf8',
22
+ cwd: project,
23
+ env: { ...process.env, HOME: project, USERPROFILE: project },
24
+ });
25
+ return out.trim() ? JSON.parse(out.trim()) : null;
26
+ }
27
+
28
+ function isDeny(result) {
29
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
30
+ }
31
+
32
+ test('FIXED: an Edit (new_string) writing status:done to the catalog is now detected', () => {
33
+ const payload = {
34
+ tool_name: 'Edit',
35
+ tool_input: {
36
+ file_path: 'feature_list.json',
37
+ old_string: 'x',
38
+ new_string: JSON.stringify({
39
+ features: [{ name: 'checkout', status: 'done' }],
40
+ }),
41
+ },
42
+ };
43
+ assert.ok(
44
+ isDeny(runGate(payload)),
45
+ 'writtenContentOf reads new_string and catches status:done via Edit',
46
+ );
47
+ });
48
+
49
+ test('FIXED: an Edit (new_string) exceeding maxInProgress is now detected', () => {
50
+ const payload = {
51
+ tool_name: 'Edit',
52
+ tool_input: {
53
+ file_path: 'feature_list.json',
54
+ old_string: 'x',
55
+ new_string: JSON.stringify({
56
+ features: [
57
+ { name: 'a', status: 'in_progress' },
58
+ { name: 'b', status: 'in_progress' },
59
+ ],
60
+ }),
61
+ },
62
+ };
63
+ assert.ok(
64
+ isDeny(runGate(payload)),
65
+ 'writtenContentOf reads new_string and catches the maxInProgress overrun via Edit',
66
+ );
67
+ });
68
+
69
+ test('OK: the equivalent Write-tool payload IS caught (control)', () => {
70
+ const payload = {
71
+ tool_name: 'Write',
72
+ tool_input: {
73
+ file_path: 'feature_list.json',
74
+ content: JSON.stringify({
75
+ features: [{ name: 'checkout', status: 'done' }],
76
+ }),
77
+ },
78
+ };
79
+ assert.ok(
80
+ isDeny(runGate(payload)),
81
+ 'control failed: Write-tool status:done should be denied',
82
+ );
83
+ });
@@ -1,100 +1,83 @@
1
- // feature-catalog — enforces the machine-readable feature catalog's own invariants on
2
- // a write to that file: at most one feature `in_progress` at a time, and `done` is
3
- // never written directly (only a review/QA process closes a feature). Migrated from
4
- // ~/.claude/hooks/guard-feature-catalog.mjs.
5
- //
6
- // ── What a project can configure (params) ───────────────────────────────────────────
7
- // catalogFileName basename of the catalog file this gate watches for (default
8
- // feature_list.json). A write to any other file is ignored.
9
- // maxInProgress how many features may be `in_progress` simultaneously.
10
- // The defaults live here, in the source, so a project reads them and knows exactly what
11
- // its override replaces.
12
- //
13
- // ── Auto-off when the project never adopted the catalog ────────────────────────────
14
- // This gate only inspects the CONTENT being written to a file named `catalogFileName`.
15
- // A project that never uses that file never triggers it — there is nothing to disable
16
- // separately, the check is inert by construction rather than by a discovery pass.
17
- //
18
- // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
19
- // Writing `status: done` directly is always denied, regardless of `maxInProgress`: only
20
- // a review/QA subagent or a validated automated process may close a feature, and this
21
- // gate has no way to tell who is writing, so it blocks the write itself.
22
-
23
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
24
-
25
- const GATE_ID = 'feature-catalog';
26
- const CONFIG_KEY = 'requireFeatureCatalog';
27
-
28
- const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
29
-
30
- const DEFAULT_CATALOG_FILE_NAME = 'feature_list.json';
31
- const DEFAULT_MAX_IN_PROGRESS = 1;
32
-
33
- const DONE_STATUS_PATTERN = /"status"\s*:\s*"done"|status\s*:\s*['"]done['"]/i;
34
- const IN_PROGRESS_STATUS_PATTERN = /"status"\s*:\s*"in_progress"/g;
35
-
36
- /** The path a write tool targets, across the field names different tools use. */
37
- function writeTargetFrom(toolInput) {
38
- return String(
39
- toolInput.TargetFile ??
40
- toolInput.target_file ??
41
- toolInput.file_path ??
42
- toolInput.path ??
43
- '',
44
- );
45
- }
46
-
47
- /** The content a write tool is about to write, across the field names tools use. */
48
- function writeContentFrom(toolInput) {
49
- return String(
50
- toolInput.CodeContent ??
51
- toolInput.ReplacementContent ??
52
- toolInput.content ??
53
- '',
54
- );
55
- }
56
-
57
- runGate(
58
- {
59
- id: GATE_ID,
60
- configKey: CONFIG_KEY,
61
- enabledByDefault: true,
62
- defaultParams: {
63
- catalogFileName: DEFAULT_CATALOG_FILE_NAME,
64
- maxInProgress: DEFAULT_MAX_IN_PROGRESS,
65
- },
66
- },
67
- ({ toolName, toolInput, parameters }) => {
68
- if (!WRITE_TOOLS.has(toolName)) return;
69
-
70
- const target = writeTargetFrom(toolInput);
71
- const catalogFileName = String(
72
- parameters.catalogFileName ?? DEFAULT_CATALOG_FILE_NAME,
73
- );
74
- if (!target.includes(catalogFileName)) return;
75
-
76
- const content = writeContentFrom(toolInput);
77
-
78
- // Base, non-negotiable: `done` is never written directly to the catalog.
79
- if (DONE_STATUS_PATTERN.test(content)) {
80
- deny(
81
- GATE_ID,
82
- `Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
83
- 'Only a review/QA subagent or a validated automated process may close a feature.',
84
- );
85
- }
86
-
87
- const maxInProgress = Number(
88
- parameters.maxInProgress ?? DEFAULT_MAX_IN_PROGRESS,
89
- );
90
- const inProgressCount = (content.match(IN_PROGRESS_STATUS_PATTERN) ?? [])
91
- .length;
92
- if (inProgressCount > maxInProgress) {
93
- deny(
94
- GATE_ID,
95
- `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
96
- `the maximum allowed is ${maxInProgress}.`,
97
- );
98
- }
99
- },
100
- );
1
+ // feature-catalog — enforces the machine-readable feature catalog's own invariants on
2
+ // a write to that file: at most one feature `in_progress` at a time, and `done` is
3
+ // never written directly (only a review/QA process closes a feature). Migrated from
4
+ // ~/.claude/hooks/guard-feature-catalog.mjs.
5
+ //
6
+ // ── What a project can configure (params) ───────────────────────────────────────────
7
+ // catalogFileName basename of the catalog file this gate watches for (default
8
+ // feature_list.json). A write to any other file is ignored.
9
+ // maxInProgress how many features may be `in_progress` simultaneously.
10
+ // The defaults live here, in the source, so a project reads them and knows exactly what
11
+ // its override replaces.
12
+ //
13
+ // ── Auto-off when the project never adopted the catalog ────────────────────────────
14
+ // This gate only inspects the CONTENT being written to a file named `catalogFileName`.
15
+ // A project that never uses that file never triggers it — there is nothing to disable
16
+ // separately, the check is inert by construction rather than by a discovery pass.
17
+ //
18
+ // ── What is NOT configurable (base, non-negotiable) ─────────────────────────────────
19
+ // Writing `status: done` directly is always denied, regardless of `maxInProgress`: only
20
+ // a review/QA subagent or a validated automated process may close a feature, and this
21
+ // gate has no way to tell who is writing, so it blocks the write itself.
22
+
23
+ import {
24
+ runGate,
25
+ deny,
26
+ toolInGroups,
27
+ writtenContentOf,
28
+ writtenPathOf,
29
+ } from '../../lib/hook-io.mjs';
30
+
31
+ const GATE_ID = 'feature-catalog';
32
+ const CONFIG_KEY = 'requireFeatureCatalog';
33
+
34
+ const DEFAULT_CATALOG_FILE_NAME = 'feature_list.json';
35
+ const DEFAULT_MAX_IN_PROGRESS = 1;
36
+
37
+ const DONE_STATUS_PATTERN = /"status"\s*:\s*"done"|status\s*:\s*['"]done['"]/i;
38
+ const IN_PROGRESS_STATUS_PATTERN = /"status"\s*:\s*"in_progress"/g;
39
+
40
+ runGate(
41
+ {
42
+ id: GATE_ID,
43
+ configKey: CONFIG_KEY,
44
+ enabledByDefault: true,
45
+ defaultParams: {
46
+ catalogFileName: DEFAULT_CATALOG_FILE_NAME,
47
+ maxInProgress: DEFAULT_MAX_IN_PROGRESS,
48
+ },
49
+ },
50
+ ({ toolName, toolInput, parameters }) => {
51
+ if (!toolInGroups(toolName, ['write'])) return;
52
+
53
+ const target = writtenPathOf(toolInput);
54
+ const catalogFileName = String(
55
+ parameters.catalogFileName ?? DEFAULT_CATALOG_FILE_NAME,
56
+ );
57
+ if (!target.includes(catalogFileName)) return;
58
+
59
+ const content = writtenContentOf(toolInput);
60
+
61
+ // Base, non-negotiable: `done` is never written directly to the catalog.
62
+ if (DONE_STATUS_PATTERN.test(content)) {
63
+ deny(
64
+ GATE_ID,
65
+ `Writing 'status: done' directly to ${catalogFileName} is not allowed. ` +
66
+ 'Only a review/QA subagent or a validated automated process may close a feature.',
67
+ );
68
+ }
69
+
70
+ const maxInProgress = Number(
71
+ parameters.maxInProgress ?? DEFAULT_MAX_IN_PROGRESS,
72
+ );
73
+ const inProgressCount = (content.match(IN_PROGRESS_STATUS_PATTERN) ?? [])
74
+ .length;
75
+ if (inProgressCount > maxInProgress) {
76
+ deny(
77
+ GATE_ID,
78
+ `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
79
+ `the maximum allowed is ${maxInProgress}.`,
80
+ );
81
+ }
82
+ },
83
+ );
@@ -0,0 +1,119 @@
1
+ // force-parallel — nudges toward parallelizing independent delegations. WARN-only: it
2
+ // never denies, because a PreToolUse hook sees one tool call at a time and has no way to
3
+ // know whether the delegations it observed COULD have been sent together — only that they
4
+ // arrived one after another.
5
+ //
6
+ // justification: no existing tool covers this. brief-before-delegate/intent-flow/risk-level
7
+ // gate the CONTENT of a single delegation prompt; none of them look across delegations in
8
+ // the same session to notice a sequential pattern.
9
+ //
10
+ // ── Honest limitation (read before trusting this gate) ──────────────────────────────
11
+ // A PreToolUse hook fires once per tool call, synchronously, with no visibility into what
12
+ // the model is "thinking" or whether independent work existed to batch. This gate can only
13
+ // count consecutive delegation calls that land close together in wall-clock time and warn
14
+ // after a threshold — it cannot prove they were independent, and it cannot force the model
15
+ // to have sent them in one message (Claude Code's own turn structure decides that, not a
16
+ // hook). Treat the warning as a nudge for the NEXT delegation, never as proof of a missed
17
+ // opportunity on the ones already sent.
18
+ //
19
+ // ── What a project can configure (params) ───────────────────────────────────────────
20
+ // sequentialThreshold consecutive delegations (within the window) before warning.
21
+ // sequentialWindowMs how close in time two delegations must land to count as the
22
+ // same sequential run; a gap resets the count.
23
+ // sequentialJustifiedMarker a marker token in the delegation prompt that escapes the
24
+ // warning — a declared reason not to parallelize is a decision.
25
+ // The defaults live here, in the source, so a project reads them and knows exactly what
26
+ // its override replaces.
27
+ //
28
+ // ── State ─────────────────────────────────────────────────────────────────────────────
29
+ // Per-session count + last-delegation timestamp, persisted at
30
+ // os.tmpdir()/claude-gates/force-parallel/<sessionId>/state.json — process-local state
31
+ // would not survive across the separate process each hook invocation spawns.
32
+
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ import { runGate, warn, toolInGroups, delegationPromptOf } from '../../lib/hook-io.mjs';
37
+
38
+ const GATE_ID = 'force-parallel';
39
+ const CONFIG_KEY = 'warnSequentialDelegations';
40
+
41
+ const DELEGATION_GROUPS = ['delegation'];
42
+ const DEFAULT_SEQUENTIAL_THRESHOLD = 3;
43
+ const DEFAULT_SEQUENTIAL_WINDOW_MS = 120000;
44
+ const DEFAULT_JUSTIFIED_MARKER = 'SEQUENTIAL-JUSTIFIED';
45
+
46
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'force-parallel');
47
+ const STATE_FILE = 'state.json';
48
+ const UNKNOWN_SESSION = 'unknown-session';
49
+
50
+ function statePathFor(sessionId) {
51
+ const safeSessionId = String(sessionId || UNKNOWN_SESSION).replace(/[^\w-]/g, '_');
52
+ return join(STATE_ROOT, safeSessionId, STATE_FILE);
53
+ }
54
+
55
+ function readState(path) {
56
+ if (!existsSync(path)) return { count: 0, lastAt: 0 };
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
59
+ return {
60
+ count: Number(parsed.count) || 0,
61
+ lastAt: Number(parsed.lastAt) || 0,
62
+ };
63
+ } catch {
64
+ return { count: 0, lastAt: 0 };
65
+ }
66
+ }
67
+
68
+ function writeState(path, state) {
69
+ mkdirSync(join(path, '..'), { recursive: true });
70
+ writeFileSync(path, JSON.stringify(state), 'utf8');
71
+ }
72
+
73
+ const WARN_MESSAGE =
74
+ 'This is the {count}th delegation sent one-by-one within {windowSeconds}s. If the ' +
75
+ 'remaining work is independent, launch the next batch together in a single message ' +
76
+ '(multiple tool calls) instead of one delegation per turn. If this delegation ' +
77
+ 'genuinely depends on a prior result, ignore this and mark the prompt with ' +
78
+ '"{marker}" to skip the warning next time.';
79
+
80
+ runGate(
81
+ {
82
+ id: GATE_ID,
83
+ configKey: CONFIG_KEY,
84
+ enabledByDefault: false,
85
+ defaultParams: {
86
+ sequentialThreshold: DEFAULT_SEQUENTIAL_THRESHOLD,
87
+ sequentialWindowMs: DEFAULT_SEQUENTIAL_WINDOW_MS,
88
+ sequentialJustifiedMarker: DEFAULT_JUSTIFIED_MARKER,
89
+ },
90
+ },
91
+ ({ toolName, toolInput, sessionId, parameters }) => {
92
+ if (!toolInGroups(toolName, DELEGATION_GROUPS)) return;
93
+
94
+ const marker = parameters.sequentialJustifiedMarker ?? DEFAULT_JUSTIFIED_MARKER;
95
+ const prompt = delegationPromptOf(toolInput);
96
+ if (prompt.includes(marker)) return; // declared reason not to parallelize: no warning
97
+
98
+ const threshold = parameters.sequentialThreshold ?? DEFAULT_SEQUENTIAL_THRESHOLD;
99
+ const windowMs = parameters.sequentialWindowMs ?? DEFAULT_SEQUENTIAL_WINDOW_MS;
100
+
101
+ const statePath = statePathFor(sessionId);
102
+ const state = readState(statePath);
103
+ const now = Date.now();
104
+
105
+ const withinWindow = now - state.lastAt <= windowMs;
106
+ const nextCount = withinWindow ? state.count + 1 : 1;
107
+
108
+ writeState(statePath, { count: nextCount, lastAt: now });
109
+
110
+ if (nextCount < threshold) return;
111
+
112
+ warn(
113
+ GATE_ID,
114
+ WARN_MESSAGE.replace('{count}', String(nextCount))
115
+ .replace('{windowSeconds}', String(Math.round(windowMs / 1000)))
116
+ .replace('{marker}', marker),
117
+ );
118
+ },
119
+ );
@@ -0,0 +1,88 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { test } from 'node:test';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
11
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'force-parallel');
12
+
13
+ function delegationPayload(sessionId, prompt = 'do work') {
14
+ return {
15
+ tool_name: 'Task',
16
+ session_id: sessionId,
17
+ tool_input: { prompt },
18
+ };
19
+ }
20
+
21
+ function isWarn(result) {
22
+ return typeof result?.hookSpecificOutput?.additionalContext === 'string';
23
+ }
24
+
25
+ function runGate(project, payload) {
26
+ mkdirSync(join(project, '.git'), { recursive: true });
27
+ mkdirSync(join(project, '.ai'), { recursive: true });
28
+ writeFileSync(
29
+ join(project, '.ai', 'config.json'),
30
+ JSON.stringify({ gates: { warnSequentialDelegations: { enabled: true } } }),
31
+ );
32
+ const out = execFileSync(process.execPath, [GATE], {
33
+ input: JSON.stringify(payload),
34
+ encoding: 'utf8',
35
+ cwd: project,
36
+ env: { ...process.env, HOME: project, USERPROFILE: project },
37
+ });
38
+ return out.trim() ? JSON.parse(out.trim()) : null;
39
+ }
40
+
41
+ test('3 sequential delegations in the same session: the 3rd warns', () => {
42
+ const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
43
+ const sessionId = `test-${randomUUID()}`;
44
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
45
+
46
+ const first = runGate(project, delegationPayload(sessionId));
47
+ const second = runGate(project, delegationPayload(sessionId));
48
+ const third = runGate(project, delegationPayload(sessionId));
49
+
50
+ assert.equal(first, null, 'first delegation should not warn');
51
+ assert.equal(second, null, 'second delegation should not warn');
52
+ assert.ok(isWarn(third), 'third consecutive delegation should warn');
53
+ assert.match(third.hookSpecificOutput.additionalContext, /force-parallel/);
54
+
55
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
56
+ });
57
+
58
+ test('a marked SEQUENTIAL-JUSTIFIED prompt never warns, even at count 3', () => {
59
+ const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
60
+ const sessionId = `test-${randomUUID()}`;
61
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
62
+
63
+ runGate(project, delegationPayload(sessionId));
64
+ runGate(project, delegationPayload(sessionId));
65
+ const third = runGate(
66
+ project,
67
+ delegationPayload(sessionId, 'needs prior result SEQUENTIAL-JUSTIFIED'),
68
+ );
69
+
70
+ assert.equal(third, null);
71
+
72
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
73
+ });
74
+
75
+ test('a non-delegation tool is never warned', () => {
76
+ const project = mkdtempSync(join(tmpdir(), 'force-parallel-'));
77
+ const sessionId = `test-${randomUUID()}`;
78
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
79
+
80
+ const result = runGate(project, {
81
+ tool_name: 'Bash',
82
+ session_id: sessionId,
83
+ tool_input: { command: 'ls' },
84
+ });
85
+ assert.equal(result, null);
86
+
87
+ rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
88
+ });