okstra 0.179.2 → 0.180.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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +14 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/report/approval-decision.d.mts +1 -0
  9. package/dist/commands/report/approval-decision.mjs +21 -0
  10. package/dist/commands/report/approval-decision.mjs.map +1 -0
  11. package/dist/commands/report/design-snapshot.d.mts +1 -0
  12. package/dist/commands/report/design-snapshot.mjs +19 -0
  13. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  14. package/docs/architecture/storage-model.md +1 -1
  15. package/docs/architecture.md +10 -10
  16. package/docs/cli.md +11 -8
  17. package/docs/project-structure-overview.md +15 -6
  18. package/docs/task-process/implementation-planning.md +2 -2
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/agents/workers/report-writer-worker.md +15 -164
  22. package/runtime/prompts/launch.template.md +6 -5
  23. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  24. package/runtime/prompts/lead/convergence.md +2 -2
  25. package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
  26. package/runtime/prompts/lead/plan-body-verification.md +39 -18
  27. package/runtime/prompts/lead/report-writer.md +64 -423
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  30. package/runtime/prompts/profiles/_common-contract.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  32. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  33. package/runtime/prompts/profiles/error-analysis.md +1 -1
  34. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  35. package/runtime/prompts/profiles/implementation-planning.md +13 -11
  36. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  37. package/runtime/prompts/profiles/project-analysis.md +1 -1
  38. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
  40. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  41. package/runtime/python/okstra_ctl/agent_activity.py +23 -3
  42. package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
  43. package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
  44. package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
  45. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  46. package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
  47. package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
  48. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  49. package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
  50. package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
  51. package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
  52. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  53. package/runtime/python/okstra_ctl/paths.py +12 -0
  54. package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
  55. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  56. package/runtime/python/okstra_ctl/render.py +48 -1
  57. package/runtime/python/okstra_ctl/render_final_report.py +7 -6
  58. package/runtime/python/okstra_ctl/report_assembly.py +354 -0
  59. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  60. package/runtime/python/okstra_ctl/report_finalize.py +60 -22
  61. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  62. package/runtime/python/okstra_ctl/report_markdown.py +69 -8
  63. package/runtime/python/okstra_ctl/report_narrative.py +319 -0
  64. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  65. package/runtime/python/okstra_ctl/run.py +25 -9
  66. package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
  67. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
  68. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  69. package/runtime/python/okstra_ctl/stage_map.py +70 -22
  70. package/runtime/python/okstra_ctl/team.py +1 -1
  71. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  72. package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  74. package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
  75. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  76. package/runtime/templates/report-writer-prompt-preamble.md +15 -21
  77. package/runtime/templates/reports/html/macros/forms.html +6 -4
  78. package/runtime/validators/validate-run.py +258 -10
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://okstra.dev/schemas/report-narrative-v3.0.json",
4
+ "title": "OKSTRA Report Writer Narrative Input (v3.0)",
5
+ "description": "Parsed report-writer Markdown. Only report-writer-owned judgments, plans, summaries, and user explanations are accepted.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "humanSummary": {"type": "object"},
10
+ "verdictCard": {"type": "object"},
11
+ "rationale": {"type": "object"},
12
+ "summary": {"type": "array"},
13
+ "ticketCoverage": {"type": "object"},
14
+ "finalVerdict": {"type": "object"},
15
+ "requirementsDiscovery": {"type": "object"},
16
+ "improvementDiscovery": {"type": "object"},
17
+ "errorAnalysis": {"type": "object"},
18
+ "analysisCommon": {"type": "object"},
19
+ "projectAnalysis": {"type": "object"},
20
+ "featureAnalysis": {"type": "object"},
21
+ "changeImpactAnalysis": {"type": "object"},
22
+ "implementationOptionSelection": {"type": "object"},
23
+ "implementationPlanning": {"type": "object"},
24
+ "releaseHandoff": {"type": "object"},
25
+ "implementation": {"type": "object"},
26
+ "finalVerification": {"type": "object"},
27
+ "recommendedNextSteps": {"type": "array"},
28
+ "followUpTasks": {"type": "array"}
29
+ }
30
+ }
@@ -1,37 +1,31 @@
1
1
  # Report Writer Prompt Preamble (canonical)
2
2
 
3
- This file is the audience-specific contract for `report-writer`. Read it end-to-end. Before work, also read the shared file named by `**Worker Error Contract Path:**`; error rules live only there.
3
+ This file is the audience-specific contract for `report-writer`. Read it and the shared Worker Error Contract end-to-end.
4
4
 
5
- ## Required reading
6
-
7
- Read every input enumerated by the Phase 6 dispatch end-to-end: task/analysis inputs, worker results, convergence state, the instruction-set-local `final-report-template.md`, and the task-type excerpt `final-report-schema.json`. Do not pull the full repository template or schema when the scoped instruction-set copies are provided.
5
+ ## Output ownership
8
6
 
9
- Write Reading Confirmation to `**Audit sidecar path:**`, not the rendered final report. Resolve `.okstra/**` paths against `**Project Root:**`.
7
+ Write only the report narrative Markdown at `**Result Path:**`, the pointer record at `**Worker Result Path:**`, and the reading audit at `**Audit sidecar path:**`.
10
8
 
11
- Write `- PROGRESS: <stage> <ISO-8601-UTC>` there before reading and at least every five minutes while pending. The only valid report-writer stages are `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`.
9
+ The report narrative Markdown contains the task judgment, plan, summaries, and user explanation. It must not write or patch `final-report-*.data.json`, the approval decision ledger, activity ledger, team state, convergence state, or design-preparation input.
12
10
 
13
- ## Report authoring handoff
11
+ The following fields belong to other owners and must not appear in the narrative: `designPreparation`, `designSurfaceCoverage`, `executionStatus`, `executionRoles`, `tokenUsage`, `crossVerification`, `approvalContext`, `clarificationItems`, `agentActivity`, and `planBodyVerification`.
14
12
 
15
- - Author the data.json at `**Result Path:**` and the audit file at `**Audit sidecar path:**`.
16
- - Follow the task-type schema excerpt and Phase 6 report-writer contract. Do not perform independent analysis, edit source code, or load implementation coding-preflight resources.
17
- - Do not invoke `okstra render-final-report`. The full reading copy is rendered on demand.
18
- - Preserve source item IDs, convergence classifications, round history, and unresolved dissent; do not recompute them from intuition.
19
- - Every `clarificationItems[]` row you leave `status: open` with `blocks: approval` carries `origin` and `userConfirmation`. `origin` is `worker-finding` only when an analyser or verifier reached it on its own evidence — when the lead's dispatch prompt told you to raise it, it is `lead-directed`, whatever the workers then agreed. `userConfirmation` is what the lead reports having done about it (`asked-and-answered` / `asked-awaiting` / `deferred-no-interactive-session`); if the dispatch does not say, write `asked-awaiting` and name the gap in your return message rather than guessing. An open approval blocker stops the whole task, so a row that misreports where it came from costs a re-run. **Enforced:** `validators/validate-run.py` `_validate_open_approval_blocker_provenance`.
13
+ Do not pre-fill a future round, future gate, usage value, activity identifier, or resolution. Report assembly derives those values after their owner input exists.
20
14
 
21
- ## Anchor headers
15
+ ## Required reading
22
16
 
23
- The generated prompt selects this file through `**Worker Preamble Path:**` and includes `**Worker Error Contract Path:**`, `**Audit sidecar path:**`, error paths, and read scope. It never includes `**Coding preflight pack:**`.
17
+ Read every path listed under `## Inputs` end-to-end. Full context is available for synthesis, but read access does not transfer write ownership. Preserve supplied technical meaning and do not invent missing evidence.
24
18
 
25
- The adjacent v2 prompt metadata supplies `participantRef`, `roleExecutionRef`, `executionLabel`, `invocationRef`, and `attempt`. Copy those values into report and audit identity fields without deriving them from the provider or model. Legacy v1 `workerId` is a read-only compatibility projection and is not written into v2 artifacts.
19
+ Write the audit sidecar before synthesis with one `- PROGRESS: <stage> <ISO-8601-UTC>` line and the required reading confirmation. Valid stages are `started`, `required-reading-complete`, `synthesis-start`, `narrative-write-start`, and `write-result-start`.
26
20
 
27
- ## Return message to the lead
21
+ ## Narrative format
28
22
 
29
- Begin the inline return with the exact `**Model:** Report writer worker, <modelExecutionValue>` line from the prompt, followed by the artifact status. Never invent or abbreviate the model.
23
+ Start with `# OKSTRA Report Narrative`. Encode fields as nested Markdown list entries using `- **Humanised Field Name**`; use `- Item N` for array entries and `> value` for scalar or multiline values. Follow the task-specific schema order and write the prose in English. JSON, YAML, JSON Pointer, and fenced JSON are not narrative syntax.
30
24
 
31
- ## Writing style
25
+ ## Pointer record
32
26
 
33
- Use concise reader-facing prose. Prefer tables when several items share a shape; reserve bullets for short standalone statements.
27
+ The pointer record names the project-relative narrative path and audit sidecar path. It does not contain the narrative, worker result corpus, or any machine-owned ledger.
34
28
 
35
- **Author the data.json in English, whatever `**Report Language:**` says.** That header is not an instruction to write in that language — it names the language the *human HTML* renders in, and you copy its value verbatim into `data.json.meta.reportLanguage`. The data.json is the English SSOT every later phase, validator and agent reads. When the value is not `en`, Phase 7 dispatches a separate translator worker that writes a sidecar the HTML renderer overlays; you never author that sidecar and never write a second language into the data.json.
29
+ ## Failure handling
36
30
 
37
- Authoring the data.json in the reader's language is rejected before anything derives from it: `okstra report-translate check-source` fails the run when Korean exceeds 20% of its prose, and the same gate runs again inside `validate-run`. The cost of getting this wrong is a full rewrite, so decide it once, up front.
31
+ If a required narrative fact is missing or contradictory, record the report-writer error and stop. If report assembly later reports another owner, do not edit that owner's input; return the failure to the named owner.
@@ -63,6 +63,7 @@
63
63
  {% for row in items %}
64
64
  {% set is_closed = row.status in ['resolved', 'obsolete'] %}
65
65
  {% set approval_context = row.approvalContext | default(None) %}
66
+ {% set resolution = row.resolution | default(None) %}
66
67
  {% set options = row.options | default([]) %}
67
68
  <article class="clarification-item" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}" data-status="{{ row.status }}">
68
69
  <p class="eyebrow">{{ row.id }} · {{ row.kind }}</p>
@@ -72,9 +73,10 @@
72
73
  <div class="approval-context" data-approval-classification="{{ approval_context.classification }}">
73
74
  <p class="eyebrow">{{ approval_context.classification }}</p>
74
75
  <p><strong>{{ t('tasks.implementation-planning.unblock-condition') }}</strong> {{ approval_context.unblockCondition | inline_code }}</p>
75
- <p><strong>{{ t('tasks.implementation-planning.agent-evidence') }}</strong>
76
- {% for activity_id in approval_context.activityIds %}<a href="#id-{{ activity_id }}">{{ activity_id }}</a>{% if not loop.last %}, {% endif %}{% endfor %}
77
- </p>
76
+ {% set check_refs = resolution.checkRefs if resolution else approval_context.activityIds | default([]) %}
77
+ {% if check_refs %}<p><strong>{{ t('tasks.implementation-planning.agent-evidence') }}</strong>
78
+ {% for activity_id in check_refs %}<a href="#id-{{ activity_id }}">{{ activity_id }}</a>{% if not loop.last %}, {% endif %}{% endfor %}
79
+ </p>{% endif %}
78
80
  </div>
79
81
  {% endif %}
80
82
  {% if options %}
@@ -85,7 +87,7 @@
85
87
  {% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
86
88
  <p class="clarification-option-rationale">{{ option.rationale }}</p>
87
89
  <dl class="clarification-option-impact">
88
- <dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{{ option.scopeImpact | join(', ') }}</dd>
90
+ <dt>{{ t('macros.forms.scope-impact') }}</dt><dd>{% if option.reach | default(None) %}{{ option.reach }}{% if option.scopeEffects | default([]) %}, {{ option.scopeEffects | join(', ') }}{% endif %}{% else %}{{ option.scopeImpact | join(', ') }}{% endif %}</dd>
89
91
  <dt>{{ t('macros.forms.added-work') }}</dt><dd>{{ option.addedWork }}</dd>
90
92
  <dt>{{ t('macros.forms.direction-change') }}</dt><dd>{{ option.directionChange }}</dd>
91
93
  </dl>
@@ -2890,7 +2890,7 @@ def validate_report(
2890
2890
  report_data: Mapping[str, Any] | None = None,
2891
2891
  team_state: Mapping[str, Any] | None = None,
2892
2892
  ) -> None:
2893
- if (report_data or {}).get("schemaVersion") == "2.0":
2893
+ if (report_data or {}).get("schemaVersion") in {"2.0", "3.0"}:
2894
2894
  _validate_v2_report(report_data or {}, failures, team_state=team_state)
2895
2895
  return
2896
2896
 
@@ -3848,9 +3848,58 @@ def _classify_plan_item_gate(item: dict) -> str:
3848
3848
  # made the gate stricter than a healthy roster would.
3849
3849
  if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3850
3850
  return "majority-disagree"
3851
+ # A tie is not consensus, and until now it read as one. The majority test is
3852
+ # strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
3853
+ # fell through to `has-dissent` and the gate passed — the dissent recorded
3854
+ # and never acted on. An even panel is not only the two-analyser roster: one
3855
+ # UNVERIFIABLE or one lost dispatch turns any roster even for that item.
3856
+ # Send the split back for a round; if it survives a round that judged the
3857
+ # rewritten text, nothing further is going to settle it and the user decides.
3858
+ # `_validate_unresolved_tie_was_reverified` is what makes the first branch
3859
+ # more than a label — `needs-reverify` folds into `passed-with-dissent`.
3860
+ if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
3861
+ if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
3862
+ return "majority-disagree"
3863
+ return "needs-reverify"
3851
3864
  return "has-dissent"
3852
3865
 
3853
3866
 
3867
+ # 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
3868
+ # 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
3869
+ # 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
3870
+ _TIE_SETTLED_ROUND = 2
3871
+
3872
+
3873
+ def _max_verdict_round(item: dict) -> int:
3874
+ """이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
3875
+
3876
+ `apply-verdicts --round <N>` 이 각 행을 찍는다. 스탬프가 없는 행은 자가수정이
3877
+ 한 번도 없었던 run 에서만 나오고, 그때는 라운드가 하나뿐이다.
3878
+ """
3879
+ rounds = [
3880
+ verdict["round"]
3881
+ for verdict in (item.get("verdicts") or [])
3882
+ if isinstance(verdict, dict)
3883
+ and isinstance(verdict.get("round"), int)
3884
+ and not isinstance(verdict.get("round"), bool)
3885
+ ]
3886
+ return max(rounds, default=1)
3887
+
3888
+
3889
+ def _is_unsettled_tie(item: dict) -> bool:
3890
+ """아직 재검증되지 않은 동수 항목."""
3891
+ return (
3892
+ _classify_plan_item_gate(item) == "needs-reverify"
3893
+ and _max_verdict_round(item) < _TIE_SETTLED_ROUND
3894
+ and len([
3895
+ verdict for verdict in (item.get("verdicts") or [])
3896
+ if isinstance(verdict, dict)
3897
+ and str(verdict.get("verdict") or "").strip().upper()
3898
+ not in ("", "VERIFICATION-ERROR")
3899
+ ]) >= 2
3900
+ )
3901
+
3902
+
3854
3903
  def _disagree_breakage_kinds(item: dict) -> set[str]:
3855
3904
  return {
3856
3905
  str(v.get("breakageKind") or "").strip().lower()
@@ -4536,6 +4585,8 @@ def _validate_approval_dispositions(
4536
4585
  row: dict,
4537
4586
  context: dict,
4538
4587
  failures: list[str],
4588
+ *,
4589
+ schema_version: str = "2.0",
4539
4590
  ) -> None:
4540
4591
  row_id = str(row.get("id") or "<unknown>")
4541
4592
  classification = str(context.get("classification") or "")
@@ -4546,7 +4597,11 @@ def _validate_approval_dispositions(
4546
4597
  for index, option in enumerate(row.get("options") or [])
4547
4598
  if isinstance(option, dict)
4548
4599
  )
4549
- resolution = context.get("resolution")
4600
+ resolution = (
4601
+ row.get("resolution")
4602
+ if schema_version == "3.0"
4603
+ else context.get("resolution")
4604
+ )
4550
4605
  if isinstance(resolution, dict):
4551
4606
  candidates.append(("resolution.disposition", resolution.get("disposition")))
4552
4607
  for field, disposition in candidates:
@@ -4562,11 +4617,17 @@ def _validate_resolved_approval(
4562
4617
  row: dict,
4563
4618
  context: dict,
4564
4619
  failures: list[str],
4620
+ *,
4621
+ schema_version: str = "2.0",
4565
4622
  ) -> None:
4566
4623
  if row.get("status") != "resolved":
4567
4624
  return
4568
4625
  row_id = str(row.get("id") or "<unknown>")
4569
- resolution = context.get("resolution")
4626
+ resolution = (
4627
+ row.get("resolution")
4628
+ if schema_version == "3.0"
4629
+ else context.get("resolution")
4630
+ )
4570
4631
  if not isinstance(resolution, dict):
4571
4632
  failures.append(
4572
4633
  f"final-report data.json: resolved approval clarification `{row_id}` "
@@ -4882,6 +4943,9 @@ def _validate_approval_context(
4882
4943
  ) -> None:
4883
4944
  if not _is_activity_contract_v1_planning(run_manifest):
4884
4945
  return
4946
+ if data.get("schemaVersion") == "3.0":
4947
+ _validate_v3_approval_context(data, failures)
4948
+ return
4885
4949
  ip = data.get("implementationPlanning") or {}
4886
4950
  pbv = ip.get("planBodyVerification") or {}
4887
4951
  plan_items_by_id = {
@@ -4991,6 +5055,81 @@ def _validate_approval_context(
4991
5055
  )
4992
5056
 
4993
5057
 
5058
+ def _v3_expected_plan_backlinks(
5059
+ activities: Mapping[str, dict],
5060
+ ) -> dict[str, set[str]]:
5061
+ expected: dict[str, set[str]] = {}
5062
+ for activity in activities.values():
5063
+ refs = {str(value) for value in activity.get("clarificationRefs") or []}
5064
+ for item_id in activity.get("planItemIds") or []:
5065
+ expected.setdefault(str(item_id), set()).update(refs)
5066
+ return expected
5067
+
5068
+
5069
+ def _validate_v3_plan_backlinks(
5070
+ data: dict, activities: Mapping[str, dict], failures: list[str],
5071
+ ) -> None:
5072
+ expected = _v3_expected_plan_backlinks(activities)
5073
+ planning = data.get("implementationPlanning") or {}
5074
+ verification = planning.get("planBodyVerification") or {}
5075
+ for item in verification.get("planItems") or []:
5076
+ if not isinstance(item, dict):
5077
+ continue
5078
+ item_id = str(item.get("id") or "")
5079
+ actual = {str(value) for value in item.get("clarificationRefs") or []}
5080
+ if actual != expected.get(item_id, set()):
5081
+ failures.append(
5082
+ f"final-report data.json: plan item `{item_id}` clarificationRefs "
5083
+ "do not match activity-ledger backlinks."
5084
+ )
5085
+
5086
+
5087
+ def _validate_v3_resolution_links(
5088
+ row: dict, activities: Mapping[str, dict], failures: list[str],
5089
+ ) -> None:
5090
+ resolution = row.get("resolution")
5091
+ if not isinstance(resolution, dict):
5092
+ return
5093
+ row_id = str(row.get("id") or "<unknown>")
5094
+ for activity_id in resolution.get("checkRefs") or []:
5095
+ activity = activities.get(activity_id)
5096
+ if activity is None:
5097
+ failures.append(
5098
+ f"final-report data.json: clarification `{row_id}` references "
5099
+ f"unknown activity `{activity_id}`."
5100
+ )
5101
+ elif row_id not in (activity.get("clarificationRefs") or []):
5102
+ failures.append(
5103
+ f"final-report data.json: activity `{activity_id}` does not "
5104
+ f"link back to clarification `{row_id}`."
5105
+ )
5106
+
5107
+
5108
+ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5109
+ """검증 가능한 원장 참조만으로 v3 승인 역추적을 다시 계산한다."""
5110
+ activities = _approval_activities_by_id(data)
5111
+ _validate_v3_plan_backlinks(data, activities, failures)
5112
+ approved = (data.get("frontmatter") or {}).get("approved") is True
5113
+ for row in data.get("clarificationItems") or []:
5114
+ if not isinstance(row, dict) or row.get("blocks") != "approval":
5115
+ continue
5116
+ context = row.get("approvalContext")
5117
+ if not isinstance(context, dict):
5118
+ continue
5119
+ _validate_approval_dispositions(
5120
+ row, context, failures, schema_version="3.0"
5121
+ )
5122
+ _validate_resolved_approval(
5123
+ row, context, failures, schema_version="3.0"
5124
+ )
5125
+ _validate_v3_resolution_links(row, activities, failures)
5126
+ if approved and row.get("status") in {"open", "answered"}:
5127
+ failures.append(
5128
+ f"final-report data.json: approval is true while clarification "
5129
+ f"`{row.get('id')}` remains `{row.get('status')}`."
5130
+ )
5131
+
5132
+
4994
5133
  def _validate_activity_contract_plan_limits(
4995
5134
  data: dict,
4996
5135
  run_manifest: dict,
@@ -5435,7 +5574,7 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
5435
5574
  )
5436
5575
 
5437
5576
 
5438
- _CLARIFICATION_OPTION_SCHEMA_VERSION = "2.0"
5577
+ _CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
5439
5578
  # The four profiles that read `_clarification-recommendation.md`. Unlike the
5440
5579
  # evidence-note gate above — which is called from inside the
5441
5580
  # `implementation-planning` branch — this one is called phase-agnostically, so
@@ -5508,7 +5647,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5508
5647
  demanding one would fail every v1 `decision` row for a structure the format
5509
5648
  has no place to hold.
5510
5649
  """
5511
- if data.get("schemaVersion") != _CLARIFICATION_OPTION_SCHEMA_VERSION:
5650
+ if data.get("schemaVersion") not in _CLARIFICATION_OPTION_SCHEMA_VERSIONS:
5512
5651
  return
5513
5652
  task_type = (data.get("header") or {}).get("taskType")
5514
5653
  if task_type not in _CLARIFICATION_OPTION_TASK_TYPES:
@@ -5517,7 +5656,10 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5517
5656
  if not isinstance(row, dict) or row.get("kind") != "decision":
5518
5657
  continue
5519
5658
  row_id = str(row.get("id") or "<unknown>")
5520
- _validate_option_set(row.get("options"), row_id, failures)
5659
+ _validate_option_set(
5660
+ row.get("options"), row_id, failures,
5661
+ schema_version=str(data.get("schemaVersion") or ""),
5662
+ )
5521
5663
  if _LEGACY_EXPECTED_FORM_RE.search(str(row.get("expectedForm") or "")):
5522
5664
  failures.append(
5523
5665
  f"final-report data.json: clarification `{row_id}` still encodes "
@@ -5529,7 +5671,7 @@ def _validate_clarification_options(data: dict, failures: list[str]) -> None:
5529
5671
 
5530
5672
 
5531
5673
  def _validate_option_set(
5532
- options: object, row_id: str, failures: list[str]
5674
+ options: object, row_id: str, failures: list[str], *, schema_version: str = "2.0"
5533
5675
  ) -> None:
5534
5676
  """Check one `decision` row's `options[]` for pickability and reach."""
5535
5677
  if not isinstance(options, list) or len(options) < 2:
@@ -5550,7 +5692,9 @@ def _validate_option_set(
5550
5692
  for index, option in enumerate(entries):
5551
5693
  tokens = option.get("scopeImpact")
5552
5694
  reach = (
5553
- [token for token in tokens if token in _REACH_TOKENS]
5695
+ [str(option.get("reach"))]
5696
+ if schema_version == "3.0" and option.get("reach") in _REACH_TOKENS
5697
+ else [token for token in tokens if token in _REACH_TOKENS]
5554
5698
  if isinstance(tokens, list)
5555
5699
  else []
5556
5700
  )
@@ -6697,6 +6841,53 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
6697
6841
  )
6698
6842
 
6699
6843
 
6844
+ def _validate_unresolved_tie_was_reverified(
6845
+ data: dict,
6846
+ failures: list[str],
6847
+ ) -> None:
6848
+ """A split panel is sent back once before the gate is declared.
6849
+
6850
+ The gate needs a strict majority to block, so a panel splitting evenly on a
6851
+ blocking kind reaches neither consensus nor `majority-disagree`. That state
6852
+ is classified `needs-reverify`, and `needs-reverify` folds into
6853
+ `passed-with-dissent` — which is correct for the shape it was built for (a
6854
+ peer that returned nothing) and wrong for this one: nothing failed here, two
6855
+ verifiers read the same plan and disagreed, and passing on that records a
6856
+ dissent nobody acted on.
6857
+
6858
+ So the round is not optional. Re-dispatch those items and record the votes
6859
+ with `--round 2`; a split that survives becomes `majority-disagree` and the
6860
+ user decides. This is satisfiable with the machinery the contract already
6861
+ defines — it is the same targeted re-verification step 7 runs after a
6862
+ self-fix, with the tied items added to that queue.
6863
+ """
6864
+ ip = data.get("implementationPlanning")
6865
+ if not isinstance(ip, dict):
6866
+ return
6867
+ pbv = ip.get("planBodyVerification")
6868
+ if not isinstance(pbv, dict):
6869
+ return
6870
+ unsettled = sorted({
6871
+ str(item.get("id") or "").strip()
6872
+ for item in pbv.get("planItems") or []
6873
+ if isinstance(item, dict)
6874
+ and not item.get("carriedForwardFromSeq")
6875
+ and _is_unsettled_tie(item)
6876
+ })
6877
+ if not unsettled:
6878
+ return
6879
+ failures.append(
6880
+ f"final-report data.json: plan item(s) {unsettled} carry an even split "
6881
+ "on a blocking breakage kind and were never re-verified. A tie is not "
6882
+ "consensus: the gate's majority test is strict, so this split neither "
6883
+ "blocks nor resolves, and declaring the gate on it passes a dissent "
6884
+ "nobody settled. Re-dispatch those items in a plan-body round and "
6885
+ "record the votes with `okstra plan-items apply-verdicts --data "
6886
+ "<data.json> --verdicts <verdicts.json> --round 2`. A split that "
6887
+ "survives that round becomes `majority-disagree` and goes to the user."
6888
+ )
6889
+
6890
+
6700
6891
  def _validate_verdict_rounds_outlive_self_fix(
6701
6892
  data: dict,
6702
6893
  failures: list[str],
@@ -7794,6 +7985,7 @@ def validate_plan_body_section(
7794
7985
  _validate_round_recorded_verdicts(data, failures)
7795
7986
  _validate_verdicts_match_current_subjects(data, failures)
7796
7987
  _validate_verdict_rounds_outlive_self_fix(data, failures)
7988
+ _validate_unresolved_tie_was_reverified(data, failures)
7797
7989
  _validate_plan_item_extraction_completeness(data, failures)
7798
7990
  _validate_plan_item_subject_substance(data, failures)
7799
7991
  _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
@@ -9272,6 +9464,52 @@ def run_plan_body_section(report_path: Path) -> int:
9272
9464
  return 0 if not failures else 2
9273
9465
 
9274
9466
 
9467
+ def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
9468
+ """게시 전 서사와 수렴 소유 상태에서 같은 계획 게이트를 채점한다."""
9469
+ from okstra_ctl.report_narrative import parse_narrative
9470
+ from okstra_ctl.final_report_schema import load_schema_version
9471
+
9472
+ try:
9473
+ narrative = parse_narrative(
9474
+ narrative_path.read_text(encoding="utf-8"),
9475
+ load_schema_version("3.0"),
9476
+ )
9477
+ state = json.loads(state_path.read_text(encoding="utf-8"))
9478
+ except (OSError, UnicodeError, ValueError) as exc:
9479
+ print(f"validate-run: plan-body inputs are invalid ({exc})", file=sys.stderr)
9480
+ return 2
9481
+ state_owner = state.get("owner") if isinstance(state, dict) else None
9482
+ verification = state.get("planBodyVerification") if isinstance(state, dict) else None
9483
+ planning = narrative.get("implementationPlanning")
9484
+ if state_owner != "convergence" or not isinstance(verification, dict):
9485
+ print("validate-run: plan-body state must be convergence-owned", file=sys.stderr)
9486
+ return 2
9487
+ if not isinstance(planning, dict):
9488
+ print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
9489
+ return 2
9490
+ data = {**narrative, "schemaVersion": "3.0"}
9491
+ data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
9492
+ failures: list[str] = []
9493
+ warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
9494
+ payload = {
9495
+ "ok": not failures,
9496
+ "section": SECTION_PLAN_BODY,
9497
+ "gate": plan_body_gate_summary(data),
9498
+ "failures": failures,
9499
+ "warnings": warnings,
9500
+ }
9501
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
9502
+ return 0 if not failures else 2
9503
+
9504
+
9505
+ def _report_path_for_state(state_path: Path) -> Path:
9506
+ match = re.search(r"-(\d{3})\.json$", state_path.name)
9507
+ seq = match.group(1) if match else "001"
9508
+ return state_path.parent.parent / "reports" / (
9509
+ f"final-report-implementation-planning-{seq}.data.json"
9510
+ )
9511
+
9512
+
9275
9513
  def _data_schema_failures(data: dict) -> list[str]:
9276
9514
  """Schema errors in the round's data.json, as failures.
9277
9515
 
@@ -9315,9 +9553,11 @@ def main() -> int:
9315
9553
  )
9316
9554
  parser.add_argument(
9317
9555
  "--report",
9318
- required=True,
9556
+ required=False,
9319
9557
  help="Project-relative or absolute path to the report record (.data.json). Schema-v1 reports still use the Markdown file.",
9320
9558
  )
9559
+ parser.add_argument("--narrative", required=False)
9560
+ parser.add_argument("--state", required=False)
9321
9561
  parser.add_argument(
9322
9562
  "--run-manifest",
9323
9563
  required=False,
@@ -9343,7 +9583,15 @@ def main() -> int:
9343
9583
  args = parser.parse_args()
9344
9584
 
9345
9585
  if args.section == SECTION_PLAN_BODY:
9346
- return run_plan_body_section(Path(args.report).resolve())
9586
+ if args.narrative and args.state and not args.report:
9587
+ return run_plan_body_inputs(
9588
+ Path(args.narrative).resolve(), Path(args.state).resolve()
9589
+ )
9590
+ if args.report and not args.narrative and not args.state:
9591
+ return run_plan_body_section(Path(args.report).resolve())
9592
+ parser.error(
9593
+ "--section plan-body requires either --report or both --narrative and --state"
9594
+ )
9347
9595
 
9348
9596
  missing = [
9349
9597
  f"--{flag.replace('_', '-')}"