claude-dev-env 7.0.0 → 7.1.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/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
package/bin/install.test.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { join, dirname } from 'node:path';
|
|
17
|
-
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
collectPackageSourceConflicts,
|
|
@@ -42,14 +42,17 @@ import {
|
|
|
42
42
|
caseOnlyRenameSourceName,
|
|
43
43
|
retiredManagedHookRelativePaths,
|
|
44
44
|
pruneRetiredHookEntriesFromSettings,
|
|
45
|
+
settingsHookCommandsAtPath,
|
|
46
|
+
retiredHookPathsNamedByCommands,
|
|
47
|
+
writeRetiredHookStandIns,
|
|
48
|
+
ownedHookRootComparisonKeys,
|
|
49
|
+
commandNamesOwnedHookScript,
|
|
50
|
+
hookCommandComparisonSpelling,
|
|
51
|
+
detectPython,
|
|
45
52
|
retainNewestRunBackupOnly,
|
|
46
53
|
refreshInstalledPstackPluginManifest,
|
|
47
54
|
} from './install.mjs';
|
|
48
55
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
49
|
-
import {
|
|
50
|
-
expandHomeDirectoryTokens,
|
|
51
|
-
expandHomeDirectoryTokensInSettings,
|
|
52
|
-
} from './expand_home_directory_tokens.mjs';
|
|
53
56
|
|
|
54
57
|
|
|
55
58
|
test('installer reports the authoritative Git hook names', () => {
|
|
@@ -348,115 +351,6 @@ test('interpreterCommandFromPath quotes an interpreter path that contains a spac
|
|
|
348
351
|
});
|
|
349
352
|
|
|
350
353
|
|
|
351
|
-
test('expandHomeDirectoryTokens expands $HOME, ${HOME}, and ~/', () => {
|
|
352
|
-
assert.equal(
|
|
353
|
-
expandHomeDirectoryTokens(
|
|
354
|
-
'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
355
|
-
'C:\\Users\\x',
|
|
356
|
-
),
|
|
357
|
-
'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
358
|
-
);
|
|
359
|
-
assert.equal(
|
|
360
|
-
expandHomeDirectoryTokens('python ${HOME}/.claude/hooks/a.py', '/home/x'),
|
|
361
|
-
'python /home/x/.claude/hooks/a.py',
|
|
362
|
-
);
|
|
363
|
-
assert.equal(
|
|
364
|
-
expandHomeDirectoryTokens('python ~/.claude/hooks/a.py', '/home/x'),
|
|
365
|
-
'python /home/x/.claude/hooks/a.py',
|
|
366
|
-
);
|
|
367
|
-
assert.equal(
|
|
368
|
-
expandHomeDirectoryTokens('echo $HOMEPATH', 'C:/Users/x'),
|
|
369
|
-
'echo $HOMEPATH',
|
|
370
|
-
);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
test('expandHomeDirectoryTokens inserts dollar characters in home paths literally', () => {
|
|
375
|
-
const homeWithReplaceMetacharacters = 'C:/Users/$&evil$1';
|
|
376
|
-
assert.equal(
|
|
377
|
-
expandHomeDirectoryTokens('python $HOME/.claude/a.py', homeWithReplaceMetacharacters),
|
|
378
|
-
'python C:/Users/$&evil$1/.claude/a.py',
|
|
379
|
-
);
|
|
380
|
-
assert.equal(
|
|
381
|
-
expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', homeWithReplaceMetacharacters),
|
|
382
|
-
'python C:/Users/$&evil$1/.claude/a.py',
|
|
383
|
-
);
|
|
384
|
-
assert.equal(
|
|
385
|
-
expandHomeDirectoryTokens('python ~/.claude/a.py', homeWithReplaceMetacharacters),
|
|
386
|
-
'python C:/Users/$&evil$1/.claude/a.py',
|
|
387
|
-
);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
test('expandHomeDirectoryTokens expands ${HOME} before $HOME so braces stay intact', () => {
|
|
392
|
-
assert.equal(
|
|
393
|
-
expandHomeDirectoryTokens('python ${HOME}/.claude/a.py', '/home/x'),
|
|
394
|
-
'python /home/x/.claude/a.py',
|
|
395
|
-
);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
test('expandHomeDirectoryTokens strips trailing slashes from the home directory', () => {
|
|
400
|
-
assert.equal(
|
|
401
|
-
expandHomeDirectoryTokens('python $HOME/.claude/a.py', 'C:/Users/x/'),
|
|
402
|
-
'python C:/Users/x/.claude/a.py',
|
|
403
|
-
);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
test('expandHomeDirectoryTokensInSettings skips non-array hook event values', () => {
|
|
408
|
-
const settings = {
|
|
409
|
-
hooks: {
|
|
410
|
-
SessionStart: 'not-an-array',
|
|
411
|
-
PreToolUse: [
|
|
412
|
-
{
|
|
413
|
-
matcher: 'Write',
|
|
414
|
-
hooks: [{ type: 'command', command: 'python $HOME/.claude/hooks/a.py' }],
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
},
|
|
418
|
-
};
|
|
419
|
-
expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
|
|
420
|
-
assert.equal(settings.hooks.SessionStart, 'not-an-array');
|
|
421
|
-
assert.equal(
|
|
422
|
-
settings.hooks.PreToolUse[0].hooks[0].command,
|
|
423
|
-
'python C:/Users/x/.claude/hooks/a.py',
|
|
424
|
-
);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
test('expandHomeDirectoryTokensInSettings rewrites hooks and statusLine', () => {
|
|
429
|
-
const settings = {
|
|
430
|
-
hooks: {
|
|
431
|
-
SessionStart: [
|
|
432
|
-
{
|
|
433
|
-
matcher: '',
|
|
434
|
-
hooks: [
|
|
435
|
-
{
|
|
436
|
-
type: 'command',
|
|
437
|
-
command: 'python $HOME/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
438
|
-
},
|
|
439
|
-
],
|
|
440
|
-
},
|
|
441
|
-
],
|
|
442
|
-
},
|
|
443
|
-
statusLine: {
|
|
444
|
-
type: 'command',
|
|
445
|
-
command: 'python "$HOME/.claude/statusline-command.py"',
|
|
446
|
-
},
|
|
447
|
-
};
|
|
448
|
-
expandHomeDirectoryTokensInSettings(settings, 'C:/Users/x');
|
|
449
|
-
assert.equal(
|
|
450
|
-
settings.hooks.SessionStart[0].hooks[0].command,
|
|
451
|
-
'python C:/Users/x/.claude/hooks/session/fix_worktree_hookspath.py',
|
|
452
|
-
);
|
|
453
|
-
assert.equal(
|
|
454
|
-
settings.statusLine.command,
|
|
455
|
-
'python "C:/Users/x/.claude/statusline-command.py"',
|
|
456
|
-
);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
|
|
460
354
|
test('mergeHooksIntoSettings expands residual $HOME in preserved user hooks', () => {
|
|
461
355
|
const hooksConfig = {
|
|
462
356
|
hooks: {
|
|
@@ -920,6 +814,19 @@ test('commandReferencesManagedHook matches a managed script followed by a whites
|
|
|
920
814
|
});
|
|
921
815
|
|
|
922
816
|
|
|
817
|
+
test('commandReferencesManagedHook matches a shipped script name under a hooks root this installer never writes', () => {
|
|
818
|
+
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
819
|
+
|
|
820
|
+
assert.ok(
|
|
821
|
+
commandReferencesManagedHook(
|
|
822
|
+
'python3 "/other-tool/.claude/hooks/blocking/code_rules_enforcer.py"',
|
|
823
|
+
managedPaths,
|
|
824
|
+
),
|
|
825
|
+
'the merge matcher reads the /.claude/hooks/ tail alone, so another tool\'s copy of a shipped script name is inside the managed set',
|
|
826
|
+
);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
|
|
923
830
|
test('commandReferencesManagedHook matches the rewritten inline validators-runner hook that carries no script tail', () => {
|
|
924
831
|
const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
|
|
925
832
|
const rewrittenInlineCommand =
|
|
@@ -1266,6 +1173,35 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1266
1173
|
'session/untracked_repo_detector.py',
|
|
1267
1174
|
'session/gh_pr_author_session_cleanup.py',
|
|
1268
1175
|
'observability/pr_description_writer_spawn_tracker.py',
|
|
1176
|
+
'blocking/plain_language_blocker.py',
|
|
1177
|
+
'lifecycle/config_change_guard.py',
|
|
1178
|
+
'blocking/docstring_rule_gate_count_blocker.py',
|
|
1179
|
+
'blocking/write_existing_file_blocker.py',
|
|
1180
|
+
'blocking/sensitive_file_protector.py',
|
|
1181
|
+
'blocking/pii_prevention_blocker.py',
|
|
1182
|
+
'blocking/duplicate_rmtree_helper_blocker.py',
|
|
1183
|
+
'blocking/claude_md_orphan_file_blocker.py',
|
|
1184
|
+
'blocking/package_inventory_stale_blocker.py',
|
|
1185
|
+
'blocking/env_var_table_code_drift_blocker.py',
|
|
1186
|
+
'blocking/pytest_testpaths_orphan_blocker.py',
|
|
1187
|
+
'blocking/destructive_command_blocker.py',
|
|
1188
|
+
'blocking/shell_substitution_blocker.py',
|
|
1189
|
+
'blocking/piped_pytest_blocker.py',
|
|
1190
|
+
'blocking/cursor_cli_python_misfire_blocker.py',
|
|
1191
|
+
'blocking/unscoped_search_blocker.py',
|
|
1192
|
+
'blocking/nas_ssh_binary_enforcer.py',
|
|
1193
|
+
'blocking/block_main_commit.py',
|
|
1194
|
+
'blocking/session_edit_stage_gate.py',
|
|
1195
|
+
'blocking/bash_pre_tool_use_dispatcher.py',
|
|
1196
|
+
'blocking/stop_dispatcher.py',
|
|
1197
|
+
'blocking/bot_mention_comment_blocker.py',
|
|
1198
|
+
'blocking/fable_spawn_gate.py',
|
|
1199
|
+
'blocking/luna_fast_mode_gate.py',
|
|
1200
|
+
'blocking/orchestrator_refresh_reschedule_gate.py',
|
|
1201
|
+
'blocking/ask_user_question_shape_blocker.py',
|
|
1202
|
+
'blocking/send_user_file_open_locally_blocker.py',
|
|
1203
|
+
'blocking/question_to_user_enforcer.py',
|
|
1204
|
+
'blocking/session_handoff_blocker.py',
|
|
1269
1205
|
]);
|
|
1270
1206
|
const shippedHooks = JSON.parse(
|
|
1271
1207
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
@@ -1417,7 +1353,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
|
|
|
1417
1353
|
});
|
|
1418
1354
|
|
|
1419
1355
|
|
|
1420
|
-
test('shipped hooks.json
|
|
1356
|
+
test('shipped hooks.json keeps only nonblocking policy handlers and lifecycle hooks', () => {
|
|
1421
1357
|
const shippedHooksConfig = JSON.parse(
|
|
1422
1358
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
1423
1359
|
);
|
|
@@ -1429,8 +1365,8 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1429
1365
|
|
|
1430
1366
|
assert.equal(
|
|
1431
1367
|
countManagedRunAllValidatorsHooks(shippedHooksConfig),
|
|
1432
|
-
|
|
1433
|
-
'shipped hooks.json must
|
|
1368
|
+
0,
|
|
1369
|
+
'shipped hooks.json must retire the inline run_all_validators runner from the write path',
|
|
1434
1370
|
);
|
|
1435
1371
|
|
|
1436
1372
|
const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
|
|
@@ -1438,6 +1374,12 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1438
1374
|
.flatMap(group => group.hooks.map(hook => hook.command))
|
|
1439
1375
|
.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
|
|
1440
1376
|
assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
|
|
1377
|
+
const allCommands = Object.values(shippedHooksConfig.hooks).flatMap(matcherGroups => (
|
|
1378
|
+
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
|
|
1379
|
+
));
|
|
1380
|
+
for (const retiredPath of RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS) {
|
|
1381
|
+
assert.ok(allCommands.every(command => !command.includes(retiredPath)), retiredPath);
|
|
1382
|
+
}
|
|
1441
1383
|
|
|
1442
1384
|
const allSessionStartCommands = (shippedHooksConfig.hooks.SessionStart || [])
|
|
1443
1385
|
.flatMap(group => group.hooks.map(hook => hook.command));
|
|
@@ -2184,6 +2126,22 @@ function createSettingsFixture(settings, indentWidth) {
|
|
|
2184
2126
|
}
|
|
2185
2127
|
|
|
2186
2128
|
|
|
2129
|
+
/**
|
|
2130
|
+
* Build the ownership one sandbox's own ~/.claude/hooks root supplies.
|
|
2131
|
+
*
|
|
2132
|
+
* A retirement walk that falls back to its own defaults reads the installed hook
|
|
2133
|
+
* roots and the home directory of the machine running the test, so the answer
|
|
2134
|
+
* changes with the host. Handing every walk a sandbox ownership keeps the test
|
|
2135
|
+
* reading the fixture it wrote and nothing else.
|
|
2136
|
+
*
|
|
2137
|
+
* @param {string} sandboxRoot The sandbox home the `$HOME` and `~` spellings expand to.
|
|
2138
|
+
* @returns {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} The ownership.
|
|
2139
|
+
*/
|
|
2140
|
+
function sandboxClaudeHooksOwnership(sandboxRoot) {
|
|
2141
|
+
return sandboxHookOwnership(sandboxRoot, [join(sandboxRoot, '.claude', 'hooks')]);
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
|
|
2187
2145
|
test('pruneRetiredHookEntriesFromSettings removes the retired entry and keeps the user-authored one', () => {
|
|
2188
2146
|
const retiredCommand = `python3 "$HOME/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}"`;
|
|
2189
2147
|
const userCommand = 'python3 my_own_gate.py --user-authored';
|
|
@@ -2206,7 +2164,9 @@ test('pruneRetiredHookEntriesFromSettings removes the retired entry and keeps th
|
|
|
2206
2164
|
}, 4);
|
|
2207
2165
|
try {
|
|
2208
2166
|
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
2209
|
-
settingsPath,
|
|
2167
|
+
settingsPath,
|
|
2168
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2169
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2210
2170
|
);
|
|
2211
2171
|
|
|
2212
2172
|
assert.equal(removedCount, 2, 'both entries running the retired script are removed');
|
|
@@ -2251,7 +2211,9 @@ test('pruneRetiredHookEntriesFromSettings keeps every settings shape the install
|
|
|
2251
2211
|
}, 4);
|
|
2252
2212
|
try {
|
|
2253
2213
|
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
2254
|
-
settingsPath,
|
|
2214
|
+
settingsPath,
|
|
2215
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2216
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2255
2217
|
);
|
|
2256
2218
|
|
|
2257
2219
|
assert.equal(removedCount, 1, 'the one entry running the retired script is the only removal');
|
|
@@ -2392,7 +2354,9 @@ test('pruneRetiredHookEntriesFromSettings leaves settings.json byte-identical wh
|
|
|
2392
2354
|
const bytesBefore = readFileSync(settingsPath, 'utf8');
|
|
2393
2355
|
|
|
2394
2356
|
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
2395
|
-
settingsPath,
|
|
2357
|
+
settingsPath,
|
|
2358
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2359
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2396
2360
|
);
|
|
2397
2361
|
|
|
2398
2362
|
assert.equal(removedCount, 0, 'no entry runs a retired script');
|
|
@@ -2407,6 +2371,361 @@ test('pruneRetiredHookEntriesFromSettings leaves settings.json byte-identical wh
|
|
|
2407
2371
|
});
|
|
2408
2372
|
|
|
2409
2373
|
|
|
2374
|
+
const ALL_HOST_CONFIGURATION_INDENT_WIDTHS = [2, 4];
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* Build a settings fixture whose one matcher group registers a retired hook
|
|
2379
|
+
* beside a user-authored entry, so a prune removes one entry and rewrites the file.
|
|
2380
|
+
*
|
|
2381
|
+
* @param {string} relativePath The retired hook path the registration runs.
|
|
2382
|
+
* @returns {object} A settings object holding both entries.
|
|
2383
|
+
*/
|
|
2384
|
+
function settingsRegisteringRetiredAndUserHooks(relativePath) {
|
|
2385
|
+
return {
|
|
2386
|
+
hooks: {
|
|
2387
|
+
PreToolUse: [{
|
|
2388
|
+
matcher: 'Write|Edit',
|
|
2389
|
+
hooks: [
|
|
2390
|
+
{ type: 'command', command: `python3 "$HOME/.claude/hooks/${relativePath}"` },
|
|
2391
|
+
{ type: 'command', command: 'python3 my_own_gate.py --user-authored' },
|
|
2392
|
+
],
|
|
2393
|
+
}],
|
|
2394
|
+
},
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
test('pruneRetiredHookEntriesFromSettings writes back the indentation the host configuration file already used', () => {
|
|
2400
|
+
for (const indentWidth of ALL_HOST_CONFIGURATION_INDENT_WIDTHS) {
|
|
2401
|
+
const { settingsPath, sandboxRoot } = createSettingsFixture(
|
|
2402
|
+
settingsRegisteringRetiredAndUserHooks(RETIRED_HOOK_RELATIVE_PATH), indentWidth,
|
|
2403
|
+
);
|
|
2404
|
+
try {
|
|
2405
|
+
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
2406
|
+
settingsPath,
|
|
2407
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2408
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2409
|
+
);
|
|
2410
|
+
|
|
2411
|
+
assert.equal(removedCount, 1, `the retired entry leaves the ${indentWidth}-space file`);
|
|
2412
|
+
const settingsText = readFileSync(settingsPath, 'utf8');
|
|
2413
|
+
assert.equal(
|
|
2414
|
+
settingsText,
|
|
2415
|
+
JSON.stringify(JSON.parse(settingsText), null, indentWidth) + '\n',
|
|
2416
|
+
`a file written with ${indentWidth} spaces is handed back with ${indentWidth} spaces`,
|
|
2417
|
+
);
|
|
2418
|
+
} finally {
|
|
2419
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
const SECOND_RETIRED_HOOK_RELATIVE_PATH = 'blocking/retired_second_gate.py';
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Build a sandbox holding a ~/.claude/hooks root and a settings.json beside it.
|
|
2430
|
+
*
|
|
2431
|
+
* @param {object} settings The settings object to serialize.
|
|
2432
|
+
* @returns {{sandboxRoot: string, hooksRoot: string, settingsPath: string}} The sandbox paths.
|
|
2433
|
+
*/
|
|
2434
|
+
function createRetiredHookSandbox(settings) {
|
|
2435
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-retired-hook-stand-in-'));
|
|
2436
|
+
const hooksRoot = join(sandboxRoot, '.claude', 'hooks');
|
|
2437
|
+
mkdirSync(hooksRoot, { recursive: true });
|
|
2438
|
+
const settingsPath = join(sandboxRoot, 'settings.json');
|
|
2439
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
2440
|
+
return { sandboxRoot, hooksRoot, settingsPath };
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
/**
|
|
2445
|
+
* Build a settings fixture registering one retired hook command.
|
|
2446
|
+
*
|
|
2447
|
+
* @param {string} relativePath The retired hook path the registration runs.
|
|
2448
|
+
* @returns {object} A settings object holding that registration and a user entry.
|
|
2449
|
+
*/
|
|
2450
|
+
function settingsRegistering(relativePath) {
|
|
2451
|
+
return {
|
|
2452
|
+
hooks: {
|
|
2453
|
+
PreToolUse: [{
|
|
2454
|
+
matcher: 'AskUserQuestion',
|
|
2455
|
+
hooks: [
|
|
2456
|
+
{ type: 'command', command: `python3 "$HOME/.claude/hooks/${relativePath}"` },
|
|
2457
|
+
{ type: 'command', command: 'python3 my_own_gate.py --user-authored' },
|
|
2458
|
+
],
|
|
2459
|
+
}],
|
|
2460
|
+
},
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
test('retiredHookPathsNamedByCommands names only the retired scripts a live registration runs', () => {
|
|
2466
|
+
const { sandboxRoot, settingsPath } = createRetiredHookSandbox(
|
|
2467
|
+
settingsRegistering(RETIRED_HOOK_RELATIVE_PATH),
|
|
2468
|
+
);
|
|
2469
|
+
try {
|
|
2470
|
+
const namedPaths = retiredHookPathsNamedByCommands(
|
|
2471
|
+
settingsHookCommandsAtPath(settingsPath),
|
|
2472
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
|
|
2473
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2474
|
+
);
|
|
2475
|
+
|
|
2476
|
+
assert.deepEqual(
|
|
2477
|
+
[...namedPaths],
|
|
2478
|
+
[RETIRED_HOOK_RELATIVE_PATH],
|
|
2479
|
+
'the registered retired script is named and the unregistered one is left out',
|
|
2480
|
+
);
|
|
2481
|
+
} finally {
|
|
2482
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
|
|
2487
|
+
test('settingsHookCommandsAtPath reads no command from a settings file it cannot use', () => {
|
|
2488
|
+
const { sandboxRoot, settingsPath } = createRetiredHookSandbox({ hooks: {} });
|
|
2489
|
+
try {
|
|
2490
|
+
writeFileSync(settingsPath, '{ this is not JSON');
|
|
2491
|
+
|
|
2492
|
+
assert.deepEqual(
|
|
2493
|
+
settingsHookCommandsAtPath(settingsPath),
|
|
2494
|
+
[],
|
|
2495
|
+
'settings the installer cannot parse hold no command',
|
|
2496
|
+
);
|
|
2497
|
+
assert.deepEqual(
|
|
2498
|
+
settingsHookCommandsAtPath(join(sandboxRoot, 'absent.json')),
|
|
2499
|
+
[],
|
|
2500
|
+
'a settings file that is not there holds no command',
|
|
2501
|
+
);
|
|
2502
|
+
assert.equal(
|
|
2503
|
+
retiredHookPathsNamedByCommands(
|
|
2504
|
+
[],
|
|
2505
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2506
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2507
|
+
).size,
|
|
2508
|
+
0,
|
|
2509
|
+
'with no command to read, no retired script is named',
|
|
2510
|
+
);
|
|
2511
|
+
} finally {
|
|
2512
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
test('a stale registration keeps working because the retired hook leaves an inert stand-in behind', () => {
|
|
2518
|
+
const { sandboxRoot, hooksRoot, settingsPath } = createRetiredHookSandbox(
|
|
2519
|
+
settingsRegistering(RETIRED_HOOK_RELATIVE_PATH),
|
|
2520
|
+
);
|
|
2521
|
+
try {
|
|
2522
|
+
const namedPaths = retiredHookPathsNamedByCommands(
|
|
2523
|
+
settingsHookCommandsAtPath(settingsPath),
|
|
2524
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
|
|
2525
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
const standInPaths = writeRetiredHookStandIns(hooksRoot, namedPaths);
|
|
2529
|
+
|
|
2530
|
+
const standInPath = join(hooksRoot, ...RETIRED_HOOK_RELATIVE_PATH.split('/'));
|
|
2531
|
+
assert.deepEqual(standInPaths, [standInPath], 'one stand-in is written, for the registered path');
|
|
2532
|
+
assert.equal(
|
|
2533
|
+
existsSync(join(hooksRoot, ...SECOND_RETIRED_HOOK_RELATIVE_PATH.split('/'))),
|
|
2534
|
+
false,
|
|
2535
|
+
'a retired path no registration names gets no stand-in',
|
|
2536
|
+
);
|
|
2537
|
+
|
|
2538
|
+
const standInRun = spawnSync(detectPython(), [standInPath], { encoding: 'utf8' });
|
|
2539
|
+
|
|
2540
|
+
assert.equal(standInRun.status, 0, 'the stand-in exits 0, so a stale registration allows the tool');
|
|
2541
|
+
assert.equal(standInRun.stdout, '', 'the stand-in writes nothing to stdout');
|
|
2542
|
+
assert.equal(standInRun.stderr, '', 'the stand-in writes nothing to stderr');
|
|
2543
|
+
} finally {
|
|
2544
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2545
|
+
}
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
test('writeRetiredHookStandIns leaves a hook file the prune could not move exactly as it stands', () => {
|
|
2550
|
+
const { sandboxRoot, hooksRoot } = createRetiredHookSandbox({ hooks: {} });
|
|
2551
|
+
try {
|
|
2552
|
+
const survivingPath = join(hooksRoot, ...RETIRED_HOOK_RELATIVE_PATH.split('/'));
|
|
2553
|
+
mkdirSync(dirname(survivingPath), { recursive: true });
|
|
2554
|
+
const survivingContents = 'raise SystemExit(2)\n';
|
|
2555
|
+
writeFileSync(survivingPath, survivingContents);
|
|
2556
|
+
|
|
2557
|
+
const standInPaths = writeRetiredHookStandIns(
|
|
2558
|
+
hooksRoot, new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2559
|
+
);
|
|
2560
|
+
|
|
2561
|
+
assert.deepEqual(standInPaths, [], 'a path still holding a file gets no stand-in');
|
|
2562
|
+
assert.equal(
|
|
2563
|
+
readFileSync(survivingPath, 'utf8'),
|
|
2564
|
+
survivingContents,
|
|
2565
|
+
'the file a failed prune left behind keeps its own bytes',
|
|
2566
|
+
);
|
|
2567
|
+
} finally {
|
|
2568
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
const AGENTS_HOOKS_ROOT_SEGMENTS = ['.agents', 'hooks'];
|
|
2574
|
+
const CODEX_HOOKS_ROOT_SEGMENTS = ['.codex', 'hooks'];
|
|
2575
|
+
const FOREIGN_HOOKS_ROOT_SEGMENTS = ['other-tool', '.claude', 'hooks'];
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* Build the ownership one retirement walk reads for one sandbox.
|
|
2580
|
+
*
|
|
2581
|
+
* @param {string} sandboxRoot The sandbox home the `$HOME` spelling expands to.
|
|
2582
|
+
* @param {string[]} allHookRootPaths The hooks directories the installer owns there.
|
|
2583
|
+
* @returns {{allOwnedHookRootKeys: Set<string>, homeDirectory: string}} The ownership.
|
|
2584
|
+
*/
|
|
2585
|
+
function sandboxHookOwnership(sandboxRoot, allHookRootPaths) {
|
|
2586
|
+
return {
|
|
2587
|
+
allOwnedHookRootKeys: ownedHookRootComparisonKeys(allHookRootPaths),
|
|
2588
|
+
homeDirectory: sandboxRoot,
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
/**
|
|
2594
|
+
* Build the command one hook registration runs for a script under a hooks root.
|
|
2595
|
+
*
|
|
2596
|
+
* @param {string} hooksRoot The hooks directory the registration names.
|
|
2597
|
+
* @param {string} relativePath The script path under that directory.
|
|
2598
|
+
* @returns {string} The registration command.
|
|
2599
|
+
*/
|
|
2600
|
+
function hookCommandUnder(hooksRoot, relativePath) {
|
|
2601
|
+
const scriptPath = join(hooksRoot, ...relativePath.split('/')).replace(/\\/g, '/');
|
|
2602
|
+
return `python3 "${scriptPath}"`;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
test('a retired registration spelled under the agents hooks root is named, because both roots are owned', () => {
|
|
2607
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-owned-hook-roots-'));
|
|
2608
|
+
try {
|
|
2609
|
+
const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
|
|
2610
|
+
const agentsHooksRoot = join(sandboxRoot, ...AGENTS_HOOKS_ROOT_SEGMENTS);
|
|
2611
|
+
const codexHooksRoot = join(sandboxRoot, ...CODEX_HOOKS_ROOT_SEGMENTS);
|
|
2612
|
+
|
|
2613
|
+
const namedPaths = retiredHookPathsNamedByCommands(
|
|
2614
|
+
[
|
|
2615
|
+
hookCommandUnder(agentsHooksRoot, RETIRED_HOOK_RELATIVE_PATH),
|
|
2616
|
+
hookCommandUnder(codexHooksRoot, SECOND_RETIRED_HOOK_RELATIVE_PATH),
|
|
2617
|
+
],
|
|
2618
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH]),
|
|
2619
|
+
sandboxHookOwnership(
|
|
2620
|
+
sandboxRoot, [claudeHooksRoot, agentsHooksRoot, codexHooksRoot],
|
|
2621
|
+
),
|
|
2622
|
+
);
|
|
2623
|
+
|
|
2624
|
+
assert.deepEqual(
|
|
2625
|
+
[...namedPaths].sort(),
|
|
2626
|
+
[RETIRED_HOOK_RELATIVE_PATH, SECOND_RETIRED_HOOK_RELATIVE_PATH].sort(),
|
|
2627
|
+
'a registration under any owned hooks root names its retired script',
|
|
2628
|
+
);
|
|
2629
|
+
} finally {
|
|
2630
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
test('a retired registration reaching an owned root through a junction is named', () => {
|
|
2636
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-owned-hook-junction-'));
|
|
2637
|
+
try {
|
|
2638
|
+
const agentsHooksRoot = join(sandboxRoot, ...AGENTS_HOOKS_ROOT_SEGMENTS);
|
|
2639
|
+
const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
|
|
2640
|
+
mkdirSync(agentsHooksRoot, { recursive: true });
|
|
2641
|
+
mkdirSync(dirname(claudeHooksRoot), { recursive: true });
|
|
2642
|
+
symlinkSync(agentsHooksRoot, claudeHooksRoot, 'junction');
|
|
2643
|
+
|
|
2644
|
+
const namedPaths = retiredHookPathsNamedByCommands(
|
|
2645
|
+
[hookCommandUnder(agentsHooksRoot, RETIRED_HOOK_RELATIVE_PATH)],
|
|
2646
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2647
|
+
sandboxHookOwnership(sandboxRoot, [claudeHooksRoot]),
|
|
2648
|
+
);
|
|
2649
|
+
|
|
2650
|
+
assert.deepEqual(
|
|
2651
|
+
[...namedPaths],
|
|
2652
|
+
[RETIRED_HOOK_RELATIVE_PATH],
|
|
2653
|
+
'the junction and the directory it reaches are one owned hooks root',
|
|
2654
|
+
);
|
|
2655
|
+
} finally {
|
|
2656
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
test('a registration under a hooks root the installer does not own is never named or pruned', () => {
|
|
2662
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-foreign-hook-root-'));
|
|
2663
|
+
try {
|
|
2664
|
+
const claudeHooksRoot = join(sandboxRoot, '.claude', 'hooks');
|
|
2665
|
+
const foreignHooksRoot = join(sandboxRoot, ...FOREIGN_HOOKS_ROOT_SEGMENTS);
|
|
2666
|
+
const foreignCommand = hookCommandUnder(foreignHooksRoot, RETIRED_HOOK_RELATIVE_PATH);
|
|
2667
|
+
const settingsPath = join(sandboxRoot, 'settings.json');
|
|
2668
|
+
writeFileSync(settingsPath, JSON.stringify({
|
|
2669
|
+
hooks: {
|
|
2670
|
+
PreToolUse: [{
|
|
2671
|
+
matcher: '*',
|
|
2672
|
+
hooks: [{ type: 'command', command: foreignCommand }],
|
|
2673
|
+
}],
|
|
2674
|
+
},
|
|
2675
|
+
}, null, 4) + '\n');
|
|
2676
|
+
const ownership = sandboxHookOwnership(sandboxRoot, [claudeHooksRoot]);
|
|
2677
|
+
|
|
2678
|
+
const namedPaths = retiredHookPathsNamedByCommands(
|
|
2679
|
+
[foreignCommand], new Set([RETIRED_HOOK_RELATIVE_PATH]), ownership,
|
|
2680
|
+
);
|
|
2681
|
+
const removedCount = pruneRetiredHookEntriesFromSettings(
|
|
2682
|
+
settingsPath, new Set([RETIRED_HOOK_RELATIVE_PATH]), ownership,
|
|
2683
|
+
);
|
|
2684
|
+
|
|
2685
|
+
assert.equal(namedPaths.size, 0, 'a foreign registration earns no stand-in');
|
|
2686
|
+
assert.equal(removedCount, 0, 'a foreign registration is never pruned');
|
|
2687
|
+
assert.equal(
|
|
2688
|
+
JSON.parse(readFileSync(settingsPath, 'utf8')).hooks.PreToolUse[0].hooks[0].command,
|
|
2689
|
+
foreignCommand,
|
|
2690
|
+
'the foreign entry stays exactly as the other tool wrote it',
|
|
2691
|
+
);
|
|
2692
|
+
} finally {
|
|
2693
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2694
|
+
}
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
const SANDBOX_HOME_DIRECTORY = process.platform === 'win32'
|
|
2699
|
+
? 'c:/cdev-sandbox-home'
|
|
2700
|
+
: '/cdev-sandbox-home';
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
test('a hook command spelled with a leading tilde expands to the home directory the tilde stands for', () => {
|
|
2704
|
+
const tildeCommand = `python3 ~/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}`;
|
|
2705
|
+
|
|
2706
|
+
const comparisonSpelling = hookCommandComparisonSpelling(
|
|
2707
|
+
tildeCommand, SANDBOX_HOME_DIRECTORY,
|
|
2708
|
+
);
|
|
2709
|
+
|
|
2710
|
+
assert.equal(
|
|
2711
|
+
comparisonSpelling,
|
|
2712
|
+
`python3 ${SANDBOX_HOME_DIRECTORY}/.claude/hooks/${RETIRED_HOOK_RELATIVE_PATH}`,
|
|
2713
|
+
'the home directory replaces the tilde rather than following it',
|
|
2714
|
+
);
|
|
2715
|
+
assert.ok(
|
|
2716
|
+
commandNamesOwnedHookScript(
|
|
2717
|
+
tildeCommand,
|
|
2718
|
+
RETIRED_HOOK_RELATIVE_PATH,
|
|
2719
|
+
sandboxHookOwnership(
|
|
2720
|
+
SANDBOX_HOME_DIRECTORY,
|
|
2721
|
+
[join(SANDBOX_HOME_DIRECTORY, '.claude', 'hooks')],
|
|
2722
|
+
),
|
|
2723
|
+
),
|
|
2724
|
+
'the expanded command reaches the owned hooks root',
|
|
2725
|
+
);
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2728
|
+
|
|
2410
2729
|
const PRIOR_RUN_BACKUP_NAMES = ['2020-01-01T00-00-00-000Z', '2021-06-15T12-30-45-123Z'];
|
|
2411
2730
|
const THIS_RUN_BACKUP_NAME = '2026-07-25T18-04-11-923Z';
|
|
2412
2731
|
const MOVED_SKILL_BACKUP_SEGMENTS = ['skills', 'demo'];
|
|
@@ -2578,3 +2897,64 @@ test('the pstack step reports no path for a skills root without pstack', () => {
|
|
|
2578
2897
|
rmSync(skillsRoot, { recursive: true, force: true });
|
|
2579
2898
|
}
|
|
2580
2899
|
});
|
|
2900
|
+
|
|
2901
|
+
const RETIRED_SKILLS = ['pr-small-cl', 'session-log', 'session-tidy'];
|
|
2902
|
+
|
|
2903
|
+
test('each core skill has a shipped entry point', () => {
|
|
2904
|
+
for (const skillName of CORE_SKILLS) {
|
|
2905
|
+
assert.ok(existsSync(new URL(`../.agents/skills/${skillName}/SKILL.md`, import.meta.url)), skillName);
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
|
|
2909
|
+
test('retired skills leave install groups and stay in the cleanup registry', () => {
|
|
2910
|
+
const selectedSkills = Object.values(INSTALL_GROUPS).flatMap(group => group.skills ?? []);
|
|
2911
|
+
assert.equal(Object.hasOwn(INSTALL_GROUPS, 'journal'), false);
|
|
2912
|
+
for (const skillName of RETIRED_SKILLS) {
|
|
2913
|
+
assert.equal(selectedSkills.includes(skillName), false, skillName);
|
|
2914
|
+
assert.ok(EVER_SHIPPED_SKILL_NAMES.has(skillName), skillName);
|
|
2915
|
+
}
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
test('README and vault guidance use current session tools', () => {
|
|
2919
|
+
const readme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8');
|
|
2920
|
+
const vaultRule = readFileSync(new URL('../rules/vault-context.md', import.meta.url), 'utf8');
|
|
2921
|
+
assert.doesNotMatch(readme, /--only journal|session-log|session-tidy/);
|
|
2922
|
+
assert.doesNotMatch(vaultRule, /`session-log`|`recall`/);
|
|
2923
|
+
assert.match(vaultRule, /obsidian MCP tools/);
|
|
2924
|
+
assert.match(vaultRule, /project.*frontmatter/);
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
test('the help output states that the installer reads only flags', () => {
|
|
2928
|
+
const helpRun = spawnSync(
|
|
2929
|
+
process.execPath,
|
|
2930
|
+
[fileURLToPath(new URL('./install.mjs', import.meta.url)), '--help'],
|
|
2931
|
+
{ encoding: 'utf8', env: process.env },
|
|
2932
|
+
);
|
|
2933
|
+
|
|
2934
|
+
assert.equal(helpRun.status, 0, helpRun.stderr);
|
|
2935
|
+
assert.match(helpRun.stdout, /reads only flags/i);
|
|
2936
|
+
assert.match(helpRun.stdout, /bare path argument carries no meaning/i);
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2939
|
+
|
|
2940
|
+
|
|
2941
|
+
test('pruneRetiredHookEntriesFromSettings warns and returns 0 when the host configuration file cannot be read', () => {
|
|
2942
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-unreadable-host-configuration-'));
|
|
2943
|
+
const unreadableConfigurationPath = join(sandboxRoot, 'hooks.json');
|
|
2944
|
+
mkdirSync(unreadableConfigurationPath);
|
|
2945
|
+
try {
|
|
2946
|
+
const { returnedValue, allWarnings } = captureWarnings(() => (
|
|
2947
|
+
pruneRetiredHookEntriesFromSettings(
|
|
2948
|
+
unreadableConfigurationPath,
|
|
2949
|
+
new Set([RETIRED_HOOK_RELATIVE_PATH]),
|
|
2950
|
+
sandboxClaudeHooksOwnership(sandboxRoot),
|
|
2951
|
+
)
|
|
2952
|
+
));
|
|
2953
|
+
|
|
2954
|
+
assert.equal(returnedValue, 0, 'an unreadable file removes no entry and ends the walk of that file');
|
|
2955
|
+
assert.equal(allWarnings.length, 1, 'the unreadable file is reported once');
|
|
2956
|
+
assert.match(allWarnings[0], /hooks\.json/, 'the warning names the file it left alone');
|
|
2957
|
+
} finally {
|
|
2958
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
2959
|
+
}
|
|
2960
|
+
});
|