claude-dev-env 1.90.0 → 1.92.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 (63) hide show
  1. package/CLAUDE.md +0 -13
  2. package/agents/clean-coder.md +2 -2
  3. package/agents/code-verifier.md +0 -1
  4. package/agents/test_agent_frontmatter.py +78 -0
  5. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  6. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  7. package/bin/install.mjs +1 -0
  8. package/docs/CODE_RULES.md +2 -2
  9. package/hooks/blocking/CLAUDE.md +5 -2
  10. package/hooks/blocking/code_rules_comments.py +2 -2
  11. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  12. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  13. package/hooks/blocking/conftest.py +115 -0
  14. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  15. package/hooks/blocking/pr_description_enforcer.py +46 -22
  16. package/hooks/blocking/pr_description_pr_number.py +5 -3
  17. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  18. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  19. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  20. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  21. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  22. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  23. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  24. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  25. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  27. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  28. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  29. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  30. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  31. package/hooks/blocking/verification_verdict_store.py +73 -5
  32. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  33. package/hooks/blocking/verified_commit_gate.py +6 -0
  34. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  35. package/hooks/hooks.json +7 -2
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  38. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  39. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  40. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  41. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  42. package/hooks/validators/run_all_validators.py +216 -4
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +2 -0
  46. package/rules/nas-ssh-invocation.md +21 -0
  47. package/rules/proof-of-work-pr-comments.md +26 -0
  48. package/scripts/CLAUDE.md +1 -0
  49. package/scripts/claude-chain.example.json +8 -0
  50. package/scripts/claude_chain_runner.py +400 -0
  51. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
  52. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  53. package/scripts/sync_to_cursor/rules.py +1 -1
  54. package/scripts/test_claude_chain_runner.py +472 -0
  55. package/skills/CLAUDE.md +3 -0
  56. package/skills/team-advisor/SKILL.md +188 -0
  57. package/skills/team-advisor-refresh/SKILL.md +25 -0
  58. package/skills/usage-pause/SKILL.md +108 -0
  59. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  60. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  61. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  62. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  63. package/system-prompts/software-engineer.xml +3 -2
@@ -1,10 +1,11 @@
1
- """PreToolUse hook gating gh pr create/edit/comment body content.
2
-
3
- Reads a PreToolUse JSON payload on stdin, recognises the body-carrying
4
- gh pr create/edit/comment Bash invocations, audits the PR body against the
5
- Anthropic claude-code style rules, and denies the command when the body fails.
6
- Readability-management CLI flags short-circuit the stdin path to adjust the
7
- persisted readability state.
1
+ """PreToolUse hook gating gh pr create/edit/comment bodies and gh pr ready.
2
+
3
+ Reads a PreToolUse JSON payload on stdin, audits body-carrying gh pr
4
+ create/edit/comment invocations against the Anthropic claude-code style
5
+ rules plus the proof-of-work audit for proof-shaped comment bodies, and
6
+ denies gh pr ready while the PR carries no passing proof comment.
7
+ Readability-management CLI flags short-circuit the stdin path to adjust
8
+ the persisted readability state.
8
9
  """
9
10
 
10
11
  import json
@@ -32,6 +33,12 @@ from blocking.pr_description_pr_number import ( # noqa: E402
32
33
  _command_carries_body_flag,
33
34
  _extract_pr_number_from_command,
34
35
  )
36
+ from blocking.pr_description_proof_of_work import ( # noqa: E402
37
+ audit_proof_comment_body,
38
+ evaluate_pr_ready_gate,
39
+ is_pr_ready_command,
40
+ is_proof_shaped_body,
41
+ )
35
42
  from blocking.pr_description_readability import ( # noqa: E402
36
43
  _build_readability_escape_hatch_message,
37
44
  _dispatch_cli_flag,
@@ -131,6 +138,28 @@ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
131
138
  return violations
132
139
 
133
140
 
141
+ def _emit_denial(denial_reason: str) -> None:
142
+ """Print the PreToolUse deny payload for a violation and log the block.
143
+
144
+ Args:
145
+ denial_reason: The permissionDecisionReason text for the deny payload.
146
+ """
147
+ denial_payload = {
148
+ "hookSpecificOutput": {
149
+ "hookEventName": "PreToolUse",
150
+ "permissionDecision": "deny",
151
+ "permissionDecisionReason": denial_reason,
152
+ }
153
+ }
154
+ log_hook_block(
155
+ calling_hook_name="pr_description_enforcer.py",
156
+ hook_event="PreToolUse",
157
+ block_reason=denial_reason,
158
+ )
159
+ print(json.dumps(denial_payload))
160
+ sys.stdout.flush()
161
+
162
+
134
163
  def main() -> None:
135
164
  for each_argv_token in sys.argv[1:]:
136
165
  if each_argv_token in ALL_READABILITY_CLI_FLAG_TOKENS:
@@ -154,6 +183,12 @@ def main() -> None:
154
183
  if not command:
155
184
  sys.exit(0)
156
185
 
186
+ if is_pr_ready_command(command):
187
+ ready_denial_reason = evaluate_pr_ready_gate(command)
188
+ if ready_denial_reason is not None:
189
+ _emit_denial(ready_denial_reason)
190
+ sys.exit(0)
191
+
157
192
  has_any_body_flag = _command_carries_body_flag(command)
158
193
  is_pr_create = "gh pr create" in command and has_any_body_flag
159
194
  is_pr_edit = "gh pr edit" in command and has_any_body_flag
@@ -173,28 +208,17 @@ def main() -> None:
173
208
 
174
209
  violations = validate_pr_body(body, pr_number=extracted_pr_number)
175
210
 
211
+ if is_pr_comment and is_proof_shaped_body(body):
212
+ violations.extend(audit_proof_comment_body(body, extracted_pr_number))
213
+
176
214
  if violations:
177
215
  violation_list = "; ".join(violations)
178
216
  pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
179
- denial_reason = (
217
+ _emit_denial(
180
218
  f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
181
219
  f"Rewrite the body yourself in Anthropic claude-code style. "
182
220
  f"Guide:{pr_guide_reference}"
183
221
  )
184
- denial_payload = {
185
- "hookSpecificOutput": {
186
- "hookEventName": "PreToolUse",
187
- "permissionDecision": "deny",
188
- "permissionDecisionReason": denial_reason,
189
- }
190
- }
191
- log_hook_block(
192
- calling_hook_name="pr_description_enforcer.py",
193
- hook_event="PreToolUse",
194
- block_reason=denial_reason,
195
- )
196
- print(json.dumps(denial_payload))
197
- sys.stdout.flush()
198
222
 
199
223
  sys.exit(0)
200
224
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  Reports whether a captured shell command carries any body or body-file flag,
4
4
  and extracts the positional PR number (bare integer or GitHub PR URL) from a
5
- gh pr edit/comment command while skipping value-taking flags and their values.
5
+ gh pr edit/comment/ready command while skipping value-taking flags and their
6
+ values.
6
7
  """
7
8
 
8
9
  import re
@@ -29,6 +30,7 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
29
30
  strip_surrounding_quotes,
30
31
  )
31
32
  from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
33
+ ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS,
32
34
  GH_PR_COMMAND_MIN_TOKEN_COUNT,
33
35
  )
34
36
 
@@ -59,7 +61,7 @@ def _resolve_positional_pr_number(token: str) -> int | None:
59
61
 
60
62
 
61
63
  def _extract_pr_number_from_command(command: str) -> int | None:
62
- """Return the PR number positional argument from a `gh pr edit|comment` command.
64
+ """Return the PR number positional argument from a `gh pr edit|comment|ready` command.
63
65
 
64
66
  Skips value-taking non-body flags (and their value tokens) so that ``--repo owner/r``
65
67
  pairs do not consume the trailing PR number. Accepts both a bare integer literal
@@ -83,7 +85,7 @@ def _extract_pr_number_from_command(command: str) -> int | None:
83
85
  if all_tokens[0] != "gh" or all_tokens[1] != "pr":
84
86
  return None
85
87
  subcommand_token = all_tokens[2]
86
- if subcommand_token not in {"edit", "comment"}:
88
+ if subcommand_token not in ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS:
87
89
  return None
88
90
  all_value_taking_bare_flags: frozenset[str] = (
89
91
  non_body_value_flags | all_body_flags | {body_file_flag, body_file_short_flag}
@@ -0,0 +1,367 @@
1
+ """Audit proof-of-work PR comments and gate gh pr ready on a passing one.
2
+
3
+ A proof comment shows the work behind a PR: the command run on real data,
4
+ the measured outcomes, the parent issue it advances, an image when the
5
+ change is visual, and the gaps the offline proof cannot cover. This module
6
+ detects proof-shaped comment bodies, audits the five parts, and blocks
7
+ ``gh pr ready`` while no passing proof comment exists on the PR. Every gh
8
+ query fails open, so a tooling failure never blocks a command.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_dir not in sys.path:
21
+ sys.path.insert(0, _hooks_dir)
22
+
23
+ from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
24
+ from blocking.pr_description_pr_number import ( # noqa: E402
25
+ _extract_pr_number_from_command,
26
+ )
27
+ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
28
+ BULLET_MARKER_PATTERN,
29
+ FENCED_CODE_BLOCK_PATTERN,
30
+ TABLE_ROW_LINE_PATTERN,
31
+ )
32
+ from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E402
33
+ ALL_HONEST_GAP_PHRASES,
34
+ ALL_PR_DIFF_SUBCOMMANDS,
35
+ ALL_PR_VIEW_NUMBER_ARGUMENTS,
36
+ ALL_PROOF_HEADING_KEYWORDS,
37
+ ALL_VISUAL_FILE_SUFFIXES,
38
+ COMMENT_BODY_JSON_FIELD,
39
+ DIGIT_PATTERN,
40
+ GH_API_SUBCOMMAND,
41
+ GH_COMMAND_TIMEOUT_SECONDS,
42
+ GH_EXECUTABLE,
43
+ GH_PAGINATE_FLAG,
44
+ GH_SLURP_FLAG,
45
+ HEX_COLOR_ADDED_LINE_PATTERN,
46
+ IMAGE_EMBED_PATTERN,
47
+ ISSUE_REFERENCE_PATTERN,
48
+ MAX_DIFF_SCAN_CHARS,
49
+ PLAN_LINKAGE_KEYWORD_PATTERN,
50
+ PR_COMMENTS_API_PATH_TEMPLATE,
51
+ PR_DIFF_NAME_ONLY_FLAG,
52
+ PR_NUMBER_JSON_FIELD,
53
+ PR_READY_GATE_MESSAGE_TEMPLATE,
54
+ PR_READY_INVOCATION_PATTERN,
55
+ PR_READY_UNDO_FLAG,
56
+ PROOF_PART_COMMAND_MESSAGE,
57
+ PROOF_PART_HONEST_GAPS_MESSAGE,
58
+ PROOF_PART_MEASURED_MESSAGE,
59
+ PROOF_PART_PLAN_LINKAGE_MESSAGE,
60
+ PROOF_PART_VISUAL_MESSAGE,
61
+ )
62
+ from hooks_constants.setup_project_paths_constants import UTF8_ENCODING # noqa: E402
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ def is_pr_ready_command(command: str) -> bool:
68
+ """Decide whether a shell command marks a PR ready for review.
69
+
70
+ Args:
71
+ command: The raw shell command captured by the hook.
72
+
73
+ Returns:
74
+ True for a ``gh pr ready`` command without ``--undo``; False for
75
+ every other command, including the undo form that returns a PR to
76
+ draft and any command that only mentions the phrase ``gh pr ready``
77
+ inside a quoted argument or commit message.
78
+ """
79
+ if PR_READY_UNDO_FLAG in command:
80
+ return False
81
+ return PR_READY_INVOCATION_PATTERN.search(command) is not None
82
+
83
+
84
+ def is_proof_shaped_body(body: str) -> bool:
85
+ """Decide whether a comment body presents itself as a proof of work.
86
+
87
+ Args:
88
+ body: The comment body markdown text.
89
+
90
+ Returns:
91
+ True when any markdown heading names proof or verification; False
92
+ when no heading does.
93
+ """
94
+ for each_header in _iter_section_headers(body):
95
+ header_lower = each_header.lower()
96
+ if any(each_keyword in header_lower for each_keyword in ALL_PROOF_HEADING_KEYWORDS):
97
+ return True
98
+ return False
99
+
100
+
101
+ def audit_proof_comment_body(body: str, pr_number: int | None) -> list[str]:
102
+ """Audit a proof-shaped comment body for the five proof parts.
103
+
104
+ Args:
105
+ body: The comment body markdown text.
106
+ pr_number: The PR number named in the command, or None when the
107
+ command targets the current branch's PR.
108
+
109
+ Returns:
110
+ One message per missing proof part. Empty when the body carries a
111
+ command block, a measured-value element, a plan-linkage sentence,
112
+ an honest-gaps statement, and (when the PR diff is visual) an
113
+ image embed.
114
+ """
115
+ is_visual_change = _detect_visual_change(pr_number)
116
+ return _missing_proof_parts(body, is_visual_change)
117
+
118
+
119
+ def evaluate_pr_ready_gate(command: str) -> str | None:
120
+ """Decide whether a gh pr ready command may proceed.
121
+
122
+ Args:
123
+ command: The raw shell command captured by the hook.
124
+
125
+ Returns:
126
+ A denial reason stating the five-part proof standard when the PR
127
+ carries no passing proof comment, or None when a passing proof
128
+ comment exists or any gh query fails.
129
+ """
130
+ resolved_pr_number = _extract_pr_number_from_command(command)
131
+ if resolved_pr_number is None:
132
+ resolved_pr_number = _resolve_current_pr_number()
133
+ if resolved_pr_number is None:
134
+ return None
135
+ all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number)
136
+ if all_comment_bodies is None:
137
+ return None
138
+ is_visual_change = _detect_visual_change(resolved_pr_number)
139
+ for each_body in all_comment_bodies:
140
+ if not is_proof_shaped_body(each_body):
141
+ continue
142
+ if not _missing_proof_parts(each_body, is_visual_change):
143
+ return None
144
+ return PR_READY_GATE_MESSAGE_TEMPLATE.format(pr_number=resolved_pr_number)
145
+
146
+
147
+ def _run_gh_command(all_gh_arguments: list[str]) -> str | None:
148
+ """Run one gh command and return its stdout.
149
+
150
+ Args:
151
+ all_gh_arguments: The gh arguments after the executable name.
152
+
153
+ Returns:
154
+ The command stdout on a zero exit, or None when gh is missing,
155
+ times out, or exits non-zero — the caller fails open on None.
156
+ """
157
+ try:
158
+ completed_process = subprocess.run(
159
+ [GH_EXECUTABLE, *all_gh_arguments],
160
+ capture_output=True,
161
+ text=True,
162
+ encoding=UTF8_ENCODING,
163
+ errors="replace",
164
+ timeout=GH_COMMAND_TIMEOUT_SECONDS,
165
+ check=False,
166
+ )
167
+ except (OSError, subprocess.TimeoutExpired):
168
+ logger.warning("gh command failed to run: %s", all_gh_arguments)
169
+ return None
170
+ if completed_process.returncode != 0:
171
+ logger.warning("gh command exited %d: %s", completed_process.returncode, all_gh_arguments)
172
+ return None
173
+ return completed_process.stdout
174
+
175
+
176
+ def _resolve_current_pr_number() -> int | None:
177
+ """Resolve the PR number of the current branch's open PR.
178
+
179
+ Returns:
180
+ The PR number from ``gh pr view``, or None when no PR resolves or
181
+ gh fails.
182
+ """
183
+ view_stdout = _run_gh_command(list(ALL_PR_VIEW_NUMBER_ARGUMENTS))
184
+ if view_stdout is None:
185
+ return None
186
+ try:
187
+ view_record = json.loads(view_stdout)
188
+ except json.JSONDecodeError:
189
+ return None
190
+ if not isinstance(view_record, dict):
191
+ return None
192
+ resolved_number = view_record.get(PR_NUMBER_JSON_FIELD)
193
+ if isinstance(resolved_number, int):
194
+ return resolved_number
195
+ return None
196
+
197
+
198
+ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
199
+ """Read every comment body on a PR through the paginated issues API.
200
+
201
+ The query runs ``gh api --paginate --slurp`` so every page lands in one
202
+ JSON array of pages, and the flattening happens here after the full
203
+ pagination rather than per page.
204
+
205
+ Args:
206
+ pr_number: The PR number whose comments to read.
207
+
208
+ Returns:
209
+ The comment bodies across all pages, or None when gh fails or the
210
+ output is not the slurped page-array shape — the caller fails open
211
+ on None.
212
+ """
213
+ comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
214
+ api_stdout = _run_gh_command(
215
+ [GH_API_SUBCOMMAND, comments_api_path, GH_PAGINATE_FLAG, GH_SLURP_FLAG]
216
+ )
217
+ if api_stdout is None:
218
+ return None
219
+ try:
220
+ all_comment_pages = json.loads(api_stdout)
221
+ except json.JSONDecodeError:
222
+ return None
223
+ if not isinstance(all_comment_pages, list):
224
+ return None
225
+ all_comment_bodies: list[str] = []
226
+ for each_page in all_comment_pages:
227
+ if not isinstance(each_page, list):
228
+ continue
229
+ for each_comment_record in each_page:
230
+ if isinstance(each_comment_record, dict):
231
+ all_comment_bodies.append(str(each_comment_record.get(COMMENT_BODY_JSON_FIELD, "")))
232
+ return all_comment_bodies
233
+
234
+
235
+ def _detect_visual_change(pr_number: int | None) -> bool:
236
+ """Decide whether the PR diff carries visual work.
237
+
238
+ A diff is visual when a changed file carries an image, HTML, or
239
+ stylesheet suffix, or when an added line carries a six-digit hex color
240
+ value.
241
+
242
+ Args:
243
+ pr_number: The PR number to diff, or None to diff the current
244
+ branch's PR.
245
+
246
+ Returns:
247
+ True when the diff is visual; False when it is not or when any gh
248
+ query fails — an unknowable diff never adds the visual requirement.
249
+ """
250
+ all_number_arguments = [str(pr_number)] if pr_number is not None else []
251
+ name_only_stdout = _run_gh_command(
252
+ [*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, PR_DIFF_NAME_ONLY_FLAG]
253
+ )
254
+ if name_only_stdout is None:
255
+ return False
256
+ for each_changed_path in name_only_stdout.splitlines():
257
+ if Path(each_changed_path.strip()).suffix.lower() in ALL_VISUAL_FILE_SUFFIXES:
258
+ return True
259
+ diff_stdout = _run_gh_command([*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments])
260
+ if diff_stdout is None:
261
+ return False
262
+ return HEX_COLOR_ADDED_LINE_PATTERN.search(diff_stdout[:MAX_DIFF_SCAN_CHARS]) is not None
263
+
264
+
265
+ def _missing_proof_parts(body: str, is_visual_change: bool) -> list[str]:
266
+ """Collect the proof parts a comment body is missing.
267
+
268
+ Args:
269
+ body: The comment body markdown text.
270
+ is_visual_change: Whether the PR diff carries visual work, which
271
+ adds the image-embed requirement.
272
+
273
+ Returns:
274
+ One message per missing part, in the standard's order: command
275
+ block, measured values, plan linkage, visual element, honest gaps.
276
+ """
277
+ prose_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
278
+ all_missing_parts: list[str] = []
279
+ if not _has_command_block(body):
280
+ all_missing_parts.append(PROOF_PART_COMMAND_MESSAGE)
281
+ if not _has_measured_value(prose_without_fences):
282
+ all_missing_parts.append(PROOF_PART_MEASURED_MESSAGE)
283
+ if not _has_plan_linkage(prose_without_fences):
284
+ all_missing_parts.append(PROOF_PART_PLAN_LINKAGE_MESSAGE)
285
+ if is_visual_change and not _has_visual_element(prose_without_fences):
286
+ all_missing_parts.append(PROOF_PART_VISUAL_MESSAGE)
287
+ if not _has_honest_gaps(prose_without_fences):
288
+ all_missing_parts.append(PROOF_PART_HONEST_GAPS_MESSAGE)
289
+ return all_missing_parts
290
+
291
+
292
+ def _has_command_block(body: str) -> bool:
293
+ """Report whether the body carries a fenced code block with content.
294
+
295
+ Args:
296
+ body: The comment body markdown text.
297
+
298
+ Returns:
299
+ True when at least one fenced code block holds a non-empty line
300
+ between its fence lines.
301
+ """
302
+ for each_fence_match in FENCED_CODE_BLOCK_PATTERN.finditer(body):
303
+ all_inner_lines = each_fence_match.group(0).splitlines()[1:-1]
304
+ if any(each_line.strip() for each_line in all_inner_lines):
305
+ return True
306
+ return False
307
+
308
+
309
+ def _has_measured_value(prose_without_fences: str) -> bool:
310
+ """Report whether the prose carries a measured-value element.
311
+
312
+ Args:
313
+ prose_without_fences: The body text with fenced code blocks removed.
314
+
315
+ Returns:
316
+ True when a pipe-table row or a bullet line carries a digit.
317
+ """
318
+ for each_line in prose_without_fences.splitlines():
319
+ if not DIGIT_PATTERN.search(each_line):
320
+ continue
321
+ if TABLE_ROW_LINE_PATTERN.match(each_line) or BULLET_MARKER_PATTERN.match(each_line):
322
+ return True
323
+ return False
324
+
325
+
326
+ def _has_plan_linkage(prose_without_fences: str) -> bool:
327
+ """Report whether the prose links the PR to its parent plan.
328
+
329
+ Args:
330
+ prose_without_fences: The body text with fenced code blocks removed.
331
+
332
+ Returns:
333
+ True when one line carries both an issue reference (``#123``) and a
334
+ linkage word (issue, phase, plan, parent, advances, milestone, or
335
+ part of).
336
+ """
337
+ for each_line in prose_without_fences.splitlines():
338
+ has_issue_reference = ISSUE_REFERENCE_PATTERN.search(each_line) is not None
339
+ if has_issue_reference and PLAN_LINKAGE_KEYWORD_PATTERN.search(each_line) is not None:
340
+ return True
341
+ return False
342
+
343
+
344
+ def _has_visual_element(prose_without_fences: str) -> bool:
345
+ """Report whether the prose embeds an image.
346
+
347
+ Args:
348
+ prose_without_fences: The body text with fenced code blocks removed.
349
+
350
+ Returns:
351
+ True when a markdown image embed (``![...](...)``) is present.
352
+ """
353
+ return IMAGE_EMBED_PATTERN.search(prose_without_fences) is not None
354
+
355
+
356
+ def _has_honest_gaps(prose_without_fences: str) -> bool:
357
+ """Report whether the prose states what the proof cannot show.
358
+
359
+ Args:
360
+ prose_without_fences: The body text with fenced code blocks removed.
361
+
362
+ Returns:
363
+ True when the prose carries a gap phrase (gap, limitation, cannot,
364
+ does not show, not shown, unverified, or not covered).
365
+ """
366
+ prose_lower = prose_without_fences.lower()
367
+ return any(each_phrase in prose_lower for each_phrase in ALL_HONEST_GAP_PHRASES)
@@ -122,7 +122,10 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
122
122
  """Run the shared code_rules_gate engine in staged mode.
123
123
 
124
124
  Args:
125
- repository_root: Repository root passed to the gate's --repo-root.
125
+ repository_root: Repository root passed to the gate's --repo-root
126
+ and used as the gate subprocess working directory, so relative
127
+ paths the gate's checks resolve land in the committing worktree
128
+ rather than the hook's own working directory.
126
129
 
127
130
  Returns:
128
131
  Tuple of the gate exit code and its stderr report. A missing gate
@@ -147,6 +150,7 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
147
150
  encoding="utf-8",
148
151
  errors="replace",
149
152
  timeout=GATE_TIMEOUT_SECONDS,
153
+ cwd=str(repository_root),
150
154
  )
151
155
  except subprocess.TimeoutExpired:
152
156
  return 1, (
@@ -17,6 +17,8 @@ import sys
17
17
  from pathlib import Path
18
18
  from types import SimpleNamespace
19
19
 
20
+ import pytest
21
+
20
22
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
21
23
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
22
24
  if _BLOCKING_DIRECTORY not in sys.path:
@@ -119,11 +121,15 @@ def test_check_comment_changes_skips_removal_when_old_python_un_parseable() -> N
119
121
  assert issues == []
120
122
 
121
123
 
122
- def test_check_comment_changes_still_detects_removal_on_parseable_python() -> None:
124
+ def test_check_comment_changes_advises_without_blocking_on_removal_on_parseable_python(
125
+ capsys: pytest.CaptureFixture[str],
126
+ ) -> None:
123
127
  old_content = 'x = 1 # existing comment\n'
124
128
  new_content = 'x = 1\n'
125
129
  issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
126
- assert any("Existing comment removed" in each_issue for each_issue in issues)
130
+ assert not any("Existing comment removed" in each_issue for each_issue in issues)
131
+ captured = capsys.readouterr()
132
+ assert "[CODE_RULES advisory] Existing comment removed" in captured.err
127
133
 
128
134
 
129
135
  def test_python_check_should_exempt_directive_without_space_after_hash() -> None:
@@ -287,6 +287,37 @@ def make_conflict_repository(tmp_path: Path) -> Path:
287
287
  return repository_root
288
288
 
289
289
 
290
+ def make_stale_self_upstream_conflict_repository(tmp_path: Path) -> Path:
291
+ repository_root = make_conflict_repository(tmp_path)
292
+ feature_sha = run_git(repository_root, "rev-parse", "HEAD")
293
+ run_git(repository_root, "remote", "add", "origin", str(repository_root))
294
+ run_git(repository_root, "update-ref", "refs/remotes/origin/feature", feature_sha)
295
+ run_git(repository_root, "config", "branch.feature.remote", "origin")
296
+ run_git(repository_root, "config", "branch.feature.merge", "refs/heads/feature")
297
+ return repository_root
298
+
299
+
300
+ def make_merge_in_progress_repository(tmp_path: Path) -> Path:
301
+ repository_root = tmp_path / "repo"
302
+ repository_root.mkdir()
303
+ initialize_repository(repository_root)
304
+ base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
305
+ run_git(repository_root, "update-ref", "refs/remotes/origin/main", base_sha)
306
+ run_git(repository_root, "checkout", "-b", "feature")
307
+ commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
308
+ advance_origin_main_divergent(repository_root, base_sha, "shared.py", SHARED_DIVERGENT_SOURCE)
309
+ return repository_root
310
+
311
+
312
+ def start_conflicting_merge(repository_root: Path) -> None:
313
+ subprocess.run(
314
+ ["git", "-C", str(repository_root), "merge", "--no-edit", "origin/main"],
315
+ check=False,
316
+ capture_output=True,
317
+ text=True,
318
+ )
319
+
320
+
290
321
  def test_non_code_verifier_agent_is_no_op(tmp_path: Path) -> None:
291
322
  repository_root = make_conflict_repository(tmp_path)
292
323
  write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
@@ -473,6 +504,46 @@ def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
473
504
  assert "violator.py" in reason
474
505
 
475
506
 
507
+ def test_stale_self_upstream_still_judges_default_branch(tmp_path: Path) -> None:
508
+ repository_root = make_stale_self_upstream_conflict_repository(tmp_path)
509
+ tracked_upstream = run_git(
510
+ repository_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
511
+ )
512
+ assert tracked_upstream == "origin/feature", "fixture must track the branch's own remote ref"
513
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
514
+ result = run_hook(payload, repository_root)
515
+ assert not is_allow(result)
516
+ reason = deny_reason(result)
517
+ assert "shared.py" in reason
518
+ assert "Merge conflicts vs" in reason
519
+
520
+
521
+ def test_staged_resolved_merge_allows(tmp_path: Path) -> None:
522
+ repository_root = make_merge_in_progress_repository(tmp_path)
523
+ start_conflicting_merge(repository_root)
524
+ write_working_tree_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE)
525
+ run_git(repository_root, "add", "shared.py")
526
+ remaining_unmerged = run_git(repository_root, "diff", "--name-only", "--diff-filter=U")
527
+ assert remaining_unmerged == "", "the staged resolution must clear every unmerged index entry"
528
+ merge_head_sha = run_git(repository_root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD")
529
+ assert merge_head_sha, "a merge must still be in progress so MERGE_HEAD is present"
530
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
531
+ result = run_hook(payload, repository_root)
532
+ assert is_allow(result)
533
+
534
+
535
+ def test_unresolved_merge_keeps_denying(tmp_path: Path) -> None:
536
+ repository_root = make_merge_in_progress_repository(tmp_path)
537
+ start_conflicting_merge(repository_root)
538
+ remaining_unmerged = run_git(repository_root, "diff", "--name-only", "--diff-filter=U")
539
+ assert "shared.py" in remaining_unmerged, "the merge must leave shared.py unmerged"
540
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
541
+ result = run_hook(payload, repository_root)
542
+ assert not is_allow(result)
543
+ reason = deny_reason(result)
544
+ assert "shared.py" in reason
545
+
546
+
476
547
  def test_hook_imports_real_config_when_parent_holds_shadowing_config(
477
548
  tmp_path: Path,
478
549
  ) -> None: