claude-dev-env 2.0.1 → 2.1.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 (127) hide show
  1. package/CLAUDE.md +1 -1
  2. package/agents/clean-coder.md +31 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  4. package/bin/install.test.mjs +2 -2
  5. package/docs/CLAUDE.md +6 -3
  6. package/docs/CODE_RULES.md +5 -1
  7. package/docs/agent-spawn-protocol.md +39 -0
  8. package/docs/nas-ssh-invocation.md +23 -0
  9. package/docs/worker-completion-gate.md +33 -0
  10. package/hooks/blocking/CLAUDE.md +1 -0
  11. package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
  12. package/hooks/blocking/code_rules_shared.py +126 -47
  13. package/hooks/blocking/config/CLAUDE.md +2 -0
  14. package/hooks/blocking/config/verified_commit_constants.py +8 -1
  15. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  16. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  17. package/hooks/blocking/conftest.py +36 -30
  18. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  19. package/hooks/blocking/plain_language_blocker.py +8 -0
  20. package/hooks/blocking/state_description_blocker.py +4 -1
  21. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
  23. package/hooks/blocking/test_code_rules_shared.py +120 -20
  24. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  25. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  26. package/hooks/blocking/test_state_description_blocker.py +15 -0
  27. package/hooks/blocking/test_verification_verdict_store.py +12 -0
  28. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  29. package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
  30. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  31. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
  32. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  33. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  34. package/hooks/blocking/verified_commit_gate.py +113 -568
  35. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  36. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  37. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  38. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  39. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  40. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  41. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  42. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  43. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  44. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  45. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  46. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  47. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  48. package/hooks/hooks_constants/CLAUDE.md +4 -1
  49. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  50. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  51. package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
  52. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  53. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  54. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  55. package/hooks/validators/mypy_integration.py +145 -24
  56. package/hooks/validators/python_antipattern_checks.py +16 -0
  57. package/hooks/validators/run_all_validators.py +9 -3
  58. package/hooks/validators/test_mypy_integration.py +154 -0
  59. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  60. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  61. package/package.json +1 -1
  62. package/rules/CLAUDE.md +3 -3
  63. package/rules/agent-spawn-protocol.md +5 -43
  64. package/rules/code-standards.md +1 -36
  65. package/rules/env-var-table-code-drift.md +2 -21
  66. package/rules/hook-prose-matches-detector.md +5 -16
  67. package/rules/nas-ssh-invocation.md +3 -15
  68. package/rules/no-historical-clutter.md +7 -49
  69. package/rules/no-inline-destructive-literals.md +3 -5
  70. package/rules/package-inventory-stale-entry.md +7 -32
  71. package/rules/re-stage-before-commit.md +6 -23
  72. package/rules/shell-invocation-policy.md +1 -1
  73. package/rules/vault-context.md +3 -3
  74. package/rules/verified-commit-gate-skip.md +28 -0
  75. package/rules/workers-done-before-complete.md +2 -30
  76. package/scripts/claude_chain_runner.py +39 -3
  77. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
  78. package/scripts/test_claude_chain_runner.py +112 -0
  79. package/scripts/test_grok_headless_runner.py +0 -1
  80. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  81. package/skills/auditing-claude-config/SKILL.md +114 -176
  82. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  83. package/skills/autoconverge/CLAUDE.md +1 -0
  84. package/skills/autoconverge/SKILL.md +310 -346
  85. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  86. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  87. package/skills/closeout/SKILL.md +7 -9
  88. package/skills/copilot-finding-triage/SKILL.md +5 -7
  89. package/skills/copilot-review/CLAUDE.md +3 -2
  90. package/skills/copilot-review/SKILL.md +119 -155
  91. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  92. package/skills/everything-search/SKILL.md +7 -1
  93. package/skills/fresh-branch/CLAUDE.md +6 -9
  94. package/skills/fresh-branch/SKILL.md +84 -33
  95. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  96. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  97. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  98. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  99. package/skills/orchestrator/SKILL.md +161 -147
  100. package/skills/pr-converge/SKILL.md +277 -422
  101. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  102. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  103. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  104. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  105. package/skills/privacy-hygiene/SKILL.md +68 -115
  106. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  107. package/skills/session-log/CLAUDE.md +2 -1
  108. package/skills/session-log/SKILL.md +4 -26
  109. package/skills/session-log/templates/frontmatter.md +40 -0
  110. package/skills/skill-builder/CLAUDE.md +8 -7
  111. package/skills/skill-builder/SKILL.md +26 -11
  112. package/skills/skill-builder/references/CLAUDE.md +3 -1
  113. package/skills/skill-builder/references/delegation-map.md +21 -12
  114. package/skills/skill-builder/references/description-field.md +9 -11
  115. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  116. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  117. package/skills/skill-builder/references/skill-modularity.md +8 -9
  118. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  119. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  120. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  121. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  122. package/skills/skill-builder/workflows/new-skill.md +23 -15
  123. package/skills/skill-builder/workflows/polish-skill.md +28 -21
  124. package/system-prompts/CLAUDE.md +3 -3
  125. package/docs/agents-md-alignment-plan.md +0 -123
  126. package/docs/emotion-informed-prompt-design.md +0 -362
  127. package/rules/es-exe-file-search.md +0 -17
@@ -0,0 +1,28 @@
1
+ # verified_commit_gate_parts
2
+
3
+ The concern modules `verified_commit_gate.py` wires together to run the
4
+ verified-commit gate. Each module owns one concern; the entry hook imports
5
+ them and re-exports their surface for the test suite.
6
+
7
+ ## Modules
8
+
9
+ | File | Purpose |
10
+ |---|---|
11
+ | `command_tokenization.py` | Quote-aware tokenizing of a shell command: line-continuation collapse, quoted-span detection, and deciding whether a `git` word is a real invocation |
12
+ | `directory_resolution.py` | Resolves the active directory a `cd`/`pushd` verb leaves the shell in, and reads a directory-change verb's destination |
13
+ | `gated_invocations.py` | Finds every gated `git commit`/`git push` in a command and the directory each one targets |
14
+ | `deny_reason.py` | Decides whether a commit/push in a directory needs a passing verdict |
15
+ | `deny_payload.py` | Builds the PreToolUse deny payload, including the verify-skip context that goes with it |
16
+ | `__init__.py` | Package marker |
17
+
18
+ ## Subdirectory
19
+
20
+ | Entry | Description |
21
+ |---|---|
22
+ | `tests/` | pytest suite with one test module per module above |
23
+
24
+ ## Running tests
25
+
26
+ ```bash
27
+ python -m pytest packages/claude-dev-env/hooks/blocking/verified_commit_gate_parts/tests/
28
+ ```
@@ -0,0 +1 @@
1
+ """Concern modules of ``verified_commit_gate_parts``, wired by the entry hook."""
@@ -0,0 +1,174 @@
1
+ """Quote-aware tokenizing of a shell command for the verified-commit gate.
2
+
3
+ ::
4
+
5
+ echo "Next: git commit" -> the quoted "git" is prose, not gated
6
+ "git" commit -> the quoted "git" is a wrapper-quoted binary, gated
7
+
8
+ The gate reads a raw command string, so it needs its own lightweight
9
+ quote-awareness to tell a real ``git`` invocation from a ``git`` word sitting
10
+ inside a quoted commit message, a ``gh pr comment`` body, or an ``echo``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from config.verified_commit_constants import ALL_GIT_BINARY_NAMES
18
+
19
+
20
+ def collapse_line_continuations(command_text: str) -> str:
21
+ """Remove backslash-newline line continuations the shell would erase.
22
+
23
+ Bash joins a line continuation by deleting both characters, so
24
+ ``git \\<newline>commit`` runs as a plain ``git commit``. Stripping the
25
+ pair before tokenizing makes the token stream match what the shell
26
+ executes.
27
+
28
+ Args:
29
+ command_text: The raw command string from the tool payload.
30
+
31
+ Returns:
32
+ The command with every backslash-newline pair removed.
33
+ """
34
+ return re.sub(r"\\\r?\n", "", command_text)
35
+
36
+
37
+ def quoted_spans(command_text: str) -> list[tuple[int, int]]:
38
+ """Find the character spans of every quoted region in a command.
39
+
40
+ Scans single- and double-quoted runs left to right so a verb sitting
41
+ inside a quoted ``-m`` commit message is recognised as message text
42
+ rather than a real shell directory change.
43
+
44
+ Args:
45
+ command_text: The raw command string from the tool payload.
46
+
47
+ Returns:
48
+ The ``(start, end)`` span of each quoted region, in order.
49
+ """
50
+ quoted_region_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
51
+ return [
52
+ (each_match.start(), each_match.end())
53
+ for each_match in quoted_region_pattern.finditer(command_text)
54
+ ]
55
+
56
+
57
+ def is_inside_quoted_region(position: int, all_quoted_spans: list[tuple[int, int]]) -> bool:
58
+ """Decide whether a position falls inside any quoted region.
59
+
60
+ Args:
61
+ position: A character offset into the command string.
62
+ all_quoted_spans: The quoted-region spans from ``quoted_spans``.
63
+
64
+ Returns:
65
+ True when the offset sits within a quoted region's bounds.
66
+ """
67
+ for each_span_start, each_span_end in all_quoted_spans:
68
+ if each_span_start <= position < each_span_end:
69
+ return True
70
+ return False
71
+
72
+
73
+ def containing_quoted_span(
74
+ position: int, all_quoted_spans: list[tuple[int, int]]
75
+ ) -> tuple[int, int] | None:
76
+ """Return the quoted region a position falls inside, or None.
77
+
78
+ Args:
79
+ position: A character offset into the command string.
80
+ all_quoted_spans: The quoted-region spans from ``quoted_spans``.
81
+
82
+ Returns:
83
+ The ``(start, end)`` span containing the offset, or None when the
84
+ offset sits outside every quoted region.
85
+ """
86
+ for each_span_start, each_span_end in all_quoted_spans:
87
+ if each_span_start <= position < each_span_end:
88
+ return (each_span_start, each_span_end)
89
+ return None
90
+
91
+
92
+ def command_carries_trailing_marker(command_text: str, marker: str) -> bool:
93
+ """Decide whether a marker appears as a real trailing shell comment.
94
+
95
+ ::
96
+
97
+ git commit -m "wire up" # verify-skip -> True (real comment)
98
+ git commit -m "the # verify-skip hatch" -> False (inside quotes)
99
+
100
+ The marker counts only when its leading ``#`` sits at a word boundary and
101
+ outside every quoted region, so a data-only mention does not trip it.
102
+
103
+ Args:
104
+ command_text: The raw command string from the tool payload.
105
+ marker: The comment marker to detect (for example ``# verify-skip``).
106
+
107
+ Returns:
108
+ True when the marker begins a real trailing comment outside quotes.
109
+ """
110
+ all_quoted_spans = quoted_spans(command_text)
111
+ search_start = 0
112
+ while True:
113
+ marker_index = command_text.find(marker, search_start)
114
+ if marker_index < 0:
115
+ return False
116
+ preceding_character = command_text[marker_index - 1] if marker_index > 0 else ""
117
+ begins_at_word_boundary = marker_index == 0 or preceding_character.isspace()
118
+ is_outside_quotes = not is_inside_quoted_region(marker_index, all_quoted_spans)
119
+ if begins_at_word_boundary and is_outside_quotes:
120
+ return True
121
+ search_start = marker_index + 1
122
+
123
+
124
+ def strip_token_quotes(token_text: str) -> str:
125
+ """Remove quote characters from a token's edges.
126
+
127
+ Tokens cut from inside a quoted shell-wrapper argument can carry an
128
+ unpaired edge quote (``push"``), so both edges are stripped rather
129
+ than only matched pairs.
130
+
131
+ Args:
132
+ token_text: One quote-aware token from a command string.
133
+
134
+ Returns:
135
+ The token without leading or trailing quote characters.
136
+ """
137
+ return token_text.strip("\"'")
138
+
139
+
140
+ def _quoted_final_path_segment(command_text: str, all_quoted_span_bounds: tuple[int, int]) -> str:
141
+ """Read the lowercased final path segment of a quoted region's content."""
142
+ span_start, span_end = all_quoted_span_bounds
143
+ quoted_content = strip_token_quotes(command_text[span_start:span_end])
144
+ return re.split(r"[\\/]", quoted_content)[-1].lower()
145
+
146
+
147
+ def git_word_match_gates(
148
+ git_word_match: re.Match[str],
149
+ command_text: str,
150
+ all_quoted_spans: list[tuple[int, int]],
151
+ ) -> bool:
152
+ """Decide whether a ``git`` word match counts as a real invocation.
153
+
154
+ ::
155
+
156
+ echo "Next: git commit" -> quoted prose, not gated
157
+ "git" commit -> quoted binary path, gated
158
+
159
+ A ``git`` word outside every quoted region always gates. Inside a quoted
160
+ region it gates only when the region's final ``[\\/]``-split path segment
161
+ is ``git`` or ``git.exe``.
162
+
163
+ Args:
164
+ git_word_match: A ``git`` word match in the command.
165
+ command_text: The raw command string from the tool payload.
166
+ all_quoted_spans: The quoted-region spans from ``quoted_spans``.
167
+
168
+ Returns:
169
+ True when the matched ``git`` word counts as a real git invocation.
170
+ """
171
+ containing_span = containing_quoted_span(git_word_match.start(), all_quoted_spans)
172
+ if containing_span is None:
173
+ return True
174
+ return _quoted_final_path_segment(command_text, containing_span) in ALL_GIT_BINARY_NAMES
@@ -0,0 +1,53 @@
1
+ """Build the PreToolUse deny payload the verified-commit gate emits.
2
+
3
+ ::
4
+
5
+ deny_reason = "BLOCKED: needs a verdict"
6
+ build_deny_payload(deny_reason)["hookSpecificOutput"]["permissionDecision"]
7
+ -> "deny"
8
+
9
+ The payload carries the deny decision and reason next to
10
+ ``VERIFY_SKIP_ADDITIONAL_CONTEXT``, so the blocked agent learns at the moment
11
+ of the block when the ``# verify-skip`` marker is legitimate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TypedDict
17
+
18
+ from config.verified_commit_context_constants import VERIFY_SKIP_ADDITIONAL_CONTEXT
19
+ from config.verified_commit_gate_output_constants import (
20
+ DENY_PERMISSION_DECISION,
21
+ PRE_TOOL_USE_HOOK_EVENT_NAME,
22
+ )
23
+
24
+
25
+ class _HookSpecificOutput(TypedDict):
26
+ hookEventName: str
27
+ permissionDecision: str
28
+ permissionDecisionReason: str
29
+ additionalContext: str
30
+
31
+
32
+ class DenyPayload(TypedDict):
33
+ hookSpecificOutput: _HookSpecificOutput
34
+
35
+
36
+ def build_deny_payload(deny_reason: str) -> DenyPayload:
37
+ """Build the PreToolUse deny payload for a blocked commit/push.
38
+
39
+ Args:
40
+ deny_reason: The corrective message naming why the command is denied.
41
+
42
+ Returns:
43
+ The ``hookSpecificOutput`` deny payload, including the verify-skip
44
+ additional context.
45
+ """
46
+ return {
47
+ "hookSpecificOutput": {
48
+ "hookEventName": PRE_TOOL_USE_HOOK_EVENT_NAME,
49
+ "permissionDecision": DENY_PERMISSION_DECISION,
50
+ "permissionDecisionReason": deny_reason,
51
+ "additionalContext": VERIFY_SKIP_ADDITIONAL_CONTEXT,
52
+ }
53
+ }
@@ -0,0 +1,80 @@
1
+ """Decide whether a commit/push in a directory needs a passing verdict.
2
+
3
+ ::
4
+
5
+ branch has no upstream base -> allow (nothing to verify against)
6
+ diff is docs/tests/AST-unchanged -> allow (mechanically exempt)
7
+ a minted or workflow verdict binds -> allow
8
+ otherwise -> deny, quoting the surface hash
9
+
10
+ A verdict binds by the content hash of the live change surface, not by
11
+ work-tree location, so a verdict minted while verifying any work tree of the
12
+ same surface clears the commit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from config.verified_commit_constants import CORRECTIVE_MESSAGE, HASH_PREVIEW_LENGTH
18
+ from verification_verdict_store import (
19
+ branch_surface_manifest,
20
+ is_verification_exempt_diff,
21
+ load_valid_verdict,
22
+ manifest_sha256,
23
+ minted_verdict_covers_surface,
24
+ resolve_merge_base,
25
+ resolve_repo_root,
26
+ workflow_verdict_covers_surface,
27
+ )
28
+
29
+
30
+ def _resolve_repo_and_base(target_directory: str) -> tuple[str, str] | None:
31
+ """Resolve the repo root and merge-base for a directory, or None."""
32
+ repo_root = resolve_repo_root(target_directory)
33
+ if repo_root is None:
34
+ return None
35
+ merge_base_sha = resolve_merge_base(repo_root)
36
+ if merge_base_sha is None:
37
+ return None
38
+ return repo_root, merge_base_sha
39
+
40
+
41
+ def _verdict_covers_surface(
42
+ repo_root: str, live_manifest_sha256: str, transcript_path: str
43
+ ) -> bool:
44
+ """Decide whether any passing verdict already binds to the live surface.
45
+
46
+ A workflow-spawned code-verifier's own transcript covers the workflow
47
+ case, where SubagentStop never fires to mint a verdict file.
48
+ """
49
+ if load_valid_verdict(repo_root, live_manifest_sha256) is not None:
50
+ return True
51
+ if minted_verdict_covers_surface(live_manifest_sha256):
52
+ return True
53
+ return workflow_verdict_covers_surface(transcript_path, live_manifest_sha256)
54
+
55
+
56
+ def deny_reason_for_directory(target_directory: str, transcript_path: str) -> str | None:
57
+ """Decide whether a commit/push in a directory must be blocked.
58
+
59
+ Args:
60
+ target_directory: The directory the git command targets.
61
+ transcript_path: The live session's transcript path from the payload.
62
+
63
+ Returns:
64
+ The deny reason when the branch diff needs a verdict and none binds
65
+ to it; None when the command may proceed.
66
+ """
67
+ repo_and_base = _resolve_repo_and_base(target_directory)
68
+ if repo_and_base is None:
69
+ return None
70
+ repo_root, merge_base_sha = repo_and_base
71
+ if is_verification_exempt_diff(repo_root, merge_base_sha):
72
+ return None
73
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
74
+ if surface_manifest_text is None:
75
+ return f"{CORRECTIVE_MESSAGE} (surface manifest failed in {repo_root})"
76
+ live_manifest_sha256 = manifest_sha256(surface_manifest_text)
77
+ if _verdict_covers_surface(repo_root, live_manifest_sha256, transcript_path):
78
+ return None
79
+ hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
80
+ return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
@@ -0,0 +1,170 @@
1
+ """Resolve the active directory a ``cd``/``pushd`` verb leaves the shell in.
2
+
3
+ ::
4
+
5
+ cd subdir && git commit -> commit gates against session_dir/subdir
6
+ cd /repo && git commit -> commit gates against /repo
7
+
8
+ A gated git invocation with no ``-C``/``--work-tree`` flag runs in whatever
9
+ directory the shell is in at that point in the command, so the gate walks
10
+ each directory-change verb to know the directory the following commit
11
+ actually targets.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+
19
+ from config.verified_commit_constants import (
20
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
21
+ DIRECTORY_CHANGE_PATH_OPTIONS,
22
+ )
23
+ from verified_commit_gate_parts.command_tokenization import strip_token_quotes
24
+
25
+
26
+ def split_option_value(option_token: str) -> tuple[str, str | None]:
27
+ """Split a ``--name=value`` option token into its name and value.
28
+
29
+ Args:
30
+ option_token: One quote-stripped token after the ``git`` word.
31
+
32
+ Returns:
33
+ The option name and its attached value, or the whole token and None
34
+ when the token carries no ``=`` value.
35
+ """
36
+ if option_token.startswith("--") and "=" in option_token:
37
+ option_name, _, attached_value = option_token.partition("=")
38
+ return (option_name, attached_value)
39
+ return (option_token, None)
40
+
41
+
42
+ def value_after_option(all_following_tokens: list[str], option_index: int) -> str | None:
43
+ """Read the separate value token that follows a value-taking option.
44
+
45
+ Args:
46
+ all_following_tokens: Quote-stripped tokens after the ``git`` word.
47
+ option_index: Index of the value-taking option token.
48
+
49
+ Returns:
50
+ The next token when one exists, or None at the end of the tokens.
51
+ """
52
+ if option_index + 1 < len(all_following_tokens):
53
+ return all_following_tokens[option_index + 1]
54
+ return None
55
+
56
+
57
+ def expand_home_prefix(directory_token: str) -> str:
58
+ """Expand a leading ``~`` to the home directory the shell would use.
59
+
60
+ Git does not expand ``~`` for ``-C`` or ``--work-tree`` and never sees a
61
+ shell's ``cd ~`` expansion, so the gate expands the token itself;
62
+ otherwise it resolves a non-existent ``~/...`` path that git rejects while
63
+ the shell commits in the real home-anchored repo.
64
+
65
+ Args:
66
+ directory_token: A directory token that may start with ``~``.
67
+
68
+ Returns:
69
+ The token with any leading home prefix expanded, unchanged otherwise.
70
+ """
71
+ if directory_token.startswith("~"):
72
+ return os.path.expanduser(directory_token)
73
+ return directory_token
74
+
75
+
76
+ def is_absolute_directory(directory_token: str) -> bool:
77
+ """Decide whether a directory-change target is already absolute.
78
+
79
+ Treats a POSIX root, a Windows drive or UNC root, a leading slash or
80
+ backslash, and a home-relative ``~`` token as absolute so they are used
81
+ as given rather than joined onto the active directory.
82
+
83
+ Args:
84
+ directory_token: The destination of a directory-change verb.
85
+
86
+ Returns:
87
+ True when the token names an absolute or home-anchored location.
88
+ """
89
+ if directory_token.startswith("~"):
90
+ return True
91
+ if directory_token.startswith(("/", "\\")):
92
+ return True
93
+ return os.path.isabs(directory_token)
94
+
95
+
96
+ def resolve_against(active_directory: str, changed_directory: str) -> str:
97
+ """Resolve a directory-change target against the active directory.
98
+
99
+ An absolute or home-anchored target becomes the new active directory; a
100
+ relative target is joined onto it so a ``cd subdir`` gates against the
101
+ session directory's subdirectory rather than a token git would resolve
102
+ against the hook process's own working directory.
103
+
104
+ Args:
105
+ active_directory: The directory in effect before this change.
106
+ changed_directory: The destination of a directory-change verb.
107
+
108
+ Returns:
109
+ The directory the shell runs in after the change.
110
+ """
111
+ if is_absolute_directory(changed_directory):
112
+ return expand_home_prefix(changed_directory)
113
+ return os.path.join(active_directory, changed_directory)
114
+
115
+
116
+ def argument_tokens_after_verb(command_text: str, match_end: int) -> list[str]:
117
+ """Cut the run of argument tokens that follows a directory-change verb.
118
+
119
+ Reads tokens until the first shell command separator (``;``, ``&``,
120
+ ``|``, or a newline), so only the verb's own arguments are returned and a
121
+ following command is left untouched.
122
+
123
+ Args:
124
+ command_text: The raw command string from the tool payload.
125
+ match_end: The offset just past the directory-change verb word.
126
+
127
+ Returns:
128
+ The quote-aware argument tokens following the verb, in order.
129
+ """
130
+ argument_run_pattern = re.compile(r"[ \t]+((?:\"[^\"]*\"|'[^']*'|[^\s;&|])+)")
131
+ argument_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|[^\s;&|]+")
132
+ all_argument_tokens: list[str] = []
133
+ scan_position = match_end
134
+ while True:
135
+ run_match = argument_run_pattern.match(command_text, scan_position)
136
+ if run_match is None:
137
+ return all_argument_tokens
138
+ all_argument_tokens.extend(argument_token_pattern.findall(run_match.group(1)))
139
+ scan_position = run_match.end()
140
+
141
+
142
+ def directory_change_target(command_text: str, match_end: int) -> str | None:
143
+ """Read the destination of a directory-change verb.
144
+
145
+ Walks the arguments after the verb, skipping a leading ``--`` terminator
146
+ and consuming the value after a PowerShell path option
147
+ (``-Path``/``-LiteralPath``) so the destination is the path rather than
148
+ the flag. A leading shell operator (``cd && git ...``) means no argument
149
+ and the active directory stays unchanged.
150
+
151
+ Args:
152
+ command_text: The raw command string from the tool payload.
153
+ match_end: The offset just past the directory-change verb word.
154
+
155
+ Returns:
156
+ The destination path when one follows the verb, or None for a bare
157
+ ``cd`` (a return to the home directory, which the gate ignores).
158
+ """
159
+ all_argument_tokens = argument_tokens_after_verb(command_text, match_end)
160
+ token_index = 0
161
+ while token_index < len(all_argument_tokens):
162
+ each_token = strip_token_quotes(all_argument_tokens[token_index])
163
+ if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
164
+ token_index += 1
165
+ continue
166
+ if each_token.lower() in DIRECTORY_CHANGE_PATH_OPTIONS:
167
+ token_index += 1
168
+ continue
169
+ return each_token
170
+ return None