@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,96 +1,42 @@
|
|
|
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
33
|
const MAX_ENTRIES_PER_KEY = 12;
|
|
34
|
+
const MAX_OVERRIDE_SENTENCE_WORDS = 8;
|
|
35
|
+
const MIN_TOKEN_LENGTH = 2;
|
|
74
36
|
|
|
75
|
-
// State root: this gate's own subdirectory under the OS temp dir, never a path that
|
|
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).
|
|
92
37
|
const OVERRIDE_BOUNDARY_BEFORE = String.raw`(?<![\p{L}\p{N}_])`;
|
|
93
38
|
const OVERRIDE_BOUNDARY_AFTER = String.raw`(?![\p{L}\p{N}_])`;
|
|
39
|
+
// One short regex per imperative: a single big alternation trips the regex-complexity lint.
|
|
94
40
|
const OVERRIDE_ALTERNATIVES = [
|
|
95
41
|
String.raw`reintent[aá]lo`,
|
|
96
42
|
String.raw`reintenta(lo)?`,
|
|
@@ -108,15 +54,19 @@ const OVERRIDE_PATTERNS = OVERRIDE_ALTERNATIVES.map(
|
|
|
108
54
|
),
|
|
109
55
|
);
|
|
110
56
|
|
|
57
|
+
function wordCount(text) {
|
|
58
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
111
61
|
function isOverrideImperative(text) {
|
|
112
|
-
return
|
|
62
|
+
return String(text)
|
|
63
|
+
.split(/[.!?;\n]+/)
|
|
64
|
+
.filter((sentence) => wordCount(sentence) <= MAX_OVERRIDE_SENTENCE_WORDS)
|
|
65
|
+
.some((sentence) =>
|
|
66
|
+
OVERRIDE_PATTERNS.some((pattern) => pattern.test(sentence)),
|
|
67
|
+
);
|
|
113
68
|
}
|
|
114
69
|
|
|
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
70
|
const SECTION_HEADING_NAMES = [
|
|
121
71
|
'scope',
|
|
122
72
|
'steps',
|
|
@@ -135,45 +85,28 @@ const SECTION_HEADING_NAMES = [
|
|
|
135
85
|
];
|
|
136
86
|
|
|
137
87
|
function headingNamePattern(name) {
|
|
138
|
-
|
|
139
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
140
|
-
.replace(/ /g, '\\s+');
|
|
141
|
-
return new RegExp(escaped, 'i');
|
|
88
|
+
return new RegExp(escapeRegExp(name).replace(/ /g, '\\s+'), 'i');
|
|
142
89
|
}
|
|
143
90
|
|
|
144
91
|
const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
|
|
145
92
|
|
|
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
93
|
function indexOfSectionMarker(text) {
|
|
149
94
|
let earliest = -1;
|
|
150
95
|
for (const namePattern of HEADING_NAME_PATTERNS) {
|
|
151
96
|
const match = namePattern.exec(text);
|
|
152
97
|
if (!match) continue;
|
|
153
98
|
const after = text.slice(match.index + match[0].length);
|
|
154
|
-
if (!/^\s*:/.test(after)) continue;
|
|
99
|
+
if (!/^\s*:/.test(after)) continue;
|
|
155
100
|
if (earliest === -1 || match.index < earliest) earliest = match.index;
|
|
156
101
|
}
|
|
157
102
|
return earliest;
|
|
158
103
|
}
|
|
159
104
|
|
|
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
|
-
);
|
|
105
|
+
const LEVEL_LINE_PATTERNS = [
|
|
106
|
+
new RegExp('^level\\s*:?$', 'i'),
|
|
107
|
+
new RegExp('^nivel\\s*:?$', 'i'),
|
|
108
|
+
];
|
|
172
109
|
|
|
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
110
|
function stripLeadingMarkup(line) {
|
|
178
111
|
return line
|
|
179
112
|
.trim()
|
|
@@ -182,11 +115,10 @@ function stripLeadingMarkup(line) {
|
|
|
182
115
|
.replace(/^\*\*/, '');
|
|
183
116
|
}
|
|
184
117
|
|
|
185
|
-
/** A line that is pure template scaffolding: discarded on the fallback path. */
|
|
186
118
|
function isScaffoldingLine(line) {
|
|
187
119
|
const stripped = stripLeadingMarkup(line);
|
|
188
|
-
if (
|
|
189
|
-
|
|
120
|
+
if (LEVEL_LINE_PATTERNS.some((pattern) => pattern.test(stripped)))
|
|
121
|
+
return true;
|
|
190
122
|
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
191
123
|
const match = namePattern.exec(stripped);
|
|
192
124
|
return (
|
|
@@ -197,15 +129,9 @@ function isScaffoldingLine(line) {
|
|
|
197
129
|
});
|
|
198
130
|
}
|
|
199
131
|
|
|
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
132
|
const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
|
|
205
133
|
const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
|
|
206
134
|
|
|
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
135
|
function isEndOfInScope(line) {
|
|
210
136
|
const stripped = stripLeadingMarkup(line);
|
|
211
137
|
return HEADING_NAME_PATTERNS.some((namePattern) => {
|
|
@@ -214,9 +140,6 @@ function isEndOfInScope(line) {
|
|
|
214
140
|
});
|
|
215
141
|
}
|
|
216
142
|
|
|
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
143
|
const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
|
|
221
144
|
const KNOWN_EXTENSIONS = [
|
|
222
145
|
'mjs',
|
|
@@ -244,10 +167,8 @@ const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
|
|
|
244
167
|
`[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
|
|
245
168
|
'g',
|
|
246
169
|
);
|
|
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.
|
|
170
|
+
// A flat character class validated in JS afterwards: no nested repeated groups for a
|
|
171
|
+
// backtracking engine to explore.
|
|
251
172
|
const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
|
|
252
173
|
|
|
253
174
|
function looksLikeRelativePath(run) {
|
|
@@ -267,25 +188,20 @@ const STOP_WORDS = new Set(
|
|
|
267
188
|
).split(' '),
|
|
268
189
|
);
|
|
269
190
|
|
|
270
|
-
function statePathFor(sessionId) {
|
|
271
|
-
return join(STATE_ROOT, sessionId, 'state.json');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
191
|
function stripDiacritics(text) {
|
|
275
|
-
return text.normalize('NFD').replace(/
|
|
192
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '');
|
|
276
193
|
}
|
|
277
194
|
|
|
278
195
|
function tokenize(text) {
|
|
279
196
|
return stripDiacritics(String(text).toLowerCase())
|
|
280
197
|
.split(/[^a-z0-9]+/)
|
|
281
|
-
.filter(
|
|
198
|
+
.filter(
|
|
199
|
+
(token) => token.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(token),
|
|
200
|
+
);
|
|
282
201
|
}
|
|
283
202
|
|
|
284
203
|
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
|
|
285
204
|
|
|
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
205
|
function stripTrailingPunctuation(text) {
|
|
290
206
|
let end = text.length;
|
|
291
207
|
while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
|
|
@@ -313,8 +229,11 @@ function mentionedPaths(prompt) {
|
|
|
313
229
|
return [...found].sort();
|
|
314
230
|
}
|
|
315
231
|
|
|
316
|
-
|
|
317
|
-
|
|
232
|
+
function contentAfterHeading(line) {
|
|
233
|
+
const colon = line.indexOf(':');
|
|
234
|
+
return colon === -1 ? '' : line.slice(colon + 1).trim();
|
|
235
|
+
}
|
|
236
|
+
|
|
318
237
|
function identityText(prompt) {
|
|
319
238
|
const lines = String(prompt).split(/\r?\n/);
|
|
320
239
|
const parts = [];
|
|
@@ -332,6 +251,7 @@ function identityText(prompt) {
|
|
|
332
251
|
}
|
|
333
252
|
|
|
334
253
|
if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
|
|
254
|
+
parts.push(contentAfterHeading(strippedLine));
|
|
335
255
|
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
336
256
|
if (isEndOfInScope(lines[cursor])) break;
|
|
337
257
|
parts.push(lines[cursor]);
|
|
@@ -342,7 +262,6 @@ function identityText(prompt) {
|
|
|
342
262
|
return parts.join(' ').trim();
|
|
343
263
|
}
|
|
344
264
|
|
|
345
|
-
/** Fallback path: whole prompt minus lines that are pure scaffolding. */
|
|
346
265
|
function textWithoutScaffolding(prompt) {
|
|
347
266
|
return String(prompt)
|
|
348
267
|
.split(/\r?\n/)
|
|
@@ -351,8 +270,6 @@ function textWithoutScaffolding(prompt) {
|
|
|
351
270
|
.trim();
|
|
352
271
|
}
|
|
353
272
|
|
|
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
273
|
function identitySignature(prompt) {
|
|
357
274
|
const identity =
|
|
358
275
|
identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
|
|
@@ -367,6 +284,7 @@ function identitySignature(prompt) {
|
|
|
367
284
|
features.push(...tokens);
|
|
368
285
|
}
|
|
369
286
|
|
|
287
|
+
// Paths are duplicated so they weigh more than prose: they discriminate tasks best.
|
|
370
288
|
for (const path of mentionedPaths(prompt)) {
|
|
371
289
|
features.push(`path:${path}`, `path:${path}`);
|
|
372
290
|
}
|
|
@@ -374,7 +292,6 @@ function identitySignature(prompt) {
|
|
|
374
292
|
return features;
|
|
375
293
|
}
|
|
376
294
|
|
|
377
|
-
/** Dice coefficient over a multiset of features. */
|
|
378
295
|
function similarity(featuresA, featuresB) {
|
|
379
296
|
if (!Array.isArray(featuresA) || !Array.isArray(featuresB)) return 0;
|
|
380
297
|
if (featuresA.length === 0 || featuresB.length === 0) return 0;
|
|
@@ -395,57 +312,41 @@ function similarity(featuresA, featuresB) {
|
|
|
395
312
|
return (2 * intersection) / (featuresA.length + featuresB.length);
|
|
396
313
|
}
|
|
397
314
|
|
|
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
315
|
function occurrencesFor(state, key) {
|
|
416
316
|
const value = state[key];
|
|
417
317
|
if (!Array.isArray(value)) return [];
|
|
418
318
|
return value.filter((entry) => entry && Array.isArray(entry.signature));
|
|
419
319
|
}
|
|
420
320
|
|
|
421
|
-
function
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
321
|
+
function similarOccurrenceCount(state, signature, similarityThreshold) {
|
|
322
|
+
let count = 0;
|
|
323
|
+
for (const key of Object.keys(state)) {
|
|
324
|
+
for (const entry of occurrencesFor(state, key)) {
|
|
325
|
+
if (similarity(entry.signature, signature) >= similarityThreshold)
|
|
326
|
+
count += 1;
|
|
327
|
+
}
|
|
427
328
|
}
|
|
329
|
+
return count;
|
|
428
330
|
}
|
|
429
331
|
|
|
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
332
|
function identityKey(signature) {
|
|
446
333
|
return createHash('sha256').update(signature.join(' ')).digest('hex');
|
|
447
334
|
}
|
|
448
335
|
|
|
336
|
+
function withoutRelatedKeys(state, key, signature, similarityThreshold) {
|
|
337
|
+
const next = { ...state };
|
|
338
|
+
for (const existingKey of Object.keys(next)) {
|
|
339
|
+
const isRelated =
|
|
340
|
+
existingKey === key ||
|
|
341
|
+
occurrencesFor(next, existingKey).some(
|
|
342
|
+
(entry) =>
|
|
343
|
+
similarity(entry.signature, signature) >= similarityThreshold,
|
|
344
|
+
);
|
|
345
|
+
if (isRelated) delete next[existingKey];
|
|
346
|
+
}
|
|
347
|
+
return next;
|
|
348
|
+
}
|
|
349
|
+
|
|
449
350
|
function denyRepeatedAttempt(count) {
|
|
450
351
|
deny(
|
|
451
352
|
CONFIG_KEY,
|
|
@@ -468,60 +369,45 @@ runGate(
|
|
|
468
369
|
similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
|
|
469
370
|
},
|
|
470
371
|
},
|
|
471
|
-
({ toolName, toolInput, sessionId, parameters }) => {
|
|
372
|
+
({ toolName, toolInput, sessionId, parameters, cwd }) => {
|
|
472
373
|
if (!toolInGroups(toolName, ['delegation'])) return;
|
|
473
374
|
|
|
474
375
|
const prompt = delegationPromptOf(toolInput);
|
|
475
376
|
if (!prompt.trim()) return;
|
|
476
377
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const effectiveSessionId = sessionId || NO_SESSION_BUCKET;
|
|
480
|
-
|
|
481
|
-
const statePath = statePathFor(effectiveSessionId);
|
|
482
|
-
const state = readState(statePath);
|
|
483
|
-
|
|
378
|
+
const stateOptions = { cwd };
|
|
379
|
+
const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
|
|
484
380
|
const signature = identitySignature(prompt);
|
|
485
381
|
const key = identityKey(signature);
|
|
382
|
+
const { similarityThreshold } = parameters;
|
|
486
383
|
|
|
487
384
|
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);
|
|
385
|
+
writeSessionState(
|
|
386
|
+
GATE_ID,
|
|
387
|
+
sessionId,
|
|
388
|
+
withoutRelatedKeys(state, key, signature, similarityThreshold),
|
|
389
|
+
stateOptions,
|
|
390
|
+
);
|
|
507
391
|
return;
|
|
508
392
|
}
|
|
509
393
|
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
394
|
+
const count =
|
|
395
|
+
1 + similarOccurrenceCount(state, signature, similarityThreshold);
|
|
396
|
+
const occurrences = [
|
|
397
|
+
...occurrencesFor(state, key),
|
|
398
|
+
{ signature, seenAt: Date.now() },
|
|
399
|
+
].slice(-MAX_ENTRIES_PER_KEY);
|
|
400
|
+
writeSessionState(
|
|
401
|
+
GATE_ID,
|
|
402
|
+
sessionId,
|
|
403
|
+
{ ...state, [key]: occurrences },
|
|
404
|
+
stateOptions,
|
|
515
405
|
);
|
|
516
406
|
|
|
517
|
-
const
|
|
518
|
-
|
|
407
|
+
const retryThreshold = Math.max(
|
|
408
|
+
MIN_RETRY_THRESHOLD,
|
|
409
|
+
parameters.retryThreshold,
|
|
519
410
|
);
|
|
520
|
-
|
|
521
|
-
writeState(statePath, state);
|
|
522
|
-
|
|
523
|
-
if (count >= parameters.retryThreshold) {
|
|
524
|
-
denyRepeatedAttempt(count);
|
|
525
|
-
}
|
|
411
|
+
if (count >= retryThreshold) denyRepeatedAttempt(count);
|
|
526
412
|
},
|
|
527
413
|
);
|