okstra 0.206.0 → 0.207.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli-registry.mjs +7 -1
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +1 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -0
- package/docs/architecture.md +40 -16
- package/docs/cli.md +17 -15
- package/docs/contributor-change-matrix.md +3 -2
- package/docs/performance-improvement-plan-v2.md +1 -1
- package/docs/project-structure-overview.md +43 -20
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/code-review.json +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/bin/okstra-compact-reminder.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/direction-selection-worker.json +1 -1
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/lead/adapters/cmux.md +4 -3
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +44 -12
- package/runtime/prompts/lead/okstra-lead-contract.md +44 -73
- package/runtime/prompts/lead/phase-routing.md +64 -0
- package/runtime/prompts/lead/report-writer.md +10 -8
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_coverage-critic.md +1 -1
- package/runtime/prompts/profiles/forbidden-actions.json +0 -94
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +1 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +10 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +0 -39
- package/runtime/python/okstra_ctl/analysis_scope.py +31 -0
- package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
- package/runtime/python/okstra_ctl/asset_roots.py +19 -0
- package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
- package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
- package/runtime/python/okstra_ctl/code_review_target.py +92 -6
- package/runtime/python/okstra_ctl/consumers.py +12 -0
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
- package/runtime/python/okstra_ctl/dispatch_state.py +34 -5
- package/runtime/python/okstra_ctl/doctor.py +2 -1
- package/runtime/python/okstra_ctl/domain/provider.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
- package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +46 -9
- package/runtime/python/okstra_ctl/handoff.py +11 -466
- package/runtime/python/okstra_ctl/handoff_error.py +5 -0
- package/runtime/python/okstra_ctl/implementation_direction.py +0 -477
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +13 -1
- package/runtime/python/okstra_ctl/lead_progress.py +33 -1
- package/runtime/python/okstra_ctl/manager_view.py +26 -19
- package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
- package/runtime/python/okstra_ctl/models.py +4 -1
- package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
- package/runtime/python/okstra_ctl/option_comparison.py +3 -165
- package/runtime/python/okstra_ctl/option_votes.py +3 -191
- package/runtime/python/okstra_ctl/paths.py +8 -6
- package/runtime/python/okstra_ctl/phases/catalog.py +56 -12
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/entry.py +39 -0
- package/runtime/python/okstra_ctl/{report_html/view_models/change_impact_analysis.py → phases/change_impact_analysis/report.py} +3 -3
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/spec.md +26 -0
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/validation.py +23 -0
- package/runtime/python/okstra_ctl/phases/error_analysis/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/error_analysis/boundary.json +9 -0
- package/runtime/{prompts/profiles/error-analysis.md → python/okstra_ctl/phases/error_analysis/profile.md} +2 -2
- package/runtime/python/okstra_ctl/{report_html/view_models/error_analysis.py → phases/error_analysis/report.py} +9 -8
- package/runtime/{templates/reports → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/error_analysis/spec.md +118 -0
- package/runtime/python/okstra_ctl/phases/error_analysis/validation.py +241 -0
- package/runtime/python/okstra_ctl/phases/feature_analysis/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/feature_analysis/boundary.json +8 -0
- package/runtime/python/okstra_ctl/phases/feature_analysis/entry.py +63 -0
- package/runtime/python/okstra_ctl/{report_html/view_models/feature_analysis.py → phases/feature_analysis/report.py} +12 -5
- package/runtime/python/okstra_ctl/phases/feature_analysis/spec.md +22 -0
- package/runtime/python/okstra_ctl/phases/feature_analysis/validation.py +27 -0
- package/runtime/python/okstra_ctl/phases/feature_analysis/wizard.py +95 -0
- package/runtime/python/okstra_ctl/phases/final_verification/boundary.json +8 -0
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -2
- package/runtime/{templates/reports → python/okstra_ctl/phases/final_verification/report_assets}/final-verification-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/spec.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/boundary.json +17 -0
- package/runtime/python/okstra_ctl/{implementation_stage.py → phases/implementation/entry.py} +22 -10
- package/runtime/{prompts/host-orchestration/implementation.md → python/okstra_ctl/phases/implementation/host-rules.md} +1 -1
- package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-deliverable.md +1 -1
- package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-executor.md +4 -3
- package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-verifier.md +18 -7
- package/runtime/{prompts/profiles/implementation.md → python/okstra_ctl/phases/implementation/profile.md} +5 -5
- package/runtime/python/okstra_ctl/{report_html/view_models/implementation.py → phases/implementation/report.py} +3 -3
- package/runtime/{templates/reports → python/okstra_ctl/phases/implementation/report_assets}/implementation-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/spec.md +238 -0
- package/runtime/python/okstra_ctl/phases/implementation/validation.py +205 -0
- package/runtime/python/okstra_ctl/phases/implementation/wizard.py +39 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +80 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +10 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/comparison.py +168 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +27 -0
- package/runtime/{prompts/profiles/implementation-option-selection.md → python/okstra_ctl/phases/implementation_option_selection/profile.md} +3 -3
- package/runtime/python/okstra_ctl/{report_html/view_models/implementation_option_selection.py → phases/implementation_option_selection/report.py} +2 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +83 -0
- package/runtime/python/okstra_ctl/{implementation_options.py → phases/implementation_option_selection/validation.py} +3 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/votes.py +194 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +2345 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +12 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/entry.py +161 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/guidance.py +178 -0
- package/runtime/{prompts/lead → python/okstra_ctl/phases/implementation_planning/instructions}/plan-body-verification.md +61 -51
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +3295 -0
- package/runtime/{prompts/profiles/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/profile.md} +74 -25
- package/runtime/python/okstra_ctl/phases/implementation_planning/report.py +237 -0
- package/runtime/{templates/reports → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning-input.template.md +2 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/spec.md +204 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/validation.py +597 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +166 -0
- package/runtime/python/okstra_ctl/phases/improvement_discovery/boundary.json +12 -0
- package/runtime/python/okstra_ctl/{improvement_lenses.py → phases/improvement_discovery/lenses.py} +1 -6
- package/runtime/{prompts/profiles/improvement-discovery.md → python/okstra_ctl/phases/improvement_discovery/profile.md} +5 -5
- package/runtime/python/okstra_ctl/{report_html/view_models/improvement_discovery.py → phases/improvement_discovery/report.py} +3 -3
- package/runtime/{templates/reports → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery-input.template.md +1 -2
- package/runtime/python/okstra_ctl/phases/improvement_discovery/spec.md +29 -0
- package/runtime/{validators/validate_improvement_report.py → python/okstra_ctl/phases/improvement_discovery/validation.py} +5 -14
- package/runtime/python/okstra_ctl/phases/project_analysis/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/project_analysis/boundary.json +8 -0
- package/runtime/python/okstra_ctl/phases/project_analysis/entry.py +11 -0
- package/runtime/python/okstra_ctl/{report_html/view_models/project_analysis.py → phases/project_analysis/report.py} +3 -3
- package/runtime/python/okstra_ctl/phases/project_analysis/spec.md +33 -0
- package/runtime/python/okstra_ctl/phases/project_analysis/validation.py +55 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +17 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +147 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +446 -0
- package/runtime/{prompts/profiles/release-handoff.md → python/okstra_ctl/phases/release_handoff/profile.md} +3 -3
- package/runtime/python/okstra_ctl/{report_html/view_models/release_handoff.py → phases/release_handoff/report.py} +3 -3
- package/runtime/{templates/reports → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +233 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/wizard.py +84 -0
- package/runtime/python/okstra_ctl/phases/requirements_discovery/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/requirements_discovery/boundary.json +9 -0
- package/runtime/{prompts/profiles/requirements-discovery.md → python/okstra_ctl/phases/requirements_discovery/profile.md} +2 -3
- package/runtime/python/okstra_ctl/{report_html/view_models/requirements_discovery.py → phases/requirements_discovery/report.py} +3 -3
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +132 -0
- package/runtime/{validators/validate_fanout.py → python/okstra_ctl/phases/requirements_discovery/validation.py} +11 -12
- package/runtime/python/okstra_ctl/phases/technical_verification/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/boundary.json +9 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +100 -0
- package/runtime/{prompts/profiles/technical-verification.md → python/okstra_ctl/phases/technical_verification/profile.md} +2 -2
- package/runtime/python/okstra_ctl/{report_html/view_models/technical_verification.py → phases/technical_verification/report.py} +2 -2
- package/runtime/python/okstra_ctl/phases/technical_verification/spec.md +37 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +90 -0
- package/runtime/python/okstra_ctl/plan_approval.py +70 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +2 -2130
- package/runtime/python/okstra_ctl/process_group.py +118 -0
- package/runtime/python/okstra_ctl/profile_show.py +3 -3
- package/runtime/python/okstra_ctl/render.py +15 -4
- package/runtime/python/okstra_ctl/report_assembly.py +28 -92
- package/runtime/python/okstra_ctl/report_finalize.py +106 -2
- package/runtime/python/okstra_ctl/report_html/context_links.py +1 -1
- package/runtime/python/okstra_ctl/report_projections.py +1 -36
- package/runtime/python/okstra_ctl/report_routing.py +23 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +4 -73
- package/runtime/python/okstra_ctl/report_validation_identity.py +38 -0
- package/runtime/python/okstra_ctl/report_views.py +1 -1
- package/runtime/python/okstra_ctl/run.py +68 -350
- package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
- package/runtime/python/okstra_ctl/stage_map.py +13 -0
- package/runtime/python/okstra_ctl/team.py +108 -9
- package/runtime/python/okstra_ctl/technical_verification_facts.py +52 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +31 -31
- package/runtime/python/okstra_ctl/wizard/api.py +18 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +3 -12
- package/runtime/python/okstra_ctl/wizard/registry.py +20 -12
- package/runtime/python/okstra_ctl/wizard/steps_analysis.py +0 -97
- package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +10 -263
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +2 -1
- package/runtime/python/okstra_ctl/work_categories.py +1 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +36 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
- package/runtime/python/okstra_ctl/worker_runner.py +21 -3
- package/runtime/python/okstra_ctl/workflow.py +26 -143
- package/runtime/python/okstra_ctl/write_policy.py +57 -7
- package/runtime/python/okstra_project/dirs.py +14 -0
- package/runtime/python/okstra_project/resolver.py +2 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +3 -3
- package/runtime/skills/okstra-code-review/SKILL.md +70 -32
- package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/templates/manager/view.template.html +18 -1
- package/runtime/templates/reports/quick-input.template.md +1 -1
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/validate-brief.py +2 -2
- package/runtime/validators/validate-run.py +299 -3940
- package/runtime/validators/validate_analysis_report.py +14 -126
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +0 -147
- package/runtime/python/okstra_ctl/technical_verification.py +0 -195
- /package/runtime/{prompts/profiles/change-impact-analysis.json → python/okstra_ctl/phases/change_impact_analysis/profile.json} +0 -0
- /package/runtime/{prompts/profiles/change-impact-analysis.md → python/okstra_ctl/phases/change_impact_analysis/profile.md} +0 -0
- /package/runtime/{templates/reports → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis-input.template.md +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/change_impact_analysis/report_assets}/change-impact-analysis.template.md +0 -0
- /package/runtime/{prompts/profiles/error-analysis.json → python/okstra_ctl/phases/error_analysis/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/error_analysis/report_assets}/error-analysis.template.md +0 -0
- /package/runtime/{prompts/profiles/feature-analysis.json → python/okstra_ctl/phases/feature_analysis/profile.json} +0 -0
- /package/runtime/{prompts/profiles/feature-analysis.md → python/okstra_ctl/phases/feature_analysis/profile.md} +0 -0
- /package/runtime/{templates/reports → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis-input.template.md +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/feature_analysis/report_assets}/feature-analysis.template.md +0 -0
- /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-diff-review.md +0 -0
- /package/runtime/{prompts/profiles → python/okstra_ctl/phases/implementation/instructions}/_implementation-self-check.md +0 -0
- /package/runtime/{prompts/profiles/implementation.json → python/okstra_ctl/phases/implementation/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation/report_assets}/implementation.template.md +0 -0
- /package/runtime/{prompts/profiles/implementation-option-selection.json → python/okstra_ctl/phases/implementation_option_selection/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_option_selection/report_assets}/implementation-option-selection.template.md +0 -0
- /package/runtime/{prompts/host-orchestration/implementation-planning.md → python/okstra_ctl/phases/implementation_planning/host-rules.md} +0 -0
- /package/runtime/{prompts/profiles/implementation-planning.json → python/okstra_ctl/phases/implementation_planning/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/implementation_planning/report_assets}/implementation-planning.template.md +0 -0
- /package/runtime/{prompts/profiles/improvement-discovery.json → python/okstra_ctl/phases/improvement_discovery/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/improvement_discovery/report_assets}/improvement-discovery.template.md +0 -0
- /package/runtime/{prompts/profiles/project-analysis.json → python/okstra_ctl/phases/project_analysis/profile.json} +0 -0
- /package/runtime/{prompts/profiles/project-analysis.md → python/okstra_ctl/phases/project_analysis/profile.md} +0 -0
- /package/runtime/{templates/reports → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis-input.template.md +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/project_analysis/report_assets}/project-analysis.template.md +0 -0
- /package/runtime/{prompts/profiles/release-handoff.json → python/okstra_ctl/phases/release_handoff/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/release_handoff/report_assets}/release-handoff.template.md +0 -0
- /package/runtime/python/okstra_ctl/{fanout.py → phases/requirements_discovery/fanout.py} +0 -0
- /package/runtime/{prompts/profiles/requirements-discovery.json → python/okstra_ctl/phases/requirements_discovery/profile.json} +0 -0
- /package/runtime/{templates/reports → python/okstra_ctl/phases/requirements_discovery/report_assets}/fan-out-unit.template.md +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/requirements_discovery/report_assets}/requirements-discovery.template.md +0 -0
- /package/runtime/{prompts/profiles/technical-verification.json → python/okstra_ctl/phases/technical_verification/profile.json} +0 -0
- /package/runtime/{templates/reports/html/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.html +0 -0
- /package/runtime/{templates/reports/md/tasks → python/okstra_ctl/phases/technical_verification/report_assets}/technical-verification.template.md +0 -0
|
@@ -0,0 +1,3295 @@
|
|
|
1
|
+
"""구현 계획 본문·재검증·승인 근거의 전용 판정."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from okstra_ctl.conformance import ( # noqa: E402
|
|
14
|
+
declared_stage_surface_gaps,
|
|
15
|
+
exempt_stage_surface_conflicts,
|
|
16
|
+
parse_conformance_tests as _parse_conformance_tests,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from okstra_ctl.build_tools import ( # noqa: E402
|
|
21
|
+
command_invokes_build_tool,
|
|
22
|
+
resolve_build_tool_tokens,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
27
|
+
clarification_disposition,
|
|
28
|
+
incorporated_clarification_ids,
|
|
29
|
+
row_blocks_progress,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
from okstra_ctl.final_report_paths import ( # noqa: E402
|
|
34
|
+
final_report_data_path as _data_path_for,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
from okstra_ctl.plan_items import ( # noqa: E402
|
|
39
|
+
CRITIC_WORKER_ID,
|
|
40
|
+
advisory_plan_body_gating,
|
|
41
|
+
requires_plan_repair,
|
|
42
|
+
analyser_key as _analyser_key,
|
|
43
|
+
is_critic_worker,
|
|
44
|
+
lead_decision_basis,
|
|
45
|
+
self_fix_rounds,
|
|
46
|
+
stage_scope_bucket as _item_stage_scope_bucket,
|
|
47
|
+
voting_analyser_keys,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
from okstra_ctl.incremental_scope import ( # noqa: E402
|
|
52
|
+
coverage_row_blocked_on,
|
|
53
|
+
stages_for_clarification,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
from okstra_ctl.design_prep import ( # noqa: E402
|
|
58
|
+
DesignPrepError,
|
|
59
|
+
_planning_seq as _design_prep_planning_seq,
|
|
60
|
+
_render_request as _render_design_prep_request,
|
|
61
|
+
_report_language as _design_prep_report_language,
|
|
62
|
+
_request_identity as _design_prep_request_identity,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
from okstra_ctl.plan_items import ( # noqa: E402
|
|
70
|
+
expected_plan_item_ids,
|
|
71
|
+
extract_plan_items,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
from okstra_ctl.report_validation_identity import _report_run_seq, _report_task_type
|
|
76
|
+
|
|
77
|
+
# Plan-body gate outcomes ranked by how favorable each is to approval.
|
|
78
|
+
# A higher rank claims a healthier verification result. The recompute check
|
|
79
|
+
# below fails only when the *declared* gate outranks what the recorded
|
|
80
|
+
# per-worker verdicts support — i.e. the lead claimed a better outcome than
|
|
81
|
+
# the votes justify. A lead writing a conservatively *worse* gate is allowed,
|
|
82
|
+
# so genuine edge cases in this recompute never manufacture false failures.
|
|
83
|
+
_PLAN_GATE_RANK = {
|
|
84
|
+
"aborted-non-result": 0,
|
|
85
|
+
"blocked-by-disagreement": 0,
|
|
86
|
+
"passed-with-dissent": 1,
|
|
87
|
+
"passed": 2,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
|
|
92
|
+
# needed), because the defect is concrete, safety-critical, and adversarially
|
|
93
|
+
# verifiable: `a` = cited path/symbol mismatch. `b`/`c`/`e` still need a
|
|
94
|
+
# majority — `b` in particular is prone to planning-vs-implementation
|
|
95
|
+
# environment false positives.
|
|
96
|
+
_SINGLE_VOTE_BLOCKING_KINDS = {"a"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Rollback ordering (`d`) is executed by a human, not by okstra's workers or
|
|
100
|
+
# verifiers, so a rollback-ordering dissent is recorded but never gates
|
|
101
|
+
# approval: it is dropped from the blocking-disagree tally entirely, so an item
|
|
102
|
+
# whose only DISAGREEs are advisory-only can never rise above `has-dissent`.
|
|
103
|
+
_ADVISORY_ONLY_KINDS = {"d"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Stop reasons that justify promoting a still-broken planner-fixable item to the
|
|
107
|
+
# user: the self-fix budget ran out, or a round produced no net resolution so
|
|
108
|
+
# further rounds would repeat themselves.
|
|
109
|
+
#
|
|
110
|
+
# `cause-group-recurrence` is the legacy spelling of that same exhaustion
|
|
111
|
+
# (plan-body-verification.md "Loop termination"). A pre-activity-contract report
|
|
112
|
+
# carrying it is a report whose loop stopped because the cause kept recurring —
|
|
113
|
+
# refusing it here left such a run with no exit at all: the loop may not run
|
|
114
|
+
# again, and the surviving item may not be promoted either.
|
|
115
|
+
_SELF_FIX_EXHAUSTED_REASONS = frozenset(
|
|
116
|
+
{"max-rounds-reached", "no-progress", "cause-group-recurrence"}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_variation_point_item(item: dict) -> bool:
|
|
121
|
+
"""Whether this is a `P-Var-*` variation-point item, which is majority-gated
|
|
122
|
+
(`prompts/lead/plan-body-verification.md` "`P-Var-<N>` … is majority-gated").
|
|
123
|
+
Whether a behavior has two implementations, and whether the plan extracted the
|
|
124
|
+
right interface for it, is a design judgement — it lacks the concrete certainty
|
|
125
|
+
of kind `a`, where a verifier points at two spelled-out references that
|
|
126
|
+
contradict each other. So kind `a` carries no extra weight on a P-Var item: it
|
|
127
|
+
neither single-vote-blocks nor counts as correctness-critical, exactly like the
|
|
128
|
+
`b` / `c` / `e` kinds the prompt routes P-Var defects to. Only a
|
|
129
|
+
`majority-disagree` gates it — that part is unchanged.
|
|
130
|
+
"""
|
|
131
|
+
return str(item.get("id") or "").upper().startswith("P-VAR")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
|
|
135
|
+
"""이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
|
|
136
|
+
return [
|
|
137
|
+
row
|
|
138
|
+
for row in (item.get("verdicts") or [])
|
|
139
|
+
if isinstance(row, dict)
|
|
140
|
+
and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
|
|
141
|
+
and str(row.get("breakageKind") or "").strip().lower() in kinds
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
|
|
146
|
+
"""1표 차단이 성립하는지.
|
|
147
|
+
|
|
148
|
+
1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
|
|
149
|
+
실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
|
|
150
|
+
1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
|
|
151
|
+
않는다" 라고 쓰기만 하면 그대로 차단이 됐다.
|
|
152
|
+
|
|
153
|
+
이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
|
|
154
|
+
막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.
|
|
155
|
+
|
|
156
|
+
아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
|
|
157
|
+
없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
|
|
158
|
+
작동하고, 선언한 주장만 재현을 요구받는다.
|
|
159
|
+
"""
|
|
160
|
+
dissents = _single_vote_dissents(item, kinds)
|
|
161
|
+
declared = [row for row in dissents if row.get("claimKind")]
|
|
162
|
+
if not declared:
|
|
163
|
+
return bool(dissents)
|
|
164
|
+
return any(
|
|
165
|
+
str(row.get("claimKind") or "") == "fact"
|
|
166
|
+
and str(row.get("reproductionResult") or "") == "reproduced"
|
|
167
|
+
for row in declared
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _critic_non_error_verdicts(item: dict) -> list[dict]:
|
|
172
|
+
"""현재 기록된 비판 검토자의 최신 유효 판정."""
|
|
173
|
+
rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
|
|
174
|
+
critic = [
|
|
175
|
+
row
|
|
176
|
+
for row in rows
|
|
177
|
+
if is_critic_worker(row.get("worker", ""))
|
|
178
|
+
and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}
|
|
179
|
+
]
|
|
180
|
+
latest = max((row.get("round", 1) for row in critic), default=0)
|
|
181
|
+
return [row for row in critic if row.get("round", 1) == latest]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _critic_gate_class(item: dict) -> str | None:
|
|
185
|
+
"""비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
|
|
186
|
+
critic = _critic_non_error_verdicts(item)
|
|
187
|
+
if not critic:
|
|
188
|
+
return None
|
|
189
|
+
critic_dissent = [
|
|
190
|
+
row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"
|
|
191
|
+
]
|
|
192
|
+
if critic_dissent:
|
|
193
|
+
if str(item.get("id", "")).upper().startswith("P-RB"):
|
|
194
|
+
return "has-dissent"
|
|
195
|
+
return (
|
|
196
|
+
"majority-disagree"
|
|
197
|
+
if any(
|
|
198
|
+
str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
|
|
199
|
+
for row in critic_dissent
|
|
200
|
+
)
|
|
201
|
+
else "has-dissent"
|
|
202
|
+
)
|
|
203
|
+
dissent = any(
|
|
204
|
+
str(row.get("verdict", "")).upper() == "DISAGREE"
|
|
205
|
+
for row in item.get("verdicts", [])
|
|
206
|
+
)
|
|
207
|
+
return "has-dissent" if dissent else "full-consensus"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _classify_plan_item_gate(item: dict) -> str:
|
|
211
|
+
"""Recompute one plan item's gate class from its per-worker verdicts,
|
|
212
|
+
per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
|
|
213
|
+
``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
|
|
214
|
+
``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
|
|
215
|
+
(``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
|
|
216
|
+
``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
|
|
217
|
+
advisory-only, rollback items, and a single-vote kind that lost its
|
|
218
|
+
reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
|
|
219
|
+
settles it.
|
|
220
|
+
"""
|
|
221
|
+
corrected = _critic_gate_class(item)
|
|
222
|
+
if corrected is not None:
|
|
223
|
+
return corrected
|
|
224
|
+
tokens = [
|
|
225
|
+
(
|
|
226
|
+
str(v.get("verdict") or "").strip().upper(),
|
|
227
|
+
str(v.get("breakageKind") or "").strip().lower(),
|
|
228
|
+
)
|
|
229
|
+
for v in (item.get("verdicts") or [])
|
|
230
|
+
if isinstance(v, dict) and not is_critic_worker(str(v.get("worker") or ""))
|
|
231
|
+
]
|
|
232
|
+
non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
|
|
233
|
+
if not non_error:
|
|
234
|
+
return "all-non-result"
|
|
235
|
+
disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
|
|
236
|
+
agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
|
|
237
|
+
if not disagree:
|
|
238
|
+
return "full-consensus"
|
|
239
|
+
# Rollback is a human-run operation, so rollback dissent never blocks the
|
|
240
|
+
# gate — closed from two angles so a verifier cannot re-block it by relabelling:
|
|
241
|
+
# (1) a whole rollback plan item (`P-Rb-*`) is advisory regardless of
|
|
242
|
+
# breakage kind — otherwise a `DISAGREE(b)` "rollback command is
|
|
243
|
+
# ambiguous" would sail past the kind-`d` exemption and block;
|
|
244
|
+
# (2) a rollback-ordering dissent (`d`) is advisory on ANY item, since a
|
|
245
|
+
# rollback-order defect raised against a non-rollback item is still a
|
|
246
|
+
# human-run concern.
|
|
247
|
+
# Both are recorded as dissent and fold into `has-dissent`, never blocking.
|
|
248
|
+
if str(item.get("id") or "").upper().startswith("P-RB"):
|
|
249
|
+
return "has-dissent"
|
|
250
|
+
blocking_disagree = [
|
|
251
|
+
(vd, bk) for (vd, bk) in disagree if bk not in _ADVISORY_ONLY_KINDS
|
|
252
|
+
]
|
|
253
|
+
if not blocking_disagree:
|
|
254
|
+
return "has-dissent"
|
|
255
|
+
blocking_kinds = {bk for (_vd, bk) in blocking_disagree if bk}
|
|
256
|
+
# Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
|
|
257
|
+
# safety-critical, adversarially-verifiable defect is enough to block, even
|
|
258
|
+
# in a two-worker roster — a lone correct dissent must not be outvoted here.
|
|
259
|
+
# `a` for any item except `P-Var-*` (majority-gated, see
|
|
260
|
+
# `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
|
|
261
|
+
is_req = str(item.get("id") or "").upper().startswith("P-REQ")
|
|
262
|
+
single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
|
|
263
|
+
if (
|
|
264
|
+
not _is_variation_point_item(item)
|
|
265
|
+
and blocking_kinds & single_vote_kinds
|
|
266
|
+
and _single_vote_block_survives(item, single_vote_kinds)
|
|
267
|
+
):
|
|
268
|
+
# "One confirmed DISAGREE" presupposes the item was actually
|
|
269
|
+
# cross-verified. When the peer returned a non-result nothing confirmed
|
|
270
|
+
# the dissent, so blocking here would reproduce the same
|
|
271
|
+
# worker-failure-makes-the-gate-stricter paradox the majority branch
|
|
272
|
+
# below guards against. Route it to a re-verify round instead.
|
|
273
|
+
if len(non_error) < 2:
|
|
274
|
+
return "needs-reverify"
|
|
275
|
+
return "majority-disagree"
|
|
276
|
+
# Otherwise a genuine majority is required — and a majority needs at least
|
|
277
|
+
# two participating votes, so a lone surviving DISAGREE (its peer returned a
|
|
278
|
+
# non-result) does NOT block. That fixes the paradox where a worker failure
|
|
279
|
+
# made the gate stricter than a healthy roster would.
|
|
280
|
+
if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
|
|
281
|
+
return "majority-disagree"
|
|
282
|
+
if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
|
|
283
|
+
return "needs-reverify"
|
|
284
|
+
if (
|
|
285
|
+
len(non_error) >= 2
|
|
286
|
+
and blocking_disagree
|
|
287
|
+
and (not (blocking_kinds & single_vote_kinds) or _is_variation_point_item(item))
|
|
288
|
+
):
|
|
289
|
+
# 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
|
|
290
|
+
# 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
|
|
291
|
+
# 근거를 잃었으므로 이 분기에 안 들어온다.
|
|
292
|
+
return "majority-disagree"
|
|
293
|
+
return "has-dissent"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_even_analyser_split(item: dict) -> bool:
|
|
297
|
+
tokens = [
|
|
298
|
+
str(row.get("verdict") or "").strip().upper()
|
|
299
|
+
for row in (item.get("verdicts") or [])
|
|
300
|
+
if isinstance(row, dict)
|
|
301
|
+
and not is_critic_worker(str(row.get("worker") or ""))
|
|
302
|
+
and str(row.get("verdict") or "").strip().upper()
|
|
303
|
+
not in ("", "VERIFICATION-ERROR")
|
|
304
|
+
]
|
|
305
|
+
if len(tokens) < 2:
|
|
306
|
+
return False
|
|
307
|
+
disagree = sum(1 for token in tokens if token == "DISAGREE")
|
|
308
|
+
agree = sum(1 for token in tokens if token in {"AGREE", "SUPPLEMENT"})
|
|
309
|
+
return disagree == agree and disagree > 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_unsettled_tie(item: dict) -> bool:
|
|
313
|
+
"""분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
|
|
314
|
+
return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _disagree_breakage_kinds(item: dict) -> set[str]:
|
|
318
|
+
return {
|
|
319
|
+
str(v.get("breakageKind") or "").strip().lower()
|
|
320
|
+
for v in (item.get("verdicts") or [])
|
|
321
|
+
if isinstance(v, dict)
|
|
322
|
+
and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
|
|
323
|
+
and str(v.get("breakageKind") or "").strip()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _has_planner_fixable_majority(item: dict) -> bool:
|
|
328
|
+
disagrees = [
|
|
329
|
+
v
|
|
330
|
+
for v in (item.get("verdicts") or [])
|
|
331
|
+
if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
|
|
332
|
+
]
|
|
333
|
+
fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
|
|
334
|
+
return bool(disagrees) and len(fixable) * 2 > len(disagrees)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _is_correctness_critical(item: dict) -> bool:
|
|
338
|
+
"""Whether this item's defect would make `implementation` produce wrong or
|
|
339
|
+
unsafe code — the single-vote-blocking kind `a` (cited path/symbol mismatch)
|
|
340
|
+
on any item but `P-Var-*`, or `f` (requirement-coverage mismatch) on a
|
|
341
|
+
`P-Req-*` item.
|
|
342
|
+
Kinds `b`/`c`/`e` are plan-prose defects: they degrade the document, not the
|
|
343
|
+
resulting code. Rollback ordering (`d`) is advisory — a human runs the
|
|
344
|
+
rollback — so it never counts as correctness-critical. A `P-Var-*` item is
|
|
345
|
+
majority-gated end to end, so a kind-`a` dissent on one is no more critical
|
|
346
|
+
than the `b`/`e` its defect should have been raised under; otherwise the same
|
|
347
|
+
mis-tag that no longer single-vote-blocks would still veto the downgrade.
|
|
348
|
+
"""
|
|
349
|
+
if _is_variation_point_item(item):
|
|
350
|
+
return False
|
|
351
|
+
kinds = _disagree_breakage_kinds(item)
|
|
352
|
+
is_req = str(item.get("id") or "").upper().startswith("P-REQ")
|
|
353
|
+
return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _self_fix_budget_exhausted(pbv: dict) -> bool:
|
|
357
|
+
rounds_applied = pbv.get("selfFixRoundsApplied")
|
|
358
|
+
return (
|
|
359
|
+
isinstance(rounds_applied, int)
|
|
360
|
+
and rounds_applied >= 1
|
|
361
|
+
and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _state_classification(item: dict, gate_class: str) -> str:
|
|
366
|
+
"""This item's `planItems[].rounds[].classification` for the state file.
|
|
367
|
+
|
|
368
|
+
Blocking-kind `dissent-isolated` / `partial-consensus` is already
|
|
369
|
+
`majority-disagree` at the gate. `has-dissent` that remains is advisory
|
|
370
|
+
or a single-vote kind that lost reproduction; the state file then splits
|
|
371
|
+
that remainder into `dissent-isolated` vs `partial-consensus`.
|
|
372
|
+
|
|
373
|
+
*gate_class* is passed in rather than recomputed so that the caller's
|
|
374
|
+
effective classification — which may have been downgraded by
|
|
375
|
+
`_is_dissent_downgraded` — is the one this translates.
|
|
376
|
+
|
|
377
|
+
`contested` never appears: it is only meaningful at `maxRounds > 1`, and at
|
|
378
|
+
the default `maxRounds=1` the round protocol folds any otherwise-unresolved
|
|
379
|
+
item into `partial-consensus`.
|
|
380
|
+
"""
|
|
381
|
+
if gate_class == "all-non-result":
|
|
382
|
+
# No non-error vote at all is the `needs-reverify` shape taken to its
|
|
383
|
+
# limit — "fewer than 2 participating votes" covers zero.
|
|
384
|
+
return "needs-reverify"
|
|
385
|
+
if gate_class != "has-dissent":
|
|
386
|
+
return gate_class
|
|
387
|
+
dissenting = sum(
|
|
388
|
+
1
|
|
389
|
+
for vote in (item.get("verdicts") or [])
|
|
390
|
+
if isinstance(vote, dict)
|
|
391
|
+
and str(vote.get("verdict") or "").strip().upper() == "DISAGREE"
|
|
392
|
+
)
|
|
393
|
+
return "dissent-isolated" if dissenting == 1 else "partial-consensus"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _clarification_ids_on_activity(activity: dict) -> set[str]:
|
|
397
|
+
refs: set[str] = set()
|
|
398
|
+
for key in ("clarificationRefs", "evidenceRefs"):
|
|
399
|
+
for value in activity.get(key) or []:
|
|
400
|
+
if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(
|
|
401
|
+
value
|
|
402
|
+
):
|
|
403
|
+
refs.add(value)
|
|
404
|
+
return refs
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _plan_item_ids_for_clarification(
|
|
408
|
+
row: dict,
|
|
409
|
+
context: dict,
|
|
410
|
+
data: dict,
|
|
411
|
+
) -> list[str]:
|
|
412
|
+
"""이 C 행이 가리키는 계획 항목.
|
|
413
|
+
|
|
414
|
+
계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
|
|
415
|
+
`evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
|
|
416
|
+
역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
|
|
417
|
+
"""
|
|
418
|
+
linked = [
|
|
419
|
+
item_id
|
|
420
|
+
for item_id in (context.get("planItemIds") or [])
|
|
421
|
+
if isinstance(item_id, str) and item_id
|
|
422
|
+
]
|
|
423
|
+
if linked:
|
|
424
|
+
return linked
|
|
425
|
+
row_id = str(row.get("id") or "")
|
|
426
|
+
if not row_id:
|
|
427
|
+
return []
|
|
428
|
+
singleton: list[str] = []
|
|
429
|
+
bulk: list[str] = []
|
|
430
|
+
for activity in data.get("agentActivity") or []:
|
|
431
|
+
if not isinstance(activity, dict):
|
|
432
|
+
continue
|
|
433
|
+
refs = _clarification_ids_on_activity(activity)
|
|
434
|
+
if row_id not in refs:
|
|
435
|
+
continue
|
|
436
|
+
ids = [
|
|
437
|
+
item_id
|
|
438
|
+
for item_id in (activity.get("planItemIds") or [])
|
|
439
|
+
if isinstance(item_id, str) and item_id
|
|
440
|
+
]
|
|
441
|
+
if refs == {row_id}:
|
|
442
|
+
singleton.extend(ids)
|
|
443
|
+
else:
|
|
444
|
+
bulk.extend(ids)
|
|
445
|
+
if singleton or bulk:
|
|
446
|
+
return singleton or bulk
|
|
447
|
+
items = (
|
|
448
|
+
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
449
|
+
).get("planItems") or []
|
|
450
|
+
return [
|
|
451
|
+
str(item.get("id") or "")
|
|
452
|
+
for item in items
|
|
453
|
+
if isinstance(item, dict)
|
|
454
|
+
and row_id
|
|
455
|
+
in {
|
|
456
|
+
ref for ref in (item.get("clarificationRefs") or []) if isinstance(ref, str)
|
|
457
|
+
}
|
|
458
|
+
and item.get("id")
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _user_accepted_plan_item_ids(data: dict) -> set[str]:
|
|
463
|
+
"""사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
|
|
464
|
+
|
|
465
|
+
DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
|
|
466
|
+
"""
|
|
467
|
+
accepted: set[str] = set()
|
|
468
|
+
for row in data.get("clarificationItems") or []:
|
|
469
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
470
|
+
continue
|
|
471
|
+
if row_blocks_progress(
|
|
472
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
473
|
+
):
|
|
474
|
+
continue
|
|
475
|
+
context = row.get("approvalContext")
|
|
476
|
+
if not isinstance(context, dict):
|
|
477
|
+
context = {}
|
|
478
|
+
accepted.update(_plan_item_ids_for_clarification(row, context, data))
|
|
479
|
+
return accepted
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
|
|
483
|
+
"""호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
|
|
484
|
+
return _user_accepted_plan_item_ids(data)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
|
|
488
|
+
"""자동 수정 이후의 설계 판단만 리드가 결정하며 사실·사용자 권한은 남긴다."""
|
|
489
|
+
classification = _classify_plan_item_gate(item)
|
|
490
|
+
votes = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
|
|
491
|
+
non_result = any(
|
|
492
|
+
row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes
|
|
493
|
+
)
|
|
494
|
+
if (
|
|
495
|
+
classification not in {"majority-disagree", "needs-reverify", "all-non-result"}
|
|
496
|
+
and not non_result
|
|
497
|
+
):
|
|
498
|
+
return None
|
|
499
|
+
if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
|
|
500
|
+
return None
|
|
501
|
+
disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
|
|
502
|
+
verified = item.get("contentHash")
|
|
503
|
+
if (
|
|
504
|
+
not self_fix_rounds(pbv)
|
|
505
|
+
or pbv.get("gating") is False
|
|
506
|
+
or not verified
|
|
507
|
+
or item.get("verifiedContentHash") != verified
|
|
508
|
+
or _is_correctness_critical(item)
|
|
509
|
+
or not disagrees
|
|
510
|
+
or len(voting_analyser_keys([item])) < 2
|
|
511
|
+
or non_result
|
|
512
|
+
or any(
|
|
513
|
+
row.get("claimKind") not in {None, "judgement"}
|
|
514
|
+
or row.get("fixability") != "planner-fixable"
|
|
515
|
+
or row.get("breakageKind") not in {"b", "c", "e"}
|
|
516
|
+
for row in disagrees
|
|
517
|
+
)
|
|
518
|
+
):
|
|
519
|
+
return "user"
|
|
520
|
+
return "lead"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _lead_decision_applies(item: dict, pbv: dict) -> bool:
|
|
524
|
+
decision = item.get("leadDecision")
|
|
525
|
+
return (
|
|
526
|
+
isinstance(decision, dict)
|
|
527
|
+
and bool(str(decision.get("decision") or "").strip())
|
|
528
|
+
and decision.get("basisHash") == lead_decision_basis(item)
|
|
529
|
+
and _plan_item_decision_authority(item, pbv) == "lead"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _is_dissent_downgraded(
|
|
534
|
+
item: dict,
|
|
535
|
+
pbv: dict,
|
|
536
|
+
accepted_item_ids: set[str],
|
|
537
|
+
) -> bool:
|
|
538
|
+
"""유효한 리드 결정 또는 사용자 진행 처분은 반대 표를 보존하며 차단을 해소한다."""
|
|
539
|
+
return _lead_decision_applies(item, pbv) or (
|
|
540
|
+
_classify_plan_item_gate(item) == "majority-disagree"
|
|
541
|
+
and str(item.get("id") or "") in accepted_item_ids
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _stage_scope_bucket(item: dict, pbv: dict) -> str:
|
|
546
|
+
"""Whether this item has standing to block the stage about to start.
|
|
547
|
+
|
|
548
|
+
The plan covers every stage; implementation runs one at a time. Judging all
|
|
549
|
+
of them at once means a defect in a stage nobody has reached, or in one
|
|
550
|
+
already frozen, stops the next stage from starting — and a frozen stage's
|
|
551
|
+
item cannot be fixed at all, because the Stage Ledger forbids editing its
|
|
552
|
+
commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
|
|
553
|
+
frozen.
|
|
554
|
+
|
|
555
|
+
Returns `in-scope` (may block), `observed` (only frozen stages), or
|
|
556
|
+
`deferred` (only stages not yet startable). Anything unresolvable is
|
|
557
|
+
`in-scope`: an absent ledger is no basis to narrow. Plan-wide items
|
|
558
|
+
(`P-Opt-*`, `P-Var-*`, `P-Dep-*`, `P-Dir-1`) with no `stageScope` stay
|
|
559
|
+
in-scope. An unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stays in-scope only
|
|
560
|
+
until a stage is `done`; after that it is `deferred` so a re-plan does not
|
|
561
|
+
re-score the whole checklist.
|
|
562
|
+
|
|
563
|
+
디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
|
|
564
|
+
항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
|
|
565
|
+
"""
|
|
566
|
+
ledger = pbv.get("stageLedger")
|
|
567
|
+
return _item_stage_scope_bucket(
|
|
568
|
+
item,
|
|
569
|
+
ledger if isinstance(ledger, dict) else None,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
|
|
574
|
+
"""Why this item stopped blocking, or ``None`` if it never did.
|
|
575
|
+
|
|
576
|
+
A gate that passes while defects were set aside has to say which ones and on
|
|
577
|
+
what grounds. Without that the two halves of the acceptance condition — the
|
|
578
|
+
next stage can start, and the known risks are written down — collapse into
|
|
579
|
+
the first, and a defect deferred for a good reason is indistinguishable in
|
|
580
|
+
the record from one nobody found.
|
|
581
|
+
"""
|
|
582
|
+
raw = (
|
|
583
|
+
"has-dissent"
|
|
584
|
+
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
585
|
+
else _classify_plan_item_gate(item)
|
|
586
|
+
)
|
|
587
|
+
if raw != "majority-disagree":
|
|
588
|
+
return None
|
|
589
|
+
bucket = _stage_scope_bucket(item, pbv)
|
|
590
|
+
if bucket != "in-scope":
|
|
591
|
+
return bucket
|
|
592
|
+
return "record" if str(item.get("block") or "") == "record" else None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
|
|
596
|
+
"""Every set-aside item, in id order, as the gate records them."""
|
|
597
|
+
register = [
|
|
598
|
+
{"id": str(item.get("id") or ""), "reason": reason}
|
|
599
|
+
for item in (pbv.get("planItems") or [])
|
|
600
|
+
if isinstance(item, dict)
|
|
601
|
+
for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
|
|
602
|
+
if reason is not None
|
|
603
|
+
]
|
|
604
|
+
return sorted(register, key=lambda row: row["id"])
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _plan_item_gate_class(
|
|
608
|
+
item: dict,
|
|
609
|
+
pbv: dict,
|
|
610
|
+
accepted_item_ids: set[str],
|
|
611
|
+
) -> str:
|
|
612
|
+
"""The gate class for one item, after stage scope is applied.
|
|
613
|
+
|
|
614
|
+
An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
|
|
615
|
+
gate still reads `passed-with-dissent` rather than `passed` and the record
|
|
616
|
+
says something is outstanding. Silently scoring it `passed` would hide the
|
|
617
|
+
defect instead of deferring it.
|
|
618
|
+
"""
|
|
619
|
+
classification = (
|
|
620
|
+
"has-dissent"
|
|
621
|
+
if _is_dissent_downgraded(item, pbv, accepted_item_ids)
|
|
622
|
+
else _classify_plan_item_gate(item)
|
|
623
|
+
)
|
|
624
|
+
if classification != "majority-disagree":
|
|
625
|
+
return classification
|
|
626
|
+
if _stage_scope_bucket(item, pbv) != "in-scope":
|
|
627
|
+
return "has-dissent"
|
|
628
|
+
if str(item.get("block") or "") == "record":
|
|
629
|
+
# 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
|
|
630
|
+
# 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
|
|
631
|
+
# `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
|
|
632
|
+
return "has-dissent"
|
|
633
|
+
return classification
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _recompute_plan_body_gate(
|
|
637
|
+
pbv: dict,
|
|
638
|
+
accepted_item_ids: set[str] | None = None,
|
|
639
|
+
) -> str | None:
|
|
640
|
+
"""Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
|
|
641
|
+
Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
|
|
642
|
+
no plan items to judge (disabled / empty round)."""
|
|
643
|
+
accepted = accepted_item_ids or set()
|
|
644
|
+
classes = [
|
|
645
|
+
_plan_item_gate_class(it, pbv, accepted)
|
|
646
|
+
for it in (pbv.get("planItems") or [])
|
|
647
|
+
if isinstance(it, dict)
|
|
648
|
+
and (_stage_scope_bucket(it, pbv) == "in-scope" or it.get("verdicts"))
|
|
649
|
+
]
|
|
650
|
+
if not classes:
|
|
651
|
+
return None
|
|
652
|
+
if all(c == "all-non-result" for c in classes):
|
|
653
|
+
return "aborted-non-result"
|
|
654
|
+
if pbv.get("gating") is False and not requires_plan_repair(pbv):
|
|
655
|
+
if any(
|
|
656
|
+
c
|
|
657
|
+
in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result")
|
|
658
|
+
for c in classes
|
|
659
|
+
):
|
|
660
|
+
return "passed-with-dissent"
|
|
661
|
+
return "passed"
|
|
662
|
+
if any(c == "majority-disagree" for c in classes):
|
|
663
|
+
return "blocked-by-disagreement"
|
|
664
|
+
if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
|
|
665
|
+
# `all-non-result` belongs here for the same reason `needs-reverify`
|
|
666
|
+
# does — it IS that shape with zero participating votes instead of one
|
|
667
|
+
# (`_state_classification` maps it there, and the contract's step 5
|
|
668
|
+
# lists `needs-reverify` under `passed-with-dissent`). Left out, an
|
|
669
|
+
# item no verifier could judge scored `passed`: the all-error case
|
|
670
|
+
# already reads `needs-reverify` in the state file while the gate it
|
|
671
|
+
# feeds says every item reached consensus.
|
|
672
|
+
return "passed-with-dissent"
|
|
673
|
+
return "passed"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _validate_plan_body_gate_recompute(
|
|
677
|
+
data: dict,
|
|
678
|
+
failures: list[str],
|
|
679
|
+
accepted_item_ids: set[str] | None = None,
|
|
680
|
+
) -> None:
|
|
681
|
+
"""H1 — the declared `Gate result` must not claim a healthier outcome than
|
|
682
|
+
the recorded per-worker verdicts support. Closes the forgery hole where a
|
|
683
|
+
lead writes `gateResult: passed` while workers actually voted DISAGREE:
|
|
684
|
+
the verdicts live in `planItems[].verdicts`, so the gate is recomputable
|
|
685
|
+
and no longer depends on the lead's honesty alone.
|
|
686
|
+
"""
|
|
687
|
+
ip = data.get("implementationPlanning")
|
|
688
|
+
if not isinstance(ip, dict):
|
|
689
|
+
return
|
|
690
|
+
pbv = ip.get("planBodyVerification")
|
|
691
|
+
if not isinstance(pbv, dict):
|
|
692
|
+
return
|
|
693
|
+
declared = str(pbv.get("gateResult") or "").strip().lower()
|
|
694
|
+
accepted = (
|
|
695
|
+
_resolved_noncritical_dissent_ids(data)
|
|
696
|
+
if accepted_item_ids is None
|
|
697
|
+
else accepted_item_ids
|
|
698
|
+
)
|
|
699
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted)
|
|
700
|
+
if recomputed is None or declared not in _PLAN_GATE_RANK:
|
|
701
|
+
return
|
|
702
|
+
if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
|
|
703
|
+
failures.append(
|
|
704
|
+
"final-report data.json: implementationPlanning.planBodyVerification "
|
|
705
|
+
f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
|
|
706
|
+
f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
|
|
707
|
+
"a P-Req item, or all-non-result dispatches were recorded). The gate "
|
|
708
|
+
"value must honestly aggregate the worker votes — do not upgrade it "
|
|
709
|
+
"to unblock the run (plan-body-verification.md Round protocol)."
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _cited_clarification_id(row: dict) -> str | None:
|
|
714
|
+
"""The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
|
|
715
|
+
for field in ("status", "approvalDisposition"):
|
|
716
|
+
value = str(row.get(field) or "").strip()
|
|
717
|
+
if value.startswith("blocked "):
|
|
718
|
+
return value.split(" ", 1)[1].strip()
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _blocks_approval(row: dict) -> bool:
|
|
723
|
+
"""Whether one Requirement Coverage row blocks approval on its face, per
|
|
724
|
+
`prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
|
|
725
|
+
`gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
|
|
726
|
+
is blocked.
|
|
727
|
+
"""
|
|
728
|
+
status = str(row.get("status") or "").strip()
|
|
729
|
+
if status == "gap" or status.startswith("blocked C-"):
|
|
730
|
+
return True
|
|
731
|
+
disposition = str(row.get("approvalDisposition") or "").strip()
|
|
732
|
+
return status == "documented-deviation" and disposition.startswith("blocked C-")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _plan_item_clarification_ids(item: object) -> set[str]:
|
|
736
|
+
"""이 plan item 이 가리키는 `C-NNN` 들.
|
|
737
|
+
|
|
738
|
+
계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
|
|
739
|
+
`report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
|
|
740
|
+
유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
|
|
741
|
+
그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
|
|
742
|
+
파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
|
|
743
|
+
(`incremental_scope` 가 이미 그렇게 한다).
|
|
744
|
+
"""
|
|
745
|
+
if not isinstance(item, dict):
|
|
746
|
+
return set()
|
|
747
|
+
ids = {
|
|
748
|
+
str(ref).strip()
|
|
749
|
+
for ref in (item.get("clarificationRefs") or [])
|
|
750
|
+
if str(ref).strip()
|
|
751
|
+
}
|
|
752
|
+
single = item.get("clarificationId")
|
|
753
|
+
if isinstance(single, str) and single.strip():
|
|
754
|
+
ids.add(single.strip())
|
|
755
|
+
return ids
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
|
|
759
|
+
"""`C-NNN` ids this run's own plan-body round created by promoting a
|
|
760
|
+
majority-disagree item (step 8). Used to break the Requirement Coverage
|
|
761
|
+
↔ Clarification cycle: a coverage row citing one of these echoes a blocker
|
|
762
|
+
the gate already counted, rather than contributing an independent one.
|
|
763
|
+
"""
|
|
764
|
+
return {
|
|
765
|
+
clarification_id
|
|
766
|
+
for item in (pbv.get("planItems") or [])
|
|
767
|
+
for clarification_id in _plan_item_clarification_ids(item)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
|
|
772
|
+
"""Coverage rows that block the gate on their own — excluding rows whose
|
|
773
|
+
blocker is a `C-NNN` this same run's plan-body round promoted."""
|
|
774
|
+
promoted = _plan_body_promoted_clarification_ids(pbv)
|
|
775
|
+
return [
|
|
776
|
+
str(row.get("id") or "<unknown>")
|
|
777
|
+
for row in (ip.get("requirementCoverage") or [])
|
|
778
|
+
if isinstance(row, dict)
|
|
779
|
+
and _blocks_approval(row)
|
|
780
|
+
and _cited_clarification_id(row) not in promoted
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _gate_blocking_causes(
|
|
785
|
+
pbv: dict,
|
|
786
|
+
coverage_blockers: list[str],
|
|
787
|
+
accepted_item_ids: set[str] | None = None,
|
|
788
|
+
) -> set[str]:
|
|
789
|
+
"""Which inputs actually block approval, as `gateBlockedBy` enum values."""
|
|
790
|
+
causes = set()
|
|
791
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
|
|
792
|
+
if pbv.get("gating") is False and not requires_plan_repair(pbv):
|
|
793
|
+
if recomputed == "aborted-non-result":
|
|
794
|
+
causes.add("non-result")
|
|
795
|
+
return causes
|
|
796
|
+
if recomputed == "blocked-by-disagreement":
|
|
797
|
+
causes.add("majority-disagree")
|
|
798
|
+
elif recomputed == "aborted-non-result":
|
|
799
|
+
causes.add("non-result")
|
|
800
|
+
if coverage_blockers:
|
|
801
|
+
causes.add("coverage-gap")
|
|
802
|
+
return causes
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
_APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
_SELF_FIX_NOTE_ROUND_RE = re.compile(r"self-fixed in round\s*(\d+)", re.IGNORECASE)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _items_resolved_in_round(plan_items: object, round_number: int) -> set[str]:
|
|
812
|
+
resolved: set[str] = set()
|
|
813
|
+
for item in plan_items if isinstance(plan_items, list) else []:
|
|
814
|
+
if not isinstance(item, dict):
|
|
815
|
+
continue
|
|
816
|
+
match = _SELF_FIX_NOTE_ROUND_RE.search(str(item.get("selfFixNote") or ""))
|
|
817
|
+
if match and int(match.group(1)) == round_number:
|
|
818
|
+
resolved.add(str(item.get("id")))
|
|
819
|
+
return resolved
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _detect_self_fix_recurrence(pbv: dict) -> list[str]:
|
|
823
|
+
"""Rounds that re-target ground the previous round already worked, unresolved.
|
|
824
|
+
|
|
825
|
+
`no-progress` is judged at the round's end from what it resolved. Repeating
|
|
826
|
+
the previous round's *unresolved* remainder is the same conclusion reached
|
|
827
|
+
one dispatch earlier — the observed shape was two rounds spent on one
|
|
828
|
+
identical seven-item set. This names that shape so the loop can exit on it
|
|
829
|
+
rather than paying for the round that proves it.
|
|
830
|
+
|
|
831
|
+
Advisory only. Narrowing onto what the last round genuinely left open is
|
|
832
|
+
legitimate progress, and the rule separating that from re-digging the same
|
|
833
|
+
hole is not settled (design D-1), so this reports rather than fails.
|
|
834
|
+
"""
|
|
835
|
+
groups = pbv.get("selfFixGroups") if isinstance(pbv, dict) else None
|
|
836
|
+
if not isinstance(groups, list):
|
|
837
|
+
return []
|
|
838
|
+
by_round: dict[int, set[str]] = {}
|
|
839
|
+
for group in groups:
|
|
840
|
+
if not isinstance(group, dict) or not isinstance(group.get("round"), int):
|
|
841
|
+
continue
|
|
842
|
+
ids = {str(i) for i in group.get("itemIds") or []}
|
|
843
|
+
by_round.setdefault(group["round"], set()).update(ids)
|
|
844
|
+
|
|
845
|
+
warnings: list[str] = []
|
|
846
|
+
plan_items = pbv.get("planItems")
|
|
847
|
+
for round_number in sorted(by_round)[1:]:
|
|
848
|
+
previous = by_round.get(round_number - 1)
|
|
849
|
+
if not previous:
|
|
850
|
+
continue
|
|
851
|
+
unresolved = previous - _items_resolved_in_round(plan_items, round_number - 1)
|
|
852
|
+
current = by_round[round_number]
|
|
853
|
+
if current and current <= unresolved:
|
|
854
|
+
warnings.append(
|
|
855
|
+
f"self-fix round {round_number} re-targets only items round "
|
|
856
|
+
f"{round_number - 1} left unresolved ({', '.join(sorted(current))}) "
|
|
857
|
+
"— the previous round's correction did not move this cause. "
|
|
858
|
+
"Consider exiting with `no-progress` instead of spending the "
|
|
859
|
+
"remaining budget on the same ground."
|
|
860
|
+
)
|
|
861
|
+
return warnings
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
865
|
+
"""The gate's own arithmetic base, checked against the votes it ran on.
|
|
866
|
+
|
|
867
|
+
A majority over two votes and a majority over three are different claims,
|
|
868
|
+
and a shrunken roster loosens the gate silently: with two analysers,
|
|
869
|
+
1-AGREE/1-DISAGREE is a tie, so it never reaches `majority-disagree`. The
|
|
870
|
+
field only reports; the arithmetic is unchanged. It is recomputable from
|
|
871
|
+
the recorded verdicts, so a figure the table denies is a defect.
|
|
872
|
+
|
|
873
|
+
재계산은 `okstra_ctl.plan_items.voting_analyser_keys` 하나뿐이고, 기록하는
|
|
874
|
+
쪽(`okstra plan-items complete-round`)도 같은 함수를 부른다. 두 곳이 각자
|
|
875
|
+
세던 동안 생산자는 이번 라운드 큐만, 이쪽은 전 항목·전 라운드를 세서
|
|
876
|
+
critic 이 동수만 가른 라운드에서 값이 갈렸다.
|
|
877
|
+
"""
|
|
878
|
+
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
879
|
+
declared = pbv.get("participatingAnalysers")
|
|
880
|
+
if not isinstance(declared, dict):
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
rostered = declared.get("rostered")
|
|
884
|
+
voting = declared.get("voting")
|
|
885
|
+
if not isinstance(rostered, int) or not isinstance(voting, int):
|
|
886
|
+
failures.append(
|
|
887
|
+
"final-report data.json: planBodyVerification.participatingAnalysers "
|
|
888
|
+
"needs integer `rostered` and `voting`."
|
|
889
|
+
)
|
|
890
|
+
return
|
|
891
|
+
if voting > rostered:
|
|
892
|
+
failures.append(
|
|
893
|
+
"final-report data.json: planBodyVerification.participatingAnalysers "
|
|
894
|
+
f"claims {voting} voting of {rostered} rostered — more workers voted "
|
|
895
|
+
"than were on the roster."
|
|
896
|
+
)
|
|
897
|
+
return
|
|
898
|
+
|
|
899
|
+
observed = voting_analyser_keys(pbv.get("planItems") or [])
|
|
900
|
+
if observed and voting != len(observed):
|
|
901
|
+
failures.append(
|
|
902
|
+
"final-report data.json: planBodyVerification.participatingAnalysers "
|
|
903
|
+
f"declares {voting} voting analyser(s) but the recorded verdicts carry "
|
|
904
|
+
f"{len(observed)} ({', '.join(sorted(observed))}). A worker whose "
|
|
905
|
+
"dispatch returned no result is excluded from the gate arithmetic and "
|
|
906
|
+
"must not be counted here either."
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _validate_gate_blocked_by(
|
|
911
|
+
data: dict,
|
|
912
|
+
failures: list[str],
|
|
913
|
+
accepted_item_ids: set[str] | None = None,
|
|
914
|
+
) -> None:
|
|
915
|
+
"""선언된 `gateResult` 가 실제로 남아 있는 차단 원인과 맞는지 본다.
|
|
916
|
+
|
|
917
|
+
승인을 막는 입력은 둘이다 — `majority-disagree` 플랜 항목, 그리고
|
|
918
|
+
Requirement Coverage 의 `gap` / `blocked C-NNN` 행. 두 갈래를 남긴다:
|
|
919
|
+
(a) 막는 원인이 있는데 `passed` 계열을 선언한 경우, (b) 막는 원인이 하나도
|
|
920
|
+
없는데 차단 값을 그대로 둔 경우. 둘 다 실측 사고에서 나왔다 — (b) 는 gate
|
|
921
|
+
토큰이 1라운드 값에 멈춰 프로젝트 전체에서 run 을 못 열게 만들었다.
|
|
922
|
+
"""
|
|
923
|
+
ip = data.get("implementationPlanning")
|
|
924
|
+
if not isinstance(ip, dict):
|
|
925
|
+
return
|
|
926
|
+
pbv = ip.get("planBodyVerification")
|
|
927
|
+
if not isinstance(pbv, dict):
|
|
928
|
+
return
|
|
929
|
+
round_count = pbv.get("roundCount")
|
|
930
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
declared_gate = str(pbv.get("gateResult") or "").strip().lower()
|
|
934
|
+
coverage_blockers = _independent_coverage_blockers(ip, pbv)
|
|
935
|
+
accepted = (
|
|
936
|
+
_resolved_noncritical_dissent_ids(data)
|
|
937
|
+
if accepted_item_ids is None
|
|
938
|
+
else accepted_item_ids
|
|
939
|
+
)
|
|
940
|
+
actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)
|
|
941
|
+
|
|
942
|
+
if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
|
|
943
|
+
failures.append(
|
|
944
|
+
"final-report data.json: implementationPlanning.planBodyVerification "
|
|
945
|
+
f"`gateResult` is `{declared_gate}` but "
|
|
946
|
+
f"{sorted(actual_causes)} blocks approval "
|
|
947
|
+
f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
|
|
948
|
+
"Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
|
|
949
|
+
"of the worker verdicts (implementation-planning.md "
|
|
950
|
+
'§"Requirement Coverage").'
|
|
951
|
+
)
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
if not actual_causes and _PLAN_GATE_RANK.get(declared_gate) == 0:
|
|
955
|
+
# A blocking value with nothing left blocking it. The two checks around
|
|
956
|
+
# this one both walk from a recorded cause outward, so a gate that
|
|
957
|
+
# simply stopped being updated fell between them: a self-fix loop
|
|
958
|
+
# resolved every majority-disagree item, `gateBlockedBy` emptied
|
|
959
|
+
# correctly, and the gate token stayed at its round-1 value. The plan
|
|
960
|
+
# was approvable and nothing said so — run-prep refused the approval,
|
|
961
|
+
# and the refusal propagated far enough to take the run wizard down
|
|
962
|
+
# with it, so no run could be started in that project at all. Rescoring
|
|
963
|
+
# with `okstra plan-verify` and recording what it returns is the fix;
|
|
964
|
+
# the round is not complete until that call agrees with the report.
|
|
965
|
+
failures.append(
|
|
966
|
+
"final-report data.json: implementationPlanning.planBodyVerification "
|
|
967
|
+
f"`gateResult` is `{declared_gate}` but nothing blocks approval — "
|
|
968
|
+
"no plan item is `majority-disagree`, no dispatch was a non-result, "
|
|
969
|
+
"and no Requirement Coverage row blocks independently. A gate that "
|
|
970
|
+
"withholds approval with no recorded cause is almost always a value "
|
|
971
|
+
"left behind by an earlier round: rescore with `okstra plan-verify` "
|
|
972
|
+
"and record its `gate.recomputed` "
|
|
973
|
+
'(plan-body-verification.md §"Round protocol" step 5).'
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
# 선언 `gateBlockedBy` 집합과 재계산 집합을 대조하던 갈래는 삭제했다.
|
|
977
|
+
# 생산자(`okstra plan-items complete-round`)가 이 검증기의 계산 함수를
|
|
978
|
+
# 그대로 import 해 필드를 쓰므로 값이 갈릴 자리가 없고, 그 필드를 읽어
|
|
979
|
+
# 실행을 구동하는 소비자도 없다.
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _has_clarification_backtrace(
|
|
983
|
+
row_id: str, plan_items: object, coverage: object
|
|
984
|
+
) -> bool:
|
|
985
|
+
"""Whether the plan records anything this clarification blocks.
|
|
986
|
+
|
|
987
|
+
Two link shapes, both authored by the same run: the `P-*` plan item that
|
|
988
|
+
carries the `clarificationId`, and the requirement-coverage row blocked on
|
|
989
|
+
the id. `incremental-scope` resolves impacted stages from exactly these
|
|
990
|
+
two, and the coverage side goes through its predicate so the gate and the
|
|
991
|
+
resolver cannot disagree about what counts as a link.
|
|
992
|
+
"""
|
|
993
|
+
if isinstance(plan_items, list) and any(
|
|
994
|
+
row_id in _plan_item_clarification_ids(item) for item in plan_items
|
|
995
|
+
):
|
|
996
|
+
return True
|
|
997
|
+
return isinstance(coverage, list) and any(
|
|
998
|
+
coverage_row_blocked_on(row, row_id) for row in coverage
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _validate_approval_clarification_backtrace(data: dict, failures: list[str]) -> None:
|
|
1003
|
+
"""An approval blocker must record what it blocks.
|
|
1004
|
+
|
|
1005
|
+
`_validate_plan_body_clarification_matching` already walks the other
|
|
1006
|
+
direction — a majority-disagree plan item must cite a `blocks: approval`
|
|
1007
|
+
row. Nothing walked this way, so a row could withhold approval while
|
|
1008
|
+
recording no blast radius at all. The cost lands on the re-run:
|
|
1009
|
+
`incremental-scope` resolves impacted stages from these links and will not
|
|
1010
|
+
silently narrow past an id that traces to no stage, so this report fails
|
|
1011
|
+
rather than forcing a full re-run.
|
|
1012
|
+
|
|
1013
|
+
The link must also *resolve to a stage*, which is the thing the re-run
|
|
1014
|
+
actually reads. Checking only that a link exists let a row satisfy this
|
|
1015
|
+
gate while the next re-run still could not place the answer: `P-Req-*`
|
|
1016
|
+
and `P-Val-*` ids are numbered by position in their own array, so they
|
|
1017
|
+
carry no stage, and a blocked coverage row whose `coveredBy` is prose
|
|
1018
|
+
cites none either.
|
|
1019
|
+
"""
|
|
1020
|
+
if (data.get("header") or {}).get("taskType") != "implementation-planning":
|
|
1021
|
+
return
|
|
1022
|
+
planning = data.get("implementationPlanning")
|
|
1023
|
+
if not isinstance(planning, dict):
|
|
1024
|
+
return
|
|
1025
|
+
coverage = planning.get("requirementCoverage")
|
|
1026
|
+
verification = planning.get("planBodyVerification")
|
|
1027
|
+
plan_items = (
|
|
1028
|
+
verification.get("planItems") if isinstance(verification, dict) else None
|
|
1029
|
+
)
|
|
1030
|
+
for row in data.get("clarificationItems") or []:
|
|
1031
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
1032
|
+
continue
|
|
1033
|
+
if str(row.get("status") or "") in {"answered", "resolved"}:
|
|
1034
|
+
continue
|
|
1035
|
+
row_id = str(row.get("id") or "<unknown>")
|
|
1036
|
+
if not _has_clarification_backtrace(row_id, plan_items, coverage):
|
|
1037
|
+
failures.append(
|
|
1038
|
+
f"final-report data.json: clarification `{row_id}` blocks approval "
|
|
1039
|
+
"but has no back-trace into the plan — no plan item carries it as "
|
|
1040
|
+
"`clarificationId`, and no requirement-coverage row is `blocked "
|
|
1041
|
+
f"{row_id}` in its `status` or `approvalDisposition`. An item that "
|
|
1042
|
+
"withholds approval without recording what it affects cannot "
|
|
1043
|
+
"place the next re-run's scope; this report fails rather than "
|
|
1044
|
+
"forcing a full re-run."
|
|
1045
|
+
)
|
|
1046
|
+
continue
|
|
1047
|
+
if stages_for_clarification(data, row_id):
|
|
1048
|
+
continue
|
|
1049
|
+
failures.append(
|
|
1050
|
+
f"final-report data.json: clarification `{row_id}` blocks approval "
|
|
1051
|
+
"and is linked, but the link resolves to no stage. `incremental-"
|
|
1052
|
+
"scope` reads the stage from a `P-Step-<stage>.<step>` / `P-Prep-"
|
|
1053
|
+
"S<stage>-<kind>` plan-item id, from `stageScope` / `stageRefs` on "
|
|
1054
|
+
"the linked plan item or coverage row, or from a `Stage N` citation "
|
|
1055
|
+
f"in the blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` "
|
|
1056
|
+
"id carries no stage number, so a row linked only that way must "
|
|
1057
|
+
"carry `stageRefs` or cite the stage in `coveredBy`. A blocker "
|
|
1058
|
+
"whose blast radius resolves to no stage cannot auto-narrow the "
|
|
1059
|
+
"next re-run; this report fails rather than forcing a full re-run."
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
|
|
1064
|
+
"""A self-fix round must be instructed by cause, not as a flat item list.
|
|
1065
|
+
|
|
1066
|
+
Blocked items are usually several derivatives of one defect. Instructed
|
|
1067
|
+
item-by-item, each patch corrects its own section and leaves the sibling
|
|
1068
|
+
sections still asserting the old value, so the next round re-finds the same
|
|
1069
|
+
family and the budget drains without converging. Recording the grouping
|
|
1070
|
+
makes the lead commit to a diagnosis and makes a one-group-per-item
|
|
1071
|
+
non-diagnosis visible in the artifact rather than invisible in a prompt.
|
|
1072
|
+
|
|
1073
|
+
Recording rounds here also ties `selfFixRoundsApplied` to work that exists
|
|
1074
|
+
in the data: it was a free-floating self-reported integer, yet
|
|
1075
|
+
`_validate_self_fix_before_clarification` gates promotion on its value.
|
|
1076
|
+
"""
|
|
1077
|
+
ip = data.get("implementationPlanning")
|
|
1078
|
+
if not isinstance(ip, dict):
|
|
1079
|
+
return
|
|
1080
|
+
pbv = ip.get("planBodyVerification")
|
|
1081
|
+
if not isinstance(pbv, dict):
|
|
1082
|
+
return
|
|
1083
|
+
rounds_applied = pbv.get("selfFixRoundsApplied")
|
|
1084
|
+
if not isinstance(rounds_applied, int) or rounds_applied < 1:
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
|
|
1088
|
+
if not groups:
|
|
1089
|
+
failures.append(
|
|
1090
|
+
"final-report data.json: planBodyVerification declares "
|
|
1091
|
+
f"`selfFixRoundsApplied`={rounds_applied} but records no "
|
|
1092
|
+
"`selfFixGroups`. Each round's targets MUST be grouped by common "
|
|
1093
|
+
"cause before being handed to report-writer — a flat item list "
|
|
1094
|
+
"makes every patch leave its siblings' contradictions standing "
|
|
1095
|
+
'(plan-body-verification.md §"Round protocol" step 7).'
|
|
1096
|
+
)
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
|
|
1100
|
+
if len(set(rounds)) > 1:
|
|
1101
|
+
failures.append(
|
|
1102
|
+
"final-report data.json: automatic self-fix is limited to one rewrite; "
|
|
1103
|
+
"resolve remaining items through lead decisions or user confirmation."
|
|
1104
|
+
)
|
|
1105
|
+
if rounds and max(rounds) != rounds_applied:
|
|
1106
|
+
failures.append(
|
|
1107
|
+
"final-report data.json: planBodyVerification "
|
|
1108
|
+
f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
|
|
1109
|
+
f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
|
|
1110
|
+
"must be derivable from recorded work, not asserted independently of "
|
|
1111
|
+
"it — promotion eligibility is gated on this number."
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
known_ids = {
|
|
1115
|
+
str(item.get("id")).strip()
|
|
1116
|
+
for item in (pbv.get("planItems") or [])
|
|
1117
|
+
if isinstance(item, dict) and str(item.get("id") or "").strip()
|
|
1118
|
+
}
|
|
1119
|
+
grouped_ids = [
|
|
1120
|
+
str(item_id).strip()
|
|
1121
|
+
for group in groups
|
|
1122
|
+
for item_id in (group.get("itemIds") or [])
|
|
1123
|
+
if str(item_id or "").strip()
|
|
1124
|
+
]
|
|
1125
|
+
unknown = sorted({i for i in grouped_ids if i not in known_ids})
|
|
1126
|
+
if unknown:
|
|
1127
|
+
failures.append(
|
|
1128
|
+
"final-report data.json: planBodyVerification.selfFixGroups targets "
|
|
1129
|
+
f"plan item(s) {unknown} that do not exist in `planItems`."
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
fixed_ids = {
|
|
1133
|
+
str(item.get("id")).strip()
|
|
1134
|
+
for item in (pbv.get("planItems") or [])
|
|
1135
|
+
if isinstance(item, dict)
|
|
1136
|
+
and str(item.get("selfFixNote") or "").strip()
|
|
1137
|
+
and str(item.get("id") or "").strip()
|
|
1138
|
+
}
|
|
1139
|
+
ungrouped = sorted(fixed_ids - set(grouped_ids))
|
|
1140
|
+
if ungrouped:
|
|
1141
|
+
failures.append(
|
|
1142
|
+
"final-report data.json: plan item(s) "
|
|
1143
|
+
f"{ungrouped} carry a `selfFixNote` but appear in no "
|
|
1144
|
+
"`selfFixGroups` entry. Every item a round corrected must be "
|
|
1145
|
+
"attributable to the cause group it was instructed under."
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
_ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _answered_clarification_ids(data: dict) -> list[str]:
|
|
1153
|
+
"""Clarifications this run incorporated an answer for — the rows whose
|
|
1154
|
+
answers can invalidate statements the previous run wrote."""
|
|
1155
|
+
return [
|
|
1156
|
+
str(row.get("id")).strip()
|
|
1157
|
+
for row in (data.get("clarificationItems") or [])
|
|
1158
|
+
if isinstance(row, dict)
|
|
1159
|
+
and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
|
|
1160
|
+
and str(row.get("userInput") or "").strip()
|
|
1161
|
+
and str(row.get("id") or "").strip()
|
|
1162
|
+
]
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _validate_supersession_ledger(
|
|
1166
|
+
data: dict,
|
|
1167
|
+
failures: list[str],
|
|
1168
|
+
*,
|
|
1169
|
+
carried: dict | None = None,
|
|
1170
|
+
new_plan: bool = False,
|
|
1171
|
+
) -> None:
|
|
1172
|
+
"""Incorporating an answer means retiring what it invalidates, not only
|
|
1173
|
+
adding what it decides.
|
|
1174
|
+
|
|
1175
|
+
`new_plan` marks a plan built from a selected direction. Prepare seeds
|
|
1176
|
+
that run's ledger with every answer the option-selection record carried
|
|
1177
|
+
(2026-09-05), and a first plan has no earlier statement those answers
|
|
1178
|
+
could retire — an entry per carried row would be `no-dependent-statement`
|
|
1179
|
+
by construction. Those ids are exempt; answers the plan itself raised and
|
|
1180
|
+
settled still need their entry.
|
|
1181
|
+
|
|
1182
|
+
A re-run reconciles each `C-*` row's `Status` and writes the new decision
|
|
1183
|
+
into the plan, but nothing required it to remove the sentences the answer
|
|
1184
|
+
made false. The result is one plan carrying two opposite instructions for
|
|
1185
|
+
the same symbol — the implementer then has to guess which one is live, and
|
|
1186
|
+
the §5.5.9 round correctly blocks on it. This check makes the writer state,
|
|
1187
|
+
per answered clarification, what it retired or why nothing was contingent
|
|
1188
|
+
on that answer. The claim's *truth* is what the §5.5.9 adversarial round
|
|
1189
|
+
tests; this only forces the claim to exist and be attributable.
|
|
1190
|
+
"""
|
|
1191
|
+
ip = data.get("implementationPlanning")
|
|
1192
|
+
if not isinstance(ip, dict):
|
|
1193
|
+
return
|
|
1194
|
+
answered = set(_answered_clarification_ids(data))
|
|
1195
|
+
if new_plan:
|
|
1196
|
+
answered.difference_update((carried or {}).keys())
|
|
1197
|
+
else:
|
|
1198
|
+
answered.update((carried or {}).keys())
|
|
1199
|
+
if not answered:
|
|
1200
|
+
return
|
|
1201
|
+
ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
|
|
1202
|
+
covered = {
|
|
1203
|
+
str(entry.get("clarificationId") or "").strip()
|
|
1204
|
+
for entry in ledger
|
|
1205
|
+
if str(entry.get("clarificationId") or "").strip()
|
|
1206
|
+
}
|
|
1207
|
+
missing = [cid for cid in answered if cid not in covered]
|
|
1208
|
+
if missing:
|
|
1209
|
+
failures.append(
|
|
1210
|
+
"final-report data.json: implementationPlanning.supersessionLedger has "
|
|
1211
|
+
f"no entry for answered clarification(s) {sorted(missing)}. Every "
|
|
1212
|
+
"answer this run incorporated MUST record what it superseded "
|
|
1213
|
+
"(`disposition: superseded` with the retired statement and the "
|
|
1214
|
+
"sections revised) or state that no plan statement was contingent "
|
|
1215
|
+
"on it (`disposition: no-dependent-statement` with a rationale). "
|
|
1216
|
+
"Adding the new decision while leaving the contradicting sentence "
|
|
1217
|
+
"in place is what puts two opposite instructions in one plan "
|
|
1218
|
+
'(_common-contract.md §"clarification response carry-in").'
|
|
1219
|
+
)
|
|
1220
|
+
stale = covered - answered
|
|
1221
|
+
carry_in = data.get("clarificationCarryIn")
|
|
1222
|
+
# 이월 원장은 이전 런에서 받은 답을 이번 런이 반영한 기록이다.
|
|
1223
|
+
# 이번 런 clarificationItems 에 userInput 이 없다고 stale 로 보면
|
|
1224
|
+
# C-024 같은 이월 행이 "this run did not answer" 가 된다.
|
|
1225
|
+
if (
|
|
1226
|
+
stale
|
|
1227
|
+
and isinstance(carry_in, dict)
|
|
1228
|
+
and str(carry_in.get("sourceFile") or "").strip()
|
|
1229
|
+
):
|
|
1230
|
+
stale = set()
|
|
1231
|
+
if stale:
|
|
1232
|
+
failures.append(
|
|
1233
|
+
"final-report data.json: implementationPlanning.supersessionLedger "
|
|
1234
|
+
f"cites {sorted(stale)}, which this run did not answer. A ledger "
|
|
1235
|
+
"entry must correspond 1:1 to a clarification whose answer this "
|
|
1236
|
+
"run incorporated."
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
1241
|
+
"""A round that ran must leave the votes it ran on — item by item.
|
|
1242
|
+
|
|
1243
|
+
The gate is re-derived from `planItems[].verdicts[]`, so an empty table
|
|
1244
|
+
removes the very evidence the recompute judges. A *healthier* declared gate
|
|
1245
|
+
is already caught — empty verdicts recompute to `aborted-non-result`, which
|
|
1246
|
+
every passing value outranks. What slipped through was the conservative
|
|
1247
|
+
declaration: a lead writing `aborted-non-result` over an empty table
|
|
1248
|
+
produces a gate nothing can audit, indistinguishable from a round that was
|
|
1249
|
+
dispatched and whose results were never transcribed.
|
|
1250
|
+
|
|
1251
|
+
The per-item form is what survives a self-fix loop. Round 2+ queues are
|
|
1252
|
+
targeted, so an item the planner adds mid-loop and never puts in one keeps
|
|
1253
|
+
an empty `verdicts[]` while every neighbour carries votes — and nothing
|
|
1254
|
+
downstream reads that as a gap. An empty table classifies `all-non-result`
|
|
1255
|
+
(`_classify_plan_item_gate`), which states as `needs-reverify`, which
|
|
1256
|
+
`_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
|
|
1257
|
+
no verifier ever judged leaves the gate in a passing value. The whole-table
|
|
1258
|
+
check could not see it, since it stands down the moment any one item has a
|
|
1259
|
+
vote.
|
|
1260
|
+
|
|
1261
|
+
An unjudged item is distinguishable from a legitimately unresolved one, and
|
|
1262
|
+
the difference is what is recorded rather than what is missing. A peer that
|
|
1263
|
+
returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
|
|
1264
|
+
so an all-error item still carries rows and still folds to `needs-reverify`
|
|
1265
|
+
on purpose. An empty table means no dispatch was accounted for at all.
|
|
1266
|
+
"""
|
|
1267
|
+
ip = data.get("implementationPlanning")
|
|
1268
|
+
if not isinstance(ip, dict):
|
|
1269
|
+
return
|
|
1270
|
+
pbv = ip.get("planBodyVerification")
|
|
1271
|
+
if not isinstance(pbv, dict):
|
|
1272
|
+
return
|
|
1273
|
+
round_count = pbv.get("roundCount")
|
|
1274
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
1275
|
+
return
|
|
1276
|
+
items = [
|
|
1277
|
+
it
|
|
1278
|
+
for it in (pbv.get("planItems") or [])
|
|
1279
|
+
if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
|
|
1280
|
+
]
|
|
1281
|
+
if not items:
|
|
1282
|
+
return
|
|
1283
|
+
empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
|
|
1284
|
+
if not empty:
|
|
1285
|
+
return
|
|
1286
|
+
if len(empty) == len(items):
|
|
1287
|
+
failures.append(
|
|
1288
|
+
"final-report data.json: planBodyVerification declares "
|
|
1289
|
+
f"`roundCount`={round_count} but every one of the {len(items)} "
|
|
1290
|
+
"`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
|
|
1291
|
+
"record the votes it produced — the gate is re-derived from this "
|
|
1292
|
+
"table, so an empty one leaves the declared `gateResult` unauditable. "
|
|
1293
|
+
"A dispatch that returned nothing is recorded as `verification-error`, "
|
|
1294
|
+
'not omitted (plan-body-verification.md §"Round protocol" step 4).'
|
|
1295
|
+
)
|
|
1296
|
+
return
|
|
1297
|
+
shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
|
|
1298
|
+
more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
|
|
1299
|
+
failures.append(
|
|
1300
|
+
"final-report data.json: planBodyVerification declares "
|
|
1301
|
+
f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
|
|
1302
|
+
f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
|
|
1303
|
+
"extracted plan item MUST be judged by the round — an item with no "
|
|
1304
|
+
"vote at all is not a dissent the gate can weigh, it is a plan item "
|
|
1305
|
+
"nobody verified, and it currently folds into `passed-with-dissent` "
|
|
1306
|
+
"alongside items that were properly cross-checked. Either dispatch it "
|
|
1307
|
+
"in this round's queue, or record the non-result as a "
|
|
1308
|
+
"`verification-error` verdict per plan-body-verification.md "
|
|
1309
|
+
'§"Round protocol" step 3 — an item is never left with no row.'
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _plan_items_routed_to_a_user_decision(data: dict) -> set[str]:
|
|
1314
|
+
"""`blocks: approval` C 행과 이어진 계획 항목 id.
|
|
1315
|
+
|
|
1316
|
+
처분 여부는 보지 않는다. 행이 존재한다는 것 자체가 그 항목이 사용자 결정
|
|
1317
|
+
채널로 나갔다는 뜻이고, 아직 답이 없는 행은 `row_blocks_progress` 가
|
|
1318
|
+
승인을 막는다 — `_validate_v3_approval_context` 가 그 상태의 `approved:
|
|
1319
|
+
true` 를 거부한다. 링크는 양방향으로 읽는다: 항목 쪽 `clarificationRefs`
|
|
1320
|
+
와, 행 → 활동 원장 역추적(`_plan_item_ids_for_clarification`). 계약 3.0
|
|
1321
|
+
리포트는 후자로만 이어지는 경우가 있다.
|
|
1322
|
+
"""
|
|
1323
|
+
rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
|
|
1324
|
+
approval_rows = [
|
|
1325
|
+
row for row in rows if row.get("blocks") == "approval" and row.get("id")
|
|
1326
|
+
]
|
|
1327
|
+
approval_ids = {str(row["id"]) for row in approval_rows}
|
|
1328
|
+
linked: set[str] = set()
|
|
1329
|
+
items = (
|
|
1330
|
+
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
1331
|
+
).get("planItems") or []
|
|
1332
|
+
for item in items:
|
|
1333
|
+
if isinstance(item, dict) and _plan_item_clarification_ids(item) & approval_ids:
|
|
1334
|
+
linked.add(str(item.get("id") or "").strip())
|
|
1335
|
+
for row in approval_rows:
|
|
1336
|
+
context = row.get("approvalContext")
|
|
1337
|
+
linked.update(
|
|
1338
|
+
str(item_id).strip()
|
|
1339
|
+
for item_id in _plan_item_ids_for_clarification(
|
|
1340
|
+
row, context if isinstance(context, dict) else {}, data
|
|
1341
|
+
)
|
|
1342
|
+
)
|
|
1343
|
+
linked.discard("")
|
|
1344
|
+
return linked
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def _validate_unresolved_tie_was_reverified(
|
|
1348
|
+
data: dict,
|
|
1349
|
+
failures: list[str],
|
|
1350
|
+
) -> None:
|
|
1351
|
+
"""A split panel is settled by critic-worker or by the user, not by silence.
|
|
1352
|
+
|
|
1353
|
+
The gate needs a strict majority to block, so a panel splitting evenly on a
|
|
1354
|
+
blocking kind reaches neither consensus nor `majority-disagree`. That state
|
|
1355
|
+
is classified `needs-reverify`, which `_recompute_plan_body_gate` folds into
|
|
1356
|
+
`passed-with-dissent` — so without a settlement the split passes with nobody
|
|
1357
|
+
deciding it.
|
|
1358
|
+
|
|
1359
|
+
두 가지 해소가 있고 로스터가 어느 쪽인지 정한다. critic 이 배정된 run 은
|
|
1360
|
+
`critic-worker` 표가 가른다. critic 이 없는 로스터(`invocationAssignments`
|
|
1361
|
+
에 `critic/*` 없음, `okstra_ctl.plan_items.critic_is_rostered`)는 라운드
|
|
1362
|
+
안에 가를 표가 아예 없으므로 `next_dispatch` 가 `user-decision` 을 내고
|
|
1363
|
+
리드가 항목마다 승인 결정을 연다. 그 결정 행이 이 항목의 해소다. 검증기는
|
|
1364
|
+
로스터를 볼 수 없으므로(이 검사의 입력은 리포트 정본뿐) 둘 중 하나가
|
|
1365
|
+
기록되어 있으면 해소로 읽고, 둘 다 없을 때만 발화한다.
|
|
1366
|
+
|
|
1367
|
+
같은 조건을 다른 술어로 한 번 더 세던 두 번째 동수 검사를 여기로 합쳤다.
|
|
1368
|
+
그쪽 술어는 판정 행의 `round` 를 1 로 눌러 놓고 `_is_unsettled_tie` 를
|
|
1369
|
+
불렀는데, `_is_even_analyser_split` 는 `round` 를 보지 않으므로 두 술어의
|
|
1370
|
+
값이 언제나 같았다 — 같은 항목이 두 번 실패로 올라왔다. 여기 남은 조건이
|
|
1371
|
+
두 집합의 합집합이다.
|
|
1372
|
+
"""
|
|
1373
|
+
ip = data.get("implementationPlanning")
|
|
1374
|
+
if not isinstance(ip, dict):
|
|
1375
|
+
return
|
|
1376
|
+
pbv = ip.get("planBodyVerification")
|
|
1377
|
+
if not isinstance(pbv, dict):
|
|
1378
|
+
return
|
|
1379
|
+
# 사용자 결정 채널로 나간 항목은 제외한다. 진행 처분이 이미 붙은 항목
|
|
1380
|
+
# (accept-risk 등)도 여기 포함된다 — 리드 계약이 accept-risk 를 "게이트를
|
|
1381
|
+
# 끝내고 재검증 AGREE 를 요구하지 않는" 처분으로 정의하므로, 계속 실패를
|
|
1382
|
+
# 올리면 승인 처분으로 빠져나갈 수 없는 규칙이 된다.
|
|
1383
|
+
decided = _plan_items_routed_to_a_user_decision(data)
|
|
1384
|
+
unsettled = sorted(
|
|
1385
|
+
{
|
|
1386
|
+
str(item.get("id") or "").strip()
|
|
1387
|
+
for item in pbv.get("planItems") or []
|
|
1388
|
+
if isinstance(item, dict)
|
|
1389
|
+
and not item.get("carriedForwardFromSeq")
|
|
1390
|
+
and str(item.get("id") or "").strip() not in decided
|
|
1391
|
+
and not _lead_decision_applies(item, pbv)
|
|
1392
|
+
and _stage_scope_bucket(item, pbv) == "in-scope"
|
|
1393
|
+
and _is_unsettled_tie(item)
|
|
1394
|
+
}
|
|
1395
|
+
)
|
|
1396
|
+
if not unsettled:
|
|
1397
|
+
return
|
|
1398
|
+
failures.append(
|
|
1399
|
+
"final-report data.json: plan item(s) "
|
|
1400
|
+
f"{unsettled} carry an even split on a blocking breakage kind and "
|
|
1401
|
+
f"have no `{CRITIC_WORKER_ID}` vote and no `blocks: approval` "
|
|
1402
|
+
"clarification row. A tie is not consensus. With a critic on the "
|
|
1403
|
+
f"roster, dispatch `{CRITIC_WORKER_ID}` on those items only (`okstra "
|
|
1404
|
+
"plan-items prepare --tie-vote`), read its answer with `okstra "
|
|
1405
|
+
"plan-items collect-verdicts --items <the --tie-vote plan-items "
|
|
1406
|
+
f"artifact> --result {CRITIC_WORKER_ID}=<path> --output <envelope>`, "
|
|
1407
|
+
"then record it with `okstra plan-items apply-verdicts --append "
|
|
1408
|
+
"--round 2`. Skipping collect-verdicts and pointing apply-verdicts at "
|
|
1409
|
+
"the raw result is refused: this round's queue is the tie items, not "
|
|
1410
|
+
"the round's full dispatch queue. Critic AGREE settles the split; "
|
|
1411
|
+
"critic DISAGREE blocks. With no critic on the roster `okstra "
|
|
1412
|
+
"plan-items next-dispatch` answers `user-decision` instead: open one "
|
|
1413
|
+
"`okstra approval-decision open` per item (classification "
|
|
1414
|
+
"`noncritical-dissent`) and write the matching `## 1. Clarification "
|
|
1415
|
+
"Items` row, and that row settles the tie here while it gates approval."
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
|
|
1420
|
+
"""gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
|
|
1421
|
+
ip = data.get("implementationPlanning")
|
|
1422
|
+
if not isinstance(ip, dict):
|
|
1423
|
+
return
|
|
1424
|
+
pbv = ip.get("planBodyVerification")
|
|
1425
|
+
if not isinstance(pbv, dict) or pbv.get("gating") is not False:
|
|
1426
|
+
return
|
|
1427
|
+
facts = (
|
|
1428
|
+
ip.get("designPreparation") is not None
|
|
1429
|
+
or ip.get("stageMap")
|
|
1430
|
+
or ip.get("stages")
|
|
1431
|
+
)
|
|
1432
|
+
if requires_plan_repair(pbv):
|
|
1433
|
+
failures.append(
|
|
1434
|
+
"plan-body-verification: objective verification defects require gating=true; repair the affected items"
|
|
1435
|
+
)
|
|
1436
|
+
if facts and not advisory_plan_body_gating(ip):
|
|
1437
|
+
failures.append(
|
|
1438
|
+
"final-report data.json: implementationPlanning.planBodyVerification "
|
|
1439
|
+
"`gating` is false, but that is only legal when "
|
|
1440
|
+
"designPreparation.mode is `no-design-inputs` (empty items) and the "
|
|
1441
|
+
"Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
|
|
1442
|
+
"non-empty designPreparation items keep the gating contract."
|
|
1443
|
+
)
|
|
1444
|
+
applied = pbv.get("selfFixRoundsApplied")
|
|
1445
|
+
if isinstance(applied, int) and applied > 0:
|
|
1446
|
+
failures.append(
|
|
1447
|
+
"final-report data.json: implementationPlanning.planBodyVerification "
|
|
1448
|
+
"`gating` is false, so the self-fix loop must not run "
|
|
1449
|
+
f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
|
|
1450
|
+
"verification round."
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _validate_verdict_rounds_outlive_self_fix(
|
|
1455
|
+
data: dict,
|
|
1456
|
+
failures: list[str],
|
|
1457
|
+
) -> None:
|
|
1458
|
+
"""A verdict must judge the plan the gate is about to pass.
|
|
1459
|
+
|
|
1460
|
+
Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
|
|
1461
|
+
so a verdict cast in round R judged the text as it stood after self-fix
|
|
1462
|
+
R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
|
|
1463
|
+
text has changed and the verdict is stale by construction. No semantic
|
|
1464
|
+
analysis is needed to know that; the arithmetic settles it.
|
|
1465
|
+
|
|
1466
|
+
The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
|
|
1467
|
+
compares each row's own recorded `subject`, which catches a positional shift
|
|
1468
|
+
but not the case that matters here: an item whose own wording never changed
|
|
1469
|
+
while the stage it points at was rewritten under it. Observed on a real run
|
|
1470
|
+
— the gate read `passed-with-dissent` with zero blockers, and re-running one
|
|
1471
|
+
round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
|
|
1472
|
+
because their surviving verdicts predated two self-fix rounds.
|
|
1473
|
+
|
|
1474
|
+
Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
|
|
1475
|
+
the prior run's record and is judged by that run's seq, not this one's
|
|
1476
|
+
rounds.
|
|
1477
|
+
"""
|
|
1478
|
+
ip = data.get("implementationPlanning")
|
|
1479
|
+
if not isinstance(ip, dict):
|
|
1480
|
+
return
|
|
1481
|
+
pbv = ip.get("planBodyVerification")
|
|
1482
|
+
if not isinstance(pbv, dict):
|
|
1483
|
+
return
|
|
1484
|
+
applied = pbv.get("selfFixRoundsApplied")
|
|
1485
|
+
if not isinstance(applied, int) or applied < 1:
|
|
1486
|
+
# With no rewrite after any round there is nothing a verdict can be
|
|
1487
|
+
# stale against, and an unstamped row is then simply unremarkable.
|
|
1488
|
+
return
|
|
1489
|
+
|
|
1490
|
+
stale: list[str] = []
|
|
1491
|
+
unstamped: list[str] = []
|
|
1492
|
+
for item in pbv.get("planItems") or []:
|
|
1493
|
+
if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
|
|
1494
|
+
continue
|
|
1495
|
+
if _stage_scope_bucket(item, pbv) != "in-scope":
|
|
1496
|
+
continue
|
|
1497
|
+
item_id = str(item.get("id") or "").strip()
|
|
1498
|
+
verified = item.get("verifiedContentHash")
|
|
1499
|
+
current = item.get("contentHash")
|
|
1500
|
+
if (
|
|
1501
|
+
isinstance(verified, str)
|
|
1502
|
+
and isinstance(current, str)
|
|
1503
|
+
and verified == current
|
|
1504
|
+
):
|
|
1505
|
+
# 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
|
|
1506
|
+
continue
|
|
1507
|
+
for verdict in item.get("verdicts") or []:
|
|
1508
|
+
if not isinstance(verdict, dict):
|
|
1509
|
+
continue
|
|
1510
|
+
round_number = verdict.get("round")
|
|
1511
|
+
if not isinstance(round_number, int) or isinstance(round_number, bool):
|
|
1512
|
+
unstamped.append(item_id)
|
|
1513
|
+
elif round_number <= applied:
|
|
1514
|
+
stale.append(item_id)
|
|
1515
|
+
if unstamped:
|
|
1516
|
+
failures.append(
|
|
1517
|
+
f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
|
|
1518
|
+
f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
|
|
1519
|
+
"the plan. Without the round there is no way to tell whether the "
|
|
1520
|
+
"verdict judged the current text or a version two rewrites old. "
|
|
1521
|
+
"Re-record the round's votes with `okstra plan-items apply-verdicts "
|
|
1522
|
+
"--round <N>`."
|
|
1523
|
+
)
|
|
1524
|
+
if stale:
|
|
1525
|
+
failures.append(
|
|
1526
|
+
f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
|
|
1527
|
+
f"verdict from a round at or before self-fix round {applied}, so the "
|
|
1528
|
+
"text they judged has since been rewritten. The gate is computed "
|
|
1529
|
+
"from these votes, so passing on them declares a plan verified that "
|
|
1530
|
+
"nobody verified. Re-verify those items in a round after the last "
|
|
1531
|
+
'self-fix (plan-body-verification.md §"Round protocol" step 7).'
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _validate_verdicts_match_current_subjects(
|
|
1536
|
+
data: dict,
|
|
1537
|
+
failures: list[str],
|
|
1538
|
+
) -> None:
|
|
1539
|
+
"""A verdict must still be attached to the element it was cast on.
|
|
1540
|
+
|
|
1541
|
+
`P-*` ids are positional (`plan_items.py` numbers rows by array index), so
|
|
1542
|
+
when a self-fix round deletes a plan element every later row shifts up one.
|
|
1543
|
+
A dangling id at the tail is already caught by
|
|
1544
|
+
`_validate_plan_item_extraction_completeness`, but the shift itself is not:
|
|
1545
|
+
the id set still matches while each surviving verdict now points at its
|
|
1546
|
+
neighbour. The recorded `subject` is what makes the shift visible — it is a
|
|
1547
|
+
snapshot of the row the worker actually judged.
|
|
1548
|
+
"""
|
|
1549
|
+
ip = data.get("implementationPlanning")
|
|
1550
|
+
if not isinstance(ip, dict):
|
|
1551
|
+
return
|
|
1552
|
+
pbv = ip.get("planBodyVerification")
|
|
1553
|
+
if not isinstance(pbv, dict):
|
|
1554
|
+
return
|
|
1555
|
+
round_count = pbv.get("roundCount")
|
|
1556
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
1557
|
+
return
|
|
1558
|
+
try:
|
|
1559
|
+
current = {
|
|
1560
|
+
str(item["id"]): str(item.get("subject") or "")
|
|
1561
|
+
for item in extract_plan_items(ip)
|
|
1562
|
+
}
|
|
1563
|
+
except Exception: # noqa: BLE001
|
|
1564
|
+
# Extraction failure is already reported by the completeness check;
|
|
1565
|
+
# do not double-report it here as a spurious subject mismatch.
|
|
1566
|
+
return
|
|
1567
|
+
|
|
1568
|
+
drifted = []
|
|
1569
|
+
for item in pbv.get("planItems") or []:
|
|
1570
|
+
if not isinstance(item, dict):
|
|
1571
|
+
continue
|
|
1572
|
+
item_id = str(item.get("id") or "").strip()
|
|
1573
|
+
recorded = str(item.get("subject") or "").strip()
|
|
1574
|
+
expected = current.get(item_id)
|
|
1575
|
+
if expected is None or not recorded:
|
|
1576
|
+
continue
|
|
1577
|
+
if recorded != expected.strip():
|
|
1578
|
+
drifted.append(item_id)
|
|
1579
|
+
if drifted:
|
|
1580
|
+
failures.append(
|
|
1581
|
+
f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
|
|
1582
|
+
"`subject` that no longer matches the plan element at that "
|
|
1583
|
+
"position. `P-*` ids are positional, so deleting an element during "
|
|
1584
|
+
"self-fix shifts every later row and silently re-points its "
|
|
1585
|
+
"verdicts at a different element — a recorded blocker then refers "
|
|
1586
|
+
"to something the reader cannot find. Re-extract the plan items "
|
|
1587
|
+
"and re-verify the shifted ones instead of carrying the old votes "
|
|
1588
|
+
'forward (plan-body-verification.md §"Round protocol" step 7).'
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
|
|
1593
|
+
"""A gate nobody can act on is a stalled task.
|
|
1594
|
+
|
|
1595
|
+
`aborted-non-result` correctly refuses approval and run-prep fail-closes
|
|
1596
|
+
the `implementation` entry, but the clarification matcher only walks
|
|
1597
|
+
`majority-disagree` items — and an aborted round has none. So the report
|
|
1598
|
+
stated no blocker, `okstra-user-response` had nothing to present, and the
|
|
1599
|
+
run stalled with no remedy until someone read the gate value by hand.
|
|
1600
|
+
"""
|
|
1601
|
+
ip = data.get("implementationPlanning")
|
|
1602
|
+
if not isinstance(ip, dict):
|
|
1603
|
+
return
|
|
1604
|
+
pbv = ip.get("planBodyVerification")
|
|
1605
|
+
if not isinstance(pbv, dict):
|
|
1606
|
+
return
|
|
1607
|
+
if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
|
|
1608
|
+
return
|
|
1609
|
+
has_open_blocker = any(
|
|
1610
|
+
isinstance(row, dict)
|
|
1611
|
+
and row.get("blocks") == "approval"
|
|
1612
|
+
and str(row.get("status") or "").strip() == "open"
|
|
1613
|
+
for row in (data.get("clarificationItems") or [])
|
|
1614
|
+
)
|
|
1615
|
+
if not has_open_blocker:
|
|
1616
|
+
failures.append(
|
|
1617
|
+
"final-report data.json: planBodyVerification `gateResult` is "
|
|
1618
|
+
"`aborted-non-result` but no open `Blocks=approval` clarification "
|
|
1619
|
+
"row explains it. An aborted round blocks approval without "
|
|
1620
|
+
"producing any majority-disagree item, so without this row the "
|
|
1621
|
+
"report names no blocker, `okstra-user-response` has nothing to "
|
|
1622
|
+
"present, and the task stalls with no stated remedy. Add a row "
|
|
1623
|
+
"naming which dispatches returned no result and what re-running "
|
|
1624
|
+
'them requires (plan-body-verification.md §"Round protocol").'
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
|
|
1629
|
+
"""Worker roles that actually returned a plan-body reverify result.
|
|
1630
|
+
|
|
1631
|
+
Result files are named
|
|
1632
|
+
``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
|
|
1633
|
+
`plan-body-verification.md` §"Round protocol" step 3, and the role slug is
|
|
1634
|
+
``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
|
|
1635
|
+
so the caller can distinguish "no artifacts to check against" from "nobody
|
|
1636
|
+
voted".
|
|
1637
|
+
"""
|
|
1638
|
+
worker_results_dir = report_path.parent.parent / "worker-results"
|
|
1639
|
+
if not worker_results_dir.is_dir():
|
|
1640
|
+
return None
|
|
1641
|
+
# Scoped to this run's seq for the same reason the audit check is: the
|
|
1642
|
+
# directory accumulates every run, so an unscoped glob would let a prior
|
|
1643
|
+
# run's result file vouch for a vote this run never collected.
|
|
1644
|
+
seqs = _plan_verify_seq_aliases(report_path)
|
|
1645
|
+
workers = set()
|
|
1646
|
+
for seq in seqs or {_report_run_seq(report_path) or "*"}:
|
|
1647
|
+
for path in worker_results_dir.glob(f"*-plan-verify-r*-{task_type}-{seq}.md"):
|
|
1648
|
+
role = path.name.split("-plan-verify-r", 1)[0]
|
|
1649
|
+
if role:
|
|
1650
|
+
workers.add(role)
|
|
1651
|
+
return workers
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _manifest_seq_for_report(report_path: Path, category: str) -> set[str]:
|
|
1655
|
+
"""이 리포트 seq 를 낸 매니페스트가 그 카테고리에 기록한 seq 들.
|
|
1656
|
+
|
|
1657
|
+
`paths.compute_run_paths` 는 7개 카테고리 seq 를 디렉터리별로 따로 스캔해
|
|
1658
|
+
배정한다(`paths.next_run_seq`). 같은 run dir 로 재실행하면 카테고리마다
|
|
1659
|
+
다른 속도로 올라가 reports 017 / state 025 같은 분기가 실제로 생긴다.
|
|
1660
|
+
매니페스트의 `runSequencesByCategory` 만이 그 분기를 한 런으로 묶는 기록이다.
|
|
1661
|
+
"""
|
|
1662
|
+
seq = _report_run_seq(report_path)
|
|
1663
|
+
manifests_dir = report_path.parent.parent / "manifests"
|
|
1664
|
+
if not seq or not manifests_dir.is_dir():
|
|
1665
|
+
return set()
|
|
1666
|
+
found: set[str] = set()
|
|
1667
|
+
for path in manifests_dir.glob("run-manifest-*.json"):
|
|
1668
|
+
try:
|
|
1669
|
+
payload = load_owned_object(path, artifact="run manifest")
|
|
1670
|
+
except JsonBoundaryError:
|
|
1671
|
+
continue
|
|
1672
|
+
categories = payload.get("runSequencesByCategory")
|
|
1673
|
+
if not isinstance(categories, dict):
|
|
1674
|
+
continue
|
|
1675
|
+
if str(categories.get("reports") or "") != seq:
|
|
1676
|
+
continue
|
|
1677
|
+
value = str(categories.get(category) or "").strip()
|
|
1678
|
+
if value:
|
|
1679
|
+
found.add(value)
|
|
1680
|
+
return found
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _plan_verify_seq_aliases(report_path: Path) -> set[str]:
|
|
1684
|
+
"""이 리포트 seq 와, 같은 리포트를 가리키는 런의 workerResults seq.
|
|
1685
|
+
|
|
1686
|
+
reports 와 workerResults 가 갈라지면 워커는 018 로 쓰고 검사는 014 만
|
|
1687
|
+
본다. 같은 리포트를 연 매니페스트의 두 seq 를 모두 인정한다.
|
|
1688
|
+
"""
|
|
1689
|
+
seq = _report_run_seq(report_path)
|
|
1690
|
+
aliases: set[str] = {seq} if seq else set()
|
|
1691
|
+
return aliases | _manifest_seq_for_report(report_path, "workerResults")
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _plan_verify_dispatched_results(
|
|
1695
|
+
report_path: Path, task_type: str
|
|
1696
|
+
) -> dict[str, set[str]] | None:
|
|
1697
|
+
"""이 런이 실제로 디스패치한 plan-body 재검증 결과 파일명(역할별).
|
|
1698
|
+
|
|
1699
|
+
파일명을 seq 로 되짚는 대신 **기록된 디스패치**에서 읽는다. `okstra team`
|
|
1700
|
+
은 워커를 띄울 때마다 team-state 에 `workerDispatches[]` 행을 남기고
|
|
1701
|
+
(`dispatch_core._dispatch_record` — `kind`, `workerResultPath` 포함), 그
|
|
1702
|
+
`workerResultPath` 는 리드가 디스패치 요청에 실어 보낸 경로 그대로다
|
|
1703
|
+
(`dispatch_state.py` 의 `require_string(item, "workerResultPath")`). 결과
|
|
1704
|
+
파일이 어느 seq 로 쓰였든 그 기록이 정답을 들고 있다.
|
|
1705
|
+
|
|
1706
|
+
plan-body 상태 파일(`plan-body-verification-<task-type>-<seq>.json`)의
|
|
1707
|
+
라운드/판정 행에는 파일명이 없어서 이 용도로 못 쓴다.
|
|
1708
|
+
|
|
1709
|
+
team-state 를 못 찾으면 ``None`` — 호출자가 seq 글롭으로 내려간다.
|
|
1710
|
+
빈 dict 은 "기록은 있는데 재검증 디스패치가 한 건도 없다" 로, 라운드가
|
|
1711
|
+
아예 안 돈 경우다.
|
|
1712
|
+
"""
|
|
1713
|
+
state_dir = report_path.parent.parent / "state"
|
|
1714
|
+
if not state_dir.is_dir():
|
|
1715
|
+
return None
|
|
1716
|
+
seqs = {_report_run_seq(report_path) or ""} | _manifest_seq_for_report(
|
|
1717
|
+
report_path, "state"
|
|
1718
|
+
)
|
|
1719
|
+
dispatched: dict[str, set[str]] = {}
|
|
1720
|
+
seen_state = False
|
|
1721
|
+
for seq in sorted(s for s in seqs if s):
|
|
1722
|
+
path = state_dir / f"team-state-{task_type}-{seq}.json"
|
|
1723
|
+
if not path.is_file():
|
|
1724
|
+
continue
|
|
1725
|
+
try:
|
|
1726
|
+
payload = load_owned_object(path, artifact="team state")
|
|
1727
|
+
except JsonBoundaryError:
|
|
1728
|
+
continue
|
|
1729
|
+
seen_state = True
|
|
1730
|
+
for row in payload.get("workerDispatches") or []:
|
|
1731
|
+
if not isinstance(row, dict):
|
|
1732
|
+
continue
|
|
1733
|
+
# critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
|
|
1734
|
+
# 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
|
|
1735
|
+
# run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
|
|
1736
|
+
# fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
|
|
1737
|
+
# kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
|
|
1738
|
+
kind = str(row.get("kind") or "")
|
|
1739
|
+
if not (
|
|
1740
|
+
kind.startswith("reverify-r")
|
|
1741
|
+
or kind.startswith("plan-verify-r")
|
|
1742
|
+
or kind == "critic"
|
|
1743
|
+
):
|
|
1744
|
+
continue
|
|
1745
|
+
name = Path(str(row.get("workerResultPath") or "")).name
|
|
1746
|
+
if "-plan-verify-r" not in name:
|
|
1747
|
+
continue
|
|
1748
|
+
role = name.split("-plan-verify-r", 1)[0]
|
|
1749
|
+
if role:
|
|
1750
|
+
dispatched.setdefault(role, set()).add(name)
|
|
1751
|
+
return dispatched if seen_state else None
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _plan_verify_seq_near_misses(report_path: Path, task_type: str) -> list[str]:
|
|
1755
|
+
"""이 런의 것으로 인정되지 않은, 같은 디렉터리의 plan-verify 결과 파일.
|
|
1756
|
+
|
|
1757
|
+
"파일이 없다" 와 "파일은 있는데 이 런의 seq 가 아니다" 는 해소책이 다르다.
|
|
1758
|
+
앞의 것은 디스패치를 다시 돌려야 하고, 뒤의 것은 이미 나온 결과로 게이트를
|
|
1759
|
+
다시 계산해야 한다. 다만 재실행이 누적된 디렉터리에서는 이 목록이 수백 건이
|
|
1760
|
+
되므로, 호출부가 표본만 싣는다(`_unbacked_remedy_clause`).
|
|
1761
|
+
"""
|
|
1762
|
+
seq = _report_run_seq(report_path)
|
|
1763
|
+
if not seq:
|
|
1764
|
+
return []
|
|
1765
|
+
directory = report_path.parent.parent / "worker-results"
|
|
1766
|
+
accepted: set[Path] = set()
|
|
1767
|
+
for alias in _plan_verify_seq_aliases(report_path):
|
|
1768
|
+
accepted.update(directory.glob(f"*-plan-verify-r*-{task_type}-{alias}.md"))
|
|
1769
|
+
for names in (
|
|
1770
|
+
_plan_verify_dispatched_results(report_path, task_type) or {}
|
|
1771
|
+
).values():
|
|
1772
|
+
accepted.update(directory / name for name in names)
|
|
1773
|
+
return sorted(
|
|
1774
|
+
path.name
|
|
1775
|
+
for path in directory.glob(f"*-plan-verify-r*-{task_type}-*.md")
|
|
1776
|
+
if path not in accepted
|
|
1777
|
+
)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def _validate_plan_body_verdict_provenance(
|
|
1781
|
+
data: dict,
|
|
1782
|
+
report_path: Path,
|
|
1783
|
+
failures: list[str],
|
|
1784
|
+
) -> None:
|
|
1785
|
+
"""A recorded verdict must trace back to a worker that was actually asked.
|
|
1786
|
+
|
|
1787
|
+
Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
|
|
1788
|
+
data.json the lead authored, and nothing tied a vote to a dispatch. A lead
|
|
1789
|
+
that skipped the round entirely and wrote `AGREE` for two workers produced
|
|
1790
|
+
`gateResult: passed`, a flippable `approved:`, and a clean validator run —
|
|
1791
|
+
the same self-report weakness `selfFixRoundsApplied` had, but on the votes
|
|
1792
|
+
the whole gate is computed from.
|
|
1793
|
+
"""
|
|
1794
|
+
ip = data.get("implementationPlanning")
|
|
1795
|
+
if not isinstance(ip, dict):
|
|
1796
|
+
return
|
|
1797
|
+
pbv = ip.get("planBodyVerification")
|
|
1798
|
+
if not isinstance(pbv, dict):
|
|
1799
|
+
return
|
|
1800
|
+
round_count = pbv.get("roundCount")
|
|
1801
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
1802
|
+
return
|
|
1803
|
+
|
|
1804
|
+
voters = {
|
|
1805
|
+
str(v.get("worker") or "").strip()
|
|
1806
|
+
for item in (pbv.get("planItems") or [])
|
|
1807
|
+
if isinstance(item, dict)
|
|
1808
|
+
for v in (item.get("verdicts") or [])
|
|
1809
|
+
if isinstance(v, dict) and str(v.get("worker") or "").strip()
|
|
1810
|
+
}
|
|
1811
|
+
if not voters:
|
|
1812
|
+
return
|
|
1813
|
+
|
|
1814
|
+
task_type = str(
|
|
1815
|
+
(data.get("header") or {}).get("taskType") or ""
|
|
1816
|
+
) or _report_task_type(report_path)
|
|
1817
|
+
if not task_type:
|
|
1818
|
+
# Neither source names it, so the glob below would be built from an
|
|
1819
|
+
# empty segment and match nothing — reporting every verdict as unbacked
|
|
1820
|
+
# on the strength of a path this check could not construct.
|
|
1821
|
+
return
|
|
1822
|
+
results_dir = report_path.parent.parent / "worker-results"
|
|
1823
|
+
recorded = _plan_verify_dispatched_results(report_path, task_type)
|
|
1824
|
+
if recorded is None:
|
|
1825
|
+
# 기록된 디스패치가 없다 — seq 글롭으로 내려간다.
|
|
1826
|
+
dispatched = _plan_verify_result_workers(report_path, task_type)
|
|
1827
|
+
if dispatched is None:
|
|
1828
|
+
return
|
|
1829
|
+
source = (
|
|
1830
|
+
f"no team-state for this run was readable under `runs/{task_type}/"
|
|
1831
|
+
f"state/`, so this fell back to globbing `*-plan-verify-r*-"
|
|
1832
|
+
f"{task_type}-<seq>.md` under `runs/{task_type}/worker-results/` "
|
|
1833
|
+
f"for seq(s) {sorted(_plan_verify_seq_aliases(report_path))}"
|
|
1834
|
+
)
|
|
1835
|
+
returned = {_analyser_key(name) for name in dispatched}
|
|
1836
|
+
never_dispatched: set[str] = set()
|
|
1837
|
+
else:
|
|
1838
|
+
# 기록된 디스패치가 정답이다. 투표가 뒷받침되려면 (1) 그 역할로 나간
|
|
1839
|
+
# 재검증 디스패치 기록이 있고 (2) 그 기록이 적어 둔 결과 파일이 디스크에
|
|
1840
|
+
# 실제로 있어야 한다. seq 는 어디에도 안 쓴다 — 갈라진 seq 로 나간
|
|
1841
|
+
# 디스패치도 기록에는 자기 파일명 그대로 남아 있다.
|
|
1842
|
+
source = (
|
|
1843
|
+
f"resolved from the `workerDispatches[]` rows this run's team-state "
|
|
1844
|
+
f"recorded under `runs/{task_type}/state/` (each row's own "
|
|
1845
|
+
f"`workerResultPath`, not a seq glob)"
|
|
1846
|
+
)
|
|
1847
|
+
recorded_keys = {_analyser_key(role) for role in recorded}
|
|
1848
|
+
returned = {
|
|
1849
|
+
_analyser_key(role)
|
|
1850
|
+
for role, names in recorded.items()
|
|
1851
|
+
if any((results_dir / name).is_file() for name in names)
|
|
1852
|
+
}
|
|
1853
|
+
never_dispatched = {
|
|
1854
|
+
_analyser_key(voter)
|
|
1855
|
+
for voter in voters
|
|
1856
|
+
if _analyser_key(voter) not in recorded_keys
|
|
1857
|
+
}
|
|
1858
|
+
# 파일명 슬러그와 투표 키를 같은 축으로 놓는다. 결과 파일명은 cmux 어댑터가
|
|
1859
|
+
# `-worker-` 토큰을 요구하는데(`workerResultPath`) 투표 키는 워커 id 그대로다.
|
|
1860
|
+
# 워커 id 가 `-worker` 로 끝나던 기본 로스터에서는 두 규칙이 우연히 같은
|
|
1861
|
+
# 이름을 냈지만, `grok-planner` 처럼 역할 접미사가 붙은 id 에서는 두 규칙을
|
|
1862
|
+
# 동시에 만족하는 이름이 존재하지 않는다.
|
|
1863
|
+
unbacked = sorted(voter for voter in voters if _analyser_key(voter) not in returned)
|
|
1864
|
+
if unbacked:
|
|
1865
|
+
no_dispatch = sorted(
|
|
1866
|
+
voter for voter in unbacked if _analyser_key(voter) in never_dispatched
|
|
1867
|
+
)
|
|
1868
|
+
failures.append(
|
|
1869
|
+
"final-report data.json: planBodyVerification records verdicts from "
|
|
1870
|
+
f"{unbacked} but no plan-body reverify result file backs them — "
|
|
1871
|
+
f"{source}."
|
|
1872
|
+
+ _unbacked_remedy_clause(report_path, task_type, no_dispatch)
|
|
1873
|
+
+ " A vote the gate is computed from MUST trace back to a dispatch "
|
|
1874
|
+
"that actually returned — otherwise the round can be skipped and "
|
|
1875
|
+
'the gate still read `passed` (plan-body-verification.md §"Round '
|
|
1876
|
+
'protocol" step 3).'
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
_NEAR_MISS_SAMPLE = 6
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _unbacked_remedy_clause(
|
|
1884
|
+
report_path: Path, task_type: str, no_dispatch: list[str]
|
|
1885
|
+
) -> str:
|
|
1886
|
+
"""뒷받침 없는 투표에 남길 실제 갈래와 정당한 해소책.
|
|
1887
|
+
|
|
1888
|
+
파일 이름을 바꾸라는 안내를 여기서 걷어냈다. 그 안내는 실행됐고(디렉터리에
|
|
1889
|
+
개명 사본이 남았다), 개명은 어느 디스패치가 그 결과를 냈는지를 지워 검사가
|
|
1890
|
+
막으려던 바로 그 상태 — 대조할 기록이 없는 투표 — 를 만든다.
|
|
1891
|
+
"""
|
|
1892
|
+
parts: list[str] = []
|
|
1893
|
+
if no_dispatch:
|
|
1894
|
+
parts.append(
|
|
1895
|
+
f" No reverify dispatch was recorded at all for {no_dispatch}, so "
|
|
1896
|
+
"those verdicts are unbacked: either the round genuinely never ran "
|
|
1897
|
+
"(re-dispatch it, or record `verification-error` for the workers "
|
|
1898
|
+
"that produced no result) or it ran outside `okstra team dispatch` "
|
|
1899
|
+
"and left no `workerDispatches[]` row, which is itself the "
|
|
1900
|
+
"violation."
|
|
1901
|
+
)
|
|
1902
|
+
near = _plan_verify_seq_near_misses(report_path, task_type)
|
|
1903
|
+
if near:
|
|
1904
|
+
sample = near[:_NEAR_MISS_SAMPLE]
|
|
1905
|
+
more = (
|
|
1906
|
+
f" (+{len(near) - len(sample)} more; the directory accumulates "
|
|
1907
|
+
"every rerun of this task-type)"
|
|
1908
|
+
if len(near) > len(sample)
|
|
1909
|
+
else ""
|
|
1910
|
+
)
|
|
1911
|
+
parts.append(
|
|
1912
|
+
f" The directory does hold plan-verify results under other "
|
|
1913
|
+
f"sequences — {sample}{more}. If one of those is this round's "
|
|
1914
|
+
"output, the round was dispatched under a sequence this report does "
|
|
1915
|
+
"not carry: recompute the gate from the files that exist (re-run "
|
|
1916
|
+
"`okstra plan-items apply-verdicts --result <worker>=<file>` "
|
|
1917
|
+
"against them and re-record the round) so the verdicts and their "
|
|
1918
|
+
"evidence agree. Do NOT rename a result file to this report's seq — "
|
|
1919
|
+
"renaming destroys the link between a vote and the dispatch that "
|
|
1920
|
+
"produced it, which is exactly what this check reads."
|
|
1921
|
+
)
|
|
1922
|
+
return "".join(parts)
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
_UNIFORM_VERIFIER_MIN_ITEMS = 5
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _detect_uniform_verifier(pbv: dict) -> list[str]:
|
|
1929
|
+
"""Verifiers whose every vote in the round was the same verdict.
|
|
1930
|
+
|
|
1931
|
+
`participatingAnalysers` counts whether a worker voted, not whether the
|
|
1932
|
+
votes carried information. A verifier that answers AGREE to every item is
|
|
1933
|
+
counted as a third opinion while contributing no refutation signal, so the
|
|
1934
|
+
report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
|
|
1935
|
+
`nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
|
|
1936
|
+
evidence paths, on a round where the two other analysers jointly refuted a
|
|
1937
|
+
real defect.)
|
|
1938
|
+
|
|
1939
|
+
Advisory only. A unanimous round is a legitimate outcome, and any ratio
|
|
1940
|
+
strict enough to catch a rubber stamp also fails honest agreement, so this
|
|
1941
|
+
reports the counts and leaves the judgement to the reader.
|
|
1942
|
+
"""
|
|
1943
|
+
items = pbv.get("planItems") if isinstance(pbv, dict) else None
|
|
1944
|
+
if not isinstance(items, list):
|
|
1945
|
+
return []
|
|
1946
|
+
verdicts_by_worker: dict[str, set[str]] = {}
|
|
1947
|
+
counts: dict[str, int] = {}
|
|
1948
|
+
for item in items:
|
|
1949
|
+
if not isinstance(item, dict):
|
|
1950
|
+
continue
|
|
1951
|
+
for verdict in item.get("verdicts") or []:
|
|
1952
|
+
if not isinstance(verdict, dict):
|
|
1953
|
+
continue
|
|
1954
|
+
worker = str(verdict.get("worker") or "").strip()
|
|
1955
|
+
value = str(verdict.get("verdict") or "").strip()
|
|
1956
|
+
if not worker or not value or value == "verification-error":
|
|
1957
|
+
continue
|
|
1958
|
+
verdicts_by_worker.setdefault(worker, set()).add(value)
|
|
1959
|
+
counts[worker] = counts.get(worker, 0) + 1
|
|
1960
|
+
warnings = []
|
|
1961
|
+
for worker in sorted(verdicts_by_worker):
|
|
1962
|
+
distinct = verdicts_by_worker[worker]
|
|
1963
|
+
total = counts[worker]
|
|
1964
|
+
if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
|
|
1965
|
+
continue
|
|
1966
|
+
warnings.append(
|
|
1967
|
+
f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
|
|
1968
|
+
f"for all {total} items it voted on, so this round's refutation "
|
|
1969
|
+
"signal came from its peers alone. Confirm the worker actually "
|
|
1970
|
+
"opened the cited evidence (its `-audit-` sidecar lists what it "
|
|
1971
|
+
"read) before reading the gate as a full cross-check."
|
|
1972
|
+
)
|
|
1973
|
+
return warnings
|
|
1974
|
+
|
|
1975
|
+
|
|
1976
|
+
def _validate_plan_item_extraction_completeness(
|
|
1977
|
+
data: dict,
|
|
1978
|
+
failures: list[str],
|
|
1979
|
+
) -> None:
|
|
1980
|
+
"""Require the exact deterministic P-* extraction when a round ran."""
|
|
1981
|
+
ip = data.get("implementationPlanning")
|
|
1982
|
+
if not isinstance(ip, dict):
|
|
1983
|
+
return
|
|
1984
|
+
pbv = ip.get("planBodyVerification")
|
|
1985
|
+
if not isinstance(pbv, dict):
|
|
1986
|
+
return
|
|
1987
|
+
round_count = pbv.get("roundCount")
|
|
1988
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
1989
|
+
return
|
|
1990
|
+
try:
|
|
1991
|
+
expected_sequence = expected_plan_item_ids(ip)
|
|
1992
|
+
except Exception as exc: # noqa: BLE001
|
|
1993
|
+
failures.append(
|
|
1994
|
+
f"final-report data.json: deterministic plan-item extraction failed: {exc}"
|
|
1995
|
+
)
|
|
1996
|
+
return
|
|
1997
|
+
|
|
1998
|
+
actual_sequence = [
|
|
1999
|
+
str(item.get("id") or "").strip()
|
|
2000
|
+
for item in (pbv.get("planItems") or [])
|
|
2001
|
+
if isinstance(item, dict)
|
|
2002
|
+
]
|
|
2003
|
+
expected_ids = set(expected_sequence)
|
|
2004
|
+
actual_ids = set(actual_sequence)
|
|
2005
|
+
missing = expected_ids - actual_ids
|
|
2006
|
+
unexpected = actual_ids - expected_ids
|
|
2007
|
+
duplicate_ids = {
|
|
2008
|
+
item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if missing:
|
|
2012
|
+
failures.append(
|
|
2013
|
+
"final-report data.json: planBodyVerification.planItems is missing "
|
|
2014
|
+
"deterministically extracted verdict item ID(s): "
|
|
2015
|
+
+ ", ".join(sorted(missing))
|
|
2016
|
+
)
|
|
2017
|
+
if unexpected:
|
|
2018
|
+
failures.append(
|
|
2019
|
+
"final-report data.json: planBodyVerification.planItems contains "
|
|
2020
|
+
"unexpected verdict item ID(s): " + ", ".join(sorted(unexpected))
|
|
2021
|
+
)
|
|
2022
|
+
if duplicate_ids:
|
|
2023
|
+
failures.append(
|
|
2024
|
+
"final-report data.json: planBodyVerification.planItems contains "
|
|
2025
|
+
"duplicate verdict item ID(s): " + ", ".join(sorted(duplicate_ids))
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _validate_variation_point_analysis(
|
|
2030
|
+
vpa: object,
|
|
2031
|
+
architecture_style: str,
|
|
2032
|
+
failures: list[str],
|
|
2033
|
+
) -> None:
|
|
2034
|
+
"""Conditional rules + architecture-style overlay for variation points.
|
|
2035
|
+
|
|
2036
|
+
The schema enforces shape only. Two layers of meaning sit on top:
|
|
2037
|
+
|
|
2038
|
+
Layer 1 is style-agnostic. Declaring "no variation exists" is a claim that
|
|
2039
|
+
needs a written reason, and it must not be paired with declared points —
|
|
2040
|
+
`plan_items._extract_variation_point_items` emits a lone `P-Var-0` in that
|
|
2041
|
+
branch and drops them, so the contradiction would silently exempt every
|
|
2042
|
+
declared point from per-point verification. `extract: true` is a claim in
|
|
2043
|
+
the same way: it names the interface the next implementation plugs into and
|
|
2044
|
+
the Stage Map stage that builds it, so both fields have to be filled. The
|
|
2045
|
+
schema cannot carry this as a `minLength` — the empty string is the natural
|
|
2046
|
+
shape of an `extract: false` decision.
|
|
2047
|
+
|
|
2048
|
+
Layer 2 fires only for a project that declares `architecture.style`
|
|
2049
|
+
`hexagonal`: extracting a variation point there means introducing a port,
|
|
2050
|
+
not a helper. An unconfigured project resolves to `none` and keeps layer-1
|
|
2051
|
+
behaviour only.
|
|
2052
|
+
"""
|
|
2053
|
+
if not isinstance(vpa, dict):
|
|
2054
|
+
failures.append("variationPointAnalysis is missing or not an object")
|
|
2055
|
+
return
|
|
2056
|
+
# Schema violations are reported, not raised, so this check still runs on a
|
|
2057
|
+
# malformed block. A type guard here keeps a bad field from aborting the
|
|
2058
|
+
# whole validation and discarding every failure collected so far.
|
|
2059
|
+
raw_points = vpa.get("points")
|
|
2060
|
+
if raw_points is not None and not isinstance(raw_points, list):
|
|
2061
|
+
failures.append("variationPointAnalysis: points must be an array")
|
|
2062
|
+
return
|
|
2063
|
+
points = raw_points or []
|
|
2064
|
+
if not bool(vpa.get("hasMultipleImplementations")):
|
|
2065
|
+
rationale = vpa.get("noVariationRationale")
|
|
2066
|
+
if not isinstance(rationale, str) or not rationale.strip():
|
|
2067
|
+
failures.append(
|
|
2068
|
+
"variationPointAnalysis: hasMultipleImplementations=false "
|
|
2069
|
+
"requires a non-empty noVariationRationale"
|
|
2070
|
+
)
|
|
2071
|
+
if points:
|
|
2072
|
+
failures.append(
|
|
2073
|
+
"variationPointAnalysis: hasMultipleImplementations=false but "
|
|
2074
|
+
"points is non-empty — declared variation points would be "
|
|
2075
|
+
"silently dropped"
|
|
2076
|
+
)
|
|
2077
|
+
return
|
|
2078
|
+
if not points:
|
|
2079
|
+
failures.append(
|
|
2080
|
+
"variationPointAnalysis: hasMultipleImplementations=true requires "
|
|
2081
|
+
"at least one point"
|
|
2082
|
+
)
|
|
2083
|
+
return
|
|
2084
|
+
for index, point in enumerate(points, start=1):
|
|
2085
|
+
if not isinstance(point, dict):
|
|
2086
|
+
failures.append(f"variationPointAnalysis point {index}: must be an object")
|
|
2087
|
+
continue
|
|
2088
|
+
decision = point.get("extractionDecision")
|
|
2089
|
+
if not isinstance(decision, dict):
|
|
2090
|
+
continue # shape is the schema's job; don't double-report it.
|
|
2091
|
+
if not decision.get("extract"):
|
|
2092
|
+
continue
|
|
2093
|
+
for field in ("interfaceKind", "coveredBy"):
|
|
2094
|
+
value = decision.get(field)
|
|
2095
|
+
if not isinstance(value, str) or not value.strip():
|
|
2096
|
+
failures.append(
|
|
2097
|
+
f"variationPointAnalysis point {index}: extract=true "
|
|
2098
|
+
f"requires a non-empty {field}, got {value!r}"
|
|
2099
|
+
)
|
|
2100
|
+
if (
|
|
2101
|
+
architecture_style == "hexagonal"
|
|
2102
|
+
and decision.get("interfaceKind") != "port"
|
|
2103
|
+
):
|
|
2104
|
+
failures.append(
|
|
2105
|
+
f"variationPointAnalysis point {index}: architecture style "
|
|
2106
|
+
f"'hexagonal' requires interfaceKind 'port', got "
|
|
2107
|
+
f"{decision.get('interfaceKind')!r}"
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
_DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
_DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
_DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _design_prep_rows(
|
|
2121
|
+
planning: dict,
|
|
2122
|
+
failures: list[str],
|
|
2123
|
+
) -> tuple[list[dict], dict[str, dict]] | None:
|
|
2124
|
+
preparation = planning.get("designPreparation")
|
|
2125
|
+
if not isinstance(preparation, dict):
|
|
2126
|
+
failures.append(
|
|
2127
|
+
"final-report data.json: implementationPlanning.designPreparation "
|
|
2128
|
+
"is malformed; expected an object"
|
|
2129
|
+
)
|
|
2130
|
+
return None
|
|
2131
|
+
raw_items = preparation.get("items")
|
|
2132
|
+
if not isinstance(raw_items, list) or any(
|
|
2133
|
+
not isinstance(item, dict) for item in raw_items
|
|
2134
|
+
):
|
|
2135
|
+
failures.append(
|
|
2136
|
+
"final-report data.json: implementationPlanning.designPreparation.items "
|
|
2137
|
+
"is malformed; expected an array of objects"
|
|
2138
|
+
)
|
|
2139
|
+
return None
|
|
2140
|
+
items = list(raw_items)
|
|
2141
|
+
items_by_id: dict[str, dict] = {}
|
|
2142
|
+
for item in items:
|
|
2143
|
+
item_id = item.get("id")
|
|
2144
|
+
if not isinstance(item_id, str) or not item_id:
|
|
2145
|
+
failures.append(
|
|
2146
|
+
"final-report data.json: designPreparation item has malformed id"
|
|
2147
|
+
)
|
|
2148
|
+
continue
|
|
2149
|
+
if item_id in items_by_id:
|
|
2150
|
+
failures.append(
|
|
2151
|
+
f"final-report data.json: duplicate designPreparation item {item_id}"
|
|
2152
|
+
)
|
|
2153
|
+
items_by_id[item_id] = item
|
|
2154
|
+
return items, items_by_id
|
|
2155
|
+
|
|
2156
|
+
|
|
2157
|
+
def _validate_design_prep_states(items: list[dict], failures: list[str]) -> None:
|
|
2158
|
+
for item in items:
|
|
2159
|
+
item_id = str(item.get("id") or "<missing>")
|
|
2160
|
+
stage_refs = item.get("stageRefs") or []
|
|
2161
|
+
review_at = item.get("reviewAt")
|
|
2162
|
+
if isinstance(review_at, dict) and "stage" in review_at:
|
|
2163
|
+
if review_at.get("stage") not in stage_refs:
|
|
2164
|
+
failures.append(
|
|
2165
|
+
f"final-report data.json: {item_id}.reviewAt.stage must belong "
|
|
2166
|
+
"to stageRefs"
|
|
2167
|
+
)
|
|
2168
|
+
status = item.get("status")
|
|
2169
|
+
if status == "blocked" and (
|
|
2170
|
+
not isinstance(item.get("humanConfirmation"), dict)
|
|
2171
|
+
or item["humanConfirmation"].get("required") is not True
|
|
2172
|
+
):
|
|
2173
|
+
failures.append(
|
|
2174
|
+
f"final-report data.json: blocked {item_id} requires "
|
|
2175
|
+
"humanConfirmation.required=true"
|
|
2176
|
+
)
|
|
2177
|
+
if status in _DESIGN_PREP_REQUEST_STATUSES and not isinstance(
|
|
2178
|
+
item.get("requestPath"), str
|
|
2179
|
+
):
|
|
2180
|
+
failures.append(
|
|
2181
|
+
f"final-report data.json: {status} {item_id} requires requestPath"
|
|
2182
|
+
)
|
|
2183
|
+
if status in _DESIGN_PREP_TERMINAL_STATUSES and "requestPath" in item:
|
|
2184
|
+
failures.append(
|
|
2185
|
+
f"final-report data.json: terminal {item_id} must not carry requestPath"
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _validate_design_prep_requests(
|
|
2190
|
+
data: dict,
|
|
2191
|
+
report_path: Path,
|
|
2192
|
+
items: list[dict],
|
|
2193
|
+
failures: list[str],
|
|
2194
|
+
) -> None:
|
|
2195
|
+
data_path = _data_path_for(report_path)
|
|
2196
|
+
planning_seq = _design_prep_planning_seq(data_path)
|
|
2197
|
+
report_language = _design_prep_report_language(data)
|
|
2198
|
+
for item in items:
|
|
2199
|
+
if item.get("status") not in _DESIGN_PREP_REQUEST_STATUSES:
|
|
2200
|
+
continue
|
|
2201
|
+
item_id = str(item.get("id") or "<missing>")
|
|
2202
|
+
target, expected = _render_design_prep_request(
|
|
2203
|
+
data_path=data_path,
|
|
2204
|
+
planning_seq=planning_seq,
|
|
2205
|
+
report_language=report_language,
|
|
2206
|
+
item=item,
|
|
2207
|
+
)
|
|
2208
|
+
if not target.is_file():
|
|
2209
|
+
failures.append(
|
|
2210
|
+
f"final-report data.json: design-prep request is missing for {item_id}: "
|
|
2211
|
+
f"{target}"
|
|
2212
|
+
)
|
|
2213
|
+
continue
|
|
2214
|
+
try:
|
|
2215
|
+
actual = target.read_bytes()
|
|
2216
|
+
except OSError as exc:
|
|
2217
|
+
failures.append(
|
|
2218
|
+
f"final-report data.json: cannot read design-prep request for "
|
|
2219
|
+
f"{item_id}: {exc}"
|
|
2220
|
+
)
|
|
2221
|
+
continue
|
|
2222
|
+
# 동일성 판정은 조립(`design_prep._request_conflicts`)과 같은 신원을 쓴다.
|
|
2223
|
+
# 두 곳이 다른 기준을 걸면 조립이 통과시킨 파일을 검증이 stale 로 떨어뜨려,
|
|
2224
|
+
# run 이 고칠 수 없는 실패에 갇힌다 — 이월된 요청은 발행 리포트 줄만
|
|
2225
|
+
# 다르고, 그 줄을 현행화하려면 이전 run 의 기록을 덮어써야 한다.
|
|
2226
|
+
if _design_prep_request_identity(actual) != _design_prep_request_identity(
|
|
2227
|
+
expected
|
|
2228
|
+
):
|
|
2229
|
+
failures.append(
|
|
2230
|
+
f"final-report data.json: design-prep request content or "
|
|
2231
|
+
f"fingerprint is stale for {item_id}: {target}"
|
|
2232
|
+
)
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
def _validate_design_prep_contract(
|
|
2236
|
+
data: dict,
|
|
2237
|
+
report_path: Path | None,
|
|
2238
|
+
report_contracts: set[str],
|
|
2239
|
+
failures: list[str],
|
|
2240
|
+
) -> list[str]:
|
|
2241
|
+
warnings: list[str] = []
|
|
2242
|
+
planning = data.get("implementationPlanning")
|
|
2243
|
+
if not isinstance(planning, dict):
|
|
2244
|
+
if _DESIGN_PREP_CONTRACT in report_contracts:
|
|
2245
|
+
failures.append(
|
|
2246
|
+
"final-report data.json: implementationPlanning is malformed"
|
|
2247
|
+
)
|
|
2248
|
+
return warnings
|
|
2249
|
+
preparation = planning.get("designPreparation")
|
|
2250
|
+
if not isinstance(preparation, dict):
|
|
2251
|
+
if _DESIGN_PREP_CONTRACT in report_contracts:
|
|
2252
|
+
failures.append(
|
|
2253
|
+
"final-report data.json: marker implementation-design-prep-v1 "
|
|
2254
|
+
"requires implementationPlanning.designPreparation"
|
|
2255
|
+
)
|
|
2256
|
+
else:
|
|
2257
|
+
warnings.append("legacy-unassessed")
|
|
2258
|
+
return warnings
|
|
2259
|
+
if _DESIGN_PREP_CONTRACT not in report_contracts:
|
|
2260
|
+
return warnings
|
|
2261
|
+
try:
|
|
2262
|
+
# 탐지기 재실행 대조와 prep 항목 ↔ coverage 양방향 참조 대조는
|
|
2263
|
+
# 삭제했다. 둘 다 `design_snapshot.build` 가 한 번에 만든 값을 같은
|
|
2264
|
+
# 입력으로 되계산해 자기 자신과 맞춰 보는 항등식이었다.
|
|
2265
|
+
parsed = _design_prep_rows(planning, failures)
|
|
2266
|
+
if parsed is None:
|
|
2267
|
+
return warnings
|
|
2268
|
+
items, _ = parsed
|
|
2269
|
+
_validate_design_prep_states(items, failures)
|
|
2270
|
+
if report_path is not None:
|
|
2271
|
+
_validate_design_prep_requests(data, report_path, items, failures)
|
|
2272
|
+
except (
|
|
2273
|
+
DesignSurfaceError,
|
|
2274
|
+
DesignPrepError,
|
|
2275
|
+
KeyError,
|
|
2276
|
+
TypeError,
|
|
2277
|
+
ValueError,
|
|
2278
|
+
) as exc:
|
|
2279
|
+
failures.append(
|
|
2280
|
+
f"final-report data.json: design-preparation contract is malformed: {exc}"
|
|
2281
|
+
)
|
|
2282
|
+
except Exception as exc: # noqa: BLE001
|
|
2283
|
+
failures.append(
|
|
2284
|
+
"final-report data.json: design-preparation validation failed closed "
|
|
2285
|
+
f"on malformed input: {exc}"
|
|
2286
|
+
)
|
|
2287
|
+
return warnings
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
# A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
|
|
2291
|
+
# not the plain-language "what this item is" label §5.5.9 renders as a heading.
|
|
2292
|
+
_MIN_SUBJECT_LEN = 3
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
_BARE_PLAN_ITEM_ID_RE = re.compile(
|
|
2296
|
+
r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE
|
|
2297
|
+
)
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
|
|
2301
|
+
"""H2 follow-up — `planItems[].subject` must be a real label, not a
|
|
2302
|
+
placeholder. The schema only enforces non-empty, so `"x"` or a copied
|
|
2303
|
+
`P-Opt-1` id would otherwise slip through and defeat the whole point of the
|
|
2304
|
+
subject (letting a reader see *what* each AGREE/DISAGREE is about).
|
|
2305
|
+
"""
|
|
2306
|
+
ip = data.get("implementationPlanning")
|
|
2307
|
+
if not isinstance(ip, dict):
|
|
2308
|
+
return
|
|
2309
|
+
pbv = ip.get("planBodyVerification")
|
|
2310
|
+
if not isinstance(pbv, dict):
|
|
2311
|
+
return
|
|
2312
|
+
for item in pbv.get("planItems") or []:
|
|
2313
|
+
if not isinstance(item, dict):
|
|
2314
|
+
continue
|
|
2315
|
+
item_id = str(item.get("id") or "").strip()
|
|
2316
|
+
subject = str(item.get("subject") or "").strip()
|
|
2317
|
+
if (
|
|
2318
|
+
len(subject) < _MIN_SUBJECT_LEN
|
|
2319
|
+
or subject == item_id
|
|
2320
|
+
or _BARE_PLAN_ITEM_ID_RE.match(subject)
|
|
2321
|
+
):
|
|
2322
|
+
failures.append(
|
|
2323
|
+
f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
|
|
2324
|
+
f"placeholder subject `{subject}`. Give a plain-language label of "
|
|
2325
|
+
"what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
|
|
2326
|
+
"the §5.5.9 reader knows what each verdict is about "
|
|
2327
|
+
"(plan-body-verification.md Plan-item extraction)."
|
|
2328
|
+
)
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def _validate_plan_body_clarification_matching(
|
|
2332
|
+
data: dict,
|
|
2333
|
+
failures: list[str],
|
|
2334
|
+
accepted_item_ids: set[str] | None = None,
|
|
2335
|
+
) -> None:
|
|
2336
|
+
"""H5 — every plan item whose *gate class after stage scope* is
|
|
2337
|
+
`majority-disagree` must point at an existing `blocks: approval`
|
|
2338
|
+
clarification row. Observed / deferred / record items stay in `setAside`
|
|
2339
|
+
and must not become a new C row — that is what grew the clarification
|
|
2340
|
+
list while the next stage was already executable.
|
|
2341
|
+
"""
|
|
2342
|
+
ip = data.get("implementationPlanning")
|
|
2343
|
+
if not isinstance(ip, dict):
|
|
2344
|
+
return
|
|
2345
|
+
pbv = ip.get("planBodyVerification")
|
|
2346
|
+
if not isinstance(pbv, dict):
|
|
2347
|
+
return
|
|
2348
|
+
round_count = pbv.get("roundCount")
|
|
2349
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
2350
|
+
return
|
|
2351
|
+
# `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
|
|
2352
|
+
# "If `false`, the round is advisory-only and never blocks approval".
|
|
2353
|
+
# 게이트 계산은 이미 그것을 존중한다(`_recompute_plan_body_gate`,
|
|
2354
|
+
# `_gate_blocking_causes`). 이 검사만 그 상태를 안 보면 자문 라운드가
|
|
2355
|
+
# 승인 차단 행을 강제하게 되어, 막지 않기로 한 판정이 다시 막는다.
|
|
2356
|
+
if pbv.get("gating") is False and not requires_plan_repair(pbv):
|
|
2357
|
+
return
|
|
2358
|
+
accepted = (
|
|
2359
|
+
_resolved_noncritical_dissent_ids(data)
|
|
2360
|
+
if accepted_item_ids is None
|
|
2361
|
+
else accepted_item_ids
|
|
2362
|
+
)
|
|
2363
|
+
clar_rows = [
|
|
2364
|
+
r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)
|
|
2365
|
+
]
|
|
2366
|
+
all_ids = {r.get("id") for r in clar_rows if r.get("id")}
|
|
2367
|
+
approval_ids = {
|
|
2368
|
+
r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")
|
|
2369
|
+
}
|
|
2370
|
+
for item in pbv.get("planItems") or []:
|
|
2371
|
+
if not isinstance(item, dict):
|
|
2372
|
+
continue
|
|
2373
|
+
if _plan_item_gate_class(item, pbv, accepted) != "majority-disagree":
|
|
2374
|
+
continue
|
|
2375
|
+
item_id = item.get("id") or "<unknown>"
|
|
2376
|
+
cids = _plan_item_clarification_ids(item)
|
|
2377
|
+
if not cids:
|
|
2378
|
+
failures.append(
|
|
2379
|
+
f"final-report data.json: plan item `{item_id}` is majority-disagree "
|
|
2380
|
+
"but carries no `clarificationRefs`. A blocking disagreement MUST "
|
|
2381
|
+
"surface as a `## 1. Clarification Items` row (blocks=approval) so "
|
|
2382
|
+
"the user sees the blocker (implementation-planning.md self-review "
|
|
2383
|
+
"step 12). Report assembly derives this link from the activity "
|
|
2384
|
+
"ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
|
|
2385
|
+
"decision through `okstra approval-decision` rather than editing "
|
|
2386
|
+
"the report."
|
|
2387
|
+
)
|
|
2388
|
+
continue
|
|
2389
|
+
for cid in sorted(cids - approval_ids):
|
|
2390
|
+
reason = (
|
|
2391
|
+
"references a non-existent §1 row"
|
|
2392
|
+
if cid not in all_ids
|
|
2393
|
+
else "references a §1 row whose `blocks` is not `approval`"
|
|
2394
|
+
)
|
|
2395
|
+
failures.append(
|
|
2396
|
+
f"final-report data.json: plan item `{item_id}` (majority-disagree) "
|
|
2397
|
+
f"has clarificationRefs entry `{cid}` which {reason}. Every "
|
|
2398
|
+
"majority-disagree item MUST reach a `blocks: approval` "
|
|
2399
|
+
"Clarification row."
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
|
|
2404
|
+
"""A planner-fixable defect MUST exhaust the self-fix budget before it is
|
|
2405
|
+
promoted to a `## 1. Clarification Items` row. Closes the hole where the
|
|
2406
|
+
lead dumps a fixable plan defect (abbreviated path, prose command,
|
|
2407
|
+
placeholder, coverage remap) onto the user instead of having report-writer
|
|
2408
|
+
correct it (plan-body-verification.md "Self-fix round").
|
|
2409
|
+
"""
|
|
2410
|
+
ip = data.get("implementationPlanning")
|
|
2411
|
+
if not isinstance(ip, dict):
|
|
2412
|
+
return
|
|
2413
|
+
pbv = ip.get("planBodyVerification")
|
|
2414
|
+
if not isinstance(pbv, dict):
|
|
2415
|
+
return
|
|
2416
|
+
round_count = pbv.get("roundCount")
|
|
2417
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
2418
|
+
return
|
|
2419
|
+
# `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
|
|
2420
|
+
# "If `false`, the round is advisory-only and never blocks approval" 이고
|
|
2421
|
+
# 같은 행이 "does not run the self-fix loop" 라고 못박는다.
|
|
2422
|
+
#
|
|
2423
|
+
# 이 검사가 그 상태를 안 보면 `_validate_advisory_plan_body_gating` 과 정면
|
|
2424
|
+
# 충돌한다: 그쪽은 `gating=false` 에서 `selfFixRoundsApplied > 0` 을 실패로
|
|
2425
|
+
# 잡는데 이 검사는 `>= 1` 을 요구한다. 한 필드에 반대 방향 요구가 걸리므로
|
|
2426
|
+
# 자문 라운드에 planner-fixable 과반 반대가 하나라도 나오면 통과 가능한
|
|
2427
|
+
# 값이 없다. `okstra plan-items complete-round` 도 자문 라운드의 self-fix
|
|
2428
|
+
# 기록을 거부하므로(plan_items_cli.py) 우회로도 없다.
|
|
2429
|
+
if pbv.get("gating") is False and not requires_plan_repair(pbv):
|
|
2430
|
+
return
|
|
2431
|
+
if _self_fix_budget_exhausted(pbv):
|
|
2432
|
+
return
|
|
2433
|
+
rounds_applied = pbv.get("selfFixRoundsApplied")
|
|
2434
|
+
stop_reason = pbv.get("selfFixStopReason")
|
|
2435
|
+
for item in pbv.get("planItems") or []:
|
|
2436
|
+
if not isinstance(item, dict):
|
|
2437
|
+
continue
|
|
2438
|
+
if _plan_item_gate_class(item, pbv, set()) != "majority-disagree":
|
|
2439
|
+
continue
|
|
2440
|
+
if _has_planner_fixable_majority(item):
|
|
2441
|
+
allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
|
|
2442
|
+
failures.append(
|
|
2443
|
+
"final-report data.json: plan item "
|
|
2444
|
+
f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
|
|
2445
|
+
f"planner-fixable majority but the self-fix budget is not "
|
|
2446
|
+
f"exhausted (`selfFixRoundsApplied`={rounds_applied!r}, "
|
|
2447
|
+
f"`selfFixStopReason`={stop_reason!r}; need >=1 rounds and a stop "
|
|
2448
|
+
f"reason of {allowed}). A planner-fixable defect MUST be corrected "
|
|
2449
|
+
"by report-writer self-fix rounds until the budget runs out or a "
|
|
2450
|
+
"round makes no progress, before it becomes a clarification row "
|
|
2451
|
+
"(plan-body-verification.md Self-fix round)."
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
# Allowed `fixability` values, mirroring the schema enum
|
|
2456
|
+
# (schemas/final-report-v2.0.schema.json planItems[].verdicts[].fixability).
|
|
2457
|
+
_FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
|
|
2461
|
+
"""Every `DISAGREE` verdict MUST carry a valid `fixability`
|
|
2462
|
+
(`planner-fixable` / `needs-user-input`). The schema enum only constrains a
|
|
2463
|
+
*present* value; it does not require the field, so a DISAGREE with a missing
|
|
2464
|
+
or mislabelled fixability is schema-valid. That silently degrades
|
|
2465
|
+
`_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
|
|
2466
|
+
DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
|
|
2467
|
+
skip the self-fix round and land on the user. This is the enforcement point
|
|
2468
|
+
for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
|
|
2469
|
+
"""
|
|
2470
|
+
ip = data.get("implementationPlanning")
|
|
2471
|
+
if not isinstance(ip, dict):
|
|
2472
|
+
return
|
|
2473
|
+
pbv = ip.get("planBodyVerification")
|
|
2474
|
+
if not isinstance(pbv, dict):
|
|
2475
|
+
return
|
|
2476
|
+
round_count = pbv.get("roundCount")
|
|
2477
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
2478
|
+
return
|
|
2479
|
+
for item in pbv.get("planItems") or []:
|
|
2480
|
+
if not isinstance(item, dict):
|
|
2481
|
+
continue
|
|
2482
|
+
item_id = item.get("id") or "<unknown>"
|
|
2483
|
+
for verdict in item.get("verdicts") or []:
|
|
2484
|
+
if not isinstance(verdict, dict):
|
|
2485
|
+
continue
|
|
2486
|
+
if str(verdict.get("verdict") or "").upper() != "DISAGREE":
|
|
2487
|
+
continue
|
|
2488
|
+
fixability = verdict.get("fixability")
|
|
2489
|
+
if fixability not in _FIXABILITY_VALUES:
|
|
2490
|
+
worker = verdict.get("worker") or "<worker>"
|
|
2491
|
+
allowed = " / ".join(sorted(_FIXABILITY_VALUES))
|
|
2492
|
+
failures.append(
|
|
2493
|
+
f"final-report data.json: plan item `{item_id}` has a "
|
|
2494
|
+
f"`DISAGREE` verdict from `{worker}` with "
|
|
2495
|
+
f"fixability `{fixability}` — a DISAGREE MUST declare a "
|
|
2496
|
+
f"fixability of {allowed}. A missing/invalid value is "
|
|
2497
|
+
"counted as non-fixable and lets a planner-fixable defect "
|
|
2498
|
+
"skip the self-fix round (plan-body-verification.md "
|
|
2499
|
+
'"fixability (DISAGREE 전용, 필수)").'
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def validate_plan_body_section(
|
|
2504
|
+
data: dict,
|
|
2505
|
+
report_path: Path,
|
|
2506
|
+
failures: list[str],
|
|
2507
|
+
) -> list[str]:
|
|
2508
|
+
"""Run every §5.5.9 plan-body check and return the advisory warnings.
|
|
2509
|
+
|
|
2510
|
+
Grouped into one callable so the round protocol can run the same checks at
|
|
2511
|
+
each round boundary that the full run validation runs at the end. Before
|
|
2512
|
+
this seam existed the only way to reach them was a finished report plus all
|
|
2513
|
+
four manifests, so a lead computing the gate by hand mid-loop had nothing to
|
|
2514
|
+
check itself against until Phase 7 — and a whole self-fix budget could be
|
|
2515
|
+
spent against a mis-scored gate.
|
|
2516
|
+
"""
|
|
2517
|
+
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
2518
|
+
accepted_item_ids = _resolved_noncritical_dissent_ids(data)
|
|
2519
|
+
_validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
|
|
2520
|
+
_validate_gate_blocked_by(data, failures, accepted_item_ids)
|
|
2521
|
+
_validate_participating_analysers(data, failures)
|
|
2522
|
+
_validate_self_fix_grouping(data, failures)
|
|
2523
|
+
_validate_plan_body_verdict_provenance(data, report_path, failures)
|
|
2524
|
+
_validate_aborted_gate_has_clarification(data, failures)
|
|
2525
|
+
_validate_round_recorded_verdicts(data, failures)
|
|
2526
|
+
_validate_verdicts_match_current_subjects(data, failures)
|
|
2527
|
+
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
2528
|
+
_validate_unresolved_tie_was_reverified(data, failures)
|
|
2529
|
+
_validate_advisory_plan_body_gating(data, failures)
|
|
2530
|
+
_validate_plan_item_extraction_completeness(data, failures)
|
|
2531
|
+
_validate_plan_item_subject_substance(data, failures)
|
|
2532
|
+
_validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
|
|
2533
|
+
_validate_disagree_has_fixability(data, failures)
|
|
2534
|
+
_validate_self_fix_before_clarification(data, failures)
|
|
2535
|
+
return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
|
|
2536
|
+
|
|
2537
|
+
|
|
2538
|
+
def _gate_summary_item(
|
|
2539
|
+
item: dict,
|
|
2540
|
+
pbv: dict,
|
|
2541
|
+
accepted_item_ids: set[str],
|
|
2542
|
+
) -> dict:
|
|
2543
|
+
"""One `gate.items[]` row: the gate class plus its state-file counterpart."""
|
|
2544
|
+
classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
|
|
2545
|
+
return {
|
|
2546
|
+
"id": item.get("id"),
|
|
2547
|
+
"classification": classification,
|
|
2548
|
+
"stateClassification": _state_classification(item, classification),
|
|
2549
|
+
"correctnessCritical": _is_correctness_critical(item),
|
|
2550
|
+
"decisionAuthority": _plan_item_decision_authority(item, pbv),
|
|
2551
|
+
"leadDecisionApplied": _lead_decision_applies(item, pbv),
|
|
2552
|
+
# 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
|
|
2553
|
+
# 실제 합의가 산출물에서 같은 모양으로 읽힌다.
|
|
2554
|
+
"stageScope": _stage_scope_bucket(item, pbv),
|
|
2555
|
+
"block": str(item.get("block") or "execution"),
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def plan_body_gate_summary(data: dict) -> dict | None:
|
|
2560
|
+
"""The §5.5.9 gate as the round protocol's step 5 needs it — per-item
|
|
2561
|
+
classification, the whole-gate value, and the `gateBlockedBy` causes, all
|
|
2562
|
+
recomputed from `planItems[].verdicts`. Returns ``None`` when the report
|
|
2563
|
+
carries no plan items to judge.
|
|
2564
|
+
|
|
2565
|
+
This is what a lead records instead of tallying the verdicts by hand: the
|
|
2566
|
+
single-vote-blocking kinds, the advisory-only kinds and the P-Var / P-Rb
|
|
2567
|
+
exemptions are one implementation here, not a rule to be re-derived per
|
|
2568
|
+
round from the prompt's prose.
|
|
2569
|
+
"""
|
|
2570
|
+
ip = data.get("implementationPlanning")
|
|
2571
|
+
if not isinstance(ip, dict):
|
|
2572
|
+
return None
|
|
2573
|
+
pbv = ip.get("planBodyVerification")
|
|
2574
|
+
if not isinstance(pbv, dict):
|
|
2575
|
+
return None
|
|
2576
|
+
accepted_item_ids = _resolved_noncritical_dissent_ids(data)
|
|
2577
|
+
recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
|
|
2578
|
+
if recomputed is None:
|
|
2579
|
+
return None
|
|
2580
|
+
items = [
|
|
2581
|
+
_gate_summary_item(item, pbv, accepted_item_ids)
|
|
2582
|
+
for item in (pbv.get("planItems") or [])
|
|
2583
|
+
if isinstance(item, dict)
|
|
2584
|
+
]
|
|
2585
|
+
coverage_blockers = _independent_coverage_blockers(ip, pbv)
|
|
2586
|
+
return {
|
|
2587
|
+
"declared": pbv.get("gateResult"),
|
|
2588
|
+
"recomputed": recomputed,
|
|
2589
|
+
"declaredBlockedBy": sorted(
|
|
2590
|
+
str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
|
|
2591
|
+
),
|
|
2592
|
+
"blockedBy": sorted(
|
|
2593
|
+
_gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
|
|
2594
|
+
),
|
|
2595
|
+
"coverageBlockers": coverage_blockers,
|
|
2596
|
+
"setAside": _set_aside_register(pbv, accepted_item_ids),
|
|
2597
|
+
"blockingItems": [
|
|
2598
|
+
item["id"]
|
|
2599
|
+
for item in items
|
|
2600
|
+
if item["classification"] == "majority-disagree"
|
|
2601
|
+
],
|
|
2602
|
+
"items": items,
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
_COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
|
|
2607
|
+
|
|
2608
|
+
|
|
2609
|
+
_COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
_COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
_DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
_DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
def _deviation_target(
|
|
2622
|
+
ref: str,
|
|
2623
|
+
clarifications: dict,
|
|
2624
|
+
decisions: dict,
|
|
2625
|
+
carried: dict,
|
|
2626
|
+
) -> dict | None:
|
|
2627
|
+
if ref.startswith("C-"):
|
|
2628
|
+
row = clarifications.get(ref) or carried.get(ref)
|
|
2629
|
+
return row if isinstance(row, dict) else None
|
|
2630
|
+
row = decisions.get(ref)
|
|
2631
|
+
return row if isinstance(row, dict) else None
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _deviation_is_user_confirmed(
|
|
2635
|
+
ref: str,
|
|
2636
|
+
clarifications: dict,
|
|
2637
|
+
carried: dict,
|
|
2638
|
+
) -> bool:
|
|
2639
|
+
row = clarifications.get(ref)
|
|
2640
|
+
if (
|
|
2641
|
+
isinstance(row, dict)
|
|
2642
|
+
and row.get("status") in {"answered", "resolved"}
|
|
2643
|
+
and str(row.get("userInput") or "").strip()
|
|
2644
|
+
):
|
|
2645
|
+
return True
|
|
2646
|
+
carried_row = carried.get(ref)
|
|
2647
|
+
if not isinstance(carried_row, dict):
|
|
2648
|
+
return False
|
|
2649
|
+
resolution = carried_row.get("resolutionInput")
|
|
2650
|
+
if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
|
|
2651
|
+
return True
|
|
2652
|
+
return bool(str(carried_row.get("userConfirmation") or "").strip())
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
def _resolved_deviation_refs(
|
|
2656
|
+
row_id: str,
|
|
2657
|
+
refs: object,
|
|
2658
|
+
clarifications: dict,
|
|
2659
|
+
decisions: dict,
|
|
2660
|
+
failures: list[str],
|
|
2661
|
+
carried: dict | None = None,
|
|
2662
|
+
ledger_ids: set[str] | None = None,
|
|
2663
|
+
) -> list[str]:
|
|
2664
|
+
valid_refs: list[str] = []
|
|
2665
|
+
carried_rows = carried or {}
|
|
2666
|
+
known_ledger = ledger_ids or set()
|
|
2667
|
+
for ref in refs if isinstance(refs, list) else []:
|
|
2668
|
+
if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
|
|
2669
|
+
failures.append(
|
|
2670
|
+
f"final-report data.json: requirementCoverage `{row_id}` has "
|
|
2671
|
+
f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
|
|
2672
|
+
)
|
|
2673
|
+
continue
|
|
2674
|
+
if (
|
|
2675
|
+
_deviation_target(ref, clarifications, decisions, carried_rows) is None
|
|
2676
|
+
and ref not in known_ledger
|
|
2677
|
+
):
|
|
2678
|
+
failures.append(
|
|
2679
|
+
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
2680
|
+
f"decisionRef `{ref}` does not exist in this report."
|
|
2681
|
+
)
|
|
2682
|
+
continue
|
|
2683
|
+
valid_refs.append(ref)
|
|
2684
|
+
return valid_refs
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
def _validate_deviation_disposition(
|
|
2688
|
+
row_id: str,
|
|
2689
|
+
disposition: object,
|
|
2690
|
+
refs: list[str],
|
|
2691
|
+
clarifications: dict,
|
|
2692
|
+
failures: list[str],
|
|
2693
|
+
carried: dict | None = None,
|
|
2694
|
+
ledger_ids: set[str] | None = None,
|
|
2695
|
+
) -> None:
|
|
2696
|
+
carried_rows = carried or {}
|
|
2697
|
+
known_ledger = ledger_ids or set()
|
|
2698
|
+
if disposition == "accepted":
|
|
2699
|
+
confirmed = any(
|
|
2700
|
+
ref.startswith("C-")
|
|
2701
|
+
and (
|
|
2702
|
+
ref in known_ledger
|
|
2703
|
+
or _deviation_is_user_confirmed(ref, clarifications, carried_rows)
|
|
2704
|
+
)
|
|
2705
|
+
for ref in refs
|
|
2706
|
+
)
|
|
2707
|
+
if not confirmed:
|
|
2708
|
+
failures.append(
|
|
2709
|
+
f"final-report data.json: requirementCoverage `{row_id}` is "
|
|
2710
|
+
"documented-deviation with approvalDisposition `accepted`, but "
|
|
2711
|
+
"none of its decisionRefs is a user-confirmed clarification "
|
|
2712
|
+
"(`status` answered/resolved with non-empty `userInput`)."
|
|
2713
|
+
)
|
|
2714
|
+
return
|
|
2715
|
+
blocked = (
|
|
2716
|
+
_DEVIATION_BLOCKED_DISPOSITION_RE.fullmatch(disposition)
|
|
2717
|
+
if isinstance(disposition, str)
|
|
2718
|
+
else None
|
|
2719
|
+
)
|
|
2720
|
+
if blocked is None:
|
|
2721
|
+
return
|
|
2722
|
+
clarification_id = blocked.group(1)
|
|
2723
|
+
clarification = clarifications.get(clarification_id) or carried_rows.get(
|
|
2724
|
+
clarification_id
|
|
2725
|
+
)
|
|
2726
|
+
if clarification is None:
|
|
2727
|
+
failures.append(
|
|
2728
|
+
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
2729
|
+
f"approvalDisposition references `{clarification_id}`, which does "
|
|
2730
|
+
"not exist in this report."
|
|
2731
|
+
)
|
|
2732
|
+
elif (
|
|
2733
|
+
clarification.get("status") != "open"
|
|
2734
|
+
or clarification.get("blocks") != "approval"
|
|
2735
|
+
):
|
|
2736
|
+
failures.append(
|
|
2737
|
+
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
2738
|
+
f"approvalDisposition `{disposition}` must point to an open "
|
|
2739
|
+
"clarification with `blocks: approval`."
|
|
2740
|
+
)
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _validate_requirement_deviations(
|
|
2744
|
+
data: dict,
|
|
2745
|
+
failures: list[str],
|
|
2746
|
+
*,
|
|
2747
|
+
carried: dict | None = None,
|
|
2748
|
+
) -> None:
|
|
2749
|
+
"""Require documented deviations to reference real decisions and approval."""
|
|
2750
|
+
planning = data.get("implementationPlanning")
|
|
2751
|
+
if not isinstance(planning, dict):
|
|
2752
|
+
return
|
|
2753
|
+
clarifications = {
|
|
2754
|
+
row.get("id"): row
|
|
2755
|
+
for row in (data.get("clarificationItems") or [])
|
|
2756
|
+
if isinstance(row, dict) and row.get("id")
|
|
2757
|
+
}
|
|
2758
|
+
decisions = {
|
|
2759
|
+
f"D-{row.get('number')}": row
|
|
2760
|
+
for row in (planning.get("decisionDrafts") or [])
|
|
2761
|
+
if isinstance(row, dict) and row.get("number")
|
|
2762
|
+
}
|
|
2763
|
+
carried_rows = carried or {}
|
|
2764
|
+
ledger_ids = {
|
|
2765
|
+
str(entry.get("clarificationId") or "").strip()
|
|
2766
|
+
for entry in (planning.get("supersessionLedger") or [])
|
|
2767
|
+
if isinstance(entry, dict) and str(entry.get("clarificationId") or "").strip()
|
|
2768
|
+
}
|
|
2769
|
+
for row in planning.get("requirementCoverage") or []:
|
|
2770
|
+
if not isinstance(row, dict) or row.get("status") != "documented-deviation":
|
|
2771
|
+
continue
|
|
2772
|
+
row_id = row.get("id") or "<row>"
|
|
2773
|
+
refs = _resolved_deviation_refs(
|
|
2774
|
+
row_id,
|
|
2775
|
+
row.get("decisionRefs"),
|
|
2776
|
+
clarifications,
|
|
2777
|
+
decisions,
|
|
2778
|
+
failures,
|
|
2779
|
+
carried_rows,
|
|
2780
|
+
ledger_ids,
|
|
2781
|
+
)
|
|
2782
|
+
_validate_deviation_disposition(
|
|
2783
|
+
row_id,
|
|
2784
|
+
row.get("approvalDisposition"),
|
|
2785
|
+
refs,
|
|
2786
|
+
clarifications,
|
|
2787
|
+
failures,
|
|
2788
|
+
carried_rows,
|
|
2789
|
+
ledger_ids,
|
|
2790
|
+
)
|
|
2791
|
+
|
|
2792
|
+
|
|
2793
|
+
def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
|
|
2794
|
+
"""H3 (partial) — a `covered` requirement row's `coveredBy` must name a
|
|
2795
|
+
concrete plan element that actually exists, not the spec-forbidden bare
|
|
2796
|
+
"recommended option" nor a phantom stage. Whether the cited step truly
|
|
2797
|
+
*satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
|
|
2798
|
+
the coarser hole where `coveredBy` points at nothing real.
|
|
2799
|
+
"""
|
|
2800
|
+
ip = data.get("implementationPlanning")
|
|
2801
|
+
if not isinstance(ip, dict):
|
|
2802
|
+
return
|
|
2803
|
+
rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
|
|
2804
|
+
if not rows:
|
|
2805
|
+
return
|
|
2806
|
+
stage_numbers = {
|
|
2807
|
+
s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
|
|
2808
|
+
}
|
|
2809
|
+
for row in rows:
|
|
2810
|
+
if row.get("status") != "covered":
|
|
2811
|
+
continue
|
|
2812
|
+
rid = row.get("id") or "<row>"
|
|
2813
|
+
covered = str(row.get("coveredBy") or "").strip()
|
|
2814
|
+
if covered.lower() in _COVERED_BY_VAGUE:
|
|
2815
|
+
failures.append(
|
|
2816
|
+
f"final-report data.json: requirementCoverage `{rid}` is `covered` "
|
|
2817
|
+
f"but coveredBy is just `{covered}`. Name the specific Option "
|
|
2818
|
+
"Candidate and Stage/Step that satisfies it, not 'recommended "
|
|
2819
|
+
"option' (profile Requirement Coverage)."
|
|
2820
|
+
)
|
|
2821
|
+
continue
|
|
2822
|
+
if not _COVERED_BY_ANCHOR_RE.search(covered):
|
|
2823
|
+
failures.append(
|
|
2824
|
+
f"final-report data.json: requirementCoverage `{rid}` coveredBy "
|
|
2825
|
+
f"`{covered}` names no Option / Stage / Step. A `covered` row must "
|
|
2826
|
+
"cite the concrete plan element that satisfies the requirement."
|
|
2827
|
+
)
|
|
2828
|
+
continue
|
|
2829
|
+
phantom = [
|
|
2830
|
+
n
|
|
2831
|
+
for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
|
|
2832
|
+
if stage_numbers and (n := int(m.group(1))) not in stage_numbers
|
|
2833
|
+
]
|
|
2834
|
+
if phantom:
|
|
2835
|
+
failures.append(
|
|
2836
|
+
f"final-report data.json: requirementCoverage `{rid}` coveredBy "
|
|
2837
|
+
f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
|
|
2838
|
+
"A coverage row must point at a real stage."
|
|
2839
|
+
)
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
def _planning_conformance_declarations(
|
|
2843
|
+
stages: object,
|
|
2844
|
+
failures: list[str],
|
|
2845
|
+
) -> list[dict]:
|
|
2846
|
+
declarations: list[dict] = []
|
|
2847
|
+
for stage in stages if isinstance(stages, list) else []:
|
|
2848
|
+
if (
|
|
2849
|
+
not isinstance(stage, dict)
|
|
2850
|
+
or not str(stage.get("conformanceTests") or "").strip()
|
|
2851
|
+
):
|
|
2852
|
+
continue
|
|
2853
|
+
parsed = _parse_conformance_tests(stage.get("conformanceTests"))
|
|
2854
|
+
if parsed is None:
|
|
2855
|
+
failures.append(
|
|
2856
|
+
"final-report data.json: stage "
|
|
2857
|
+
f"{stage.get('stage')} has malformed conformanceTests "
|
|
2858
|
+
f"declaration: got {str(stage.get('conformanceTests'))!r}; "
|
|
2859
|
+
"expected `<task_root>/qa/scripts/stage-<N>.<ext> "
|
|
2860
|
+
"(requires=[db|io|http|external,...])`."
|
|
2861
|
+
)
|
|
2862
|
+
continue
|
|
2863
|
+
script, requires = parsed
|
|
2864
|
+
declarations.append(
|
|
2865
|
+
{
|
|
2866
|
+
"stageKey": f"approved-plan-stage-{stage.get('stage')}",
|
|
2867
|
+
"script": script,
|
|
2868
|
+
"requires": sorted(requires),
|
|
2869
|
+
}
|
|
2870
|
+
)
|
|
2871
|
+
return declarations
|
|
2872
|
+
|
|
2873
|
+
|
|
2874
|
+
def _implemented_stages(data_path: Path) -> frozenset[int]:
|
|
2875
|
+
"""이 태스크에서 구현이 끝난 stage. 원장을 못 읽으면 빈 집합이다."""
|
|
2876
|
+
from okstra_ctl.consumers import read_stage_consumer_state
|
|
2877
|
+
|
|
2878
|
+
try:
|
|
2879
|
+
state = read_stage_consumer_state(data_path.parent.parent)
|
|
2880
|
+
except (OSError, UnicodeError, ValueError):
|
|
2881
|
+
return frozenset()
|
|
2882
|
+
return frozenset(state.done_stages)
|
|
2883
|
+
|
|
2884
|
+
|
|
2885
|
+
def _is_implemented_stage(stage: object, implemented: frozenset[int]) -> bool:
|
|
2886
|
+
number = stage.get("stage") if isinstance(stage, dict) else None
|
|
2887
|
+
return (
|
|
2888
|
+
isinstance(number, int)
|
|
2889
|
+
and not isinstance(number, bool)
|
|
2890
|
+
and (number in implemented)
|
|
2891
|
+
)
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
def _validate_planning_conformance_declared(
|
|
2895
|
+
report_path: Path,
|
|
2896
|
+
failures: list[str],
|
|
2897
|
+
surface_patterns: object = None,
|
|
2898
|
+
) -> None:
|
|
2899
|
+
"""계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식을 본다.
|
|
2900
|
+
|
|
2901
|
+
스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
|
|
2902
|
+
선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
|
|
2903
|
+
`conformanceTests` 는 여전히 실패한다.
|
|
2904
|
+
|
|
2905
|
+
면제 stage 의 `plannedPaths` 가 db/io/http/external 표면을 건드리면 여기서
|
|
2906
|
+
막는다 — 구현 게이트의 diff-surface 대조(`_validate_conformance_surfaces`)와
|
|
2907
|
+
같은 패턴이다. 종전에는 그 대조가 구현이 끝난 뒤에만 돌아, 승인된 계획을
|
|
2908
|
+
고칠 수 없는 자리에서 run 전체가 막혔다(2026-09-09 dev-10784 Stage 2).
|
|
2909
|
+
"""
|
|
2910
|
+
data_path = _data_path_for(report_path)
|
|
2911
|
+
if not data_path.is_file():
|
|
2912
|
+
return
|
|
2913
|
+
try:
|
|
2914
|
+
data = load_owned_object(data_path, artifact="planning report")
|
|
2915
|
+
except JsonBoundaryError:
|
|
2916
|
+
return
|
|
2917
|
+
ip = data.get("implementationPlanning")
|
|
2918
|
+
if not isinstance(ip, dict):
|
|
2919
|
+
return
|
|
2920
|
+
# 구현이 끝난 stage 는 이 게이트의 대상이 아니다. 그 본문은 다음 계획 run 에
|
|
2921
|
+
# 그대로 이월되고(ADR-0015), 이월된 본문은 고칠 수 없다 — 규칙이 그 사이에
|
|
2922
|
+
# 넓어졌다면 통과 가능한 값이 없는 요구가 된다(2026-09-24, dev-10860: 이월된
|
|
2923
|
+
# stage 2·3·5 가 오늘의 표면 패턴으로 `requires` 누락 판정).
|
|
2924
|
+
implemented = _implemented_stages(data_path)
|
|
2925
|
+
stages = [
|
|
2926
|
+
stage
|
|
2927
|
+
for stage in ip.get("stages") or ()
|
|
2928
|
+
if not _is_implemented_stage(stage, implemented)
|
|
2929
|
+
]
|
|
2930
|
+
_planning_conformance_declarations(stages, failures)
|
|
2931
|
+
if ip.get("planningContract") != "selected-direction":
|
|
2932
|
+
from okstra_ctl.implementation_direction import (
|
|
2933
|
+
stage_validation_executability_errors,
|
|
2934
|
+
)
|
|
2935
|
+
|
|
2936
|
+
failures.extend(stage_validation_executability_errors(ip))
|
|
2937
|
+
for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
|
|
2938
|
+
if conflict["stage"] in implemented:
|
|
2939
|
+
continue
|
|
2940
|
+
failures.append(
|
|
2941
|
+
"conformance gate BLOCKING: stage "
|
|
2942
|
+
f"{conflict['stage']} declares `Conformance exemption:` but its "
|
|
2943
|
+
f"planned paths touch surface(s) {conflict['surfaces']}: "
|
|
2944
|
+
f"{', '.join(conflict['paths'])} — an exemption cannot hide a "
|
|
2945
|
+
"db/io/http/external change (prompts/profiles/implementation-planning.md "
|
|
2946
|
+
'"Per-stage conformance declaration"); declare `Conformance tests:` '
|
|
2947
|
+
f"with requires={conflict['surfaces']} for that stage, or move those "
|
|
2948
|
+
"paths out of it. The implementation run's diff-surface check "
|
|
2949
|
+
"blocks the same stage after the work is done, where the approved "
|
|
2950
|
+
"plan can no longer be corrected."
|
|
2951
|
+
)
|
|
2952
|
+
for gap in declared_stage_surface_gaps(data, surface_patterns):
|
|
2953
|
+
if gap["stage"] in implemented:
|
|
2954
|
+
continue
|
|
2955
|
+
failures.append(
|
|
2956
|
+
"conformance gate BLOCKING: stage "
|
|
2957
|
+
f"{gap['stage']} declares `Conformance tests:` with "
|
|
2958
|
+
f"requires={gap['requires']} but its planned paths touch surface(s) "
|
|
2959
|
+
f"{gap['surfaces']}: {', '.join(gap['paths'])} — add "
|
|
2960
|
+
f"{gap['surfaces']} to that stage's `requires`, or move those paths "
|
|
2961
|
+
"out of it. The implementation run's diff-surface check demands "
|
|
2962
|
+
"the wider set after the work is done, where the approved plan can "
|
|
2963
|
+
"no longer be corrected."
|
|
2964
|
+
)
|
|
2965
|
+
|
|
2966
|
+
|
|
2967
|
+
def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
|
|
2968
|
+
return (
|
|
2969
|
+
run_manifest.get("activityContractVersion") == 1
|
|
2970
|
+
and run_manifest.get("taskType") == "implementation-planning"
|
|
2971
|
+
)
|
|
2972
|
+
|
|
2973
|
+
|
|
2974
|
+
def _validate_approval_context(
|
|
2975
|
+
data: dict,
|
|
2976
|
+
run_manifest: dict,
|
|
2977
|
+
failures: list[str],
|
|
2978
|
+
) -> None:
|
|
2979
|
+
"""활동 계약 v1 계획 run 의 승인 검사를 v3 경로로 넘긴다.
|
|
2980
|
+
|
|
2981
|
+
v2 전용 본문은 삭제했다. 같은 요구를 조립 시점의 `report_assembly.py` /
|
|
2982
|
+
`approval_decisions.py` 가 이미 거부하고, 새 run 은 전부 schemaVersion 3.0
|
|
2983
|
+
이라 v2 갈래에 도달하는 값이 없었다.
|
|
2984
|
+
"""
|
|
2985
|
+
if not _is_activity_contract_v1_planning(run_manifest):
|
|
2986
|
+
return
|
|
2987
|
+
if data.get("schemaVersion") == "3.0":
|
|
2988
|
+
_validate_v3_approval_context(data, failures)
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
2992
|
+
"""승인 플래그가 아직 진행을 막는 행과 공존하지 못하게 한다.
|
|
2993
|
+
|
|
2994
|
+
backlinks / dispositions / resolution-link 대조 세 갈래는 뺐다. 셋 다
|
|
2995
|
+
조립(`report_assembly.py`, `approval_decisions.py`)이 같은 입력으로 만든
|
|
2996
|
+
값을 같은 식으로 되계산하는 항등식이라 조립을 우회하지 않는 한 걸릴 값이
|
|
2997
|
+
없다.
|
|
2998
|
+
"""
|
|
2999
|
+
approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
3000
|
+
incorporated = incorporated_clarification_ids(data)
|
|
3001
|
+
for row in data.get("clarificationItems") or []:
|
|
3002
|
+
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
3003
|
+
continue
|
|
3004
|
+
# `approvalContext` 없는 행을 건너뛰는 것은 이전과 같은 범위다. 여기서
|
|
3005
|
+
# 범위를 넓히면 삭제한 세 갈래가 막던 행이 새 차단으로 되살아난다.
|
|
3006
|
+
if not isinstance(row.get("approvalContext"), dict):
|
|
3007
|
+
continue
|
|
3008
|
+
row_id = str(row.get("id") or "")
|
|
3009
|
+
if approved and row_blocks_progress(
|
|
3010
|
+
str(row.get("status") or ""),
|
|
3011
|
+
clarification_disposition(row),
|
|
3012
|
+
incorporated=row_id in incorporated,
|
|
3013
|
+
):
|
|
3014
|
+
failures.append(
|
|
3015
|
+
f"final-report data.json: approval is true while clarification "
|
|
3016
|
+
f"`{row.get('id')}` remains `{row.get('status')}`."
|
|
3017
|
+
)
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
_CHECKLIST_REF_RE = re.compile(r"VC-\d+")
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
def _detect_missing_dependency_precondition(
|
|
3024
|
+
data: dict, project_root: Path
|
|
3025
|
+
) -> list[str]:
|
|
3026
|
+
"""A stage that runs the toolchain must point at a declared precondition.
|
|
3027
|
+
|
|
3028
|
+
The planning worktree installs no dependencies, so `yarn … test` cannot run
|
|
3029
|
+
there. A plan that says nothing about it produces steps whose commands die
|
|
3030
|
+
on `exit 127`, and the verification round then spends itself on a defect the
|
|
3031
|
+
planner cannot fix by editing the plan.
|
|
3032
|
+
|
|
3033
|
+
The shape checked is the one an observed self-fix loop arrived at after two
|
|
3034
|
+
rounds: one `phase: pre` checklist item declaring the install, referenced by
|
|
3035
|
+
every stage that needs it. Only the reference is machine-checked — whether
|
|
3036
|
+
the cited item genuinely covers dependencies is a semantic judgement left to
|
|
3037
|
+
the §5.5.9 round, the same boundary the scope-provenance gate draws.
|
|
3038
|
+
"""
|
|
3039
|
+
planning = data.get("implementationPlanning")
|
|
3040
|
+
if not isinstance(planning, dict):
|
|
3041
|
+
return []
|
|
3042
|
+
tokens = resolve_build_tool_tokens(project_root)
|
|
3043
|
+
if not tokens:
|
|
3044
|
+
return []
|
|
3045
|
+
|
|
3046
|
+
checklist = {
|
|
3047
|
+
str(row.get("id")): str(row.get("phase") or "")
|
|
3048
|
+
for row in (planning.get("validationChecklist") or [])
|
|
3049
|
+
if isinstance(row, dict) and row.get("id")
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
warnings: list[str] = []
|
|
3053
|
+
for stage in planning.get("stages") or []:
|
|
3054
|
+
if not isinstance(stage, dict):
|
|
3055
|
+
continue
|
|
3056
|
+
commands = [
|
|
3057
|
+
str(step.get("command") or "")
|
|
3058
|
+
for step in (stage.get("stepwiseExecution") or [])
|
|
3059
|
+
if isinstance(step, dict)
|
|
3060
|
+
]
|
|
3061
|
+
if not any(command_invokes_build_tool(c, tokens=tokens) for c in commands):
|
|
3062
|
+
continue
|
|
3063
|
+
refs = _CHECKLIST_REF_RE.findall(str(stage.get("stageValidation") or ""))
|
|
3064
|
+
if not refs:
|
|
3065
|
+
warnings.append(
|
|
3066
|
+
f"Stage {stage.get('stage')} runs the project toolchain but its "
|
|
3067
|
+
"Stage Validation cites no `VC-NNN` precondition. The planning "
|
|
3068
|
+
"worktree has no dependencies installed, so declare the install "
|
|
3069
|
+
"once as a `phase: pre` Validation Checklist item and reference "
|
|
3070
|
+
"it here."
|
|
3071
|
+
)
|
|
3072
|
+
continue
|
|
3073
|
+
if not any(checklist.get(ref) == "pre" for ref in refs):
|
|
3074
|
+
cited = ", ".join(sorted(set(refs)))
|
|
3075
|
+
warnings.append(
|
|
3076
|
+
f"Stage {stage.get('stage')} runs the project toolchain and cites "
|
|
3077
|
+
f"{cited}, but none of those is a `phase: pre` Validation "
|
|
3078
|
+
"Checklist item — a precondition verified after the fact is not a "
|
|
3079
|
+
"precondition."
|
|
3080
|
+
)
|
|
3081
|
+
return warnings
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
_UNMAPPED_FALLBACK_REASON = "no impacted stages resolved"
|
|
3085
|
+
|
|
3086
|
+
|
|
3087
|
+
def _prior_planning_data(report_path: Path) -> dict | None:
|
|
3088
|
+
"""The newest implementation-planning data.json preceding *report_path*."""
|
|
3089
|
+
match = re.search(r"-(\d+)\.md$", report_path.name)
|
|
3090
|
+
if not match:
|
|
3091
|
+
return None
|
|
3092
|
+
current_seq = int(match.group(1))
|
|
3093
|
+
candidates = sorted(
|
|
3094
|
+
(
|
|
3095
|
+
path
|
|
3096
|
+
for path in report_path.parent.glob(
|
|
3097
|
+
"final-report-implementation-planning-*.data.json"
|
|
3098
|
+
)
|
|
3099
|
+
if (m := re.search(r"-(\d+)\.data\.json$", path.name))
|
|
3100
|
+
and int(m.group(1)) < current_seq
|
|
3101
|
+
),
|
|
3102
|
+
key=lambda p: p.name,
|
|
3103
|
+
)
|
|
3104
|
+
for path in reversed(candidates):
|
|
3105
|
+
try:
|
|
3106
|
+
payload = load_owned_object(path, artifact="prior planning report")
|
|
3107
|
+
except JsonBoundaryError:
|
|
3108
|
+
continue
|
|
3109
|
+
return payload
|
|
3110
|
+
return None
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
def _detect_unmapped_incremental_fallback(data: dict, report_path: Path) -> list[str]:
|
|
3114
|
+
"""A re-run that fell back to full while the prior report could have mapped it.
|
|
3115
|
+
|
|
3116
|
+
Both "the answer restructures the plan" and "no stage could be resolved"
|
|
3117
|
+
return `mode: full`, and only the second is a missed narrowing. The lead
|
|
3118
|
+
declares the first through `--full-reason`, so the reason prefix separates
|
|
3119
|
+
them; this reports the second only when the trace would have succeeded.
|
|
3120
|
+
|
|
3121
|
+
Advisory: a lead that never passed `--full-reason` produces the fallback
|
|
3122
|
+
reason for both cases, so failing here would punish runs written before the
|
|
3123
|
+
flag existed.
|
|
3124
|
+
"""
|
|
3125
|
+
planning = data.get("implementationPlanning")
|
|
3126
|
+
if not isinstance(planning, dict):
|
|
3127
|
+
return []
|
|
3128
|
+
decision = planning.get("incrementalDecision")
|
|
3129
|
+
if not isinstance(decision, dict) or decision.get("mode") != "full":
|
|
3130
|
+
return []
|
|
3131
|
+
if _UNMAPPED_FALLBACK_REASON not in str(decision.get("reason") or ""):
|
|
3132
|
+
return []
|
|
3133
|
+
|
|
3134
|
+
answered = _answered_clarification_ids(data)
|
|
3135
|
+
if not answered:
|
|
3136
|
+
return []
|
|
3137
|
+
prior = _prior_planning_data(report_path)
|
|
3138
|
+
if prior is None:
|
|
3139
|
+
return []
|
|
3140
|
+
|
|
3141
|
+
try:
|
|
3142
|
+
from okstra_ctl.incremental_scope import clarification_impacted_stages
|
|
3143
|
+
|
|
3144
|
+
stages = clarification_impacted_stages(prior, set(answered))
|
|
3145
|
+
except (ImportError, ValueError):
|
|
3146
|
+
return []
|
|
3147
|
+
if not stages:
|
|
3148
|
+
return []
|
|
3149
|
+
return [
|
|
3150
|
+
"incrementalDecision fell back to full for lack of a resolved stage, but "
|
|
3151
|
+
f"the prior report maps {', '.join(sorted(answered))} to stage(s) "
|
|
3152
|
+
f"{', '.join(str(s) for s in sorted(stages))}. Pass the answered ids "
|
|
3153
|
+
"through `--answered-clarifications` so the re-run narrows, or declare "
|
|
3154
|
+
"the structural change with `--full-reason` when full is the judgement."
|
|
3155
|
+
]
|
|
3156
|
+
|
|
3157
|
+
|
|
3158
|
+
_PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
def _validate_plan_body_state_file(
|
|
3162
|
+
data: dict,
|
|
3163
|
+
report_path: Path,
|
|
3164
|
+
failures: list[str],
|
|
3165
|
+
state_path: Path | None = None,
|
|
3166
|
+
) -> None:
|
|
3167
|
+
"""The per-round state file must exist once a round has run.
|
|
3168
|
+
|
|
3169
|
+
Nothing read this file, so its documented schema was dead contract — yet
|
|
3170
|
+
it is the only record of *superseded* rounds. `planItems[].verdicts` in
|
|
3171
|
+
data.json is overwritten by each self-fix re-verification, so after the
|
|
3172
|
+
loop the report shows the final votes and no trace of what the earlier
|
|
3173
|
+
rounds found. Both defect investigations of this phase depended on the
|
|
3174
|
+
sidecar to recover that history.
|
|
3175
|
+
|
|
3176
|
+
Deliberately does NOT cross-check any gate against data.json: the two are
|
|
3177
|
+
different views by design (per-round history vs. final state), and
|
|
3178
|
+
demanding equality would fail every run whose self-fix loop worked.
|
|
3179
|
+
"""
|
|
3180
|
+
ip = data.get("implementationPlanning")
|
|
3181
|
+
if not isinstance(ip, dict):
|
|
3182
|
+
return
|
|
3183
|
+
pbv = ip.get("planBodyVerification")
|
|
3184
|
+
if not isinstance(pbv, dict):
|
|
3185
|
+
return
|
|
3186
|
+
round_count = pbv.get("roundCount")
|
|
3187
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
3188
|
+
return
|
|
3189
|
+
state_dir = report_path.parent.parent / "state"
|
|
3190
|
+
if state_path is not None:
|
|
3191
|
+
# 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
|
|
3192
|
+
# `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
|
|
3193
|
+
# 그 정본과 어긋날 수 있다 — 실제로 그랬다.
|
|
3194
|
+
written = [state_path] if state_path.is_file() else []
|
|
3195
|
+
else:
|
|
3196
|
+
# 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
|
|
3197
|
+
# `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
|
|
3198
|
+
# 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
|
|
3199
|
+
# 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
|
|
3200
|
+
# 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
|
|
3201
|
+
written = sorted(
|
|
3202
|
+
state_dir.glob("plan-body-verification-implementation-planning-*.json")
|
|
3203
|
+
)
|
|
3204
|
+
if not written:
|
|
3205
|
+
failures.append(
|
|
3206
|
+
f"plan-body verification ran ({round_count} round(s)) but no "
|
|
3207
|
+
f"`state/plan-body-verification-*.json` was written. It is the only "
|
|
3208
|
+
"record of superseded rounds — data.json keeps just the final "
|
|
3209
|
+
"verdicts, so without it a self-fixed run leaves no trace of what "
|
|
3210
|
+
'the earlier rounds found (plan-body-verification.md §"schema"). '
|
|
3211
|
+
"The path is rendered into the launch prompt's `Run Paths` block; "
|
|
3212
|
+
"write it there rather than deriving a name."
|
|
3213
|
+
)
|
|
3214
|
+
return
|
|
3215
|
+
# 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
|
|
3216
|
+
expected = written[-1]
|
|
3217
|
+
try:
|
|
3218
|
+
state = load_owned_object(expected, artifact="plan-body verification state")
|
|
3219
|
+
except JsonBoundaryError as exc:
|
|
3220
|
+
failures.append(f"plan-body verification state file is unreadable: {exc}")
|
|
3221
|
+
return
|
|
3222
|
+
missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
|
|
3223
|
+
for key in missing:
|
|
3224
|
+
failures.append(
|
|
3225
|
+
f"plan-body verification state file `{expected.name}` is "
|
|
3226
|
+
f"missing required key `{key}`."
|
|
3227
|
+
)
|
|
3228
|
+
if not missing:
|
|
3229
|
+
_validate_plan_body_state_rounds(
|
|
3230
|
+
state, pbv, expected.name, round_count, failures
|
|
3231
|
+
)
|
|
3232
|
+
|
|
3233
|
+
|
|
3234
|
+
def _validate_plan_body_state_rounds(
|
|
3235
|
+
state: dict,
|
|
3236
|
+
pbv: dict,
|
|
3237
|
+
name: str,
|
|
3238
|
+
round_count: int,
|
|
3239
|
+
failures: list[str],
|
|
3240
|
+
) -> None:
|
|
3241
|
+
"""Every round that ran must survive in the sidecar, its votes included.
|
|
3242
|
+
|
|
3243
|
+
The round protocol used to write this file once, before the self-fix loop,
|
|
3244
|
+
and never asked for it again — so a run with three re-verifications kept
|
|
3245
|
+
round 1 only, and the superseded rounds this file exists to preserve were
|
|
3246
|
+
exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
|
|
3247
|
+
data.json against `round` 1 here).
|
|
3248
|
+
"""
|
|
3249
|
+
history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
|
|
3250
|
+
# A non-int `round` names no round, so it cannot cover one — and reading it
|
|
3251
|
+
# into a set would abort the whole validation on unhashable lead-authored JSON.
|
|
3252
|
+
recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
|
|
3253
|
+
if recorded != set(range(1, round_count + 1)):
|
|
3254
|
+
seen = sorted(recorded)
|
|
3255
|
+
failures.append(
|
|
3256
|
+
f"plan-body verification state file `{name}` records `roundHistory[]` "
|
|
3257
|
+
f"rounds {seen} but the report declares `roundCount`={round_count}. "
|
|
3258
|
+
f"One entry per round 1..{round_count} is required: data.json keeps "
|
|
3259
|
+
"only the final verdicts, so a sidecar frozen at an earlier round "
|
|
3260
|
+
"loses every round it superseded (plan-body-verification.md "
|
|
3261
|
+
'§"Round protocol" step 7 "Round completion").'
|
|
3262
|
+
)
|
|
3263
|
+
gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
|
|
3264
|
+
if gateless:
|
|
3265
|
+
failures.append(
|
|
3266
|
+
f"plan-body verification state file `{name}`: `roundHistory[]` "
|
|
3267
|
+
f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
|
|
3268
|
+
"gate is what tells the reader which round blocked and on what, and "
|
|
3269
|
+
"the sidecar is the only place it survives."
|
|
3270
|
+
)
|
|
3271
|
+
declared = pbv.get("selfFixRoundsApplied")
|
|
3272
|
+
if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
|
|
3273
|
+
failures.append(
|
|
3274
|
+
f"plan-body verification state file `{name}` records "
|
|
3275
|
+
f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
|
|
3276
|
+
f"report declares {declared}. The sidecar is rewritten at each round's "
|
|
3277
|
+
"end, so a stale count means the later rounds were never written to it."
|
|
3278
|
+
)
|
|
3279
|
+
voted = {
|
|
3280
|
+
vote["round"]
|
|
3281
|
+
for item in (state.get("planItems") or [])
|
|
3282
|
+
if isinstance(item, dict)
|
|
3283
|
+
for vote in (item.get("rounds") or [])
|
|
3284
|
+
if isinstance(vote, dict) and isinstance(vote.get("round"), int)
|
|
3285
|
+
}
|
|
3286
|
+
uncited = [
|
|
3287
|
+
n for n in sorted(recorded & set(range(1, round_count + 1))) if n not in voted
|
|
3288
|
+
]
|
|
3289
|
+
if uncited:
|
|
3290
|
+
failures.append(
|
|
3291
|
+
f"plan-body verification state file `{name}`: round(s) {uncited} "
|
|
3292
|
+
"appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
|
|
3293
|
+
"records a vote cast in them. A re-verification round whose verdicts "
|
|
3294
|
+
"were never written down is precisely the history this file holds."
|
|
3295
|
+
)
|