@hunter-harness/workflow-harness 0.4.0 → 0.4.1
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/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.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 +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/general/pi/.harness-build.json +6 -0
- package/harness/bundles/general/pi/CONTEXT.md +98 -0
- package/harness/bundles/general/pi/README.md +478 -0
- package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/general/pi/harness-review/reference.md +114 -0
- package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
- package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
- package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/general/pi/harness-run/reference.md +886 -0
- package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
- package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
- package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
- package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/general/pi/harness-test/reference.md +880 -0
- package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.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 +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- 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 +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
- package/harness/bundles/java/pi/.harness-build.json +6 -0
- package/harness/bundles/java/pi/CONTEXT.md +98 -0
- package/harness/bundles/java/pi/README.md +478 -0
- package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
- package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
- package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
- package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
- package/harness/bundles/java/pi/harness-package/reference.md +210 -0
- package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/java/pi/harness-review/reference.md +114 -0
- package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
- package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
- package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/java/pi/harness-run/reference.md +820 -0
- package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
- package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
- package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
- package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
- package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/java/pi/harness-test/reference.md +792 -0
- package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/manifests/general/claude-code.json +17 -17
- package/harness/manifests/general/codebuddy.json +17 -17
- package/harness/manifests/general/codex.json +17 -17
- package/harness/manifests/general/cursor.json +17 -17
- package/harness/manifests/general/pi.json +475 -0
- package/harness/manifests/java/claude-code.json +19 -19
- package/harness/manifests/java/codebuddy.json +19 -19
- package/harness/manifests/java/codex.json +19 -19
- package/harness/manifests/java/cursor.json +19 -19
- package/harness/manifests/java/pi.json +499 -0
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1774 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-agent workflow context and append-only transition receipts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
20
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
22
|
+
|
|
23
|
+
import harness_ledger as hl # noqa: E402
|
|
24
|
+
import harness_paths as hpaths # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PHASE_GRAPH = {
|
|
28
|
+
"plan": ("execute",),
|
|
29
|
+
"execute": ("review", "execute"),
|
|
30
|
+
"review": ("submit", "execute"),
|
|
31
|
+
"submit": (),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# 唯一权威清单在 harness_paths(叶子模块,harness_phase 引的是同一个对象)。
|
|
35
|
+
WORKFLOW_PHASES = hpaths.WORKFLOW_PHASES
|
|
36
|
+
|
|
37
|
+
DISPLAY_TITLE_MAX_LENGTH = 80
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now() -> dt.datetime:
|
|
41
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
45
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
raise ValueError(f"JSON object required: {path}")
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_display_title(value: Any) -> str | None:
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
raise ValueError("display title must be text")
|
|
56
|
+
title = value.strip()
|
|
57
|
+
if not title:
|
|
58
|
+
raise ValueError("display title must not be empty")
|
|
59
|
+
if len(title) > DISPLAY_TITLE_MAX_LENGTH:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"display title exceeds {DISPLAY_TITLE_MAX_LENGTH} characters"
|
|
62
|
+
)
|
|
63
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in title):
|
|
64
|
+
raise ValueError("display title contains control characters")
|
|
65
|
+
return title
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ensure_display_title(contract_root: Path, value: Any) -> str | None:
|
|
69
|
+
path = contract_root / "meta" / "change-title.json"
|
|
70
|
+
if path.is_file():
|
|
71
|
+
payload = _read_json(path)
|
|
72
|
+
return _normalize_display_title(payload.get("displayTitle"))
|
|
73
|
+
title = _normalize_display_title(value)
|
|
74
|
+
if title is None:
|
|
75
|
+
return None
|
|
76
|
+
_write_json_atomic(
|
|
77
|
+
path,
|
|
78
|
+
{
|
|
79
|
+
"schemaVersion": 1,
|
|
80
|
+
"displayTitle": title,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
return title
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
|
87
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
fd, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
|
89
|
+
temp = Path(raw)
|
|
90
|
+
try:
|
|
91
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
92
|
+
handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
93
|
+
handle.flush()
|
|
94
|
+
os.fsync(handle.fileno())
|
|
95
|
+
os.replace(temp, path)
|
|
96
|
+
finally:
|
|
97
|
+
temp.unlink(missing_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _append_ndjson(path: Path, payload: dict[str, Any]) -> None:
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
lock = path.with_suffix(path.suffix + ".lock")
|
|
103
|
+
deadline = time.monotonic() + 2
|
|
104
|
+
descriptor: int | None = None
|
|
105
|
+
while descriptor is None:
|
|
106
|
+
try:
|
|
107
|
+
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
108
|
+
except FileExistsError:
|
|
109
|
+
if time.monotonic() >= deadline:
|
|
110
|
+
raise TimeoutError(f"context transition log locked: {path}")
|
|
111
|
+
time.sleep(0.02)
|
|
112
|
+
try:
|
|
113
|
+
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
114
|
+
handle.write(
|
|
115
|
+
json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n"
|
|
116
|
+
)
|
|
117
|
+
handle.flush()
|
|
118
|
+
os.fsync(handle.fileno())
|
|
119
|
+
finally:
|
|
120
|
+
os.close(descriptor)
|
|
121
|
+
lock.unlink(missing_ok=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@contextmanager
|
|
125
|
+
def _exclusive_state_lock(path: Path):
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
deadline = time.monotonic() + 2
|
|
128
|
+
descriptor: int | None = None
|
|
129
|
+
while descriptor is None:
|
|
130
|
+
try:
|
|
131
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
132
|
+
except FileExistsError:
|
|
133
|
+
if time.monotonic() >= deadline:
|
|
134
|
+
raise TimeoutError(f"context state locked: {path}")
|
|
135
|
+
time.sleep(0.02)
|
|
136
|
+
try:
|
|
137
|
+
yield
|
|
138
|
+
finally:
|
|
139
|
+
os.close(descriptor)
|
|
140
|
+
path.unlink(missing_ok=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _read_ndjson(path: Path) -> list[dict[str, Any]]:
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
return []
|
|
146
|
+
entries: list[dict[str, Any]] = []
|
|
147
|
+
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
148
|
+
if not line.strip():
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
item = json.loads(line)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
continue
|
|
154
|
+
if isinstance(item, dict):
|
|
155
|
+
entries.append(item)
|
|
156
|
+
return entries
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sha256(path: Path) -> str:
|
|
160
|
+
return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _payload_hash(payload: dict[str, Any]) -> str:
|
|
164
|
+
return "sha256:" + hashlib.sha256(
|
|
165
|
+
json.dumps(
|
|
166
|
+
payload,
|
|
167
|
+
ensure_ascii=False,
|
|
168
|
+
sort_keys=True,
|
|
169
|
+
separators=(",", ":"),
|
|
170
|
+
).encode("utf-8")
|
|
171
|
+
).hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _head(project: Path) -> str | None:
|
|
175
|
+
process = subprocess.run(
|
|
176
|
+
["git", "-C", str(project), "rev-parse", "--verify", "HEAD"],
|
|
177
|
+
text=True,
|
|
178
|
+
encoding="utf-8",
|
|
179
|
+
errors="replace",
|
|
180
|
+
stdout=subprocess.PIPE,
|
|
181
|
+
stderr=subprocess.PIPE,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
value = process.stdout.strip()
|
|
185
|
+
return value if process.returncode == 0 and value else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _git_common_dir(project: Path) -> Path | None:
|
|
189
|
+
process = subprocess.run(
|
|
190
|
+
["git", "-C", str(project), "rev-parse", "--git-common-dir"],
|
|
191
|
+
text=True,
|
|
192
|
+
encoding="utf-8",
|
|
193
|
+
errors="replace",
|
|
194
|
+
stdout=subprocess.PIPE,
|
|
195
|
+
stderr=subprocess.PIPE,
|
|
196
|
+
check=False,
|
|
197
|
+
)
|
|
198
|
+
value = process.stdout.strip()
|
|
199
|
+
if process.returncode != 0 or not value:
|
|
200
|
+
return None
|
|
201
|
+
common = Path(value)
|
|
202
|
+
return common.resolve() if common.is_absolute() else (project / common).resolve()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _same_repository(project: Path, candidate: Path) -> bool:
|
|
206
|
+
project_common = _git_common_dir(project)
|
|
207
|
+
candidate_common = _git_common_dir(candidate)
|
|
208
|
+
if project_common is not None or candidate_common is not None:
|
|
209
|
+
return (
|
|
210
|
+
project_common is not None
|
|
211
|
+
and candidate_common is not None
|
|
212
|
+
and project_common == candidate_common
|
|
213
|
+
)
|
|
214
|
+
return candidate.is_relative_to(project)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _contract_error_code(exc: BaseException) -> str:
|
|
218
|
+
"""Keep PROJECT_ROOT_INVALID distinct from a genuinely missing change."""
|
|
219
|
+
if str(exc).startswith("PROJECT_ROOT_INVALID"):
|
|
220
|
+
return "PROJECT_ROOT_INVALID"
|
|
221
|
+
return "CHANGE_NOT_FOUND"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _contract(project: Path, change: str) -> tuple[Path, dict[str, Any], Path]:
|
|
225
|
+
root = project.resolve()
|
|
226
|
+
# A bare project *name* (--project udp) resolves to <cwd>/udp and then
|
|
227
|
+
# reports CHANGE_NOT_FOUND, which sends the caller hunting for the change
|
|
228
|
+
# instead of fixing the argument. Separate the two failures.
|
|
229
|
+
if not root.is_dir():
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"PROJECT_ROOT_INVALID: {root} is not a directory — --project takes "
|
|
232
|
+
"a path to the project root (use '.'), not the project name"
|
|
233
|
+
)
|
|
234
|
+
changes_root = (root / ".harness" / "changes").resolve()
|
|
235
|
+
if not changes_root.is_dir():
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"PROJECT_ROOT_INVALID: {changes_root} does not exist — "
|
|
238
|
+
f"{root} is not a harness project root"
|
|
239
|
+
)
|
|
240
|
+
contract_root = (root / ".harness" / "changes" / change).resolve()
|
|
241
|
+
if not contract_root.is_relative_to(changes_root) or not contract_root.is_dir():
|
|
242
|
+
raise ValueError(f"CHANGE_NOT_FOUND: {change}")
|
|
243
|
+
context_path = contract_root / "meta" / "change-context.json"
|
|
244
|
+
contract = _read_json(context_path) if context_path.is_file() else {}
|
|
245
|
+
ownership = contract.get("stateOwnership")
|
|
246
|
+
runtime_rel = (
|
|
247
|
+
ownership.get("runtimeRoot")
|
|
248
|
+
if isinstance(ownership, dict)
|
|
249
|
+
else None
|
|
250
|
+
)
|
|
251
|
+
if runtime_rel:
|
|
252
|
+
candidate = (root / str(runtime_rel)).resolve()
|
|
253
|
+
state_parent = (root / ".harness" / "state" / "changes").resolve()
|
|
254
|
+
expected = (state_parent / change).resolve()
|
|
255
|
+
if candidate != expected or not candidate.is_relative_to(state_parent):
|
|
256
|
+
raise ValueError("STATE_ROOT_INVALID")
|
|
257
|
+
state_root = candidate
|
|
258
|
+
else:
|
|
259
|
+
state_root = contract_root
|
|
260
|
+
return contract_root, contract, state_root
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _phase_plan(contract_root: Path) -> tuple[list[str] | None, str]:
|
|
264
|
+
"""读出阶段计划;无法采信时说明原因,而不是悄悄退成 legacy。
|
|
265
|
+
|
|
266
|
+
以前这里遇到未知阶段名一律返回 (None, "legacy"):阶段计划整个失效,
|
|
267
|
+
`_allowed_next_phases` 退回硬编码的 PHASE_GRAPH,而调用方看不出发生过这件事。
|
|
268
|
+
未知阶段名不是假想问题——阶段清单少一个 merge 时,worktree 变更的
|
|
269
|
+
plannedPhases 就会带着 merge 走到这里。
|
|
270
|
+
|
|
271
|
+
未知名先过 LEGACY_PHASE_ALIASES 读时映射(阶段改名后历史 change 靠它继续可读),
|
|
272
|
+
仍然解析不了才降级,并把原因与具体阶段名带在 source 里。
|
|
273
|
+
|
|
274
|
+
权威切换(2026-08):v2 发布快照(plan-profile.json)优先,classify 工作
|
|
275
|
+
副本回退——发布后的阶段计划以已审批快照为准。
|
|
276
|
+
"""
|
|
277
|
+
loaded = hpaths.load_change_gate_policy(contract_root)
|
|
278
|
+
policy = loaded.get("policy")
|
|
279
|
+
if policy is None:
|
|
280
|
+
if loaded.get("working_error"):
|
|
281
|
+
return None, "legacy:policy-unreadable"
|
|
282
|
+
return None, "legacy"
|
|
283
|
+
unknown: list[str] = []
|
|
284
|
+
for field, source in (
|
|
285
|
+
("plannedPhases", "change"),
|
|
286
|
+
("defaultPhases", "policy-default"),
|
|
287
|
+
):
|
|
288
|
+
raw = policy.get(field)
|
|
289
|
+
if not isinstance(raw, list):
|
|
290
|
+
continue
|
|
291
|
+
phases = [str(item).strip() for item in raw if str(item).strip()]
|
|
292
|
+
if not phases or len(phases) != len(set(phases)):
|
|
293
|
+
continue
|
|
294
|
+
resolved = [hpaths.resolve_phase_name(item) for item in phases]
|
|
295
|
+
missing = [item for item, hit in zip(phases, resolved) if hit is None]
|
|
296
|
+
if missing:
|
|
297
|
+
unknown.extend(missing)
|
|
298
|
+
continue
|
|
299
|
+
# 映射后可能出现重复(两个旧名合并成同一个新阶段),保序去重。
|
|
300
|
+
return list(dict.fromkeys(str(item) for item in resolved)), source
|
|
301
|
+
if unknown:
|
|
302
|
+
return None, "legacy:unknown-phases=" + ",".join(dict.fromkeys(unknown))
|
|
303
|
+
return None, "legacy"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _allowed_next_phases(contract_root: Path, from_phase: str) -> list[str]:
|
|
307
|
+
planned, _source = _phase_plan(contract_root)
|
|
308
|
+
resolved_from = hpaths.resolve_phase_name(from_phase) or from_phase
|
|
309
|
+
if planned is None:
|
|
310
|
+
return list(PHASE_GRAPH.get(resolved_from, ()))
|
|
311
|
+
if resolved_from not in planned:
|
|
312
|
+
return []
|
|
313
|
+
index = planned.index(resolved_from)
|
|
314
|
+
allowed = planned[index + 1 : index + 2]
|
|
315
|
+
if resolved_from in {"execute", "review"} and "execute" in planned:
|
|
316
|
+
allowed = [*allowed, "execute"]
|
|
317
|
+
return list(dict.fromkeys(allowed))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def configure_phase_plan(
|
|
321
|
+
project: Path,
|
|
322
|
+
change: str,
|
|
323
|
+
*,
|
|
324
|
+
phases: list[str],
|
|
325
|
+
operator: str,
|
|
326
|
+
reason: str,
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""Persist the single phase plan consumed by Context, Archive and Platform."""
|
|
329
|
+
project = Path(project).resolve()
|
|
330
|
+
try:
|
|
331
|
+
contract_root, _contract_data, _state_root = _contract(project, change)
|
|
332
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
333
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
334
|
+
normalized = [str(item).strip() for item in phases if str(item).strip()]
|
|
335
|
+
resolved = [hpaths.resolve_phase_name(item) for item in normalized]
|
|
336
|
+
if (
|
|
337
|
+
not normalized
|
|
338
|
+
or len(normalized) != len(set(normalized))
|
|
339
|
+
or any(item is None for item in resolved)
|
|
340
|
+
or len(set(resolved)) != len(resolved)
|
|
341
|
+
or resolved[0] != "plan"
|
|
342
|
+
or resolved[-1] != "archive"
|
|
343
|
+
):
|
|
344
|
+
return {
|
|
345
|
+
"ok": False,
|
|
346
|
+
"code": "PHASE_PLAN_INVALID",
|
|
347
|
+
"message": "phase plan must be unique, start with plan, and end with archive",
|
|
348
|
+
"allowedPhases": list(WORKFLOW_PHASES),
|
|
349
|
+
}
|
|
350
|
+
if not operator.strip() or not reason.strip():
|
|
351
|
+
return {
|
|
352
|
+
"ok": False,
|
|
353
|
+
"code": "PHASE_PLAN_JUSTIFICATION_REQUIRED",
|
|
354
|
+
}
|
|
355
|
+
# 旧名入参(plan,run,test,review,archive)归一为现阶段名后落盘;
|
|
356
|
+
# 上面已校验 resolved 无 None 且保序唯一。
|
|
357
|
+
normalized = [str(item) for item in resolved]
|
|
358
|
+
worktree_path = contract_root / "meta" / "worktree.json"
|
|
359
|
+
if worktree_path.is_file():
|
|
360
|
+
try:
|
|
361
|
+
worktree = _read_json(worktree_path)
|
|
362
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
363
|
+
worktree = {}
|
|
364
|
+
if bool(worktree.get("requested") or worktree.get("created")) and "merge" not in normalized:
|
|
365
|
+
normalized.insert(normalized.index("archive"), "merge")
|
|
366
|
+
policy_path = contract_root / "meta" / "gate-policy.json"
|
|
367
|
+
policy = _read_json(policy_path) if policy_path.is_file() else {"schemaVersion": 1}
|
|
368
|
+
skipped = [
|
|
369
|
+
{
|
|
370
|
+
"phase": phase,
|
|
371
|
+
"reason": reason.strip(),
|
|
372
|
+
"operator": operator.strip(),
|
|
373
|
+
"decidedAt": _now().isoformat(),
|
|
374
|
+
}
|
|
375
|
+
for phase in WORKFLOW_PHASES
|
|
376
|
+
if phase not in normalized and phase != "merge"
|
|
377
|
+
]
|
|
378
|
+
policy["plannedPhases"] = normalized
|
|
379
|
+
policy["skippedPhases"] = skipped
|
|
380
|
+
policy["phasePlan"] = {
|
|
381
|
+
"source": "change-override",
|
|
382
|
+
"operator": operator.strip(),
|
|
383
|
+
"reason": reason.strip(),
|
|
384
|
+
"updatedAt": _now().isoformat(),
|
|
385
|
+
}
|
|
386
|
+
_write_json_atomic(policy_path, policy)
|
|
387
|
+
return {
|
|
388
|
+
"ok": True,
|
|
389
|
+
"code": "PHASE_PLAN_CONFIGURED",
|
|
390
|
+
"plannedPhases": normalized,
|
|
391
|
+
"skippedPhases": skipped,
|
|
392
|
+
"path": str(policy_path),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _active_changes(project: Path) -> list[str]:
|
|
397
|
+
root = project.resolve() / ".harness" / "changes"
|
|
398
|
+
if not root.is_dir():
|
|
399
|
+
return []
|
|
400
|
+
active: list[str] = []
|
|
401
|
+
for child in sorted(root.iterdir()):
|
|
402
|
+
path = child / "meta" / "change-context.json"
|
|
403
|
+
if not child.is_dir() or not path.is_file():
|
|
404
|
+
continue
|
|
405
|
+
try:
|
|
406
|
+
contract = _read_json(path)
|
|
407
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
408
|
+
continue
|
|
409
|
+
lifecycle = contract.get("lifecycle")
|
|
410
|
+
if isinstance(lifecycle, dict) and lifecycle.get("status") == "active":
|
|
411
|
+
active.append(child.name)
|
|
412
|
+
return active
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _paths(state_root: Path) -> dict[str, Path]:
|
|
416
|
+
runtime = state_root / "runtime"
|
|
417
|
+
return {
|
|
418
|
+
"runtime": runtime,
|
|
419
|
+
"transitions": runtime / "transitions.ndjson",
|
|
420
|
+
"begins": runtime / "transition-begins.ndjson",
|
|
421
|
+
"current": runtime / "current-context.json",
|
|
422
|
+
"lease": runtime / "context-lease.json",
|
|
423
|
+
"adoptions": runtime / "context-adoptions.ndjson",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _parse_time(value: Any) -> dt.datetime | None:
|
|
428
|
+
if not isinstance(value, str) or not value.strip():
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
432
|
+
except ValueError:
|
|
433
|
+
return None
|
|
434
|
+
if parsed.tzinfo is None:
|
|
435
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
436
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _execution_root(project: Path, contract_root: Path, state_root: Path) -> Path:
|
|
440
|
+
declared = False
|
|
441
|
+
# Context、ledger 与 gate 必须以同一份可信 worktree 契约为准。split
|
|
442
|
+
# state 可能携带运行元数据,因此两个根都交给统一解析器检查。
|
|
443
|
+
for change_root in (state_root, contract_root):
|
|
444
|
+
inferred = hl.infer_execution_project_root(change_root)
|
|
445
|
+
if inferred is not None:
|
|
446
|
+
return inferred
|
|
447
|
+
declared = declared or hl.declares_execution_worktree(change_root)
|
|
448
|
+
if declared:
|
|
449
|
+
raise ValueError("EXECUTION_WORKTREE_INVALID")
|
|
450
|
+
return project.resolve()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _reselect_review_fixback(
|
|
454
|
+
project: Path,
|
|
455
|
+
change: str,
|
|
456
|
+
*,
|
|
457
|
+
contract_root: Path,
|
|
458
|
+
state_root: Path,
|
|
459
|
+
executor: str,
|
|
460
|
+
) -> dict[str, Any]:
|
|
461
|
+
"""Supersede an unbegun review→submit choice with review→execute."""
|
|
462
|
+
|
|
463
|
+
paths = _paths(state_root)
|
|
464
|
+
with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
|
|
465
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
466
|
+
latest = transitions[-1] if transitions else None
|
|
467
|
+
if (
|
|
468
|
+
isinstance(latest, dict)
|
|
469
|
+
and hpaths.resolve_phase_name(latest.get("fromPhase")) == "review"
|
|
470
|
+
and hpaths.resolve_phase_name(latest.get("toPhase")) == "execute"
|
|
471
|
+
and latest.get("trigger") == "review-fixback"
|
|
472
|
+
):
|
|
473
|
+
return {
|
|
474
|
+
"ok": True,
|
|
475
|
+
"code": "FIXBACK_BRANCH_ALREADY_SELECTED",
|
|
476
|
+
"receipt": latest,
|
|
477
|
+
}
|
|
478
|
+
if not (
|
|
479
|
+
isinstance(latest, dict)
|
|
480
|
+
and latest.get("fromPhase") == "review"
|
|
481
|
+
and latest.get("toPhase") == "submit"
|
|
482
|
+
):
|
|
483
|
+
return {
|
|
484
|
+
"ok": False,
|
|
485
|
+
"code": "FIXBACK_RESELECT_UNAVAILABLE",
|
|
486
|
+
"message": "当前没有可安全重选的评审后继分支。",
|
|
487
|
+
}
|
|
488
|
+
begins = _read_ndjson(paths["begins"])
|
|
489
|
+
submit_begun = any(
|
|
490
|
+
isinstance(item, dict)
|
|
491
|
+
and item.get("phase") == "submit"
|
|
492
|
+
and item.get("receiptHash") == latest.get("receiptHash")
|
|
493
|
+
for item in begins
|
|
494
|
+
)
|
|
495
|
+
event_paths = [state_root / "events.ndjson"]
|
|
496
|
+
if state_root != contract_root:
|
|
497
|
+
event_paths.append(contract_root / "events.ndjson")
|
|
498
|
+
submit_started = any(
|
|
499
|
+
item.get("phase") == "submit" and item.get("type") == "phase.start"
|
|
500
|
+
for path in event_paths
|
|
501
|
+
for item in _read_ndjson(path)
|
|
502
|
+
)
|
|
503
|
+
if submit_begun or submit_started:
|
|
504
|
+
return {
|
|
505
|
+
"ok": False,
|
|
506
|
+
"code": "FIXBACK_RESELECT_UNSAFE",
|
|
507
|
+
"message": "提交阶段已经开始,不能自动切回修复;请先完成或明确恢复提交现场。",
|
|
508
|
+
}
|
|
509
|
+
if paths["lease"].is_file():
|
|
510
|
+
try:
|
|
511
|
+
lease = _read_json(paths["lease"])
|
|
512
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
513
|
+
return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
|
|
514
|
+
if lease.get("phase") != "submit" or lease.get("owner") != executor:
|
|
515
|
+
return {
|
|
516
|
+
"ok": False,
|
|
517
|
+
"code": "FIXBACK_RESELECT_UNSAFE",
|
|
518
|
+
"message": "当前阶段租约不属于本次可撤销的提交准备。",
|
|
519
|
+
}
|
|
520
|
+
paths["lease"].unlink(missing_ok=True)
|
|
521
|
+
# Any submit context present here was only prepared, never begun. The
|
|
522
|
+
# new execute context is written by prepare_context after this atomic choice.
|
|
523
|
+
paths["current"].unlink(missing_ok=True)
|
|
524
|
+
|
|
525
|
+
receipt: dict[str, Any] = {
|
|
526
|
+
"schemaVersion": 1,
|
|
527
|
+
"changeName": change,
|
|
528
|
+
"fromPhase": "review",
|
|
529
|
+
"toPhase": "execute",
|
|
530
|
+
"status": "OK",
|
|
531
|
+
"executor": executor,
|
|
532
|
+
"productCommit": _head(project),
|
|
533
|
+
"artifacts": [],
|
|
534
|
+
"attempt": 1
|
|
535
|
+
+ sum(
|
|
536
|
+
1
|
|
537
|
+
for item in transitions
|
|
538
|
+
if hpaths.resolve_phase_name(item.get("fromPhase")) == "review"
|
|
539
|
+
and hpaths.resolve_phase_name(item.get("toPhase")) == "execute"
|
|
540
|
+
),
|
|
541
|
+
"closedAt": _now().isoformat(),
|
|
542
|
+
"previousReceiptHash": latest.get("receiptHash"),
|
|
543
|
+
"supersedesReceiptHash": latest.get("receiptHash"),
|
|
544
|
+
"trigger": "review-fixback",
|
|
545
|
+
"selectionReason": "用户选择处理评审中的代码修复项",
|
|
546
|
+
}
|
|
547
|
+
receipt["receiptHash"] = _payload_hash(receipt)
|
|
548
|
+
_append_ndjson(paths["transitions"], receipt)
|
|
549
|
+
invalidation = _invalidate_for_fixback(
|
|
550
|
+
state_root,
|
|
551
|
+
transition_hash=receipt["receiptHash"],
|
|
552
|
+
from_phase="review",
|
|
553
|
+
to_phase="execute",
|
|
554
|
+
)
|
|
555
|
+
return {
|
|
556
|
+
"ok": True,
|
|
557
|
+
"code": "FIXBACK_BRANCH_RESELECTED",
|
|
558
|
+
"receipt": receipt,
|
|
559
|
+
"invalidation": invalidation,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _claim_prepared_context(
|
|
564
|
+
project: Path,
|
|
565
|
+
change: str,
|
|
566
|
+
*,
|
|
567
|
+
contract_root: Path,
|
|
568
|
+
state_root: Path,
|
|
569
|
+
phase: str,
|
|
570
|
+
executor: str,
|
|
571
|
+
ttl_seconds: int,
|
|
572
|
+
preparation_id: str | None,
|
|
573
|
+
display_title: str | None,
|
|
574
|
+
) -> dict[str, Any]:
|
|
575
|
+
"""Atomically check and claim the lease/current preparation pair."""
|
|
576
|
+
|
|
577
|
+
paths = _paths(state_root)
|
|
578
|
+
with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
|
|
579
|
+
recovery: dict[str, Any] | None = None
|
|
580
|
+
if paths["lease"].is_file():
|
|
581
|
+
try:
|
|
582
|
+
existing_lease = _read_json(paths["lease"])
|
|
583
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
584
|
+
return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
|
|
585
|
+
expiry = _parse_time(existing_lease.get("expiresAt"))
|
|
586
|
+
expired = expiry is None or _now() >= expiry
|
|
587
|
+
if not expired and existing_lease.get("owner") != executor:
|
|
588
|
+
return {
|
|
589
|
+
"ok": False,
|
|
590
|
+
"code": "CONTEXT_LEASE_HELD",
|
|
591
|
+
"holder": existing_lease.get("owner"),
|
|
592
|
+
"expiresAt": existing_lease.get("expiresAt"),
|
|
593
|
+
}
|
|
594
|
+
if (
|
|
595
|
+
not expired
|
|
596
|
+
and preparation_id is not None
|
|
597
|
+
and existing_lease.get("preparationId") != preparation_id
|
|
598
|
+
):
|
|
599
|
+
return {
|
|
600
|
+
"ok": False,
|
|
601
|
+
"code": "CONTEXT_PREPARATION_ACTIVE",
|
|
602
|
+
"message": "已有一轮修复准备或执行正在进行,本次重复启动未修改现有上下文。",
|
|
603
|
+
"preparationId": existing_lease.get("preparationId"),
|
|
604
|
+
"expiresAt": existing_lease.get("expiresAt"),
|
|
605
|
+
}
|
|
606
|
+
if expired:
|
|
607
|
+
recovery = {
|
|
608
|
+
"code": "LEASE_EXPIRED_RECOVERED",
|
|
609
|
+
"previousOwner": existing_lease.get("owner"),
|
|
610
|
+
"expiredAt": existing_lease.get("expiresAt"),
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
614
|
+
latest = transitions[-1] if transitions else None
|
|
615
|
+
try:
|
|
616
|
+
current = (
|
|
617
|
+
_read_json(paths["current"])
|
|
618
|
+
if paths["current"].is_file()
|
|
619
|
+
else None
|
|
620
|
+
)
|
|
621
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
622
|
+
return {"ok": False, "code": "CONTEXT_CURRENT_INVALID"}
|
|
623
|
+
# 跨阶段推进必须有 receipt 证明它合法;同一个阶段换执行者不需要。
|
|
624
|
+
# 以前两者都要 receipt,于是上一阶段崩溃没能正常 close 时,接手的工具
|
|
625
|
+
# 连"继续把这个阶段干完"都做不到。并发保护不受影响:租约未过期而
|
|
626
|
+
# owner 不同,上面 CONTEXT_LEASE_HELD 已经拦下了,能走到这里只剩
|
|
627
|
+
# 「租约已过期」和「租约已被上一阶段 close 删除」两种情况。
|
|
628
|
+
adoption: dict[str, Any] | None = None
|
|
629
|
+
if recovery is None and isinstance(current, dict) and (
|
|
630
|
+
hpaths.resolve_phase_name(current.get("phase")) != phase
|
|
631
|
+
):
|
|
632
|
+
valid_receipt = (
|
|
633
|
+
isinstance(latest, dict)
|
|
634
|
+
and hpaths.resolve_phase_name(latest.get("toPhase")) == phase
|
|
635
|
+
and hpaths.resolve_phase_name(latest.get("fromPhase"))
|
|
636
|
+
== hpaths.resolve_phase_name(current.get("phase"))
|
|
637
|
+
)
|
|
638
|
+
if not valid_receipt:
|
|
639
|
+
return {
|
|
640
|
+
"ok": False,
|
|
641
|
+
"code": "HANDOFF_REQUIRED",
|
|
642
|
+
"fromExecutor": current.get("executor"),
|
|
643
|
+
"fromPhase": current.get("phase"),
|
|
644
|
+
"toExecutor": executor,
|
|
645
|
+
"toPhase": phase,
|
|
646
|
+
}
|
|
647
|
+
executor_changed = (
|
|
648
|
+
isinstance(current, dict) and current.get("executor") != executor
|
|
649
|
+
)
|
|
650
|
+
if recovery is not None or executor_changed:
|
|
651
|
+
adoption = {
|
|
652
|
+
"previousExecutor": (
|
|
653
|
+
current.get("executor") if isinstance(current, dict) else None
|
|
654
|
+
),
|
|
655
|
+
"newExecutor": executor,
|
|
656
|
+
"reason": (
|
|
657
|
+
"lease_expired" if recovery is not None else "same_phase_handoff"
|
|
658
|
+
),
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
execution_root = _execution_root(project, contract_root, state_root)
|
|
663
|
+
except ValueError as exc:
|
|
664
|
+
return {
|
|
665
|
+
"ok": False,
|
|
666
|
+
"code": "EXECUTION_WORKTREE_INVALID",
|
|
667
|
+
"error": str(exc),
|
|
668
|
+
}
|
|
669
|
+
now = _now()
|
|
670
|
+
lease: dict[str, Any] = {
|
|
671
|
+
"schemaVersion": 1,
|
|
672
|
+
"changeName": change,
|
|
673
|
+
"phase": phase,
|
|
674
|
+
"owner": executor,
|
|
675
|
+
"acquiredAt": now.isoformat(),
|
|
676
|
+
"expiresAt": (
|
|
677
|
+
now + dt.timedelta(seconds=max(1, int(ttl_seconds)))
|
|
678
|
+
).isoformat(),
|
|
679
|
+
}
|
|
680
|
+
if preparation_id is not None:
|
|
681
|
+
lease["preparationId"] = preparation_id
|
|
682
|
+
current_context: dict[str, Any] = {
|
|
683
|
+
"schemaVersion": 1,
|
|
684
|
+
"changeName": change,
|
|
685
|
+
"phase": phase,
|
|
686
|
+
"executor": executor,
|
|
687
|
+
"executionRoot": str(execution_root),
|
|
688
|
+
"preparedAt": now.isoformat(),
|
|
689
|
+
"receiptHash": latest.get("receiptHash") if latest else None,
|
|
690
|
+
"displayTitle": display_title,
|
|
691
|
+
}
|
|
692
|
+
if preparation_id is not None:
|
|
693
|
+
current_context["preparationId"] = preparation_id
|
|
694
|
+
_write_json_atomic(paths["lease"], lease)
|
|
695
|
+
_write_json_atomic(paths["current"], current_context)
|
|
696
|
+
if adoption is not None:
|
|
697
|
+
# 接管必须留痕。过期租约本来就允许任何执行者顶替,但以前只在返回体
|
|
698
|
+
# 里挂一个 recovery,退出进程就没了——事后查"这个阶段是谁接手的"
|
|
699
|
+
# 无据可依。收据形状照 harness_archive.adopt_existing_range。
|
|
700
|
+
adoption = {
|
|
701
|
+
"schemaVersion": 1,
|
|
702
|
+
"changeName": change,
|
|
703
|
+
"phase": phase,
|
|
704
|
+
**adoption,
|
|
705
|
+
"previousOwner": recovery.get("previousOwner") if recovery else None,
|
|
706
|
+
"expiredAt": recovery.get("expiredAt") if recovery else None,
|
|
707
|
+
"adoptedAt": now.isoformat(),
|
|
708
|
+
}
|
|
709
|
+
_append_ndjson(paths["adoptions"], adoption)
|
|
710
|
+
return {
|
|
711
|
+
"ok": True,
|
|
712
|
+
"lease": lease,
|
|
713
|
+
"current": current_context,
|
|
714
|
+
"transitions": transitions,
|
|
715
|
+
"latestTransition": latest,
|
|
716
|
+
"recovery": recovery,
|
|
717
|
+
"adoption": adoption,
|
|
718
|
+
"executionRoot": str(execution_root),
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _unknown_phase_error(phase: Any) -> dict[str, Any]:
|
|
723
|
+
return {
|
|
724
|
+
"ok": False,
|
|
725
|
+
"code": "PHASE_UNKNOWN",
|
|
726
|
+
"phase": str(phase),
|
|
727
|
+
"allowedPhases": list(WORKFLOW_PHASES),
|
|
728
|
+
"legacyAliases": dict(hpaths.LEGACY_PHASE_ALIASES),
|
|
729
|
+
"message": (
|
|
730
|
+
f"未知阶段名 {phase!r};合法阶段 {list(WORKFLOW_PHASES)},"
|
|
731
|
+
f"旧名别名 {hpaths.LEGACY_PHASE_ALIASES}。"
|
|
732
|
+
),
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def prepare_context(
|
|
737
|
+
project: Path,
|
|
738
|
+
*,
|
|
739
|
+
phase: str,
|
|
740
|
+
executor: str,
|
|
741
|
+
change: str | None = None,
|
|
742
|
+
display_title: str | None = None,
|
|
743
|
+
ttl_seconds: int = 3600,
|
|
744
|
+
trigger: str | None = None,
|
|
745
|
+
preparation_id: str | None = None,
|
|
746
|
+
) -> dict[str, Any]:
|
|
747
|
+
project = Path(project).resolve()
|
|
748
|
+
canonical_phase = hpaths.resolve_phase_name(phase)
|
|
749
|
+
if canonical_phase is None:
|
|
750
|
+
return _unknown_phase_error(phase)
|
|
751
|
+
phase = canonical_phase
|
|
752
|
+
candidates = _active_changes(project)
|
|
753
|
+
if change is None:
|
|
754
|
+
if not candidates:
|
|
755
|
+
return {
|
|
756
|
+
"ok": False,
|
|
757
|
+
"code": "ACTIVE_CHANGE_MISSING",
|
|
758
|
+
"candidates": [],
|
|
759
|
+
}
|
|
760
|
+
if len(candidates) > 1:
|
|
761
|
+
return {
|
|
762
|
+
"ok": False,
|
|
763
|
+
"code": "ACTIVE_CHANGE_AMBIGUOUS",
|
|
764
|
+
"candidates": candidates,
|
|
765
|
+
}
|
|
766
|
+
change = candidates[0]
|
|
767
|
+
try:
|
|
768
|
+
contract_root, contract, state_root = _contract(project, change)
|
|
769
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
770
|
+
return {"ok": False, "code": str(exc).split(":", 1)[0], "error": str(exc)}
|
|
771
|
+
try:
|
|
772
|
+
resolved_display_title = _ensure_display_title(
|
|
773
|
+
contract_root, display_title
|
|
774
|
+
)
|
|
775
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
776
|
+
return {
|
|
777
|
+
"ok": False,
|
|
778
|
+
"code": "DISPLAY_TITLE_INVALID",
|
|
779
|
+
"error": str(exc),
|
|
780
|
+
}
|
|
781
|
+
lifecycle = contract.get("lifecycle")
|
|
782
|
+
if (
|
|
783
|
+
isinstance(lifecycle, dict)
|
|
784
|
+
and lifecycle.get("status") not in {None, "active"}
|
|
785
|
+
):
|
|
786
|
+
return {"ok": False, "code": "CHANGE_NOT_ACTIVE", "changeName": change}
|
|
787
|
+
planned_phases, phase_plan_source = _phase_plan(contract_root)
|
|
788
|
+
if planned_phases is not None and phase not in planned_phases:
|
|
789
|
+
return {
|
|
790
|
+
"ok": False,
|
|
791
|
+
"code": "PHASE_NOT_PLANNED",
|
|
792
|
+
"phase": phase,
|
|
793
|
+
"plannedPhases": planned_phases,
|
|
794
|
+
}
|
|
795
|
+
paths = _paths(state_root)
|
|
796
|
+
paths["runtime"].mkdir(parents=True, exist_ok=True)
|
|
797
|
+
reselection: dict[str, Any] | None = None
|
|
798
|
+
if trigger == "review-fixback" and phase == "execute":
|
|
799
|
+
reselection = _reselect_review_fixback(
|
|
800
|
+
project,
|
|
801
|
+
change,
|
|
802
|
+
contract_root=contract_root,
|
|
803
|
+
state_root=state_root,
|
|
804
|
+
executor=executor,
|
|
805
|
+
)
|
|
806
|
+
if not reselection.get("ok"):
|
|
807
|
+
return reselection
|
|
808
|
+
# v2 计划的交接凭证由此补录:finalize 不写 context 事务,缺它 run 阶段进不去
|
|
809
|
+
if phase != "plan":
|
|
810
|
+
_bootstrap_v2_plan_transition(
|
|
811
|
+
project,
|
|
812
|
+
change,
|
|
813
|
+
contract_root=contract_root,
|
|
814
|
+
state_root=state_root,
|
|
815
|
+
to_phase=phase,
|
|
816
|
+
executor=executor,
|
|
817
|
+
)
|
|
818
|
+
claim = _claim_prepared_context(
|
|
819
|
+
project,
|
|
820
|
+
change,
|
|
821
|
+
contract_root=contract_root,
|
|
822
|
+
state_root=state_root,
|
|
823
|
+
phase=phase,
|
|
824
|
+
executor=executor,
|
|
825
|
+
ttl_seconds=ttl_seconds,
|
|
826
|
+
preparation_id=preparation_id,
|
|
827
|
+
display_title=resolved_display_title,
|
|
828
|
+
)
|
|
829
|
+
if not claim.get("ok"):
|
|
830
|
+
return claim
|
|
831
|
+
transitions = claim["transitions"]
|
|
832
|
+
latest = claim["latestTransition"]
|
|
833
|
+
execution_root = Path(claim["executionRoot"])
|
|
834
|
+
lease = claim["lease"]
|
|
835
|
+
recovery = claim["recovery"]
|
|
836
|
+
next_phases = (
|
|
837
|
+
_allowed_next_phases(contract_root, phase)
|
|
838
|
+
if planned_phases is not None
|
|
839
|
+
else (
|
|
840
|
+
list(
|
|
841
|
+
PHASE_GRAPH.get(
|
|
842
|
+
hpaths.resolve_phase_name(str(latest.get("toPhase"))) or "",
|
|
843
|
+
(),
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
if latest
|
|
847
|
+
else ["plan", "execute"]
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
return {
|
|
851
|
+
"ok": True,
|
|
852
|
+
"code": "CONTEXT_PREPARED",
|
|
853
|
+
"changeName": change,
|
|
854
|
+
"phase": phase,
|
|
855
|
+
"executionRoot": str(execution_root),
|
|
856
|
+
"contractRoot": str(contract_root),
|
|
857
|
+
"stateRoot": str(state_root),
|
|
858
|
+
"nextPhases": next_phases,
|
|
859
|
+
"plannedPhases": planned_phases,
|
|
860
|
+
"phasePlanSource": phase_plan_source,
|
|
861
|
+
"legacyBootstrap": not transitions,
|
|
862
|
+
"latestTransition": latest,
|
|
863
|
+
"lease": lease,
|
|
864
|
+
"recovery": recovery,
|
|
865
|
+
"displayTitle": resolved_display_title,
|
|
866
|
+
"branchSelection": reselection,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _plan_run_identity(change_dir: Path) -> tuple[str, int] | None:
|
|
871
|
+
"""已存在的 plan `phase.start` 身份(run-id, attempt)。
|
|
872
|
+
|
|
873
|
+
finalize 按生命周期身份 fail-closed:重跑引导时换一个 run-id,整条链路就对不上。
|
|
874
|
+
因此引导必须复用已有身份,而不是每次新生成。
|
|
875
|
+
"""
|
|
876
|
+
for item in reversed(_read_ndjson(change_dir / "events.ndjson")):
|
|
877
|
+
if item.get("phase") != "plan" or item.get("type") != "phase.start":
|
|
878
|
+
continue
|
|
879
|
+
run_id = item.get("run_id")
|
|
880
|
+
if isinstance(run_id, str) and run_id:
|
|
881
|
+
attempt = item.get("attempt")
|
|
882
|
+
return run_id, attempt if isinstance(attempt, int) and attempt > 0 else 1
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def bootstrap_plan(
|
|
887
|
+
project: Path,
|
|
888
|
+
*,
|
|
889
|
+
change: str,
|
|
890
|
+
executor: str,
|
|
891
|
+
display_title: str | None = None,
|
|
892
|
+
stage: str = "plan",
|
|
893
|
+
ttl_seconds: int = 3600,
|
|
894
|
+
) -> dict[str, Any]:
|
|
895
|
+
"""阶段 0 一次性引导:doctor + prepare + capture + classify + phase.start。
|
|
896
|
+
|
|
897
|
+
拆成五条子进程调用时,每条的完整 argv 都要调用方记住(少 `--project` 或
|
|
898
|
+
`--change-dir` 就白跑一轮),run-id 还要调用方自己保证小写字母开头。这些都是
|
|
899
|
+
确定性工作,交给脚本做;调用方只需要一次调用和一份紧凑摘要。
|
|
900
|
+
|
|
901
|
+
重跑安全:已有 plan `phase.start` 时复用同一 run-id / attempt,不追加第二条。
|
|
902
|
+
"""
|
|
903
|
+
import uuid
|
|
904
|
+
|
|
905
|
+
import harness_change as hchg
|
|
906
|
+
import harness_events as he
|
|
907
|
+
import harness_gate as hg
|
|
908
|
+
import harness_runtime as hr
|
|
909
|
+
import harness_state as hs
|
|
910
|
+
|
|
911
|
+
project = Path(project).resolve()
|
|
912
|
+
changes_root = project / ".harness" / "changes"
|
|
913
|
+
if not changes_root.is_dir():
|
|
914
|
+
return {
|
|
915
|
+
"ok": False,
|
|
916
|
+
"code": "PROJECT_ROOT_INVALID",
|
|
917
|
+
"error": f"{changes_root} 不存在——先在该项目运行 hunter-harness init",
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
# 新 change 的目录此前没有任何脚本负责创建,靠的是后续 Write 的副作用;
|
|
921
|
+
# 引导阶段显式建立骨架,prepare 才有 contract 可读
|
|
922
|
+
change_dir = changes_root / change
|
|
923
|
+
created_change = not change_dir.is_dir()
|
|
924
|
+
if created_change:
|
|
925
|
+
(change_dir / "meta").mkdir(parents=True, exist_ok=True)
|
|
926
|
+
hchg.migrate_change(project, change)
|
|
927
|
+
|
|
928
|
+
prepared = prepare_context(
|
|
929
|
+
project,
|
|
930
|
+
phase="plan",
|
|
931
|
+
executor=executor,
|
|
932
|
+
change=change,
|
|
933
|
+
display_title=display_title,
|
|
934
|
+
ttl_seconds=ttl_seconds,
|
|
935
|
+
)
|
|
936
|
+
if not prepared.get("ok"):
|
|
937
|
+
return prepared
|
|
938
|
+
|
|
939
|
+
change_name = str(prepared["changeName"])
|
|
940
|
+
# executionRoot 是代码执行根(无 worktree 时等于项目根),change_dir 才是状态目录。
|
|
941
|
+
# 计划产物一律落 change_dir,混用会把 gate-policy/events 写到项目根去。
|
|
942
|
+
change_dir = (changes_root / change_name).resolve()
|
|
943
|
+
execution_root = Path(prepared["executionRoot"])
|
|
944
|
+
|
|
945
|
+
# doctor 失败不阻断引导:它是环境体检,结论随摘要返回给调用方判断
|
|
946
|
+
try:
|
|
947
|
+
health = hr.doctor(project, change_dir, agent=executor)
|
|
948
|
+
except Exception as exc: # noqa: BLE001 - 体检失败要报告而不是中断引导
|
|
949
|
+
health = {"ok": False, "code": "DOCTOR_FAILED", "error": str(exc)}
|
|
950
|
+
|
|
951
|
+
# 首次 capture 把当时的 HEAD 固定为不可变 changeBase(design §3.6)
|
|
952
|
+
snapshot, changed_segments = hs.capture_current_state(
|
|
953
|
+
project=project,
|
|
954
|
+
change_dir=change_dir,
|
|
955
|
+
change_name=change_name,
|
|
956
|
+
worktree_root=project,
|
|
957
|
+
)
|
|
958
|
+
git_state = snapshot.get("git") or {}
|
|
959
|
+
|
|
960
|
+
workflow = hg._load_workflow_policy(project=project)
|
|
961
|
+
classification = hg.classify_risk(change_dir, stage, workflow=workflow)
|
|
962
|
+
classification.setdefault("tierOverride", None)
|
|
963
|
+
classification["classifiedAt"] = _now().isoformat().replace("+00:00", "Z")
|
|
964
|
+
policy_path = change_dir / "meta" / "gate-policy.json"
|
|
965
|
+
hg._write_json(policy_path, hg.gate_policy_document(classification))
|
|
966
|
+
|
|
967
|
+
identity = _plan_run_identity(change_dir)
|
|
968
|
+
reused = identity is not None
|
|
969
|
+
if identity is None:
|
|
970
|
+
# v2 identity:必须小写字母开头,裸 UUID 有 10/16 概率数字开头被拒
|
|
971
|
+
run_id = f"plan_{uuid.uuid4()}"
|
|
972
|
+
attempt = 1
|
|
973
|
+
appended = he.append_event(
|
|
974
|
+
change_dir,
|
|
975
|
+
phase="plan",
|
|
976
|
+
type_="phase.start",
|
|
977
|
+
run_id=run_id,
|
|
978
|
+
attempt=attempt,
|
|
979
|
+
executor_tool=executor,
|
|
980
|
+
note=f"/harness-plan 引导:{change_name}",
|
|
981
|
+
)
|
|
982
|
+
if not appended.get("ok", True):
|
|
983
|
+
return appended
|
|
984
|
+
else:
|
|
985
|
+
run_id, attempt = identity
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
"ok": True,
|
|
989
|
+
"code": "PLAN_BOOTSTRAPPED",
|
|
990
|
+
"changeName": change_name,
|
|
991
|
+
"displayTitle": prepared.get("displayTitle"),
|
|
992
|
+
"changeCreated": created_change,
|
|
993
|
+
"changeDir": str(change_dir),
|
|
994
|
+
"executionRoot": str(execution_root),
|
|
995
|
+
"runId": run_id,
|
|
996
|
+
"attempt": attempt,
|
|
997
|
+
"reused": reused,
|
|
998
|
+
"tier": classification.get("tier"),
|
|
999
|
+
"tierSource": classification.get("source"),
|
|
1000
|
+
"defaultPhases": list(classification.get("defaultPhases") or []),
|
|
1001
|
+
"conditionalPhases": list(classification.get("conditionalPhases") or []),
|
|
1002
|
+
"requiredValidations": list(classification.get("requiredValidations") or []),
|
|
1003
|
+
"gatePolicyPath": str(policy_path),
|
|
1004
|
+
"plannedPhases": prepared.get("plannedPhases"),
|
|
1005
|
+
"changeBase": git_state.get("base"),
|
|
1006
|
+
"head": git_state.get("head"),
|
|
1007
|
+
"changedSegments": sorted(changed_segments),
|
|
1008
|
+
"legacyBootstrap": prepared.get("legacyBootstrap"),
|
|
1009
|
+
"doctorOk": bool(health.get("ok", True)),
|
|
1010
|
+
"doctor": health,
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _invalidate_for_fixback(
|
|
1015
|
+
state_root: Path,
|
|
1016
|
+
*,
|
|
1017
|
+
transition_hash: str,
|
|
1018
|
+
from_phase: str,
|
|
1019
|
+
to_phase: str,
|
|
1020
|
+
) -> dict[str, Any]:
|
|
1021
|
+
# A review→run handoff happens before product files are changed. Invalidating
|
|
1022
|
+
# every target here made unrelated API/build evidence unusable. The Fixback
|
|
1023
|
+
# resolver now invalidates only entries whose inputsFiles intersect the
|
|
1024
|
+
# issue's actual changedFiles.
|
|
1025
|
+
record = {
|
|
1026
|
+
"schemaVersion": 1,
|
|
1027
|
+
"code": "FIXBACK_INVALIDATION_DEFERRED",
|
|
1028
|
+
"transitionHash": transition_hash,
|
|
1029
|
+
"fromPhase": from_phase,
|
|
1030
|
+
"toPhase": to_phase,
|
|
1031
|
+
"targetIds": [],
|
|
1032
|
+
"deferred": True,
|
|
1033
|
+
"reason": "changed-files-not-known",
|
|
1034
|
+
"createdAt": _now().isoformat(),
|
|
1035
|
+
}
|
|
1036
|
+
_append_ndjson(
|
|
1037
|
+
state_root / "evidence" / "verification-invalidations.ndjson",
|
|
1038
|
+
record,
|
|
1039
|
+
)
|
|
1040
|
+
return record
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _committed_publication_journal(contract_root: Path) -> Path | None:
|
|
1044
|
+
"""已 committed 的 v2 发布 journal —— plan 确实完成的机器证据。"""
|
|
1045
|
+
journal_dir = contract_root / "meta" / "publication-journals"
|
|
1046
|
+
if not journal_dir.is_dir():
|
|
1047
|
+
return None
|
|
1048
|
+
for path in sorted(journal_dir.glob("*.json")):
|
|
1049
|
+
try:
|
|
1050
|
+
payload = _read_json(path)
|
|
1051
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1052
|
+
continue
|
|
1053
|
+
if isinstance(payload, dict) and payload.get("state") == "committed":
|
|
1054
|
+
return path
|
|
1055
|
+
return None
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _published_plan_artifacts(project: Path, contract_root: Path, change: str) -> list[dict[str, Any]]:
|
|
1059
|
+
"""交接携带的 v2 发布产物(存在者入账,缺失者跳过)。"""
|
|
1060
|
+
entries: list[dict[str, Any]] = []
|
|
1061
|
+
for rel in (
|
|
1062
|
+
f"plans/{change}-design.md",
|
|
1063
|
+
f"plans/{change}-plan.md",
|
|
1064
|
+
f"plans/{change}-implementation-detail.md",
|
|
1065
|
+
f"plans/{change}-test-scenarios.md",
|
|
1066
|
+
"meta/scenario-manifest.json",
|
|
1067
|
+
):
|
|
1068
|
+
candidate = contract_root / rel
|
|
1069
|
+
if candidate.is_file():
|
|
1070
|
+
entries.append(
|
|
1071
|
+
{
|
|
1072
|
+
"path": candidate.resolve().relative_to(project).as_posix(),
|
|
1073
|
+
"sha256": _sha256(candidate),
|
|
1074
|
+
}
|
|
1075
|
+
)
|
|
1076
|
+
return entries
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _bootstrap_v2_plan_transition(
|
|
1080
|
+
project: Path,
|
|
1081
|
+
change: str,
|
|
1082
|
+
*,
|
|
1083
|
+
contract_root: Path,
|
|
1084
|
+
state_root: Path,
|
|
1085
|
+
to_phase: str,
|
|
1086
|
+
executor: str,
|
|
1087
|
+
) -> dict[str, Any] | None:
|
|
1088
|
+
"""v2 发布已 committed 却没有交接凭证时,补录 plan → to_phase 凭证。
|
|
1089
|
+
|
|
1090
|
+
v2 plan finalize 只写 plan-events.ndjson 与发布 journal,不碰 context 事务
|
|
1091
|
+
存储,于是 execute 阶段 prepare 必报 HANDOFF_REQUIRED、begin 必报
|
|
1092
|
+
LEGACY_BOOTSTRAP_REQUIRED——两个错误都不给恢复路径,调用方只能读脚本源码
|
|
1093
|
+
自己拼出 classify + configure-plan + close,参数全靠现编,可审计性更差。
|
|
1094
|
+
|
|
1095
|
+
committed 的发布 journal 是比人工 close 更强的完成证据:据此补录,并在凭证上
|
|
1096
|
+
留 bootstrapSource/bootstrapEvidence,事后能与人工 close 区分。没有该证据时
|
|
1097
|
+
返回 None,交接继续 fail-closed。
|
|
1098
|
+
"""
|
|
1099
|
+
paths = _paths(state_root)
|
|
1100
|
+
if _read_ndjson(paths["transitions"]):
|
|
1101
|
+
return None
|
|
1102
|
+
if to_phase not in _allowed_next_phases(contract_root, "plan"):
|
|
1103
|
+
return None
|
|
1104
|
+
journal = _committed_publication_journal(contract_root)
|
|
1105
|
+
if journal is None:
|
|
1106
|
+
return None
|
|
1107
|
+
receipt: dict[str, Any] = {
|
|
1108
|
+
"schemaVersion": 1,
|
|
1109
|
+
"changeName": change,
|
|
1110
|
+
"fromPhase": "plan",
|
|
1111
|
+
"toPhase": to_phase,
|
|
1112
|
+
"status": "OK",
|
|
1113
|
+
"executor": executor,
|
|
1114
|
+
"productCommit": _head(project),
|
|
1115
|
+
"artifacts": _published_plan_artifacts(project, contract_root, change),
|
|
1116
|
+
"attempt": 1,
|
|
1117
|
+
"closedAt": _now().isoformat(),
|
|
1118
|
+
"previousReceiptHash": None,
|
|
1119
|
+
"bootstrapSource": "plan_publication_journal",
|
|
1120
|
+
"bootstrapEvidence": journal.resolve().relative_to(project).as_posix(),
|
|
1121
|
+
}
|
|
1122
|
+
receipt["receiptHash"] = _payload_hash(receipt)
|
|
1123
|
+
_append_ndjson(paths["transitions"], receipt)
|
|
1124
|
+
return receipt
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def close_transition(
|
|
1128
|
+
project: Path,
|
|
1129
|
+
change: str,
|
|
1130
|
+
*,
|
|
1131
|
+
from_phase: str,
|
|
1132
|
+
to_phase: str,
|
|
1133
|
+
executor: str | None,
|
|
1134
|
+
artifacts: list[str] | None = None,
|
|
1135
|
+
status: str = "OK",
|
|
1136
|
+
) -> dict[str, Any]:
|
|
1137
|
+
project = Path(project).resolve()
|
|
1138
|
+
canonical_from = hpaths.resolve_phase_name(from_phase)
|
|
1139
|
+
canonical_to = hpaths.resolve_phase_name(to_phase)
|
|
1140
|
+
if canonical_from is None:
|
|
1141
|
+
return _unknown_phase_error(from_phase)
|
|
1142
|
+
if canonical_to is None:
|
|
1143
|
+
return _unknown_phase_error(to_phase)
|
|
1144
|
+
from_phase = canonical_from
|
|
1145
|
+
to_phase = canonical_to
|
|
1146
|
+
try:
|
|
1147
|
+
contract_root, _contract_data, state_root = _contract(project, change)
|
|
1148
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1149
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1150
|
+
allowed_next = _allowed_next_phases(contract_root, from_phase)
|
|
1151
|
+
if to_phase not in allowed_next:
|
|
1152
|
+
planned_phases, source = _phase_plan(contract_root)
|
|
1153
|
+
return {
|
|
1154
|
+
"ok": False,
|
|
1155
|
+
"code": "TRANSITION_ILLEGAL",
|
|
1156
|
+
"fromPhase": from_phase,
|
|
1157
|
+
"toPhase": to_phase,
|
|
1158
|
+
"allowedNextPhases": allowed_next,
|
|
1159
|
+
"plannedPhases": planned_phases,
|
|
1160
|
+
"phasePlanSource": source,
|
|
1161
|
+
}
|
|
1162
|
+
paths = _paths(state_root)
|
|
1163
|
+
|
|
1164
|
+
artifact_entries: list[dict[str, Any]] = []
|
|
1165
|
+
for raw in artifacts or []:
|
|
1166
|
+
requested = Path(raw)
|
|
1167
|
+
artifact_bases: list[Path] = []
|
|
1168
|
+
for base in (project, contract_root, state_root):
|
|
1169
|
+
resolved_base = base.resolve()
|
|
1170
|
+
if resolved_base not in artifact_bases:
|
|
1171
|
+
artifact_bases.append(resolved_base)
|
|
1172
|
+
candidates = (
|
|
1173
|
+
[requested]
|
|
1174
|
+
if requested.is_absolute()
|
|
1175
|
+
else [base / requested for base in artifact_bases]
|
|
1176
|
+
)
|
|
1177
|
+
valid: list[Path] = []
|
|
1178
|
+
for candidate in candidates:
|
|
1179
|
+
try:
|
|
1180
|
+
resolved_candidate = candidate.resolve()
|
|
1181
|
+
except OSError:
|
|
1182
|
+
continue
|
|
1183
|
+
if resolved_candidate in valid:
|
|
1184
|
+
continue
|
|
1185
|
+
if resolved_candidate.is_relative_to(project) and resolved_candidate.is_file():
|
|
1186
|
+
valid.append(resolved_candidate)
|
|
1187
|
+
if len(valid) > 1:
|
|
1188
|
+
return {
|
|
1189
|
+
"ok": False,
|
|
1190
|
+
"code": "TRANSITION_ARTIFACT_AMBIGUOUS",
|
|
1191
|
+
"path": raw,
|
|
1192
|
+
"candidates": [item.relative_to(project).as_posix() for item in valid],
|
|
1193
|
+
"message": "relative artifact exists under both project and change roots",
|
|
1194
|
+
}
|
|
1195
|
+
if not valid:
|
|
1196
|
+
return {
|
|
1197
|
+
"ok": False,
|
|
1198
|
+
"code": "TRANSITION_ARTIFACT_INVALID",
|
|
1199
|
+
"path": raw,
|
|
1200
|
+
"acceptedBases": [str(base) for base in artifact_bases],
|
|
1201
|
+
"examples": [
|
|
1202
|
+
str(contract_root / "meta" / "plan-finalization.json"),
|
|
1203
|
+
f".harness/changes/{change}/meta/plan-finalization.json",
|
|
1204
|
+
f".harness/state/changes/{change}/evidence/verification-ledger.json",
|
|
1205
|
+
"meta/plan-finalization.json",
|
|
1206
|
+
],
|
|
1207
|
+
}
|
|
1208
|
+
resolved_artifact = valid[0]
|
|
1209
|
+
artifact_entries.append(
|
|
1210
|
+
{
|
|
1211
|
+
"path": resolved_artifact.relative_to(project).as_posix(),
|
|
1212
|
+
"sha256": _sha256(resolved_artifact),
|
|
1213
|
+
}
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
1217
|
+
if not paths["lease"].is_file():
|
|
1218
|
+
latest = transitions[-1] if transitions else None
|
|
1219
|
+
if (
|
|
1220
|
+
isinstance(latest, dict)
|
|
1221
|
+
and hpaths.resolve_phase_name(latest.get("fromPhase")) == from_phase
|
|
1222
|
+
and hpaths.resolve_phase_name(latest.get("toPhase")) == to_phase
|
|
1223
|
+
and latest.get("status") == status
|
|
1224
|
+
and latest.get("artifacts") == artifact_entries
|
|
1225
|
+
and (executor is None or latest.get("executor") == executor)
|
|
1226
|
+
):
|
|
1227
|
+
return {
|
|
1228
|
+
"ok": True,
|
|
1229
|
+
"code": "TRANSITION_ALREADY_CLOSED",
|
|
1230
|
+
"idempotent": True,
|
|
1231
|
+
"receipt": latest,
|
|
1232
|
+
"path": str(paths["transitions"]),
|
|
1233
|
+
"invalidation": None,
|
|
1234
|
+
}
|
|
1235
|
+
return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
|
|
1236
|
+
try:
|
|
1237
|
+
lease = _read_json(paths["lease"])
|
|
1238
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1239
|
+
return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
|
|
1240
|
+
effective_executor = executor or str(lease.get("owner") or "")
|
|
1241
|
+
if not effective_executor:
|
|
1242
|
+
return {"ok": False, "code": "CONTEXT_EXECUTOR_REQUIRED"}
|
|
1243
|
+
if lease.get("owner") != effective_executor or (
|
|
1244
|
+
hpaths.resolve_phase_name(lease.get("phase")) != from_phase
|
|
1245
|
+
):
|
|
1246
|
+
return {
|
|
1247
|
+
"ok": False,
|
|
1248
|
+
"code": "CONTEXT_LEASE_MISMATCH",
|
|
1249
|
+
"holder": lease.get("owner"),
|
|
1250
|
+
"leasePhase": lease.get("phase"),
|
|
1251
|
+
}
|
|
1252
|
+
# Owner and phase already matched above, so nobody took this lease over: a
|
|
1253
|
+
# takeover rewrites the file and would have produced CONTEXT_LEASE_MISMATCH.
|
|
1254
|
+
# A lapsed deadline therefore proves only that the phase outlived the TTL.
|
|
1255
|
+
expiry = _parse_time(lease.get("expiresAt"))
|
|
1256
|
+
lease_lapsed = expiry is None or _now() >= expiry
|
|
1257
|
+
|
|
1258
|
+
previous_hash = transitions[-1].get("receiptHash") if transitions else None
|
|
1259
|
+
attempt = (
|
|
1260
|
+
1
|
|
1261
|
+
+ sum(
|
|
1262
|
+
1
|
|
1263
|
+
for item in transitions
|
|
1264
|
+
if hpaths.resolve_phase_name(item.get("fromPhase")) == from_phase
|
|
1265
|
+
and hpaths.resolve_phase_name(item.get("toPhase")) == to_phase
|
|
1266
|
+
)
|
|
1267
|
+
)
|
|
1268
|
+
receipt: dict[str, Any] = {
|
|
1269
|
+
"schemaVersion": 1,
|
|
1270
|
+
"changeName": change,
|
|
1271
|
+
"fromPhase": from_phase,
|
|
1272
|
+
"toPhase": to_phase,
|
|
1273
|
+
"status": status,
|
|
1274
|
+
"executor": effective_executor,
|
|
1275
|
+
"productCommit": _head(project),
|
|
1276
|
+
"artifacts": artifact_entries,
|
|
1277
|
+
"attempt": attempt,
|
|
1278
|
+
"closedAt": _now().isoformat(),
|
|
1279
|
+
"previousReceiptHash": previous_hash,
|
|
1280
|
+
}
|
|
1281
|
+
if lease_lapsed:
|
|
1282
|
+
# Recorded, not hidden: the phase ran past its lease without a renewal.
|
|
1283
|
+
receipt["leaseLapsed"] = {
|
|
1284
|
+
"expiresAt": lease.get("expiresAt"),
|
|
1285
|
+
"acquiredAt": lease.get("acquiredAt"),
|
|
1286
|
+
}
|
|
1287
|
+
receipt["receiptHash"] = _payload_hash(receipt)
|
|
1288
|
+
_append_ndjson(paths["transitions"], receipt)
|
|
1289
|
+
paths["lease"].unlink(missing_ok=True)
|
|
1290
|
+
invalidation = None
|
|
1291
|
+
if to_phase == "execute" and from_phase in {"execute", "review"}:
|
|
1292
|
+
invalidation = _invalidate_for_fixback(
|
|
1293
|
+
state_root,
|
|
1294
|
+
transition_hash=receipt["receiptHash"],
|
|
1295
|
+
from_phase=from_phase,
|
|
1296
|
+
to_phase=to_phase,
|
|
1297
|
+
)
|
|
1298
|
+
return {
|
|
1299
|
+
"ok": True,
|
|
1300
|
+
"code": "TRANSITION_CLOSED",
|
|
1301
|
+
"receipt": receipt,
|
|
1302
|
+
"path": str(paths["transitions"]),
|
|
1303
|
+
"invalidation": invalidation,
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _begin_transition_unlocked(
|
|
1308
|
+
project: Path,
|
|
1309
|
+
change: str,
|
|
1310
|
+
*,
|
|
1311
|
+
phase: str,
|
|
1312
|
+
executor: str,
|
|
1313
|
+
preparation_id: str | None = None,
|
|
1314
|
+
) -> dict[str, Any]:
|
|
1315
|
+
project = Path(project).resolve()
|
|
1316
|
+
try:
|
|
1317
|
+
contract_root, _contract_data, state_root = _contract(project, change)
|
|
1318
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1319
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1320
|
+
paths = _paths(state_root)
|
|
1321
|
+
try:
|
|
1322
|
+
execution_root = _execution_root(project, contract_root, state_root)
|
|
1323
|
+
except ValueError as exc:
|
|
1324
|
+
return {
|
|
1325
|
+
"ok": False,
|
|
1326
|
+
"code": "EXECUTION_WORKTREE_INVALID",
|
|
1327
|
+
"error": str(exc),
|
|
1328
|
+
}
|
|
1329
|
+
if preparation_id is not None:
|
|
1330
|
+
current = _read_json(paths["current"]) if paths["current"].is_file() else None
|
|
1331
|
+
lease = _read_json(paths["lease"]) if paths["lease"].is_file() else None
|
|
1332
|
+
if not (
|
|
1333
|
+
isinstance(current, dict)
|
|
1334
|
+
and isinstance(lease, dict)
|
|
1335
|
+
and current.get("preparationId") == preparation_id
|
|
1336
|
+
and lease.get("preparationId") == preparation_id
|
|
1337
|
+
):
|
|
1338
|
+
return {
|
|
1339
|
+
"ok": False,
|
|
1340
|
+
"code": "CONTEXT_PREPARATION_MISMATCH",
|
|
1341
|
+
"message": "本次修复准备凭证与当前上下文不一致,未启动新阶段。",
|
|
1342
|
+
}
|
|
1343
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
1344
|
+
if not transitions and phase != "plan":
|
|
1345
|
+
# prepare 之外的入口(或跳过了 prepare)同样要能从发布证据恢复
|
|
1346
|
+
_bootstrap_v2_plan_transition(
|
|
1347
|
+
project,
|
|
1348
|
+
change,
|
|
1349
|
+
contract_root=contract_root,
|
|
1350
|
+
state_root=state_root,
|
|
1351
|
+
to_phase=phase,
|
|
1352
|
+
executor=executor,
|
|
1353
|
+
)
|
|
1354
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
1355
|
+
if not transitions:
|
|
1356
|
+
return {
|
|
1357
|
+
"ok": False,
|
|
1358
|
+
"code": "LEGACY_BOOTSTRAP_REQUIRED",
|
|
1359
|
+
"legacyBootstrap": True,
|
|
1360
|
+
"message": (
|
|
1361
|
+
"没有交接凭证。v2 计划应有 committed 的 meta/publication-journals/*.json "
|
|
1362
|
+
"作为补录证据;legacy 计划需先完成 plan 阶段的 close。"
|
|
1363
|
+
"首次进入某阶段(还没有任何 transition 凭证)时不要用 context begin——"
|
|
1364
|
+
"改跑 `harness_gate.py begin --change-dir .harness/changes/<change> "
|
|
1365
|
+
"--phase <phase> --run-id <phase>_<uuid4hex> --note \"<触发指令>\"`,"
|
|
1366
|
+
"它会领取租约并写好 phase.start。"
|
|
1367
|
+
),
|
|
1368
|
+
}
|
|
1369
|
+
receipt = transitions[-1]
|
|
1370
|
+
if hpaths.resolve_phase_name(receipt.get("toPhase")) != phase:
|
|
1371
|
+
return {
|
|
1372
|
+
"ok": False,
|
|
1373
|
+
"code": "HANDOFF_REQUIRED",
|
|
1374
|
+
"expectedPhase": receipt.get("toPhase"),
|
|
1375
|
+
"requestedPhase": phase,
|
|
1376
|
+
}
|
|
1377
|
+
unhashed = {key: value for key, value in receipt.items() if key != "receiptHash"}
|
|
1378
|
+
issues: list[str] = []
|
|
1379
|
+
if _payload_hash(unhashed) != receipt.get("receiptHash"):
|
|
1380
|
+
issues.append("receipt hash mismatch")
|
|
1381
|
+
if len(transitions) > 1 and receipt.get("previousReceiptHash") != transitions[-2].get(
|
|
1382
|
+
"receiptHash"
|
|
1383
|
+
):
|
|
1384
|
+
issues.append("receipt chain mismatch")
|
|
1385
|
+
if receipt.get("productCommit") != _head(project):
|
|
1386
|
+
issues.append("HEAD drift")
|
|
1387
|
+
for artifact in receipt.get("artifacts", []):
|
|
1388
|
+
if not isinstance(artifact, dict):
|
|
1389
|
+
issues.append("artifact entry invalid")
|
|
1390
|
+
continue
|
|
1391
|
+
path = (project / str(artifact.get("path") or "")).resolve()
|
|
1392
|
+
if (
|
|
1393
|
+
not path.is_relative_to(project)
|
|
1394
|
+
or not path.is_file()
|
|
1395
|
+
or _sha256(path) != artifact.get("sha256")
|
|
1396
|
+
):
|
|
1397
|
+
issues.append(f"artifact drift: {artifact.get('path')}")
|
|
1398
|
+
if issues:
|
|
1399
|
+
return {
|
|
1400
|
+
"ok": False,
|
|
1401
|
+
"code": "HANDOFF_IDENTITY_MISMATCH",
|
|
1402
|
+
"issues": issues,
|
|
1403
|
+
"receipt": receipt,
|
|
1404
|
+
}
|
|
1405
|
+
acknowledgment = {
|
|
1406
|
+
"schemaVersion": 1,
|
|
1407
|
+
"changeName": change,
|
|
1408
|
+
"phase": phase,
|
|
1409
|
+
"executor": executor,
|
|
1410
|
+
"receiptHash": receipt["receiptHash"],
|
|
1411
|
+
"begunAt": _now().isoformat(),
|
|
1412
|
+
}
|
|
1413
|
+
if preparation_id is not None:
|
|
1414
|
+
acknowledgment["preparationId"] = preparation_id
|
|
1415
|
+
_append_ndjson(paths["begins"], acknowledgment)
|
|
1416
|
+
current_context = {
|
|
1417
|
+
"schemaVersion": 1,
|
|
1418
|
+
"changeName": change,
|
|
1419
|
+
"phase": phase,
|
|
1420
|
+
"executor": executor,
|
|
1421
|
+
"executionRoot": str(execution_root),
|
|
1422
|
+
"receiptHash": receipt["receiptHash"],
|
|
1423
|
+
"begunAt": acknowledgment["begunAt"],
|
|
1424
|
+
}
|
|
1425
|
+
if preparation_id is not None:
|
|
1426
|
+
current_context["preparationId"] = preparation_id
|
|
1427
|
+
_write_json_atomic(paths["current"], current_context)
|
|
1428
|
+
return {
|
|
1429
|
+
"ok": True,
|
|
1430
|
+
"code": "TRANSITION_BEGUN",
|
|
1431
|
+
"receipt": receipt,
|
|
1432
|
+
"acknowledgment": acknowledgment,
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def begin_transition(
|
|
1437
|
+
project: Path,
|
|
1438
|
+
change: str,
|
|
1439
|
+
*,
|
|
1440
|
+
phase: str,
|
|
1441
|
+
executor: str,
|
|
1442
|
+
preparation_id: str | None = None,
|
|
1443
|
+
) -> dict[str, Any]:
|
|
1444
|
+
"""Acknowledge a transition while serializing branch selection."""
|
|
1445
|
+
|
|
1446
|
+
project = Path(project).resolve()
|
|
1447
|
+
canonical_phase = hpaths.resolve_phase_name(phase)
|
|
1448
|
+
if canonical_phase is None:
|
|
1449
|
+
return _unknown_phase_error(phase)
|
|
1450
|
+
phase = canonical_phase
|
|
1451
|
+
try:
|
|
1452
|
+
_contract_root, _contract_data, state_root = _contract(project, change)
|
|
1453
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1454
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1455
|
+
paths = _paths(state_root)
|
|
1456
|
+
with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
|
|
1457
|
+
return _begin_transition_unlocked(
|
|
1458
|
+
project,
|
|
1459
|
+
change,
|
|
1460
|
+
phase=phase,
|
|
1461
|
+
executor=executor,
|
|
1462
|
+
preparation_id=preparation_id,
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def cancel_prepared_context(
|
|
1467
|
+
project: Path,
|
|
1468
|
+
change: str,
|
|
1469
|
+
*,
|
|
1470
|
+
phase: str,
|
|
1471
|
+
executor: str,
|
|
1472
|
+
preparation_id: str | None = None,
|
|
1473
|
+
) -> dict[str, Any]:
|
|
1474
|
+
"""Remove a target-phase preparation that never obtained its gate."""
|
|
1475
|
+
|
|
1476
|
+
project = Path(project).resolve()
|
|
1477
|
+
canonical_phase = hpaths.resolve_phase_name(phase)
|
|
1478
|
+
if canonical_phase is None:
|
|
1479
|
+
return _unknown_phase_error(phase)
|
|
1480
|
+
phase = canonical_phase
|
|
1481
|
+
try:
|
|
1482
|
+
_contract_root, _contract_data, state_root = _contract(project, change)
|
|
1483
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1484
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1485
|
+
paths = _paths(state_root)
|
|
1486
|
+
removed: list[str] = []
|
|
1487
|
+
receipt_hash: str | None = None
|
|
1488
|
+
with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
|
|
1489
|
+
if paths["current"].is_file():
|
|
1490
|
+
try:
|
|
1491
|
+
current = _read_json(paths["current"])
|
|
1492
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1493
|
+
current = None
|
|
1494
|
+
if isinstance(current, dict) and (
|
|
1495
|
+
hpaths.resolve_phase_name(current.get("phase")) == phase
|
|
1496
|
+
and current.get("executor") == executor
|
|
1497
|
+
and (
|
|
1498
|
+
preparation_id is None
|
|
1499
|
+
or current.get("preparationId") == preparation_id
|
|
1500
|
+
)
|
|
1501
|
+
):
|
|
1502
|
+
receipt_hash = str(current.get("receiptHash") or "") or None
|
|
1503
|
+
paths["current"].unlink(missing_ok=True)
|
|
1504
|
+
removed.append("current")
|
|
1505
|
+
if paths["lease"].is_file():
|
|
1506
|
+
try:
|
|
1507
|
+
lease = _read_json(paths["lease"])
|
|
1508
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1509
|
+
lease = None
|
|
1510
|
+
if isinstance(lease, dict) and (
|
|
1511
|
+
hpaths.resolve_phase_name(lease.get("phase")) == phase
|
|
1512
|
+
and lease.get("owner") == executor
|
|
1513
|
+
and (
|
|
1514
|
+
preparation_id is None
|
|
1515
|
+
or lease.get("preparationId") == preparation_id
|
|
1516
|
+
)
|
|
1517
|
+
):
|
|
1518
|
+
paths["lease"].unlink(missing_ok=True)
|
|
1519
|
+
removed.append("lease")
|
|
1520
|
+
begins = _read_ndjson(paths["begins"])
|
|
1521
|
+
retained = [
|
|
1522
|
+
item
|
|
1523
|
+
for item in begins
|
|
1524
|
+
if not (
|
|
1525
|
+
receipt_hash is not None
|
|
1526
|
+
and hpaths.resolve_phase_name(item.get("phase")) == phase
|
|
1527
|
+
and item.get("executor") == executor
|
|
1528
|
+
and item.get("receiptHash") == receipt_hash
|
|
1529
|
+
and (
|
|
1530
|
+
preparation_id is None
|
|
1531
|
+
or item.get("preparationId") == preparation_id
|
|
1532
|
+
)
|
|
1533
|
+
)
|
|
1534
|
+
]
|
|
1535
|
+
if len(retained) != len(begins):
|
|
1536
|
+
paths["begins"].parent.mkdir(parents=True, exist_ok=True)
|
|
1537
|
+
payload = "".join(
|
|
1538
|
+
json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n"
|
|
1539
|
+
for item in retained
|
|
1540
|
+
)
|
|
1541
|
+
fd, raw = tempfile.mkstemp(
|
|
1542
|
+
prefix=f".{paths['begins'].name}.",
|
|
1543
|
+
suffix=".tmp",
|
|
1544
|
+
dir=str(paths["begins"].parent),
|
|
1545
|
+
)
|
|
1546
|
+
temporary = Path(raw)
|
|
1547
|
+
try:
|
|
1548
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
1549
|
+
handle.write(payload)
|
|
1550
|
+
handle.flush()
|
|
1551
|
+
os.fsync(handle.fileno())
|
|
1552
|
+
os.replace(temporary, paths["begins"])
|
|
1553
|
+
finally:
|
|
1554
|
+
temporary.unlink(missing_ok=True)
|
|
1555
|
+
removed.append("begin")
|
|
1556
|
+
return {
|
|
1557
|
+
"ok": True,
|
|
1558
|
+
"code": (
|
|
1559
|
+
"PREPARED_CONTEXT_CANCELLED"
|
|
1560
|
+
if removed
|
|
1561
|
+
else "PREPARED_CONTEXT_NOT_OWNED"
|
|
1562
|
+
),
|
|
1563
|
+
"removed": removed,
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def renew_lease(
|
|
1568
|
+
project: Path,
|
|
1569
|
+
change: str,
|
|
1570
|
+
*,
|
|
1571
|
+
executor: str,
|
|
1572
|
+
ttl_seconds: int = 3600,
|
|
1573
|
+
) -> dict[str, Any]:
|
|
1574
|
+
"""Extend the current phase lease in place.
|
|
1575
|
+
|
|
1576
|
+
Only the recorded owner may renew, and only while no other executor has
|
|
1577
|
+
taken the lease over. Renewal touches nothing but ``expiresAt``, so it is
|
|
1578
|
+
safe to call on a heartbeat during a long phase.
|
|
1579
|
+
"""
|
|
1580
|
+
project = project.resolve()
|
|
1581
|
+
try:
|
|
1582
|
+
_, _, state_root = _contract(project, change)
|
|
1583
|
+
except ValueError as exc:
|
|
1584
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1585
|
+
paths = _paths(state_root)
|
|
1586
|
+
if not paths["lease"].is_file():
|
|
1587
|
+
return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
|
|
1588
|
+
with _exclusive_state_lock(paths["runtime"] / "branch-selection.lock"):
|
|
1589
|
+
try:
|
|
1590
|
+
lease = _read_json(paths["lease"])
|
|
1591
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1592
|
+
return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
|
|
1593
|
+
if lease.get("owner") != executor:
|
|
1594
|
+
return {
|
|
1595
|
+
"ok": False,
|
|
1596
|
+
"code": "CONTEXT_LEASE_MISMATCH",
|
|
1597
|
+
"holder": lease.get("owner"),
|
|
1598
|
+
}
|
|
1599
|
+
expiry = _parse_time(lease.get("expiresAt"))
|
|
1600
|
+
lapsed = expiry is None or _now() >= expiry
|
|
1601
|
+
lease["expiresAt"] = (
|
|
1602
|
+
_now() + dt.timedelta(seconds=max(1, int(ttl_seconds)))
|
|
1603
|
+
).isoformat()
|
|
1604
|
+
lease["renewedAt"] = _now().isoformat()
|
|
1605
|
+
_write_json_atomic(paths["lease"], lease)
|
|
1606
|
+
return {
|
|
1607
|
+
"ok": True,
|
|
1608
|
+
"code": "CONTEXT_LEASE_RENEWED",
|
|
1609
|
+
"lease": lease,
|
|
1610
|
+
"wasLapsed": lapsed,
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def context_view(project: Path, change: str) -> dict[str, Any]:
|
|
1615
|
+
project = Path(project).resolve()
|
|
1616
|
+
try:
|
|
1617
|
+
contract_root, contract, state_root = _contract(project, change)
|
|
1618
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1619
|
+
return {"ok": False, "code": _contract_error_code(exc), "error": str(exc)}
|
|
1620
|
+
paths = _paths(state_root)
|
|
1621
|
+
transitions = _read_ndjson(paths["transitions"])
|
|
1622
|
+
begins = _read_ndjson(paths["begins"])
|
|
1623
|
+
current = (
|
|
1624
|
+
_read_json(paths["current"]) if paths["current"].is_file() else None
|
|
1625
|
+
)
|
|
1626
|
+
ledger_path = state_root / "evidence" / "verification-ledger.json"
|
|
1627
|
+
findings_path = state_root / "evidence" / "review-findings.json"
|
|
1628
|
+
events_paths = [state_root / "events.ndjson"]
|
|
1629
|
+
if state_root != contract_root:
|
|
1630
|
+
events_paths.append(contract_root / "events.ndjson")
|
|
1631
|
+
return {
|
|
1632
|
+
"ok": True,
|
|
1633
|
+
"code": "CONTEXT_VIEW",
|
|
1634
|
+
"changeName": change,
|
|
1635
|
+
"lifecycle": contract.get("lifecycle"),
|
|
1636
|
+
"currentPhase": (
|
|
1637
|
+
transitions[-1].get("toPhase")
|
|
1638
|
+
if transitions
|
|
1639
|
+
else current.get("phase")
|
|
1640
|
+
if isinstance(current, dict)
|
|
1641
|
+
else None
|
|
1642
|
+
),
|
|
1643
|
+
"transitions": transitions,
|
|
1644
|
+
"attemptHistory": {"closes": transitions, "begins": begins},
|
|
1645
|
+
"current": current,
|
|
1646
|
+
"ledger": _read_json(ledger_path) if ledger_path.is_file() else None,
|
|
1647
|
+
"findings": _read_json(findings_path) if findings_path.is_file() else None,
|
|
1648
|
+
"events": [
|
|
1649
|
+
item
|
|
1650
|
+
for path in events_paths
|
|
1651
|
+
for item in _read_ndjson(path)
|
|
1652
|
+
],
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1657
|
+
parser = argparse.ArgumentParser(prog="harness_context.py")
|
|
1658
|
+
parser.add_argument("--json", action="store_true")
|
|
1659
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1660
|
+
prepare = sub.add_parser("prepare")
|
|
1661
|
+
prepare.add_argument("--json", action="store_true")
|
|
1662
|
+
prepare.add_argument("--project", required=True, type=Path)
|
|
1663
|
+
prepare.add_argument("--change")
|
|
1664
|
+
prepare.add_argument("--phase", required=True)
|
|
1665
|
+
prepare.add_argument("--executor", required=True)
|
|
1666
|
+
prepare.add_argument("--title")
|
|
1667
|
+
prepare.add_argument("--ttl-seconds", type=int, default=3600)
|
|
1668
|
+
prepare.add_argument("--trigger", choices=["review-fixback"])
|
|
1669
|
+
bootstrap = sub.add_parser("bootstrap-plan")
|
|
1670
|
+
bootstrap.add_argument("--json", action="store_true")
|
|
1671
|
+
bootstrap.add_argument("--project", required=True, type=Path)
|
|
1672
|
+
bootstrap.add_argument("--change", required=True)
|
|
1673
|
+
bootstrap.add_argument("--executor", required=True)
|
|
1674
|
+
bootstrap.add_argument("--title")
|
|
1675
|
+
bootstrap.add_argument("--stage", default="plan", choices=["plan", "post-run"])
|
|
1676
|
+
bootstrap.add_argument("--ttl-seconds", type=int, default=3600)
|
|
1677
|
+
close = sub.add_parser("close")
|
|
1678
|
+
close.add_argument("--json", action="store_true")
|
|
1679
|
+
close.add_argument("--project", required=True, type=Path)
|
|
1680
|
+
close.add_argument("--change", required=True)
|
|
1681
|
+
close.add_argument("--from-phase", required=True)
|
|
1682
|
+
close.add_argument("--to-phase", required=True)
|
|
1683
|
+
close.add_argument("--executor", required=True)
|
|
1684
|
+
close.add_argument("--artifact", action="append", default=[])
|
|
1685
|
+
close.add_argument("--status", default="OK")
|
|
1686
|
+
begin = sub.add_parser("begin")
|
|
1687
|
+
begin.add_argument("--json", action="store_true")
|
|
1688
|
+
begin.add_argument("--project", required=True, type=Path)
|
|
1689
|
+
begin.add_argument("--change", required=True)
|
|
1690
|
+
begin.add_argument("--phase", required=True)
|
|
1691
|
+
begin.add_argument("--executor", required=True)
|
|
1692
|
+
renew = sub.add_parser(
|
|
1693
|
+
"renew", help="extend the current phase lease without touching context"
|
|
1694
|
+
)
|
|
1695
|
+
renew.add_argument("--json", action="store_true")
|
|
1696
|
+
renew.add_argument("--project", required=True, type=Path)
|
|
1697
|
+
renew.add_argument("--change", required=True)
|
|
1698
|
+
renew.add_argument("--executor", required=True)
|
|
1699
|
+
renew.add_argument("--ttl-seconds", type=int, default=3600)
|
|
1700
|
+
configure = sub.add_parser("configure-plan")
|
|
1701
|
+
configure.add_argument("--json", action="store_true")
|
|
1702
|
+
configure.add_argument("--project", required=True, type=Path)
|
|
1703
|
+
configure.add_argument("--change", required=True)
|
|
1704
|
+
configure.add_argument("--phases", required=True)
|
|
1705
|
+
configure.add_argument("--operator", required=True)
|
|
1706
|
+
configure.add_argument("--reason", required=True)
|
|
1707
|
+
view = sub.add_parser("view")
|
|
1708
|
+
view.add_argument("--json", action="store_true")
|
|
1709
|
+
view.add_argument("--project", required=True, type=Path)
|
|
1710
|
+
view.add_argument("--change", required=True)
|
|
1711
|
+
return parser
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1715
|
+
args = build_parser().parse_args(argv)
|
|
1716
|
+
if args.command == "prepare":
|
|
1717
|
+
result = prepare_context(
|
|
1718
|
+
args.project,
|
|
1719
|
+
phase=args.phase,
|
|
1720
|
+
executor=args.executor,
|
|
1721
|
+
change=args.change,
|
|
1722
|
+
display_title=args.title,
|
|
1723
|
+
ttl_seconds=args.ttl_seconds,
|
|
1724
|
+
trigger=args.trigger,
|
|
1725
|
+
)
|
|
1726
|
+
elif args.command == "bootstrap-plan":
|
|
1727
|
+
result = bootstrap_plan(
|
|
1728
|
+
args.project,
|
|
1729
|
+
change=args.change,
|
|
1730
|
+
executor=args.executor,
|
|
1731
|
+
display_title=args.title,
|
|
1732
|
+
stage=args.stage,
|
|
1733
|
+
ttl_seconds=args.ttl_seconds,
|
|
1734
|
+
)
|
|
1735
|
+
elif args.command == "close":
|
|
1736
|
+
result = close_transition(
|
|
1737
|
+
args.project,
|
|
1738
|
+
args.change,
|
|
1739
|
+
from_phase=args.from_phase,
|
|
1740
|
+
to_phase=args.to_phase,
|
|
1741
|
+
executor=args.executor,
|
|
1742
|
+
artifacts=args.artifact,
|
|
1743
|
+
status=args.status,
|
|
1744
|
+
)
|
|
1745
|
+
elif args.command == "begin":
|
|
1746
|
+
result = begin_transition(
|
|
1747
|
+
args.project,
|
|
1748
|
+
args.change,
|
|
1749
|
+
phase=args.phase,
|
|
1750
|
+
executor=args.executor,
|
|
1751
|
+
)
|
|
1752
|
+
elif args.command == "renew":
|
|
1753
|
+
result = renew_lease(
|
|
1754
|
+
args.project,
|
|
1755
|
+
args.change,
|
|
1756
|
+
executor=args.executor,
|
|
1757
|
+
ttl_seconds=args.ttl_seconds,
|
|
1758
|
+
)
|
|
1759
|
+
elif args.command == "configure-plan":
|
|
1760
|
+
result = configure_phase_plan(
|
|
1761
|
+
args.project,
|
|
1762
|
+
args.change,
|
|
1763
|
+
phases=[item.strip() for item in args.phases.split(",") if item.strip()],
|
|
1764
|
+
operator=args.operator,
|
|
1765
|
+
reason=args.reason,
|
|
1766
|
+
)
|
|
1767
|
+
else:
|
|
1768
|
+
result = context_view(args.project, args.change)
|
|
1769
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1770
|
+
return 0 if result.get("ok") else 1
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
if __name__ == "__main__":
|
|
1774
|
+
raise SystemExit(main())
|