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
package/CLAUDE.md CHANGED
@@ -4,19 +4,6 @@ The user is short on time and appreciates brevity in replies. When you reply, al
4
4
 
5
5
  The user delegates execution to you and expects zero manual steps unless strictly necessary. Execute every command you can directly. Only instruct the user to do something manually when you are technically unable to do it yourself. When a task involves credentials or other sensitive input, display a minimal secure UI (e.g., a password dialog) to collect it rather than asking the user to paste it into chat or run the command themselves. When direction is ambiguous, use AskUserQuestion to clarify before acting.
6
6
 
7
- You have access to an `advisor` tool backed by a stronger reviewer model. It takes NO parameters — when you call advisor(), your entire conversation history is automatically forwarded. They see the task, every tool call you've made, every result you've seen.
8
-
9
- Call advisor BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, fetching a source, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are.
10
-
11
- Also call advisor:
12
- - When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, save the result, commit the change. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't.
13
- - When stuck — errors recurring, approach not converging, results that don't fit.
14
- - When considering a change of approach.
15
-
16
- On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling — the advisor adds most of its value on the first call, before the approach crystallizes.
17
-
18
- ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
19
-
20
7
  ## Timeless Documentation (all `.md` files)
21
8
 
22
9
  Every Markdown file I write or edit describes the system's **current** state only. The test: a reader a year out, with zero prior context, finds every sentence true and complete without knowing what came before. State what **is**, not what changed — git history records change; docs record the contract.
@@ -159,7 +159,7 @@ Use logging for application and runtime output. `print()` is allowed when stdout
159
159
 
160
160
  ### Comment preservation
161
161
 
162
- Existing comments on lines that remain otherwise unchanged stay exactly as you found them. The hook enforces both directions: the gate fires on a new inline `#` or `//` in production code, and the gate also fires when an existing comment disappears from a line you touched. New code self-documents via names; new docstrings on functions, methods, classes, and modules remain allowed.
162
+ Existing comments on lines you leave otherwise unchanged stay exactly as you found them. The hook treats the two directions differently: the gate fires and blocks the edit on a new inline `#` or `//` in production code, while an existing comment disappearing from a line you touched prints a stderr advisory and lets the edit through. New code self-documents via names; new docstrings on functions, methods, classes, and modules stay allowed.
163
163
 
164
164
  ### Centralized configuration
165
165
 
@@ -338,7 +338,7 @@ These gates are checked by `code_rules_enforcer.py`. Satisfying each gate lets y
338
338
  | Rule | What this rule looks for |
339
339
  |------|--------------------------|
340
340
  | Self-documenting names only | New `#` or `//` in production code (shebangs, `# type:`, `# noqa`, eslint-directives, docstrings exempt) |
341
- | Comment preservation | Removal of existing comments on lines that remain otherwise unchanged |
341
+ | Comment preservation | Advisory — removal of existing comments on lines you leave otherwise unchanged prints a stderr advisory and does not block |
342
342
  | Imports at top | `import` statements placed inside function bodies |
343
343
  | Logging format | `log_*(f"...")` — replace with `log_*("...", arg)` |
344
344
  | File length | Advisory at 400 lines (soft), strong nudge at 1000 — emitted to stderr; the write proceeds |
@@ -3,7 +3,6 @@ name: code-verifier
3
3
  description: Post-hoc verification agent for the two-phase code workflow. Spawned by the main session after coder agents finish. Runs every check itself in a fresh context — named gates, tests against recorded baselines, two-way diff-vs-task reading — and ends with a fenced verdict block the verifier_verdict_minter hook turns into the commit-gate verdict. Read and execute only; it never edits files.
4
4
  tools: Read, Grep, Glob, Bash
5
5
  model: sonnet
6
- effort: medium
7
6
  color: orange
8
7
  ---
9
8
 
@@ -0,0 +1,78 @@
1
+ """Behavior tests for agent-definition YAML frontmatter.
2
+
3
+ Every agent `.md` in this directory opens with a frontmatter block the Claude
4
+ Code subagent loader reads. The loader accepts a fixed key set; an unrecognized
5
+ top-level key breaks the spawn — the subagent starts with a broken definition,
6
+ idles, and dies without a report::
7
+
8
+ ok: name / description / tools / model / color
9
+ flag: effort <- unrecognized, subagent dies delivering nothing
10
+
11
+ The accepted set is the one `agents/CLAUDE.md` documents (name, description,
12
+ tools, color) plus the `model` pin the working agents carry (clean-coder pins
13
+ opus, pr-description-writer pins haiku). Top-level keys are read with a line
14
+ scan so an agent whose `description` embeds informal `<example>` prose is not
15
+ mistaken for one carrying extra keys.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+ import yaml
25
+
26
+ ACCEPTED_FRONTMATTER_KEYS = frozenset(
27
+ {"name", "description", "tools", "model", "color"}
28
+ )
29
+ FRONTMATTER_FENCE = "---"
30
+ FRONTMATTER_SEGMENT_COUNT = 3
31
+ CODE_VERIFIER_AGENT_NAME = "code-verifier"
32
+ TOP_LEVEL_KEY_PATTERN = re.compile(r"^([a-z][a-z0-9_]*):", re.MULTILINE)
33
+
34
+
35
+ def _agent_definition_paths() -> list[Path]:
36
+ agents_directory = Path(__file__).parent
37
+ all_markdown_files = sorted(agents_directory.glob("*.md"))
38
+ return [
39
+ each_markdown_file
40
+ for each_markdown_file in all_markdown_files
41
+ if each_markdown_file.read_text(encoding="utf-8").startswith(FRONTMATTER_FENCE)
42
+ ]
43
+
44
+
45
+ def _frontmatter_block(agent_definition_path: Path) -> str:
46
+ agent_text = agent_definition_path.read_text(encoding="utf-8")
47
+ fence_segments = agent_text.split(FRONTMATTER_FENCE, FRONTMATTER_SEGMENT_COUNT - 1)
48
+ return fence_segments[1]
49
+
50
+
51
+ def _top_level_keys(frontmatter_block: str) -> set[str]:
52
+ return set(TOP_LEVEL_KEY_PATTERN.findall(frontmatter_block))
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "agent_definition_path",
57
+ _agent_definition_paths(),
58
+ ids=lambda each_path: each_path.name,
59
+ )
60
+ def test_agent_frontmatter_uses_only_accepted_keys(
61
+ agent_definition_path: Path,
62
+ ) -> None:
63
+ declared_keys = _top_level_keys(_frontmatter_block(agent_definition_path))
64
+ unaccepted_keys = declared_keys - ACCEPTED_FRONTMATTER_KEYS
65
+ assert not unaccepted_keys, (
66
+ f"{agent_definition_path.name} carries frontmatter keys the subagent "
67
+ f"loader does not accept: {sorted(unaccepted_keys)}"
68
+ )
69
+
70
+
71
+ def test_code_verifier_frontmatter_parses_and_names_the_agent() -> None:
72
+ agents_directory = Path(__file__).parent
73
+ code_verifier_block = _frontmatter_block(
74
+ agents_directory / f"{CODE_VERIFIER_AGENT_NAME}.md"
75
+ )
76
+ parsed_frontmatter = yaml.safe_load(code_verifier_block)
77
+ assert parsed_frontmatter["name"] == CODE_VERIFIER_AGENT_NAME
78
+ assert set(parsed_frontmatter) <= ACCEPTED_FRONTMATTER_KEYS
@@ -24,7 +24,7 @@
24
24
  | J5 | Abbreviations | `ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `elem`, `val`, `tmp`, `str`, `num`, `arr`, `obj`, `fn`, `cb`, `req`, `res`. (Loop counters `i`/`j`/`k` and `e` for exceptions are exempt.) |
25
25
  | J6 | Vague names | `result`, `data`, `output`, `response`, `value`, `item`, `temp`, `info`, `stuff`, `thing`. Vague prefixes: `handle`, `process`, `manage`, `do`. |
26
26
  | J7 | Type hints | Missing type annotation on a parameter or return; presence of `Any` or `# type: ignore`. |
27
- | J8 | New inline comments | New `#` or `//` comments in production code added by this diff. (Existing comments are NEVER removed Comment Preservation rule.) |
27
+ | J8 | New inline comments | New `#` or `//` comments in production code added by this diff. (Removing an existing comment is a Comment Preservation concern the hook flags with a stderr advisory rather than a block.) |
28
28
  | J9 | Logging format | `log_*(f"...")` rather than `log_*("...", arg)`. |
29
29
  | J10 | Imports inside functions | `import` statements placed inside function bodies. |
30
30
  | J11 | sys.path.insert dedup | `sys.path.insert(0, X)` must be guarded by `if X not in sys.path:` (test files exempt). |
@@ -69,7 +69,7 @@ A `test_*.py` name or a `.mjs` extension takes the line out of the write-time ga
69
69
  **J8. New inline comments**
70
70
  - Every `#` or `//` comment line **added** by this diff in production code — flag, except for exempt markers (shebangs, `# type:`, `# noqa`, `# pylint:`, `# pragma:`, `// @ts-`, `// eslint-`, `// prettier-`, `/// `).
71
71
  - Module/function/class docstrings are always allowed.
72
- - Existing comments are NEVER removed (Comment Preservation rule); if the diff removes an existing comment, that is a separate violation outside J8 (also blocked by the hook).
72
+ - Existing comments are NEVER removed (Comment Preservation rule); if the diff removes an existing comment, that is a separate violation outside J8 (the hook emits a stderr advisory for it rather than blocking).
73
73
  - Test files are exempt.
74
74
  - Adversarial probes: (a) is there any `# type:` or marker comment that is actually inert prose rather than a real type-checker / linter directive? (b) is any docstring carrying inline-comment content (line-level explanations rather than module/function description)? (c) does any newly-added blank line between code stanzas function as a comment substitute, suggesting the author wanted to add a comment but couldn't?
75
75
 
package/bin/install.mjs CHANGED
@@ -147,6 +147,7 @@ const INSTALL_GROUPS = {
147
147
  core: {
148
148
  description: 'Development standards, hooks, agents, commands',
149
149
  skills: [
150
+ 'team-advisor', 'team-advisor-refresh',
150
151
  'anthropic-plan', 'everything-search',
151
152
  'pr-review-responder',
152
153
  'recall', 'remember', 'task-build', 'verified-build'
@@ -4,9 +4,9 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
4
4
 
5
5
  ---
6
6
 
7
- ## COMMENT PRESERVATION (ABSOLUTE RULE)
7
+ ## COMMENT PRESERVATION
8
8
 
9
- **NEVER remove existing comments.** Existing comments are SACRED. Only evaluate comments on lines you are actively changing. Do not add new inline comments in production code — write self-documenting code. Docstrings (module/class/function) are always allowed. Test files are exempt. The hook enforces both directions: it blocks new inline comments AND blocks deletion of existing ones.
9
+ **Keep existing comments in place.** Only evaluate comments on lines you are actively changing. Do not add new inline comments in production code — write self-documenting code. Docstrings (module/class/function) are always allowed. Test files are exempt. The hook treats the two directions differently: adding a new inline comment blocks the edit, while removing an existing comment prints a stderr advisory and lets the edit through.
10
10
 
11
11
  ---
12
12
 
@@ -24,7 +24,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
24
24
  | `code_rules_banned_identifiers.py` | Banned short names (`ctx`, `cfg`, `msg`, etc.), banned prefixes (`handle_`, `process_`, etc.) |
25
25
  | `code_rules_boolean_mustcheck.py` | Boolean naming (`is_`/`has_`/… prefixes) and must-check return values |
26
26
  | `code_rules_command_dispatch.py` | A `hooks/blocking/` command classifier matching a multi-word command regex without a start anchor or first-word tokenization |
27
- | `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
27
+ | `code_rules_comments.py` | No new inline comments; advisory on deletion of existing ones |
28
28
  | `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
29
29
  | `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
30
30
  | `code_rules_dead_config_field.py` | `*Config` / `*Selectors` dataclass fields with no live references |
@@ -78,10 +78,11 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
78
78
  | `hook_prose_detector_consistency.py` | PreToolUse (Write/Edit) | Hook docstrings/messages that claim a trigger the detector cannot fire on |
79
79
  | `intent_only_ending_blocker.py` | Stop | Responses that end on a plan or intent without doing the work |
80
80
  | `open_questions_in_plans_blocker.py` | PreToolUse (Write/Edit) | Plan documents with unresolved open questions |
81
+ | `nas_ssh_binary_enforcer.py` | PreToolUse (Bash) | A bare `ssh`/`scp`/`sftp` command word targeting the NAS at `192.168.1.100` (Git Bash's MSYS ssh stalls on an interactive password prompt), or the full `System32/OpenSSH` binary to that host without `-o BatchMode=yes` |
81
82
  | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
82
83
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
83
84
  | `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
84
- | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit |
85
+ | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit, proof-shaped `gh pr comment` bodies missing proof-of-work parts, and `gh pr ready` while the PR carries no passing proof comment |
85
86
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
86
87
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
87
88
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
@@ -111,8 +112,10 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
111
112
  | `pr_description_body_audit.py` | Body audit logic for `pr_description_enforcer.py` |
112
113
  | `pr_description_command_parser.py` | `gh` command parsing for `pr_description_enforcer.py` |
113
114
  | `pr_description_pr_number.py` | PR number extraction logic |
115
+ | `pr_description_proof_of_work.py` | Proof-of-work comment audit and `gh pr ready` gate logic for `pr_description_enforcer.py` |
114
116
  | `pr_description_readability.py` | Readability checks on PR description bodies |
115
117
  | `verification_verdict_store.py` | Reads and writes verdict files under `~/.claude/verification/` |
118
+ | `verified_commit_config_bootstrap.py` | Binds `config.verified_commit_constants` to the sibling `config/` file by explicit location, so the gate family resolves its constants regardless of a foreign `config` package's `sys.path` order |
116
119
 
117
120
  ## Conventions
118
121
 
@@ -147,7 +147,7 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
147
147
 
148
148
  Inline comments (after code on same line): BLOCK when added.
149
149
  Standalone comment lines: NUDGE (print advisory) when added.
150
- Existing comments being removed: BLOCK (comment preservation principle).
150
+ Existing comments being removed: NUDGE (print advisory) — comment preservation is advisory.
151
151
 
152
152
  When the file is Python and either *old_content* or *new_content* cannot
153
153
  be tokenized (common for mid-edit Edit fragments), the comparison is
@@ -188,7 +188,7 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
188
188
  code_was_removed = new_line_count < old_line_count - len(removed_comments)
189
189
  if not code_was_removed:
190
190
  sample = next(iter(removed_comments))
191
- issues.append(f"Existing comment removed: {sample[:60]} - NEVER delete existing comments")
191
+ print(f"[CODE_RULES advisory] Existing comment removed: {sample[:60]} - comment preservation is advisory", file=sys.stderr)
192
192
 
193
193
  return issues
194
194
 
@@ -43,9 +43,11 @@ from verification_verdict_store import ( # noqa: E402
43
43
  )
44
44
 
45
45
  from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
46
+ ALL_MERGE_HEAD_PROBE_FLAGS,
46
47
  ALL_MERGE_TREE_COMMAND_FLAGS,
47
48
  ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
48
49
  ALL_UNIFIED_ZERO_DIFF_FLAGS,
50
+ ALL_UNMERGED_PATHS_DIFF_FLAGS,
49
51
  CODE_RULES_SECTION_HEADER,
50
52
  CODE_VERIFIER_SUBAGENT_TYPE,
51
53
  DENY_REASON_LEAD,
@@ -145,9 +147,49 @@ def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
145
147
  return completed_process.returncode, completed_process.stdout
146
148
 
147
149
 
150
+ def _merge_in_progress(repo_root: str) -> bool:
151
+ """Decide whether an unfinished merge is recorded in the work tree.
152
+
153
+ Args:
154
+ repo_root: The repository top-level directory.
155
+
156
+ Returns:
157
+ True when ``MERGE_HEAD`` resolves — a merge is started but not yet
158
+ committed; False when no merge is in progress or git fails.
159
+ """
160
+ return run_git(repo_root, *ALL_MERGE_HEAD_PROBE_FLAGS) is not None
161
+
162
+
163
+ def _staged_merge_conflicts(repo_root: str) -> list[str] | None:
164
+ """Return the unmerged paths of an in-progress merge from the index.
165
+
166
+ A merge whose conflicts are fully resolved and staged leaves zero unmerged
167
+ index entries, so the resolved state is committable and this returns an
168
+ empty list. A merge with conflicts still open returns those paths, so the
169
+ caller keeps denying until the resolution is staged.
170
+
171
+ Args:
172
+ repo_root: The repository top-level directory.
173
+
174
+ Returns:
175
+ The still-unmerged paths (empty when the resolution is fully staged),
176
+ or None when git fails — the caller fails OPEN on None.
177
+ """
178
+ unmerged_text = run_git(repo_root, *ALL_UNMERGED_PATHS_DIFF_FLAGS)
179
+ if unmerged_text is None:
180
+ return None
181
+ return [each_line for each_line in unmerged_text.splitlines() if each_line]
182
+
183
+
148
184
  def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
149
185
  """Return the files that conflict when HEAD trial-merges against the base.
150
186
 
187
+ While a merge is in progress the branch tip has not yet recorded the
188
+ resolution, so the trial-merge of the base against HEAD would re-report
189
+ conflicts the staged index already fixed. In that state the index is the
190
+ authority: an empty unmerged set is a committable resolution, and any
191
+ remaining unmerged path is a live conflict.
192
+
151
193
  Args:
152
194
  repo_root: The repository top-level directory.
153
195
  base_ref: The base ref to trial-merge HEAD against.
@@ -158,6 +200,8 @@ def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
158
200
  timeout, or an exit code that is neither clean nor conflict) — the
159
201
  caller fails OPEN on None.
160
202
  """
203
+ if _merge_in_progress(repo_root):
204
+ return _staged_merge_conflicts(repo_root)
161
205
  merge_outcome = _run_trial_merge(repo_root, base_ref)
162
206
  if merge_outcome is None:
163
207
  return None
@@ -86,6 +86,8 @@ TEST_FILE_SUFFIX = "_test.py"
86
86
  CONFTEST_FILE_NAME = "conftest.py"
87
87
  MINIMUM_STATUS_FIELD_COUNT = 2
88
88
  ALL_FALLBACK_BASE_REFERENCES = ("origin/main", "origin/master")
89
+ DETACHED_HEAD_LABEL = "HEAD"
90
+ BRANCH_REMOTE_CONFIG_KEY_TEMPLATE = "branch.{branch_name}.remote"
89
91
  ALL_TOOLING_STATE_PREFIXES = (
90
92
  ".claude/verification/",
91
93
  ".claude/worktrees/",
@@ -0,0 +1,115 @@
1
+ """Pytest fixture that reproduces foreign-``config`` shadowing for the gate family.
2
+
3
+ The verified-commit gate hooks import shared constants as
4
+ ``from config.verified_commit_constants import ...``. This fixture mirrors the
5
+ installed layout that breaks that import -- a real ``hooks/blocking`` package
6
+ beside a decoy ``hooks/config`` regular package -- and runs a caller-supplied
7
+ driver as a subprocess whose ``sys.path`` places the decoy ahead of
8
+ ``blocking``, the ordering under which the wrong ``config`` wins resolution.
9
+ """
10
+
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import textwrap
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ _BLOCKING_SOURCE_DIRECTORY = Path(__file__).resolve().parent
21
+ _HOOKS_CONSTANTS_SOURCE_DIRECTORY = _BLOCKING_SOURCE_DIRECTORY.parent / "hooks_constants"
22
+ _SUBPROCESS_TIMEOUT_SECONDS = 60
23
+ _MIRRORED_BLOCKING_FILE_NAMES = (
24
+ "verified_commit_gate.py",
25
+ "verification_verdict_store.py",
26
+ "verifier_verdict_minter.py",
27
+ "verdict_directory_write_blocker.py",
28
+ "verified_commit_config_bootstrap.py",
29
+ )
30
+ _DECOY_CONFIG_INIT_SOURCE = '"""Foreign config package that must never win resolution."""\n'
31
+ _DECOY_CONFIG_CONSTANTS_SOURCE = (
32
+ '"""Stale stand-in carrying none of the real gate constants."""\n\nIS_DECOY_CONFIG = True\n'
33
+ )
34
+
35
+
36
+ def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
37
+ """Mirror the hook tree with a decoy ``config`` package beside ``blocking``.
38
+
39
+ Args:
40
+ tmp_path: The directory the mirrored tree is built under.
41
+
42
+ Returns:
43
+ The mirrored ``hooks`` directory and its ``blocking`` subdirectory.
44
+ """
45
+ mirrored_hooks_directory = tmp_path / "hooks"
46
+ mirrored_blocking_directory = mirrored_hooks_directory / "blocking"
47
+ mirrored_blocking_directory.mkdir(parents=True)
48
+
49
+ for each_file_name in _MIRRORED_BLOCKING_FILE_NAMES:
50
+ source_file = _BLOCKING_SOURCE_DIRECTORY / each_file_name
51
+ if source_file.exists():
52
+ shutil.copy2(source_file, mirrored_blocking_directory / each_file_name)
53
+
54
+ shutil.copytree(
55
+ _BLOCKING_SOURCE_DIRECTORY / "config",
56
+ mirrored_blocking_directory / "config",
57
+ )
58
+ shutil.copytree(
59
+ _HOOKS_CONSTANTS_SOURCE_DIRECTORY,
60
+ mirrored_hooks_directory / "hooks_constants",
61
+ )
62
+
63
+ decoy_config_directory = mirrored_hooks_directory / "config"
64
+ decoy_config_directory.mkdir()
65
+ (decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
66
+ (decoy_config_directory / "verified_commit_constants.py").write_text(
67
+ _DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
68
+ )
69
+ return mirrored_hooks_directory, mirrored_blocking_directory
70
+
71
+
72
+ @pytest.fixture
73
+ def run_under_config_shadow(
74
+ tmp_path: Path,
75
+ ) -> Callable[[str], subprocess.CompletedProcess[str]]:
76
+ """Return a runner that executes a driver under a foreign-``config`` sys.path.
77
+
78
+ ::
79
+
80
+ completed = run_under_config_shadow("import verified_commit_gate")
81
+ ok: completed.returncode == 0 once the bootstrap seeds the real module
82
+ flag: nonzero with ModuleNotFoundError while the decoy config wins
83
+
84
+ The runner prefixes the driver with a ``sys.path`` preamble that puts the
85
+ mirrored ``hooks`` directory (holding the decoy ``config``) ahead of
86
+ ``blocking``, then runs the probe with the active interpreter.
87
+
88
+ Args:
89
+ tmp_path: Pytest's per-test temporary directory, fixture-injected.
90
+
91
+ Returns:
92
+ A function taking driver source text and returning the finished
93
+ subprocess result.
94
+ """
95
+ mirrored_hooks_directory, mirrored_blocking_directory = _build_shadowed_hook_tree(tmp_path)
96
+
97
+ def run_driver(driver_body: str) -> subprocess.CompletedProcess[str]:
98
+ probe_script = tmp_path / "probe.py"
99
+ sys_path_preamble = textwrap.dedent(
100
+ f"""\
101
+ import sys
102
+ sys.path.insert(0, {str(mirrored_blocking_directory)!r})
103
+ sys.path.insert(0, {str(mirrored_hooks_directory)!r})
104
+ """
105
+ )
106
+ probe_script.write_text(sys_path_preamble + textwrap.dedent(driver_body), encoding="utf-8")
107
+ return subprocess.run(
108
+ [sys.executable, str(probe_script)],
109
+ check=False,
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=_SUBPROCESS_TIMEOUT_SECONDS,
113
+ )
114
+
115
+ return run_driver
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: force the Windows OpenSSH binary for NAS ssh/scp/sftp commands.
3
+
4
+ Root cause: Git Bash's MSYS ssh reads ``~/.ssh/id_ed25519`` as world-readable
5
+ through its ACL mapping, rejects the key as "bad permissions", offers no key, and
6
+ falls back to an interactive password prompt that hangs unattended agent sessions.
7
+ The Windows OpenSSH binary under ``System32/OpenSSH`` authenticates the same key
8
+ without prompting.
9
+
10
+ Detection strategy: this hook fires only on the Bash tool. The command is
11
+ shlex-tokenized (quotes removed), glued shell control operators are exploded off
12
+ the tokens, and the tokens are split into simple-command segments on ``&&`` /
13
+ ``||`` / ``;`` / ``|&`` / ``|`` / ``&``. For each segment the effective leading program is
14
+ found by skipping ``VAR=value`` assignments and launcher wrappers (``timeout``,
15
+ ``nohup``, ``nice``, ``stdbuf``, ``setsid``, ``env``) with their flags and duration
16
+ arguments. A segment is evaluated only when its leading program's basename is
17
+ ``ssh``/``scp``/``sftp`` (with or without a ``.exe`` suffix) AND a token in that same
18
+ segment holds the NAS address ``192.168.1.100`` as a connection host — bare,
19
+ ``user@host``, or a ``host:path`` target. The address inside a remote-command
20
+ argument or a remote path component on another host does not count.
21
+
22
+ An evaluated segment is DENIED with the binary-swap message when its leading program
23
+ is a bare ssh-family word rather than a path ending in ``OpenSSH/ssh.exe`` (or
24
+ ``scp.exe`` / ``sftp.exe``). An evaluated segment whose leading program is that full
25
+ OpenSSH path is DENIED with the batch-mode message when no token in the segment
26
+ carries ``BatchMode=yes``, so an authentication regression fails loudly rather than
27
+ prompting. Everything else is allowed: ssh to any other host, the full OpenSSH path
28
+ with ``BatchMode=yes``, non-Bash tools, a command that only mentions the address
29
+ without an ssh-family leading program, and a command shlex cannot tokenize.
30
+ """
31
+
32
+ import json
33
+ import shlex
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
38
+ if _hooks_dir not in sys.path:
39
+ sys.path.insert(0, _hooks_dir)
40
+
41
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
+ from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
43
+ ALL_LAUNCHER_WRAPPER_COMMANDS,
44
+ ALL_OPENSSH_BINARY_PATH_SUFFIXES,
45
+ ALL_SHELL_CONTROL_OPERATOR_TOKENS,
46
+ ALL_SSH_FAMILY_COMMAND_BASENAMES,
47
+ BARE_SSH_BINARY_MESSAGE,
48
+ BASH_TOOL_NAME,
49
+ BATCH_MODE_PATTERN,
50
+ CONTROL_OPERATOR_SPLIT_PATTERN,
51
+ LAUNCHER_DURATION_PATTERN,
52
+ LEADING_ASSIGNMENT_PATTERN,
53
+ MISSING_BATCH_MODE_MESSAGE,
54
+ NAS_HOST_TOKEN_PATTERN,
55
+ )
56
+
57
+
58
+ def _token_basename(token: str) -> str:
59
+ return token.replace("\\", "/").rsplit("/", 1)[-1].lower()
60
+
61
+
62
+ def _is_openssh_binary_path(token: str) -> bool:
63
+ normalized_token = token.replace("\\", "/").lower()
64
+ return any(
65
+ normalized_token.endswith(each_suffix)
66
+ for each_suffix in ALL_OPENSSH_BINARY_PATH_SUFFIXES
67
+ )
68
+
69
+
70
+ def _split_into_segments(all_command_tokens: list[str]) -> list[list[str]]:
71
+ all_exploded_tokens: list[str] = []
72
+ for each_token in all_command_tokens:
73
+ for each_fragment in CONTROL_OPERATOR_SPLIT_PATTERN.split(each_token):
74
+ if each_fragment:
75
+ all_exploded_tokens.append(each_fragment)
76
+ all_segments: list[list[str]] = []
77
+ current_segment: list[str] = []
78
+ for each_token in all_exploded_tokens:
79
+ if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
80
+ all_segments.append(current_segment)
81
+ current_segment = []
82
+ continue
83
+ current_segment.append(each_token)
84
+ all_segments.append(current_segment)
85
+ return all_segments
86
+
87
+
88
+ def _effective_leading_program(all_segment_tokens: list[str]) -> str | None:
89
+ has_seen_launcher_wrapper = False
90
+ for each_token in all_segment_tokens:
91
+ if LEADING_ASSIGNMENT_PATTERN.match(each_token):
92
+ continue
93
+ if _token_basename(each_token) in ALL_LAUNCHER_WRAPPER_COMMANDS:
94
+ has_seen_launcher_wrapper = True
95
+ continue
96
+ if has_seen_launcher_wrapper and (
97
+ each_token.startswith("-") or LAUNCHER_DURATION_PATTERN.match(each_token)
98
+ ):
99
+ continue
100
+ return each_token
101
+ return None
102
+
103
+
104
+ def _segment_references_nas(all_segment_tokens: list[str]) -> bool:
105
+ return any(NAS_HOST_TOKEN_PATTERN.search(each_token) for each_token in all_segment_tokens)
106
+
107
+
108
+ def _segment_carries_batch_mode(all_segment_tokens: list[str]) -> bool:
109
+ return any(BATCH_MODE_PATTERN.search(each_token) for each_token in all_segment_tokens)
110
+
111
+
112
+ def _find_nas_ssh_violation(command: str) -> str | None:
113
+ """Return the deny message for a NAS ssh-family command, or None to allow.
114
+
115
+ ::
116
+
117
+ ssh -p 9222 jon@192.168.1.100 "ls" flag: bare-binary message
118
+ "…/OpenSSH/ssh.exe" -p 9222 jon@192.168… flag: missing-batch-mode message
119
+ "…/OpenSSH/ssh.exe" -o BatchMode=yes …NAS ok: None
120
+ ssh jon@example.com "ls" ok: None
121
+
122
+ Tokenizes the command, splits it into simple-command segments, and evaluates
123
+ each segment whose leading program is an ssh-family word AND whose tokens carry
124
+ the NAS address. A bare ssh-family leader returns the binary-swap message; the
125
+ full OpenSSH path leader without a ``BatchMode=yes`` token returns the batch-mode
126
+ message. Returns None when shlex cannot tokenize the command.
127
+
128
+ Args:
129
+ command: The raw Bash command string from the tool input.
130
+
131
+ Returns:
132
+ The deny message string when a segment violates the NAS ssh policy, else None.
133
+ """
134
+ try:
135
+ all_command_tokens = shlex.split(command)
136
+ except ValueError:
137
+ return None
138
+ for each_segment in _split_into_segments(all_command_tokens):
139
+ leading_program = _effective_leading_program(each_segment)
140
+ if leading_program is None:
141
+ continue
142
+ if _token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
143
+ continue
144
+ if not _segment_references_nas(each_segment):
145
+ continue
146
+ if not _is_openssh_binary_path(leading_program):
147
+ return BARE_SSH_BINARY_MESSAGE
148
+ if not _segment_carries_batch_mode(each_segment):
149
+ return MISSING_BATCH_MODE_MESSAGE
150
+ return None
151
+
152
+
153
+ def main() -> None:
154
+ try:
155
+ hook_input = json.load(sys.stdin)
156
+ except json.JSONDecodeError:
157
+ sys.exit(0)
158
+
159
+ tool_name = hook_input.get("tool_name", "")
160
+ if tool_name != BASH_TOOL_NAME:
161
+ sys.exit(0)
162
+
163
+ command = hook_input.get("tool_input", {}).get("command", "")
164
+ if not command:
165
+ sys.exit(0)
166
+
167
+ deny_reason = _find_nas_ssh_violation(command)
168
+ if deny_reason is None:
169
+ sys.exit(0)
170
+
171
+ deny_payload = {
172
+ "hookSpecificOutput": {
173
+ "hookEventName": "PreToolUse",
174
+ "permissionDecision": "deny",
175
+ "permissionDecisionReason": deny_reason,
176
+ }
177
+ }
178
+ log_hook_block(
179
+ calling_hook_name="nas_ssh_binary_enforcer.py",
180
+ hook_event="PreToolUse",
181
+ block_reason=deny_reason,
182
+ tool_name=tool_name,
183
+ offending_input_preview=command,
184
+ )
185
+ print(json.dumps(deny_payload))
186
+ sys.stdout.flush()
187
+ sys.exit(0)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()