claude-dev-env 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/verified-commit-gate-skip.md +28 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +6 -3
- package/skills/autoconverge/SKILL.md +421 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +21 -11
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +469 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
package/CLAUDE.md
CHANGED
|
@@ -43,7 +43,7 @@ Run every multi-step code task in two phases:
|
|
|
43
43
|
1. **Coders** — one coder agent per scoped assignment writes the code. A coder that hits a decision it can't reasonably solve consults the advisor (see beginning of this file).
|
|
44
44
|
2. **Verification** — when the coders finish, the main session spawns the `code-verifier` agent in a fresh context, but you must first verify that their work is based on upstream's origin main (aka: the commit live on github). It derives and runs the checks itself rather than trusting coder reports: the task's named gates, tests against baselines recorded before the coders ran, and a two-way diff-vs-assignment reading (every task item maps to a hunk, every hunk maps to a task item, nothing missing). A finding must cite a failing command or a named task item. Before it emits the verdict, it puts the draft through one strongest-tier validation subagent — selected per the advisor protocol's host detection and tier ladder — that tries to refute it, and it re-checks and corrects any part the validator overturns. Source: the fresh-context review step in Claude Code best practices (https://code.claude.com/docs/en/best-practices) — the agent doing the work isn't the one grading it.
|
|
45
45
|
|
|
46
|
-
Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff.
|
|
46
|
+
Repair agents run only on reported findings; the verifier re-checks after each repair. Work lands (commit, push, draft PR) only on a clean verdict — enforced by the `verified_commit_gate` hook, which blocks `git commit`/`git push` unless a hook-minted verdict covers the current branch diff. One exemption is mechanical, not discretionary: a diff whose every changed file is non-code or has an unchanged Python AST once docstrings are stripped (docs, docstrings, comments). One escape hatch is manual and narrow: appending `# verify-skip` as a trailing shell comment (outside every quoted region) to the blocked commit or push command bypasses the gate for that one command — allowed only when the branch surface is the same code a code-verifier already passed clean and the gate is blocking on a verdict that doesn't cover it (an unminted verdict, staging churn, a reverted concurrent write); any real code change since the clean verdict runs a fresh verification instead. Full rule: `~/.claude/rules/verified-commit-gate-skip.md`.
|
|
47
47
|
|
|
48
48
|
## Sub-agent Output Validation
|
|
49
49
|
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@ PreToolUse hooks that deny (block) tool calls when a rule is violated. The main
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `config/` | Shared constants for the verified-commit gate family (`verified_commit_constants.py`) |
|
|
10
10
|
| `tdd_enforcer_parts/` | Concern modules the `tdd_enforcer.py` entry hook wires together: path classification, content analysis, candidate-path resolution, freshness, git-tracking restore detection, decisions, and constants |
|
|
11
|
+
| `verified_commit_gate_parts/` | Concern modules the `verified_commit_gate.py` entry hook wires together: command tokenization, directory-change resolution, gated git-invocation resolution, deny-reason resolution, and deny-payload assembly |
|
|
11
12
|
| `claude_md_orphan_file_blocker_parts/` | Concern modules the `claude_md_orphan_file_blocker.py` entry hook wires together: reference extraction, subtree scan, scan plan, decision, and constants |
|
|
12
13
|
| `package_inventory_stale_blocker_parts/` | Concern modules the `package_inventory_stale_blocker.py` entry hook wires together: inventory detection, decision, and constants |
|
|
13
14
|
| `inventory_intent_records/` | The shared per-session pending-intent store both inventory blockers read to break the file/row add-order deadlock |
|
|
@@ -31,11 +31,11 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
31
31
|
STRICT_TEST_FILE_BASENAME_PATTERN,
|
|
32
32
|
)
|
|
33
33
|
from hooks_constants.harness_scratchpad_constants import ( # noqa: E402
|
|
34
|
+
CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME,
|
|
34
35
|
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
35
|
-
|
|
36
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_NAME,
|
|
36
37
|
HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX,
|
|
37
38
|
HOOK_PAYLOAD_SESSION_ID_KEY,
|
|
38
|
-
HOOK_PAYLOAD_WORKING_DIRECTORY_KEY,
|
|
39
39
|
)
|
|
40
40
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
41
41
|
TYPE_CHECKING_IDENTIFIER,
|
|
@@ -275,78 +275,157 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
275
275
|
return False
|
|
276
276
|
|
|
277
277
|
|
|
278
|
-
def
|
|
279
|
-
"""
|
|
278
|
+
def _session_id_for_scratchpad(hook_payload: dict) -> str:
|
|
279
|
+
"""Return the session id from the payload, or the harness environment value.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
hook_payload: The PreToolUse payload that may carry ``session_id``.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The payload session id when present, otherwise the
|
|
286
|
+
``CLAUDE_CODE_SESSION_ID`` environment value, or an empty string when
|
|
287
|
+
neither source carries one.
|
|
288
|
+
"""
|
|
289
|
+
payload_session_id = hook_payload.get(HOOK_PAYLOAD_SESSION_ID_KEY, "")
|
|
290
|
+
if isinstance(payload_session_id, str) and payload_session_id:
|
|
291
|
+
return payload_session_id
|
|
292
|
+
return os.environ.get(CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME, "")
|
|
280
293
|
|
|
281
|
-
::
|
|
282
294
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<tempdir>/claude-<uid>/-home-user-project/5f2c.../scratchpad
|
|
286
|
-
| |
|
|
287
|
-
os.getuid() cwd with each "/" turned to "-"
|
|
295
|
+
def _is_harness_user_directory(directory_name: str) -> bool:
|
|
296
|
+
"""Return whether a path component is the harness user directory.
|
|
288
297
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
fields the harness puts in every PreToolUse payload. The rebuilt directory
|
|
292
|
-
is returned only when it exists on disk, so a wrong guess or a non-POSIX
|
|
293
|
-
platform yields None and the gates keep full enforcement.
|
|
298
|
+
The harness names this directory ``claude`` on Windows and ``claude-<uid>``
|
|
299
|
+
on POSIX, so both the exact name and the prefixed form count.
|
|
294
300
|
|
|
295
301
|
Args:
|
|
296
|
-
|
|
302
|
+
directory_name: A single path component below the temp-directory root.
|
|
297
303
|
|
|
298
304
|
Returns:
|
|
299
|
-
|
|
305
|
+
True when the component is the harness user directory.
|
|
300
306
|
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
session_id = hook_payload.get(HOOK_PAYLOAD_SESSION_ID_KEY, "")
|
|
305
|
-
working_directory = hook_payload.get(HOOK_PAYLOAD_WORKING_DIRECTORY_KEY, "")
|
|
306
|
-
if not isinstance(session_id, str) or not session_id:
|
|
307
|
-
return None
|
|
308
|
-
if not isinstance(working_directory, str) or not working_directory:
|
|
309
|
-
return None
|
|
310
|
-
mangled_working_directory = working_directory.replace("\\", "/").replace(
|
|
311
|
-
"/", HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT
|
|
312
|
-
)
|
|
313
|
-
user_directory_name = f"{HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX}{get_user_id()}"
|
|
314
|
-
scratchpad_root = os.path.join(
|
|
315
|
-
tempfile.gettempdir(),
|
|
316
|
-
user_directory_name,
|
|
317
|
-
mangled_working_directory,
|
|
318
|
-
session_id,
|
|
319
|
-
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
307
|
+
return (
|
|
308
|
+
directory_name == HARNESS_SCRATCHPAD_USER_DIRECTORY_NAME
|
|
309
|
+
or directory_name.startswith(HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX)
|
|
320
310
|
)
|
|
321
|
-
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _relative_parts_under_temp_root(
|
|
314
|
+
real_target: str, real_temp_root: str
|
|
315
|
+
) -> tuple[str, ...]:
|
|
316
|
+
"""Return real_target's path components below real_temp_root, or empty when outside.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
real_target: The resolved candidate path.
|
|
320
|
+
real_temp_root: The resolved temp-directory root.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
The path components between the temp root and the target, or an empty
|
|
324
|
+
tuple when the target sits outside the temp directory or on another
|
|
325
|
+
drive.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
relative_path = os.path.relpath(real_target, real_temp_root)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return ()
|
|
331
|
+
all_relative_parts = Path(relative_path).parts
|
|
332
|
+
if all_relative_parts and all_relative_parts[0] == os.path.pardir:
|
|
333
|
+
return ()
|
|
334
|
+
return all_relative_parts
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _existing_scratchpad_root(
|
|
338
|
+
all_relative_parts: tuple[str, ...], real_temp_root: str, session_id: str
|
|
339
|
+
) -> str | None:
|
|
340
|
+
"""Return the on-disk scratchpad root matching the harness shape, or None.
|
|
341
|
+
|
|
342
|
+
::
|
|
343
|
+
|
|
344
|
+
<temp-root>/<user-dir>/<mangled-cwd>/<session-id>/scratchpad/<file>
|
|
345
|
+
| | |
|
|
346
|
+
claude user dir session id leaf name
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
all_relative_parts: The target's path components below the temp root.
|
|
350
|
+
real_temp_root: The resolved temp-directory root.
|
|
351
|
+
session_id: The session id the scratchpad's parent segment must equal.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The scratchpad directory path when the shape matches and that directory
|
|
355
|
+
exists on disk, otherwise None.
|
|
356
|
+
"""
|
|
357
|
+
if not all_relative_parts or not _is_harness_user_directory(all_relative_parts[0]):
|
|
322
358
|
return None
|
|
323
|
-
|
|
359
|
+
for each_session_index in range(2, len(all_relative_parts) - 1):
|
|
360
|
+
leaf_index = each_session_index + 1
|
|
361
|
+
if all_relative_parts[each_session_index] != session_id:
|
|
362
|
+
continue
|
|
363
|
+
if all_relative_parts[leaf_index] != HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME:
|
|
364
|
+
continue
|
|
365
|
+
scratchpad_root = os.path.join(
|
|
366
|
+
real_temp_root, *all_relative_parts[: leaf_index + 1]
|
|
367
|
+
)
|
|
368
|
+
if os.path.isdir(scratchpad_root):
|
|
369
|
+
return scratchpad_root
|
|
370
|
+
return None
|
|
324
371
|
|
|
325
372
|
|
|
326
373
|
def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
|
|
327
374
|
"""Return True when file_path resolves under the harness session scratchpad.
|
|
328
375
|
|
|
329
376
|
One-off scripts written to the session scratchpad are throwaway tooling
|
|
330
|
-
outside every repo, so the TDD and CODE_RULES gates skip them.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
377
|
+
outside every repo, so the TDD and CODE_RULES gates skip them.
|
|
378
|
+
|
|
379
|
+
The match keys on the session id — from the payload, or the
|
|
380
|
+
``CLAUDE_CODE_SESSION_ID`` environment variable — and on the temp-directory
|
|
381
|
+
path shape ``<user-dir>/<mangled-cwd>/<session-id>/scratchpad``, so it holds
|
|
382
|
+
on Windows and POSIX alike. file_path resolves through the real filesystem
|
|
383
|
+
(symlinks followed) before the shape test, and the scratchpad directory must
|
|
384
|
+
exist on disk, so a crafted path that never existed keeps full enforcement.
|
|
334
385
|
|
|
335
386
|
Args:
|
|
336
387
|
file_path: The path the write targets.
|
|
337
|
-
hook_payload: The PreToolUse payload carrying
|
|
388
|
+
hook_payload: The PreToolUse payload carrying the session id.
|
|
338
389
|
|
|
339
390
|
Returns:
|
|
340
391
|
True when file_path's real path sits at or under the session scratchpad.
|
|
341
392
|
"""
|
|
342
393
|
if not file_path:
|
|
343
394
|
return False
|
|
344
|
-
|
|
345
|
-
if
|
|
395
|
+
session_id = _session_id_for_scratchpad(hook_payload)
|
|
396
|
+
if not session_id:
|
|
346
397
|
return False
|
|
347
398
|
real_target = os.path.realpath(file_path)
|
|
348
|
-
|
|
349
|
-
|
|
399
|
+
real_temp_root = os.path.realpath(tempfile.gettempdir())
|
|
400
|
+
all_relative_parts = _relative_parts_under_temp_root(real_target, real_temp_root)
|
|
401
|
+
return (
|
|
402
|
+
_existing_scratchpad_root(all_relative_parts, real_temp_root, session_id)
|
|
403
|
+
is not None
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
408
|
+
"""Return True when file_path is a throwaway scratch path exempt from repo gates.
|
|
409
|
+
|
|
410
|
+
Combines the two throwaway-path families a repo gate skips: the root-anchored
|
|
411
|
+
ephemeral scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``) and the
|
|
412
|
+
harness session scratchpad. The session scratchpad match reads the session id
|
|
413
|
+
from the payload when one is supplied, and from the harness environment
|
|
414
|
+
variable otherwise, so a caller that holds no payload still gets the match.
|
|
415
|
+
One call answers path exemption for both families, so a gate that skips
|
|
416
|
+
throwaway paths need not repeat the two checks itself.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
file_path: The candidate path to classify.
|
|
420
|
+
hook_payload: The PreToolUse payload carrying the session id, or None to
|
|
421
|
+
read the session id from the environment alone.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
True when the path is ephemeral scratch or under the session scratchpad.
|
|
425
|
+
"""
|
|
426
|
+
if is_ephemeral_script_path(file_path):
|
|
427
|
+
return True
|
|
428
|
+
return is_under_session_scratchpad(file_path, hook_payload or {})
|
|
350
429
|
|
|
351
430
|
|
|
352
431
|
def is_migration_file(file_path: str) -> bool:
|
|
@@ -12,6 +12,8 @@ A Python package that holds shared constants for the verified-commit gate family
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| `__init__.py` | Declares this as a regular package (not a namespace package) so it resolves first on `sys.path` |
|
|
14
14
|
| `verified_commit_constants.py` | All tunables for the gate: directory names, regex patterns for detecting verdict paths and obfuscation attempts, timeout values, git subcommand sets, bypass marker, and corrective messages |
|
|
15
|
+
| `verified_commit_context_constants.py` | `VERIFY_SKIP_ADDITIONAL_CONTEXT`, the ``# verify-skip`` usage rule the gate attaches to a deny payload's `additionalContext` |
|
|
16
|
+
| `verified_commit_gate_output_constants.py` | `PRE_TOOL_USE_HOOK_EVENT_NAME` and `DENY_PERMISSION_DECISION` (the deny payload's event name and decision string), `GATE_HOOK_MODULE_NAME` (the gate's own module name for block logging), and `REGEX_ALTERNATION_SEPARATOR` (the `\|` join for the directory-change verb alternation in `gated_invocations.py`) |
|
|
15
17
|
|
|
16
18
|
## Key constants in `verified_commit_constants.py`
|
|
17
19
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The additionalContext text the verified-commit gate attaches to a deny.
|
|
2
|
+
|
|
3
|
+
The gate injects this next to the blocked tool result so the agent learns,
|
|
4
|
+
at the moment of the block, when the '# verify-skip' marker is legitimate
|
|
5
|
+
and when a fresh verification is required instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
VERIFY_SKIP_ADDITIONAL_CONTEXT = (
|
|
9
|
+
"The verified-commit gate honors a one-command escape hatch: appending the "
|
|
10
|
+
"marker '# verify-skip' as a trailing shell comment (outside every quoted "
|
|
11
|
+
"region) to the blocked command bypasses the gate for that command only. "
|
|
12
|
+
"It is allowed only when the branch surface is the "
|
|
13
|
+
"same code a code-verifier already passed clean and the gate is "
|
|
14
|
+
"blocking on a verdict that does not cover it (an unminted verdict, "
|
|
15
|
+
"staging churn, or a reverted concurrent write) — confirm the verified "
|
|
16
|
+
"suite still passes and the diff holds nothing beyond the clean verdict "
|
|
17
|
+
"before using it. Any real code change since that clean verdict, a "
|
|
18
|
+
"first verification, or unrepaired findings all require a fresh "
|
|
19
|
+
"verification by the code-verifier agent instead. Full rule: "
|
|
20
|
+
"~/.claude/rules/verified-commit-gate-skip.md."
|
|
21
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Output-shape constants for the verified-commit gate's deny payload.
|
|
2
|
+
|
|
3
|
+
Shared by ``verified_commit_gate.py`` and its ``verified_commit_gate_parts``
|
|
4
|
+
package so the PreToolUse event name, the deny decision string, the hook's
|
|
5
|
+
own module name for block logging, and the regex-alternation join separator
|
|
6
|
+
live in one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
PRE_TOOL_USE_HOOK_EVENT_NAME = "PreToolUse"
|
|
12
|
+
DENY_PERMISSION_DECISION = "deny"
|
|
13
|
+
GATE_HOOK_MODULE_NAME = "verified_commit_gate.py"
|
|
14
|
+
REGEX_ALTERNATION_SEPARATOR = "|"
|
|
@@ -33,6 +33,27 @@ _DECOY_CONFIG_CONSTANTS_SOURCE = (
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _copy_mirrored_packages(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> None:
|
|
37
|
+
"""Copy the ``config``, gate-parts, and ``hooks_constants`` packages into the mirror."""
|
|
38
|
+
shutil.copytree(_BLOCKING_SOURCE_DIRECTORY / "config", mirrored_blocking_directory / "config")
|
|
39
|
+
shutil.copytree(
|
|
40
|
+
_BLOCKING_SOURCE_DIRECTORY / "verified_commit_gate_parts",
|
|
41
|
+
mirrored_blocking_directory / "verified_commit_gate_parts",
|
|
42
|
+
ignore=shutil.ignore_patterns("tests", "__pycache__"),
|
|
43
|
+
)
|
|
44
|
+
shutil.copytree(_HOOKS_CONSTANTS_SOURCE_DIRECTORY, mirrored_hooks_directory / "hooks_constants")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_decoy_config_package(mirrored_hooks_directory: Path) -> None:
|
|
48
|
+
"""Write the decoy ``config`` package that must never win resolution."""
|
|
49
|
+
decoy_config_directory = mirrored_hooks_directory / "config"
|
|
50
|
+
decoy_config_directory.mkdir()
|
|
51
|
+
(decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
|
|
52
|
+
(decoy_config_directory / "verified_commit_constants.py").write_text(
|
|
53
|
+
_DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
36
57
|
def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
37
58
|
"""Mirror the hook tree with a decoy ``config`` package beside ``blocking``.
|
|
38
59
|
|
|
@@ -51,22 +72,20 @@ def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
|
51
72
|
if source_file.exists():
|
|
52
73
|
shutil.copy2(source_file, mirrored_blocking_directory / each_file_name)
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
shutil.copytree(
|
|
59
|
-
_HOOKS_CONSTANTS_SOURCE_DIRECTORY,
|
|
60
|
-
mirrored_hooks_directory / "hooks_constants",
|
|
61
|
-
)
|
|
75
|
+
_copy_mirrored_packages(mirrored_hooks_directory, mirrored_blocking_directory)
|
|
76
|
+
_write_decoy_config_package(mirrored_hooks_directory)
|
|
77
|
+
return mirrored_hooks_directory, mirrored_blocking_directory
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
|
|
80
|
+
def _probe_command(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> str:
|
|
81
|
+
"""Build the ``sys.path`` preamble that shadows ``blocking`` with the decoy config."""
|
|
82
|
+
return textwrap.dedent(
|
|
83
|
+
f"""\
|
|
84
|
+
import sys
|
|
85
|
+
sys.path.insert(0, {str(mirrored_blocking_directory)!r})
|
|
86
|
+
sys.path.insert(0, {str(mirrored_hooks_directory)!r})
|
|
87
|
+
"""
|
|
68
88
|
)
|
|
69
|
-
return mirrored_hooks_directory, mirrored_blocking_directory
|
|
70
89
|
|
|
71
90
|
|
|
72
91
|
@pytest.fixture
|
|
@@ -75,15 +94,8 @@ def run_under_config_shadow(
|
|
|
75
94
|
) -> Callable[[str], subprocess.CompletedProcess[str]]:
|
|
76
95
|
"""Return a runner that executes a driver under a foreign-``config`` sys.path.
|
|
77
96
|
|
|
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.
|
|
97
|
+
``run_under_config_shadow("import verified_commit_gate")`` returns 0 once
|
|
98
|
+
the bootstrap seeds the real module, nonzero while the decoy config wins.
|
|
87
99
|
|
|
88
100
|
Args:
|
|
89
101
|
tmp_path: Pytest's per-test temporary directory, fixture-injected.
|
|
@@ -93,16 +105,10 @@ def run_under_config_shadow(
|
|
|
93
105
|
subprocess result.
|
|
94
106
|
"""
|
|
95
107
|
mirrored_hooks_directory, mirrored_blocking_directory = _build_shadowed_hook_tree(tmp_path)
|
|
108
|
+
sys_path_preamble = _probe_command(mirrored_hooks_directory, mirrored_blocking_directory)
|
|
96
109
|
|
|
97
110
|
def run_driver(driver_body: str) -> subprocess.CompletedProcess[str]:
|
|
98
111
|
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
112
|
probe_script.write_text(sys_path_preamble + textwrap.dedent(driver_body), encoding="utf-8")
|
|
107
113
|
return subprocess.run(
|
|
108
114
|
[sys.executable, str(probe_script)],
|
|
@@ -18,9 +18,81 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
18
18
|
if _hooks_dir not in sys.path:
|
|
19
19
|
sys.path.insert(0, _hooks_dir)
|
|
20
20
|
|
|
21
|
+
from hooks_constants.convergence_gate_blocker_constants import ( # noqa: E402
|
|
22
|
+
COMMAND_SEPARATOR_PATTERN,
|
|
23
|
+
GH_PR_READY_ANCHOR_PATTERN,
|
|
24
|
+
PR_URL_OWNER_REPO_NUMBER_PATTERN,
|
|
25
|
+
REPO_OVERRIDE_FLAG_PATTERN,
|
|
26
|
+
)
|
|
21
27
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
28
|
|
|
23
29
|
|
|
30
|
+
def _ready_command_segment(command: str) -> str:
|
|
31
|
+
"""Return the ``gh pr ready`` invocation, clipped at the next command separator.
|
|
32
|
+
|
|
33
|
+
::
|
|
34
|
+
|
|
35
|
+
gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo
|
|
36
|
+
^^^^^^^^^^^^^^^^ clipped -> returned segment
|
|
37
|
+
|
|
38
|
+
Scanning only this segment keeps a ``--repo`` flag or PR URL that belongs to
|
|
39
|
+
a chained command from binding the gate to the wrong PR.
|
|
40
|
+
"""
|
|
41
|
+
ready_match = re.search(GH_PR_READY_ANCHOR_PATTERN, command)
|
|
42
|
+
if ready_match is None:
|
|
43
|
+
return command
|
|
44
|
+
ready_tail = command[ready_match.start() :]
|
|
45
|
+
separator_match = re.search(COMMAND_SEPARATOR_PATTERN, ready_tail)
|
|
46
|
+
if separator_match is None:
|
|
47
|
+
return ready_tail
|
|
48
|
+
return ready_tail[: separator_match.start()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_pr_url(command: str) -> tuple[str, str, int] | None:
|
|
52
|
+
"""Return (owner, repo, pr_number) from a full PR URL in the command."""
|
|
53
|
+
url_match = re.search(PR_URL_OWNER_REPO_NUMBER_PATTERN, command)
|
|
54
|
+
if url_match is None:
|
|
55
|
+
return None
|
|
56
|
+
return url_match.group("owner"), url_match.group("repo"), int(url_match.group("number"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_repo_flag(command: str) -> tuple[str, str] | None:
|
|
60
|
+
"""Return (owner, repo) from a --repo/-R flag in the command."""
|
|
61
|
+
flag_match = re.search(REPO_OVERRIDE_FLAG_PATTERN, command)
|
|
62
|
+
if flag_match is None:
|
|
63
|
+
return None
|
|
64
|
+
return flag_match.group("owner"), flag_match.group("repo")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, int] | None:
|
|
68
|
+
"""Resolve the (owner, repo, pr_number) the gate keys its evidence to.
|
|
69
|
+
|
|
70
|
+
A full PR URL in the command yields all three. A --repo/-R flag yields
|
|
71
|
+
the repository while the PR number resolves from the command number or
|
|
72
|
+
the cwd. With neither present, both the repository and the number
|
|
73
|
+
resolve from the cwd.
|
|
74
|
+
"""
|
|
75
|
+
ready_segment = _ready_command_segment(command)
|
|
76
|
+
pr_url_identity = _parse_pr_url(ready_segment)
|
|
77
|
+
if pr_url_identity is not None:
|
|
78
|
+
return pr_url_identity
|
|
79
|
+
|
|
80
|
+
pr_number = _resolve_pr_number(ready_segment, cwd)
|
|
81
|
+
if pr_number is None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
repo_flag_identity = _parse_repo_flag(ready_segment)
|
|
85
|
+
if repo_flag_identity is not None:
|
|
86
|
+
flag_owner, flag_repo = repo_flag_identity
|
|
87
|
+
return flag_owner, flag_repo, pr_number
|
|
88
|
+
|
|
89
|
+
cwd_identity = _resolve_owner_repo(cwd)
|
|
90
|
+
if cwd_identity is None:
|
|
91
|
+
return None
|
|
92
|
+
cwd_owner, cwd_repo = cwd_identity
|
|
93
|
+
return cwd_owner, cwd_repo, pr_number
|
|
94
|
+
|
|
95
|
+
|
|
24
96
|
def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
|
|
25
97
|
direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
|
|
26
98
|
if direct_match:
|
|
@@ -107,19 +179,15 @@ def main() -> None:
|
|
|
107
179
|
sys.exit(0)
|
|
108
180
|
|
|
109
181
|
command = hook_input.get("tool_input", {}).get("command", "")
|
|
110
|
-
gh_pr_ready_pattern = re.compile(
|
|
182
|
+
gh_pr_ready_pattern = re.compile(GH_PR_READY_ANCHOR_PATTERN)
|
|
111
183
|
if not gh_pr_ready_pattern.search(command):
|
|
112
184
|
sys.exit(0)
|
|
113
185
|
|
|
114
186
|
cwd = hook_input.get("cwd")
|
|
115
|
-
|
|
116
|
-
if
|
|
117
|
-
sys.exit(0)
|
|
118
|
-
|
|
119
|
-
owner_repo = _resolve_owner_repo(cwd)
|
|
120
|
-
if owner_repo is None:
|
|
187
|
+
target_identity = _resolve_target_identity(command, cwd)
|
|
188
|
+
if target_identity is None:
|
|
121
189
|
sys.exit(0)
|
|
122
|
-
owner, repo =
|
|
190
|
+
owner, repo, pr_number = target_identity
|
|
123
191
|
|
|
124
192
|
completed_process = _run_convergence_check(
|
|
125
193
|
check_convergence_script, owner, repo, pr_number, cwd
|
|
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
+
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
22
23
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
23
24
|
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
24
25
|
ALL_SOFTWARE_TERMS,
|
|
@@ -324,6 +325,13 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
324
325
|
if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
|
|
325
326
|
return None
|
|
326
327
|
|
|
328
|
+
if raw_tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
329
|
+
target_file_path = raw_tool_input.get("file_path", "")
|
|
330
|
+
if isinstance(target_file_path, str) and is_ephemeral_path(
|
|
331
|
+
target_file_path, payload_by_key
|
|
332
|
+
):
|
|
333
|
+
return None
|
|
334
|
+
|
|
327
335
|
prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
|
|
328
336
|
if not prose_text:
|
|
329
337
|
return None
|
|
@@ -19,8 +19,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
-
from
|
|
22
|
+
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
23
23
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
24
25
|
from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
25
26
|
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
26
27
|
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
@@ -297,6 +298,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
297
298
|
file_path = tool_input.get("file_path", "")
|
|
298
299
|
if not isinstance(file_path, str) or not file_path:
|
|
299
300
|
return None
|
|
301
|
+
if is_ephemeral_path(file_path, payload_by_key):
|
|
302
|
+
return None
|
|
300
303
|
if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
|
|
301
304
|
return None
|
|
302
305
|
|