okstra 0.171.0 → 0.173.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.
Files changed (106) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +11 -0
  3. package/docs/architecture.md +29 -14
  4. package/docs/cli.md +40 -7
  5. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  6. package/docs/performance-improvement-plan-v2.md +6 -5
  7. package/docs/project-structure-overview.md +24 -14
  8. package/docs/task-process/README.md +5 -3
  9. package/docs/task-process/error-analysis.md +2 -2
  10. package/docs/task-process/final-verification.md +2 -2
  11. package/docs/task-process/implementation-option-selection.md +70 -0
  12. package/docs/task-process/implementation-planning.md +23 -15
  13. package/docs/task-process/requirements-discovery.md +2 -2
  14. package/package.json +1 -1
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/report-writer-worker.md +30 -6
  17. package/runtime/bin/lib/okstra/cli.sh +5 -1
  18. package/runtime/bin/lib/okstra/globals.sh +1 -0
  19. package/runtime/bin/lib/okstra/usage.sh +3 -0
  20. package/runtime/bin/okstra.sh +2 -0
  21. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  22. package/runtime/prompts/duties/planning-worker.md +12 -4
  23. package/runtime/prompts/launch.template.md +4 -0
  24. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  25. package/runtime/prompts/lead/context-loader.md +1 -1
  26. package/runtime/prompts/lead/convergence.md +5 -5
  27. package/runtime/prompts/lead/okstra-lead-contract.md +42 -17
  28. package/runtime/prompts/lead/plan-body-verification.md +42 -14
  29. package/runtime/prompts/lead/report-writer.md +38 -15
  30. package/runtime/prompts/lead/team-contract.md +2 -0
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
  32. package/runtime/prompts/profiles/_common-contract.md +3 -2
  33. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  34. package/runtime/prompts/profiles/error-analysis.md +3 -3
  35. package/runtime/prompts/profiles/final-verification.md +3 -3
  36. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  37. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  38. package/runtime/prompts/profiles/implementation-planning.md +56 -37
  39. package/runtime/prompts/profiles/implementation.md +2 -1
  40. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  41. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  42. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  43. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  44. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  45. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  46. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  47. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  48. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  49. package/runtime/python/okstra_ctl/agent_activity.py +306 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/clarification_items.py +37 -20
  53. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  54. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  55. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  56. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  57. package/runtime/python/okstra_ctl/lead_events.py +47 -4
  58. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  59. package/runtime/python/okstra_ctl/render.py +12 -3
  60. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  61. package/runtime/python/okstra_ctl/report_contract.py +45 -13
  62. package/runtime/python/okstra_ctl/report_finalize.py +51 -14
  63. package/runtime/python/okstra_ctl/report_html/common.py +5 -3
  64. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  65. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  66. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  67. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +42 -11
  68. package/runtime/python/okstra_ctl/report_translation.py +14 -0
  69. package/runtime/python/okstra_ctl/report_views.py +148 -12
  70. package/runtime/python/okstra_ctl/run.py +350 -2
  71. package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
  72. package/runtime/python/okstra_ctl/user_response.py +75 -0
  73. package/runtime/python/okstra_ctl/wizard.py +144 -0
  74. package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
  75. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  76. package/runtime/python/okstra_ctl/workflow.py +29 -7
  77. package/runtime/schemas/final-report-v2.0.schema.json +1623 -143
  78. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  79. package/runtime/templates/reports/final-report-v2.template.md +12 -0
  80. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  81. package/runtime/templates/reports/html/assets/base.css +7 -0
  82. package/runtime/templates/reports/html/base.template.html +3 -2
  83. package/runtime/templates/reports/html/i18n/en.json +27 -2
  84. package/runtime/templates/reports/html/i18n/ko.json +27 -2
  85. package/runtime/templates/reports/html/macros/forms.html +42 -4
  86. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  87. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +61 -2
  88. package/runtime/templates/reports/i18n/en.json +17 -0
  89. package/runtime/templates/reports/implementation-input.template.md +4 -2
  90. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  91. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  92. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  93. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  94. package/runtime/templates/reports/report.js +137 -21
  95. package/runtime/templates/reports/task-brief.template.md +9 -3
  96. package/runtime/templates/reports/user-response.template.md +28 -5
  97. package/runtime/templates/worker-prompt-preamble.md +16 -0
  98. package/runtime/validators/validate-implementation-plan-stages.py +106 -1
  99. package/runtime/validators/validate-report-views.py +2 -2
  100. package/runtime/validators/validate-run.py +1124 -54
  101. package/runtime/validators/validate_improvement_report.py +5 -1
  102. package/runtime/validators/validate_session_conformance.py +523 -35
  103. package/src/cli-registry.mjs +7 -0
  104. package/src/commands/execute/codex-run.mjs +1 -0
  105. package/src/commands/execute/render-bundle.mjs +1 -0
  106. package/src/commands/report/agent-activity.mjs +21 -0
@@ -71,7 +71,12 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
71
71
  coverage_row_blocked_on,
72
72
  stages_for_clarification,
73
73
  )
74
- from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
74
+ from okstra_ctl.workflow import ( # noqa: E402
75
+ DEFAULT_NEXT_PHASE,
76
+ ERROR_ANALYSIS_ROUTING_DIRECTIONS,
77
+ PHASE_SEQUENCE,
78
+ REQUIREMENTS_DISCOVERY_ROUTING_TARGETS,
79
+ )
75
80
  from okstra_ctl.md_table import ( # noqa: E402
76
81
  is_separator_row as _is_markdown_separator,
77
82
  split_pipe_row as _split_pipe_row,
@@ -80,9 +85,16 @@ from okstra_ctl.final_report_paths import final_report_data_path as _data_path_f
80
85
  from okstra_ctl.improvement_assignment import ( # noqa: E402
81
86
  validate_primary_lens_assignments,
82
87
  )
88
+ from okstra_ctl.implementation_options import ( # noqa: E402
89
+ validate_implementation_option_selection,
90
+ )
91
+ from okstra_ctl.implementation_direction import ( # noqa: E402
92
+ validate_selected_direction_plan,
93
+ )
83
94
  from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
84
95
  from okstra_ctl.scope_provenance import ( # noqa: E402
85
96
  brief_citation_problem,
97
+ brief_end_state_id_sequence,
86
98
  brief_end_state_ids,
87
99
  brief_headings,
88
100
  parse_source,
@@ -113,6 +125,7 @@ from okstra_ctl.agent_invocation import ( # noqa: E402
113
125
  agent_model_assignment_from_payload,
114
126
  verify_agent_invocation,
115
127
  )
128
+ from okstra_ctl.lead_events import LeadEventParseError, read_lead_events # noqa: E402
116
129
  from okstra_ctl.worker_audit_ledger import ( # noqa: E402
117
130
  READING_CONFIRMATION_HEADING_RE,
118
131
  check_worker_results_audit,
@@ -405,7 +418,28 @@ def _error_analysis_next_phase(data: Mapping[str, Any]) -> str | None:
405
418
  if not isinstance(routing, Mapping):
406
419
  return None
407
420
  target = routing.get("nextTaskType")
408
- if target in {"error-analysis", "implementation-planning"}:
421
+ if target in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
422
+ return str(target)
423
+ return None
424
+
425
+
426
+ def _requirements_discovery_next_phase(data: Mapping[str, Any]) -> str | None:
427
+ if not isinstance(data, Mapping):
428
+ return None
429
+ header = data.get("header")
430
+ if (
431
+ not isinstance(header, Mapping)
432
+ or header.get("taskType") != "requirements-discovery"
433
+ ):
434
+ return None
435
+ requirements = data.get("requirementsDiscovery")
436
+ if not isinstance(requirements, Mapping):
437
+ return None
438
+ routing = requirements.get("routing")
439
+ if not isinstance(routing, Mapping):
440
+ return None
441
+ target = routing.get("nextTaskType")
442
+ if target in REQUIREMENTS_DISCOVERY_ROUTING_TARGETS:
409
443
  return str(target)
410
444
  return None
411
445
 
@@ -444,11 +478,12 @@ def update_workflow_metadata(
444
478
  # Validation just passed → actively advance to the next phase in
445
479
  # the sequence rather than preserving a stale value that may equal
446
480
  # current_phase (which would cause the lifecycle pointer to stall).
447
- report_next_phase = (
448
- _error_analysis_next_phase(report_data or {})
449
- if current_phase == "error-analysis"
450
- else None
451
- )
481
+ if current_phase == "requirements-discovery":
482
+ report_next_phase = _requirements_discovery_next_phase(report_data or {})
483
+ elif current_phase == "error-analysis":
484
+ report_next_phase = _error_analysis_next_phase(report_data or {})
485
+ else:
486
+ report_next_phase = None
452
487
  next_recommended_phase = report_next_phase or advance_next_phase(
453
488
  current_phase, phase_sequence
454
489
  )
@@ -3141,10 +3176,15 @@ def _validate_error_analysis_consistency(
3141
3176
  target = routing.get("nextTaskType")
3142
3177
  leading_cause_id = routing.get("leadingCauseId")
3143
3178
  candidate_id_set = set(candidate_ids)
3144
- if target == "implementation-planning":
3179
+ if isinstance(target, str) and target not in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
3180
+ failures.append(
3181
+ "final-report data.json: errorAnalysis.routing has unsupported "
3182
+ f"routing target `{target}`."
3183
+ )
3184
+ if target == "implementation-option-selection":
3145
3185
  if not candidates:
3146
3186
  failures.append(
3147
- "final-report data.json: implementation-planning routing requires "
3187
+ "final-report data.json: implementation-option-selection routing requires "
3148
3188
  "at least one cause candidate."
3149
3189
  )
3150
3190
  if (
@@ -3152,7 +3192,7 @@ def _validate_error_analysis_consistency(
3152
3192
  or leading_cause_id not in candidate_id_set
3153
3193
  ):
3154
3194
  failures.append(
3155
- "final-report data.json: implementation-planning routing "
3195
+ "final-report data.json: implementation-option-selection routing "
3156
3196
  "leadingCauseId must reference a cause candidate."
3157
3197
  )
3158
3198
  elif target == "error-analysis" and (
@@ -3164,10 +3204,7 @@ def _validate_error_analysis_consistency(
3164
3204
  "empty or reference a cause candidate."
3165
3205
  )
3166
3206
 
3167
- expected_direction = {
3168
- "implementation-planning": "begin-planning",
3169
- "error-analysis": "continue-investigation",
3170
- }.get(target)
3207
+ expected_direction = ERROR_ANALYSIS_ROUTING_DIRECTIONS.get(target)
3171
3208
  verdict_card_value = data.get("verdictCard")
3172
3209
  verdict_card = (
3173
3210
  verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
@@ -3229,7 +3266,7 @@ def _validate_error_analysis_consistency(
3229
3266
 
3230
3267
  if isinstance(target, str) and target in {
3231
3268
  "error-analysis",
3232
- "implementation-planning",
3269
+ "implementation-option-selection",
3233
3270
  }:
3234
3271
  for field_name, value in (
3235
3272
  ("verdictCard.nextStep", verdict_card.get("nextStep")),
@@ -3343,11 +3380,15 @@ def validate_final_report_data(
3343
3380
  if errors:
3344
3381
  return data
3345
3382
 
3383
+ manifest = run_manifest or {}
3384
+ _validate_approval_context(data, manifest, failures, report_path)
3385
+ _validate_activity_contract_plan_limits(data, manifest, failures)
3386
+
3346
3387
  analysis_result = validate_analysis_report(
3347
3388
  data=data,
3348
3389
  report_path=report_path,
3349
3390
  project_root=project_root or report_path.parent,
3350
- run_manifest=run_manifest or {},
3391
+ run_manifest=manifest,
3351
3392
  clarification_text=clarification_text,
3352
3393
  )
3353
3394
  manifest_task_type = str((run_manifest or {}).get("taskType") or "")
@@ -3368,7 +3409,25 @@ def validate_final_report_data(
3368
3409
 
3369
3410
  task_type = (data.get("header") or {}).get("taskType")
3370
3411
  _validate_verifier_fail_blocks_verdict(data, failures)
3371
- if task_type == "implementation":
3412
+ if task_type == "implementation-option-selection":
3413
+ selection = data.get("implementationOptionSelection") or {}
3414
+ validation_root = project_root or report_path.parent
3415
+ original_ids = brief_end_state_id_sequence(
3416
+ _brief_path_from_manifest(manifest, validation_root)
3417
+ )
3418
+ roster = manifest.get("recommendedWorkers") or ()
3419
+ participating_analysers = tuple(
3420
+ worker for worker in roster if worker != "report-writer"
3421
+ )
3422
+ failures.extend(
3423
+ f"implementation-option-selection: {error}"
3424
+ for error in validate_implementation_option_selection(
3425
+ selection,
3426
+ original_ids,
3427
+ participating_analysers,
3428
+ )
3429
+ )
3430
+ elif task_type == "implementation":
3372
3431
  _validate_stage_carry_sidecar_exists(data, report_path, failures)
3373
3432
  if task_type == "error-analysis":
3374
3433
  _validate_error_analysis_consistency(data, failures)
@@ -3377,6 +3436,31 @@ def validate_final_report_data(
3377
3436
  _validate_verified_row_recorded(data, report_path, failures)
3378
3437
  elif task_type == "implementation-planning":
3379
3438
  active_report_contracts = report_contracts or set()
3439
+ planning = data.get("implementationPlanning") or {}
3440
+ selected_direction_contract = (
3441
+ isinstance(planning, Mapping)
3442
+ and planning.get("planningContract") == "selected-direction"
3443
+ )
3444
+ if selected_direction_contract:
3445
+ validation_root = project_root or report_path.parent
3446
+ try:
3447
+ brief_path = _brief_path_from_manifest(manifest, validation_root)
3448
+ except (OSError, ValueError) as exc:
3449
+ failures.append(
3450
+ "implementation-planning selected-direction: run manifest "
3451
+ f"taskBriefPath is malformed: {exc}"
3452
+ )
3453
+ brief_path = validation_root / "__invalid-brief__"
3454
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
3455
+ snapshot_path = task_root / "instruction-set" / "selected-direction.json"
3456
+ failures.extend(
3457
+ f"implementation-planning selected-direction: {error}"
3458
+ for error in validate_selected_direction_plan(
3459
+ data, brief_path, snapshot_path
3460
+ )
3461
+ )
3462
+ if planning.get("outcome") == "direction-invalidated":
3463
+ return data
3380
3464
  _validate_implementation_planning_cross_project(data, failures)
3381
3465
  _validate_implementation_planning_decision_drafts(data, failures)
3382
3466
  for warning in validate_plan_body_section(data, report_path, failures):
@@ -3396,8 +3480,9 @@ def validate_final_report_data(
3396
3480
  resolve_architecture(_project_root_from_report(report_path)),
3397
3481
  failures,
3398
3482
  )
3399
- _validate_requirement_deviations(data, failures)
3400
- _validate_requirement_coverage_covered_by(data, failures)
3483
+ if not selected_direction_contract:
3484
+ _validate_requirement_deviations(data, failures)
3485
+ _validate_requirement_coverage_covered_by(data, failures)
3401
3486
  warnings = _validate_design_prep_contract(
3402
3487
  data,
3403
3488
  report_path,
@@ -3750,32 +3835,66 @@ def _state_classification(item: dict, gate_class: str) -> str:
3750
3835
  return "dissent-isolated" if dissenting == 1 else "partial-consensus"
3751
3836
 
3752
3837
 
3753
- def _is_dissent_downgraded(item: dict, pbv: dict) -> bool:
3838
+ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
3839
+ """Plan items whose remaining dissent the user explicitly accepted."""
3840
+ accepted: set[str] = set()
3841
+ for row in data.get("clarificationItems") or []:
3842
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
3843
+ continue
3844
+ context = row.get("approvalContext")
3845
+ if not isinstance(context, dict):
3846
+ continue
3847
+ resolution = context.get("resolution")
3848
+ if (
3849
+ row.get("status") == "resolved"
3850
+ and context.get("classification") == "noncritical-dissent"
3851
+ and isinstance(resolution, dict)
3852
+ and resolution.get("disposition") == "accept-risk"
3853
+ and str(resolution.get("userText") or "").strip()
3854
+ and _approval_context_activity_refs_exist(
3855
+ data, str(row.get("id") or ""), context, resolution
3856
+ )
3857
+ ):
3858
+ accepted.update(
3859
+ item_id
3860
+ for item_id in context.get("planItemIds") or []
3861
+ if isinstance(item_id, str)
3862
+ )
3863
+ return accepted
3864
+
3865
+
3866
+ def _is_dissent_downgraded(
3867
+ item: dict,
3868
+ pbv: dict,
3869
+ accepted_item_ids: set[str],
3870
+ ) -> bool:
3754
3871
  """Whether a surviving `majority-disagree` item stops blocking approval.
3755
3872
 
3756
- A planner-fixable defect the planner failed to fix is still a *planner*
3757
- defect; promoting it to a `Blocks=approval` row asks the user to proofread
3758
- the plan document. Once the self-fix budget is exhausted, such an item is
3759
- recorded as a Working Assumption in `## 5. Missing Information and Risks`
3760
- instead of blocking the gate. Defects that would make the implementation
3761
- itself wrong or unsafe (`_is_correctness_critical`) are excluded and keep
3762
- blocking, so correctness never trades away for throughput.
3873
+ Exhausting the automatic self-fix budget records the unresolved dissent but
3874
+ does not accept it. Only an explicit, resolved noncritical risk-acceptance
3875
+ row can lower the item to `has-dissent`. Correctness-critical defects remain
3876
+ blocking regardless of the user's selected disposition.
3763
3877
  """
3764
3878
  return (
3765
3879
  _classify_plan_item_gate(item) == "majority-disagree"
3766
3880
  and not _is_correctness_critical(item)
3767
3881
  and _has_planner_fixable_majority(item)
3768
3882
  and _self_fix_budget_exhausted(pbv)
3883
+ and str(item.get("id") or "") in accepted_item_ids
3769
3884
  )
3770
3885
 
3771
3886
 
3772
- def _recompute_plan_body_gate(pbv: dict) -> str | None:
3887
+ def _recompute_plan_body_gate(
3888
+ pbv: dict,
3889
+ accepted_item_ids: set[str] | None = None,
3890
+ ) -> str | None:
3773
3891
  """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
3774
3892
  Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
3775
3893
  no plan items to judge (disabled / empty round)."""
3894
+ accepted = accepted_item_ids or set()
3776
3895
  classes = [
3777
3896
  "has-dissent"
3778
- if _is_dissent_downgraded(it, pbv)
3897
+ if _is_dissent_downgraded(it, pbv, accepted)
3779
3898
  else _classify_plan_item_gate(it)
3780
3899
  for it in (pbv.get("planItems") or [])
3781
3900
  if isinstance(it, dict)
@@ -3791,7 +3910,11 @@ def _recompute_plan_body_gate(pbv: dict) -> str | None:
3791
3910
  return "passed"
3792
3911
 
3793
3912
 
3794
- def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
3913
+ def _validate_plan_body_gate_recompute(
3914
+ data: dict,
3915
+ failures: list[str],
3916
+ accepted_item_ids: set[str] | None = None,
3917
+ ) -> None:
3795
3918
  """H1 — the declared `Gate result` must not claim a healthier outcome than
3796
3919
  the recorded per-worker verdicts support. Closes the forgery hole where a
3797
3920
  lead writes `gateResult: passed` while workers actually voted DISAGREE:
@@ -3805,7 +3928,12 @@ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
3805
3928
  if not isinstance(pbv, dict):
3806
3929
  return
3807
3930
  declared = str(pbv.get("gateResult") or "").strip().lower()
3808
- recomputed = _recompute_plan_body_gate(pbv)
3931
+ accepted = (
3932
+ _resolved_noncritical_dissent_ids(data)
3933
+ if accepted_item_ids is None
3934
+ else accepted_item_ids
3935
+ )
3936
+ recomputed = _recompute_plan_body_gate(pbv, accepted)
3809
3937
  if recomputed is None or declared not in _PLAN_GATE_RANK:
3810
3938
  return
3811
3939
  if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
@@ -3869,10 +3997,14 @@ def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
3869
3997
  ]
3870
3998
 
3871
3999
 
3872
- def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
4000
+ def _gate_blocking_causes(
4001
+ pbv: dict,
4002
+ coverage_blockers: list[str],
4003
+ accepted_item_ids: set[str] | None = None,
4004
+ ) -> set[str]:
3873
4005
  """Which inputs actually block approval, as `gateBlockedBy` enum values."""
3874
4006
  causes = set()
3875
- recomputed = _recompute_plan_body_gate(pbv)
4007
+ recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
3876
4008
  if recomputed == "blocked-by-disagreement":
3877
4009
  causes.add("majority-disagree")
3878
4010
  elif recomputed == "aborted-non-result":
@@ -3882,6 +4014,885 @@ def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
3882
4014
  return causes
3883
4015
 
3884
4016
 
4017
+ _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
4018
+ "user-decision": frozenset({"select", "request-revision", "reject"}),
4019
+ "noncritical-dissent": frozenset(
4020
+ {"accept-risk", "request-revision", "reject"}
4021
+ ),
4022
+ "correctness-critical": frozenset({"request-revision", "reject"}),
4023
+ }
4024
+
4025
+
4026
+ def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
4027
+ return (
4028
+ run_manifest.get("activityContractVersion") == 1
4029
+ and run_manifest.get("taskType") == "implementation-planning"
4030
+ )
4031
+
4032
+
4033
+ def _independent_coverage_clarification_ids(ip: dict, pbv: dict) -> set[str]:
4034
+ promoted = _plan_body_promoted_clarification_ids(pbv)
4035
+ return {
4036
+ clarification_id
4037
+ for row in (ip.get("requirementCoverage") or [])
4038
+ if isinstance(row, dict) and _blocks_approval(row)
4039
+ for clarification_id in [_cited_clarification_id(row)]
4040
+ if clarification_id and clarification_id not in promoted
4041
+ }
4042
+
4043
+
4044
+ def _expected_approval_classification(
4045
+ row: dict,
4046
+ plan_items_by_id: dict[str, dict],
4047
+ independent_coverage_clarification_ids: set[str],
4048
+ ) -> str:
4049
+ linked = [
4050
+ plan_items_by_id[item_id]
4051
+ for item_id in (row.get("approvalContext") or {}).get("planItemIds") or []
4052
+ if item_id in plan_items_by_id
4053
+ ]
4054
+ if any(_is_correctness_critical(item) for item in linked):
4055
+ return "correctness-critical"
4056
+ if str(row.get("id") or "") in independent_coverage_clarification_ids:
4057
+ return "correctness-critical"
4058
+ for item in linked:
4059
+ disagree_votes = [
4060
+ verdict
4061
+ for verdict in (item.get("verdicts") or [])
4062
+ if isinstance(verdict, dict)
4063
+ and str(verdict.get("verdict") or "").upper() == "DISAGREE"
4064
+ ]
4065
+ needs_user_input = sum(
4066
+ verdict.get("fixability") == "needs-user-input"
4067
+ for verdict in disagree_votes
4068
+ )
4069
+ if disagree_votes and needs_user_input * 2 > len(disagree_votes):
4070
+ return "user-decision"
4071
+ if any(_classify_plan_item_gate(item) == "majority-disagree" for item in linked):
4072
+ return "noncritical-dissent"
4073
+ return "user-decision"
4074
+
4075
+
4076
+ _STATE_DISAGREE_VOTE_RE = re.compile(r"^DISAGREE\(([a-f])\)$")
4077
+ _APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")
4078
+
4079
+
4080
+ def _state_round_as_plan_item(item_id: str, round_row: dict) -> dict:
4081
+ verdicts = []
4082
+ votes = round_row.get("votes")
4083
+ for worker, raw_vote in (votes.items() if isinstance(votes, dict) else ()):
4084
+ vote = str(raw_vote or "").strip()
4085
+ match = _STATE_DISAGREE_VOTE_RE.fullmatch(vote)
4086
+ if match:
4087
+ verdicts.append(
4088
+ {"worker": worker, "verdict": "DISAGREE", "breakageKind": match.group(1)}
4089
+ )
4090
+ elif vote in {"AGREE", "SUPPLEMENT", "verification-error"}:
4091
+ verdicts.append({"worker": worker, "verdict": vote})
4092
+ return {"id": item_id, "verdicts": verdicts}
4093
+
4094
+
4095
+ def _historical_plan_item_evidence(state: dict) -> tuple[dict[str, str], set[str]]:
4096
+ classifications: dict[str, str] = {}
4097
+ item_ids: set[str] = set()
4098
+ for item in state.get("planItems") or []:
4099
+ if not isinstance(item, dict):
4100
+ continue
4101
+ item_id = str(item.get("id") or "").strip()
4102
+ if not item_id:
4103
+ continue
4104
+ item_ids.add(item_id)
4105
+ for round_row in item.get("rounds") or []:
4106
+ if not isinstance(round_row, dict):
4107
+ continue
4108
+ historical = _state_round_as_plan_item(item_id, round_row)
4109
+ if _is_correctness_critical(historical):
4110
+ classifications[item_id] = "correctness-critical"
4111
+ break
4112
+ if _classify_plan_item_gate(historical) == "majority-disagree":
4113
+ classifications.setdefault(item_id, "noncritical-dissent")
4114
+ return classifications, item_ids
4115
+
4116
+
4117
+ def _historical_coverage_clarification_ids(
4118
+ state: dict,
4119
+ plan_classifications: dict[str, str],
4120
+ ) -> set[str]:
4121
+ coverage_gap_rounds = {
4122
+ round_row.get("round")
4123
+ for round_row in (state.get("roundHistory") or [])
4124
+ if isinstance(round_row, dict)
4125
+ and isinstance(round_row.get("round"), int)
4126
+ and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
4127
+ }
4128
+ return {
4129
+ clarification_id
4130
+ for item in (state.get("planItems") or [])
4131
+ if isinstance(item, dict)
4132
+ for item_id in [str(item.get("id") or "").strip()]
4133
+ for clarification_id in [str(item.get("clarificationId") or "").strip()]
4134
+ if item_id
4135
+ and item_id not in plan_classifications
4136
+ and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(clarification_id)
4137
+ and any(
4138
+ isinstance(round_row, dict)
4139
+ and round_row.get("round") in coverage_gap_rounds
4140
+ for round_row in (item.get("rounds") or [])
4141
+ )
4142
+ }
4143
+
4144
+
4145
+ def _read_approval_history(
4146
+ report_path: Path | None,
4147
+ ) -> tuple[dict[str, str], set[str], set[str], dict, Path | None]:
4148
+ if report_path is None or (seq := _report_run_seq(report_path)) is None:
4149
+ return {}, set(), set(), {}, None
4150
+ state_path = (
4151
+ report_path.parent.parent
4152
+ / "state"
4153
+ / f"plan-body-verification-implementation-planning-{seq}.json"
4154
+ )
4155
+ try:
4156
+ state = json.loads(state_path.read_text(encoding="utf-8"))
4157
+ except (OSError, json.JSONDecodeError):
4158
+ return {}, set(), set(), {}, None
4159
+ if not isinstance(state, dict):
4160
+ return {}, set(), set(), {}, None
4161
+ classifications, item_ids = _historical_plan_item_evidence(state)
4162
+ coverage_ids = _historical_coverage_clarification_ids(
4163
+ state,
4164
+ classifications,
4165
+ )
4166
+ return classifications, item_ids, coverage_ids, state, state_path
4167
+
4168
+
4169
+ def _nonblocking_coverage_clarification_ids(ip: dict) -> set[str]:
4170
+ return {
4171
+ ref
4172
+ for row in (ip.get("requirementCoverage") or [])
4173
+ if isinstance(row, dict) and not _blocks_approval(row)
4174
+ for ref in (row.get("decisionRefs") or [])
4175
+ if isinstance(ref, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
4176
+ }
4177
+
4178
+
4179
+ def _historical_approval_classification(
4180
+ row_id: str,
4181
+ linked_ids: list[str],
4182
+ historical_plan_classifications: dict[str, str],
4183
+ historical_coverage_ids: set[str],
4184
+ ) -> str | None:
4185
+ if row_id in historical_coverage_ids:
4186
+ return "correctness-critical"
4187
+ classes = {
4188
+ historical_plan_classifications[item_id]
4189
+ for item_id in linked_ids
4190
+ if item_id in historical_plan_classifications
4191
+ }
4192
+ if "correctness-critical" in classes:
4193
+ return "correctness-critical"
4194
+ if "noncritical-dissent" in classes:
4195
+ return "noncritical-dissent"
4196
+ return None
4197
+
4198
+
4199
+ def _approval_activities_by_id(data: dict) -> dict[str, dict]:
4200
+ return {
4201
+ activity_id: activity
4202
+ for activity in (data.get("agentActivity") or [])
4203
+ if isinstance(activity, dict)
4204
+ for activity_id in [activity.get("activityId")]
4205
+ if isinstance(activity_id, str) and activity_id
4206
+ }
4207
+
4208
+
4209
+ def _canonical_activity_timestamps(
4210
+ run_manifest: Mapping[str, Any],
4211
+ report_path: Path | None,
4212
+ ) -> dict[str, str]:
4213
+ raw_path = run_manifest.get("leadEventsPath")
4214
+ if not isinstance(raw_path, str) or not raw_path.strip():
4215
+ return {}
4216
+ path = Path(raw_path)
4217
+ if not path.is_absolute() and report_path is not None:
4218
+ path = _project_root_from_report(report_path) / path
4219
+ try:
4220
+ events = read_lead_events(path)
4221
+ except (LeadEventParseError, OSError):
4222
+ return {}
4223
+ return {
4224
+ str(event.details.get("activityId")): event.timestamp
4225
+ for event in events
4226
+ if event.event_type == "activity"
4227
+ and isinstance(event.details.get("activityId"), str)
4228
+ }
4229
+
4230
+
4231
+ def _is_decision_required_activity(activity: dict | None) -> bool:
4232
+ return bool(
4233
+ activity
4234
+ and activity.get("kind") == "user-decision-required"
4235
+ and activity.get("outcome") == "blocked"
4236
+ )
4237
+
4238
+
4239
+ def _is_applied_decision_check(activity: dict | None) -> bool:
4240
+ commands = (activity or {}).get("commands")
4241
+ return bool(
4242
+ activity
4243
+ and activity.get("kind") == "user-decision-evaluated"
4244
+ and activity.get("outcome") == "resolved"
4245
+ and str(activity.get("resultPath") or "").strip()
4246
+ and isinstance(commands, list)
4247
+ and bool(commands)
4248
+ and all(
4249
+ isinstance(command, dict) and command.get("exitCode") == 0
4250
+ for command in commands
4251
+ )
4252
+ )
4253
+
4254
+
4255
+ def _activity_matches_approval_context(
4256
+ activity: dict | None,
4257
+ row_id: str,
4258
+ context: dict,
4259
+ ) -> bool:
4260
+ if not activity:
4261
+ return False
4262
+ evidence_refs = {
4263
+ ref
4264
+ for ref in (activity.get("evidenceRefs") or [])
4265
+ if isinstance(ref, str)
4266
+ }
4267
+ activity_item_ids = {
4268
+ item_id
4269
+ for item_id in (activity.get("planItemIds") or [])
4270
+ if isinstance(item_id, str)
4271
+ }
4272
+ context_item_ids = {
4273
+ item_id
4274
+ for item_id in (context.get("planItemIds") or [])
4275
+ if isinstance(item_id, str)
4276
+ }
4277
+ clarification_refs = {
4278
+ ref for ref in evidence_refs if _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
4279
+ }
4280
+ return clarification_refs == {row_id} and context_item_ids == activity_item_ids
4281
+
4282
+
4283
+ _TARGETED_REVERIFICATION_REF_RE = re.compile(
4284
+ r"^plan-body-verification:round-(?P<round>\d+)$"
4285
+ )
4286
+
4287
+
4288
+ def _targeted_reverification_round(activity: dict | None) -> int | None:
4289
+ rounds = {
4290
+ int(match.group("round"))
4291
+ for ref in ((activity or {}).get("evidenceRefs") or [])
4292
+ if isinstance(ref, str)
4293
+ for match in [_TARGETED_REVERIFICATION_REF_RE.fullmatch(ref)]
4294
+ if match is not None
4295
+ }
4296
+ if len(rounds) != 1:
4297
+ return None
4298
+ return next(iter(rounds))
4299
+
4300
+
4301
+ def _approval_context_activity_refs_exist(
4302
+ data: dict,
4303
+ row_id: str,
4304
+ context: dict,
4305
+ resolution: dict,
4306
+ ) -> bool:
4307
+ activities = _approval_activities_by_id(data)
4308
+ activity_ids = {
4309
+ value for value in (context.get("activityIds") or []) if isinstance(value, str)
4310
+ }
4311
+ check_refs = {
4312
+ value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
4313
+ }
4314
+ activity_order = {
4315
+ activity.get("activityId"): index
4316
+ for index, activity in enumerate(data.get("agentActivity") or [])
4317
+ if isinstance(activity, dict)
4318
+ }
4319
+ ordered = bool(activity_ids and check_refs) and max(
4320
+ activity_order.get(ref, -1) for ref in activity_ids
4321
+ ) < min(activity_order.get(ref, -1) for ref in check_refs)
4322
+ return (
4323
+ bool(activity_ids)
4324
+ and bool(check_refs)
4325
+ and all(
4326
+ _is_decision_required_activity(activities.get(ref))
4327
+ and _activity_matches_approval_context(
4328
+ activities.get(ref), row_id, context
4329
+ )
4330
+ for ref in activity_ids
4331
+ )
4332
+ and all(
4333
+ _is_applied_decision_check(activities.get(ref))
4334
+ and _targeted_reverification_round(activities.get(ref)) is not None
4335
+ and _activity_matches_approval_context(
4336
+ activities.get(ref), row_id, context
4337
+ )
4338
+ for ref in check_refs
4339
+ )
4340
+ and ordered
4341
+ )
4342
+
4343
+
4344
+ def _validate_approval_activity_refs(
4345
+ row_id: str,
4346
+ context: dict,
4347
+ activities: dict[str, dict],
4348
+ failures: list[str],
4349
+ ) -> None:
4350
+ activity_ids = {
4351
+ value for value in (context.get("activityIds") or []) if isinstance(value, str)
4352
+ }
4353
+ unknown_activity_ids = sorted(activity_ids - set(activities))
4354
+ if not activity_ids or unknown_activity_ids:
4355
+ failures.append(
4356
+ f"final-report data.json: approval clarification `{row_id}` activityIds "
4357
+ f"must reference agentActivity[].activityId values; unknown="
4358
+ f"{unknown_activity_ids or 'none'}, recorded={sorted(activity_ids)}."
4359
+ )
4360
+ elif not all(
4361
+ _is_decision_required_activity(activities.get(ref)) for ref in activity_ids
4362
+ ):
4363
+ failures.append(
4364
+ f"final-report data.json: approval clarification `{row_id}` activityIds "
4365
+ "must reference blocked user-decision-required activities."
4366
+ )
4367
+ elif not all(
4368
+ _activity_matches_approval_context(activities.get(ref), row_id, context)
4369
+ for ref in activity_ids
4370
+ ):
4371
+ failures.append(
4372
+ f"final-report data.json: approval clarification `{row_id}` activityIds "
4373
+ f"must cite exactly one clarification (`{row_id}`) in evidenceRefs "
4374
+ "and exactly match approvalContext.planItemIds."
4375
+ )
4376
+ resolution = context.get("resolution")
4377
+ if not isinstance(resolution, dict):
4378
+ return
4379
+ check_refs = {
4380
+ value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
4381
+ }
4382
+ unknown_check_refs = sorted(check_refs - set(activities))
4383
+ if check_refs and unknown_check_refs:
4384
+ failures.append(
4385
+ f"final-report data.json: approval clarification `{row_id}` resolution."
4386
+ f"checkRefs must reference agentActivity[].activityId values; unknown="
4387
+ f"{unknown_check_refs}."
4388
+ )
4389
+ elif check_refs and not all(
4390
+ _is_applied_decision_check(activities.get(ref))
4391
+ and _targeted_reverification_round(activities.get(ref)) is not None
4392
+ for ref in check_refs
4393
+ ):
4394
+ failures.append(
4395
+ f"final-report data.json: approval clarification `{row_id}` resolution."
4396
+ "checkRefs must reference resolved user-decision-evaluated activities "
4397
+ "with successful check evidence and one "
4398
+ "`plan-body-verification:round-N` evidenceRef."
4399
+ )
4400
+ elif check_refs and not all(
4401
+ _activity_matches_approval_context(activities.get(ref), row_id, context)
4402
+ for ref in check_refs
4403
+ ):
4404
+ failures.append(
4405
+ f"final-report data.json: approval clarification `{row_id}` resolution."
4406
+ f"checkRefs must cite exactly one clarification (`{row_id}`) in "
4407
+ "evidenceRefs and exactly match approvalContext.planItemIds."
4408
+ )
4409
+ elif check_refs:
4410
+ order = {
4411
+ activity.get("activityId"): index
4412
+ for index, activity in enumerate(activities.values())
4413
+ }
4414
+ if activity_ids and max(order.get(ref, -1) for ref in activity_ids) >= min(
4415
+ order.get(ref, -1) for ref in check_refs
4416
+ ):
4417
+ failures.append(
4418
+ f"final-report data.json: approval clarification `{row_id}` "
4419
+ "user-decision-evaluated activity must occur after every "
4420
+ "user-decision-required activity."
4421
+ )
4422
+
4423
+
4424
+ def _validate_approval_dispositions(
4425
+ row: dict,
4426
+ context: dict,
4427
+ failures: list[str],
4428
+ ) -> None:
4429
+ row_id = str(row.get("id") or "<unknown>")
4430
+ classification = str(context.get("classification") or "")
4431
+ allowed = _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION.get(classification, frozenset())
4432
+ candidates = [("recommendedDisposition", context.get("recommendedDisposition"))]
4433
+ candidates.extend(
4434
+ (f"options[{index}].disposition", option.get("disposition"))
4435
+ for index, option in enumerate(row.get("options") or [])
4436
+ if isinstance(option, dict)
4437
+ )
4438
+ resolution = context.get("resolution")
4439
+ if isinstance(resolution, dict):
4440
+ candidates.append(("resolution.disposition", resolution.get("disposition")))
4441
+ for field, disposition in candidates:
4442
+ if disposition not in allowed:
4443
+ failures.append(
4444
+ f"final-report data.json: approval clarification `{row_id}` "
4445
+ f"classification `{classification}` does not allow `{disposition}` "
4446
+ f"in {field}; allowed dispositions are {sorted(allowed)}."
4447
+ )
4448
+
4449
+
4450
+ def _validate_resolved_approval(
4451
+ row: dict,
4452
+ context: dict,
4453
+ failures: list[str],
4454
+ ) -> None:
4455
+ if row.get("status") != "resolved":
4456
+ return
4457
+ row_id = str(row.get("id") or "<unknown>")
4458
+ resolution = context.get("resolution")
4459
+ if not isinstance(resolution, dict):
4460
+ failures.append(
4461
+ f"final-report data.json: resolved approval clarification `{row_id}` "
4462
+ "requires resolution.userText and non-empty resolution.checkRefs."
4463
+ )
4464
+ return
4465
+ if not str(resolution.get("userText") or "").strip():
4466
+ failures.append(
4467
+ f"final-report data.json: resolved approval clarification `{row_id}` "
4468
+ "requires non-empty resolution.userText."
4469
+ )
4470
+ check_refs = resolution.get("checkRefs")
4471
+ if not isinstance(check_refs, list) or not any(
4472
+ isinstance(value, str) and value for value in check_refs
4473
+ ):
4474
+ failures.append(
4475
+ f"final-report data.json: resolved approval clarification `{row_id}` "
4476
+ "requires non-empty resolution.checkRefs."
4477
+ )
4478
+ if (
4479
+ context.get("classification") == "noncritical-dissent"
4480
+ and resolution.get("disposition") != "accept-risk"
4481
+ ):
4482
+ failures.append(
4483
+ f"final-report data.json: resolved noncritical-dissent `{row_id}` "
4484
+ "requires an explicit accept-risk disposition."
4485
+ )
4486
+
4487
+
4488
+ def _has_successful_targeted_reverification(item: dict) -> bool:
4489
+ verdicts = [
4490
+ str(verdict.get("verdict") or "").strip().upper()
4491
+ for verdict in (item.get("verdicts") or [])
4492
+ if isinstance(verdict, dict)
4493
+ ]
4494
+ return bool(verdicts) and all(
4495
+ verdict in {"AGREE", "SUPPLEMENT"} for verdict in verdicts
4496
+ )
4497
+
4498
+
4499
+ def _parse_approval_timestamp(value: Any) -> datetime | None:
4500
+ if not isinstance(value, str) or not value.strip():
4501
+ return None
4502
+ try:
4503
+ parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
4504
+ except ValueError:
4505
+ return None
4506
+ if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
4507
+ return None
4508
+ return parsed
4509
+
4510
+
4511
+ def _referenced_approval_timestamps(
4512
+ refs: Any,
4513
+ activity_timestamps: dict[str, str],
4514
+ ) -> list[datetime | None]:
4515
+ return [
4516
+ _parse_approval_timestamp(activity_timestamps.get(ref))
4517
+ for ref in (refs or [])
4518
+ if isinstance(ref, str)
4519
+ ]
4520
+
4521
+
4522
+ def _target_round_completed_at(
4523
+ approval_state: dict,
4524
+ target_round: int,
4525
+ ) -> datetime | None:
4526
+ matching_rounds = [
4527
+ row
4528
+ for row in (approval_state.get("roundHistory") or [])
4529
+ if isinstance(row, dict) and row.get("round") == target_round
4530
+ ]
4531
+ if len(matching_rounds) != 1:
4532
+ return None
4533
+ return _parse_approval_timestamp(matching_rounds[0].get("completedAt"))
4534
+
4535
+
4536
+ def _validate_target_round_causality(
4537
+ row_id: str,
4538
+ target_round: int,
4539
+ approval_state: dict,
4540
+ context: dict,
4541
+ resolution: dict,
4542
+ activity_timestamps: dict[str, str],
4543
+ failures: list[str],
4544
+ ) -> None:
4545
+ completed_at = _target_round_completed_at(approval_state, target_round)
4546
+ required_at = _referenced_approval_timestamps(
4547
+ context.get("activityIds"),
4548
+ activity_timestamps,
4549
+ )
4550
+ evaluated_at = _referenced_approval_timestamps(
4551
+ resolution.get("checkRefs"),
4552
+ activity_timestamps,
4553
+ )
4554
+ if (
4555
+ completed_at is None
4556
+ or not required_at
4557
+ or not evaluated_at
4558
+ or None in required_at
4559
+ or None in evaluated_at
4560
+ ):
4561
+ failures.append(
4562
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4563
+ f"state round {target_round} requires one UTC completedAt plus canonical "
4564
+ "timestamps for every required and evaluated activity."
4565
+ )
4566
+ return
4567
+ if completed_at <= max(required_at):
4568
+ failures.append(
4569
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4570
+ f"state round {target_round} completedAt must be after every referenced "
4571
+ "user-decision-required activity."
4572
+ )
4573
+ if completed_at > min(evaluated_at):
4574
+ failures.append(
4575
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4576
+ f"state round {target_round} completedAt must be no later than every "
4577
+ "referenced user-decision-evaluated activity."
4578
+ )
4579
+
4580
+
4581
+ def _required_activity_plan_item_ids(
4582
+ context: dict,
4583
+ activities: dict[str, dict],
4584
+ ) -> set[str]:
4585
+ return {
4586
+ item_id
4587
+ for ref in (context.get("activityIds") or [])
4588
+ if isinstance(ref, str) and _is_decision_required_activity(activities.get(ref))
4589
+ for item_id in (activities[ref].get("planItemIds") or [])
4590
+ if isinstance(item_id, str)
4591
+ }
4592
+
4593
+
4594
+ def _terminal_unknown_plan_items_are_historical(
4595
+ row: dict,
4596
+ context: dict,
4597
+ unknown_ids: set[str],
4598
+ historical_item_ids: set[str],
4599
+ historical_coverage_ids: set[str],
4600
+ activities: dict[str, dict],
4601
+ ) -> bool:
4602
+ status = row.get("status")
4603
+ if status not in {"resolved", "obsolete"}:
4604
+ return False
4605
+ if status == "resolved" and str(row.get("id") or "") not in historical_coverage_ids:
4606
+ return False
4607
+ required_item_ids = _required_activity_plan_item_ids(context, activities)
4608
+ return bool(unknown_ids) and unknown_ids <= historical_item_ids & required_item_ids
4609
+
4610
+
4611
+ def _validate_correctness_resolution(
4612
+ row: dict,
4613
+ linked_ids: list[str],
4614
+ linked_items: list[dict],
4615
+ independent_coverage_clarification_ids: set[str],
4616
+ activities: dict[str, dict],
4617
+ approval_state: dict,
4618
+ approval_state_path: Path | None,
4619
+ activity_timestamps: dict[str, str],
4620
+ failures: list[str],
4621
+ ) -> None:
4622
+ context = row.get("approvalContext") or {}
4623
+ if (
4624
+ context.get("classification") != "correctness-critical"
4625
+ or row.get("status") != "resolved"
4626
+ ):
4627
+ return
4628
+ row_id = str(row.get("id") or "<unknown>")
4629
+ resolution = context.get("resolution") or {}
4630
+ target_rounds = {
4631
+ round_number
4632
+ for ref in (resolution.get("checkRefs") or [])
4633
+ if isinstance(ref, str)
4634
+ for round_number in [_targeted_reverification_round(activities.get(ref))]
4635
+ if round_number is not None
4636
+ }
4637
+ if len(target_rounds) != 1:
4638
+ failures.append(
4639
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4640
+ "requires exactly one evidenced targeted reverification state round."
4641
+ )
4642
+ return
4643
+ target_round = next(iter(target_rounds))
4644
+ _validate_target_round_causality(
4645
+ row_id,
4646
+ target_round,
4647
+ approval_state,
4648
+ context,
4649
+ resolution,
4650
+ activity_timestamps,
4651
+ failures,
4652
+ )
4653
+ state_name = approval_state_path.name if approval_state_path is not None else ""
4654
+ result_paths_match = bool(state_name) and all(
4655
+ tuple(
4656
+ Path(str(activities[ref].get("resultPath") or "")).parts[-2:]
4657
+ ) == ("state", state_name)
4658
+ for ref in (resolution.get("checkRefs") or [])
4659
+ if isinstance(ref, str) and ref in activities
4660
+ )
4661
+ if not result_paths_match:
4662
+ failures.append(
4663
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4664
+ "evaluation resultPath must reference the matching plan-body "
4665
+ "verification state artifact."
4666
+ )
4667
+ state_items = {
4668
+ str(item.get("id") or ""): item
4669
+ for item in (approval_state.get("planItems") or [])
4670
+ if isinstance(item, dict) and str(item.get("id") or "")
4671
+ }
4672
+ state_failures: dict[str, str] = {}
4673
+ current_by_id = {str(item.get("id") or ""): item for item in linked_items}
4674
+ for item_id in linked_ids:
4675
+ state_item = state_items.get(item_id)
4676
+ rounds = state_item.get("rounds") if isinstance(state_item, dict) else None
4677
+ target_state_round = next(
4678
+ (
4679
+ round_row
4680
+ for round_row in (rounds or [])
4681
+ if isinstance(round_row, dict)
4682
+ and round_row.get("round") == target_round
4683
+ ),
4684
+ None,
4685
+ )
4686
+ blocking_rounds = [
4687
+ round_row.get("round")
4688
+ for round_row in (rounds or [])
4689
+ if isinstance(round_row, dict)
4690
+ and isinstance(round_row.get("round"), int)
4691
+ and round_row.get("round") < target_round
4692
+ and (
4693
+ _is_correctness_critical(
4694
+ _state_round_as_plan_item(item_id, round_row)
4695
+ )
4696
+ or _classify_plan_item_gate(
4697
+ _state_round_as_plan_item(item_id, round_row)
4698
+ )
4699
+ == "majority-disagree"
4700
+ )
4701
+ ]
4702
+ if (
4703
+ isinstance(state_item, dict)
4704
+ and state_item.get("clarificationId") == row_id
4705
+ ):
4706
+ blocking_rounds.extend(
4707
+ round_row.get("round")
4708
+ for round_row in (approval_state.get("roundHistory") or [])
4709
+ if isinstance(round_row, dict)
4710
+ and isinstance(round_row.get("round"), int)
4711
+ and round_row.get("round") < target_round
4712
+ and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
4713
+ )
4714
+ target_votes = (
4715
+ target_state_round.get("votes")
4716
+ if isinstance(target_state_round, dict)
4717
+ else None
4718
+ )
4719
+ successful = bool(target_votes) and all(
4720
+ vote in {"AGREE", "SUPPLEMENT"} for vote in target_votes.values()
4721
+ )
4722
+ if not blocking_rounds or not successful:
4723
+ state_failures[item_id] = (
4724
+ f"state round {target_round} is not a successful post-blocker round"
4725
+ )
4726
+ continue
4727
+ current = current_by_id.get(item_id)
4728
+ if current is not None:
4729
+ report_votes = {
4730
+ str(verdict.get("worker") or ""): str(verdict.get("verdict") or "")
4731
+ for verdict in (current.get("verdicts") or [])
4732
+ if isinstance(verdict, dict)
4733
+ }
4734
+ if report_votes != target_votes:
4735
+ state_failures[item_id] = (
4736
+ f"state round {target_round} votes do not match final report verdicts"
4737
+ )
4738
+ if state_failures:
4739
+ failures.append(
4740
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4741
+ f"targeted reverification state round {target_round} is not bound to "
4742
+ f"the resolved evidence; failures={state_failures}."
4743
+ )
4744
+ unresolved = {
4745
+ str(item.get("id") or "<unknown>"): [
4746
+ str(verdict.get("verdict") or "")
4747
+ for verdict in (item.get("verdicts") or [])
4748
+ if isinstance(verdict, dict)
4749
+ ]
4750
+ for item in linked_items
4751
+ if not _has_successful_targeted_reverification(item)
4752
+ }
4753
+ if unresolved:
4754
+ failures.append(
4755
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4756
+ "cannot resolve until targeted reverification records only AGREE or "
4757
+ f"acceptable SUPPLEMENT verdicts; unresolved plan items={unresolved}."
4758
+ )
4759
+ if row_id in independent_coverage_clarification_ids:
4760
+ failures.append(
4761
+ f"final-report data.json: correctness-critical clarification `{row_id}` "
4762
+ "cannot resolve while an independent requirement coverage blocker remains."
4763
+ )
4764
+
4765
+
4766
+ def _validate_approval_context(
4767
+ data: dict,
4768
+ run_manifest: dict,
4769
+ failures: list[str],
4770
+ report_path: Path | None = None,
4771
+ ) -> None:
4772
+ if not _is_activity_contract_v1_planning(run_manifest):
4773
+ return
4774
+ ip = data.get("implementationPlanning") or {}
4775
+ pbv = ip.get("planBodyVerification") or {}
4776
+ plan_items_by_id = {
4777
+ str(item.get("id")): item
4778
+ for item in (pbv.get("planItems") or [])
4779
+ if isinstance(item, dict) and str(item.get("id") or "")
4780
+ }
4781
+ coverage_ids = _independent_coverage_clarification_ids(ip, pbv)
4782
+ (
4783
+ historical_classes,
4784
+ historical_item_ids,
4785
+ historical_coverage_ids,
4786
+ approval_state,
4787
+ approval_state_path,
4788
+ ) = _read_approval_history(report_path)
4789
+ historical_coverage_ids &= _nonblocking_coverage_clarification_ids(
4790
+ ip
4791
+ )
4792
+ activities = _approval_activities_by_id(data)
4793
+ activity_timestamps = _canonical_activity_timestamps(run_manifest, report_path)
4794
+ report_approved = (data.get("frontmatter") or {}).get("approved") is True
4795
+ for row in data.get("clarificationItems") or []:
4796
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
4797
+ continue
4798
+ row_id = str(row.get("id") or "<unknown>")
4799
+ context = row.get("approvalContext")
4800
+ if not isinstance(context, dict):
4801
+ failures.append(
4802
+ f"final-report data.json: approval clarification `{row_id}` requires "
4803
+ "approvalContext under activity contract v1."
4804
+ )
4805
+ continue
4806
+ linked_ids = [
4807
+ value
4808
+ for value in context.get("planItemIds") or []
4809
+ if isinstance(value, str)
4810
+ ]
4811
+ unknown_ids = set(linked_ids) - set(plan_items_by_id)
4812
+ if unknown_ids and not _terminal_unknown_plan_items_are_historical(
4813
+ row,
4814
+ context,
4815
+ unknown_ids,
4816
+ historical_item_ids,
4817
+ historical_coverage_ids,
4818
+ activities,
4819
+ ):
4820
+ failures.append(
4821
+ f"final-report data.json: approval clarification `{row_id}` planItemIds "
4822
+ f"reference unknown plan items {sorted(unknown_ids)}."
4823
+ )
4824
+ linked_items = [
4825
+ plan_items_by_id[item_id]
4826
+ for item_id in linked_ids
4827
+ if item_id in plan_items_by_id
4828
+ ]
4829
+ current_expected = _expected_approval_classification(
4830
+ row, plan_items_by_id, coverage_ids
4831
+ )
4832
+ historical_expected = _historical_approval_classification(
4833
+ row_id, linked_ids, historical_classes, historical_coverage_ids
4834
+ )
4835
+ expected = (
4836
+ historical_expected
4837
+ if row.get("status") in {"resolved", "obsolete"} and historical_expected
4838
+ else current_expected
4839
+ )
4840
+ if context.get("classification") != expected:
4841
+ failures.append(
4842
+ f"final-report data.json: approval clarification `{row_id}` classification "
4843
+ f"is `{context.get('classification')}` but plan evidence requires `{expected}`."
4844
+ )
4845
+ obsolete_has_active_cause = current_expected != "user-decision" or any(
4846
+ item_id in plan_items_by_id for item_id in linked_ids
4847
+ )
4848
+ if row.get("status") == "obsolete" and obsolete_has_active_cause:
4849
+ failures.append(
4850
+ f"final-report data.json: obsolete approval clarification `{row_id}` "
4851
+ f"still has an active `{current_expected}` cause in the current plan."
4852
+ )
4853
+ _validate_approval_activity_refs(row_id, context, activities, failures)
4854
+ _validate_approval_dispositions(row, context, failures)
4855
+ _validate_resolved_approval(row, context, failures)
4856
+ _validate_correctness_resolution(
4857
+ row,
4858
+ linked_ids,
4859
+ linked_items,
4860
+ coverage_ids,
4861
+ activities,
4862
+ approval_state,
4863
+ approval_state_path,
4864
+ activity_timestamps,
4865
+ failures,
4866
+ )
4867
+ if report_approved and row.get("status") in {"open", "answered"}:
4868
+ failures.append(
4869
+ f"final-report data.json: approval is true while clarification `{row_id}` "
4870
+ f"has status `{row.get('status')}`; open and answered approval "
4871
+ "rows remain blocking."
4872
+ )
4873
+
4874
+
4875
+ def _validate_activity_contract_plan_limits(
4876
+ data: dict,
4877
+ run_manifest: dict,
4878
+ failures: list[str],
4879
+ ) -> None:
4880
+ if not _is_activity_contract_v1_planning(run_manifest):
4881
+ return
4882
+ pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
4883
+ rounds_applied = pbv.get("selfFixRoundsApplied", 0)
4884
+ if isinstance(rounds_applied, int) and rounds_applied > 1:
4885
+ failures.append(
4886
+ "final-report data.json: activity contract v1 selfFixRoundsApplied "
4887
+ "must be at most one automatic self-fix round"
4888
+ )
4889
+ if pbv.get("selfFixStopReason") == "cause-group-recurrence":
4890
+ failures.append(
4891
+ "final-report data.json: activity contract v1 cannot newly emit "
4892
+ "cause-group-recurrence"
4893
+ )
4894
+
4895
+
3885
4896
  _CHECKLIST_REF_RE = re.compile(r"VC-\d+")
3886
4897
 
3887
4898
 
@@ -4172,7 +5183,11 @@ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
4172
5183
  )
4173
5184
 
4174
5185
 
4175
- def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
5186
+ def _validate_gate_blocked_by(
5187
+ data: dict,
5188
+ failures: list[str],
5189
+ accepted_item_ids: set[str] | None = None,
5190
+ ) -> None:
4176
5191
  """The gate value names an *outcome*; `gateBlockedBy` names the *cause*.
4177
5192
 
4178
5193
  Two independent inputs can block approval — a `majority-disagree` plan item
@@ -4200,7 +5215,12 @@ def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
4200
5215
  if isinstance(c, str) and str(c).strip()
4201
5216
  }
4202
5217
  coverage_blockers = _independent_coverage_blockers(ip, pbv)
4203
- actual_causes = _gate_blocking_causes(pbv, coverage_blockers)
5218
+ accepted = (
5219
+ _resolved_noncritical_dissent_ids(data)
5220
+ if accepted_item_ids is None
5221
+ else accepted_item_ids
5222
+ )
5223
+ actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)
4204
5224
 
4205
5225
  if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
4206
5226
  failures.append(
@@ -6227,7 +7247,11 @@ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> No
6227
7247
  )
6228
7248
 
6229
7249
 
6230
- def _validate_plan_body_clarification_matching(data: dict, failures: list[str]) -> None:
7250
+ def _validate_plan_body_clarification_matching(
7251
+ data: dict,
7252
+ failures: list[str],
7253
+ accepted_item_ids: set[str] | None = None,
7254
+ ) -> None:
6231
7255
  """H5 — every plan item that the recorded verdicts make `majority-disagree`
6232
7256
  must point (via `clarificationId`) at an existing `blocks: approval`
6233
7257
  clarification row. Closes the hole where a majority-disagree item blocks the
@@ -6243,6 +7267,11 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
6243
7267
  round_count = pbv.get("roundCount")
6244
7268
  if not isinstance(round_count, int) or round_count < 1:
6245
7269
  return
7270
+ accepted = (
7271
+ _resolved_noncritical_dissent_ids(data)
7272
+ if accepted_item_ids is None
7273
+ else accepted_item_ids
7274
+ )
6246
7275
  clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
6247
7276
  all_ids = {r.get("id") for r in clar_rows if r.get("id")}
6248
7277
  approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
@@ -6251,7 +7280,7 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
6251
7280
  continue
6252
7281
  if _classify_plan_item_gate(item) != "majority-disagree":
6253
7282
  continue
6254
- if _is_dissent_downgraded(item, pbv):
7283
+ if _is_dissent_downgraded(item, pbv, accepted):
6255
7284
  continue
6256
7285
  item_id = item.get("id") or "<unknown>"
6257
7286
  cid = item.get("clarificationId")
@@ -6427,8 +7456,9 @@ def validate_plan_body_section(
6427
7456
  spent against a mis-scored gate.
6428
7457
  """
6429
7458
  pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
6430
- _validate_plan_body_gate_recompute(data, failures)
6431
- _validate_gate_blocked_by(data, failures)
7459
+ accepted_item_ids = _resolved_noncritical_dissent_ids(data)
7460
+ _validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
7461
+ _validate_gate_blocked_by(data, failures, accepted_item_ids)
6432
7462
  _validate_participating_analysers(data, failures)
6433
7463
  _validate_self_fix_rewrite_scope(data, failures)
6434
7464
  _validate_self_fix_grouping(data, failures)
@@ -6440,17 +7470,21 @@ def validate_plan_body_section(
6440
7470
  _validate_verdict_rounds_outlive_self_fix(data, failures)
6441
7471
  _validate_plan_item_extraction_completeness(data, failures)
6442
7472
  _validate_plan_item_subject_substance(data, failures)
6443
- _validate_plan_body_clarification_matching(data, failures)
7473
+ _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
6444
7474
  _validate_disagree_has_fixability(data, failures)
6445
7475
  _validate_self_fix_before_clarification(data, failures)
6446
7476
  return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
6447
7477
 
6448
7478
 
6449
- def _gate_summary_item(item: dict, pbv: dict) -> dict:
7479
+ def _gate_summary_item(
7480
+ item: dict,
7481
+ pbv: dict,
7482
+ accepted_item_ids: set[str],
7483
+ ) -> dict:
6450
7484
  """One `gate.items[]` row: the gate class plus its state-file counterpart."""
6451
7485
  classification = (
6452
7486
  "has-dissent"
6453
- if _is_dissent_downgraded(item, pbv)
7487
+ if _is_dissent_downgraded(item, pbv, accepted_item_ids)
6454
7488
  else _classify_plan_item_gate(item)
6455
7489
  )
6456
7490
  return {
@@ -6478,11 +7512,12 @@ def plan_body_gate_summary(data: dict) -> dict | None:
6478
7512
  pbv = ip.get("planBodyVerification")
6479
7513
  if not isinstance(pbv, dict):
6480
7514
  return None
6481
- recomputed = _recompute_plan_body_gate(pbv)
7515
+ accepted_item_ids = _resolved_noncritical_dissent_ids(data)
7516
+ recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
6482
7517
  if recomputed is None:
6483
7518
  return None
6484
7519
  items = [
6485
- _gate_summary_item(item, pbv)
7520
+ _gate_summary_item(item, pbv, accepted_item_ids)
6486
7521
  for item in (pbv.get("planItems") or [])
6487
7522
  if isinstance(item, dict)
6488
7523
  ]
@@ -6493,7 +7528,9 @@ def plan_body_gate_summary(data: dict) -> dict | None:
6493
7528
  "declaredBlockedBy": sorted(
6494
7529
  str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
6495
7530
  ),
6496
- "blockedBy": sorted(_gate_blocking_causes(pbv, coverage_blockers)),
7531
+ "blockedBy": sorted(
7532
+ _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
7533
+ ),
6497
7534
  "coverageBlockers": coverage_blockers,
6498
7535
  "blockingItems": [
6499
7536
  item["id"] for item in items if item["classification"] == "majority-disagree"
@@ -6846,6 +7883,13 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
6846
7883
  )
6847
7884
 
6848
7885
 
7886
+ _FINAL_VERIFICATION_ROUTING_TOKEN_RE = re.compile(
7887
+ r"(?<![A-Za-z-])(?:release-handoff\(stage-group\)|release-handoff|done|"
7888
+ r"implementation|error-analysis|implementation-option-selection|"
7889
+ r"implementation-planning)(?![A-Za-z-])"
7890
+ )
7891
+
7892
+
6849
7893
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
6850
7894
  """Enforce verdict ↔ blocker/condition/routing consistency on the
6851
7895
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -6860,7 +7904,16 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
6860
7904
  fv = data.get("finalVerification") or {}
6861
7905
  blockers = fv.get("acceptanceBlockers") or []
6862
7906
  conditions = verdict.get("conditionalAcceptanceConditions") or []
6863
- routing = fv.get("routingRecommendation") or ""
7907
+ routing_value = fv.get("routingRecommendation")
7908
+ routing = routing_value if isinstance(routing_value, str) else ""
7909
+ routing_tokens = _FINAL_VERIFICATION_ROUTING_TOKEN_RE.findall(routing)
7910
+ routing_token = routing_tokens[0] if len(routing_tokens) == 1 else None
7911
+
7912
+ if routing_token is None:
7913
+ failures.append(
7914
+ "final-verification: routingRecommendation must contain exactly one "
7915
+ "supported routing token."
7916
+ )
6864
7917
 
6865
7918
  if token == "accepted" and blockers:
6866
7919
  failures.append(
@@ -6877,7 +7930,10 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
6877
7930
  "final-verification: verdict `conditional-accept` but "
6878
7931
  "conditionalAcceptanceConditions is empty — list every condition."
6879
7932
  )
6880
- if "release-handoff" in routing and token != "accepted":
7933
+ if (
7934
+ routing_token in {"release-handoff", "release-handoff(stage-group)"}
7935
+ and token != "accepted"
7936
+ ):
6881
7937
  failures.append(
6882
7938
  f"final-verification: routingRecommendation cites `release-handoff` "
6883
7939
  f"but verdict is `{token}` — release-handoff routing is allowed only "
@@ -6890,8 +7946,7 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
6890
7946
  f"final-verification: verificationScope must be `whole-task` or "
6891
7947
  f"`single-stage`, got {scope!r}."
6892
7948
  )
6893
- if (scope == "single-stage" and "release-handoff" in routing
6894
- and "release-handoff(stage-group)" not in routing):
7949
+ if scope == "single-stage" and routing_token == "release-handoff":
6895
7950
  failures.append(
6896
7951
  "final-verification: verificationScope `single-stage` cannot recommend "
6897
7952
  "plain release-handoff routing — a single-stage accepted verdict may "
@@ -7290,14 +8345,15 @@ def _validate_fix_cycle(run_manifest: dict, data: dict, failures: list[str]) ->
7290
8345
  def _validate_session_conformance(
7291
8346
  team_state: dict,
7292
8347
  team_state_path: Path,
8348
+ run_manifest: Mapping[str, Any],
7293
8349
  project_root: Path,
7294
8350
  report_path: Path,
7295
8351
  task_type: str,
7296
8352
  claude_projects_dir: str | None,
7297
8353
  failures: list[str],
7298
8354
  ) -> None:
7299
- """prompts/lead/okstra-lead-contract.md BLOCKING 계약 3종(PROGRESS 체크포인트 / claude-worker
7300
- heartbeat / implementation entry guard)의 post-hoc 검사를 위임하고 실패를
8355
+ """prompts/lead/okstra-lead-contract.md의 PROGRESS / activity / heartbeat /
8356
+ implementation entry guard 사후 검사를 위임하고 실패를
7301
8357
  ``session-conformance: `` 접두로 folding 한다. 설계:
7302
8358
  docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
7303
8359
  """
@@ -7314,6 +8370,7 @@ def _validate_session_conformance(
7314
8370
  result = validate_session_conformance(
7315
8371
  team_state=team_state,
7316
8372
  team_state_path=team_state_path,
8373
+ run_manifest=run_manifest,
7317
8374
  project_root=project_root,
7318
8375
  report_path=report_path,
7319
8376
  task_type=task_type,
@@ -8149,6 +9206,7 @@ def main() -> int:
8149
9206
  _validate_session_conformance(
8150
9207
  team_state,
8151
9208
  team_state_path,
9209
+ run_manifest,
8152
9210
  project_root,
8153
9211
  report_path,
8154
9212
  task_type,
@@ -8184,16 +9242,28 @@ def main() -> int:
8184
9242
  for warning in conformance_warnings:
8185
9243
  print(f"validate-run: warning: {warning}", file=sys.stderr)
8186
9244
  if task_type in _BRIEF_DERIVED_PHASES:
8187
- brief_path = _brief_path_from_manifest(task_manifest, project_root)
9245
+ planning = validation_data.get("implementationPlanning")
9246
+ selected_direction_plan = (
9247
+ task_type == "implementation-planning"
9248
+ and isinstance(planning, Mapping)
9249
+ and planning.get("planningContract") == "selected-direction"
9250
+ )
9251
+ brief_path = (
9252
+ project_root / "__selected-direction-brief-validated-from-run-manifest__"
9253
+ if selected_direction_plan
9254
+ else _brief_path_from_manifest(task_manifest, project_root)
9255
+ )
8188
9256
  if task_type in _END_STATE_PHASES:
8189
9257
  if task_type == "implementation-planning":
8190
9258
  _validate_planning_conformance_declared(report_path, failures)
8191
- _validate_end_state_coverage(validation_data, brief_path, failures)
8192
- if task_type == "implementation-planning":
9259
+ if not selected_direction_plan:
9260
+ _validate_end_state_coverage(validation_data, brief_path, failures)
9261
+ if task_type == "implementation-planning" and not selected_direction_plan:
8193
9262
  _validate_requirement_provenance(
8194
9263
  validation_data, brief_path, failures
8195
9264
  )
8196
9265
  _validate_stage_has_requirement(validation_data, failures)
9266
+ if task_type == "implementation-planning":
8197
9267
  _append_stage_data_failures(validation_data, failures)
8198
9268
  if task_type == "improvement-discovery":
8199
9269
  run_dir = report_path.parent.parent