claude-dev-env 1.55.1 → 1.56.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/CLAUDE.md CHANGED
@@ -41,6 +41,34 @@ Ask the subagent for a specific answer: "return the file:line where X is defined
41
41
 
42
42
  Reserve `Read`/`Grep`/`Glob` for files you will actually touch this turn. Compose subagent prompts via the protocol in `agent-spawn-protocol`.
43
43
 
44
+ ## Target Execution Workflow for Code Tasks
45
+
46
+ Run every multi-step code task in two phases:
47
+
48
+ 1. **Coders** — one Sonnet agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the tool-less `fable-advisor` agent — which returns a plan, a correction, or a stop signal — and resumes. Source: Anthropic's advisor strategy (https://claude.com/blog/the-advisor-strategy).
49
+ 2. **Verification** — when the coders finish, the main session spawns the `fable-verifier` agent in a fresh context. It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
50
+
51
+ Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. The one exemption is mechanical, not discretionary: a diff whose every changed file is non-code or has an unchanged Python AST once docstrings are stripped (docs, docstrings, comments).
52
+
53
+ ## Converge & Review Loop Discipline
54
+
55
+ - **Worktree isolation:** Run every PR convergence and review loop in an isolated worktree, never a shared checkout that concurrent processes may advance. Verify isolation (the working directory path includes `.claude/worktrees/`) before the first tick or round.
56
+ - **No hedging in findings:** Findings and PR reports state verified facts only — never `likely`, `probably`, `should`, `appears to`. Verify each claim against the code before stating it; the anti-hallucination Stop hook rejects hedged responses.
57
+ - **Tight edit scope:** Edit exactly what the task names — no whole-file rewrites, no renaming public method parameters, no changes beyond the stated task. When the user asks for a "lasting" or "reusable" fix, prefer the durable systemic fix over a one-off edit.
58
+ - **GitHub MCP first:** The GitHub MCP (`mcp__plugin_github_github__*`) is the primary path for PR and review-thread inspection; raw `gh api` is the fallback, not the default — MCP calls work the same from any worktree.
59
+
60
+ ## Sub-agent Output Validation
61
+
62
+ After any sub-agent returns a PR description, file list, or counts, verify each claim against the actual diff and repo state before using it. Flag and correct any invented paths, fabricated counts, or out-of-scope changes before they land in commits or PR bodies.
63
+
64
+ ## Git Sync Intent
65
+
66
+ When asked to sync git ("get X onto origin main", "update main"), fast-forward local main to origin — do NOT commit untracked working-tree files unless explicitly told to.
67
+
68
+ ## Scheduled Task Cadence
69
+
70
+ For scheduled/cron tasks, default to sub-hour intervals (30-minute); do not propose hourly cadences.
71
+
44
72
  ## Additional Non-overlapping Rules
45
73
 
46
74
  - **task_scope:** Match every action to what was explicitly requested. When intent is ambiguous, research official docs and present options via AskUserQuestion before making any changes. Proceed with edits only on explicit instruction.
@@ -21,12 +21,10 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
21
21
  ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS,
22
22
  ALL_EXEMPT_ROOT_FILENAMES_LOWER,
23
23
  CLAUDE_DEV_ENV_REPO_NAME_SEGMENT,
24
- CLAUDE_DIRECTORY_PATH_PREFIX,
25
- CLAUDE_DIRECTORY_SEGMENT_MARKER,
24
+ CLAUDE_DIRECTORY_NAME,
25
+ CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX,
26
26
  MINIMUM_SEGMENT_COUNT_TO_MATCH_INDICATOR,
27
27
  PACKAGES_TOP_LEVEL_SEGMENT,
28
- PLUGIN_DIRECTORY_PATH_PREFIX,
29
- PLUGIN_DIRECTORY_SEGMENT_MARKER,
30
28
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
31
29
  REPO_ROOT_MARKER_NAME,
32
30
  RESOLVED_HOME_DIRECTORY_LOWER,
@@ -38,7 +36,9 @@ def is_exempt_path(file_path: str) -> bool:
38
36
  """Return True when the .md file path is exempt from the blocker policy.
39
37
 
40
38
  Exemption sources, in order of evaluation:
41
- - Any segment under `.claude/` or `.claude-plugin/` (case-insensitive)
39
+ - Any directory segment named `.claude` or prefixed `.claude-`
40
+ (case-insensitive): project infrastructure, profile directories like
41
+ `.claude-mel/`, and `.claude-plugin/`
42
42
  - Basename in `ALL_EXEMPT_ANYWHERE_FILENAMES` (e.g. SKILL.md)
43
43
  - Anchored under `packages/claude-dev-env/<one of
44
44
  ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES>/...` (docs, rules,
@@ -60,15 +60,7 @@ def is_exempt_path(file_path: str) -> bool:
60
60
  expanded_path = os.path.expanduser(file_path)
61
61
  normalized = os.path.normpath(expanded_path).replace("\\", "/")
62
62
  lower_normalized = normalized.lower()
63
- if (
64
- CLAUDE_DIRECTORY_SEGMENT_MARKER in lower_normalized
65
- or lower_normalized.startswith(CLAUDE_DIRECTORY_PATH_PREFIX)
66
- ):
67
- return True
68
- if (
69
- PLUGIN_DIRECTORY_SEGMENT_MARKER in lower_normalized
70
- or lower_normalized.startswith(PLUGIN_DIRECTORY_PATH_PREFIX)
71
- ):
63
+ if _has_claude_infrastructure_segment(lower_normalized):
72
64
  return True
73
65
  basename_lower = os.path.basename(normalized).lower()
74
66
  if basename_lower in ALL_EXEMPT_ANYWHERE_FILENAMES_LOWER:
@@ -101,6 +93,33 @@ def _resolve_absolute_directory(normalized_path: str) -> str:
101
93
  return os.path.abspath(directory)
102
94
 
103
95
 
96
+ def _has_claude_infrastructure_segment(lower_normalized_path: str) -> bool:
97
+ """A directory named `.claude` or prefixed `.claude-` (profile and
98
+ plugin directories) holds Claude infrastructure; any path inside one
99
+ is exempt.
100
+
101
+ Only directory segments are matched. The final segment is always the
102
+ file's basename (``normpath`` strips any trailing slash), so a file
103
+ merely named with the ``.claude-`` prefix in an ordinary directory
104
+ stays subject to the policy.
105
+
106
+ Args:
107
+ lower_normalized_path: Lowercased path with separators normalized
108
+ to forward slashes.
109
+
110
+ Returns:
111
+ True when any directory segment names a Claude infrastructure
112
+ directory.
113
+ """
114
+ all_directory_segments = lower_normalized_path.split("/")[:-1]
115
+ for each_segment in all_directory_segments:
116
+ if each_segment == CLAUDE_DIRECTORY_NAME:
117
+ return True
118
+ if each_segment.startswith(CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX):
119
+ return True
120
+ return False
121
+
122
+
104
123
  def _has_plugin_directory_segment(lower_normalized_path: str) -> bool:
105
124
  for each_directory_segment in ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS:
106
125
  segment_marker = f"/{each_directory_segment}/"
@@ -25,6 +25,7 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
25
25
  ALL_EXEMPT_PLUGIN_DIRECTORY_SEGMENTS,
26
26
  CLAUDE_DEV_ENV_REPO_NAME_SEGMENT,
27
27
  CLAUDE_DIRECTORY_NAME,
28
+ CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX,
28
29
  PACKAGES_TOP_LEVEL_SEGMENT,
29
30
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
30
31
  )
@@ -63,7 +64,7 @@ def _block_context() -> str:
63
64
  "Reference for HTML effectiveness patterns:\n"
64
65
  f"{_html_effectiveness_url}\n"
65
66
  "Exceptions (.md still allowed):\n"
66
- f"- Files inside {CLAUDE_DIRECTORY_NAME}/ or {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ directories\n"
67
+ f"- Files inside {CLAUDE_DIRECTORY_NAME}/ or {CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX}*/ directories\n"
67
68
  f"- {_exempt_anywhere_filenames_summary} anywhere\n"
68
69
  f"- Files under {_exempt_plugin_segments_summary} directories\n"
69
70
  f"- Files under {_claude_dev_env_source_directories_summary} source directories\n"
@@ -79,7 +80,7 @@ def _block_system_message() -> str:
79
80
  ".md files are blocked in this project — generate a self-contained .html "
80
81
  f"file instead. See {_html_effectiveness_url} for "
81
82
  f"design patterns and examples. Exemptions: {CLAUDE_DIRECTORY_NAME}/ and "
82
- f"{PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ infrastructure, "
83
+ f"{CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX}*/ infrastructure, "
83
84
  f"{_exempt_anywhere_filenames_summary} anywhere, {_exempt_plugin_segments_summary} trees, "
84
85
  f"{_claude_dev_env_source_directories_summary} source trees, "
85
86
  f"files under a {PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/ root, "
@@ -0,0 +1,197 @@
1
+ """PreToolUse hook that runs the staged CODE_RULES gate before git commit.
2
+
3
+ Intercepts Bash `git commit` invocations (including `git -C <path> commit`),
4
+ resolves the repository root, and runs the shared code_rules_gate engine in
5
+ ``--staged`` mode over the staged files. A commit that would introduce
6
+ CODE_RULES violations is denied with the gate's file:line report so the
7
+ violations surface before the commit instead of stalling converge loops at
8
+ commit time. Non-commit commands, repositories with no staged Python files,
9
+ and clean staged changes pass through silently. A gate-engine failure denies
10
+ the commit with the failure detail — the gate never fails open.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _blocking_dir = str(Path(__file__).resolve().parent)
20
+ if _blocking_dir not in sys.path:
21
+ sys.path.insert(0, _blocking_dir)
22
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
23
+ if _hooks_dir not in sys.path:
24
+ sys.path.insert(0, _hooks_dir)
25
+
26
+ from block_main_commit import ( # noqa: E402
27
+ extract_git_working_directory,
28
+ is_commit_command,
29
+ parse_bash_command_from_stdin,
30
+ resolve_directory,
31
+ )
32
+ from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
33
+ ALL_GIT_REPOSITORY_ROOT_COMMAND,
34
+ ALL_STAGED_PYTHON_FILES_COMMAND,
35
+ GATE_RELATIVE_PATH,
36
+ GATE_TIMEOUT_SECONDS,
37
+ GIT_COMMAND_TIMEOUT_SECONDS,
38
+ GIT_DASH_C_COMMIT_PATTERN,
39
+ )
40
+
41
+
42
+ def is_git_commit_invocation(bash_command: str) -> bool:
43
+ """Report whether *bash_command* runs a git commit.
44
+
45
+ Matches both the plain ``git commit`` substring form and the
46
+ ``git -C <path> commit`` form, where the directory flag sits between
47
+ the two words.
48
+
49
+ Args:
50
+ bash_command: The Bash tool command string from the hook payload.
51
+
52
+ Returns:
53
+ True when the command invokes git commit; False otherwise.
54
+ """
55
+ if is_commit_command(bash_command):
56
+ return True
57
+ return re.search(GIT_DASH_C_COMMIT_PATTERN, bash_command) is not None
58
+
59
+
60
+ def resolve_repository_root(working_directory: str | None) -> Path | None:
61
+ """Resolve the git repository root for the commit's working directory.
62
+
63
+ Args:
64
+ working_directory: Directory the commit runs in, or None for the
65
+ hook's current working directory.
66
+
67
+ Returns:
68
+ The repository root path, or None when the directory is not inside
69
+ a git repository or git is unavailable.
70
+ """
71
+ try:
72
+ completed_process = subprocess.run(
73
+ list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
77
+ cwd=working_directory,
78
+ )
79
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
80
+ return None
81
+ if completed_process.returncode != 0:
82
+ return None
83
+ top_level_text = completed_process.stdout.strip()
84
+ if not top_level_text:
85
+ return None
86
+ return Path(top_level_text)
87
+
88
+
89
+ def list_staged_python_files(repository_root: Path) -> list[str]:
90
+ """List repository-relative paths of staged Python files.
91
+
92
+ Args:
93
+ repository_root: Repository root used as the git working directory.
94
+
95
+ Returns:
96
+ Repository-relative paths of Python files staged for add, copy,
97
+ modify, or rename. Empty when the listing command fails — the
98
+ caller then allows the commit because git itself will surface the
99
+ repository problem.
100
+ """
101
+ try:
102
+ completed_process = subprocess.run(
103
+ list(ALL_STAGED_PYTHON_FILES_COMMAND),
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
107
+ cwd=str(repository_root),
108
+ )
109
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
110
+ return []
111
+ if completed_process.returncode != 0:
112
+ return []
113
+ return [
114
+ each_line.strip()
115
+ for each_line in completed_process.stdout.splitlines()
116
+ if each_line.strip()
117
+ ]
118
+
119
+
120
+ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
121
+ """Run the shared code_rules_gate engine in staged mode.
122
+
123
+ Args:
124
+ repository_root: Repository root passed to the gate's --repo-root.
125
+
126
+ Returns:
127
+ Tuple of the gate exit code and its stderr report. A missing gate
128
+ script or a gate timeout returns a non-zero code with an
129
+ explanatory message so the commit is denied rather than waved
130
+ through on infrastructure failure.
131
+ """
132
+ gate_path = Path(__file__).resolve().parents[2] / GATE_RELATIVE_PATH
133
+ if not gate_path.is_file():
134
+ return 1, f"precommit_code_rules_gate: gate engine missing at {gate_path}"
135
+ try:
136
+ completed_process = subprocess.run(
137
+ [
138
+ sys.executable,
139
+ str(gate_path),
140
+ "--repo-root",
141
+ str(repository_root),
142
+ "--staged",
143
+ ],
144
+ capture_output=True,
145
+ text=True,
146
+ encoding="utf-8",
147
+ errors="replace",
148
+ timeout=GATE_TIMEOUT_SECONDS,
149
+ )
150
+ except subprocess.TimeoutExpired:
151
+ return 1, (
152
+ f"precommit_code_rules_gate: gate engine timed out after {GATE_TIMEOUT_SECONDS}s"
153
+ )
154
+ return completed_process.returncode, completed_process.stderr
155
+
156
+
157
+ def build_denial_response(gate_report: str) -> dict:
158
+ """Build the PreToolUse deny payload carrying the gate report.
159
+
160
+ Args:
161
+ gate_report: The gate's stderr report listing file:line violations.
162
+
163
+ Returns:
164
+ The hookSpecificOutput deny mapping for the PreToolUse protocol.
165
+ """
166
+ denial_reason = (
167
+ f"BLOCKED: staged files violate CODE_RULES; fix before committing.\n{gate_report.strip()}"
168
+ )
169
+ return {
170
+ "hookSpecificOutput": {
171
+ "hookEventName": "PreToolUse",
172
+ "permissionDecision": "deny",
173
+ "permissionDecisionReason": denial_reason,
174
+ }
175
+ }
176
+
177
+
178
+ def main() -> None:
179
+ """Gate git commits on the staged CODE_RULES report."""
180
+ bash_command = parse_bash_command_from_stdin()
181
+ if not is_git_commit_invocation(bash_command):
182
+ sys.exit(0)
183
+ working_directory = resolve_directory(extract_git_working_directory(bash_command))
184
+ repository_root = resolve_repository_root(working_directory)
185
+ if repository_root is None:
186
+ sys.exit(0)
187
+ if not list_staged_python_files(repository_root):
188
+ sys.exit(0)
189
+ gate_exit_code, gate_report = run_staged_gate(repository_root)
190
+ if gate_exit_code == 0:
191
+ sys.exit(0)
192
+ print(json.dumps(build_denial_response(gate_report)))
193
+ sys.exit(0)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
@@ -1,7 +1,8 @@
1
1
  """Tests for md_to_html_blocker directory and filename exemptions.
2
2
 
3
- Covers which directory trees (`.claude/`, `.claude-plugin/`, source subtrees
4
- under `packages/claude-dev-env/`, `agents/`, `skills/`, `commands/`) and which
3
+ Covers which directory trees (`.claude/`, `.claude-*/` profile and plugin
4
+ directories, source subtrees under `packages/claude-dev-env/`, `agents/`,
5
+ `skills/`, `commands/`) and which
5
6
  root-level filenames (`README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`,
6
7
  `SKILL.md`) are exempt from the `.md` block, and the segment-anchored matching
7
8
  that prevents nested look-alike paths from bypassing the block.
@@ -366,3 +367,68 @@ def test_blocks_ordinary_docs_md_file():
366
367
  assert result.returncode == 0
367
368
  output = json.loads(result.stdout)
368
369
  assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
370
+
371
+
372
+ def test_passes_claude_profile_memory_directory():
373
+ """A Claude profile directory (`.claude-<name>/`, e.g. `.claude-mel/`)
374
+ carries the same infrastructure as `.claude/`; per-project memory files
375
+ under it accept .md writes."""
376
+ result = _run_hook(
377
+ "Write",
378
+ {
379
+ "file_path": (
380
+ "C:/Users/sample/.claude-mel/projects"
381
+ "/sample-project/memory/fact.md"
382
+ ),
383
+ "content": "# Fact",
384
+ },
385
+ )
386
+ assert result.returncode == 0
387
+ assert result.stdout == ""
388
+
389
+
390
+ def test_passes_relative_claude_profile_directory():
391
+ result = _run_hook(
392
+ "Write",
393
+ {
394
+ "file_path": ".claude-mel/projects/sample/memory/fact.md",
395
+ "content": "# Fact",
396
+ },
397
+ )
398
+ assert result.returncode == 0
399
+ assert result.stdout == ""
400
+
401
+
402
+ def test_passes_claude_profile_directory_case_insensitive():
403
+ result = _run_hook(
404
+ "Write",
405
+ {"file_path": "C:/Users/sample/.Claude-Mel/MEMORY.md", "content": "# Index"},
406
+ )
407
+ assert result.returncode == 0
408
+ assert result.stdout == ""
409
+
410
+
411
+ def test_blocks_dot_directory_that_starts_with_claude_but_lacks_hyphen():
412
+ """`.claudette/` is not Claude infrastructure: only a directory named
413
+ exactly `.claude` or carrying the `.claude-` prefix is exempt."""
414
+ result = _run_hook(
415
+ "Write",
416
+ {"file_path": "notes/.claudette/intro.md", "content": "# Intro"},
417
+ )
418
+ assert result.returncode == 0
419
+ output = json.loads(result.stdout)
420
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
421
+
422
+
423
+ def test_blocks_claude_prefixed_filename_in_plain_directory():
424
+ """A file merely named with the `.claude-` prefix (e.g.
425
+ `docs/.claude-notes.md`) is not Claude infrastructure: the exemption
426
+ matches directory segments only, so a `.claude-*.md` basename inside
427
+ an ordinary directory is blocked."""
428
+ result = _run_hook(
429
+ "Write",
430
+ {"file_path": "docs/.claude-notes.md", "content": "# Notes"},
431
+ )
432
+ assert result.returncode == 0
433
+ output = json.loads(result.stdout)
434
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -0,0 +1,126 @@
1
+ """Behavior tests for the precommit_code_rules_gate PreToolUse hook.
2
+
3
+ Each test builds a real git repository in a temporary directory, stages
4
+ real files, and runs the hook script as a subprocess with a PreToolUse
5
+ JSON payload on stdin — the exact production invocation path.
6
+ """
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ HOOK_PATH = Path(__file__).resolve().parent / "precommit_code_rules_gate.py"
14
+
15
+ CLEAN_MODULE_SOURCE = '''"""Increment helper used by the precommit gate tests."""
16
+
17
+
18
+ def add_one(number: int) -> int:
19
+ """Return *number* plus one.
20
+
21
+ Args:
22
+ number: The integer to increment.
23
+
24
+ Returns:
25
+ The incremented integer.
26
+ """
27
+ return number + 1
28
+ '''
29
+
30
+ VIOLATING_MODULE_SOURCE = '''"""Module carrying a banned identifier for the precommit gate tests."""
31
+
32
+
33
+ def compute_total() -> int:
34
+ """Return a fixed total.
35
+
36
+ Returns:
37
+ The fixed total.
38
+ """
39
+ result = 1
40
+ return result
41
+ '''
42
+
43
+
44
+ def run_git(repository_root: Path, *git_arguments: str) -> None:
45
+ subprocess.run(
46
+ ["git", "-C", str(repository_root), *git_arguments],
47
+ check=True,
48
+ capture_output=True,
49
+ )
50
+
51
+
52
+ def initialize_repository(repository_root: Path) -> None:
53
+ run_git(repository_root, "init")
54
+ run_git(repository_root, "config", "user.email", "tests@example.com")
55
+ run_git(repository_root, "config", "user.name", "Gate Tests")
56
+ run_git(repository_root, "commit", "--allow-empty", "-m", "initial")
57
+
58
+
59
+ def stage_file(repository_root: Path, relative_name: str, source_text: str) -> None:
60
+ (repository_root / relative_name).write_text(source_text, encoding="utf-8")
61
+ run_git(repository_root, "add", relative_name)
62
+
63
+
64
+ def run_hook(bash_command: str, working_directory: Path) -> subprocess.CompletedProcess[str]:
65
+ payload = json.dumps({"tool_input": {"command": bash_command}})
66
+ return subprocess.run(
67
+ [sys.executable, str(HOOK_PATH)],
68
+ input=payload,
69
+ capture_output=True,
70
+ text=True,
71
+ cwd=str(working_directory),
72
+ timeout=120,
73
+ )
74
+
75
+
76
+ def parse_denial(hook_stdout: str) -> dict:
77
+ return json.loads(hook_stdout)["hookSpecificOutput"]
78
+
79
+
80
+ def test_non_commit_command_passes_through(tmp_path: Path) -> None:
81
+ initialize_repository(tmp_path)
82
+ completed_hook = run_hook("git status", tmp_path)
83
+ assert completed_hook.returncode == 0
84
+ assert completed_hook.stdout.strip() == ""
85
+
86
+
87
+ def test_commit_with_clean_staged_python_file_is_allowed(tmp_path: Path) -> None:
88
+ initialize_repository(tmp_path)
89
+ stage_file(tmp_path, "incrementer.py", CLEAN_MODULE_SOURCE)
90
+ completed_hook = run_hook("git commit -m add", tmp_path)
91
+ assert completed_hook.returncode == 0
92
+ assert completed_hook.stdout.strip() == ""
93
+
94
+
95
+ def test_commit_with_violating_staged_file_is_blocked(tmp_path: Path) -> None:
96
+ initialize_repository(tmp_path)
97
+ stage_file(tmp_path, "totals.py", VIOLATING_MODULE_SOURCE)
98
+ completed_hook = run_hook("git commit -m add", tmp_path)
99
+ assert completed_hook.returncode == 0
100
+ denial = parse_denial(completed_hook.stdout)
101
+ assert denial["permissionDecision"] == "deny"
102
+ assert "totals.py" in denial["permissionDecisionReason"]
103
+ assert "Line" in denial["permissionDecisionReason"]
104
+
105
+
106
+ def test_git_dash_c_commit_form_is_blocked(tmp_path: Path) -> None:
107
+ repository_root = tmp_path / "repo"
108
+ repository_root.mkdir()
109
+ initialize_repository(repository_root)
110
+ stage_file(repository_root, "totals.py", VIOLATING_MODULE_SOURCE)
111
+ elsewhere = tmp_path / "elsewhere"
112
+ elsewhere.mkdir()
113
+ quoted_root = str(repository_root)
114
+ completed_hook = run_hook(f'git -C "{quoted_root}" commit -m add', elsewhere)
115
+ assert completed_hook.returncode == 0
116
+ denial = parse_denial(completed_hook.stdout)
117
+ assert denial["permissionDecision"] == "deny"
118
+ assert "totals.py" in denial["permissionDecisionReason"]
119
+
120
+
121
+ def test_commit_with_no_staged_python_files_is_allowed(tmp_path: Path) -> None:
122
+ initialize_repository(tmp_path)
123
+ stage_file(tmp_path, "notes.md", "# Notes\n")
124
+ completed_hook = run_hook("git commit -m docs", tmp_path)
125
+ assert completed_hook.returncode == 0
126
+ assert completed_hook.stdout.strip() == ""
package/hooks/hooks.json CHANGED
@@ -105,6 +105,11 @@
105
105
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/block_main_commit.py",
106
106
  "timeout": 15
107
107
  },
108
+ {
109
+ "type": "command",
110
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/precommit_code_rules_gate.py",
111
+ "timeout": 30
112
+ },
108
113
  {
109
114
  "type": "command",
110
115
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_description_enforcer.py",
@@ -24,10 +24,7 @@ REPO_ROOT_MARKER_NAME: str = ".git"
24
24
  CLAUDE_DIRECTORY_NAME: str = ".claude"
25
25
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME: str = ".claude-plugin"
26
26
 
27
- CLAUDE_DIRECTORY_SEGMENT_MARKER: str = f"/{CLAUDE_DIRECTORY_NAME}/"
28
- CLAUDE_DIRECTORY_PATH_PREFIX: str = f"{CLAUDE_DIRECTORY_NAME}/"
29
- PLUGIN_DIRECTORY_SEGMENT_MARKER: str = f"/{PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/"
30
- PLUGIN_DIRECTORY_PATH_PREFIX: str = f"{PLUGIN_ROOT_MARKER_DIRECTORY_NAME}/"
27
+ CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX: str = f"{CLAUDE_DIRECTORY_NAME}-"
31
28
 
32
29
  ALL_EXEMPT_ANYWHERE_FILENAMES_LOWER: frozenset[str] = frozenset(
33
30
  each_filename.lower() for each_filename in ALL_EXEMPT_ANYWHERE_FILENAMES
@@ -68,12 +65,9 @@ __all__ = [
68
65
  "ALL_EXEMPT_ROOT_FILENAMES_LOWER",
69
66
  "CLAUDE_DEV_ENV_REPO_NAME_SEGMENT",
70
67
  "CLAUDE_DIRECTORY_NAME",
71
- "CLAUDE_DIRECTORY_PATH_PREFIX",
72
- "CLAUDE_DIRECTORY_SEGMENT_MARKER",
68
+ "CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX",
73
69
  "MINIMUM_SEGMENT_COUNT_TO_MATCH_INDICATOR",
74
70
  "PACKAGES_TOP_LEVEL_SEGMENT",
75
- "PLUGIN_DIRECTORY_PATH_PREFIX",
76
- "PLUGIN_DIRECTORY_SEGMENT_MARKER",
77
71
  "PLUGIN_ROOT_MARKER_DIRECTORY_NAME",
78
72
  "REPO_ROOT_MARKER_NAME",
79
73
  "RESOLVED_HOME_DIRECTORY_LOWER",
@@ -0,0 +1,26 @@
1
+ """Constants for the precommit_code_rules_gate PreToolUse hook.
2
+
3
+ Command parsing, git timeouts, and the staged-gate invocation surface used
4
+ to run the shared code_rules_gate engine before a git commit.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ GIT_DASH_C_COMMIT_PATTERN: str = r"git\s+-C\s+[\"']?[^\"';&|]+?[\"']?\s+commit\b"
10
+ GIT_COMMAND_TIMEOUT_SECONDS: int = 5
11
+ GATE_TIMEOUT_SECONDS: int = 25
12
+ GATE_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts" / "code_rules_gate.py"
13
+ ALL_STAGED_PYTHON_FILES_COMMAND: tuple[str, ...] = (
14
+ "git",
15
+ "diff",
16
+ "--cached",
17
+ "--name-only",
18
+ "--diff-filter=ACMR",
19
+ "--",
20
+ "*.py",
21
+ )
22
+ ALL_GIT_REPOSITORY_ROOT_COMMAND: tuple[str, ...] = (
23
+ "git",
24
+ "rev-parse",
25
+ "--show-toplevel",
26
+ )
@@ -115,3 +115,11 @@ def test_plugin_root_marker_directory_name_is_dot_claude_plugin() -> None:
115
115
  a plugin repo root and exempted."""
116
116
  assert constants_module.PLUGIN_ROOT_MARKER_DIRECTORY_NAME == ".claude-plugin"
117
117
  assert "PLUGIN_ROOT_MARKER_DIRECTORY_NAME" in constants_module.__all__
118
+
119
+
120
+ def test_claude_profile_directory_name_prefix_is_dot_claude_hyphen() -> None:
121
+ """A directory whose name carries the `.claude-` prefix (profile
122
+ directories like `.claude-mel/`, plus `.claude-plugin/`) is Claude
123
+ infrastructure; any path inside one bypasses the .md block."""
124
+ assert constants_module.CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX == ".claude-"
125
+ assert "CLAUDE_PROFILE_DIRECTORY_NAME_PREFIX" in constants_module.__all__
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.55.1",
3
+ "version": "1.56.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -196,6 +196,7 @@
196
196
  <constraints>
197
197
  <constraint>Read every file completely. Do not skim. Do not skip any file or any line.</constraint>
198
198
  <constraint>Write findings to the temp file immediately. Do not accumulate them in memory and batch-write at the end. You will forget things.</constraint>
199
+ <constraint>Double-quote every path in shell commands and write paths with forward slashes (e.g. C:/Users/...), even on Windows.</constraint>
199
200
  <constraint>Every finding must cite the file and line of the problem AND the file and line of the evidence that proves it is a problem. No floating claims.</constraint>
200
201
  <constraint>When two files contradict each other, flag BOTH files. Do not guess which is correct unless a canonical source resolves it.</constraint>
201
202
  <constraint>If you cannot determine the correct value or form, flag the inconsistency and mark it "unresolvable — no canonical source found". Do not guess.</constraint>
@@ -124,7 +124,7 @@ def per_pr_workspace(
124
124
  slug = slugify_pr_identity(owner, repo, pr_number)
125
125
  return PerPrWorkspace(
126
126
  worktree=pr_workspace_dir / WORKTREE_DIRNAME,
127
- diff_patch_template=str(pr_workspace_dir / slug / DIFF_PATCH_TEMPLATE),
127
+ diff_patch_template=(pr_workspace_dir / slug / DIFF_PATCH_TEMPLATE).as_posix(),
128
128
  outcome_xml_template=OUTCOME_XML_TEMPLATE,
129
129
  fix_outcome_xml_template=FIX_OUTCOME_XML_TEMPLATE,
130
130
  )
@@ -62,14 +62,14 @@ def build_audit_prompt_xml(
62
62
  SubElement(context, "pr_number").text = str(pr_number)
63
63
  SubElement(context, "head_ref").text = head_ref
64
64
  SubElement(context, "base_ref").text = base_ref
65
- SubElement(context, "worktree_path").text = str(worktree_path)
66
- SubElement(context, "run_temp_dir").text = str(run_temp_dir)
65
+ SubElement(context, "worktree_path").text = worktree_path.as_posix()
66
+ SubElement(context, "run_temp_dir").text = run_temp_dir.as_posix()
67
67
 
68
68
  scope = SubElement(root, "scope")
69
69
  scope.text = (
70
70
  f"Audit the full diff of {owner}/{repo}#{pr_number} "
71
71
  f"({head_ref} against {base_ref}) for CODE_RULES violations, "
72
- f"bugs, and anti-patterns. Work in {worktree_path}."
72
+ f"bugs, and anti-patterns. Work in {worktree_path.as_posix()}."
73
73
  )
74
74
 
75
75
  bug_categories = SubElement(root, "bug_categories")
@@ -63,7 +63,7 @@ def build_fix_prompt_xml(
63
63
  SubElement(context, "pr_number").text = str(pr_number)
64
64
  SubElement(context, "head_ref").text = head_ref
65
65
  SubElement(context, "base_ref").text = base_ref
66
- SubElement(context, "worktree_path").text = str(worktree_path)
66
+ SubElement(context, "worktree_path").text = worktree_path.as_posix()
67
67
 
68
68
  bugs_elem = SubElement(root, "bugs")
69
69
  if isinstance(findings_data, list):
@@ -124,7 +124,7 @@ def main(
124
124
  arguments.is_multi_pr if is_multi_pr is None else is_multi_pr
125
125
  ),
126
126
  )
127
- print(state_path)
127
+ print(state_path.as_posix())
128
128
  return 0
129
129
 
130
130
 
@@ -26,6 +26,8 @@ ALL_AUDIT_CONSTRAINT_TEXTS = [
26
26
  "Every finding must cite file:line.",
27
27
  "Document each finding with severity, file, line, and suggested fix.",
28
28
  "Read each file in the diff before reporting on it.",
29
+ "Double-quote every path in shell commands and write paths with "
30
+ "forward slashes (e.g. C:/Users/...), even on Windows.",
29
31
  ]
30
32
 
31
33
  ALL_AUDIT_CATEGORY_ENTRIES = [
@@ -71,6 +73,8 @@ ALL_FIX_CONSTRAINT_TEXTS = [
71
73
  "Every fix must have a corresponding test.",
72
74
  "Remove deprecated code directly and update all call sites.",
73
75
  "Handle each error case with a named exception type.",
76
+ "Double-quote every path in shell commands and write paths with "
77
+ "forward slashes (e.g. C:/Users/...), even on Windows.",
74
78
  ]
75
79
 
76
80
  XML_PRETTY_INDENT = " "
@@ -164,7 +164,7 @@ def main(all_arguments: list[str]) -> int:
164
164
  run_temp_dir=run_temp_dir,
165
165
  all_pr_entries=all_pr_entries,
166
166
  )
167
- print(f"Removed {removed_count} worktree(s), cleaned {run_temp_dir}")
167
+ print(f"Removed {removed_count} worktree(s), cleaned {run_temp_dir.as_posix()}")
168
168
  return 0
169
169
 
170
170
 
@@ -47,7 +47,17 @@ def test_per_pr_workspace_diff_patch_template_carries_loop_placeholder() -> None
47
47
  workspace = path_resolver.per_pr_workspace(run_temp_dir, "owner", "repo", 7)
48
48
  rendered = workspace.diff_patch_template.format(loop=3)
49
49
  assert rendered.endswith("loop-3.patch")
50
- assert "owner-repo-pr-7" in rendered.replace("\\", "/")
50
+ assert "owner-repo-pr-7" in rendered
51
+
52
+
53
+ def test_per_pr_workspace_diff_patch_template_uses_forward_slashes() -> None:
54
+ run_temp_dir = Path("C:/Users/jon/AppData/Local/Temp/bugteam-pr-376")
55
+ workspace = path_resolver.per_pr_workspace(run_temp_dir, "owner", "repo", 376)
56
+ assert "\\" not in workspace.diff_patch_template
57
+ assert workspace.diff_patch_template == (
58
+ "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/"
59
+ "pr-376/owner-repo-pr-376/loop-{loop}.patch"
60
+ )
51
61
 
52
62
 
53
63
  def test_per_pr_workspace_is_frozen() -> None:
@@ -60,6 +60,30 @@ def _build_audit_root() -> Element:
60
60
  )
61
61
 
62
62
 
63
+ def test_context_and_scope_render_paths_with_forward_slashes() -> None:
64
+ root = build_audit_prompt.build_audit_prompt_xml(
65
+ owner="jl-cmd",
66
+ repo="claude-code-config",
67
+ pr_number=376,
68
+ loop=1,
69
+ head_ref="feat/branch",
70
+ base_ref="main",
71
+ worktree_path=Path("C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree"),
72
+ run_temp_dir=Path("C:/Users/jon/AppData/Local/Temp/bugteam-pr-376"),
73
+ )
74
+ context = root.find("context")
75
+ assert context is not None
76
+ worktree_text = context.findtext("worktree_path")
77
+ run_temp_text = context.findtext("run_temp_dir")
78
+ assert worktree_text == "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree"
79
+ assert run_temp_text == "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376"
80
+ scope = root.find("scope")
81
+ assert scope is not None
82
+ assert scope.text is not None
83
+ assert "\\" not in scope.text
84
+ assert "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree" in scope.text
85
+
86
+
63
87
  def test_bug_categories_carry_ids_a_through_p_in_order() -> None:
64
88
  root = _build_audit_root()
65
89
  bug_categories = root.find("bug_categories")
@@ -0,0 +1,49 @@
1
+ """Tests for build_fix_prompt's agent-facing path rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from types import ModuleType
10
+
11
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
12
+ if str(_SCRIPTS_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_SCRIPTS_DIR))
14
+
15
+
16
+ def _load_build_fix_prompt() -> ModuleType:
17
+ module_path = _SCRIPTS_DIR / "build_fix_prompt.py"
18
+ spec = importlib.util.spec_from_file_location("build_fix_prompt", module_path)
19
+ assert spec is not None
20
+ assert spec.loader is not None
21
+ module = importlib.util.module_from_spec(spec)
22
+ sys.modules["build_fix_prompt"] = module
23
+ spec.loader.exec_module(module)
24
+ return module
25
+
26
+
27
+ build_fix_prompt = _load_build_fix_prompt()
28
+
29
+
30
+ def test_context_worktree_path_renders_with_forward_slashes(tmp_path: Path) -> None:
31
+ findings_json_path = tmp_path / "findings.json"
32
+ findings_json_path.write_text(
33
+ json.dumps([{"severity": "P1", "file": "a.py", "line": 1}]),
34
+ encoding="utf-8",
35
+ )
36
+ root = build_fix_prompt.build_fix_prompt_xml(
37
+ owner="jl-cmd",
38
+ repo="claude-code-config",
39
+ pr_number=376,
40
+ loop=1,
41
+ head_ref="feat/branch",
42
+ base_ref="main",
43
+ worktree_path=Path("C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree"),
44
+ findings_json_path=findings_json_path,
45
+ )
46
+ context = root.find("context")
47
+ assert context is not None
48
+ worktree_text = context.findtext("worktree_path")
49
+ assert worktree_text == "C:/Users/jon/AppData/Local/Temp/bugteam-pr-376/worktree"
@@ -46,3 +46,26 @@ def test_create_loop_state_writes_state_under_typed_worktree(
46
46
  written_state = json.loads(state_path.read_text(encoding="utf-8"))
47
47
  assert written_state["starting_sha"] == "abc1234"
48
48
  assert written_state["loop_count"] == 0
49
+
50
+
51
+ def test_main_prints_state_path_with_forward_slashes(
52
+ tmp_path: Path,
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ capsys: pytest.CaptureFixture[str],
55
+ ) -> None:
56
+ path_resolver_module = init_loop_state.resolve_run_temp_dir.__globals__["tempfile"]
57
+ monkeypatch.setattr(path_resolver_module, "gettempdir", lambda: str(tmp_path))
58
+ exit_code = init_loop_state.main(
59
+ [
60
+ "--pr-number",
61
+ "422",
62
+ "--head-ref",
63
+ "feat/branch",
64
+ "--starting-sha",
65
+ "abc1234",
66
+ ]
67
+ )
68
+ assert exit_code == 0
69
+ printed_path = capsys.readouterr().out.strip()
70
+ assert "\\" not in printed_path
71
+ assert printed_path.endswith("worktree/loop-state.json")
@@ -68,6 +68,17 @@ completion. Watch live progress with `/workflows`.
68
68
  The workflow returns
69
69
  `{ converged, rounds, finalSha, blocker }`.
70
70
 
71
+ ## Budget-aware round boundaries
72
+
73
+ The workflow's `budget` API is the pacing signal: when a usage target is
74
+ set, `converge.mjs` checks `budget.remaining()` before each round and
75
+ stops at the round boundary when one full round (three parallel lenses +
76
+ one fix commit + re-verify) does not fit. On a budget stop the workflow
77
+ returns `blocker: "budget"` with the run id; resume with
78
+ `Workflow({scriptPath, resumeFromRunId})` — completed rounds replay from
79
+ the journal. Never start a round the budget cannot finish: a half-run
80
+ round records nothing resumable and replays dirty.
81
+
71
82
  ## Teardown (on workflow completion)
72
83
 
73
84
  1. **When `converged` is true:** rewrite the PR description and clean the
@@ -43,6 +43,27 @@ working directory routes into the PR's repo for local work and returns to
43
43
  the session worktree before teardown. See
44
44
  [`reference/per-tick.md` § Step 1.5](reference/per-tick.md).
45
45
 
46
+ ## Budget-aware tick boundaries
47
+
48
+ Before starting any tick, estimate whether the remaining session/usage
49
+ budget covers one full clean tick (worst case: a BUGBOT fetch + a
50
+ full-diff CODE_REVIEW + a fix commit + replies). If it does not, do not
51
+ start the tick. Stop at the current tick boundary: write updated state to
52
+ `$CLAUDE_JOB_DIR/pr-converge-state.json`, then report the exact resume
53
+ command (`/pr-converge <PR URL>`) and the persisted `phase`/`tick_count`.
54
+ A tick cut off mid-flight poisons the resume state — clean SHAs recorded
55
+ against work that never landed — so an unstarted tick is always cheaper
56
+ than a half-finished one.
57
+
58
+ ## Findings discipline
59
+
60
+ Every finding, reply, and report states verified facts only — no hedging
61
+ language (`likely`, `probably`, `should`, `appears to`). Verify each
62
+ claim against the code on `current_head` before stating it; the
63
+ anti-hallucination Stop hook rejects hedged output, forcing a rework
64
+ pass. A claim that cannot be verified is reported as unverified, not
65
+ softened.
66
+
46
67
  ## State persistence
47
68
 
48
69
  Single-PR mode persists loop state to `$CLAUDE_JOB_DIR/pr-converge-state.json`.
@@ -354,6 +375,7 @@ round as converged. This rule holds every tick, every loop, every PR.
354
375
  `python "$HOME/.claude/skills/bugteam/scripts/revoke_project_claude_permissions.py"`
355
376
 
356
377
  - [ ] **Step 11: Print final report**
378
+ Print this block verbatim — no paraphrase, no extra commentary:
357
379
  ```
358
380
  /pr-converge exit: converged
359
381
  Loops: <N>