@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,96 +1,41 @@
|
|
|
1
|
-
// circuit-breaker — cuts the same delegation retried without substantial change within
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// relaunches Agent/Task with an essentially identical prompt, the previous attempt did
|
|
5
|
-
// not land: nothing else would explain relaunching the same request. That repetition is
|
|
6
|
-
// the signal this gate watches, persisted per session under os.tmpdir() (never a path
|
|
7
|
-
// hardcoded to a particular user/machine).
|
|
1
|
+
// circuit-breaker — cuts the same delegation retried without substantial change within a
|
|
2
|
+
// session. A hook cannot see why a previous attempt did not land, but relaunching an
|
|
3
|
+
// essentially identical prompt is the signal that it did not.
|
|
8
4
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// duplicated features, so they weigh more). Scaffolding headings are excluded. A prompt
|
|
18
|
-
// with no recognizable template section falls back to the whole prompt minus pure
|
|
19
|
-
// scaffolding lines, so the gate is never blind for lack of structure.
|
|
20
|
-
//
|
|
21
|
-
// ── Similarity ───────────────────────────────────────────────────────────────────────
|
|
22
|
-
// Dice coefficient over word bigrams of the signature (a multiset), not character
|
|
23
|
-
// bigrams and not the raw prompt: word bigrams discriminate ("fix guard" vs "fix cache")
|
|
24
|
-
// where character bigrams mostly measure shared vocabulary/scaffolding.
|
|
25
|
-
//
|
|
26
|
-
// ── Escape hatch ─────────────────────────────────────────────────────────────────────
|
|
27
|
-
// A prompt that explicitly gives a real override IMPERATIVE ("retry anyway", "force it",
|
|
28
|
-
// "insist") is read as the user already deciding to proceed despite the pattern: allow,
|
|
29
|
-
// and reset that key's counter so it does not stay open blocking the next legitimate
|
|
30
|
-
// attempt. The bare word "retry" mentioned as ordinary task vocabulary (e.g. "fix the
|
|
31
|
-
// retry loop") does NOT count — only an imperative phrasing does (see OVERRIDE_PATTERN).
|
|
32
|
-
//
|
|
33
|
-
// ── Keying: by task identity, not by the caller-chosen subagent_type ────────────────
|
|
34
|
-
// subagent_type is free text the caller controls. Keying the counter by that string lets
|
|
35
|
-
// the exact same task evade detection just by varying it per relaunch. This gate instead
|
|
36
|
-
// keys by a hash of the normalized identity signature itself (see identitySignature
|
|
37
|
-
// below) — the task's content, not a label the caller can rename at will.
|
|
38
|
-
//
|
|
39
|
-
// ── Trusting the count: recomputed, not stored ──────────────────────────────────────
|
|
40
|
-
// The persisted state is a list of past signatures (hashes + feature sets), never a raw
|
|
41
|
-
// counter. The attempt count for THIS call is always recomputed as
|
|
42
|
-
// "how many stored past entries are similar to this one, plus one for this call" — so a
|
|
43
|
-
// payload that pre-seeds or edits a `count` field on disk has nothing to tamper with:
|
|
44
|
-
// there is no counter field to overwrite, only a history the gate recounts itself. This
|
|
45
|
-
// does not add cryptographic integrity (no secret is available to sign with in a hook),
|
|
46
|
-
// but it does close the specific hole of a trusted, directly-writable numeric field.
|
|
47
|
-
//
|
|
48
|
-
// ── No session id: a stable fallback bucket, never a silent bypass ─────────────────
|
|
49
|
-
// A missing/blank session_id no longer disables the breaker. It falls back to a fixed,
|
|
50
|
-
// well-known bucket (NO_SESSION_BUCKET) instead of returning early — the per-task
|
|
51
|
-
// discrimination still comes from identityKey (a hash of the prompt's own identity
|
|
52
|
-
// signature), so two different tasks sharing that bucket never collide, while the SAME
|
|
53
|
-
// task repeated without a session id is still tracked and eventually trips the breaker.
|
|
5
|
+
// Decisions: identity is an extracted signature (GOAL line, IN SCOPE body, mentioned paths)
|
|
6
|
+
// compared by Dice over word bigrams, not the raw prompt — the shared delegation template
|
|
7
|
+
// would otherwise make unrelated tasks look alike. The attempt count is recomputed from
|
|
8
|
+
// stored signatures across every key (a one-word edit is still the same task), never read
|
|
9
|
+
// as a trusted number off disk. The key is a hash of the signature, not the caller-chosen
|
|
10
|
+
// subagent_type. An override imperative counts only in a short sentence, so "force it" inside
|
|
11
|
+
// a long instruction is task vocabulary, not the user's decision to proceed. A threshold
|
|
12
|
+
// below 2 is treated as 2: a first attempt is never a retry.
|
|
54
13
|
|
|
55
14
|
import { createHash } from 'node:crypto';
|
|
56
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
57
|
-
import { tmpdir } from 'node:os';
|
|
58
|
-
import { join } from 'node:path';
|
|
59
15
|
import {
|
|
60
16
|
runGate,
|
|
61
17
|
deny,
|
|
18
|
+
escapeRegExp,
|
|
62
19
|
toolInGroups,
|
|
63
20
|
delegationPromptOf,
|
|
64
21
|
} from '../../lib/hook-io.mjs';
|
|
22
|
+
import {
|
|
23
|
+
readSessionState,
|
|
24
|
+
writeSessionState,
|
|
25
|
+
} from '../../lib/session-state.mjs';
|
|
65
26
|
|
|
66
27
|
const GATE_ID = 'circuit-breaker';
|
|
67
28
|
const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
|
|
68
29
|
|
|
69
|
-
// Two attempts at the same task, then stop and ask the user: the first attempt is normal,
|
|
70
|
-
// the second identical relaunch is the signal it did not land, so the breaker trips there.
|
|
71
30
|
const DEFAULT_RETRY_THRESHOLD = 2;
|
|
31
|
+
const MIN_RETRY_THRESHOLD = 2;
|
|
72
32
|
const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// bakes in a username or machine name — the project rule this gate must not violate.
|
|
77
|
-
const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
|
|
78
|
-
|
|
79
|
-
// Fixed bucket used when the payload carries no session id. Distinct tasks inside this
|
|
80
|
-
// bucket are still told apart by identityKey (derived from the prompt itself), so this
|
|
81
|
-
// is a location, not a discriminator — it never causes two unrelated tasks to collide.
|
|
82
|
-
const NO_SESSION_BUCKET = 'no-session';
|
|
83
|
-
|
|
84
|
-
// An override imperative: a directive to proceed anyway, not the bare topic word
|
|
85
|
-
// appearing as ordinary task vocabulary. Requires either a Spanish/English imperative
|
|
86
|
-
// verb form ("reintenta", "forzalo", "insisti") or the word "retry"/"force" paired
|
|
87
|
-
// immediately with "anyway"/"it"/"again" or similar — never "retry"/"force" alone,
|
|
88
|
-
// which a normal task description ("fix the retry loop") can contain innocently.
|
|
89
|
-
// One big alternation trips the linter's complexity/backtracking check, so each imperative
|
|
90
|
-
// is its own short regex sharing the same word boundaries, tested with `.some()`. This
|
|
91
|
-
// matches EXACTLY the strings the single combined pattern did (verified case-by-case).
|
|
33
|
+
const MAX_OVERRIDE_SENTENCE_WORDS = 8;
|
|
34
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
35
|
+
|
|
92
36
|
const OVERRIDE_BOUNDARY_BEFORE = String.raw`(?<![\p{L}\p{N}_])`;
|
|
93
37
|
const OVERRIDE_BOUNDARY_AFTER = String.raw`(?![\p{L}\p{N}_])`;
|
|
38
|
+
// One short regex per imperative: a single big alternation trips the regex-complexity lint.
|
|
94
39
|
const OVERRIDE_ALTERNATIVES = [
|
|
95
40
|
String.raw`reintent[aá]lo`,
|
|
96
41
|
String.raw`reintenta(lo)?`,
|
|
@@ -108,15 +53,19 @@ const OVERRIDE_PATTERNS = OVERRIDE_ALTERNATIVES.map(
|
|
|
108
53
|
),
|
|
109
54
|
);
|
|
110
55
|
|
|
56
|
+
function wordCount(text) {
|
|
57
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
111
60
|
function isOverrideImperative(text) {
|
|
112
|
-
return
|
|
61
|
+
return String(text)
|
|
62
|
+
.split(/[.!?;\n]+/)
|
|
63
|
+
.filter((sentence) => wordCount(sentence) <= MAX_OVERRIDE_SENTENCE_WORDS)
|
|
64
|
+
.some((sentence) =>
|
|
65
|
+
OVERRIDE_PATTERNS.some((pattern) => pattern.test(sentence)),
|
|
66
|
+
);
|
|
113
67
|
}
|
|
114
68
|
|
|
115
|
-
// Template section-heading names (rules/04-subagent-standards.md), listed once as
|
|
116
|
-
// plain strings and matched with simple per-name regexes rather than one combined
|
|
117
|
-
// alternation — a single large alternation of variable-length pieces is what trips the
|
|
118
|
-
// linter's backtracking-risk and complexity checks; testing a short list against a
|
|
119
|
-
// small, fixed-shape pattern per name does not.
|
|
120
69
|
const SECTION_HEADING_NAMES = [
|
|
121
70
|
'scope',
|
|
122
71
|
'steps',
|
|
@@ -135,45 +84,28 @@ const SECTION_HEADING_NAMES = [
|
|
|
135
84
|
];
|
|
136
85
|
|
|
137
86
|
function headingNamePattern(name) {
|
|
138
|
-
|
|
139
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
140
|
-
.replace(/ /g, '\\s+');
|
|
141
|
-
return new RegExp(escaped, 'i');
|
|
87
|
+
return new RegExp(escapeRegExp(name).replace(/ /g, '\\s+'), 'i');
|
|
142
88
|
}
|
|
143
89
|
|
|
144
90
|
const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
|
|
145
91
|
|
|
146
|
-
/** Index of the first section marker in `text`, or -1 when none is found. Mirrors
|
|
147
|
-
* `String.prototype.search` for a single combined pattern, without needing one. */
|
|
148
92
|
function indexOfSectionMarker(text) {
|
|
149
93
|
let earliest = -1;
|
|
150
94
|
for (const namePattern of HEADING_NAME_PATTERNS) {
|
|
151
95
|
const match = namePattern.exec(text);
|
|
152
96
|
if (!match) continue;
|
|
153
97
|
const after = text.slice(match.index + match[0].length);
|
|
154
|
-
if (!/^\s*:/.test(after)) continue;
|
|
98
|
+
if (!/^\s*:/.test(after)) continue;
|
|
155
99
|
if (earliest === -1 || match.index < earliest) earliest = match.index;
|
|
156
100
|
}
|
|
157
101
|
return earliest;
|
|
158
102
|
}
|
|
159
103
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// ending in `:?$` reads to the linter's hard-coded-path heuristic as a filesystem path
|
|
165
|
-
// (which it is not), and `+` concatenation of a literal with a variable trips prefer-template
|
|
166
|
-
// (which then wants the template literal back). concat() carries the same regex source
|
|
167
|
-
// without tripping either.
|
|
168
|
-
const SPANISH_LEVEL_LINE_PATTERN = new RegExp(
|
|
169
|
-
'^'.concat(SPANISH_LEVEL_WORD, '\\s*:?$'),
|
|
170
|
-
'i',
|
|
171
|
-
);
|
|
104
|
+
const LEVEL_LINE_PATTERNS = [
|
|
105
|
+
new RegExp('^level\\s*:?$', 'i'),
|
|
106
|
+
new RegExp('^nivel\\s*:?$', 'i'),
|
|
107
|
+
];
|
|
172
108
|
|
|
173
|
-
// A leading list marker, bold marker, heading hash and surrounding whitespace are all
|
|
174
|
-
// stripped once, plainly, before a regex looks for the heading name itself — no run of
|
|
175
|
-
// two adjacent unbounded quantifiers over overlapping characters (the shape that
|
|
176
|
-
// triggers backtracking-risk warnings).
|
|
177
109
|
function stripLeadingMarkup(line) {
|
|
178
110
|
return line
|
|
179
111
|
.trim()
|
|
@@ -182,11 +114,10 @@ function stripLeadingMarkup(line) {
|
|
|
182
114
|
.replace(/^\*\*/, '');
|
|
183
115
|
}
|
|
184
116
|
|
|
185
|
-
/** A line that is pure template scaffolding: discarded on the fallback path. */
|
|
186
117
|
function isScaffoldingLine(line) {
|
|
187
118
|
const stripped = stripLeadingMarkup(line);
|
|
188
|
-
if (
|
|
189
|
-
|
|
119
|
+
if (LEVEL_LINE_PATTERNS.some((pattern) => pattern.test(stripped)))
|
|
120
|
+
return true;
|
|
190
121
|
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
191
122
|
const match = namePattern.exec(stripped);
|
|
192
123
|
return (
|
|
@@ -197,15 +128,9 @@ function isScaffoldingLine(line) {
|
|
|
197
128
|
});
|
|
198
129
|
}
|
|
199
130
|
|
|
200
|
-
// Matches the goal-heading NAME only (no trailing bold markers folded into the same
|
|
201
|
-
// regex — an adjacent `\s*` next to `\**` is what triggers the backtracking-risk
|
|
202
|
-
// warning). Bold markers before the colon, if any, are stripped separately at the call
|
|
203
|
-
// site with a single plain replace.
|
|
204
131
|
const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
|
|
205
132
|
const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
|
|
206
133
|
|
|
207
|
-
/** True when `line` is the heading that ends the IN SCOPE section (any other template
|
|
208
|
-
* section heading), tested the same short-list way as `indexOfSectionMarker`. */
|
|
209
134
|
function isEndOfInScope(line) {
|
|
210
135
|
const stripped = stripLeadingMarkup(line);
|
|
211
136
|
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
@@ -214,9 +139,6 @@ function isEndOfInScope(line) {
|
|
|
214
139
|
});
|
|
215
140
|
}
|
|
216
141
|
|
|
217
|
-
// Path/filename shapes, checked as three separate simple patterns rather than one
|
|
218
|
-
// combined alternation (each stays well clear of the backtracking-risk threshold; a
|
|
219
|
-
// single merged pattern of these variable-length alternatives is what tripped it).
|
|
220
142
|
const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
|
|
221
143
|
const KNOWN_EXTENSIONS = [
|
|
222
144
|
'mjs',
|
|
@@ -244,10 +166,8 @@ const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
|
|
|
244
166
|
`[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
|
|
245
167
|
'g',
|
|
246
168
|
);
|
|
247
|
-
// A
|
|
248
|
-
//
|
|
249
|
-
// validated in JS to require a real separator — the shape a backtracking engine could
|
|
250
|
-
// explore ambiguously is removed instead of bounded.
|
|
169
|
+
// A flat character class validated in JS afterwards: no nested repeated groups for a
|
|
170
|
+
// backtracking engine to explore.
|
|
251
171
|
const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
|
|
252
172
|
|
|
253
173
|
function looksLikeRelativePath(run) {
|
|
@@ -267,25 +187,20 @@ const STOP_WORDS = new Set(
|
|
|
267
187
|
).split(' '),
|
|
268
188
|
);
|
|
269
189
|
|
|
270
|
-
function statePathFor(sessionId) {
|
|
271
|
-
return join(STATE_ROOT, sessionId, 'state.json');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
190
|
function stripDiacritics(text) {
|
|
275
|
-
return text.normalize('NFD').replace(/
|
|
191
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '');
|
|
276
192
|
}
|
|
277
193
|
|
|
278
194
|
function tokenize(text) {
|
|
279
195
|
return stripDiacritics(String(text).toLowerCase())
|
|
280
196
|
.split(/[^a-z0-9]+/)
|
|
281
|
-
.filter(
|
|
197
|
+
.filter(
|
|
198
|
+
(token) => token.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(token),
|
|
199
|
+
);
|
|
282
200
|
}
|
|
283
201
|
|
|
284
202
|
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
|
|
285
203
|
|
|
286
|
-
/** Strips trailing punctuation one character at a time (a bounded loop, not a
|
|
287
|
-
* quantified character class anchored at the end) — a path match can pick up a
|
|
288
|
-
* sentence's closing punctuation, which is not part of the path. */
|
|
289
204
|
function stripTrailingPunctuation(text) {
|
|
290
205
|
let end = text.length;
|
|
291
206
|
while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
|
|
@@ -313,8 +228,11 @@ function mentionedPaths(prompt) {
|
|
|
313
228
|
return [...found].sort();
|
|
314
229
|
}
|
|
315
230
|
|
|
316
|
-
|
|
317
|
-
|
|
231
|
+
function contentAfterHeading(line) {
|
|
232
|
+
const colon = line.indexOf(':');
|
|
233
|
+
return colon === -1 ? '' : line.slice(colon + 1).trim();
|
|
234
|
+
}
|
|
235
|
+
|
|
318
236
|
function identityText(prompt) {
|
|
319
237
|
const lines = String(prompt).split(/\r?\n/);
|
|
320
238
|
const parts = [];
|
|
@@ -332,6 +250,7 @@ function identityText(prompt) {
|
|
|
332
250
|
}
|
|
333
251
|
|
|
334
252
|
if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
|
|
253
|
+
parts.push(contentAfterHeading(strippedLine));
|
|
335
254
|
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
336
255
|
if (isEndOfInScope(lines[cursor])) break;
|
|
337
256
|
parts.push(lines[cursor]);
|
|
@@ -342,7 +261,6 @@ function identityText(prompt) {
|
|
|
342
261
|
return parts.join(' ').trim();
|
|
343
262
|
}
|
|
344
263
|
|
|
345
|
-
/** Fallback path: whole prompt minus lines that are pure scaffolding. */
|
|
346
264
|
function textWithoutScaffolding(prompt) {
|
|
347
265
|
return String(prompt)
|
|
348
266
|
.split(/\r?\n/)
|
|
@@ -351,8 +269,6 @@ function textWithoutScaffolding(prompt) {
|
|
|
351
269
|
.trim();
|
|
352
270
|
}
|
|
353
271
|
|
|
354
|
-
/** Comparable signature: word bigrams of the identity text, plus mentioned paths as
|
|
355
|
-
* duplicated features (they discriminate tasks better than prose). A multiset. */
|
|
356
272
|
function identitySignature(prompt) {
|
|
357
273
|
const identity =
|
|
358
274
|
identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
|
|
@@ -367,6 +283,7 @@ function identitySignature(prompt) {
|
|
|
367
283
|
features.push(...tokens);
|
|
368
284
|
}
|
|
369
285
|
|
|
286
|
+
// Paths are duplicated so they weigh more than prose: they discriminate tasks best.
|
|
370
287
|
for (const path of mentionedPaths(prompt)) {
|
|
371
288
|
features.push(`path:${path}`, `path:${path}`);
|
|
372
289
|
}
|
|
@@ -374,7 +291,6 @@ function identitySignature(prompt) {
|
|
|
374
291
|
return features;
|
|
375
292
|
}
|
|
376
293
|
|
|
377
|
-
/** Dice coefficient over a multiset of features. */
|
|
378
294
|
function similarity(featuresA, featuresB) {
|
|
379
295
|
if (!Array.isArray(featuresA) || !Array.isArray(featuresB)) return 0;
|
|
380
296
|
if (featuresA.length === 0 || featuresB.length === 0) return 0;
|
|
@@ -395,57 +311,41 @@ function similarity(featuresA, featuresB) {
|
|
|
395
311
|
return (2 * intersection) / (featuresA.length + featuresB.length);
|
|
396
312
|
}
|
|
397
313
|
|
|
398
|
-
function readState(path) {
|
|
399
|
-
if (!existsSync(path)) return {};
|
|
400
|
-
try {
|
|
401
|
-
const content = JSON.parse(readFileSync(path, 'utf8'));
|
|
402
|
-
return content && typeof content === 'object' && !Array.isArray(content)
|
|
403
|
-
? content
|
|
404
|
-
: {};
|
|
405
|
-
} catch {
|
|
406
|
-
return {}; // corrupt/unreadable state is treated as empty, never as a block
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Past occurrences recorded for a key: only `{ signature, seenAt }` entries survive —
|
|
411
|
-
* there is no `count` field in the persisted shape at all, so there is nothing for a
|
|
412
|
-
* forged/edited state file to inflate. The attempt count is always derived by counting
|
|
413
|
-
* how many of these stored occurrences are similar to the CURRENT signature (see
|
|
414
|
-
* countSimilarOccurrences), never read as a trusted number off disk. */
|
|
415
314
|
function occurrencesFor(state, key) {
|
|
416
315
|
const value = state[key];
|
|
417
316
|
if (!Array.isArray(value)) return [];
|
|
418
317
|
return value.filter((entry) => entry && Array.isArray(entry.signature));
|
|
419
318
|
}
|
|
420
319
|
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
320
|
+
function similarOccurrenceCount(state, signature, similarityThreshold) {
|
|
321
|
+
let count = 0;
|
|
322
|
+
for (const key of Object.keys(state)) {
|
|
323
|
+
for (const entry of occurrencesFor(state, key)) {
|
|
324
|
+
if (similarity(entry.signature, signature) >= similarityThreshold)
|
|
325
|
+
count += 1;
|
|
326
|
+
}
|
|
427
327
|
}
|
|
328
|
+
return count;
|
|
428
329
|
}
|
|
429
330
|
|
|
430
|
-
/** How many stored past occurrences are similar enough to `signature` to count as the
|
|
431
|
-
* same repeated task, PLUS one for the current call itself. Recomputed fresh from the
|
|
432
|
-
* occurrence list every time — the count is a fact derived from stored signatures, not
|
|
433
|
-
* a number the state file carries and a forged file could set directly. */
|
|
434
|
-
function countSimilarOccurrences(occurrences, signature, similarityThreshold) {
|
|
435
|
-
const matches = occurrences.filter(
|
|
436
|
-
(entry) => similarity(entry.signature, signature) >= similarityThreshold,
|
|
437
|
-
);
|
|
438
|
-
return matches.length + 1;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** Task-identity key: a hash of the normalized signature, not the caller-chosen
|
|
442
|
-
* subagent_type string. The exact same task tracked under a different subagent_type on
|
|
443
|
-
* each relaunch still lands on the same key, because the key is derived from what the
|
|
444
|
-
* task IS, not from a free-text label the caller can vary at will. */
|
|
445
331
|
function identityKey(signature) {
|
|
446
332
|
return createHash('sha256').update(signature.join(' ')).digest('hex');
|
|
447
333
|
}
|
|
448
334
|
|
|
335
|
+
function withoutRelatedKeys(state, key, signature, similarityThreshold) {
|
|
336
|
+
const next = { ...state };
|
|
337
|
+
for (const existingKey of Object.keys(next)) {
|
|
338
|
+
const isRelated =
|
|
339
|
+
existingKey === key ||
|
|
340
|
+
occurrencesFor(next, existingKey).some(
|
|
341
|
+
(entry) =>
|
|
342
|
+
similarity(entry.signature, signature) >= similarityThreshold,
|
|
343
|
+
);
|
|
344
|
+
if (isRelated) delete next[existingKey];
|
|
345
|
+
}
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
|
|
449
349
|
function denyRepeatedAttempt(count) {
|
|
450
350
|
deny(
|
|
451
351
|
CONFIG_KEY,
|
|
@@ -468,60 +368,39 @@ runGate(
|
|
|
468
368
|
similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
|
|
469
369
|
},
|
|
470
370
|
},
|
|
471
|
-
({ toolName, toolInput, sessionId, parameters }) => {
|
|
371
|
+
({ toolName, toolInput, sessionId, parameters, cwd }) => {
|
|
472
372
|
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
473
373
|
|
|
474
374
|
const prompt = delegationPromptOf(toolInput);
|
|
475
375
|
if (!prompt.trim()) return;
|
|
476
376
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const effectiveSessionId = sessionId || NO_SESSION_BUCKET;
|
|
480
|
-
|
|
481
|
-
const statePath = statePathFor(effectiveSessionId);
|
|
482
|
-
const state = readState(statePath);
|
|
483
|
-
|
|
377
|
+
const stateOptions = { cwd };
|
|
378
|
+
const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
|
|
484
379
|
const signature = identitySignature(prompt);
|
|
485
380
|
const key = identityKey(signature);
|
|
381
|
+
const { similarityThreshold } = parameters;
|
|
486
382
|
|
|
487
383
|
if (isOverrideImperative(prompt)) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// threshold the normal path uses) is cleared, plus the current key itself.
|
|
495
|
-
for (const existingKey of Object.keys(state)) {
|
|
496
|
-
const occurrences = occurrencesFor(state, existingKey);
|
|
497
|
-
const isRelated =
|
|
498
|
-
existingKey === key ||
|
|
499
|
-
occurrences.some(
|
|
500
|
-
(entry) =>
|
|
501
|
-
similarity(entry.signature, signature) >=
|
|
502
|
-
parameters.similarityThreshold,
|
|
503
|
-
);
|
|
504
|
-
if (isRelated) delete state[existingKey];
|
|
505
|
-
}
|
|
506
|
-
writeState(statePath, state);
|
|
384
|
+
writeSessionState(
|
|
385
|
+
GATE_ID,
|
|
386
|
+
sessionId,
|
|
387
|
+
withoutRelatedKeys(state, key, signature, similarityThreshold),
|
|
388
|
+
stateOptions,
|
|
389
|
+
);
|
|
507
390
|
return;
|
|
508
391
|
}
|
|
509
392
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
393
|
+
// Count includes the current attempt (+1) against stored occurrences.
|
|
394
|
+
// Recording happens in the PostToolUse tracker (track.mjs), not here: a
|
|
395
|
+
// delegation rejected by ANOTHER gate (running in parallel) must not inflate
|
|
396
|
+
// the counter — only delegations that actually launched count as attempts.
|
|
397
|
+
const count =
|
|
398
|
+
1 + similarOccurrenceCount(state, signature, similarityThreshold);
|
|
516
399
|
|
|
517
|
-
const
|
|
518
|
-
|
|
400
|
+
const retryThreshold = Math.max(
|
|
401
|
+
MIN_RETRY_THRESHOLD,
|
|
402
|
+
parameters.retryThreshold,
|
|
519
403
|
);
|
|
520
|
-
|
|
521
|
-
writeState(statePath, state);
|
|
522
|
-
|
|
523
|
-
if (count >= parameters.retryThreshold) {
|
|
524
|
-
denyRepeatedAttempt(count);
|
|
525
|
-
}
|
|
404
|
+
if (count >= retryThreshold) denyRepeatedAttempt(count);
|
|
526
405
|
},
|
|
527
406
|
);
|