claude-dev-env 2.12.0 → 2.14.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/AGENTS.md +61 -0
- package/CLAUDE.md +1 -57
- package/_shared/AGENTS.md +15 -0
- package/_shared/CLAUDE.md +1 -15
- package/_shared/advisor/AGENTS.md +22 -0
- package/_shared/advisor/CLAUDE.md +1 -22
- package/_shared/pr-loop/AGENTS.md +27 -0
- package/_shared/pr-loop/CLAUDE.md +1 -27
- package/_shared/pr-loop/scripts/AGENTS.md +35 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -35
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/AGENTS.md +42 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +1 -42
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/AGENTS.md +26 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -26
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
- package/_shared/pr-loop/scripts/tests/AGENTS.md +44 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -44
- package/_shared/process-tree/AGENTS.md +41 -0
- package/_shared/process-tree/CLAUDE.md +1 -41
- package/agents/AGENTS.md +31 -0
- package/agents/CLAUDE.md +1 -32
- package/agents/clean-coder.md +6 -6
- package/agents/pr-description-writer.md +11 -81
- package/agents/test_agent_frontmatter.py +4 -4
- package/audit-rubrics/AGENTS.md +43 -0
- package/audit-rubrics/CLAUDE.md +1 -43
- package/audit-rubrics/category_rubrics/AGENTS.md +37 -0
- package/audit-rubrics/category_rubrics/CLAUDE.md +1 -37
- package/audit-rubrics/prompts/AGENTS.md +37 -0
- package/audit-rubrics/prompts/CLAUDE.md +1 -37
- package/bin/AGENTS.md +112 -0
- package/bin/CLAUDE.md +1 -112
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +6 -7
- package/bin/install.mjs +71 -13
- package/bin/install.prune.test.mjs +26 -0
- package/bin/install.settings-defaults.test.mjs +60 -33
- package/bin/install.test.mjs +36 -4
- package/commands/AGENTS.md +23 -0
- package/commands/CLAUDE.md +1 -23
- package/commands/hook-log-extract.md +3 -3
- package/commands/sr-loop.md +9 -5
- package/docs/AGENTS.md +32 -0
- package/docs/CLAUDE.md +1 -33
- package/docs/CODE_RULES.md +1 -1
- package/docs/imagegen.md +22 -0
- package/docs/references/AGENTS.md +17 -0
- package/docs/references/CLAUDE.md +1 -18
- package/hooks/AGENTS.md +31 -0
- package/hooks/CLAUDE.md +1 -31
- package/hooks/_gh_pr_author_swap_utils.py +14 -0
- package/hooks/advisory/AGENTS.md +16 -0
- package/hooks/advisory/CLAUDE.md +1 -16
- package/hooks/blocking/AGENTS.md +123 -0
- package/hooks/blocking/CLAUDE.md +1 -148
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/AGENTS.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +1 -28
- package/hooks/blocking/code_rules_banned_identifiers.py +1 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +149 -37
- package/hooks/blocking/code_rules_probe_chains.py +6 -2
- package/hooks/blocking/config/AGENTS.md +10 -0
- package/hooks/blocking/config/CLAUDE.md +1 -22
- package/hooks/blocking/conventional_pr_title_gate.py +1 -2
- package/hooks/blocking/gh_pr_author_restore.py +7 -2
- package/hooks/blocking/intent_only_ending_blocker.py +11 -16
- package/hooks/blocking/inventory_intent_records/AGENTS.md +26 -0
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +1 -26
- package/hooks/blocking/package_inventory_stale_blocker_parts/AGENTS.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +1 -26
- package/hooks/blocking/pii_prevention_blocker_parts/AGENTS.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +1 -24
- package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +4 -4
- package/hooks/blocking/tdd_enforcer_parts/AGENTS.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +1 -30
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
- package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +171 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_gh_pr_author_restore.py +2 -1
- package/hooks/blocking/test_intent_only_ending_blocker.py +4 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
- package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
- package/hooks/diagnostic/AGENTS.md +43 -0
- package/hooks/diagnostic/CLAUDE.md +1 -43
- package/hooks/diagnostic/hook_log_extractor.py +4 -38
- package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
- package/hooks/diagnostic/migrations/AGENTS.md +16 -0
- package/hooks/diagnostic/migrations/CLAUDE.md +1 -16
- package/hooks/diagnostic/queries/AGENTS.md +19 -0
- package/hooks/diagnostic/queries/CLAUDE.md +1 -19
- package/hooks/diagnostic/test_hook_log_extractor.py +10 -23
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
- package/hooks/git-hooks/AGENTS.md +31 -0
- package/hooks/git-hooks/CLAUDE.md +1 -31
- package/hooks/git-hooks/git_hooks_constants/AGENTS.md +21 -0
- package/hooks/git-hooks/git_hooks_constants/CLAUDE.md +1 -21
- package/hooks/git-hooks/git_hooks_constants/__init__.py +4 -12
- package/hooks/git-hooks/pre_push.py +49 -118
- package/hooks/git-hooks/test_pre_push.py +111 -137
- package/hooks/hooks.json +0 -35
- package/hooks/hooks_constants/AGENTS.md +104 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -113
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
- package/hooks/hooks_constants/hook_log_extractor_constants.py +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +4 -3
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/working_style_prompt_constants.py +29 -20
- package/hooks/lifecycle/AGENTS.md +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -20
- package/hooks/observability/AGENTS.md +20 -0
- package/hooks/observability/CLAUDE.md +1 -20
- package/hooks/session/AGENTS.md +33 -0
- package/hooks/session/CLAUDE.md +1 -33
- package/hooks/session/gh_pr_author_session_cleanup.py +7 -2
- package/hooks/session/task_list_loop_starter.py +3 -3
- package/hooks/session/test_gh_pr_author_session_cleanup.py +2 -1
- package/hooks/session/test_task_list_loop_starter.py +10 -2
- package/hooks/session/test_working_style_prompt.py +9 -4
- package/hooks/validation/AGENTS.md +20 -0
- package/hooks/validation/CLAUDE.md +1 -20
- package/hooks/validators/AGENTS.md +51 -0
- package/hooks/validators/CLAUDE.md +1 -51
- package/hooks/validators/health_check.py +52 -19
- package/hooks/validators/python_style_checks.py +1 -1
- package/hooks/validators/test_health_check.py +112 -27
- package/hooks/workflow/AGENTS.md +16 -0
- package/hooks/workflow/CLAUDE.md +1 -16
- package/installable-surfaces.manifest.json +1 -0
- package/output-styles/AGENTS.md +15 -0
- package/output-styles/CLAUDE.md +1 -15
- package/package.json +2 -1
- package/rules/AGENTS.md +61 -0
- package/rules/CLAUDE.md +1 -63
- package/rules/code-standards.md +5 -9
- package/rules/doc-prose-cuts.md +1 -1
- package/rules/gh-cli-conventions.md +0 -1
- package/rules/git-workflow.md +1 -3
- package/scripts/AGENTS.md +62 -0
- package/scripts/CLAUDE.md +1 -63
- package/scripts/_code_review_test_support.py +0 -95
- package/scripts/codex_compat_materializer.py +4 -0
- package/scripts/dev_env_scripts_constants/AGENTS.md +21 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -21
- package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
- package/scripts/invoke_code_review.py +8 -451
- package/scripts/sync_to_cursor/AGENTS.md +23 -0
- package/scripts/sync_to_cursor/CLAUDE.md +1 -23
- package/scripts/test_invoke_code_review.py +5 -61
- package/scripts/test_invoke_code_review_cli.py +1 -45
- package/scripts/test_invoke_code_review_contract.py +1 -118
- package/scripts/test_validate_instruction_pairs.py +90 -0
- package/scripts/test_verify_installable_package.py +5 -1
- package/scripts/tests/AGENTS.md +33 -0
- package/scripts/tests/CLAUDE.md +1 -33
- package/scripts/tests/test_codex_compat_materializer.py +36 -1
- package/scripts/validate_instruction_pairs.py +183 -0
- package/settings.json +1 -10
- package/skills/AGENTS.md +72 -0
- package/skills/CLAUDE.md +1 -64
- package/skills/_shared/AGENTS.md +44 -0
- package/skills/_shared/CLAUDE.md +1 -44
- package/skills/_shared/advisor/AGENTS.md +9 -0
- package/skills/_shared/advisor/CLAUDE.md +1 -9
- package/skills/_shared/pr-loop/AGENTS.md +50 -0
- package/skills/_shared/pr-loop/CLAUDE.md +1 -50
- package/skills/_shared/pr-loop/prompts/AGENTS.md +9 -0
- package/skills/_shared/pr-loop/prompts/CLAUDE.md +1 -9
- package/skills/_shared/pr-loop/scripts/AGENTS.md +35 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -35
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/AGENTS.md +24 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -24
- package/skills/anthropic-plan/AGENTS.md +34 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -34
- package/skills/anthropic-plan/scripts/AGENTS.md +11 -0
- package/skills/anthropic-plan/scripts/CLAUDE.md +1 -11
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/AGENTS.md +16 -0
- package/skills/anthropic-plan/scripts/anthropic_plan_scripts_constants/CLAUDE.md +1 -16
- package/skills/anthropic-plan/templates/AGENTS.md +13 -0
- package/skills/anthropic-plan/templates/CLAUDE.md +1 -13
- package/skills/anthropic-plan/workflow/AGENTS.md +14 -0
- package/skills/anthropic-plan/workflow/CLAUDE.md +1 -14
- package/skills/auditing-claude-config/AGENTS.md +21 -0
- package/skills/auditing-claude-config/CLAUDE.md +1 -21
- package/skills/autoconverge/AGENTS.md +36 -0
- package/skills/autoconverge/CLAUDE.md +1 -36
- package/skills/autoconverge/reference/AGENTS.md +16 -0
- package/skills/autoconverge/reference/CLAUDE.md +1 -16
- package/skills/autoconverge/reference/convergence.md +7 -7
- package/skills/autoconverge/reference/gotchas.md +3 -10
- package/skills/autoconverge/workflow/AGENTS.md +26 -0
- package/skills/autoconverge/workflow/CLAUDE.md +1 -26
- package/skills/autoconverge/workflow/autoconverge_report_constants/AGENTS.md +16 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/CLAUDE.md +1 -16
- package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
- package/skills/autoconverge/workflow/converge.mjs +201 -238
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
- package/skills/bugteam/AGENTS.md +30 -0
- package/skills/bugteam/CLAUDE.md +1 -30
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/SKILL.md +9 -0
- package/skills/bugteam/reference/AGENTS.md +20 -0
- package/skills/bugteam/reference/CLAUDE.md +1 -20
- package/skills/bugteam/reference/audit-and-teammates.md +10 -6
- package/skills/bugteam/reference/obstacles/AGENTS.md +24 -0
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -24
- package/skills/bugteam/scripts/AGENTS.md +30 -0
- package/skills/bugteam/scripts/CLAUDE.md +1 -30
- package/skills/bugteam/scripts/bugteam_scripts_constants/AGENTS.md +18 -0
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +1 -18
- package/skills/bugteam/test_skill_additions.py +9 -0
- package/skills/closeout/reference/handoff-prompt-template.md +1 -1
- package/skills/codex-review/AGENTS.md +46 -0
- package/skills/codex-review/CLAUDE.md +1 -46
- package/skills/codex-review/reference/AGENTS.md +15 -0
- package/skills/codex-review/reference/CLAUDE.md +1 -15
- package/skills/codex-review/scripts/codex_review_scripts_constants/AGENTS.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +1 -18
- package/skills/codex-review/test_skill_scaffold.py +3 -3
- package/skills/comments/SKILL.md +65 -0
- package/skills/copilot-review/AGENTS.md +18 -0
- package/skills/copilot-review/CLAUDE.md +1 -18
- package/skills/descriptions/SKILL.md +51 -0
- package/skills/emergencies/SKILL.md +42 -0
- package/skills/everything-search/AGENTS.md +17 -0
- package/skills/everything-search/CLAUDE.md +1 -17
- package/skills/fresh-branch/AGENTS.md +14 -0
- package/skills/fresh-branch/CLAUDE.md +1 -14
- package/skills/grok-spawn/AGENTS.md +28 -0
- package/skills/grok-spawn/CLAUDE.md +1 -28
- package/skills/orchestrator/SKILL.md +2 -4
- package/skills/plan-to-pr/SKILL.md +6 -7
- package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
- package/skills/plan-to-pr/reference/process-inventory.md +2 -2
- package/skills/plan-to-pr/reference/review-loop.md +2 -2
- package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
- package/skills/plan-to-pr/reference/task-seeds.md +1 -1
- package/skills/plan-to-pr/reference/task-ticket.md +3 -4
- package/skills/plan-to-pr/scripts/config/constants.py +1 -2
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
- package/skills/plan-to-pr/test_skill_contract.py +2 -2
- package/skills/pr-converge/AGENTS.md +32 -0
- package/skills/pr-converge/CLAUDE.md +1 -32
- package/skills/pr-converge/SKILL.md +13 -16
- package/skills/pr-converge/pr_converge_skill_constants/AGENTS.md +26 -0
- package/skills/pr-converge/pr_converge_skill_constants/CLAUDE.md +1 -26
- package/skills/pr-converge/reference/AGENTS.md +28 -0
- package/skills/pr-converge/reference/CLAUDE.md +1 -28
- package/skills/pr-converge/reference/obstacles/AGENTS.md +23 -0
- package/skills/pr-converge/reference/obstacles/CLAUDE.md +1 -23
- package/skills/pr-converge/reference/progress-checklist.md +6 -5
- package/skills/pr-converge/reference/state-schema.md +1 -27
- package/skills/pr-converge/scripts/AGENTS.md +46 -0
- package/skills/pr-converge/scripts/CLAUDE.md +1 -46
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/AGENTS.md +18 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -18
- package/skills/pr-converge/workflows/AGENTS.md +16 -0
- package/skills/pr-converge/workflows/CLAUDE.md +1 -16
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
- package/skills/pr-loop-lifecycle/SKILL.md +1 -1
- package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
- package/skills/prototype/SKILL.md +4 -4
- package/skills/prototype/reference/honest-limitations.md +5 -5
- package/skills/prototype/reference/promotion-tasks.md +2 -2
- package/skills/prototype/workflows/promotion.md +4 -6
- package/skills/rebase/AGENTS.md +32 -0
- package/skills/rebase/CLAUDE.md +1 -32
- package/skills/recall/AGENTS.md +30 -0
- package/skills/recall/CLAUDE.md +1 -30
- package/skills/remember/AGENTS.md +31 -0
- package/skills/remember/CLAUDE.md +1 -31
- package/skills/reviews/SKILL.md +85 -0
- package/skills/session-log/AGENTS.md +32 -0
- package/skills/session-log/CLAUDE.md +1 -32
- package/skills/session-tidy/AGENTS.md +36 -0
- package/skills/session-tidy/CLAUDE.md +1 -36
- package/skills/skill-builder/AGENTS.md +49 -0
- package/skills/skill-builder/CLAUDE.md +1 -49
- package/skills/skill-builder/references/AGENTS.md +25 -0
- package/skills/skill-builder/references/CLAUDE.md +1 -25
- package/skills/skill-builder/references/deterministic-elements.md +1 -1
- package/skills/skill-builder/templates/AGENTS.md +14 -0
- package/skills/skill-builder/templates/CLAUDE.md +1 -14
- package/skills/skill-builder/workflows/AGENTS.md +19 -0
- package/skills/skill-builder/workflows/CLAUDE.md +1 -19
- package/skills/small-cl/SKILL.md +52 -0
- package/skills/task-build/AGENTS.md +29 -0
- package/skills/task-build/CLAUDE.md +1 -29
- package/skills/update/AGENTS.md +38 -0
- package/skills/update/CLAUDE.md +1 -38
- package/system-prompts/AGENTS.md +17 -0
- package/system-prompts/CLAUDE.md +1 -17
- package/system-prompts/software-engineer.xml +3 -3
- package/agents/code-verifier.md +0 -74
- package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
- package/docs/references/code-review-enforcement.md +0 -132
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
- package/hooks/blocking/code_review_gate_deny.py +0 -74
- package/hooks/blocking/code_review_pr_create_gate.py +0 -198
- package/hooks/blocking/code_review_push_gate.py +0 -145
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
- package/hooks/blocking/code_review_stamp_store.py +0 -233
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
- package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
- package/hooks/blocking/config/verified_commit_constants.py +0 -160
- package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
- package/hooks/blocking/conftest.py +0 -123
- package/hooks/blocking/convergence_gate_blocker.py +0 -310
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
- package/hooks/blocking/pr_description_body_audit.py +0 -148
- package/hooks/blocking/pr_description_command_parser.py +0 -237
- package/hooks/blocking/pr_description_enforcer.py +0 -227
- package/hooks/blocking/pr_description_pr_number.py +0 -155
- package/hooks/blocking/pr_description_proof_of_work.py +0 -385
- package/hooks/blocking/pr_description_readability.py +0 -364
- package/hooks/blocking/reviewer_spawn_gate.py +0 -182
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
- package/hooks/blocking/test_code_review_gate_deny.py +0 -54
- package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
- package/hooks/blocking/test_code_review_push_gate.py +0 -205
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
- package/hooks/blocking/test_code_review_stamp_store.py +0 -205
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
- package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
- package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
- package/hooks/blocking/test_pr_description_enforcer.py +0 -221
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
- package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
- package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
- package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
- package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
- package/hooks/blocking/test_verification_verdict_store.py +0 -974
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
- package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
- package/hooks/blocking/test_verified_commit_gate.py +0 -581
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
- package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
- package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
- package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
- package/hooks/blocking/verification_verdict_store.py +0 -1039
- package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
- package/hooks/blocking/verified_commit_gate.py +0 -113
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
- package/hooks/blocking/verifier_verdict_minter.py +0 -280
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
- package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
- package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
- package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
- package/rules/proof-of-work-pr-comments.md +0 -22
- package/rules/verified-commit-gate-skip.md +0 -4
- package/scripts/Show-Asset.ps1 +0 -106
|
@@ -1,1039 +0,0 @@
|
|
|
1
|
-
"""Shared verdict storage and branch-diff logic for the verified-commit gate.
|
|
2
|
-
|
|
3
|
-
The verified-commit workflow has two halves that must agree byte-for-byte on
|
|
4
|
-
what a verdict covers: ``verifier_verdict_minter.py`` (SubagentStop) writes a
|
|
5
|
-
verdict bound to the current change surface, and ``verified_commit_gate.py``
|
|
6
|
-
(PreToolUse on Bash) refuses ``git commit`` / ``git push`` unless a verdict
|
|
7
|
-
matching the live surface exists. This module owns that shared contract:
|
|
8
|
-
locating the repo, computing the canonical surface manifest and its hash,
|
|
9
|
-
deriving the verdict file path, deciding the mechanical docs-only exemption,
|
|
10
|
-
and reading/writing verdict files.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import ast
|
|
16
|
-
import datetime
|
|
17
|
-
import hashlib
|
|
18
|
-
import json
|
|
19
|
-
import re
|
|
20
|
-
import subprocess
|
|
21
|
-
import sys
|
|
22
|
-
import time
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from collections.abc import Mapping
|
|
25
|
-
|
|
26
|
-
blocking_directory = str(Path(__file__).resolve().parent)
|
|
27
|
-
if blocking_directory not in sys.path:
|
|
28
|
-
sys.path.insert(0, blocking_directory)
|
|
29
|
-
|
|
30
|
-
from verified_commit_config_bootstrap import ( # noqa: E402
|
|
31
|
-
register_verified_commit_constants,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
register_verified_commit_constants()
|
|
35
|
-
|
|
36
|
-
from config.verified_commit_constants import (
|
|
37
|
-
AGENT_META_SIDECAR_SUFFIX,
|
|
38
|
-
AGENT_META_TYPE_KEY,
|
|
39
|
-
AGENT_TRANSCRIPT_GLOB,
|
|
40
|
-
BRANCH_REFERENCE_PREFIX,
|
|
41
|
-
BRANCH_REMOTE_CONFIG_KEY_TEMPLATE,
|
|
42
|
-
BRANCH_WORKTREE_ABSENT_MESSAGE,
|
|
43
|
-
CLAUDE_HOME_DIRECTORY_NAME,
|
|
44
|
-
CONFTEST_FILE_NAME,
|
|
45
|
-
DETACHED_HEAD_LABEL,
|
|
46
|
-
DOCS_ONLY_EXTENSIONS,
|
|
47
|
-
ALL_FALLBACK_BASE_REFERENCES,
|
|
48
|
-
EMPTY_SURFACE_GUARD_MESSAGE,
|
|
49
|
-
GIT_TIMEOUT_SECONDS,
|
|
50
|
-
LEDGER_KEY_ALL_PASS,
|
|
51
|
-
LEDGER_KEY_BRANCH,
|
|
52
|
-
LEDGER_KEY_FINDING_CHECK_NAMES,
|
|
53
|
-
LEDGER_KEY_FINDING_COUNT,
|
|
54
|
-
LEDGER_KEY_MANIFEST_HASH,
|
|
55
|
-
LEDGER_KEY_MINTED_FROM_AGENT_ID,
|
|
56
|
-
LEDGER_KEY_REPO_ROOT,
|
|
57
|
-
LEDGER_KEY_TIMESTAMP,
|
|
58
|
-
LOGS_DIRECTORY_NAME,
|
|
59
|
-
MANIFEST_HASH_CLI_FLAG,
|
|
60
|
-
MANIFEST_HASH_FOR_BRANCH_CLI_FLAG,
|
|
61
|
-
MINIMUM_STATUS_FIELD_COUNT,
|
|
62
|
-
MINTING_AGENT_TYPE,
|
|
63
|
-
PYTHON_EXTENSION,
|
|
64
|
-
ROOT_KEY_HEX_LENGTH,
|
|
65
|
-
SUBAGENTS_DIRECTORY_NAME,
|
|
66
|
-
TEST_FILE_PREFIX,
|
|
67
|
-
TEST_FILE_SUFFIX,
|
|
68
|
-
ALL_TOOLING_STATE_PREFIXES,
|
|
69
|
-
TRANSCRIPT_ASSISTANT_ENTRY_TYPE,
|
|
70
|
-
TRANSCRIPT_CONTENT_KEY,
|
|
71
|
-
TRANSCRIPT_CONTENT_TYPE_KEY,
|
|
72
|
-
TRANSCRIPT_ENTRY_TYPE_KEY,
|
|
73
|
-
TRANSCRIPT_MESSAGE_KEY,
|
|
74
|
-
TRANSCRIPT_TEXT_CONTENT_TYPE,
|
|
75
|
-
TRANSCRIPT_TEXT_KEY,
|
|
76
|
-
VERDICT_DIRECTORY_NAME,
|
|
77
|
-
VERDICT_FENCE_PATTERN,
|
|
78
|
-
VERDICT_FILE_GLOB,
|
|
79
|
-
VERDICT_FINDING_CHECK_KEY,
|
|
80
|
-
VERDICT_JSON_INDENT,
|
|
81
|
-
VERDICT_KEY_ALL_PASS,
|
|
82
|
-
VERDICT_KEY_FINDINGS,
|
|
83
|
-
VERDICT_KEY_MANIFEST_SHA256,
|
|
84
|
-
VERIFIER_VERDICTS_JSONL_FILENAME,
|
|
85
|
-
WORKTREE_LIST_BRANCH_PREFIX,
|
|
86
|
-
WORKTREE_LIST_PATH_PREFIX,
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def run_git(repo_directory: str, *git_arguments: str) -> str | None:
|
|
91
|
-
"""Run a git command and return its stdout, or None on any failure.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
repo_directory: Directory git runs in (``git -C``).
|
|
95
|
-
*git_arguments: The git subcommand and its arguments.
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
Decoded stdout with trailing whitespace stripped, or None when git
|
|
99
|
-
exits nonzero, times out, or is not installed.
|
|
100
|
-
"""
|
|
101
|
-
try:
|
|
102
|
-
completed_process = subprocess.run(
|
|
103
|
-
["git", "-C", repo_directory, *git_arguments],
|
|
104
|
-
capture_output=True,
|
|
105
|
-
text=True,
|
|
106
|
-
encoding="utf-8",
|
|
107
|
-
errors="replace",
|
|
108
|
-
timeout=GIT_TIMEOUT_SECONDS,
|
|
109
|
-
check=False,
|
|
110
|
-
)
|
|
111
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
112
|
-
return None
|
|
113
|
-
if completed_process.returncode != 0:
|
|
114
|
-
return None
|
|
115
|
-
return completed_process.stdout.rstrip()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def resolve_repo_root(start_directory: str) -> str | None:
|
|
119
|
-
"""Resolve the repository top level for a directory.
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
start_directory: Any directory inside (or outside) a work tree.
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
The absolute repo root path, or None when the directory is not
|
|
126
|
-
inside a git work tree.
|
|
127
|
-
"""
|
|
128
|
-
return run_git(start_directory, "rev-parse", "--show-toplevel")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _tracked_upstream_reference(repo_root: str) -> str | None:
|
|
132
|
-
"""Read HEAD's configured upstream tracking reference.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
repo_root: The repository top-level directory.
|
|
136
|
-
|
|
137
|
-
Returns:
|
|
138
|
-
The upstream reference (``origin/develop`` and the like) when HEAD
|
|
139
|
-
tracks one, or None when no upstream is configured.
|
|
140
|
-
"""
|
|
141
|
-
return run_git(
|
|
142
|
-
repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _head_branch_name(repo_root: str) -> str | None:
|
|
147
|
-
"""Read the short name of the branch HEAD points at.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
repo_root: The repository top-level directory.
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
The short branch name, or None when HEAD is detached or git fails.
|
|
154
|
-
"""
|
|
155
|
-
branch_name = run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
156
|
-
if not branch_name or branch_name == DETACHED_HEAD_LABEL:
|
|
157
|
-
return None
|
|
158
|
-
return branch_name
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _upstream_is_own_remote_counterpart(repo_root: str, tracked_upstream: str) -> bool:
|
|
162
|
-
"""Decide whether HEAD's upstream is the current branch's own remote ref.
|
|
163
|
-
|
|
164
|
-
::
|
|
165
|
-
|
|
166
|
-
branch feature tracks origin/feature -> True (own counterpart)
|
|
167
|
-
branch feature tracks origin/develop -> False (different branch)
|
|
168
|
-
|
|
169
|
-
A branch tracking its own remote counterpart makes the merge base against
|
|
170
|
-
that ref resolve to the branch tip itself, masking the branch's real state
|
|
171
|
-
versus the default branch — so the caller ranks that ref below the
|
|
172
|
-
default-branch fallbacks.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
repo_root: The repository top-level directory.
|
|
176
|
-
tracked_upstream: HEAD's configured upstream tracking reference.
|
|
177
|
-
|
|
178
|
-
Returns:
|
|
179
|
-
True when the upstream names ``<remote>/<current-branch>``; False when
|
|
180
|
-
it names a different branch, HEAD is detached, or git fails.
|
|
181
|
-
"""
|
|
182
|
-
branch_name = _head_branch_name(repo_root)
|
|
183
|
-
if branch_name is None:
|
|
184
|
-
return False
|
|
185
|
-
configured_remote = run_git(
|
|
186
|
-
repo_root, "config", BRANCH_REMOTE_CONFIG_KEY_TEMPLATE.format(branch_name=branch_name)
|
|
187
|
-
)
|
|
188
|
-
if not configured_remote:
|
|
189
|
-
return False
|
|
190
|
-
return tracked_upstream == f"{configured_remote}/{branch_name}"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def candidate_base_references(repo_root: str) -> tuple[str, ...]:
|
|
194
|
-
"""Collect the upstream references to probe for the merge base, in order.
|
|
195
|
-
|
|
196
|
-
Probes ``origin/HEAD`` first, then a tracked upstream that names a
|
|
197
|
-
different branch (so a differently-named default like ``origin/develop`` is
|
|
198
|
-
found), then the fixed ``origin/main`` / ``origin/master`` fallbacks. A
|
|
199
|
-
tracked upstream that is the current branch's own remote counterpart
|
|
200
|
-
(``origin/<current-branch>``) ranks last, below the fallbacks: on a PR
|
|
201
|
-
branch whose fork carries ``origin/main`` the merge base comes from
|
|
202
|
-
``origin/main`` rather than the branch's stale pushed ref, while a repo
|
|
203
|
-
whose default branch is the tracked branch itself (no ``origin/main`` or
|
|
204
|
-
``origin/master`` present) still falls through to that own-counterpart ref.
|
|
205
|
-
|
|
206
|
-
Args:
|
|
207
|
-
repo_root: The repository top-level directory.
|
|
208
|
-
|
|
209
|
-
Returns:
|
|
210
|
-
The ordered upstream references to try, with duplicates removed.
|
|
211
|
-
"""
|
|
212
|
-
upstream_head = run_git(repo_root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
213
|
-
tracked_upstream = _tracked_upstream_reference(repo_root)
|
|
214
|
-
high_priority_upstream: tuple[str, ...] = ()
|
|
215
|
-
demoted_upstream: tuple[str, ...] = ()
|
|
216
|
-
if tracked_upstream:
|
|
217
|
-
if _upstream_is_own_remote_counterpart(repo_root, tracked_upstream):
|
|
218
|
-
demoted_upstream = (tracked_upstream,)
|
|
219
|
-
else:
|
|
220
|
-
high_priority_upstream = (tracked_upstream,)
|
|
221
|
-
ordered_references = (
|
|
222
|
-
((upstream_head,) if upstream_head else ())
|
|
223
|
-
+ high_priority_upstream
|
|
224
|
-
+ ALL_FALLBACK_BASE_REFERENCES
|
|
225
|
-
+ demoted_upstream
|
|
226
|
-
)
|
|
227
|
-
return tuple(dict.fromkeys(ordered_references))
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def resolve_merge_base(repo_root: str) -> str | None:
|
|
231
|
-
"""Find the merge base between HEAD and the default upstream branch.
|
|
232
|
-
|
|
233
|
-
Args:
|
|
234
|
-
repo_root: The repository top-level directory.
|
|
235
|
-
|
|
236
|
-
Returns:
|
|
237
|
-
The merge-base commit sha, or None when no upstream base resolves —
|
|
238
|
-
the caller decides how to treat base-less repositories.
|
|
239
|
-
"""
|
|
240
|
-
for each_reference in candidate_base_references(repo_root):
|
|
241
|
-
merge_base_sha = run_git(repo_root, "merge-base", "HEAD", each_reference)
|
|
242
|
-
if merge_base_sha:
|
|
243
|
-
return merge_base_sha
|
|
244
|
-
return None
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def untracked_file_paths(repo_root: str) -> list[str] | None:
|
|
248
|
-
"""List untracked, non-ignored files outside tooling-state directories.
|
|
249
|
-
|
|
250
|
-
Paths under the transient tooling-state subtrees named in
|
|
251
|
-
``ALL_TOOLING_STATE_PREFIXES`` are skipped: the Claude and Cursor
|
|
252
|
-
scratch subdirectories carrying verification verdicts, worktree copies,
|
|
253
|
-
daemon state, team state, and session state, plus the top-level plugin
|
|
254
|
-
runtime data directory a plugin rewrites while it works. These hold session state
|
|
255
|
-
and stale worktree copies, never the branch's work, and in real
|
|
256
|
-
checkouts they run to thousands of files. A verdict binds to surface
|
|
257
|
-
content, so runtime state left in the surface moves the hash under a
|
|
258
|
-
freshly minted verdict and denies the next push. Production hook,
|
|
259
|
-
agent, and skill files tracked elsewhere under ``.claude/`` are kept,
|
|
260
|
-
so a new untracked one still binds to the verdict surface.
|
|
261
|
-
|
|
262
|
-
Args:
|
|
263
|
-
repo_root: The repository top-level directory.
|
|
264
|
-
|
|
265
|
-
Returns:
|
|
266
|
-
Sorted repo-relative paths, or None when git fails.
|
|
267
|
-
"""
|
|
268
|
-
listing_text = run_git(
|
|
269
|
-
repo_root, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"
|
|
270
|
-
)
|
|
271
|
-
if listing_text is None:
|
|
272
|
-
return None
|
|
273
|
-
return sorted(
|
|
274
|
-
each_line
|
|
275
|
-
for each_line in listing_text.splitlines()
|
|
276
|
-
if each_line and not each_line.startswith(ALL_TOOLING_STATE_PREFIXES)
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def branch_surface_manifest(repo_root: str, merge_base_sha: str) -> str | None:
|
|
281
|
-
"""Compute the canonical change-surface manifest a verdict covers.
|
|
282
|
-
|
|
283
|
-
The surface is every path that differs from the merge base plus every
|
|
284
|
-
untracked file, each bound by a digest of its current work-tree
|
|
285
|
-
content. Binding paths and contents — not patch text or index state —
|
|
286
|
-
makes the hash invariant under ``git add`` and commit slicing, while
|
|
287
|
-
any content edit or new file after verification still changes it.
|
|
288
|
-
|
|
289
|
-
Args:
|
|
290
|
-
repo_root: The repository top-level directory.
|
|
291
|
-
merge_base_sha: The merge-base commit sha the branch grew from.
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
One ``<path> sha256=<digest>`` line per surface file (deleted
|
|
295
|
-
files carry a ``deleted`` marker), or None when git or a file
|
|
296
|
-
read fails.
|
|
297
|
-
"""
|
|
298
|
-
changed_paths_text = run_git(
|
|
299
|
-
repo_root, "-c", "core.quotePath=false", "diff", "--name-only", "--no-renames",
|
|
300
|
-
merge_base_sha,
|
|
301
|
-
)
|
|
302
|
-
if changed_paths_text is None:
|
|
303
|
-
return None
|
|
304
|
-
untracked_paths = untracked_file_paths(repo_root)
|
|
305
|
-
if untracked_paths is None:
|
|
306
|
-
return None
|
|
307
|
-
surface_paths = sorted(
|
|
308
|
-
{each_path for each_path in changed_paths_text.splitlines() if each_path}
|
|
309
|
-
| set(untracked_paths)
|
|
310
|
-
)
|
|
311
|
-
manifest_lines = []
|
|
312
|
-
for each_path in surface_paths:
|
|
313
|
-
surface_file = Path(repo_root) / each_path
|
|
314
|
-
if not surface_file.is_file():
|
|
315
|
-
manifest_lines.append(f"{each_path} deleted")
|
|
316
|
-
continue
|
|
317
|
-
try:
|
|
318
|
-
content_digest = hashlib.sha256(surface_file.read_bytes()).hexdigest()
|
|
319
|
-
except OSError:
|
|
320
|
-
return None
|
|
321
|
-
manifest_lines.append(f"{each_path} sha256={content_digest}")
|
|
322
|
-
return "\n".join(manifest_lines)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def manifest_sha256(surface_manifest_text: str) -> str:
|
|
326
|
-
"""Hash a change-surface manifest.
|
|
327
|
-
|
|
328
|
-
Args:
|
|
329
|
-
surface_manifest_text: The manifest from ``branch_surface_manifest``.
|
|
330
|
-
|
|
331
|
-
Returns:
|
|
332
|
-
The hex sha256 digest of the encoded manifest text.
|
|
333
|
-
"""
|
|
334
|
-
return hashlib.sha256(surface_manifest_text.encode("utf-8")).hexdigest()
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def empty_surface_hash() -> str:
|
|
338
|
-
"""Return the manifest hash that represents an empty change surface.
|
|
339
|
-
|
|
340
|
-
A work tree whose HEAD equals the merge base has no changed or untracked
|
|
341
|
-
files, so ``branch_surface_manifest`` returns ``""`` and this hash is what
|
|
342
|
-
the minter or store CLI would produce for it. Comparing an attested hash
|
|
343
|
-
against this value lets the minter refuse to mint for a verifier that ran
|
|
344
|
-
in the wrong work tree.
|
|
345
|
-
|
|
346
|
-
Returns:
|
|
347
|
-
The hex sha256 digest of the empty surface manifest (the empty string).
|
|
348
|
-
"""
|
|
349
|
-
return manifest_sha256("")
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def worktree_path_for_branch(repo_directory: str, branch_name: str) -> str | None:
|
|
353
|
-
"""Find the work-tree directory that has a given branch checked out.
|
|
354
|
-
|
|
355
|
-
Parses the porcelain output of ``git worktree list --porcelain`` and
|
|
356
|
-
returns the ``worktree <path>`` line whose block carries a
|
|
357
|
-
``branch refs/heads/<branch_name>`` line. Returns None when git fails
|
|
358
|
-
or no work tree holds the branch.
|
|
359
|
-
|
|
360
|
-
Args:
|
|
361
|
-
repo_directory: Any directory inside the repository.
|
|
362
|
-
branch_name: The short branch name to locate (without ``refs/heads/``).
|
|
363
|
-
|
|
364
|
-
Returns:
|
|
365
|
-
The absolute path of the work tree that has the branch checked out,
|
|
366
|
-
or None when no work tree holds the branch or git fails.
|
|
367
|
-
"""
|
|
368
|
-
porcelain_output = run_git(repo_directory, "worktree", "list", "--porcelain")
|
|
369
|
-
if porcelain_output is None:
|
|
370
|
-
return None
|
|
371
|
-
target_branch_reference = f"{BRANCH_REFERENCE_PREFIX}{branch_name}"
|
|
372
|
-
current_worktree_path: str | None = None
|
|
373
|
-
for each_line in porcelain_output.splitlines():
|
|
374
|
-
if each_line.startswith(WORKTREE_LIST_PATH_PREFIX):
|
|
375
|
-
current_worktree_path = each_line[len(WORKTREE_LIST_PATH_PREFIX):]
|
|
376
|
-
elif each_line.startswith(WORKTREE_LIST_BRANCH_PREFIX):
|
|
377
|
-
branch_reference = each_line[len(WORKTREE_LIST_BRANCH_PREFIX):]
|
|
378
|
-
if branch_reference == target_branch_reference and current_worktree_path:
|
|
379
|
-
return current_worktree_path
|
|
380
|
-
return None
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def verdict_directory() -> Path:
|
|
384
|
-
"""Return the shared directory holding every work tree's verdict file.
|
|
385
|
-
|
|
386
|
-
Verdicts live outside any repository (under the user's Claude home) so no
|
|
387
|
-
repo accumulates untracked verdict files; every work tree's verdict shares
|
|
388
|
-
this one directory, distinguished by file name.
|
|
389
|
-
|
|
390
|
-
Returns:
|
|
391
|
-
The verdict directory under the user's Claude home.
|
|
392
|
-
"""
|
|
393
|
-
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def root_key_for_repo(repo_root: str) -> str:
|
|
397
|
-
"""Derive the shared path-key that names a work tree's store file.
|
|
398
|
-
|
|
399
|
-
::
|
|
400
|
-
|
|
401
|
-
root_key_for_repo("/repo") == root_key_for_repo("/repo/sub") (same tree)
|
|
402
|
-
root_key_for_repo("/Repo") == root_key_for_repo("/repo") (case-folded)
|
|
403
|
-
|
|
404
|
-
The path is resolved, forward-slashed, and lowercased before hashing, so a
|
|
405
|
-
subdirectory of a work tree keys to the same file as its root and case
|
|
406
|
-
differences fold together. Both the verdict store and the code-review stamp
|
|
407
|
-
store key their per-work-tree files by this one derivation, so the two never
|
|
408
|
-
drift and an existing verdict file still resolves.
|
|
409
|
-
|
|
410
|
-
Args:
|
|
411
|
-
repo_root: The repository top-level directory.
|
|
412
|
-
|
|
413
|
-
Returns:
|
|
414
|
-
The lowercased hex path-key, truncated to ``ROOT_KEY_HEX_LENGTH`` chars.
|
|
415
|
-
"""
|
|
416
|
-
normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
|
|
417
|
-
return hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def verdict_path_for_repo(repo_root: str) -> Path:
|
|
421
|
-
"""Derive the verdict file path for a repository work tree.
|
|
422
|
-
|
|
423
|
-
Verdicts live outside the repository (under the user's Claude home) so
|
|
424
|
-
no repo accumulates untracked files, keyed by the shared
|
|
425
|
-
``root_key_for_repo`` derivation so every worktree gets its own verdict.
|
|
426
|
-
|
|
427
|
-
Args:
|
|
428
|
-
repo_root: The repository top-level directory.
|
|
429
|
-
|
|
430
|
-
Returns:
|
|
431
|
-
The verdict file path for this work tree.
|
|
432
|
-
"""
|
|
433
|
-
return verdict_directory() / f"{root_key_for_repo(repo_root)}.json"
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
|
|
437
|
-
"""Load the verdict for a repo when it passes and covers the live surface.
|
|
438
|
-
|
|
439
|
-
Args:
|
|
440
|
-
repo_root: The repository top-level directory.
|
|
441
|
-
expected_manifest_sha256: Hash of the live surface manifest the
|
|
442
|
-
verdict must match exactly.
|
|
443
|
-
|
|
444
|
-
Returns:
|
|
445
|
-
The verdict mapping when it exists, parses, reports ``all_pass``
|
|
446
|
-
true, and binds to the expected manifest hash; otherwise None.
|
|
447
|
-
"""
|
|
448
|
-
verdict_file = verdict_path_for_repo(repo_root)
|
|
449
|
-
try:
|
|
450
|
-
verdict_record = json.loads(verdict_file.read_text(encoding="utf-8"))
|
|
451
|
-
except (OSError, json.JSONDecodeError):
|
|
452
|
-
return None
|
|
453
|
-
if not isinstance(verdict_record, dict):
|
|
454
|
-
return None
|
|
455
|
-
if verdict_record.get(VERDICT_KEY_ALL_PASS) is not True:
|
|
456
|
-
return None
|
|
457
|
-
if verdict_record.get(VERDICT_KEY_MANIFEST_SHA256) != expected_manifest_sha256:
|
|
458
|
-
return None
|
|
459
|
-
return verdict_record
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def minted_verdict_covers_surface(expected_manifest_sha256: str) -> bool:
|
|
463
|
-
"""Decide whether any minted verdict covers the live surface, keyed by hash.
|
|
464
|
-
|
|
465
|
-
A verdict's bound ``manifest_sha256`` commits to the exact set of surface
|
|
466
|
-
file paths and their byte contents; the work tree's location never enters
|
|
467
|
-
the hash. A clean verdict minted while verifying one work tree therefore
|
|
468
|
-
proves the same change surface in a sibling work tree of the same branch,
|
|
469
|
-
even though each work tree files its verdict under its own path-keyed name.
|
|
470
|
-
Scanning every verdict file by bound hash lets that verdict clear the
|
|
471
|
-
sibling's commit, while a verdict bound to a different hash — different
|
|
472
|
-
code — never matches. The path-keyed ``load_valid_verdict`` stays the fast
|
|
473
|
-
same-work-tree lookup; this is the cross-work-tree fallback.
|
|
474
|
-
|
|
475
|
-
Args:
|
|
476
|
-
expected_manifest_sha256: Hash of the live surface manifest the verdict
|
|
477
|
-
must match exactly.
|
|
478
|
-
|
|
479
|
-
Returns:
|
|
480
|
-
True as soon as one verdict file reports ``all_pass`` true and binds to
|
|
481
|
-
the expected hash; False when none match or the directory is absent.
|
|
482
|
-
"""
|
|
483
|
-
verdict_dir = verdict_directory()
|
|
484
|
-
if not verdict_dir.is_dir():
|
|
485
|
-
return False
|
|
486
|
-
for each_verdict_file in sorted(verdict_dir.glob(VERDICT_FILE_GLOB)):
|
|
487
|
-
try:
|
|
488
|
-
verdict_record = json.loads(each_verdict_file.read_text(encoding="utf-8"))
|
|
489
|
-
except (OSError, json.JSONDecodeError):
|
|
490
|
-
continue
|
|
491
|
-
if not isinstance(verdict_record, dict):
|
|
492
|
-
continue
|
|
493
|
-
if verdict_record.get(VERDICT_KEY_ALL_PASS) is not True:
|
|
494
|
-
continue
|
|
495
|
-
if verdict_record.get(VERDICT_KEY_MANIFEST_SHA256) == expected_manifest_sha256:
|
|
496
|
-
return True
|
|
497
|
-
return False
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
def _subagents_directory_for_transcript(transcript_path: str) -> Path | None:
|
|
501
|
-
"""Locate the live session's subagents directory from a transcript path.
|
|
502
|
-
|
|
503
|
-
Handles both transcript shapes the runtime produces: a transcript already
|
|
504
|
-
inside a ``.../subagents/...`` tree resolves to its nearest ancestor named
|
|
505
|
-
``subagents``; a session transcript ``<dir>/<session-id>.jsonl`` resolves
|
|
506
|
-
to ``<dir>/<session-id>/subagents``.
|
|
507
|
-
|
|
508
|
-
Args:
|
|
509
|
-
transcript_path: The live session's transcript path from the payload.
|
|
510
|
-
|
|
511
|
-
Returns:
|
|
512
|
-
The existing subagents directory, or None when neither shape yields
|
|
513
|
-
an existing directory.
|
|
514
|
-
"""
|
|
515
|
-
if not transcript_path:
|
|
516
|
-
return None
|
|
517
|
-
transcript_file = Path(transcript_path)
|
|
518
|
-
for each_ancestor in transcript_file.parents:
|
|
519
|
-
if each_ancestor.name == SUBAGENTS_DIRECTORY_NAME and each_ancestor.is_dir():
|
|
520
|
-
return each_ancestor
|
|
521
|
-
session_subagents_directory = (
|
|
522
|
-
transcript_file.with_suffix("") / SUBAGENTS_DIRECTORY_NAME
|
|
523
|
-
)
|
|
524
|
-
if session_subagents_directory.is_dir():
|
|
525
|
-
return session_subagents_directory
|
|
526
|
-
return None
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def _agent_type_for_transcript(transcript_file: Path) -> str | None:
|
|
530
|
-
"""Read an agent transcript's sidecar to learn the agent type it ran as.
|
|
531
|
-
|
|
532
|
-
The agent type comes solely from the harness-written
|
|
533
|
-
``agent-<id>.meta.json`` sidecar, which only the harness writes — the main
|
|
534
|
-
session can neither write the sidecar nor forge the agentType it records. A
|
|
535
|
-
verdict fence in the transcript is never used to infer the type, because the
|
|
536
|
-
main session controls the prompt of any Agent-tool subagent it spawns and
|
|
537
|
-
could instruct one to print such a fence.
|
|
538
|
-
|
|
539
|
-
Args:
|
|
540
|
-
transcript_file: An ``agent-*.jsonl`` transcript path.
|
|
541
|
-
|
|
542
|
-
Returns:
|
|
543
|
-
The ``agentType`` recorded in the ``<stem>.meta.json`` sidecar, or None
|
|
544
|
-
when the sidecar is absent, unreadable, or unparseable, it does not hold
|
|
545
|
-
a JSON object, or it names no string ``agentType``.
|
|
546
|
-
"""
|
|
547
|
-
sidecar_file = transcript_file.with_suffix(AGENT_META_SIDECAR_SUFFIX)
|
|
548
|
-
try:
|
|
549
|
-
sidecar_text = sidecar_file.read_text(encoding="utf-8")
|
|
550
|
-
except (OSError, UnicodeDecodeError):
|
|
551
|
-
return None
|
|
552
|
-
try:
|
|
553
|
-
sidecar_record = json.loads(sidecar_text)
|
|
554
|
-
except json.JSONDecodeError:
|
|
555
|
-
return None
|
|
556
|
-
if not isinstance(sidecar_record, dict):
|
|
557
|
-
return None
|
|
558
|
-
recorded_agent_type = sidecar_record.get(AGENT_META_TYPE_KEY)
|
|
559
|
-
return recorded_agent_type if isinstance(recorded_agent_type, str) else None
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def _assistant_text_blocks(transcript_file: Path) -> list[str]:
|
|
563
|
-
"""Collect every assistant text block from an agent transcript.
|
|
564
|
-
|
|
565
|
-
Args:
|
|
566
|
-
transcript_file: An ``agent-*.jsonl`` transcript path.
|
|
567
|
-
|
|
568
|
-
Returns:
|
|
569
|
-
The text of each assistant message content block, in order; empty
|
|
570
|
-
when the file is missing, unreadable, or holds no assistant text.
|
|
571
|
-
"""
|
|
572
|
-
try:
|
|
573
|
-
transcript_lines = (
|
|
574
|
-
transcript_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
575
|
-
)
|
|
576
|
-
except OSError:
|
|
577
|
-
return []
|
|
578
|
-
all_text_blocks: list[str] = []
|
|
579
|
-
for each_line in transcript_lines:
|
|
580
|
-
if not each_line.strip():
|
|
581
|
-
continue
|
|
582
|
-
try:
|
|
583
|
-
transcript_entry = json.loads(each_line)
|
|
584
|
-
except json.JSONDecodeError:
|
|
585
|
-
continue
|
|
586
|
-
all_text_blocks.extend(_entry_text_blocks(transcript_entry))
|
|
587
|
-
return all_text_blocks
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def _entry_text_blocks(transcript_entry: object) -> list[str]:
|
|
591
|
-
"""Extract assistant text from one parsed transcript entry.
|
|
592
|
-
|
|
593
|
-
Args:
|
|
594
|
-
transcript_entry: One parsed JSONL transcript entry.
|
|
595
|
-
|
|
596
|
-
Returns:
|
|
597
|
-
The text of each text content block on an assistant entry, in order;
|
|
598
|
-
empty for any other entry shape.
|
|
599
|
-
"""
|
|
600
|
-
if not isinstance(transcript_entry, dict):
|
|
601
|
-
return []
|
|
602
|
-
if transcript_entry.get(TRANSCRIPT_ENTRY_TYPE_KEY) != TRANSCRIPT_ASSISTANT_ENTRY_TYPE:
|
|
603
|
-
return []
|
|
604
|
-
message_record = transcript_entry.get(TRANSCRIPT_MESSAGE_KEY)
|
|
605
|
-
if not isinstance(message_record, dict):
|
|
606
|
-
return []
|
|
607
|
-
content_blocks = message_record.get(TRANSCRIPT_CONTENT_KEY)
|
|
608
|
-
if not isinstance(content_blocks, list):
|
|
609
|
-
return []
|
|
610
|
-
all_text_blocks: list[str] = []
|
|
611
|
-
for each_block in content_blocks:
|
|
612
|
-
if not isinstance(each_block, dict):
|
|
613
|
-
continue
|
|
614
|
-
if each_block.get(TRANSCRIPT_CONTENT_TYPE_KEY) != TRANSCRIPT_TEXT_CONTENT_TYPE:
|
|
615
|
-
continue
|
|
616
|
-
block_text = each_block.get(TRANSCRIPT_TEXT_KEY)
|
|
617
|
-
if isinstance(block_text, str):
|
|
618
|
-
all_text_blocks.append(block_text)
|
|
619
|
-
return all_text_blocks
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def _last_verdict_record(all_text_blocks: list[str]) -> dict | None:
|
|
623
|
-
"""Parse the last verdict fence across an agent's assistant text blocks.
|
|
624
|
-
|
|
625
|
-
Args:
|
|
626
|
-
all_text_blocks: The assistant text blocks from one transcript.
|
|
627
|
-
|
|
628
|
-
Returns:
|
|
629
|
-
The parsed verdict mapping when the last verdict fence carries a bool
|
|
630
|
-
``all_pass``, a list ``findings``, and a string ``manifest_sha256``;
|
|
631
|
-
otherwise None.
|
|
632
|
-
"""
|
|
633
|
-
verdict_fence_pattern = re.compile(VERDICT_FENCE_PATTERN, re.DOTALL)
|
|
634
|
-
all_fence_bodies = [
|
|
635
|
-
each_match.group(1)
|
|
636
|
-
for each_block in all_text_blocks
|
|
637
|
-
for each_match in verdict_fence_pattern.finditer(each_block)
|
|
638
|
-
]
|
|
639
|
-
if not all_fence_bodies:
|
|
640
|
-
return None
|
|
641
|
-
try:
|
|
642
|
-
verdict_record = json.loads(all_fence_bodies[-1])
|
|
643
|
-
except json.JSONDecodeError:
|
|
644
|
-
return None
|
|
645
|
-
if not isinstance(verdict_record, dict):
|
|
646
|
-
return None
|
|
647
|
-
if not isinstance(verdict_record.get(VERDICT_KEY_ALL_PASS), bool):
|
|
648
|
-
return None
|
|
649
|
-
if not isinstance(verdict_record.get(VERDICT_KEY_FINDINGS), list):
|
|
650
|
-
return None
|
|
651
|
-
if not isinstance(verdict_record.get(VERDICT_KEY_MANIFEST_SHA256), str):
|
|
652
|
-
return None
|
|
653
|
-
return verdict_record
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def workflow_verdict_covers_surface(
|
|
657
|
-
transcript_path: str, expected_manifest_sha256: str
|
|
658
|
-
) -> bool:
|
|
659
|
-
"""Decide whether a workflow code-verifier verdict covers the live surface.
|
|
660
|
-
|
|
661
|
-
A workflow-spawned ``code-verifier`` emits its verdict as assistant text in
|
|
662
|
-
its own transcript rather than through the SubagentStop minter, so this
|
|
663
|
-
walks the live session's subagent transcripts for a ``code-verifier`` whose
|
|
664
|
-
final verdict reports ``all_pass`` true and binds to the expected manifest
|
|
665
|
-
hash.
|
|
666
|
-
|
|
667
|
-
Args:
|
|
668
|
-
transcript_path: The live session's transcript path from the payload.
|
|
669
|
-
expected_manifest_sha256: Hash of the live surface manifest the verdict
|
|
670
|
-
must match exactly.
|
|
671
|
-
|
|
672
|
-
Returns:
|
|
673
|
-
True as soon as one ``code-verifier`` transcript carries a passing
|
|
674
|
-
verdict bound to the expected hash; False when none match or the
|
|
675
|
-
subagents directory cannot be located.
|
|
676
|
-
"""
|
|
677
|
-
subagents_directory = _subagents_directory_for_transcript(transcript_path)
|
|
678
|
-
if subagents_directory is None:
|
|
679
|
-
return False
|
|
680
|
-
for each_transcript_file in subagents_directory.rglob(AGENT_TRANSCRIPT_GLOB):
|
|
681
|
-
if _agent_type_for_transcript(each_transcript_file) != MINTING_AGENT_TYPE:
|
|
682
|
-
continue
|
|
683
|
-
verdict_record = _last_verdict_record(
|
|
684
|
-
_assistant_text_blocks(each_transcript_file)
|
|
685
|
-
)
|
|
686
|
-
if verdict_record is None:
|
|
687
|
-
continue
|
|
688
|
-
if verdict_record[VERDICT_KEY_ALL_PASS] is not True:
|
|
689
|
-
continue
|
|
690
|
-
if verdict_record[VERDICT_KEY_MANIFEST_SHA256] == expected_manifest_sha256:
|
|
691
|
-
return True
|
|
692
|
-
return False
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def _finding_check_names(all_findings: list) -> list[str]:
|
|
696
|
-
"""Collect the ``check`` name from each finding mapping that carries one.
|
|
697
|
-
|
|
698
|
-
Args:
|
|
699
|
-
all_findings: The verifier's findings list (empty when clean).
|
|
700
|
-
|
|
701
|
-
Returns:
|
|
702
|
-
Ordered list of non-empty check name strings; entries without a
|
|
703
|
-
string ``check`` field are omitted.
|
|
704
|
-
"""
|
|
705
|
-
all_check_names: list[str] = []
|
|
706
|
-
for each_finding in all_findings:
|
|
707
|
-
if not isinstance(each_finding, Mapping):
|
|
708
|
-
continue
|
|
709
|
-
check_name = each_finding.get(VERDICT_FINDING_CHECK_KEY)
|
|
710
|
-
if isinstance(check_name, str) and check_name:
|
|
711
|
-
all_check_names.append(check_name)
|
|
712
|
-
return all_check_names
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
def _append_verdict_ledger_row(
|
|
716
|
-
repo_root: str,
|
|
717
|
-
bound_manifest_sha256: str,
|
|
718
|
-
is_all_pass: bool,
|
|
719
|
-
all_findings: list,
|
|
720
|
-
minted_from_agent_id: str,
|
|
721
|
-
) -> None:
|
|
722
|
-
"""Append one fail-safe JSONL row for a minted verdict.
|
|
723
|
-
|
|
724
|
-
Writes to ``~/.claude/logs/verifier-verdicts.jsonl``. Swallows home
|
|
725
|
-
resolution and IO errors so a ledger failure never changes the mint.
|
|
726
|
-
|
|
727
|
-
Args:
|
|
728
|
-
repo_root: The repository top-level directory.
|
|
729
|
-
bound_manifest_sha256: Hash of the surface the verdict covers.
|
|
730
|
-
is_all_pass: Whether the verifier reported a clean verdict.
|
|
731
|
-
all_findings: The verifier's findings list (empty when clean).
|
|
732
|
-
minted_from_agent_id: The subagent invocation id, kept for audit.
|
|
733
|
-
"""
|
|
734
|
-
try:
|
|
735
|
-
home_directory = Path.home()
|
|
736
|
-
except RuntimeError:
|
|
737
|
-
return
|
|
738
|
-
try:
|
|
739
|
-
ledger_path = (
|
|
740
|
-
home_directory
|
|
741
|
-
/ CLAUDE_HOME_DIRECTORY_NAME
|
|
742
|
-
/ LOGS_DIRECTORY_NAME
|
|
743
|
-
/ VERIFIER_VERDICTS_JSONL_FILENAME
|
|
744
|
-
)
|
|
745
|
-
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
746
|
-
branch_name = _head_branch_name(repo_root)
|
|
747
|
-
ledger_row = {
|
|
748
|
-
LEDGER_KEY_TIMESTAMP: datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
749
|
-
LEDGER_KEY_REPO_ROOT: repo_root,
|
|
750
|
-
LEDGER_KEY_BRANCH: branch_name,
|
|
751
|
-
LEDGER_KEY_MANIFEST_HASH: bound_manifest_sha256,
|
|
752
|
-
LEDGER_KEY_ALL_PASS: is_all_pass,
|
|
753
|
-
LEDGER_KEY_FINDING_COUNT: len(all_findings),
|
|
754
|
-
LEDGER_KEY_FINDING_CHECK_NAMES: _finding_check_names(all_findings),
|
|
755
|
-
LEDGER_KEY_MINTED_FROM_AGENT_ID: minted_from_agent_id,
|
|
756
|
-
}
|
|
757
|
-
with ledger_path.open("a", encoding="utf-8") as ledger_file:
|
|
758
|
-
ledger_file.write(json.dumps(ledger_row) + "\n")
|
|
759
|
-
except OSError:
|
|
760
|
-
return
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
def write_verdict(
|
|
764
|
-
repo_root: str,
|
|
765
|
-
bound_manifest_sha256: str,
|
|
766
|
-
is_all_pass: bool,
|
|
767
|
-
all_findings: list,
|
|
768
|
-
minted_from_agent_id: str,
|
|
769
|
-
) -> Path:
|
|
770
|
-
"""Write a verdict file binding a verification outcome to a surface hash.
|
|
771
|
-
|
|
772
|
-
Also appends one durable JSONL ledger row under the Claude logs directory.
|
|
773
|
-
A ledger write failure never changes the mint outcome.
|
|
774
|
-
|
|
775
|
-
Args:
|
|
776
|
-
repo_root: The repository top-level directory.
|
|
777
|
-
bound_manifest_sha256: Hash of the surface manifest the verdict covers.
|
|
778
|
-
is_all_pass: Whether the verifier reported a clean verdict.
|
|
779
|
-
all_findings: The verifier's findings list (empty when clean).
|
|
780
|
-
minted_from_agent_id: The subagent invocation id, kept for audit.
|
|
781
|
-
|
|
782
|
-
Returns:
|
|
783
|
-
The path the verdict was written to.
|
|
784
|
-
"""
|
|
785
|
-
verdict_file = verdict_path_for_repo(repo_root)
|
|
786
|
-
verdict_file.parent.mkdir(parents=True, exist_ok=True)
|
|
787
|
-
verdict_record = {
|
|
788
|
-
VERDICT_KEY_ALL_PASS: is_all_pass,
|
|
789
|
-
VERDICT_KEY_MANIFEST_SHA256: bound_manifest_sha256,
|
|
790
|
-
"repo_root": repo_root,
|
|
791
|
-
"findings": all_findings,
|
|
792
|
-
"minted_from_agent_id": minted_from_agent_id,
|
|
793
|
-
"minted_at_epoch_seconds": int(time.time()),
|
|
794
|
-
}
|
|
795
|
-
verdict_file.write_text(
|
|
796
|
-
json.dumps(verdict_record, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
797
|
-
)
|
|
798
|
-
_append_verdict_ledger_row(
|
|
799
|
-
repo_root,
|
|
800
|
-
bound_manifest_sha256,
|
|
801
|
-
is_all_pass,
|
|
802
|
-
all_findings,
|
|
803
|
-
minted_from_agent_id,
|
|
804
|
-
)
|
|
805
|
-
return verdict_file
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
def stripped_ast_dump(python_source: str) -> str | None:
|
|
809
|
-
"""Dump a Python module's AST with every docstring removed.
|
|
810
|
-
|
|
811
|
-
Comments never reach the AST, so two sources with equal stripped dumps
|
|
812
|
-
differ only in docstrings, comments, or formatting — never in behavior.
|
|
813
|
-
|
|
814
|
-
Args:
|
|
815
|
-
python_source: The module source text.
|
|
816
|
-
|
|
817
|
-
Returns:
|
|
818
|
-
The ``ast.dump`` text of the stripped tree, or None when the source
|
|
819
|
-
does not parse (callers treat unparseable sources as changed).
|
|
820
|
-
"""
|
|
821
|
-
try:
|
|
822
|
-
module_tree = ast.parse(python_source)
|
|
823
|
-
except (SyntaxError, ValueError):
|
|
824
|
-
return None
|
|
825
|
-
for each_node in ast.walk(module_tree):
|
|
826
|
-
if not isinstance(
|
|
827
|
-
each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
828
|
-
):
|
|
829
|
-
continue
|
|
830
|
-
node_body = each_node.body
|
|
831
|
-
if (
|
|
832
|
-
node_body
|
|
833
|
-
and isinstance(node_body[0], ast.Expr)
|
|
834
|
-
and isinstance(node_body[0].value, ast.Constant)
|
|
835
|
-
and isinstance(node_body[0].value.value, str)
|
|
836
|
-
):
|
|
837
|
-
each_node.body = node_body[1:] or [ast.Pass()]
|
|
838
|
-
return ast.dump(module_tree)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
def _is_python_change_docstring_only(
|
|
842
|
-
repo_root: str, merge_base_sha: str, repo_relative_path: str
|
|
843
|
-
) -> bool:
|
|
844
|
-
"""Decide whether one Python file changed only in docstrings or comments.
|
|
845
|
-
|
|
846
|
-
Args:
|
|
847
|
-
repo_root: The repository top-level directory.
|
|
848
|
-
merge_base_sha: The merge-base commit holding the old version.
|
|
849
|
-
repo_relative_path: The file's path relative to the repo root.
|
|
850
|
-
|
|
851
|
-
Returns:
|
|
852
|
-
True only when both versions parse and their docstring-stripped
|
|
853
|
-
ASTs match exactly.
|
|
854
|
-
"""
|
|
855
|
-
old_source = run_git(repo_root, "show", f"{merge_base_sha}:{repo_relative_path}")
|
|
856
|
-
if old_source is None:
|
|
857
|
-
return False
|
|
858
|
-
try:
|
|
859
|
-
new_source = (Path(repo_root) / repo_relative_path).read_text(
|
|
860
|
-
encoding="utf-8", errors="replace"
|
|
861
|
-
)
|
|
862
|
-
except OSError:
|
|
863
|
-
return False
|
|
864
|
-
old_dump = stripped_ast_dump(old_source)
|
|
865
|
-
new_dump = stripped_ast_dump(new_source)
|
|
866
|
-
return old_dump is not None and old_dump == new_dump
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
def _is_test_file_path(repo_relative_path: str) -> bool:
|
|
870
|
-
"""Decide whether a path names a pytest test file.
|
|
871
|
-
|
|
872
|
-
Args:
|
|
873
|
-
repo_relative_path: The file's path relative to the repo root.
|
|
874
|
-
|
|
875
|
-
Returns:
|
|
876
|
-
True when the file name follows a pytest collection convention
|
|
877
|
-
(``test_*.py``, ``*_test.py``, or ``conftest.py``).
|
|
878
|
-
"""
|
|
879
|
-
file_name = Path(repo_relative_path).name
|
|
880
|
-
if file_name == CONFTEST_FILE_NAME:
|
|
881
|
-
return True
|
|
882
|
-
if not file_name.endswith(PYTHON_EXTENSION):
|
|
883
|
-
return False
|
|
884
|
-
return file_name.startswith(TEST_FILE_PREFIX) or file_name.endswith(TEST_FILE_SUFFIX)
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
def is_verification_exempt_diff(repo_root: str, merge_base_sha: str) -> bool:
|
|
888
|
-
"""Decide the mechanical exemption: nothing production-behavioral changed.
|
|
889
|
-
|
|
890
|
-
A diff is exempt only when every changed file is a docs/image file (by
|
|
891
|
-
extension), a pytest test file (by name convention), or a Python file
|
|
892
|
-
whose docstring-stripped AST is unchanged. Untracked files count as
|
|
893
|
-
changes: only docs-extension and test-named ones are exempt, since an
|
|
894
|
-
untracked production Python file has no merge-base version to compare
|
|
895
|
-
against. Renames are decomposed into a delete plus an add
|
|
896
|
-
(``--no-renames``) so renaming code to a docs extension still gates
|
|
897
|
-
the deletion. Production edits key on a fact the diff author cannot
|
|
898
|
-
steer — any behavioral edit changes the AST and gets gated. Test files
|
|
899
|
-
are exempt by policy: a test-only surface cannot change production
|
|
900
|
-
behavior, and test quality is covered by review, not by the verifier.
|
|
901
|
-
|
|
902
|
-
Args:
|
|
903
|
-
repo_root: The repository top-level directory.
|
|
904
|
-
merge_base_sha: The merge-base commit sha the branch grew from.
|
|
905
|
-
|
|
906
|
-
Returns:
|
|
907
|
-
True when every change is exempt; False otherwise, and False
|
|
908
|
-
whenever git output cannot be read (fail closed).
|
|
909
|
-
"""
|
|
910
|
-
name_status_text = run_git(
|
|
911
|
-
repo_root, "-c", "core.quotePath=false", "diff", "--name-status", "--no-renames",
|
|
912
|
-
merge_base_sha,
|
|
913
|
-
)
|
|
914
|
-
if name_status_text is None:
|
|
915
|
-
return False
|
|
916
|
-
untracked_paths = untracked_file_paths(repo_root)
|
|
917
|
-
if untracked_paths is None:
|
|
918
|
-
return False
|
|
919
|
-
for each_untracked_path in untracked_paths:
|
|
920
|
-
if _is_test_file_path(each_untracked_path):
|
|
921
|
-
continue
|
|
922
|
-
if Path(each_untracked_path).suffix.lower() not in DOCS_ONLY_EXTENSIONS:
|
|
923
|
-
return False
|
|
924
|
-
if not name_status_text:
|
|
925
|
-
return True
|
|
926
|
-
for each_status_line in name_status_text.splitlines():
|
|
927
|
-
status_fields = each_status_line.split("\t")
|
|
928
|
-
if len(status_fields) < MINIMUM_STATUS_FIELD_COUNT:
|
|
929
|
-
return False
|
|
930
|
-
change_code = status_fields[0]
|
|
931
|
-
changed_path = status_fields[-1]
|
|
932
|
-
if _is_test_file_path(changed_path):
|
|
933
|
-
continue
|
|
934
|
-
file_extension = Path(changed_path).suffix.lower()
|
|
935
|
-
if file_extension in DOCS_ONLY_EXTENSIONS:
|
|
936
|
-
continue
|
|
937
|
-
if file_extension != PYTHON_EXTENSION:
|
|
938
|
-
return False
|
|
939
|
-
if not change_code.startswith("M"):
|
|
940
|
-
return False
|
|
941
|
-
if not _is_python_change_docstring_only(repo_root, merge_base_sha, changed_path):
|
|
942
|
-
return False
|
|
943
|
-
return True
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
def _print_live_manifest_hash(repo_directory: str) -> int:
|
|
947
|
-
"""Print the live surface manifest hash for a repo, for a workflow verifier.
|
|
948
|
-
|
|
949
|
-
A workflow code-verifier runs this to learn the exact hash to bind its
|
|
950
|
-
verdict to, so stdout carries only the hash and nothing else. When the
|
|
951
|
-
work tree has no changed or untracked files (empty change surface), this
|
|
952
|
-
prints the empty-surface guard message to stderr and returns nonzero —
|
|
953
|
-
an empty surface means the verifier is pointed at the wrong work tree.
|
|
954
|
-
|
|
955
|
-
Args:
|
|
956
|
-
repo_directory: A directory inside the work tree to bind the verdict to.
|
|
957
|
-
|
|
958
|
-
Returns:
|
|
959
|
-
0 after printing the hash; nonzero with no stdout when the repo root,
|
|
960
|
-
merge base, or manifest cannot be resolved, or when the change surface
|
|
961
|
-
is empty (wrong work tree).
|
|
962
|
-
"""
|
|
963
|
-
repo_root = resolve_repo_root(repo_directory)
|
|
964
|
-
if repo_root is None:
|
|
965
|
-
return 1
|
|
966
|
-
merge_base_sha = resolve_merge_base(repo_root)
|
|
967
|
-
if merge_base_sha is None:
|
|
968
|
-
return 1
|
|
969
|
-
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
970
|
-
if surface_manifest_text is None:
|
|
971
|
-
return 1
|
|
972
|
-
if surface_manifest_text == "":
|
|
973
|
-
print(EMPTY_SURFACE_GUARD_MESSAGE.format(repo_root=repo_root), file=sys.stderr)
|
|
974
|
-
return 1
|
|
975
|
-
print(manifest_sha256(surface_manifest_text))
|
|
976
|
-
return 0
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
def _print_branch_manifest_hash(branch_name: str) -> int:
|
|
980
|
-
"""Print the manifest hash for the work tree that holds a given branch.
|
|
981
|
-
|
|
982
|
-
Resolves the repository root from the current working directory, then
|
|
983
|
-
finds the work tree that has ``branch_name`` checked out, and delegates
|
|
984
|
-
to ``_print_live_manifest_hash`` for that work tree. This mode is immune
|
|
985
|
-
to the verifier's own cwd: it always hashes the work tree that holds the
|
|
986
|
-
branch under review, regardless of where the verifier itself is running.
|
|
987
|
-
|
|
988
|
-
Args:
|
|
989
|
-
branch_name: The short branch name to locate (without ``refs/heads/``).
|
|
990
|
-
|
|
991
|
-
Returns:
|
|
992
|
-
0 after printing the hash; nonzero with a stderr message when the
|
|
993
|
-
repo root cannot be resolved, no work tree holds the branch, or the
|
|
994
|
-
located work tree has an empty change surface.
|
|
995
|
-
"""
|
|
996
|
-
repo_root = resolve_repo_root(str(Path.cwd()))
|
|
997
|
-
if repo_root is None:
|
|
998
|
-
print("ERROR: Current directory is not inside a git repository.", file=sys.stderr)
|
|
999
|
-
return 1
|
|
1000
|
-
branch_worktree_path = worktree_path_for_branch(repo_root, branch_name)
|
|
1001
|
-
if branch_worktree_path is None:
|
|
1002
|
-
print(
|
|
1003
|
-
BRANCH_WORKTREE_ABSENT_MESSAGE.format(branch=branch_name),
|
|
1004
|
-
file=sys.stderr,
|
|
1005
|
-
)
|
|
1006
|
-
return 1
|
|
1007
|
-
return _print_live_manifest_hash(branch_worktree_path)
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
def main() -> None:
|
|
1011
|
-
"""Run the verdict-store CLI: compute the live surface-manifest hash.
|
|
1012
|
-
|
|
1013
|
-
Two modes:
|
|
1014
|
-
|
|
1015
|
-
``--manifest-hash <work-tree-dir>``
|
|
1016
|
-
Print the live ``manifest_sha256`` for the given work tree directory
|
|
1017
|
-
so a workflow code-verifier can bind its verdict to the exact surface
|
|
1018
|
-
the gate checks. Fails with a stderr message when the change surface
|
|
1019
|
-
is empty (wrong work tree).
|
|
1020
|
-
|
|
1021
|
-
``--manifest-hash-for-branch <branch>``
|
|
1022
|
-
Resolve the work tree that has ``<branch>`` checked out (via
|
|
1023
|
-
``git worktree list --porcelain``) and print its manifest hash.
|
|
1024
|
-
Immune to the verifier's own cwd — always targets the branch's own
|
|
1025
|
-
work tree. Fails when no work tree holds the branch or the surface
|
|
1026
|
-
is empty.
|
|
1027
|
-
|
|
1028
|
-
Exits nonzero with no stdout on any other argument shape or when the
|
|
1029
|
-
surface cannot be resolved.
|
|
1030
|
-
"""
|
|
1031
|
-
if len(sys.argv) == 3 and sys.argv[1] == MANIFEST_HASH_CLI_FLAG:
|
|
1032
|
-
sys.exit(_print_live_manifest_hash(sys.argv[2]))
|
|
1033
|
-
if len(sys.argv) == 3 and sys.argv[1] == MANIFEST_HASH_FOR_BRANCH_CLI_FLAG:
|
|
1034
|
-
sys.exit(_print_branch_manifest_hash(sys.argv[2]))
|
|
1035
|
-
sys.exit(1)
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if __name__ == "__main__":
|
|
1039
|
-
main()
|