@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +129 -11
- package/README.md +120 -10
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +12 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +159 -25
- package/cli/task.mjs +104 -11
- package/cli/toggle.mjs +125 -0
- package/package.json +4 -3
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +152 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +148 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +249 -38
|
@@ -1,134 +1,92 @@
|
|
|
1
|
-
// force-parallel —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
+
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
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
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,
|
|
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;
|
|
66
|
+
if (marker && prompt.includes(marker)) return;
|
|
107
67
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
const nextCount = withinWindow ? state.count + 1 : 1;
|
|
78
|
+
if (count < parameters.sequentialThreshold) return;
|
|
119
79
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
warn(
|
|
80
|
+
const windowSeconds = Math.round(
|
|
81
|
+
parameters.sequentialWindowMs / MS_PER_SECOND,
|
|
82
|
+
);
|
|
83
|
+
deny(
|
|
125
84
|
CONFIG_KEY,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
23
11
|
import { homedir } from 'node:os';
|
|
24
|
-
import {
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
116
|
-
if (!root) return;
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 ${
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// definition
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
41
|
-
|
|
42
|
-
'
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
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
|
|