okstra 0.186.5 → 0.186.7

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 (54) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +3 -3
  3. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/okstra-render-report-views.py +6 -5
  7. package/runtime/prompts/launch.template.md +14 -0
  8. package/runtime/prompts/lead/convergence.md +2 -2
  9. package/runtime/prompts/lead/okstra-lead-contract.md +4 -14
  10. package/runtime/prompts/lead/plan-body-verification.md +4 -3
  11. package/runtime/prompts/lead/report-writer.md +3 -1
  12. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  13. package/runtime/prompts/profiles/error-analysis.md +2 -2
  14. package/runtime/prompts/profiles/final-verification.md +2 -2
  15. package/runtime/prompts/profiles/implementation-planning.md +3 -3
  16. package/runtime/prompts/profiles/requirements-discovery.md +2 -2
  17. package/runtime/prompts/wizard/prompts.ko.json +2 -4
  18. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -5
  19. package/runtime/python/okstra_ctl/agent_activity.py +6 -0
  20. package/runtime/python/okstra_ctl/clarification_items.py +67 -11
  21. package/runtime/python/okstra_ctl/next_phase.py +6 -3
  22. package/runtime/python/okstra_ctl/plan_items.py +32 -6
  23. package/runtime/python/okstra_ctl/plan_items_cli.py +58 -3
  24. package/runtime/python/okstra_ctl/render_final_report.py +3 -1
  25. package/runtime/python/okstra_ctl/report_assembly.py +9 -2
  26. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  27. package/runtime/python/okstra_ctl/report_html/common.py +2 -7
  28. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  29. package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
  30. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  31. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  32. package/runtime/python/okstra_ctl/run.py +21 -6
  33. package/runtime/python/okstra_ctl/usage_cells.py +15 -0
  34. package/runtime/python/okstra_ctl/user_response.py +72 -6
  35. package/runtime/python/okstra_ctl/wizard.py +1 -5
  36. package/runtime/python/okstra_token_usage/codex.py +32 -3
  37. package/runtime/python/okstra_token_usage/collect.py +148 -15
  38. package/runtime/python/okstra_token_usage/grok.py +24 -5
  39. package/runtime/python/okstra_token_usage/report.py +12 -2
  40. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  41. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  42. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  43. package/runtime/templates/reports/html/assets/base.css +3 -9
  44. package/runtime/templates/reports/html/assets/base.js +0 -21
  45. package/runtime/templates/reports/html/base.template.html +14 -4
  46. package/runtime/templates/reports/html/i18n/en.json +19 -0
  47. package/runtime/templates/reports/html/i18n/ko.json +19 -0
  48. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  49. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +20 -29
  50. package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
  51. package/runtime/validators/lib/runners.sh +5 -1
  52. package/runtime/validators/validate-report-views.py +2 -1
  53. package/runtime/validators/validate-run.py +97 -82
  54. package/runtime/validators/validate_session_conformance.py +71 -18
@@ -84,13 +84,9 @@
84
84
  </section>
85
85
  {% endif %}
86
86
 
87
- {# The verification checklist, cross-project dependencies, migration risk,
88
- rollback strategy and plan-body verification rounds are the implementer's
89
- and the auditor's working material, not the approver's: this reader is
90
- deciding whether to greenlight the plan, and none of those tables move that
91
- decision. They stay in the markdown report, which is what the next phase and
92
- the audit trail read. Requirement coverage is the exception — "does this plan
93
- actually do what the brief asked" is the approval question itself. #}
87
+ {# 검증 체크리스트·프로젝트 간 의존·마이그레이션 위험·롤백·활동 대장은
88
+ 구현자와 감사 추적용이다. 계획 본문 검증의 판정과 근거는 승인 판단이라
89
+ 이 화면에 남긴다. A-NNN 인용은 이 문서에 id 가 있어야 checkRef 가 산다. #}
94
90
  <section data-report-section="requirement-coverage" data-report-field="implementationPlanning.requirementCoverage">
95
91
  <h2>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h2>
96
92
  {% if planning.get("planningContract") == "selected-direction" %}
@@ -100,6 +96,22 @@
100
96
  {% endif %}
101
97
  </section>
102
98
 
99
+ {% if planning.get("planBodyVerification") %}
100
+ <section data-report-section="plan-body-verification" data-report-field="implementationPlanning.planBodyVerification">
101
+ <h2>{{ t('tasks.implementation-planning.plan-body-verification') }}</h2>
102
+ <p><span class="status status-{{ planning.planBodyVerification.gateResult }}">{{ planning.planBodyVerification.gateResult | enum_label('planGate') }}</span></p>
103
+ {% if planning.planBodyVerification.get("uniformVerifiers") %}
104
+ <p>{{ t('tasks.implementation-planning.uniform-verifier-label') }} — {% for row in planning.planBodyVerification.uniformVerifiers %}{{ row.worker | inline_code }} · {{ row.verdict | inline_code }} ({{ row.itemCount }}){% if not loop.last %}, {% endif %}{% endfor %}</p>
105
+ <p class="evidence-refs">{{ t('tasks.implementation-planning.uniform-verifier-legend') }}</p>
106
+ {% endif %}
107
+ <div class="summary-grid">{% for item in planning.planBodyVerification.planItems %}<article class="summary-card" id="id-{{ item.id }}"><p class="eyebrow">{{ item.id | inline_code }}{% if item.get("sourceSection") %} · {{ t('tasks.implementation-planning.source-section') }} {{ item.sourceSection | inline_code }}{% endif %}</p><h3>{{ item.subject | inline_code }}</h3>{% for vote in item.verdicts %}<p><strong>{{ vote.worker | inline_code }}</strong> <span class="status status-{{ vote.verdict | lower }}">{{ vote.verdict }}</span>{% if vote.get("breakageKind") %} · {{ t('tasks.implementation-planning.breakage') }} {{ vote.breakageKind | inline_code }}{% endif %}{% if vote.get("fixability") %} · {{ t('tasks.implementation-planning.fixability') }} {{ vote.fixability | inline_code }}{% endif %}{% if vote.get("claimKind") %} · {{ t('tasks.implementation-planning.claim') }} {{ vote.claimKind | inline_code }}{% endif %}{% if vote.get("reproductionResult") %} · {{ t('tasks.implementation-planning.reproduction') }} {{ vote.reproductionResult | inline_code }}{% endif %}</p>{% if vote.get("note") %}<p class="evidence-refs">{{ t('tasks.implementation-planning.note') }} {{ vote.note | inline_code }}</p>{% endif %}{% if vote.get("explanation") %}<p>{{ vote.explanation | inline_code }}</p>{% endif %}{% endfor %}</article>{% endfor %}</div>
108
+ {% if planning.planBodyVerification.get("dissentLog") %}
109
+ <h3>{{ t('tasks.implementation-planning.dissent-left-standing') }}</h3>
110
+ <ul>{% for row in planning.planBodyVerification.dissentLog %}<li>{{ row.planItem | inline_code }} · {{ row.workerRole | inline_code }} — {{ row.body | inline_code }}</li>{% endfor %}</ul>
111
+ {% endif %}
112
+ </section>
113
+ {% endif %}
114
+
103
115
  {% if planning.get("supersessionLedger") is not none %}
104
116
  <section data-report-section="superseded" data-report-field="implementationPlanning.supersessionLedger">
105
117
  <h2>{{ t('tasks.implementation-planning.what-your-answers-overturned') }}</h2>
@@ -134,28 +146,7 @@
134
146
  {% endif %}
135
147
 
136
148
  {% if agentActivities %}
137
- <section data-report-section="agent-activity">
138
- <h2>{{ t('tasks.implementation-planning.what-each-agent-did') }}</h2>
139
- <div class="activity-list">
140
- {% for row in agentActivities %}
141
- <article class="activity-card" id="id-{{ row.activityId }}">
142
- <p class="eyebrow">{{ row.activityId | inline_code }} · {{ row.agent }}</p>
143
- <h3>{{ row.summary | inline_code }}</h3>
144
- <p>{{ t('tasks.implementation-planning.result') }}: {{ row.outcome | inline_code }}</p>
145
- <details>
146
- <summary>{{ t('tasks.implementation-planning.commands-and-evidence') }}</summary>
147
- <p><code>{{ row.kind }}</code></p>
148
- {% if row.planItemIds %}<p>{{ row.planItemIds | join(', ') | inline_code }}</p>{% endif %}
149
- {% for command in row.commands %}
150
- <p><code>{{ command.command }}</code> · exit {{ command.exitCode }} · {{ command.outputSummary | inline_code }}</p>
151
- {% endfor %}
152
- {% if row.evidenceRefs %}<p>{{ row.evidenceRefs | join(', ') | inline_code }}</p>{% endif %}
153
- {% if row.resultPath %}<p><code>{{ row.resultPath }}</code></p>{% endif %}
154
- </details>
155
- </article>
156
- {% endfor %}
157
- </div>
158
- </section>
149
+ <div hidden>{% for row in agentActivities %}<span id="id-{{ row.activityId }}"></span>{% endfor %}</div>
159
150
  {% endif %}
160
151
 
161
152
  <section data-report-section="open-decisions">
@@ -24,7 +24,7 @@
24
24
  <h2>{{ t('tasks.implementation.verification-result') }}</h2>
25
25
  {{ render_narrative(narrative.validationExplanation, "implementation.userNarrative.validationExplanation") }}
26
26
  <div class="summary-grid">{% for row in implementation.validationEvidence %}{{ summary_card(row.phase ~ " · " ~ t('tasks.implementation.exit-code') ~ " " ~ row.exitCode, row.outputTail, "important" if row.exitCode else "neutral") }}{% endfor %}</div>
27
- <p><strong>{{ t('tasks.implementation.independent-check') }}</strong> {% for row in implementation.verifierResults %}<span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</p>
27
+ {% for row in implementation.verifierResults %}<article class="summary-card tone-{{ row.verdict | lower }}"><p class="eyebrow">{{ row.verifier | inline_code }} · <span class="status status-{{ row.verdict | lower }}">{{ row.verdict }}</span></p>{% if row.get("independentValidationRerun") %}<p><strong>{{ t('tasks.implementation.independent-rerun') }}</strong> {{ row.independentValidationRerun | inline_code }}</p>{% endif %}{% if row.get("readOnlyCommandLog") %}<p><strong>{{ t('tasks.implementation.command-log') }}</strong> {{ row.readOnlyCommandLog | inline_code }}</p>{% endif %}{% if row.get("discrepancy") %}<p class="evidence-refs">{{ t('tasks.implementation.discrepancy') }} {{ row.discrepancy | inline_code }}</p>{% endif %}{% if row.get("declinedFixRecommendations") %}<p>{{ row.declinedFixRecommendations | inline_code }}</p>{% endif %}</article>{% endfor %}
28
28
  </section>
29
29
 
30
30
  <section data-report-section="remaining-issues">
@@ -102,8 +102,12 @@ if not process.stdout.strip():
102
102
  payload = json.loads(process.stdout)
103
103
  actual_status = payload.get("validationStatus")
104
104
  if actual_status != expected_status:
105
+ extra = ""
106
+ if expected_status == "passed":
107
+ listed = payload.get("failures") or []
108
+ extra = "\n" + "\n".join(str(item) for item in listed)
105
109
  raise SystemExit(
106
- f"validator status mismatch: expected {expected_status}, got {actual_status}"
110
+ f"validator status mismatch: expected {expected_status}, got {actual_status}{extra}"
107
111
  )
108
112
 
109
113
  if expected_status == "passed" and process.returncode != 0:
@@ -40,6 +40,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
40
40
  sys.path.insert(0, str(_ssot_dir))
41
41
 
42
42
  from okstra_ctl.clarification_items import ( # noqa: E402
43
+ STRUCTURED_REPORT_VERSIONS,
43
44
  parse_clarification_items,
44
45
  section_1_present_but_unparsed,
45
46
  )
@@ -87,7 +88,7 @@ def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
87
88
  data = json.loads(data_path.read_text(encoding="utf-8"))
88
89
  except (OSError, json.JSONDecodeError):
89
90
  return None
90
- if isinstance(data, dict) and data.get("schemaVersion") == "2.0":
91
+ if isinstance(data, dict) and data.get("schemaVersion") in STRUCTURED_REPORT_VERSIONS:
91
92
  return data_path, data
92
93
  return None
93
94
 
@@ -80,7 +80,9 @@ from okstra_ctl.report_translation import ( # noqa: E402
80
80
  )
81
81
  from okstra_ctl.stage_citations import enumerated_stage_numbers # noqa: E402
82
82
  from okstra_ctl.plan_items import ( # noqa: E402
83
+ CRITIC_WORKER_ID,
83
84
  advisory_plan_body_gating,
85
+ is_critic_worker,
84
86
  stage_scope_bucket as _item_stage_scope_bucket,
85
87
  )
86
88
  from okstra_ctl.incremental_scope import ( # noqa: E402
@@ -92,6 +94,7 @@ from okstra_ctl.clarification_items import ( # noqa: E402
92
94
  APPROVAL_BLOCKS,
93
95
  PROCEEDING_DISPOSITIONS,
94
96
  clarification_disposition,
97
+ incorporated_clarification_ids,
95
98
  progress_blocking_ids,
96
99
  row_blocks_progress,
97
100
  )
@@ -512,7 +515,7 @@ def _validate_agent_dispatch_contract(
512
515
 
513
516
  links = [
514
517
  row for row in (team_state.get("agentResultLinks") or [])
515
- if isinstance(row, Mapping)
518
+ if isinstance(row, Mapping) and not row.get("supersededBy")
516
519
  ]
517
520
  paths: dict[str, str] = {}
518
521
  dispatch_link_counts: dict[str, int] = {}
@@ -3922,6 +3925,34 @@ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
3922
3925
  )
3923
3926
 
3924
3927
 
3928
+ def _critic_non_error_verdicts(item: dict) -> list[dict]:
3929
+ return [
3930
+ row
3931
+ for row in (item.get("verdicts") or [])
3932
+ if isinstance(row, dict)
3933
+ and is_critic_worker(str(row.get("worker") or ""))
3934
+ and str(row.get("verdict") or "").strip().upper()
3935
+ not in ("", "VERIFICATION-ERROR")
3936
+ ]
3937
+
3938
+
3939
+ def _tie_gate_class(item: dict, agree: list, disagree: list) -> str | None:
3940
+ """분석자 동수면 critic 이 가르고, 없으면 재검증. 동수가 아니면 None."""
3941
+ if not (len(disagree) == len(agree) and disagree):
3942
+ return None
3943
+ critic = _critic_non_error_verdicts(item)
3944
+ if not critic:
3945
+ return "needs-reverify"
3946
+ if any(
3947
+ str(row.get("verdict") or "").strip().upper() == "DISAGREE"
3948
+ and str(row.get("breakageKind") or "").strip().lower()
3949
+ not in _ADVISORY_ONLY_KINDS
3950
+ for row in critic
3951
+ ):
3952
+ return "majority-disagree"
3953
+ return "has-dissent"
3954
+
3955
+
3925
3956
  def _classify_plan_item_gate(item: dict) -> str:
3926
3957
  """Recompute one plan item's gate class from its per-worker verdicts,
3927
3958
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
@@ -3930,7 +3961,8 @@ def _classify_plan_item_gate(item: dict) -> str:
3930
3961
  (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
3931
3962
  ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
3932
3963
  advisory-only, rollback items, and a single-vote kind that lost its
3933
- reproduction.
3964
+ reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
3965
+ settles it.
3934
3966
  """
3935
3967
  tokens = [
3936
3968
  (
@@ -3939,6 +3971,7 @@ def _classify_plan_item_gate(item: dict) -> str:
3939
3971
  )
3940
3972
  for v in (item.get("verdicts") or [])
3941
3973
  if isinstance(v, dict)
3974
+ and not is_critic_worker(str(v.get("worker") or ""))
3942
3975
  ]
3943
3976
  non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
3944
3977
  if not non_error:
@@ -3988,19 +4021,9 @@ def _classify_plan_item_gate(item: dict) -> str:
3988
4021
  # made the gate stricter than a healthy roster would.
3989
4022
  if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
3990
4023
  return "majority-disagree"
3991
- # A tie is not consensus, and until now it read as one. The majority test is
3992
- # strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
3993
- # fell through to `has-dissent` and the gate passed — the dissent recorded
3994
- # and never acted on. An even panel is not only the two-analyser roster: one
3995
- # UNVERIFIABLE or one lost dispatch turns any roster even for that item.
3996
- # Send the split back for a round; if it survives a round that judged the
3997
- # rewritten text, nothing further is going to settle it and the user decides.
3998
- # `_validate_unresolved_tie_was_reverified` is what makes the first branch
3999
- # more than a label — `needs-reverify` folds into `passed-with-dissent`.
4000
- if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
4001
- if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
4002
- return "majority-disagree"
4003
- return "needs-reverify"
4024
+ settled = _tie_gate_class(item, agree, blocking_disagree)
4025
+ if settled is not None and len(non_error) >= 2:
4026
+ return settled
4004
4027
  if (
4005
4028
  len(non_error) >= 2
4006
4029
  and blocking_disagree
@@ -4016,12 +4039,6 @@ def _classify_plan_item_gate(item: dict) -> str:
4016
4039
  return "has-dissent"
4017
4040
 
4018
4041
 
4019
- # 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
4020
- # 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
4021
- # 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
4022
- _TIE_SETTLED_ROUND = 2
4023
-
4024
-
4025
4042
  def _max_verdict_round(item: dict) -> int:
4026
4043
  """이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
4027
4044
 
@@ -4038,18 +4055,25 @@ def _max_verdict_round(item: dict) -> int:
4038
4055
  return max(rounds, default=1)
4039
4056
 
4040
4057
 
4058
+ def _is_even_analyser_split(item: dict) -> bool:
4059
+ tokens = [
4060
+ str(row.get("verdict") or "").strip().upper()
4061
+ for row in (item.get("verdicts") or [])
4062
+ if isinstance(row, dict)
4063
+ and not is_critic_worker(str(row.get("worker") or ""))
4064
+ and str(row.get("verdict") or "").strip().upper()
4065
+ not in ("", "VERIFICATION-ERROR")
4066
+ ]
4067
+ if len(tokens) < 2:
4068
+ return False
4069
+ disagree = sum(1 for token in tokens if token == "DISAGREE")
4070
+ agree = sum(1 for token in tokens if token in {"AGREE", "SUPPLEMENT"})
4071
+ return disagree == agree and disagree > 0
4072
+
4073
+
4041
4074
  def _is_unsettled_tie(item: dict) -> bool:
4042
- """아직 재검증되지 않은 동수 항목."""
4043
- return (
4044
- _classify_plan_item_gate(item) == "needs-reverify"
4045
- and _max_verdict_round(item) < _TIE_SETTLED_ROUND
4046
- and len([
4047
- verdict for verdict in (item.get("verdicts") or [])
4048
- if isinstance(verdict, dict)
4049
- and str(verdict.get("verdict") or "").strip().upper()
4050
- not in ("", "VERIFICATION-ERROR")
4051
- ]) >= 2
4052
- )
4075
+ """분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
4076
+ return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)
4053
4077
 
4054
4078
 
4055
4079
  def _disagree_breakage_kinds(item: dict) -> set[str]:
@@ -5353,6 +5377,7 @@ def _validate_approval_context(
5353
5377
  activities = _approval_activities_by_id(data)
5354
5378
  activity_timestamps = _canonical_activity_timestamps(run_manifest, report_path)
5355
5379
  report_approved = (data.get("frontmatter") or {}).get("approved") is True
5380
+ incorporated = incorporated_clarification_ids(data)
5356
5381
  for row in data.get("clarificationItems") or []:
5357
5382
  if not isinstance(row, dict) or row.get("blocks") != "approval":
5358
5383
  continue
@@ -5434,7 +5459,9 @@ def _validate_approval_context(
5434
5459
  failures,
5435
5460
  )
5436
5461
  if report_approved and row_blocks_progress(
5437
- str(row.get("status") or ""), clarification_disposition(row)
5462
+ str(row.get("status") or ""),
5463
+ clarification_disposition(row),
5464
+ incorporated=row_id in incorporated,
5438
5465
  ):
5439
5466
  failures.append(
5440
5467
  f"final-report data.json: approval is true while clarification `{row_id}` "
@@ -5498,6 +5525,7 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5498
5525
  activities = _approval_activities_by_id(data)
5499
5526
  _validate_v3_plan_backlinks(data, activities, failures)
5500
5527
  approved = (data.get("frontmatter") or {}).get("approved") is True
5528
+ incorporated = incorporated_clarification_ids(data)
5501
5529
  for row in data.get("clarificationItems") or []:
5502
5530
  if not isinstance(row, dict) or row.get("blocks") != "approval":
5503
5531
  continue
@@ -5511,8 +5539,11 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5511
5539
  row, context, failures, schema_version="3.0"
5512
5540
  )
5513
5541
  _validate_v3_resolution_links(row, activities, failures)
5542
+ row_id = str(row.get("id") or "")
5514
5543
  if approved and row_blocks_progress(
5515
- str(row.get("status") or ""), clarification_disposition(row)
5544
+ str(row.get("status") or ""),
5545
+ clarification_disposition(row),
5546
+ incorporated=row_id in incorporated,
5516
5547
  ):
5517
5548
  failures.append(
5518
5549
  f"final-report data.json: approval is true while clarification "
@@ -5528,12 +5559,8 @@ def _validate_activity_contract_plan_limits(
5528
5559
  if not _is_activity_contract_v1_planning(run_manifest):
5529
5560
  return
5530
5561
  pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
5531
- rounds_applied = pbv.get("selfFixRoundsApplied", 0)
5532
- if isinstance(rounds_applied, int) and rounds_applied > 1:
5533
- failures.append(
5534
- "final-report data.json: activity contract v1 selfFixRoundsApplied "
5535
- "must be at most one automatic self-fix round"
5536
- )
5562
+ # 리포트 칸은 이어진 런의 누적이다. 자동 자가수정 1회 상한은 이번 창의
5563
+ # 상태 파일이 세고, 세션 적합성이 그 횟수와 `self-fix-applied` 를 맞춘다.
5537
5564
  if pbv.get("selfFixStopReason") == "cause-group-recurrence":
5538
5565
  failures.append(
5539
5566
  "final-report data.json: activity contract v1 cannot newly emit "
@@ -6287,7 +6314,11 @@ def _next_step_texts(steps: object) -> list[str]:
6287
6314
 
6288
6315
  def _has_unresolved_approval_blocker(data: dict) -> bool:
6289
6316
  return bool(
6290
- progress_blocking_ids(data.get("clarificationItems"), APPROVAL_BLOCKS)
6317
+ progress_blocking_ids(
6318
+ data.get("clarificationItems"),
6319
+ APPROVAL_BLOCKS,
6320
+ report_data=data,
6321
+ )
6291
6322
  )
6292
6323
 
6293
6324
 
@@ -7320,21 +7351,12 @@ def _validate_unresolved_tie_was_reverified(
7320
7351
  data: dict,
7321
7352
  failures: list[str],
7322
7353
  ) -> None:
7323
- """A split panel is sent back once before the gate is declared.
7354
+ """A split panel goes to critic-worker before the gate is declared.
7324
7355
 
7325
7356
  The gate needs a strict majority to block, so a panel splitting evenly on a
7326
7357
  blocking kind reaches neither consensus nor `majority-disagree`. That state
7327
- is classified `needs-reverify`, and `needs-reverify` folds into
7328
- `passed-with-dissent` — which is correct for the shape it was built for (a
7329
- peer that returned nothing) and wrong for this one: nothing failed here, two
7330
- verifiers read the same plan and disagreed, and passing on that records a
7331
- dissent nobody acted on.
7332
-
7333
- So the round is not optional. Re-dispatch those items and record the votes
7334
- with `--round 2`; a split that survives becomes `majority-disagree` and the
7335
- user decides. This is satisfiable with the machinery the contract already
7336
- defines — it is the same targeted re-verification step 7 runs after a
7337
- self-fix, with the tied items added to that queue.
7358
+ is classified `needs-reverify` until `critic-worker` settles it. Passing
7359
+ without that vote records a dissent nobody acted on.
7338
7360
  """
7339
7361
  ip = data.get("implementationPlanning")
7340
7362
  if not isinstance(ip, dict):
@@ -7353,14 +7375,13 @@ def _validate_unresolved_tie_was_reverified(
7353
7375
  if not unsettled:
7354
7376
  return
7355
7377
  failures.append(
7356
- f"final-report data.json: plan item(s) {unsettled} carry an even split "
7357
- "on a blocking breakage kind and were never re-verified. A tie is not "
7358
- "consensus: the gate's majority test is strict, so this split neither "
7359
- "blocks nor resolves, and declaring the gate on it passes a dissent "
7360
- "nobody settled. Re-dispatch those items in a plan-body round and "
7361
- "record the votes with `okstra plan-items apply-verdicts --data "
7362
- "<data.json> --verdicts <verdicts.json> --round 2`. A split that "
7363
- "survives that round becomes `majority-disagree` and goes to the user."
7378
+ "final-report data.json: plan item(s) "
7379
+ f"{unsettled} carry an even split on a blocking breakage kind and "
7380
+ f"have no `{CRITIC_WORKER_ID}` vote. A tie is not consensus. Dispatch "
7381
+ f"`{CRITIC_WORKER_ID}` on those items only (`okstra plan-items "
7382
+ "prepare --tie-vote`) and record the vote with `okstra plan-items "
7383
+ "apply-verdicts --append --round 2`. Critic AGREE settles the split; "
7384
+ "critic DISAGREE blocks."
7364
7385
  )
7365
7386
 
7366
7387
 
@@ -7368,7 +7389,7 @@ def _validate_tie_received_extra_vote(
7368
7389
  data: dict,
7369
7390
  failures: list[str],
7370
7391
  ) -> None:
7371
- """동수는 같은 둘을 다시 돌리는 것이 아니라 세 번째 표로 가른다."""
7392
+ """동수는 같은 둘을 다시 돌리는 것이 아니라 critic 이 가른다."""
7372
7393
  ip = data.get("implementationPlanning")
7373
7394
  if not isinstance(ip, dict):
7374
7395
  return
@@ -7384,17 +7405,17 @@ def _validate_tie_received_extra_vote(
7384
7405
  and str(item.get("id") or "").strip() not in accepted
7385
7406
  and _stage_scope_bucket(item, pbv) == "in-scope"
7386
7407
  and _is_even_blocking_split(item)
7387
- and _distinct_verdict_workers(item) < 3
7408
+ and not _critic_non_error_verdicts(item)
7388
7409
  })
7389
7410
  if not missing:
7390
7411
  return
7391
7412
  failures.append(
7392
7413
  f"final-report data.json: plan item(s) {missing} carry an even split "
7393
- "on a blocking breakage kind and have no third vote. Re-running the "
7394
- "original two does not settle a 1-1 split. Dispatch one extra analyser "
7395
- "whose prompt is those items only (`okstra plan-items prepare "
7396
- "--tie-vote`) and record the vote with `okstra plan-items "
7397
- "apply-verdicts --append --round 2`."
7414
+ "on a blocking breakage kind and have no critic vote. Re-running the "
7415
+ "original two does not settle a 1-1 split. Dispatch "
7416
+ f"`{CRITIC_WORKER_ID}` whose prompt is those items only "
7417
+ "(`okstra plan-items prepare --tie-vote`) and record the vote with "
7418
+ "`okstra plan-items apply-verdicts --append --round 2`."
7398
7419
  )
7399
7420
 
7400
7421
 
@@ -7411,14 +7432,6 @@ def _is_even_blocking_split(item: dict) -> bool:
7411
7432
  return _is_unsettled_tie(forced)
7412
7433
 
7413
7434
 
7414
- def _distinct_verdict_workers(item: dict) -> int:
7415
- return len({
7416
- str(row.get("worker") or "")
7417
- for row in (item.get("verdicts") or [])
7418
- if isinstance(row, dict) and str(row.get("worker") or "").strip()
7419
- })
7420
-
7421
-
7422
7435
  def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
7423
7436
  """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
7424
7437
  ip = data.get("implementationPlanning")
@@ -9622,7 +9635,13 @@ def _validate_convergence_rounds_match_manifest(
9622
9635
  def _validate_convergence_states(
9623
9636
  run_dir, failures, run_manifest: dict | None = None, project_root: Path | None = None,
9624
9637
  ) -> None:
9625
- """이번 런이 가리키는 수렴 상태만 본다. 디렉터리의 옛 seq 파일은 건너뛴다."""
9638
+ """이번 런이 가리키는 수렴 상태만 본다. 디렉터리의 옛 seq 파일은 건너뛴다.
9639
+
9640
+ 매니페스트가 경로를 채워도 파일이 없으면 검사하지 않는다. render-only 와
9641
+ workflow 픽스처는 수렴을 돌리지 않아 파일이 없고, 없는 파일을 실패로 치면
9642
+ 예전 glob 이 빈 결과를 내던 계약이 깨진다. 목적은 현재 런이 아닌 seq 를
9643
+ 보지 않는 것이다.
9644
+ """
9626
9645
  from pathlib import Path as _Path
9627
9646
 
9628
9647
  declared = (run_manifest or {}).get("convergenceStatePath")
@@ -9632,7 +9651,7 @@ def _validate_convergence_states(
9632
9651
  if project_root is None:
9633
9652
  return
9634
9653
  path = project_root / path
9635
- paths = [path]
9654
+ paths = [path] if path.is_file() else []
9636
9655
  else:
9637
9656
  state_dir = _Path(run_dir) / "state"
9638
9657
  if not state_dir.is_dir():
@@ -9644,10 +9663,6 @@ def _validate_convergence_states(
9644
9663
  ]
9645
9664
  for state_path in paths:
9646
9665
  if not state_path.is_file():
9647
- if isinstance(declared, str) and declared.strip():
9648
- failures.append(
9649
- f"convergence state {state_path.name}: missing declared artifact"
9650
- )
9651
9666
  continue
9652
9667
  try:
9653
9668
  state = json.loads(state_path.read_text(encoding="utf-8"))
@@ -47,6 +47,7 @@ from okstra_ctl.lead_events import ( # noqa: E402
47
47
  )
48
48
  from okstra_ctl.agent_activity import ACTIVITY_FIELDS # noqa: E402
49
49
  from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
50
+ from okstra_ctl.final_report_paths import final_report_data_path # noqa: E402
50
51
  from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
51
52
  from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
52
53
 
@@ -624,16 +625,7 @@ def _plan_body_state_path(run_dir: Path, suffix: str) -> Path:
624
625
 
625
626
  def _ids_reported_as_asked(report_path: Path) -> list[str]:
626
627
  """리포트가 "사용자에게 물었다"고 기록한 열린 승인 차단 행의 id."""
627
- name = report_path.name
628
- if not name.endswith(".md"):
629
- return []
630
- data_path = report_path.with_name(name.removesuffix(".md") + ".data.json")
631
- try:
632
- doc = json.loads(data_path.read_text())
633
- except (OSError, json.JSONDecodeError):
634
- return []
635
- if not isinstance(doc, dict):
636
- return []
628
+ doc = _read_report_data(report_path)
637
629
  asked: list[str] = []
638
630
  for row in doc.get("clarificationItems") or []:
639
631
  if not isinstance(row, dict):
@@ -666,12 +658,13 @@ def _activity_index(events: list[LeadEvent]) -> dict[str, list[LeadEvent]]:
666
658
 
667
659
 
668
660
  def _read_report_data(report_path: Path) -> Mapping[str, Any]:
669
- name = report_path.name
670
- if not name.endswith(".md"):
671
- return {}
672
- data_path = report_path.with_name(name.removesuffix(".md") + ".data.json")
661
+ """`--report` 가 data.json · markdown · html 이어도 같은 레코드를 연다.
662
+
663
+ Phase 7 는 data.json 을 넘긴다. `.md` 만 받던 동안 투영과 self-fix 횟수가
664
+ 빈 객체에서 나와 `projected=<missing>` / `selfFixRoundsApplied=0` 이 됐다.
665
+ """
673
666
  try:
674
- data = json.loads(data_path.read_text(encoding="utf-8"))
667
+ data = json.loads(final_report_data_path(report_path).read_text(encoding="utf-8"))
675
668
  except (OSError, json.JSONDecodeError):
676
669
  return {}
677
670
  return data if isinstance(data, Mapping) else {}
@@ -758,7 +751,16 @@ def _check_projected_agent_activity(
758
751
  {field: event.details.get(field) for field in ACTIVITY_FIELDS}
759
752
  for event in events
760
753
  ]
761
- projected = report_data.get("agentActivity")
754
+ raw_projected = report_data.get("agentActivity")
755
+ projected = (
756
+ [
757
+ {field: row.get(field) for field in ACTIVITY_FIELDS}
758
+ for row in raw_projected
759
+ if isinstance(row, Mapping)
760
+ ]
761
+ if isinstance(raw_projected, list)
762
+ else raw_projected
763
+ )
762
764
  if projected == expected:
763
765
  return
764
766
  mismatch = "length"
@@ -850,6 +852,43 @@ def _allowed_automatic_rounds(self_fix_rounds: int, *, gating: bool = True) -> i
850
852
  return 2 + max(self_fix_rounds, 0)
851
853
 
852
854
 
855
+ def _self_fix_rounds_from_state(run_dir: Path, suffix: str | None) -> int | None:
856
+ """이번 런 상태 파일의 자가수정 횟수. 없으면 None — 호출자가 리포트로 폴백.
857
+
858
+ 리포트 칸은 이어진 seq 의 누적이라, 이번 창의 `self-fix-applied` 건수와
859
+ 비교하면 어긋난다.
860
+ """
861
+ if not suffix:
862
+ return None
863
+ try:
864
+ doc = json.loads(_plan_body_state_path(run_dir, suffix).read_text())
865
+ except (OSError, json.JSONDecodeError):
866
+ return None
867
+ if not isinstance(doc, dict):
868
+ return None
869
+ projection = doc.get("planBodyVerification")
870
+ value = (
871
+ projection.get("selfFixRoundsApplied")
872
+ if isinstance(projection, dict)
873
+ else None
874
+ )
875
+ if isinstance(value, int) and value >= 0:
876
+ return value
877
+ value = doc.get("selfFixRoundsApplied")
878
+ return value if isinstance(value, int) and value >= 0 else None
879
+
880
+
881
+ def _is_plan_body_verification_activity(event: LeadEvent) -> bool:
882
+ """`verification-round-completed` 가 계획 본문 배치인지.
883
+
884
+ 같은 kind 로 적대 재검증 라운드도 남는다. 그 건을 본문 라운드에 넣으면
885
+ recorded 가 roundCount 보다 커진다. 요약이 `adversarial reverify` 이면
886
+ 본문이 아니다. 픽스처 요약(`verification-round-completed for …`)은 본문이다.
887
+ """
888
+ summary = str(event.details.get("summary") or "").lower()
889
+ return "adversarial reverify" not in summary and "adversarial re-verify" not in summary
890
+
891
+
853
892
  def _plan_body_verification(report_data: Mapping[str, Any]) -> Mapping[str, Any] | None:
854
893
  planning = report_data.get("implementationPlanning")
855
894
  verification = (
@@ -970,14 +1009,23 @@ def _check_activity_round_counts(
970
1009
  errors: list[str],
971
1010
  ) -> None:
972
1011
  verification_rounds = _plan_body_rounds_ran(run_dir, suffix)
973
- recorded_verifications = len(indexed.get("verification-round-completed", []))
1012
+ recorded_verifications = len([
1013
+ event
1014
+ for event in indexed.get("verification-round-completed", [])
1015
+ if _is_plan_body_verification_activity(event)
1016
+ ])
974
1017
  user_reverification_rounds = _resolved_correctness_reverification_rounds(
975
1018
  indexed,
976
1019
  report_data,
977
1020
  verification_rounds,
978
1021
  )
979
1022
  automatic_rounds = verification_rounds - len(user_reverification_rounds)
980
- self_fix_rounds = _self_fix_rounds_applied(report_data)
1023
+ state_self_fix = _self_fix_rounds_from_state(run_dir, suffix)
1024
+ self_fix_rounds = (
1025
+ state_self_fix
1026
+ if state_self_fix is not None
1027
+ else _self_fix_rounds_applied(report_data)
1028
+ )
981
1029
  allowed_rounds = _allowed_automatic_rounds(
982
1030
  self_fix_rounds, gating=_plan_body_gating(report_data),
983
1031
  )
@@ -1435,6 +1483,11 @@ def _check_cmux_adapter_read(
1435
1483
  return
1436
1484
  if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
1437
1485
  return
1486
+ dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
1487
+ if dispatch_mode in _WORKER_DISPATCH_MODES:
1488
+ # grok 같은 artifact-only 호스트는 Read 도구 기록이 없다. 워커가
1489
+ # okstra 디스패치로 나갔으면 어댑터가 가리키는 경로를 탄 것이다.
1490
+ return
1438
1491
  errors.append(
1439
1492
  f"cmux adapter: no read of `{CMUX_ADAPTER_BASENAME}` (a `Read` call or a "
1440
1493
  f"shell command naming it) found in the "