@opengsd/gsd-path 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +33 -13
- package/CHANGELOG.md +68 -0
- package/DOCS.md +75 -6
- package/FULL.md +12 -15
- package/HOOKS.md +18 -9
- package/QUICK.md +8 -6
- package/README.md +218 -14
- package/RUNTIME.md +16 -5
- package/UPDATE.md +30 -11
- package/WORKFLOW.md +35 -16
- package/package.json +23 -1
- package/platforms/claude/dispatch.md +4 -4
- package/platforms/copilot/dispatch.md +4 -4
- package/platforms/cursor/dispatch.md +5 -10
- package/platforms/grok/dispatch.md +4 -6
- package/platforms/kimi/dispatch.md +4 -4
- package/platforms/kiro/dispatch.md +4 -6
- package/platforms/opencode/dispatch.md +4 -4
- package/platforms/qwen/dispatch.md +4 -4
- package/platforms/shared-agents/dispatch.md +12 -19
- package/scripts/_common.py +5 -0
- package/scripts/archive_milestone.py +30 -124
- package/scripts/bootstrap_repository.py +33 -4
- package/scripts/build_recovery.py +292 -0
- package/scripts/build_state.py +2 -0
- package/scripts/check_docs_audit.py +7 -3
- package/scripts/check_handoffs.py +7 -1
- package/scripts/check_task_briefs.py +3 -0
- package/scripts/detect_project.py +22 -3
- package/scripts/discussion_records.py +3 -0
- package/scripts/discussion_validate.py +8 -3
- package/scripts/dispatch_driver.py +233 -22
- package/scripts/git_guard.py +14 -5
- package/scripts/guard_hook.py +11 -1
- package/scripts/install.mjs +54 -1448
- package/scripts/install.py +155 -188
- package/scripts/integration.py +54 -28
- package/scripts/isolation.py +24 -6
- package/scripts/jev_review.py +138 -0
- package/scripts/lean_verification.py +3 -1
- package/scripts/loop_run.py +2 -0
- package/scripts/model_policy.py +279 -0
- package/scripts/path_config.py +248 -0
- package/scripts/pipeline_diagnose.py +15 -3
- package/scripts/pipeline_git.py +3 -0
- package/scripts/pipeline_state.py +117 -186
- package/scripts/pipeline_undo.py +14 -7
- package/scripts/promote_lookahead.py +33 -162
- package/scripts/review_panel.py +11 -0
- package/scripts/roadmap.py +326 -0
- package/scripts/runtime_store.py +278 -0
- package/scripts/skill-resources.json +182 -4
- package/scripts/state_checkpoint.py +114 -72
- package/scripts/state_promote.py +47 -44
- package/scripts/status_runtime.py +92 -6
- package/scripts/task_context.py +73 -0
- package/scripts/token_budget.py +40 -1
- package/scripts/workflow_run.py +128 -8
- package/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path/BUILD.md +54 -575
- package/skills/gsd-path/DECIDE.md +4 -6
- package/skills/gsd-path/DEFINE.md +23 -21
- package/skills/gsd-path/DOCS-AUDIT.md +2 -0
- package/skills/gsd-path/INSPECT.md +32 -8
- package/skills/gsd-path/PLAN.md +29 -19
- package/skills/gsd-path/RESEARCH.md +5 -6
- package/skills/gsd-path/ROADMAP.md +24 -58
- package/skills/gsd-path/SHIP.md +29 -17
- package/skills/gsd-path/SKILL.md +46 -22
- package/skills/gsd-path/references/build-abandon.md +33 -0
- package/skills/gsd-path/references/build-native.md +502 -0
- package/skills/gsd-path/references/build-recovery.md +78 -0
- package/skills/gsd-path/references/coder.md +8 -3
- package/skills/gsd-path/references/config.md +57 -0
- package/skills/gsd-path/references/dispatch.md +12 -19
- package/skills/gsd-path/references/docs-auditor.md +14 -0
- package/skills/gsd-path/references/jev-review.md +82 -0
- package/skills/gsd-path/references/model-policy.md +139 -0
- package/skills/gsd-path/references/reviewer-final.md +21 -0
- package/skills/gsd-path/references/reviewer-panel.md +17 -0
- package/skills/gsd-path/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path/references/reviewer-wave.md +89 -0
- package/skills/gsd-path/references/reviewer.md +12 -148
- package/skills/gsd-path/references/runtime-selection.md +25 -0
- package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/_common.py +5 -0
- package/skills/gsd-path/scripts/archive_milestone.py +30 -124
- package/skills/gsd-path/scripts/bootstrap_repository.py +33 -4
- package/skills/gsd-path/scripts/build_recovery.py +292 -0
- package/skills/gsd-path/scripts/build_state.py +2 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +7 -3
- package/skills/gsd-path/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path/scripts/detect_project.py +22 -3
- package/skills/gsd-path/scripts/discussion_records.py +3 -0
- package/skills/gsd-path/scripts/discussion_validate.py +8 -3
- package/skills/gsd-path/scripts/dispatch_driver.py +233 -22
- package/skills/gsd-path/scripts/git_guard.py +768 -0
- package/skills/gsd-path/scripts/integration.py +54 -28
- package/skills/gsd-path/scripts/isolation.py +24 -6
- package/skills/gsd-path/scripts/jev_review.py +138 -0
- package/skills/gsd-path/scripts/lean_verification.py +3 -1
- package/skills/gsd-path/scripts/loop_run.py +2 -0
- package/skills/gsd-path/scripts/model_policy.py +279 -0
- package/skills/gsd-path/scripts/path_config.py +248 -0
- package/skills/gsd-path/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path/scripts/promote_lookahead.py +33 -162
- package/skills/gsd-path/scripts/review_panel.py +11 -0
- package/skills/gsd-path/scripts/roadmap.py +326 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path/scripts/state_promote.py +47 -44
- package/skills/gsd-path/scripts/status_runtime.py +208 -0
- package/skills/gsd-path/scripts/task_context.py +73 -0
- package/skills/gsd-path/scripts/token_budget.py +40 -1
- package/skills/gsd-path/scripts/workflow_run.py +128 -8
- package/skills/gsd-path/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path/templates/skeptic.md +1 -1
- package/skills/gsd-path/templates/task.md +2 -0
- package/skills/gsd-path/templates/wave-panel.md +1 -1
- package/skills/gsd-path/templates/wave-review.md +1 -1
- package/skills/gsd-path-build/SKILL.md +54 -575
- package/skills/gsd-path-build/references/build-abandon.md +33 -0
- package/skills/gsd-path-build/references/build-native.md +502 -0
- package/skills/gsd-path-build/references/build-recovery.md +78 -0
- package/skills/gsd-path-build/references/coder.md +8 -3
- package/skills/gsd-path-build/references/dispatch.md +12 -19
- package/skills/gsd-path-build/references/jev-review.md +82 -0
- package/skills/gsd-path-build/references/model-policy.md +139 -0
- package/skills/gsd-path-build/references/reviewer-final.md +21 -0
- package/skills/gsd-path-build/references/reviewer-panel.md +17 -0
- package/skills/gsd-path-build/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +89 -0
- package/skills/gsd-path-build/references/reviewer.md +12 -148
- package/skills/gsd-path-build/references/runtime-selection.md +25 -0
- package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/_common.py +5 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +30 -124
- package/skills/gsd-path-build/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-build/scripts/build_state.py +2 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-build/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-build/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +8 -3
- package/skills/gsd-path-build/scripts/dispatch_driver.py +233 -22
- package/skills/gsd-path-build/scripts/integration.py +54 -28
- package/skills/gsd-path-build/scripts/isolation.py +24 -6
- package/skills/gsd-path-build/scripts/jev_review.py +138 -0
- package/skills/gsd-path-build/scripts/loop_run.py +2 -0
- package/skills/gsd-path-build/scripts/model_policy.py +279 -0
- package/skills/gsd-path-build/scripts/path_config.py +248 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-build/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-build/scripts/review_panel.py +11 -0
- package/skills/gsd-path-build/scripts/roadmap.py +326 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-build/scripts/state_promote.py +47 -44
- package/skills/gsd-path-build/scripts/task_context.py +73 -0
- package/skills/gsd-path-build/scripts/token_budget.py +40 -1
- package/skills/gsd-path-build/scripts/workflow_run.py +128 -8
- package/skills/gsd-path-build/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-build/templates/skeptic.md +1 -1
- package/skills/gsd-path-build/templates/task.md +2 -0
- package/skills/gsd-path-build/templates/wave-panel.md +1 -1
- package/skills/gsd-path-build/templates/wave-review.md +1 -1
- package/skills/gsd-path-decide/SKILL.md +4 -6
- package/skills/gsd-path-decide/references/dispatch.md +12 -19
- package/skills/gsd-path-decide/references/model-policy.md +139 -0
- package/skills/gsd-path-decide/references/runtime-selection.md +25 -0
- package/skills/gsd-path-decide/scripts/_common.py +5 -0
- package/skills/gsd-path-decide/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-decide/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-decide/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-decide/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-decide/scripts/integration.py +1459 -0
- package/skills/gsd-path-decide/scripts/isolation.py +24 -6
- package/skills/gsd-path-decide/scripts/loop_run.py +839 -0
- package/skills/gsd-path-decide/scripts/model_policy.py +279 -0
- package/skills/gsd-path-decide/scripts/path_config.py +248 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-decide/scripts/review_panel.py +564 -0
- package/skills/gsd-path-decide/scripts/roadmap.py +326 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-decide/scripts/state_promote.py +47 -44
- package/skills/gsd-path-decide/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-define/SKILL.md +23 -21
- package/skills/gsd-path-define/references/build-recovery.md +78 -0
- package/skills/gsd-path-define/references/runtime-selection.md +25 -0
- package/skills/gsd-path-define/scripts/_common.py +5 -0
- package/skills/gsd-path-define/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-define/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-define/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-define/scripts/detect_project.py +22 -3
- package/skills/gsd-path-define/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-define/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-define/scripts/integration.py +1459 -0
- package/skills/gsd-path-define/scripts/isolation.py +24 -6
- package/skills/gsd-path-define/scripts/loop_run.py +839 -0
- package/skills/gsd-path-define/scripts/model_policy.py +279 -0
- package/skills/gsd-path-define/scripts/path_config.py +248 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-define/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-define/scripts/review_panel.py +564 -0
- package/skills/gsd-path-define/scripts/roadmap.py +326 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-define/scripts/state_promote.py +47 -44
- package/skills/gsd-path-define/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-discuss/SKILL.md +2 -0
- package/skills/gsd-path-discuss/references/runtime-selection.md +25 -0
- package/skills/gsd-path-discuss/scripts/_common.py +5 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-discuss/scripts/loop_run.py +839 -0
- package/skills/gsd-path-discuss/scripts/model_policy.py +279 -0
- package/skills/gsd-path-discuss/scripts/path_config.py +248 -0
- package/skills/gsd-path-discuss/scripts/review_panel.py +564 -0
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-docs-audit/SKILL.md +2 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +12 -19
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +14 -0
- package/skills/gsd-path-docs-audit/references/model-policy.md +139 -0
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +25 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +5 -0
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +7 -3
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-docs-audit/scripts/integration.py +1459 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +24 -6
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +839 -0
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +279 -0
- package/skills/gsd-path-docs-audit/scripts/path_config.py +248 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-docs-audit/scripts/review_panel.py +564 -0
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +326 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +47 -44
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-forensics/SKILL.md +9 -5
- package/skills/gsd-path-forensics/references/runtime-selection.md +25 -0
- package/skills/gsd-path-forensics/scripts/_common.py +5 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-forensics/scripts/loop_run.py +839 -0
- package/skills/gsd-path-forensics/scripts/model_policy.py +279 -0
- package/skills/gsd-path-forensics/scripts/path_config.py +248 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +15 -3
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +14 -7
- package/skills/gsd-path-forensics/scripts/review_panel.py +564 -0
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-inspect/SKILL.md +32 -8
- package/skills/gsd-path-inspect/references/dispatch.md +12 -19
- package/skills/gsd-path-inspect/references/docs-auditor.md +14 -0
- package/skills/gsd-path-inspect/references/model-policy.md +139 -0
- package/skills/gsd-path-inspect/references/runtime-selection.md +25 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/_common.py +5 -0
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-inspect/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +7 -3
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +22 -3
- package/skills/gsd-path-inspect/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-inspect/scripts/integration.py +1459 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +24 -6
- package/skills/gsd-path-inspect/scripts/loop_run.py +839 -0
- package/skills/gsd-path-inspect/scripts/model_policy.py +279 -0
- package/skills/gsd-path-inspect/scripts/path_config.py +248 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-inspect/scripts/review_panel.py +564 -0
- package/skills/gsd-path-inspect/scripts/roadmap.py +326 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-inspect/scripts/state_promote.py +47 -44
- package/skills/gsd-path-inspect/scripts/workflow_run.py +237 -0
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-loop/SKILL.md +2 -0
- package/skills/gsd-path-loop/references/runtime-selection.md +25 -0
- package/skills/gsd-path-loop/scripts/_common.py +5 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +2 -0
- package/skills/gsd-path-loop/scripts/model_policy.py +279 -0
- package/skills/gsd-path-loop/scripts/path_config.py +248 -0
- package/skills/gsd-path-loop/scripts/review_panel.py +564 -0
- package/skills/gsd-path-loop/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-migrate/SKILL.md +2 -0
- package/skills/gsd-path-migrate/references/runtime-selection.md +25 -0
- package/skills/gsd-path-migrate/scripts/_common.py +185 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-migrate/scripts/loop_run.py +839 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +279 -0
- package/skills/gsd-path-migrate/scripts/path_config.py +248 -0
- package/skills/gsd-path-migrate/scripts/review_panel.py +564 -0
- package/skills/gsd-path-plan/SKILL.md +29 -19
- package/skills/gsd-path-plan/references/build-recovery.md +78 -0
- package/skills/gsd-path-plan/references/dispatch.md +12 -19
- package/skills/gsd-path-plan/references/model-policy.md +139 -0
- package/skills/gsd-path-plan/references/runtime-selection.md +25 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/_common.py +5 -0
- package/skills/gsd-path-plan/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-plan/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-plan/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-plan/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-plan/scripts/integration.py +1459 -0
- package/skills/gsd-path-plan/scripts/isolation.py +24 -6
- package/skills/gsd-path-plan/scripts/loop_run.py +839 -0
- package/skills/gsd-path-plan/scripts/model_policy.py +279 -0
- package/skills/gsd-path-plan/scripts/path_config.py +248 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +11 -0
- package/skills/gsd-path-plan/scripts/roadmap.py +326 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-plan/scripts/state_promote.py +47 -44
- package/skills/gsd-path-plan/scripts/workflow_run.py +128 -8
- package/skills/gsd-path-plan/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-plan/templates/task.md +2 -0
- package/skills/gsd-path-research/SKILL.md +5 -6
- package/skills/gsd-path-research/references/dispatch.md +12 -19
- package/skills/gsd-path-research/references/model-policy.md +139 -0
- package/skills/gsd-path-research/references/runtime-selection.md +25 -0
- package/skills/gsd-path-research/scripts/_common.py +5 -0
- package/skills/gsd-path-research/scripts/archive_milestone.py +2171 -0
- package/skills/gsd-path-research/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-research/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-research/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-research/scripts/discussion_validate.py +1326 -0
- package/skills/gsd-path-research/scripts/integration.py +1459 -0
- package/skills/gsd-path-research/scripts/isolation.py +24 -6
- package/skills/gsd-path-research/scripts/loop_run.py +839 -0
- package/skills/gsd-path-research/scripts/model_policy.py +279 -0
- package/skills/gsd-path-research/scripts/path_config.py +248 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-research/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-research/scripts/review_panel.py +564 -0
- package/skills/gsd-path-research/scripts/roadmap.py +326 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-research/scripts/state_promote.py +47 -44
- package/skills/gsd-path-research/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-roadmap/SKILL.md +24 -58
- package/skills/gsd-path-roadmap/references/dispatch.md +12 -19
- package/skills/gsd-path-roadmap/references/model-policy.md +139 -0
- package/skills/gsd-path-roadmap/references/runtime-selection.md +25 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +5 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +30 -124
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +22 -3
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +8 -3
- package/skills/gsd-path-roadmap/scripts/integration.py +54 -28
- package/skills/gsd-path-roadmap/scripts/isolation.py +24 -6
- package/skills/gsd-path-roadmap/scripts/loop_run.py +839 -0
- package/skills/gsd-path-roadmap/scripts/model_policy.py +279 -0
- package/skills/gsd-path-roadmap/scripts/path_config.py +248 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +33 -162
- package/skills/gsd-path-roadmap/scripts/review_panel.py +11 -0
- package/skills/gsd-path-roadmap/scripts/roadmap.py +326 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-roadmap/scripts/state_promote.py +47 -44
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-ship/SKILL.md +29 -17
- package/skills/gsd-path-ship/references/dispatch.md +12 -19
- package/skills/gsd-path-ship/references/jev-review.md +82 -0
- package/skills/gsd-path-ship/references/model-policy.md +139 -0
- package/skills/gsd-path-ship/references/reviewer-final.md +21 -0
- package/skills/gsd-path-ship/references/reviewer-panel.md +17 -0
- package/skills/gsd-path-ship/references/reviewer-skeptic.md +20 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +89 -0
- package/skills/gsd-path-ship/references/reviewer.md +12 -148
- package/skills/gsd-path-ship/references/runtime-selection.md +25 -0
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/_common.py +5 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +30 -124
- package/skills/gsd-path-ship/scripts/build_recovery.py +292 -0
- package/skills/gsd-path-ship/scripts/build_state.py +2 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +7 -1
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +3 -0
- package/skills/gsd-path-ship/scripts/detect_project.py +1536 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +8 -3
- package/skills/gsd-path-ship/scripts/integration.py +54 -28
- package/skills/gsd-path-ship/scripts/isolation.py +24 -6
- package/skills/gsd-path-ship/scripts/jev_review.py +138 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +3 -1
- package/skills/gsd-path-ship/scripts/loop_run.py +839 -0
- package/skills/gsd-path-ship/scripts/model_policy.py +279 -0
- package/skills/gsd-path-ship/scripts/path_config.py +248 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +3 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +117 -186
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +286 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +11 -0
- package/skills/gsd-path-ship/scripts/roadmap.py +326 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +114 -72
- package/skills/gsd-path-ship/scripts/state_promote.py +47 -44
- package/skills/gsd-path-ship/scripts/workflow_run.py +128 -8
- package/skills/gsd-path-ship/scripts/worktree_paths.py +94 -0
- package/skills/gsd-path-ship/templates/wave-review.md +1 -1
- package/skills/gsd-path-undo/SKILL.md +2 -0
- package/skills/gsd-path-undo/references/runtime-selection.md +25 -0
- package/skills/gsd-path-undo/scripts/_common.py +5 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +3 -0
- package/skills/gsd-path-undo/scripts/loop_run.py +839 -0
- package/skills/gsd-path-undo/scripts/model_policy.py +279 -0
- package/skills/gsd-path-undo/scripts/path_config.py +248 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +14 -7
- package/skills/gsd-path-undo/scripts/review_panel.py +564 -0
- package/skills/gsd-path-undo/scripts/worktree_paths.py +94 -0
- package/skills/path/BUILD.md +54 -575
- package/skills/path/DECIDE.md +4 -6
- package/skills/path/DEFINE.md +23 -21
- package/skills/path/DOCS-AUDIT.md +2 -0
- package/skills/path/INSPECT.md +32 -8
- package/skills/path/PLAN.md +29 -19
- package/skills/path/RESEARCH.md +5 -6
- package/skills/path/ROADMAP.md +24 -58
- package/skills/path/SHIP.md +29 -17
- package/skills/path/SKILL.md +46 -22
- package/skills/path/references/build-abandon.md +33 -0
- package/skills/path/references/build-native.md +502 -0
- package/skills/path/references/build-recovery.md +78 -0
- package/skills/path/references/coder.md +8 -3
- package/skills/path/references/config.md +57 -0
- package/skills/path/references/dispatch.md +12 -19
- package/skills/path/references/docs-auditor.md +14 -0
- package/skills/path/references/jev-review.md +82 -0
- package/skills/path/references/model-policy.md +139 -0
- package/skills/path/references/reviewer-final.md +21 -0
- package/skills/path/references/reviewer-panel.md +17 -0
- package/skills/path/references/reviewer-skeptic.md +20 -0
- package/skills/path/references/reviewer-wave.md +89 -0
- package/skills/path/references/reviewer.md +12 -148
- package/skills/path/references/runtime-selection.md +25 -0
- package/skills/path/scripts/_common.py +5 -0
- package/skills/path/scripts/archive_milestone.py +30 -124
- package/skills/path/scripts/bootstrap_repository.py +33 -4
- package/skills/path/scripts/build_recovery.py +292 -0
- package/skills/path/scripts/build_state.py +2 -0
- package/skills/path/scripts/check_docs_audit.py +7 -3
- package/skills/path/scripts/check_handoffs.py +7 -1
- package/skills/path/scripts/check_task_briefs.py +3 -0
- package/skills/path/scripts/detect_project.py +22 -3
- package/skills/path/scripts/discussion_records.py +3 -0
- package/skills/path/scripts/discussion_validate.py +8 -3
- package/skills/path/scripts/dispatch_driver.py +233 -22
- package/skills/path/scripts/git_guard.py +768 -0
- package/skills/path/scripts/integration.py +54 -28
- package/skills/path/scripts/isolation.py +24 -6
- package/skills/path/scripts/jev_review.py +138 -0
- package/skills/path/scripts/lean_verification.py +3 -1
- package/skills/path/scripts/loop_run.py +2 -0
- package/skills/path/scripts/model_policy.py +279 -0
- package/skills/path/scripts/path_config.py +248 -0
- package/skills/path/scripts/pipeline_git.py +3 -0
- package/skills/path/scripts/pipeline_state.py +117 -186
- package/skills/path/scripts/promote_lookahead.py +33 -162
- package/skills/path/scripts/review_panel.py +11 -0
- package/skills/path/scripts/roadmap.py +326 -0
- package/skills/path/scripts/state_checkpoint.py +114 -72
- package/skills/path/scripts/state_promote.py +47 -44
- package/skills/path/scripts/status_runtime.py +208 -0
- package/skills/path/scripts/task_context.py +73 -0
- package/skills/path/scripts/token_budget.py +40 -1
- package/skills/path/scripts/workflow_run.py +128 -8
- package/skills/path/scripts/worktree_paths.py +94 -0
- package/skills/path/templates/skeptic.md +1 -1
- package/skills/path/templates/task.md +2 -0
- package/skills/path/templates/wave-panel.md +1 -1
- package/skills/path/templates/wave-review.md +1 -1
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Deterministic gates for a GSD Path LOOP.md loop spec.
|
|
4
|
+
|
|
5
|
+
The loop skill (a model) reads the prose sections of a LOOP.md; this helper
|
|
6
|
+
reads only its fixed machine fields — exact `key: value` lines, one per
|
|
7
|
+
line, no nesting, read only from the header above the first `## `
|
|
8
|
+
section. `check` decides run/skip from status, cooldown, and period
|
|
9
|
+
budgets without running skip_when; `claim` runs skip_when before
|
|
10
|
+
admitting a pass; `verify` re-runs the spec's verifier
|
|
11
|
+
commands independently of any worker; `claim` atomically admits one pass and
|
|
12
|
+
`finish` closes it append-only from helper-owned timing and verify events;
|
|
13
|
+
`status` aggregates completed outcomes and exposes unfinished recovery.
|
|
14
|
+
Relative paths (spec `log:`, skip_when, verify commands) resolve against the
|
|
15
|
+
caller's working directory — invoke from the repository root.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
sys.dont_write_bytecode = True
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from datetime import datetime, timedelta, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Iterator, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
import msvcrt
|
|
37
|
+
else:
|
|
38
|
+
import fcntl
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoopError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
MANDATORY_FIELDS = ("loop", "status", "trigger", "verify", "max_iterations", "wall_clock", "log")
|
|
46
|
+
OPTIONAL_FIELDS = ("cooldown", "skip_when", "period", "period_budget")
|
|
47
|
+
KNOWN_FIELDS = frozenset((*MANDATORY_FIELDS, *OPTIONAL_FIELDS))
|
|
48
|
+
REPEATABLE_FIELDS = frozenset({"verify"})
|
|
49
|
+
STATUSES = ("active", "paused", "done")
|
|
50
|
+
RESULTS = ("pass", "fail", "blocked", "skipped")
|
|
51
|
+
CLAIM_EVENT = "claim"
|
|
52
|
+
VERIFY_EVENT = "verify"
|
|
53
|
+
FINISH_EVENT = "finish"
|
|
54
|
+
FIELD_LINE = re.compile(r"^(?P<key>[a-z_]+):(?P<rest>[ \t].*)?$")
|
|
55
|
+
TRAILING_COMMENT = re.compile(r"[ \t]*<!--.*?-->[ \t]*$")
|
|
56
|
+
DURATION = re.compile(r"^(?P<amount>\d+)(?P<unit>s|m|h|d)?$")
|
|
57
|
+
UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, None: 1}
|
|
58
|
+
OUTPUT_TAIL_LINES = 20
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_duration(raw: str, field: str) -> int:
|
|
62
|
+
match = DURATION.fullmatch(raw.strip())
|
|
63
|
+
if not match:
|
|
64
|
+
raise LoopError(f"{field} must be a duration like 300s, 15m, 2h, or 1d")
|
|
65
|
+
return int(match.group("amount")) * UNIT_SECONDS[match.group("unit")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_spec(path: Path) -> dict:
|
|
69
|
+
if path.is_symlink() or not path.is_file():
|
|
70
|
+
raise LoopError(f"spec must be a real file: {path}")
|
|
71
|
+
fields: dict[str, object] = {"verify": []}
|
|
72
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
73
|
+
if line.startswith("## "):
|
|
74
|
+
break # machine fields live in the header; prose sections follow
|
|
75
|
+
match = FIELD_LINE.match(line)
|
|
76
|
+
if not match or match.group("key") not in KNOWN_FIELDS:
|
|
77
|
+
continue
|
|
78
|
+
key = match.group("key")
|
|
79
|
+
rest = match.group("rest")
|
|
80
|
+
value = TRAILING_COMMENT.sub("", rest).strip() if rest else ""
|
|
81
|
+
if not value:
|
|
82
|
+
raise LoopError(f"{path}:{number}: field {key} has no value")
|
|
83
|
+
if key in REPEATABLE_FIELDS:
|
|
84
|
+
fields[key].append(value)
|
|
85
|
+
elif key in fields:
|
|
86
|
+
raise LoopError(f"{path}:{number}: duplicate field {key}")
|
|
87
|
+
else:
|
|
88
|
+
fields[key] = value
|
|
89
|
+
missing = [key for key in MANDATORY_FIELDS if not fields.get(key)]
|
|
90
|
+
if missing:
|
|
91
|
+
raise LoopError(f"{path}: missing mandatory fields: {', '.join(missing)}")
|
|
92
|
+
if fields["status"] not in STATUSES:
|
|
93
|
+
raise LoopError(f"{path}: status must be one of {', '.join(STATUSES)}")
|
|
94
|
+
fields["max_iterations"] = parse_positive_int(fields["max_iterations"], "max_iterations")
|
|
95
|
+
fields["wall_clock"] = parse_duration(fields["wall_clock"], "wall_clock")
|
|
96
|
+
for key in ("cooldown", "period", "period_budget"):
|
|
97
|
+
if key in fields:
|
|
98
|
+
fields[key] = parse_duration(fields[key], key)
|
|
99
|
+
if "period_budget" in fields and "period" not in fields:
|
|
100
|
+
raise LoopError(f"{path}: period_budget requires period")
|
|
101
|
+
return fields
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_positive_int(raw: str, field: str) -> int:
|
|
105
|
+
try:
|
|
106
|
+
value = int(raw)
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
raise LoopError(f"{field} must be a positive integer") from error
|
|
109
|
+
if value < 1:
|
|
110
|
+
raise LoopError(f"{field} must be a positive integer")
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def log_path(fields: dict) -> Path:
|
|
115
|
+
return Path(fields["log"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@contextmanager
|
|
119
|
+
def log_lock(fields: dict) -> Iterator[None]:
|
|
120
|
+
path = log_path(fields)
|
|
121
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
123
|
+
with lock_path.open("a+b") as handle:
|
|
124
|
+
if sys.platform == "win32":
|
|
125
|
+
handle.seek(0, os.SEEK_END)
|
|
126
|
+
if handle.tell() == 0:
|
|
127
|
+
handle.write(b"\0")
|
|
128
|
+
handle.flush()
|
|
129
|
+
handle.seek(0)
|
|
130
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
131
|
+
else:
|
|
132
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
133
|
+
try:
|
|
134
|
+
yield
|
|
135
|
+
finally:
|
|
136
|
+
if sys.platform == "win32":
|
|
137
|
+
handle.seek(0)
|
|
138
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
139
|
+
else:
|
|
140
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_log(fields: dict) -> list[dict]:
|
|
144
|
+
path = log_path(fields)
|
|
145
|
+
if not path.exists():
|
|
146
|
+
return []
|
|
147
|
+
records = []
|
|
148
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
149
|
+
if not line.strip():
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
record = json.loads(line)
|
|
153
|
+
except json.JSONDecodeError as error:
|
|
154
|
+
raise LoopError(f"{path}:{number}: log line is not valid JSON") from error
|
|
155
|
+
if not isinstance(record, dict):
|
|
156
|
+
raise LoopError(f"{path}:{number}: log line must be a JSON object")
|
|
157
|
+
records.append(record)
|
|
158
|
+
return records
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def append_log(fields: dict, record: dict) -> None:
|
|
162
|
+
path = log_path(fields)
|
|
163
|
+
payload = (json.dumps(record, sort_keys=True) + "\n").encode()
|
|
164
|
+
with path.open("ab", buffering=0) as handle:
|
|
165
|
+
written = handle.write(payload)
|
|
166
|
+
if written != len(payload):
|
|
167
|
+
raise LoopError(f"could not append a complete record to {path}")
|
|
168
|
+
os.fsync(handle.fileno())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_timestamp(record: dict, path: Path, number: int) -> datetime:
|
|
172
|
+
raw = record.get("timestamp")
|
|
173
|
+
if not isinstance(raw, str):
|
|
174
|
+
raise LoopError(f"{path}:{number}: log record lacks a timestamp")
|
|
175
|
+
try:
|
|
176
|
+
parsed = datetime.fromisoformat(raw)
|
|
177
|
+
except ValueError as error:
|
|
178
|
+
raise LoopError(f"{path}:{number}: log record timestamp is invalid") from error
|
|
179
|
+
if parsed.tzinfo is None:
|
|
180
|
+
raise LoopError(f"{path}:{number}: log record timestamp must be timezone-aware")
|
|
181
|
+
return parsed
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def claim_id(record: dict, path: Path, number: int) -> str:
|
|
185
|
+
value = record.get("claim")
|
|
186
|
+
if not isinstance(value, str) or not value:
|
|
187
|
+
raise LoopError(f"{path}:{number}: claim event lacks a claim id")
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def require_record_fields(
|
|
192
|
+
record: dict, expected: set[str], path: Path, number: int, event: str
|
|
193
|
+
) -> None:
|
|
194
|
+
if set(record) != expected:
|
|
195
|
+
raise LoopError(f"{path}:{number}: {event} record has unsupported fields")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def validate_text_field(record: dict, field: str, path: Path, number: int) -> str:
|
|
199
|
+
value = record.get(field)
|
|
200
|
+
if not isinstance(value, str):
|
|
201
|
+
raise LoopError(f"{path}:{number}: {field} must be text")
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def log_state(
|
|
206
|
+
fields: dict, records: list[dict]
|
|
207
|
+
) -> tuple[list[dict], dict, dict, dict, Optional[dict]]:
|
|
208
|
+
path = log_path(fields)
|
|
209
|
+
outcomes = []
|
|
210
|
+
claims = {}
|
|
211
|
+
verifications: dict[str, list[dict]] = {}
|
|
212
|
+
finishes = {}
|
|
213
|
+
for number, record in enumerate(records, 1):
|
|
214
|
+
event = record.get("event")
|
|
215
|
+
if event is None:
|
|
216
|
+
require_record_fields(
|
|
217
|
+
record,
|
|
218
|
+
{
|
|
219
|
+
"timestamp",
|
|
220
|
+
"loop",
|
|
221
|
+
"trigger",
|
|
222
|
+
"iterations",
|
|
223
|
+
"result",
|
|
224
|
+
"wall_clock_used",
|
|
225
|
+
"rescue",
|
|
226
|
+
"notes",
|
|
227
|
+
},
|
|
228
|
+
path,
|
|
229
|
+
number,
|
|
230
|
+
"legacy outcome",
|
|
231
|
+
)
|
|
232
|
+
parse_timestamp(record, path, number)
|
|
233
|
+
if record.get("loop") != fields["loop"] or record.get("trigger") != fields["trigger"]:
|
|
234
|
+
raise LoopError(f"{path}:{number}: outcome belongs to another loop")
|
|
235
|
+
if record.get("result") not in RESULTS:
|
|
236
|
+
raise LoopError(f"{path}:{number}: outcome has an invalid result")
|
|
237
|
+
if integer_field(record, "iterations", path, number) > fields["max_iterations"]:
|
|
238
|
+
raise LoopError(f"{path}:{number}: outcome exceeds max_iterations")
|
|
239
|
+
integer_field(record, "wall_clock_used", path, number)
|
|
240
|
+
if not isinstance(record.get("rescue"), bool):
|
|
241
|
+
raise LoopError(f"{path}:{number}: rescue must be boolean")
|
|
242
|
+
validate_text_field(record, "notes", path, number)
|
|
243
|
+
outcomes.append(record)
|
|
244
|
+
continue
|
|
245
|
+
if event not in {CLAIM_EVENT, VERIFY_EVENT, FINISH_EVENT}:
|
|
246
|
+
raise LoopError(f"{path}:{number}: unknown log event {event}")
|
|
247
|
+
identifier = claim_id(record, path, number)
|
|
248
|
+
if event == CLAIM_EVENT:
|
|
249
|
+
require_record_fields(
|
|
250
|
+
record,
|
|
251
|
+
{
|
|
252
|
+
"event",
|
|
253
|
+
"claim",
|
|
254
|
+
"timestamp",
|
|
255
|
+
"loop",
|
|
256
|
+
"trigger",
|
|
257
|
+
"max_iterations",
|
|
258
|
+
"wall_clock_limit",
|
|
259
|
+
},
|
|
260
|
+
path,
|
|
261
|
+
number,
|
|
262
|
+
"claim",
|
|
263
|
+
)
|
|
264
|
+
if identifier in claims:
|
|
265
|
+
raise LoopError(f"{path}:{number}: duplicate claim {identifier}")
|
|
266
|
+
parse_timestamp(record, path, number)
|
|
267
|
+
if record.get("loop") != fields["loop"] or record.get("trigger") != fields["trigger"]:
|
|
268
|
+
raise LoopError(f"{path}:{number}: claim belongs to another loop")
|
|
269
|
+
if integer_field(record, "max_iterations", path, number) < 1:
|
|
270
|
+
raise LoopError(f"{path}:{number}: claim max_iterations must be positive")
|
|
271
|
+
if integer_field(record, "wall_clock_limit", path, number) < 1:
|
|
272
|
+
raise LoopError(f"{path}:{number}: claim wall_clock_limit must be positive")
|
|
273
|
+
claims[identifier] = record
|
|
274
|
+
continue
|
|
275
|
+
if event == VERIFY_EVENT:
|
|
276
|
+
require_record_fields(
|
|
277
|
+
record,
|
|
278
|
+
{
|
|
279
|
+
"event",
|
|
280
|
+
"claim",
|
|
281
|
+
"timestamp",
|
|
282
|
+
"sequence",
|
|
283
|
+
"iteration",
|
|
284
|
+
"result",
|
|
285
|
+
"commands",
|
|
286
|
+
},
|
|
287
|
+
path,
|
|
288
|
+
number,
|
|
289
|
+
"verify",
|
|
290
|
+
)
|
|
291
|
+
if identifier not in claims:
|
|
292
|
+
raise LoopError(
|
|
293
|
+
f"{path}:{number}: verify references unknown claim {identifier}"
|
|
294
|
+
)
|
|
295
|
+
if identifier in finishes:
|
|
296
|
+
raise LoopError(
|
|
297
|
+
f"{path}:{number}: verify follows finish for claim {identifier}"
|
|
298
|
+
)
|
|
299
|
+
if record.get("result") not in {"pass", "fail"}:
|
|
300
|
+
raise LoopError(
|
|
301
|
+
f"{path}:{number}: verify result must be pass or fail"
|
|
302
|
+
)
|
|
303
|
+
verified_at = parse_timestamp(record, path, number)
|
|
304
|
+
claim_record = claims[identifier]
|
|
305
|
+
claim_number = records.index(claim_record) + 1
|
|
306
|
+
if verified_at < parse_timestamp(claim_record, path, claim_number):
|
|
307
|
+
raise LoopError(f"{path}:{number}: verify predates its claim")
|
|
308
|
+
sequence = integer_field(record, "sequence", path, number)
|
|
309
|
+
expected = len(verifications.get(identifier, [])) + 1
|
|
310
|
+
if sequence != expected:
|
|
311
|
+
raise LoopError(
|
|
312
|
+
f"{path}:{number}: verify sequence must be {expected}"
|
|
313
|
+
)
|
|
314
|
+
iteration = integer_field(record, "iteration", path, number)
|
|
315
|
+
if iteration != max(0, sequence - 1):
|
|
316
|
+
raise LoopError(
|
|
317
|
+
f"{path}:{number}: verify iteration does not match sequence"
|
|
318
|
+
)
|
|
319
|
+
max_iterations = integer_field(
|
|
320
|
+
claim_record, "max_iterations", path, claim_number
|
|
321
|
+
)
|
|
322
|
+
if sequence > max_iterations + 1:
|
|
323
|
+
raise LoopError(f"{path}:{number}: verify exceeds max_iterations")
|
|
324
|
+
if integer_field(record, "commands", path, number) != len(fields["verify"]):
|
|
325
|
+
raise LoopError(f"{path}:{number}: verify command count does not match spec")
|
|
326
|
+
verifications.setdefault(identifier, []).append(record)
|
|
327
|
+
continue
|
|
328
|
+
if identifier not in claims:
|
|
329
|
+
raise LoopError(f"{path}:{number}: finish references unknown claim {identifier}")
|
|
330
|
+
if identifier in finishes:
|
|
331
|
+
raise LoopError(f"{path}:{number}: duplicate finish for claim {identifier}")
|
|
332
|
+
require_record_fields(
|
|
333
|
+
record,
|
|
334
|
+
{
|
|
335
|
+
"event",
|
|
336
|
+
"claim",
|
|
337
|
+
"timestamp",
|
|
338
|
+
"loop",
|
|
339
|
+
"trigger",
|
|
340
|
+
"iterations",
|
|
341
|
+
"result",
|
|
342
|
+
"wall_clock_used",
|
|
343
|
+
"rescue",
|
|
344
|
+
"notes",
|
|
345
|
+
},
|
|
346
|
+
path,
|
|
347
|
+
number,
|
|
348
|
+
"finish",
|
|
349
|
+
)
|
|
350
|
+
claim_record = claims[identifier]
|
|
351
|
+
claim_number = records.index(claim_record) + 1
|
|
352
|
+
started = parse_timestamp(claim_record, path, claim_number)
|
|
353
|
+
finished_at = parse_timestamp(record, path, number)
|
|
354
|
+
if finished_at < started:
|
|
355
|
+
raise LoopError(f"{path}:{number}: finish predates its claim")
|
|
356
|
+
if record.get("loop") != claim_record.get("loop") or record.get("trigger") != claim_record.get("trigger"):
|
|
357
|
+
raise LoopError(f"{path}:{number}: finish does not match its claim")
|
|
358
|
+
verification_records = verifications.get(identifier, [])
|
|
359
|
+
iterations = integer_field(record, "iterations", path, number)
|
|
360
|
+
if iterations != max(0, len(verification_records) - 1):
|
|
361
|
+
raise LoopError(f"{path}:{number}: finish iterations do not match verify events")
|
|
362
|
+
elapsed = max(0, math.ceil((finished_at - started).total_seconds()))
|
|
363
|
+
if integer_field(record, "wall_clock_used", path, number) != elapsed:
|
|
364
|
+
raise LoopError(f"{path}:{number}: finish wall clock does not match claim timestamps")
|
|
365
|
+
result = record.get("result")
|
|
366
|
+
if result not in RESULTS:
|
|
367
|
+
raise LoopError(f"{path}:{number}: finish has an invalid result")
|
|
368
|
+
if not isinstance(record.get("rescue"), bool):
|
|
369
|
+
raise LoopError(f"{path}:{number}: finish rescue must be boolean")
|
|
370
|
+
validate_text_field(record, "notes", path, number)
|
|
371
|
+
latest = verification_records[-1]["result"] if verification_records else None
|
|
372
|
+
if result == "pass" and latest != "pass":
|
|
373
|
+
raise LoopError(f"{path}:{number}: passing finish lacks a passing verify")
|
|
374
|
+
if latest == "pass" and result != "pass":
|
|
375
|
+
raise LoopError(f"{path}:{number}: passing verify must finish as pass")
|
|
376
|
+
if result == "fail":
|
|
377
|
+
max_iterations = integer_field(
|
|
378
|
+
claim_record, "max_iterations", path, claim_number
|
|
379
|
+
)
|
|
380
|
+
wall_clock_limit = integer_field(
|
|
381
|
+
claim_record, "wall_clock_limit", path, claim_number
|
|
382
|
+
)
|
|
383
|
+
if latest != "fail" or (
|
|
384
|
+
iterations < max_iterations and elapsed < wall_clock_limit
|
|
385
|
+
):
|
|
386
|
+
raise LoopError(f"{path}:{number}: failing finish has no exhausted limit")
|
|
387
|
+
finishes[identifier] = record
|
|
388
|
+
outcomes.append(record)
|
|
389
|
+
active = [record for identifier, record in claims.items() if identifier not in finishes]
|
|
390
|
+
if len(active) > 1:
|
|
391
|
+
raise LoopError(f"{path}: multiple unfinished claims require recovery")
|
|
392
|
+
return outcomes, claims, verifications, finishes, active[0] if active else None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def integer_field(record: dict, field: str, path: Path, number: int) -> int:
|
|
396
|
+
value = record.get(field, 0)
|
|
397
|
+
if isinstance(value, bool):
|
|
398
|
+
raise LoopError(f"{path}:{number}: {field} must be an integer")
|
|
399
|
+
try:
|
|
400
|
+
parsed = int(value)
|
|
401
|
+
except (TypeError, ValueError) as error:
|
|
402
|
+
raise LoopError(f"{path}:{number}: {field} must be an integer") from error
|
|
403
|
+
if parsed < 0:
|
|
404
|
+
raise LoopError(f"{path}:{number}: {field} must not be negative")
|
|
405
|
+
return parsed
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def remaining_period_budget(fields: dict, outcomes: list[dict], now: datetime) -> Optional[int]:
|
|
409
|
+
if "period_budget" not in fields:
|
|
410
|
+
return None
|
|
411
|
+
path = log_path(fields)
|
|
412
|
+
window_start = now - timedelta(seconds=fields["period"])
|
|
413
|
+
consumed = 0
|
|
414
|
+
for number, record in enumerate(outcomes, 1):
|
|
415
|
+
if parse_timestamp(record, path, number) >= window_start:
|
|
416
|
+
consumed += integer_field(record, "wall_clock_used", path, number)
|
|
417
|
+
return max(0, fields["period_budget"] - consumed)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def precheck(fields: dict) -> Optional[dict]:
|
|
421
|
+
if fields["status"] != "active":
|
|
422
|
+
return {"decision": "skip", "reason": f"status {fields['status']}"}
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def skip_when_matched(fields: dict) -> Optional[dict]:
|
|
427
|
+
if "skip_when" in fields:
|
|
428
|
+
exit_code, _ = run_shell(fields["skip_when"], fields["wall_clock"])
|
|
429
|
+
if exit_code == 0:
|
|
430
|
+
return {"decision": "skip", "reason": "skip_when matched"}
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def admission(fields: dict, records: list[dict], now: datetime) -> dict:
|
|
435
|
+
outcomes, _claims, _verifications, _finishes, active = log_state(fields, records)
|
|
436
|
+
if active is not None:
|
|
437
|
+
line = records.index(active) + 1
|
|
438
|
+
started = parse_timestamp(active, log_path(fields), line)
|
|
439
|
+
wall_clock_limit = integer_field(
|
|
440
|
+
active, "wall_clock_limit", log_path(fields), line
|
|
441
|
+
)
|
|
442
|
+
elapsed = max(0, int((now - started).total_seconds()))
|
|
443
|
+
expired = elapsed >= wall_clock_limit
|
|
444
|
+
result = {
|
|
445
|
+
"decision": "skip",
|
|
446
|
+
"reason": (
|
|
447
|
+
f"claim {active['claim']} is expired"
|
|
448
|
+
if expired
|
|
449
|
+
else "another loop pass is still active"
|
|
450
|
+
),
|
|
451
|
+
"recovery": {
|
|
452
|
+
"elapsed_seconds": min(elapsed, wall_clock_limit),
|
|
453
|
+
"wall_clock_limit": wall_clock_limit,
|
|
454
|
+
"recoverable": expired,
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
if expired:
|
|
458
|
+
result["claim"] = active["claim"]
|
|
459
|
+
return result
|
|
460
|
+
path = log_path(fields)
|
|
461
|
+
timestamps = [
|
|
462
|
+
parse_timestamp(record, path, number)
|
|
463
|
+
for number, record in enumerate(outcomes, 1)
|
|
464
|
+
]
|
|
465
|
+
remaining_cooldown = 0
|
|
466
|
+
if "cooldown" in fields and timestamps:
|
|
467
|
+
elapsed = (now - max(timestamps)).total_seconds()
|
|
468
|
+
remaining_cooldown = max(0, int(fields["cooldown"] - elapsed))
|
|
469
|
+
if remaining_cooldown > 0:
|
|
470
|
+
return {
|
|
471
|
+
"decision": "skip",
|
|
472
|
+
"reason": f"cooldown active ({remaining_cooldown}s remaining)",
|
|
473
|
+
}
|
|
474
|
+
remaining_budget = remaining_period_budget(fields, outcomes, now)
|
|
475
|
+
if remaining_budget == 0:
|
|
476
|
+
return {"decision": "skip", "reason": "period budget exhausted"}
|
|
477
|
+
return {
|
|
478
|
+
"decision": "run",
|
|
479
|
+
"reason": "gates green",
|
|
480
|
+
"remaining": {
|
|
481
|
+
"cooldown_seconds": remaining_cooldown,
|
|
482
|
+
"period_budget_seconds": remaining_budget,
|
|
483
|
+
"wall_clock_seconds": fields["wall_clock"],
|
|
484
|
+
"max_iterations": fields["max_iterations"],
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def command_check(fields: dict) -> dict:
|
|
490
|
+
skipped = precheck(fields)
|
|
491
|
+
if skipped is not None:
|
|
492
|
+
return skipped
|
|
493
|
+
decision = admission(fields, load_log(fields), datetime.now(timezone.utc))
|
|
494
|
+
if "skip_when" in fields:
|
|
495
|
+
decision["skip_when"] = "not run by check; claim runs it"
|
|
496
|
+
return decision
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def command_claim(fields: dict) -> dict:
|
|
500
|
+
skipped = precheck(fields) or skip_when_matched(fields)
|
|
501
|
+
if skipped is not None:
|
|
502
|
+
return skipped
|
|
503
|
+
with log_lock(fields):
|
|
504
|
+
now = datetime.now(timezone.utc)
|
|
505
|
+
decision = admission(fields, load_log(fields), now)
|
|
506
|
+
if decision["decision"] != "run":
|
|
507
|
+
return decision
|
|
508
|
+
remaining_budget = decision["remaining"]["period_budget_seconds"]
|
|
509
|
+
wall_clock_limit = fields["wall_clock"]
|
|
510
|
+
if remaining_budget is not None:
|
|
511
|
+
wall_clock_limit = min(wall_clock_limit, remaining_budget)
|
|
512
|
+
identifier = uuid.uuid4().hex
|
|
513
|
+
append_log(
|
|
514
|
+
fields,
|
|
515
|
+
{
|
|
516
|
+
"event": CLAIM_EVENT,
|
|
517
|
+
"claim": identifier,
|
|
518
|
+
"timestamp": now.isoformat(),
|
|
519
|
+
"loop": fields["loop"],
|
|
520
|
+
"trigger": fields["trigger"],
|
|
521
|
+
"max_iterations": fields["max_iterations"],
|
|
522
|
+
"wall_clock_limit": wall_clock_limit,
|
|
523
|
+
},
|
|
524
|
+
)
|
|
525
|
+
decision["claim"] = identifier
|
|
526
|
+
decision["remaining"]["wall_clock_seconds"] = wall_clock_limit
|
|
527
|
+
return decision
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def run_shell(command: str, timeout: float) -> tuple[Optional[int], str]:
|
|
531
|
+
"""Run one spec command; a hung command counts as failed (exit None)."""
|
|
532
|
+
try:
|
|
533
|
+
completed = subprocess.run(
|
|
534
|
+
command, shell=True, capture_output=True, text=True, check=False, timeout=timeout
|
|
535
|
+
)
|
|
536
|
+
except subprocess.TimeoutExpired as expired:
|
|
537
|
+
output = (expired.stdout or b"").decode(errors="replace") + (expired.stderr or b"").decode(errors="replace")
|
|
538
|
+
return None, f"{output}\ntimed out at the admitted wall-clock deadline"
|
|
539
|
+
return completed.returncode, completed.stdout + completed.stderr
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def command_verify(fields: dict, claim_identifier: Optional[str]) -> dict:
|
|
543
|
+
if not claim_identifier:
|
|
544
|
+
raise LoopError("verify requires --claim")
|
|
545
|
+
with log_lock(fields):
|
|
546
|
+
records = load_log(fields)
|
|
547
|
+
_outcomes, claims, verifications, _finishes, active = log_state(
|
|
548
|
+
fields, records
|
|
549
|
+
)
|
|
550
|
+
claim = claims.get(claim_identifier)
|
|
551
|
+
if claim is None:
|
|
552
|
+
raise LoopError(f"unknown claim {claim_identifier}")
|
|
553
|
+
if active is None or active.get("claim") != claim_identifier:
|
|
554
|
+
raise LoopError(f"claim {claim_identifier} is not the unfinished claim")
|
|
555
|
+
claim_line = records.index(claim) + 1
|
|
556
|
+
prior = verifications.get(claim_identifier, [])
|
|
557
|
+
max_iterations = integer_field(
|
|
558
|
+
claim, "max_iterations", log_path(fields), claim_line
|
|
559
|
+
)
|
|
560
|
+
if len(prior) > max_iterations:
|
|
561
|
+
raise LoopError(f"claim {claim_identifier} has exhausted max_iterations")
|
|
562
|
+
if prior and prior[-1]["result"] == "pass":
|
|
563
|
+
raise LoopError(f"claim {claim_identifier} already verified pass")
|
|
564
|
+
started = parse_timestamp(claim, log_path(fields), claim_line)
|
|
565
|
+
wall_clock_limit = integer_field(
|
|
566
|
+
claim, "wall_clock_limit", log_path(fields), claim_line
|
|
567
|
+
)
|
|
568
|
+
remaining = (
|
|
569
|
+
started
|
|
570
|
+
+ timedelta(seconds=wall_clock_limit)
|
|
571
|
+
- datetime.now(timezone.utc)
|
|
572
|
+
).total_seconds()
|
|
573
|
+
if remaining <= 0:
|
|
574
|
+
raise LoopError(f"claim {claim_identifier} has exhausted its wall clock")
|
|
575
|
+
deadline = time.monotonic() + remaining
|
|
576
|
+
failures = []
|
|
577
|
+
for command in fields["verify"]:
|
|
578
|
+
command_budget = deadline - time.monotonic()
|
|
579
|
+
if command_budget <= 0:
|
|
580
|
+
failures.append(
|
|
581
|
+
{
|
|
582
|
+
"command": command,
|
|
583
|
+
"exit_code": None,
|
|
584
|
+
"output_tail": "admitted wall-clock deadline exhausted before command",
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
break
|
|
588
|
+
exit_code, output = run_shell(command, command_budget)
|
|
589
|
+
if exit_code != 0:
|
|
590
|
+
lines = output.splitlines()
|
|
591
|
+
failures.append(
|
|
592
|
+
{
|
|
593
|
+
"command": command,
|
|
594
|
+
"exit_code": exit_code,
|
|
595
|
+
"output_tail": "\n".join(lines[-OUTPUT_TAIL_LINES:]),
|
|
596
|
+
}
|
|
597
|
+
)
|
|
598
|
+
result = "fail" if failures else "pass"
|
|
599
|
+
sequence = len(prior) + 1
|
|
600
|
+
iteration = max(0, sequence - 1)
|
|
601
|
+
append_log(
|
|
602
|
+
fields,
|
|
603
|
+
{
|
|
604
|
+
"event": VERIFY_EVENT,
|
|
605
|
+
"claim": claim_identifier,
|
|
606
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
607
|
+
"sequence": sequence,
|
|
608
|
+
"iteration": iteration,
|
|
609
|
+
"result": result,
|
|
610
|
+
"commands": len(fields["verify"]),
|
|
611
|
+
},
|
|
612
|
+
)
|
|
613
|
+
payload = {
|
|
614
|
+
"result": result,
|
|
615
|
+
"claim": claim_identifier,
|
|
616
|
+
"commands": len(fields["verify"]),
|
|
617
|
+
"iteration": iteration,
|
|
618
|
+
}
|
|
619
|
+
if failures:
|
|
620
|
+
payload["failures"] = failures
|
|
621
|
+
return payload
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def command_finish(fields: dict, arguments: argparse.Namespace) -> dict:
|
|
625
|
+
if not arguments.claim:
|
|
626
|
+
raise LoopError("finish requires --claim")
|
|
627
|
+
with log_lock(fields):
|
|
628
|
+
records = load_log(fields)
|
|
629
|
+
_outcomes, claims, verifications, finishes, active = log_state(fields, records)
|
|
630
|
+
claim = claims.get(arguments.claim)
|
|
631
|
+
if claim is None:
|
|
632
|
+
raise LoopError(f"unknown claim {arguments.claim}")
|
|
633
|
+
completed = finishes.get(arguments.claim)
|
|
634
|
+
if completed is not None:
|
|
635
|
+
candidate = {
|
|
636
|
+
"result": arguments.result,
|
|
637
|
+
"iterations": arguments.iterations,
|
|
638
|
+
"rescue": arguments.rescue,
|
|
639
|
+
"notes": arguments.notes,
|
|
640
|
+
}
|
|
641
|
+
if any(completed.get(key) != value for key, value in candidate.items()):
|
|
642
|
+
raise LoopError(f"claim {arguments.claim} is already finished differently")
|
|
643
|
+
return {
|
|
644
|
+
"recorded": completed["result"],
|
|
645
|
+
"claim": arguments.claim,
|
|
646
|
+
"log": str(log_path(fields)),
|
|
647
|
+
"reason": "already finished",
|
|
648
|
+
}
|
|
649
|
+
if active is None or active.get("claim") != arguments.claim:
|
|
650
|
+
raise LoopError(f"claim {arguments.claim} is not the unfinished claim")
|
|
651
|
+
claim_line = records.index(claim) + 1
|
|
652
|
+
started = parse_timestamp(claim, log_path(fields), claim_line)
|
|
653
|
+
max_iterations = integer_field(
|
|
654
|
+
claim, "max_iterations", log_path(fields), claim_line
|
|
655
|
+
)
|
|
656
|
+
wall_clock_limit = integer_field(
|
|
657
|
+
claim, "wall_clock_limit", log_path(fields), claim_line
|
|
658
|
+
)
|
|
659
|
+
verification_records = verifications.get(arguments.claim, [])
|
|
660
|
+
iterations = max(0, len(verification_records) - 1)
|
|
661
|
+
if arguments.iterations != iterations:
|
|
662
|
+
raise LoopError(
|
|
663
|
+
f"--iterations must match helper-observed fix cycles "
|
|
664
|
+
f"({arguments.iterations} != {iterations})"
|
|
665
|
+
)
|
|
666
|
+
now = datetime.now(timezone.utc)
|
|
667
|
+
elapsed = max(0, math.ceil((now - started).total_seconds()))
|
|
668
|
+
if elapsed > wall_clock_limit and arguments.result not in {"fail", "blocked"}:
|
|
669
|
+
raise LoopError(
|
|
670
|
+
f"claim {arguments.claim} exhausted its wall clock; "
|
|
671
|
+
"finish with fail or blocked"
|
|
672
|
+
)
|
|
673
|
+
latest_result = verification_records[-1]["result"] if verification_records else None
|
|
674
|
+
if arguments.result == "pass" and latest_result != "pass":
|
|
675
|
+
raise LoopError("pass requires a recorded passing verify")
|
|
676
|
+
if latest_result == "pass" and arguments.result != "pass":
|
|
677
|
+
raise LoopError("a recorded passing verify must finish as pass")
|
|
678
|
+
if arguments.result == "fail" and (
|
|
679
|
+
latest_result != "fail"
|
|
680
|
+
or (iterations < max_iterations and elapsed < wall_clock_limit)
|
|
681
|
+
):
|
|
682
|
+
raise LoopError("fail requires a failed verify and an exhausted limit")
|
|
683
|
+
record = {
|
|
684
|
+
"event": FINISH_EVENT,
|
|
685
|
+
"claim": arguments.claim,
|
|
686
|
+
"timestamp": now.isoformat(),
|
|
687
|
+
"loop": fields["loop"],
|
|
688
|
+
"trigger": fields["trigger"],
|
|
689
|
+
"iterations": iterations,
|
|
690
|
+
"result": arguments.result,
|
|
691
|
+
"wall_clock_used": elapsed,
|
|
692
|
+
"rescue": arguments.rescue,
|
|
693
|
+
"notes": arguments.notes,
|
|
694
|
+
}
|
|
695
|
+
append_log(fields, record)
|
|
696
|
+
return {
|
|
697
|
+
"recorded": record["result"],
|
|
698
|
+
"claim": arguments.claim,
|
|
699
|
+
"log": str(log_path(fields)),
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def command_recover(fields: dict, claim_identifier: Optional[str]) -> dict:
|
|
704
|
+
"""Close one expired unfinished claim without caller-reported telemetry."""
|
|
705
|
+
|
|
706
|
+
if not claim_identifier:
|
|
707
|
+
raise LoopError("recover requires --claim")
|
|
708
|
+
with log_lock(fields):
|
|
709
|
+
records = load_log(fields)
|
|
710
|
+
_outcomes, claims, verifications, finishes, active = log_state(fields, records)
|
|
711
|
+
claim = claims.get(claim_identifier)
|
|
712
|
+
if claim is None:
|
|
713
|
+
raise LoopError(f"unknown claim {claim_identifier}")
|
|
714
|
+
completed = finishes.get(claim_identifier)
|
|
715
|
+
verification_records = verifications.get(claim_identifier, [])
|
|
716
|
+
latest = verification_records[-1]["result"] if verification_records else None
|
|
717
|
+
recovered_result = latest if latest in {"pass", "fail"} else "blocked"
|
|
718
|
+
recovery_notes = f"recovered unfinished claim as {recovered_result}"
|
|
719
|
+
if completed is not None:
|
|
720
|
+
if (
|
|
721
|
+
completed.get("result") != recovered_result
|
|
722
|
+
or completed.get("notes") != recovery_notes
|
|
723
|
+
):
|
|
724
|
+
raise LoopError(f"claim {claim_identifier} is already finished differently")
|
|
725
|
+
return {
|
|
726
|
+
"recorded": recovered_result,
|
|
727
|
+
"claim": claim_identifier,
|
|
728
|
+
"log": str(log_path(fields)),
|
|
729
|
+
"reason": "already recovered",
|
|
730
|
+
}
|
|
731
|
+
if active is None or active.get("claim") != claim_identifier:
|
|
732
|
+
raise LoopError(f"claim {claim_identifier} is not the unfinished claim")
|
|
733
|
+
claim_line = records.index(claim) + 1
|
|
734
|
+
started = parse_timestamp(claim, log_path(fields), claim_line)
|
|
735
|
+
wall_clock_limit = integer_field(
|
|
736
|
+
claim, "wall_clock_limit", log_path(fields), claim_line
|
|
737
|
+
)
|
|
738
|
+
now = datetime.now(timezone.utc)
|
|
739
|
+
elapsed_raw = max(0.0, (now - started).total_seconds())
|
|
740
|
+
if elapsed_raw < wall_clock_limit:
|
|
741
|
+
raise LoopError(f"claim {claim_identifier} has not expired")
|
|
742
|
+
iterations = max(0, len(verification_records) - 1)
|
|
743
|
+
record = {
|
|
744
|
+
"event": FINISH_EVENT,
|
|
745
|
+
"claim": claim_identifier,
|
|
746
|
+
"timestamp": now.isoformat(),
|
|
747
|
+
"loop": fields["loop"],
|
|
748
|
+
"trigger": fields["trigger"],
|
|
749
|
+
"iterations": iterations,
|
|
750
|
+
"result": recovered_result,
|
|
751
|
+
"wall_clock_used": math.ceil(elapsed_raw),
|
|
752
|
+
"rescue": False,
|
|
753
|
+
"notes": recovery_notes,
|
|
754
|
+
}
|
|
755
|
+
append_log(fields, record)
|
|
756
|
+
return {
|
|
757
|
+
"recorded": recovered_result,
|
|
758
|
+
"claim": claim_identifier,
|
|
759
|
+
"iterations": iterations,
|
|
760
|
+
"log": str(log_path(fields)),
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def command_status(fields: dict) -> dict:
|
|
765
|
+
records = load_log(fields)
|
|
766
|
+
outcomes, _claims, _verifications, _finishes, active = log_state(fields, records)
|
|
767
|
+
counts = {result: 0 for result in RESULTS}
|
|
768
|
+
wall_clock_used = 0
|
|
769
|
+
human_rescues = 0
|
|
770
|
+
path = log_path(fields)
|
|
771
|
+
for number, record in enumerate(outcomes, 1):
|
|
772
|
+
result = record.get("result")
|
|
773
|
+
if result in counts:
|
|
774
|
+
counts[result] += 1
|
|
775
|
+
wall_clock_used += integer_field(record, "wall_clock_used", path, number)
|
|
776
|
+
if record.get("rescue"):
|
|
777
|
+
human_rescues += 1
|
|
778
|
+
accepted = counts["pass"]
|
|
779
|
+
evaluated = accepted + counts["fail"]
|
|
780
|
+
return {
|
|
781
|
+
"loop": fields["loop"],
|
|
782
|
+
"runs": len(outcomes),
|
|
783
|
+
"accepted": accepted,
|
|
784
|
+
"rejected": counts["fail"],
|
|
785
|
+
"blocked": counts["blocked"],
|
|
786
|
+
"skipped": counts["skipped"],
|
|
787
|
+
"human_rescues": human_rescues,
|
|
788
|
+
"wall_clock_used_seconds": wall_clock_used,
|
|
789
|
+
"acceptance_rate": round(accepted / evaluated, 4) if evaluated else None,
|
|
790
|
+
"wall_clock_per_accept_seconds": (
|
|
791
|
+
round(wall_clock_used / accepted, 2) if accepted else None
|
|
792
|
+
),
|
|
793
|
+
"active_claim": active["claim"] if active is not None else None,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def parser() -> argparse.ArgumentParser:
|
|
798
|
+
result = argparse.ArgumentParser(description=__doc__)
|
|
799
|
+
result.add_argument(
|
|
800
|
+
"command", choices=("check", "claim", "verify", "finish", "recover", "status")
|
|
801
|
+
)
|
|
802
|
+
result.add_argument("--spec", type=Path, required=True)
|
|
803
|
+
result.add_argument("--claim")
|
|
804
|
+
result.add_argument("--result", choices=RESULTS)
|
|
805
|
+
result.add_argument("--iterations", type=int, default=0)
|
|
806
|
+
result.add_argument("--notes", default="")
|
|
807
|
+
result.add_argument("--rescue", action="store_true")
|
|
808
|
+
return result
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
812
|
+
arguments = parser().parse_args(argv)
|
|
813
|
+
try:
|
|
814
|
+
fields = parse_spec(arguments.spec)
|
|
815
|
+
if arguments.command == "check":
|
|
816
|
+
payload = command_check(fields)
|
|
817
|
+
elif arguments.command == "claim":
|
|
818
|
+
payload = command_claim(fields)
|
|
819
|
+
elif arguments.command == "verify":
|
|
820
|
+
payload = command_verify(fields, arguments.claim)
|
|
821
|
+
elif arguments.command == "recover":
|
|
822
|
+
payload = command_recover(fields, arguments.claim)
|
|
823
|
+
elif arguments.command == "finish":
|
|
824
|
+
if arguments.result is None:
|
|
825
|
+
raise LoopError(f"{arguments.command} requires --result")
|
|
826
|
+
payload = command_finish(fields, arguments)
|
|
827
|
+
else:
|
|
828
|
+
payload = command_status(fields)
|
|
829
|
+
except LoopError as error:
|
|
830
|
+
print(str(error), file=sys.stderr)
|
|
831
|
+
return 2
|
|
832
|
+
print(json.dumps(payload, sort_keys=True))
|
|
833
|
+
if arguments.command == "verify" and payload["result"] == "fail":
|
|
834
|
+
return 1
|
|
835
|
+
return 0
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
if __name__ == "__main__":
|
|
839
|
+
raise SystemExit(main())
|