@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,2234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness events.ndjson writer and execution-log renderer (D2).
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
append — append one schema_version 3 event, then auto-render execution-log.md
|
|
6
|
+
render — full re-render of logs/execution-log.md from events.ndjson
|
|
7
|
+
summary — phase durations, event counts, and issue list (JSON)
|
|
8
|
+
|
|
9
|
+
Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import copy
|
|
16
|
+
import contextlib
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Sequence
|
|
28
|
+
|
|
29
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
30
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
32
|
+
|
|
33
|
+
import harness_paths # noqa: E402
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _project_root_for_change(change_dir: Path) -> Path | None:
|
|
37
|
+
resolved = change_dir.resolve()
|
|
38
|
+
for candidate in (resolved, *resolved.parents):
|
|
39
|
+
if candidate.name == "changes" and candidate.parent.name == ".harness":
|
|
40
|
+
return candidate.parent.parent
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def nudge_remote_sync(change_dir: Path) -> None:
|
|
45
|
+
"""Best-effort wake-up after the event append has been flushed to disk."""
|
|
46
|
+
project_root = _project_root_for_change(change_dir)
|
|
47
|
+
if project_root is None:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
from harness_events_sync import schedule_events_sync
|
|
51
|
+
|
|
52
|
+
schedule_events_sync(project_root, change_dir)
|
|
53
|
+
except Exception:
|
|
54
|
+
# Local event durability is authoritative; remote outages never roll it back.
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _nudge_remote_sync(change_dir: Path) -> None:
|
|
59
|
+
"""Compatibility wrapper for older callers and focused tests."""
|
|
60
|
+
nudge_remote_sync(change_dir)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
64
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
65
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
66
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
EVENT_TYPES = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"phase.start",
|
|
72
|
+
"phase.end",
|
|
73
|
+
"phase.auto_sealed",
|
|
74
|
+
"phase.prepare.start",
|
|
75
|
+
"phase.prepare.end",
|
|
76
|
+
"gate.blocked",
|
|
77
|
+
"gate.recovered",
|
|
78
|
+
"command",
|
|
79
|
+
"verification",
|
|
80
|
+
"artifact",
|
|
81
|
+
"issue",
|
|
82
|
+
"issue.resolve",
|
|
83
|
+
"decision",
|
|
84
|
+
"correction",
|
|
85
|
+
"change.rename",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
SCHEMA_VERSION = 3
|
|
90
|
+
_NOTE_FALLBACK_MAXLEN = 60
|
|
91
|
+
HEADER_LINE = (
|
|
92
|
+
"本文件由 harness_events.py 自动渲染,请勿手工编辑;事实源为 events.ndjson"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Optional fields accepted on append; only non-None values are written.
|
|
96
|
+
OPTIONAL_FIELDS = (
|
|
97
|
+
"command",
|
|
98
|
+
"exit_code",
|
|
99
|
+
"duration_ms",
|
|
100
|
+
"note",
|
|
101
|
+
"name",
|
|
102
|
+
"status",
|
|
103
|
+
"path",
|
|
104
|
+
"kind",
|
|
105
|
+
"code",
|
|
106
|
+
"severity",
|
|
107
|
+
"message",
|
|
108
|
+
"decision",
|
|
109
|
+
"reason",
|
|
110
|
+
"issue_id",
|
|
111
|
+
"scope",
|
|
112
|
+
"target_event_id",
|
|
113
|
+
"target_field",
|
|
114
|
+
"old_value_hash",
|
|
115
|
+
"new_value_json",
|
|
116
|
+
"run_id",
|
|
117
|
+
"attempt",
|
|
118
|
+
"executor_tool",
|
|
119
|
+
"executor_agent",
|
|
120
|
+
"executor_model",
|
|
121
|
+
"handoff_from_tool",
|
|
122
|
+
"handoff_reason",
|
|
123
|
+
"trace_id",
|
|
124
|
+
"span_id",
|
|
125
|
+
"parent_span_id",
|
|
126
|
+
"runner_ms",
|
|
127
|
+
"orchestration_active_ms",
|
|
128
|
+
"wall_clock_ms",
|
|
129
|
+
"user_wait_ms",
|
|
130
|
+
"renamed_from",
|
|
131
|
+
"renamed_to",
|
|
132
|
+
"change_uuid",
|
|
133
|
+
"execution_mode",
|
|
134
|
+
"decision_reason_code",
|
|
135
|
+
"fallback_reason_code",
|
|
136
|
+
"trigger",
|
|
137
|
+
"from_phase",
|
|
138
|
+
"result_status",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_PROVENANCE_FIELDS = frozenset(
|
|
142
|
+
{
|
|
143
|
+
"run_id",
|
|
144
|
+
"attempt",
|
|
145
|
+
"executor_tool",
|
|
146
|
+
"executor_agent",
|
|
147
|
+
"executor_model",
|
|
148
|
+
"handoff_from_tool",
|
|
149
|
+
"handoff_reason",
|
|
150
|
+
"trace_id",
|
|
151
|
+
"span_id",
|
|
152
|
+
"parent_span_id",
|
|
153
|
+
"runner_ms",
|
|
154
|
+
"orchestration_active_ms",
|
|
155
|
+
"wall_clock_ms",
|
|
156
|
+
"user_wait_ms",
|
|
157
|
+
"execution_mode",
|
|
158
|
+
"decision_reason_code",
|
|
159
|
+
"fallback_reason_code",
|
|
160
|
+
"trigger",
|
|
161
|
+
"from_phase",
|
|
162
|
+
"result_status",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
_REVIEW_DECISION_REASON_CODES = frozenset({"REVIEW_DELEGATED"})
|
|
166
|
+
_REVIEW_FALLBACK_REASON_CODES = frozenset(
|
|
167
|
+
{
|
|
168
|
+
"REVIEW_INLINE_UNAVAILABLE",
|
|
169
|
+
"REVIEW_INLINE_SPAWN_FAILED",
|
|
170
|
+
"REVIEW_INLINE_INVALID_RESULT",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
_EVENT_ALLOWED_FIELDS = {
|
|
174
|
+
"phase.start": frozenset({"note"}) | _PROVENANCE_FIELDS,
|
|
175
|
+
"phase.prepare.start": frozenset({"note"}) | _PROVENANCE_FIELDS,
|
|
176
|
+
"phase.prepare.end": frozenset(
|
|
177
|
+
{"status", "code", "message", "reason", "note", "duration_ms"}
|
|
178
|
+
)
|
|
179
|
+
| _PROVENANCE_FIELDS,
|
|
180
|
+
"phase.end": frozenset(
|
|
181
|
+
{"status", "duration_ms", "note", "reason", "issue_id"}
|
|
182
|
+
)
|
|
183
|
+
| _PROVENANCE_FIELDS,
|
|
184
|
+
"phase.auto_sealed": frozenset({"status", "reason", "note"}) | _PROVENANCE_FIELDS,
|
|
185
|
+
"gate.blocked": frozenset({"status", "code", "reason", "note"})
|
|
186
|
+
| _PROVENANCE_FIELDS,
|
|
187
|
+
"gate.recovered": frozenset({"status", "code", "reason", "note"})
|
|
188
|
+
| _PROVENANCE_FIELDS,
|
|
189
|
+
"command": frozenset({"command", "exit_code", "duration_ms", "note"})
|
|
190
|
+
| _PROVENANCE_FIELDS,
|
|
191
|
+
"verification": frozenset(
|
|
192
|
+
{"name", "status", "reason", "command", "exit_code", "duration_ms", "note"}
|
|
193
|
+
)
|
|
194
|
+
| _PROVENANCE_FIELDS,
|
|
195
|
+
"artifact": frozenset({"path", "kind", "note"}) | _PROVENANCE_FIELDS,
|
|
196
|
+
"issue": frozenset({"code", "scope", "severity", "message", "reason", "note"})
|
|
197
|
+
| frozenset({"issue_id"})
|
|
198
|
+
| _PROVENANCE_FIELDS,
|
|
199
|
+
"issue.resolve": frozenset({"issue_id", "reason", "note"})
|
|
200
|
+
| _PROVENANCE_FIELDS,
|
|
201
|
+
"decision": frozenset({"decision", "reason", "note"}) | _PROVENANCE_FIELDS,
|
|
202
|
+
"correction": frozenset(
|
|
203
|
+
{
|
|
204
|
+
"target_event_id",
|
|
205
|
+
"target_field",
|
|
206
|
+
"old_value_hash",
|
|
207
|
+
"new_value_json",
|
|
208
|
+
"reason",
|
|
209
|
+
"note",
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
| _PROVENANCE_FIELDS,
|
|
213
|
+
"change.rename": frozenset(
|
|
214
|
+
{"renamed_from", "renamed_to", "change_uuid", "note"}
|
|
215
|
+
)
|
|
216
|
+
| _PROVENANCE_FIELDS,
|
|
217
|
+
}
|
|
218
|
+
_EVENT_REQUIRED_FIELDS = {
|
|
219
|
+
"issue": ("severity",),
|
|
220
|
+
"issue.resolve": ("issue_id", "reason"),
|
|
221
|
+
"verification": ("name", "status"),
|
|
222
|
+
"correction": (
|
|
223
|
+
"target_event_id",
|
|
224
|
+
"target_field",
|
|
225
|
+
"old_value_hash",
|
|
226
|
+
"new_value_json",
|
|
227
|
+
"reason",
|
|
228
|
+
),
|
|
229
|
+
"phase.auto_sealed": ("reason",),
|
|
230
|
+
"phase.prepare.end": ("status",),
|
|
231
|
+
}
|
|
232
|
+
_CORRECTION_PROTECTED_FIELDS = frozenset(
|
|
233
|
+
{"schema_version", "id", "timestamp", "phase", "type"}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# HH-WF-20260730-001: reasons a write-path auto-seal may attribute to an
|
|
237
|
+
# attempt that was still open when it was closed on its behalf.
|
|
238
|
+
_AUTO_SEAL_REASONS = frozenset(
|
|
239
|
+
{"executor_lost", "user_wait", "external_wait", "superseded", "unknown"}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Best-effort mapping from wait-style metadata events (seen inside an open
|
|
243
|
+
# attempt's own event stream) to the auto-seal reason they imply.
|
|
244
|
+
_WAIT_EVENT_REASON_MAP = {
|
|
245
|
+
"user.wait": "user_wait",
|
|
246
|
+
"environment.wait": "external_wait",
|
|
247
|
+
"env.wait": "external_wait",
|
|
248
|
+
"external.wait": "external_wait",
|
|
249
|
+
"ci.wait": "external_wait",
|
|
250
|
+
}
|
|
251
|
+
_RECOVERY_EVENT_TYPES = frozenset({"recovery", "phase.recovery", "attempt.recovery"})
|
|
252
|
+
|
|
253
|
+
# Terminal event types that close a phase attempt (RET/HH-WF-20260730-001).
|
|
254
|
+
# ``phase.end`` is an explicit human/automation-reported terminal; the
|
|
255
|
+
# write path also inserts ``phase.auto_sealed`` when a new ``phase.start``
|
|
256
|
+
# (or another terminal path) finds a still-open prior attempt.
|
|
257
|
+
TERMINAL_PHASE_EVENT_TYPES = frozenset({"phase.end", "phase.auto_sealed"})
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def now_iso() -> str:
|
|
261
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
265
|
+
if as_json:
|
|
266
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
267
|
+
else:
|
|
268
|
+
ok = payload.get("ok", True)
|
|
269
|
+
msg = payload.get("message") or payload.get("path") or ("ok" if ok else "error")
|
|
270
|
+
sys.stdout.write(f"{msg}\n")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def emit_error(
|
|
274
|
+
message: str,
|
|
275
|
+
*,
|
|
276
|
+
as_json: bool,
|
|
277
|
+
code: int = 1,
|
|
278
|
+
error_code: str | None = None,
|
|
279
|
+
) -> int:
|
|
280
|
+
payload = {"ok": False, "error": message}
|
|
281
|
+
if error_code:
|
|
282
|
+
payload["code"] = error_code
|
|
283
|
+
if as_json:
|
|
284
|
+
sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
285
|
+
else:
|
|
286
|
+
sys.stderr.write(f"error: {message}\n")
|
|
287
|
+
return code
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def resolve_change_dir(raw: str) -> Path:
|
|
291
|
+
return Path(raw).expanduser().resolve()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _state_dir(change_dir: Path) -> Path:
|
|
295
|
+
return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def events_path(change_dir: Path) -> Path:
|
|
299
|
+
return _state_dir(change_dir) / "events.ndjson"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def execution_log_path(change_dir: Path) -> Path:
|
|
303
|
+
return _state_dir(change_dir) / "logs" / "execution-log.md"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def archived_change_dir(change_dir: Path) -> Path | None:
|
|
307
|
+
"""Return the matching archive when change_dir follows the project layout."""
|
|
308
|
+
changes_root = change_dir.parent
|
|
309
|
+
harness_root = changes_root.parent
|
|
310
|
+
if changes_root.name != "changes" or harness_root.name != ".harness":
|
|
311
|
+
return None
|
|
312
|
+
archive_root = harness_root / "archive"
|
|
313
|
+
if not archive_root.is_dir():
|
|
314
|
+
return None
|
|
315
|
+
suffix = "-" + change_dir.name
|
|
316
|
+
matches = sorted(
|
|
317
|
+
candidate for candidate in archive_root.iterdir()
|
|
318
|
+
if candidate.is_dir() and (
|
|
319
|
+
candidate.name == change_dir.name or candidate.name.endswith(suffix)
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
return matches[-1] if matches else None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def parse_timestamp(value: Any) -> dt.datetime | None:
|
|
326
|
+
if not isinstance(value, str) or not value.strip():
|
|
327
|
+
return None
|
|
328
|
+
text = value.strip()
|
|
329
|
+
if text.endswith("Z"):
|
|
330
|
+
text = text[:-1] + "+00:00"
|
|
331
|
+
try:
|
|
332
|
+
return dt.datetime.fromisoformat(text)
|
|
333
|
+
except ValueError:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def duration_ms_between(start: Any, end: Any) -> int | None:
|
|
338
|
+
start_dt = parse_timestamp(start)
|
|
339
|
+
end_dt = parse_timestamp(end)
|
|
340
|
+
if start_dt is None or end_dt is None:
|
|
341
|
+
return None
|
|
342
|
+
return max(0, int((end_dt - start_dt).total_seconds() * 1000))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
|
|
346
|
+
"""Normalize schema_version 1/2/3 events for rendering and summary."""
|
|
347
|
+
event = dict(raw)
|
|
348
|
+
version = event.get("schema_version", 1)
|
|
349
|
+
try:
|
|
350
|
+
version_int = int(version)
|
|
351
|
+
except (TypeError, ValueError):
|
|
352
|
+
version_int = 1
|
|
353
|
+
event["schema_version"] = version_int
|
|
354
|
+
if version_int < SCHEMA_VERSION and "schemaValidation" not in event:
|
|
355
|
+
event["schemaValidation"] = "legacy"
|
|
356
|
+
if "note" not in event or event["note"] is None:
|
|
357
|
+
event["note"] = ""
|
|
358
|
+
return event
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def canonical_value_hash(value: Any) -> str:
|
|
362
|
+
"""Return the stable optimistic-concurrency hash for a corrected value."""
|
|
363
|
+
payload = json.dumps(
|
|
364
|
+
value,
|
|
365
|
+
ensure_ascii=False,
|
|
366
|
+
sort_keys=True,
|
|
367
|
+
separators=(",", ":"),
|
|
368
|
+
).encode("utf-8")
|
|
369
|
+
return "sha256:" + hashlib.sha256(payload).hexdigest()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def apply_event_corrections(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
373
|
+
"""Project append-only correction events without mutating event history.
|
|
374
|
+
|
|
375
|
+
Corrections may target only earlier events and use an old-value hash as an
|
|
376
|
+
optimistic-concurrency guard. The correction records remain in the raw
|
|
377
|
+
stream; projections return only the corrected domain events.
|
|
378
|
+
"""
|
|
379
|
+
projected: list[dict[str, Any]] = []
|
|
380
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
381
|
+
for raw in events:
|
|
382
|
+
event = copy.deepcopy(raw)
|
|
383
|
+
if event.get("type") != "correction":
|
|
384
|
+
projected.append(event)
|
|
385
|
+
event_id = str(event.get("id") or "").strip()
|
|
386
|
+
if event_id:
|
|
387
|
+
by_id[event_id] = event
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
correction_id = str(event.get("id") or "<unknown>")
|
|
391
|
+
target_id = str(event.get("target_event_id") or "").strip()
|
|
392
|
+
target = by_id.get(target_id)
|
|
393
|
+
if target is None:
|
|
394
|
+
raise ValueError(
|
|
395
|
+
f"CORRECTION_TARGET_NOT_FOUND: {correction_id} targets {target_id}"
|
|
396
|
+
)
|
|
397
|
+
field = str(event.get("target_field") or "").strip()
|
|
398
|
+
if not field or field in _CORRECTION_PROTECTED_FIELDS:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"CORRECTION_FIELD_NOT_ALLOWED: {correction_id} targets {field}"
|
|
401
|
+
)
|
|
402
|
+
actual_hash = canonical_value_hash(target.get(field))
|
|
403
|
+
expected_hash = str(event.get("old_value_hash") or "").strip()
|
|
404
|
+
if actual_hash != expected_hash:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
"CORRECTION_OLD_VALUE_MISMATCH: "
|
|
407
|
+
f"{correction_id} expected {expected_hash}, found {actual_hash}"
|
|
408
|
+
)
|
|
409
|
+
target[field] = copy.deepcopy(event.get("new_value"))
|
|
410
|
+
return projected
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _issue_identity(event: dict[str, Any]) -> str:
|
|
414
|
+
explicit = str(event.get("issue_id") or "").strip()
|
|
415
|
+
if explicit:
|
|
416
|
+
return explicit
|
|
417
|
+
code = str(event.get("code") or "").strip()
|
|
418
|
+
scope = str(event.get("scope") or "").strip()
|
|
419
|
+
if code:
|
|
420
|
+
return f"code:{code}|scope:{scope}"
|
|
421
|
+
return str(event.get("id") or "").strip()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def current_issues(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
425
|
+
"""Return unresolved issues while preserving the full append-only history."""
|
|
426
|
+
active: dict[str, dict[str, Any]] = {}
|
|
427
|
+
for event in apply_event_corrections(events):
|
|
428
|
+
event_type = event.get("type")
|
|
429
|
+
if event_type == "issue":
|
|
430
|
+
identity = _issue_identity(event)
|
|
431
|
+
if identity:
|
|
432
|
+
item = copy.deepcopy(event)
|
|
433
|
+
item["issue_id"] = identity
|
|
434
|
+
active[identity] = item
|
|
435
|
+
elif event_type == "issue.resolve":
|
|
436
|
+
active.pop(_issue_identity(event), None)
|
|
437
|
+
elif event_type == "phase.end" and str(event.get("status") or "").upper() == "OK":
|
|
438
|
+
identity = str(event.get("issue_id") or "").strip()
|
|
439
|
+
if not identity:
|
|
440
|
+
continue
|
|
441
|
+
phase = event.get("phase")
|
|
442
|
+
attempt = event.get("attempt")
|
|
443
|
+
issue = active.get(identity)
|
|
444
|
+
if issue is None or issue.get("phase") != phase:
|
|
445
|
+
continue
|
|
446
|
+
issue_attempt = issue.get("attempt")
|
|
447
|
+
if (
|
|
448
|
+
isinstance(attempt, int)
|
|
449
|
+
and isinstance(issue_attempt, int)
|
|
450
|
+
and issue_attempt >= attempt
|
|
451
|
+
):
|
|
452
|
+
continue
|
|
453
|
+
active.pop(identity, None)
|
|
454
|
+
return list(active.values())
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def load_events(path: Path) -> list[dict[str, Any]]:
|
|
458
|
+
if not path.exists():
|
|
459
|
+
return []
|
|
460
|
+
events: list[dict[str, Any]] = []
|
|
461
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
462
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
463
|
+
stripped = line.strip()
|
|
464
|
+
if not stripped:
|
|
465
|
+
continue
|
|
466
|
+
try:
|
|
467
|
+
obj = json.loads(stripped)
|
|
468
|
+
except json.JSONDecodeError as exc:
|
|
469
|
+
raise ValueError(f"invalid JSON at {path} line {line_no}: {exc}") from exc
|
|
470
|
+
if not isinstance(obj, dict):
|
|
471
|
+
raise ValueError(f"event at {path} line {line_no} is not an object")
|
|
472
|
+
events.append(normalize_event(obj))
|
|
473
|
+
return events
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def merge_event_files(paths: list[Path]) -> list[dict[str, Any]]:
|
|
477
|
+
"""Union events from multiple NDJSON files by event ID (UT-001/RET-05).
|
|
478
|
+
|
|
479
|
+
Each event ID appears exactly once; first-seen copy wins. Missing IDs are
|
|
480
|
+
kept keyed by (file index, line number) so unidentified events are never
|
|
481
|
+
silently dropped.
|
|
482
|
+
"""
|
|
483
|
+
merged: list[dict[str, Any]] = []
|
|
484
|
+
seen: set[str] = set()
|
|
485
|
+
for path in paths:
|
|
486
|
+
for event in load_events(Path(path)):
|
|
487
|
+
event_id = str(event.get("id") or "").strip()
|
|
488
|
+
if event_id:
|
|
489
|
+
if event_id in seen:
|
|
490
|
+
continue
|
|
491
|
+
seen.add(event_id)
|
|
492
|
+
merged.append(event)
|
|
493
|
+
return merged
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def atomic_append_line(path: Path, line: str) -> None:
|
|
497
|
+
"""Write line to a temp file first, then append to the target (append-only)."""
|
|
498
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
payload = line if line.endswith("\n") else line + "\n"
|
|
500
|
+
# Ensure UTF-8 without BOM for the temp payload.
|
|
501
|
+
data = payload.encode("utf-8")
|
|
502
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
503
|
+
prefix=f".{path.name}.",
|
|
504
|
+
suffix=".tmp",
|
|
505
|
+
dir=str(path.parent),
|
|
506
|
+
)
|
|
507
|
+
tmp_path = Path(tmp_name)
|
|
508
|
+
try:
|
|
509
|
+
with os.fdopen(fd, "wb") as tmp_f:
|
|
510
|
+
tmp_f.write(data)
|
|
511
|
+
tmp_f.flush()
|
|
512
|
+
os.fsync(tmp_f.fileno())
|
|
513
|
+
with path.open("ab") as out_f:
|
|
514
|
+
out_f.write(data)
|
|
515
|
+
out_f.flush()
|
|
516
|
+
os.fsync(out_f.fileno())
|
|
517
|
+
finally:
|
|
518
|
+
try:
|
|
519
|
+
tmp_path.unlink(missing_ok=True)
|
|
520
|
+
except OSError:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@contextlib.contextmanager
|
|
525
|
+
def event_file_lock(lock_path: Path, timeout_seconds: float = 10.0):
|
|
526
|
+
"""Acquire a cross-process exclusive lock or raise TimeoutError.
|
|
527
|
+
|
|
528
|
+
§6.2: Windows uses msvcrt.locking; POSIX uses fcntl.flock. The lock file is
|
|
529
|
+
``<change-dir>/events.ndjson.lock``. On timeout the caller must fail non-zero
|
|
530
|
+
-- never continue without a lock. ``finally`` always unlocks and closes.
|
|
531
|
+
"""
|
|
532
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
# Ensure at least 1 byte exists; msvcrt.locking on an empty file can misbehave
|
|
534
|
+
# on some Windows versions.
|
|
535
|
+
if not lock_path.exists() or lock_path.stat().st_size == 0:
|
|
536
|
+
with open(lock_path, "ab") as seed_f:
|
|
537
|
+
seed_f.write(b"\0")
|
|
538
|
+
handle = open(lock_path, "r+b")
|
|
539
|
+
try:
|
|
540
|
+
handle.seek(0)
|
|
541
|
+
_acquire_file_lock(handle, lock_path, timeout_seconds)
|
|
542
|
+
try:
|
|
543
|
+
yield
|
|
544
|
+
finally:
|
|
545
|
+
_release_file_lock(handle)
|
|
546
|
+
finally:
|
|
547
|
+
handle.close()
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _acquire_file_lock(handle, lock_path: Path, timeout_seconds: float) -> None:
|
|
551
|
+
deadline = time.monotonic() + timeout_seconds
|
|
552
|
+
if os.name == "nt":
|
|
553
|
+
import msvcrt
|
|
554
|
+
|
|
555
|
+
while True:
|
|
556
|
+
try:
|
|
557
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
558
|
+
return
|
|
559
|
+
except OSError:
|
|
560
|
+
if time.monotonic() >= deadline:
|
|
561
|
+
raise TimeoutError(
|
|
562
|
+
f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
|
|
563
|
+
)
|
|
564
|
+
time.sleep(0.02)
|
|
565
|
+
else:
|
|
566
|
+
import fcntl
|
|
567
|
+
|
|
568
|
+
while True:
|
|
569
|
+
try:
|
|
570
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
571
|
+
return
|
|
572
|
+
except OSError:
|
|
573
|
+
if time.monotonic() >= deadline:
|
|
574
|
+
raise TimeoutError(
|
|
575
|
+
f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
|
|
576
|
+
)
|
|
577
|
+
time.sleep(0.02)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _release_file_lock(handle) -> None:
|
|
581
|
+
try:
|
|
582
|
+
if os.name == "nt":
|
|
583
|
+
import msvcrt
|
|
584
|
+
|
|
585
|
+
handle.seek(0)
|
|
586
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
587
|
+
else:
|
|
588
|
+
import fcntl
|
|
589
|
+
|
|
590
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
591
|
+
except OSError:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def new_event_id(existing: list[dict[str, Any]] | None = None) -> str:
|
|
596
|
+
"""Return a full-entropy event id.
|
|
597
|
+
|
|
598
|
+
§6.2: UUID 不需要扫描历史去重;直接使用完整 ``uuid.uuid4().hex``。
|
|
599
|
+
``existing`` is accepted for backward-compat callers but intentionally unused.
|
|
600
|
+
"""
|
|
601
|
+
return f"evt-{uuid.uuid4().hex}"
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def append_event(
|
|
605
|
+
change_dir: Path,
|
|
606
|
+
*,
|
|
607
|
+
phase: str,
|
|
608
|
+
type_: str,
|
|
609
|
+
note: str = "",
|
|
610
|
+
status: str | None = None,
|
|
611
|
+
code: str | None = None,
|
|
612
|
+
message: str | None = None,
|
|
613
|
+
reason: str | None = None,
|
|
614
|
+
duration_ms: int | None = None,
|
|
615
|
+
kind: str | None = None,
|
|
616
|
+
path: str | None = None,
|
|
617
|
+
run_id: str | None = None,
|
|
618
|
+
attempt: int | None = None,
|
|
619
|
+
executor_tool: str | None = None,
|
|
620
|
+
executor_agent: str | None = None,
|
|
621
|
+
executor_model: str | None = None,
|
|
622
|
+
execution_mode: str | None = None,
|
|
623
|
+
decision_reason_code: str | None = None,
|
|
624
|
+
fallback_reason_code: str | None = None,
|
|
625
|
+
trigger: str | None = None,
|
|
626
|
+
from_phase: str | None = None,
|
|
627
|
+
result_status: str | None = None,
|
|
628
|
+
runner_ms: int | None = None,
|
|
629
|
+
orchestration_active_ms: int | None = None,
|
|
630
|
+
wall_clock_ms: int | None = None,
|
|
631
|
+
user_wait_ms: int | None = None,
|
|
632
|
+
renamed_from: str | None = None,
|
|
633
|
+
renamed_to: str | None = None,
|
|
634
|
+
change_uuid: str | None = None,
|
|
635
|
+
) -> dict[str, Any]:
|
|
636
|
+
"""Programmatic append API (retro §5.31 C5/T16).
|
|
637
|
+
|
|
638
|
+
Validates and appends an event without going through argparse. Returns
|
|
639
|
+
a payload dict with ``ok``/``code``/``event``.
|
|
640
|
+
"""
|
|
641
|
+
args = argparse.Namespace(
|
|
642
|
+
change_dir=str(change_dir),
|
|
643
|
+
phase=phase,
|
|
644
|
+
type=type_,
|
|
645
|
+
note=note,
|
|
646
|
+
kind=kind,
|
|
647
|
+
path=path,
|
|
648
|
+
run_id=run_id,
|
|
649
|
+
executor_tool=executor_tool,
|
|
650
|
+
command=None,
|
|
651
|
+
exit_code=None,
|
|
652
|
+
duration_ms=duration_ms,
|
|
653
|
+
status=status,
|
|
654
|
+
name=None,
|
|
655
|
+
code=code,
|
|
656
|
+
severity=None,
|
|
657
|
+
message=message,
|
|
658
|
+
decision=None,
|
|
659
|
+
reason=reason,
|
|
660
|
+
issue_id=None,
|
|
661
|
+
scope=None,
|
|
662
|
+
target_event_id=None,
|
|
663
|
+
target_field=None,
|
|
664
|
+
old_value_hash=None,
|
|
665
|
+
new_value_json=None,
|
|
666
|
+
renamed_from=renamed_from,
|
|
667
|
+
renamed_to=renamed_to,
|
|
668
|
+
change_uuid=change_uuid,
|
|
669
|
+
attempt=attempt,
|
|
670
|
+
executor_agent=executor_agent,
|
|
671
|
+
executor_model=executor_model,
|
|
672
|
+
handoff_from_tool=None,
|
|
673
|
+
handoff_reason=None,
|
|
674
|
+
trace_id=None,
|
|
675
|
+
span_id=None,
|
|
676
|
+
parent_span_id=None,
|
|
677
|
+
runner_ms=runner_ms,
|
|
678
|
+
orchestration_active_ms=orchestration_active_ms,
|
|
679
|
+
wall_clock_ms=wall_clock_ms,
|
|
680
|
+
user_wait_ms=user_wait_ms,
|
|
681
|
+
execution_mode=execution_mode,
|
|
682
|
+
decision_reason_code=decision_reason_code,
|
|
683
|
+
fallback_reason_code=fallback_reason_code,
|
|
684
|
+
trigger=trigger,
|
|
685
|
+
from_phase=from_phase,
|
|
686
|
+
result_status=result_status,
|
|
687
|
+
legacy_lenient=False,
|
|
688
|
+
json=True,
|
|
689
|
+
)
|
|
690
|
+
as_json = True
|
|
691
|
+
if type_ not in EVENT_TYPES:
|
|
692
|
+
return {"ok": False, "code": "EVENT_TYPE_INVALID", "message": f"unsupported type: {type_}"}
|
|
693
|
+
validation = validate_append_event(args)
|
|
694
|
+
if validation:
|
|
695
|
+
error_code, message = validation
|
|
696
|
+
return {"ok": False, "code": error_code, "message": message}
|
|
697
|
+
archived = archived_change_dir(change_dir)
|
|
698
|
+
if archived is not None:
|
|
699
|
+
return {"ok": False, "code": "ARCHIVED_CHANGE_IMMUTABLE", "message": str(archived)}
|
|
700
|
+
events_path_obj = events_path(change_dir)
|
|
701
|
+
event = build_event(args, [])
|
|
702
|
+
result = append_with_auto_seal(events_path_obj, event)
|
|
703
|
+
if result.get("phaseAlreadyClosed"):
|
|
704
|
+
return {
|
|
705
|
+
"ok": False,
|
|
706
|
+
"code": "PHASE_ALREADY_CLOSED",
|
|
707
|
+
"message": (
|
|
708
|
+
"PHASE_ALREADY_CLOSED: refusing a second phase.end for the "
|
|
709
|
+
"same attempt. A retry needs a fresh run id AND the next "
|
|
710
|
+
"attempt number (attempts are global per phase, not per run id)."
|
|
711
|
+
),
|
|
712
|
+
"event": event,
|
|
713
|
+
"autoSealed": result.get("autoSealed") or [],
|
|
714
|
+
}
|
|
715
|
+
_nudge_remote_sync(change_dir)
|
|
716
|
+
return {
|
|
717
|
+
"ok": True,
|
|
718
|
+
"event": event,
|
|
719
|
+
"events_path": str(events_path_obj),
|
|
720
|
+
"rendered": False,
|
|
721
|
+
"autoSealed": result.get("autoSealed") or [],
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def batch_append_events(
|
|
726
|
+
change_dir: Path,
|
|
727
|
+
events: Sequence[dict[str, Any]],
|
|
728
|
+
) -> dict[str, Any]:
|
|
729
|
+
"""Validate then append many events under one lock (Wave-2 H-15).
|
|
730
|
+
|
|
731
|
+
Any invalid item aborts the whole batch before mutation. ``phase.end`` items
|
|
732
|
+
are rejected here — use single ``append`` so render semantics stay explicit.
|
|
733
|
+
"""
|
|
734
|
+
if not isinstance(events, (list, tuple)) or not events:
|
|
735
|
+
return {
|
|
736
|
+
"ok": False,
|
|
737
|
+
"code": "BATCH_EMPTY",
|
|
738
|
+
"message": "batch-append requires a non-empty events array",
|
|
739
|
+
}
|
|
740
|
+
archived = archived_change_dir(change_dir)
|
|
741
|
+
if archived is not None:
|
|
742
|
+
return {
|
|
743
|
+
"ok": False,
|
|
744
|
+
"code": "ARCHIVED_CHANGE_IMMUTABLE",
|
|
745
|
+
"message": str(archived),
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
built: list[dict[str, Any]] = []
|
|
749
|
+
for index, raw in enumerate(events):
|
|
750
|
+
if not isinstance(raw, dict):
|
|
751
|
+
return {
|
|
752
|
+
"ok": False,
|
|
753
|
+
"code": "BATCH_ITEM_INVALID",
|
|
754
|
+
"message": f"events[{index}] must be an object",
|
|
755
|
+
"index": index,
|
|
756
|
+
}
|
|
757
|
+
type_ = str(raw.get("type") or "").strip()
|
|
758
|
+
phase = str(raw.get("phase") or "").strip()
|
|
759
|
+
if not type_ or type_ not in EVENT_TYPES:
|
|
760
|
+
return {
|
|
761
|
+
"ok": False,
|
|
762
|
+
"code": "EVENT_TYPE_INVALID",
|
|
763
|
+
"message": f"events[{index}]: unsupported type: {type_}",
|
|
764
|
+
"index": index,
|
|
765
|
+
}
|
|
766
|
+
if not phase:
|
|
767
|
+
return {
|
|
768
|
+
"ok": False,
|
|
769
|
+
"code": "EVENT_REQUIRED_FIELD",
|
|
770
|
+
"message": f"events[{index}]: phase required",
|
|
771
|
+
"index": index,
|
|
772
|
+
}
|
|
773
|
+
if type_ == "phase.end":
|
|
774
|
+
return {
|
|
775
|
+
"ok": False,
|
|
776
|
+
"code": "BATCH_PHASE_END_FORBIDDEN",
|
|
777
|
+
"message": f"events[{index}]: phase.end must use single append",
|
|
778
|
+
"index": index,
|
|
779
|
+
}
|
|
780
|
+
args = argparse.Namespace(
|
|
781
|
+
change_dir=str(change_dir),
|
|
782
|
+
phase=phase,
|
|
783
|
+
type=type_,
|
|
784
|
+
note=raw.get("note"),
|
|
785
|
+
kind=raw.get("kind"),
|
|
786
|
+
path=raw.get("path"),
|
|
787
|
+
run_id=raw.get("run_id"),
|
|
788
|
+
executor_tool=raw.get("executor_tool"),
|
|
789
|
+
command=raw.get("command"),
|
|
790
|
+
exit_code=raw.get("exit_code"),
|
|
791
|
+
duration_ms=raw.get("duration_ms"),
|
|
792
|
+
status=raw.get("status"),
|
|
793
|
+
name=raw.get("name"),
|
|
794
|
+
code=raw.get("code"),
|
|
795
|
+
severity=raw.get("severity"),
|
|
796
|
+
message=raw.get("message"),
|
|
797
|
+
decision=raw.get("decision"),
|
|
798
|
+
reason=raw.get("reason"),
|
|
799
|
+
issue_id=raw.get("issue_id"),
|
|
800
|
+
scope=raw.get("scope"),
|
|
801
|
+
target_event_id=raw.get("target_event_id"),
|
|
802
|
+
target_field=raw.get("target_field"),
|
|
803
|
+
old_value_hash=raw.get("old_value_hash"),
|
|
804
|
+
new_value_json=raw.get("new_value_json"),
|
|
805
|
+
renamed_from=raw.get("renamed_from"),
|
|
806
|
+
renamed_to=raw.get("renamed_to"),
|
|
807
|
+
change_uuid=raw.get("change_uuid"),
|
|
808
|
+
attempt=raw.get("attempt"),
|
|
809
|
+
executor_agent=raw.get("executor_agent"),
|
|
810
|
+
executor_model=raw.get("executor_model"),
|
|
811
|
+
handoff_from_tool=raw.get("handoff_from_tool"),
|
|
812
|
+
handoff_reason=raw.get("handoff_reason"),
|
|
813
|
+
trace_id=raw.get("trace_id"),
|
|
814
|
+
span_id=raw.get("span_id"),
|
|
815
|
+
parent_span_id=raw.get("parent_span_id"),
|
|
816
|
+
runner_ms=raw.get("runner_ms"),
|
|
817
|
+
orchestration_active_ms=raw.get("orchestration_active_ms"),
|
|
818
|
+
wall_clock_ms=raw.get("wall_clock_ms"),
|
|
819
|
+
user_wait_ms=raw.get("user_wait_ms"),
|
|
820
|
+
legacy_lenient=False,
|
|
821
|
+
json=True,
|
|
822
|
+
)
|
|
823
|
+
validation = validate_append_event(args)
|
|
824
|
+
if validation:
|
|
825
|
+
error_code, message = validation
|
|
826
|
+
return {
|
|
827
|
+
"ok": False,
|
|
828
|
+
"code": error_code,
|
|
829
|
+
"message": f"events[{index}]: {message}",
|
|
830
|
+
"index": index,
|
|
831
|
+
}
|
|
832
|
+
built.append(build_event(args, []))
|
|
833
|
+
|
|
834
|
+
events_path_obj = events_path(change_dir)
|
|
835
|
+
lock_path = events_path_obj.with_name(events_path_obj.name + ".lock")
|
|
836
|
+
with event_file_lock(lock_path):
|
|
837
|
+
for event in built:
|
|
838
|
+
atomic_append_line(
|
|
839
|
+
events_path_obj, json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
840
|
+
)
|
|
841
|
+
_nudge_remote_sync(change_dir)
|
|
842
|
+
return {
|
|
843
|
+
"ok": True,
|
|
844
|
+
"action": "batch-append",
|
|
845
|
+
"count": len(built),
|
|
846
|
+
"events": built,
|
|
847
|
+
"events_path": str(events_path_obj),
|
|
848
|
+
"rendered": False,
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def build_event(args: argparse.Namespace, existing: list[dict[str, Any]]) -> dict[str, Any]:
|
|
853
|
+
event: dict[str, Any] = {
|
|
854
|
+
"schema_version": SCHEMA_VERSION,
|
|
855
|
+
"id": new_event_id(existing),
|
|
856
|
+
"timestamp": now_iso(),
|
|
857
|
+
"phase": args.phase,
|
|
858
|
+
"type": args.type,
|
|
859
|
+
}
|
|
860
|
+
for field in OPTIONAL_FIELDS:
|
|
861
|
+
value = getattr(args, field, None)
|
|
862
|
+
if value is None:
|
|
863
|
+
continue
|
|
864
|
+
if field == "new_value_json":
|
|
865
|
+
event["new_value"] = json.loads(value)
|
|
866
|
+
else:
|
|
867
|
+
event[field] = value
|
|
868
|
+
if args.type == "issue" and not event.get("issue_id"):
|
|
869
|
+
event["issue_id"] = _issue_identity(event)
|
|
870
|
+
environment_defaults = {
|
|
871
|
+
"run_id": "HUNTER_HARNESS_RUN_ID",
|
|
872
|
+
"executor_tool": "HUNTER_HARNESS_TOOL",
|
|
873
|
+
"executor_agent": "HUNTER_HARNESS_AGENT",
|
|
874
|
+
"executor_model": "HUNTER_HARNESS_MODEL",
|
|
875
|
+
}
|
|
876
|
+
for field, env_name in environment_defaults.items():
|
|
877
|
+
if field not in event and os.environ.get(env_name):
|
|
878
|
+
event[field] = os.environ[env_name]
|
|
879
|
+
if "note" not in event:
|
|
880
|
+
event["note"] = ""
|
|
881
|
+
return event
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
|
|
885
|
+
"""Validate type-specific append fields before any file is mutated."""
|
|
886
|
+
event_type = str(args.type)
|
|
887
|
+
for field in _EVENT_REQUIRED_FIELDS.get(event_type, ()):
|
|
888
|
+
value = getattr(args, field, None)
|
|
889
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
890
|
+
return (
|
|
891
|
+
"EVENT_REQUIRED_FIELD",
|
|
892
|
+
f"EVENT_REQUIRED_FIELD: {event_type} requires --{field.replace('_', '-')}",
|
|
893
|
+
)
|
|
894
|
+
allowed = _EVENT_ALLOWED_FIELDS[event_type]
|
|
895
|
+
for field in OPTIONAL_FIELDS:
|
|
896
|
+
value = getattr(args, field, None)
|
|
897
|
+
if value is not None and field not in allowed:
|
|
898
|
+
return (
|
|
899
|
+
"EVENT_FIELD_NOT_ALLOWED",
|
|
900
|
+
"EVENT_FIELD_NOT_ALLOWED: "
|
|
901
|
+
f"{event_type} does not accept --{field.replace('_', '-')}",
|
|
902
|
+
)
|
|
903
|
+
for field, length in (("trace_id", 32), ("span_id", 16), ("parent_span_id", 16)):
|
|
904
|
+
value = getattr(args, field, None)
|
|
905
|
+
if value is not None and not re.fullmatch(rf"[0-9a-f]{{{length}}}", str(value)):
|
|
906
|
+
return (
|
|
907
|
+
"EVENT_TRACE_FIELD_INVALID",
|
|
908
|
+
f"EVENT_TRACE_FIELD_INVALID: --{field.replace('_', '-')} must be {length} lowercase hex characters",
|
|
909
|
+
)
|
|
910
|
+
for field in (
|
|
911
|
+
"runner_ms",
|
|
912
|
+
"orchestration_active_ms",
|
|
913
|
+
"wall_clock_ms",
|
|
914
|
+
"user_wait_ms",
|
|
915
|
+
):
|
|
916
|
+
value = getattr(args, field, None)
|
|
917
|
+
if value is not None and (not isinstance(value, int) or value < 0):
|
|
918
|
+
return (
|
|
919
|
+
"EVENT_TIMING_FIELD_INVALID",
|
|
920
|
+
f"EVENT_TIMING_FIELD_INVALID: --{field.replace('_', '-')} must be a nonnegative integer",
|
|
921
|
+
)
|
|
922
|
+
if event_type == "decision" and str(getattr(args, "phase", "")) == "review":
|
|
923
|
+
body = "\n".join(
|
|
924
|
+
str(getattr(args, field, "") or "")
|
|
925
|
+
for field in ("decision", "reason", "note")
|
|
926
|
+
)
|
|
927
|
+
if re.search(r"\bREVIEW_[A-Z0-9_]+\b", body):
|
|
928
|
+
return (
|
|
929
|
+
"EVENT_REVIEW_REASON_IN_BODY",
|
|
930
|
+
"EVENT_REVIEW_REASON_IN_BODY: 评审原因码只能写入结构化字段,正文请使用中文说明",
|
|
931
|
+
)
|
|
932
|
+
execution_mode = str(getattr(args, "execution_mode", "") or "").strip()
|
|
933
|
+
decision_code = str(
|
|
934
|
+
getattr(args, "decision_reason_code", "") or ""
|
|
935
|
+
).strip()
|
|
936
|
+
fallback_code = str(
|
|
937
|
+
getattr(args, "fallback_reason_code", "") or ""
|
|
938
|
+
).strip()
|
|
939
|
+
structured = bool(execution_mode or decision_code or fallback_code)
|
|
940
|
+
if structured and execution_mode not in {"delegated", "inline"}:
|
|
941
|
+
return (
|
|
942
|
+
"EVENT_REVIEW_EXECUTION_MODE_INVALID",
|
|
943
|
+
"EVENT_REVIEW_EXECUTION_MODE_INVALID: execution-mode 必须为 delegated 或 inline",
|
|
944
|
+
)
|
|
945
|
+
if decision_code and decision_code not in _REVIEW_DECISION_REASON_CODES:
|
|
946
|
+
return (
|
|
947
|
+
"EVENT_REVIEW_REASON_INVALID",
|
|
948
|
+
"EVENT_REVIEW_REASON_INVALID: 未知的评审委派原因码",
|
|
949
|
+
)
|
|
950
|
+
if fallback_code and fallback_code not in _REVIEW_FALLBACK_REASON_CODES:
|
|
951
|
+
return (
|
|
952
|
+
"EVENT_REVIEW_REASON_INVALID",
|
|
953
|
+
"EVENT_REVIEW_REASON_INVALID: 未知的评审回退原因码",
|
|
954
|
+
)
|
|
955
|
+
if execution_mode == "delegated":
|
|
956
|
+
if (
|
|
957
|
+
decision_code != "REVIEW_DELEGATED"
|
|
958
|
+
or fallback_code
|
|
959
|
+
or not str(getattr(args, "executor_agent", "") or "").strip()
|
|
960
|
+
):
|
|
961
|
+
return (
|
|
962
|
+
"EVENT_REVIEW_REASON_INVALID",
|
|
963
|
+
"EVENT_REVIEW_REASON_INVALID: 委派评审必须记录执行 Agent 和 REVIEW_DELEGATED",
|
|
964
|
+
)
|
|
965
|
+
if execution_mode == "inline" and (
|
|
966
|
+
decision_code or fallback_code not in _REVIEW_FALLBACK_REASON_CODES
|
|
967
|
+
):
|
|
968
|
+
return (
|
|
969
|
+
"EVENT_REVIEW_REASON_INVALID",
|
|
970
|
+
"EVENT_REVIEW_REASON_INVALID: 主会话评审必须记录一个稳定的回退原因码",
|
|
971
|
+
)
|
|
972
|
+
if event_type == "phase.auto_sealed":
|
|
973
|
+
reason_value = str(getattr(args, "reason", "") or "").strip()
|
|
974
|
+
if reason_value and reason_value not in _AUTO_SEAL_REASONS:
|
|
975
|
+
return (
|
|
976
|
+
"EVENT_REASON_INVALID",
|
|
977
|
+
"EVENT_REASON_INVALID: phase.auto_sealed --reason must be one of "
|
|
978
|
+
f"{sorted(_AUTO_SEAL_REASONS)}",
|
|
979
|
+
)
|
|
980
|
+
if event_type == "correction":
|
|
981
|
+
target_field = str(getattr(args, "target_field", "") or "").strip()
|
|
982
|
+
if target_field in _CORRECTION_PROTECTED_FIELDS:
|
|
983
|
+
return (
|
|
984
|
+
"CORRECTION_FIELD_NOT_ALLOWED",
|
|
985
|
+
f"CORRECTION_FIELD_NOT_ALLOWED: cannot correct {target_field}",
|
|
986
|
+
)
|
|
987
|
+
try:
|
|
988
|
+
json.loads(str(getattr(args, "new_value_json", "")))
|
|
989
|
+
except json.JSONDecodeError as exc:
|
|
990
|
+
return (
|
|
991
|
+
"CORRECTION_VALUE_INVALID_JSON",
|
|
992
|
+
f"CORRECTION_VALUE_INVALID_JSON: {exc}",
|
|
993
|
+
)
|
|
994
|
+
# Retro 2026-07-21 H-8: artifact events always require a non-empty path.
|
|
995
|
+
# Knowledge/exploration notes must use issue/decision, not pathless artifacts.
|
|
996
|
+
if event_type == "artifact":
|
|
997
|
+
path = str(getattr(args, "path", "") or "").strip()
|
|
998
|
+
if not path:
|
|
999
|
+
return (
|
|
1000
|
+
"ARTIFACT_PATH_REQUIRED",
|
|
1001
|
+
"ARTIFACT_PATH_REQUIRED: artifact requires --path "
|
|
1002
|
+
"(use issue/decision for informational notes)",
|
|
1003
|
+
)
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def status_symbol(status: Any, reason: Any = None) -> str:
|
|
1008
|
+
text = str(status or "").strip().lower()
|
|
1009
|
+
reason_text = str(reason or "").strip()
|
|
1010
|
+
if not text:
|
|
1011
|
+
return "—"
|
|
1012
|
+
ok_set = {"ok", "passed", "pass", "success", "green", "✅", "✅ok"}
|
|
1013
|
+
warn_set = {"warn", "warning", "yellow", "skipped", "skip", "🟡", "🟡warn"}
|
|
1014
|
+
fail_set = {"fail", "failed", "error", "red", "blocked", "❌", "❌fail"}
|
|
1015
|
+
if text in ok_set or text.startswith("ok") or "✅" in text:
|
|
1016
|
+
return "✅OK"
|
|
1017
|
+
if text in warn_set or "warn" in text or "🟡" in text:
|
|
1018
|
+
return f"🟡WARN({reason_text})" if reason_text else "🟡WARN"
|
|
1019
|
+
if text in fail_set or "fail" in text or "error" in text or "❌" in text:
|
|
1020
|
+
return f"❌FAIL({reason_text})" if reason_text else "❌FAIL"
|
|
1021
|
+
if reason_text:
|
|
1022
|
+
return f"{status}({reason_text})"
|
|
1023
|
+
return str(status)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def severity_symbol(severity: Any, message: Any = None) -> str:
|
|
1027
|
+
text = str(severity or "").strip().lower()
|
|
1028
|
+
msg = str(message or "").strip()
|
|
1029
|
+
if text in {"error", "fail", "failed", "critical"}:
|
|
1030
|
+
return f"❌FAIL({msg})" if msg else "❌FAIL"
|
|
1031
|
+
if text in {"warn", "warning"}:
|
|
1032
|
+
return f"🟡WARN({msg})" if msg else "🟡WARN"
|
|
1033
|
+
if text in {"info", "ok", "note"}:
|
|
1034
|
+
return f"✅OK({msg})" if msg else "✅OK"
|
|
1035
|
+
# Empty/unknown severity: never emit literal "issue"/"None".
|
|
1036
|
+
if not text:
|
|
1037
|
+
return ""
|
|
1038
|
+
return f"{severity}: {msg}" if msg else str(severity)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def format_duration(ms: int | None) -> str:
|
|
1042
|
+
if ms is None:
|
|
1043
|
+
return "—"
|
|
1044
|
+
if ms < 1000:
|
|
1045
|
+
return f"{ms}ms"
|
|
1046
|
+
seconds = ms / 1000
|
|
1047
|
+
if seconds < 60:
|
|
1048
|
+
return f"{seconds:.1f}s"
|
|
1049
|
+
minutes = int(seconds // 60)
|
|
1050
|
+
rem = seconds - minutes * 60
|
|
1051
|
+
return f"{minutes}m{rem:04.1f}s"
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def group_events_by_phase(events: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
1055
|
+
"""Preserve first-seen phase order; keep events in file order within each phase."""
|
|
1056
|
+
order: list[str] = []
|
|
1057
|
+
buckets: dict[str, list[dict[str, Any]]] = {}
|
|
1058
|
+
for event in events:
|
|
1059
|
+
phase = str(event.get("phase") or "unknown")
|
|
1060
|
+
if phase not in buckets:
|
|
1061
|
+
order.append(phase)
|
|
1062
|
+
buckets[phase] = []
|
|
1063
|
+
buckets[phase].append(event)
|
|
1064
|
+
return [(phase, buckets[phase]) for phase in order]
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1068
|
+
"""Split repeated starts into attempts and flag legacy events written after end.
|
|
1069
|
+
|
|
1070
|
+
``phase.end`` and ``phase.auto_sealed`` are both terminal: either one closes
|
|
1071
|
+
the current attempt (HH-WF-20260730-001).
|
|
1072
|
+
"""
|
|
1073
|
+
attempts: list[dict[str, Any]] = []
|
|
1074
|
+
current: dict[str, Any] | None = None
|
|
1075
|
+
next_attempt = 1
|
|
1076
|
+
pre_start_metadata_types = {
|
|
1077
|
+
"correction",
|
|
1078
|
+
"decision",
|
|
1079
|
+
"environment.wait",
|
|
1080
|
+
"env.wait",
|
|
1081
|
+
"external.wait",
|
|
1082
|
+
"issue",
|
|
1083
|
+
"ci.wait",
|
|
1084
|
+
# Preparation is intentionally outside the formal phase lifecycle.
|
|
1085
|
+
# A blocked preflight must remain visible in the raw timeline without
|
|
1086
|
+
# manufacturing an attempt or inflating the phase duration/count.
|
|
1087
|
+
"phase.prepare.start",
|
|
1088
|
+
"phase.prepare.end",
|
|
1089
|
+
}
|
|
1090
|
+
for event in phase_events:
|
|
1091
|
+
if event.get("type") == "phase.start":
|
|
1092
|
+
if current is not None:
|
|
1093
|
+
current["warnings"].append("new phase.start before prior phase.end")
|
|
1094
|
+
attempts.append(current)
|
|
1095
|
+
raw_attempt = event.get("attempt")
|
|
1096
|
+
attempt = raw_attempt if isinstance(raw_attempt, int) and raw_attempt > 0 else next_attempt
|
|
1097
|
+
next_attempt = max(next_attempt, attempt + 1)
|
|
1098
|
+
current = {"attempt": attempt, "events": [event], "warnings": []}
|
|
1099
|
+
continue
|
|
1100
|
+
if current is None:
|
|
1101
|
+
if attempts:
|
|
1102
|
+
prior_type = attempts[-1]["events"][-1].get("type")
|
|
1103
|
+
attempts[-1]["events"].append(event)
|
|
1104
|
+
if prior_type in TERMINAL_PHASE_EVENT_TYPES:
|
|
1105
|
+
attempts[-1]["warnings"].append("event recorded after phase.end")
|
|
1106
|
+
continue
|
|
1107
|
+
if event.get("type") in pre_start_metadata_types:
|
|
1108
|
+
# Decisions and diagnostics may be emitted while selecting or
|
|
1109
|
+
# recovering a phase. They describe workflow context, not an
|
|
1110
|
+
# execution attempt, so they must not manufacture an orphan.
|
|
1111
|
+
continue
|
|
1112
|
+
raw_attempt = event.get("attempt")
|
|
1113
|
+
attempt = raw_attempt if isinstance(raw_attempt, int) and raw_attempt > 0 else next_attempt
|
|
1114
|
+
next_attempt = max(next_attempt, attempt + 1)
|
|
1115
|
+
current = {
|
|
1116
|
+
"attempt": attempt,
|
|
1117
|
+
"events": [event],
|
|
1118
|
+
"warnings": ["missing phase.start"],
|
|
1119
|
+
}
|
|
1120
|
+
else:
|
|
1121
|
+
current["events"].append(event)
|
|
1122
|
+
if event.get("type") in TERMINAL_PHASE_EVENT_TYPES:
|
|
1123
|
+
attempts.append(current)
|
|
1124
|
+
current = None
|
|
1125
|
+
if current is not None:
|
|
1126
|
+
attempts.append(current)
|
|
1127
|
+
return attempts
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def phase_start_time(phase_events: list[dict[str, Any]]) -> str:
|
|
1131
|
+
for event in phase_events:
|
|
1132
|
+
if event.get("type") == "phase.start":
|
|
1133
|
+
return str(event.get("timestamp") or "")
|
|
1134
|
+
if phase_events:
|
|
1135
|
+
return str(phase_events[0].get("timestamp") or "")
|
|
1136
|
+
return ""
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def phase_duration_ms(phase_events: list[dict[str, Any]]) -> int | None:
|
|
1140
|
+
start_ts = None
|
|
1141
|
+
end_ts = None
|
|
1142
|
+
for event in phase_events:
|
|
1143
|
+
etype = event.get("type")
|
|
1144
|
+
if etype == "phase.start" and start_ts is None:
|
|
1145
|
+
start_ts = event.get("timestamp")
|
|
1146
|
+
elif etype in TERMINAL_PHASE_EVENT_TYPES:
|
|
1147
|
+
end_ts = event.get("timestamp")
|
|
1148
|
+
if start_ts and end_ts:
|
|
1149
|
+
# Closed phases end at the matching phase.end. Late events appended
|
|
1150
|
+
# after closure are reported separately (late_event_stats) and never
|
|
1151
|
+
# extend the closed duration (RET-21).
|
|
1152
|
+
return duration_ms_between(start_ts, end_ts)
|
|
1153
|
+
# Fallback: first to last timestamp in the phase bucket.
|
|
1154
|
+
stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
|
|
1155
|
+
if len(stamps) >= 2:
|
|
1156
|
+
return duration_ms_between(stamps[0], stamps[-1])
|
|
1157
|
+
return None
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def late_event_stats(phase_events: list[dict[str, Any]]) -> dict[str, int]:
|
|
1161
|
+
"""Count events recorded after the final closing phase.end (RET-21)."""
|
|
1162
|
+
final_end_index = None
|
|
1163
|
+
for index, event in enumerate(phase_events):
|
|
1164
|
+
if event.get("type") == "phase.end":
|
|
1165
|
+
final_end_index = index
|
|
1166
|
+
if final_end_index is None:
|
|
1167
|
+
return {"lateEventCount": 0, "lateEventSpanMs": 0}
|
|
1168
|
+
end_ts = phase_events[final_end_index].get("timestamp")
|
|
1169
|
+
late_stamps = [
|
|
1170
|
+
event["timestamp"]
|
|
1171
|
+
for event in phase_events[final_end_index + 1 :]
|
|
1172
|
+
if event.get("timestamp")
|
|
1173
|
+
]
|
|
1174
|
+
if not late_stamps:
|
|
1175
|
+
return {"lateEventCount": 0, "lateEventSpanMs": 0}
|
|
1176
|
+
span = duration_ms_between(end_ts, late_stamps[-1]) or 0
|
|
1177
|
+
return {"lateEventCount": len(late_stamps), "lateEventSpanMs": span}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _seal_timestamp_for_attempt(
|
|
1181
|
+
events_in: list[dict[str, Any]],
|
|
1182
|
+
*,
|
|
1183
|
+
cutoff_ts: str | None,
|
|
1184
|
+
next_attempt_start: Any = None,
|
|
1185
|
+
) -> tuple[Any, str | None]:
|
|
1186
|
+
"""Pick a typed terminal and seal time for an unclosed attempt."""
|
|
1187
|
+
recovery_ts = None
|
|
1188
|
+
explicit_terminal: tuple[Any, str] | None = None
|
|
1189
|
+
for event in events_in:
|
|
1190
|
+
event_type = str(event.get("type") or "").lower()
|
|
1191
|
+
if event_type in {
|
|
1192
|
+
"recovery",
|
|
1193
|
+
"phase.recovery",
|
|
1194
|
+
"attempt.recovery",
|
|
1195
|
+
}:
|
|
1196
|
+
recovery_ts = event.get("timestamp") or recovery_ts
|
|
1197
|
+
terminal = {
|
|
1198
|
+
"attempt.abandoned": "ABANDONED",
|
|
1199
|
+
"phase.abandoned": "ABANDONED",
|
|
1200
|
+
"attempt.interrupted": "INTERRUPTED",
|
|
1201
|
+
"phase.interrupted": "INTERRUPTED",
|
|
1202
|
+
"attempt.orphaned": "ORPHANED",
|
|
1203
|
+
"phase.orphaned": "ORPHANED",
|
|
1204
|
+
}.get(event_type)
|
|
1205
|
+
if terminal and event.get("timestamp"):
|
|
1206
|
+
explicit_terminal = (event.get("timestamp"), terminal)
|
|
1207
|
+
if explicit_terminal is not None:
|
|
1208
|
+
return explicit_terminal
|
|
1209
|
+
if next_attempt_start:
|
|
1210
|
+
return next_attempt_start, "RECOVERED"
|
|
1211
|
+
if recovery_ts:
|
|
1212
|
+
return recovery_ts, "RECOVERED"
|
|
1213
|
+
if cutoff_ts:
|
|
1214
|
+
return cutoff_ts, "INCOMPLETE_AT_CUTOFF"
|
|
1215
|
+
# Fall back to last event timestamp so wall clock is not silently dropped.
|
|
1216
|
+
for event in reversed(events_in):
|
|
1217
|
+
if event.get("timestamp"):
|
|
1218
|
+
return event.get("timestamp"), "INTERRUPTED"
|
|
1219
|
+
return None, "ORPHANED"
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def attempt_invocations(
|
|
1223
|
+
phase_events: list[dict[str, Any]],
|
|
1224
|
+
*,
|
|
1225
|
+
cutoff_ts: str | None = None,
|
|
1226
|
+
) -> list[dict[str, Any]]:
|
|
1227
|
+
"""Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
|
|
1228
|
+
|
|
1229
|
+
Closed attempts keep phase.end status. Attempts closed by a write-path
|
|
1230
|
+
``phase.auto_sealed`` event (HH-WF-20260730-001) keep that event's status
|
|
1231
|
+
(typically RECOVERED) but are never activeEligible: recovered/superseded
|
|
1232
|
+
time must not count as active execution. Unclosed attempts (no terminal
|
|
1233
|
+
event at all yet) are sealed as INCOMPLETE at the next attempt start, an
|
|
1234
|
+
explicit recovery event, or cutoff.
|
|
1235
|
+
"""
|
|
1236
|
+
attempts = split_phase_attempts(phase_events)
|
|
1237
|
+
invocations: list[dict[str, Any]] = []
|
|
1238
|
+
for index, attempt in enumerate(attempts):
|
|
1239
|
+
events_in = attempt.get("events") or []
|
|
1240
|
+
start_ts = None
|
|
1241
|
+
end_ts = None
|
|
1242
|
+
status = None
|
|
1243
|
+
terminal_status = None
|
|
1244
|
+
closed_by_end = False
|
|
1245
|
+
closed_by_auto_seal = False
|
|
1246
|
+
for event in events_in:
|
|
1247
|
+
if event.get("type") == "phase.start" and start_ts is None:
|
|
1248
|
+
start_ts = event.get("timestamp")
|
|
1249
|
+
elif event.get("type") == "phase.end":
|
|
1250
|
+
end_ts = event.get("timestamp")
|
|
1251
|
+
if "status" in event:
|
|
1252
|
+
status = event.get("status")
|
|
1253
|
+
terminal_status = str(event.get("status") or "").upper() or None
|
|
1254
|
+
closed_by_end = True
|
|
1255
|
+
elif event.get("type") == "phase.auto_sealed":
|
|
1256
|
+
end_ts = event.get("timestamp")
|
|
1257
|
+
seal_status = event.get("status") or "RECOVERED"
|
|
1258
|
+
status = seal_status
|
|
1259
|
+
terminal_status = str(seal_status or "").upper() or "RECOVERED"
|
|
1260
|
+
closed_by_auto_seal = True
|
|
1261
|
+
missing_start = start_ts is None
|
|
1262
|
+
sealed_incomplete = False
|
|
1263
|
+
if not closed_by_end and not closed_by_auto_seal:
|
|
1264
|
+
next_start = None
|
|
1265
|
+
if index + 1 < len(attempts):
|
|
1266
|
+
for event in attempts[index + 1].get("events") or []:
|
|
1267
|
+
if event.get("type") == "phase.start" and event.get("timestamp"):
|
|
1268
|
+
next_start = event.get("timestamp")
|
|
1269
|
+
break
|
|
1270
|
+
seal_ts, seal_status = _seal_timestamp_for_attempt(
|
|
1271
|
+
events_in,
|
|
1272
|
+
cutoff_ts=cutoff_ts,
|
|
1273
|
+
next_attempt_start=next_start,
|
|
1274
|
+
)
|
|
1275
|
+
if seal_ts is not None:
|
|
1276
|
+
end_ts = seal_ts
|
|
1277
|
+
status = seal_status
|
|
1278
|
+
terminal_status = seal_status
|
|
1279
|
+
sealed_incomplete = True
|
|
1280
|
+
elif missing_start:
|
|
1281
|
+
terminal_status = "ORPHANED"
|
|
1282
|
+
status = "ORPHANED"
|
|
1283
|
+
sealed_incomplete = True
|
|
1284
|
+
duration = None
|
|
1285
|
+
if start_ts and end_ts:
|
|
1286
|
+
duration = duration_ms_between(start_ts, end_ts)
|
|
1287
|
+
# Active execution counts phase.end-closed attempts only. Both
|
|
1288
|
+
# cutoff/INCOMPLETE seals and auto-sealed (recovered/superseded)
|
|
1289
|
+
# attempts are excluded (HH-WF-20260730-001).
|
|
1290
|
+
active_eligible = closed_by_end and not sealed_incomplete
|
|
1291
|
+
invocations.append(
|
|
1292
|
+
{
|
|
1293
|
+
"attempt": attempt.get("attempt"),
|
|
1294
|
+
"status": status,
|
|
1295
|
+
"terminalStatus": terminal_status,
|
|
1296
|
+
"startedAt": start_ts,
|
|
1297
|
+
"endedAt": end_ts,
|
|
1298
|
+
"durationMs": duration,
|
|
1299
|
+
"activeEligible": active_eligible,
|
|
1300
|
+
"sealedIncomplete": sealed_incomplete,
|
|
1301
|
+
"closedByAutoSeal": closed_by_auto_seal,
|
|
1302
|
+
"warnings": list(attempt.get("warnings") or []),
|
|
1303
|
+
}
|
|
1304
|
+
)
|
|
1305
|
+
return invocations
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def phase_final_state(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1309
|
+
"""Reduce a phase to the state of its latest attempt."""
|
|
1310
|
+
invocations = attempt_invocations(phase_events)
|
|
1311
|
+
if not invocations:
|
|
1312
|
+
return {"attempt": None, "status": None, "durationMs": None, "closed": False}
|
|
1313
|
+
latest = invocations[-1]
|
|
1314
|
+
return {
|
|
1315
|
+
"attempt": latest.get("attempt"),
|
|
1316
|
+
"status": latest.get("status"),
|
|
1317
|
+
"durationMs": latest.get("durationMs"),
|
|
1318
|
+
"closed": latest.get("terminalStatus") not in {
|
|
1319
|
+
None,
|
|
1320
|
+
"INCOMPLETE",
|
|
1321
|
+
"INCOMPLETE_AT_CUTOFF",
|
|
1322
|
+
"ORPHANED",
|
|
1323
|
+
"INTERRUPTED",
|
|
1324
|
+
},
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def canonical_phase_timing(
|
|
1329
|
+
phase_events: list[dict[str, Any]],
|
|
1330
|
+
*,
|
|
1331
|
+
cutoff_ts: str | None = None,
|
|
1332
|
+
) -> dict[str, Any]:
|
|
1333
|
+
"""Single reducer for every duration view (RET-20 / IA-2).
|
|
1334
|
+
|
|
1335
|
+
activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
|
|
1336
|
+
recoveredMs: sum of durations for attempts terminated via write-path
|
|
1337
|
+
auto-seal (HH-WF-20260730-001) — always disjoint from activeExecutionMs.
|
|
1338
|
+
wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
|
|
1339
|
+
"""
|
|
1340
|
+
invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
|
|
1341
|
+
stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
|
|
1342
|
+
# Cutoff seals INCOMPLETE attempts but must not inflate stage wall by itself.
|
|
1343
|
+
# Include cutoff in the wall span only when it sealed an otherwise-open attempt.
|
|
1344
|
+
if cutoff_ts and any(inv.get("sealedIncomplete") for inv in invocations):
|
|
1345
|
+
stamps = list(stamps) + [cutoff_ts]
|
|
1346
|
+
closed_durations = [
|
|
1347
|
+
invocation["durationMs"]
|
|
1348
|
+
for invocation in invocations
|
|
1349
|
+
if invocation.get("activeEligible") and invocation.get("durationMs") is not None
|
|
1350
|
+
]
|
|
1351
|
+
active = sum(closed_durations) if closed_durations else (0 if invocations else None)
|
|
1352
|
+
recovered_durations = [
|
|
1353
|
+
invocation["durationMs"]
|
|
1354
|
+
for invocation in invocations
|
|
1355
|
+
if invocation.get("closedByAutoSeal") and invocation.get("durationMs") is not None
|
|
1356
|
+
]
|
|
1357
|
+
recovered = sum(recovered_durations) if recovered_durations else (0 if invocations else None)
|
|
1358
|
+
late = late_event_stats(phase_events)
|
|
1359
|
+
if len(stamps) >= 2:
|
|
1360
|
+
wall = duration_ms_between(stamps[0], stamps[-1])
|
|
1361
|
+
else:
|
|
1362
|
+
wall = active
|
|
1363
|
+
unclosed = sum(
|
|
1364
|
+
1
|
|
1365
|
+
for invocation in invocations
|
|
1366
|
+
if invocation.get("terminalStatus")
|
|
1367
|
+
in {"INCOMPLETE", "INCOMPLETE_AT_CUTOFF", "ORPHANED", "INTERRUPTED"}
|
|
1368
|
+
)
|
|
1369
|
+
return {
|
|
1370
|
+
"activeExecutionMs": active,
|
|
1371
|
+
"recoveredMs": recovered,
|
|
1372
|
+
"wallClockSpanMs": wall,
|
|
1373
|
+
"lateEventCount": late["lateEventCount"],
|
|
1374
|
+
"lateEventSpanMs": late["lateEventSpanMs"],
|
|
1375
|
+
"unclosedAttemptCount": unclosed,
|
|
1376
|
+
"attempts": invocations,
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def render_command_block(commands: list[dict[str, Any]]) -> list[str]:
|
|
1381
|
+
if not commands:
|
|
1382
|
+
return []
|
|
1383
|
+
|
|
1384
|
+
def command_display(event: dict[str, Any]) -> str:
|
|
1385
|
+
cmd = str(event.get("command") or "").strip()
|
|
1386
|
+
if cmd:
|
|
1387
|
+
return cmd
|
|
1388
|
+
return str(event.get("note") or "").strip()
|
|
1389
|
+
|
|
1390
|
+
if len(commands) == 1:
|
|
1391
|
+
event = commands[0]
|
|
1392
|
+
display = command_display(event)
|
|
1393
|
+
if not display:
|
|
1394
|
+
return []
|
|
1395
|
+
exit_code = event.get("exit_code")
|
|
1396
|
+
exit_text = "?" if exit_code is None else str(exit_code)
|
|
1397
|
+
duration = format_duration(
|
|
1398
|
+
int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
|
|
1399
|
+
)
|
|
1400
|
+
note = str(event.get("note") or "").strip()
|
|
1401
|
+
parts = [f"- command: `{display}`", f"exit={exit_text}", f"duration={duration}"]
|
|
1402
|
+
# Avoid duplicating note when it was already used as the display text.
|
|
1403
|
+
if note and note != display:
|
|
1404
|
+
parts.append(f"note={note}")
|
|
1405
|
+
return [" · ".join(parts)]
|
|
1406
|
+
|
|
1407
|
+
rows: list[str] = []
|
|
1408
|
+
for event in commands:
|
|
1409
|
+
display = command_display(event)
|
|
1410
|
+
if not display:
|
|
1411
|
+
continue
|
|
1412
|
+
cmd = display.replace("|", "\\|")
|
|
1413
|
+
exit_code = event.get("exit_code")
|
|
1414
|
+
exit_text = "?" if exit_code is None else str(exit_code)
|
|
1415
|
+
duration = format_duration(
|
|
1416
|
+
int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
|
|
1417
|
+
)
|
|
1418
|
+
note = str(event.get("note") or "").replace("|", "\\|")
|
|
1419
|
+
# If note was promoted to the command cell, leave the note column empty.
|
|
1420
|
+
note_cell = "" if note == display else note
|
|
1421
|
+
rows.append(f"| `{cmd}` | {exit_text} | {duration} | {note_cell} |")
|
|
1422
|
+
if not rows:
|
|
1423
|
+
return []
|
|
1424
|
+
lines = [
|
|
1425
|
+
"",
|
|
1426
|
+
"| 命令 | exit | duration | note |",
|
|
1427
|
+
"| --- | ---: | ---: | --- |",
|
|
1428
|
+
*rows,
|
|
1429
|
+
"",
|
|
1430
|
+
]
|
|
1431
|
+
return lines
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def render_event_line(event: dict[str, Any]) -> list[str]:
|
|
1435
|
+
etype = event.get("type")
|
|
1436
|
+
if etype == "phase.start":
|
|
1437
|
+
note = str(event.get("note") or "").strip()
|
|
1438
|
+
suffix = f" — {note}" if note else ""
|
|
1439
|
+
lines = [f"- phase.start @ {event.get('timestamp', '')}{suffix}"]
|
|
1440
|
+
tool = str(event.get("executor_tool") or "").strip()
|
|
1441
|
+
agent = str(event.get("executor_agent") or "").strip()
|
|
1442
|
+
handoff = str(event.get("handoff_from_tool") or "").strip()
|
|
1443
|
+
if handoff and tool:
|
|
1444
|
+
lines.append(f"- 工具交接: {handoff} → {tool}")
|
|
1445
|
+
elif tool:
|
|
1446
|
+
lines.append(f"- 执行来源: {tool}" + (f" / {agent}" if agent else ""))
|
|
1447
|
+
return lines
|
|
1448
|
+
if etype == "phase.end":
|
|
1449
|
+
note = str(event.get("note") or "").strip()
|
|
1450
|
+
suffix = f" — {note}" if note else ""
|
|
1451
|
+
return [f"- phase.end @ {event.get('timestamp', '')}{suffix}"]
|
|
1452
|
+
if etype == "phase.auto_sealed":
|
|
1453
|
+
reason = str(event.get("reason") or "").strip() or "unknown"
|
|
1454
|
+
status = str(event.get("status") or "RECOVERED").strip()
|
|
1455
|
+
note = str(event.get("note") or "").strip()
|
|
1456
|
+
suffix = f" — {note}" if note else ""
|
|
1457
|
+
return [
|
|
1458
|
+
f"- phase.auto_sealed ({status}/{reason}) @ {event.get('timestamp', '')}{suffix}"
|
|
1459
|
+
]
|
|
1460
|
+
if etype == "verification":
|
|
1461
|
+
note = str(event.get("note") or "").strip()
|
|
1462
|
+
name = str(event.get("name") or "").strip() or (
|
|
1463
|
+
note[:_NOTE_FALLBACK_MAXLEN] if note else ""
|
|
1464
|
+
)
|
|
1465
|
+
if not name:
|
|
1466
|
+
return []
|
|
1467
|
+
symbol = status_symbol(event.get("status"), event.get("reason"))
|
|
1468
|
+
return [f"- verification: {name} → {symbol}"]
|
|
1469
|
+
if etype == "decision":
|
|
1470
|
+
decision = str(event.get("decision") or "").strip()
|
|
1471
|
+
reason = str(event.get("reason") or event.get("note") or "").strip()
|
|
1472
|
+
if not decision:
|
|
1473
|
+
return [f"- decision: {reason}"] if reason else []
|
|
1474
|
+
if reason:
|
|
1475
|
+
return [f"- decision: {decision} — {reason}"]
|
|
1476
|
+
return [f"- decision: {decision}"]
|
|
1477
|
+
if etype == "issue":
|
|
1478
|
+
note = str(event.get("note") or "").strip()
|
|
1479
|
+
message = str(event.get("message") or "").strip() or note
|
|
1480
|
+
symbol = severity_symbol(event.get("severity"), message)
|
|
1481
|
+
if not symbol:
|
|
1482
|
+
return [f"- issue: {note}"] if note else []
|
|
1483
|
+
code = event.get("code")
|
|
1484
|
+
prefix = f"[{code}] " if code else ""
|
|
1485
|
+
return [f"- issue: {prefix}{symbol}"]
|
|
1486
|
+
if etype == "issue.resolve":
|
|
1487
|
+
issue_id = str(event.get("issue_id") or "").strip()
|
|
1488
|
+
reason = str(event.get("reason") or event.get("note") or "").strip()
|
|
1489
|
+
suffix = f" — {reason}" if reason else ""
|
|
1490
|
+
return [f"- issue.resolve: {issue_id}{suffix}"] if issue_id else []
|
|
1491
|
+
if etype == "correction":
|
|
1492
|
+
target = str(event.get("target_event_id") or "").strip()
|
|
1493
|
+
field = str(event.get("target_field") or "").strip()
|
|
1494
|
+
reason = str(event.get("reason") or event.get("note") or "").strip()
|
|
1495
|
+
suffix = f" — {reason}" if reason else ""
|
|
1496
|
+
label = f"{target}.{field}".strip(".")
|
|
1497
|
+
return [f"- correction: {label}{suffix}"] if label else []
|
|
1498
|
+
if etype == "artifact":
|
|
1499
|
+
path = str(event.get("path") or "").strip()
|
|
1500
|
+
note = str(event.get("note") or "").strip()
|
|
1501
|
+
kind = event.get("kind")
|
|
1502
|
+
if not path:
|
|
1503
|
+
return [f"- artifact: {note}"] if note else []
|
|
1504
|
+
if kind:
|
|
1505
|
+
return [f"- artifact: `{path}` ({kind})"]
|
|
1506
|
+
return [f"- artifact: `{path}`"]
|
|
1507
|
+
if etype == "command":
|
|
1508
|
+
return render_command_block([event])
|
|
1509
|
+
# Unknown types: skip from human log to keep size down.
|
|
1510
|
+
return []
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def render_execution_log(events: list[dict[str, Any]]) -> str:
|
|
1514
|
+
lines: list[str] = [
|
|
1515
|
+
f"> [!warning] {HEADER_LINE}",
|
|
1516
|
+
"",
|
|
1517
|
+
"# Execution Log",
|
|
1518
|
+
"",
|
|
1519
|
+
]
|
|
1520
|
+
if not events:
|
|
1521
|
+
lines.append("_(暂无事件)_")
|
|
1522
|
+
lines.append("")
|
|
1523
|
+
return "\n".join(lines)
|
|
1524
|
+
|
|
1525
|
+
projected_domain_events = iter(apply_event_corrections(events))
|
|
1526
|
+
projected_events = [
|
|
1527
|
+
copy.deepcopy(event)
|
|
1528
|
+
if event.get("type") == "correction"
|
|
1529
|
+
else next(projected_domain_events)
|
|
1530
|
+
for event in events
|
|
1531
|
+
]
|
|
1532
|
+
for phase, phase_events in group_events_by_phase(projected_events):
|
|
1533
|
+
attempts = split_phase_attempts(phase_events)
|
|
1534
|
+
for attempt_record in attempts:
|
|
1535
|
+
attempt_events = attempt_record["events"]
|
|
1536
|
+
start = phase_start_time(attempt_events) or "—"
|
|
1537
|
+
duration = phase_duration_ms(attempt_events)
|
|
1538
|
+
label = f"{phase}(尝试 {attempt_record['attempt']})" if len(attempts) > 1 else phase
|
|
1539
|
+
lines.append(f"## {label} — {start}")
|
|
1540
|
+
lines.append("")
|
|
1541
|
+
if duration is not None:
|
|
1542
|
+
lines.append(f"- 阶段耗时: {format_duration(duration)}")
|
|
1543
|
+
for warning in attempt_record["warnings"]:
|
|
1544
|
+
lines.append(f"- 生命周期警告: {warning}")
|
|
1545
|
+
buffer: list[dict[str, Any]] = []
|
|
1546
|
+
|
|
1547
|
+
def flush_commands() -> None:
|
|
1548
|
+
nonlocal buffer
|
|
1549
|
+
if buffer:
|
|
1550
|
+
lines.extend(render_command_block(buffer))
|
|
1551
|
+
buffer = []
|
|
1552
|
+
|
|
1553
|
+
for event in attempt_events:
|
|
1554
|
+
if event.get("type") == "command":
|
|
1555
|
+
buffer.append(event)
|
|
1556
|
+
continue
|
|
1557
|
+
flush_commands()
|
|
1558
|
+
lines.extend(render_event_line(event))
|
|
1559
|
+
flush_commands()
|
|
1560
|
+
lines.append("")
|
|
1561
|
+
|
|
1562
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def write_execution_log(change_dir: Path, content: str) -> Path:
|
|
1566
|
+
path = execution_log_path(change_dir)
|
|
1567
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1568
|
+
# UTF-8 without BOM
|
|
1569
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
1570
|
+
return path
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def execution_log_render_enabled(change_dir: Path) -> bool:
|
|
1574
|
+
"""P1 slim-files: execution-log.md auto-render policy.
|
|
1575
|
+
|
|
1576
|
+
`.harness/config/render-policy.json` with ``{"executionLog": "on-demand"}``
|
|
1577
|
+
disables the automatic render on ``phase.end``/auto-seal; the explicit
|
|
1578
|
+
``harness_events.py render`` subcommand rebuilds the projection at any
|
|
1579
|
+
time. Default stays "auto" (render on phase.end).
|
|
1580
|
+
"""
|
|
1581
|
+
current = change_dir.resolve()
|
|
1582
|
+
for candidate in [current, *current.parents]:
|
|
1583
|
+
if candidate.name == ".harness":
|
|
1584
|
+
config = candidate / "config" / "render-policy.json"
|
|
1585
|
+
else:
|
|
1586
|
+
config = candidate / ".harness" / "config" / "render-policy.json"
|
|
1587
|
+
if not config.is_file():
|
|
1588
|
+
continue
|
|
1589
|
+
try:
|
|
1590
|
+
document = json.loads(config.read_text(encoding="utf-8-sig"))
|
|
1591
|
+
except (OSError, json.JSONDecodeError):
|
|
1592
|
+
return True
|
|
1593
|
+
if isinstance(document, dict):
|
|
1594
|
+
mode = str(document.get("executionLog") or "").strip().lower()
|
|
1595
|
+
if mode == "on-demand":
|
|
1596
|
+
return False
|
|
1597
|
+
return True
|
|
1598
|
+
return True
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1602
|
+
phases: dict[str, Any] = {}
|
|
1603
|
+
issues: list[dict[str, Any]] = []
|
|
1604
|
+
projected_events = apply_event_corrections(events)
|
|
1605
|
+
|
|
1606
|
+
for phase, phase_events in group_events_by_phase(projected_events):
|
|
1607
|
+
attempt_records: list[dict[str, Any]] = []
|
|
1608
|
+
for record in split_phase_attempts(phase_events):
|
|
1609
|
+
attempt_events = record["events"]
|
|
1610
|
+
starts = [e for e in attempt_events if e.get("type") == "phase.start"]
|
|
1611
|
+
ends = [e for e in attempt_events if e.get("type") == "phase.end"]
|
|
1612
|
+
start_ts = starts[0].get("timestamp") if starts else None
|
|
1613
|
+
end_ts = ends[-1].get("timestamp") if ends else None
|
|
1614
|
+
provenance = starts[0] if starts else (attempt_events[0] if attempt_events else {})
|
|
1615
|
+
attempt_records.append({
|
|
1616
|
+
"attempt": record["attempt"],
|
|
1617
|
+
"event_count": len(attempt_events),
|
|
1618
|
+
"started_at": start_ts,
|
|
1619
|
+
"ended_at": end_ts,
|
|
1620
|
+
"duration_ms": phase_duration_ms(attempt_events),
|
|
1621
|
+
"status": ends[-1].get("status") if ends else None,
|
|
1622
|
+
"run_id": provenance.get("run_id"),
|
|
1623
|
+
"executor_tool": provenance.get("executor_tool"),
|
|
1624
|
+
"executor_agent": provenance.get("executor_agent"),
|
|
1625
|
+
"handoff_from_tool": provenance.get("handoff_from_tool"),
|
|
1626
|
+
"warnings": record["warnings"],
|
|
1627
|
+
})
|
|
1628
|
+
durations = [a["duration_ms"] for a in attempt_records if a["duration_ms"] is not None]
|
|
1629
|
+
first = attempt_records[0] if attempt_records else {}
|
|
1630
|
+
last = attempt_records[-1] if attempt_records else {}
|
|
1631
|
+
phases[phase] = {
|
|
1632
|
+
"event_count": len(phase_events),
|
|
1633
|
+
"started_at": first.get("started_at"),
|
|
1634
|
+
"ended_at": last.get("ended_at"),
|
|
1635
|
+
"duration_ms": sum(durations) if durations else None,
|
|
1636
|
+
"status": last.get("status"),
|
|
1637
|
+
"attempts": attempt_records,
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
for event in projected_events:
|
|
1641
|
+
if event.get("type") != "issue":
|
|
1642
|
+
continue
|
|
1643
|
+
issues.append(
|
|
1644
|
+
{
|
|
1645
|
+
"id": event.get("id"),
|
|
1646
|
+
"issue_id": _issue_identity(event),
|
|
1647
|
+
"timestamp": event.get("timestamp"),
|
|
1648
|
+
"phase": event.get("phase"),
|
|
1649
|
+
"code": event.get("code"),
|
|
1650
|
+
"severity": event.get("severity"),
|
|
1651
|
+
"message": event.get("message"),
|
|
1652
|
+
}
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
return {
|
|
1656
|
+
"ok": True,
|
|
1657
|
+
"change_dir": str(change_dir),
|
|
1658
|
+
"event_count": len(events),
|
|
1659
|
+
"phases": phases,
|
|
1660
|
+
"issues": issues,
|
|
1661
|
+
"current_issues": [
|
|
1662
|
+
{
|
|
1663
|
+
"id": event.get("id"),
|
|
1664
|
+
"issue_id": _issue_identity(event),
|
|
1665
|
+
"timestamp": event.get("timestamp"),
|
|
1666
|
+
"phase": event.get("phase"),
|
|
1667
|
+
"code": event.get("code"),
|
|
1668
|
+
"severity": event.get("severity"),
|
|
1669
|
+
"message": event.get("message"),
|
|
1670
|
+
}
|
|
1671
|
+
for event in current_issues(events)
|
|
1672
|
+
],
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def phase_end_already_recorded(
|
|
1677
|
+
events: list[dict[str, Any]], candidate: dict[str, Any]
|
|
1678
|
+
) -> bool:
|
|
1679
|
+
"""Return whether the candidate attempt already has a terminal event.
|
|
1680
|
+
|
|
1681
|
+
``phase.auto_sealed`` counts as a terminal here too (HH-WF-20260730-001):
|
|
1682
|
+
a stale ``phase.end`` for an attempt that was already auto-sealed (e.g.
|
|
1683
|
+
superseded by a later ``phase.start``) must not close it a second time.
|
|
1684
|
+
"""
|
|
1685
|
+
phase_events = [
|
|
1686
|
+
event for event in events if event.get("phase") == candidate.get("phase")
|
|
1687
|
+
]
|
|
1688
|
+
candidate_attempt = candidate.get("attempt")
|
|
1689
|
+
if isinstance(candidate_attempt, int):
|
|
1690
|
+
return any(
|
|
1691
|
+
event.get("type") in TERMINAL_PHASE_EVENT_TYPES
|
|
1692
|
+
and event.get("attempt") == candidate_attempt
|
|
1693
|
+
for event in phase_events
|
|
1694
|
+
)
|
|
1695
|
+
attempts = split_phase_attempts(phase_events)
|
|
1696
|
+
if not attempts:
|
|
1697
|
+
return False
|
|
1698
|
+
latest_events = attempts[-1].get("events") or []
|
|
1699
|
+
return any(event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in latest_events)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def infer_auto_seal_reason(attempt_events: list[dict[str, Any]]) -> str:
|
|
1703
|
+
"""Best-effort ``phase.auto_sealed`` reason from an open attempt's own events.
|
|
1704
|
+
|
|
1705
|
+
HH-WF-20260730-001: prefer an explicit wait signal recorded while the
|
|
1706
|
+
attempt was open, then an explicit recovery marker, else fall back to
|
|
1707
|
+
``superseded`` (the default meaning: a new phase.start arrived).
|
|
1708
|
+
"""
|
|
1709
|
+
for event in attempt_events:
|
|
1710
|
+
mapped = _WAIT_EVENT_REASON_MAP.get(str(event.get("type") or "").lower())
|
|
1711
|
+
if mapped:
|
|
1712
|
+
return mapped
|
|
1713
|
+
for event in attempt_events:
|
|
1714
|
+
if str(event.get("type") or "").lower() in _RECOVERY_EVENT_TYPES:
|
|
1715
|
+
return "executor_lost"
|
|
1716
|
+
return "superseded"
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def open_attempts_for_phase(
|
|
1720
|
+
existing_events: list[dict[str, Any]], phase: str
|
|
1721
|
+
) -> list[dict[str, Any]]:
|
|
1722
|
+
"""Return ``split_phase_attempts`` records for ``phase`` that started but
|
|
1723
|
+
have no terminal (``phase.end``/``phase.auto_sealed``) event yet.
|
|
1724
|
+
"""
|
|
1725
|
+
phase_events = [event for event in existing_events if event.get("phase") == phase]
|
|
1726
|
+
if not phase_events:
|
|
1727
|
+
return []
|
|
1728
|
+
open_attempts: list[dict[str, Any]] = []
|
|
1729
|
+
for attempt in split_phase_attempts(phase_events):
|
|
1730
|
+
events_in = attempt.get("events") or []
|
|
1731
|
+
if not events_in:
|
|
1732
|
+
continue
|
|
1733
|
+
if not any(event.get("type") == "phase.start" for event in events_in):
|
|
1734
|
+
# No actual start recorded (e.g. pure metadata bucket) — nothing
|
|
1735
|
+
# to auto-seal; sealing it would manufacture a phantom attempt.
|
|
1736
|
+
continue
|
|
1737
|
+
if any(
|
|
1738
|
+
event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in events_in
|
|
1739
|
+
):
|
|
1740
|
+
# Prefer "any terminal" over "last event is terminal": late events
|
|
1741
|
+
# may be appended onto a closed attempt after phase.end/auto_sealed.
|
|
1742
|
+
continue
|
|
1743
|
+
open_attempts.append(attempt)
|
|
1744
|
+
return open_attempts
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def seal_open_phase_attempts(
|
|
1748
|
+
existing_events: list[dict[str, Any]],
|
|
1749
|
+
*,
|
|
1750
|
+
phase: str,
|
|
1751
|
+
seal_reason: str | None = None,
|
|
1752
|
+
seal_ts: str | None = None,
|
|
1753
|
+
) -> list[dict[str, Any]]:
|
|
1754
|
+
"""Build ``phase.auto_sealed`` events for still-open attempts of ``phase``.
|
|
1755
|
+
|
|
1756
|
+
HH-WF-20260730-001: write-path auto-seal. Returns the seal event dicts —
|
|
1757
|
+
callers append them (then the triggering event, e.g. the new
|
|
1758
|
+
``phase.start``) under the same lock so the whole sequence is atomic from
|
|
1759
|
+
other processes' point of view.
|
|
1760
|
+
|
|
1761
|
+
When ``seal_reason`` is omitted, each open attempt gets a best-effort
|
|
1762
|
+
inferred reason (wait / recovery / superseded).
|
|
1763
|
+
"""
|
|
1764
|
+
open_attempts = open_attempts_for_phase(existing_events, phase)
|
|
1765
|
+
if not open_attempts:
|
|
1766
|
+
return []
|
|
1767
|
+
ts = seal_ts or now_iso()
|
|
1768
|
+
seal_events: list[dict[str, Any]] = []
|
|
1769
|
+
for attempt in open_attempts:
|
|
1770
|
+
events_in = attempt.get("events") or []
|
|
1771
|
+
provenance_source = events_in[0] if events_in else {}
|
|
1772
|
+
if seal_reason is None:
|
|
1773
|
+
reason = infer_auto_seal_reason(events_in)
|
|
1774
|
+
elif seal_reason in _AUTO_SEAL_REASONS:
|
|
1775
|
+
reason = seal_reason
|
|
1776
|
+
else:
|
|
1777
|
+
reason = "unknown"
|
|
1778
|
+
seal_event: dict[str, Any] = {
|
|
1779
|
+
"schema_version": SCHEMA_VERSION,
|
|
1780
|
+
"id": new_event_id(),
|
|
1781
|
+
"timestamp": ts,
|
|
1782
|
+
"phase": phase,
|
|
1783
|
+
"type": "phase.auto_sealed",
|
|
1784
|
+
"status": "RECOVERED",
|
|
1785
|
+
"reason": reason,
|
|
1786
|
+
"note": "",
|
|
1787
|
+
"attempt": attempt.get("attempt"),
|
|
1788
|
+
}
|
|
1789
|
+
for field in ("run_id", "executor_tool", "executor_agent", "executor_model"):
|
|
1790
|
+
value = provenance_source.get(field)
|
|
1791
|
+
if value is not None:
|
|
1792
|
+
seal_event[field] = value
|
|
1793
|
+
seal_events.append(seal_event)
|
|
1794
|
+
return seal_events
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def append_with_auto_seal(
|
|
1798
|
+
events_file: Path,
|
|
1799
|
+
event: dict[str, Any],
|
|
1800
|
+
*,
|
|
1801
|
+
existing_events: list[dict[str, Any]] | None = None,
|
|
1802
|
+
) -> dict[str, Any]:
|
|
1803
|
+
"""Append ``event`` under lock; auto-seal open attempts when type is phase.start.
|
|
1804
|
+
|
|
1805
|
+
Returns ``{ok, event, autoSealed, phaseAlreadyClosed, events_path}``.
|
|
1806
|
+
Caller must NOT hold the events lock — this function acquires it.
|
|
1807
|
+
"""
|
|
1808
|
+
lock_path = events_file.with_name(events_file.name + ".lock")
|
|
1809
|
+
event_type = str(event.get("type") or "")
|
|
1810
|
+
phase = str(event.get("phase") or "")
|
|
1811
|
+
auto_sealed: list[dict[str, Any]] = []
|
|
1812
|
+
phase_already_closed = False
|
|
1813
|
+
with event_file_lock(lock_path):
|
|
1814
|
+
loaded = (
|
|
1815
|
+
existing_events
|
|
1816
|
+
if existing_events is not None
|
|
1817
|
+
else (
|
|
1818
|
+
load_events(events_file)
|
|
1819
|
+
if event_type in {"phase.start", "phase.end", "correction"}
|
|
1820
|
+
else []
|
|
1821
|
+
)
|
|
1822
|
+
)
|
|
1823
|
+
if event_type in {"phase.start", "phase.end", "phase.auto_sealed"} and phase:
|
|
1824
|
+
phase_attempts = split_phase_attempts(
|
|
1825
|
+
[item for item in loaded if item.get("phase") == phase]
|
|
1826
|
+
)
|
|
1827
|
+
latest_attempt = max(
|
|
1828
|
+
(
|
|
1829
|
+
int(item.get("attempt"))
|
|
1830
|
+
for item in phase_attempts
|
|
1831
|
+
if isinstance(item.get("attempt"), int)
|
|
1832
|
+
),
|
|
1833
|
+
default=0,
|
|
1834
|
+
)
|
|
1835
|
+
if not isinstance(event.get("attempt"), int) or int(event["attempt"]) <= 0:
|
|
1836
|
+
event["attempt"] = (
|
|
1837
|
+
latest_attempt + 1 if event_type == "phase.start" else latest_attempt or 1
|
|
1838
|
+
)
|
|
1839
|
+
if event_type == "phase.start" and phase:
|
|
1840
|
+
auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
|
|
1841
|
+
for seal_event in auto_sealed:
|
|
1842
|
+
atomic_append_line(
|
|
1843
|
+
events_file,
|
|
1844
|
+
json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
|
|
1845
|
+
)
|
|
1846
|
+
if event_type == "phase.end":
|
|
1847
|
+
phase_already_closed = phase_end_already_recorded(loaded, event)
|
|
1848
|
+
if not phase_already_closed:
|
|
1849
|
+
atomic_append_line(
|
|
1850
|
+
events_file,
|
|
1851
|
+
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
|
1852
|
+
)
|
|
1853
|
+
return {
|
|
1854
|
+
"ok": not phase_already_closed,
|
|
1855
|
+
"event": event,
|
|
1856
|
+
"autoSealed": auto_sealed,
|
|
1857
|
+
"phaseAlreadyClosed": phase_already_closed,
|
|
1858
|
+
"events_path": str(events_file),
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def cmd_append(args: argparse.Namespace) -> int:
|
|
1863
|
+
as_json = bool(args.json)
|
|
1864
|
+
if args.type not in EVENT_TYPES:
|
|
1865
|
+
return emit_error(
|
|
1866
|
+
f"unsupported type: {args.type}; expected one of {sorted(EVENT_TYPES)}",
|
|
1867
|
+
as_json=as_json,
|
|
1868
|
+
)
|
|
1869
|
+
legacy_lenient = bool(getattr(args, "legacy_lenient", False))
|
|
1870
|
+
validation = validate_append_event(args)
|
|
1871
|
+
if validation:
|
|
1872
|
+
error_code, message = validation
|
|
1873
|
+
legacy_compatible = legacy_lenient and args.type in {"issue", "verification"}
|
|
1874
|
+
if not legacy_compatible:
|
|
1875
|
+
return emit_error(message, as_json=as_json, error_code=error_code)
|
|
1876
|
+
change_dir = resolve_change_dir(args.change_dir)
|
|
1877
|
+
archived = archived_change_dir(change_dir)
|
|
1878
|
+
if archived is not None:
|
|
1879
|
+
return emit_error(
|
|
1880
|
+
"ARCHIVED_CHANGE_IMMUTABLE: refusing to append to archived change "
|
|
1881
|
+
f"{change_dir.name} ({archived})",
|
|
1882
|
+
as_json=as_json,
|
|
1883
|
+
)
|
|
1884
|
+
path = events_path(change_dir)
|
|
1885
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
1886
|
+
|
|
1887
|
+
# §6.1/§6.2: 普通 append = 加锁 -> 追加一行 -> fsync -> 解锁,不 load 历史、不渲染。
|
|
1888
|
+
# new_event_id 用完整 uuid,无需扫描去重。锁覆盖 atomic_append_line 的
|
|
1889
|
+
# open/write/flush/fsync 全过程。
|
|
1890
|
+
event = build_event(args, [])
|
|
1891
|
+
# Explicit compatibility mode preserves old append behavior and marks the
|
|
1892
|
+
# resulting event so strict consumers can distinguish it from valid v3.
|
|
1893
|
+
if validation:
|
|
1894
|
+
event["schemaValidation"] = "legacy"
|
|
1895
|
+
if legacy_lenient and args.type == "issue" and not args.severity:
|
|
1896
|
+
event["severity"] = "info"
|
|
1897
|
+
print(
|
|
1898
|
+
"warning: issue without --severity, defaulted to info",
|
|
1899
|
+
file=sys.stderr,
|
|
1900
|
+
)
|
|
1901
|
+
if legacy_lenient and args.type == "verification" and (not args.name or not args.status):
|
|
1902
|
+
print(
|
|
1903
|
+
"warning: verification missing --name or --status",
|
|
1904
|
+
file=sys.stderr,
|
|
1905
|
+
)
|
|
1906
|
+
phase_closed = False
|
|
1907
|
+
phase_start_duplicate = False
|
|
1908
|
+
projection_error: str | None = None
|
|
1909
|
+
attempt_error: str | None = None
|
|
1910
|
+
auto_sealed_events: list[dict[str, Any]] = []
|
|
1911
|
+
try:
|
|
1912
|
+
with event_file_lock(lock_path):
|
|
1913
|
+
existing_events = (
|
|
1914
|
+
load_events(path)
|
|
1915
|
+
if args.type in {"phase.end", "correction", "phase.start"}
|
|
1916
|
+
or event.get("run_id") is not None
|
|
1917
|
+
or event.get("attempt") is not None
|
|
1918
|
+
else []
|
|
1919
|
+
)
|
|
1920
|
+
run_id = event.get("run_id")
|
|
1921
|
+
if run_id is not None:
|
|
1922
|
+
run_attempts = {
|
|
1923
|
+
int(item["attempt"])
|
|
1924
|
+
for item in existing_events
|
|
1925
|
+
if item.get("phase") == event.get("phase")
|
|
1926
|
+
and item.get("run_id") == run_id
|
|
1927
|
+
and isinstance(item.get("attempt"), int)
|
|
1928
|
+
and int(item["attempt"]) > 0
|
|
1929
|
+
}
|
|
1930
|
+
if run_attempts:
|
|
1931
|
+
expected_attempt = max(run_attempts)
|
|
1932
|
+
explicit_attempt = event.get("attempt")
|
|
1933
|
+
if (
|
|
1934
|
+
isinstance(explicit_attempt, int)
|
|
1935
|
+
and explicit_attempt != expected_attempt
|
|
1936
|
+
):
|
|
1937
|
+
attempt_error = (
|
|
1938
|
+
"EVENT_ATTEMPT_CONFLICT: run_id is already bound to "
|
|
1939
|
+
f"attempt {expected_attempt}, received "
|
|
1940
|
+
f"{explicit_attempt}. One run id maps to exactly one "
|
|
1941
|
+
"attempt — allocate a fresh run id for the next attempt."
|
|
1942
|
+
)
|
|
1943
|
+
else:
|
|
1944
|
+
event["attempt"] = expected_attempt
|
|
1945
|
+
if attempt_error is not None:
|
|
1946
|
+
pass
|
|
1947
|
+
elif args.type == "phase.end":
|
|
1948
|
+
phase_closed = phase_end_already_recorded(existing_events, event)
|
|
1949
|
+
elif args.type == "correction":
|
|
1950
|
+
try:
|
|
1951
|
+
apply_event_corrections([*existing_events, event])
|
|
1952
|
+
except ValueError as exc:
|
|
1953
|
+
projection_error = str(exc)
|
|
1954
|
+
elif args.type == "phase.start":
|
|
1955
|
+
# Re-appending the same (phase, run_id) is the caller restating a
|
|
1956
|
+
# start that already exists — `gate begin` writes one, and the
|
|
1957
|
+
# logging protocol used to ask for a second. Treat it as a no-op
|
|
1958
|
+
# rather than a duplicate; two identical phase.start events make
|
|
1959
|
+
# `plan finalize` unrecoverable via PHASE_START_DUPLICATE.
|
|
1960
|
+
if run_id is not None and any(
|
|
1961
|
+
item.get("phase") == args.phase
|
|
1962
|
+
and item.get("type") == "phase.start"
|
|
1963
|
+
and item.get("run_id") == run_id
|
|
1964
|
+
for item in existing_events
|
|
1965
|
+
):
|
|
1966
|
+
phase_start_duplicate = True
|
|
1967
|
+
# HH-WF-20260730-001: a new phase.start must not be appended
|
|
1968
|
+
# while a prior attempt for the same phase is still open —
|
|
1969
|
+
# auto-seal it first so timing never has two open attempts.
|
|
1970
|
+
open_attempts = (
|
|
1971
|
+
[] if phase_start_duplicate
|
|
1972
|
+
else open_attempts_for_phase(existing_events, args.phase)
|
|
1973
|
+
)
|
|
1974
|
+
if open_attempts:
|
|
1975
|
+
inferred_reason = infer_auto_seal_reason(
|
|
1976
|
+
open_attempts[-1].get("events") or []
|
|
1977
|
+
)
|
|
1978
|
+
auto_sealed_events = seal_open_phase_attempts(
|
|
1979
|
+
existing_events,
|
|
1980
|
+
phase=args.phase,
|
|
1981
|
+
seal_reason=inferred_reason,
|
|
1982
|
+
)
|
|
1983
|
+
if (
|
|
1984
|
+
not phase_closed
|
|
1985
|
+
and not phase_start_duplicate
|
|
1986
|
+
and projection_error is None
|
|
1987
|
+
and attempt_error is None
|
|
1988
|
+
):
|
|
1989
|
+
for seal_event in auto_sealed_events:
|
|
1990
|
+
atomic_append_line(
|
|
1991
|
+
path,
|
|
1992
|
+
json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
|
|
1993
|
+
)
|
|
1994
|
+
atomic_append_line(
|
|
1995
|
+
path,
|
|
1996
|
+
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
|
1997
|
+
)
|
|
1998
|
+
except (OSError, TimeoutError, ValueError) as exc:
|
|
1999
|
+
return emit_error(f"append failed: {exc}", as_json=as_json)
|
|
2000
|
+
if phase_start_duplicate:
|
|
2001
|
+
if as_json:
|
|
2002
|
+
print(json.dumps({
|
|
2003
|
+
"ok": True,
|
|
2004
|
+
"skipped": True,
|
|
2005
|
+
"reason": "phase-start-already-recorded",
|
|
2006
|
+
"phase": args.phase,
|
|
2007
|
+
"run_id": event.get("run_id"),
|
|
2008
|
+
}, ensure_ascii=False))
|
|
2009
|
+
else:
|
|
2010
|
+
print("ok (phase.start already recorded for this run id)")
|
|
2011
|
+
return 0
|
|
2012
|
+
if phase_closed:
|
|
2013
|
+
return emit_error(
|
|
2014
|
+
"PHASE_ALREADY_CLOSED: refusing a second phase.end for the same "
|
|
2015
|
+
"attempt. A retry needs a fresh run id AND the next attempt number "
|
|
2016
|
+
"(attempts are global per phase, not per run id). For a published "
|
|
2017
|
+
"plan rerun plan evidence-pack + plan finalize with the next attempt.",
|
|
2018
|
+
as_json=as_json,
|
|
2019
|
+
error_code="PHASE_ALREADY_CLOSED",
|
|
2020
|
+
)
|
|
2021
|
+
if projection_error is not None:
|
|
2022
|
+
error_code = projection_error.split(":", 1)[0]
|
|
2023
|
+
return emit_error(
|
|
2024
|
+
projection_error,
|
|
2025
|
+
as_json=as_json,
|
|
2026
|
+
error_code=error_code,
|
|
2027
|
+
)
|
|
2028
|
+
if attempt_error is not None:
|
|
2029
|
+
return emit_error(
|
|
2030
|
+
attempt_error,
|
|
2031
|
+
as_json=as_json,
|
|
2032
|
+
error_code="EVENT_ATTEMPT_CONFLICT",
|
|
2033
|
+
)
|
|
2034
|
+
|
|
2035
|
+
_nudge_remote_sync(change_dir)
|
|
2036
|
+
|
|
2037
|
+
# §6.1: phase.end append(或写路径产生了 auto-seal)-> 追加成功后执行一次
|
|
2038
|
+
# render(从完整 events 重建 log)。普通 command/issue 等 append 不渲染
|
|
2039
|
+
# (O(1));显式 `render` 子命令随时重建。
|
|
2040
|
+
rendered = False
|
|
2041
|
+
log_path = None
|
|
2042
|
+
log_lines = None
|
|
2043
|
+
if (args.type == "phase.end" or auto_sealed_events) and \
|
|
2044
|
+
execution_log_render_enabled(change_dir):
|
|
2045
|
+
try:
|
|
2046
|
+
events = load_events(path)
|
|
2047
|
+
content = render_execution_log(events)
|
|
2048
|
+
log_path = write_execution_log(change_dir, content)
|
|
2049
|
+
log_lines = len(content.splitlines())
|
|
2050
|
+
rendered = True
|
|
2051
|
+
except (OSError, ValueError) as exc:
|
|
2052
|
+
return emit_error(f"phase.end render failed: {exc}", as_json=as_json)
|
|
2053
|
+
|
|
2054
|
+
payload: dict[str, Any] = {
|
|
2055
|
+
"ok": True,
|
|
2056
|
+
"action": "append",
|
|
2057
|
+
"event": event,
|
|
2058
|
+
"events_path": str(path),
|
|
2059
|
+
"rendered": rendered,
|
|
2060
|
+
}
|
|
2061
|
+
if args.type == "phase.start":
|
|
2062
|
+
payload["autoSealed"] = auto_sealed_events
|
|
2063
|
+
if log_path is not None:
|
|
2064
|
+
payload["execution_log_path"] = str(log_path)
|
|
2065
|
+
payload["execution_log_lines"] = log_lines
|
|
2066
|
+
emit_json(payload, as_json=as_json)
|
|
2067
|
+
return 0
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def cmd_batch_append(args: argparse.Namespace) -> int:
|
|
2071
|
+
as_json = bool(args.json)
|
|
2072
|
+
change_dir = resolve_change_dir(args.change_dir)
|
|
2073
|
+
batch_path = Path(str(args.file)).expanduser()
|
|
2074
|
+
try:
|
|
2075
|
+
raw = json.loads(batch_path.read_text(encoding="utf-8-sig"))
|
|
2076
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
2077
|
+
return emit_error(f"batch-append failed to read --file: {exc}", as_json=as_json)
|
|
2078
|
+
result = batch_append_events(change_dir, raw if isinstance(raw, list) else [])
|
|
2079
|
+
if not result.get("ok"):
|
|
2080
|
+
return emit_error(
|
|
2081
|
+
str(result.get("message") or "batch-append failed"),
|
|
2082
|
+
as_json=as_json,
|
|
2083
|
+
error_code=str(result.get("code") or "BATCH_APPEND_FAILED"),
|
|
2084
|
+
)
|
|
2085
|
+
emit_json(result, as_json=as_json)
|
|
2086
|
+
return 0
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def cmd_render(args: argparse.Namespace) -> int:
|
|
2090
|
+
as_json = bool(args.json)
|
|
2091
|
+
change_dir = resolve_change_dir(args.change_dir)
|
|
2092
|
+
path = events_path(change_dir)
|
|
2093
|
+
try:
|
|
2094
|
+
events = load_events(path)
|
|
2095
|
+
content = render_execution_log(events)
|
|
2096
|
+
log_path = write_execution_log(change_dir, content)
|
|
2097
|
+
except (OSError, ValueError) as exc:
|
|
2098
|
+
return emit_error(f"render failed: {exc}", as_json=as_json)
|
|
2099
|
+
|
|
2100
|
+
payload = {
|
|
2101
|
+
"ok": True,
|
|
2102
|
+
"action": "render",
|
|
2103
|
+
"events_path": str(path),
|
|
2104
|
+
"execution_log_path": str(log_path),
|
|
2105
|
+
"event_count": len(events),
|
|
2106
|
+
"execution_log_lines": len(content.splitlines()),
|
|
2107
|
+
}
|
|
2108
|
+
emit_json(payload, as_json=as_json)
|
|
2109
|
+
return 0
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def cmd_summary(args: argparse.Namespace) -> int:
|
|
2113
|
+
# Structured summary is always JSON (task card: summary --json).
|
|
2114
|
+
change_dir = resolve_change_dir(args.change_dir)
|
|
2115
|
+
path = events_path(change_dir)
|
|
2116
|
+
try:
|
|
2117
|
+
events = load_events(path)
|
|
2118
|
+
payload = build_summary(change_dir, events)
|
|
2119
|
+
except (OSError, ValueError) as exc:
|
|
2120
|
+
return emit_error(f"summary failed: {exc}", as_json=True)
|
|
2121
|
+
|
|
2122
|
+
emit_json(payload, as_json=True)
|
|
2123
|
+
return 0
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2127
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
2128
|
+
common.add_argument(
|
|
2129
|
+
"--json",
|
|
2130
|
+
action="store_true",
|
|
2131
|
+
help="emit machine-readable JSON on stdout",
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
parser = argparse.ArgumentParser(
|
|
2135
|
+
prog="harness_events.py",
|
|
2136
|
+
description="Append/render/summarize harness change events.ndjson",
|
|
2137
|
+
parents=[common],
|
|
2138
|
+
)
|
|
2139
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
2140
|
+
|
|
2141
|
+
p_append = sub.add_parser(
|
|
2142
|
+
"append",
|
|
2143
|
+
parents=[common],
|
|
2144
|
+
help="append one event and auto-render execution-log",
|
|
2145
|
+
)
|
|
2146
|
+
p_append.add_argument("--change-dir", required=True)
|
|
2147
|
+
p_append.add_argument("--phase", required=True)
|
|
2148
|
+
p_append.add_argument("--type", required=True)
|
|
2149
|
+
p_append.add_argument("--command", default=None)
|
|
2150
|
+
p_append.add_argument("--exit-code", type=int, default=None)
|
|
2151
|
+
p_append.add_argument("--duration-ms", type=int, default=None)
|
|
2152
|
+
p_append.add_argument("--note", default=None)
|
|
2153
|
+
p_append.add_argument("--name", default=None)
|
|
2154
|
+
p_append.add_argument("--status", default=None)
|
|
2155
|
+
p_append.add_argument("--path", default=None)
|
|
2156
|
+
p_append.add_argument("--kind", default=None)
|
|
2157
|
+
p_append.add_argument("--code", default=None)
|
|
2158
|
+
p_append.add_argument("--severity", default=None)
|
|
2159
|
+
p_append.add_argument("--message", default=None)
|
|
2160
|
+
p_append.add_argument("--decision", default=None)
|
|
2161
|
+
p_append.add_argument("--reason", default=None)
|
|
2162
|
+
p_append.add_argument("--issue-id", default=None)
|
|
2163
|
+
p_append.add_argument("--scope", default=None)
|
|
2164
|
+
p_append.add_argument("--target-event-id", default=None)
|
|
2165
|
+
p_append.add_argument("--target-field", default=None)
|
|
2166
|
+
p_append.add_argument("--old-value-hash", default=None)
|
|
2167
|
+
p_append.add_argument("--new-value-json", default=None)
|
|
2168
|
+
p_append.add_argument("--run-id", default=None)
|
|
2169
|
+
p_append.add_argument("--attempt", type=int, default=None)
|
|
2170
|
+
p_append.add_argument("--executor-tool", default=None)
|
|
2171
|
+
p_append.add_argument("--executor-agent", default=None)
|
|
2172
|
+
p_append.add_argument("--executor-model", default=None)
|
|
2173
|
+
p_append.add_argument("--handoff-from-tool", default=None)
|
|
2174
|
+
p_append.add_argument("--handoff-reason", default=None)
|
|
2175
|
+
p_append.add_argument("--trace-id", default=None)
|
|
2176
|
+
p_append.add_argument("--span-id", default=None)
|
|
2177
|
+
p_append.add_argument("--parent-span-id", default=None)
|
|
2178
|
+
p_append.add_argument("--runner-ms", type=int, default=None)
|
|
2179
|
+
p_append.add_argument("--orchestration-active-ms", type=int, default=None)
|
|
2180
|
+
p_append.add_argument("--wall-clock-ms", type=int, default=None)
|
|
2181
|
+
p_append.add_argument("--user-wait-ms", type=int, default=None)
|
|
2182
|
+
p_append.add_argument("--execution-mode", default=None)
|
|
2183
|
+
p_append.add_argument("--decision-reason-code", default=None)
|
|
2184
|
+
p_append.add_argument("--fallback-reason-code", default=None)
|
|
2185
|
+
p_append.add_argument("--trigger", default=None)
|
|
2186
|
+
p_append.add_argument("--from-phase", default=None)
|
|
2187
|
+
p_append.add_argument("--result-status", default=None)
|
|
2188
|
+
p_append.add_argument(
|
|
2189
|
+
"--legacy-lenient",
|
|
2190
|
+
action="store_true",
|
|
2191
|
+
help="accept legacy incomplete/type-mismatched events and mark them explicitly",
|
|
2192
|
+
)
|
|
2193
|
+
p_append.set_defaults(func=cmd_append)
|
|
2194
|
+
|
|
2195
|
+
p_batch = sub.add_parser(
|
|
2196
|
+
"batch-append",
|
|
2197
|
+
parents=[common],
|
|
2198
|
+
help="validate and append many events under one lock (H-15)",
|
|
2199
|
+
)
|
|
2200
|
+
p_batch.add_argument("--change-dir", required=True)
|
|
2201
|
+
p_batch.add_argument(
|
|
2202
|
+
"--file",
|
|
2203
|
+
required=True,
|
|
2204
|
+
help="JSON file containing a non-empty array of event objects",
|
|
2205
|
+
)
|
|
2206
|
+
p_batch.set_defaults(func=cmd_batch_append)
|
|
2207
|
+
|
|
2208
|
+
p_render = sub.add_parser(
|
|
2209
|
+
"render",
|
|
2210
|
+
parents=[common],
|
|
2211
|
+
help="re-render execution-log.md from events.ndjson",
|
|
2212
|
+
)
|
|
2213
|
+
p_render.add_argument("--change-dir", required=True)
|
|
2214
|
+
p_render.set_defaults(func=cmd_render)
|
|
2215
|
+
|
|
2216
|
+
p_summary = sub.add_parser(
|
|
2217
|
+
"summary",
|
|
2218
|
+
parents=[common],
|
|
2219
|
+
help="summarize phases/issues from events.ndjson",
|
|
2220
|
+
)
|
|
2221
|
+
p_summary.add_argument("--change-dir", required=True)
|
|
2222
|
+
p_summary.set_defaults(func=cmd_summary)
|
|
2223
|
+
|
|
2224
|
+
return parser
|
|
2225
|
+
|
|
2226
|
+
|
|
2227
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2228
|
+
parser = build_parser()
|
|
2229
|
+
args = parser.parse_args(argv)
|
|
2230
|
+
return int(args.func(args))
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
if __name__ == "__main__":
|
|
2234
|
+
raise SystemExit(main())
|