claude-dev-env 8.78.1 → 8.78.2

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 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
- commandString => commandReferencesManagedHook(
1516
- commandString, managedHookRelativePaths, sharedSettingsRealPath,
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
- commandString => commandReferencesManagedHook(commandString, managedHookRelativePaths),
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 an entry whose command is not a string reaches the
1834
- * predicate as-is — each predicate reads a non-string command as an entry this
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 {(commandString: unknown) => boolean} shouldRemoveHook Names the hooks that leave.
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?.command));
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
- commandString => commandReferencesRetiredHook(
1892
- commandString, retiredHookRelativePaths, ownership,
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,48 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+
4
+ import { mergeHooksIntoSettings, pruneManagedHooksFromSettings } from './install.mjs';
5
+
6
+ const REPLY_MATCHER = 'mcp__hearthbot__reply';
7
+ const MANAGED_STATUS = 'claude-dev-env: checking each claim for its source';
8
+ const USER_PROMPT_HOOK = { type: 'prompt', prompt: 'User check. $ARGUMENTS', statusMessage: 'my own check' };
9
+
10
+ function hooksConfigWithPrompt(promptText) {
11
+ return {
12
+ hooks: {
13
+ PreToolUse: [
14
+ {
15
+ matcher: REPLY_MATCHER,
16
+ hooks: [{ type: 'prompt', prompt: promptText, timeout: 30, statusMessage: MANAGED_STATUS }],
17
+ },
18
+ ],
19
+ },
20
+ };
21
+ }
22
+
23
+ function replyHooks(settings) {
24
+ return (settings.hooks?.PreToolUse ?? [])
25
+ .filter(eachGroup => eachGroup.matcher === REPLY_MATCHER)
26
+ .flatMap(eachGroup => eachGroup.hooks);
27
+ }
28
+
29
+ test('should keep one managed prompt hook across reinstalls and carry the new prompt text', () => {
30
+ const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
31
+
32
+ mergeHooksIntoSettings(settings, hooksConfigWithPrompt('First judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
33
+ mergeHooksIntoSettings(settings, hooksConfigWithPrompt('Second judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
34
+
35
+ assert.deepEqual(
36
+ replyHooks(settings).map(eachHook => eachHook.prompt),
37
+ ['User check. $ARGUMENTS', 'Second judge. $ARGUMENTS'],
38
+ );
39
+ });
40
+
41
+ test('should remove the managed prompt hook on uninstall and keep the user prompt hook', () => {
42
+ const settings = { hooks: { PreToolUse: [{ matcher: REPLY_MATCHER, hooks: [USER_PROMPT_HOOK] }] } };
43
+ mergeHooksIntoSettings(settings, hooksConfigWithPrompt('Judge. $ARGUMENTS'), '/home/example/.claude', 'python3');
44
+
45
+ pruneManagedHooksFromSettings(settings, new Set());
46
+
47
+ assert.deepEqual(replyHooks(settings), [USER_PROMPT_HOOK]);
48
+ });
@@ -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
  }
@@ -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["command"]
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["command"]
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["command"]
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]["command"]
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["command"]
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["command"])
42
+ for each_script in SCRIPT_PATTERN.findall(each_hook.get("command", ""))
43
43
  }
44
44
  return {
45
45
  each_script
@@ -34,6 +34,7 @@ def registered_hook_commands() -> list[str]:
34
34
  for all_groups in registrations["hooks"].values()
35
35
  for group in all_groups
36
36
  for hook in group["hooks"]
37
+ if "command" in hook
37
38
  ]
38
39
 
39
40
 
@@ -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["command"]:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.78.1",
3
+ "version": "8.78.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 [each_hook["command"] for each_group in manifest["hooks"][event_name] for each_hook in each_group["hooks"]]
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: