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.
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.mjs +128 -6
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- 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
|
-
|
|
59
|
-
|
|
60
|
-
`
|
|
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
|
-
|
|
66
|
+
python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode classify
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
|
|
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
|
-
-
|
|
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`
|
package/skills/rebase/SKILL.md
CHANGED
|
@@ -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.
|
|
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 →
|
|
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
|
|
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
|
|
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()
|