@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- 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 +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- 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 +98 -212
- 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 +51 -92
- 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 +262 -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/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- 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 +112 -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 +448 -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 +137 -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 +6 -6
- package/registry.json +204 -36
|
@@ -1,28 +1,22 @@
|
|
|
1
|
-
// feature-catalog — enforces the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
38
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
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
|
-
|
|
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
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,
|
|
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;
|
|
67
|
+
if (marker && prompt.includes(marker)) return;
|
|
107
68
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 —
|
|
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
|
);
|