@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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,81 +1,38 @@
1
- // intent-flow — denies an implementation delegation that has real intent to mutate a
2
- // high-impact area (money, auth, persisted data, public contract, irreversible...)
3
- // without declaring IN SCOPE / OUT OF SCOPE / EDGE CASES, and hard-denies whenever it
4
- // also leaves a high-impact UNKNOWN unresolved.
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
- // ── How this differs from brief-before-delegate ─────────────────────────────────────
7
- // brief-before-delegate asks "was the task thought through" (goal+steps+criterion).
8
- // This gate asks a different, complementary question: "was the SCOPE declared what
9
- // is in, what is explicitly out, and what edge cases were considered for work that
10
- // really intends to touch a sensitive area?" Both run on the same delegation prompt
11
- // independently; neither depends on the other's verdict.
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 { MUTATION_RISK_SIGNAL } from '../../lib/signals.mjs';
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 ("${excerpt}${ellipsis}") does not carry a scope list: missing IN ` +
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
+ }