@hunter-harness/workflow-harness 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +3 -3
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-archive/reference.md +30 -12
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/checklist.md +5 -0
- package/harness/bundles/general/claude-code/harness-run/reference.md +33 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/checklist.md +9 -0
- package/harness/bundles/general/claude-code/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/claude-code/harness-test/reference.md +38 -0
- package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +1209 -170
- package/harness/bundles/general/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/claude-code/scripts/harness_events.py +174 -25
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +203 -29
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +457 -10
- package/harness/bundles/general/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/general/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +3 -3
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-archive/reference.md +30 -12
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +5 -0
- package/harness/bundles/general/codebuddy/harness-run/reference.md +33 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/checklist.md +9 -0
- package/harness/bundles/general/codebuddy/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/codebuddy/harness-test/reference.md +38 -0
- package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1209 -170
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +174 -25
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +203 -29
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +457 -10
- package/harness/bundles/general/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +3 -3
- package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-archive/reference.md +30 -12
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- 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-run/checklist.md +5 -0
- package/harness/bundles/general/codex/harness-run/reference.md +33 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/checklist.md +9 -0
- package/harness/bundles/general/codex/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/codex/harness-test/reference.md +38 -0
- package/harness/bundles/general/codex/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/general/codex/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/general/codex/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +1209 -170
- package/harness/bundles/general/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codex/scripts/harness_events.py +174 -25
- package/harness/bundles/general/codex/scripts/harness_gate.py +203 -29
- package/harness/bundles/general/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codex/scripts/harness_ledger.py +457 -10
- package/harness/bundles/general/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +3 -3
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-archive/reference.md +30 -12
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- 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-run/checklist.md +5 -0
- package/harness/bundles/general/cursor/harness-run/reference.md +33 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/checklist.md +9 -0
- package/harness/bundles/general/cursor/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/cursor/harness-test/reference.md +38 -0
- package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/general/cursor/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/general/cursor/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +1209 -170
- package/harness/bundles/general/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/cursor/scripts/harness_events.py +174 -25
- package/harness/bundles/general/cursor/scripts/harness_gate.py +203 -29
- package/harness/bundles/general/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +457 -10
- package/harness/bundles/general/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/general/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +3 -3
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-archive/reference.md +30 -12
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +1209 -170
- package/harness/bundles/java/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/claude-code/scripts/harness_events.py +174 -25
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +203 -29
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +457 -10
- package/harness/bundles/java/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/java/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +3 -3
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-archive/reference.md +30 -12
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1209 -170
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +174 -25
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +203 -29
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +457 -10
- package/harness/bundles/java/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +3 -3
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-archive/reference.md +30 -12
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- 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 +15 -10
- package/harness/bundles/java/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/java/codex/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/java/codex/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +1209 -170
- package/harness/bundles/java/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codex/scripts/harness_events.py +174 -25
- package/harness/bundles/java/codex/scripts/harness_gate.py +203 -29
- package/harness/bundles/java/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codex/scripts/harness_ledger.py +457 -10
- package/harness/bundles/java/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +3 -3
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-archive/reference.md +30 -12
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +5 -1
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-plan/checklist.md +6 -0
- 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 +15 -10
- package/harness/bundles/java/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +26 -4
- package/harness/bundles/java/cursor/protocols/ledger-protocol.md +38 -0
- package/harness/bundles/java/cursor/protocols/powershell-protocol.md +16 -0
- package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +17 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +1209 -170
- package/harness/bundles/java/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/cursor/scripts/harness_events.py +174 -25
- package/harness/bundles/java/cursor/scripts/harness_gate.py +203 -29
- package/harness/bundles/java/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +457 -10
- package/harness/bundles/java/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/java/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +244 -49
- package/harness/manifests/general/claude-code.json +58 -38
- package/harness/manifests/general/codebuddy.json +58 -38
- package/harness/manifests/general/codex.json +58 -38
- package/harness/manifests/general/cursor.json +58 -38
- package/harness/manifests/java/claude-code.json +52 -36
- package/harness/manifests/java/codebuddy.json +52 -36
- package/harness/manifests/java/codex.json +52 -36
- package/harness/manifests/java/cursor.json +52 -36
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -54,14 +54,23 @@ MANIFEST_COMPARE_EXCLUDE = frozenset(
|
|
|
54
54
|
}
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
SCHEMA_VERSION = "2.
|
|
57
|
+
SCHEMA_VERSION = "2.3"
|
|
58
58
|
NOT_AVAILABLE = "not_available"
|
|
59
59
|
|
|
60
|
+
# Compiled once for evidence-text count fallbacks in _ledger_unit_tests / _ledger_api_tests.
|
|
61
|
+
_RE_UNIT_COUNTS = re.compile(
|
|
62
|
+
r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)"
|
|
63
|
+
)
|
|
64
|
+
_RE_API_PASSED = re.compile(r"(\d+)/(\d+)\s*passed", re.I)
|
|
65
|
+
|
|
60
66
|
# Ensure sibling harness_events is importable.
|
|
61
67
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
62
68
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
63
69
|
|
|
64
70
|
import harness_events as he # noqa: E402
|
|
71
|
+
import harness_ledger as hl # noqa: E402
|
|
72
|
+
import harness_paths as hp # noqa: E402
|
|
73
|
+
import harness_review as hr # noqa: E402
|
|
65
74
|
|
|
66
75
|
|
|
67
76
|
# ---------------------------------------------------------------------------
|
|
@@ -429,7 +438,11 @@ def worktree_requested(change_dir: Path) -> bool:
|
|
|
429
438
|
# ---------------------------------------------------------------------------
|
|
430
439
|
|
|
431
440
|
|
|
432
|
-
def check_status(
|
|
441
|
+
def check_status(
|
|
442
|
+
change_dir: Path,
|
|
443
|
+
*,
|
|
444
|
+
allow_missing_review: bool = False,
|
|
445
|
+
) -> dict[str, Any]:
|
|
433
446
|
"""Read-only archive preconditions. Never mutates."""
|
|
434
447
|
blockers: list[dict[str, str]] = []
|
|
435
448
|
warnings: list[dict[str, str]] = []
|
|
@@ -574,6 +587,10 @@ def check_status(change_dir: Path) -> dict[str, Any]:
|
|
|
574
587
|
}
|
|
575
588
|
)
|
|
576
589
|
|
|
590
|
+
gate_policy = load_gate_policy(change_dir)
|
|
591
|
+
risk_tier = str((gate_policy or {}).get("tier") or "unknown")
|
|
592
|
+
checks["riskTier"] = risk_tier
|
|
593
|
+
|
|
577
594
|
if review_reports:
|
|
578
595
|
checks["review_report_status"] = "present"
|
|
579
596
|
elif review_phase_completed(events):
|
|
@@ -589,12 +606,23 @@ def check_status(change_dir: Path) -> dict[str, Any]:
|
|
|
589
606
|
)
|
|
590
607
|
else:
|
|
591
608
|
checks["review_report_status"] = "not-run"
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
609
|
+
review_msg = {
|
|
610
|
+
"code": "review-not-run",
|
|
611
|
+
"message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
|
|
612
|
+
}
|
|
613
|
+
warnings.append(review_msg)
|
|
614
|
+
if risk_tier == "full" and not review_ran:
|
|
615
|
+
tier_issue = {
|
|
616
|
+
"code": "review-required-on-full-tier",
|
|
617
|
+
"message": (
|
|
618
|
+
"gate-policy tier=full requires review evidence; "
|
|
619
|
+
"pass --allow-missing-review to override"
|
|
620
|
+
),
|
|
596
621
|
}
|
|
597
|
-
|
|
622
|
+
if allow_missing_review:
|
|
623
|
+
warnings.append(tier_issue)
|
|
624
|
+
else:
|
|
625
|
+
blockers.append(tier_issue)
|
|
598
626
|
|
|
599
627
|
archivable = len(blockers) == 0
|
|
600
628
|
return {
|
|
@@ -637,33 +665,87 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
637
665
|
if not ledger:
|
|
638
666
|
return empty
|
|
639
667
|
validations = ledger.get("validations") or ledger.get("verification") or {}
|
|
640
|
-
unit =
|
|
668
|
+
unit = (
|
|
669
|
+
validations.get("unitTestFull")
|
|
670
|
+
or validations.get("unitTest")
|
|
671
|
+
or validations.get("unitTests")
|
|
672
|
+
or {}
|
|
673
|
+
)
|
|
641
674
|
if not isinstance(unit, dict):
|
|
642
675
|
return empty
|
|
643
676
|
status = str(unit.get("status") or "").upper()
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
677
|
+
|
|
678
|
+
run = failures = errors = skipped = 0
|
|
679
|
+
pass_rate: Any = None
|
|
680
|
+
source = "committed"
|
|
681
|
+
counted = False
|
|
682
|
+
|
|
683
|
+
metrics = unit.get("metrics")
|
|
684
|
+
if isinstance(metrics, dict) and any(
|
|
685
|
+
k in metrics for k in ("run", "testsRun", "total", "failures", "errors", "skipped")
|
|
686
|
+
):
|
|
687
|
+
if "total" in metrics:
|
|
688
|
+
# ledger v3 typed metrics (UT-005/RET-15): total/passed/failed.
|
|
689
|
+
run = int(metrics.get("total", 0) or 0)
|
|
690
|
+
failures = int(metrics.get("failed", 0) or 0)
|
|
691
|
+
errors = int(metrics.get("errors", 0) or 0)
|
|
692
|
+
skipped = int(metrics.get("skipped", 0) or 0)
|
|
693
|
+
else:
|
|
694
|
+
run = int(metrics.get("run", metrics.get("testsRun", 0)) or 0)
|
|
695
|
+
failures = int(metrics.get("failures", 0) or 0)
|
|
696
|
+
errors = int(metrics.get("errors", 0) or 0)
|
|
697
|
+
skipped = int(metrics.get("skipped", 0) or 0)
|
|
698
|
+
pass_rate = metrics.get("passRate")
|
|
699
|
+
source = "committed"
|
|
700
|
+
counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
|
|
701
|
+
|
|
702
|
+
evidence = unit.get("evidence")
|
|
703
|
+
if not counted and isinstance(evidence, dict):
|
|
704
|
+
run = int(
|
|
705
|
+
evidence.get(
|
|
706
|
+
"run",
|
|
707
|
+
evidence.get("testsRun", unit.get("run", unit.get("testsRun", 0))),
|
|
708
|
+
)
|
|
709
|
+
or 0
|
|
649
710
|
)
|
|
650
|
-
failures = evidence.get("failures", unit.get("failures", 0))
|
|
651
|
-
errors = evidence.get("errors", unit.get("errors", 0))
|
|
652
|
-
skipped = evidence.get("skipped", unit.get("skipped", 0))
|
|
711
|
+
failures = int(evidence.get("failures", unit.get("failures", 0)) or 0)
|
|
712
|
+
errors = int(evidence.get("errors", unit.get("errors", 0)) or 0)
|
|
713
|
+
skipped = int(evidence.get("skipped", unit.get("skipped", 0)) or 0)
|
|
653
714
|
pass_rate = evidence.get("passRate") or unit.get("passRate")
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
715
|
+
source = "committed"
|
|
716
|
+
counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
|
|
717
|
+
|
|
718
|
+
evidence_text = evidence if isinstance(evidence, str) else ""
|
|
719
|
+
if not counted and evidence_text:
|
|
720
|
+
matches = list(_RE_UNIT_COUNTS.finditer(evidence_text))
|
|
721
|
+
if matches:
|
|
722
|
+
m = matches[-1]
|
|
723
|
+
run = int(m.group(1))
|
|
724
|
+
failures = int(m.group(2))
|
|
725
|
+
errors = int(m.group(3))
|
|
726
|
+
skipped = int(m.group(4))
|
|
727
|
+
source = "evidence-text"
|
|
728
|
+
counted = True
|
|
729
|
+
|
|
730
|
+
if not counted:
|
|
731
|
+
run = int(unit.get("run", unit.get("testsRun", 0)) or 0)
|
|
732
|
+
failures = int(unit.get("failures", 0) or 0)
|
|
733
|
+
errors = int(unit.get("errors", 0) or 0)
|
|
734
|
+
skipped = int(unit.get("skipped", 0) or 0)
|
|
659
735
|
pass_rate = unit.get("passRate")
|
|
736
|
+
source = "committed"
|
|
660
737
|
|
|
661
|
-
source = "committed"
|
|
662
738
|
if status in {"NOT_RUN", "SKIPPED", "USER_SKIPPED"}:
|
|
663
739
|
source = "not-run"
|
|
664
740
|
elif str(unit.get("reused") or "").lower() in {"true", "1"} or "REUSED" in status:
|
|
665
741
|
source = "committed"
|
|
666
742
|
|
|
743
|
+
if pass_rate is None and run > 0:
|
|
744
|
+
passed = max(run - failures - errors, 0)
|
|
745
|
+
pass_rate = f"{passed / run:.0%}"
|
|
746
|
+
elif pass_rate is None:
|
|
747
|
+
pass_rate = NOT_AVAILABLE
|
|
748
|
+
|
|
667
749
|
result = {
|
|
668
750
|
"run": int(run or 0),
|
|
669
751
|
"failures": int(failures or 0),
|
|
@@ -677,7 +759,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
677
759
|
return result
|
|
678
760
|
|
|
679
761
|
|
|
680
|
-
def _ledger_api_tests(
|
|
762
|
+
def _ledger_api_tests(
|
|
763
|
+
ledger: dict[str, Any] | None,
|
|
764
|
+
*,
|
|
765
|
+
change_dir: Path | None = None,
|
|
766
|
+
) -> dict[str, Any]:
|
|
681
767
|
empty = {
|
|
682
768
|
"status": "NOT_RUN",
|
|
683
769
|
"total": 0,
|
|
@@ -685,6 +771,7 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
685
771
|
"failed": 0,
|
|
686
772
|
"blocked": 0,
|
|
687
773
|
"passRate": NOT_AVAILABLE,
|
|
774
|
+
"source": "not-run",
|
|
688
775
|
}
|
|
689
776
|
if not ledger:
|
|
690
777
|
return empty
|
|
@@ -697,12 +784,76 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
697
784
|
status = "OK"
|
|
698
785
|
elif status in {"SKIP", "SKIPPED"}:
|
|
699
786
|
status = "USER_SKIPPED"
|
|
700
|
-
|
|
701
|
-
total =
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
787
|
+
|
|
788
|
+
total = passed = failed = blocked = 0
|
|
789
|
+
pass_rate: Any = None
|
|
790
|
+
source = "committed"
|
|
791
|
+
counted = False
|
|
792
|
+
|
|
793
|
+
metrics = api.get("metrics")
|
|
794
|
+
if isinstance(metrics, dict) and any(
|
|
795
|
+
k in metrics for k in ("total", "passed", "failed", "blocked")
|
|
796
|
+
):
|
|
797
|
+
total = int(metrics.get("total", 0) or 0)
|
|
798
|
+
passed = int(metrics.get("passed", 0) or 0)
|
|
799
|
+
failed = int(metrics.get("failed", 0) or 0)
|
|
800
|
+
blocked = int(metrics.get("blocked", 0) or 0)
|
|
801
|
+
pass_rate = metrics.get("passRate")
|
|
802
|
+
source = "committed"
|
|
803
|
+
counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
|
|
804
|
+
|
|
805
|
+
evidence_raw = api.get("evidence")
|
|
806
|
+
if not counted and isinstance(evidence_raw, dict):
|
|
807
|
+
total = int(evidence_raw.get("total", api.get("total", 0)) or 0)
|
|
808
|
+
passed = int(evidence_raw.get("passed", api.get("passed", 0)) or 0)
|
|
809
|
+
failed = int(evidence_raw.get("failed", api.get("failed", 0)) or 0)
|
|
810
|
+
blocked = int(evidence_raw.get("blocked", api.get("blocked", 0)) or 0)
|
|
811
|
+
pass_rate = evidence_raw.get("passRate") or api.get("passRate")
|
|
812
|
+
source = "committed"
|
|
813
|
+
counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
|
|
814
|
+
|
|
815
|
+
evidence_text = evidence_raw if isinstance(evidence_raw, str) else ""
|
|
816
|
+
if not counted and evidence_text:
|
|
817
|
+
matches = list(_RE_API_PASSED.finditer(evidence_text))
|
|
818
|
+
if matches:
|
|
819
|
+
m = matches[-1]
|
|
820
|
+
passed = int(m.group(1))
|
|
821
|
+
total = int(m.group(2))
|
|
822
|
+
failed = max(total - passed, 0)
|
|
823
|
+
blocked = 0
|
|
824
|
+
source = "evidence-text"
|
|
825
|
+
counted = True
|
|
826
|
+
|
|
827
|
+
if not counted and change_dir is not None:
|
|
828
|
+
results_path = change_dir / "runtime" / "api-test-results.json"
|
|
829
|
+
if results_path.is_file():
|
|
830
|
+
try:
|
|
831
|
+
raw = read_json(results_path)
|
|
832
|
+
except (OSError, json.JSONDecodeError):
|
|
833
|
+
raw = None
|
|
834
|
+
if isinstance(raw, dict) and all(
|
|
835
|
+
isinstance(raw.get(k), int) for k in ("total", "passed", "failed", "blocked")
|
|
836
|
+
):
|
|
837
|
+
total = int(raw["total"])
|
|
838
|
+
passed = int(raw["passed"])
|
|
839
|
+
failed = int(raw["failed"])
|
|
840
|
+
blocked = int(raw["blocked"])
|
|
841
|
+
source = "api-test-results"
|
|
842
|
+
counted = True
|
|
843
|
+
|
|
844
|
+
if not counted:
|
|
845
|
+
total = int(api.get("total", 0) or 0)
|
|
846
|
+
passed = int(api.get("passed", 0) or 0)
|
|
847
|
+
failed = int(api.get("failed", 0) or 0)
|
|
848
|
+
blocked = int(api.get("blocked", 0) or 0)
|
|
849
|
+
pass_rate = api.get("passRate")
|
|
850
|
+
source = "committed"
|
|
851
|
+
|
|
852
|
+
if pass_rate is None and total > 0:
|
|
853
|
+
pass_rate = f"{passed / total:.0%}"
|
|
854
|
+
elif pass_rate is None:
|
|
855
|
+
pass_rate = NOT_AVAILABLE
|
|
856
|
+
|
|
706
857
|
return {
|
|
707
858
|
"status": status,
|
|
708
859
|
"total": int(total or 0),
|
|
@@ -710,9 +861,57 @@ def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
710
861
|
"failed": int(failed or 0),
|
|
711
862
|
"blocked": int(blocked or 0),
|
|
712
863
|
"passRate": pass_rate if pass_rate is not None else NOT_AVAILABLE,
|
|
864
|
+
"source": source,
|
|
713
865
|
}
|
|
714
866
|
|
|
715
867
|
|
|
868
|
+
def _risks_from_test_results(change_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
869
|
+
"""PARTIAL/OPEN/UNKNOWN/DEFERRED scenarios -> (knownRisks, manualActions).
|
|
870
|
+
|
|
871
|
+
Scenario IDs are preserved so risks stay traceable to the test report
|
|
872
|
+
(UT-013/RET-25); PARTIAL is a fact, not a pass.
|
|
873
|
+
"""
|
|
874
|
+
risk_statuses = {"PARTIAL", "OPEN", "UNKNOWN", "DEFERRED", "BLOCKED"}
|
|
875
|
+
results_path = change_dir / "runtime" / "api-test-results.json"
|
|
876
|
+
if not results_path.is_file():
|
|
877
|
+
return [], []
|
|
878
|
+
try:
|
|
879
|
+
raw = read_json(results_path)
|
|
880
|
+
except (OSError, json.JSONDecodeError):
|
|
881
|
+
return [], []
|
|
882
|
+
scenarios = raw.get("scenarios") if isinstance(raw, dict) else None
|
|
883
|
+
if not isinstance(scenarios, list):
|
|
884
|
+
return [], []
|
|
885
|
+
risks: list[dict[str, Any]] = []
|
|
886
|
+
actions: list[dict[str, Any]] = []
|
|
887
|
+
for item in scenarios:
|
|
888
|
+
if not isinstance(item, dict):
|
|
889
|
+
continue
|
|
890
|
+
status = str(item.get("status") or "").upper()
|
|
891
|
+
if status not in risk_statuses:
|
|
892
|
+
continue
|
|
893
|
+
scenario_id = str(item.get("id") or item.get("scenario") or "").strip()
|
|
894
|
+
note = str(item.get("note") or item.get("message") or "").strip()
|
|
895
|
+
risks.append(
|
|
896
|
+
{
|
|
897
|
+
"phase": "test",
|
|
898
|
+
"severity": "medium",
|
|
899
|
+
"scenarioId": scenario_id,
|
|
900
|
+
"status": status,
|
|
901
|
+
"message": f"{scenario_id}: {status}" + (f" — {note}" if note else ""),
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
actions.append(
|
|
905
|
+
{
|
|
906
|
+
"stage": "test",
|
|
907
|
+
"status": status,
|
|
908
|
+
"scenarioId": scenario_id,
|
|
909
|
+
"action": "补齐该场景的完整验证或显式接受风险",
|
|
910
|
+
}
|
|
911
|
+
)
|
|
912
|
+
return risks, actions
|
|
913
|
+
|
|
914
|
+
|
|
716
915
|
def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
|
|
717
916
|
if not ledger:
|
|
718
917
|
return "NOT_RUN"
|
|
@@ -728,6 +927,58 @@ def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
|
|
|
728
927
|
return "NOT_RUN"
|
|
729
928
|
|
|
730
929
|
|
|
930
|
+
def _typed_test_metrics(entry: dict[str, Any], *, total_key: str) -> dict[str, Any]:
|
|
931
|
+
"""Project a ledger v3 typed metrics entry to the canonical view."""
|
|
932
|
+
metrics = entry.get("metrics") if isinstance(entry.get("metrics"), dict) else {}
|
|
933
|
+
total = int(metrics.get(total_key, 0) or 0)
|
|
934
|
+
passed = int(metrics.get("passed", 0) or 0)
|
|
935
|
+
failed = int(metrics.get("failed", 0) or 0)
|
|
936
|
+
status = str(entry.get("status") or "").upper() or "NOT_RUN"
|
|
937
|
+
out: dict[str, Any] = {
|
|
938
|
+
"status": status,
|
|
939
|
+
"total": total,
|
|
940
|
+
"passed": passed,
|
|
941
|
+
"failed": failed,
|
|
942
|
+
"passRate": f"{passed / total:.0%}" if total > 0 else NOT_AVAILABLE,
|
|
943
|
+
"source": "committed" if total > 0 else "not-run",
|
|
944
|
+
}
|
|
945
|
+
if "blocked" in metrics:
|
|
946
|
+
out["blocked"] = int(metrics.get("blocked", 0) or 0)
|
|
947
|
+
if "skipped" in metrics:
|
|
948
|
+
out["skipped"] = int(metrics.get("skipped", 0) or 0)
|
|
949
|
+
applicability = entry.get("applicability")
|
|
950
|
+
if isinstance(applicability, dict):
|
|
951
|
+
out["applicability"] = applicability
|
|
952
|
+
return out
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def build_verification_projection(
|
|
956
|
+
ledger: dict[str, Any] | None,
|
|
957
|
+
*,
|
|
958
|
+
change_dir: Path | None = None,
|
|
959
|
+
) -> dict[str, Any]:
|
|
960
|
+
"""Canonical verification view shared by collector and validators (RET-16).
|
|
961
|
+
|
|
962
|
+
apiContract and browserE2E are distinct typed projections; the legacy
|
|
963
|
+
apiTests mapping is retained for schema compatibility.
|
|
964
|
+
"""
|
|
965
|
+
validations = (ledger or {}).get("validations") or {}
|
|
966
|
+
projection: dict[str, Any] = {
|
|
967
|
+
"unitTests": _ledger_unit_tests(ledger),
|
|
968
|
+
"apiTests": _ledger_api_tests(ledger, change_dir=change_dir),
|
|
969
|
+
"dbCompatibility": _ledger_db_compat(ledger),
|
|
970
|
+
}
|
|
971
|
+
api_contract = validations.get("apiContract")
|
|
972
|
+
if isinstance(api_contract, dict):
|
|
973
|
+
projection["apiContract"] = _typed_test_metrics(
|
|
974
|
+
api_contract, total_key="scenariosTotal"
|
|
975
|
+
)
|
|
976
|
+
browser_e2e = validations.get("browserE2E")
|
|
977
|
+
if isinstance(browser_e2e, dict):
|
|
978
|
+
projection["browserE2E"] = _typed_test_metrics(browser_e2e, total_key="total")
|
|
979
|
+
return projection
|
|
980
|
+
|
|
981
|
+
|
|
731
982
|
def _parse_durations_from_log(log_text: str) -> dict[str, Any]:
|
|
732
983
|
"""Best-effort parse of harness-* sections from execution-log."""
|
|
733
984
|
stages: list[dict[str, Any]] = []
|
|
@@ -902,7 +1153,15 @@ def _artifacts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|
|
902
1153
|
return out
|
|
903
1154
|
|
|
904
1155
|
|
|
905
|
-
def _durations_from_event_phases(
|
|
1156
|
+
def _durations_from_event_phases(
|
|
1157
|
+
event_summary: dict[str, Any],
|
|
1158
|
+
events: list[dict[str, Any]] | None = None,
|
|
1159
|
+
) -> dict[str, Any]:
|
|
1160
|
+
# Canonical per-phase timing (UT-008/RET-20): one reducer feeds every view.
|
|
1161
|
+
canonical: dict[str, dict[str, Any]] = {}
|
|
1162
|
+
if events:
|
|
1163
|
+
for phase_name, phase_events in he.group_events_by_phase(events):
|
|
1164
|
+
canonical[phase_name] = he.canonical_phase_timing(phase_events)
|
|
906
1165
|
stages: list[dict[str, Any]] = []
|
|
907
1166
|
total_ms = 0
|
|
908
1167
|
for name, info in (event_summary.get("phases") or {}).items():
|
|
@@ -916,7 +1175,7 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
|
|
|
916
1175
|
result = "OK"
|
|
917
1176
|
elif result in {"FAILED", "ERROR"}:
|
|
918
1177
|
result = "FAIL"
|
|
919
|
-
|
|
1178
|
+
stage: dict[str, Any] = {
|
|
920
1179
|
"stage": str(name),
|
|
921
1180
|
"skill": f"harness-{name}",
|
|
922
1181
|
"startedAt": info.get("started_at") or NOT_AVAILABLE,
|
|
@@ -924,7 +1183,13 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
|
|
|
924
1183
|
"minutes": minutes,
|
|
925
1184
|
"result": result,
|
|
926
1185
|
"attempts": attempts,
|
|
927
|
-
}
|
|
1186
|
+
}
|
|
1187
|
+
timing = canonical.get(name)
|
|
1188
|
+
if timing:
|
|
1189
|
+
stage["activeExecutionMs"] = timing.get("activeExecutionMs")
|
|
1190
|
+
stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
|
|
1191
|
+
stage["lateEventCount"] = timing.get("lateEventCount")
|
|
1192
|
+
stages.append(stage)
|
|
928
1193
|
total_min = round(total_ms / 60000, 2)
|
|
929
1194
|
return {
|
|
930
1195
|
"totalLabel": f"约 {int(round(total_min))} 分",
|
|
@@ -977,7 +1242,7 @@ def _stage_status_from_sources(
|
|
|
977
1242
|
elif phase in status and sev in {"warn", "warning"} and status[phase] == "OK":
|
|
978
1243
|
status[phase] = "WARN"
|
|
979
1244
|
|
|
980
|
-
api = _ledger_api_tests(ledger)
|
|
1245
|
+
api = _ledger_api_tests(ledger, change_dir=change_dir)
|
|
981
1246
|
db = _ledger_db_compat(ledger)
|
|
982
1247
|
api_status = str(api.get("status") or "").upper()
|
|
983
1248
|
if api_status in {"FAIL", "FAILED", "ERROR"} or int(api.get("failed") or 0) > 0:
|
|
@@ -1002,28 +1267,178 @@ def _stage_status_from_sources(
|
|
|
1002
1267
|
def _compute_final_status(
|
|
1003
1268
|
stage_status: dict[str, str],
|
|
1004
1269
|
verification: dict[str, Any],
|
|
1005
|
-
) -> str:
|
|
1270
|
+
) -> tuple[str, list[str]]:
|
|
1006
1271
|
api = verification.get("apiTests") or {}
|
|
1007
1272
|
db = str(verification.get("dbCompatibility") or "")
|
|
1008
1273
|
api_status = str(api.get("status") or "")
|
|
1009
|
-
|
|
1274
|
+
reasons: list[str] = []
|
|
1275
|
+
for phase, v in stage_status.items():
|
|
1010
1276
|
if v == "FAIL":
|
|
1011
|
-
return "FAIL"
|
|
1277
|
+
return "FAIL", [f"stage {phase}=FAIL"]
|
|
1012
1278
|
unit = verification.get("unitTests") or {}
|
|
1013
|
-
if int(unit.get("failures") or 0) > 0
|
|
1014
|
-
return "FAIL"
|
|
1279
|
+
if int(unit.get("failures") or 0) > 0:
|
|
1280
|
+
return "FAIL", [f"unitTests.failures={unit.get('failures')}"]
|
|
1281
|
+
if int(unit.get("errors") or 0) > 0:
|
|
1282
|
+
return "FAIL", [f"unitTests.errors={unit.get('errors')}"]
|
|
1015
1283
|
if int(api.get("failed") or 0) > 0:
|
|
1016
|
-
return "FAIL"
|
|
1284
|
+
return "FAIL", [f"apiTests.failed={api.get('failed')}"]
|
|
1017
1285
|
conditional = {
|
|
1018
1286
|
"USER_SKIPPED", "BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA",
|
|
1019
1287
|
"NOT_RUN", "PARTIAL",
|
|
1020
1288
|
}
|
|
1021
|
-
if api_status in conditional
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1289
|
+
if api_status in conditional:
|
|
1290
|
+
reasons.append(f"apiTests.status={api_status}")
|
|
1291
|
+
if db in conditional:
|
|
1292
|
+
reasons.append(f"dbCompatibility={db}")
|
|
1293
|
+
if reasons:
|
|
1294
|
+
return "CONDITIONAL_OK", reasons
|
|
1295
|
+
for phase, v in stage_status.items():
|
|
1296
|
+
if v == "WARN":
|
|
1297
|
+
return "WARN", [f"stage {phase}=WARN"]
|
|
1298
|
+
if v in conditional:
|
|
1299
|
+
return "CONDITIONAL_OK", [f"stage {phase}={v}"]
|
|
1300
|
+
return "OK", []
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def load_gate_policy(change_dir: Path) -> dict[str, Any] | None:
|
|
1304
|
+
path = change_dir / "meta" / "gate-policy.json"
|
|
1305
|
+
if not path.is_file():
|
|
1306
|
+
return None
|
|
1307
|
+
try:
|
|
1308
|
+
data = read_json(path)
|
|
1309
|
+
except (OSError, json.JSONDecodeError):
|
|
1310
|
+
return None
|
|
1311
|
+
if not isinstance(data, dict) or data.get("schemaVersion") != 1:
|
|
1312
|
+
return None
|
|
1313
|
+
return data
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
_RISK_SEVERITIES = frozenset({"warning", "error", "critical"})
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _cleanup_transients(work_dir: Path) -> dict[str, Any]:
|
|
1320
|
+
"""Delete lock/pid/launcher/credential files; truncate oversized logs."""
|
|
1321
|
+
deleted: list[str] = []
|
|
1322
|
+
truncated: list[dict[str, Any]] = []
|
|
1323
|
+
|
|
1324
|
+
def _rel(path: Path) -> str:
|
|
1325
|
+
try:
|
|
1326
|
+
return path.relative_to(work_dir).as_posix()
|
|
1327
|
+
except ValueError:
|
|
1328
|
+
return str(path)
|
|
1329
|
+
|
|
1330
|
+
lock = work_dir / "events.ndjson.lock"
|
|
1331
|
+
if lock.is_file():
|
|
1332
|
+
try:
|
|
1333
|
+
lock.unlink()
|
|
1334
|
+
deleted.append(_rel(lock))
|
|
1335
|
+
except OSError:
|
|
1336
|
+
pass
|
|
1337
|
+
|
|
1338
|
+
runtime = work_dir / "runtime"
|
|
1339
|
+
if runtime.is_dir():
|
|
1340
|
+
for path in sorted(runtime.iterdir()):
|
|
1341
|
+
if not path.is_file():
|
|
1342
|
+
continue
|
|
1343
|
+
name = path.name
|
|
1344
|
+
drop = False
|
|
1345
|
+
if name.endswith(".pid"):
|
|
1346
|
+
drop = True
|
|
1347
|
+
elif name in {
|
|
1348
|
+
"_harness_service_launcher.py",
|
|
1349
|
+
"_harness_service.command.txt",
|
|
1350
|
+
}:
|
|
1351
|
+
drop = True
|
|
1352
|
+
elif re.search(r"credential|token|secret", name, re.I):
|
|
1353
|
+
drop = True
|
|
1354
|
+
if drop:
|
|
1355
|
+
try:
|
|
1356
|
+
path.unlink()
|
|
1357
|
+
deleted.append(_rel(path))
|
|
1358
|
+
except OSError:
|
|
1359
|
+
pass
|
|
1360
|
+
|
|
1361
|
+
logs_root = work_dir / "logs"
|
|
1362
|
+
if logs_root.is_dir():
|
|
1363
|
+
for path in sorted(logs_root.rglob("*.log")):
|
|
1364
|
+
if not path.is_file():
|
|
1365
|
+
continue
|
|
1366
|
+
try:
|
|
1367
|
+
size = path.stat().st_size
|
|
1368
|
+
except OSError:
|
|
1369
|
+
continue
|
|
1370
|
+
if size <= 65536:
|
|
1371
|
+
continue
|
|
1372
|
+
try:
|
|
1373
|
+
with path.open("rb") as handle:
|
|
1374
|
+
handle.seek(-65536, 2)
|
|
1375
|
+
tail = handle.read()
|
|
1376
|
+
header = (
|
|
1377
|
+
f"# [truncated by harness-archive finalize: original {size} bytes]\n"
|
|
1378
|
+
).encode("utf-8")
|
|
1379
|
+
path.write_bytes(header + tail)
|
|
1380
|
+
truncated.append({"path": _rel(path), "originalBytes": size})
|
|
1381
|
+
except OSError:
|
|
1382
|
+
pass
|
|
1383
|
+
|
|
1384
|
+
return {"deleted": deleted, "truncated": truncated}
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
|
|
1388
|
+
"""Generate meta/archive-meta.md from summary-data (single ownership)."""
|
|
1389
|
+
archive_id = work_dir.name
|
|
1390
|
+
change_name = str(summary.get("changeName") or work_dir.name)
|
|
1391
|
+
archived_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
1392
|
+
final_status = str(summary.get("finalStatus") or "UNKNOWN")
|
|
1393
|
+
lines = [
|
|
1394
|
+
"---",
|
|
1395
|
+
f"archive-id: {archive_id}",
|
|
1396
|
+
f"change-name: {change_name}",
|
|
1397
|
+
f"archived-at: {archived_at}",
|
|
1398
|
+
f"final-commit: {summary.get('finalCommit') or ''}",
|
|
1399
|
+
f"base-commit: {summary.get('baseCommit') or ''}",
|
|
1400
|
+
f"final-status: {final_status}",
|
|
1401
|
+
"source: harness-archive",
|
|
1402
|
+
"---",
|
|
1403
|
+
f"# 归档元数据 — {change_name}",
|
|
1404
|
+
"",
|
|
1405
|
+
"## 阶段状态",
|
|
1406
|
+
"",
|
|
1407
|
+
"| 阶段 | 状态 |",
|
|
1408
|
+
"|---|---|",
|
|
1409
|
+
]
|
|
1410
|
+
for stage, status in (summary.get("stageStatus") or {}).items():
|
|
1411
|
+
lines.append(f"| {stage} | {status} |")
|
|
1412
|
+
lines.extend(["", "## 变更文件", "", "| 路径 | + | - |", "|---|---|---|"])
|
|
1413
|
+
changed = summary.get("changedFiles") or []
|
|
1414
|
+
if changed:
|
|
1415
|
+
for item in changed:
|
|
1416
|
+
lines.append(
|
|
1417
|
+
f"| {item.get('path') or ''} | "
|
|
1418
|
+
f"{item.get('insertions', 0)} | {item.get('deletions', 0)} |"
|
|
1419
|
+
)
|
|
1420
|
+
else:
|
|
1421
|
+
lines.append("| (无) | | |")
|
|
1422
|
+
lines.extend(["", "## 已知风险", ""])
|
|
1423
|
+
risks = summary.get("knownRisks") or []
|
|
1424
|
+
if risks:
|
|
1425
|
+
for risk in risks:
|
|
1426
|
+
if isinstance(risk, dict):
|
|
1427
|
+
lines.append(
|
|
1428
|
+
f"- [{risk.get('severity') or 'unknown'}] "
|
|
1429
|
+
f"{risk.get('message') or risk}"
|
|
1430
|
+
)
|
|
1431
|
+
else:
|
|
1432
|
+
lines.append(f"- {risk}")
|
|
1433
|
+
else:
|
|
1434
|
+
lines.append("无")
|
|
1435
|
+
lines.append("")
|
|
1436
|
+
out = work_dir / "meta" / "archive-meta.md"
|
|
1437
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1438
|
+
out.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
|
1439
|
+
return out
|
|
1440
|
+
|
|
1441
|
+
|
|
1027
1442
|
|
|
1028
1443
|
|
|
1029
1444
|
def _changed_files_from_git(
|
|
@@ -1091,6 +1506,37 @@ def _review_summary(
|
|
|
1091
1506
|
"yellowDeferred": 0,
|
|
1092
1507
|
"summary": "",
|
|
1093
1508
|
}
|
|
1509
|
+
sidecar = hr.findings_path(change_dir)
|
|
1510
|
+
if sidecar.is_file():
|
|
1511
|
+
status = hr.status(change_dir)
|
|
1512
|
+
items = status.get("items") or []
|
|
1513
|
+
red_items = [item for item in items if item.get("severity") == "RED"]
|
|
1514
|
+
yellow_items = [item for item in items if item.get("severity") == "YELLOW"]
|
|
1515
|
+
base.update(
|
|
1516
|
+
{
|
|
1517
|
+
"status": "ADVISORY",
|
|
1518
|
+
"red": len(red_items),
|
|
1519
|
+
"yellow": len(yellow_items),
|
|
1520
|
+
"redFixed": sum(
|
|
1521
|
+
1 for item in red_items if item.get("disposition") == "FIXED"
|
|
1522
|
+
),
|
|
1523
|
+
"redConfirmed": sum(
|
|
1524
|
+
1
|
|
1525
|
+
for item in red_items
|
|
1526
|
+
if item.get("disposition") in {"OPEN", "ACCEPTED_RISK", "DEFERRED"}
|
|
1527
|
+
),
|
|
1528
|
+
"yellowFixed": sum(
|
|
1529
|
+
1 for item in yellow_items if item.get("disposition") == "FIXED"
|
|
1530
|
+
),
|
|
1531
|
+
"yellowDeferred": sum(
|
|
1532
|
+
1
|
|
1533
|
+
for item in yellow_items
|
|
1534
|
+
if item.get("disposition") in {"DEFERRED", "ACCEPTED_RISK"}
|
|
1535
|
+
),
|
|
1536
|
+
"summary": f"structured review run {status.get('runId') or 'unknown'}",
|
|
1537
|
+
}
|
|
1538
|
+
)
|
|
1539
|
+
return base
|
|
1094
1540
|
if existing and isinstance(existing.get("reviewSummary"), dict):
|
|
1095
1541
|
merged = dict(base)
|
|
1096
1542
|
merged.update(existing["reviewSummary"])
|
|
@@ -1148,6 +1594,24 @@ def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]])
|
|
|
1148
1594
|
scope = re.search(r"(?im)^\s*>?\s*(?:变更范围|目标)\s*[::]\s*(.+)$", body)
|
|
1149
1595
|
if scope:
|
|
1150
1596
|
return scope.group(1).strip()
|
|
1597
|
+
# UT-015/RET-27: structured "## 目标" section body wins over the first
|
|
1598
|
+
# task-table row — the task row is an activity, not the objective.
|
|
1599
|
+
section = re.search(
|
|
1600
|
+
r"(?im)^#{1,4}\s*(?:\d+[\.、]\s*)?(?:目标|业务目标|需求背景)\s*$",
|
|
1601
|
+
body,
|
|
1602
|
+
)
|
|
1603
|
+
if section:
|
|
1604
|
+
lines: list[str] = []
|
|
1605
|
+
for line in body[section.end():].splitlines():
|
|
1606
|
+
if re.match(r"^\s*#{1,4}\s", line):
|
|
1607
|
+
break
|
|
1608
|
+
clean = line.strip()
|
|
1609
|
+
if clean and not clean.startswith(("|", ">", "---")):
|
|
1610
|
+
lines.append(clean)
|
|
1611
|
+
if lines:
|
|
1612
|
+
break
|
|
1613
|
+
if lines:
|
|
1614
|
+
return lines[0]
|
|
1151
1615
|
first_task = re.search(r"(?m)^\s*\|\s*1\s*\|\s*([^|]+?)\s*\|", body)
|
|
1152
1616
|
if first_task:
|
|
1153
1617
|
return first_task.group(1).strip()
|
|
@@ -1297,9 +1761,10 @@ def collect_summary_data(
|
|
|
1297
1761
|
|
|
1298
1762
|
# verification
|
|
1299
1763
|
if not for_replay or not isinstance(data.get("verification"), dict):
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1764
|
+
projection = build_verification_projection(ledger, change_dir=change_dir)
|
|
1765
|
+
unit = projection["unitTests"]
|
|
1766
|
+
api = projection["apiTests"]
|
|
1767
|
+
db = projection["dbCompatibility"]
|
|
1303
1768
|
if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
|
|
1304
1769
|
if "status" not in unit:
|
|
1305
1770
|
unit["status"] = "NOT_RUN"
|
|
@@ -1314,6 +1779,9 @@ def collect_summary_data(
|
|
|
1314
1779
|
"dbCompatibility": db,
|
|
1315
1780
|
"coverageDisplay": coverage,
|
|
1316
1781
|
}
|
|
1782
|
+
for typed_key in ("apiContract", "browserE2E"):
|
|
1783
|
+
if typed_key in projection:
|
|
1784
|
+
data["verification"][typed_key] = projection[typed_key]
|
|
1317
1785
|
else:
|
|
1318
1786
|
# Ensure nested keys exist for old schema 2.1
|
|
1319
1787
|
ver = data.setdefault("verification", {})
|
|
@@ -1326,14 +1794,21 @@ def collect_summary_data(
|
|
|
1326
1794
|
if not for_replay or not isinstance(data.get("stageStatus"), dict):
|
|
1327
1795
|
data["stageStatus"] = _stage_status_from_sources(events, ledger, change_dir)
|
|
1328
1796
|
if not for_replay or not data.get("finalStatus") or str(data.get("finalStatus")).startswith("OK |"):
|
|
1329
|
-
|
|
1797
|
+
final_status, final_reasons = _compute_final_status(
|
|
1330
1798
|
data.get("stageStatus") or {},
|
|
1331
1799
|
data.get("verification") or {},
|
|
1332
1800
|
)
|
|
1801
|
+
data["finalStatus"] = final_status
|
|
1802
|
+
data["finalStatusReasons"] = list(final_reasons)
|
|
1803
|
+
else:
|
|
1804
|
+
data.setdefault("finalStatusReasons", [])
|
|
1805
|
+
|
|
1806
|
+
gate_policy = load_gate_policy(change_dir)
|
|
1807
|
+
data["riskTier"] = str((gate_policy or {}).get("tier") or "unknown")
|
|
1333
1808
|
|
|
1334
1809
|
# durations / skillCalls
|
|
1335
1810
|
if events:
|
|
1336
|
-
data["durations"] = _durations_from_event_phases(event_summary)
|
|
1811
|
+
data["durations"] = _durations_from_event_phases(event_summary, events)
|
|
1337
1812
|
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
1338
1813
|
elif log_text and (not for_replay or not data.get("durations")):
|
|
1339
1814
|
data["durations"] = _parse_durations_from_log(log_text)
|
|
@@ -1352,6 +1827,32 @@ def collect_summary_data(
|
|
|
1352
1827
|
head = data.get("finalCommit")
|
|
1353
1828
|
if base and head and base != NOT_AVAILABLE and head != NOT_AVAILABLE:
|
|
1354
1829
|
diff_stat, changed = _changed_files_from_git(project, str(base), str(head))
|
|
1830
|
+
ownership_projection: dict[str, Any] | None = None
|
|
1831
|
+
try:
|
|
1832
|
+
hp.load_change_contract(change_dir)
|
|
1833
|
+
ownership_projection = hl.compute_ownership_diff(
|
|
1834
|
+
project,
|
|
1835
|
+
base=str(base),
|
|
1836
|
+
head=str(head),
|
|
1837
|
+
change_dir=change_dir,
|
|
1838
|
+
)
|
|
1839
|
+
except (OSError, ValueError, RuntimeError):
|
|
1840
|
+
ownership_projection = None
|
|
1841
|
+
if ownership_projection is not None:
|
|
1842
|
+
allowed = set(ownership_projection.get("files") or [])
|
|
1843
|
+
changed = [item for item in changed if item.get("path") in allowed]
|
|
1844
|
+
diff_stat = {
|
|
1845
|
+
**diff_stat,
|
|
1846
|
+
"filesChanged": len(changed),
|
|
1847
|
+
"insertions": sum(int(item.get("insertions") or 0) for item in changed),
|
|
1848
|
+
"deletions": sum(int(item.get("deletions") or 0) for item in changed),
|
|
1849
|
+
}
|
|
1850
|
+
data["ownershipDiff"] = ownership_projection
|
|
1851
|
+
if write:
|
|
1852
|
+
write_json(
|
|
1853
|
+
change_dir / "evidence" / "ownership-diff.json",
|
|
1854
|
+
ownership_projection,
|
|
1855
|
+
)
|
|
1355
1856
|
if changed:
|
|
1356
1857
|
data["diffStat"] = diff_stat
|
|
1357
1858
|
data["changedFiles"] = changed
|
|
@@ -1378,6 +1879,17 @@ def collect_summary_data(
|
|
|
1378
1879
|
}
|
|
1379
1880
|
data["changedFiles"] = []
|
|
1380
1881
|
|
|
1882
|
+
data.setdefault(
|
|
1883
|
+
"ownershipDiff",
|
|
1884
|
+
{
|
|
1885
|
+
"files": [item.get("path") for item in data.get("changedFiles") or []],
|
|
1886
|
+
"staticEvidenceFiles": [],
|
|
1887
|
+
"foreignPaths": [],
|
|
1888
|
+
"excludedRuntimeCount": 0,
|
|
1889
|
+
"ownedFileCount": len(data.get("changedFiles") or []),
|
|
1890
|
+
},
|
|
1891
|
+
)
|
|
1892
|
+
|
|
1381
1893
|
# artifacts (build products stay empty unless already known; reportPipeline has event artifacts)
|
|
1382
1894
|
if not isinstance(data.get("artifacts"), list):
|
|
1383
1895
|
data["artifacts"] = []
|
|
@@ -1397,25 +1909,35 @@ def collect_summary_data(
|
|
|
1397
1909
|
|
|
1398
1910
|
# Derive risks/actions from evidence. These fields are facts, not model prose.
|
|
1399
1911
|
if not for_replay:
|
|
1400
|
-
|
|
1912
|
+
maintenance_notes: list[str] = [
|
|
1401
1913
|
str(event.get("note") or event.get("message") or "")
|
|
1402
1914
|
for event in events
|
|
1403
1915
|
if event.get("type") == "decision" and (event.get("note") or event.get("message"))
|
|
1404
1916
|
]
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1917
|
+
known_risks: list[dict[str, Any]] = []
|
|
1918
|
+
for event in events:
|
|
1919
|
+
if event.get("type") != "issue":
|
|
1920
|
+
continue
|
|
1921
|
+
if event.get("phase") == "archive" and event.get("code") == "missing-command":
|
|
1922
|
+
continue
|
|
1923
|
+
sev = str(event.get("severity") or "").strip().lower()
|
|
1924
|
+
message = event.get("message") or event.get("note") or event.get("code") or ""
|
|
1925
|
+
if sev in _RISK_SEVERITIES:
|
|
1926
|
+
known_risks.append(
|
|
1927
|
+
{
|
|
1928
|
+
"phase": event.get("phase"),
|
|
1929
|
+
"severity": sev,
|
|
1930
|
+
"message": message,
|
|
1931
|
+
}
|
|
1932
|
+
)
|
|
1933
|
+
else:
|
|
1934
|
+
note = str(message).strip()
|
|
1935
|
+
if note:
|
|
1936
|
+
maintenance_notes.append(note)
|
|
1937
|
+
data["maintenanceNotes"] = maintenance_notes
|
|
1938
|
+
scenario_risks, scenario_actions = _risks_from_test_results(change_dir)
|
|
1939
|
+
data["knownRisks"] = known_risks + scenario_risks
|
|
1940
|
+
data["manualActions"] = scenario_actions
|
|
1419
1941
|
for name, value in (data.get("stageStatus") or {}).items():
|
|
1420
1942
|
if value in {"BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA", "NOT_RUN", "USER_SKIPPED"}:
|
|
1421
1943
|
data["manualActions"].append({
|
|
@@ -1427,6 +1949,8 @@ def collect_summary_data(
|
|
|
1427
1949
|
data.setdefault("maintenanceNotes", [])
|
|
1428
1950
|
data.setdefault("knownRisks", [])
|
|
1429
1951
|
data.setdefault("manualActions", [])
|
|
1952
|
+
data.setdefault("finalStatusReasons", [])
|
|
1953
|
+
data.setdefault("riskTier", "unknown")
|
|
1430
1954
|
|
|
1431
1955
|
# archiveManifest
|
|
1432
1956
|
am = {
|
|
@@ -1521,7 +2045,13 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
1521
2045
|
f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
|
|
1522
2046
|
f'<p><strong>finalStatus</strong>: '
|
|
1523
2047
|
f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
|
|
2048
|
+
f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
|
|
1524
2049
|
]
|
|
2050
|
+
for reason in summary.get("finalStatusReasons") or []:
|
|
2051
|
+
parts.append(f"<li>{esc(reason)}</li>")
|
|
2052
|
+
parts.append("</ul></div>")
|
|
2053
|
+
if summary.get("riskTier"):
|
|
2054
|
+
parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
|
|
1525
2055
|
|
|
1526
2056
|
pipeline = summary.get("reportPipeline") or {}
|
|
1527
2057
|
cmds = pipeline.get("commands") or []
|
|
@@ -1681,6 +2211,288 @@ def render_final_summary(
|
|
|
1681
2211
|
# ---------------------------------------------------------------------------
|
|
1682
2212
|
|
|
1683
2213
|
|
|
2214
|
+
def _manifest_self_stats(
|
|
2215
|
+
work_dir: Path,
|
|
2216
|
+
manifest: dict[str, Any],
|
|
2217
|
+
manifest_path: Path,
|
|
2218
|
+
) -> dict[str, Any]:
|
|
2219
|
+
"""UT-011/RET-23: physical files vs manifest entries (self-exclusion)."""
|
|
2220
|
+
physical = sum(1 for path in work_dir.rglob("*") if path.is_file())
|
|
2221
|
+
entries = int(manifest.get("fileCount") or len(manifest.get("files") or []))
|
|
2222
|
+
rel_manifest = manifest_path.resolve().relative_to(work_dir.resolve()).as_posix()
|
|
2223
|
+
in_entries = any(
|
|
2224
|
+
str(item.get("path") or "").replace("\\", "/") == rel_manifest
|
|
2225
|
+
for item in manifest.get("files") or []
|
|
2226
|
+
if isinstance(item, dict)
|
|
2227
|
+
)
|
|
2228
|
+
coverage = round(entries / physical * 100, 2) if physical else 100.0
|
|
2229
|
+
return {
|
|
2230
|
+
"physicalFileCount": physical,
|
|
2231
|
+
"entryCount": entries,
|
|
2232
|
+
"selfExcluded": not in_entries,
|
|
2233
|
+
"coveragePercent": coverage,
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _append_finalize_failure_issue(
|
|
2238
|
+
original_change_dir: Path,
|
|
2239
|
+
code: str,
|
|
2240
|
+
message: str,
|
|
2241
|
+
) -> None:
|
|
2242
|
+
"""Record a finalize failure on the restored original as an issue event.
|
|
2243
|
+
|
|
2244
|
+
Never a second phase.end: the frozen cutoff already closed the archive
|
|
2245
|
+
phase exactly once (RET-19/RET-21).
|
|
2246
|
+
"""
|
|
2247
|
+
if not original_change_dir.is_dir():
|
|
2248
|
+
return
|
|
2249
|
+
try:
|
|
2250
|
+
append_event(
|
|
2251
|
+
original_change_dir,
|
|
2252
|
+
phase="archive",
|
|
2253
|
+
type_="issue",
|
|
2254
|
+
code=code,
|
|
2255
|
+
severity="error",
|
|
2256
|
+
message=message,
|
|
2257
|
+
)
|
|
2258
|
+
except OSError:
|
|
2259
|
+
pass
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
|
|
2263
|
+
"""Freeze the events cutoff: fsync events, write evidence-cutoff.json.
|
|
2264
|
+
|
|
2265
|
+
After this point no event may be appended to the archived events file;
|
|
2266
|
+
the cutoff hash lets any later reader prove that (INT-006/RET-19).
|
|
2267
|
+
"""
|
|
2268
|
+
events_file = he.events_path(work_dir)
|
|
2269
|
+
events = he.load_events(events_file) if events_file.is_file() else []
|
|
2270
|
+
if events_file.is_file():
|
|
2271
|
+
# Windows fsync requires a writable handle; O_RDONLY raises EBADF.
|
|
2272
|
+
fd = os.open(str(events_file), os.O_RDWR | os.O_BINARY)
|
|
2273
|
+
try:
|
|
2274
|
+
os.fsync(fd)
|
|
2275
|
+
finally:
|
|
2276
|
+
os.close(fd)
|
|
2277
|
+
raw = events_file.read_bytes()
|
|
2278
|
+
else:
|
|
2279
|
+
raw = b""
|
|
2280
|
+
cutoff = {
|
|
2281
|
+
"eventCount": len(events),
|
|
2282
|
+
"sha256": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
2283
|
+
"frozenAt": now_iso(),
|
|
2284
|
+
"path": "events.ndjson",
|
|
2285
|
+
}
|
|
2286
|
+
write_json(work_dir / "evidence" / "evidence-cutoff.json", cutoff)
|
|
2287
|
+
return cutoff
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
2291
|
+
"""Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
|
|
2292
|
+
issues: list[dict[str, str]] = []
|
|
2293
|
+
seen: dict[str, str] = {}
|
|
2294
|
+
for item in entries:
|
|
2295
|
+
if not isinstance(item, dict):
|
|
2296
|
+
continue
|
|
2297
|
+
rel = str(item.get("path") or "").strip()
|
|
2298
|
+
digest = str(item.get("sha256") or "").strip()
|
|
2299
|
+
if not rel or not digest:
|
|
2300
|
+
continue
|
|
2301
|
+
prior = seen.get(rel)
|
|
2302
|
+
if prior is not None and prior != digest:
|
|
2303
|
+
issues.append(
|
|
2304
|
+
{
|
|
2305
|
+
"code": "artifact-hash-conflict",
|
|
2306
|
+
"severity": "error",
|
|
2307
|
+
"message": f"immutable artifact conflict at {rel}: {prior[:12]}… != {digest[:12]}…",
|
|
2308
|
+
}
|
|
2309
|
+
)
|
|
2310
|
+
else:
|
|
2311
|
+
seen[rel] = digest
|
|
2312
|
+
errors = [i for i in issues if i.get("severity") == "error"]
|
|
2313
|
+
return {
|
|
2314
|
+
"ok": len(errors) == 0,
|
|
2315
|
+
"issues": issues,
|
|
2316
|
+
"error_count": len(errors),
|
|
2317
|
+
"warning_count": len(issues) - len(errors),
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
def validate_source_consistency(
|
|
2322
|
+
change_dir: Path,
|
|
2323
|
+
summary: dict[str, Any],
|
|
2324
|
+
) -> dict[str, Any]:
|
|
2325
|
+
"""Layer 1: summary facts must equal the frozen sources (UT-014/RET-26).
|
|
2326
|
+
|
|
2327
|
+
Checks event count against the cutoff file, verification counts against
|
|
2328
|
+
ledger typed metrics, and review counts against sidecars when present.
|
|
2329
|
+
"""
|
|
2330
|
+
issues: list[dict[str, str]] = []
|
|
2331
|
+
|
|
2332
|
+
# 1. event count/hash vs frozen cutoff (fallback: live events file).
|
|
2333
|
+
events_file = he.events_path(change_dir)
|
|
2334
|
+
actual_count = None
|
|
2335
|
+
cutoff_path = change_dir / "evidence" / "evidence-cutoff.json"
|
|
2336
|
+
cutoff: dict[str, Any] | None = None
|
|
2337
|
+
if cutoff_path.is_file():
|
|
2338
|
+
try:
|
|
2339
|
+
cutoff = read_json(cutoff_path)
|
|
2340
|
+
actual_count = cutoff.get("eventCount")
|
|
2341
|
+
except (OSError, json.JSONDecodeError):
|
|
2342
|
+
actual_count = None
|
|
2343
|
+
if actual_count is None and events_file.is_file():
|
|
2344
|
+
actual_count = len(he.load_events(events_file))
|
|
2345
|
+
summary_count = (summary.get("reportPipeline") or {}).get("event_count")
|
|
2346
|
+
if actual_count is not None and summary_count is not None:
|
|
2347
|
+
if int(summary_count) != int(actual_count):
|
|
2348
|
+
issues.append(
|
|
2349
|
+
{
|
|
2350
|
+
"code": "event-count-mismatch",
|
|
2351
|
+
"severity": "error",
|
|
2352
|
+
"message": (
|
|
2353
|
+
f"summary event_count={summary_count} but cutoff has "
|
|
2354
|
+
f"{actual_count} events"
|
|
2355
|
+
),
|
|
2356
|
+
}
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
if cutoff is not None and events_file.is_file():
|
|
2360
|
+
actual_hash = "sha256:" + hashlib.sha256(events_file.read_bytes()).hexdigest()
|
|
2361
|
+
if cutoff.get("sha256") != actual_hash:
|
|
2362
|
+
issues.append(
|
|
2363
|
+
{
|
|
2364
|
+
"code": "cutoff-hash-mismatch",
|
|
2365
|
+
"severity": "error",
|
|
2366
|
+
"message": "evidence cutoff hash does not match events.ndjson",
|
|
2367
|
+
}
|
|
2368
|
+
)
|
|
2369
|
+
|
|
2370
|
+
# 2. verification vs ledger typed metrics.
|
|
2371
|
+
ledger = load_ledger(change_dir)
|
|
2372
|
+
if ledger:
|
|
2373
|
+
projection = build_verification_projection(ledger, change_dir=change_dir)
|
|
2374
|
+
ver = summary.get("verification") or {}
|
|
2375
|
+
unit_src = projection.get("unitTests") or {}
|
|
2376
|
+
unit_sum = ver.get("unitTests") or {}
|
|
2377
|
+
if unit_src.get("run"):
|
|
2378
|
+
if int(unit_sum.get("run") or 0) != int(unit_src["run"]):
|
|
2379
|
+
issues.append(
|
|
2380
|
+
{
|
|
2381
|
+
"code": "verification-mismatch",
|
|
2382
|
+
"severity": "error",
|
|
2383
|
+
"message": (
|
|
2384
|
+
f"unitTests: summary run={unit_sum.get('run')} but "
|
|
2385
|
+
f"ledger projection run={unit_src['run']}"
|
|
2386
|
+
),
|
|
2387
|
+
}
|
|
2388
|
+
)
|
|
2389
|
+
for typed_key in ("apiContract", "browserE2E"):
|
|
2390
|
+
src = projection.get(typed_key)
|
|
2391
|
+
if not isinstance(src, dict) or not src.get("total"):
|
|
2392
|
+
continue
|
|
2393
|
+
rendered = ver.get(typed_key) or {}
|
|
2394
|
+
if int(rendered.get("total") or 0) != int(src["total"]):
|
|
2395
|
+
issues.append(
|
|
2396
|
+
{
|
|
2397
|
+
"code": "verification-mismatch",
|
|
2398
|
+
"severity": "error",
|
|
2399
|
+
"message": (
|
|
2400
|
+
f"{typed_key}: summary total={rendered.get('total')} "
|
|
2401
|
+
f"but ledger projection total={src['total']}"
|
|
2402
|
+
),
|
|
2403
|
+
}
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
# 3. Rebuild canonical projections from frozen sources and compare the
|
|
2407
|
+
# fields that must never come from prose or an existing summary.
|
|
2408
|
+
expected = collect_summary_data(change_dir, write=False, for_replay=False)
|
|
2409
|
+
projection_fields = {
|
|
2410
|
+
"reviewSummary": "review-mismatch",
|
|
2411
|
+
"knownRisks": "risk-mismatch",
|
|
2412
|
+
"manualActions": "manual-actions-mismatch",
|
|
2413
|
+
"durations": "phase-timing-mismatch",
|
|
2414
|
+
"changedFiles": "ownership-diff-mismatch",
|
|
2415
|
+
"ownershipDiff": "ownership-diff-mismatch",
|
|
2416
|
+
"artifacts": "artifact-mismatch",
|
|
2417
|
+
}
|
|
2418
|
+
for field, code in projection_fields.items():
|
|
2419
|
+
if summary.get(field) != expected.get(field):
|
|
2420
|
+
issues.append(
|
|
2421
|
+
{
|
|
2422
|
+
"code": code,
|
|
2423
|
+
"severity": "error",
|
|
2424
|
+
"message": f"summary {field} does not match frozen source projection",
|
|
2425
|
+
}
|
|
2426
|
+
)
|
|
2427
|
+
|
|
2428
|
+
# 4. Manifest structure/checksums and summary semantics.
|
|
2429
|
+
before_path = change_dir / "evidence" / "archive-manifest-before.json"
|
|
2430
|
+
if before_path.is_file():
|
|
2431
|
+
try:
|
|
2432
|
+
manifest = read_json(before_path)
|
|
2433
|
+
entries = manifest.get("files") if isinstance(manifest, dict) else None
|
|
2434
|
+
valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
|
|
2435
|
+
seen: set[str] = set()
|
|
2436
|
+
if valid:
|
|
2437
|
+
for entry in entries:
|
|
2438
|
+
rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
|
|
2439
|
+
digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
|
|
2440
|
+
if (
|
|
2441
|
+
not rel
|
|
2442
|
+
or rel in seen
|
|
2443
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
2444
|
+
):
|
|
2445
|
+
valid = False
|
|
2446
|
+
break
|
|
2447
|
+
seen.add(rel)
|
|
2448
|
+
target = (change_dir / rel).resolve()
|
|
2449
|
+
if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
|
|
2450
|
+
valid = False
|
|
2451
|
+
break
|
|
2452
|
+
if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
|
|
2453
|
+
valid = False
|
|
2454
|
+
break
|
|
2455
|
+
if not valid:
|
|
2456
|
+
issues.append(
|
|
2457
|
+
{
|
|
2458
|
+
"code": "manifest-invalid",
|
|
2459
|
+
"severity": "error",
|
|
2460
|
+
"message": "archive-manifest-before structure or checksum is invalid",
|
|
2461
|
+
}
|
|
2462
|
+
)
|
|
2463
|
+
except (OSError, json.JSONDecodeError):
|
|
2464
|
+
issues.append(
|
|
2465
|
+
{
|
|
2466
|
+
"code": "manifest-invalid",
|
|
2467
|
+
"severity": "error",
|
|
2468
|
+
"message": "archive-manifest-before is unreadable",
|
|
2469
|
+
}
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
# 5. Artifact paths must resolve to immutable files inside the archive.
|
|
2473
|
+
root = change_dir.resolve()
|
|
2474
|
+
for artifact in summary.get("artifacts") or []:
|
|
2475
|
+
raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
|
|
2476
|
+
candidate = (root / raw).resolve() if raw else root
|
|
2477
|
+
if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
|
|
2478
|
+
issues.append(
|
|
2479
|
+
{
|
|
2480
|
+
"code": "artifact-missing",
|
|
2481
|
+
"severity": "error",
|
|
2482
|
+
"message": f"artifact path is missing or outside archive: {raw}",
|
|
2483
|
+
}
|
|
2484
|
+
)
|
|
2485
|
+
|
|
2486
|
+
errors = [i for i in issues if i.get("severity") == "error"]
|
|
2487
|
+
warnings = [i for i in issues if i.get("severity") != "error"]
|
|
2488
|
+
return {
|
|
2489
|
+
"ok": len(errors) == 0,
|
|
2490
|
+
"issues": issues,
|
|
2491
|
+
"error_count": len(errors),
|
|
2492
|
+
"warning_count": len(warnings),
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
|
|
1684
2496
|
def validate_summary(
|
|
1685
2497
|
summary: dict[str, Any],
|
|
1686
2498
|
html_path: Path | None,
|
|
@@ -1795,7 +2607,12 @@ def validate_summary(
|
|
|
1795
2607
|
|
|
1796
2608
|
# risks / manual actions — empty arrays are OK (placeholder)
|
|
1797
2609
|
for risk in summary.get("knownRisks") or []:
|
|
1798
|
-
|
|
2610
|
+
# UT-016/RET-28: canonical projection — structured risk objects
|
|
2611
|
+
# project to their message; str(dict) never matches rendered HTML.
|
|
2612
|
+
if isinstance(risk, dict):
|
|
2613
|
+
text = str(risk.get("message") or risk.get("summary") or "").strip()
|
|
2614
|
+
else:
|
|
2615
|
+
text = str(risk)
|
|
1799
2616
|
if text and text not in html and _html_escape(text) not in html:
|
|
1800
2617
|
issues.append(
|
|
1801
2618
|
{
|
|
@@ -2006,6 +2823,7 @@ def cmd_finalize(
|
|
|
2006
2823
|
archive_root: Path,
|
|
2007
2824
|
*,
|
|
2008
2825
|
skip_ingest: bool = False,
|
|
2826
|
+
allow_missing_review: bool = False,
|
|
2009
2827
|
) -> tuple[int, dict[str, Any]]:
|
|
2010
2828
|
"""Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
|
|
2011
2829
|
warnings: list[str] = []
|
|
@@ -2015,6 +2833,43 @@ def cmd_finalize(
|
|
|
2015
2833
|
archive_root.mkdir(parents=True, exist_ok=True)
|
|
2016
2834
|
archive_dir = archive_root / f"{today_date()}-{change_name}"
|
|
2017
2835
|
project_root = find_project_root(original_change_dir)
|
|
2836
|
+
try:
|
|
2837
|
+
resolved_state_dir = hp.resolve_state_dir_for_contract(
|
|
2838
|
+
original_change_dir, project_root
|
|
2839
|
+
)
|
|
2840
|
+
except ValueError as exc:
|
|
2841
|
+
return 1, {
|
|
2842
|
+
"ok": False,
|
|
2843
|
+
"action": "finalize",
|
|
2844
|
+
"change_dir": str(original_change_dir),
|
|
2845
|
+
"error": f"invalid split runtime root: {exc}",
|
|
2846
|
+
}
|
|
2847
|
+
split_state_dir = (
|
|
2848
|
+
resolved_state_dir
|
|
2849
|
+
if resolved_state_dir.resolve() != original_change_dir.resolve()
|
|
2850
|
+
else None
|
|
2851
|
+
)
|
|
2852
|
+
split_materialized_names = (
|
|
2853
|
+
{item.name for item in split_state_dir.iterdir()}
|
|
2854
|
+
if split_state_dir is not None and split_state_dir.is_dir()
|
|
2855
|
+
else set()
|
|
2856
|
+
)
|
|
2857
|
+
|
|
2858
|
+
def _restore_finalize_failure() -> None:
|
|
2859
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2860
|
+
if split_state_dir is None or not original_change_dir.is_dir():
|
|
2861
|
+
return
|
|
2862
|
+
# The split state remains authoritative on failure. Remove only the
|
|
2863
|
+
# top-level names materialized from that state so retry starts clean.
|
|
2864
|
+
for name in sorted(split_materialized_names):
|
|
2865
|
+
target = original_change_dir / name
|
|
2866
|
+
try:
|
|
2867
|
+
if target.is_dir():
|
|
2868
|
+
shutil.rmtree(target)
|
|
2869
|
+
elif target.exists():
|
|
2870
|
+
target.unlink()
|
|
2871
|
+
except OSError as exc:
|
|
2872
|
+
warnings.append(f"could not dematerialize split state {target}: {exc}")
|
|
2018
2873
|
|
|
2019
2874
|
payload: dict[str, Any] = {
|
|
2020
2875
|
"ok": False,
|
|
@@ -2035,6 +2890,7 @@ def cmd_finalize(
|
|
|
2035
2890
|
return 1, payload
|
|
2036
2891
|
|
|
2037
2892
|
work_dir = original_change_dir
|
|
2893
|
+
before_manifest: dict[str, Any] | None = None
|
|
2038
2894
|
|
|
2039
2895
|
def _safe_append(**kwargs: Any) -> None:
|
|
2040
2896
|
nonlocal work_dir
|
|
@@ -2046,6 +2902,30 @@ def cmd_finalize(
|
|
|
2046
2902
|
# Step 9 starts here: phase.start
|
|
2047
2903
|
_safe_append(phase="archive", type_="phase.start", note="finalize start")
|
|
2048
2904
|
|
|
2905
|
+
# --- 0. cleanup transients (before before-manifest) ---
|
|
2906
|
+
try:
|
|
2907
|
+
cleanup_result = _cleanup_transients(split_state_dir or work_dir)
|
|
2908
|
+
payload["steps"]["cleanup"] = cleanup_result
|
|
2909
|
+
deleted_n = len(cleanup_result.get("deleted") or [])
|
|
2910
|
+
trunc_n = len(cleanup_result.get("truncated") or [])
|
|
2911
|
+
_safe_append(
|
|
2912
|
+
phase="archive",
|
|
2913
|
+
type_="command",
|
|
2914
|
+
command="cleanup-transients",
|
|
2915
|
+
exit_code=0,
|
|
2916
|
+
note=f"deleted={deleted_n} truncated={trunc_n}",
|
|
2917
|
+
)
|
|
2918
|
+
except OSError as exc:
|
|
2919
|
+
warnings.append(f"cleanup failed: {exc}")
|
|
2920
|
+
payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
|
|
2921
|
+
|
|
2922
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
2923
|
+
_merge_runtime_state(split_state_dir, work_dir)
|
|
2924
|
+
payload["steps"]["split_state_merge"] = {
|
|
2925
|
+
"ok": True,
|
|
2926
|
+
"stateDir": str(split_state_dir),
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2049
2929
|
# --- 1. before-manifest ---
|
|
2050
2930
|
before_path = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2051
2931
|
try:
|
|
@@ -2079,6 +2959,10 @@ def cmd_finalize(
|
|
|
2079
2959
|
)
|
|
2080
2960
|
return 1, payload
|
|
2081
2961
|
|
|
2962
|
+
# Sync events/evidence appended after the first materialization.
|
|
2963
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
2964
|
+
_merge_runtime_state(split_state_dir, work_dir)
|
|
2965
|
+
|
|
2082
2966
|
# --- 2. move ---
|
|
2083
2967
|
try:
|
|
2084
2968
|
archive_root.mkdir(parents=True, exist_ok=True)
|
|
@@ -2110,7 +2994,33 @@ def cmd_finalize(
|
|
|
2110
2994
|
pass
|
|
2111
2995
|
return 1, payload
|
|
2112
2996
|
|
|
2113
|
-
# ---
|
|
2997
|
+
# --- 2b. user-flag decision (archive fact, must precede the cutoff) ---
|
|
2998
|
+
if allow_missing_review:
|
|
2999
|
+
_safe_append(
|
|
3000
|
+
phase="archive",
|
|
3001
|
+
type_="decision",
|
|
3002
|
+
note="review missing on full tier (allowed by user)",
|
|
3003
|
+
)
|
|
3004
|
+
|
|
3005
|
+
# --- 3. unique phase.end + freeze cutoff (RET-19 freeze-first) ---
|
|
3006
|
+
# From here on, NO event may be appended to the archived events file;
|
|
3007
|
+
# report generation runs as pure functions over the frozen sources.
|
|
3008
|
+
_safe_append(
|
|
3009
|
+
phase="archive",
|
|
3010
|
+
type_="phase.end",
|
|
3011
|
+
status="WARN" if warnings else "OK",
|
|
3012
|
+
note="finalize facts complete",
|
|
3013
|
+
)
|
|
3014
|
+
try:
|
|
3015
|
+
cutoff = _freeze_evidence_cutoff(work_dir)
|
|
3016
|
+
payload["steps"]["freeze"] = {"ok": True, "eventCount": cutoff["eventCount"]}
|
|
3017
|
+
except OSError as exc:
|
|
3018
|
+
payload["error"] = f"freeze failed: {exc}"
|
|
3019
|
+
payload["steps"]["freeze"] = {"ok": False, "error": str(exc)}
|
|
3020
|
+
_restore_finalize_failure()
|
|
3021
|
+
return 1, payload
|
|
3022
|
+
|
|
3023
|
+
# --- 4. collect (pure function of frozen sources) ---
|
|
2114
3024
|
try:
|
|
2115
3025
|
summary = collect_summary_data(
|
|
2116
3026
|
work_dir,
|
|
@@ -2119,46 +3029,49 @@ def cmd_finalize(
|
|
|
2119
3029
|
for_replay=False,
|
|
2120
3030
|
)
|
|
2121
3031
|
summary_path = work_dir / "reports" / "final" / "summary-data.json"
|
|
3032
|
+
if allow_missing_review:
|
|
3033
|
+
reasons = list(summary.get("finalStatusReasons") or [])
|
|
3034
|
+
reason = "review missing on full tier (allowed by user)"
|
|
3035
|
+
if reason not in reasons:
|
|
3036
|
+
reasons.append(reason)
|
|
3037
|
+
summary["finalStatusReasons"] = reasons
|
|
3038
|
+
write_json(summary_path, summary)
|
|
2122
3039
|
payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
|
|
2123
|
-
_safe_append(
|
|
2124
|
-
phase="archive",
|
|
2125
|
-
type_="artifact",
|
|
2126
|
-
path="reports/final/summary-data.json",
|
|
2127
|
-
kind="summary-data",
|
|
2128
|
-
)
|
|
2129
3040
|
except Exception as exc: # noqa: BLE001 — surface collect failures
|
|
2130
3041
|
payload["error"] = f"collect failed: {exc}"
|
|
2131
3042
|
payload["steps"]["collect"] = {"ok": False, "error": str(exc)}
|
|
2132
|
-
|
|
3043
|
+
_restore_finalize_failure()
|
|
3044
|
+
return 1, payload
|
|
3045
|
+
|
|
3046
|
+
# --- 5. source consistency (layer 1; UT-014) ---
|
|
3047
|
+
source_result = validate_source_consistency(work_dir, summary)
|
|
3048
|
+
payload["steps"]["source_consistency"] = source_result
|
|
3049
|
+
summary.setdefault("reportPipeline", {})["sourceConsistency"] = {
|
|
3050
|
+
"ok": bool(source_result.get("ok")),
|
|
3051
|
+
"issues": source_result.get("issues") or [],
|
|
3052
|
+
}
|
|
3053
|
+
try:
|
|
3054
|
+
write_json(summary_path, summary)
|
|
3055
|
+
except OSError as exc:
|
|
3056
|
+
warnings.append(f"could not write sourceConsistency: {exc}")
|
|
3057
|
+
if not source_result.get("ok"):
|
|
3058
|
+
payload["issues"] = source_result.get("issues") or []
|
|
3059
|
+
payload["error"] = "source consistency failed; original change dir restored"
|
|
3060
|
+
_restore_finalize_failure()
|
|
3061
|
+
_append_finalize_failure_issue(original_change_dir, "source-consistency-failed", payload["error"])
|
|
3062
|
+
payload["warnings"] = warnings
|
|
3063
|
+
payload["ok"] = False
|
|
2133
3064
|
return 1, payload
|
|
2134
3065
|
|
|
2135
|
-
# ---
|
|
3066
|
+
# --- 6. render (Node, else Python fallback) ---
|
|
2136
3067
|
render_result = render_final_summary(work_dir, summary_path)
|
|
2137
3068
|
payload["steps"]["render"] = render_result
|
|
2138
3069
|
if not render_result.get("ok"):
|
|
2139
|
-
# Node + fallback both failed: restore + exit non-0 (§4.1 rule 4)。
|
|
2140
3070
|
# 永不关闭一个没有 final-summary 的归档。
|
|
2141
3071
|
msg = str(render_result.get("fallbackReason") or "render failed")
|
|
2142
3072
|
payload["error"] = f"final-summary render failed: {msg}"
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
type_="issue",
|
|
2146
|
-
code="render-failed",
|
|
2147
|
-
severity="error",
|
|
2148
|
-
message=msg,
|
|
2149
|
-
)
|
|
2150
|
-
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2151
|
-
_restore_target = original_change_dir if original_change_dir.is_dir() else work_dir
|
|
2152
|
-
try:
|
|
2153
|
-
append_event(
|
|
2154
|
-
_restore_target,
|
|
2155
|
-
phase="archive",
|
|
2156
|
-
type_="phase.end",
|
|
2157
|
-
status="FAIL",
|
|
2158
|
-
note="finalize aborted; render failed; original preserved",
|
|
2159
|
-
)
|
|
2160
|
-
except OSError:
|
|
2161
|
-
pass
|
|
3073
|
+
_restore_finalize_failure()
|
|
3074
|
+
_append_finalize_failure_issue(original_change_dir, "render-failed", msg)
|
|
2162
3075
|
payload["warnings"] = warnings
|
|
2163
3076
|
payload["ok"] = False
|
|
2164
3077
|
return 1, payload
|
|
@@ -2168,24 +3081,10 @@ def cmd_finalize(
|
|
|
2168
3081
|
f"node render unavailable; used python-fallback: "
|
|
2169
3082
|
f"{render_result.get('fallbackReason')}"
|
|
2170
3083
|
)
|
|
2171
|
-
_safe_append(
|
|
2172
|
-
phase="archive",
|
|
2173
|
-
type_="artifact",
|
|
2174
|
-
path="reports/final/final-summary.html",
|
|
2175
|
-
kind="final-report",
|
|
2176
|
-
)
|
|
2177
|
-
_safe_append(
|
|
2178
|
-
phase="archive",
|
|
2179
|
-
type_="command",
|
|
2180
|
-
command=f"render-final-summary ({renderer})",
|
|
2181
|
-
exit_code=0,
|
|
2182
|
-
note=f"final-summary rendered by {renderer}",
|
|
2183
|
-
)
|
|
2184
3084
|
|
|
2185
3085
|
html_path = work_dir / "reports" / "final" / "final-summary.html"
|
|
2186
3086
|
|
|
2187
|
-
# ---
|
|
2188
|
-
# Refresh summary from disk (collect already wrote it); do not re-collect.
|
|
3087
|
+
# --- 7. renderer consistency (layer 2) ---
|
|
2189
3088
|
try:
|
|
2190
3089
|
summary = read_json(summary_path)
|
|
2191
3090
|
except (OSError, json.JSONDecodeError):
|
|
@@ -2203,20 +3102,19 @@ def cmd_finalize(
|
|
|
2203
3102
|
except OSError as exc:
|
|
2204
3103
|
warnings.append(f"could not write validationIssues: {exc}")
|
|
2205
3104
|
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
)
|
|
3105
|
+
# --- 8. archive-meta (before after-manifest so the manifest covers it) ---
|
|
3106
|
+
try:
|
|
3107
|
+
summary = read_json(summary_path)
|
|
3108
|
+
meta_path = write_archive_meta(work_dir, summary)
|
|
3109
|
+
payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
|
|
3110
|
+
except Exception as exc: # noqa: BLE001 — meta soft-fail
|
|
3111
|
+
warnings.append(f"archive-meta write failed: {exc}")
|
|
3112
|
+
payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
|
|
2214
3113
|
|
|
2215
|
-
# ---
|
|
3114
|
+
# --- 9. after-manifest + compare ---
|
|
2216
3115
|
after_path = work_dir / "evidence" / "archive-manifest-after.json"
|
|
2217
3116
|
try:
|
|
2218
3117
|
after_manifest = generate_manifest(work_dir, after_path)
|
|
2219
|
-
# Re-read before from archive (moved with the tree)
|
|
2220
3118
|
before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2221
3119
|
if before_in_archive.is_file():
|
|
2222
3120
|
before_manifest = read_json(before_in_archive)
|
|
@@ -2226,46 +3124,34 @@ def cmd_finalize(
|
|
|
2226
3124
|
"path": str(after_path),
|
|
2227
3125
|
"compare": compare_result,
|
|
2228
3126
|
}
|
|
2229
|
-
_safe_append(
|
|
2230
|
-
phase="archive",
|
|
2231
|
-
type_="artifact",
|
|
2232
|
-
path="evidence/archive-manifest-after.json",
|
|
2233
|
-
kind="manifest-after",
|
|
2234
|
-
)
|
|
2235
3127
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
2236
3128
|
payload["error"] = f"after-manifest failed: {exc}"
|
|
2237
3129
|
payload["steps"]["after_manifest"] = {"ok": False, "error": str(exc)}
|
|
2238
|
-
|
|
3130
|
+
_restore_finalize_failure()
|
|
2239
3131
|
return 1, payload
|
|
2240
3132
|
|
|
2241
|
-
#
|
|
3133
|
+
# --- 10. final summary update + final render/validate (no more events) ---
|
|
3134
|
+
summary = read_json(summary_path)
|
|
2242
3135
|
summary["archiveManifest"] = {
|
|
2243
3136
|
"movedFiles": compare_result.get("movedFiles", 0),
|
|
2244
3137
|
"generatedFiles": compare_result.get("generatedFiles", 0),
|
|
2245
3138
|
"totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
|
|
2246
3139
|
"checksumStatus": compare_result.get("checksumStatus", "FAIL"),
|
|
3140
|
+
**_manifest_self_stats(work_dir, after_manifest, after_path),
|
|
2247
3141
|
}
|
|
2248
|
-
|
|
2249
|
-
write_json(summary_path, summary)
|
|
2250
|
-
except OSError as exc:
|
|
2251
|
-
warnings.append(f"could not update archiveManifest: {exc}")
|
|
2252
|
-
|
|
2253
|
-
# The first render is needed so the after-manifest includes a final report,
|
|
2254
|
-
# but it predates the final manifest statistics. Render and validate once
|
|
2255
|
-
# more from the now-final summary so HTML never contradicts summary-data.
|
|
3142
|
+
write_json(summary_path, summary)
|
|
2256
3143
|
render_result = render_final_summary(work_dir, summary_path)
|
|
2257
3144
|
if not render_result.get("ok"):
|
|
2258
3145
|
payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
|
|
2259
|
-
|
|
3146
|
+
_restore_finalize_failure()
|
|
2260
3147
|
return 1, payload
|
|
3148
|
+
summary = read_json(summary_path)
|
|
2261
3149
|
validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
|
|
2262
3150
|
payload["steps"]["validate"] = validate_result
|
|
2263
3151
|
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
|
|
2264
3152
|
write_json(summary_path, summary)
|
|
2265
3153
|
|
|
2266
|
-
# ---
|
|
2267
|
-
# After move, "original" is gone; on failure we restore. On success, ensure
|
|
2268
|
-
# the changes path does not linger.
|
|
3154
|
+
# --- 11. closure: only when both validators and manifest pass ---
|
|
2269
3155
|
validate_ok = bool(validate_result.get("ok"))
|
|
2270
3156
|
manifest_ok = bool(compare_result.get("ok"))
|
|
2271
3157
|
can_close = validate_ok and manifest_ok
|
|
@@ -2286,19 +3172,10 @@ def cmd_finalize(
|
|
|
2286
3172
|
payload["issues"] = issues_out
|
|
2287
3173
|
payload["error"] = "validate or manifest check failed; original change dir restored"
|
|
2288
3174
|
payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
append_event(
|
|
2294
|
-
_safe_append_restored,
|
|
2295
|
-
phase="archive",
|
|
2296
|
-
type_="phase.end",
|
|
2297
|
-
status="FAIL",
|
|
2298
|
-
note="finalize aborted; original preserved",
|
|
2299
|
-
)
|
|
2300
|
-
except OSError:
|
|
2301
|
-
pass
|
|
3175
|
+
_restore_finalize_failure()
|
|
3176
|
+
_append_finalize_failure_issue(
|
|
3177
|
+
original_change_dir, "closure-failed", payload["error"]
|
|
3178
|
+
)
|
|
2302
3179
|
payload["warnings"] = warnings
|
|
2303
3180
|
payload["ok"] = False
|
|
2304
3181
|
return 1, payload
|
|
@@ -2318,16 +3195,15 @@ def cmd_finalize(
|
|
|
2318
3195
|
"note": "removed by move",
|
|
2319
3196
|
}
|
|
2320
3197
|
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
3198
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
3199
|
+
try:
|
|
3200
|
+
shutil.rmtree(split_state_dir)
|
|
3201
|
+
payload["steps"]["delete_runtime_state"] = {"ok": True, "deleted": True}
|
|
3202
|
+
except OSError as exc:
|
|
3203
|
+
warnings.append(f"could not remove archived runtime state: {exc}")
|
|
3204
|
+
payload["steps"]["delete_runtime_state"] = {"ok": False, "error": str(exc)}
|
|
2328
3205
|
|
|
2329
|
-
# ---
|
|
2330
|
-
# knowledge subprocesses; it enqueues a pending outbox item and returns) ---
|
|
3206
|
+
# --- 12. maintenance outbox + service ---
|
|
2331
3207
|
if skip_ingest:
|
|
2332
3208
|
payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
|
|
2333
3209
|
payload["knowledgeMaintenance"] = "SKIPPED"
|
|
@@ -2346,13 +3222,6 @@ def cmd_finalize(
|
|
|
2346
3222
|
if service_result.get("warning"):
|
|
2347
3223
|
warnings.append(str(service_result["warning"]))
|
|
2348
3224
|
|
|
2349
|
-
_safe_append(
|
|
2350
|
-
phase="archive",
|
|
2351
|
-
type_="phase.end",
|
|
2352
|
-
status="WARN" if warnings else "OK",
|
|
2353
|
-
note="finalize complete",
|
|
2354
|
-
)
|
|
2355
|
-
|
|
2356
3225
|
payload["ok"] = True
|
|
2357
3226
|
payload["warnings"] = warnings
|
|
2358
3227
|
payload["summary_data"] = str(summary_path)
|
|
@@ -2360,6 +3229,17 @@ def cmd_finalize(
|
|
|
2360
3229
|
return 0, payload
|
|
2361
3230
|
|
|
2362
3231
|
|
|
3232
|
+
def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
|
|
3233
|
+
"""Materialize split-v1 dynamic state into the archive contract tree."""
|
|
3234
|
+
for source in sorted(state_dir.iterdir()):
|
|
3235
|
+
target = contract_dir / source.name
|
|
3236
|
+
if source.is_dir():
|
|
3237
|
+
shutil.copytree(source, target, dirs_exist_ok=True, copy_function=shutil.copy2)
|
|
3238
|
+
elif source.is_file():
|
|
3239
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3240
|
+
shutil.copy2(source, target)
|
|
3241
|
+
|
|
3242
|
+
|
|
2363
3243
|
def _restore_on_failure(
|
|
2364
3244
|
archive_dir: Path,
|
|
2365
3245
|
original_change_dir: Path,
|
|
@@ -2455,7 +3335,10 @@ def cmd_replay(
|
|
|
2455
3335
|
|
|
2456
3336
|
def cmd_status_cli(args: argparse.Namespace) -> int:
|
|
2457
3337
|
change_dir = resolve_path(args.change_dir)
|
|
2458
|
-
result = check_status(
|
|
3338
|
+
result = check_status(
|
|
3339
|
+
change_dir,
|
|
3340
|
+
allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
|
|
3341
|
+
)
|
|
2459
3342
|
emit_json(result)
|
|
2460
3343
|
# Checks completed → exit 0; archivable flag conveys the gate result.
|
|
2461
3344
|
return 0 if result.get("ok") else 1
|
|
@@ -2468,6 +3351,7 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
|
|
|
2468
3351
|
change_dir,
|
|
2469
3352
|
archive_root,
|
|
2470
3353
|
skip_ingest=bool(args.skip_ingest),
|
|
3354
|
+
allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
|
|
2471
3355
|
)
|
|
2472
3356
|
emit_json(payload)
|
|
2473
3357
|
return code
|
|
@@ -2481,6 +3365,144 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
|
|
|
2481
3365
|
return code
|
|
2482
3366
|
|
|
2483
3367
|
|
|
3368
|
+
def _render_html_to(
|
|
3369
|
+
change_dir: Path,
|
|
3370
|
+
summary_path: Path,
|
|
3371
|
+
out_path: Path,
|
|
3372
|
+
) -> dict[str, Any]:
|
|
3373
|
+
"""Render summary HTML to an arbitrary path (node first, python fallback).
|
|
3374
|
+
|
|
3375
|
+
Unlike ``render_final_summary`` this never touches the canonical
|
|
3376
|
+
``reports/final/final-summary.html`` — repair renders into staging.
|
|
3377
|
+
"""
|
|
3378
|
+
project = find_project_root(change_dir)
|
|
3379
|
+
node = resolve_node_path(project)
|
|
3380
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3381
|
+
if node and RENDER_SCRIPT.is_file():
|
|
3382
|
+
try:
|
|
3383
|
+
proc = subprocess.run(
|
|
3384
|
+
[node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
|
|
3385
|
+
capture_output=True,
|
|
3386
|
+
text=True,
|
|
3387
|
+
encoding="utf-8",
|
|
3388
|
+
errors="replace",
|
|
3389
|
+
timeout=60,
|
|
3390
|
+
check=False,
|
|
3391
|
+
)
|
|
3392
|
+
if proc.returncode == 0 and out_path.is_file():
|
|
3393
|
+
return {"ok": True, "renderer": "node", "out_path": str(out_path)}
|
|
3394
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
3395
|
+
pass
|
|
3396
|
+
try:
|
|
3397
|
+
summary = read_json(summary_path)
|
|
3398
|
+
html = render_fallback_html(summary)
|
|
3399
|
+
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
3400
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
3401
|
+
return {"ok": False, "renderer": "none", "error": str(exc)}
|
|
3402
|
+
return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
3406
|
+
"""Versioned repair (task 11 / RET-40).
|
|
3407
|
+
|
|
3408
|
+
Re-collect a candidate from the frozen sources, run both validators, and
|
|
3409
|
+
only then write an immutable ``derived/v<N>/`` plus a repair record. The
|
|
3410
|
+
original summary/HTML/manifest is never overwritten; the authoritative
|
|
3411
|
+
pointer moves only when both validators pass.
|
|
3412
|
+
"""
|
|
3413
|
+
payload: dict[str, Any] = {
|
|
3414
|
+
"ok": False,
|
|
3415
|
+
"action": "repair",
|
|
3416
|
+
"archive_dir": str(archive_dir),
|
|
3417
|
+
}
|
|
3418
|
+
if not archive_dir.is_dir():
|
|
3419
|
+
payload["error"] = f"archive dir not found: {archive_dir}"
|
|
3420
|
+
return 1, payload
|
|
3421
|
+
summary_path = archive_dir / "reports" / "final" / "summary-data.json"
|
|
3422
|
+
if not summary_path.is_file():
|
|
3423
|
+
payload["error"] = "summary-data.json missing; cannot repair"
|
|
3424
|
+
return 1, payload
|
|
3425
|
+
|
|
3426
|
+
# 1. candidate: fresh collect from frozen sources (read-only on archive).
|
|
3427
|
+
try:
|
|
3428
|
+
candidate = collect_summary_data(archive_dir, write=False, for_replay=False)
|
|
3429
|
+
except Exception as exc: # noqa: BLE001
|
|
3430
|
+
payload["error"] = f"repair collect failed: {exc}"
|
|
3431
|
+
return 1, payload
|
|
3432
|
+
|
|
3433
|
+
# 2. stage outside the archive; run both validators on the candidate.
|
|
3434
|
+
staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
|
|
3435
|
+
try:
|
|
3436
|
+
staged_summary = staging / "summary-data.json"
|
|
3437
|
+
write_json(staged_summary, candidate)
|
|
3438
|
+
source_result = validate_source_consistency(archive_dir, candidate)
|
|
3439
|
+
render_result = _render_html_to(
|
|
3440
|
+
archive_dir, staged_summary, staging / "final-summary.html"
|
|
3441
|
+
)
|
|
3442
|
+
staged_html = staging / "final-summary.html"
|
|
3443
|
+
renderer_result = validate_summary(
|
|
3444
|
+
candidate,
|
|
3445
|
+
staged_html if staged_html.is_file() else None,
|
|
3446
|
+
)
|
|
3447
|
+
payload["validators"] = {
|
|
3448
|
+
"source": source_result,
|
|
3449
|
+
"renderer": renderer_result,
|
|
3450
|
+
}
|
|
3451
|
+
if not (source_result.get("ok") and renderer_result.get("ok")):
|
|
3452
|
+
payload["error"] = "repair validators failed; derived version not written"
|
|
3453
|
+
return 1, payload
|
|
3454
|
+
|
|
3455
|
+
# 3. immutable derived version.
|
|
3456
|
+
derived = archive_dir / "derived"
|
|
3457
|
+
derived.mkdir(exist_ok=True)
|
|
3458
|
+
existing = [
|
|
3459
|
+
int(p.name[1:])
|
|
3460
|
+
for p in derived.iterdir()
|
|
3461
|
+
if p.is_dir() and p.name.startswith("v") and p.name[1:].isdigit()
|
|
3462
|
+
]
|
|
3463
|
+
version = f"v{(max(existing) + 1) if existing else 1}"
|
|
3464
|
+
version_dir = derived / version
|
|
3465
|
+
version_dir.mkdir()
|
|
3466
|
+
final_summary = version_dir / "summary-data.json"
|
|
3467
|
+
write_json(final_summary, candidate)
|
|
3468
|
+
if staged_html.is_file():
|
|
3469
|
+
shutil.copy2(staged_html, version_dir / "final-summary.html")
|
|
3470
|
+
record = {
|
|
3471
|
+
"version": version,
|
|
3472
|
+
"createdAt": now_iso(),
|
|
3473
|
+
"summarySha256": "sha256:" + hashlib.sha256(final_summary.read_bytes()).hexdigest(),
|
|
3474
|
+
"baseSummarySha256": "sha256:" + hashlib.sha256(summary_path.read_bytes()).hexdigest(),
|
|
3475
|
+
"validators": {
|
|
3476
|
+
"source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
|
|
3477
|
+
"renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
|
|
3478
|
+
},
|
|
3479
|
+
}
|
|
3480
|
+
write_json(version_dir / "repair-record.json", record)
|
|
3481
|
+
|
|
3482
|
+
# 4. authoritative pointer — only after both validators passed.
|
|
3483
|
+
write_json(
|
|
3484
|
+
derived / "authoritative.json",
|
|
3485
|
+
{
|
|
3486
|
+
"version": version,
|
|
3487
|
+
"summarySha256": record["summarySha256"],
|
|
3488
|
+
"updatedAt": record["createdAt"],
|
|
3489
|
+
},
|
|
3490
|
+
)
|
|
3491
|
+
payload["ok"] = True
|
|
3492
|
+
payload["version"] = version
|
|
3493
|
+
payload["derived_dir"] = str(version_dir)
|
|
3494
|
+
return 0, payload
|
|
3495
|
+
finally:
|
|
3496
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
3497
|
+
|
|
3498
|
+
|
|
3499
|
+
def cmd_repair_cli(args: argparse.Namespace) -> int:
|
|
3500
|
+
archive_dir = resolve_path(args.archive_dir)
|
|
3501
|
+
code, payload = cmd_repair(archive_dir)
|
|
3502
|
+
emit_json(payload)
|
|
3503
|
+
return code
|
|
3504
|
+
|
|
3505
|
+
|
|
2484
3506
|
def build_parser() -> argparse.ArgumentParser:
|
|
2485
3507
|
parser = argparse.ArgumentParser(
|
|
2486
3508
|
prog="harness_archive.py",
|
|
@@ -2491,12 +3513,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2491
3513
|
p_status = sub.add_parser("status", help="pre-archive gate checks (read-only)")
|
|
2492
3514
|
p_status.add_argument("--change-dir", required=True)
|
|
2493
3515
|
p_status.add_argument("--json", action="store_true", default=True)
|
|
3516
|
+
p_status.add_argument(
|
|
3517
|
+
"--allow-missing-review",
|
|
3518
|
+
action="store_true",
|
|
3519
|
+
help="downgrade full-tier missing review from blocker to warning",
|
|
3520
|
+
)
|
|
2494
3521
|
p_status.set_defaults(func=cmd_status_cli)
|
|
2495
3522
|
|
|
2496
3523
|
p_fin = sub.add_parser("finalize", help="single-process archive finalize")
|
|
2497
3524
|
p_fin.add_argument("--change-dir", required=True)
|
|
2498
3525
|
p_fin.add_argument("--archive-root", required=True)
|
|
2499
3526
|
p_fin.add_argument("--skip-ingest", action="store_true")
|
|
3527
|
+
p_fin.add_argument(
|
|
3528
|
+
"--allow-missing-review",
|
|
3529
|
+
action="store_true",
|
|
3530
|
+
help="record override when full-tier review evidence is missing",
|
|
3531
|
+
)
|
|
2500
3532
|
p_fin.add_argument("--json", action="store_true", default=True)
|
|
2501
3533
|
p_fin.set_defaults(func=cmd_finalize_cli)
|
|
2502
3534
|
|
|
@@ -2506,6 +3538,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2506
3538
|
p_rep.add_argument("--json", action="store_true", default=True)
|
|
2507
3539
|
p_rep.set_defaults(func=cmd_replay_cli)
|
|
2508
3540
|
|
|
3541
|
+
p_repair = sub.add_parser(
|
|
3542
|
+
"repair", help="versioned repair: validated derived version, original untouched"
|
|
3543
|
+
)
|
|
3544
|
+
p_repair.add_argument("--archive-dir", required=True)
|
|
3545
|
+
p_repair.add_argument("--json", action="store_true", default=True)
|
|
3546
|
+
p_repair.set_defaults(func=cmd_repair_cli)
|
|
3547
|
+
|
|
2509
3548
|
return parser
|
|
2510
3549
|
|
|
2511
3550
|
|