@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  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 +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  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 +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,134 +1,92 @@
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 — denies delegations that keep arriving one turn at a time instead of as a
2
+ // batch. Deterministic: when the sequential count hits the threshold, the delegation is
3
+ // blocked outright the agent must collect independent delegations and send them together.
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
- warn,
13
+ deny,
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
- enabledByDefault: false,
54
+ enabledByDefault: true,
94
55
  defaultParams: {
95
56
  sequentialThreshold: DEFAULT_SEQUENTIAL_THRESHOLD,
96
57
  sequentialWindowMs: DEFAULT_SEQUENTIAL_WINDOW_MS,
97
58
  sequentialJustifiedMarker: DEFAULT_JUSTIFIED_MARKER,
98
59
  },
99
60
  },
100
- ({ toolName, toolInput, sessionId, parameters }) => {
101
- if (!toolInGroups(toolName, DELEGATION_GROUPS)) return;
61
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
62
+ if (!toolInGroups(toolName, ['delegation'])) return;
102
63
 
103
- const marker =
104
- parameters.sequentialJustifiedMarker ?? DEFAULT_JUSTIFIED_MARKER;
64
+ const marker = String(parameters.sequentialJustifiedMarker ?? '');
105
65
  const prompt = delegationPromptOf(toolInput);
106
- if (prompt.includes(marker)) return; // declared reason not to parallelize: no warning
66
+ if (marker && prompt.includes(marker)) return;
107
67
 
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);
68
+ const stateOptions = { cwd };
69
+ const stored = readSessionState(GATE_ID, sessionId, {}, stateOptions);
70
+ const previous = {
71
+ count: Number(stored.count) || 0,
72
+ lastAt: Number(stored.lastAt) || 0,
73
+ };
115
74
  const now = Date.now();
75
+ const count = nextCount(previous, now, parameters.sequentialWindowMs);
76
+ writeSessionState(GATE_ID, sessionId, { count, lastAt: now }, stateOptions);
116
77
 
117
- const withinWindow = now - state.lastAt <= windowMs;
118
- const nextCount = withinWindow ? state.count + 1 : 1;
78
+ if (count < parameters.sequentialThreshold) return;
119
79
 
120
- writeState(statePath, { count: nextCount, lastAt: now });
121
-
122
- if (nextCount < threshold) return;
123
-
124
- warn(
80
+ const windowSeconds = Math.round(
81
+ parameters.sequentialWindowMs / MS_PER_SECOND,
82
+ );
83
+ deny(
125
84
  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),
85
+ `This is the ${ordinal(count)} delegation sent one-by-one within ${windowSeconds}s. ` +
86
+ 'Independent delegations MUST be launched together in a single message (multiple tool ' +
87
+ 'calls in one response). Collect the remaining independent delegations and send them as ' +
88
+ 'a batch. If this delegation genuinely depends on a prior result, add ' +
89
+ `"${marker || DEFAULT_JUSTIFIED_MARKER}" to the prompt to declare the dependency.`,
132
90
  );
133
91
  },
134
92
  );
@@ -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
  );
@@ -1,34 +1,34 @@
1
- // implementation-pipeline — denies a delegation that sends a builder subagent straight
2
- // to writing code without the prompt declaring the pipeline stages around it:
3
- // definition (where the context came from), writing's own verification plan, and a
4
- // separate reviewer for validation/QA/closure. Migrated from
5
- // ~/.claude/hooks/guard-pipeline-de-implementacion.mjs.
1
+ // implementation-pipeline — denies a STANDARD/HIGH-RISK implementation delegation whose
2
+ // prompt does not declare the pipeline around the build: where the context came from
3
+ // (definition), what is verified and how (writing), and who reviews the result (reviewer).
6
4
  //
7
- // ── What a project can configure (params) ───────────────────────────────────────────
8
- // builderSubagents subagent types that count as builders the ones this gate
9
- // holds to the pipeline. Replaces the built-in list wholesale.
10
- // exemptSubagents subagent types exempt outright: read-only pipeline stages that
11
- // cannot be required to declare a pipeline around themselves.
12
- // Replaces the built-in list wholesale.
13
- // The defaults live here, in the source, so a project reads them and knows exactly what
14
- // its override replaces.
15
- //
16
- // ── Off by default, and quiet outside its narrow trigger ───────────────────────────
17
- // This gate only evaluates a delegation whose prompt declares a STANDARD/HIGH-RISK
18
- // level, uses an implementation verb, targets a builder subagent, and is not about the
19
- // harness itself. A prompt that only describes work, or that is read-only/exploratory,
20
- // never reaches the check.
5
+ // Decisions: every non-exempt subagent is a builder an absent or unknown subagent_type is
6
+ // not a reason to skip the check, only the exempt list is. A negated mention ("do not write
7
+ // tests", "no brief.md yet") does not satisfy a stage. Work on the agent harness itself
8
+ // (.claude/, .ai/, CLAUDE.md...) is exempt; ordinary app paths such as src/hooks/ are not.
21
9
 
10
+ import {
11
+ DEFAULT_EXEMPT_SUBAGENTS,
12
+ DEMANDING_LEVELS,
13
+ LEVELS,
14
+ allMatches,
15
+ isHarnessWork,
16
+ isImplementationRequest,
17
+ isSubagentNamedIn,
18
+ operativeLevelOf,
19
+ } from '../../lib/delegation.mjs';
22
20
  import {
23
21
  runGate,
24
22
  deny,
25
23
  toolInGroups,
26
24
  delegationPromptOf,
27
25
  } from '../../lib/hook-io.mjs';
26
+ import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
28
27
 
29
28
  const GATE_ID = 'implementation-pipeline';
30
29
  const CONFIG_KEY = 'requireImplementationPipeline';
31
30
 
31
+ // Kept for config compatibility: every non-exempt type is held to the pipeline.
32
32
  const DEFAULT_BUILDER_SUBAGENTS = [
33
33
  'frontend',
34
34
  'backend',
@@ -37,46 +37,11 @@ const DEFAULT_BUILDER_SUBAGENTS = [
37
37
  'general-purpose',
38
38
  ];
39
39
 
40
- const DEFAULT_EXEMPT_SUBAGENTS = [
41
- 'scout',
42
- 'explore',
43
- 'plan',
44
- 'revision',
45
- 'contraste',
46
- 'test-planner',
47
- 'qa',
48
- 'ui',
49
- 'ux',
50
- ];
51
-
52
- function withWordBoundary(alternation) {
53
- return new RegExp(
54
- `(?:^|[^\\p{L}\\p{N}_])(?:${alternation})(?:[^\\p{L}\\p{N}_]|$)`,
55
- 'iu',
56
- );
57
- }
58
-
59
- const IMPLEMENTATION_VERBS = withWordBoundary(
60
- 'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
61
- 'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|' +
62
- 'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
63
- 'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
64
- 'escrib(í|e)|escribir|implement\\w*|writ(?:e|ing)|creat\\w*|fix\\w*|build\\w*|refactor\\w*|migrat\\w*|' +
65
- 'add\\w*|remov\\w*|delet\\w*|modify|modifies|modifying|rewrit\\w*',
40
+ const NEGATION_LOOK_BACK = 15;
41
+ const NEGATION_PATTERN = withUnicodeWordBoundary(
42
+ "no|not|don'?t|do not|sin|never",
66
43
  );
67
44
 
68
- /** Declared LEVEL near the word "level"/"classification" in Spanish or English, matching
69
- * the plugin-wide convention (see risk-level.mjs). */
70
- const DEMANDING_LEVEL_PATTERN =
71
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(STANDARD|HIGH-RISK)\b/iu;
72
- const EXEMPT_LEVEL_PATTERN =
73
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\b(QUESTION|MICRO)\b/iu;
74
- const HIGH_RISK_PATTERN =
75
- /(nivel|level|clasificaci[oó]n|classification)[^\n]{0,25}?\bHIGH-RISK\b/iu;
76
-
77
- const HARNESS_PATTERN =
78
- /(\.claude[\\/]|hooks[\\/]|plugins[\\/]gates|settings\.json|[\\/]agents[\\/]\w|[\\/]rules[\\/]\w|[\\/]skills[\\/]\w)/i;
79
-
80
45
  // Several small patterns instead of one long alternation: a single big regex here
81
46
  // tripped the linter's regex-complexity limit. Any one matching counts as declared.
82
47
  const REVIEWER_NAME_PATTERN =
@@ -103,18 +68,26 @@ const DEFINITION_PATTERN =
103
68
  const WRITING_PATTERN =
104
69
  /\b(test|tests|prueba|pruebas|vitest|jest|playwright|typecheck|lint|verification|verificaci[oó]n|verification criteria)\b/i;
105
70
 
106
- /** The three pipeline stages this gate requires be declared, in this order. */
71
+ function isNegated(prompt, match) {
72
+ const from = Math.max(0, match.index - NEGATION_LOOK_BACK);
73
+ return NEGATION_PATTERN.test(prompt.slice(from, match.index));
74
+ }
75
+
76
+ function hasAffirmativeMention(prompt, pattern) {
77
+ return allMatches(pattern, prompt).some((match) => !isNegated(prompt, match));
78
+ }
79
+
107
80
  const REQUIRED_STAGES = [
108
81
  {
109
82
  id: 'definition',
110
- isDeclared: (prompt) => DEFINITION_PATTERN.test(prompt),
83
+ isDeclared: (prompt) => hasAffirmativeMention(prompt, DEFINITION_PATTERN),
111
84
  missing:
112
85
  'DEFINITION: does not declare where the context came from (a prior scout, read-only exploration, or an already-written asserts/brief).',
113
86
  line: 'Verified context: <path to the cited asserts/brief> — or: a prior scout ran, findings at <path>.',
114
87
  },
115
88
  {
116
89
  id: 'writing',
117
- isDeclared: (prompt) => WRITING_PATTERN.test(prompt),
90
+ isDeclared: (prompt) => hasAffirmativeMention(prompt, WRITING_PATTERN),
118
91
  missing:
119
92
  'WRITING: does not declare what is verified nor with what command. A builder does not validate its own work without a criterion written beforehand.',
120
93
  line: 'Verification: <exact command> and what is expected. Anything that cannot run is reported OMITTED, never PASS.',
@@ -128,19 +101,11 @@ const REQUIRED_STAGES = [
128
101
  },
129
102
  ];
130
103
 
131
- function isExempt(toolInput, prompt, builderSubagents, exemptSubagents) {
132
- const subagentType = String(
133
- toolInput.subagent_type ?? toolInput.subagentType ?? '',
134
- ).toLowerCase();
135
- if (exemptSubagents.includes(subagentType)) return true;
136
- // An unknown type is not assumed to be a builder: the safe side here is not to
137
- // invent requirements for agents this gate cannot classify.
138
- if (subagentType && !builderSubagents.includes(subagentType)) return true;
139
- if (EXEMPT_LEVEL_PATTERN.test(prompt)) return true;
140
- if (!DEMANDING_LEVEL_PATTERN.test(prompt)) return true;
141
- if (!IMPLEMENTATION_VERBS.test(prompt)) return true;
142
- if (HARNESS_PATTERN.test(prompt)) return true;
143
- return false;
104
+ function isExempt(toolInput, prompt, exemptSubagents) {
105
+ if (isSubagentNamedIn(toolInput, exemptSubagents)) return true;
106
+ if (!DEMANDING_LEVELS.has(operativeLevelOf(prompt))) return true;
107
+ if (!isImplementationRequest(prompt)) return true;
108
+ return isHarnessWork(prompt);
144
109
  }
145
110
 
146
111
  runGate(
@@ -158,19 +123,14 @@ runGate(
158
123
 
159
124
  const prompt = delegationPromptOf(toolInput);
160
125
  if (!prompt.trim()) return;
161
-
162
- const builderSubagents =
163
- parameters.builderSubagents ?? DEFAULT_BUILDER_SUBAGENTS;
164
- const exemptSubagents =
165
- parameters.exemptSubagents ?? DEFAULT_EXEMPT_SUBAGENTS;
166
- if (isExempt(toolInput, prompt, builderSubagents, exemptSubagents)) return;
126
+ if (isExempt(toolInput, prompt, parameters.exemptSubagents)) return;
167
127
 
168
128
  const missingStages = REQUIRED_STAGES.filter(
169
129
  (stage) => !stage.isDeclared(prompt),
170
130
  );
171
131
  if (missingStages.length === 0) return;
172
132
 
173
- const isHighRisk = HIGH_RISK_PATTERN.test(prompt);
133
+ const isHighRisk = operativeLevelOf(prompt) === LEVELS.HIGH_RISK;
174
134
  const details = missingStages.map((stage) => stage.missing).join(' ');
175
135
  const lines = missingStages.map((stage) => ` ${stage.line}`).join('\n');
176
136