@devrik-tools/claude-gates 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -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 +65 -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-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -1
@@ -1,43 +1,48 @@
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
- );
1
+ import {
2
+ runGate,
3
+ warn,
4
+ toolInGroups,
5
+ writtenContentOf,
6
+ } from '../../lib/hook-io.mjs';
7
+
8
+ const GATE_ID = 'diagnosis-before-patch';
9
+ const CONFIG_KEY = 'warnTimeoutChangeWithoutDiagnosis';
10
+
11
+ const DEFAULT_TIMEOUT_PATTERNS = [
12
+ String.raw`\b[A-Z_]*TIMEOUT[A-Z_]*\s*[:=]\s*['"]?\d`,
13
+ String.raw`\b[A-Z_]*DEADLINE[A-Z_]*\s*[:=]\s*['"]?\d`,
14
+ String.raw`\b[A-Z_]*IDLE[A-Z_]*\s*[:=]\s*['"]?\d`,
15
+ String.raw`\b(max_?retry|retries|backoff)\b\s*[:=]\s*['"]?\d`,
16
+ String.raw`\b(query_?timeout|hard_?deadline)\b`,
17
+ ];
18
+
19
+ function extractText(toolName, toolInput) {
20
+ if (!toolInGroups(toolName, ['write'])) return '';
21
+ return writtenContentOf(toolInput);
22
+ }
23
+
24
+ runGate(
25
+ {
26
+ id: GATE_ID,
27
+ configKey: CONFIG_KEY,
28
+ enabledByDefault: true,
29
+ defaultParams: {
30
+ timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
31
+ },
32
+ },
33
+ ({ toolName, toolInput, parameters }) => {
34
+ const text = extractText(toolName, toolInput);
35
+ if (!text) return;
36
+
37
+ const patterns = parameters.timeoutPatterns.map(
38
+ (source) => new RegExp(source, 'i'),
39
+ );
40
+ const touchesTimeout = patterns.some((pattern) => pattern.test(text));
41
+ if (!touchesTimeout) return;
42
+
43
+ warn(
44
+ GATE_ID,
45
+ '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.',
46
+ );
47
+ },
48
+ );
@@ -1,83 +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 {
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
- );
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
+ );
@@ -1,119 +1,134 @@
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
- );
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 {
37
+ runGate,
38
+ warn,
39
+ toolInGroups,
40
+ delegationPromptOf,
41
+ } from '../../lib/hook-io.mjs';
42
+
43
+ const GATE_ID = 'force-parallel';
44
+ const CONFIG_KEY = 'warnSequentialDelegations';
45
+
46
+ const DELEGATION_GROUPS = ['delegation'];
47
+ const DEFAULT_SEQUENTIAL_THRESHOLD = 3;
48
+ const DEFAULT_SEQUENTIAL_WINDOW_MS = 120000;
49
+ const DEFAULT_JUSTIFIED_MARKER = 'SEQUENTIAL-JUSTIFIED';
50
+ const MS_PER_SECOND = 1000;
51
+
52
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'force-parallel');
53
+ const STATE_FILE = 'state.json';
54
+ const UNKNOWN_SESSION = 'unknown-session';
55
+
56
+ function statePathFor(sessionId) {
57
+ const safeSessionId = String(sessionId || UNKNOWN_SESSION).replace(
58
+ /[^\w-]/g,
59
+ '_',
60
+ );
61
+ return join(STATE_ROOT, safeSessionId, STATE_FILE);
62
+ }
63
+
64
+ function readState(path) {
65
+ if (!existsSync(path)) return { count: 0, lastAt: 0 };
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
68
+ return {
69
+ count: Number(parsed.count) || 0,
70
+ lastAt: Number(parsed.lastAt) || 0,
71
+ };
72
+ } catch {
73
+ return { count: 0, lastAt: 0 };
74
+ }
75
+ }
76
+
77
+ function writeState(path, state) {
78
+ mkdirSync(join(path, '..'), { recursive: true });
79
+ writeFileSync(path, JSON.stringify(state), 'utf8');
80
+ }
81
+
82
+ const WARN_MESSAGE =
83
+ 'This is the {count}th delegation sent one-by-one within {windowSeconds}s. If the ' +
84
+ 'remaining work is independent, launch the next batch together in a single message ' +
85
+ '(multiple tool calls) instead of one delegation per turn. If this delegation ' +
86
+ 'genuinely depends on a prior result, ignore this and mark the prompt with ' +
87
+ '"{marker}" to skip the warning next time.';
88
+
89
+ runGate(
90
+ {
91
+ id: GATE_ID,
92
+ configKey: CONFIG_KEY,
93
+ enabledByDefault: false,
94
+ defaultParams: {
95
+ sequentialThreshold: DEFAULT_SEQUENTIAL_THRESHOLD,
96
+ sequentialWindowMs: DEFAULT_SEQUENTIAL_WINDOW_MS,
97
+ sequentialJustifiedMarker: DEFAULT_JUSTIFIED_MARKER,
98
+ },
99
+ },
100
+ ({ toolName, toolInput, sessionId, parameters }) => {
101
+ if (!toolInGroups(toolName, DELEGATION_GROUPS)) return;
102
+
103
+ const marker =
104
+ parameters.sequentialJustifiedMarker ?? DEFAULT_JUSTIFIED_MARKER;
105
+ const prompt = delegationPromptOf(toolInput);
106
+ if (prompt.includes(marker)) return; // declared reason not to parallelize: no warning
107
+
108
+ const threshold =
109
+ parameters.sequentialThreshold ?? DEFAULT_SEQUENTIAL_THRESHOLD;
110
+ const windowMs =
111
+ parameters.sequentialWindowMs ?? DEFAULT_SEQUENTIAL_WINDOW_MS;
112
+
113
+ const statePath = statePathFor(sessionId);
114
+ const state = readState(statePath);
115
+ const now = Date.now();
116
+
117
+ const withinWindow = now - state.lastAt <= windowMs;
118
+ const nextCount = withinWindow ? state.count + 1 : 1;
119
+
120
+ writeState(statePath, { count: nextCount, lastAt: now });
121
+
122
+ if (nextCount < threshold) return;
123
+
124
+ warn(
125
+ GATE_ID,
126
+ WARN_MESSAGE.replace('{count}', String(nextCount))
127
+ .replace(
128
+ '{windowSeconds}',
129
+ String(Math.round(windowMs / MS_PER_SECOND)),
130
+ )
131
+ .replace('{marker}', marker),
132
+ );
133
+ },
134
+ );