claude-dev-env 1.58.0 → 1.60.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 (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: update
3
- description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
3
+ description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. When main is not the checked-out branch, it then offers to switch the checkout to main so the update reaches the files on disk. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
4
4
  ---
5
5
 
6
6
  # update
7
7
 
8
8
  ## Overview
9
9
 
10
- Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never touches any branch other than `main`.
10
+ Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never rewrites any branch other than `main`. When `main` is not the checked-out branch, a final confirmed step offers to switch the checkout to `main` so the new commits reach the files on disk — the only branch switch the skill makes, and only after the operator approves it.
11
11
 
12
12
  The repository is whatever path the user gives as the `/update <path>` argument. With no argument, the default is the current repository's top level. Either way the path is confirmed before any write.
13
13
 
@@ -121,11 +121,42 @@ git -C "<path>" status --short
121
121
  - **Checked-out branch.** When a branch other than `main` is checked out, say so plainly: the fast-forward moves a ref only and no file on disk changes. Code that runs from this checkout comes from the checked-out branch, not from `main`.
122
122
  - **Dirty tracked files.** List every modified tracked file from `status --short`. Uncommitted edits sit on top of the checked-out branch and are what runs — flag them, because a ref update can neither see nor repair them. When the tree is clean, report "working tree clean".
123
123
 
124
+ ### Phase 5 — Offer to land `main` on disk
125
+
126
+ A fast-forward of the `main` ref leaves the files on disk untouched when another branch is checked out. The new commits exist in the repo but not in the working tree — the gap Phase 4 reports. This phase offers to close it.
127
+
128
+ Skip this phase and finish when the checked-out branch is already `main`: the new commits are already on disk.
129
+
130
+ Otherwise, when the checked-out branch is not `main`, run two safety checks before offering a switch:
131
+
132
+ 1. **Tree clean of tracked changes.** Read `git -C "<path>" status --porcelain` and treat any line that does not start with `??` as a change to a tracked file. If any exist, do not offer the switch: report that `main` moved in the ref only, that switching would risk the uncommitted work, and stop. Never stash, reset, or discard. Untracked files (lines starting with `??`) are fine — they carry across a switch.
133
+ 2. **`main` free to check out.** Read `git -C "<path>" worktree list`. If another worktree holds `main`, an in-place switch is impossible; report that path and stop.
134
+
135
+ When both checks pass, ask **one** `AskUserQuestion` — header "Get on disk":
136
+
137
+ - Recommended first choice, "Switch checkout to main": `git -C "<path>" checkout main` puts the new commits on disk. The branch you were on keeps its commits and is left unchanged.
138
+ - Second choice, "Stay on `<branch>`": leave the checkout where it is. The update stays in the `main` ref only and reaches disk later.
139
+
140
+ On "Switch", run:
141
+
142
+ ```
143
+ git -C "<path>" checkout main
144
+ ```
145
+
146
+ If `checkout` reports it would overwrite untracked files, stop and report those paths — never pass `-f`, which would drop the operator's untracked file. On success, confirm the branch and the tip now on disk:
147
+
148
+ ```
149
+ git -C "<path>" branch --show-current
150
+ git -C "<path>" log --oneline -1
151
+ ```
152
+
153
+ On "Stay", report that `main` is current in the ref and the new content reaches disk only after a later switch.
154
+
124
155
  ## Constraints (non-negotiable)
125
156
 
126
157
  - **Fast-forward only.** If the remote's `main` is not a descendant of local `main`, stop. Never `--force`, never `branch -f`, never a merge commit. Divergence is a job for `/rebase`.
127
158
  - **Always confirm both** the path and the source remote first, even when the path is given as an argument. Skipping a confirmation is not allowed — the confirmation is the point of this skill.
128
- - **Touch only `main`.** Never switch the repo's checked-out branch. The single exception is advancing `main` in place when `main` is already checked out.
159
+ - **Switch the checkout only with approval.** The fast-forward itself touches the `main` ref alone. Switching the checked-out branch to `main` happens only in Phase 5, only after the operator approves the `AskUserQuestion`, only with a tree clean of tracked changes, and never with `-f`. The skill never switches to any branch but `main`.
129
160
  - **Never discard local work.** A dirty tree blocks the in-place fast-forward; stop and report rather than stash or reset.
130
161
 
131
162
  ## Gotchas
@@ -135,12 +166,13 @@ git -C "<path>" status --short
135
166
  - `<remote>/main` only moves after an explicit `git fetch`. Fetch inside every run; never compare against a remote-tracking ref left over from an earlier fetch.
136
167
  - `origin` is not always the source of truth. When a fork is `origin` and the canonical repo is another remote (often `upstream`), the confirmed remote should be the canonical one, not whichever is named `origin`.
137
168
  - Quote the path on every command — `git -C "<path>"` — so paths with spaces or a NAS drive letter survive.
138
- - "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run.
169
+ - "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run, and Phase 5 offers to close it.
170
+ - `git checkout main` carries untracked files across unchanged, but refuses with "untracked working tree files would be overwritten" when an untracked file sits at a path `main` tracks. Phase 5 reports those paths and stops rather than passing `-f`, which would drop the operator's untracked file.
139
171
 
140
172
  ## What this skill does NOT do
141
173
 
142
174
  - Does not push, open a PR, or change any branch other than `main`.
143
- - Does not create or switch feature branches — that is `/fresh-branch`.
175
+ - Does not create feature branches, and switches only to `main` (Phase 5, with approval) creating or switching to any other branch is `/fresh-branch`.
144
176
  - Does not reconcile a diverged `main` — that is `/rebase`.
145
177
 
146
178
  ## File index
@@ -215,7 +215,7 @@
215
215
  Before spawning any Agent or Task tool invocation, verify context sufficiency. Confirm the
216
216
  specific files, directories, or codebase areas involved; the constraints that apply; what
217
217
  success looks like; and that the task is unambiguous enough to delegate. When any of those
218
- is unknown, investigate first using Read, Glob, or Zoekt MCP for code search — or ask the user.
218
+ is unknown, investigate first using Read, Glob, or Grep for code search — or ask the user.
219
219
 
220
220
  After confirming context, use the /prompt-generator skill to produce a structured prompt.
221
221
  Feed it the task description and goal, target files and directories, constraints and
@@ -224,7 +224,7 @@
224
224
 
225
225
  When multiple tool calls are mutually independent, make all calls in a single response.
226
226
  Sequence calls only when a later call requires an earlier call's result. Reading three files:
227
- call all three Read operations at once. Running independent searches: launch all Zoekt MCP
227
+ call all three Read operations at once. Running independent searches: launch all Grep
228
228
  and Glob calls simultaneously. Use real parameter values only. When uncertain whether calls are
229
229
  independent, run them sequentially.
230
230
  </agent_workflow>
@@ -356,10 +356,6 @@
356
356
  Use dedicated tools for file operations: Read for reading files, Edit for modifying existing
357
357
  files, Write for creating new files, and Glob for finding files by pattern.
358
358
 
359
- For code-content search in Zoekt-indexed trees, use Zoekt MCP tools:
360
- mcp__zoekt__search, mcp__zoekt__search_symbols, mcp__zoekt__find_references,
361
- mcp__zoekt__file_content.
362
-
363
359
  For file discovery on Windows, use Everything via es.exe.
364
360
 
365
361
  Reserve Bash for system commands and terminal operations that require shell execution.
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: deny Grep, Search, and shell search in indexed trees; steer to Zoekt MCP."""
3
-
4
- import json
5
- import os
6
- import sys
7
-
8
- from content_search_zoekt_bash_block_reason import block_reason_for_bash_command
9
- from content_search_zoekt_block_payload import build_block_payload
10
- from content_search_zoekt_indexed_paths import is_in_indexed_repo, is_specific_file
11
- from content_search_zoekt_redirect_guidance import (
12
- get_zoekt_redirect_guidance,
13
- get_zoekt_redirect_reason_brief,
14
- )
15
-
16
-
17
- def main() -> None:
18
- try:
19
- input_data = json.load(sys.stdin)
20
- except json.JSONDecodeError:
21
- sys.exit(0)
22
-
23
- tool_name = input_data.get("tool_name", "")
24
- tool_input = input_data.get("tool_input", {})
25
-
26
- content_search_tools = frozenset({"Grep", "Search"})
27
- block_reason = None
28
-
29
- if tool_name in content_search_tools:
30
- pattern = tool_input.get("pattern", "")
31
- path = tool_input.get("path", "")
32
-
33
- if not path:
34
- path = os.getcwd()
35
-
36
- if is_specific_file(path):
37
- sys.exit(0)
38
-
39
- if is_in_indexed_repo(path):
40
- block_reason = f"{tool_name}(pattern: \"{pattern}\", path: \"{path}\")"
41
-
42
- elif tool_name == "Bash":
43
- command = tool_input.get("command", "")
44
- block_reason = block_reason_for_bash_command(command)
45
-
46
- if block_reason is None:
47
- sys.exit(0)
48
- short_label = f"blocked {block_reason}; use Zoekt MCP"
49
- payload = build_block_payload(
50
- brief_label=short_label,
51
- permission_decision_reason=get_zoekt_redirect_reason_brief(),
52
- additional_context=get_zoekt_redirect_guidance(),
53
- )
54
- print(json.dumps(payload))
55
- sys.exit(0)
56
-
57
-
58
- if __name__ == "__main__":
59
- main()
@@ -1,25 +0,0 @@
1
- """Match Bash one-liners that act as content search (grep, rg, findstr, etc.)."""
2
-
3
- import re
4
-
5
-
6
- def block_reason_for_bash_command(command: str) -> str | None:
7
- bash_content_search_patterns = (
8
- (re.compile(r"^\s*grep\s", re.IGNORECASE), "grep"),
9
- (re.compile(r"^\s*grep$", re.IGNORECASE), "grep"),
10
- (re.compile(r"\|\s*grep\s", re.IGNORECASE), "piped grep"),
11
- (re.compile(r"\|\s*grep$", re.IGNORECASE), "piped grep"),
12
- (re.compile(r"^\s*rg\s", re.IGNORECASE), "ripgrep"),
13
- (re.compile(r"^\s*rg$", re.IGNORECASE), "ripgrep"),
14
- (re.compile(r"\|\s*rg\s", re.IGNORECASE), "piped ripgrep"),
15
- (re.compile(r"^\s*findstr\s", re.IGNORECASE), "findstr"),
16
- (re.compile(r"^\s*Select-String", re.IGNORECASE), "PowerShell Select-String"),
17
- (re.compile(r"^\s*sls\s", re.IGNORECASE), "PowerShell sls"),
18
- (re.compile(r"^\s*ack\s", re.IGNORECASE), "ack"),
19
- (re.compile(r"^\s*ag\s", re.IGNORECASE), "silver searcher"),
20
- (re.compile(r"^\s*git\s+grep\s", re.IGNORECASE), "git grep"),
21
- )
22
- for regex, command_name in bash_content_search_patterns:
23
- if regex.search(command):
24
- return f"Bash({command_name})"
25
- return None
@@ -1,21 +0,0 @@
1
- """JSON shape for Claude Code PreToolUse deny: hookSpecificOutput plus short systemMessage."""
2
-
3
-
4
- def build_block_payload(
5
- brief_label: str,
6
- permission_decision_reason: str,
7
- additional_context: str | None = None,
8
- ) -> dict:
9
- destructive_gate_label_prefix = "[destructive-gate]"
10
- hook_specific_output: dict = {
11
- "hookEventName": "PreToolUse",
12
- "permissionDecision": "deny",
13
- "permissionDecisionReason": permission_decision_reason,
14
- }
15
- if additional_context is not None:
16
- hook_specific_output["additionalContext"] = additional_context
17
- return {
18
- "hookSpecificOutput": hook_specific_output,
19
- "systemMessage": f"{destructive_gate_label_prefix} {brief_label}",
20
- "suppressOutput": True,
21
- }
@@ -1,24 +0,0 @@
1
- """Normalize paths and test membership under Zoekt-indexed roots (from env, JSON file, or defaults)."""
2
-
3
- import re
4
-
5
-
6
- def normalize_path(path: str) -> str:
7
- return path.replace("\\", "/").lower()
8
-
9
-
10
- def is_specific_file(path: str) -> bool:
11
- file_extension_pattern = re.compile(r"\.\w{1,10}$")
12
- return bool(file_extension_pattern.search(path))
13
-
14
-
15
- def is_in_indexed_repo(path: str) -> bool:
16
- from content_search_zoekt_indexed_roots_config import indexed_root_prefixes
17
-
18
- norm = normalize_path(path)
19
- if not norm.endswith("/"):
20
- norm += "/"
21
- for prefix in indexed_root_prefixes():
22
- if norm.startswith(prefix):
23
- return True
24
- return False
@@ -1,131 +0,0 @@
1
- """Resolve Zoekt-indexed filesystem roots from environment or JSON file (no built-in roots in this package)."""
2
-
3
- import json
4
- import os
5
- from functools import lru_cache
6
- from pathlib import Path
7
-
8
- from content_search_zoekt_indexed_paths import normalize_path
9
-
10
-
11
- def _environment_variable_indexed_roots() -> str:
12
- return "ZOEKT_REDIRECT_INDEXED_ROOTS"
13
-
14
-
15
- def _environment_variable_roots_file() -> str:
16
- return "ZOEKT_REDIRECT_INDEXED_ROOTS_FILE"
17
-
18
-
19
- def _json_object_roots_key() -> str:
20
- return "roots"
21
-
22
-
23
- def _default_config_relative_parts() -> tuple[str, str]:
24
- return (".claude", "zoekt-indexed-roots.json")
25
-
26
-
27
- def _built_in_fallback_roots() -> tuple[str, ...]:
28
- return ()
29
-
30
-
31
- def _parse_json_roots_list(raw: str) -> list[str] | None:
32
- try:
33
- parsed = json.loads(raw)
34
- except json.JSONDecodeError:
35
- return None
36
- if not isinstance(parsed, list):
37
- return None
38
- if not all(isinstance(item, str) for item in parsed):
39
- return None
40
- return list(parsed)
41
-
42
-
43
- def _config_file_path() -> Path:
44
- override = os.environ.get(_environment_variable_roots_file())
45
- if override is not None and override.strip() != "":
46
- return Path(override).expanduser()
47
- relative_dot_claude, relative_file_name = _default_config_relative_parts()
48
- return Path.home() / relative_dot_claude / relative_file_name
49
-
50
-
51
- def _roots_from_json_file() -> list[str] | None:
52
- path = _config_file_path()
53
- if not path.is_file():
54
- return None
55
- try:
56
- text = path.read_text(encoding="utf-8")
57
- except OSError:
58
- return None
59
- try:
60
- data = json.loads(text)
61
- except json.JSONDecodeError:
62
- return None
63
- if not isinstance(data, dict):
64
- return None
65
- roots_key = _json_object_roots_key()
66
- roots_value = data.get(roots_key)
67
- if roots_value is None:
68
- return None
69
- if not isinstance(roots_value, list):
70
- return None
71
- if not all(isinstance(item, str) for item in roots_value):
72
- return None
73
- return list(roots_value)
74
-
75
-
76
- def _roots_from_environment_variable() -> list[str] | None:
77
- variable_name = _environment_variable_indexed_roots()
78
- if variable_name not in os.environ:
79
- return None
80
- raw = os.environ.get(variable_name, "")
81
- if raw.strip() == "":
82
- return []
83
- parsed = _parse_json_roots_list(raw)
84
- if parsed is None:
85
- return None
86
- return parsed
87
-
88
-
89
- def _expand_root_to_prefix_variants(root: str) -> list[str]:
90
- trimmed = root.strip()
91
- if trimmed == "":
92
- return []
93
- norm = normalize_path(trimmed)
94
- if not norm.endswith("/"):
95
- norm = norm + "/"
96
- variants = [norm]
97
- if len(norm) >= 3 and norm[1] == ":" and norm[0].isalpha() and norm[2] == "/":
98
- drive_letter = norm[0]
99
- remainder = norm[2:]
100
- wsl_prefix = f"/mnt/{drive_letter}{remainder}"
101
- if wsl_prefix not in variants:
102
- variants.append(wsl_prefix)
103
- return variants
104
-
105
-
106
- def _expand_all_roots(raw_roots: list[str]) -> tuple[str, ...]:
107
- prefixes: list[str] = []
108
- for root in raw_roots:
109
- prefixes.extend(_expand_root_to_prefix_variants(root))
110
- unique = frozenset(prefixes)
111
- return tuple(sorted(unique, key=len, reverse=True))
112
-
113
-
114
- def _raw_roots_resolution_order() -> list[str]:
115
- from_env = _roots_from_environment_variable()
116
- if from_env is not None:
117
- return from_env
118
- from_file = _roots_from_json_file()
119
- if from_file is not None:
120
- return from_file
121
- return list(_built_in_fallback_roots())
122
-
123
-
124
- @lru_cache(maxsize=1)
125
- def indexed_root_prefixes() -> tuple[str, ...]:
126
- raw_roots = _raw_roots_resolution_order()
127
- return _expand_all_roots(raw_roots)
128
-
129
-
130
- def clear_indexed_root_prefixes_cache() -> None:
131
- indexed_root_prefixes.cache_clear()
@@ -1,52 +0,0 @@
1
- """Zoekt MCP usage and repo-to-disk path mapping for PreToolUse outputs."""
2
-
3
-
4
- def get_zoekt_redirect_reason_brief() -> str:
5
- return (
6
- "Use Zoekt MCP (e.g. mcp__zoekt__search) instead of Grep/Search in Zoekt-indexed trees."
7
- )
8
-
9
-
10
- def worktree_path_filter_fragment() -> str:
11
- return "\\.claude/worktrees/"
12
-
13
-
14
- def worktree_path_display_fragment() -> str:
15
- return ".claude/worktrees/"
16
-
17
-
18
- def get_zoekt_redirect_guidance() -> str:
19
- worktree_filter_fragment = worktree_path_filter_fragment()
20
- worktree_display_fragment = worktree_path_display_fragment()
21
- return (
22
- "Use Zoekt MCP instead: mcp__zoekt__search(query=\"your pattern\"). "
23
- "Supports regex, 'file:pattern' for file filtering, 'lang:py' for language. "
24
- "Also available: mcp__zoekt__search_symbols, mcp__zoekt__find_references, mcp__zoekt__file_content. "
25
- "Example: mcp__zoekt__search(query=\"verify_theme_assets file:\\.py$\")\n\n"
26
- "WORKTREE NOISE: indexed trees include git worktrees under "
27
- + worktree_display_fragment
28
- + " that duplicate the same code across branches. By default append '-file:"
29
- + worktree_filter_fragment
30
- + "' to each query so results stay on the primary checkout. When the user explicitly "
31
- "asks about a worktree, branch, or PR checkout, drop that exclusion or filter to it "
32
- "positively, e.g. mcp__zoekt__search(query=\"your pattern file:"
33
- + worktree_filter_fragment
34
- + "<branch>/\").\n\n"
35
- "INDEX FRESHNESS: the index trails just-written code — recent or unpushed commits sync and reindex "
36
- "on a short delay, so a symbol you just added can be missing from Zoekt for a few minutes even though "
37
- "it is on disk. Worktrees are fully indexed and retrievable with the positive 'file:"
38
- + worktree_filter_fragment
39
- + "<branch>/' filter above, but that same lag applies to anything freshly edited. When a Zoekt search "
40
- "returns nothing for code you know exists on disk, do not treat it as absent: Grep a specific file "
41
- "(a path ending in a file extension is exempt from this redirect) or read the file directly to confirm "
42
- "current contents.\n\n"
43
- "INDEX ROOTS (when Grep/Search in a tree is redirected): set ZOEKT_REDIRECT_INDEXED_ROOTS to a JSON array "
44
- "of absolute paths, or ~/.claude/zoekt-indexed-roots.json as {\"roots\": [\"/abs/path/to/repo/\", ...]}. "
45
- "Optional ZOEKT_REDIRECT_INDEXED_ROOTS_FILE points to a different JSON file. "
46
- "WSL /mnt/<drive>/... prefixes are derived from Windows roots automatically. "
47
- "This package ships no built-in roots (public repo); you must configure roots locally.\n\n"
48
- "ZOEKT REPO LABEL -> LOCAL DISK (for editing files after a Zoekt hit): "
49
- "keep the same directories in zoekt-indexed-roots.json as you index in Zoekt. "
50
- "Example pattern only — yours will differ: if Zoekt shows \"acme-lib - src/foo.py\" and that repo "
51
- "lives at /srv/checkout/acme-lib/ on your machine, edit /srv/checkout/acme-lib/src/foo.py."
52
- )
@@ -1,61 +0,0 @@
1
- """Subprocess integration tests for content-search-to-zoekt-redirector PreToolUse hook."""
2
-
3
- import json
4
- import pathlib
5
- import subprocess
6
- import sys
7
- import unittest
8
- from typing import Any
9
-
10
-
11
- class ContentSearchHookIntegrationTests(unittest.TestCase):
12
- def test_bash_grep_command_emits_stdout_json_deny(self) -> None:
13
- hook_directory = pathlib.Path(__file__).resolve().parent
14
- if str(hook_directory) not in sys.path:
15
- sys.path.insert(0, str(hook_directory))
16
- from content_search_zoekt_redirect_guidance import (
17
- get_zoekt_redirect_guidance,
18
- get_zoekt_redirect_reason_brief,
19
- )
20
-
21
- hook_path = hook_directory / "content_search_to_zoekt_redirector.py"
22
- destructive_gate_label_prefix = "[destructive-gate]"
23
- destructive_gate_label_prefix_value = f"{destructive_gate_label_prefix} "
24
- expected_decision = "deny"
25
- hook_stdin_payload = json.dumps(
26
- {"tool_name": "Bash", "tool_input": {"command": "grep foo bar"}},
27
- )
28
-
29
- completed = subprocess.run(
30
- [sys.executable, str(hook_path)],
31
- input=hook_stdin_payload,
32
- capture_output=True,
33
- text=True,
34
- cwd=str(hook_directory),
35
- )
36
- self.assertEqual(completed.returncode, 0)
37
- self.assertEqual(completed.stderr, "")
38
- payload: dict[str, Any] = json.loads(completed.stdout)
39
- self.assertTrue(
40
- payload["systemMessage"].startswith(destructive_gate_label_prefix_value),
41
- )
42
- self.assertEqual(
43
- payload["hookSpecificOutput"]["permissionDecisionReason"],
44
- get_zoekt_redirect_reason_brief(),
45
- )
46
- self.assertEqual(
47
- payload["hookSpecificOutput"]["additionalContext"],
48
- get_zoekt_redirect_guidance(),
49
- )
50
- self.assertEqual(
51
- payload["hookSpecificOutput"]["permissionDecision"],
52
- expected_decision,
53
- )
54
- self.assertEqual(
55
- payload["hookSpecificOutput"]["hookEventName"],
56
- "PreToolUse",
57
- )
58
-
59
-
60
- if __name__ == "__main__":
61
- unittest.main()
@@ -1,92 +0,0 @@
1
- """Unit tests for Zoekt redirector PreToolUse deny payload (build_block_payload)."""
2
-
3
- import json
4
- import pathlib
5
- import sys
6
- import unittest
7
- from typing import Any
8
-
9
- HOOK_DIRECTORY = pathlib.Path(__file__).resolve().parent
10
- if str(HOOK_DIRECTORY) not in sys.path:
11
- sys.path.insert(0, str(HOOK_DIRECTORY))
12
-
13
- from content_search_zoekt_block_payload import build_block_payload
14
- from content_search_zoekt_redirect_guidance import (
15
- get_zoekt_redirect_guidance,
16
- get_zoekt_redirect_reason_brief,
17
- worktree_path_display_fragment,
18
- worktree_path_filter_fragment,
19
- )
20
-
21
-
22
- class BuildBlockPayloadTests(unittest.TestCase):
23
- def test_payload_matches_pretooluse_contract(self) -> None:
24
- destructive_gate_label_prefix = "[destructive-gate]"
25
- payload: dict[str, Any] = build_block_payload("demo", "body")
26
- prefix_with_space = f"{destructive_gate_label_prefix} "
27
- self.assertTrue(payload["systemMessage"].startswith(prefix_with_space))
28
- self.assertEqual(
29
- payload["hookSpecificOutput"]["permissionDecisionReason"],
30
- "body",
31
- )
32
- self.assertEqual(payload["hookSpecificOutput"]["permissionDecision"], "deny")
33
- self.assertEqual(
34
- payload["hookSpecificOutput"]["hookEventName"],
35
- "PreToolUse",
36
- )
37
- self.assertEqual(payload["suppressOutput"], True)
38
- self.assertNotIn("decision", payload)
39
- self.assertNotIn("reason", payload)
40
- self.assertNotIn("additionalContext", payload["hookSpecificOutput"])
41
-
42
- def test_serialized_payload_under_documented_context_cap(self) -> None:
43
- cap_characters = 10_000
44
- payload = build_block_payload(
45
- brief_label="blocked Bash(grep); use Zoekt MCP",
46
- permission_decision_reason=get_zoekt_redirect_reason_brief(),
47
- additional_context=get_zoekt_redirect_guidance(),
48
- )
49
- serialized = json.dumps(payload)
50
- self.assertLessEqual(
51
- len(serialized),
52
- cap_characters,
53
- msg="Hooks doc caps additionalContext/systemMessage/plain stdout injection at 10,000 characters",
54
- )
55
-
56
-
57
- class RedirectGuidanceWorktreeTests(unittest.TestCase):
58
- def test_guidance_defaults_to_excluding_worktrees(self) -> None:
59
- guidance = get_zoekt_redirect_guidance()
60
- expected_default_exclusion = f"-file:{worktree_path_filter_fragment()}"
61
- self.assertIn(expected_default_exclusion, guidance)
62
-
63
- def test_guidance_explains_how_to_search_a_worktree(self) -> None:
64
- guidance = get_zoekt_redirect_guidance()
65
- positive_filter_example = (
66
- f'query="your pattern file:{worktree_path_filter_fragment()}<branch>/'
67
- )
68
- self.assertIn(positive_filter_example, guidance)
69
- self.assertIn("worktree", guidance.lower())
70
-
71
- def test_worktree_filter_fragment_is_a_regex_escaped_path(self) -> None:
72
- self.assertEqual(worktree_path_filter_fragment(), "\\.claude/worktrees/")
73
-
74
- def test_guidance_describes_worktree_path_unescaped_in_prose(self) -> None:
75
- guidance = get_zoekt_redirect_guidance()
76
- prose_substring = (
77
- f"git worktrees under {worktree_path_display_fragment()} that"
78
- )
79
- self.assertIn(prose_substring, guidance)
80
-
81
- def test_worktree_display_fragment_is_the_unescaped_path(self) -> None:
82
- self.assertEqual(worktree_path_display_fragment(), ".claude/worktrees/")
83
-
84
- def test_guidance_documents_index_freshness_escape_hatch(self) -> None:
85
- guidance = get_zoekt_redirect_guidance()
86
- self.assertIn("INDEX FRESHNESS:", guidance)
87
- self.assertIn("exempt from this redirect", guidance)
88
- self.assertIn("read the file directly", guidance)
89
-
90
-
91
- if __name__ == "__main__":
92
- unittest.main()