@hunter-harness/workflow-harness 0.2.85 → 0.3.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/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +46 -17
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-execute/SKILL.md +189 -0
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +10 -9
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +19 -91
- package/harness/bundles/general/claude-code/harness-plan/protocols.md +3 -1
- package/harness/bundles/general/claude-code/harness-plan/reference.md +74 -83
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-review/protocols.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +13 -5
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +11 -2
- package/harness/bundles/general/claude-code/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +23 -14
- package/harness/bundles/general/claude-code/scripts/harness_change.py +25 -7
- package/harness/bundles/general/claude-code/scripts/harness_context.py +154 -60
- package/harness/bundles/general/claude-code/scripts/harness_events.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/general/claude-code/scripts/harness_fixback.py +14 -10
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +293 -90
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +47 -2
- package/harness/bundles/general/claude-code/scripts/harness_paths.py +175 -0
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +65 -23
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/general/claude-code/scripts/harness_review.py +25 -4
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +46 -17
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +10 -9
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +19 -91
- package/harness/bundles/general/codebuddy/harness-plan/protocols.md +3 -1
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +74 -83
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-review/protocols.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +13 -5
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +11 -2
- package/harness/bundles/general/codebuddy/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +23 -14
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +25 -7
- package/harness/bundles/general/codebuddy/scripts/harness_context.py +154 -60
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +14 -10
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +293 -90
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +47 -2
- package/harness/bundles/general/codebuddy/scripts/harness_paths.py +175 -0
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +65 -23
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/general/codebuddy/scripts/harness_review.py +25 -4
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +46 -17
- package/harness/bundles/general/codex/harness-archive/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-plan/SKILL.md +10 -9
- package/harness/bundles/general/codex/harness-plan/checklist.md +19 -91
- package/harness/bundles/general/codex/harness-plan/protocols.md +3 -1
- package/harness/bundles/general/codex/harness-plan/reference.md +74 -83
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-review/protocols.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +13 -5
- package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +11 -2
- package/harness/bundles/general/codex/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +23 -14
- package/harness/bundles/general/codex/scripts/harness_change.py +25 -7
- package/harness/bundles/general/codex/scripts/harness_context.py +154 -60
- package/harness/bundles/general/codex/scripts/harness_events.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/general/codex/scripts/harness_fixback.py +14 -10
- package/harness/bundles/general/codex/scripts/harness_gate.py +293 -90
- package/harness/bundles/general/codex/scripts/harness_ledger.py +47 -2
- package/harness/bundles/general/codex/scripts/harness_paths.py +175 -0
- package/harness/bundles/general/codex/scripts/harness_phase.py +65 -23
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/general/codex/scripts/harness_review.py +25 -4
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +46 -17
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +10 -9
- package/harness/bundles/general/cursor/harness-plan/checklist.md +19 -91
- package/harness/bundles/general/cursor/harness-plan/protocols.md +3 -1
- package/harness/bundles/general/cursor/harness-plan/reference.md +74 -83
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-review/protocols.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +13 -5
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +11 -2
- package/harness/bundles/general/cursor/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +23 -14
- package/harness/bundles/general/cursor/scripts/harness_change.py +25 -7
- package/harness/bundles/general/cursor/scripts/harness_context.py +154 -60
- package/harness/bundles/general/cursor/scripts/harness_events.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/general/cursor/scripts/harness_fixback.py +14 -10
- package/harness/bundles/general/cursor/scripts/harness_gate.py +293 -90
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +47 -2
- package/harness/bundles/general/cursor/scripts/harness_paths.py +175 -0
- package/harness/bundles/general/cursor/scripts/harness_phase.py +65 -23
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/general/cursor/scripts/harness_review.py +25 -4
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +46 -17
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-execute/SKILL.md +189 -0
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +10 -9
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +19 -91
- package/harness/bundles/java/claude-code/harness-plan/protocols.md +3 -1
- package/harness/bundles/java/claude-code/harness-plan/reference.md +74 -83
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-review/protocols.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +13 -5
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +11 -2
- package/harness/bundles/java/claude-code/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +23 -14
- package/harness/bundles/java/claude-code/scripts/harness_change.py +25 -7
- package/harness/bundles/java/claude-code/scripts/harness_context.py +154 -60
- package/harness/bundles/java/claude-code/scripts/harness_events.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/java/claude-code/scripts/harness_fixback.py +14 -10
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +293 -90
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +47 -2
- package/harness/bundles/java/claude-code/scripts/harness_paths.py +175 -0
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +65 -23
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/java/claude-code/scripts/harness_review.py +25 -4
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +46 -17
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +10 -9
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +19 -91
- package/harness/bundles/java/codebuddy/harness-plan/protocols.md +3 -1
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +74 -83
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-review/protocols.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +13 -5
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +11 -2
- package/harness/bundles/java/codebuddy/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +23 -14
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +25 -7
- package/harness/bundles/java/codebuddy/scripts/harness_context.py +154 -60
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +14 -10
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +293 -90
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +47 -2
- package/harness/bundles/java/codebuddy/scripts/harness_paths.py +175 -0
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +65 -23
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/java/codebuddy/scripts/harness_review.py +25 -4
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +46 -17
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-archive/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-plan/SKILL.md +10 -9
- package/harness/bundles/java/codex/harness-plan/checklist.md +19 -91
- package/harness/bundles/java/codex/harness-plan/protocols.md +3 -1
- package/harness/bundles/java/codex/harness-plan/reference.md +74 -83
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-review/protocols.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +13 -5
- package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +11 -2
- package/harness/bundles/java/codex/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +23 -14
- package/harness/bundles/java/codex/scripts/harness_change.py +25 -7
- package/harness/bundles/java/codex/scripts/harness_context.py +154 -60
- package/harness/bundles/java/codex/scripts/harness_events.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/java/codex/scripts/harness_fixback.py +14 -10
- package/harness/bundles/java/codex/scripts/harness_gate.py +293 -90
- package/harness/bundles/java/codex/scripts/harness_ledger.py +47 -2
- package/harness/bundles/java/codex/scripts/harness_paths.py +175 -0
- package/harness/bundles/java/codex/scripts/harness_phase.py +65 -23
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/java/codex/scripts/harness_review.py +25 -4
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +46 -17
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +10 -9
- package/harness/bundles/java/cursor/harness-plan/checklist.md +19 -91
- package/harness/bundles/java/cursor/harness-plan/protocols.md +3 -1
- package/harness/bundles/java/cursor/harness-plan/reference.md +74 -83
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-review/protocols.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +13 -5
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +11 -2
- package/harness/bundles/java/cursor/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +23 -14
- package/harness/bundles/java/cursor/scripts/harness_change.py +25 -7
- package/harness/bundles/java/cursor/scripts/harness_context.py +154 -60
- package/harness/bundles/java/cursor/scripts/harness_events.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_events_sync.py +1 -0
- package/harness/bundles/java/cursor/scripts/harness_fixback.py +14 -10
- package/harness/bundles/java/cursor/scripts/harness_gate.py +293 -90
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +47 -2
- package/harness/bundles/java/cursor/scripts/harness_paths.py +175 -0
- package/harness/bundles/java/cursor/scripts/harness_phase.py +65 -23
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +140 -874
- package/harness/bundles/java/cursor/scripts/harness_review.py +25 -4
- package/harness/manifests/general/claude-code.json +40 -32
- package/harness/manifests/general/codebuddy.json +40 -32
- package/harness/manifests/general/codex.json +40 -32
- package/harness/manifests/general/cursor.json +40 -32
- package/harness/manifests/java/claude-code.json +42 -34
- package/harness/manifests/java/codebuddy.json +42 -34
- package/harness/manifests/java/codex.json +42 -34
- package/harness/manifests/java/cursor.json +42 -34
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""已发布计划的只读验收与 v2 清单解包(read-side only)。
|
|
3
|
+
|
|
4
|
+
legacy staging/finalize/republish 写侧已于 2026-08 移除(roadmap 14;采纳度
|
|
5
|
+
gating 由用户决策解除)。新 change 的发布走 `hunter-harness plan evidence-pack`
|
|
6
|
+
+ `plan finalize`(v2);计划修订走 evidence-pack 重跑(expected_baseline
|
|
7
|
+
= present + 新 attempt)。本模块保留:
|
|
8
|
+
- `verify_plan`:已发布计划的只读验收(legacy receipt 与 v2 事务/journal 两种
|
|
9
|
+
证据形状),供 `harness_gate.validate_plan_handoff` 与 `verify` 子命令使用;
|
|
10
|
+
- v2 manifest / checkpoints 解包与 plan/scenario 表解析:供 gate 与 ledger
|
|
11
|
+
在只读侧消费(哈希校验后)。
|
|
12
|
+
|
|
13
|
+
历史 change 的 legacy 产物必须保持可读;删除的是写入路径,不是读路径。
|
|
14
|
+
"""
|
|
3
15
|
|
|
4
16
|
from __future__ import annotations
|
|
5
17
|
|
|
6
18
|
import argparse
|
|
7
|
-
import contextlib
|
|
8
19
|
import hashlib
|
|
9
|
-
import io
|
|
10
20
|
import json
|
|
11
21
|
import os
|
|
12
22
|
import re
|
|
13
|
-
import shutil
|
|
14
23
|
import stat
|
|
15
24
|
import sys
|
|
16
|
-
import tempfile
|
|
17
25
|
from pathlib import Path, PurePosixPath
|
|
18
26
|
from typing import Any
|
|
19
27
|
|
|
@@ -44,7 +52,7 @@ _BUILD_FILE_NAMES = {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
# C8: valid ownerPhase values (lifecycle phases that can own tasks).
|
|
47
|
-
VALID_OWNER_PHASES = {"plan", "
|
|
55
|
+
VALID_OWNER_PHASES = {"plan", "execute", "review", "submit"}
|
|
48
56
|
SLICE_CANDIDATE_TYPES = {
|
|
49
57
|
"independent-release-candidate",
|
|
50
58
|
"child-of-aggregate",
|
|
@@ -210,7 +218,7 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, Any]]:
|
|
|
210
218
|
and complete_row
|
|
211
219
|
and owner_phase_index < len(cells)
|
|
212
220
|
and cells[owner_phase_index].strip()
|
|
213
|
-
else "
|
|
221
|
+
else "execute"
|
|
214
222
|
),
|
|
215
223
|
"requiredEvidenceKind": PRIORITY_EVIDENCE_KIND.get(
|
|
216
224
|
priority,
|
|
@@ -261,6 +269,127 @@ def scenario_manifest_schema_version(scenarios: list[dict[str, Any]]) -> int:
|
|
|
261
269
|
return 1
|
|
262
270
|
|
|
263
271
|
|
|
272
|
+
# v2 场景键 → 门禁与 ledger 消费的 legacy 键。可执行三元是可选的:缺了只是把
|
|
273
|
+
# manifest 降到 schemaVersion 1(绑不上结构化执行收据),不算缺口。
|
|
274
|
+
_V2_SCENARIO_FIELD_MAP = (
|
|
275
|
+
("scenario_id", "id"),
|
|
276
|
+
("priority", "priority"),
|
|
277
|
+
("required_evidence_kind", "requiredEvidenceKind"),
|
|
278
|
+
("owner_phase", "ownerPhase"),
|
|
279
|
+
)
|
|
280
|
+
_V2_SCENARIO_OPTIONAL_FIELD_MAP = (
|
|
281
|
+
("executable_test_id", "executableTestId"),
|
|
282
|
+
("test_file", "testFile"),
|
|
283
|
+
("test_title", "testTitle"),
|
|
284
|
+
)
|
|
285
|
+
V2_MANIFEST_UNSUPPORTED = "SCENARIO_MANIFEST_V2_UNSUPPORTED"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_v2_scenario_manifest(manifest: Any) -> bool:
|
|
289
|
+
"""v2 plan finalize 派生的 scenario_manifest artifact 包装体。
|
|
290
|
+
|
|
291
|
+
它没有顶层 schemaVersion,场景字段全在 content.scenarios 下且改了名。
|
|
292
|
+
"""
|
|
293
|
+
return (
|
|
294
|
+
isinstance(manifest, dict)
|
|
295
|
+
and manifest.get("artifact_type") == "scenario_manifest"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def unpack_v2_scenario_manifest(manifest: Any) -> dict[str, Any] | None:
|
|
300
|
+
"""v2 artifact 包装体 → 消费端的 legacy 形状;字段不齐时 fail-closed。
|
|
301
|
+
|
|
302
|
+
返回 None 表示"这不是 v2 包装体",调用方按 legacy 原样处理。否则返回
|
|
303
|
+
``{"ok": True, "manifest": {...}}``(已是 legacy 形状,后续判定逻辑对
|
|
304
|
+
v2 与 legacy 完全一致)或 ``{"ok": False, "code": V2_MANIFEST_UNSUPPORTED}``。
|
|
305
|
+
|
|
306
|
+
这个函数住在 finalizer 里,是因为 legacy manifest 的 schema 本来就由本模块
|
|
307
|
+
定义(``scenario_manifest_schema_version`` / ``PRIORITY_EVIDENCE_KIND``)。
|
|
308
|
+
门禁与 ledger 都 import 它,两边不会各推一套解包规则。
|
|
309
|
+
|
|
310
|
+
为什么必须逐场景校验、不能取键的并集:并集只要有**一条**场景带了
|
|
311
|
+
priority 就算"present",其余缺 priority 的场景会静默落进非必需集,
|
|
312
|
+
``required_ids`` 随之缩水——那正是把证据门禁悄悄关掉的老路子。
|
|
313
|
+
"""
|
|
314
|
+
if not is_v2_scenario_manifest(manifest):
|
|
315
|
+
return None
|
|
316
|
+
content = manifest.get("content")
|
|
317
|
+
raw = content.get("scenarios") if isinstance(content, dict) else None
|
|
318
|
+
if not isinstance(raw, list):
|
|
319
|
+
return {
|
|
320
|
+
"ok": False,
|
|
321
|
+
"code": V2_MANIFEST_UNSUPPORTED,
|
|
322
|
+
"message": "v2 scenario-manifest 的 content.scenarios 必须是数组",
|
|
323
|
+
"missingFields": [legacy for _, legacy in _V2_SCENARIO_FIELD_MAP],
|
|
324
|
+
"artifactType": "scenario_manifest",
|
|
325
|
+
}
|
|
326
|
+
scenarios: list[dict[str, Any]] = []
|
|
327
|
+
missing: set[str] = set()
|
|
328
|
+
for item in raw:
|
|
329
|
+
if not isinstance(item, dict):
|
|
330
|
+
missing.update(legacy for _, legacy in _V2_SCENARIO_FIELD_MAP)
|
|
331
|
+
continue
|
|
332
|
+
mapped: dict[str, Any] = {}
|
|
333
|
+
for source, legacy in _V2_SCENARIO_FIELD_MAP:
|
|
334
|
+
value = item.get(source)
|
|
335
|
+
if not str(value or "").strip():
|
|
336
|
+
missing.add(legacy)
|
|
337
|
+
continue
|
|
338
|
+
mapped[legacy] = value
|
|
339
|
+
for source, legacy in _V2_SCENARIO_OPTIONAL_FIELD_MAP:
|
|
340
|
+
value = item.get(source)
|
|
341
|
+
if str(value or "").strip():
|
|
342
|
+
mapped[legacy] = value
|
|
343
|
+
scenarios.append(mapped)
|
|
344
|
+
if missing:
|
|
345
|
+
return {
|
|
346
|
+
"ok": False,
|
|
347
|
+
"code": V2_MANIFEST_UNSUPPORTED,
|
|
348
|
+
"message": (
|
|
349
|
+
"meta/scenario-manifest.json 是 v2 plan artifact 包装体,"
|
|
350
|
+
"部分场景缺少消费端需要的字段;请在规划阶段补齐后重新发布,"
|
|
351
|
+
"不要手改派生产物"
|
|
352
|
+
),
|
|
353
|
+
"missingFields": sorted(missing),
|
|
354
|
+
"artifactType": "scenario_manifest",
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
"ok": True,
|
|
358
|
+
"manifest": {
|
|
359
|
+
# 与 legacy 同一条规则判版本,不另立一套。
|
|
360
|
+
"schemaVersion": scenario_manifest_schema_version(scenarios),
|
|
361
|
+
"scenarios": scenarios,
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def unpack_v2_implementation_checkpoints(document: Any) -> dict[str, Any] | None:
|
|
367
|
+
"""v2 的 implementation_checkpoints artifact 包装体 → 门禁消费的形状。
|
|
368
|
+
|
|
369
|
+
返回 None 表示"这不是 v2 包装体",调用方按原样处理。
|
|
370
|
+
|
|
371
|
+
v2 的 content 是 ``{tasks, foundation_gate}``,而 ``checkpoint_status`` 找的是
|
|
372
|
+
``checkpoints: [{id, status}]``——找不到就返回 "missing",``foundation_gate_blocks``
|
|
373
|
+
随即放行。也就是说 foundation-gate 对**所有 v2 计划**是静默关闭的。信息本身是够
|
|
374
|
+
的(``foundation_gate`` 就是那个状态),只是形状不同,所以这里解包而不是改文件名
|
|
375
|
+
——与 scenario-manifest 同一模式。
|
|
376
|
+
"""
|
|
377
|
+
if (
|
|
378
|
+
not isinstance(document, dict)
|
|
379
|
+
or document.get("artifact_type") != "implementation_checkpoints"
|
|
380
|
+
):
|
|
381
|
+
return None
|
|
382
|
+
content = document.get("content")
|
|
383
|
+
content = content if isinstance(content, dict) else {}
|
|
384
|
+
status = str(content.get("foundation_gate") or "").strip() or "pending"
|
|
385
|
+
tasks = content.get("tasks")
|
|
386
|
+
return {
|
|
387
|
+
"schemaVersion": 1,
|
|
388
|
+
"checkpoints": [{"id": "foundation-gate", "status": status, "blocking": True}],
|
|
389
|
+
"tasks": tasks if isinstance(tasks, list) else [],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
264
393
|
def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
|
|
265
394
|
"""C8: parse plan.md task table rows, extracting optional ownerPhase/implementationDoneWhen/verificationPhase columns.
|
|
266
395
|
|
|
@@ -334,21 +463,6 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
|
|
|
334
463
|
return tasks
|
|
335
464
|
|
|
336
465
|
|
|
337
|
-
def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
|
338
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
339
|
-
fd, raw_tmp = tempfile.mkstemp(
|
|
340
|
-
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
341
|
-
)
|
|
342
|
-
tmp = Path(raw_tmp)
|
|
343
|
-
try:
|
|
344
|
-
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
345
|
-
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
346
|
-
handle.write("\n")
|
|
347
|
-
handle.flush()
|
|
348
|
-
os.fsync(handle.fileno())
|
|
349
|
-
os.replace(tmp, path)
|
|
350
|
-
finally:
|
|
351
|
-
tmp.unlink(missing_ok=True)
|
|
352
466
|
|
|
353
467
|
|
|
354
468
|
def _frontmatter(text: str) -> dict[str, str] | None:
|
|
@@ -388,362 +502,17 @@ def _is_link_or_reparse(path: Path) -> bool:
|
|
|
388
502
|
return bool(attributes & _FILE_ATTRIBUTE_REPARSE_POINT)
|
|
389
503
|
|
|
390
504
|
|
|
391
|
-
def lint_slice_plan(plan_path: Path) -> dict[str, Any]:
|
|
392
|
-
"""Validate the identity, reuse, and storage contract for a slice plan."""
|
|
393
|
-
text = Path(plan_path).read_text(encoding="utf-8-sig")
|
|
394
|
-
frontmatter = _frontmatter(text)
|
|
395
|
-
if frontmatter is None:
|
|
396
|
-
return _result_error(
|
|
397
|
-
"SLICE_PLAN_CONTRACT_INVALID",
|
|
398
|
-
f"{plan_path.name}: frontmatter is required",
|
|
399
|
-
)
|
|
400
|
-
enabled = (
|
|
401
|
-
str(frontmatter.get("slice-plan") or "").strip().lower()
|
|
402
|
-
in {"true", "yes", "1"}
|
|
403
|
-
or "candidate-type" in frontmatter
|
|
404
|
-
)
|
|
405
|
-
if not enabled:
|
|
406
|
-
return {
|
|
407
|
-
"ok": True,
|
|
408
|
-
"configured": False,
|
|
409
|
-
"code": "SLICE_PLAN_NOT_CONFIGURED",
|
|
410
|
-
}
|
|
411
505
|
|
|
412
|
-
issues: list[str] = []
|
|
413
|
-
candidate_type = str(frontmatter.get("candidate-type") or "").strip()
|
|
414
|
-
aggregate_parent = str(frontmatter.get("aggregate-parent") or "").strip()
|
|
415
|
-
evidence_reuse = str(frontmatter.get("evidence-reuse") or "").strip()
|
|
416
|
-
raw_budget = str(frontmatter.get("artifact-budget-bytes") or "").strip()
|
|
417
|
-
if candidate_type not in SLICE_CANDIDATE_TYPES:
|
|
418
|
-
issues.append(
|
|
419
|
-
"candidate-type must be one of "
|
|
420
|
-
+ ", ".join(sorted(SLICE_CANDIDATE_TYPES))
|
|
421
|
-
)
|
|
422
|
-
if not aggregate_parent:
|
|
423
|
-
issues.append(
|
|
424
|
-
"aggregate-parent is required (use 'none' for an independent candidate)"
|
|
425
|
-
)
|
|
426
|
-
if not evidence_reuse:
|
|
427
|
-
issues.append(
|
|
428
|
-
"evidence-reuse is required (use 'none' when no evidence is reused)"
|
|
429
|
-
)
|
|
430
|
-
try:
|
|
431
|
-
artifact_budget = int(raw_budget)
|
|
432
|
-
if artifact_budget <= 0:
|
|
433
|
-
raise ValueError
|
|
434
|
-
except ValueError:
|
|
435
|
-
artifact_budget = 0
|
|
436
|
-
issues.append("artifact-budget-bytes must be a positive integer")
|
|
437
|
-
if issues:
|
|
438
|
-
return {
|
|
439
|
-
"ok": False,
|
|
440
|
-
"code": "SLICE_PLAN_CONTRACT_INVALID",
|
|
441
|
-
"error": f"{plan_path.name}: " + "; ".join(issues),
|
|
442
|
-
"issues": issues,
|
|
443
|
-
}
|
|
444
|
-
return {
|
|
445
|
-
"ok": True,
|
|
446
|
-
"configured": True,
|
|
447
|
-
"code": "SLICE_PLAN_CONTRACT_OK",
|
|
448
|
-
"candidateType": candidate_type,
|
|
449
|
-
"aggregateParent": aggregate_parent,
|
|
450
|
-
"evidenceReuse": evidence_reuse,
|
|
451
|
-
"artifactBudgetBytes": artifact_budget,
|
|
452
|
-
}
|
|
453
506
|
|
|
454
507
|
|
|
455
|
-
def _artifact_files(staging: Path) -> list[Path]:
|
|
456
|
-
files: list[Path] = []
|
|
457
|
-
for path in staging.rglob("*"):
|
|
458
|
-
if _is_link_or_reparse(path):
|
|
459
|
-
raise ValueError(f"PLAN_ARTIFACT_SYMLINK: {path}")
|
|
460
|
-
if path.is_file():
|
|
461
|
-
files.append(path)
|
|
462
|
-
return sorted(files, key=lambda item: item.relative_to(staging).as_posix())
|
|
463
508
|
|
|
464
509
|
|
|
465
|
-
def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
|
|
466
|
-
staging = staging.resolve()
|
|
467
|
-
required = {Path(name) for name in _required_artifact_names(change_name)}
|
|
468
|
-
try:
|
|
469
|
-
files = _artifact_files(staging)
|
|
470
|
-
except ValueError as exc:
|
|
471
|
-
code = str(exc).split(":", 1)[0]
|
|
472
|
-
return _result_error(code, str(exc))
|
|
473
|
-
rel_files = {path.relative_to(staging) for path in files}
|
|
474
|
-
missing = sorted(path.as_posix() for path in required - rel_files)
|
|
475
|
-
if missing:
|
|
476
|
-
return _result_error(
|
|
477
|
-
"PLAN_ARTIFACT_MISSING", "missing required artifacts: " + ", ".join(missing)
|
|
478
|
-
)
|
|
479
510
|
|
|
480
|
-
for path in files:
|
|
481
|
-
rel = path.relative_to(staging)
|
|
482
|
-
if rel.parts[0] not in {"spec", "plans", "meta"}:
|
|
483
|
-
return _result_error(
|
|
484
|
-
"PLAN_ARTIFACT_PATH_INVALID", f"unexpected artifact root: {rel.as_posix()}"
|
|
485
|
-
)
|
|
486
|
-
if path.suffix.lower() == ".json":
|
|
487
|
-
try:
|
|
488
|
-
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
489
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
490
|
-
return _result_error(
|
|
491
|
-
"PLAN_ARTIFACT_INVALID_JSON", f"{rel.as_posix()}: {exc}"
|
|
492
|
-
)
|
|
493
|
-
if not isinstance(payload, dict):
|
|
494
|
-
return _result_error(
|
|
495
|
-
"PLAN_ARTIFACT_INVALID_JSON",
|
|
496
|
-
f"{rel.as_posix()}: top-level JSON must be an object",
|
|
497
|
-
)
|
|
498
|
-
elif path.suffix.lower() == ".md":
|
|
499
|
-
try:
|
|
500
|
-
text = path.read_text(encoding="utf-8-sig")
|
|
501
|
-
except OSError as exc:
|
|
502
|
-
return _result_error("PLAN_ARTIFACT_UNREADABLE", str(exc))
|
|
503
|
-
frontmatter = _frontmatter(text)
|
|
504
|
-
if frontmatter is None or frontmatter.get("change-name") != change_name:
|
|
505
|
-
return _result_error(
|
|
506
|
-
"PLAN_ARTIFACT_FRONTMATTER_INVALID",
|
|
507
|
-
f"{rel.as_posix()}: change-name frontmatter mismatch",
|
|
508
|
-
)
|
|
509
|
-
for raw_link in _LINK.findall(text):
|
|
510
|
-
link = raw_link.strip().split("#", 1)[0]
|
|
511
|
-
if not link or "://" in link or link.startswith("mailto:"):
|
|
512
|
-
continue
|
|
513
|
-
target = (path.parent / link).resolve()
|
|
514
|
-
try:
|
|
515
|
-
target.relative_to(staging)
|
|
516
|
-
except ValueError:
|
|
517
|
-
return _result_error(
|
|
518
|
-
"PLAN_ARTIFACT_REFERENCE_INVALID",
|
|
519
|
-
f"{rel.as_posix()}: reference escapes staging: {raw_link}",
|
|
520
|
-
)
|
|
521
|
-
if not target.is_file():
|
|
522
|
-
return _result_error(
|
|
523
|
-
"PLAN_ARTIFACT_REFERENCE_MISSING",
|
|
524
|
-
f"{rel.as_posix()}: missing reference {raw_link}",
|
|
525
|
-
)
|
|
526
511
|
|
|
527
|
-
# C8: validate ownerPhase values in plan.md task table.
|
|
528
|
-
plan_path = staging / "plans" / f"{change_name}-plan.md"
|
|
529
|
-
try:
|
|
530
|
-
tasks = parse_plan_tasks(plan_path)
|
|
531
|
-
except PlanParseError as exc:
|
|
532
|
-
return _result_error(exc.code, str(exc))
|
|
533
|
-
if not tasks:
|
|
534
|
-
return _result_error(
|
|
535
|
-
"PLAN_TASKS_EMPTY",
|
|
536
|
-
f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
|
|
537
|
-
)
|
|
538
|
-
task_ids = [str(task.get("num") or "").strip() for task in tasks]
|
|
539
|
-
duplicate_task_ids = sorted(
|
|
540
|
-
{task_id for task_id in task_ids if task_ids.count(task_id) > 1}
|
|
541
|
-
)
|
|
542
|
-
if duplicate_task_ids:
|
|
543
|
-
return _result_error(
|
|
544
|
-
"PLAN_TASK_ID_DUPLICATE",
|
|
545
|
-
"duplicate task IDs: " + ", ".join(duplicate_task_ids),
|
|
546
|
-
)
|
|
547
|
-
for task in tasks:
|
|
548
|
-
owner = task.get("ownerPhase")
|
|
549
|
-
if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
|
|
550
|
-
return _result_error(
|
|
551
|
-
"PLAN_OWNER_PHASE_INVALID",
|
|
552
|
-
f"task {task.get('num', '?')}: ownerPhase '{owner}' not in {sorted(VALID_OWNER_PHASES)}",
|
|
553
|
-
)
|
|
554
|
-
slice_plan = lint_slice_plan(plan_path)
|
|
555
|
-
if not slice_plan.get("ok"):
|
|
556
|
-
return slice_plan
|
|
557
512
|
|
|
558
|
-
# C9: parse test-scenarios.md for scenario manifest.
|
|
559
|
-
scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
|
|
560
|
-
try:
|
|
561
|
-
scenarios = parse_test_scenarios(scenarios_path)
|
|
562
|
-
except PlanParseError as exc:
|
|
563
|
-
return _result_error(exc.code, str(exc))
|
|
564
|
-
if not scenarios:
|
|
565
|
-
return _result_error(
|
|
566
|
-
"PLAN_SCENARIOS_EMPTY",
|
|
567
|
-
f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
|
|
568
|
-
)
|
|
569
|
-
scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
|
|
570
|
-
duplicate_scenario_ids = sorted(
|
|
571
|
-
{
|
|
572
|
-
scenario_id
|
|
573
|
-
for scenario_id in scenario_ids
|
|
574
|
-
if scenario_ids.count(scenario_id) > 1
|
|
575
|
-
}
|
|
576
|
-
)
|
|
577
|
-
if duplicate_scenario_ids:
|
|
578
|
-
return _result_error(
|
|
579
|
-
"PLAN_SCENARIO_ID_DUPLICATE",
|
|
580
|
-
"duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
|
|
581
|
-
)
|
|
582
|
-
for scenario in scenarios:
|
|
583
|
-
priority = str(scenario.get("priority") or "").upper()
|
|
584
|
-
if priority not in PRIORITY_EVIDENCE_KIND:
|
|
585
|
-
return _result_error(
|
|
586
|
-
"PLAN_SCENARIO_PRIORITY_INVALID",
|
|
587
|
-
f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
|
|
588
|
-
)
|
|
589
|
-
owner = str(scenario.get("ownerPhase") or "")
|
|
590
|
-
if owner not in VALID_OWNER_PHASES:
|
|
591
|
-
return _result_error(
|
|
592
|
-
"PLAN_SCENARIO_OWNER_PHASE_INVALID",
|
|
593
|
-
f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
|
|
594
|
-
)
|
|
595
|
-
if (
|
|
596
|
-
scenario.get("requiredEvidenceKind") == "ledger"
|
|
597
|
-
and scenario.get("executableMappingDeclared")
|
|
598
|
-
):
|
|
599
|
-
missing_mapping = [
|
|
600
|
-
field
|
|
601
|
-
for field in ("executableTestId", "testFile", "testTitle")
|
|
602
|
-
if not str(scenario.get(field) or "").strip()
|
|
603
|
-
]
|
|
604
|
-
if missing_mapping:
|
|
605
|
-
return _result_error(
|
|
606
|
-
"PLAN_SCENARIO_EXECUTABLE_MAPPING_MISSING",
|
|
607
|
-
f"scenario {scenario.get('id', '?')}: missing executable mapping fields: "
|
|
608
|
-
+ ", ".join(missing_mapping),
|
|
609
|
-
)
|
|
610
513
|
|
|
611
|
-
digest = hashlib.sha256()
|
|
612
|
-
artifact_names: list[str] = []
|
|
613
|
-
for path in files:
|
|
614
|
-
rel = path.relative_to(staging).as_posix()
|
|
615
|
-
artifact_names.append(rel)
|
|
616
|
-
digest.update(rel.encode("utf-8"))
|
|
617
|
-
digest.update(b"\0")
|
|
618
|
-
digest.update(path.read_bytes())
|
|
619
|
-
digest.update(b"\0")
|
|
620
|
-
return {
|
|
621
|
-
"ok": True,
|
|
622
|
-
"files": artifact_names,
|
|
623
|
-
"artifactsHash": "sha256:" + digest.hexdigest(),
|
|
624
|
-
"tasks": tasks,
|
|
625
|
-
"scenarios": scenarios,
|
|
626
|
-
"slicePlan": slice_plan,
|
|
627
|
-
}
|
|
628
514
|
|
|
629
515
|
|
|
630
|
-
def validate_product_ownership(change_dir: Path, staging: Path) -> dict[str, Any]:
|
|
631
|
-
"""Ensure Plan declares stable prefix ownership for explicit code/test/build paths."""
|
|
632
|
-
context_path = change_dir / "meta" / "change-context.json"
|
|
633
|
-
if not context_path.is_file():
|
|
634
|
-
return {"ok": True, "code": "PLAN_PRODUCT_PATHS_LEGACY_UNDECLARED"}
|
|
635
|
-
try:
|
|
636
|
-
context = json.loads(context_path.read_text(encoding="utf-8-sig"))
|
|
637
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
638
|
-
return _result_error("PLAN_CHANGE_CONTEXT_INVALID", str(exc))
|
|
639
|
-
ownership = context.get("ownership") if isinstance(context, dict) else None
|
|
640
|
-
raw_paths = ownership.get("productPaths") if isinstance(ownership, dict) else None
|
|
641
|
-
product_paths = [
|
|
642
|
-
str(item).strip().replace("\\", "/").removeprefix("./")
|
|
643
|
-
for item in raw_paths or []
|
|
644
|
-
if isinstance(item, str) and item.strip()
|
|
645
|
-
]
|
|
646
|
-
unsupported = sorted(path for path in product_paths if any(char in path for char in "*?[]"))
|
|
647
|
-
if unsupported:
|
|
648
|
-
result = _result_error(
|
|
649
|
-
"PLAN_PRODUCT_PATHS_GLOB_UNSUPPORTED",
|
|
650
|
-
"productPaths 只接受精确文件或目录前缀,不支持通配:" + ", ".join(unsupported),
|
|
651
|
-
)
|
|
652
|
-
result["unsupportedPaths"] = unsupported
|
|
653
|
-
return result
|
|
654
|
-
|
|
655
|
-
planned_paths: set[str] = set()
|
|
656
|
-
for document in sorted(staging.rglob("*.md")):
|
|
657
|
-
text = document.read_text(encoding="utf-8-sig")
|
|
658
|
-
for raw in _BACKTICK.findall(text):
|
|
659
|
-
candidate = raw.strip().strip("'\"").replace("\\", "/")
|
|
660
|
-
if not candidate or " " in candidate or "://" in candidate or "<" in candidate:
|
|
661
|
-
continue
|
|
662
|
-
candidate = re.sub(r":\d+(?::\d+)?$", "", candidate)
|
|
663
|
-
candidate = candidate.removeprefix("./").strip("/.,;,;")
|
|
664
|
-
if not candidate or candidate.startswith("../") or candidate.startswith(".harness/"):
|
|
665
|
-
continue
|
|
666
|
-
pure = PurePosixPath(candidate)
|
|
667
|
-
if pure.name.lower() in _BUILD_FILE_NAMES or pure.suffix.lower() in _CODE_PATH_SUFFIXES:
|
|
668
|
-
planned_paths.add(candidate)
|
|
669
|
-
|
|
670
|
-
def covered(path: str) -> bool:
|
|
671
|
-
return any(
|
|
672
|
-
path == owned.rstrip("/")
|
|
673
|
-
or path.startswith(owned.rstrip("/") + "/")
|
|
674
|
-
for owned in product_paths
|
|
675
|
-
if owned.rstrip("/")
|
|
676
|
-
)
|
|
677
|
-
|
|
678
|
-
missing = sorted(path for path in planned_paths if not covered(path))
|
|
679
|
-
if missing:
|
|
680
|
-
result = _result_error(
|
|
681
|
-
"PLAN_PRODUCT_PATHS_INCOMPLETE",
|
|
682
|
-
"productPaths 未覆盖计划明确引用的产品文件:" + ", ".join(missing),
|
|
683
|
-
)
|
|
684
|
-
result["missingPaths"] = missing
|
|
685
|
-
result["recoveryAction"] = "在 Plan 阶段补充精确文件或目录前缀后重新 finalize。"
|
|
686
|
-
return result
|
|
687
|
-
return {
|
|
688
|
-
"ok": True,
|
|
689
|
-
"code": "PLAN_PRODUCT_PATHS_COMPLETE",
|
|
690
|
-
"plannedPaths": sorted(planned_paths),
|
|
691
|
-
"productPaths": product_paths,
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
|
|
696
|
-
try:
|
|
697
|
-
events = harness_events.load_events(harness_events.events_path(change_dir))
|
|
698
|
-
except (OSError, ValueError):
|
|
699
|
-
return False
|
|
700
|
-
return any(
|
|
701
|
-
event.get("phase") == "plan"
|
|
702
|
-
and event.get("type") == "phase.end"
|
|
703
|
-
and event.get("run_id") == run_id
|
|
704
|
-
and event.get("attempt") == attempt
|
|
705
|
-
and str(event.get("status") or "").upper() == "OK"
|
|
706
|
-
for event in events
|
|
707
|
-
)
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
def _validate_plan_start(
|
|
711
|
-
change_dir: Path,
|
|
712
|
-
run_id: str,
|
|
713
|
-
attempt: int,
|
|
714
|
-
) -> dict[str, Any]:
|
|
715
|
-
events_path = harness_events.events_path(change_dir)
|
|
716
|
-
try:
|
|
717
|
-
events = harness_events.load_events(events_path)
|
|
718
|
-
except (OSError, ValueError) as exc:
|
|
719
|
-
return _result_error("EVENTS_PARSE_ERROR", str(exc))
|
|
720
|
-
matching = [
|
|
721
|
-
event
|
|
722
|
-
for event in events
|
|
723
|
-
if event.get("phase") == "plan"
|
|
724
|
-
and event.get("type") == "phase.start"
|
|
725
|
-
and event.get("run_id") == run_id
|
|
726
|
-
and event.get("attempt") == attempt
|
|
727
|
-
]
|
|
728
|
-
if not matching:
|
|
729
|
-
return _result_error(
|
|
730
|
-
"PHASE_START_MISSING",
|
|
731
|
-
"no matching plan phase.start event found for finalizer runId/attempt; "
|
|
732
|
-
"append one with the same run id and attempt before finalizing, or use "
|
|
733
|
-
"the `republish` subcommand which allocates and appends both for you",
|
|
734
|
-
)
|
|
735
|
-
if len(matching) > 1:
|
|
736
|
-
return _result_error(
|
|
737
|
-
"PHASE_START_DUPLICATE",
|
|
738
|
-
f"found {len(matching)} matching plan phase.start events for run id "
|
|
739
|
-
f"{run_id}. `harness_gate.py begin` already appends phase.start; a "
|
|
740
|
-
"second manual append of the same run id creates this state. Recover "
|
|
741
|
-
"with a fresh run id and the next attempt number (attempts are global "
|
|
742
|
-
"per phase, not per run id): append phase.start with both, then "
|
|
743
|
-
"finalize with the same pair.",
|
|
744
|
-
)
|
|
745
|
-
return {"ok": True, "phaseStartCount": 1}
|
|
746
|
-
|
|
747
516
|
|
|
748
517
|
def _receipt_artifact_targets(
|
|
749
518
|
change_dir: Path,
|
|
@@ -877,7 +646,9 @@ def _verify_plan_v2(change_dir: Path) -> dict[str, Any] | None:
|
|
|
877
646
|
f"plans/{change_dir.name}-plan.md",
|
|
878
647
|
f"plans/{change_dir.name}-implementation-detail.md",
|
|
879
648
|
f"plans/{change_dir.name}-test-scenarios.md",
|
|
880
|
-
|
|
649
|
+
# v2 发布的是派生视图 meta/plan-profile.json,不是 Python classify 写的
|
|
650
|
+
# meta/gate-policy.json——后者是 run/test 门禁的权威输入,不能被发布覆盖。
|
|
651
|
+
"meta/plan-profile.json",
|
|
881
652
|
"meta/worktree.json",
|
|
882
653
|
}
|
|
883
654
|
missing_required = sorted(required - set(ownership_paths))
|
|
@@ -1203,515 +974,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
1203
974
|
}
|
|
1204
975
|
|
|
1205
976
|
|
|
1206
|
-
def _read_design_capabilities(staging: Path, change_name: str) -> list[str]:
|
|
1207
|
-
"""Read capabilities from design frontmatter (retro §5.4)."""
|
|
1208
|
-
design_path = staging / "spec" / f"{change_name}-design.md"
|
|
1209
|
-
if not design_path.is_file():
|
|
1210
|
-
return []
|
|
1211
|
-
try:
|
|
1212
|
-
text = design_path.read_text(encoding="utf-8-sig")
|
|
1213
|
-
except OSError:
|
|
1214
|
-
return []
|
|
1215
|
-
frontmatter = _frontmatter(text)
|
|
1216
|
-
if not frontmatter:
|
|
1217
|
-
return []
|
|
1218
|
-
raw = frontmatter.get("capabilities") or ""
|
|
1219
|
-
if not raw:
|
|
1220
|
-
return []
|
|
1221
|
-
# capabilities may be comma-separated or YAML list
|
|
1222
|
-
if raw.startswith("["):
|
|
1223
|
-
try:
|
|
1224
|
-
parsed = json.loads(raw)
|
|
1225
|
-
if isinstance(parsed, list):
|
|
1226
|
-
return [str(c) for c in parsed if isinstance(c, str)]
|
|
1227
|
-
except json.JSONDecodeError:
|
|
1228
|
-
pass
|
|
1229
|
-
return [c.strip() for c in raw.split(",") if c.strip()]
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def _reclassify_gate_policy(
|
|
1233
|
-
staging: Path,
|
|
1234
|
-
change_name: str,
|
|
1235
|
-
) -> dict[str, Any]:
|
|
1236
|
-
"""Reclassify gate policy based on approved design capabilities (retro §5.4).
|
|
1237
|
-
|
|
1238
|
-
Reads design frontmatter `capabilities`, invokes harness_gate.py classify
|
|
1239
|
-
to recompute the gate DAG, and updates staging/meta/gate-policy.json.
|
|
1240
|
-
Returns {"ok": bool, "capabilities": [...], "drift": bool}.
|
|
1241
|
-
"""
|
|
1242
|
-
capabilities = _read_design_capabilities(staging, change_name)
|
|
1243
|
-
if not capabilities:
|
|
1244
|
-
return {"ok": True, "capabilities": [], "drift": False, "updated": False}
|
|
1245
|
-
|
|
1246
|
-
gate_policy_path = staging / "meta" / "gate-policy.json"
|
|
1247
|
-
try:
|
|
1248
|
-
existing = json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
|
|
1249
|
-
except (OSError, json.JSONDecodeError):
|
|
1250
|
-
existing = {}
|
|
1251
977
|
|
|
1252
|
-
existing_caps = set(existing.get("capabilities") or [])
|
|
1253
|
-
design_caps = set(capabilities)
|
|
1254
|
-
drift = existing_caps != design_caps
|
|
1255
978
|
|
|
1256
|
-
if drift:
|
|
1257
|
-
# Update gate-policy.json with design capabilities
|
|
1258
|
-
existing["capabilities"] = sorted(design_caps)
|
|
1259
|
-
try:
|
|
1260
|
-
gate_policy_path.write_text(
|
|
1261
|
-
json.dumps(existing, ensure_ascii=False, indent=2) + "\n",
|
|
1262
|
-
encoding="utf-8",
|
|
1263
|
-
newline="\n",
|
|
1264
|
-
)
|
|
1265
|
-
except OSError as exc:
|
|
1266
|
-
return {
|
|
1267
|
-
"ok": False,
|
|
1268
|
-
"error": f"failed to update gate-policy.json: {exc}",
|
|
1269
|
-
"capabilities": capabilities,
|
|
1270
|
-
"drift": True,
|
|
1271
|
-
"updated": False,
|
|
1272
|
-
}
|
|
1273
979
|
|
|
1274
|
-
return {
|
|
1275
|
-
"ok": True,
|
|
1276
|
-
"capabilities": sorted(design_caps),
|
|
1277
|
-
"drift": drift,
|
|
1278
|
-
"updated": drift,
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int, str]:
|
|
1283
|
-
stdout = io.StringIO()
|
|
1284
|
-
stderr = io.StringIO()
|
|
1285
|
-
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
1286
|
-
code = harness_events.main(
|
|
1287
|
-
[
|
|
1288
|
-
"append",
|
|
1289
|
-
"--change-dir",
|
|
1290
|
-
str(change_dir),
|
|
1291
|
-
"--phase",
|
|
1292
|
-
"plan",
|
|
1293
|
-
"--type",
|
|
1294
|
-
"phase.end",
|
|
1295
|
-
"--status",
|
|
1296
|
-
"OK",
|
|
1297
|
-
"--reason",
|
|
1298
|
-
"plan artifacts validated and published",
|
|
1299
|
-
"--run-id",
|
|
1300
|
-
run_id,
|
|
1301
|
-
"--attempt",
|
|
1302
|
-
str(attempt),
|
|
1303
|
-
"--json",
|
|
1304
|
-
]
|
|
1305
|
-
)
|
|
1306
|
-
return code, stderr.getvalue().strip()
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
def _rollback_publish(
|
|
1310
|
-
created: list[Path], replaced: list[tuple[Path, bytes]]
|
|
1311
|
-
) -> None:
|
|
1312
|
-
"""Undo a failed publish: delete new files, restore overwritten ones."""
|
|
1313
|
-
for target in reversed(created):
|
|
1314
|
-
target.unlink(missing_ok=True)
|
|
1315
|
-
for target, original in reversed(replaced):
|
|
1316
|
-
try:
|
|
1317
|
-
target.write_bytes(original)
|
|
1318
|
-
except OSError:
|
|
1319
|
-
# Best effort: the caller already reports the publish failure.
|
|
1320
|
-
pass
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
def _plan_attempts(change_dir: Path) -> list[int]:
|
|
1324
|
-
"""Every attempt number already used by a plan lifecycle event."""
|
|
1325
|
-
try:
|
|
1326
|
-
events = harness_events.load_events(harness_events.events_path(change_dir))
|
|
1327
|
-
except (OSError, ValueError):
|
|
1328
|
-
return []
|
|
1329
|
-
attempts: list[int] = []
|
|
1330
|
-
for event in events:
|
|
1331
|
-
if event.get("phase") != "plan":
|
|
1332
|
-
continue
|
|
1333
|
-
value = event.get("attempt")
|
|
1334
|
-
if isinstance(value, int) and not isinstance(value, bool):
|
|
1335
|
-
attempts.append(value)
|
|
1336
|
-
return attempts
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
|
|
1340
|
-
try:
|
|
1341
|
-
events = harness_events.load_events(harness_events.events_path(change_dir))
|
|
1342
|
-
except (OSError, ValueError):
|
|
1343
|
-
return False
|
|
1344
|
-
return any(event.get("run_id") == run_id for event in events)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
def _append_plan_start(
|
|
1348
|
-
change_dir: Path, run_id: str, attempt: int, reason: str
|
|
1349
|
-
) -> tuple[int, str]:
|
|
1350
|
-
stdout = io.StringIO()
|
|
1351
|
-
stderr = io.StringIO()
|
|
1352
|
-
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
1353
|
-
code = harness_events.main(
|
|
1354
|
-
[
|
|
1355
|
-
"append",
|
|
1356
|
-
"--change-dir",
|
|
1357
|
-
str(change_dir),
|
|
1358
|
-
"--phase",
|
|
1359
|
-
"plan",
|
|
1360
|
-
"--type",
|
|
1361
|
-
"phase.start",
|
|
1362
|
-
# phase.start accepts only `note` beyond provenance fields.
|
|
1363
|
-
"--note",
|
|
1364
|
-
reason,
|
|
1365
|
-
"--run-id",
|
|
1366
|
-
run_id,
|
|
1367
|
-
"--attempt",
|
|
1368
|
-
str(attempt),
|
|
1369
|
-
"--json",
|
|
1370
|
-
]
|
|
1371
|
-
)
|
|
1372
|
-
return code, stderr.getvalue().strip()
|
|
1373
980
|
|
|
1374
981
|
|
|
1375
|
-
def finalize_plan(
|
|
1376
|
-
change_dir: Path,
|
|
1377
|
-
staging: Path,
|
|
1378
|
-
*,
|
|
1379
|
-
change_name: str,
|
|
1380
|
-
run_id: str,
|
|
1381
|
-
attempt: int,
|
|
1382
|
-
allow_amend: bool = False,
|
|
1383
|
-
amend_reason: str | None = None,
|
|
1384
|
-
) -> dict[str, Any]:
|
|
1385
|
-
change_dir = change_dir.resolve()
|
|
1386
|
-
staging = staging.resolve()
|
|
1387
|
-
|
|
1388
|
-
# C2 (retro §5.4): reclassify gate policy based on approved design capabilities.
|
|
1389
|
-
# This updates staging/meta/gate-policy.json before validation, so the hash
|
|
1390
|
-
# reflects the final gate policy. Drift between design capabilities and
|
|
1391
|
-
# gate-policy capabilities is resolved in favor of the approved design.
|
|
1392
|
-
reclassify = _reclassify_gate_policy(staging, change_name)
|
|
1393
|
-
if not reclassify.get("ok"):
|
|
1394
|
-
return _result_error("CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed"))
|
|
1395
|
-
|
|
1396
|
-
validation = validate_staging(staging, change_name)
|
|
1397
|
-
if not validation["ok"]:
|
|
1398
|
-
return validation
|
|
1399
|
-
ownership_validation = validate_product_ownership(change_dir, staging)
|
|
1400
|
-
if not ownership_validation["ok"]:
|
|
1401
|
-
return ownership_validation
|
|
1402
|
-
start_validation = _validate_plan_start(change_dir, run_id, attempt)
|
|
1403
|
-
if not start_validation["ok"]:
|
|
1404
|
-
return start_validation
|
|
1405
982
|
|
|
1406
|
-
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
1407
|
-
lock_path = change_dir / "meta" / "plan-finalize.lock"
|
|
1408
|
-
receipt: dict[str, Any] | None = None
|
|
1409
|
-
supersedes: dict[str, Any] | None = None
|
|
1410
|
-
if receipt_path.is_file():
|
|
1411
|
-
try:
|
|
1412
|
-
loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
1413
|
-
receipt = loaded if isinstance(loaded, dict) else None
|
|
1414
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
1415
|
-
return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
|
|
1416
|
-
if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
|
|
1417
|
-
if not allow_amend:
|
|
1418
|
-
return _result_error(
|
|
1419
|
-
"PLAN_FINALIZATION_HASH_CONFLICT",
|
|
1420
|
-
"finalizer was already invoked with a different artifact set; "
|
|
1421
|
-
"to publish an approved amendment run: harness_plan_finalize.py "
|
|
1422
|
-
"republish --change-dir <dir> --staging-dir <dir> --change "
|
|
1423
|
-
f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
|
|
1424
|
-
)
|
|
1425
|
-
# Authorized amendment: keep the superseded identity in the new
|
|
1426
|
-
# receipt so the published history stays auditable.
|
|
1427
|
-
supersedes = {
|
|
1428
|
-
"artifactsHash": receipt.get("artifactsHash"),
|
|
1429
|
-
"runId": receipt.get("runId"),
|
|
1430
|
-
"attempt": receipt.get("attempt"),
|
|
1431
|
-
}
|
|
1432
|
-
elif (
|
|
1433
|
-
receipt
|
|
1434
|
-
and receipt.get("status") == "finalized"
|
|
1435
|
-
and _terminal_exists(change_dir, run_id, attempt)
|
|
1436
|
-
):
|
|
1437
|
-
return {
|
|
1438
|
-
"ok": True,
|
|
1439
|
-
"action": "finalize",
|
|
1440
|
-
"idempotent": True,
|
|
1441
|
-
"artifactsHash": validation["artifactsHash"],
|
|
1442
|
-
"files": validation["files"],
|
|
1443
|
-
"receiptPath": str(receipt_path),
|
|
1444
|
-
"artifactRef": "meta/plan-finalization.json",
|
|
1445
|
-
}
|
|
1446
983
|
|
|
1447
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1448
|
-
try:
|
|
1449
|
-
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
1450
|
-
os.close(lock_fd)
|
|
1451
|
-
except FileExistsError:
|
|
1452
|
-
return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
|
|
1453
|
-
|
|
1454
|
-
created: list[Path] = []
|
|
1455
|
-
# Amendment overwrites replace already-published bytes. Keep the originals
|
|
1456
|
-
# so a failed publish restores them instead of deleting the published plan.
|
|
1457
|
-
replaced: list[tuple[Path, bytes]] = []
|
|
1458
|
-
terminal_committed = False
|
|
1459
|
-
try:
|
|
1460
|
-
for rel_text in validation["files"]:
|
|
1461
|
-
source = staging / rel_text
|
|
1462
|
-
target = change_dir / rel_text
|
|
1463
|
-
if (
|
|
1464
|
-
not allow_amend
|
|
1465
|
-
and target.exists()
|
|
1466
|
-
and target.read_bytes() != source.read_bytes()
|
|
1467
|
-
):
|
|
1468
|
-
return _result_error(
|
|
1469
|
-
"PLAN_TARGET_CONFLICT",
|
|
1470
|
-
f"refusing to overwrite {rel_text}; publish an approved "
|
|
1471
|
-
"amendment with the `republish` subcommand instead",
|
|
1472
|
-
)
|
|
1473
984
|
|
|
1474
|
-
for rel_text in validation["files"]:
|
|
1475
|
-
source = staging / rel_text
|
|
1476
|
-
target = change_dir / rel_text
|
|
1477
|
-
existed = target.exists()
|
|
1478
|
-
if existed:
|
|
1479
|
-
original = target.read_bytes()
|
|
1480
|
-
if not allow_amend or original == source.read_bytes():
|
|
1481
|
-
continue
|
|
1482
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1483
|
-
fd, raw_tmp = tempfile.mkstemp(
|
|
1484
|
-
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
|
1485
|
-
)
|
|
1486
|
-
os.close(fd)
|
|
1487
|
-
tmp = Path(raw_tmp)
|
|
1488
|
-
try:
|
|
1489
|
-
shutil.copyfile(source, tmp)
|
|
1490
|
-
os.replace(tmp, target)
|
|
1491
|
-
if existed:
|
|
1492
|
-
replaced.append((target, original))
|
|
1493
|
-
else:
|
|
1494
|
-
created.append(target)
|
|
1495
|
-
finally:
|
|
1496
|
-
tmp.unlink(missing_ok=True)
|
|
1497
|
-
|
|
1498
|
-
# C8: write implementation-checkpoints.json with parsed task ownerPhase.
|
|
1499
|
-
tasks = validation.get("tasks") or []
|
|
1500
|
-
if tasks:
|
|
1501
|
-
checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
|
|
1502
|
-
checkpoints_payload = {
|
|
1503
|
-
"schemaVersion": 1,
|
|
1504
|
-
"changeName": change_name,
|
|
1505
|
-
"tasks": tasks,
|
|
1506
|
-
"foundationGate": "approved",
|
|
1507
|
-
}
|
|
1508
|
-
if checkpoints_path.is_file():
|
|
1509
|
-
replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
|
|
1510
|
-
else:
|
|
1511
|
-
created.append(checkpoints_path)
|
|
1512
|
-
_atomic_write_json(checkpoints_path, checkpoints_payload)
|
|
1513
|
-
|
|
1514
|
-
# C9: write scenario-manifest.json with parsed scenarios.
|
|
1515
|
-
scenarios = validation.get("scenarios") or []
|
|
1516
|
-
manifest_path = change_dir / "meta" / "scenario-manifest.json"
|
|
1517
|
-
manifest_payload = {
|
|
1518
|
-
"schemaVersion": scenario_manifest_schema_version(scenarios),
|
|
1519
|
-
"changeName": change_name,
|
|
1520
|
-
"scenarios": [
|
|
1521
|
-
{
|
|
1522
|
-
key: value
|
|
1523
|
-
for key, value in scenario.items()
|
|
1524
|
-
if key != "executableMappingDeclared"
|
|
1525
|
-
}
|
|
1526
|
-
for scenario in scenarios
|
|
1527
|
-
],
|
|
1528
|
-
}
|
|
1529
|
-
if manifest_path.is_file():
|
|
1530
|
-
replaced.append((manifest_path, manifest_path.read_bytes()))
|
|
1531
|
-
else:
|
|
1532
|
-
created.append(manifest_path)
|
|
1533
|
-
_atomic_write_json(manifest_path, manifest_payload)
|
|
1534
|
-
|
|
1535
|
-
pending_receipt = {
|
|
1536
|
-
"schemaVersion": SCHEMA_VERSION,
|
|
1537
|
-
"changeName": change_name,
|
|
1538
|
-
"status": "publishing",
|
|
1539
|
-
"artifactsHash": validation["artifactsHash"],
|
|
1540
|
-
"files": validation["files"],
|
|
1541
|
-
"runId": run_id,
|
|
1542
|
-
"attempt": attempt,
|
|
1543
|
-
}
|
|
1544
|
-
if supersedes is not None:
|
|
1545
|
-
pending_receipt["supersedes"] = supersedes
|
|
1546
|
-
pending_receipt["amendReason"] = amend_reason or ""
|
|
1547
|
-
_atomic_write_json(receipt_path, pending_receipt)
|
|
1548
|
-
terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
|
|
1549
|
-
terminal_committed = _terminal_exists(change_dir, run_id, attempt)
|
|
1550
|
-
if terminal_code != 0 and not terminal_committed:
|
|
1551
|
-
_rollback_publish(created, replaced)
|
|
1552
|
-
receipt_path.unlink(missing_ok=True)
|
|
1553
|
-
return _result_error(
|
|
1554
|
-
"PLAN_TERMINAL_APPEND_FAILED",
|
|
1555
|
-
terminal_error or "phase.end append failed",
|
|
1556
|
-
)
|
|
1557
|
-
pending_receipt["status"] = "finalized"
|
|
1558
|
-
_atomic_write_json(receipt_path, pending_receipt)
|
|
1559
|
-
result: dict[str, Any] = {
|
|
1560
|
-
"ok": True,
|
|
1561
|
-
"action": "republish" if supersedes is not None else "finalize",
|
|
1562
|
-
"idempotent": False,
|
|
1563
|
-
"artifactsHash": validation["artifactsHash"],
|
|
1564
|
-
"files": validation["files"],
|
|
1565
|
-
"receiptPath": str(receipt_path),
|
|
1566
|
-
"artifactRef": "meta/plan-finalization.json",
|
|
1567
|
-
"executionLogPath": str(harness_events.execution_log_path(change_dir)),
|
|
1568
|
-
}
|
|
1569
|
-
if supersedes is not None:
|
|
1570
|
-
result["supersedes"] = supersedes
|
|
1571
|
-
result["scenarioCount"] = len(scenarios)
|
|
1572
|
-
result["taskCount"] = len(tasks)
|
|
1573
|
-
return result
|
|
1574
|
-
except OSError as exc:
|
|
1575
|
-
if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
|
|
1576
|
-
return _result_error(
|
|
1577
|
-
"PLAN_FINALIZATION_RECOVERY_REQUIRED",
|
|
1578
|
-
f"terminal committed; retry finalization to complete receipt: {exc}",
|
|
1579
|
-
)
|
|
1580
|
-
_rollback_publish(created, replaced)
|
|
1581
|
-
receipt_path.unlink(missing_ok=True)
|
|
1582
|
-
return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
|
|
1583
|
-
finally:
|
|
1584
|
-
lock_path.unlink(missing_ok=True)
|
|
1585
985
|
|
|
1586
986
|
|
|
1587
|
-
def republish_plan(
|
|
1588
|
-
change_dir: Path,
|
|
1589
|
-
staging: Path,
|
|
1590
|
-
*,
|
|
1591
|
-
change_name: str,
|
|
1592
|
-
run_id: str,
|
|
1593
|
-
reason: str,
|
|
1594
|
-
) -> dict[str, Any]:
|
|
1595
|
-
"""Publish an approved amendment to an already-finalized plan.
|
|
1596
|
-
|
|
1597
|
-
Amending a published plan (adding a scenario, correcting a task) used to
|
|
1598
|
-
require reverse-engineering a five-step lifecycle dance across three
|
|
1599
|
-
modules — new run id, incremented attempt, stale receipt removal, dangling
|
|
1600
|
-
attempt sealing — and every wrong turn produced a different error code
|
|
1601
|
-
(PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
|
|
1602
|
-
EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
|
|
1603
|
-
hand-edited meta/scenario-manifest.json, creating exactly the drift the
|
|
1604
|
-
hash guard exists to prevent.
|
|
1605
|
-
|
|
1606
|
-
This performs the whole sequence atomically and keeps the superseded
|
|
1607
|
-
identity in the receipt, so amendment stays auditable rather than silent.
|
|
1608
|
-
"""
|
|
1609
|
-
change_dir = change_dir.resolve()
|
|
1610
|
-
staging = staging.resolve()
|
|
1611
987
|
|
|
1612
|
-
if not str(reason).strip():
|
|
1613
|
-
return _result_error(
|
|
1614
|
-
"PLAN_AMEND_REASON_REQUIRED",
|
|
1615
|
-
"--reason is required: an amendment to a published plan must record why",
|
|
1616
|
-
)
|
|
1617
988
|
|
|
1618
|
-
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
1619
|
-
if not receipt_path.is_file():
|
|
1620
|
-
return _result_error(
|
|
1621
|
-
"PLAN_NOT_FINALIZED",
|
|
1622
|
-
"no plan-finalization.json to amend; use `finalize` for the first "
|
|
1623
|
-
"publication",
|
|
1624
|
-
)
|
|
1625
989
|
|
|
1626
|
-
if _run_id_in_use(change_dir, run_id):
|
|
1627
|
-
return _result_error(
|
|
1628
|
-
"PLAN_AMEND_RUN_ID_IN_USE",
|
|
1629
|
-
f"run id {run_id} already appears in this change's events; "
|
|
1630
|
-
"an amendment needs a fresh run id",
|
|
1631
|
-
)
|
|
1632
990
|
|
|
1633
|
-
# Nothing to amend is a no-op, not a new attempt: probe the staged hash
|
|
1634
|
-
# before writing any lifecycle event.
|
|
1635
|
-
reclassify = _reclassify_gate_policy(staging, change_name)
|
|
1636
|
-
if not reclassify.get("ok"):
|
|
1637
|
-
return _result_error(
|
|
1638
|
-
"CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
|
|
1639
|
-
)
|
|
1640
|
-
probe = validate_staging(staging, change_name)
|
|
1641
|
-
if not probe["ok"]:
|
|
1642
|
-
return probe
|
|
1643
|
-
try:
|
|
1644
|
-
published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
|
|
1645
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
1646
|
-
return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
|
|
1647
|
-
if (
|
|
1648
|
-
isinstance(published, dict)
|
|
1649
|
-
and published.get("status") == "finalized"
|
|
1650
|
-
and published.get("artifactsHash") == probe["artifactsHash"]
|
|
1651
|
-
):
|
|
1652
|
-
return {
|
|
1653
|
-
"ok": True,
|
|
1654
|
-
"action": "republish",
|
|
1655
|
-
"idempotent": True,
|
|
1656
|
-
"artifactsHash": probe["artifactsHash"],
|
|
1657
|
-
"files": probe["files"],
|
|
1658
|
-
"receiptPath": str(receipt_path),
|
|
1659
|
-
"artifactRef": "meta/plan-finalization.json",
|
|
1660
|
-
"note": "published plan already matches staging; nothing to amend",
|
|
1661
|
-
}
|
|
1662
991
|
|
|
1663
|
-
attempt = max(_plan_attempts(change_dir), default=0) + 1
|
|
1664
|
-
start_code, start_error = _append_plan_start(
|
|
1665
|
-
change_dir, run_id, attempt, f"plan amendment: {reason}"
|
|
1666
|
-
)
|
|
1667
|
-
if start_code != 0:
|
|
1668
|
-
return _result_error(
|
|
1669
|
-
"PLAN_AMEND_START_FAILED",
|
|
1670
|
-
start_error or "failed to append plan phase.start for the amendment",
|
|
1671
|
-
)
|
|
1672
992
|
|
|
1673
|
-
result = finalize_plan(
|
|
1674
|
-
change_dir,
|
|
1675
|
-
staging,
|
|
1676
|
-
change_name=change_name,
|
|
1677
|
-
run_id=run_id,
|
|
1678
|
-
attempt=attempt,
|
|
1679
|
-
allow_amend=True,
|
|
1680
|
-
amend_reason=reason,
|
|
1681
|
-
)
|
|
1682
|
-
if isinstance(result, dict):
|
|
1683
|
-
result.setdefault("runId", run_id)
|
|
1684
|
-
result.setdefault("attempt", attempt)
|
|
1685
|
-
return result
|
|
1686
993
|
|
|
1687
994
|
|
|
1688
995
|
def build_parser() -> argparse.ArgumentParser:
|
|
1689
996
|
parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
|
|
1690
997
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
1691
|
-
republish = sub.add_parser(
|
|
1692
|
-
"republish",
|
|
1693
|
-
help=(
|
|
1694
|
-
"publish an approved amendment to an already-finalized plan "
|
|
1695
|
-
"(allocates a fresh attempt and records what it supersedes)"
|
|
1696
|
-
),
|
|
1697
|
-
)
|
|
1698
|
-
republish.add_argument("--change-dir", required=True)
|
|
1699
|
-
republish.add_argument("--staging-dir", required=True)
|
|
1700
|
-
republish.add_argument("--change", required=True)
|
|
1701
|
-
republish.add_argument(
|
|
1702
|
-
"--run-id", required=True, help="a fresh run id, unused by this change"
|
|
1703
|
-
)
|
|
1704
|
-
republish.add_argument(
|
|
1705
|
-
"--reason", required=True, help="why the published plan is being amended"
|
|
1706
|
-
)
|
|
1707
|
-
republish.add_argument("--json", action="store_true")
|
|
1708
|
-
finalize = sub.add_parser("finalize")
|
|
1709
|
-
finalize.add_argument("--change-dir", required=True)
|
|
1710
|
-
finalize.add_argument("--staging-dir", required=True)
|
|
1711
|
-
finalize.add_argument("--change", required=True)
|
|
1712
|
-
finalize.add_argument("--run-id", required=True)
|
|
1713
|
-
finalize.add_argument("--attempt", required=True, type=int)
|
|
1714
|
-
finalize.add_argument("--json", action="store_true")
|
|
1715
998
|
verify = sub.add_parser("verify")
|
|
1716
999
|
verify.add_argument("--change-dir", required=True)
|
|
1717
1000
|
verify.add_argument("--json", action="store_true")
|
|
@@ -1720,24 +1003,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1720
1003
|
|
|
1721
1004
|
def main(argv: list[str] | None = None) -> int:
|
|
1722
1005
|
args = build_parser().parse_args(argv)
|
|
1723
|
-
|
|
1724
|
-
result = verify_plan(Path(args.change_dir))
|
|
1725
|
-
elif args.command == "republish":
|
|
1726
|
-
result = republish_plan(
|
|
1727
|
-
Path(args.change_dir),
|
|
1728
|
-
Path(args.staging_dir),
|
|
1729
|
-
change_name=args.change,
|
|
1730
|
-
run_id=args.run_id,
|
|
1731
|
-
reason=args.reason,
|
|
1732
|
-
)
|
|
1733
|
-
else:
|
|
1734
|
-
result = finalize_plan(
|
|
1735
|
-
Path(args.change_dir),
|
|
1736
|
-
Path(args.staging_dir),
|
|
1737
|
-
change_name=args.change,
|
|
1738
|
-
run_id=args.run_id,
|
|
1739
|
-
attempt=args.attempt,
|
|
1740
|
-
)
|
|
1006
|
+
result = verify_plan(Path(args.change_dir))
|
|
1741
1007
|
stream = sys.stdout if result["ok"] else sys.stderr
|
|
1742
1008
|
if args.json:
|
|
1743
1009
|
stream.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|