claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,280 +1,280 @@
1
- """SubagentStop hook: mint a commit-gate verdict when code-verifier finishes.
2
-
3
- Only this hook writes verdict files — the main session is denied writes to
4
- the verdict directory, so a session cannot fabricate a passing verdict. The
5
- SubagentStop payload names the stopping subagent's own transcript
6
- (``agent_transcript_path``), which sits beside a harness-written
7
- ``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
8
- reads that type from the sidecar, so it resolves identically in interactive,
9
- background, and worktree-switched sessions; only the harness writes the
10
- sidecar, so the type it records cannot be forged. When that type is
11
- ``code-verifier``, the hook pulls the verdict block out of the agent's own
12
- transcript (``agent_transcript_path``), recomputes the live change-surface
13
- hash for the session repository, and writes the verdict bound to that hash.
14
- The companion ``verified_commit_gate.py`` (PreToolUse) then allows ``git
15
- commit`` / ``git push`` only while the work tree still matches the verified
16
- state.
17
-
18
- The verifier's final message must end with a fenced block::
19
-
20
- ```verdict
21
- {"all_pass": true, "findings": []}
22
- ```
23
-
24
- A missing or unparseable block mints nothing, which leaves the gate closed.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import json
30
- import re
31
- import sys
32
- from pathlib import Path
33
-
34
- blocking_directory = str(Path(__file__).resolve().parent)
35
- if blocking_directory not in sys.path:
36
- sys.path.insert(0, blocking_directory)
37
-
38
- from verified_commit_config_bootstrap import ( # noqa: E402
39
- register_verified_commit_constants,
40
- )
41
-
42
- register_verified_commit_constants()
43
-
44
- from config.verified_commit_constants import (
45
- MINTING_AGENT_TYPE,
46
- VERDICT_KEY_MANIFEST_SHA256,
47
- )
48
- from verification_verdict_store import (
49
- branch_surface_manifest,
50
- empty_surface_hash,
51
- manifest_sha256,
52
- resolve_merge_base,
53
- resolve_repo_root,
54
- write_verdict,
55
- )
56
-
57
-
58
- def assistant_text_blocks(transcript_path: str) -> list[str]:
59
- """Collect every assistant text block from a transcript JSONL file.
60
-
61
- Args:
62
- transcript_path: Path to the subagent's transcript.
63
-
64
- Returns:
65
- The text of each assistant content block, in transcript order;
66
- empty when the file is missing or holds no assistant text.
67
- """
68
- collected_blocks: list[str] = []
69
- try:
70
- transcript_lines = (
71
- Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
72
- )
73
- except OSError:
74
- return collected_blocks
75
- for each_line in transcript_lines:
76
- try:
77
- transcript_entry = json.loads(each_line)
78
- except json.JSONDecodeError:
79
- continue
80
- if not isinstance(transcript_entry, dict):
81
- continue
82
- if transcript_entry.get("type") != "assistant":
83
- continue
84
- message_body = transcript_entry.get("message")
85
- if not isinstance(message_body, dict):
86
- continue
87
- content_blocks = message_body.get("content")
88
- if not isinstance(content_blocks, list):
89
- continue
90
- for each_block in content_blocks:
91
- if isinstance(each_block, dict) and each_block.get("type") == "text":
92
- block_text = each_block.get("text")
93
- if isinstance(block_text, str):
94
- collected_blocks.append(block_text)
95
- return collected_blocks
96
-
97
-
98
- def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
99
- """Extract the final verdict fence from assistant text blocks.
100
-
101
- Args:
102
- all_text_blocks: Assistant text blocks in transcript order.
103
-
104
- Returns:
105
- The parsed verdict mapping carrying a boolean ``all_pass`` and a
106
- list ``findings``, or None when no block holds a wellformed fence.
107
- """
108
- verdict_fence_pattern = re.compile(r"```verdict\s*\n(.*?)```", re.DOTALL)
109
- fence_bodies: list[str] = []
110
- for each_block in all_text_blocks:
111
- fence_bodies.extend(verdict_fence_pattern.findall(each_block))
112
- for each_fence_body in reversed(fence_bodies):
113
- try:
114
- verdict_record = json.loads(each_fence_body)
115
- except json.JSONDecodeError:
116
- continue
117
- if not isinstance(verdict_record, dict):
118
- continue
119
- if not isinstance(verdict_record.get("all_pass"), bool):
120
- continue
121
- if not isinstance(verdict_record.get("findings"), list):
122
- continue
123
- return verdict_record
124
- return None
125
-
126
-
127
- def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
128
- """Read the spawning agentType from a subagent transcript's sidecar.
129
-
130
- Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
131
- ``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
132
- from this sidecar binds it to the stopping subagent itself, so it resolves
133
- identically in interactive, background, and worktree-switched sessions and
134
- needs no parent-transcript scan or flush retry. Only the harness writes this
135
- sidecar, so a session cannot forge the type it reports.
136
-
137
- Args:
138
- agent_transcript_path: The stopping subagent's own transcript path from
139
- the SubagentStop payload.
140
-
141
- Returns:
142
- The recorded ``agentType``, or None when the path is empty, the sidecar
143
- is absent, unreadable, or unparseable, it does not hold a JSON object,
144
- or it names no string ``agentType``.
145
- """
146
- if not agent_transcript_path:
147
- return None
148
- transcript_file = Path(agent_transcript_path)
149
- sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
150
- try:
151
- sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
152
- except (OSError, UnicodeDecodeError, json.JSONDecodeError):
153
- return None
154
- if not isinstance(sidecar_record, dict):
155
- return None
156
- recorded_type = sidecar_record.get("agentType")
157
- return recorded_type if isinstance(recorded_type, str) else None
158
-
159
-
160
- def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
161
- """Recover the spawning agent type for a SubagentStop payload.
162
-
163
- The stopping subagent's own transcript (``agent_transcript_path``) sits
164
- beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
165
- ``agentType``. The type comes solely from that sidecar, which only the
166
- harness writes — the main session can neither write the sidecar nor forge
167
- the agentType it records. A verdict fence in the transcript is never used to
168
- infer the type, because the main session controls the prompt of any
169
- Agent-tool subagent it spawns and could instruct one to print such a fence.
170
-
171
- Args:
172
- subagent_stop_payload: The SubagentStop hook payload.
173
-
174
- Returns:
175
- The agent type the sidecar records, or None when the
176
- ``agent_transcript_path`` is empty, the sidecar is absent, unreadable,
177
- or unparseable, it does not hold a JSON object, or it names no string
178
- ``agentType``.
179
- """
180
- return _agent_type_from_meta_sidecar(
181
- subagent_stop_payload.get("agent_transcript_path", "")
182
- )
183
-
184
-
185
- def _attested_or_recomputed_hash(verdict_record: dict, repo_root: str) -> str | None:
186
- """Choose the surface hash the minted verdict binds to.
187
-
188
- A code-verifier that verifies a work tree other than the stop event's cwd
189
- attests the surface it checked by emitting ``manifest_sha256`` in its
190
- verdict fence (computed against the verified work tree via the
191
- ``--manifest-hash`` CLI). Binding the minted verdict to that attested hash
192
- keeps the verdict tied to the code actually verified rather than the
193
- subagent's cwd, so a verdict earned for one work tree clears a commit in a
194
- sibling work tree of the same surface. When the fence attests no hash, the
195
- minter recomputes one from the cwd work tree, which is correct whenever the
196
- verifier ran in the work tree it verified.
197
-
198
- Returns None (mints nothing) in two empty-surface cases:
199
-
200
- - The attested hash equals ``empty_surface_hash()`` — the verifier called
201
- the store CLI on a wrong (empty) work tree and the hash it got back
202
- represents nothing.
203
- - The recompute branch produces an empty manifest — the cwd work tree also
204
- has no changed or untracked files versus the merge base.
205
-
206
- Args:
207
- verdict_record: The parsed verdict fence from the verifier transcript.
208
- repo_root: The work-tree root resolved from the stop event's cwd, used
209
- for the recompute fallback.
210
-
211
- Returns:
212
- The attested ``manifest_sha256`` when the fence carries a non-empty
213
- string one that is not the empty-surface sentinel; the cwd work tree's
214
- recomputed surface hash when the fence attests nothing and the surface
215
- is non-empty; or None when the attested hash is the empty-surface
216
- sentinel, the surface manifest is empty, or no upstream base resolves.
217
- """
218
- attested_manifest_sha256 = verdict_record.get(VERDICT_KEY_MANIFEST_SHA256)
219
- if isinstance(attested_manifest_sha256, str) and attested_manifest_sha256:
220
- if attested_manifest_sha256 == empty_surface_hash():
221
- return None
222
- return attested_manifest_sha256
223
- merge_base_sha = resolve_merge_base(repo_root)
224
- if merge_base_sha is None:
225
- return None
226
- surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
227
- if not surface_manifest_text:
228
- return None
229
- return manifest_sha256(surface_manifest_text)
230
-
231
-
232
- def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
233
- """Mint a verdict file for a code-verifier stop event.
234
-
235
- Args:
236
- subagent_stop_payload: The SubagentStop hook payload.
237
-
238
- Returns:
239
- The verdict file path when minted; None when the payload is not a
240
- code-verifier stop, the transcript holds no verdict, the cwd is not a
241
- work tree, or — for a verdict that attests no ``manifest_sha256`` of
242
- its own — that work tree has no upstream base to recompute the surface
243
- hash from.
244
- """
245
- if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
246
- return None
247
- agent_transcript_path = subagent_stop_payload.get("agent_transcript_path", "")
248
- if not agent_transcript_path:
249
- return None
250
- verdict_record = last_verdict_in_blocks(assistant_text_blocks(agent_transcript_path))
251
- if verdict_record is None:
252
- return None
253
- repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
254
- if repo_root is None:
255
- return None
256
- bound_manifest_sha256 = _attested_or_recomputed_hash(verdict_record, repo_root)
257
- if bound_manifest_sha256 is None:
258
- return None
259
- return write_verdict(
260
- repo_root,
261
- bound_manifest_sha256,
262
- verdict_record["all_pass"],
263
- verdict_record["findings"],
264
- str(subagent_stop_payload.get("agent_id", "")),
265
- )
266
-
267
-
268
- def main() -> None:
269
- """Read the SubagentStop payload and mint a verdict when one applies."""
270
- try:
271
- subagent_stop_payload = json.load(sys.stdin)
272
- except json.JSONDecodeError:
273
- return
274
- if not isinstance(subagent_stop_payload, dict):
275
- return
276
- mint_for_payload(subagent_stop_payload)
277
-
278
-
279
- if __name__ == "__main__":
280
- main()
1
+ """SubagentStop hook: mint a commit-gate verdict when code-verifier finishes.
2
+
3
+ Only this hook writes verdict files — the main session is denied writes to
4
+ the verdict directory, so a session cannot fabricate a passing verdict. The
5
+ SubagentStop payload names the stopping subagent's own transcript
6
+ (``agent_transcript_path``), which sits beside a harness-written
7
+ ``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
8
+ reads that type from the sidecar, so it resolves identically in interactive,
9
+ background, and worktree-switched sessions; only the harness writes the
10
+ sidecar, so the type it records cannot be forged. When that type is
11
+ ``code-verifier``, the hook pulls the verdict block out of the agent's own
12
+ transcript (``agent_transcript_path``), recomputes the live change-surface
13
+ hash for the session repository, and writes the verdict bound to that hash.
14
+ The companion ``verified_commit_gate.py`` (PreToolUse) then allows ``git
15
+ commit`` / ``git push`` only while the work tree still matches the verified
16
+ state.
17
+
18
+ The verifier's final message must end with a fenced block::
19
+
20
+ ```verdict
21
+ {"all_pass": true, "findings": []}
22
+ ```
23
+
24
+ A missing or unparseable block mints nothing, which leaves the gate closed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ blocking_directory = str(Path(__file__).resolve().parent)
35
+ if blocking_directory not in sys.path:
36
+ sys.path.insert(0, blocking_directory)
37
+
38
+ from verified_commit_config_bootstrap import ( # noqa: E402
39
+ register_verified_commit_constants,
40
+ )
41
+
42
+ register_verified_commit_constants()
43
+
44
+ from config.verified_commit_constants import (
45
+ MINTING_AGENT_TYPE,
46
+ VERDICT_KEY_MANIFEST_SHA256,
47
+ )
48
+ from verification_verdict_store import (
49
+ branch_surface_manifest,
50
+ empty_surface_hash,
51
+ manifest_sha256,
52
+ resolve_merge_base,
53
+ resolve_repo_root,
54
+ write_verdict,
55
+ )
56
+
57
+
58
+ def assistant_text_blocks(transcript_path: str) -> list[str]:
59
+ """Collect every assistant text block from a transcript JSONL file.
60
+
61
+ Args:
62
+ transcript_path: Path to the subagent's transcript.
63
+
64
+ Returns:
65
+ The text of each assistant content block, in transcript order;
66
+ empty when the file is missing or holds no assistant text.
67
+ """
68
+ collected_blocks: list[str] = []
69
+ try:
70
+ transcript_lines = (
71
+ Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
72
+ )
73
+ except OSError:
74
+ return collected_blocks
75
+ for each_line in transcript_lines:
76
+ try:
77
+ transcript_entry = json.loads(each_line)
78
+ except json.JSONDecodeError:
79
+ continue
80
+ if not isinstance(transcript_entry, dict):
81
+ continue
82
+ if transcript_entry.get("type") != "assistant":
83
+ continue
84
+ message_body = transcript_entry.get("message")
85
+ if not isinstance(message_body, dict):
86
+ continue
87
+ content_blocks = message_body.get("content")
88
+ if not isinstance(content_blocks, list):
89
+ continue
90
+ for each_block in content_blocks:
91
+ if isinstance(each_block, dict) and each_block.get("type") == "text":
92
+ block_text = each_block.get("text")
93
+ if isinstance(block_text, str):
94
+ collected_blocks.append(block_text)
95
+ return collected_blocks
96
+
97
+
98
+ def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
99
+ """Extract the final verdict fence from assistant text blocks.
100
+
101
+ Args:
102
+ all_text_blocks: Assistant text blocks in transcript order.
103
+
104
+ Returns:
105
+ The parsed verdict mapping carrying a boolean ``all_pass`` and a
106
+ list ``findings``, or None when no block holds a wellformed fence.
107
+ """
108
+ verdict_fence_pattern = re.compile(r"```verdict\s*\n(.*?)```", re.DOTALL)
109
+ fence_bodies: list[str] = []
110
+ for each_block in all_text_blocks:
111
+ fence_bodies.extend(verdict_fence_pattern.findall(each_block))
112
+ for each_fence_body in reversed(fence_bodies):
113
+ try:
114
+ verdict_record = json.loads(each_fence_body)
115
+ except json.JSONDecodeError:
116
+ continue
117
+ if not isinstance(verdict_record, dict):
118
+ continue
119
+ if not isinstance(verdict_record.get("all_pass"), bool):
120
+ continue
121
+ if not isinstance(verdict_record.get("findings"), list):
122
+ continue
123
+ return verdict_record
124
+ return None
125
+
126
+
127
+ def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
128
+ """Read the spawning agentType from a subagent transcript's sidecar.
129
+
130
+ Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
131
+ ``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
132
+ from this sidecar binds it to the stopping subagent itself, so it resolves
133
+ identically in interactive, background, and worktree-switched sessions and
134
+ needs no parent-transcript scan or flush retry. Only the harness writes this
135
+ sidecar, so a session cannot forge the type it reports.
136
+
137
+ Args:
138
+ agent_transcript_path: The stopping subagent's own transcript path from
139
+ the SubagentStop payload.
140
+
141
+ Returns:
142
+ The recorded ``agentType``, or None when the path is empty, the sidecar
143
+ is absent, unreadable, or unparseable, it does not hold a JSON object,
144
+ or it names no string ``agentType``.
145
+ """
146
+ if not agent_transcript_path:
147
+ return None
148
+ transcript_file = Path(agent_transcript_path)
149
+ sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
150
+ try:
151
+ sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
152
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
153
+ return None
154
+ if not isinstance(sidecar_record, dict):
155
+ return None
156
+ recorded_type = sidecar_record.get("agentType")
157
+ return recorded_type if isinstance(recorded_type, str) else None
158
+
159
+
160
+ def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
161
+ """Recover the spawning agent type for a SubagentStop payload.
162
+
163
+ The stopping subagent's own transcript (``agent_transcript_path``) sits
164
+ beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
165
+ ``agentType``. The type comes solely from that sidecar, which only the
166
+ harness writes — the main session can neither write the sidecar nor forge
167
+ the agentType it records. A verdict fence in the transcript is never used to
168
+ infer the type, because the main session controls the prompt of any
169
+ Agent-tool subagent it spawns and could instruct one to print such a fence.
170
+
171
+ Args:
172
+ subagent_stop_payload: The SubagentStop hook payload.
173
+
174
+ Returns:
175
+ The agent type the sidecar records, or None when the
176
+ ``agent_transcript_path`` is empty, the sidecar is absent, unreadable,
177
+ or unparseable, it does not hold a JSON object, or it names no string
178
+ ``agentType``.
179
+ """
180
+ return _agent_type_from_meta_sidecar(
181
+ subagent_stop_payload.get("agent_transcript_path", "")
182
+ )
183
+
184
+
185
+ def _attested_or_recomputed_hash(verdict_record: dict, repo_root: str) -> str | None:
186
+ """Choose the surface hash the minted verdict binds to.
187
+
188
+ A code-verifier that verifies a work tree other than the stop event's cwd
189
+ attests the surface it checked by emitting ``manifest_sha256`` in its
190
+ verdict fence (computed against the verified work tree via the
191
+ ``--manifest-hash`` CLI). Binding the minted verdict to that attested hash
192
+ keeps the verdict tied to the code actually verified rather than the
193
+ subagent's cwd, so a verdict earned for one work tree clears a commit in a
194
+ sibling work tree of the same surface. When the fence attests no hash, the
195
+ minter recomputes one from the cwd work tree, which is correct whenever the
196
+ verifier ran in the work tree it verified.
197
+
198
+ Returns None (mints nothing) in two empty-surface cases:
199
+
200
+ - The attested hash equals ``empty_surface_hash()`` — the verifier called
201
+ the store CLI on a wrong (empty) work tree and the hash it got back
202
+ represents nothing.
203
+ - The recompute branch produces an empty manifest — the cwd work tree also
204
+ has no changed or untracked files versus the merge base.
205
+
206
+ Args:
207
+ verdict_record: The parsed verdict fence from the verifier transcript.
208
+ repo_root: The work-tree root resolved from the stop event's cwd, used
209
+ for the recompute fallback.
210
+
211
+ Returns:
212
+ The attested ``manifest_sha256`` when the fence carries a non-empty
213
+ string one that is not the empty-surface sentinel; the cwd work tree's
214
+ recomputed surface hash when the fence attests nothing and the surface
215
+ is non-empty; or None when the attested hash is the empty-surface
216
+ sentinel, the surface manifest is empty, or no upstream base resolves.
217
+ """
218
+ attested_manifest_sha256 = verdict_record.get(VERDICT_KEY_MANIFEST_SHA256)
219
+ if isinstance(attested_manifest_sha256, str) and attested_manifest_sha256:
220
+ if attested_manifest_sha256 == empty_surface_hash():
221
+ return None
222
+ return attested_manifest_sha256
223
+ merge_base_sha = resolve_merge_base(repo_root)
224
+ if merge_base_sha is None:
225
+ return None
226
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
227
+ if not surface_manifest_text:
228
+ return None
229
+ return manifest_sha256(surface_manifest_text)
230
+
231
+
232
+ def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
233
+ """Mint a verdict file for a code-verifier stop event.
234
+
235
+ Args:
236
+ subagent_stop_payload: The SubagentStop hook payload.
237
+
238
+ Returns:
239
+ The verdict file path when minted; None when the payload is not a
240
+ code-verifier stop, the transcript holds no verdict, the cwd is not a
241
+ work tree, or — for a verdict that attests no ``manifest_sha256`` of
242
+ its own — that work tree has no upstream base to recompute the surface
243
+ hash from.
244
+ """
245
+ if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
246
+ return None
247
+ agent_transcript_path = subagent_stop_payload.get("agent_transcript_path", "")
248
+ if not agent_transcript_path:
249
+ return None
250
+ verdict_record = last_verdict_in_blocks(assistant_text_blocks(agent_transcript_path))
251
+ if verdict_record is None:
252
+ return None
253
+ repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
254
+ if repo_root is None:
255
+ return None
256
+ bound_manifest_sha256 = _attested_or_recomputed_hash(verdict_record, repo_root)
257
+ if bound_manifest_sha256 is None:
258
+ return None
259
+ return write_verdict(
260
+ repo_root,
261
+ bound_manifest_sha256,
262
+ verdict_record["all_pass"],
263
+ verdict_record["findings"],
264
+ str(subagent_stop_payload.get("agent_id", "")),
265
+ )
266
+
267
+
268
+ def main() -> None:
269
+ """Read the SubagentStop payload and mint a verdict when one applies."""
270
+ try:
271
+ subagent_stop_payload = json.load(sys.stdin)
272
+ except json.JSONDecodeError:
273
+ return
274
+ if not isinstance(subagent_stop_payload, dict):
275
+ return
276
+ mint_for_payload(subagent_stop_payload)
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()
@@ -542,3 +542,28 @@ def test_main_allows_deletion_push_even_when_code_review_gate_would_block(
542
542
  exit_code = pre_push.main()
543
543
 
544
544
  assert exit_code == 0
545
+
546
+
547
+ def test_main_allows_when_code_review_enforcement_flag_is_off(
548
+ tmp_path: Path,
549
+ monkeypatch: pytest.MonkeyPatch,
550
+ ) -> None:
551
+ """Native pre-push honors the real gate with enforcement off.
552
+
553
+ Points at the production push gate (not a reason stub). With the shipped
554
+ default `CODE_REVIEW_ENFORCEMENT_ENABLED = False`, the gate returns no
555
+ deny reason and the backstop allows the push — one path covering flag,
556
+ real gate load, and pre-push exit together.
557
+ """
558
+ _write_passing_code_rules_gate(tmp_path, monkeypatch)
559
+ real_gate_path = (
560
+ Path(__file__).resolve().parent.parent
561
+ / "blocking"
562
+ / "code_review_push_gate.py"
563
+ )
564
+ assert real_gate_path.is_file()
565
+ monkeypatch.setenv("CODE_REVIEW_PUSH_GATE_PATH", str(real_gate_path))
566
+
567
+ exit_code = pre_push.main()
568
+
569
+ assert exit_code == 0
package/hooks/hooks.json CHANGED
@@ -102,6 +102,16 @@
102
102
  }
103
103
  ]
104
104
  },
105
+ {
106
+ "matcher": "ScheduleWakeup|CronCreate",
107
+ "hooks": [
108
+ {
109
+ "type": "command",
110
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/orchestrator_refresh_reschedule_gate.py",
111
+ "timeout": 15
112
+ }
113
+ ]
114
+ },
105
115
  {
106
116
  "matcher": "Skill",
107
117
  "hooks": [
@@ -64,6 +64,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
64
64
  | `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
65
65
  | `python_style_checks_constants.py` | Command-line argument count and blank-line count between top-level functions for the style validator |
66
66
  | `reviewer_spawn_gate_constants.py` | Bash tool name, the sentinel marker, the Copilot and Bugbot trigger markers, the availability-script relative path and override env-var name, and the deny-message template for the reviewer-spawn gate |
67
+ | `ruff_integration_constants.py` | ``NO_COLOR`` / ``FORCE_COLOR`` environment variable names for plain ruff diagnostics |
67
68
  | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
68
69
  | `sensitive_file_protector_constants.py` | Sensitive filename patterns, the committed-template suffixes that earn an exemption, the write/edit tool names, and the deny decision and message template for `sensitive_file_protector` |
69
70
  | `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
@@ -90,6 +91,6 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
90
91
  - Most files in this package are pure constants modules — no side effects, no I/O.
91
92
  - Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
92
93
  - Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
93
- - `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, `hosted_hook_runner.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
94
+ - `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, `hosted_hook_runner.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they hold logic shared across hook surfaces.
94
95
  - `local_identity.py` is a loader: it reads the environment or `~/.claude/local-identity.json` (its path overridable via `CLAUDE_LOCAL_IDENTITY_PATH`) to resolve private NAS values (with committed placeholder defaults), the PII commit-scan exempt-repo slug set (`CLAUDE_PII_EXEMPT_REPOS` / `pii_exempt_repositories`), and the per-repository allowlisted-values mapping (`pii_allowlisted_values`).
95
96