@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.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +69 -9
  3. package/README.md +68 -7
  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 +11 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +114 -22
  14. package/cli/task.mjs +36 -8
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +1 -1
  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 +98 -212
  29. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  30. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  31. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  32. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  33. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  34. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  35. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  36. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  37. package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
  38. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  39. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  40. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  41. package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
  42. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  43. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  44. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  45. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  46. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  47. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  48. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  49. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  50. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  51. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  52. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  53. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  54. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  55. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  57. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  58. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  59. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  60. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  61. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  62. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  63. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  64. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  65. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  66. package/plugins/gates/hooks/hooks.json +112 -42
  67. package/plugins/gates/hooks/lib/config.mjs +6 -3
  68. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  69. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  70. package/plugins/gates/hooks/lib/git.mjs +262 -0
  71. package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
  72. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  73. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  74. package/plugins/gates/hooks/lib/testing.mjs +137 -0
  75. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  76. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  77. package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
  78. package/registry.json +204 -36
@@ -1,15 +1,23 @@
1
- import { readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ // dependency-skills a dependency that is NEW to the project must be covered by a skill.
2
+ // Only the delta counts: a rewrite of package.json that keeps every existing dependency is
3
+ // not "adding" anything, so the gate compares what will be written (or installed from the
4
+ // shell) against the package.json already on disk. A version bump is never a new dependency.
5
+
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { basename, isAbsolute, join } from 'node:path';
8
+ import { projectRootOf } from '../../lib/config.mjs';
3
9
  import {
4
10
  runGate,
5
11
  deny,
6
12
  toolInGroups,
7
13
  writtenPathOf,
8
14
  writtenContentOf,
15
+ shellCommandOf,
9
16
  } from '../../lib/hook-io.mjs';
10
17
 
11
18
  const GATE_ID = 'dependency-skills';
12
19
  const CONFIG_KEY = 'requireSkillForNewDependency';
20
+ const PACKAGE_FILE = 'package.json';
13
21
 
14
22
  const DEFAULT_DEPS_WITHOUT_OWN_API = [
15
23
  'clsx',
@@ -22,6 +30,15 @@ const DEFAULT_DEPS_WITHOUT_OWN_API = [
22
30
  'regenerator-runtime',
23
31
  ];
24
32
 
33
+ const INSTALL_COMMANDS = [
34
+ { manager: 'npm', verbs: ['i', 'install', 'add'] },
35
+ { manager: 'pnpm', verbs: ['add'] },
36
+ { manager: 'yarn', verbs: ['add'] },
37
+ { manager: 'bun', verbs: ['add'] },
38
+ ];
39
+ const REGISTRY_NAME_PATTERN = /^(?:@[\w.-]+\/)?[\w.-]+$/;
40
+ const SEGMENT_SEPARATOR = /&&|\|\||[;|\n]/;
41
+
25
42
  function normalize(name) {
26
43
  return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
27
44
  }
@@ -31,21 +48,23 @@ function isExempt(dependencyName, dependenciesWithoutOwnApi) {
31
48
  return dependenciesWithoutOwnApi.includes(dependencyName);
32
49
  }
33
50
 
51
+ // statSync (not Dirent.isDirectory) so a skill installed as a junction/symlink counts.
34
52
  function listSkillDirectories(skillsDirectoryPath) {
35
53
  try {
36
- return readdirSync(skillsDirectoryPath, { withFileTypes: true })
37
- .filter((entry) => entry.isDirectory())
38
- .map((entry) => entry.name);
54
+ return readdirSync(skillsDirectoryPath).filter((name) => {
55
+ try {
56
+ return statSync(join(skillsDirectoryPath, name)).isDirectory();
57
+ } catch {
58
+ return false;
59
+ }
60
+ });
39
61
  } catch {
40
- // No skills directory in this project — nothing to cross-check against.
41
62
  return [];
42
63
  }
43
64
  }
44
65
 
45
- // A skill covers a dependency when its normalized name equals the dependency's, or contains
46
- // it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
47
- // bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
48
- // exemption that silenced the warning for an unreviewed dependency.
66
+ // A skill covers a dependency when its normalized name equals the dependency's or contains
67
+ // it as a whole `-` segment; bidirectional substring matching let `rip` cover `stripe`.
49
68
  function skillNameMatches(normalizedDependency, skillDirectoryName) {
50
69
  const normalizedSkill = normalize(skillDirectoryName);
51
70
  if (normalizedSkill === normalizedDependency) return true;
@@ -59,25 +78,100 @@ function hasMatchingSkill(dependencyName, skillDirectories) {
59
78
  );
60
79
  }
61
80
 
62
- function needsSkillReview(dependencyName, parameters, skillDirectories) {
63
- if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
64
- return !hasMatchingSkill(dependencyName, skillDirectories);
81
+ function dependencyNamesOf(parsed) {
82
+ if (!parsed || typeof parsed !== 'object') return [];
83
+ const names = new Set();
84
+ for (const field of ['dependencies', 'devDependencies']) {
85
+ const block = parsed[field];
86
+ if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
87
+ for (const name of Object.keys(block)) names.add(name);
88
+ }
89
+ return [...names];
65
90
  }
66
91
 
67
- /** Parsed `package.json` dependency names, or null when it cannot be read yet. */
68
- function dependencyNamesIn(content) {
69
- let parsed;
92
+ function parseJsonOrNull(text) {
70
93
  try {
71
- parsed = JSON.parse(content);
94
+ return JSON.parse(text);
72
95
  } catch {
73
- // Mid-edit or partially written package.json — nothing reliable to check yet.
74
96
  return null;
75
97
  }
76
- const dependencies = {
77
- ...(parsed.dependencies ?? {}),
78
- ...(parsed.devDependencies ?? {}),
79
- };
80
- return Object.keys(dependencies);
98
+ }
99
+
100
+ function readTextOrEmpty(path) {
101
+ try {
102
+ return readFileSync(path, 'utf8');
103
+ } catch {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ function applyEdit(base, edit) {
109
+ const oldString = String(edit.old_string ?? '');
110
+ const newString = String(edit.new_string ?? '');
111
+ if (!oldString) return base || newString;
112
+ if (edit.replace_all) return base.split(oldString).join(newString);
113
+ const at = base.indexOf(oldString);
114
+ if (at === -1) return base;
115
+ return base.slice(0, at) + newString + base.slice(at + oldString.length);
116
+ }
117
+
118
+ function writtenPackageText(toolInput, diskText) {
119
+ if (Array.isArray(toolInput.edits)) {
120
+ return toolInput.edits.reduce(
121
+ (text, edit) => applyEdit(text, edit ?? {}),
122
+ diskText,
123
+ );
124
+ }
125
+ if (typeof toolInput.old_string === 'string')
126
+ return applyEdit(diskText, toolInput);
127
+ return writtenContentOf(toolInput);
128
+ }
129
+
130
+ function packageNameOf(token) {
131
+ const versionAt = token.indexOf('@', token.startsWith('@') ? 1 : 0);
132
+ const name = versionAt === -1 ? token : token.slice(0, versionAt);
133
+ return REGISTRY_NAME_PATTERN.test(name) ? name : null;
134
+ }
135
+
136
+ function installedPackagesIn(command) {
137
+ const names = [];
138
+ for (const segment of command.split(SEGMENT_SEPARATOR)) {
139
+ const [manager = '', verb = '', ...rest] = segment.trim().split(/\s+/);
140
+ const spec = INSTALL_COMMANDS.find(
141
+ (candidate) => candidate.manager === manager.toLowerCase(),
142
+ );
143
+ if (!spec || !spec.verbs.includes(verb)) continue;
144
+ for (const token of rest) {
145
+ if (token.startsWith('-')) continue;
146
+ const name = packageNameOf(token);
147
+ if (name) names.push(name);
148
+ }
149
+ }
150
+ return names;
151
+ }
152
+
153
+ function resolveAgainst(root, path) {
154
+ return isAbsolute(path) ? path : join(root, path);
155
+ }
156
+
157
+ /** Dependencies the call introduces, or null when the written JSON is not parseable yet. */
158
+ function newDependenciesOf(toolName, toolInput, root) {
159
+ if (toolInGroups(toolName, ['shell'])) {
160
+ const names = installedPackagesIn(shellCommandOf(toolInput));
161
+ if (names.length === 0) return [];
162
+ const disk = dependencyNamesOf(
163
+ parseJsonOrNull(readTextOrEmpty(join(root, PACKAGE_FILE))),
164
+ );
165
+ return names.filter((name) => !disk.includes(name));
166
+ }
167
+ if (!toolInGroups(toolName, ['write'])) return [];
168
+ const filePath = writtenPathOf(toolInput);
169
+ if (basename(filePath) !== PACKAGE_FILE) return [];
170
+ const diskText = readTextOrEmpty(resolveAgainst(root, filePath));
171
+ const written = parseJsonOrNull(writtenPackageText(toolInput, diskText));
172
+ if (written === null) return null;
173
+ const disk = dependencyNamesOf(parseJsonOrNull(diskText));
174
+ return dependencyNamesOf(written).filter((name) => !disk.includes(name));
81
175
  }
82
176
 
83
177
  runGate(
@@ -87,27 +181,21 @@ runGate(
87
181
  enabledByDefault: true,
88
182
  defaultParams: {
89
183
  depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
90
- projectSkillsDir: '.claude/skills',
184
+ projectSkillsDir: join('.claude', 'skills'),
91
185
  },
92
186
  },
93
- ({ toolName, toolInput, parameters }) => {
94
- if (!toolInGroups(toolName, ['write'])) return;
95
-
96
- const filePath = writtenPathOf(toolInput);
97
- if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
98
-
99
- const content = writtenContentOf(toolInput);
100
- if (!content) return;
101
-
102
- const dependencyNames = dependencyNamesIn(content);
103
- if (!dependencyNames || dependencyNames.length === 0) return;
187
+ ({ toolName, toolInput, parameters, cwd }) => {
188
+ const root = projectRootOf(cwd) ?? cwd;
189
+ const newDependencies = newDependenciesOf(toolName, toolInput, root);
190
+ if (!newDependencies || newDependencies.length === 0) return;
104
191
 
105
192
  const skillDirectories = listSkillDirectories(
106
- join(process.cwd(), parameters.projectSkillsDir),
193
+ resolveAgainst(root, parameters.projectSkillsDir),
107
194
  );
108
-
109
- const unmatched = dependencyNames.filter((name) =>
110
- needsSkillReview(name, parameters, skillDirectories),
195
+ const unmatched = newDependencies.filter(
196
+ (name) =>
197
+ !isExempt(name, parameters.depsWithoutOwnApi) &&
198
+ !hasMatchingSkill(name, skillDirectories),
111
199
  );
112
200
  if (unmatched.length === 0) return;
113
201
 
@@ -121,10 +209,3 @@ runGate(
121
209
  );
122
210
  },
123
211
  );
124
-
125
- // Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
126
- // dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
127
- // .ai/skills-baseline.json freeze mechanism — that belongs to a separate
128
- // adopt-stack-and-skills.mjs script that does not exist in this repo. This
129
- // gate only does simple substring matching between dependency name and skill
130
- // directory name, both normalized.
@@ -1,9 +1,17 @@
1
+ // diagnosis-before-patch — a timeout/deadline/retry value that CHANGES gets a reminder to
2
+ // bring the evidence first. Only a change counts: a new file, or a value that stays as it
3
+ // is on disk, is not a symptom being patched. Advisory only, never a block.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { isAbsolute, join } from 'node:path';
7
+ import { projectRootOf } from '../../lib/config.mjs';
1
8
  import {
2
9
  runGate,
3
10
  warn,
4
11
  toolInGroups,
5
12
  writtenContentOf,
6
13
  writtenPathOf,
14
+ compileRegexList,
7
15
  } from '../../lib/hook-io.mjs';
8
16
 
9
17
  const GATE_ID = 'diagnosis-before-patch';
@@ -17,9 +25,57 @@ const DEFAULT_TIMEOUT_PATTERNS = [
17
25
  String.raw`\b(query_?timeout|hard_?deadline)\b`,
18
26
  ];
19
27
 
20
- function extractText(toolName, toolInput) {
21
- if (!toolInGroups(toolName, ['write'])) return '';
22
- return writtenContentOf(toolInput);
28
+ // From a pattern hit, the key and the numeric value that follows it (value may be empty).
29
+ const ASSIGNMENT_PATTERN = /^([\w.$-]+)\s*[:=]?\s*['"]?([\d.]*)/;
30
+ const ASSIGNMENT_WINDOW = 200;
31
+
32
+ function assignmentsIn(text, patterns) {
33
+ const values = new Map();
34
+ for (const pattern of patterns) {
35
+ const global = new RegExp(pattern.source, `${pattern.flags}g`);
36
+ for (const match of text.matchAll(global)) {
37
+ const slice = text.slice(match.index, match.index + ASSIGNMENT_WINDOW);
38
+ const assignment = ASSIGNMENT_PATTERN.exec(slice);
39
+ if (!assignment) continue;
40
+ const [, key, value] = assignment;
41
+ if (!values.has(key)) values.set(key, value);
42
+ }
43
+ }
44
+ return values;
45
+ }
46
+
47
+ function changedKeys(before, after) {
48
+ const changes = [];
49
+ for (const [key, value] of after) {
50
+ if (before.has(key) && before.get(key) !== value)
51
+ changes.push({ key, from: before.get(key), to: value });
52
+ }
53
+ return changes;
54
+ }
55
+
56
+ function readTextOrNull(path) {
57
+ try {
58
+ return readFileSync(path, 'utf8');
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Edit-style tools carry old/new pairs; anything else is compared with the file on disk.
65
+ function beforeAfterPairs(toolInput, root) {
66
+ if (Array.isArray(toolInput.edits)) {
67
+ return toolInput.edits.map((edit) => [
68
+ String(edit?.old_string ?? ''),
69
+ String(edit?.new_string ?? ''),
70
+ ]);
71
+ }
72
+ if (typeof toolInput.old_string === 'string') {
73
+ return [[toolInput.old_string, writtenContentOf(toolInput)]];
74
+ }
75
+ const path = writtenPathOf(toolInput);
76
+ if (!path) return [];
77
+ const onDisk = readTextOrNull(isAbsolute(path) ? path : join(root, path));
78
+ return onDisk === null ? [] : [[onDisk, writtenContentOf(toolInput)]];
23
79
  }
24
80
 
25
81
  runGate(
@@ -27,32 +83,38 @@ runGate(
27
83
  id: GATE_ID,
28
84
  configKey: CONFIG_KEY,
29
85
  enabledByDefault: true,
86
+ severity: 'warn',
30
87
  defaultParams: {
31
88
  timeoutPatterns: DEFAULT_TIMEOUT_PATTERNS,
32
89
  },
33
90
  },
34
- ({ toolName, toolInput, parameters }) => {
35
- const text = extractText(toolName, toolInput);
36
- if (!text) return;
91
+ ({ toolName, toolInput, parameters, cwd }) => {
92
+ if (!toolInGroups(toolName, ['write'])) return;
93
+ const { patterns } = compileRegexList(parameters.timeoutPatterns);
94
+ if (patterns.length === 0) return;
37
95
 
38
- const patterns = parameters.timeoutPatterns.map(
39
- (source) => new RegExp(source, 'i'),
96
+ const root = projectRootOf(cwd) ?? cwd;
97
+ const changes = beforeAfterPairs(toolInput, root).flatMap(
98
+ ([before, after]) =>
99
+ changedKeys(
100
+ assignmentsIn(before, patterns),
101
+ assignmentsIn(after, patterns),
102
+ ),
40
103
  );
41
- const matched = patterns
42
- .map((pattern) => pattern.exec(text))
43
- .find((match) => match !== null);
44
- if (!matched) return;
104
+ if (changes.length === 0) return;
45
105
 
46
106
  const filePath = writtenPathOf(toolInput) || '(unknown path)';
107
+ const described = changes
108
+ .map((change) => `${change.key}: ${change.from} -> ${change.to}`)
109
+ .join(', ');
47
110
  warn(
48
111
  CONFIG_KEY,
49
- `You are writing '${matched[0].trim()}' into ${filePath} — a timeout/deadline/retry ` +
50
- 'value. Before changing a value to fix a symptom ("X is slow/fails"), confirm you ' +
51
- 'have the evidence that proves the cause: a log line from the failing provider/' +
52
- "process, not a hypothesis. If you don't have that log line yet, get it before " +
53
- 'writing this change — do not guess a new number. A timeout should measure ' +
54
- 'inactivity, not total time: a process that is still progressing should not be cut ' +
55
- 'off. This is a warning, not a block — the write proceeds either way.',
112
+ `You are changing a timeout/deadline/retry value in ${filePath} (${described}). ` +
113
+ 'Before changing a value to fix a symptom ("X is slow/fails"), confirm you have ' +
114
+ 'the evidence that proves the cause: a log line from the failing provider/process, ' +
115
+ "not a hypothesis. If you don't have that log line yet, get it before writing this " +
116
+ 'change — do not guess a new number. A timeout should measure inactivity, not total ' +
117
+ 'time. This is a warning, not a block the write proceeds either way.',
56
118
  );
57
119
  },
58
120
  );
@@ -0,0 +1,41 @@
1
+ import { deny, runGate } from '../../lib/hook-io.mjs';
2
+ import {
3
+ DEFAULT_PARAMS,
4
+ GATE_ID,
5
+ isResearchTool,
6
+ queryOf,
7
+ readState,
8
+ } from './shared.mjs';
9
+
10
+ const CONFIG_KEY = 'requireEngramBeforeResearch';
11
+
12
+ const TOPIC_EXCERPT_LENGTH = 80;
13
+
14
+ function denyResearchWithoutMemory(toolName, toolInput) {
15
+ const topic = queryOf(toolInput);
16
+ const suggestion = topic
17
+ ? ` (e.g. mem_search "${topic.slice(0, TOPIC_EXCERPT_LENGTH)}")`
18
+ : '';
19
+ deny(
20
+ CONFIG_KEY,
21
+ `${toolName} was called before any engram lookup in this session. Persistent memory is the ` +
22
+ `first source: call mem_search with the topic you are about to research${suggestion}. ` +
23
+ 'If it returns nothing, research with context7 (libraries) or the web, then mem_save what ' +
24
+ 'you learned before ending the turn.',
25
+ );
26
+ }
27
+
28
+ runGate(
29
+ {
30
+ id: GATE_ID,
31
+ configKey: CONFIG_KEY,
32
+ enabledByDefault: true,
33
+ defaultParams: { ...DEFAULT_PARAMS },
34
+ },
35
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
36
+ if (!isResearchTool(toolName, parameters)) return;
37
+ const state = readState(sessionId, cwd);
38
+ if (state.memSearchCount > 0) return;
39
+ denyResearchWithoutMemory(toolName, toolInput);
40
+ },
41
+ );
@@ -0,0 +1,64 @@
1
+ import { gateParameters, isGateEnabled } from '../../lib/config.mjs';
2
+ import { coerceParameters, readHookPayload } from '../../lib/hook-io.mjs';
3
+ import { CONFIG_KEY, DEFAULT_PARAMS } from './shared.mjs';
4
+
5
+ const PROBE_TIMEOUT_MS = 1500;
6
+ const SESSION_START_EVENT = 'SessionStart';
7
+ const NOT_ENROLLED = 'non_enrolled_pending_mutations';
8
+
9
+ async function getJson(url) {
10
+ const response = await fetch(url, {
11
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
12
+ });
13
+ if (!response.ok) return null;
14
+ return response.json();
15
+ }
16
+
17
+ function withoutTrailingSlash(text) {
18
+ let end = text.length;
19
+ while (end > 0 && text[end - 1] === '/') end -= 1;
20
+ return text.slice(0, end);
21
+ }
22
+
23
+ function speak(context) {
24
+ process.stdout.write(
25
+ JSON.stringify({
26
+ hookSpecificOutput: {
27
+ hookEventName: SESSION_START_EVENT,
28
+ additionalContext: context,
29
+ },
30
+ }),
31
+ );
32
+ }
33
+
34
+ function enrollmentProblem(status, projectName) {
35
+ if (!status || status.reason_code !== NOT_ENROLLED) return null;
36
+ const message = String(status.reason_message ?? '');
37
+ if (projectName && !message.includes(`${projectName}=`)) return null;
38
+ return projectName ?? 'this project';
39
+ }
40
+
41
+ async function main() {
42
+ const cwd = process.cwd();
43
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) return;
44
+ const { parameters } = coerceParameters(
45
+ DEFAULT_PARAMS,
46
+ gateParameters(CONFIG_KEY, cwd),
47
+ );
48
+ const base = withoutTrailingSlash(String(parameters.engramUrl));
49
+ const status = await getJson(`${base}/sync/status`);
50
+ if (!status) return;
51
+ const current = await getJson(
52
+ `${base}/project/current?cwd=${encodeURIComponent(cwd)}`,
53
+ );
54
+ const project = enrollmentProblem(status, current?.project);
55
+ if (!project) return;
56
+ speak(
57
+ `[${CONFIG_KEY}] Engram cloud is configured but "${project}" is not enrolled, so its memories stay ` +
58
+ `local only (daemon reports ${status.reason_code}). Run \`engram cloud enroll ${project}\` once ` +
59
+ 'to replicate them; local SQLite remains the source of truth either way.',
60
+ );
61
+ }
62
+
63
+ readHookPayload();
64
+ main().catch(() => {});
@@ -0,0 +1,90 @@
1
+ import {
2
+ mcpActionSegment,
3
+ mcpServerSegment,
4
+ toolInGroups,
5
+ } from '../../lib/hook-io.mjs';
6
+ import {
7
+ readSessionState,
8
+ writeSessionState,
9
+ } from '../../lib/session-state.mjs';
10
+
11
+ export const GATE_ID = 'engram-first';
12
+ export const CONFIG_KEY = 'requireEngramBeforeResearch';
13
+
14
+ export const DEFAULT_PARAMS = Object.freeze({
15
+ engramServers: ['engram', 'plugin_engram_engram'],
16
+ context7Servers: ['context7', 'plugin_context7_context7'],
17
+ researchTools: ['WebSearch', 'WebFetch'],
18
+ engramUrl: 'http://127.0.0.1:7437',
19
+ requireSaveBeforeStop: true,
20
+ });
21
+
22
+ const MAX_RECORDED_QUERIES = 30;
23
+ const MAX_QUERY_LENGTH = 200;
24
+
25
+ export const EMPTY_STATE = Object.freeze({
26
+ memSearchCount: 0,
27
+ memSearchHits: 0,
28
+ memSearchQueries: [],
29
+ memSaveCount: 0,
30
+ researchCalls: 0,
31
+ lastResearchAt: 0,
32
+ lastMemSaveAt: 0,
33
+ });
34
+
35
+ function serverMatches(toolName, servers) {
36
+ const server = mcpServerSegment(String(toolName ?? '')).toLowerCase();
37
+ return servers.some((name) => String(name).toLowerCase() === server);
38
+ }
39
+
40
+ export function isEngramTool(toolName, parameters) {
41
+ return serverMatches(toolName, parameters.engramServers);
42
+ }
43
+
44
+ export function isContext7Tool(toolName, parameters) {
45
+ return serverMatches(toolName, parameters.context7Servers);
46
+ }
47
+
48
+ export function isResearchTool(toolName, parameters) {
49
+ if (isEngramTool(toolName, parameters)) return false;
50
+ const lowered = String(toolName ?? '').toLowerCase();
51
+ if (parameters.researchTools.some((name) => name.toLowerCase() === lowered))
52
+ return true;
53
+ if (isContext7Tool(toolName, parameters)) return true;
54
+ return toolInGroups(toolName, ['research']);
55
+ }
56
+
57
+ export function engramActionOf(toolName) {
58
+ return mcpActionSegment(String(toolName ?? '')).toLowerCase();
59
+ }
60
+
61
+ export function readState(sessionId, cwd) {
62
+ return {
63
+ ...EMPTY_STATE,
64
+ ...readSessionState(GATE_ID, sessionId, {}, { cwd }),
65
+ };
66
+ }
67
+
68
+ export function writeState(sessionId, state, cwd) {
69
+ return writeSessionState(GATE_ID, sessionId, state, { cwd });
70
+ }
71
+
72
+ export function rememberQuery(state, query) {
73
+ const queries = [
74
+ ...state.memSearchQueries,
75
+ String(query ?? '').slice(0, MAX_QUERY_LENGTH),
76
+ ];
77
+ return queries.slice(-MAX_RECORDED_QUERIES);
78
+ }
79
+
80
+ export function queryOf(toolInput) {
81
+ if (!toolInput || typeof toolInput !== 'object') return '';
82
+ return String(
83
+ toolInput.query ??
84
+ toolInput.title ??
85
+ toolInput.libraryName ??
86
+ toolInput.prompt ??
87
+ toolInput.url ??
88
+ '',
89
+ );
90
+ }
@@ -0,0 +1,23 @@
1
+ import { block, runStopHook } from '../../lib/hook-io.mjs';
2
+ import { CONFIG_KEY, DEFAULT_PARAMS, GATE_ID, readState } from './shared.mjs';
3
+
4
+ runStopHook(
5
+ {
6
+ id: GATE_ID,
7
+ configKey: CONFIG_KEY,
8
+ enabledByDefault: true,
9
+ defaultParams: { ...DEFAULT_PARAMS },
10
+ },
11
+ ({ sessionId, parameters, cwd }) => {
12
+ if (!parameters.requireSaveBeforeStop) return;
13
+ const state = readState(sessionId, cwd);
14
+ if (state.researchCalls === 0) return;
15
+ if (state.lastMemSaveAt >= state.lastResearchAt) return;
16
+ block(
17
+ CONFIG_KEY,
18
+ `This session made ${state.researchCalls} research call(s) (web/context7) and nothing was saved to ` +
19
+ 'engram afterwards. Call mem_save with what the research established (What/Why/Where/Learned, ' +
20
+ 'or mem_session_summary if it was broad), then end the turn. Research that is not saved is repeated next session.',
21
+ );
22
+ },
23
+ );
@@ -0,0 +1,97 @@
1
+ import { gateParameters, isGateEnabled } from '../../lib/config.mjs';
2
+ import {
3
+ allow,
4
+ coerceParameters,
5
+ readHookPayload,
6
+ sessionIdOf,
7
+ toolInputOf,
8
+ toolNameOf,
9
+ toolResponseOf,
10
+ } from '../../lib/hook-io.mjs';
11
+ import {
12
+ CONFIG_KEY,
13
+ DEFAULT_PARAMS,
14
+ engramActionOf,
15
+ isEngramTool,
16
+ isResearchTool,
17
+ queryOf,
18
+ readState,
19
+ rememberQuery,
20
+ writeState,
21
+ } from './shared.mjs';
22
+
23
+ const SAVE_ACTIONS = new Set(['mem_save', 'mem_session_summary', 'mem_update']);
24
+ const NO_RESULT_PATTERN = /no memories found|no results|0 results/i;
25
+
26
+ function responseText(toolResponse) {
27
+ if (typeof toolResponse === 'string') return toolResponse;
28
+ try {
29
+ return JSON.stringify(toolResponse ?? '');
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ function searchHit(toolResponse) {
36
+ const text = responseText(toolResponse);
37
+ return text.length > 0 && !NO_RESULT_PATTERN.test(text);
38
+ }
39
+
40
+ function nextState(state, { toolName, toolInput, toolResponse, parameters }) {
41
+ const now = Date.now();
42
+ if (isEngramTool(toolName, parameters)) {
43
+ const action = engramActionOf(toolName);
44
+ if (action === 'mem_search') {
45
+ return {
46
+ ...state,
47
+ memSearchCount: state.memSearchCount + 1,
48
+ memSearchHits: state.memSearchHits + (searchHit(toolResponse) ? 1 : 0),
49
+ memSearchQueries: rememberQuery(state, queryOf(toolInput)),
50
+ };
51
+ }
52
+ if (SAVE_ACTIONS.has(action)) {
53
+ return {
54
+ ...state,
55
+ memSaveCount: state.memSaveCount + 1,
56
+ lastMemSaveAt: now,
57
+ };
58
+ }
59
+ return state;
60
+ }
61
+ if (isResearchTool(toolName, parameters)) {
62
+ return {
63
+ ...state,
64
+ researchCalls: state.researchCalls + 1,
65
+ lastResearchAt: now,
66
+ };
67
+ }
68
+ return state;
69
+ }
70
+
71
+ function main() {
72
+ const rawPayload = readHookPayload();
73
+ if (rawPayload === null) allow();
74
+ const cwd = process.cwd();
75
+ if (!isGateEnabled(CONFIG_KEY, true, cwd)) allow();
76
+ const { parameters } = coerceParameters(
77
+ DEFAULT_PARAMS,
78
+ gateParameters(CONFIG_KEY, cwd),
79
+ );
80
+ const sessionId = sessionIdOf(rawPayload);
81
+ const context = {
82
+ toolName: toolNameOf(rawPayload) ?? '',
83
+ toolInput: toolInputOf(rawPayload),
84
+ toolResponse: toolResponseOf(rawPayload),
85
+ parameters,
86
+ };
87
+ const state = readState(sessionId, cwd);
88
+ const updated = nextState(state, context);
89
+ if (updated !== state) writeState(sessionId, updated, cwd);
90
+ allow();
91
+ }
92
+
93
+ try {
94
+ main();
95
+ } catch {
96
+ allow();
97
+ }