okstra 0.172.0 → 0.174.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 (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -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/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -43,14 +43,16 @@ from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
43
43
  from okstra_project.resolver import resolve_architecture # noqa: E402
44
44
 
45
45
  from okstra_ctl.conformance import ( # noqa: E402
46
- CAPABILITY_WHITELIST,
47
46
  detect_surfaces,
48
47
  evaluate_conformance,
49
48
  manifest_required_surfaces,
49
+ normalize_conformance_script as _normalize_conformance_script,
50
+ parse_conformance_tests as _parse_conformance_tests,
50
51
  qa_result_from_dict,
51
52
  validate_conformance_manifest,
52
53
  )
53
54
  from okstra_ctl.paths import RunRef # noqa: E402
55
+ from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION # noqa: E402
54
56
  from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
55
57
  from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
56
58
  from okstra_ctl.build_tools import ( # noqa: E402
@@ -71,7 +73,12 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
71
73
  coverage_row_blocked_on,
72
74
  stages_for_clarification,
73
75
  )
74
- from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
76
+ from okstra_ctl.workflow import ( # noqa: E402
77
+ DEFAULT_NEXT_PHASE,
78
+ ERROR_ANALYSIS_ROUTING_DIRECTIONS,
79
+ PHASE_SEQUENCE,
80
+ REQUIREMENTS_DISCOVERY_ROUTING_TARGETS,
81
+ )
75
82
  from okstra_ctl.md_table import ( # noqa: E402
76
83
  is_separator_row as _is_markdown_separator,
77
84
  split_pipe_row as _split_pipe_row,
@@ -80,9 +87,16 @@ from okstra_ctl.final_report_paths import final_report_data_path as _data_path_f
80
87
  from okstra_ctl.improvement_assignment import ( # noqa: E402
81
88
  validate_primary_lens_assignments,
82
89
  )
90
+ from okstra_ctl.implementation_options import ( # noqa: E402
91
+ validate_implementation_option_selection,
92
+ )
93
+ from okstra_ctl.implementation_direction import ( # noqa: E402
94
+ validate_selected_direction_plan,
95
+ )
83
96
  from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
84
97
  from okstra_ctl.scope_provenance import ( # noqa: E402
85
98
  brief_citation_problem,
99
+ brief_end_state_id_sequence,
86
100
  brief_end_state_ids,
87
101
  brief_headings,
88
102
  parse_source,
@@ -129,7 +143,7 @@ from okstra_ctl.convergence_provenance import ( # noqa: E402
129
143
 
130
144
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
131
145
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
132
- WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
146
+ WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed"}
133
147
  _AGENT_DISPATCH_DIGEST_KEYS = (
134
148
  "catalogDigest",
135
149
  "assignmentDigest",
@@ -406,7 +420,28 @@ def _error_analysis_next_phase(data: Mapping[str, Any]) -> str | None:
406
420
  if not isinstance(routing, Mapping):
407
421
  return None
408
422
  target = routing.get("nextTaskType")
409
- if target in {"error-analysis", "implementation-planning"}:
423
+ if target in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
424
+ return str(target)
425
+ return None
426
+
427
+
428
+ def _requirements_discovery_next_phase(data: Mapping[str, Any]) -> str | None:
429
+ if not isinstance(data, Mapping):
430
+ return None
431
+ header = data.get("header")
432
+ if (
433
+ not isinstance(header, Mapping)
434
+ or header.get("taskType") != "requirements-discovery"
435
+ ):
436
+ return None
437
+ requirements = data.get("requirementsDiscovery")
438
+ if not isinstance(requirements, Mapping):
439
+ return None
440
+ routing = requirements.get("routing")
441
+ if not isinstance(routing, Mapping):
442
+ return None
443
+ target = routing.get("nextTaskType")
444
+ if target in REQUIREMENTS_DISCOVERY_ROUTING_TARGETS:
410
445
  return str(target)
411
446
  return None
412
447
 
@@ -445,11 +480,12 @@ def update_workflow_metadata(
445
480
  # Validation just passed → actively advance to the next phase in
446
481
  # the sequence rather than preserving a stale value that may equal
447
482
  # current_phase (which would cause the lifecycle pointer to stall).
448
- report_next_phase = (
449
- _error_analysis_next_phase(report_data or {})
450
- if current_phase == "error-analysis"
451
- else None
452
- )
483
+ if current_phase == "requirements-discovery":
484
+ report_next_phase = _requirements_discovery_next_phase(report_data or {})
485
+ elif current_phase == "error-analysis":
486
+ report_next_phase = _error_analysis_next_phase(report_data or {})
487
+ else:
488
+ report_next_phase = None
453
489
  next_recommended_phase = report_next_phase or advance_next_phase(
454
490
  current_phase, phase_sequence
455
491
  )
@@ -1648,35 +1684,6 @@ def _approved_plan_stage_entry(
1648
1684
  }
1649
1685
 
1650
1686
 
1651
- _CONFORMANCE_TESTS_RE = re.compile(
1652
- r"^(?P<script>\S+)\s+\(requires=\[(?P<requires>[^\]]+)\]\)$"
1653
- )
1654
-
1655
-
1656
- def _normalize_conformance_script(script: str) -> str:
1657
- prefix = "<task_root>/"
1658
- return script[len(prefix):] if script.startswith(prefix) else script
1659
-
1660
-
1661
- def _parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
1662
- """Parse `<script> (requires=[cap,...])` from a stage declaration."""
1663
- if not isinstance(value, str):
1664
- return None
1665
- match = _CONFORMANCE_TESTS_RE.fullmatch(value.strip())
1666
- if match is None:
1667
- return None
1668
- script = _normalize_conformance_script(match.group("script"))
1669
- capabilities = [part.strip() for part in match.group("requires").split(",")]
1670
- if (
1671
- not script
1672
- or any(not capability for capability in capabilities)
1673
- or len(set(capabilities)) != len(capabilities)
1674
- or any(capability not in CAPABILITY_WHITELIST for capability in capabilities)
1675
- ):
1676
- return None
1677
- return script, frozenset(capabilities)
1678
-
1679
-
1680
1687
  def _approved_plan_conformance_manifest(
1681
1688
  approved_plan_path: Path,
1682
1689
  task_root: Path,
@@ -2321,7 +2328,7 @@ def _validate_selfmock(report_path: Path, failures: list[str]) -> None:
2321
2328
  read the flat `self-mock.json`.
2322
2329
 
2323
2330
  Only the implementation template renders the §5.7.3 diff summary
2324
- (`templates/reports/final-report.template.md:589`); a final-verification report
2331
+ (`templates/reports/final-report-v2.template.md:589`); a final-verification report
2325
2332
  quotes the diff as a blockquote instead, so this gate is vacuous there by
2326
2333
  design — self-mock is enforced at the implementation stage, and
2327
2334
  final-verification is a read-only re-verify that adds no test files.
@@ -2683,65 +2690,6 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
2683
2690
  )
2684
2691
 
2685
2692
 
2686
- PLANNING_REQUIRED_SECTIONS = (
2687
- "Option Candidates",
2688
- "Trade-off",
2689
- "Recommended Option",
2690
- "Stage Map",
2691
- "Stepwise Execution Order",
2692
- "Dependency",
2693
- "Validation Checklist",
2694
- "Rollback",
2695
- "Requirement Coverage",
2696
- "Plan Body Verification",
2697
- "Cross-Project Dependencies",
2698
- "Decision Drafts",
2699
- )
2700
-
2701
- # §5.7 implementation deliverables — substring scan against report body.
2702
- IMPLEMENTATION_REQUIRED_SECTIONS = (
2703
- "Approved Plan Reference",
2704
- "Commit List",
2705
- "Diff Summary",
2706
- "Out-of-plan Edits",
2707
- "Stage Sidecar Evidence",
2708
- "Validation Evidence",
2709
- "Verifier Results",
2710
- "Rollback Verification",
2711
- "Manual User Test (Draft)",
2712
- "Routing Recommendation",
2713
- )
2714
-
2715
- # §5.8 final-verification deliverables — substring scan against report body.
2716
- FINAL_VERIFICATION_REQUIRED_SECTIONS = (
2717
- "Source Implementation Report",
2718
- "Acceptance Blockers",
2719
- "Residual Risk",
2720
- "Validation Evidence",
2721
- "Read-only Command Log",
2722
- "Conditional Acceptance Conditions",
2723
- "Manual User Test Results",
2724
- "Routing Recommendation",
2725
- "Could-Not-Verify Roll-up",
2726
- )
2727
-
2728
- # Allowed Verdict Token vocabulary, by task-type. `release-handoff` is
2729
- # author-tagged but reads its entry gate from final-verification's report
2730
- # and renders `not-applicable` itself.
2731
- FINAL_VERIFICATION_VERDICT_TOKENS = (
2732
- "accepted",
2733
- "conditional-accept",
2734
- "blocked",
2735
- )
2736
-
2737
- # `## 7. Final Verdict` Verdict Token cell — captures the value between
2738
- # backticks on the `Verdict Token` row. Tolerant to extra column whitespace
2739
- # and to leading bold/italic markers in the label cell.
2740
- _FINAL_VERDICT_TOKEN_RE = re.compile(
2741
- r"^\|[ \t]*\*{0,2}Verdict Token\*{0,2}[ \t]*\|[ \t]*`(?P<value>[^`\n]*)`",
2742
- re.MULTILINE,
2743
- )
2744
-
2745
2693
  # Verdict Card Verdict Token row (top-of-report at-a-glance). Same shape
2746
2694
  # as `_FINAL_VERDICT_TOKEN_RE` but matched against the first occurrence in
2747
2695
  # the Verdict Card block, scoped to the body between `## Verdict Card`
@@ -2752,23 +2700,6 @@ _VERDICT_CARD_BLOCK_RE = re.compile(
2752
2700
  re.DOTALL | re.MULTILINE,
2753
2701
  )
2754
2702
 
2755
- # `## 7. Final Verdict` block scope — used to scope the Verdict Token
2756
- # regex so that we don't accidentally match a Verdict Token row that
2757
- # lives in the Verdict Card or anywhere else.
2758
- _FINAL_VERDICT_BLOCK_RE = re.compile(
2759
- r"^##[ \t]+7\.[ \t]+Final Verdict" + _HEADING_TAIL
2760
- + r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
2761
- re.DOTALL | re.MULTILINE,
2762
- )
2763
-
2764
- # `## 5.6 Release Handoff Deliverables` and `## 5.6.6 Merge Conflict
2765
- # Probe` are required when task-type == release-handoff. The probe sub-
2766
- # section was retro-added to the template; old runs that predate it ship
2767
- # without it, but new runs must include it.
2768
- _MERGE_CONFLICT_PROBE_HEADING_RE = re.compile(
2769
- r"^###[ \t]+5\.6\.6[ \t]+Merge Conflict Probe\b", re.MULTILINE
2770
- )
2771
-
2772
2703
  PLAN_VERIFY_GATE_VALUES = (
2773
2704
  "passed",
2774
2705
  "passed-with-dissent",
@@ -2776,207 +2707,10 @@ PLAN_VERIFY_GATE_VALUES = (
2776
2707
  "aborted-non-result",
2777
2708
  )
2778
2709
 
2779
- # `Gate result:` line in §5.5.9 of the final report — captures the value
2780
- # token that follows. Tolerates arbitrary markdown formatting between the
2781
- # label and the value (backticks for inline code, double-asterisks for
2782
- # bold, colons, hyphens, whitespace). The captured value is then
2783
- # validated against `PLAN_VERIFY_GATE_VALUES` below so typo'd or unknown
2784
- # values surface as their own failure rather than silently no-matching.
2785
- _GATE_RESULT_RE = re.compile(
2786
- r"Gate result[^A-Za-z\n]+(?P<value>[a-z][a-z\-]+)",
2787
- re.IGNORECASE,
2788
- )
2789
-
2790
- # §5.5.9 Plan Body Verification section body — scopes the gate-result search
2791
- # so an earlier prose mention of "Gate result" elsewhere in the report cannot
2792
- # hijack the authoritative value. Matched from the `Plan Body Verification`
2793
- # heading to the next heading (or end-of-file).
2794
- _PLAN_BODY_VERIFICATION_BLOCK_RE = re.compile(
2795
- r"^#{2,}[ \t][^\n]*\bPlan Body Verification\b[^\n]*\n"
2796
- r"(?P<body>.*?)(?=^#{2,}[ \t]|\Z)",
2797
- re.DOTALL | re.MULTILINE,
2798
- )
2799
-
2800
- # Frontmatter approval flag — `approved: true|false` line inside the
2801
- # leading `---` YAML block. Mirrors `APPROVED_FRONTMATTER_PATTERN` in
2802
- # scripts/okstra_ctl/run.py.
2803
- _APPROVED_FRONTMATTER_RE = re.compile(
2804
- r"^approved:[ \t]+(true|false)[ \t]*$",
2805
- re.IGNORECASE | re.MULTILINE,
2806
- )
2807
2710
  # Tolerate a leading UTF-8 BOM and/or blank lines before the opening `---`
2808
2711
  # so a hand-edited or differently-rendered report does not silently bypass
2809
2712
  # the approved-frontmatter gate (the `.match` would otherwise return None).
2810
2713
  _FRONTMATTER_BLOCK_RE = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
2811
- _REQUIREMENT_COVERAGE_HEADING_RE = re.compile(
2812
- r"^###[ \t]+(?:5\.5\.8[ \t]+)?Requirement Coverage\b",
2813
- re.IGNORECASE | re.MULTILINE,
2814
- )
2815
- _NEXT_THIRD_LEVEL_HEADING_RE = re.compile(r"^###[ \t]+", re.MULTILINE)
2816
-
2817
-
2818
- def _extract_final_verdict_token(content: str) -> str | None:
2819
- """Return the `Verdict Token` cell value from the `## 7. Final Verdict`
2820
- block, or None when the row is absent. Scoped to §7 so the Verdict
2821
- Card row (which has the same shape) does not shadow the authoritative
2822
- value.
2823
- """
2824
- block = _FINAL_VERDICT_BLOCK_RE.search(content)
2825
- if block is None:
2826
- return None
2827
- match = _FINAL_VERDICT_TOKEN_RE.search(block.group("body"))
2828
- if match is None:
2829
- return None
2830
- return match.group("value")
2831
-
2832
-
2833
- # 렌더러는 ID 정의 셀(`<a id="r-001"></a>R-001`)에도 스크롤 앵커를 넣는다.
2834
- # 셀 정규화 때 그 빈 앵커를 벗겨야 ID 컬럼이 bare 토큰으로 읽힌다
2835
- # (clarification_items._CELL_ANCHOR_RE 와 동형).
2836
- _CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
2837
-
2838
-
2839
- def _split_markdown_row(line: str) -> list[str]:
2840
- return [
2841
- _CELL_ANCHOR_RE.sub("", cell).strip().strip("`").strip()
2842
- for cell in _split_pipe_row(line)
2843
- ]
2844
-
2845
-
2846
- def _append_requirement_coverage_failures(
2847
- content: str,
2848
- gate_value: str,
2849
- failures: list[str],
2850
- ) -> None:
2851
- """Validate implementation-planning §5.5.8 requirement coverage.
2852
-
2853
- The table is intentionally lightweight: it cannot prove semantic truth, but
2854
- it makes requirement-to-plan mapping explicit and blocks publishable plans
2855
- that admit uncovered requirements.
2856
- """
2857
- heading = _REQUIREMENT_COVERAGE_HEADING_RE.search(content)
2858
- if heading is None:
2859
- failures.append(
2860
- "implementation-planning report is missing `Requirement Coverage` "
2861
- "section — every task-brief requirement must map to option/stage/step."
2862
- )
2863
- return
2864
-
2865
- rest = content[heading.end():]
2866
- next_heading = _NEXT_THIRD_LEVEL_HEADING_RE.search(rest)
2867
- section = rest[: next_heading.start()] if next_heading else rest
2868
- lines = section.splitlines()
2869
-
2870
- header_idx = -1
2871
- headers: list[str] = []
2872
- for idx, line in enumerate(lines):
2873
- if not line.lstrip().startswith("|"):
2874
- continue
2875
- cells = [c.lower() for c in _split_markdown_row(line)]
2876
- if "id" in cells and "requirement" in cells and "status" in cells:
2877
- header_idx = idx
2878
- headers = cells
2879
- break
2880
- if header_idx < 0:
2881
- failures.append(
2882
- "implementation-planning Requirement Coverage section has no table "
2883
- "with `ID`, `Requirement`, and `Status` columns."
2884
- )
2885
- return
2886
-
2887
- id_col = headers.index("id")
2888
- status_col = headers.index("status")
2889
- rows: list[tuple[str, str]] = []
2890
- body_started = False
2891
- for line in lines[header_idx + 1:]:
2892
- if not line.lstrip().startswith("|"):
2893
- if body_started:
2894
- break
2895
- continue
2896
- if _is_markdown_separator(line):
2897
- body_started = True
2898
- continue
2899
- if not body_started:
2900
- continue
2901
- cells = _split_markdown_row(line)
2902
- if max(id_col, status_col) >= len(cells):
2903
- failures.append(
2904
- "implementation-planning Requirement Coverage table has a "
2905
- f"malformed row: `{line.strip()}`"
2906
- )
2907
- continue
2908
- rows.append((cells[id_col], cells[status_col].lower()))
2909
-
2910
- if not rows:
2911
- failures.append(
2912
- "implementation-planning Requirement Coverage table has no data rows."
2913
- )
2914
- return
2915
-
2916
- for row_id, status in rows:
2917
- # `status` is already lower-cased at parse time, so the blocker form
2918
- # arrives as `blocked c-001`; match lower-case here even though the
2919
- # canonical authored form (and the remedy message) is `blocked C-NNN`.
2920
- if not re.fullmatch(
2921
- r"covered|gap|blocked c-\d{3,}|"
2922
- r"documented-deviation — refs: (?:c-\d{3,}|d-\d{4,})"
2923
- r"(?:, (?:c-\d{3,}|d-\d{4,}))*; approval: "
2924
- r"(?:accepted|blocked c-\d{3,})",
2925
- status,
2926
- ):
2927
- failures.append(
2928
- "implementation-planning Requirement Coverage row "
2929
- f"`{row_id}` has invalid Status `{status}`; expected "
2930
- "`covered`, `gap`, `blocked C-NNN`, or a rendered "
2931
- "`documented-deviation` disposition."
2932
- )
2933
-
2934
- if gate_value in ("passed", "passed-with-dissent"):
2935
- uncovered = [
2936
- f"{row_id} ({status})"
2937
- for row_id, status in rows
2938
- if status != "covered"
2939
- and not status.endswith("; approval: accepted")
2940
- ]
2941
- if uncovered:
2942
- failures.append(
2943
- "implementation-planning Gate result is publishable but "
2944
- "Requirement Coverage has uncovered row(s): "
2945
- + ", ".join(uncovered)
2946
- )
2947
- def _extract_verdict_card_token(content: str) -> str | None:
2948
- """Return the `Verdict Token` cell from the Verdict Card block."""
2949
- block = _VERDICT_CARD_BLOCK_RE.search(content)
2950
- if block is None:
2951
- return None
2952
- match = _FINAL_VERDICT_TOKEN_RE.search(block.group("body"))
2953
- if match is None:
2954
- return None
2955
- return match.group("value")
2956
-
2957
-
2958
- def _validate_verdict_card_consistency(content: str, failures: list[str]) -> None:
2959
- """Verdict Card is a non-authoritative index of §7. If both blocks
2960
- carry a Verdict Token row, the values MUST byte-match (modulo case
2961
- and surrounding whitespace) — divergence is a contract violation per
2962
- `report-writer` SKILL.md "Authoring Contract".
2963
- """
2964
- card_value = _extract_verdict_card_token(content)
2965
- final_value = _extract_final_verdict_token(content)
2966
- if card_value is None or final_value is None:
2967
- # Missing-Card and missing-§7 are surfaced by other checks; this
2968
- # function only enforces the consistency contract between the two.
2969
- return
2970
- if card_value.strip().lower() != final_value.strip().lower():
2971
- failures.append(
2972
- "Verdict Card `Verdict Token` value "
2973
- f"`{card_value}` does not match `## 7. Final Verdict` value "
2974
- f"`{final_value}` — the Card is a non-authoritative index and "
2975
- "MUST byte-match §7. Either fix the Card or update §7; do not "
2976
- "ship divergent values."
2977
- )
2978
-
2979
-
2980
2714
  def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
2981
2715
  """`verdictCard.direction` must byte-match its authoritative home in §7.
2982
2716
 
@@ -3142,10 +2876,15 @@ def _validate_error_analysis_consistency(
3142
2876
  target = routing.get("nextTaskType")
3143
2877
  leading_cause_id = routing.get("leadingCauseId")
3144
2878
  candidate_id_set = set(candidate_ids)
3145
- if target == "implementation-planning":
2879
+ if isinstance(target, str) and target not in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
2880
+ failures.append(
2881
+ "final-report data.json: errorAnalysis.routing has unsupported "
2882
+ f"routing target `{target}`."
2883
+ )
2884
+ if target == "implementation-option-selection":
3146
2885
  if not candidates:
3147
2886
  failures.append(
3148
- "final-report data.json: implementation-planning routing requires "
2887
+ "final-report data.json: implementation-option-selection routing requires "
3149
2888
  "at least one cause candidate."
3150
2889
  )
3151
2890
  if (
@@ -3153,7 +2892,7 @@ def _validate_error_analysis_consistency(
3153
2892
  or leading_cause_id not in candidate_id_set
3154
2893
  ):
3155
2894
  failures.append(
3156
- "final-report data.json: implementation-planning routing "
2895
+ "final-report data.json: implementation-option-selection routing "
3157
2896
  "leadingCauseId must reference a cause candidate."
3158
2897
  )
3159
2898
  elif target == "error-analysis" and (
@@ -3165,10 +2904,7 @@ def _validate_error_analysis_consistency(
3165
2904
  "empty or reference a cause candidate."
3166
2905
  )
3167
2906
 
3168
- expected_direction = {
3169
- "implementation-planning": "begin-planning",
3170
- "error-analysis": "continue-investigation",
3171
- }.get(target)
2907
+ expected_direction = ERROR_ANALYSIS_ROUTING_DIRECTIONS.get(target)
3172
2908
  verdict_card_value = data.get("verdictCard")
3173
2909
  verdict_card = (
3174
2910
  verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
@@ -3230,7 +2966,7 @@ def _validate_error_analysis_consistency(
3230
2966
 
3231
2967
  if isinstance(target, str) and target in {
3232
2968
  "error-analysis",
3233
- "implementation-planning",
2969
+ "implementation-option-selection",
3234
2970
  }:
3235
2971
  for field_name, value in (
3236
2972
  ("verdictCard.nextStep", verdict_card.get("nextStep")),
@@ -3301,9 +3037,8 @@ def validate_final_report_data(
3301
3037
  The data.json is the source-of-truth that the renderer reads to
3302
3038
  produce the markdown. If schema validation passes here, the rendered
3303
3039
  markdown is guaranteed to contain every section / row the contract
3304
- requires (the template loops over the data). The downstream
3305
- substring checks in ``validate_phase_boundary`` are kept as a safety
3306
- net but are expected to be redundant.
3040
+ requires (the template loops over the data), so the schema is the
3041
+ only place the deliverable contract is enforced.
3307
3042
 
3308
3043
  Missing data.json is reported as a single failure rather than a
3309
3044
  cascade of substring failures — that points the writer at the right
@@ -3373,7 +3108,25 @@ def validate_final_report_data(
3373
3108
 
3374
3109
  task_type = (data.get("header") or {}).get("taskType")
3375
3110
  _validate_verifier_fail_blocks_verdict(data, failures)
3376
- if task_type == "implementation":
3111
+ if task_type == "implementation-option-selection":
3112
+ selection = data.get("implementationOptionSelection") or {}
3113
+ validation_root = project_root or report_path.parent
3114
+ original_ids = brief_end_state_id_sequence(
3115
+ _brief_path_from_manifest(manifest, validation_root)
3116
+ )
3117
+ roster = manifest.get("recommendedWorkers") or ()
3118
+ participating_analysers = tuple(
3119
+ worker for worker in roster if worker != "report-writer"
3120
+ )
3121
+ failures.extend(
3122
+ f"implementation-option-selection: {error}"
3123
+ for error in validate_implementation_option_selection(
3124
+ selection,
3125
+ original_ids,
3126
+ participating_analysers,
3127
+ )
3128
+ )
3129
+ elif task_type == "implementation":
3377
3130
  _validate_stage_carry_sidecar_exists(data, report_path, failures)
3378
3131
  if task_type == "error-analysis":
3379
3132
  _validate_error_analysis_consistency(data, failures)
@@ -3382,6 +3135,31 @@ def validate_final_report_data(
3382
3135
  _validate_verified_row_recorded(data, report_path, failures)
3383
3136
  elif task_type == "implementation-planning":
3384
3137
  active_report_contracts = report_contracts or set()
3138
+ planning = data.get("implementationPlanning") or {}
3139
+ selected_direction_contract = (
3140
+ isinstance(planning, Mapping)
3141
+ and planning.get("planningContract") == "selected-direction"
3142
+ )
3143
+ if selected_direction_contract:
3144
+ validation_root = project_root or report_path.parent
3145
+ try:
3146
+ brief_path = _brief_path_from_manifest(manifest, validation_root)
3147
+ except (OSError, ValueError) as exc:
3148
+ failures.append(
3149
+ "implementation-planning selected-direction: run manifest "
3150
+ f"taskBriefPath is malformed: {exc}"
3151
+ )
3152
+ brief_path = validation_root / "__invalid-brief__"
3153
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
3154
+ snapshot_path = task_root / "instruction-set" / "selected-direction.json"
3155
+ failures.extend(
3156
+ f"implementation-planning selected-direction: {error}"
3157
+ for error in validate_selected_direction_plan(
3158
+ data, brief_path, snapshot_path
3159
+ )
3160
+ )
3161
+ if planning.get("outcome") == "direction-invalidated":
3162
+ return data
3385
3163
  _validate_implementation_planning_cross_project(data, failures)
3386
3164
  _validate_implementation_planning_decision_drafts(data, failures)
3387
3165
  for warning in validate_plan_body_section(data, report_path, failures):
@@ -3401,8 +3179,9 @@ def validate_final_report_data(
3401
3179
  resolve_architecture(_project_root_from_report(report_path)),
3402
3180
  failures,
3403
3181
  )
3404
- _validate_requirement_deviations(data, failures)
3405
- _validate_requirement_coverage_covered_by(data, failures)
3182
+ if not selected_direction_contract:
3183
+ _validate_requirement_deviations(data, failures)
3184
+ _validate_requirement_coverage_covered_by(data, failures)
3406
3185
  warnings = _validate_design_prep_contract(
3407
3186
  data,
3408
3187
  report_path,
@@ -7314,7 +7093,7 @@ def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -
7314
7093
 
7315
7094
 
7316
7095
  # Allowed `fixability` values, mirroring the schema enum
7317
- # (schemas/final-report-v1.0.schema.json planItems[].verdicts[].fixability).
7096
+ # (schemas/final-report-v2.0.schema.json planItems[].verdicts[].fixability).
7318
7097
  _FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
7319
7098
 
7320
7099
 
@@ -7803,6 +7582,13 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
7803
7582
  )
7804
7583
 
7805
7584
 
7585
+ _FINAL_VERIFICATION_ROUTING_TOKEN_RE = re.compile(
7586
+ r"(?<![A-Za-z-])(?:release-handoff\(stage-group\)|release-handoff|done|"
7587
+ r"implementation|error-analysis|implementation-option-selection|"
7588
+ r"implementation-planning)(?![A-Za-z-])"
7589
+ )
7590
+
7591
+
7806
7592
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
7807
7593
  """Enforce verdict ↔ blocker/condition/routing consistency on the
7808
7594
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -7817,7 +7603,16 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
7817
7603
  fv = data.get("finalVerification") or {}
7818
7604
  blockers = fv.get("acceptanceBlockers") or []
7819
7605
  conditions = verdict.get("conditionalAcceptanceConditions") or []
7820
- routing = fv.get("routingRecommendation") or ""
7606
+ routing_value = fv.get("routingRecommendation")
7607
+ routing = routing_value if isinstance(routing_value, str) else ""
7608
+ routing_tokens = _FINAL_VERIFICATION_ROUTING_TOKEN_RE.findall(routing)
7609
+ routing_token = routing_tokens[0] if len(routing_tokens) == 1 else None
7610
+
7611
+ if routing_token is None:
7612
+ failures.append(
7613
+ "final-verification: routingRecommendation must contain exactly one "
7614
+ "supported routing token."
7615
+ )
7821
7616
 
7822
7617
  if token == "accepted" and blockers:
7823
7618
  failures.append(
@@ -7834,7 +7629,10 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
7834
7629
  "final-verification: verdict `conditional-accept` but "
7835
7630
  "conditionalAcceptanceConditions is empty — list every condition."
7836
7631
  )
7837
- if "release-handoff" in routing and token != "accepted":
7632
+ if (
7633
+ routing_token in {"release-handoff", "release-handoff(stage-group)"}
7634
+ and token != "accepted"
7635
+ ):
7838
7636
  failures.append(
7839
7637
  f"final-verification: routingRecommendation cites `release-handoff` "
7840
7638
  f"but verdict is `{token}` — release-handoff routing is allowed only "
@@ -7847,8 +7645,7 @@ def _validate_final_verification_consistency(data: dict, failures: list[str]) ->
7847
7645
  f"final-verification: verificationScope must be `whole-task` or "
7848
7646
  f"`single-stage`, got {scope!r}."
7849
7647
  )
7850
- if (scope == "single-stage" and "release-handoff" in routing
7851
- and "release-handoff(stage-group)" not in routing):
7648
+ if scope == "single-stage" and routing_token == "release-handoff":
7852
7649
  failures.append(
7853
7650
  "final-verification: verificationScope `single-stage` cannot recommend "
7854
7651
  "plain release-handoff routing — a single-stage accepted verdict may "
@@ -7917,14 +7714,13 @@ def _load_stage_validator():
7917
7714
  def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) -> None:
7918
7715
  """Run the stage relationship checks that schema v2 cannot express.
7919
7716
 
7920
- `_append_stage_structure_failures` scans rendered Markdown and sits after
7921
- the v2 early return in `validate_phase_boundary`, so for a v2 report the
7922
- depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and the
7923
- TDD-exemption vocabulary had nothing enforcing them. The same validator
7924
- owns both modes so the rule vocabulary stays defined once.
7717
+ The depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and
7718
+ the TDD-exemption vocabulary are relationships between stages, which a
7719
+ JSON Schema cannot state. They are enforced here, against the data.json,
7720
+ by the same validator that owns the rule vocabulary.
7925
7721
  """
7926
- if (data or {}).get("schemaVersion") != "2.0":
7927
- return # v1 reports are covered by the Markdown scan.
7722
+ if (data or {}).get("schemaVersion") != CURRENT_REPORT_SCHEMA_VERSION:
7723
+ return # Schema validation already rejected an unknown version.
7928
7724
  planning = (data or {}).get("implementationPlanning")
7929
7725
  if not isinstance(planning, Mapping):
7930
7726
  return # Schema validation already reported the missing block.
@@ -7939,157 +7735,6 @@ def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) ->
7939
7735
  )
7940
7736
 
7941
7737
 
7942
- def _append_stage_structure_failures(content: str, failures: list[str]) -> None:
7943
- """Enforce the Stage Map structural contract at the implementation-planning
7944
- boundary. Without this, a plan missing `## 5.5 Stage Map` passes the
7945
- planning gate, gets approved, and only fails later at the `implementation`
7946
- entry (validators/validate-implementation-plan-stages.py via
7947
- prepare_task_bundle). Running the same validator here moves the failure to
7948
- produce-time."""
7949
- mod = _load_stage_validator()
7950
- if mod is None: # pragma: no cover — repo/runtime always ship the file
7951
- failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
7952
- return
7953
- for e in mod.collect_validation_errors(content):
7954
- failures.append(
7955
- f"implementation-planning Stage Map structure invalid "
7956
- f"[{e.code} stage={e.stage}]: {e.message}"
7957
- )
7958
-
7959
-
7960
- def validate_phase_boundary(
7961
- task_type: str,
7962
- report_path: Path,
7963
- failures: list[str],
7964
- *,
7965
- report_data: Mapping[str, Any] | None = None,
7966
- ) -> None:
7967
- """Phase-specific contract checks.
7968
-
7969
- For `implementation-planning` runs, the final report must contain the
7970
- required deliverable sections; absence indicates a planning run that
7971
- skipped its core outputs (or an implementation run that ran under the
7972
- wrong task type).
7973
-
7974
- Additionally enforces the Plan Body Verification gate (§5.5.9):
7975
- - gate ∈ {passed, passed-with-dissent} → top-level Approval checkbox
7976
- MUST be present.
7977
- - gate ∈ {blocked-by-disagreement, aborted-non-result} → checkbox
7978
- MUST be absent (lead converted findings into Clarification rows
7979
- instead of opening the gate).
7980
- """
7981
- if (report_data or {}).get("schemaVersion") == "2.0":
7982
- return
7983
- if not report_path.exists():
7984
- return
7985
- content = report_path.read_text()
7986
-
7987
- # Verdict Card vs §7. Final Verdict Verdict Token consistency. The Card
7988
- # is a non-authoritative index; divergence is a contract violation.
7989
- _validate_verdict_card_consistency(content, failures)
7990
-
7991
- if task_type == "implementation":
7992
- for needle in IMPLEMENTATION_REQUIRED_SECTIONS:
7993
- if needle not in content:
7994
- failures.append(
7995
- "implementation report is missing required §5.7 "
7996
- f"deliverable section: `{needle}`"
7997
- )
7998
-
7999
- if task_type == "final-verification":
8000
- for needle in FINAL_VERIFICATION_REQUIRED_SECTIONS:
8001
- if needle not in content:
8002
- failures.append(
8003
- "final-verification report is missing required §5.8 "
8004
- f"deliverable section: `{needle}`"
8005
- )
8006
- token_value = _extract_final_verdict_token(content)
8007
- if token_value is None:
8008
- failures.append(
8009
- "final-verification report `## 7. Final Verdict` table is "
8010
- "missing the `Verdict Token` row — required by the release-"
8011
- "handoff entry gate."
8012
- )
8013
- elif token_value.strip().lower() not in FINAL_VERIFICATION_VERDICT_TOKENS:
8014
- failures.append(
8015
- "final-verification report `Verdict Token` value "
8016
- f"`{token_value}` is not one of "
8017
- f"{', '.join(FINAL_VERIFICATION_VERDICT_TOKENS)}."
8018
- )
8019
-
8020
- if task_type == "release-handoff":
8021
- if _MERGE_CONFLICT_PROBE_HEADING_RE.search(content) is None:
8022
- failures.append(
8023
- "release-handoff report is missing `### 5.6.6 Merge Conflict "
8024
- "Probe` sub-section — required by the release-handoff profile "
8025
- "(self-review 6, merge-conflict probe audit). When the run is "
8026
- "`local checkout` / `skip`, record the single line `- Not run "
8027
- "(user picked local checkout or skip).` under the heading."
8028
- )
8029
-
8030
- if task_type != "implementation-planning":
8031
- return
8032
- for needle in PLANNING_REQUIRED_SECTIONS:
8033
- if needle not in content:
8034
- failures.append(
8035
- "implementation-planning report is missing required section: "
8036
- f"`{needle}`"
8037
- )
8038
-
8039
- # Scope the gate-result search to the §5.5.9 Plan Body Verification block
8040
- # so an earlier prose mention of "Gate result" cannot hijack the value.
8041
- pbv_block = _PLAN_BODY_VERIFICATION_BLOCK_RE.search(content)
8042
- gate_match = (
8043
- _GATE_RESULT_RE.search(pbv_block.group("body")) if pbv_block else None
8044
- )
8045
- if gate_match is None:
8046
- # The `Plan Body Verification` heading check above already covers
8047
- # the wholly-missing case; a heading present without a `Gate result`
8048
- # line is its own contract violation.
8049
- if "Plan Body Verification" in content:
8050
- failures.append(
8051
- "implementation-planning report has `Plan Body Verification` "
8052
- "section but no `Gate result:` line — required by §5.5.9."
8053
- )
8054
- return
8055
- gate_value = gate_match.group("value").strip().lower()
8056
- if gate_value not in PLAN_VERIFY_GATE_VALUES:
8057
- failures.append(
8058
- "implementation-planning report `Gate result` value "
8059
- f"`{gate_value}` is not one of "
8060
- f"{', '.join(PLAN_VERIFY_GATE_VALUES)}."
8061
- )
8062
- return
8063
- fm_block = _FRONTMATTER_BLOCK_RE.match(content)
8064
- fm_match = (
8065
- _APPROVED_FRONTMATTER_RE.search(fm_block.group(1)) if fm_block else None
8066
- )
8067
- if gate_value in ("passed", "passed-with-dissent") and fm_match is None:
8068
- failures.append(
8069
- "implementation-planning report Gate result is "
8070
- f"`{gate_value}` but the frontmatter `approved:` field is missing — "
8071
- "render `approved: false` so the user (or `--approve`) can flip it."
8072
- )
8073
- if (
8074
- gate_value in ("blocked-by-disagreement", "aborted-non-result")
8075
- and fm_match is not None
8076
- and fm_match.group(1).lower() == "true"
8077
- ):
8078
- failures.append(
8079
- "implementation-planning report Gate result is "
8080
- f"`{gate_value}` but the frontmatter has `approved: true` — gate "
8081
- "must NOT publish a pre-approved plan when verification did not pass."
8082
- )
8083
-
8084
- _append_requirement_coverage_failures(content, gate_value, failures)
8085
-
8086
- # Only a publishable plan (gate passed) can be flipped to `approved: true`
8087
- # and reach the `implementation` entry, so the Stage Map structure is
8088
- # enforced only here — a blocked/aborted plan may legitimately be incomplete.
8089
- if gate_value in ("passed", "passed-with-dissent"):
8090
- _append_stage_structure_failures(content, failures)
8091
-
8092
-
8093
7738
  def _brief_path_from_manifest(task_manifest: dict, project_root: Path) -> Path:
8094
7739
  """This run's brief, or a non-existent path when the manifest names none.
8095
7740
 
@@ -9092,12 +8737,6 @@ def main() -> int:
9092
8737
  )
9093
8738
  validate_team_state_usage(team_state, failures)
9094
8739
 
9095
- validate_phase_boundary(
9096
- task_type,
9097
- report_path,
9098
- failures,
9099
- report_data=validation_data,
9100
- )
9101
8740
  _validate_phase_boundary_error_log(
9102
8741
  report_path.parent.parent,
9103
8742
  task_type,
@@ -9144,16 +8783,28 @@ def main() -> int:
9144
8783
  for warning in conformance_warnings:
9145
8784
  print(f"validate-run: warning: {warning}", file=sys.stderr)
9146
8785
  if task_type in _BRIEF_DERIVED_PHASES:
9147
- brief_path = _brief_path_from_manifest(task_manifest, project_root)
8786
+ planning = validation_data.get("implementationPlanning")
8787
+ selected_direction_plan = (
8788
+ task_type == "implementation-planning"
8789
+ and isinstance(planning, Mapping)
8790
+ and planning.get("planningContract") == "selected-direction"
8791
+ )
8792
+ brief_path = (
8793
+ project_root / "__selected-direction-brief-validated-from-run-manifest__"
8794
+ if selected_direction_plan
8795
+ else _brief_path_from_manifest(task_manifest, project_root)
8796
+ )
9148
8797
  if task_type in _END_STATE_PHASES:
9149
8798
  if task_type == "implementation-planning":
9150
8799
  _validate_planning_conformance_declared(report_path, failures)
9151
- _validate_end_state_coverage(validation_data, brief_path, failures)
9152
- if task_type == "implementation-planning":
8800
+ if not selected_direction_plan:
8801
+ _validate_end_state_coverage(validation_data, brief_path, failures)
8802
+ if task_type == "implementation-planning" and not selected_direction_plan:
9153
8803
  _validate_requirement_provenance(
9154
8804
  validation_data, brief_path, failures
9155
8805
  )
9156
8806
  _validate_stage_has_requirement(validation_data, failures)
8807
+ if task_type == "implementation-planning":
9157
8808
  _append_stage_data_failures(validation_data, failures)
9158
8809
  if task_type == "improvement-discovery":
9159
8810
  run_dir = report_path.parent.parent