claude-dev-env 2.17.0 → 2.19.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.
Files changed (117) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/orchestrator/SKILL.md +6 -4
  7. package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
  8. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  9. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  10. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  12. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  13. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  14. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  15. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  16. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  17. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  18. package/.agents/skills/skill-builder/SKILL.md +1 -0
  19. package/.agents/skills/team-advisor/SKILL.md +5 -5
  20. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  21. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  22. package/.agents/skills-archived/comments/SKILL.md +1 -1
  23. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  24. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  25. package/AGENTS.md +1 -1
  26. package/_shared/advisor/AGENTS.md +5 -5
  27. package/_shared/advisor/advisor-protocol.md +54 -31
  28. package/_shared/advisor/reference/advisor-block.md +5 -1
  29. package/_shared/advisor/reference/consult-format.md +1 -1
  30. package/_shared/advisor/reference/identity.md +28 -0
  31. package/_shared/advisor/reference/lifecycle.md +8 -1
  32. package/_shared/advisor/reference/sol-rung.md +12 -7
  33. package/_shared/advisor/reference/spawn-walk-log.md +6 -5
  34. package/_shared/advisor/reference/third-party-bind.md +5 -6
  35. package/_shared/advisor/reference/warm-up.md +9 -2
  36. package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
  37. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
  38. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
  39. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
  40. package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
  41. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
  42. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
  43. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
  44. package/_shared/advisor/scripts/tier_model_ids.py +75 -7
  45. package/_shared/pr-loop/AGENTS.md +2 -1
  46. package/_shared/pr-loop/preflight-proposal.md +56 -0
  47. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  48. package/_shared/pr-loop/worker-spawn.md +4 -3
  49. package/bin/ever-shipped-skills.mjs +14 -0
  50. package/bin/install-constants.mjs +5 -0
  51. package/bin/install.agents-home.test.mjs +23 -0
  52. package/bin/install.codex-rules.test.mjs +61 -6
  53. package/bin/install.mjs +57 -9
  54. package/bin/install.prune.test.mjs +65 -3
  55. package/bin/install.test.mjs +202 -2
  56. package/docs/CODE_RULES.md +4 -0
  57. package/docs/references/team-advisor-skill.md +2 -2
  58. package/hooks/advisory/migration_safety_advisor.py +14 -14
  59. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  60. package/hooks/atomic_file_writer.py +81 -0
  61. package/hooks/blocking/AGENTS.md +2 -1
  62. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  63. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  64. package/hooks/blocking/destructive_command_blocker.py +8 -7
  65. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  66. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  67. package/hooks/blocking/sensitive_file_protector.py +8 -6
  68. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  69. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  70. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  71. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  72. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  73. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  74. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  75. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  76. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  77. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  78. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  79. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  80. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  81. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  82. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  83. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  84. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  85. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  86. package/hooks/blocking/test_pii_scanner.py +81 -0
  87. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  88. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  89. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  90. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  91. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  92. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  93. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  94. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  95. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  96. package/hooks/blocking/test_test_preflight_check.py +80 -0
  97. package/hooks/git-hooks/pre_commit.py +6 -2
  98. package/hooks/git-hooks/test_pre_push.py +57 -0
  99. package/hooks/hooks_constants/AGENTS.md +3 -1
  100. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  101. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  102. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  103. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  104. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  105. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  106. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  107. package/hooks/json_file_reader.py +22 -0
  108. package/hooks/pending_sidecars.py +33 -0
  109. package/hooks/test_atomic_file_writer.py +73 -0
  110. package/hooks/test_json_file_reader.py +24 -0
  111. package/hooks/test_pending_sidecars.py +25 -0
  112. package/package.json +1 -1
  113. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  114. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  115. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  116. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  117. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -11,6 +11,7 @@ Runtime documents and scripts shared by every PR-loop skill. Changes here affect
11
11
  | `post-audit-thread-contract.md` | Single source of truth for the `post_audit_thread.py` invocation string, exit-code table, and per-caller policy (bugteam exit 2 = hard blocker; autoconverge clean-audit failed post = recorded bypass) |
12
12
  | `fix-protocol.md` | Ordered sequence a fix lens follows: read, capture SHA, TDD, apply, validate, self-audit, commit, push, reply + resolve |
13
13
  | `gh-payloads.md` | How to build GitHub review and reply payloads via MCP tools; describes the one-review-per-loop pattern |
14
+ | `preflight-proposal.md` | Shared immutable proposal evidence and mutation-boundary contract for audit preflight worktrees |
14
15
  | `state-schema.md` | Fields each PR-loop workflow tracks across iterations; documents common fields and per-skill extensions |
15
16
  | `code-rules-gate.md` | Reference for the CODE_RULES pre-commit gate check; describes what the gate blocks and when it runs |
16
17
  | `precatch-rubric.md` | Shared pre-catch lane checklist — deterministic sweep, doc-vs-code parity, test-assertion completeness, PR-description parity, adversarial audit — that autoconverge's lenses and pr-converge's CODE_REVIEW step read on demand |
@@ -24,4 +25,4 @@ Runtime documents and scripts shared by every PR-loop skill. Changes here affect
24
25
 
25
26
  ## Breaking-change rule
26
27
 
27
- Any shape change in `audit-contract.md` or `audit-reply-template.md` requires updating every consuming skill in the same commit.
28
+ Any shape change in a canonical contract listed under **Key documents** requires updating every consuming skill in the same commit.
@@ -0,0 +1,56 @@
1
+ # Preflight proposal contract
2
+
3
+ Audit skills use this contract when the caller selects `preflight-proposal`. The mode prepares a local candidate and returns evidence for downstream selection.
4
+
5
+ Each skill hub defines its command name and finding classification. Commands use this shape:
6
+
7
+ ```text
8
+ <audit-skill> preflight-proposal <pr_number> --base-sha <base_sha> --head-sha <head_sha> --worktree <isolated_worktree>
9
+ ```
10
+
11
+ ## Required inputs
12
+
13
+ | Input | Contract |
14
+ |---|---|
15
+ | `pr_number` | Resolved open pull-request number |
16
+ | `--base-sha` | Full immutable base commit SHA |
17
+ | `--head-sha` | Full immutable head commit SHA |
18
+ | `--worktree` | Caller-supplied isolated worktree path |
19
+
20
+ ## Contract
21
+
22
+ 1. Validate every required input before the first audit command.
23
+ 2. Run `git rev-parse HEAD` in the supplied worktree and require exact equality with the head SHA.
24
+ 3. Gather changed paths and audit scope from the exact `base_sha...head_sha` range. Use the resolved PR number for PR metadata.
25
+ 4. Run the complete audit and findings against the immutable range and worktree.
26
+ 5. Keep every audit edit and test inside the caller-supplied isolated worktree.
27
+ 6. Create an immutable proposal identity. Use the existing commit SHA for committed proposals. For working-tree edits, use a deterministic diff hash over the baseline `HEAD` SHA, base SHA, head SHA, sorted normalized changed paths, status records, and file bytes.
28
+ 7. Record every changed path, including tracked, untracked, renamed, and deleted paths.
29
+ 8. Record exact tests and outcomes: each test command, exit code, and outcome.
30
+ 9. Return selected-candidate-ready proposal evidence with the proposal ID, isolated worktree path, changed paths, findings, tests, and outcomes.
31
+ 10. Keep commit, push, pull-request body, pull-request comment, pull-request review, pull-request update, merge, rebase, and Ready-state mutations disabled.
32
+ 11. Return the proposal for downstream disposition. The downstream owner adds the proposal ID to its selected or dispositioned proposal collection. Reapplication uses exactly the selected records and their changed paths. Give each later finding a new proposal ID and evidence record.
33
+
34
+ ## Evidence record
35
+
36
+ Use this record shape:
37
+
38
+ ```yaml
39
+ mode: preflight-proposal
40
+ pr_number: <resolved PR number>
41
+ base_sha: <immutable base SHA>
42
+ head_sha: <immutable head SHA>
43
+ proposal_id:
44
+ kind: commit-sha | diff-sha256
45
+ value: <immutable identity>
46
+ isolated_worktree: <caller-supplied path>
47
+ changed_paths:
48
+ - <normalized path>
49
+ findings:
50
+ - <audit-classified finding>
51
+ tests:
52
+ - command: <exact command>
53
+ exit_code: <integer>
54
+ outcome: <exact outcome>
55
+ downstream_selection: required
56
+ ```
@@ -3,14 +3,23 @@
3
3
  from pathlib import Path
4
4
 
5
5
  import pytest
6
- from code_rules_gate_parts import gate_running
7
6
 
7
+ from code_rules_gate_parts import enforcer_loading, gate_running
8
+ from code_rules_gate_parts.tests._repo_test_helpers import (
9
+ init_repository,
10
+ write_commit_and_stage_change,
11
+ )
8
12
 
9
- def _clean_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
13
+
14
+ def _clean_validate(
15
+ _content: str, _path: str, _prior: str = "", **_kwargs: object
16
+ ) -> list[str]:
10
17
  return []
11
18
 
12
19
 
13
- def _dirty_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
20
+ def _dirty_validate(
21
+ _content: str, _path: str, _prior: str = "", **_kwargs: object
22
+ ) -> list[str]:
14
23
  return ["Line 1: bad"]
15
24
 
16
25
 
@@ -92,8 +101,42 @@ def test_print_violation_section_groups_by_relative_path(
92
101
  ) -> None:
93
102
  module_path = (tmp_path / "module.py").resolve()
94
103
 
95
- gate_running.print_violation_section("HEADER", {module_path: ["Line 1: issue"]}, tmp_path)
104
+ gate_running.print_violation_section(
105
+ "HEADER", {module_path: ["Line 1: issue"]}, tmp_path
106
+ )
96
107
 
97
108
  captured = capsys.readouterr()
98
109
  assert "HEADER" in captured.err
99
110
  assert "Line 1: issue" in captured.err
111
+
112
+
113
+ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
114
+ tmp_path: Path,
115
+ capsys: pytest.CaptureFixture[str],
116
+ ) -> None:
117
+ repository_root = tmp_path / "repository"
118
+ repository_root.mkdir()
119
+ init_repository(repository_root)
120
+ committed_source = "def calculate_total() -> int:\n return 0\n"
121
+ staged_source = (
122
+ "def calculate_total() -> int:\n total = 9999\n return total\n"
123
+ )
124
+ file_path = write_commit_and_stage_change(
125
+ repository_root,
126
+ "service.py",
127
+ committed_source,
128
+ staged_source,
129
+ )
130
+
131
+ resolved_file_path = file_path.resolve()
132
+ exit_code = gate_running.run_gate(
133
+ validate_content=enforcer_loading.load_validate_content(),
134
+ all_file_paths=[resolved_file_path],
135
+ repository_root=repository_root,
136
+ all_added_lines_by_path={resolved_file_path: {2, 3}},
137
+ should_read_staged_content=True,
138
+ )
139
+
140
+ captured = capsys.readouterr()
141
+ assert exit_code == 1
142
+ assert "9999" in captured.err
@@ -121,13 +121,14 @@ preflight/runner classification strings (for example `grok_binary_missing`,
121
121
  After a grok fallthrough the dispatcher calls `detect_host_profile()` from
122
122
  `tier_model_ids` (advisor scripts). Detection order:
123
123
 
124
- 1. `ADVISOR_HOST_PROFILE` when set — must be `Claude` or `ThirdParty` (any letter case).
124
+ 1. `ADVISOR_HOST_PROFILE` when set — must be `Claude`, `Codex`, or `ThirdParty` (any letter case).
125
125
  2. `THIRD_PARTY` when truthy — `1`, `true`, `yes`, or `on`.
126
126
  3. Default: `Claude`.
127
127
 
128
- Profiles are the real names `Claude` and `ThirdParty` (not a separate Grok host
128
+ Profiles are the real names `Claude`, `Codex`, and `ThirdParty` (not a separate Grok host
129
129
  profile). Grok is tier 1 on every host; host profile only chooses the post-grok
130
- branch (Agent-tool handoff vs headless claude chain).
130
+ branch (Agent-tool handoff vs headless claude chain). A Codex profile takes the
131
+ non-Claude branch.
131
132
 
132
133
  ## Code-review host routing
133
134
 
@@ -30,6 +30,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
30
30
  'build-goal-prompt',
31
31
  'bugteam',
32
32
  'caveman',
33
+ 'cert-classification-rule',
33
34
  'closeout',
34
35
  'code',
35
36
  'comments',
@@ -40,6 +41,9 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
40
41
  'copilot-review',
41
42
  'deep-research',
42
43
  'descriptions',
44
+ 'e-code-review',
45
+ 'e-simplify',
46
+ 'eli5',
43
47
  'emergencies',
44
48
  'everything-search',
45
49
  'findbugs',
@@ -64,6 +68,8 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
64
68
  'midjourney-prompt',
65
69
  'midjourney-submit',
66
70
  'monitor-open-prs',
71
+ 'pr-name-by-capability',
72
+ 'pr-refinement',
67
73
  'orchestrator',
68
74
  'orchestrator-refresh',
69
75
  'plan-to-pr',
@@ -71,6 +77,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
71
77
  'pmid',
72
78
  'pmin',
73
79
  'post-audit-findings',
80
+ 'pr-cleanup',
74
81
  'pr-consistency-audit',
75
82
  'pr-converge',
76
83
  'pr-fix-protocol',
@@ -80,6 +87,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
80
87
  'pr-scope-resolve',
81
88
  'pre-compact',
82
89
  'privacy-hygiene',
90
+ 'prototype',
83
91
  'prompt-generator',
84
92
  'qbug',
85
93
  'rebase',
@@ -90,14 +98,20 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
90
98
  'release-notes-html',
91
99
  'remember',
92
100
  'research-mode',
101
+ 'review-router',
102
+ 'review-tier',
93
103
  'reviewer-gates',
94
104
  'reviews',
95
105
  'run-claude-dev-env',
96
106
  'session-log',
97
107
  'session-tidy',
108
+ 'pr-shared-extraction',
98
109
  'show',
99
110
  'skill-builder',
100
111
  'small-cl',
112
+ 'pr-small-cl',
113
+ 'source-command-logifix',
114
+ 'source-command-sr-loop',
101
115
  'split-pr',
102
116
  'structure-prompt',
103
117
  'task-build',
@@ -170,3 +170,8 @@ export const CURSOR_SYNC_SCRIPT_FILE_NAME = 'sync_to_cursor.py';
170
170
  * Windows Python launcher command the installer may bake into hook settings.
171
171
  */
172
172
  export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
173
+
174
+ /**
175
+ * Maximum time each Python interpreter probe may run during detection.
176
+ */
177
+ export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
@@ -33,6 +33,10 @@ const SHIPPED_SKILL_NAME = 'privacy-hygiene';
33
33
  const ELI5_SKILL_NAME = 'eli5';
34
34
  const SHIPPED_AGENT_FILE_NAME = 'clean-coder.md';
35
35
  const PERSONAL_SKILL_NAME = 'my-notes';
36
+ const SHARED_DIRECTORY_NAME = '_shared';
37
+ const PR_LOOP_DIRECTORY_NAME = 'pr-loop';
38
+ const PREFLIGHT_PROPOSAL_FILE_NAME = 'preflight-proposal.md';
39
+ const PREFLIGHT_PROPOSAL_POINTER = '@~/.claude/_shared/pr-loop/preflight-proposal.md';
36
40
 
37
41
  /**
38
42
  * @param {string} homeDirectory
@@ -53,6 +57,24 @@ function runInstaller(homeDirectory, extraArguments) {
53
57
  });
54
58
  }
55
59
 
60
+ /**
61
+ * @param {{ homeDirectory: string, skillsInstallDirectory: string }} installationPaths
62
+ */
63
+ function assertProposalContractInstallation(installationPaths) {
64
+ const { homeDirectory, skillsInstallDirectory } = installationPaths;
65
+ const allContractPathSegments = [
66
+ SHARED_DIRECTORY_NAME, PR_LOOP_DIRECTORY_NAME, PREFLIGHT_PROPOSAL_FILE_NAME,
67
+ ];
68
+ const installedPointerPath = join(skillsInstallDirectory, ...allContractPathSegments);
69
+ const pointerLine = readFileSync(installedPointerPath, 'utf8').trimEnd().split('\n').at(-1);
70
+ const allTargetSegments = pointerLine.slice('@~/'.length).split('/');
71
+ const installedContractPath = join(homeDirectory, ...allTargetSegments);
72
+ const sourceContractPath = join(PACKAGE_DIRECTORY, ...allContractPathSegments);
73
+
74
+ assert.equal(pointerLine, PREFLIGHT_PROPOSAL_POINTER);
75
+ assert.equal(readFileSync(installedContractPath, 'utf8'), readFileSync(sourceContractPath, 'utf8'));
76
+ }
77
+
56
78
  test('CONTENT_DIRECTORIES omits agents because that tree installs to the agents home', () => {
57
79
  assert.equal(CONTENT_DIRECTORIES.includes(MANAGED_AGENTS_DIRECTORY_NAME), false);
58
80
  });
@@ -106,6 +128,7 @@ test('a full install writes skills and agents under .agents and points .claude a
106
128
  );
107
129
  assert.equal(realpathSync(lookupSkillFile), realpathSync(canonicalSkillFile));
108
130
  assert.equal(realpathSync(lookupEli5SkillFile), realpathSync(canonicalEli5SkillFile));
131
+ assertProposalContractInstallation({ homeDirectory, skillsInstallDirectory });
109
132
  assert.equal(
110
133
  readFileSync(lookupAgentFile, 'utf8'),
111
134
  readFileSync(canonicalAgentFile, 'utf8'),
@@ -23,16 +23,32 @@ import {
23
23
  CODEX_RULES_SHIPPED_FILE_NAME,
24
24
  DEFAULT_CODEX_DIRECTORY_NAME,
25
25
  } from './install-constants.mjs';
26
- import { CONTENT_DIRECTORIES, INSTALL_GROUPS } from './install.mjs';
26
+ import {
27
+ CONTENT_DIRECTORIES,
28
+ detectPython,
29
+ INSTALL_GROUPS,
30
+ pythonFileAndPrefixArguments,
31
+ } from './install.mjs';
27
32
 
28
33
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
34
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
30
35
  const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
36
+ const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
31
37
  const SHIPPED_RULES_SOURCE_PATH = join(
32
38
  PACKAGE_DIRECTORY,
33
39
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
34
40
  CODEX_RULES_SHIPPED_FILE_NAME,
35
41
  );
42
+ const BLOCKING_HOOKS_DIRECTORY = join(PACKAGE_DIRECTORY, 'hooks', 'blocking');
43
+ const PYTHON_PII_SCAN_SCRIPT = [
44
+ 'import json',
45
+ 'import sys',
46
+ 'from dataclasses import asdict',
47
+ 'from pii_scanner import scan_text_for_pii',
48
+ 'all_findings = scan_text_for_pii(sys.stdin.read())',
49
+ "home_path_findings = [asdict(each_finding) for each_finding in all_findings if each_finding.category == 'home-path']",
50
+ 'print(json.dumps(home_path_findings))',
51
+ ].join('\n');
36
52
 
37
53
  function runInstaller(homeDirectory, extraArguments) {
38
54
  return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
@@ -45,9 +61,44 @@ function runInstaller(homeDirectory, extraArguments) {
45
61
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
46
62
  [CODEX_HOME_ENVIRONMENT_VARIABLE]: join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME),
47
63
  },
64
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
48
65
  });
49
66
  }
50
67
 
68
+ function scanTextWithProductionPiiScanner(scannedText) {
69
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev-pii-scan-'));
70
+ try {
71
+ const detectedPythonCommand = detectPython();
72
+ if (!detectedPythonCommand) {
73
+ throw new Error('No usable Python interpreter found for the PII scanner test');
74
+ }
75
+ const { file, prefixArguments } = pythonFileAndPrefixArguments(
76
+ detectedPythonCommand,
77
+ );
78
+ const scannerJson = execFileSync(
79
+ file,
80
+ [...prefixArguments, '-c', PYTHON_PII_SCAN_SCRIPT],
81
+ {
82
+ cwd: PACKAGE_DIRECTORY,
83
+ encoding: 'utf8',
84
+ env: {
85
+ ...process.env,
86
+ PYTHONPATH: BLOCKING_HOOKS_DIRECTORY,
87
+ CLAUDE_LOCAL_IDENTITY_PATH: join(
88
+ temporaryDirectory,
89
+ 'missing-local-identity.json',
90
+ ),
91
+ },
92
+ input: scannedText,
93
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
94
+ },
95
+ );
96
+ return JSON.parse(scannerJson);
97
+ } finally {
98
+ rmSync(temporaryDirectory, { recursive: true, force: true });
99
+ }
100
+ }
101
+
51
102
  test('CONTENT_DIRECTORIES omits codex-rules because that tree installs to the Codex home', () => {
52
103
  assert.equal(CONTENT_DIRECTORIES.includes(CODEX_RULES_PACKAGE_DIRECTORY_NAME), false);
53
104
  });
@@ -123,11 +174,15 @@ test('a full install copies shipped Codex rules and leaves a local default.rules
123
174
  }
124
175
  });
125
176
 
126
- test('a full install Codex rules file contains no personal home path', () => {
127
- const shippedText = readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8');
128
- assert.equal(shippedText.includes('Users\\jon'), false);
129
- assert.equal(shippedText.includes('Users/jon'), false);
130
- assert.equal(shippedText.includes('JonEcho'), false);
177
+ test('the shipped Codex rules file has no production-scanner home-path findings', () => {
178
+ const allHomePathFindings = scanTextWithProductionPiiScanner(
179
+ readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8'),
180
+ );
181
+ assert.deepEqual(
182
+ allHomePathFindings,
183
+ [],
184
+ `home-path findings: ${JSON.stringify(allHomePathFindings, null, 2)}`,
185
+ );
131
186
  });
132
187
 
133
188
  test('--only journal skips Codex rules; --only core copies them', () => {
package/bin/install.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CODEX_RULES_PACKAGE_DIRECTORY_NAME,
28
28
  CURSOR_SYNC_SCRIPT_FILE_NAME,
29
29
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
30
+ PYTHON_PROBE_TIMEOUT_MILLISECONDS,
30
31
  } from './install-constants.mjs';
31
32
  import {
32
33
  resolveInstallRoot,
@@ -163,7 +164,7 @@ export const CORE_INCLUDE_DIRECTORIES = [
163
164
  export const CORE_SKILLS = [
164
165
  'orchestrator', 'orchestrator-refresh', 'team-advisor',
165
166
  'grok-spawn',
166
- 'small-cl',
167
+ 'pr-small-cl',
167
168
  'everything-search',
168
169
  'privacy-hygiene',
169
170
  'issue-tracker',
@@ -434,6 +435,42 @@ export function interpreterCommandFromPath(executablePath) {
434
435
  return forwardSlashedPath.includes(' ') ? `"${forwardSlashedPath}"` : forwardSlashedPath;
435
436
  }
436
437
 
438
+ /**
439
+ * Formats a path as one shell argument for the selected platform.
440
+ *
441
+ * @param {string} path Path to format.
442
+ * @param {string} platform Platform whose shell will parse the argument.
443
+ * @returns {string} A quoted shell argument.
444
+ */
445
+ export function commandArgumentFromPath(path, platform = process.platform) {
446
+ const forwardSlashedPath = path.replace(/\\/g, '/');
447
+ if (platform === 'win32') {
448
+ if (/^[A-Za-z0-9_./:@+=,-]+$/.test(forwardSlashedPath)) return forwardSlashedPath;
449
+ return forwardSlashedPath.split('%').map(segment => `"${segment}"`).join('^%');
450
+ }
451
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(forwardSlashedPath)) return forwardSlashedPath;
452
+ return `'${forwardSlashedPath.replace(/'/g, "'\\''")}'`;
453
+ }
454
+
455
+ /**
456
+ * Quotes standalone plugin-root hook paths while preserving inline commands.
457
+ *
458
+ * @param {string} commandString Source hook command.
459
+ * @param {string} pluginRootForward Forward-slash plugin root directory.
460
+ * @returns {string} Rewritten hook command.
461
+ */
462
+ function quoteStandalonePluginRootHookPath(commandString, pluginRootForward) {
463
+ const standalonePathPattern = /(^|\s)(["']?)\$\{CLAUDE_PLUGIN_ROOT\}(\/hooks\/[^\s"']*?\.py)\2(?=$|[\s;&|])/g;
464
+ return commandString.replace(
465
+ standalonePathPattern,
466
+ (_match, argumentPrefix, _sourceQuote, relativeHookPath) => (
467
+ `${argumentPrefix}${commandArgumentFromPath(
468
+ `${pluginRootForward}${relativeHookPath}`,
469
+ )}`
470
+ ),
471
+ );
472
+ }
473
+
437
474
  /**
438
475
  * Picks the interpreter command baked into every managed hook in settings.json.
439
476
  * On win32 the first working candidate is resolved to its absolute
@@ -444,14 +481,14 @@ export function interpreterCommandFromPath(executablePath) {
444
481
  *
445
482
  * @returns {string|null} The interpreter command, or null when none is usable.
446
483
  */
447
- function detectPython() {
484
+ export function detectPython() {
448
485
  const candidates = pythonCandidatesForPlatform(process.platform);
449
486
  for (const { command, versionFlag } of candidates) {
450
487
  try {
451
- const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
488
+ const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
452
489
  if (!version.includes('Python 3.')) continue;
453
490
  if (process.platform !== 'win32') return command;
454
- const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
491
+ const executablePath = execSync(`${command} -c "import sys; print(sys.executable)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: PYTHON_PROBE_TIMEOUT_MILLISECONDS }).trim();
455
492
  if (!executablePath || isWindowsStorePythonStub(executablePath)) continue;
456
493
  return interpreterCommandFromPath(executablePath);
457
494
  } catch { /* try next */ }
@@ -1266,11 +1303,13 @@ function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
1266
1303
  * @returns {boolean} True when the command is the inline validators runner.
1267
1304
  */
1268
1305
  export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
1269
- const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
1270
- return (
1271
- normalizedCommand.includes('/.claude/hooks') &&
1272
- inlineValidatorRunnerMarker.test(normalizedCommand)
1273
- );
1306
+ if (!normalizedCommand.includes('sys.path.insert(')) return false;
1307
+ if (!normalizedCommand.includes('from validators.run_all_validators import main')) return false;
1308
+ const hasLiteralHooksPath = normalizedCommand.includes('/.claude/hooks');
1309
+ const hasShippedBase64Path = normalizedCommand.includes('__claude_dev_env_managed_hooks__');
1310
+ if (!hasLiteralHooksPath && !hasShippedBase64Path) return false;
1311
+ const inlineValidatorRunnerMarker = /sys\.path\.insert\([\s\S]*from validators\.run_all_validators import main/;
1312
+ return inlineValidatorRunnerMarker.test(normalizedCommand);
1274
1313
  }
1275
1314
 
1276
1315
  /**
@@ -1348,6 +1387,7 @@ function startEventFromHookGroupList(settings, eventType) {
1348
1387
  export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
1349
1388
  const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
1350
1389
  const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
1390
+ const encodedPluginHooksPath = Buffer.from(`${pluginRootForward}/hooks`, 'utf8').toString('base64');
1351
1391
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
1352
1392
  let groupCount = 0;
1353
1393
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
@@ -1356,6 +1396,14 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
1356
1396
  for (const sourceGroup of matcherGroups) {
1357
1397
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
1358
1398
  let command = hook.command;
1399
+ command = command.replace(
1400
+ /r(['"])\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\1/g,
1401
+ () => (
1402
+ `(__import__('base64').b64decode('${encodedPluginHooksPath}').decode(), `
1403
+ + "'__claude_dev_env_managed_hooks__')[0]"
1404
+ ),
1405
+ );
1406
+ command = quoteStandalonePluginRootHookPath(command, pluginRootForward);
1359
1407
  command = command.replace(
1360
1408
  /\$\{CLAUDE_PLUGIN_ROOT\}/g,
1361
1409
  () => pluginRootForward,
@@ -13,10 +13,16 @@ import {
13
13
  import { tmpdir } from 'node:os';
14
14
  import { join, dirname, basename } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import {
17
+ MANAGED_SKILLS_DIRECTORY_NAME,
18
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
19
+ } from './install-constants.mjs';
20
+ import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
16
21
 
17
22
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
18
23
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
19
24
  const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
25
+ const INSTALLER_PROCESS_TIMEOUT_MS = 60_000;
20
26
  const EXCLUDED_PACKAGE_COPY_DIRECTORY = 'node_modules';
21
27
 
22
28
  const RETIRED_SKILL_DIRECTORIES = [
@@ -35,7 +41,7 @@ const STALE_SKILL_FILE_RELATIVE_SEGMENTS = ['scripts', 'retired_module.py'];
35
41
  const RUNTIME_ARTIFACT_RELATIVE_SEGMENTS = ['scripts', '__pycache__', 'helper.cpython-312.pyc'];
36
42
  const SCOPED_GROUP_SKILL_DIRECTORY = 'orchestrator';
37
43
  const CORE_REVIEW_GUIDE_SKILL_DIRECTORIES = [
38
- 'small-cl',
44
+ 'pr-small-cl',
39
45
  ];
40
46
  const PRIOR_RUN_BACKUP_DIRECTORY_NAMES = [
41
47
  '2020-01-01T00-00-00-000Z',
@@ -102,8 +108,8 @@ function prunedSkillBackupContains(claudeDirectory, skillsRelativePath) {
102
108
  *
103
109
  * @returns {{homeDirectory: string, claudeDirectory: string, skillsDirectory: string, manifestPath: string}}
104
110
  */
105
- function createSandbox() {
106
- const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-prune-home-'));
111
+ function createSandbox(homeDirectoryPrefix = 'cdev-prune-home-') {
112
+ const homeDirectory = mkdtempSync(join(tmpdir(), homeDirectoryPrefix));
107
113
  const claudeDirectory = join(homeDirectory, '.claude');
108
114
  const skillsDirectory = join(claudeDirectory, 'skills');
109
115
  mkdirSync(skillsDirectory, { recursive: true });
@@ -111,6 +117,61 @@ function createSandbox() {
111
117
  return { homeDirectory, claudeDirectory, skillsDirectory, manifestPath };
112
118
  }
113
119
 
120
+ test('the retired-skill fallback covers every current shipped skill', () => {
121
+ const sourceSkillsDirectory = join(
122
+ PACKAGE_DIRECTORY,
123
+ PACKAGE_AGENTS_HOME_DIRECTORY_NAME,
124
+ MANAGED_SKILLS_DIRECTORY_NAME,
125
+ );
126
+ const allCurrentSkillNames = readdirSync(sourceSkillsDirectory, { withFileTypes: true })
127
+ .filter(eachEntry => eachEntry.isDirectory())
128
+ .filter(eachEntry => existsSync(join(sourceSkillsDirectory, eachEntry.name, 'SKILL.md')))
129
+ .map(eachEntry => eachEntry.name);
130
+ const allUncoveredSkillNames = allCurrentSkillNames.filter(
131
+ eachSkillName => !EVER_SHIPPED_SKILL_NAMES.has(eachSkillName),
132
+ );
133
+
134
+ assert.deepEqual(allUncoveredSkillNames, [], 'every current skill must enter the fallback set');
135
+ });
136
+
137
+ test('a core install runs the spaced-path Write dispatcher and captures its red denial', () => {
138
+ const sandbox = createSandbox('cdev prune & (home)-');
139
+ try {
140
+ runInstaller(sandbox.homeDirectory, ['--only', 'core']);
141
+ const settings = readSettings(sandbox.claudeDirectory);
142
+ const dispatcherHooks = settings.hooks.PreToolUse
143
+ .flatMap(eachGroup => eachGroup.hooks)
144
+ .filter(eachHook => /(?:^|[\\/])pre_tool_use_dispatcher\.py["']?(?:\s|$)/.test(eachHook.command));
145
+ assert.equal(dispatcherHooks.length, 1, 'the core install writes one PreToolUse dispatcher');
146
+
147
+ const protectedFilePath = join(sandbox.homeDirectory, 'protected file.txt');
148
+ writeFileSync(protectedFilePath, 'existing content\n');
149
+ const writePayload = JSON.stringify({
150
+ tool_name: 'Write',
151
+ tool_input: { file_path: protectedFilePath, content: 'replacement content\n' },
152
+ });
153
+ const { childEnvironment } = resolveInstallerInvocation(sandbox.homeDirectory);
154
+ const dispatcherRun = spawnSync(dispatcherHooks[0].command, {
155
+ cwd: dirname(INSTALLER_PATH),
156
+ encoding: 'utf8',
157
+ env: childEnvironment,
158
+ input: `${writePayload}\n`,
159
+ shell: true,
160
+ timeout: 30000,
161
+ });
162
+
163
+ assert.equal(dispatcherRun.status, 0, dispatcherRun.stderr);
164
+ const shownRedDecision = JSON.parse(dispatcherRun.stdout.trim());
165
+ assert.equal(shownRedDecision.hookSpecificOutput.permissionDecision, 'deny');
166
+ assert.match(
167
+ shownRedDecision.hookSpecificOutput.permissionDecisionReason,
168
+ /BLOCKED: Write on existing file/,
169
+ );
170
+ } finally {
171
+ rmSync(sandbox.homeDirectory, { recursive: true, force: true });
172
+ }
173
+ });
174
+
114
175
  /**
115
176
  * Plant a skill directory under the sandbox with a single marker file.
116
177
  *
@@ -142,6 +203,7 @@ function runInstaller(homeDirectory, extraArguments) {
142
203
  cwd: dirname(installerPath),
143
204
  encoding: 'utf8',
144
205
  env: childEnvironment,
206
+ timeout: INSTALLER_PROCESS_TIMEOUT_MS,
145
207
  });
146
208
  }
147
209