claude-dev-env 1.72.0 → 1.74.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.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
package/CLAUDE.md
CHANGED
|
@@ -35,6 +35,8 @@ When I ask you to "show me", "open", "display", "let me see", or "pull up" a fil
|
|
|
35
35
|
|
|
36
36
|
It sizes each image window to the image (scaled down to fit the screen) and opens non-image files in their default app; pass every path I name. Printing a path or attaching the file is not showing it — do that only when the file truly cannot be opened, and say why.
|
|
37
37
|
|
|
38
|
+
The `send_user_file_open_locally_blocker` hook backs this up: it blocks a desk-side `SendUserFile` attach and sends you back to this command, while a phone push (`status: "proactive"`) stays allowed.
|
|
39
|
+
|
|
38
40
|
## Test Philosophy
|
|
39
41
|
|
|
40
42
|
When writing tests, always write tests that actually test the behavior of the function against actual, real data and environments.
|
|
@@ -23,9 +23,9 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
23
23
|
| O1 | Module-level responsibility verbs | A module docstring uses verbs (`detects`, `validates`, `enforces`, `recovers`, `parses`, `routes`) — every claimed responsibility is implemented by an exported symbol in the same module. Symbols absent from the module body should not appear as this module's responsibilities. |
|
|
24
24
|
| O2 | Fixture docstring vs sibling-test behavior | An autouse / module-scope fixture docstring asserts an invariant (`readability is disabled`, `network is mocked`, `tmp_path is empty`). No sibling test in the same module explicitly opts out of the invariant. |
|
|
25
25
|
| O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
|
|
26
|
-
| O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
|
|
26
|
+
| O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. A docstring step enumeration that names the body's linear steps but omits a corrective workflow step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`) is also an O4 finding: the reader trusts the step list to be complete and misses the conditional path. The branch-guarded-dispatch shape of this drift — a docstring that names two or more linear-step callees while the body guards a two-or-more-token dispatch callee inside a branch whose name the prose never spells out — is gated deterministically at Write/Edit time by `check_docstring_step_enumeration_dispatch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`), so the audit lane focuses on the step-ordering shapes the gate cannot match (re-ordered steps, plain unguarded steps the prose omits). |
|
|
27
27
|
| O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
|
|
28
|
-
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
28
|
+
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. The exception-guard shape of this drift — a docstring that promises a malformed payload `resolves to None` while a payload subscript (`payload["key"]`, `float(payload["key"])`) sits outside the try/except whose handler returns None, so a present-but-malformed payload raises rather than resolving to None — is gated deterministically at Write/Edit time by `check_docstring_unguarded_malformed_payload_claim`, so the audit lane focuses on the wider Raises/None-on-failure claims the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
30
|
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
31
31
|
|
package/bin/install.mjs
CHANGED
|
@@ -293,18 +293,59 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
|
|
|
293
293
|
return backupPath;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Hook script paths that were folded into the PreToolUse dispatcher in Stage 1.
|
|
298
|
+
* These entries no longer appear in hooks.json but must still be recognized as
|
|
299
|
+
* managed so a reinstall from an older settings shape prunes them and they do
|
|
300
|
+
* not double-run alongside the dispatcher.
|
|
301
|
+
*/
|
|
302
|
+
export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
|
|
303
|
+
'blocking/write_existing_file_blocker.py',
|
|
304
|
+
'blocking/sensitive_file_protector.py',
|
|
305
|
+
'validation/hook_format_validator.py',
|
|
306
|
+
'blocking/code_rules_enforcer.py',
|
|
307
|
+
'blocking/tdd_enforcer.py',
|
|
308
|
+
'blocking/windows_rmtree_blocker.py',
|
|
309
|
+
'blocking/state_description_blocker.py',
|
|
310
|
+
'blocking/subprocess_budget_completeness.py',
|
|
311
|
+
'blocking/hook_prose_detector_consistency.py',
|
|
312
|
+
'blocking/verified_commit_message_accuracy_blocker.py',
|
|
313
|
+
'blocking/workflow_substitution_slot_blocker.py',
|
|
314
|
+
'blocking/claude_md_orphan_file_blocker.py',
|
|
315
|
+
'blocking/pytest_testpaths_orphan_blocker.py',
|
|
316
|
+
'blocking/open_questions_in_plans_blocker.py',
|
|
317
|
+
'blocking/plain_language_blocker.py',
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Hook script paths that were folded into the PostToolUse dispatcher. These
|
|
322
|
+
* after-write hooks no longer appear in hooks.json but must still be recognized
|
|
323
|
+
* as managed so a reinstall from an older settings shape prunes them and they do
|
|
324
|
+
* not double-run alongside the PostToolUse dispatcher.
|
|
325
|
+
*/
|
|
326
|
+
export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
|
|
327
|
+
'validation/mypy_validator.py',
|
|
328
|
+
'workflow/auto_formatter.py',
|
|
329
|
+
'workflow/doc_gist_auto_publish.py',
|
|
330
|
+
]);
|
|
331
|
+
|
|
296
332
|
/**
|
|
297
333
|
* Builds the set of hook script paths this installer manages, each relative to
|
|
298
334
|
* the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
|
|
299
335
|
* `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
|
|
300
336
|
* `python3 -c` commands reference the hooks directory without a script tail and
|
|
301
|
-
* contribute nothing.
|
|
337
|
+
* contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
|
|
338
|
+
* and POST_FOLDED_HOOK_RELATIVE_PATHS so a reinstall from an older settings shape
|
|
339
|
+
* prunes both the PreToolUse and the PostToolUse folded entries.
|
|
302
340
|
*
|
|
303
341
|
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
304
342
|
* @returns {Set<string>} Forward-slash relative script paths under hooks/.
|
|
305
343
|
*/
|
|
306
344
|
export function managedHookScriptRelativePaths(hooksConfig) {
|
|
307
|
-
const relativePaths = new Set(
|
|
345
|
+
const relativePaths = new Set([
|
|
346
|
+
...FOLDED_HOOK_RELATIVE_PATHS,
|
|
347
|
+
...POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
348
|
+
]);
|
|
308
349
|
const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
|
|
309
350
|
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
310
351
|
for (const sourceGroup of matcherGroups) {
|
|
@@ -426,12 +467,38 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
|
426
467
|
);
|
|
427
468
|
}
|
|
428
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Strips every managed hook (standalone script or inline validators runner) from
|
|
472
|
+
* all existing matcher groups of one event in a settings object, dropping any
|
|
473
|
+
* group left empty. Run before the per-group merge so a managed hook that an
|
|
474
|
+
* upgrade moves to a different matcher group is pruned from its old group rather
|
|
475
|
+
* than left to double-run. User-authored hooks outside the managed set stay.
|
|
476
|
+
*
|
|
477
|
+
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
478
|
+
* @param {string} eventType The lifecycle event whose groups are pruned.
|
|
479
|
+
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
480
|
+
* @returns {void}
|
|
481
|
+
*/
|
|
482
|
+
function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
|
|
483
|
+
const existingGroups = settings.hooks[eventType];
|
|
484
|
+
if (!existingGroups) return;
|
|
485
|
+
settings.hooks[eventType] = existingGroups
|
|
486
|
+
.map(group => ({
|
|
487
|
+
...group,
|
|
488
|
+
hooks: group.hooks.filter(
|
|
489
|
+
hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
|
|
490
|
+
),
|
|
491
|
+
}))
|
|
492
|
+
.filter(group => group.hooks.length > 0);
|
|
493
|
+
}
|
|
494
|
+
|
|
429
495
|
/**
|
|
430
496
|
* Merges the installer's managed hook groups into a settings object in memory,
|
|
431
497
|
* pruning every prior managed hook (standalone script or inline validators
|
|
432
|
-
* runner) from each matcher
|
|
433
|
-
* so repeated merges stay idempotent
|
|
434
|
-
*
|
|
498
|
+
* runner) from each event's existing matcher groups before appending the freshly
|
|
499
|
+
* rewritten copies so repeated merges stay idempotent and a managed hook moved to
|
|
500
|
+
* a new matcher group does not double-run. User-authored hooks are preserved
|
|
501
|
+
* untouched.
|
|
435
502
|
*
|
|
436
503
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
437
504
|
* @param {{hooks: object}} hooksConfig Parsed hooks.json.
|
|
@@ -445,6 +512,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
|
|
|
445
512
|
let groupCount = 0;
|
|
446
513
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
447
514
|
if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
|
|
515
|
+
pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
|
|
448
516
|
for (const sourceGroup of matcherGroups) {
|
|
449
517
|
const rewrittenHooks = sourceGroup.hooks.map(hook => {
|
|
450
518
|
let command = hook.command;
|
package/bin/install.test.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import { strict as assert } from 'node:assert';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
|
4
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
@@ -9,6 +9,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
9
9
|
import {
|
|
10
10
|
collectPackageSourceConflicts,
|
|
11
11
|
CONTENT_DIRECTORIES,
|
|
12
|
+
FOLDED_HOOK_RELATIVE_PATHS,
|
|
13
|
+
POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
12
14
|
pythonCandidatesForPlatform,
|
|
13
15
|
isWindowsStorePythonStub,
|
|
14
16
|
interpreterCommandFromPath,
|
|
@@ -361,7 +363,9 @@ test('managedHookScriptRelativePaths collects every installed hook script path a
|
|
|
361
363
|
const relativePaths = managedHookScriptRelativePaths(SAMPLE_HOOKS_CONFIG);
|
|
362
364
|
assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
|
|
363
365
|
assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
|
|
364
|
-
|
|
366
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
367
|
+
assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
|
|
368
|
+
}
|
|
365
369
|
});
|
|
366
370
|
|
|
367
371
|
|
|
@@ -566,7 +570,9 @@ test('managedHookScriptRelativePathsFromSourceRoots reads each root hooks.json s
|
|
|
566
570
|
|
|
567
571
|
assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
|
|
568
572
|
assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
|
|
569
|
-
|
|
573
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
574
|
+
assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
|
|
575
|
+
}
|
|
570
576
|
} finally {
|
|
571
577
|
rmSync(sourceRoot, { recursive: true, force: true });
|
|
572
578
|
}
|
|
@@ -588,7 +594,9 @@ test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts acros
|
|
|
588
594
|
|
|
589
595
|
assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
|
|
590
596
|
assert.ok(relativePaths.has('blocking/pwsh_enforcer.py'));
|
|
591
|
-
|
|
597
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
598
|
+
assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
|
|
599
|
+
}
|
|
592
600
|
} finally {
|
|
593
601
|
rmSync(builtinRoot, { recursive: true, force: true });
|
|
594
602
|
rmSync(dependencyRoot, { recursive: true, force: true });
|
|
@@ -647,3 +655,351 @@ test('purge set sourced from package hooks.json prunes standalone managed script
|
|
|
647
655
|
rmSync(sourceRoot, { recursive: true, force: true });
|
|
648
656
|
}
|
|
649
657
|
});
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
const DISPATCHER_HOOKS_CONFIG = {
|
|
661
|
+
hooks: {
|
|
662
|
+
PreToolUse: [
|
|
663
|
+
{
|
|
664
|
+
matcher: 'Write|Edit',
|
|
665
|
+
hooks: [
|
|
666
|
+
{
|
|
667
|
+
type: 'command',
|
|
668
|
+
command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
|
|
669
|
+
timeout: 15,
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
675
|
+
hooks: [
|
|
676
|
+
{
|
|
677
|
+
type: 'command',
|
|
678
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
|
|
679
|
+
timeout: 60,
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const OLD_FOLDED_HOOKS_SETTINGS = {
|
|
688
|
+
hooks: {
|
|
689
|
+
PreToolUse: [
|
|
690
|
+
{
|
|
691
|
+
matcher: 'Write|Edit',
|
|
692
|
+
hooks: [
|
|
693
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/write_existing_file_blocker.py', timeout: 10 },
|
|
694
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/sensitive_file_protector.py', timeout: 10 },
|
|
695
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/hook_format_validator.py', timeout: 15 },
|
|
696
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
|
|
697
|
+
{ type: 'command', command: 'py -3 -c "import sys; sys.path.insert(0, r\'C:/Users/x/.claude/hooks\'); from validators.run_all_validators import main; sys.exit(main())"', timeout: 15 },
|
|
698
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/tdd_enforcer.py', timeout: 10 },
|
|
699
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/windows_rmtree_blocker.py', timeout: 10 },
|
|
700
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/state_description_blocker.py', timeout: 10 },
|
|
701
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/subprocess_budget_completeness.py', timeout: 10 },
|
|
702
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/hook_prose_detector_consistency.py', timeout: 10 },
|
|
703
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/verified_commit_message_accuracy_blocker.py', timeout: 10 },
|
|
704
|
+
],
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
708
|
+
hooks: [
|
|
709
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/workflow_substitution_slot_blocker.py', timeout: 10 },
|
|
710
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/claude_md_orphan_file_blocker.py', timeout: 10 },
|
|
711
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pytest_testpaths_orphan_blocker.py', timeout: 10 },
|
|
712
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/open_questions_in_plans_blocker.py', timeout: 10 },
|
|
713
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/plain_language_blocker.py', timeout: 10 },
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
test('FOLDED_HOOK_RELATIVE_PATHS contains all 15 hooks removed from hooks.json', () => {
|
|
722
|
+
assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 15);
|
|
723
|
+
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
|
|
724
|
+
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/plain_language_blocker.py'));
|
|
725
|
+
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
|
|
726
|
+
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher hosts', () => {
|
|
731
|
+
const dispatcherHostedHooks = [
|
|
732
|
+
'blocking/write_existing_file_blocker.py',
|
|
733
|
+
'blocking/sensitive_file_protector.py',
|
|
734
|
+
'validation/hook_format_validator.py',
|
|
735
|
+
'blocking/code_rules_enforcer.py',
|
|
736
|
+
'blocking/tdd_enforcer.py',
|
|
737
|
+
'blocking/windows_rmtree_blocker.py',
|
|
738
|
+
'blocking/state_description_blocker.py',
|
|
739
|
+
'blocking/subprocess_budget_completeness.py',
|
|
740
|
+
'blocking/hook_prose_detector_consistency.py',
|
|
741
|
+
'blocking/verified_commit_message_accuracy_blocker.py',
|
|
742
|
+
'blocking/workflow_substitution_slot_blocker.py',
|
|
743
|
+
'blocking/claude_md_orphan_file_blocker.py',
|
|
744
|
+
'blocking/pytest_testpaths_orphan_blocker.py',
|
|
745
|
+
'blocking/open_questions_in_plans_blocker.py',
|
|
746
|
+
'blocking/plain_language_blocker.py',
|
|
747
|
+
];
|
|
748
|
+
for (const hostedPath of dispatcherHostedHooks) {
|
|
749
|
+
assert.ok(
|
|
750
|
+
FOLDED_HOOK_RELATIVE_PATHS.has(hostedPath),
|
|
751
|
+
`dispatcher-hosted hook ${hostedPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry and it does not double-run`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
assert.equal(
|
|
755
|
+
FOLDED_HOOK_RELATIVE_PATHS.size,
|
|
756
|
+
dispatcherHostedHooks.length,
|
|
757
|
+
'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks, no more, no fewer'
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
test('managedHookScriptRelativePaths includes the dispatcher and all folded hooks so old entries are prunable', () => {
|
|
763
|
+
const relativePaths = managedHookScriptRelativePaths(DISPATCHER_HOOKS_CONFIG);
|
|
764
|
+
assert.ok(relativePaths.has('blocking/pre_tool_use_dispatcher.py'), 'dispatcher must be in managed set');
|
|
765
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
766
|
+
assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must be in managed set`);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one dispatcher entry and no folded entries', () => {
|
|
772
|
+
const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
|
|
773
|
+
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
774
|
+
|
|
775
|
+
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
776
|
+
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
777
|
+
|
|
778
|
+
const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
|
|
779
|
+
assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
|
|
780
|
+
|
|
781
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
782
|
+
const foldedBasename = foldedPath.split('/').pop();
|
|
783
|
+
const foldedCommands = allHookCommands.filter(
|
|
784
|
+
cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
|
|
785
|
+
);
|
|
786
|
+
assert.equal(foldedCommands.length, 0, `folded hook ${foldedBasename} must not appear as a separate entry`);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
test('mergeHooksIntoSettings into old folded-hooks settings preserves the inline validators runner', () => {
|
|
792
|
+
const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
|
|
793
|
+
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
794
|
+
|
|
795
|
+
assert.equal(
|
|
796
|
+
countManagedRunAllValidatorsHooks(settings),
|
|
797
|
+
1,
|
|
798
|
+
'exactly one run_all_validators hook must remain in Write|Edit',
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
test('mergeHooksIntoSettings is idempotent when run twice against an already-updated settings shape', () => {
|
|
804
|
+
const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
|
|
805
|
+
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
806
|
+
mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
807
|
+
|
|
808
|
+
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
809
|
+
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
810
|
+
|
|
811
|
+
const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
|
|
812
|
+
assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
|
|
813
|
+
assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
test('shipped hooks.json matches the dispatcher design: dispatchers registered, run_all_validators retained, no folded hook standalone', () => {
|
|
818
|
+
const shippedHooksConfig = JSON.parse(
|
|
819
|
+
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
|
|
823
|
+
const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
824
|
+
const preDispatcherCommands = allPreCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
|
|
825
|
+
assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
|
|
826
|
+
|
|
827
|
+
assert.equal(
|
|
828
|
+
countManagedRunAllValidatorsHooks(shippedHooksConfig),
|
|
829
|
+
1,
|
|
830
|
+
'shipped hooks.json must retain the inline run_all_validators runner in Write|Edit',
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
|
|
834
|
+
const postDispatcherCommands = allPostToolUseGroups
|
|
835
|
+
.flatMap(group => group.hooks.map(hook => hook.command))
|
|
836
|
+
.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
|
|
837
|
+
assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
|
|
838
|
+
|
|
839
|
+
const writePathCommands = allPreToolUseGroups
|
|
840
|
+
.filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
|
|
841
|
+
.flatMap(group => group.hooks.map(hook => hook.command));
|
|
842
|
+
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
843
|
+
const foldedBasename = foldedPath.split('/').pop();
|
|
844
|
+
const standaloneFoldedCommands = writePathCommands.filter(
|
|
845
|
+
cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
|
|
846
|
+
);
|
|
847
|
+
assert.equal(
|
|
848
|
+
standaloneFoldedCommands.length,
|
|
849
|
+
0,
|
|
850
|
+
`folded hook ${foldedBasename} must not ship as a standalone write-path PreToolUse entry`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
const POST_DISPATCHER_HOOKS_CONFIG = {
|
|
857
|
+
hooks: {
|
|
858
|
+
PostToolUse: [
|
|
859
|
+
{
|
|
860
|
+
matcher: 'Write|Edit',
|
|
861
|
+
hooks: [
|
|
862
|
+
{
|
|
863
|
+
type: 'command',
|
|
864
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/post_tool_use_dispatcher.py',
|
|
865
|
+
timeout: 60,
|
|
866
|
+
},
|
|
867
|
+
],
|
|
868
|
+
},
|
|
869
|
+
],
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const OLD_POST_FOLDED_HOOKS_SETTINGS = {
|
|
874
|
+
hooks: {
|
|
875
|
+
PostToolUse: [
|
|
876
|
+
{
|
|
877
|
+
matcher: 'Write|Edit',
|
|
878
|
+
hooks: [
|
|
879
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/mypy_validator.py', timeout: 30 },
|
|
880
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/auto_formatter.py', timeout: 30 },
|
|
881
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/doc_gist_auto_publish.py C:/Users/x/.claude', timeout: 60 },
|
|
882
|
+
],
|
|
883
|
+
},
|
|
884
|
+
],
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the three after-write hooks folded into the PostToolUse dispatcher', () => {
|
|
890
|
+
assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 3);
|
|
891
|
+
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
|
|
892
|
+
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
|
|
893
|
+
assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
test('managedHookScriptRelativePaths includes the PostToolUse dispatcher and all post-folded hooks so old entries are prunable', () => {
|
|
898
|
+
const relativePaths = managedHookScriptRelativePaths(POST_DISPATCHER_HOOKS_CONFIG);
|
|
899
|
+
assert.ok(relativePaths.has('validation/post_tool_use_dispatcher.py'), 'PostToolUse dispatcher must be in managed set');
|
|
900
|
+
for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
|
|
901
|
+
assert.ok(relativePaths.has(foldedPath), `post-folded hook ${foldedPath} must be in managed set`);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
test('mergeHooksIntoSettings into the old three PostToolUse entries yields exactly one post_tool_use_dispatcher entry and none of the three', () => {
|
|
907
|
+
const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
|
|
908
|
+
mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
909
|
+
|
|
910
|
+
const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
|
|
911
|
+
const allCommands = writeEditGroup.hooks.map(hook => hook.command);
|
|
912
|
+
|
|
913
|
+
const dispatcherCommands = allCommands.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
|
|
914
|
+
assert.equal(dispatcherCommands.length, 1, 'exactly one PostToolUse dispatcher entry must be present');
|
|
915
|
+
|
|
916
|
+
for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
|
|
917
|
+
const foldedBasename = foldedPath.split('/').pop();
|
|
918
|
+
const foldedCommands = allCommands.filter(cmd => cmd.includes(foldedBasename));
|
|
919
|
+
assert.equal(foldedCommands.length, 0, `post-folded hook ${foldedBasename} must not appear as a separate entry`);
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
test('mergeHooksIntoSettings installs the PostToolUse dispatcher cleanly into an empty settings object', () => {
|
|
925
|
+
const settings = {};
|
|
926
|
+
mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
927
|
+
|
|
928
|
+
const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
|
|
929
|
+
assert.equal(writeEditGroup.hooks.length, 1);
|
|
930
|
+
assert.equal(
|
|
931
|
+
writeEditGroup.hooks[0].command,
|
|
932
|
+
'py -3 C:/Users/x/.claude/hooks/validation/post_tool_use_dispatcher.py',
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
test('mergeHooksIntoSettings is idempotent for the PostToolUse dispatcher across two installs', () => {
|
|
938
|
+
const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
|
|
939
|
+
mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
940
|
+
mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
941
|
+
|
|
942
|
+
const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
|
|
943
|
+
const dispatcherCommands = writeEditGroup.hooks.filter(hook => hook.command.includes('post_tool_use_dispatcher.py'));
|
|
944
|
+
assert.equal(dispatcherCommands.length, 1, 'PostToolUse dispatcher must appear exactly once after two merges');
|
|
945
|
+
assert.equal(writeEditGroup.hooks.length, 1);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
const PRE_DISPATCHER_ONLY_HOOKS_CONFIG = {
|
|
950
|
+
hooks: {
|
|
951
|
+
PreToolUse: [
|
|
952
|
+
{
|
|
953
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
954
|
+
hooks: [
|
|
955
|
+
{
|
|
956
|
+
type: 'command',
|
|
957
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
|
|
958
|
+
timeout: 60,
|
|
959
|
+
},
|
|
960
|
+
],
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const SETTINGS_WITH_INLINE_RUNNER = {
|
|
967
|
+
hooks: {
|
|
968
|
+
PreToolUse: [
|
|
969
|
+
{
|
|
970
|
+
matcher: 'Write|Edit',
|
|
971
|
+
hooks: [
|
|
972
|
+
{
|
|
973
|
+
type: 'command',
|
|
974
|
+
command: "py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/x/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"",
|
|
975
|
+
timeout: 15,
|
|
976
|
+
},
|
|
977
|
+
],
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
981
|
+
hooks: [
|
|
982
|
+
{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pre_tool_use_dispatcher.py', timeout: 60 },
|
|
983
|
+
],
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
test('mergeHooksIntoSettings prunes the inline run_all_validators runner when the new shape no longer ships it', () => {
|
|
991
|
+
const settings = JSON.parse(JSON.stringify(SETTINGS_WITH_INLINE_RUNNER));
|
|
992
|
+
mergeHooksIntoSettings(settings, PRE_DISPATCHER_ONLY_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
|
|
993
|
+
|
|
994
|
+
assert.equal(countManagedRunAllValidatorsHooks(settings), 0, 'the inline validators runner must be pruned');
|
|
995
|
+
|
|
996
|
+
const writeEditGroup = (settings.hooks.PreToolUse || []).find(group => group.matcher === 'Write|Edit');
|
|
997
|
+
if (writeEditGroup) {
|
|
998
|
+
const runnerSurvivors = writeEditGroup.hooks.filter(hook => hook.command.includes('run_all_validators'));
|
|
999
|
+
assert.equal(runnerSurvivors.length, 0);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
|
|
1003
|
+
const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command.includes('pre_tool_use_dispatcher.py'));
|
|
1004
|
+
assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
|
|
1005
|
+
});
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -26,7 +26,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
26
26
|
| `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
|
|
27
27
|
| `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
|
|
28
28
|
| `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
|
|
29
|
-
| `code_rules_dead_config_field.py` |
|
|
29
|
+
| `code_rules_dead_config_field.py` | `*Config` / `*Selectors` dataclass fields with no live references |
|
|
30
30
|
| `code_rules_dead_dataclass_field.py` | Dataclass fields with no consuming references |
|
|
31
31
|
| `code_rules_dead_module_constant.py` | `UPPER_SNAKE` constants in `*_constants.py` modules with no importers |
|
|
32
32
|
| `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
|
|
@@ -59,8 +59,10 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
59
59
|
| `block_main_commit.py` | PreToolUse (Bash) | `git commit`/`git push` directly to `main` |
|
|
60
60
|
| `bot_mention_comment_blocker.py` | PreToolUse (Write/Edit) | PR review comments that @-mention a bot |
|
|
61
61
|
| `claude_md_orphan_file_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | Per-directory `CLAUDE.md` table cells naming a bare filename absent from the directory subtree |
|
|
62
|
+
| `code_verifier_spawn_preflight_gate.py` | PreToolUse (Agent) | Spawning the `code-verifier` subagent when the branch has a merge conflict vs its base or a CODE_RULES violation on a working-tree-added line |
|
|
62
63
|
| `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
|
|
63
64
|
| `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
|
|
65
|
+
| `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
|
|
64
66
|
| `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
|
|
65
67
|
| `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
|
|
66
68
|
| `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
|
|
@@ -70,11 +72,14 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
70
72
|
| `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
|
|
71
73
|
| `md_to_html_blocker.py` | PreToolUse (Write/Edit) | Writing `.md` files when an `.html` companion is required |
|
|
72
74
|
| `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
|
|
75
|
+
| `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory names two or more sibling files but no entry for the new file |
|
|
73
76
|
| `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
|
|
74
77
|
| `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
|
|
75
78
|
| `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit` without a PR-description-writer-authored body |
|
|
76
79
|
| `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
|
|
80
|
+
| `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
|
|
77
81
|
| `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
|
|
82
|
+
| `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file locally via `Show-Asset.ps1` |
|
|
78
83
|
| `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
|
|
79
84
|
| `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
|
|
80
85
|
| `state_description_blocker.py` | PreToolUse (Write/Edit) | Historical/comparative language in documentation |
|
|
@@ -15,6 +15,13 @@ import os
|
|
|
15
15
|
import re
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
21
|
+
if _hooks_dir not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_dir)
|
|
23
|
+
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
18
25
|
|
|
19
26
|
GIT_COMMAND_TIMEOUT_SECONDS = 5
|
|
20
27
|
PROTECTED_BRANCHES = ("main", "master")
|
|
@@ -172,6 +179,13 @@ def main() -> None:
|
|
|
172
179
|
sys.exit(0)
|
|
173
180
|
|
|
174
181
|
denial = build_denial_response(current_branch, target_dir)
|
|
182
|
+
log_hook_block(
|
|
183
|
+
calling_hook_name="block_main_commit.py",
|
|
184
|
+
hook_event="PreToolUse",
|
|
185
|
+
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
186
|
+
tool_name="Bash",
|
|
187
|
+
offending_input_preview=bash_command,
|
|
188
|
+
)
|
|
175
189
|
print(json.dumps(denial))
|
|
176
190
|
sys.exit(0)
|
|
177
191
|
|
|
@@ -21,6 +21,7 @@ from hooks_constants.bot_mention_comment_blocker_constants import ( # noqa: E40
|
|
|
21
21
|
CURSOR_MENTION_TOKEN,
|
|
22
22
|
TOOL_NAME,
|
|
23
23
|
)
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _body_contains_token(body: str, token: str) -> bool:
|
|
@@ -60,6 +61,12 @@ def main() -> None:
|
|
|
60
61
|
"permissionDecisionReason": corrective_message,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
log_hook_block(
|
|
65
|
+
calling_hook_name="bot_mention_comment_blocker.py",
|
|
66
|
+
hook_event="PreToolUse",
|
|
67
|
+
block_reason=corrective_message,
|
|
68
|
+
tool_name=TOOL_NAME,
|
|
69
|
+
)
|
|
63
70
|
print(json.dumps(deny_payload))
|
|
64
71
|
sys.stdout.flush()
|
|
65
72
|
sys.exit(0)
|