claude-dev-env 2.4.0 → 2.7.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 +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -2
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +36 -8
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +72 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -0
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +177 -22
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +129 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- package/skills/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Tests for eli11_reply_enforcer hook response shape and reply-shape detection."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "eli11_reply_enforcer.py")
|
|
9
|
+
_HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
10
|
+
_HOOKS_ROOT = os.path.join(_HOOKS_DIR, "..")
|
|
11
|
+
if _HOOKS_DIR not in sys.path:
|
|
12
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
13
|
+
if _HOOKS_ROOT not in sys.path:
|
|
14
|
+
sys.path.insert(0, _HOOKS_ROOT)
|
|
15
|
+
import eli11_reply_enforcer
|
|
16
|
+
from hooks_constants.eli11_reply_enforcer_constants import (
|
|
17
|
+
MAXIMUM_BULLET_LINE_COUNT,
|
|
18
|
+
MAXIMUM_OVERPACKED_LINE_COUNT,
|
|
19
|
+
MAXIMUM_REPLY_WORD_COUNT,
|
|
20
|
+
MAXIMUM_WORDS_PER_LINE,
|
|
21
|
+
MINIMUM_ENFORCED_WORD_COUNT,
|
|
22
|
+
TARGET_BULLET_LINE_COUNT,
|
|
23
|
+
)
|
|
24
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
25
|
+
|
|
26
|
+
SAFE_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE // 2
|
|
27
|
+
OVERPACKED_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE + 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_filler_prose(word_count: int) -> str:
|
|
31
|
+
"""Return a single prose line holding exactly the requested number of words."""
|
|
32
|
+
return " ".join(f"finding{each_index}" for each_index in range(word_count))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_bullet_block(bullet_count: int, words_per_bullet: int) -> str:
|
|
36
|
+
"""Return a bullet list of the requested size, each bullet a filler line."""
|
|
37
|
+
return "\n".join(
|
|
38
|
+
f"- {build_filler_prose(words_per_bullet)}" for _ in range(bullet_count)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_prose_block(line_count: int, words_per_line: int) -> str:
|
|
43
|
+
"""Return a block of plain prose lines, each holding the requested words."""
|
|
44
|
+
return "\n".join(build_filler_prose(words_per_line) for _ in range(line_count))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_reply_of_exactly(total_word_count: int) -> str:
|
|
48
|
+
"""Return a filler reply holding exactly the requested words, no line overpacked.
|
|
49
|
+
|
|
50
|
+
::
|
|
51
|
+
|
|
52
|
+
in: 22 -> three lines of 10, 10, and 2 filler words
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
total_word_count: How many countable words the whole reply carries.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A newline-joined reply whose every line stays under the per-line cap.
|
|
59
|
+
"""
|
|
60
|
+
all_lines = []
|
|
61
|
+
remaining_word_count = total_word_count
|
|
62
|
+
while remaining_word_count > 0:
|
|
63
|
+
line_word_count = min(SAFE_LINE_WORD_COUNT, remaining_word_count)
|
|
64
|
+
all_lines.append(build_filler_prose(line_word_count))
|
|
65
|
+
remaining_word_count -= line_word_count
|
|
66
|
+
return "\n".join(all_lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
SHORT_REPLY_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT // 2
|
|
70
|
+
OVERLONG_REPLY_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT * 2
|
|
71
|
+
JUST_OVER_WORD_CAP_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT + 1
|
|
72
|
+
UNDER_FLOOR_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT - 1
|
|
73
|
+
|
|
74
|
+
SHORT_REPLY = build_reply_of_exactly(SHORT_REPLY_WORD_COUNT)
|
|
75
|
+
OVERLONG_REPLY = build_reply_of_exactly(OVERLONG_REPLY_WORD_COUNT)
|
|
76
|
+
UNDER_FLOOR_REPLY = build_reply_of_exactly(UNDER_FLOOR_WORD_COUNT)
|
|
77
|
+
JUST_OVER_WORD_CAP_REPLY = build_reply_of_exactly(JUST_OVER_WORD_CAP_WORD_COUNT)
|
|
78
|
+
THREE_OVERPACKED_LINE_REPLY = build_prose_block(
|
|
79
|
+
MAXIMUM_OVERPACKED_LINE_COUNT + 1, OVERPACKED_LINE_WORD_COUNT
|
|
80
|
+
)
|
|
81
|
+
TWO_OVERPACKED_LINES = build_prose_block(
|
|
82
|
+
MAXIMUM_OVERPACKED_LINE_COUNT, OVERPACKED_LINE_WORD_COUNT
|
|
83
|
+
)
|
|
84
|
+
TWO_OVERPACKED_LINE_REPLY = (
|
|
85
|
+
f"{TWO_OVERPACKED_LINES}\n{build_filler_prose(SAFE_LINE_WORD_COUNT)}"
|
|
86
|
+
)
|
|
87
|
+
SEVEN_BULLET_JUST_OVER_FLOOR_REPLY = build_bullet_block(
|
|
88
|
+
MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT
|
|
89
|
+
)
|
|
90
|
+
LONG_FORM_OVERLONG_REPLY = f"Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
|
|
91
|
+
BOLD_LONG_FORM_OVERLONG_REPLY = f"**Long form:** the audit report follows.\n\n{OVERLONG_REPLY}"
|
|
92
|
+
QUOTED_LONG_FORM_OVERLONG_REPLY = f"> Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
|
|
93
|
+
HEADING_LONG_FORM_OVERLONG_REPLY = f"# Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
|
|
94
|
+
|
|
95
|
+
INSTRUCTION_LINE = "Run the migration script."
|
|
96
|
+
INSTRUCTION_LINE_WORD_COUNT = len(INSTRUCTION_LINE.split())
|
|
97
|
+
|
|
98
|
+
ACTION_WITHOUT_STEPS_FIRST_REPLY = (
|
|
99
|
+
f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}\n\n"
|
|
100
|
+
f"{INSTRUCTION_LINE}\n\nMerge the branch."
|
|
101
|
+
)
|
|
102
|
+
ACTION_WITH_STEPS_FIRST_REPLY = (
|
|
103
|
+
"1. **Run** the migration script.\n"
|
|
104
|
+
"2. **Merge** the branch.\n\n"
|
|
105
|
+
f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}"
|
|
106
|
+
)
|
|
107
|
+
AT_FLOOR_ACTION_REPLY = (
|
|
108
|
+
f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT - INSTRUCTION_LINE_WORD_COUNT)}"
|
|
109
|
+
f"\n\n{INSTRUCTION_LINE}"
|
|
110
|
+
)
|
|
111
|
+
SEVEN_BULLET_REPLY = build_bullet_block(
|
|
112
|
+
MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT + 3
|
|
113
|
+
)
|
|
114
|
+
SIX_BULLET_REPLY = build_bullet_block(
|
|
115
|
+
MAXIMUM_BULLET_LINE_COUNT, SAFE_LINE_WORD_COUNT + 5
|
|
116
|
+
)
|
|
117
|
+
FENCED_CODE_REPLY = f"{SHORT_REPLY}\n\n```python\n{OVERLONG_REPLY}\n```\n"
|
|
118
|
+
BLOCKQUOTE_REPLY = f"{SHORT_REPLY}\n\n> {build_filler_prose(OVERLONG_REPLY_WORD_COUNT)}\n"
|
|
119
|
+
TABLE_REPLY = "{}\n\n{}\n".format(
|
|
120
|
+
SHORT_REPLY,
|
|
121
|
+
"\n".join(
|
|
122
|
+
f"| {build_filler_prose(MAXIMUM_WORDS_PER_LINE)} | cell |" for _ in range(15)
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
ALL_NARRATIVE_OPENER_LINES = (
|
|
127
|
+
"Open questions remain about the stripper edge cases.",
|
|
128
|
+
"Run time stays under one second on the package suite.",
|
|
129
|
+
"Merge is complete on main.",
|
|
130
|
+
)
|
|
131
|
+
NARRATIVE_OPENER_REPLY = "{}\n\n{}".format(
|
|
132
|
+
"\n".join(ALL_NARRATIVE_OPENER_LINES),
|
|
133
|
+
build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT),
|
|
134
|
+
)
|
|
135
|
+
INSTALL_WITHOUT_STEPS_FIRST_REPLY = (
|
|
136
|
+
f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
|
|
137
|
+
"Install the package from the registry."
|
|
138
|
+
)
|
|
139
|
+
DO_THINGS_WITH_STEPS_FIRST_REPLY = (
|
|
140
|
+
"Do 3 things:\n"
|
|
141
|
+
"1. Install the package.\n"
|
|
142
|
+
"2. Restart the daemon.\n"
|
|
143
|
+
"3. Save the file.\n\n"
|
|
144
|
+
f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}"
|
|
145
|
+
)
|
|
146
|
+
DO_THINGS_WITHOUT_STEPS_FIRST_REPLY = (
|
|
147
|
+
f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
|
|
148
|
+
"Do 3 things: install, restart, save."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess:
|
|
153
|
+
"""Run the hook script with the given Stop payload and capture its output."""
|
|
154
|
+
hook_input_payload = json.dumps(payload)
|
|
155
|
+
return subprocess.run(
|
|
156
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
157
|
+
input=hook_input_payload,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
check=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
|
|
165
|
+
"""Run the hook against a single assistant message."""
|
|
166
|
+
return run_hook_with_payload({"last_assistant_message": assistant_message})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
170
|
+
"""The hook reuses the shared stripper rather than re-implementing it."""
|
|
171
|
+
assert eli11_reply_enforcer.strip_code_and_quotes is strip_code_and_quotes
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_short_reply_passes_through() -> None:
|
|
175
|
+
"""A reply under the enforced word floor is never judged."""
|
|
176
|
+
completed_process = run_hook_with_message(SHORT_REPLY)
|
|
177
|
+
assert completed_process.returncode == 0
|
|
178
|
+
assert completed_process.stdout == ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_empty_message_passes_through() -> None:
|
|
182
|
+
"""A tool-only turn carries no assistant prose and passes."""
|
|
183
|
+
completed_process = run_hook_with_message("")
|
|
184
|
+
assert completed_process.returncode == 0
|
|
185
|
+
assert completed_process.stdout == ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_stop_hook_active_flag_passes_through() -> None:
|
|
189
|
+
"""A re-entrant Stop invocation never blocks again."""
|
|
190
|
+
completed_process = run_hook_with_payload(
|
|
191
|
+
{"last_assistant_message": OVERLONG_REPLY, "stop_hook_active": True}
|
|
192
|
+
)
|
|
193
|
+
assert completed_process.returncode == 0
|
|
194
|
+
assert completed_process.stdout == ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_reply_over_word_cap_emits_block() -> None:
|
|
198
|
+
"""A reply past the word cap blocks with the count and the cap named."""
|
|
199
|
+
completed_process = run_hook_with_message(OVERLONG_REPLY)
|
|
200
|
+
assert completed_process.returncode == 0
|
|
201
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
202
|
+
assert parsed_response["decision"] == "block"
|
|
203
|
+
assert str(OVERLONG_REPLY_WORD_COUNT) in parsed_response["reason"]
|
|
204
|
+
assert str(MAXIMUM_REPLY_WORD_COUNT) in parsed_response["reason"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_reply_just_over_word_cap_emits_block() -> None:
|
|
208
|
+
"""A reply one word past the cap blocks on length alone."""
|
|
209
|
+
completed_process = run_hook_with_message(JUST_OVER_WORD_CAP_REPLY)
|
|
210
|
+
assert completed_process.returncode == 0
|
|
211
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
212
|
+
assert parsed_response["decision"] == "block"
|
|
213
|
+
assert (
|
|
214
|
+
f"{JUST_OVER_WORD_CAP_WORD_COUNT} words, over the "
|
|
215
|
+
f"{MAXIMUM_REPLY_WORD_COUNT}-word cap"
|
|
216
|
+
) in parsed_response["reason"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_reply_under_word_floor_passes_through() -> None:
|
|
220
|
+
"""A reply one word under the floor is never judged."""
|
|
221
|
+
completed_process = run_hook_with_message(UNDER_FLOOR_REPLY)
|
|
222
|
+
assert completed_process.returncode == 0
|
|
223
|
+
assert completed_process.stdout == ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_reply_at_the_word_floor_is_judged_for_action_first() -> None:
|
|
227
|
+
"""A reply of exactly the floor word count earns its action-first violation."""
|
|
228
|
+
completed_process = run_hook_with_message(AT_FLOOR_ACTION_REPLY)
|
|
229
|
+
assert completed_process.returncode == 0
|
|
230
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
231
|
+
assert parsed_response["decision"] == "block"
|
|
232
|
+
assert "put the steps first" in parsed_response["reason"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_reply_just_over_word_floor_is_judged() -> None:
|
|
236
|
+
"""A bullet-heavy reply just over the floor earns its bullet violation."""
|
|
237
|
+
completed_process = run_hook_with_message(SEVEN_BULLET_JUST_OVER_FLOOR_REPLY)
|
|
238
|
+
assert completed_process.returncode == 0
|
|
239
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
240
|
+
assert parsed_response["decision"] == "block"
|
|
241
|
+
assert (
|
|
242
|
+
f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
|
|
243
|
+
in parsed_response["reason"]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_three_overpacked_lines_emit_block() -> None:
|
|
248
|
+
"""One line past the overpacked-line cap blocks on one idea per line."""
|
|
249
|
+
completed_process = run_hook_with_message(THREE_OVERPACKED_LINE_REPLY)
|
|
250
|
+
assert completed_process.returncode == 0
|
|
251
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
252
|
+
assert parsed_response["decision"] == "block"
|
|
253
|
+
assert "lines carry too many words - one idea per line" in (
|
|
254
|
+
parsed_response["reason"]
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_two_overpacked_lines_pass_through() -> None:
|
|
259
|
+
"""Over-packed lines at the cap pass."""
|
|
260
|
+
completed_process = run_hook_with_message(TWO_OVERPACKED_LINE_REPLY)
|
|
261
|
+
assert completed_process.returncode == 0
|
|
262
|
+
assert completed_process.stdout == ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_bullet_marker_is_not_counted_as_a_line_word() -> None:
|
|
266
|
+
"""A bullet at the per-line cap stays under it once its marker comes off."""
|
|
267
|
+
capped_bullet_lines = build_bullet_block(
|
|
268
|
+
MAXIMUM_BULLET_LINE_COUNT - 2, MAXIMUM_WORDS_PER_LINE
|
|
269
|
+
)
|
|
270
|
+
all_violations = eli11_reply_enforcer.find_reply_shape_violations(
|
|
271
|
+
capped_bullet_lines
|
|
272
|
+
)
|
|
273
|
+
assert all_violations == []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_long_form_prefix_exempts_an_overlong_reply() -> None:
|
|
277
|
+
"""The Long form escape hatch clears every reply-shape check."""
|
|
278
|
+
completed_process = run_hook_with_message(LONG_FORM_OVERLONG_REPLY)
|
|
279
|
+
assert completed_process.returncode == 0
|
|
280
|
+
assert completed_process.stdout == ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_bold_long_form_prefix_exempts_an_overlong_reply() -> None:
|
|
284
|
+
"""A bold-wrapped Long form prefix still opts the reply out."""
|
|
285
|
+
completed_process = run_hook_with_message(BOLD_LONG_FORM_OVERLONG_REPLY)
|
|
286
|
+
assert completed_process.returncode == 0
|
|
287
|
+
assert completed_process.stdout == ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_quoted_long_form_prefix_exempts_an_overlong_reply() -> None:
|
|
291
|
+
"""A blockquoted Long form prefix still opts the reply out."""
|
|
292
|
+
completed_process = run_hook_with_message(QUOTED_LONG_FORM_OVERLONG_REPLY)
|
|
293
|
+
assert completed_process.returncode == 0
|
|
294
|
+
assert completed_process.stdout == ""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_heading_long_form_prefix_exempts_an_overlong_reply() -> None:
|
|
298
|
+
"""A heading-wrapped Long form prefix still opts the reply out."""
|
|
299
|
+
completed_process = run_hook_with_message(HEADING_LONG_FORM_OVERLONG_REPLY)
|
|
300
|
+
assert completed_process.returncode == 0
|
|
301
|
+
assert completed_process.stdout == ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_instruction_lines_without_leading_steps_emit_block() -> None:
|
|
305
|
+
"""Imperative instructions buried under prose block with a steps-first message."""
|
|
306
|
+
completed_process = run_hook_with_message(ACTION_WITHOUT_STEPS_FIRST_REPLY)
|
|
307
|
+
assert completed_process.returncode == 0
|
|
308
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
309
|
+
assert parsed_response["decision"] == "block"
|
|
310
|
+
assert "put the steps first" in parsed_response["reason"]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_numbered_steps_in_lead_lines_pass_through() -> None:
|
|
314
|
+
"""Numbered steps inside the lead lines satisfy the action-first check."""
|
|
315
|
+
completed_process = run_hook_with_message(ACTION_WITH_STEPS_FIRST_REPLY)
|
|
316
|
+
assert completed_process.returncode == 0
|
|
317
|
+
assert completed_process.stdout == ""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_narrative_lines_opening_with_a_tracked_verb_pass_through() -> None:
|
|
321
|
+
"""Narrative openers such as "Open questions remain" are not instructions."""
|
|
322
|
+
completed_process = run_hook_with_message(NARRATIVE_OPENER_REPLY)
|
|
323
|
+
assert completed_process.returncode == 0
|
|
324
|
+
assert completed_process.stdout == ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_each_narrative_opener_is_not_an_instruction_line() -> None:
|
|
328
|
+
"""Every narrative opener reads as a sentence subject, not an imperative."""
|
|
329
|
+
for each_line in ALL_NARRATIVE_OPENER_LINES:
|
|
330
|
+
assert not eli11_reply_enforcer.is_imperative_instruction_line(each_line)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_object_word_after_leading_verb_reads_the_second_word() -> None:
|
|
334
|
+
"""The word after the opening verb comes back, list markers ignored."""
|
|
335
|
+
assert eli11_reply_enforcer.object_word_after_leading_verb(
|
|
336
|
+
"1. **Run** the migration script."
|
|
337
|
+
) == "the"
|
|
338
|
+
assert eli11_reply_enforcer.object_word_after_leading_verb("Merge") == ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_names_imperative_object_accepts_determiners_counts_and_paths() -> None:
|
|
342
|
+
"""A determiner, a count, or a path marks a real imperative object."""
|
|
343
|
+
assert eli11_reply_enforcer.names_imperative_object("the")
|
|
344
|
+
assert eli11_reply_enforcer.names_imperative_object("3")
|
|
345
|
+
assert eli11_reply_enforcer.names_imperative_object("scripts/deploy.py")
|
|
346
|
+
assert not eli11_reply_enforcer.names_imperative_object("questions")
|
|
347
|
+
assert not eli11_reply_enforcer.names_imperative_object("")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_strip_markdown_lead_markers_removes_every_wrapper() -> None:
|
|
351
|
+
"""Blockquote, heading, and bold wrappers come off the front of a line."""
|
|
352
|
+
assert eli11_reply_enforcer.strip_markdown_lead_markers(
|
|
353
|
+
"> **Long form:** the report follows"
|
|
354
|
+
) == "Long form:** the report follows"
|
|
355
|
+
assert eli11_reply_enforcer.strip_markdown_lead_markers(
|
|
356
|
+
"# Long form: the report follows"
|
|
357
|
+
) == "Long form: the report follows"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_install_instruction_without_leading_steps_emits_block() -> None:
|
|
361
|
+
"""An install instruction buried under prose blocks on action-first."""
|
|
362
|
+
completed_process = run_hook_with_message(INSTALL_WITHOUT_STEPS_FIRST_REPLY)
|
|
363
|
+
assert completed_process.returncode == 0
|
|
364
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
365
|
+
assert parsed_response["decision"] == "block"
|
|
366
|
+
assert "put the steps first" in parsed_response["reason"]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_do_three_things_without_leading_steps_emits_block() -> None:
|
|
370
|
+
"""The canonical "Do 3 things:" opener counts as an instruction line."""
|
|
371
|
+
completed_process = run_hook_with_message(DO_THINGS_WITHOUT_STEPS_FIRST_REPLY)
|
|
372
|
+
assert completed_process.returncode == 0
|
|
373
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
374
|
+
assert parsed_response["decision"] == "block"
|
|
375
|
+
assert "put the steps first" in parsed_response["reason"]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_do_three_things_with_numbered_steps_passes_through() -> None:
|
|
379
|
+
"""The canonical action-first opener with its numbered steps passes."""
|
|
380
|
+
completed_process = run_hook_with_message(DO_THINGS_WITH_STEPS_FIRST_REPLY)
|
|
381
|
+
assert completed_process.returncode == 0
|
|
382
|
+
assert completed_process.stdout == ""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_decimal_and_year_openers_are_not_numbered_steps() -> None:
|
|
386
|
+
"""A decimal or a year opening a line is prose, not a numbered step."""
|
|
387
|
+
assert not eli11_reply_enforcer.has_leading_numbered_step(
|
|
388
|
+
["1.5% of hosts still fail."]
|
|
389
|
+
)
|
|
390
|
+
assert not eli11_reply_enforcer.has_leading_numbered_step(
|
|
391
|
+
["2024. Revenue doubled."]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_numbered_step_opener_is_a_numbered_step() -> None:
|
|
396
|
+
"""A digit, a period, and a space open a real numbered step."""
|
|
397
|
+
assert eli11_reply_enforcer.has_leading_numbered_step(["1. Run the script"])
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_more_than_six_bullets_emits_block() -> None:
|
|
401
|
+
"""One bullet past the cap blocks with the cut-to-target-bullets message."""
|
|
402
|
+
completed_process = run_hook_with_message(SEVEN_BULLET_REPLY)
|
|
403
|
+
assert completed_process.returncode == 0
|
|
404
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
405
|
+
assert parsed_response["decision"] == "block"
|
|
406
|
+
assert (
|
|
407
|
+
f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
|
|
408
|
+
in parsed_response["reason"]
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_six_bullets_pass_through() -> None:
|
|
413
|
+
"""Bullets at the cap pass."""
|
|
414
|
+
completed_process = run_hook_with_message(SIX_BULLET_REPLY)
|
|
415
|
+
assert completed_process.returncode == 0
|
|
416
|
+
assert completed_process.stdout == ""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_fenced_code_words_are_not_counted() -> None:
|
|
420
|
+
"""Words inside a fenced code block never push a reply past the cap."""
|
|
421
|
+
completed_process = run_hook_with_message(FENCED_CODE_REPLY)
|
|
422
|
+
assert completed_process.returncode == 0
|
|
423
|
+
assert completed_process.stdout == ""
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_blockquote_words_are_not_counted() -> None:
|
|
427
|
+
"""Quoted lines are the user's words and never push a reply past the cap."""
|
|
428
|
+
completed_process = run_hook_with_message(BLOCKQUOTE_REPLY)
|
|
429
|
+
assert completed_process.returncode == 0
|
|
430
|
+
assert completed_process.stdout == ""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_table_rows_are_not_counted() -> None:
|
|
434
|
+
"""Table rows carry reference data and never push a reply past the cap."""
|
|
435
|
+
completed_process = run_hook_with_message(TABLE_REPLY)
|
|
436
|
+
assert completed_process.returncode == 0
|
|
437
|
+
assert completed_process.stdout == ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_urls_are_removed_from_counted_prose() -> None:
|
|
441
|
+
"""A link target is stripped before the words are counted."""
|
|
442
|
+
prose_text = eli11_reply_enforcer.extract_reply_prose(
|
|
443
|
+
"The draft is at https://github.com/owner/repo/pull/704 now"
|
|
444
|
+
)
|
|
445
|
+
assert "github.com" not in prose_text
|
|
446
|
+
assert "draft" in prose_text
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_block_response_json_shape() -> None:
|
|
450
|
+
"""The block payload carries the Stop-hook keys and names the escape hatch."""
|
|
451
|
+
completed_process = run_hook_with_message(OVERLONG_REPLY)
|
|
452
|
+
assert completed_process.returncode == 0
|
|
453
|
+
parsed_response = json.loads(completed_process.stdout)
|
|
454
|
+
assert parsed_response["decision"] == "block"
|
|
455
|
+
assert parsed_response["suppressOutput"] is True
|
|
456
|
+
assert parsed_response["systemMessage"]
|
|
457
|
+
assert "Long form:" in parsed_response["reason"]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tests for orchestrator_refresh_reschedule_gate PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
|
|
16
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
18
|
+
|
|
19
|
+
SKILL_SCRIPTS = HOOKS_DIRECTORY.parent / "skills" / "orchestrator" / "scripts"
|
|
20
|
+
if str(SKILL_SCRIPTS) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(SKILL_SCRIPTS))
|
|
22
|
+
|
|
23
|
+
from status_gate_constants.config.constants import RUN_STATUS_ACTIVE, RUN_STATUS_DONE # noqa: E402
|
|
24
|
+
from status_gate import write_status_file # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_gate_module() -> ModuleType:
|
|
28
|
+
module_path = BLOCKING_DIRECTORY / "orchestrator_refresh_reschedule_gate.py"
|
|
29
|
+
spec = importlib.util.spec_from_file_location(
|
|
30
|
+
"orchestrator_refresh_reschedule_gate",
|
|
31
|
+
module_path,
|
|
32
|
+
)
|
|
33
|
+
assert spec is not None
|
|
34
|
+
assert spec.loader is not None
|
|
35
|
+
gate_module = importlib.util.module_from_spec(spec)
|
|
36
|
+
sys.modules[spec.name] = gate_module
|
|
37
|
+
spec.loader.exec_module(gate_module)
|
|
38
|
+
return gate_module
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_main_with_payload(
|
|
42
|
+
gate_module: ModuleType,
|
|
43
|
+
payload: dict[str, object],
|
|
44
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
45
|
+
) -> tuple[int, str]:
|
|
46
|
+
stdin_buffer = io.StringIO(json.dumps(payload))
|
|
47
|
+
stdout_buffer = io.StringIO()
|
|
48
|
+
monkeypatch.setattr(sys, "stdin", stdin_buffer)
|
|
49
|
+
monkeypatch.setattr(sys, "stdout", stdout_buffer)
|
|
50
|
+
exit_code = 0
|
|
51
|
+
try:
|
|
52
|
+
gate_module.main()
|
|
53
|
+
except SystemExit as system_exit:
|
|
54
|
+
exit_code = int(system_exit.code or 0)
|
|
55
|
+
return exit_code, stdout_buffer.getvalue()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestOrchestratorRefreshRescheduleGate:
|
|
59
|
+
def should_allow_when_status_is_active(
|
|
60
|
+
self,
|
|
61
|
+
temporary_directory: Path,
|
|
62
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
63
|
+
) -> None:
|
|
64
|
+
status_file_path = temporary_directory / "status.json"
|
|
65
|
+
write_status_file(status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=False)
|
|
66
|
+
monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
|
|
67
|
+
gate_module = load_gate_module()
|
|
68
|
+
payload = {
|
|
69
|
+
"tool_name": "ScheduleWakeup",
|
|
70
|
+
"tool_input": {"prompt": "/orchestrator-refresh", "delaySeconds": 1200},
|
|
71
|
+
"cwd": str(temporary_directory),
|
|
72
|
+
}
|
|
73
|
+
exit_code, stdout_text = run_main_with_payload(
|
|
74
|
+
gate_module, payload, monkeypatch
|
|
75
|
+
)
|
|
76
|
+
assert exit_code == 0
|
|
77
|
+
assert stdout_text == ""
|
|
78
|
+
|
|
79
|
+
def should_deny_when_status_is_done(
|
|
80
|
+
self,
|
|
81
|
+
temporary_directory: Path,
|
|
82
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
83
|
+
) -> None:
|
|
84
|
+
status_file_path = temporary_directory / "status.json"
|
|
85
|
+
write_status_file(status_file_path, RUN_STATUS_DONE, "", is_rearm_pending=False)
|
|
86
|
+
monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
|
|
87
|
+
gate_module = load_gate_module()
|
|
88
|
+
payload = {
|
|
89
|
+
"tool_name": "ScheduleWakeup",
|
|
90
|
+
"tool_input": {"prompt": "/orchestrator-refresh re-arm"},
|
|
91
|
+
"cwd": str(temporary_directory),
|
|
92
|
+
}
|
|
93
|
+
exit_code, stdout_text = run_main_with_payload(
|
|
94
|
+
gate_module, payload, monkeypatch
|
|
95
|
+
)
|
|
96
|
+
assert exit_code == 0
|
|
97
|
+
denial = json.loads(stdout_text)
|
|
98
|
+
assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
99
|
+
|
|
100
|
+
def should_deny_when_status_file_missing(
|
|
101
|
+
self,
|
|
102
|
+
temporary_directory: Path,
|
|
103
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
104
|
+
) -> None:
|
|
105
|
+
missing_path = temporary_directory / "nope.json"
|
|
106
|
+
monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(missing_path))
|
|
107
|
+
gate_module = load_gate_module()
|
|
108
|
+
payload = {
|
|
109
|
+
"tool_name": "ScheduleWakeup",
|
|
110
|
+
"tool_input": {"prompt": "/orchestrator-refresh now"},
|
|
111
|
+
"cwd": str(temporary_directory),
|
|
112
|
+
}
|
|
113
|
+
_exit_code, stdout_text = run_main_with_payload(
|
|
114
|
+
gate_module, payload, monkeypatch
|
|
115
|
+
)
|
|
116
|
+
denial = json.loads(stdout_text)
|
|
117
|
+
assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
118
|
+
|
|
119
|
+
def should_deny_when_rearm_slot_already_pending(
|
|
120
|
+
self,
|
|
121
|
+
temporary_directory: Path,
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
123
|
+
) -> None:
|
|
124
|
+
status_file_path = temporary_directory / "status.json"
|
|
125
|
+
write_status_file(
|
|
126
|
+
status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=True
|
|
127
|
+
)
|
|
128
|
+
monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
|
|
129
|
+
gate_module = load_gate_module()
|
|
130
|
+
payload = {
|
|
131
|
+
"tool_name": "ScheduleWakeup",
|
|
132
|
+
"tool_input": {"prompt": "/orchestrator-refresh", "delaySeconds": 1200},
|
|
133
|
+
"cwd": str(temporary_directory),
|
|
134
|
+
}
|
|
135
|
+
_exit_code, stdout_text = run_main_with_payload(
|
|
136
|
+
gate_module, payload, monkeypatch
|
|
137
|
+
)
|
|
138
|
+
denial = json.loads(stdout_text)
|
|
139
|
+
assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
140
|
+
assert "rearm_already_pending" in denial["hookSpecificOutput"][
|
|
141
|
+
"permissionDecisionReason"
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
def should_always_deny_cron_create_for_orchestrator_refresh(
|
|
145
|
+
self,
|
|
146
|
+
temporary_directory: Path,
|
|
147
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
148
|
+
) -> None:
|
|
149
|
+
status_file_path = temporary_directory / "status.json"
|
|
150
|
+
write_status_file(status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=False)
|
|
151
|
+
monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
|
|
152
|
+
gate_module = load_gate_module()
|
|
153
|
+
payload = {
|
|
154
|
+
"tool_name": "CronCreate",
|
|
155
|
+
"tool_input": {"prompt": "/orchestrator-refresh"},
|
|
156
|
+
"cwd": str(temporary_directory),
|
|
157
|
+
}
|
|
158
|
+
_exit_code, stdout_text = run_main_with_payload(
|
|
159
|
+
gate_module, payload, monkeypatch
|
|
160
|
+
)
|
|
161
|
+
denial = json.loads(stdout_text)
|
|
162
|
+
assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
163
|
+
assert "cron_create_forbidden" in denial["hookSpecificOutput"][
|
|
164
|
+
"permissionDecisionReason"
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def should_ignore_unrelated_schedule_prompts(
|
|
168
|
+
self,
|
|
169
|
+
temporary_directory: Path,
|
|
170
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
171
|
+
) -> None:
|
|
172
|
+
gate_module = load_gate_module()
|
|
173
|
+
payload = {
|
|
174
|
+
"tool_name": "ScheduleWakeup",
|
|
175
|
+
"tool_input": {"prompt": "/pr-converge"},
|
|
176
|
+
"cwd": str(temporary_directory),
|
|
177
|
+
}
|
|
178
|
+
exit_code, stdout_text = run_main_with_payload(
|
|
179
|
+
gate_module, payload, monkeypatch
|
|
180
|
+
)
|
|
181
|
+
assert exit_code == 0
|
|
182
|
+
assert stdout_text == ""
|
|
183
|
+
|
|
184
|
+
def should_ignore_prose_mention_of_orchestrator_refresh(
|
|
185
|
+
self,
|
|
186
|
+
temporary_directory: Path,
|
|
187
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
188
|
+
) -> None:
|
|
189
|
+
gate_module = load_gate_module()
|
|
190
|
+
payload = {
|
|
191
|
+
"tool_name": "ScheduleWakeup",
|
|
192
|
+
"tool_input": {
|
|
193
|
+
"prompt": "See docs about /orchestrator-refresh for later"
|
|
194
|
+
},
|
|
195
|
+
"cwd": str(temporary_directory),
|
|
196
|
+
}
|
|
197
|
+
exit_code, stdout_text = run_main_with_payload(
|
|
198
|
+
gate_module, payload, monkeypatch
|
|
199
|
+
)
|
|
200
|
+
assert exit_code == 0
|
|
201
|
+
assert stdout_text == ""
|
|
202
|
+
|
|
203
|
+
def should_allow_when_prompt_carries_run_slug_for_scoped_status(
|
|
204
|
+
self,
|
|
205
|
+
temporary_directory: Path,
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
207
|
+
) -> None:
|
|
208
|
+
run_slug = "demo-run"
|
|
209
|
+
status_file_path = temporary_directory.joinpath(
|
|
210
|
+
"docs", "plans", run_slug, ".orchestrator-run-status.json"
|
|
211
|
+
)
|
|
212
|
+
write_status_file(status_file_path, RUN_STATUS_ACTIVE, run_slug, is_rearm_pending=False)
|
|
213
|
+
monkeypatch.delenv("ORCHESTRATOR_RUN_STATUS_FILE", raising=False)
|
|
214
|
+
gate_module = load_gate_module()
|
|
215
|
+
payload = {
|
|
216
|
+
"tool_name": "ScheduleWakeup",
|
|
217
|
+
"tool_input": {
|
|
218
|
+
"prompt": f"/orchestrator-refresh --run-slug {run_slug}"
|
|
219
|
+
},
|
|
220
|
+
"cwd": str(temporary_directory),
|
|
221
|
+
}
|
|
222
|
+
exit_code, stdout_text = run_main_with_payload(
|
|
223
|
+
gate_module, payload, monkeypatch
|
|
224
|
+
)
|
|
225
|
+
assert exit_code == 0
|
|
226
|
+
assert stdout_text == ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.fixture
|
|
230
|
+
def temporary_directory(tmp_path: Path) -> Path:
|
|
231
|
+
return tmp_path
|