claude-dev-env 8.78.1 → 8.78.3
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/bin/install.mjs +28 -11
- package/bin/install.policy-lint.test.mjs +1 -1
- package/bin/install.profile-settings.test.mjs +1 -1
- package/bin/install.prompt-hook.test.mjs +105 -0
- package/bin/install.prune.test.mjs +2 -2
- package/bin/install.shared-settings.test.mjs +1 -1
- package/bin/install.test.mjs +11 -11
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +4 -4
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/git-hooks/test_installed_policy_lint.py +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/test_context_budget_hooks.py +1 -1
- package/hooks/test_hook_feature_map.py +1 -0
- package/hooks/workflow/test_auto_formatter.py +1 -1
- package/package.json +1 -1
- package/scripts/ci/install-playtest.mjs +1 -0
- package/scripts/ci/installer-lifecycle.mjs +1 -1
- package/scripts/tests/test_codex_compat_materializer.py +6 -1
package/bin/install.mjs
CHANGED
|
@@ -131,6 +131,7 @@ const AGENTS_HOME = INSTALL_ROOT_RESOLUTION.agentsHome;
|
|
|
131
131
|
const MANIFEST_FILE = INSTALL_ROOT_RESOLUTION.manifestFilePath;
|
|
132
132
|
const MYPY_INI_INSTALL_PATH = INSTALL_ROOT_RESOLUTION.mypyIniInstallPath;
|
|
133
133
|
const PACKAGE_NAME = 'claude-dev-env';
|
|
134
|
+
const MANAGED_PROMPT_HOOK_STATUS_PREFIX = `${PACKAGE_NAME}: `;
|
|
134
135
|
const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
135
136
|
const packageRequire = createRequire(import.meta.url);
|
|
136
137
|
|
|
@@ -1274,6 +1275,7 @@ export function managedHookScriptRelativePaths(hooksConfig) {
|
|
|
1274
1275
|
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
1275
1276
|
for (const sourceGroup of matcherGroups) {
|
|
1276
1277
|
for (const hook of sourceGroup.hooks) {
|
|
1278
|
+
if (typeof hook.command !== 'string') continue;
|
|
1277
1279
|
for (const scriptMatch of hook.command.matchAll(scriptReferencePattern)) {
|
|
1278
1280
|
relativePaths.add(scriptMatch[1]);
|
|
1279
1281
|
}
|
|
@@ -1322,6 +1324,21 @@ function managedPackageSourceRoots() {
|
|
|
1322
1324
|
return [...new Set([PACKAGE_ROOT, ...dependencyRoots])];
|
|
1323
1325
|
}
|
|
1324
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Report whether a hook entry is a model-evaluated hook this installer wrote.
|
|
1329
|
+
*
|
|
1330
|
+
* A prompt or agent hook names no script, so the installer marks its own with a
|
|
1331
|
+
* status message that starts with the managed prefix.
|
|
1332
|
+
*
|
|
1333
|
+
* @param {unknown} hook One hook entry from settings.json.
|
|
1334
|
+
* @returns {boolean} True when the entry carries no command and the managed status prefix.
|
|
1335
|
+
*/
|
|
1336
|
+
export function isManagedPromptHook(hook) {
|
|
1337
|
+
return typeof hook?.command !== 'string'
|
|
1338
|
+
&& typeof hook?.statusMessage === 'string'
|
|
1339
|
+
&& hook.statusMessage.startsWith(MANAGED_PROMPT_HOOK_STATUS_PREFIX);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1325
1342
|
/**
|
|
1326
1343
|
* Reports whether a settings.json hook command points at one of this installer's
|
|
1327
1344
|
* managed scripts, no matter how the home directory was written ($HOME, ~,
|
|
@@ -1512,8 +1529,8 @@ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePath
|
|
|
1512
1529
|
if (!Array.isArray(existingGroups)) return;
|
|
1513
1530
|
settings.hooks[eventType] = retainedMatcherGroups(
|
|
1514
1531
|
existingGroups,
|
|
1515
|
-
|
|
1516
|
-
|
|
1532
|
+
hook => isManagedPromptHook(hook) || commandReferencesManagedHook(
|
|
1533
|
+
hook?.command, managedHookRelativePaths, sharedSettingsRealPath,
|
|
1517
1534
|
),
|
|
1518
1535
|
).keptGroups;
|
|
1519
1536
|
}
|
|
@@ -1587,6 +1604,7 @@ export function mergeHooksIntoSettings(
|
|
|
1587
1604
|
pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths, sharedSettingsRealPath);
|
|
1588
1605
|
for (const sourceGroup of matcherGroups) {
|
|
1589
1606
|
const rewrittenHooks = sourceGroup.hooks.map(hook => {
|
|
1607
|
+
if (typeof hook.command !== 'string') return hook;
|
|
1590
1608
|
let command = hook.command;
|
|
1591
1609
|
command = command.replace(
|
|
1592
1610
|
/r(['"])\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\1/g,
|
|
@@ -1609,7 +1627,7 @@ export function mergeHooksIntoSettings(
|
|
|
1609
1627
|
if (existingIndex >= 0) {
|
|
1610
1628
|
const existing = settings.hooks[eventType][existingIndex];
|
|
1611
1629
|
const userHooks = (groupHookEntries(existing) || []).filter(
|
|
1612
|
-
hook => !commandReferencesManagedHook(
|
|
1630
|
+
hook => !isManagedPromptHook(hook) && !commandReferencesManagedHook(
|
|
1613
1631
|
hook?.command, managedHookRelativePaths, sharedSettingsRealPath,
|
|
1614
1632
|
)
|
|
1615
1633
|
);
|
|
@@ -1647,7 +1665,7 @@ export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths
|
|
|
1647
1665
|
if (!Array.isArray(matcherGroups)) continue;
|
|
1648
1666
|
const { keptGroups } = retainedMatcherGroups(
|
|
1649
1667
|
matcherGroups,
|
|
1650
|
-
|
|
1668
|
+
hook => isManagedPromptHook(hook) || commandReferencesManagedHook(hook?.command, managedHookRelativePaths),
|
|
1651
1669
|
);
|
|
1652
1670
|
settings.hooks[eventType] = keptGroups;
|
|
1653
1671
|
if (keptGroups.length === 0) delete settings.hooks[eventType];
|
|
@@ -1830,12 +1848,11 @@ function commandReferencesRetiredHook(commandString, retiredHookRelativePaths, o
|
|
|
1830
1848
|
*
|
|
1831
1849
|
* Every settings walk shares this pass, so a settings.json a person or another
|
|
1832
1850
|
* tool wrote meets one set of shape rules. A group carrying no hooks array is
|
|
1833
|
-
* handed back untouched, and
|
|
1834
|
-
*
|
|
1835
|
-
* installer never wrote — so a shape this installer does not recognize survives.
|
|
1851
|
+
* handed back untouched, and each entry reaches the predicate as-is, whatever its
|
|
1852
|
+
* shape, so a shape this installer does not recognize survives.
|
|
1836
1853
|
*
|
|
1837
1854
|
* @param {object[]} matcherGroups The event's matcher groups from settings.json.
|
|
1838
|
-
* @param {(
|
|
1855
|
+
* @param {(hook: unknown) => boolean} shouldRemoveHook Takes one hook entry, a command hook or a prompt hook, and returns true to drop it.
|
|
1839
1856
|
* @returns {{keptGroups: object[], removedCount: number}} The surviving groups and how many hooks left.
|
|
1840
1857
|
*/
|
|
1841
1858
|
function retainedMatcherGroups(matcherGroups, shouldRemoveHook) {
|
|
@@ -1847,7 +1864,7 @@ function retainedMatcherGroups(matcherGroups, shouldRemoveHook) {
|
|
|
1847
1864
|
keptGroups.push(group);
|
|
1848
1865
|
continue;
|
|
1849
1866
|
}
|
|
1850
|
-
const keptHooks = hookEntries.filter(hook => !shouldRemoveHook(hook
|
|
1867
|
+
const keptHooks = hookEntries.filter(hook => !shouldRemoveHook(hook));
|
|
1851
1868
|
removedCount += hookEntries.length - keptHooks.length;
|
|
1852
1869
|
if (keptHooks.length > 0) keptGroups.push({ ...group, hooks: keptHooks });
|
|
1853
1870
|
}
|
|
@@ -1888,8 +1905,8 @@ function stripRetiredHookEntries(settings, retiredHookRelativePaths, ownership)
|
|
|
1888
1905
|
if (!Array.isArray(matcherGroups)) continue;
|
|
1889
1906
|
const eventOutcome = retainedMatcherGroups(
|
|
1890
1907
|
matcherGroups,
|
|
1891
|
-
|
|
1892
|
-
|
|
1908
|
+
hook => commandReferencesRetiredHook(
|
|
1909
|
+
hook?.command, retiredHookRelativePaths, ownership,
|
|
1893
1910
|
),
|
|
1894
1911
|
);
|
|
1895
1912
|
removedCount += eventOutcome.removedCount;
|
|
@@ -86,7 +86,7 @@ function legacySettings() {
|
|
|
86
86
|
|
|
87
87
|
function allCommands(settings) {
|
|
88
88
|
return Object.values(settings.hooks).flatMap(groups =>
|
|
89
|
-
groups.flatMap(group => group.hooks.map(hook => hook.command)));
|
|
89
|
+
groups.flatMap(group => group.hooks.map(hook => hook.command ?? '')));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
test('every retired write registration already has an installer-owned prune path', () => {
|
|
@@ -47,7 +47,7 @@ function runCoreInstall(homeDirectory, profileRoot) {
|
|
|
47
47
|
function stopHookCommands(settingsPath) {
|
|
48
48
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
49
49
|
return (settings.hooks?.Stop ?? []).flatMap(
|
|
50
|
-
(eachGroup) => (eachGroup.hooks ?? []).map((eachHook) => eachHook.command.replace(/\\/g, '/')),
|
|
50
|
+
(eachGroup) => (eachGroup.hooks ?? []).map((eachHook) => (eachHook.command ?? '').replace(/\\/g, '/')),
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { isManagedPromptHook, mergeHooksIntoSettings, pruneManagedHooksFromSettings } from './install.mjs';
|
|
6
|
+
|
|
7
|
+
const SHIPPED_HOOKS_CONFIG = JSON.parse(readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8'));
|
|
8
|
+
|
|
9
|
+
const REPLY_MATCHER = 'mcp__hearthbot__reply';
|
|
10
|
+
const EXAMPLE_HOME = '/home/example';
|
|
11
|
+
const USER_PROMPT_HOOK = { type: 'prompt', prompt: 'User check. $ARGUMENTS', statusMessage: 'my own check' };
|
|
12
|
+
|
|
13
|
+
function modelHookEntries(hooksConfig) {
|
|
14
|
+
return Object.entries(hooksConfig.hooks).flatMap(([eventName, matcherGroups]) => matcherGroups.flatMap(
|
|
15
|
+
eachGroup => (eachGroup.hooks ?? [])
|
|
16
|
+
.filter(eachHook => typeof eachHook.command !== 'string')
|
|
17
|
+
.map(eachHook => ({ eventName, matcher: eachGroup.matcher ?? '', hook: eachHook })),
|
|
18
|
+
));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shippedReplyJudgeEntries() {
|
|
22
|
+
return modelHookEntries(SHIPPED_HOOKS_CONFIG).filter(
|
|
23
|
+
eachEntry => eachEntry.eventName === 'PreToolUse' && eachEntry.matcher.split('|').includes(REPLY_MATCHER),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const [SHIPPED_REPLY_JUDGE_ENTRY] = shippedReplyJudgeEntries();
|
|
28
|
+
assert.ok(SHIPPED_REPLY_JUDGE_ENTRY, 'the shipped hooks.json needs a claim judge on reply');
|
|
29
|
+
const MANAGED_STATUS = SHIPPED_REPLY_JUDGE_ENTRY.hook.statusMessage;
|
|
30
|
+
|
|
31
|
+
function mergeInto(settings, hooksConfig) {
|
|
32
|
+
mergeHooksIntoSettings(settings, hooksConfig, `${EXAMPLE_HOME}/.claude`, 'python3', null, EXAMPLE_HOME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hooksConfigWithPrompt(promptText) {
|
|
36
|
+
return {
|
|
37
|
+
hooks: {
|
|
38
|
+
PreToolUse: [
|
|
39
|
+
{
|
|
40
|
+
matcher: REPLY_MATCHER,
|
|
41
|
+
hooks: [{ type: 'prompt', prompt: promptText, timeout: 30, statusMessage: MANAGED_STATUS }],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function replyHooks(settings) {
|
|
49
|
+
return (settings.hooks?.PreToolUse ?? [])
|
|
50
|
+
.filter(eachGroup => eachGroup.matcher === REPLY_MATCHER)
|
|
51
|
+
.flatMap(eachGroup => eachGroup.hooks ?? []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('should keep one managed prompt hook across reinstalls and carry the new prompt text', () => {
|
|
55
|
+
const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
|
|
56
|
+
|
|
57
|
+
mergeInto(settings, hooksConfigWithPrompt('First judge. $ARGUMENTS'));
|
|
58
|
+
mergeInto(settings, hooksConfigWithPrompt('Second judge. $ARGUMENTS'));
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
replyHooks(settings).map(eachHook => eachHook.prompt),
|
|
62
|
+
['User check. $ARGUMENTS', 'Second judge. $ARGUMENTS'],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('should remove the managed prompt hook on uninstall and keep the user prompt hook', () => {
|
|
67
|
+
const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
|
|
68
|
+
mergeInto(settings, hooksConfigWithPrompt('Judge. $ARGUMENTS'));
|
|
69
|
+
|
|
70
|
+
pruneManagedHooksFromSettings(settings, new Set());
|
|
71
|
+
|
|
72
|
+
assert.deepEqual(replyHooks(settings), [USER_PROMPT_HOOK]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('should mark the shipped claim judge on reply, and any other shipped model hook, as managed', () => {
|
|
76
|
+
assert.ok(modelHookEntries(SHIPPED_HOOKS_CONFIG).every(eachEntry => isManagedPromptHook(eachEntry.hook)));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('should keep the shipped claim judge once, under its own event and matcher, after two installs', () => {
|
|
80
|
+
const settings = {};
|
|
81
|
+
mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
|
|
82
|
+
mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(modelHookEntries(settings), modelHookEntries(SHIPPED_HOOKS_CONFIG));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('should drop a managed prompt hook left under a retired matcher of an event the shipped config lists', () => {
|
|
88
|
+
const staleMatcher = 'mcp__hearthbot__retired';
|
|
89
|
+
const judgeOnlyMatcher = 'mcp__hearthbot__retired_alone';
|
|
90
|
+
const oldJudge = { type: 'prompt', prompt: 'Old judge. $ARGUMENTS', statusMessage: MANAGED_STATUS };
|
|
91
|
+
const settings = {
|
|
92
|
+
hooks: {
|
|
93
|
+
PreToolUse: [
|
|
94
|
+
{ matcher: staleMatcher, hooks: [oldJudge, USER_PROMPT_HOOK] },
|
|
95
|
+
{ matcher: judgeOnlyMatcher, hooks: [oldJudge] },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
mergeInto(settings, structuredClone(SHIPPED_HOOKS_CONFIG));
|
|
101
|
+
|
|
102
|
+
const staleGroup = settings.hooks.PreToolUse.find(eachGroup => eachGroup.matcher === staleMatcher);
|
|
103
|
+
assert.deepEqual(staleGroup.hooks, [USER_PROMPT_HOOK]);
|
|
104
|
+
assert.equal(settings.hooks.PreToolUse.some(eachGroup => eachGroup.matcher === judgeOnlyMatcher), false);
|
|
105
|
+
});
|
|
@@ -969,7 +969,7 @@ function allHookCommands(settings) {
|
|
|
969
969
|
return Object.values(settings.hooks || {})
|
|
970
970
|
.flat()
|
|
971
971
|
.flatMap(group => group.hooks || [])
|
|
972
|
-
.map(hook => hook.command);
|
|
972
|
+
.map(hook => hook.command ?? '');
|
|
973
973
|
}
|
|
974
974
|
|
|
975
975
|
/**
|
|
@@ -992,7 +992,7 @@ function seedRetiredManagedHook(sandbox) {
|
|
|
992
992
|
const settings = readSettings(sandbox.claudeDirectory);
|
|
993
993
|
const liveGroup = settings.hooks[RETIRED_HOOK_EVENT_TYPE]
|
|
994
994
|
.find(group => group.hooks.some(
|
|
995
|
-
hook => hook.command.includes(DISPATCHER_HOOK_COMMAND_SEGMENT),
|
|
995
|
+
hook => (hook.command ?? '').includes(DISPATCHER_HOOK_COMMAND_SEGMENT),
|
|
996
996
|
));
|
|
997
997
|
liveGroup.hooks.push({ type: 'command', command: retiredHookCommand });
|
|
998
998
|
liveGroup.hooks.push({ type: 'command', command: USER_HOOK_COMMAND });
|
|
@@ -78,7 +78,7 @@ function expectedCountByScript(hooksConfig) {
|
|
|
78
78
|
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
79
79
|
for (const group of matcherGroups) {
|
|
80
80
|
for (const hook of group.hooks) {
|
|
81
|
-
for (const match of hook.command.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g)) {
|
|
81
|
+
for (const match of (hook.command ?? '').matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g)) {
|
|
82
82
|
countByScript.set(match[1], (countByScript.get(match[1]) ?? 0) + 1);
|
|
83
83
|
}
|
|
84
84
|
}
|
package/bin/install.test.mjs
CHANGED
|
@@ -902,7 +902,7 @@ function countManagedRunAllValidatorsHooks(settings) {
|
|
|
902
902
|
let runAllValidatorsCount = 0;
|
|
903
903
|
for (const group of writeEditGroups) {
|
|
904
904
|
for (const hook of group.hooks) {
|
|
905
|
-
if (hook.command.includes('run_all_validators')) {
|
|
905
|
+
if ((hook.command ?? '').includes('run_all_validators')) {
|
|
906
906
|
runAllValidatorsCount++;
|
|
907
907
|
}
|
|
908
908
|
}
|
|
@@ -1238,7 +1238,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1238
1238
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
1239
1239
|
);
|
|
1240
1240
|
const shippedCommands = Object.values(shippedHooks.hooks).flatMap(matcherGroups => (
|
|
1241
|
-
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
|
|
1241
|
+
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command ?? ''))
|
|
1242
1242
|
));
|
|
1243
1243
|
assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, retiredPaths);
|
|
1244
1244
|
for (const retiredPath of retiredPaths) {
|
|
@@ -1343,7 +1343,7 @@ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one d
|
|
|
1343
1343
|
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
1344
1344
|
|
|
1345
1345
|
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
1346
|
-
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1346
|
+
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command ?? ''));
|
|
1347
1347
|
|
|
1348
1348
|
const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
|
|
1349
1349
|
assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
|
|
@@ -1376,7 +1376,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
|
|
|
1376
1376
|
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
1377
1377
|
|
|
1378
1378
|
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
1379
|
-
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1379
|
+
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command ?? ''));
|
|
1380
1380
|
|
|
1381
1381
|
const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
|
|
1382
1382
|
assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
|
|
@@ -1407,7 +1407,7 @@ test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle ho
|
|
|
1407
1407
|
);
|
|
1408
1408
|
|
|
1409
1409
|
const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
|
|
1410
|
-
const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1410
|
+
const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command ?? ''));
|
|
1411
1411
|
const preDispatcherCommands = allPreCommands.filter(isPreToolUseDispatcherCommand);
|
|
1412
1412
|
assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
|
|
1413
1413
|
|
|
@@ -1419,18 +1419,18 @@ test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle ho
|
|
|
1419
1419
|
|
|
1420
1420
|
const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
|
|
1421
1421
|
const postDispatcherCommands = allPostToolUseGroups
|
|
1422
|
-
.flatMap(group => group.hooks.map(hook => hook.command))
|
|
1422
|
+
.flatMap(group => group.hooks.map(hook => hook.command ?? ''))
|
|
1423
1423
|
.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
|
|
1424
1424
|
assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
|
|
1425
1425
|
const allCommands = Object.values(shippedHooksConfig.hooks).flatMap(matcherGroups => (
|
|
1426
|
-
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
|
|
1426
|
+
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command ?? ''))
|
|
1427
1427
|
));
|
|
1428
1428
|
for (const retiredPath of RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS) {
|
|
1429
1429
|
assert.ok(allCommands.every(command => !command.includes(retiredPath)), retiredPath);
|
|
1430
1430
|
}
|
|
1431
1431
|
|
|
1432
1432
|
const allSessionStartCommands = (shippedHooksConfig.hooks.SessionStart || [])
|
|
1433
|
-
.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1433
|
+
.flatMap(group => group.hooks.map(hook => hook.command ?? ''));
|
|
1434
1434
|
assert.equal(
|
|
1435
1435
|
allSessionStartCommands.some(command => command.includes('untracked_repo_detector.py')),
|
|
1436
1436
|
false,
|
|
@@ -1439,7 +1439,7 @@ test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle ho
|
|
|
1439
1439
|
|
|
1440
1440
|
const writePathCommands = allPreToolUseGroups
|
|
1441
1441
|
.filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
|
|
1442
|
-
.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1442
|
+
.flatMap(group => group.hooks.map(hook => hook.command ?? ''));
|
|
1443
1443
|
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
1444
1444
|
const foldedBasename = foldedPath.split('/').pop();
|
|
1445
1445
|
const standaloneFoldedCommands = writePathCommands.filter(
|
|
@@ -1543,7 +1543,7 @@ test('mergeHooksIntoSettings is idempotent for the PostToolUse dispatcher across
|
|
|
1543
1543
|
mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
1544
1544
|
|
|
1545
1545
|
const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
|
|
1546
|
-
const dispatcherCommands = writeEditGroup.hooks.filter(hook => hook.command.includes('post_tool_use_dispatcher.py'));
|
|
1546
|
+
const dispatcherCommands = writeEditGroup.hooks.filter(hook => (hook.command ?? '').includes('post_tool_use_dispatcher.py'));
|
|
1547
1547
|
assert.equal(dispatcherCommands.length, 1, 'PostToolUse dispatcher must appear exactly once after two merges');
|
|
1548
1548
|
assert.equal(writeEditGroup.hooks.length, 1);
|
|
1549
1549
|
});
|
|
@@ -1598,7 +1598,7 @@ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when th
|
|
|
1598
1598
|
|
|
1599
1599
|
const writeEditGroup = (settings.hooks.PreToolUse || []).find(group => group.matcher === 'Write|Edit');
|
|
1600
1600
|
if (writeEditGroup) {
|
|
1601
|
-
const runnerSurvivors = writeEditGroup.hooks.filter(hook => hook.command.includes('run_all_validators'));
|
|
1601
|
+
const runnerSurvivors = writeEditGroup.hooks.filter(hook => (hook.command ?? '').includes('run_all_validators'));
|
|
1602
1602
|
assert.equal(runnerSurvivors.length, 0);
|
|
1603
1603
|
}
|
|
1604
1604
|
|
|
@@ -720,7 +720,7 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
|
|
|
720
720
|
hooks_json_text = (_HOOKS_ROOT / "hooks.json").read_text(encoding="utf-8")
|
|
721
721
|
hooks_configuration = json.loads(hooks_json_text)
|
|
722
722
|
all_pre_tool_use_commands = [
|
|
723
|
-
each_hook
|
|
723
|
+
each_hook.get("command", "")
|
|
724
724
|
for each_group in hooks_configuration["hooks"]["PreToolUse"]
|
|
725
725
|
for each_hook in each_group["hooks"]
|
|
726
726
|
]
|
|
@@ -740,11 +740,11 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
|
|
|
740
740
|
def _standalone_apply_patch_code_rules_commands(all_pre_tool_use_groups: list[dict]) -> list[str]:
|
|
741
741
|
"""Return every standalone apply_patch command naming code_rules_enforcer.py."""
|
|
742
742
|
return [
|
|
743
|
-
each_hook
|
|
743
|
+
each_hook.get("command", "")
|
|
744
744
|
for each_group in all_pre_tool_use_groups
|
|
745
745
|
if each_group["matcher"] == APPLY_PATCH_TOOL_NAME
|
|
746
746
|
for each_hook in each_group["hooks"]
|
|
747
|
-
if "code_rules_enforcer.py" in each_hook
|
|
747
|
+
if "code_rules_enforcer.py" in each_hook.get("command", "")
|
|
748
748
|
]
|
|
749
749
|
|
|
750
750
|
|
|
@@ -753,7 +753,7 @@ def _dispatcher_matchers(all_pre_tool_use_groups: list[dict]) -> list[str]:
|
|
|
753
753
|
return [
|
|
754
754
|
each_group["matcher"]
|
|
755
755
|
for each_group in all_pre_tool_use_groups
|
|
756
|
-
if "pre_tool_use_dispatcher.py" in each_group["hooks"][0]
|
|
756
|
+
if "pre_tool_use_dispatcher.py" in each_group["hooks"][0].get("command", "")
|
|
757
757
|
]
|
|
758
758
|
|
|
759
759
|
|
|
@@ -5,6 +5,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
|
+
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, claims labeled as unchecked, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
8
9
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
9
10
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
10
11
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
@@ -17,6 +18,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
17
18
|
## When it fires
|
|
18
19
|
|
|
19
20
|
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
|
|
21
|
+
- The claim judge runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision`, timeout `30` seconds in `hooks.json`.
|
|
20
22
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
21
23
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
22
24
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
@@ -33,6 +35,7 @@ Preconditions:
|
|
|
33
35
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
34
36
|
|
|
35
37
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
38
|
+
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
|
|
36
39
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
37
40
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
38
41
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
@@ -123,7 +123,7 @@ def _install_twice(home: Path, settings_path: Path) -> bytes:
|
|
|
123
123
|
def _assert_retired_registrations(managed_root: Path, settings_bytes: bytes, foreign_command: str) -> None:
|
|
124
124
|
settings = json.loads(settings_bytes)
|
|
125
125
|
all_commands = [
|
|
126
|
-
each_hook
|
|
126
|
+
each_hook.get("command", "")
|
|
127
127
|
for each_groups in settings["hooks"].values()
|
|
128
128
|
for each_group in each_groups
|
|
129
129
|
for each_hook in each_group["hooks"]
|
package/hooks/hooks.json
CHANGED
|
@@ -142,6 +142,18 @@
|
|
|
142
142
|
}
|
|
143
143
|
]
|
|
144
144
|
},
|
|
145
|
+
{
|
|
146
|
+
"matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision",
|
|
147
|
+
"hooks": [
|
|
148
|
+
{
|
|
149
|
+
"type": "prompt",
|
|
150
|
+
"prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; a claim labeled as not yet checked; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Fact with no source: <the first failing claim>. Put the file:line, the quoted output, or the link that shows it in the message, or say you have not checked it.\"}",
|
|
151
|
+
"timeout": 30,
|
|
152
|
+
"continueOnBlock": true,
|
|
153
|
+
"statusMessage": "claude-dev-env: checking each claim for its source"
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
},
|
|
145
157
|
{
|
|
146
158
|
"matcher": "mcp__.*__update_message",
|
|
147
159
|
"hooks": [
|
|
@@ -39,7 +39,7 @@ def _context_adding_scripts() -> set[str]:
|
|
|
39
39
|
for each_event in ALL_CONTEXT_EVENTS
|
|
40
40
|
for each_group in registrations["hooks"].get(each_event, [])
|
|
41
41
|
for each_hook in each_group["hooks"]
|
|
42
|
-
for each_script in SCRIPT_PATTERN.findall(each_hook
|
|
42
|
+
for each_script in SCRIPT_PATTERN.findall(each_hook.get("command", ""))
|
|
43
43
|
}
|
|
44
44
|
return {
|
|
45
45
|
each_script
|
|
@@ -417,7 +417,7 @@ def _registered_auto_formatter_timeout() -> int:
|
|
|
417
417
|
for each_event in hooks_configuration["hooks"].values():
|
|
418
418
|
for each_matcher in each_event:
|
|
419
419
|
for each_hook in each_matcher["hooks"]:
|
|
420
|
-
if POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT in each_hook
|
|
420
|
+
if POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT in each_hook.get("command", ""):
|
|
421
421
|
return int(each_hook["timeout"])
|
|
422
422
|
raise AssertionError(
|
|
423
423
|
"post_tool_use_dispatcher (which hosts auto_formatter) is not registered in hooks.json"
|
package/package.json
CHANGED
|
@@ -115,6 +115,7 @@ export function hookCommandsForEvent(settingsPath, eventName) {
|
|
|
115
115
|
const allCommands = [];
|
|
116
116
|
for (const eachGroup of allMatcherGroups) {
|
|
117
117
|
for (const eachHook of eachGroup.hooks ?? []) {
|
|
118
|
+
if (typeof eachHook.command !== 'string') continue;
|
|
118
119
|
allCommands.push({ matcher: eachGroup.matcher ?? '', command: eachHook.command });
|
|
119
120
|
}
|
|
120
121
|
}
|
|
@@ -141,7 +141,7 @@ function main() {
|
|
|
141
141
|
for (const matcherGroups of Object.values(settings.hooks ?? {})) {
|
|
142
142
|
for (const group of matcherGroups) {
|
|
143
143
|
for (const hook of group.hooks ?? []) {
|
|
144
|
-
if (hook.command.replace(/\\/g, '/').includes('/.claude/hooks')) {
|
|
144
|
+
if ((hook.command ?? '').replace(/\\/g, '/').includes('/.claude/hooks')) {
|
|
145
145
|
managedHookCount += 1;
|
|
146
146
|
}
|
|
147
147
|
}
|
|
@@ -45,7 +45,12 @@ def _hook_group(matcher: str, *all_hook_commands: str) -> dict[str, object]:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def _hook_commands(manifest: dict[str, Any], event_name: str) -> list[str]:
|
|
48
|
-
return [
|
|
48
|
+
return [
|
|
49
|
+
each_hook["command"]
|
|
50
|
+
for each_group in manifest["hooks"][event_name]
|
|
51
|
+
for each_hook in each_group["hooks"]
|
|
52
|
+
if "command" in each_hook
|
|
53
|
+
]
|
|
49
54
|
|
|
50
55
|
|
|
51
56
|
def test_conversion_escapes_toml_content() -> None:
|