claude-dev-env 1.93.1 → 1.95.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +35 -27
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +11 -2
- package/bin/ever-shipped-skills.mjs +70 -0
- package/bin/install.mjs +138 -13
- package/bin/install.prune.test.mjs +457 -0
- package/bin/install.test.mjs +10 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_shared.py +82 -0
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +9 -2
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -0
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +4 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/hooks/pyproject.toml +75 -4
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +6 -27
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +286 -96
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +35 -25
- package/skills/orchestrator-refresh/SKILL.md +15 -11
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/pr-fix-protocol/SKILL.md +20 -20
- package/skills/privacy-hygiene/SKILL.md +4 -3
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/team-advisor/SKILL.md +15 -11
- package/skills/usage-pause/SKILL.md +10 -14
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import {
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
cpSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join, dirname, basename } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
|
|
20
|
+
const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
|
|
21
|
+
const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
|
|
22
|
+
|
|
23
|
+
const RETIRED_SKILL_DIRECTORIES = [
|
|
24
|
+
'findbugs',
|
|
25
|
+
'fixbugs',
|
|
26
|
+
'pr-scope-resolve',
|
|
27
|
+
'post-audit-findings',
|
|
28
|
+
'pr-consistency-audit',
|
|
29
|
+
'bdd-protocol',
|
|
30
|
+
];
|
|
31
|
+
const PERSONAL_SKILL_DIRECTORIES = ['balatro', 'midjourney-sref', 'credit-card-picker'];
|
|
32
|
+
const SHIPPED_SKILL_DIRECTORY = 'autoconverge';
|
|
33
|
+
const PRUNED_BACKUP_DIRECTORY_NAME = '.claude-dev-env-pruned';
|
|
34
|
+
const SKIP_PRUNE_NOTICE_MARKER = 'Skipping retired-skill prune';
|
|
35
|
+
const DEPENDENCY_STUB_PACKAGE_SEGMENTS = ['@jl-cmd', 'prompt-generator'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a stub node_modules tree the installer can resolve the declared
|
|
39
|
+
* dependency package from, and return its root for the child's NODE_PATH.
|
|
40
|
+
*
|
|
41
|
+
* The installer resolves `@jl-cmd/prompt-generator/package.json`; a lone
|
|
42
|
+
* package.json at that path is enough for `createRequire.resolve` to succeed.
|
|
43
|
+
* The stub makes the dependency resolvable inside the sandbox so a full install
|
|
44
|
+
* exercises the real prune path. It does not prove the real private package
|
|
45
|
+
* resolves in a real install — CI cannot host that package — only that the
|
|
46
|
+
* resolved-dependency branch prunes as designed.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} homeDirectory The sandbox home the stub is nested under.
|
|
49
|
+
* @returns {string} The stub modules root to place on the child's NODE_PATH.
|
|
50
|
+
*/
|
|
51
|
+
let isolatedInstallerPath = null;
|
|
52
|
+
let isolatedPackageCopyRoot = null;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Copy the package into a temp directory without node_modules and return the
|
|
56
|
+
* copy's installer path.
|
|
57
|
+
*
|
|
58
|
+
* The installer resolves its declared dependencies with Node's regular
|
|
59
|
+
* node_modules walk starting at its own file, so the real installer under the
|
|
60
|
+
* repo always finds `@jl-cmd/prompt-generator` once `npm install` has run —
|
|
61
|
+
* removing NODE_PATH from the child cannot make the dependency unresolvable
|
|
62
|
+
* there. Running the copy makes resolution genuinely fail through the
|
|
63
|
+
* installer's own catch path: no node_modules sits in the copy's ancestry and
|
|
64
|
+
* the child gets no NODE_PATH. The copy is created once and shared by every
|
|
65
|
+
* unresolved-dependency install; installs write only into their sandbox HOME.
|
|
66
|
+
*
|
|
67
|
+
* @returns {string} The path to `bin/install.mjs` inside the package copy.
|
|
68
|
+
*/
|
|
69
|
+
function ensureIsolatedInstallerPath() {
|
|
70
|
+
if (isolatedInstallerPath !== null) return isolatedInstallerPath;
|
|
71
|
+
isolatedPackageCopyRoot = mkdtempSync(join(tmpdir(), 'cdev-prune-package-'));
|
|
72
|
+
cpSync(PACKAGE_DIRECTORY, isolatedPackageCopyRoot, {
|
|
73
|
+
recursive: true,
|
|
74
|
+
filter: sourcePath => basename(sourcePath) !== EXCLUDED_PACKAGE_COPY_DIRECTORY,
|
|
75
|
+
});
|
|
76
|
+
isolatedInstallerPath = join(isolatedPackageCopyRoot, 'bin', 'install.mjs');
|
|
77
|
+
return isolatedInstallerPath;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
after(() => {
|
|
81
|
+
if (isolatedPackageCopyRoot !== null) {
|
|
82
|
+
rmSync(isolatedPackageCopyRoot, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function ensureDependencyStub(homeDirectory) {
|
|
87
|
+
const stubModulesRoot = join(homeDirectory, 'dependency-stub-modules');
|
|
88
|
+
const stubPackageDirectory = join(stubModulesRoot, ...DEPENDENCY_STUB_PACKAGE_SEGMENTS);
|
|
89
|
+
mkdirSync(stubPackageDirectory, { recursive: true });
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(stubPackageDirectory, 'package.json'),
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
name: DEPENDENCY_STUB_PACKAGE_SEGMENTS.join('/'),
|
|
94
|
+
version: '1.0.0',
|
|
95
|
+
description: 'sandbox dependency stub',
|
|
96
|
+
}) + '\n',
|
|
97
|
+
);
|
|
98
|
+
return stubModulesRoot;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Report whether a retired skill directory landed under the prune backup root.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} claudeDirectory The sandbox ~/.claude directory.
|
|
105
|
+
* @param {string} skillName The retired skill directory name to look for.
|
|
106
|
+
* @returns {boolean} True when a timestamped backup holds the skill directory.
|
|
107
|
+
*/
|
|
108
|
+
function prunedBackupContains(claudeDirectory, skillName) {
|
|
109
|
+
const backupRoot = join(claudeDirectory, PRUNED_BACKUP_DIRECTORY_NAME);
|
|
110
|
+
if (!existsSync(backupRoot)) return false;
|
|
111
|
+
return readdirSync(backupRoot, { withFileTypes: true })
|
|
112
|
+
.filter(entry => entry.isDirectory())
|
|
113
|
+
.some(timestampDir => existsSync(join(backupRoot, timestampDir.name, skillName)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create an isolated ~/.claude sandbox and return the paths a prune test reads.
|
|
118
|
+
*
|
|
119
|
+
* The returned home directory becomes the installer's HOME, so every file the
|
|
120
|
+
* install writes and every skill directory the prune inspects stays inside the
|
|
121
|
+
* temp tree and never touches the machine's real ~/.claude.
|
|
122
|
+
*
|
|
123
|
+
* @returns {{homeDirectory: string, claudeDirectory: string, skillsDirectory: string, manifestPath: string}}
|
|
124
|
+
*/
|
|
125
|
+
function createSandbox() {
|
|
126
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-prune-home-'));
|
|
127
|
+
const claudeDirectory = join(homeDirectory, '.claude');
|
|
128
|
+
const skillsDirectory = join(claudeDirectory, 'skills');
|
|
129
|
+
mkdirSync(skillsDirectory, { recursive: true });
|
|
130
|
+
const manifestPath = join(claudeDirectory, '.claude-dev-env-manifest.json');
|
|
131
|
+
return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Plant a skill directory under the sandbox with a single marker file.
|
|
136
|
+
*
|
|
137
|
+
* A personal directory is planted with no ``SKILL.md`` so it never looks like a
|
|
138
|
+
* shipped skill; a retired directory is planted with a ``SKILL.md`` so it mirrors
|
|
139
|
+
* a real skill the package once installed and later dropped.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} skillsDirectory The sandbox skills directory.
|
|
142
|
+
* @param {string} skillName The directory name to plant.
|
|
143
|
+
* @param {boolean} withSkillManifest Whether to write a ``SKILL.md`` marker.
|
|
144
|
+
*/
|
|
145
|
+
function plantSkillDirectory(skillsDirectory, skillName, withSkillManifest) {
|
|
146
|
+
const skillDirectory = join(skillsDirectory, skillName);
|
|
147
|
+
mkdirSync(skillDirectory, { recursive: true });
|
|
148
|
+
const markerName = withSkillManifest ? 'SKILL.md' : 'notes.md';
|
|
149
|
+
writeFileSync(join(skillDirectory, markerName), `seeded ${skillName}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run the real installer against the sandbox home and return its stdout.
|
|
154
|
+
*
|
|
155
|
+
* The declared dependency package is resolvable by default: the child's
|
|
156
|
+
* NODE_PATH points at a sandbox stub so `createRequire.resolve` finds it and the
|
|
157
|
+
* full-install prune runs. Passing ``{ dependencyResolvable: false }`` runs the
|
|
158
|
+
* installer from an isolated package copy with no node_modules in its ancestry
|
|
159
|
+
* and no NODE_PATH, so the dependency fails to resolve and the installer skips
|
|
160
|
+
* the prune.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} homeDirectory The sandbox home the installer writes into.
|
|
163
|
+
* @param {string[]} extraArguments Installer arguments (for example ``['--only', 'core']``).
|
|
164
|
+
* @param {{dependencyResolvable?: boolean}} options Whether the dependency resolves.
|
|
165
|
+
* @returns {string} The installer's stdout.
|
|
166
|
+
*/
|
|
167
|
+
function runInstaller(homeDirectory, extraArguments, options = {}) {
|
|
168
|
+
const dependencyResolvable = options.dependencyResolvable !== false;
|
|
169
|
+
const childEnvironment = {
|
|
170
|
+
...process.env,
|
|
171
|
+
HOME: homeDirectory,
|
|
172
|
+
USERPROFILE: homeDirectory,
|
|
173
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
174
|
+
};
|
|
175
|
+
let installerPath;
|
|
176
|
+
if (dependencyResolvable) {
|
|
177
|
+
childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
|
|
178
|
+
installerPath = INSTALLER_PATH;
|
|
179
|
+
} else {
|
|
180
|
+
delete childEnvironment.NODE_PATH;
|
|
181
|
+
installerPath = ensureIsolatedInstallerPath();
|
|
182
|
+
}
|
|
183
|
+
return execFileSync('node', [installerPath, ...extraArguments], {
|
|
184
|
+
cwd: dirname(installerPath),
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
env: childEnvironment,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readManifest(manifestPath) {
|
|
191
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test('a full reinstall over a pre-manifest dirty tree prunes retired skills and keeps personal ones', () => {
|
|
195
|
+
const sandbox = createSandbox();
|
|
196
|
+
try {
|
|
197
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
198
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
|
|
199
|
+
}
|
|
200
|
+
for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
|
|
201
|
+
plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
|
|
202
|
+
}
|
|
203
|
+
assert.equal(existsSync(sandbox.manifestPath), false, 'sandbox starts with no manifest');
|
|
204
|
+
|
|
205
|
+
const installerOutput = runInstaller(sandbox.homeDirectory, []);
|
|
206
|
+
|
|
207
|
+
assert.equal(
|
|
208
|
+
installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
209
|
+
false,
|
|
210
|
+
'the resolvable-dependency install runs the prune rather than skipping it',
|
|
211
|
+
);
|
|
212
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
213
|
+
assert.equal(
|
|
214
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
215
|
+
false,
|
|
216
|
+
`retired skill ${retiredSkill} should be pruned`,
|
|
217
|
+
);
|
|
218
|
+
assert.equal(
|
|
219
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
|
|
220
|
+
true,
|
|
221
|
+
`retired skill ${retiredSkill} should be moved to the prune backup`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
for (const personalSkill of PERSONAL_SKILL_DIRECTORIES) {
|
|
225
|
+
assert.equal(
|
|
226
|
+
existsSync(join(sandbox.skillsDirectory, personalSkill)),
|
|
227
|
+
true,
|
|
228
|
+
`personal skill ${personalSkill} should survive`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
assert.equal(
|
|
232
|
+
existsSync(join(sandbox.skillsDirectory, SHIPPED_SKILL_DIRECTORY)),
|
|
233
|
+
true,
|
|
234
|
+
'shipped skills should install',
|
|
235
|
+
);
|
|
236
|
+
const manifest = readManifest(sandbox.manifestPath);
|
|
237
|
+
assert.ok(Array.isArray(manifest.skills), 'manifest gains a skills array');
|
|
238
|
+
assert.ok(manifest.skills.includes(SHIPPED_SKILL_DIRECTORY), 'manifest skills lists shipped skills');
|
|
239
|
+
assert.equal(manifest.skills.includes('_shared'), false, 'manifest skills omits _shared');
|
|
240
|
+
assert.equal(manifest.skills.includes('__pycache__'), false, 'manifest skills omits __pycache__');
|
|
241
|
+
} finally {
|
|
242
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('a full reinstall over an old-format manifest without a skills key still prunes via the ever-shipped fallback', () => {
|
|
247
|
+
const sandbox = createSandbox();
|
|
248
|
+
try {
|
|
249
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
250
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
|
|
251
|
+
}
|
|
252
|
+
writeFileSync(
|
|
253
|
+
sandbox.manifestPath,
|
|
254
|
+
JSON.stringify({
|
|
255
|
+
package: 'claude-dev-env',
|
|
256
|
+
version: '0.0.0',
|
|
257
|
+
installedAt: new Date().toISOString(),
|
|
258
|
+
files: [join(sandbox.skillsDirectory, 'findbugs', 'SKILL.md')],
|
|
259
|
+
}, null, 2) + '\n',
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const installerOutput = runInstaller(sandbox.homeDirectory, []);
|
|
263
|
+
|
|
264
|
+
assert.equal(
|
|
265
|
+
installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
266
|
+
false,
|
|
267
|
+
'the resolvable-dependency install runs the prune rather than skipping it',
|
|
268
|
+
);
|
|
269
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
270
|
+
assert.equal(
|
|
271
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
272
|
+
false,
|
|
273
|
+
`retired skill ${retiredSkill} should be pruned via the ever-shipped fallback`,
|
|
274
|
+
);
|
|
275
|
+
assert.equal(
|
|
276
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
|
|
277
|
+
true,
|
|
278
|
+
`retired skill ${retiredSkill} should be moved to the prune backup`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const manifest = readManifest(sandbox.manifestPath);
|
|
282
|
+
assert.ok(Array.isArray(manifest.skills), 'the reinstall writes the skills key onto the old-format manifest');
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('a full reinstall prunes a manifest-recorded skill absent from the package and keeps a personal directory', () => {
|
|
289
|
+
const sandbox = createSandbox();
|
|
290
|
+
try {
|
|
291
|
+
const retiredManifestSkill = 'ghost-skill';
|
|
292
|
+
const personalSkill = 'balatro';
|
|
293
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredManifestSkill, true);
|
|
294
|
+
plantSkillDirectory(sandbox.skillsDirectory, personalSkill, false);
|
|
295
|
+
writeFileSync(
|
|
296
|
+
sandbox.manifestPath,
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
package: 'claude-dev-env',
|
|
299
|
+
version: '0.0.0',
|
|
300
|
+
installedAt: new Date().toISOString(),
|
|
301
|
+
files: [],
|
|
302
|
+
skills: [SHIPPED_SKILL_DIRECTORY, retiredManifestSkill],
|
|
303
|
+
}, null, 2) + '\n',
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const installerOutput = runInstaller(sandbox.homeDirectory, []);
|
|
307
|
+
|
|
308
|
+
assert.equal(
|
|
309
|
+
installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
310
|
+
false,
|
|
311
|
+
'the resolvable-dependency install runs the prune rather than skipping it',
|
|
312
|
+
);
|
|
313
|
+
assert.equal(
|
|
314
|
+
existsSync(join(sandbox.skillsDirectory, retiredManifestSkill)),
|
|
315
|
+
false,
|
|
316
|
+
'a skill the prior manifest recorded but the package no longer ships is pruned',
|
|
317
|
+
);
|
|
318
|
+
assert.equal(
|
|
319
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredManifestSkill),
|
|
320
|
+
true,
|
|
321
|
+
'the manifest-recorded retired skill is moved to the prune backup',
|
|
322
|
+
);
|
|
323
|
+
assert.equal(
|
|
324
|
+
existsSync(join(sandbox.skillsDirectory, personalSkill)),
|
|
325
|
+
true,
|
|
326
|
+
'a personal directory in neither the manifest nor the ever-shipped set survives',
|
|
327
|
+
);
|
|
328
|
+
} finally {
|
|
329
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('a scoped --only install leaves retired skills in place because prune runs on full installs only', () => {
|
|
334
|
+
const sandbox = createSandbox();
|
|
335
|
+
try {
|
|
336
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
337
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
runInstaller(sandbox.homeDirectory, ['--only', 'core']);
|
|
341
|
+
|
|
342
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
343
|
+
assert.equal(
|
|
344
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
345
|
+
true,
|
|
346
|
+
`retired skill ${retiredSkill} should survive a scoped install`,
|
|
347
|
+
);
|
|
348
|
+
assert.equal(
|
|
349
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
|
|
350
|
+
false,
|
|
351
|
+
`retired skill ${retiredSkill} should not be moved to backup by a scoped install`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('a full reinstall keeps pr-fix-protocol because the package ships it again', () => {
|
|
360
|
+
const sandbox = createSandbox();
|
|
361
|
+
try {
|
|
362
|
+
plantSkillDirectory(sandbox.skillsDirectory, 'pr-fix-protocol', true);
|
|
363
|
+
writeFileSync(join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md'), 'stale seeded copy\n');
|
|
364
|
+
|
|
365
|
+
const installerOutput = runInstaller(sandbox.homeDirectory, []);
|
|
366
|
+
|
|
367
|
+
assert.equal(
|
|
368
|
+
installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
369
|
+
false,
|
|
370
|
+
'the resolvable-dependency install runs the prune rather than skipping it',
|
|
371
|
+
);
|
|
372
|
+
const restoredSkillPath = join(sandbox.skillsDirectory, 'pr-fix-protocol', 'SKILL.md');
|
|
373
|
+
assert.equal(existsSync(restoredSkillPath), true, 'pr-fix-protocol survives and is reinstalled');
|
|
374
|
+
assert.notEqual(
|
|
375
|
+
readFileSync(restoredSkillPath, 'utf8'),
|
|
376
|
+
'stale seeded copy\n',
|
|
377
|
+
'the shipped pr-fix-protocol overwrites the stale seeded copy',
|
|
378
|
+
);
|
|
379
|
+
} finally {
|
|
380
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('a full reinstall with an unresolved dependency skips the prune and leaves retired skills untouched', () => {
|
|
385
|
+
const sandbox = createSandbox();
|
|
386
|
+
try {
|
|
387
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
388
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const installerOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
|
|
392
|
+
|
|
393
|
+
assert.equal(
|
|
394
|
+
installerOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
395
|
+
true,
|
|
396
|
+
'the installer logs a notice that it is skipping the prune',
|
|
397
|
+
);
|
|
398
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
399
|
+
assert.equal(
|
|
400
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
401
|
+
true,
|
|
402
|
+
`retired skill ${retiredSkill} should survive when a dependency is unresolved`,
|
|
403
|
+
);
|
|
404
|
+
assert.equal(
|
|
405
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
|
|
406
|
+
false,
|
|
407
|
+
`retired skill ${retiredSkill} should not be moved to backup when the prune is skipped`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('the prune skip is scoped: once the dependency resolves a later full install prunes normally', () => {
|
|
416
|
+
const sandbox = createSandbox();
|
|
417
|
+
try {
|
|
418
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
419
|
+
plantSkillDirectory(sandbox.skillsDirectory, retiredSkill, true);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const skippedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: false });
|
|
423
|
+
assert.equal(
|
|
424
|
+
skippedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
425
|
+
true,
|
|
426
|
+
'the unresolved-dependency install skips the prune',
|
|
427
|
+
);
|
|
428
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
429
|
+
assert.equal(
|
|
430
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
431
|
+
true,
|
|
432
|
+
`retired skill ${retiredSkill} should still be present after the skipped prune`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const resolvedOutput = runInstaller(sandbox.homeDirectory, [], { dependencyResolvable: true });
|
|
437
|
+
assert.equal(
|
|
438
|
+
resolvedOutput.includes(SKIP_PRUNE_NOTICE_MARKER),
|
|
439
|
+
false,
|
|
440
|
+
'the resolved-dependency install runs the prune, proving the skip is not a permanent disable',
|
|
441
|
+
);
|
|
442
|
+
for (const retiredSkill of RETIRED_SKILL_DIRECTORIES) {
|
|
443
|
+
assert.equal(
|
|
444
|
+
existsSync(join(sandbox.skillsDirectory, retiredSkill)),
|
|
445
|
+
false,
|
|
446
|
+
`retired skill ${retiredSkill} should be pruned once the dependency resolves`,
|
|
447
|
+
);
|
|
448
|
+
assert.equal(
|
|
449
|
+
prunedBackupContains(sandbox.claudeDirectory, retiredSkill),
|
|
450
|
+
true,
|
|
451
|
+
`retired skill ${retiredSkill} should be moved to the prune backup on the resolved install`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
});
|
package/bin/install.test.mjs
CHANGED
|
@@ -637,6 +637,12 @@ test('commandReferencesManagedHook leaves an unmanaged inline -c command that im
|
|
|
637
637
|
});
|
|
638
638
|
|
|
639
639
|
|
|
640
|
+
|
|
641
|
+
function isPreToolUseDispatcherCommand(command) {
|
|
642
|
+
// Basename-anchored match: bash_pre_tool_use_dispatcher.py must not count.
|
|
643
|
+
return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
|
|
644
|
+
}
|
|
645
|
+
|
|
640
646
|
function countManagedRunAllValidatorsHooks(settings) {
|
|
641
647
|
const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
|
|
642
648
|
group => group.matcher === 'Write|Edit'
|
|
@@ -996,7 +1002,7 @@ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one d
|
|
|
996
1002
|
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
997
1003
|
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
998
1004
|
|
|
999
|
-
const allDispatcherCommands = allHookCommands.filter(
|
|
1005
|
+
const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
|
|
1000
1006
|
assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
|
|
1001
1007
|
|
|
1002
1008
|
for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
|
|
@@ -1029,7 +1035,7 @@ test('mergeHooksIntoSettings is idempotent when run twice against an already-upd
|
|
|
1029
1035
|
const allPreToolUseGroups = settings.hooks.PreToolUse || [];
|
|
1030
1036
|
const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1031
1037
|
|
|
1032
|
-
const allDispatcherCommands = allHookCommands.filter(
|
|
1038
|
+
const allDispatcherCommands = allHookCommands.filter(isPreToolUseDispatcherCommand);
|
|
1033
1039
|
assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
|
|
1034
1040
|
assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
|
|
1035
1041
|
});
|
|
@@ -1042,7 +1048,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1042
1048
|
|
|
1043
1049
|
const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
|
|
1044
1050
|
const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1045
|
-
const preDispatcherCommands = allPreCommands.filter(
|
|
1051
|
+
const preDispatcherCommands = allPreCommands.filter(isPreToolUseDispatcherCommand);
|
|
1046
1052
|
assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
|
|
1047
1053
|
|
|
1048
1054
|
assert.equal(
|
|
@@ -1222,6 +1228,6 @@ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when th
|
|
|
1222
1228
|
}
|
|
1223
1229
|
|
|
1224
1230
|
const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
|
|
1225
|
-
const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command
|
|
1231
|
+
const dispatcherCommands = dispatcherGroup.hooks.filter(hook => isPreToolUseDispatcherCommand(hook.command));
|
|
1226
1232
|
assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
|
|
1227
1233
|
});
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
|
|
|
94
94
|
|
|
95
95
|
## 11. ENFORCEMENT SURFACES
|
|
96
96
|
|
|
97
|
-
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation
|
|
97
|
+
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit. The diagram-first docstring standard (a summary line, then a `::` example or doctest, then a couple of short prose lines) lives in `packages/claude-dev-env/rules/plain-illustrative-docstrings.md`, enforced by the `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` backstop hooks and Category O9 audit.
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -81,6 +81,8 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
81
81
|
| `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
|
|
82
82
|
| `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
|
|
83
83
|
| `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
|
|
84
|
+
| `pii_commit_command.py` | library | Token-aware git-commit detection reused by `pii_prevention_blocker.py` |
|
|
85
|
+
| `pii_payload_scan.py` | library | Write/Edit and durable post-body PII evaluation reused by `pii_prevention_blocker.py` |
|
|
84
86
|
| `pii_prevention_blocker.py` | PreToolUse (Write/Edit/MultiEdit/Bash/PowerShell/MCP GitHub) | Content that carries high-confidence personal data or secrets (real emails, home-dir paths, private IPs, credential material) on write, durable GitHub posts, or staged commit paths |
|
|
85
87
|
| `pii_scanner.py` | library | Pure text scanners shared by `pii_prevention_blocker.py` |
|
|
86
88
|
| `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
|
|
@@ -158,6 +158,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
158
158
|
is_ephemeral_script_path,
|
|
159
159
|
is_hook_infrastructure,
|
|
160
160
|
is_test_file,
|
|
161
|
+
is_under_session_scratchpad,
|
|
161
162
|
)
|
|
162
163
|
from code_rules_string_magic import ( # noqa: E402
|
|
163
164
|
check_inline_literal_collections,
|
|
@@ -1104,6 +1105,9 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1104
1105
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
1105
1106
|
file_path = tool_input.get("file_path", "")
|
|
1106
1107
|
|
|
1108
|
+
if is_under_session_scratchpad(file_path, pretooluse_payload):
|
|
1109
|
+
sys.exit(0)
|
|
1110
|
+
|
|
1107
1111
|
runs_full_verdict = _is_validated_target(file_path)
|
|
1108
1112
|
if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
|
|
1109
1113
|
sys.exit(0)
|
|
@@ -4,6 +4,7 @@ import ast
|
|
|
4
4
|
import difflib
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
import tempfile
|
|
7
8
|
from collections.abc import Collection, Iterator
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -29,6 +30,13 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
29
30
|
LEADING_DRIVE_LETTER_PATTERN,
|
|
30
31
|
STRICT_TEST_FILE_BASENAME_PATTERN,
|
|
31
32
|
)
|
|
33
|
+
from hooks_constants.harness_scratchpad_constants import ( # noqa: E402
|
|
34
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
35
|
+
HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT,
|
|
36
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX,
|
|
37
|
+
HOOK_PAYLOAD_SESSION_ID_KEY,
|
|
38
|
+
HOOK_PAYLOAD_WORKING_DIRECTORY_KEY,
|
|
39
|
+
)
|
|
32
40
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
33
41
|
TYPE_CHECKING_IDENTIFIER,
|
|
34
42
|
)
|
|
@@ -267,6 +275,80 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
267
275
|
return False
|
|
268
276
|
|
|
269
277
|
|
|
278
|
+
def _resolve_session_scratchpad_root(hook_payload: dict) -> str | None:
|
|
279
|
+
"""Rebuild the harness session scratchpad directory from a PreToolUse payload.
|
|
280
|
+
|
|
281
|
+
::
|
|
282
|
+
|
|
283
|
+
payload.cwd = /home/user/project payload.session_id = 5f2c...
|
|
284
|
+
| |
|
|
285
|
+
<tempdir>/claude-<uid>/-home-user-project/5f2c.../scratchpad
|
|
286
|
+
| |
|
|
287
|
+
os.getuid() cwd with each "/" turned to "-"
|
|
288
|
+
|
|
289
|
+
No environment variable carries this path, so it is rebuilt from the three
|
|
290
|
+
signals a hook can read: the POSIX user id and the ``cwd`` and ``session_id``
|
|
291
|
+
fields the harness puts in every PreToolUse payload. The rebuilt directory
|
|
292
|
+
is returned only when it exists on disk, so a wrong guess or a non-POSIX
|
|
293
|
+
platform yields None and the gates keep full enforcement.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The scratchpad directory path when it exists on disk, else None.
|
|
300
|
+
"""
|
|
301
|
+
get_user_id = getattr(os, "getuid", None)
|
|
302
|
+
if get_user_id is None:
|
|
303
|
+
return None
|
|
304
|
+
session_id = hook_payload.get(HOOK_PAYLOAD_SESSION_ID_KEY, "")
|
|
305
|
+
working_directory = hook_payload.get(HOOK_PAYLOAD_WORKING_DIRECTORY_KEY, "")
|
|
306
|
+
if not isinstance(session_id, str) or not session_id:
|
|
307
|
+
return None
|
|
308
|
+
if not isinstance(working_directory, str) or not working_directory:
|
|
309
|
+
return None
|
|
310
|
+
mangled_working_directory = working_directory.replace("\\", "/").replace(
|
|
311
|
+
"/", HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT
|
|
312
|
+
)
|
|
313
|
+
user_directory_name = f"{HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX}{get_user_id()}"
|
|
314
|
+
scratchpad_root = os.path.join(
|
|
315
|
+
tempfile.gettempdir(),
|
|
316
|
+
user_directory_name,
|
|
317
|
+
mangled_working_directory,
|
|
318
|
+
session_id,
|
|
319
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
320
|
+
)
|
|
321
|
+
if not os.path.isdir(scratchpad_root):
|
|
322
|
+
return None
|
|
323
|
+
return scratchpad_root
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
|
|
327
|
+
"""Return True when file_path resolves under the harness session scratchpad.
|
|
328
|
+
|
|
329
|
+
One-off scripts written to the session scratchpad are throwaway tooling
|
|
330
|
+
outside every repo, so the TDD and CODE_RULES gates skip them. Both
|
|
331
|
+
file_path and the rebuilt scratchpad root are resolved through the real
|
|
332
|
+
filesystem (symlinks followed) before the containment test, so a symlink
|
|
333
|
+
into the scratchpad exempts and a symlink out of it does not.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
file_path: The path the write targets.
|
|
337
|
+
hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
True when file_path's real path sits at or under the session scratchpad.
|
|
341
|
+
"""
|
|
342
|
+
if not file_path:
|
|
343
|
+
return False
|
|
344
|
+
scratchpad_root = _resolve_session_scratchpad_root(hook_payload)
|
|
345
|
+
if scratchpad_root is None:
|
|
346
|
+
return False
|
|
347
|
+
real_target = os.path.realpath(file_path)
|
|
348
|
+
real_root = os.path.realpath(scratchpad_root)
|
|
349
|
+
return real_target == real_root or real_target.startswith(real_root + os.sep)
|
|
350
|
+
|
|
351
|
+
|
|
270
352
|
def is_migration_file(file_path: str) -> bool:
|
|
271
353
|
"""Check if file is a Django migration (must be self-contained)."""
|
|
272
354
|
path_lower = file_path.lower().replace("\\", "/")
|
|
@@ -133,7 +133,11 @@ def _referenced_names(tree: ast.Module) -> set[str]:
|
|
|
133
133
|
}
|
|
134
134
|
return load_names | literal_values
|
|
135
135
|
|
|
136
|
-
def _dead_constant_messages(
|
|
136
|
+
def _dead_constant_messages(
|
|
137
|
+
tree: ast.Module,
|
|
138
|
+
referenced_names: set[str],
|
|
139
|
+
maximum_issues: int,
|
|
140
|
+
) -> list[str]:
|
|
137
141
|
"""Return one message per module constant absent from the referenced set."""
|
|
138
142
|
issues: list[str] = []
|
|
139
143
|
for each_name, each_line in _module_constant_targets(tree):
|
|
@@ -142,7 +146,7 @@ def _dead_constant_messages(tree: ast.Module, referenced_names: set[str]) -> lis
|
|
|
142
146
|
issues.append(
|
|
143
147
|
f"Line {each_line}: constant {each_name!r} - {DEAD_TEST_CONSTANT_GUIDANCE}"
|
|
144
148
|
)
|
|
145
|
-
if len(issues) >=
|
|
149
|
+
if len(issues) >= maximum_issues:
|
|
146
150
|
break
|
|
147
151
|
return issues
|
|
148
152
|
|
|
@@ -174,7 +178,9 @@ def check_dead_test_module_constant(content: str, file_path: str) -> list[str]:
|
|
|
174
178
|
tree = _parse_module(content)
|
|
175
179
|
if tree is None:
|
|
176
180
|
return []
|
|
177
|
-
return _dead_constant_messages(
|
|
181
|
+
return _dead_constant_messages(
|
|
182
|
+
tree, _referenced_names(tree), MAX_TEST_LAYOUT_ISSUES
|
|
183
|
+
)
|
|
178
184
|
|
|
179
185
|
def _node_names_fixture(node: ast.AST) -> bool:
|
|
180
186
|
"""Return whether one decorator sub-node spells the pytest fixture marker."""
|