@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
@@ -1,28 +1,22 @@
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.
1
+ // feature-catalog — enforces the feature catalog's own invariants on a change to that
2
+ // file: at most `maxInProgress` features in_progress, and no feature moved to `done` by a
3
+ // direct write (only a review/QA process closes a feature).
5
4
  //
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.
5
+ // Decisions: the gate judges the CHANGE, not the content — the existing catalog is read
6
+ // from disk and a feature that was already `done` stays writable; only a NEW transition to
7
+ // done is denied. An Edit is applied to the disk content when its old_string is found, so
8
+ // the in_progress count covers the whole file, not the fragment. A shell redirect/copy
9
+ // onto the catalog is denied outright: its resulting content cannot be judged. An empty
10
+ // `catalogFileName` turns the gate off.
22
11
 
12
+ import { readFileSync } from 'node:fs';
13
+ import { isAbsolute, join } from 'node:path';
14
+ import { projectRootOf, stripBom } from '../../lib/config.mjs';
23
15
  import {
24
16
  runGate,
25
17
  deny,
18
+ shellCommandOf,
19
+ shellWrittenPaths,
26
20
  toolInGroups,
27
21
  writtenContentOf,
28
22
  writtenPathOf,
@@ -34,8 +28,131 @@ const CONFIG_KEY = 'requireFeatureCatalog';
34
28
  const DEFAULT_CATALOG_FILE_NAME = 'feature_list.json';
35
29
  const DEFAULT_MAX_IN_PROGRESS = 1;
36
30
 
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;
31
+ const DONE_STATUS = 'done';
32
+ const IN_PROGRESS_STATUS = 'in_progress';
33
+ const STATUS_PATTERN = /["']?status["']?\s*:\s*["']?(done|in_progress)["']?/gi;
34
+
35
+ function parseJsonOrNull(text) {
36
+ try {
37
+ return JSON.parse(text);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function featuresOf(parsed) {
44
+ if (Array.isArray(parsed)) return parsed;
45
+ if (parsed && Array.isArray(parsed.features)) return parsed.features;
46
+ return null;
47
+ }
48
+
49
+ function countStatuses(text) {
50
+ const counts = { done: 0, inProgress: 0 };
51
+ for (const match of String(text).matchAll(STATUS_PATTERN)) {
52
+ if (match[1].toLowerCase() === DONE_STATUS) counts.done += 1;
53
+ else counts.inProgress += 1;
54
+ }
55
+ return counts;
56
+ }
57
+
58
+ function snapshotOf(text) {
59
+ const features = featuresOf(parseJsonOrNull(text));
60
+ if (!features) {
61
+ const counts = countStatuses(text);
62
+ return {
63
+ parsed: false,
64
+ done: null,
65
+ doneCount: counts.done,
66
+ inProgress: counts.inProgress,
67
+ };
68
+ }
69
+ const done = new Set();
70
+ let inProgress = 0;
71
+ features.forEach((feature, index) => {
72
+ const status = String(feature?.status ?? '').toLowerCase();
73
+ if (status === DONE_STATUS)
74
+ done.add(String(feature?.name ?? feature?.id ?? `#${index}`));
75
+ if (status === IN_PROGRESS_STATUS) inProgress += 1;
76
+ });
77
+ return { parsed: true, done, doneCount: done.size, inProgress };
78
+ }
79
+
80
+ function newlyDone(before, after) {
81
+ if (before.parsed && after.parsed)
82
+ return [...after.done].filter((name) => !before.done.has(name));
83
+ return after.doneCount > before.doneCount ? ['(unnamed feature)'] : [];
84
+ }
85
+
86
+ function diskContentOf(path) {
87
+ try {
88
+ return stripBom(readFileSync(path, 'utf8'));
89
+ } catch {
90
+ return '';
91
+ }
92
+ }
93
+
94
+ function applyEdits(diskContent, toolInput) {
95
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [toolInput];
96
+ let content = diskContent;
97
+ for (const edit of edits) {
98
+ const oldString = String(edit?.old_string ?? '');
99
+ const newString = String(edit?.new_string ?? '');
100
+ if (!oldString || !content.includes(oldString)) return null;
101
+ content = edit?.replace_all
102
+ ? content.replaceAll(oldString, newString)
103
+ : content.replace(oldString, newString);
104
+ }
105
+ return content;
106
+ }
107
+
108
+ function isEdit(toolInput) {
109
+ return (
110
+ Array.isArray(toolInput.edits) ||
111
+ typeof toolInput.old_string === 'string' ||
112
+ typeof toolInput.new_string === 'string'
113
+ );
114
+ }
115
+
116
+ function beforeAndAfter(toolInput, catalogPath) {
117
+ const diskContent = diskContentOf(catalogPath);
118
+ if (!isEdit(toolInput))
119
+ return { before: diskContent, after: writtenContentOf(toolInput) };
120
+ const applied = applyEdits(diskContent, toolInput);
121
+ if (applied !== null) return { before: diskContent, after: applied };
122
+ // The fragment cannot be placed in the file: judge it on its own, against nothing.
123
+ return { before: '', after: writtenContentOf(toolInput) };
124
+ }
125
+
126
+ function judgeChange(before, after, catalogFileName, maxInProgress) {
127
+ const beforeSnapshot = snapshotOf(before);
128
+ const afterSnapshot = snapshotOf(after);
129
+
130
+ const closed = newlyDone(beforeSnapshot, afterSnapshot);
131
+ if (closed.length > 0) {
132
+ deny(
133
+ CONFIG_KEY,
134
+ `This write moves ${closed.join(', ')} to 'status: done' in ${catalogFileName}. A feature is ` +
135
+ 'never closed by a direct write: only a review/QA subagent or a validated automated process ' +
136
+ 'may set done. Leave the previous status and let the review step close it.',
137
+ );
138
+ }
139
+
140
+ if (afterSnapshot.inProgress > maxInProgress) {
141
+ deny(
142
+ CONFIG_KEY,
143
+ `${catalogFileName} would have ${afterSnapshot.inProgress} features 'in_progress'; ` +
144
+ `the maximum allowed is ${maxInProgress} (maxInProgress). Finish or park one before starting another.`,
145
+ );
146
+ }
147
+ }
148
+
149
+ function denyShellWrite(catalogFileName, path) {
150
+ deny(
151
+ CONFIG_KEY,
152
+ `This shell command writes to the feature catalog (${path}) through a redirect/copy/move. ` +
153
+ `Edit ${catalogFileName} with the Write/Edit tool instead, so the status transitions can be checked.`,
154
+ );
155
+ }
39
156
 
40
157
  runGate(
41
158
  {
@@ -47,37 +164,26 @@ runGate(
47
164
  maxInProgress: DEFAULT_MAX_IN_PROGRESS,
48
165
  },
49
166
  },
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;
167
+ ({ toolName, toolInput, parameters, cwd }) => {
168
+ const catalogFileName = String(parameters.catalogFileName ?? '').trim();
169
+ if (!catalogFileName) return;
170
+ const targetsCatalog = (path) => String(path).includes(catalogFileName);
58
171
 
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
- CONFIG_KEY,
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.',
172
+ if (toolInGroups(toolName, ['shell'])) {
173
+ const written = shellWrittenPaths(shellCommandOf(toolInput)).find(
174
+ targetsCatalog,
67
175
  );
176
+ if (written) denyShellWrite(catalogFileName, written);
177
+ return;
68
178
  }
69
179
 
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
- CONFIG_KEY,
78
- `${catalogFileName} would have ${inProgressCount} features 'in_progress'; ` +
79
- `the maximum allowed is ${maxInProgress}.`,
80
- );
81
- }
180
+ if (!toolInGroups(toolName, ['write'])) return;
181
+ const target = writtenPathOf(toolInput);
182
+ if (!targetsCatalog(target)) return;
183
+
184
+ const root = projectRootOf(cwd) ?? cwd;
185
+ const catalogPath = isAbsolute(target) ? target : join(root, target);
186
+ const { before, after } = beforeAndAfter(toolInput, catalogPath);
187
+ judgeChange(before, after, catalogFileName, parameters.maxInProgress);
82
188
  },
83
189
  );
@@ -1,134 +1,93 @@
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.
1
+ // force-parallel — warns when delegations keep arriving one turn at a time instead of as a
2
+ // batch. Advisory only: a PreToolUse hook sees one call at a time and cannot prove the
3
+ // delegations were independent, so it nudges the NEXT delegation and never denies.
5
4
  //
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.
5
+ // Decisions: delegations landing within BATCH_GAP_MS of each other are ONE batch (a parallel
6
+ // launch in a single message) and do not raise the sequential count; only a gap between the
7
+ // batch threshold and `sequentialWindowMs` counts as sequential, and a longer gap resets.
8
+ // State lives in the shared session store (sanitized session segment, project-keyed bucket
9
+ // when the payload has no session id, atomic writes, TTL pruning).
32
10
 
33
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
- import { tmpdir } from 'node:os';
35
- import { join } from 'node:path';
36
11
  import {
37
12
  runGate,
38
13
  warn,
39
14
  toolInGroups,
40
15
  delegationPromptOf,
41
16
  } from '../../lib/hook-io.mjs';
17
+ import {
18
+ readSessionState,
19
+ writeSessionState,
20
+ } from '../../lib/session-state.mjs';
42
21
 
43
22
  const GATE_ID = 'force-parallel';
44
23
  const CONFIG_KEY = 'warnSequentialDelegations';
45
24
 
46
- const DELEGATION_GROUPS = ['delegation'];
47
25
  const DEFAULT_SEQUENTIAL_THRESHOLD = 3;
48
26
  const DEFAULT_SEQUENTIAL_WINDOW_MS = 120000;
49
27
  const DEFAULT_JUSTIFIED_MARKER = 'SEQUENTIAL-JUSTIFIED';
50
28
  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);
29
+ const BATCH_GAP_MS = 2000;
30
+
31
+ const ORDINAL_SUFFIXES = { 1: 'st', 2: 'nd', 3: 'rd' };
32
+ const TEENS_FROM = 11;
33
+ const TEENS_TO = 13;
34
+ const HUNDRED = 100;
35
+ const TEN = 10;
36
+
37
+ function ordinal(number) {
38
+ const lastTwo = number % HUNDRED;
39
+ if (lastTwo >= TEENS_FROM && lastTwo <= TEENS_TO) return `${number}th`;
40
+ return `${number}${ORDINAL_SUFFIXES[number % TEN] ?? 'th'}`;
62
41
  }
63
42
 
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
- }
43
+ function nextCount(previous, now, windowMs) {
44
+ const gap = now - previous.lastAt;
45
+ if (gap > windowMs) return 1;
46
+ if (gap < BATCH_GAP_MS) return Math.max(previous.count, 1);
47
+ return previous.count + 1;
75
48
  }
76
49
 
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
50
  runGate(
90
51
  {
91
52
  id: GATE_ID,
92
53
  configKey: CONFIG_KEY,
93
54
  enabledByDefault: false,
55
+ severity: 'warn',
94
56
  defaultParams: {
95
57
  sequentialThreshold: DEFAULT_SEQUENTIAL_THRESHOLD,
96
58
  sequentialWindowMs: DEFAULT_SEQUENTIAL_WINDOW_MS,
97
59
  sequentialJustifiedMarker: DEFAULT_JUSTIFIED_MARKER,
98
60
  },
99
61
  },
100
- ({ toolName, toolInput, sessionId, parameters }) => {
101
- if (!toolInGroups(toolName, DELEGATION_GROUPS)) return;
62
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
63
+ if (!toolInGroups(toolName, ['delegation'])) return;
102
64
 
103
- const marker =
104
- parameters.sequentialJustifiedMarker ?? DEFAULT_JUSTIFIED_MARKER;
65
+ const marker = String(parameters.sequentialJustifiedMarker ?? '');
105
66
  const prompt = delegationPromptOf(toolInput);
106
- if (prompt.includes(marker)) return; // declared reason not to parallelize: no warning
67
+ if (marker && prompt.includes(marker)) return;
107
68
 
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);
69
+ const stateOptions = { cwd };
70
+ const stored = readSessionState(GATE_ID, sessionId, {}, stateOptions);
71
+ const previous = {
72
+ count: Number(stored.count) || 0,
73
+ lastAt: Number(stored.lastAt) || 0,
74
+ };
115
75
  const now = Date.now();
76
+ const count = nextCount(previous, now, parameters.sequentialWindowMs);
77
+ writeSessionState(GATE_ID, sessionId, { count, lastAt: now }, stateOptions);
116
78
 
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;
79
+ if (count < parameters.sequentialThreshold) return;
123
80
 
81
+ const windowSeconds = Math.round(
82
+ parameters.sequentialWindowMs / MS_PER_SECOND,
83
+ );
124
84
  warn(
125
85
  CONFIG_KEY,
126
- WARN_MESSAGE.replace('{count}', String(nextCount))
127
- .replace(
128
- '{windowSeconds}',
129
- String(Math.round(windowMs / MS_PER_SECOND)),
130
- )
131
- .replace('{marker}', marker),
86
+ `This is the ${ordinal(count)} delegation sent one-by-one within ${windowSeconds}s. If the ` +
87
+ 'remaining work is independent, launch the next batch together in a single message ' +
88
+ '(multiple tool calls) instead of one delegation per turn. If this delegation ' +
89
+ 'genuinely depends on a prior result, ignore this and mark the prompt with ' +
90
+ `"${marker || DEFAULT_JUSTIFIED_MARKER}" to skip the warning next time.`,
132
91
  );
133
92
  },
134
93
  );
@@ -1,94 +1,53 @@
1
- // forge-flow — the enforcer forge cannot be. A forge MCP directs a pipeline but cannot
2
- // intercept Edit/Write/Bash, so nothing makes you actually use it. This gate closes that
3
- // hole: in a project that ADOPTED forge, a code-mutating action is denied unless there is
4
- // an active forge run for this project so every change goes through the pipeline and you
5
- // always know which phase you are in.
6
- //
7
- // justification: no existing tool covers this. A forge MCP audit (see memory
8
- // forge-mcp-auditoria-flujo) confirmed the structural hole: an MCP cannot gate other tools;
9
- // only a Claude Code PreToolUse hook can. This is that hook.
10
- //
11
- // ── When it acts (never surprises you) ──────────────────────────────────────────────
12
- // Only when the project adopted forge — a marker on disk (.ai/forge.json, or `forge: true`
13
- // in .ai/config.json). In any other project it stays silent. Off by default in the
14
- // registry, so it never fires unless a project turns it on.
15
- //
16
- // ── How it checks (deterministic, no judgment) ──────────────────────────────────────
17
- // forge persists its runs in a SQLite DB (global by default). This gate reads that DB with
18
- // node:sqlite (a Node built-in — no npm, contract intact) and looks for an active run whose
19
- // cwd matches this project. Absent → deny with an actionable message. The DB unreadable or
20
- // node:sqlite unavailable → allow (fail open: a broken lookup must not block all work).
1
+ // forge-flow — in a project that ADOPTED forge (.ai/forge.json, or `forge: true` in
2
+ // .ai/config.json), a code-mutating action is denied unless forge has an active run for
3
+ // this project. A forge MCP cannot intercept Edit/Write/Bash; only this hook can.
4
+ // The run's cwd may be the project root or any ANCESTOR of it (a monorepo run covers its
5
+ // packages); paths are compared normalized, case-insensitively on Windows.
6
+ // The DB is read with node:sqlite (a built-in, so the plugin stays npm-free). A DB that is
7
+ // absent means no runs deny; a DB that cannot be read means "unknown" → warn and allow,
8
+ // so a broken lookup never freezes work and never disables the enforcer silently.
21
9
 
22
- import { existsSync, readFileSync } from 'node:fs';
10
+ import { existsSync } from 'node:fs';
23
11
  import { homedir } from 'node:os';
24
- import { dirname, join } from 'node:path';
12
+ import { join } from 'node:path';
13
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
25
14
  import { runGate, deny, warn, toolInGroups } from '../../lib/hook-io.mjs';
26
15
 
27
16
  const GATE_ID = 'forge-flow';
28
17
  const CONFIG_KEY = 'requireForgeRunToEdit';
29
18
 
30
- // Any code-mutating surface: native write/shell AND their MCP equivalents. `execution`
31
- // already unions write+shell+mcp__ide__executeCode, and toolInGroups adds the mcp__* signal
32
- // match — so an MCP filesystem-write or shell-exec tool no longer slips past the enforcer.
33
19
  const ACTING_GROUPS = ['execution'];
34
- const PROJECT_ROOT_MARKERS = ['.git', '.ai'];
35
- const DEFAULT_FORGE_DB = join('.forge', 'forge-mcp.db');
20
+ const DEFAULT_FORGE_DB_PATH = join(homedir(), '.forge', 'forge-mcp.db');
36
21
  const FORGE_MARKER_FILE = join('.ai', 'forge.json');
37
22
  const PROJECT_CONFIG_FILE = join('.ai', 'config.json');
38
23
 
39
- function projectRootOf(startDirectory) {
40
- let current = startDirectory;
41
- while (true) {
42
- if (
43
- PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))
44
- ) {
45
- return current;
46
- }
47
- const parent = dirname(current);
48
- if (parent === current) return null;
49
- current = parent;
50
- }
24
+ function projectAdoptedForge(root) {
25
+ if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
26
+ return readJsonOrNull(join(root, PROJECT_CONFIG_FILE))?.forge === true;
51
27
  }
52
28
 
53
- function readJson(path) {
54
- try {
55
- return JSON.parse(readFileSync(path, 'utf8'));
56
- } catch {
57
- return null;
58
- }
29
+ function normalizeDirectory(path) {
30
+ let normalized = String(path ?? '').replace(/\\/g, '/');
31
+ while (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
32
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
59
33
  }
60
34
 
61
- /** True when this project opted into forge: a marker file, or forge:true in .ai/config.json. */
62
- function projectAdoptedForge(root) {
63
- if (existsSync(join(root, FORGE_MARKER_FILE))) return true;
64
- const config = readJson(join(root, PROJECT_CONFIG_FILE));
65
- return config?.forge === true;
35
+ function runCovers(runCwd, root) {
36
+ const run = normalizeDirectory(runCwd);
37
+ const project = normalizeDirectory(root);
38
+ return run !== '' && (project === run || project.startsWith(`${run}/`));
66
39
  }
67
40
 
68
- /**
69
- * True when forge has an active run whose cwd is this project. Reads forge's SQLite DB with
70
- * node:sqlite (imported dynamically so an older Node without it degrades instead of
71
- * throwing). Any failure — module missing, DB absent, locked, unreadable, schema drift —
72
- * returns true: the gate must fail OPEN, since a broken lookup must never block every edit.
73
- */
74
- // Returns one of three verdicts, so a broken lookup no longer masquerades as "run present":
75
- // { state: 'active' } → a run for this project exists; allow.
76
- // { state: 'none' } → DB readable, no active run for this project; deny.
77
- // { state: 'unknown', reason } → DB absent/locked/corrupt/schema-drift, or node:sqlite
78
- // missing; we cannot tell. Warn (visible) but allow, so a
79
- // broken DB never blocks all work AND never disables the
80
- // enforcer silently — the earlier code returned true here,
81
- // which looked identical to "run present".
82
- async function forgeRunState(root, forgeDatabasePath) {
83
- if (!existsSync(forgeDatabasePath)) return { state: 'none' }; // no DB yet → no runs → deny
41
+ async function forgeRunState(root, databasePath) {
42
+ if (!existsSync(databasePath)) return { state: 'none' };
84
43
  try {
85
44
  const { DatabaseSync } = await import('node:sqlite');
86
- const database = new DatabaseSync(forgeDatabasePath, { readOnly: true });
45
+ const database = new DatabaseSync(databasePath, { readOnly: true });
87
46
  const rows = database
88
47
  .prepare("SELECT cwd FROM runs WHERE status = 'active'")
89
48
  .all();
90
49
  database.close();
91
- return rows.some((row) => String(row.cwd) === root)
50
+ return rows.some((row) => runCovers(row.cwd, root))
92
51
  ? { state: 'active' }
93
52
  : { state: 'none' };
94
53
  } catch (error) {
@@ -96,39 +55,48 @@ async function forgeRunState(root, forgeDatabasePath) {
96
55
  }
97
56
  }
98
57
 
58
+ // The registry documents `forgeDbPath`; `forgeDatabasePath` is the name earlier configs
59
+ // used. A declared forgeDbPath wins; otherwise whichever of the two was set applies.
60
+ function configuredDatabasePath(parameters) {
61
+ const { forgeDbPath, forgeDatabasePath } = parameters;
62
+ if (typeof forgeDbPath === 'string' && forgeDbPath !== DEFAULT_FORGE_DB_PATH)
63
+ return forgeDbPath;
64
+ return typeof forgeDatabasePath === 'string' && forgeDatabasePath
65
+ ? forgeDatabasePath
66
+ : DEFAULT_FORGE_DB_PATH;
67
+ }
68
+
99
69
  const DENY_MESSAGE =
100
70
  'This project uses forge, but there is no active forge run for it. Every change should ' +
101
71
  'go through the pipeline so the next step is always clear. Start or resume a run ' +
102
72
  '(forge_start / forge_next) before editing — that is how forge tells you which phase ' +
103
- 'you are in. To work outside the pipeline, turn this gate off in .ai/config.json.';
73
+ `you are in. To work outside the pipeline, set ${CONFIG_KEY} to false in .ai/config.json.`;
104
74
 
105
75
  runGate(
106
76
  {
107
77
  id: GATE_ID,
108
78
  configKey: CONFIG_KEY,
109
79
  enabledByDefault: false,
110
- defaultParams: { forgeDatabasePath: join(homedir(), DEFAULT_FORGE_DB) },
80
+ defaultParams: {
81
+ forgeDbPath: DEFAULT_FORGE_DB_PATH,
82
+ forgeDatabasePath: DEFAULT_FORGE_DB_PATH,
83
+ },
111
84
  },
112
- async ({ toolName, parameters }) => {
85
+ async ({ toolName, parameters, cwd }) => {
113
86
  if (!toolInGroups(toolName, ACTING_GROUPS)) return;
114
87
 
115
- const root = projectRootOf(process.cwd());
116
- if (!root) return; // no project
117
- if (!projectAdoptedForge(root)) return; // project did not opt into forge
118
-
119
- const forgeDatabasePath =
120
- parameters.forgeDatabasePath ?? join(homedir(), DEFAULT_FORGE_DB);
121
- const result = await forgeRunState(root, forgeDatabasePath);
88
+ const root = projectRootOf(cwd);
89
+ if (!root || !projectAdoptedForge(root)) return;
122
90
 
123
- if (result.state === 'active') return; // pipeline is running → allow
124
- if (result.state === 'none') deny(CONFIG_KEY, DENY_MESSAGE); // no run → block
125
- // state 'unknown': the DB could not be read. Allow so a broken lookup never freezes work,
126
- // but surface it loudly — a silent allow here would disable the enforcer without a trace.
91
+ const databasePath = configuredDatabasePath(parameters);
92
+ const result = await forgeRunState(root, databasePath);
93
+ if (result.state === 'active') return;
94
+ if (result.state === 'none') deny(CONFIG_KEY, DENY_MESSAGE);
127
95
  warn(
128
96
  CONFIG_KEY,
129
97
  `forge enforcement is degraded: the forge DB could not be read (${result.reason}). ` +
130
98
  'Allowing this action, but the pipeline is NOT being enforced. Check that forge is ' +
131
- `installed and ${forgeDatabasePath} is readable, or turn this gate off if intended.`,
99
+ `installed and ${databasePath} is readable, or turn this gate off if intended.`,
132
100
  );
133
101
  },
134
102
  );