claude-dev-env 1.59.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 (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -55,16 +55,25 @@ directory, so the working directory must be the PR worktree before any local
55
55
  work begins. Re-resolve it every tick — a rebase or a fresh HEAD can move the
56
56
  branch tip.
57
57
 
58
- Read the current working tree's origin and parse its `<owner>/<repo>`,
59
- accepting the `https://github.com/<owner>/<repo>`,
60
- `git@github.com:<owner>/<repo>`, and `ssh://git@github.com/<owner>/<repo>`
61
- forms and dropping any trailing `.git`:
58
+ Classify the working directory against the PR's repo. The preflight script
59
+ reads the current working tree's origin, parses its `<owner>/<repo>` (accepting
60
+ the `https://github.com/<owner>/<repo>`, `git@github.com:<owner>/<repo>`, and
61
+ `ssh://git@github.com/<owner>/<repo>` forms and dropping any trailing `.git`),
62
+ and prints a `PREFLIGHT_OUTCOME=<same_repo|different_repo|re_rooted>` line plus a
63
+ human-readable summary:
62
64
 
63
65
  ```bash
64
- git remote get-url origin
66
+ python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode classify
65
67
  ```
66
68
 
67
- - **Parsed owner/repo matches the PR** (case-insensitive): the `EnterWorktree`
69
+ A `same_repo` outcome exits 0 only when the worktree machinery is healthy; a
70
+ `same_repo` outcome whose `git worktree list` probe failed exits non-zero in
71
+ every mode. A `different_repo` outcome exits 0 in classify mode. A `re_rooted`
72
+ outcome (no git work tree, or no readable origin) exits non-zero. Route on the
73
+ `PREFLIGHT_OUTCOME` value:
74
+
75
+ - **`PREFLIGHT_OUTCOME=same_repo`** (the working directory is a checkout of the
76
+ PR's repo): the `EnterWorktree`
68
77
  pre-flight checkout is the PR worktree, and the working directory already
69
78
  points here, so no `cd` is needed. Bring the branch to the PR head with the
70
79
  same deterministic `checkout -B` the cross-repo case uses, after confirming
@@ -74,9 +83,13 @@ git remote get-url origin
74
83
  git fetch origin
75
84
  git checkout -B <branch> origin/<branch>
76
85
  ```
86
+ When the script prints an `ABORT:` line whose recovery names `git worktree
87
+ prune`, the working tree's worktree machinery is broken and the preflight
88
+ exits non-zero: stop the tick, run that `git worktree prune` in the named
89
+ directory, and re-run rather than continuing the checkout.
77
90
 
78
- - **Parsed owner/repo differs** (the session is rooted in another repo — for
79
- example, the PR lives in `llm-settings` while the session runs from
91
+ - **`PREFLIGHT_OUTCOME=different_repo`** (the session is rooted in another repo
92
+ — for example, the PR lives in `llm-settings` while the session runs from
80
93
  `claude-code-config`): route the working directory into a checkout of the
81
94
  PR's repo. This is routine and automatic — never pause, and never raise it as
82
95
  a fork (see [ground-rules.md](ground-rules.md)). `EnterWorktree` is scoped to
@@ -128,6 +141,13 @@ git remote get-url origin
128
141
  a Windows-safe recursive remove (per
129
142
  `$HOME/.claude/rules/windows-filesystem-safe.md`).
130
143
 
144
+ - **`PREFLIGHT_OUTCOME=re_rooted`** (the working directory is not a git work
145
+ tree, or its origin remote is unreadable — a resumed or background session can
146
+ re-root to the home directory): the tick cannot locate the PR's repo from
147
+ here, so neither cwd reuse nor a temp-clone route is safe. Report the printed
148
+ `ABORT` line as a hard blocker and stop the tick. Recover by starting the
149
+ session from a checkout of the PR's repo and re-running.
150
+
131
151
  ## Step 2: Branch on `phase`
132
152
 
133
153
  ### `phase == BUGBOT`
@@ -60,11 +60,10 @@ When in doubt, ask. Both work; the choice affects history shape, not correctness
60
60
  | Tool | Use when | Example |
61
61
  |---|---|---|
62
62
  | `mcp__serena__find_symbol` / `find_referencing_symbols` | Symbol-aware language server is available — definition vs. reference distinction matters, and you want call-site context | `find_referencing_symbols(symbol_name)` returns every caller with file/line and surrounding code |
63
- | `mcp__zoekt__search_symbols` / `search` | Cross-repo or large codebase indexed in zoekt; faster than grep on big trees | `search(query)` returns ranked matches with snippets |
64
63
  | `Grep` tool (ripgrep) | Local single-repo plain-text scan; no symbol awareness needed | `Grep(pattern, type="py")` — much faster than shell `grep` and respects `.gitignore` |
65
64
  | `grep -rn` | Last resort; only when the above are unavailable | — |
66
65
 
67
- The Grep tool is the default for plain-text scans (faster than shell grep, respects gitignore). Reach for serena when you need to distinguish "this name is defined here" from "this name is referenced here," which catches false positives from comments, docstrings, and string literals. Reach for zoekt for cross-repo scans.
66
+ The Grep tool is the default for plain-text scans (faster than shell grep, respects gitignore). Reach for serena when you need to distinguish "this name is defined here" from "this name is referenced here," which catches false positives from comments, docstrings, and string literals.
68
67
 
69
68
  This is the bug that hides best. Don't skip it.
70
69
 
@@ -107,7 +106,6 @@ When in doubt, ask. Both work; the choice affects history shape, not correctness
107
106
  11. **Reference scan for removals/renames.** For every symbol the rebase deleted or renamed (per the commit messages from step 4), scan the post-rebase tree using the same tool-preference order as step 4:
108
107
 
109
108
  - **Preferred:** `mcp__serena__find_referencing_symbols` (symbol-aware; ignores false matches in comments and string literals).
110
- - **Fallback:** `mcp__zoekt__search` for cross-repo or large trees.
111
109
  - **Then:** the `Grep` tool (e.g., `Grep(pattern="<symbol>", type="py")`) for fast in-repo scans.
112
110
  - **Last resort:** `grep -rn "<symbol>" .` (let ripgrep defaults and `.gitignore` handle scoping)
113
111
 
@@ -156,7 +154,7 @@ After rebase, BEFORE push:
156
154
  python -c "import …" ──► must succeed
157
155
  pytest --collect-only ──► must succeed
158
156
  targeted pytest run ──► must pass
159
- symbol scan (serena → zoekt → Grep) ──► no stale references
157
+ symbol scan (serena → Grep) ──► no stale references
160
158
 
161
159
  Push:
162
160
  not main/master/release ──► force-with-lease, ask first
@@ -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()