claude-dev-env 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -48
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
- 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/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 -1
- 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 +3 -4
- 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/skill-writer-agent.md +84 -0
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/bin/CLAUDE.md +68 -5
- 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/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +2 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/references/CLAUDE.md +5 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +107 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +9 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +198 -0
- package/hooks/blocking/code_review_push_gate.py +145 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -155
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
- package/hooks/blocking/test_code_review_push_gate.py +205 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
- package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
- package/hooks/blocking/test_verification_verdict_store.py +974 -903
- 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/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1014
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +128 -0
- package/hooks/hooks.json +26 -1
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- 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 +2 -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/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +257 -25
- package/hooks/validators/run_all_validators.py +223 -19
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +160 -2
- package/hooks/validators/test_run_all_validators.py +140 -68
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +10 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +45 -67
- package/rules/durable-post-artifacts.md +7 -0
- package/rules/state-what-is.md +25 -0
- package/rules/verified-commit-gate-skip.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +23 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +626 -619
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/test_claude_chain_runner.py +131 -0
- 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_codec.py +77 -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/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -421
- package/skills/autoconverge/reference/convergence.md +26 -4
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
- package/skills/autoconverge/workflow/converge.mjs +191 -8
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -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/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 +81 -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 +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- 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 +162 -21
- 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 +110 -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/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- 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/skills/team-advisor/SKILL.md +2 -2
- package/scripts/test_invoke_code_review.py +0 -672
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Validate a completed Plan-to-PR packet using only the Python standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
if str(Path(__file__).parent) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
14
|
+
|
|
15
|
+
from config.constants import (
|
|
16
|
+
COMMAND_SEPARATOR,
|
|
17
|
+
HANDOFF_ACCEPTANCE_PREFIX,
|
|
18
|
+
HANDOFF_ALLOWED_FILES_PREFIX,
|
|
19
|
+
HANDOFF_APPROVAL_LINE,
|
|
20
|
+
HANDOFF_PACKET_PREFIX,
|
|
21
|
+
HANDOFF_TASK_ORDER_PREFIX,
|
|
22
|
+
HANDOFF_TASK_LINE_PATTERN,
|
|
23
|
+
HANDOFF_TEST_PREFIX,
|
|
24
|
+
HANDOFF_VERIFICATION_PREFIX,
|
|
25
|
+
MARKDOWN_FIELD_SEPARATOR,
|
|
26
|
+
PACKET_VALIDATION_ERROR_EXIT_CODE,
|
|
27
|
+
PATH_TRAVERSAL_TOKEN,
|
|
28
|
+
REPRODUCIBILITY_BLOCK_PATTERN,
|
|
29
|
+
ALL_REQUIRED_PACKET_FILES,
|
|
30
|
+
SLUG_PATTERN,
|
|
31
|
+
ALL_SOURCE_FIELDS,
|
|
32
|
+
SOURCE_LOCATOR_PATTERN,
|
|
33
|
+
ALL_TASK_COMMAND_FIELDS,
|
|
34
|
+
ALL_TASK_FIELDS,
|
|
35
|
+
TASK_LINE_PREFIX,
|
|
36
|
+
UNRESOLVED_TEXT_PATTERN,
|
|
37
|
+
DOCS_DIRECTORY_NAME,
|
|
38
|
+
MARKDOWN_HEADING_TEMPLATE,
|
|
39
|
+
NEWLINE_SEPARATOR,
|
|
40
|
+
PACKET_DIRECTORY_MINIMUM_PARTS,
|
|
41
|
+
PACKET_PARENT_PARTS_SLICE_START,
|
|
42
|
+
PACKET_SLUG_PARTS_SLICE_END,
|
|
43
|
+
ALL_PACKET_FIELDS,
|
|
44
|
+
ALL_PACKET_REQUIRED_FIELDS,
|
|
45
|
+
ALL_TASK_CONTRACT_FIELDS,
|
|
46
|
+
ALL_VALIDATION_FIELDS,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_packet(packet_directory: Path) -> list[str]:
|
|
51
|
+
"""Return field-level diagnostics for a packet directory.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
packet_directory: Packet directory to validate.
|
|
55
|
+
Returns:
|
|
56
|
+
Diagnostics, with an empty list for a valid packet.
|
|
57
|
+
"""
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
errors.extend(_validate_packet_boundary(packet_directory))
|
|
60
|
+
errors.extend(_validate_required_files(packet_directory))
|
|
61
|
+
packet_payload, packet_errors = _read_packet_payload(packet_directory)
|
|
62
|
+
errors.extend(packet_errors)
|
|
63
|
+
if packet_payload is None:
|
|
64
|
+
return errors
|
|
65
|
+
errors.extend(_validate_packet_fields(packet_payload, packet_directory))
|
|
66
|
+
errors.extend(_validate_sources(packet_payload, packet_directory))
|
|
67
|
+
errors.extend(_validate_tasks(packet_payload, packet_directory))
|
|
68
|
+
errors.extend(_validate_markdown(packet_payload, packet_directory))
|
|
69
|
+
return errors
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_packet_boundary(packet_directory: Path) -> list[str]:
|
|
73
|
+
"""Ensure the argument names a repository packet under docs/plans."""
|
|
74
|
+
resolved_path = packet_directory.resolve()
|
|
75
|
+
parts = resolved_path.parts
|
|
76
|
+
if len(parts) < PACKET_DIRECTORY_MINIMUM_PARTS or parts[
|
|
77
|
+
PACKET_PARENT_PARTS_SLICE_START:PACKET_SLUG_PARTS_SLICE_END
|
|
78
|
+
] != (
|
|
79
|
+
DOCS_DIRECTORY_NAME,
|
|
80
|
+
"plans",
|
|
81
|
+
):
|
|
82
|
+
return ["packet path must be within docs/plans/<slug>"]
|
|
83
|
+
if not SLUG_PATTERN.fullmatch(parts[-1]):
|
|
84
|
+
return ["packet path slug must contain lowercase letters, numbers, and hyphens"]
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_required_files(packet_directory: Path) -> list[str]:
|
|
89
|
+
"""Check the packet files required by the contract."""
|
|
90
|
+
return [
|
|
91
|
+
f"missing required file: {relative_path}"
|
|
92
|
+
for relative_path in ALL_REQUIRED_PACKET_FILES
|
|
93
|
+
if not (packet_directory / relative_path).is_file()
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_packet_payload(
|
|
98
|
+
packet_directory: Path,
|
|
99
|
+
) -> tuple[dict[str, object] | None, list[str]]:
|
|
100
|
+
"""Read packet.json and report parse or top-level shape errors."""
|
|
101
|
+
packet_file = packet_directory / "packet.json"
|
|
102
|
+
if not packet_file.is_file():
|
|
103
|
+
return None, []
|
|
104
|
+
try:
|
|
105
|
+
parsed_packet: object = json.loads(packet_file.read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, json.JSONDecodeError) as packet_error:
|
|
107
|
+
return None, [f"packet.json: invalid JSON: {packet_error}"]
|
|
108
|
+
if not isinstance(parsed_packet, dict):
|
|
109
|
+
return None, ["packet.json: expected an object"]
|
|
110
|
+
return parsed_packet, []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _validate_packet_fields(
|
|
114
|
+
all_packet: dict[str, object], packet_directory: Path
|
|
115
|
+
) -> list[str]:
|
|
116
|
+
"""Validate packet metadata and its repository-relative boundaries."""
|
|
117
|
+
errors = _validate_packet_field_names(all_packet)
|
|
118
|
+
errors.extend(_validate_packet_metadata(all_packet, packet_directory))
|
|
119
|
+
errors.extend(_validate_packet_lists(all_packet))
|
|
120
|
+
return errors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_packet_field_names(all_packet: dict[str, object]) -> list[str]:
|
|
124
|
+
"""Report unknown and missing packet fields."""
|
|
125
|
+
errors = [
|
|
126
|
+
f"packet.json: unknown field: {each_field}"
|
|
127
|
+
for each_field in sorted(set(all_packet) - set(ALL_PACKET_FIELDS))
|
|
128
|
+
]
|
|
129
|
+
errors.extend(
|
|
130
|
+
f"packet.json.{each_field}: required"
|
|
131
|
+
for each_field in ALL_PACKET_REQUIRED_FIELDS
|
|
132
|
+
if each_field not in all_packet
|
|
133
|
+
)
|
|
134
|
+
return errors
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_packet_metadata(
|
|
138
|
+
all_packet: dict[str, object], packet_directory: Path
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
"""Validate packet version, identity, status, and request."""
|
|
141
|
+
errors: list[str] = []
|
|
142
|
+
if all_packet.get("schema_version") != 1:
|
|
143
|
+
errors.append("packet.json.schema_version: must be 1")
|
|
144
|
+
if all_packet.get("slug") != packet_directory.name:
|
|
145
|
+
errors.append("packet.json.slug: must match the packet directory")
|
|
146
|
+
if all_packet.get("status") != "approved":
|
|
147
|
+
errors.append("packet.json.status: must be approved")
|
|
148
|
+
request = all_packet.get("request")
|
|
149
|
+
if not isinstance(request, str) or not request.strip():
|
|
150
|
+
errors.append("packet.json.request: must be a non-empty string")
|
|
151
|
+
errors.extend(
|
|
152
|
+
_validate_path_list(
|
|
153
|
+
all_packet.get("allowed_files"), "packet.json.allowed_files"
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
errors.extend(_validate_validation_record(all_packet.get("validation")))
|
|
157
|
+
return errors
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validate_packet_lists(all_packet: dict[str, object]) -> list[str]:
|
|
161
|
+
"""Validate packet decisions and resolved questions."""
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
decisions = all_packet.get("decisions")
|
|
164
|
+
if (
|
|
165
|
+
not isinstance(decisions, list)
|
|
166
|
+
or not decisions
|
|
167
|
+
or any(
|
|
168
|
+
not isinstance(each_decision, str) or not each_decision.strip()
|
|
169
|
+
for each_decision in decisions
|
|
170
|
+
)
|
|
171
|
+
):
|
|
172
|
+
errors.append("packet.json.decisions: must be a non-empty string list")
|
|
173
|
+
open_questions = all_packet.get("open_questions")
|
|
174
|
+
if not isinstance(open_questions, list) or any(
|
|
175
|
+
not isinstance(each_question, str) or not each_question.strip()
|
|
176
|
+
for each_question in open_questions
|
|
177
|
+
):
|
|
178
|
+
errors.append("packet.json.open_questions: must be a string list")
|
|
179
|
+
if all_packet.get("open_questions") != []:
|
|
180
|
+
errors.append("packet.json.open_questions: must be empty")
|
|
181
|
+
return errors
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _validate_path_list(all_paths: object, field_name: str) -> list[str]:
|
|
185
|
+
"""Validate a repository-relative path list."""
|
|
186
|
+
if not isinstance(all_paths, list) or not all_paths:
|
|
187
|
+
return [f"{field_name}: must be a non-empty list"]
|
|
188
|
+
errors: list[str] = []
|
|
189
|
+
for each_index, each_path in enumerate(all_paths):
|
|
190
|
+
if (
|
|
191
|
+
not isinstance(each_path, str)
|
|
192
|
+
or not each_path.strip()
|
|
193
|
+
or Path(each_path).is_absolute()
|
|
194
|
+
or "\\" in each_path
|
|
195
|
+
or PATH_TRAVERSAL_TOKEN in each_path
|
|
196
|
+
):
|
|
197
|
+
errors.append(
|
|
198
|
+
f"{field_name}[{each_index}]: must be a repository-relative path"
|
|
199
|
+
)
|
|
200
|
+
path_names = [each_path for each_path in all_paths if isinstance(each_path, str)]
|
|
201
|
+
if len(set(path_names)) != len(path_names):
|
|
202
|
+
errors.append(f"{field_name}: entries must be unique")
|
|
203
|
+
return errors
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _validate_validation_record(validation_record: object) -> list[str]:
|
|
207
|
+
"""Validate the packet validation evidence fields."""
|
|
208
|
+
if not isinstance(validation_record, dict):
|
|
209
|
+
return ["packet.json.validation: must be an object"]
|
|
210
|
+
allowed_fields = set(ALL_VALIDATION_FIELDS)
|
|
211
|
+
errors = [
|
|
212
|
+
f"packet.json.validation: unknown field: {each_field}"
|
|
213
|
+
for each_field in sorted(set(validation_record) - allowed_fields)
|
|
214
|
+
]
|
|
215
|
+
for each_field in ALL_VALIDATION_FIELDS[:-1]:
|
|
216
|
+
if validation_record.get(each_field) is not True:
|
|
217
|
+
errors.append(f"packet.json.validation.{each_field}: must be true")
|
|
218
|
+
if validation_record.get("validated_by") != "native-plan-to-pr":
|
|
219
|
+
errors.append("packet.json.validation.validated_by: must be native-plan-to-pr")
|
|
220
|
+
return errors
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_sources(
|
|
224
|
+
all_packet: dict[str, object], packet_directory: Path
|
|
225
|
+
) -> list[str]:
|
|
226
|
+
"""Ensure every source reference points to a non-empty repository file."""
|
|
227
|
+
sources = all_packet.get("sources")
|
|
228
|
+
if not isinstance(sources, list) or not sources:
|
|
229
|
+
return ["packet.json.sources: must be a list"]
|
|
230
|
+
repository_root = packet_directory.parents[2]
|
|
231
|
+
errors: list[str] = []
|
|
232
|
+
for each_index, each_source in enumerate(sources):
|
|
233
|
+
if not isinstance(each_source, dict):
|
|
234
|
+
errors.append(f"packet.json.sources[{each_index}]: must be an object")
|
|
235
|
+
continue
|
|
236
|
+
allowed_fields = set(ALL_SOURCE_FIELDS)
|
|
237
|
+
errors.extend(
|
|
238
|
+
f"packet.json.sources[{each_index}]: unknown field: {each_field}"
|
|
239
|
+
for each_field in sorted(set(each_source) - allowed_fields)
|
|
240
|
+
)
|
|
241
|
+
for each_field in ALL_SOURCE_FIELDS:
|
|
242
|
+
if (
|
|
243
|
+
not isinstance(each_source.get(each_field), str)
|
|
244
|
+
or not each_source[each_field].strip()
|
|
245
|
+
):
|
|
246
|
+
errors.append(
|
|
247
|
+
f"packet.json.sources[{each_index}].{each_field}: required non-empty string"
|
|
248
|
+
)
|
|
249
|
+
source_path = each_source.get("path")
|
|
250
|
+
if not isinstance(source_path, str):
|
|
251
|
+
continue
|
|
252
|
+
locator = each_source.get("locator")
|
|
253
|
+
if isinstance(locator, str) and not SOURCE_LOCATOR_PATTERN.fullmatch(
|
|
254
|
+
locator.strip()
|
|
255
|
+
):
|
|
256
|
+
errors.append(
|
|
257
|
+
f"packet.json.sources[{each_index}].locator: must be line, lines, or section locator"
|
|
258
|
+
)
|
|
259
|
+
if "\\" in source_path or PATH_TRAVERSAL_TOKEN in source_path:
|
|
260
|
+
errors.append(
|
|
261
|
+
f"packet.json.sources[{each_index}].path: must be a repository-relative path"
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
source_file = (repository_root / source_path).resolve()
|
|
265
|
+
if not _is_within(source_file, repository_root) or not source_file.is_file():
|
|
266
|
+
errors.append(
|
|
267
|
+
f"packet.json.sources[{each_index}].path: file does not exist"
|
|
268
|
+
)
|
|
269
|
+
elif not source_file.read_text(encoding="utf-8").strip():
|
|
270
|
+
errors.append(
|
|
271
|
+
f"packet.json.sources[{each_index}].path: source file is empty"
|
|
272
|
+
)
|
|
273
|
+
return errors
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _validate_tasks(all_packet: dict[str, object], packet_directory: Path) -> list[str]:
|
|
277
|
+
"""Validate task identity, scope, dependencies, and commands."""
|
|
278
|
+
tasks = all_packet.get("tasks")
|
|
279
|
+
if not isinstance(tasks, list) or not tasks:
|
|
280
|
+
return ["packet.json.tasks: must be a non-empty list"]
|
|
281
|
+
allowed_files = all_packet.get("allowed_files", [])
|
|
282
|
+
all_task_ids: set[str] = set()
|
|
283
|
+
dependencies_by_task: dict[str, list[str]] = {}
|
|
284
|
+
errors: list[str] = []
|
|
285
|
+
for each_index, each_task in enumerate(tasks):
|
|
286
|
+
if not isinstance(each_task, dict):
|
|
287
|
+
errors.append(f"packet.json.tasks[{each_index}]: must be an object")
|
|
288
|
+
continue
|
|
289
|
+
allowed_fields = set(ALL_TASK_CONTRACT_FIELDS)
|
|
290
|
+
errors.extend(
|
|
291
|
+
f"packet.json.tasks[{each_index}]: unknown field: {each_field}"
|
|
292
|
+
for each_field in sorted(set(each_task) - allowed_fields)
|
|
293
|
+
)
|
|
294
|
+
task_id = each_task.get("id")
|
|
295
|
+
if not isinstance(task_id, str) or not re.fullmatch(r"task-[0-9]+", task_id):
|
|
296
|
+
errors.append(f"packet.json.tasks[{each_index}].id: must match task-N")
|
|
297
|
+
continue
|
|
298
|
+
if task_id in all_task_ids:
|
|
299
|
+
errors.append(f"packet.json.tasks[{each_index}].id: duplicate {task_id}")
|
|
300
|
+
all_task_ids.add(task_id)
|
|
301
|
+
for each_field in ALL_TASK_FIELDS:
|
|
302
|
+
if (
|
|
303
|
+
not isinstance(each_task.get(each_field), str)
|
|
304
|
+
or not each_task[each_field].strip()
|
|
305
|
+
):
|
|
306
|
+
errors.append(
|
|
307
|
+
f"packet.json.tasks[{each_index}].{each_field}: required non-empty string"
|
|
308
|
+
)
|
|
309
|
+
task_files = each_task.get("allowed_files")
|
|
310
|
+
errors.extend(
|
|
311
|
+
_validate_path_list(
|
|
312
|
+
task_files, f"packet.json.tasks[{each_index}].allowed_files"
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
if isinstance(task_files, list) and isinstance(allowed_files, list):
|
|
316
|
+
errors.extend(
|
|
317
|
+
f"packet.json.tasks[{each_index}].allowed_files[{each_file_index}]: outside packet scope"
|
|
318
|
+
for each_file_index, each_path in enumerate(task_files)
|
|
319
|
+
if each_path not in allowed_files
|
|
320
|
+
)
|
|
321
|
+
if each_task.get("commit") != 1:
|
|
322
|
+
errors.append(f"packet.json.tasks[{each_index}].commit: must be 1")
|
|
323
|
+
for each_command_field in ALL_TASK_COMMAND_FIELDS:
|
|
324
|
+
command = each_task.get(each_command_field)
|
|
325
|
+
if isinstance(command, str) and not _is_reproducible_command(command):
|
|
326
|
+
errors.append(
|
|
327
|
+
f"packet.json.tasks[{each_index}].{each_command_field}: must be a concrete reproducible command"
|
|
328
|
+
)
|
|
329
|
+
dependencies_by_task[task_id] = _validate_dependencies(
|
|
330
|
+
each_task, task_id, all_task_ids, errors
|
|
331
|
+
)
|
|
332
|
+
errors.extend(_validate_dependency_cycles(dependencies_by_task))
|
|
333
|
+
return errors
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _validate_dependencies(
|
|
337
|
+
all_task: dict[str, object],
|
|
338
|
+
task_id: str,
|
|
339
|
+
all_task_ids: set[str],
|
|
340
|
+
all_errors: list[str],
|
|
341
|
+
) -> list[str]:
|
|
342
|
+
"""Validate one task's optional dependency list."""
|
|
343
|
+
dependencies = all_task.get("dependencies", [])
|
|
344
|
+
if not isinstance(dependencies, list):
|
|
345
|
+
all_errors.append(f"packet.json.tasks[{task_id}].dependencies: must be a list")
|
|
346
|
+
return []
|
|
347
|
+
dependency_names = [
|
|
348
|
+
each_dependency
|
|
349
|
+
for each_dependency in dependencies
|
|
350
|
+
if isinstance(each_dependency, str)
|
|
351
|
+
]
|
|
352
|
+
if len(set(dependency_names)) != len(dependency_names):
|
|
353
|
+
all_errors.append(
|
|
354
|
+
f"packet.json.tasks[{task_id}].dependencies: entries must be unique"
|
|
355
|
+
)
|
|
356
|
+
valid_dependencies: list[str] = []
|
|
357
|
+
for each_dependency in dependencies:
|
|
358
|
+
if not isinstance(each_dependency, str) or not re.fullmatch(
|
|
359
|
+
r"task-[0-9]+", each_dependency
|
|
360
|
+
):
|
|
361
|
+
all_errors.append(
|
|
362
|
+
f"packet.json.tasks[{task_id}].dependencies: invalid task ID"
|
|
363
|
+
)
|
|
364
|
+
elif each_dependency == task_id:
|
|
365
|
+
all_errors.append(
|
|
366
|
+
f"packet.json.tasks[{task_id}].dependencies: cannot depend on itself"
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
valid_dependencies.append(each_dependency)
|
|
370
|
+
return valid_dependencies
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _validate_dependency_cycles(
|
|
374
|
+
dependencies_by_task: dict[str, list[str]],
|
|
375
|
+
) -> list[str]:
|
|
376
|
+
"""Return diagnostics for missing dependencies and directed cycles."""
|
|
377
|
+
errors = [
|
|
378
|
+
f"dependency references unknown task: {each_dependency}"
|
|
379
|
+
for all_dependencies in dependencies_by_task.values()
|
|
380
|
+
for each_dependency in all_dependencies
|
|
381
|
+
if each_dependency not in dependencies_by_task
|
|
382
|
+
]
|
|
383
|
+
visiting: set[str] = set()
|
|
384
|
+
visited: set[str] = set()
|
|
385
|
+
|
|
386
|
+
def visit(task_id: str) -> None:
|
|
387
|
+
"""Visit one dependency node.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
task_id: Dependency node to inspect.
|
|
391
|
+
"""
|
|
392
|
+
if task_id in visiting:
|
|
393
|
+
errors.append(f"dependency cycle detected at {task_id}")
|
|
394
|
+
return
|
|
395
|
+
if task_id in visited:
|
|
396
|
+
return
|
|
397
|
+
visiting.add(task_id)
|
|
398
|
+
for each_dependency in dependencies_by_task.get(task_id, []):
|
|
399
|
+
if each_dependency in dependencies_by_task:
|
|
400
|
+
visit(each_dependency)
|
|
401
|
+
visiting.remove(task_id)
|
|
402
|
+
visited.add(task_id)
|
|
403
|
+
|
|
404
|
+
for each_task_id in dependencies_by_task:
|
|
405
|
+
visit(each_task_id)
|
|
406
|
+
return errors
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _validate_markdown(
|
|
410
|
+
all_packet: dict[str, object], packet_directory: Path
|
|
411
|
+
) -> list[str]:
|
|
412
|
+
"""Validate required markdown sections and packet-wide unresolved text."""
|
|
413
|
+
errors = _validate_markdown_sections(packet_directory)
|
|
414
|
+
task_text = _read_markdown_file(packet_directory, "tasks.md")
|
|
415
|
+
all_tasks = all_packet.get("tasks", [])
|
|
416
|
+
if not isinstance(all_tasks, list):
|
|
417
|
+
return errors
|
|
418
|
+
errors.extend(_validate_task_markdown(all_tasks, task_text))
|
|
419
|
+
handoff_text = _read_markdown_file(packet_directory, "handoff.md")
|
|
420
|
+
errors.extend(_validate_handoff(handoff_text, all_packet, packet_directory.name))
|
|
421
|
+
return errors
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _validate_markdown_sections(packet_directory: Path) -> list[str]:
|
|
425
|
+
"""Validate required markdown headings and unresolved text."""
|
|
426
|
+
errors: list[str] = []
|
|
427
|
+
sections_by_file = {
|
|
428
|
+
"context.md": (
|
|
429
|
+
"Request",
|
|
430
|
+
"Repository Facts",
|
|
431
|
+
"Constraints",
|
|
432
|
+
"Source References",
|
|
433
|
+
),
|
|
434
|
+
"plan.md": ("Implementation", "Decisions", "Dependencies", "Risks"),
|
|
435
|
+
"tasks.md": ("Tasks",),
|
|
436
|
+
"handoff.md": ("Approval", "Task Order"),
|
|
437
|
+
}
|
|
438
|
+
for each_filename, each_headings in sections_by_file.items():
|
|
439
|
+
each_text = _read_markdown_file(packet_directory, each_filename)
|
|
440
|
+
for each_heading in each_headings:
|
|
441
|
+
if not re.search(
|
|
442
|
+
MARKDOWN_HEADING_TEMPLATE.format(re.escape(each_heading)),
|
|
443
|
+
each_text,
|
|
444
|
+
re.MULTILINE | re.IGNORECASE,
|
|
445
|
+
):
|
|
446
|
+
errors.append(f"{each_filename}: missing section {each_heading}")
|
|
447
|
+
if _has_unresolved_text(each_text):
|
|
448
|
+
errors.append(
|
|
449
|
+
f"{each_filename}: contains unresolved questions or placeholders"
|
|
450
|
+
)
|
|
451
|
+
return errors
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _read_markdown_file(packet_directory: Path, filename: str) -> str:
|
|
455
|
+
"""Read one packet markdown file or return empty text when absent."""
|
|
456
|
+
packet_file = packet_directory / filename
|
|
457
|
+
if not packet_file.is_file():
|
|
458
|
+
return ""
|
|
459
|
+
return packet_file.read_text(encoding="utf-8")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _validate_task_markdown(all_tasks: list[object], task_text: str) -> list[str]:
|
|
463
|
+
"""Ensure tasks.md repeats every task field and file."""
|
|
464
|
+
errors: list[str] = []
|
|
465
|
+
for each_task in all_tasks:
|
|
466
|
+
if (
|
|
467
|
+
isinstance(each_task, dict)
|
|
468
|
+
and isinstance(each_task.get("id"), str)
|
|
469
|
+
and each_task["id"] not in task_text
|
|
470
|
+
):
|
|
471
|
+
errors.append(f"tasks.md: missing task {each_task['id']}")
|
|
472
|
+
if isinstance(each_task, dict):
|
|
473
|
+
task_id = each_task.get("id")
|
|
474
|
+
if not isinstance(task_id, str):
|
|
475
|
+
continue
|
|
476
|
+
for each_field_name in ALL_TASK_FIELDS:
|
|
477
|
+
field_text = each_task.get(each_field_name)
|
|
478
|
+
if isinstance(field_text, str):
|
|
479
|
+
is_field_present = (
|
|
480
|
+
_contains_exact_command(task_text, field_text)
|
|
481
|
+
if each_field_name in ALL_TASK_COMMAND_FIELDS
|
|
482
|
+
else field_text in task_text
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
is_field_present = False
|
|
486
|
+
if not is_field_present:
|
|
487
|
+
errors.append(f"tasks.md: task {task_id} missing {each_field_name}")
|
|
488
|
+
all_task_files = each_task.get("allowed_files")
|
|
489
|
+
if isinstance(all_task_files, list):
|
|
490
|
+
for each_file_path in all_task_files:
|
|
491
|
+
if (
|
|
492
|
+
isinstance(each_file_path, str)
|
|
493
|
+
and each_file_path not in task_text
|
|
494
|
+
):
|
|
495
|
+
errors.append(
|
|
496
|
+
f"tasks.md: task {task_id} missing file {each_file_path}"
|
|
497
|
+
)
|
|
498
|
+
return errors
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _contains_exact_command(markdown_text: str, command_text: str) -> bool:
|
|
502
|
+
"""Return whether a task command appears as one complete Markdown field."""
|
|
503
|
+
return any(
|
|
504
|
+
each_segment.strip() == command_text.strip()
|
|
505
|
+
for each_line in markdown_text.splitlines()
|
|
506
|
+
for each_segment in each_line.split(MARKDOWN_FIELD_SEPARATOR)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _validate_handoff(
|
|
511
|
+
handoff_text: str, all_packet: dict[str, object], packet_slug: str
|
|
512
|
+
) -> list[str]:
|
|
513
|
+
"""Ensure the host task seed restates the approved packet tasks exactly."""
|
|
514
|
+
all_handoff_lines = handoff_text.splitlines()
|
|
515
|
+
handoff_lines = set(all_handoff_lines)
|
|
516
|
+
errors = _validate_handoff_headers(handoff_lines, packet_slug)
|
|
517
|
+
all_tasks = all_packet.get("tasks", [])
|
|
518
|
+
if not isinstance(all_tasks, list):
|
|
519
|
+
return errors
|
|
520
|
+
all_task_ids = [
|
|
521
|
+
each_task["id"]
|
|
522
|
+
for each_task in all_tasks
|
|
523
|
+
if isinstance(each_task, dict) and isinstance(each_task.get("id"), str)
|
|
524
|
+
]
|
|
525
|
+
if len(all_task_ids) != len(all_tasks):
|
|
526
|
+
return errors
|
|
527
|
+
declared_task_ids = _declared_handoff_task_ids(all_handoff_lines)
|
|
528
|
+
if declared_task_ids != all_task_ids:
|
|
529
|
+
errors.append("handoff.md: declared tasks do not match packet.json")
|
|
530
|
+
task_order_line = (
|
|
531
|
+
f"{HANDOFF_TASK_ORDER_PREFIX}{COMMAND_SEPARATOR.join(all_task_ids)}"
|
|
532
|
+
)
|
|
533
|
+
if task_order_line not in handoff_lines:
|
|
534
|
+
errors.append("handoff.md: task order does not match packet.json")
|
|
535
|
+
errors.extend(_validate_handoff_task_fields(handoff_lines, all_tasks))
|
|
536
|
+
return errors
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _validate_handoff_headers(
|
|
540
|
+
all_handoff_lines: set[str], packet_slug: str
|
|
541
|
+
) -> list[str]:
|
|
542
|
+
"""Return missing packet identity and approval diagnostics."""
|
|
543
|
+
required_lines = {
|
|
544
|
+
"packet slug": f"{HANDOFF_PACKET_PREFIX}{packet_slug}",
|
|
545
|
+
"approval state": HANDOFF_APPROVAL_LINE,
|
|
546
|
+
}
|
|
547
|
+
return [
|
|
548
|
+
f"handoff.md: missing {each_field_name}"
|
|
549
|
+
for each_field_name, each_required_line in required_lines.items()
|
|
550
|
+
if each_required_line not in all_handoff_lines
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _declared_handoff_task_ids(all_handoff_lines: list[str]) -> list[str]:
|
|
555
|
+
"""Return task identifiers declared by handoff task lines."""
|
|
556
|
+
declared_task_ids: list[str] = []
|
|
557
|
+
for each_line in all_handoff_lines:
|
|
558
|
+
task_match = HANDOFF_TASK_LINE_PATTERN.match(each_line)
|
|
559
|
+
if task_match is not None:
|
|
560
|
+
declared_task_ids.append(task_match.group(1))
|
|
561
|
+
return declared_task_ids
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _validate_handoff_task_fields(
|
|
565
|
+
all_handoff_lines: set[str], all_tasks: list[object]
|
|
566
|
+
) -> list[str]:
|
|
567
|
+
"""Return diagnostics for every packet task field in the handoff."""
|
|
568
|
+
errors: list[str] = []
|
|
569
|
+
for each_task in all_tasks:
|
|
570
|
+
if not isinstance(each_task, dict) or not isinstance(each_task.get("id"), str):
|
|
571
|
+
continue
|
|
572
|
+
task_id = each_task["id"]
|
|
573
|
+
all_task_files = each_task.get("allowed_files", [])
|
|
574
|
+
task_file_names = (
|
|
575
|
+
[
|
|
576
|
+
each_file_path
|
|
577
|
+
for each_file_path in all_task_files
|
|
578
|
+
if isinstance(each_file_path, str)
|
|
579
|
+
]
|
|
580
|
+
if isinstance(all_task_files, list)
|
|
581
|
+
else []
|
|
582
|
+
)
|
|
583
|
+
task_lines = _handoff_task_lines(task_id, each_task, task_file_names)
|
|
584
|
+
errors.extend(
|
|
585
|
+
f"handoff.md: task {task_id} missing {each_field_name}"
|
|
586
|
+
for each_field_name, each_required_line in task_lines.items()
|
|
587
|
+
if each_required_line not in all_handoff_lines
|
|
588
|
+
)
|
|
589
|
+
return errors
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _handoff_task_lines(
|
|
593
|
+
task_id: str, all_task: dict[str, object], all_task_files: list[str]
|
|
594
|
+
) -> dict[str, str]:
|
|
595
|
+
"""Build exact handoff lines for one packet task."""
|
|
596
|
+
return {
|
|
597
|
+
"deliverable": f"{TASK_LINE_PREFIX}{task_id}: {all_task.get('deliverable', '')}",
|
|
598
|
+
"allowed files": f"{HANDOFF_ALLOWED_FILES_PREFIX}{COMMAND_SEPARATOR.join(all_task_files)}",
|
|
599
|
+
"acceptance command": f"{HANDOFF_ACCEPTANCE_PREFIX}{all_task.get('acceptance_command', '')}",
|
|
600
|
+
"test command": f"{HANDOFF_TEST_PREFIX}{all_task.get('test_command', '')}",
|
|
601
|
+
"verification command": f"{HANDOFF_VERIFICATION_PREFIX}{all_task.get('verification_command', '')}",
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _has_unresolved_text(text: str) -> bool:
|
|
606
|
+
"""Detect unresolved questions and common template markers outside code."""
|
|
607
|
+
prose = re.sub(r"```[\s\S]*?```|`[^`]+`", "", text)
|
|
608
|
+
return bool(UNRESOLVED_TEXT_PATTERN.search(prose))
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _is_reproducible_command(command: str) -> bool:
|
|
612
|
+
"""Accept a concrete shell command with no placeholders or randomness."""
|
|
613
|
+
try:
|
|
614
|
+
tokens = shlex.split(command)
|
|
615
|
+
except ValueError:
|
|
616
|
+
return False
|
|
617
|
+
return (
|
|
618
|
+
bool(tokens)
|
|
619
|
+
and not _has_unresolved_text(command)
|
|
620
|
+
and not REPRODUCIBILITY_BLOCK_PATTERN.search(command)
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _is_within(candidate_path: Path, parent_path: Path) -> bool:
|
|
625
|
+
"""Return whether a resolved path is within a resolved directory."""
|
|
626
|
+
try:
|
|
627
|
+
candidate_path.relative_to(parent_path)
|
|
628
|
+
except ValueError:
|
|
629
|
+
return False
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _parse_arguments() -> argparse.Namespace:
|
|
634
|
+
"""Parse the packet directory argument."""
|
|
635
|
+
parser = argparse.ArgumentParser(description="Validate a Plan-to-PR packet.")
|
|
636
|
+
parser.add_argument("packet_directory", type=Path)
|
|
637
|
+
return parser.parse_args()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def main() -> int:
|
|
641
|
+
"""Run validation and return the CLI status code.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
The process status code.
|
|
645
|
+
"""
|
|
646
|
+
errors = validate_packet(_parse_arguments().packet_directory)
|
|
647
|
+
if errors:
|
|
648
|
+
print(NEWLINE_SEPARATOR.join(errors), file=sys.stderr)
|
|
649
|
+
return PACKET_VALIDATION_ERROR_EXIT_CODE
|
|
650
|
+
print("packet validation passed")
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
if __name__ == "__main__":
|
|
655
|
+
raise SystemExit(main())
|