@devrik-tools/claude-gates 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +69 -9
- package/README.md +68 -7
- package/cli/doctor.mjs +132 -0
- package/cli/evidence.mjs +78 -0
- package/cli/hooks-manifest.mjs +89 -0
- package/cli/index.mjs +124 -6
- package/cli/init.mjs +97 -3
- package/cli/log.mjs +70 -0
- package/cli/materialize.mjs +36 -2
- package/cli/registry.mjs +11 -1
- package/cli/selection.mjs +18 -0
- package/cli/smoke-fixtures.json +114 -22
- package/cli/task.mjs +36 -8
- package/cli/toggle.mjs +125 -0
- package/package.json +1 -1
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/doctor.mjs +47 -1
- package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
- package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
- package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +98 -212
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
- package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
- package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
- package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
- package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
- package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +51 -92
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
- package/plugins/gates/hooks/gates/library-docs/index.mjs +262 -0
- package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
- package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
- package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
- package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
- package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
- package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
- package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
- package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
- package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
- package/plugins/gates/hooks/hooks.json +112 -42
- package/plugins/gates/hooks/lib/config.mjs +6 -3
- package/plugins/gates/hooks/lib/delegation.mjs +352 -0
- package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
- package/plugins/gates/hooks/lib/git.mjs +262 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +448 -83
- package/plugins/gates/hooks/lib/session-state.mjs +150 -0
- package/plugins/gates/hooks/lib/signals.mjs +18 -13
- package/plugins/gates/hooks/lib/testing.mjs +137 -0
- package/plugins/gates/hooks/lib/tools.mjs +144 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -6
- package/registry.json +204 -36
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
46
|
-
// it as a whole
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
function dependencyNamesIn(content) {
|
|
69
|
-
let parsed;
|
|
92
|
+
function parseJsonOrNull(text) {
|
|
70
93
|
try {
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
184
|
+
projectSkillsDir: join('.claude', 'skills'),
|
|
91
185
|
},
|
|
92
186
|
},
|
|
93
|
-
({ toolName, toolInput, parameters }) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
193
|
+
resolveAgainst(root, parameters.projectSkillsDir),
|
|
107
194
|
);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
"
|
|
53
|
-
'
|
|
54
|
-
'
|
|
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
|
+
}
|