@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,81 +1,38 @@
|
|
|
1
|
-
// intent-flow — denies an implementation delegation
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// intent-flow — denies an implementation delegation with real intent to mutate a high-impact
|
|
2
|
+
// area (money, auth, persisted data, public contract, irreversible...) unless it declares
|
|
3
|
+
// IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies when it also leaves a high-impact
|
|
4
|
+
// UNKNOWN unresolved.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// ── Two-stage intent check (not lexical match) ──────────────────────────────────────
|
|
14
|
-
// Stage 1 (cheap prefilter): does the raw prompt contain BOTH an implementation verb
|
|
15
|
-
// and a high-impact signal anywhere? If not, there is no candidate — allow immediately.
|
|
16
|
-
// Stage 2 (confirmation, only on candidates): strip quoted/templated text, then require
|
|
17
|
-
// near co-occurrence between an implementation verb and a high-impact signal, and
|
|
18
|
-
// discard a verb whose immediate object is a documentary deliverable (a doc/report/
|
|
19
|
-
// README/.md/.html describing the area is not mutating it). Only surviving (a)+(b)+(c)
|
|
20
|
-
// counts as real intent.
|
|
21
|
-
//
|
|
22
|
-
// ── readOnlySubagents is a declared label, not a verified capability ────────────────
|
|
23
|
-
// This hook cannot check what tools a named subagent actually has — the whitelist
|
|
24
|
-
// exemption is void whenever the prompt itself carries a mutation-risk signal (money/
|
|
25
|
-
// auth/data/write/deploy): the signal in the text outranks the label on the call.
|
|
6
|
+
// Decisions: brief-before-delegate asks "was the task thought through"; this gate asks the
|
|
7
|
+
// complementary "was the SCOPE declared" and the two never depend on each other. Intent is
|
|
8
|
+
// judged by the lib's two-stage check (verb + signal co-occurrence outside quoted text, with a
|
|
9
|
+
// documentary object discarded), not by a bare keyword hit. A read-only subagent name is a
|
|
10
|
+
// declared label, void whenever the prompt itself carries a mutation-risk signal. An empty
|
|
11
|
+
// `highImpactPatterns` means nothing is high-impact, so the gate stays silent.
|
|
26
12
|
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_HIGH_IMPACT_PATTERNS,
|
|
15
|
+
DEFAULT_READ_ONLY_SUBAGENTS,
|
|
16
|
+
buildHighImpactPattern,
|
|
17
|
+
hasRealSensitiveMutation,
|
|
18
|
+
isExemptQuery,
|
|
19
|
+
isImplementationRequest,
|
|
20
|
+
isReadOnlySubagent,
|
|
21
|
+
promptExcerpt,
|
|
22
|
+
stripQuoted,
|
|
23
|
+
} from '../../lib/delegation.mjs';
|
|
27
24
|
import {
|
|
28
25
|
runGate,
|
|
29
26
|
deny,
|
|
30
27
|
toolInGroups,
|
|
31
28
|
delegationPromptOf,
|
|
32
29
|
} from '../../lib/hook-io.mjs';
|
|
33
|
-
import {
|
|
30
|
+
import { withUnicodeWordBoundary } from '../../lib/signals.mjs';
|
|
34
31
|
|
|
35
32
|
const GATE_ID = 'intent-flow';
|
|
36
33
|
const CONFIG_KEY = 'requireScopeListBeforeDelegating';
|
|
37
34
|
|
|
38
|
-
const DEFAULT_READ_ONLY_SUBAGENTS = ['explore', 'claude-code-guide', 'plan'];
|
|
39
|
-
const DEFAULT_HIGH_IMPACT_PATTERNS = [
|
|
40
|
-
'dinero|pago|cobro|money|payment',
|
|
41
|
-
'auth|autenticaci[oó]n|authentication|permiso|permission|token|sesi[oó]n|session|seguridad|security|PII',
|
|
42
|
-
'borrar|delete|drop|migraci[oó]n|migration|schema',
|
|
43
|
-
'contrato|contract|irreversible|producci[oó]n|production|API p[uú]blica|public api',
|
|
44
|
-
];
|
|
45
|
-
|
|
46
35
|
const MIN_REQUEST_LENGTH = 40;
|
|
47
|
-
const CO_OCCURRENCE_WINDOW = 100;
|
|
48
|
-
const VERB_OBJECT_WINDOW = 60;
|
|
49
|
-
// How much of an over-length prompt to quote back in a denial message.
|
|
50
|
-
const PROMPT_EXCERPT_LENGTH = 80;
|
|
51
|
-
|
|
52
|
-
function withUnicodeWordBoundary(alternatives) {
|
|
53
|
-
return new RegExp(
|
|
54
|
-
`(?<![\\p{L}\\p{N}_])(${alternatives})(?![\\p{L}\\p{N}_])`,
|
|
55
|
-
'iu',
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// A prompt-level signal that a whitelisted read-only subagent name should NOT be
|
|
60
|
-
// trusted to exempt this call: the name is a declared label, never a verified
|
|
61
|
-
// capability this hook can check, and real mutation risk in the text must win over it.
|
|
62
|
-
// Deliberately broad — over-including only means this gate's check still runs.
|
|
63
|
-
// Centralized in lib/signals.mjs (ES+EN) — see its header for the class this covers.
|
|
64
|
-
const MUTATION_RISK_SIGNAL_PATTERN = MUTATION_RISK_SIGNAL;
|
|
65
|
-
|
|
66
|
-
const IMPLEMENTATION_VERBS = withUnicodeWordBoundary(
|
|
67
|
-
'implementa|implementar|implement(á|é)|agreg(a|á)|agregar|añad(e|í)|añadir|cre(a|á)|crear|' +
|
|
68
|
-
'arregl(a|á)|arreglar|cambi(a|á)|cambiar|migr(a|á)|migrar|escrib(í|e) c[oó]digo|escribir c[oó]digo|' +
|
|
69
|
-
'corrige|corregir|correg(í|ir)|constru(ye|í)|construir|modific(a|á)|modificar|' +
|
|
70
|
-
'refactoriz(a|á)|refactorizar|elimin(a|á)|eliminar|reescrib(e|í)|reescribir|desplieg(a|á)|desplegar|' +
|
|
71
|
-
'implement|build|fix|migrate|modify|refactor|remove|rewrite|deploy',
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
const QUERY_VERBS = withUnicodeWordBoundary(
|
|
75
|
-
'explica|explic(á|ar)|qu[eé]|c[oó]mo|muestra|mostr(á|ar)|analiza|analiz(á|ar)|investiga|' +
|
|
76
|
-
'investig(á|ar)|revisa|revis(á|ar)|audita|audit(á|ar)|diagnostica|diagnostic(á|ar)|' +
|
|
77
|
-
'explain|what|how|show|analyze|investigate|review|audit|diagnose',
|
|
78
|
-
);
|
|
79
36
|
|
|
80
37
|
const IN_SCOPE_PATTERN = withUnicodeWordBoundary(
|
|
81
38
|
'qu[eé] s[ií]|qu[eé] s[ií] entra|alcance|in scope|lo pedido|se va a hacer|incluye',
|
|
@@ -92,78 +49,6 @@ const UNRESOLVED_UNKNOWN_PATTERN = withUnicodeWordBoundary(
|
|
|
92
49
|
'unknown|no se sabe|no sabemos|sin resolver|no est[aá] claro|no est[aá] definido|' +
|
|
93
50
|
'a definir|por definir|desconocido|no est[aá] decidido',
|
|
94
51
|
);
|
|
95
|
-
const DOCUMENTARY_DELIVERABLE_PATTERN = withUnicodeWordBoundary(
|
|
96
|
-
'documento|documentaci[oó]n|reporte|informe|diagrama|readme|wiki|changelog|' +
|
|
97
|
-
'p[aá]gina de documentaci[oó]n|archivo html|p[aá]gina html|markdown|document|documentation|report',
|
|
98
|
-
);
|
|
99
|
-
const DOCUMENTARY_EXTENSION_PATTERN = /\.(md|html?|adoc)\b/iu;
|
|
100
|
-
|
|
101
|
-
function isReadOnlySubagentName(toolInput, readOnlySubagents) {
|
|
102
|
-
const type = String(
|
|
103
|
-
toolInput.subagent_type ?? toolInput.subagentType ?? '',
|
|
104
|
-
).toLowerCase();
|
|
105
|
-
return new Set(readOnlySubagents.map((name) => name.toLowerCase())).has(type);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** A whitelisted subagent name exempts a call ONLY when the prompt carries no
|
|
109
|
-
* mutation-risk signal. The name is a declared label, never a verified capability this
|
|
110
|
-
* hook can check — a real risk signal in the text must win over it. */
|
|
111
|
-
function isReadOnlySubagent(toolInput, prompt, readOnlySubagents) {
|
|
112
|
-
if (!isReadOnlySubagentName(toolInput, readOnlySubagents)) return false;
|
|
113
|
-
return !MUTATION_RISK_SIGNAL_PATTERN.test(prompt);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function isImplementationRequest(prompt) {
|
|
117
|
-
return IMPLEMENTATION_VERBS.test(prompt);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function isExemptQuery(prompt) {
|
|
121
|
-
if (isImplementationRequest(prompt)) return false; // implementation verb takes priority
|
|
122
|
-
return QUERY_VERBS.test(prompt);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function stripQuoted(text) {
|
|
126
|
-
return text
|
|
127
|
-
.replace(/```[\s\S]*?```/g, ' ')
|
|
128
|
-
.replace(/"[^"\n]{0,300}"/g, ' ')
|
|
129
|
-
.replace(/'[^'\n]{0,300}'/g, ' ');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function allMatches(pattern, text) {
|
|
133
|
-
const flags = pattern.flags.includes('g')
|
|
134
|
-
? pattern.flags
|
|
135
|
-
: `${pattern.flags}g`;
|
|
136
|
-
return [...text.matchAll(new RegExp(pattern.source, flags))];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function verbProducesDocument(text, verbMatch) {
|
|
140
|
-
const from = verbMatch.index;
|
|
141
|
-
const to = from + verbMatch[0].length + VERB_OBJECT_WINDOW;
|
|
142
|
-
const objectOfVerb = text.slice(from, to);
|
|
143
|
-
return (
|
|
144
|
-
DOCUMENTARY_DELIVERABLE_PATTERN.test(objectOfVerb) ||
|
|
145
|
-
DOCUMENTARY_EXTENSION_PATTERN.test(objectOfVerb)
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function hasRealSensitiveMutation(prompt, highImpactPattern) {
|
|
150
|
-
const isCandidate =
|
|
151
|
-
IMPLEMENTATION_VERBS.test(prompt) && highImpactPattern.test(prompt);
|
|
152
|
-
if (!isCandidate) return false; // stage 1: cheap, no stage 2 spent
|
|
153
|
-
|
|
154
|
-
const text = stripQuoted(prompt); // stage 2(a)
|
|
155
|
-
const verbs = allMatches(IMPLEMENTATION_VERBS, text);
|
|
156
|
-
const signals = allMatches(highImpactPattern, text);
|
|
157
|
-
|
|
158
|
-
for (const verb of verbs) {
|
|
159
|
-
if (verbProducesDocument(text, verb)) continue; // stage 2(c)
|
|
160
|
-
for (const signal of signals) {
|
|
161
|
-
if (Math.abs(signal.index - verb.index) <= CO_OCCURRENCE_WINDOW)
|
|
162
|
-
return true; // stage 2(b)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
52
|
|
|
168
53
|
function missingSignals(prompt) {
|
|
169
54
|
const missing = [];
|
|
@@ -189,11 +74,9 @@ function denyUnresolvedUnknown() {
|
|
|
189
74
|
}
|
|
190
75
|
|
|
191
76
|
function denyTooShortForScopeList(prompt) {
|
|
192
|
-
const excerpt = prompt.slice(0, PROMPT_EXCERPT_LENGTH);
|
|
193
|
-
const ellipsis = prompt.length > PROMPT_EXCERPT_LENGTH ? '…' : '';
|
|
194
77
|
deny(
|
|
195
78
|
CONFIG_KEY,
|
|
196
|
-
`This implementation request ("${
|
|
79
|
+
`This implementation request ("${promptExcerpt(prompt)}") does not carry a scope list: missing IN ` +
|
|
197
80
|
'SCOPE, OUT OF SCOPE and EDGE CASES. Add it to the prompt before relaunching.',
|
|
198
81
|
);
|
|
199
82
|
}
|
|
@@ -206,15 +89,6 @@ function denyMissingSignals(missing) {
|
|
|
206
89
|
);
|
|
207
90
|
}
|
|
208
91
|
|
|
209
|
-
function buildHighImpactPattern(highImpactPatterns) {
|
|
210
|
-
return new RegExp(
|
|
211
|
-
(highImpactPatterns ?? []).map((source) => `(?:${source})`).join('|'),
|
|
212
|
-
'iu',
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** True when this delegation is exempt from the whole check: not a recognizable
|
|
217
|
-
* implementation request, a read-only subagent, or an exempt query. */
|
|
218
92
|
function isExempt(toolInput, prompt, readOnlySubagents) {
|
|
219
93
|
if (!prompt.trim()) return true;
|
|
220
94
|
if (isReadOnlySubagent(toolInput, prompt, readOnlySubagents)) return true;
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { builtinModules } from 'node:module';
|
|
3
|
+
import { extname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { projectRootOf } from '../../lib/config.mjs';
|
|
5
|
+
import {
|
|
6
|
+
deny,
|
|
7
|
+
runGate,
|
|
8
|
+
shellCommandOf,
|
|
9
|
+
shellWrittenPaths,
|
|
10
|
+
toolInGroups,
|
|
11
|
+
writtenContentOf,
|
|
12
|
+
writtenPathOf,
|
|
13
|
+
} from '../../lib/hook-io.mjs';
|
|
14
|
+
import { readSessionState } from '../../lib/session-state.mjs';
|
|
15
|
+
|
|
16
|
+
export const GATE_ID = 'library-docs';
|
|
17
|
+
export const CONFIG_KEY = 'requireDocsBeforeUsingNewLibrary';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CODE_EXTENSIONS = [
|
|
20
|
+
'.js',
|
|
21
|
+
'.mjs',
|
|
22
|
+
'.cjs',
|
|
23
|
+
'.ts',
|
|
24
|
+
'.tsx',
|
|
25
|
+
'.jsx',
|
|
26
|
+
'.vue',
|
|
27
|
+
'.svelte',
|
|
28
|
+
'.py',
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_IGNORED_PACKAGES = [];
|
|
31
|
+
const DEFAULT_MAX_SCAN_FILES = 500;
|
|
32
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
33
|
+
'node_modules',
|
|
34
|
+
'.git',
|
|
35
|
+
'dist',
|
|
36
|
+
'build',
|
|
37
|
+
'coverage',
|
|
38
|
+
'.next',
|
|
39
|
+
'out',
|
|
40
|
+
'vendor',
|
|
41
|
+
'.ai',
|
|
42
|
+
]);
|
|
43
|
+
const NODE_BUILTINS = new Set(
|
|
44
|
+
builtinModules.map((name) => name.replace(/^node:/, '')),
|
|
45
|
+
);
|
|
46
|
+
const PYTHON_STDLIB = new Set(
|
|
47
|
+
(
|
|
48
|
+
'os sys re json pathlib typing datetime time math random subprocess collections itertools ' +
|
|
49
|
+
'functools logging unittest dataclasses enum io shutil tempfile argparse asyncio threading ' +
|
|
50
|
+
'socket http urllib csv hashlib base64 uuid copy abc contextlib glob string textwrap ' +
|
|
51
|
+
'statistics decimal fractions pprint traceback warnings inspect importlib pickle sqlite3 ' +
|
|
52
|
+
'xml html email struct queue signal platform getpass secrets zipfile tarfile gzip ' +
|
|
53
|
+
'configparser operator heapq bisect array weakref types numbers cmath ast dis gc atexit ' +
|
|
54
|
+
'select ssl ftplib smtplib mimetypes unicodedata locale gettext calendar zoneinfo ' +
|
|
55
|
+
'concurrent multiprocessing builtins __future__ dataclasses'
|
|
56
|
+
).split(' '),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const JS_IMPORT_PATTERNS = [
|
|
60
|
+
/\bimport\s+['"]([^'"\n]+)['"]/g,
|
|
61
|
+
/\bfrom\s+['"]([^'"\n]+)['"]/g,
|
|
62
|
+
/\brequire\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
63
|
+
/\bimport\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
64
|
+
];
|
|
65
|
+
const PY_IMPORT_LINE = /^import\s+([\w.]+)/;
|
|
66
|
+
const PY_FROM_LINE = /^from\s+([\w.]+)\s+import\b/;
|
|
67
|
+
|
|
68
|
+
function pythonSpecifiers(content) {
|
|
69
|
+
const found = [];
|
|
70
|
+
for (const rawLine of String(content ?? '').split(/\r?\n/)) {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
const match = PY_IMPORT_LINE.exec(line) ?? PY_FROM_LINE.exec(line);
|
|
73
|
+
if (match) found.push(match[1]);
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function javascriptSpecifiers(content) {
|
|
79
|
+
const found = [];
|
|
80
|
+
for (const pattern of JS_IMPORT_PATTERNS) {
|
|
81
|
+
for (const match of String(content ?? '').matchAll(pattern))
|
|
82
|
+
found.push(match[1]);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function packageOf(specifier, isPython) {
|
|
88
|
+
if (isPython) return specifier.split('.')[0];
|
|
89
|
+
if (specifier.startsWith('@'))
|
|
90
|
+
return specifier.split('/').slice(0, 2).join('/');
|
|
91
|
+
return specifier.split('/')[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isExternal(specifier, isPython) {
|
|
95
|
+
if (!specifier || specifier.startsWith('.') || specifier.startsWith('/'))
|
|
96
|
+
return false;
|
|
97
|
+
if (specifier.startsWith('node:') || specifier.startsWith('#')) return false;
|
|
98
|
+
if (isPython) return !PYTHON_STDLIB.has(packageOf(specifier, true));
|
|
99
|
+
if (NODE_BUILTINS.has(packageOf(specifier, false))) return false;
|
|
100
|
+
if (/^[a-z]:[\\/]/i.test(specifier)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function importedPackagesOf(content, filePath) {
|
|
105
|
+
const isPython = extname(filePath).toLowerCase() === '.py';
|
|
106
|
+
const specifiers = isPython
|
|
107
|
+
? pythonSpecifiers(content)
|
|
108
|
+
: javascriptSpecifiers(content);
|
|
109
|
+
const packages = new Set();
|
|
110
|
+
for (const raw of specifiers) {
|
|
111
|
+
const specifier = raw.trim();
|
|
112
|
+
if (isExternal(specifier, isPython))
|
|
113
|
+
packages.add(packageOf(specifier, isPython));
|
|
114
|
+
}
|
|
115
|
+
return [...packages];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function* codeFiles(directory, extensions, budget) {
|
|
119
|
+
const stack = [directory];
|
|
120
|
+
while (stack.length > 0 && budget.left > 0) {
|
|
121
|
+
const current = stack.pop();
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const path = join(current, entry.name);
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name) && !entry.name.startsWith('.'))
|
|
132
|
+
stack.push(path);
|
|
133
|
+
} else if (extensions.includes(extname(entry.name).toLowerCase())) {
|
|
134
|
+
budget.left -= 1;
|
|
135
|
+
yield path;
|
|
136
|
+
if (budget.left === 0) return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function packagesUsedElsewhere(root, writtenPath, extensions, maxFiles) {
|
|
143
|
+
const used = new Set();
|
|
144
|
+
const budget = { left: maxFiles };
|
|
145
|
+
for (const file of codeFiles(root, extensions, budget)) {
|
|
146
|
+
if (relative(file, writtenPath) === '') continue;
|
|
147
|
+
let content;
|
|
148
|
+
try {
|
|
149
|
+
content = readFileSync(file, 'utf8');
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const name of importedPackagesOf(content, file)) used.add(name);
|
|
154
|
+
}
|
|
155
|
+
return used;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function existingImports(absolutePath) {
|
|
159
|
+
if (!existsSync(absolutePath)) return new Set();
|
|
160
|
+
try {
|
|
161
|
+
if (!statSync(absolutePath).isFile()) return new Set();
|
|
162
|
+
return new Set(
|
|
163
|
+
importedPackagesOf(readFileSync(absolutePath, 'utf8'), absolutePath),
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
return new Set();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tokensOf(name) {
|
|
171
|
+
const lowered = name.toLowerCase();
|
|
172
|
+
const short = lowered.startsWith('@') ? lowered.split('/')[1] : lowered;
|
|
173
|
+
return [lowered, short, short.replace(/[-_.]/g, '')].filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mentions(list, name) {
|
|
177
|
+
const haystack = list.map((entry) => String(entry).toLowerCase());
|
|
178
|
+
return tokensOf(name).some((token) =>
|
|
179
|
+
haystack.some((entry) => entry.includes(token)),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function knowledgeStatus(state, name) {
|
|
184
|
+
const searched = mentions(state.memSearchHits ?? [], name);
|
|
185
|
+
const documentation = mentions(state.context7Lookups ?? [], name);
|
|
186
|
+
const saved = mentions(state.memSaves ?? [], name);
|
|
187
|
+
return {
|
|
188
|
+
searched,
|
|
189
|
+
documentation,
|
|
190
|
+
saved,
|
|
191
|
+
known: searched || (documentation && saved),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const HEREDOC_START_PATTERN = /<<-?\s*['"]?(\w+)['"]?(?:\s*>{1,2}\s*\S+)?$/;
|
|
196
|
+
|
|
197
|
+
function extractHeredocBodies(command) {
|
|
198
|
+
const lines = String(command).split(/\r?\n/);
|
|
199
|
+
const bodies = [];
|
|
200
|
+
let collecting = null;
|
|
201
|
+
let body = [];
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (collecting) {
|
|
204
|
+
if (line.trim() === collecting) {
|
|
205
|
+
bodies.push(body.join('\n'));
|
|
206
|
+
collecting = null;
|
|
207
|
+
body = [];
|
|
208
|
+
} else {
|
|
209
|
+
body.push(line);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const match = HEREDOC_START_PATTERN.exec(line.trimEnd());
|
|
214
|
+
if (match) {
|
|
215
|
+
collecting = match[1];
|
|
216
|
+
body = [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return bodies;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function remedyFor(name, status) {
|
|
223
|
+
const steps = [];
|
|
224
|
+
if (!status.searched && !status.documentation)
|
|
225
|
+
steps.push(`1) mem_search "${name} usage" (engram is the first source)`);
|
|
226
|
+
if (!status.documentation)
|
|
227
|
+
steps.push(
|
|
228
|
+
`2) if engram has nothing: context7 resolve-library-id "${name}" then get-library-docs for the API you need`,
|
|
229
|
+
);
|
|
230
|
+
if (status.documentation && !status.saved)
|
|
231
|
+
steps.push(
|
|
232
|
+
`3) mem_save what you learned about ${name} (title mentioning "${name}") so the next session reads it from engram`,
|
|
233
|
+
);
|
|
234
|
+
return steps.join('; ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
runGate(
|
|
238
|
+
{
|
|
239
|
+
id: GATE_ID,
|
|
240
|
+
configKey: CONFIG_KEY,
|
|
241
|
+
enabledByDefault: true,
|
|
242
|
+
defaultParams: {
|
|
243
|
+
codeExtensions: DEFAULT_CODE_EXTENSIONS,
|
|
244
|
+
ignoredPackages: DEFAULT_IGNORED_PACKAGES,
|
|
245
|
+
maxScanFiles: DEFAULT_MAX_SCAN_FILES,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
({ toolName, toolInput, sessionId, parameters, cwd }) => {
|
|
249
|
+
const isWrite = toolInGroups(toolName, ['write']);
|
|
250
|
+
const isShell = toolInGroups(toolName, ['shell']);
|
|
251
|
+
if (!isWrite && !isShell) return;
|
|
252
|
+
|
|
253
|
+
const root = projectRootOf(cwd) ?? cwd;
|
|
254
|
+
const ignored = new Set(
|
|
255
|
+
parameters.ignoredPackages.map((name) => String(name).toLowerCase()),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Collect { path, content } pairs to check.
|
|
259
|
+
const targets = [];
|
|
260
|
+
|
|
261
|
+
if (isWrite) {
|
|
262
|
+
const writtenPath = writtenPathOf(toolInput);
|
|
263
|
+
if (
|
|
264
|
+
writtenPath &&
|
|
265
|
+
parameters.codeExtensions.includes(extname(writtenPath).toLowerCase())
|
|
266
|
+
) {
|
|
267
|
+
targets.push({
|
|
268
|
+
path: writtenPath,
|
|
269
|
+
content: writtenContentOf(toolInput),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isShell) {
|
|
275
|
+
const command = shellCommandOf(toolInput);
|
|
276
|
+
const writtenPaths = shellWrittenPaths(command).filter((path) =>
|
|
277
|
+
parameters.codeExtensions.includes(extname(path).toLowerCase()),
|
|
278
|
+
);
|
|
279
|
+
if (writtenPaths.length > 0) {
|
|
280
|
+
const heredocBodies = extractHeredocBodies(command);
|
|
281
|
+
const allContent = heredocBodies.join('\n');
|
|
282
|
+
for (const path of writtenPaths) {
|
|
283
|
+
targets.push({ path, content: allContent || null });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (targets.length === 0) return;
|
|
289
|
+
|
|
290
|
+
const allBlocked = [];
|
|
291
|
+
const state = readSessionState(GATE_ID, sessionId, {}, { cwd });
|
|
292
|
+
|
|
293
|
+
for (const { path, content } of targets) {
|
|
294
|
+
const absolutePath = isAbsolute(path) ? path : join(root, path);
|
|
295
|
+
|
|
296
|
+
if (content === null) {
|
|
297
|
+
// Shell command writes a code file but content cannot be inspected (no heredoc).
|
|
298
|
+
deny(
|
|
299
|
+
CONFIG_KEY,
|
|
300
|
+
`This shell command writes to ${path} (a code file) via redirection. Use the Write ` +
|
|
301
|
+
'tool for code files so library usage can be verified. Heredocs are extractable ' +
|
|
302
|
+
'but plain redirections are opaque to this gate.',
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const already = existingImports(absolutePath);
|
|
307
|
+
const candidates = importedPackagesOf(content, path).filter(
|
|
308
|
+
(name) => !already.has(name) && !ignored.has(name.toLowerCase()),
|
|
309
|
+
);
|
|
310
|
+
if (candidates.length === 0) continue;
|
|
311
|
+
|
|
312
|
+
const usedElsewhere = packagesUsedElsewhere(
|
|
313
|
+
root,
|
|
314
|
+
absolutePath,
|
|
315
|
+
parameters.codeExtensions,
|
|
316
|
+
parameters.maxScanFiles,
|
|
317
|
+
);
|
|
318
|
+
const unknown = candidates.filter((name) => !usedElsewhere.has(name));
|
|
319
|
+
if (unknown.length === 0) continue;
|
|
320
|
+
|
|
321
|
+
const blocked = unknown
|
|
322
|
+
.map((name) => ({ name, status: knowledgeStatus(state, name) }))
|
|
323
|
+
.filter((entry) => !entry.status.known);
|
|
324
|
+
allBlocked.push(...blocked);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (allBlocked.length === 0) return;
|
|
328
|
+
const lines = allBlocked.map(
|
|
329
|
+
(entry) => `${entry.name}: ${remedyFor(entry.name, entry.status)}`,
|
|
330
|
+
);
|
|
331
|
+
deny(
|
|
332
|
+
CONFIG_KEY,
|
|
333
|
+
`This ${isShell ? 'shell command' : 'write'} introduces ${allBlocked.length} package(s) this project does not use ` +
|
|
334
|
+
`anywhere yet, and nothing in this session shows how to use them. Do not guess an API. ` +
|
|
335
|
+
`${lines.join(' | ')}. Then retry${isShell ? ' using the Write tool' : ''}.`,
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allow,
|
|
3
|
+
mcpActionSegment,
|
|
4
|
+
mcpServerSegment,
|
|
5
|
+
readHookPayload,
|
|
6
|
+
sessionIdOf,
|
|
7
|
+
toolInputOf,
|
|
8
|
+
toolNameOf,
|
|
9
|
+
toolResponseOf,
|
|
10
|
+
} from '../../lib/hook-io.mjs';
|
|
11
|
+
import { updateSessionState } from '../../lib/session-state.mjs';
|
|
12
|
+
|
|
13
|
+
const GATE_ID = 'library-docs';
|
|
14
|
+
const ENGRAM_SERVERS = new Set(['engram', 'plugin_engram_engram']);
|
|
15
|
+
const CONTEXT7_SERVERS = new Set(['context7', 'plugin_context7_context7']);
|
|
16
|
+
const SAVE_ACTIONS = new Set(['mem_save', 'mem_session_summary', 'mem_update']);
|
|
17
|
+
const NO_RESULT_PATTERN = /no memories found|no results|0 results/i;
|
|
18
|
+
const MAX_ENTRIES = 40;
|
|
19
|
+
const MAX_ENTRY_LENGTH = 300;
|
|
20
|
+
|
|
21
|
+
function textOf(value) {
|
|
22
|
+
if (typeof value === 'string') return value;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(value ?? '');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function inputText(toolInput) {
|
|
31
|
+
if (!toolInput || typeof toolInput !== 'object') return '';
|
|
32
|
+
return [
|
|
33
|
+
toolInput.query,
|
|
34
|
+
toolInput.title,
|
|
35
|
+
toolInput.content,
|
|
36
|
+
toolInput.libraryName,
|
|
37
|
+
toolInput.context7CompatibleLibraryID,
|
|
38
|
+
toolInput.topic,
|
|
39
|
+
]
|
|
40
|
+
.filter((value) => typeof value === 'string')
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function remember(list, entry) {
|
|
45
|
+
return [...(list ?? []), String(entry).slice(0, MAX_ENTRY_LENGTH)].slice(
|
|
46
|
+
-MAX_ENTRIES,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function classify(toolName) {
|
|
51
|
+
const server = mcpServerSegment(toolName).toLowerCase();
|
|
52
|
+
const action = mcpActionSegment(toolName).toLowerCase();
|
|
53
|
+
if (ENGRAM_SERVERS.has(server) && action === 'mem_search') return 'search';
|
|
54
|
+
if (ENGRAM_SERVERS.has(server) && SAVE_ACTIONS.has(action)) return 'save';
|
|
55
|
+
if (CONTEXT7_SERVERS.has(server)) return 'docs';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function main() {
|
|
60
|
+
const rawPayload = readHookPayload();
|
|
61
|
+
if (rawPayload === null) allow();
|
|
62
|
+
const toolName = toolNameOf(rawPayload) ?? '';
|
|
63
|
+
const kind = classify(toolName);
|
|
64
|
+
if (!kind) allow();
|
|
65
|
+
const toolInput = toolInputOf(rawPayload);
|
|
66
|
+
const response = textOf(toolResponseOf(rawPayload));
|
|
67
|
+
const text = inputText(toolInput);
|
|
68
|
+
updateSessionState(GATE_ID, sessionIdOf(rawPayload), {}, (state) => {
|
|
69
|
+
if (kind === 'search') {
|
|
70
|
+
const hit = response.length > 0 && !NO_RESULT_PATTERN.test(response);
|
|
71
|
+
return hit
|
|
72
|
+
? {
|
|
73
|
+
...state,
|
|
74
|
+
memSearchHits: remember(state.memSearchHits, `${text} ${response}`),
|
|
75
|
+
}
|
|
76
|
+
: state;
|
|
77
|
+
}
|
|
78
|
+
if (kind === 'save')
|
|
79
|
+
return { ...state, memSaves: remember(state.memSaves, text) };
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
context7Lookups: remember(
|
|
83
|
+
state.context7Lookups,
|
|
84
|
+
`${text} ${response.slice(0, MAX_ENTRY_LENGTH)}`,
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
allow();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
main();
|
|
93
|
+
} catch {
|
|
94
|
+
allow();
|
|
95
|
+
}
|