okstra 0.186.4 → 0.186.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +6 -5
- package/runtime/prompts/launch.template.md +14 -0
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -14
- package/runtime/prompts/lead/plan-body-verification.md +4 -3
- package/runtime/prompts/lead/report-writer.md +3 -1
- package/runtime/prompts/profiles/_coverage-critic.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +3 -3
- package/runtime/prompts/profiles/requirements-discovery.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +2 -4
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -5
- package/runtime/python/okstra_ctl/agent_activity.py +6 -0
- package/runtime/python/okstra_ctl/clarification_items.py +67 -11
- package/runtime/python/okstra_ctl/next_phase.py +6 -3
- package/runtime/python/okstra_ctl/plan_items.py +32 -6
- package/runtime/python/okstra_ctl/plan_items_cli.py +57 -3
- package/runtime/python/okstra_ctl/render_final_report.py +3 -1
- package/runtime/python/okstra_ctl/report_assembly.py +65 -3
- package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
- package/runtime/python/okstra_ctl/report_projections.py +45 -4
- package/runtime/python/okstra_ctl/run.py +21 -6
- package/runtime/python/okstra_ctl/usage_cells.py +15 -0
- package/runtime/python/okstra_ctl/user_response.py +72 -6
- package/runtime/python/okstra_ctl/wizard.py +1 -5
- package/runtime/python/okstra_token_usage/codex.py +32 -3
- package/runtime/python/okstra_token_usage/collect.py +148 -15
- package/runtime/python/okstra_token_usage/grok.py +24 -5
- package/runtime/python/okstra_token_usage/report.py +12 -2
- package/runtime/skills/okstra-user-response/SKILL.md +4 -2
- package/runtime/templates/reports/html/assets/base.css +3 -9
- package/runtime/templates/reports/html/assets/base.js +0 -21
- package/runtime/templates/reports/html/base.template.html +1 -4
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +9 -28
- package/runtime/validators/lib/runners.sh +5 -1
- package/runtime/validators/validate-report-views.py +2 -1
- package/runtime/validators/validate-run.py +240 -102
- package/runtime/validators/validate_session_conformance.py +71 -18
|
@@ -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] = {}
|
|
@@ -3492,8 +3495,8 @@ def validate_final_report_data(
|
|
|
3492
3495
|
errors = schema_validate(data, schema)
|
|
3493
3496
|
for err in errors:
|
|
3494
3497
|
failures.append(f"final-report data.json: {err}")
|
|
3495
|
-
|
|
3496
|
-
|
|
3498
|
+
# 스키마 실패가 계약 스캔을 가리지 않는다. 빈 decisionRefs 가
|
|
3499
|
+
# minItems 에서 막히면 종료상태 표 누락이 안 보였다.
|
|
3497
3500
|
|
|
3498
3501
|
manifest = run_manifest or {}
|
|
3499
3502
|
if data.get("executionIdentityVersion") == 2 or data.get("executionRoles"):
|
|
@@ -3595,8 +3598,11 @@ def validate_final_report_data(
|
|
|
3595
3598
|
data, _project_root_from_report(report_path)
|
|
3596
3599
|
):
|
|
3597
3600
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3601
|
+
carried = _carried_decision_map(
|
|
3602
|
+
manifest, project_root=project_root, report_path=report_path
|
|
3603
|
+
)
|
|
3598
3604
|
_validate_supersession_ledger(data, failures)
|
|
3599
|
-
_validate_clarification_evidence_note(data, failures)
|
|
3605
|
+
_validate_clarification_evidence_note(data, failures, carried=carried)
|
|
3600
3606
|
_validate_approval_clarification_backtrace(data, failures)
|
|
3601
3607
|
_validate_rerun_guidance(data, failures)
|
|
3602
3608
|
_validate_approval_guidance(data, failures)
|
|
@@ -3606,7 +3612,11 @@ def validate_final_report_data(
|
|
|
3606
3612
|
failures,
|
|
3607
3613
|
)
|
|
3608
3614
|
if not selected_direction_contract:
|
|
3609
|
-
_validate_requirement_deviations(
|
|
3615
|
+
_validate_requirement_deviations(
|
|
3616
|
+
data,
|
|
3617
|
+
failures,
|
|
3618
|
+
carried=carried,
|
|
3619
|
+
)
|
|
3610
3620
|
_validate_requirement_coverage_covered_by(data, failures)
|
|
3611
3621
|
warnings = _validate_design_prep_contract(
|
|
3612
3622
|
data,
|
|
@@ -3915,6 +3925,34 @@ def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
|
|
|
3915
3925
|
)
|
|
3916
3926
|
|
|
3917
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
|
+
|
|
3918
3956
|
def _classify_plan_item_gate(item: dict) -> str:
|
|
3919
3957
|
"""Recompute one plan item's gate class from its per-worker verdicts,
|
|
3920
3958
|
per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
|
|
@@ -3923,7 +3961,8 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3923
3961
|
(``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
|
|
3924
3962
|
``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
|
|
3925
3963
|
advisory-only, rollback items, and a single-vote kind that lost its
|
|
3926
|
-
reproduction.
|
|
3964
|
+
reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
|
|
3965
|
+
settles it.
|
|
3927
3966
|
"""
|
|
3928
3967
|
tokens = [
|
|
3929
3968
|
(
|
|
@@ -3932,6 +3971,7 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3932
3971
|
)
|
|
3933
3972
|
for v in (item.get("verdicts") or [])
|
|
3934
3973
|
if isinstance(v, dict)
|
|
3974
|
+
and not is_critic_worker(str(v.get("worker") or ""))
|
|
3935
3975
|
]
|
|
3936
3976
|
non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
|
|
3937
3977
|
if not non_error:
|
|
@@ -3981,19 +4021,9 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
3981
4021
|
# made the gate stricter than a healthy roster would.
|
|
3982
4022
|
if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
|
|
3983
4023
|
return "majority-disagree"
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
# and never acted on. An even panel is not only the two-analyser roster: one
|
|
3988
|
-
# UNVERIFIABLE or one lost dispatch turns any roster even for that item.
|
|
3989
|
-
# Send the split back for a round; if it survives a round that judged the
|
|
3990
|
-
# rewritten text, nothing further is going to settle it and the user decides.
|
|
3991
|
-
# `_validate_unresolved_tie_was_reverified` is what makes the first branch
|
|
3992
|
-
# more than a label — `needs-reverify` folds into `passed-with-dissent`.
|
|
3993
|
-
if len(non_error) >= 2 and len(blocking_disagree) == len(agree):
|
|
3994
|
-
if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
|
|
3995
|
-
return "majority-disagree"
|
|
3996
|
-
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
|
|
3997
4027
|
if (
|
|
3998
4028
|
len(non_error) >= 2
|
|
3999
4029
|
and blocking_disagree
|
|
@@ -4009,12 +4039,6 @@ def _classify_plan_item_gate(item: dict) -> str:
|
|
|
4009
4039
|
return "has-dissent"
|
|
4010
4040
|
|
|
4011
4041
|
|
|
4012
|
-
# 동수를 한 번 재검증한 뒤에도 갈리면 그때는 사용자가 판단한다. 초기 검증이
|
|
4013
|
-
# 라운드 1이고 자가수정 뒤의 표적 재검증이 라운드 2이므로, 라운드 2 이상의
|
|
4014
|
-
# 판정이 붙은 동수는 이미 한 번 돌아온 것이다.
|
|
4015
|
-
_TIE_SETTLED_ROUND = 2
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
4042
|
def _max_verdict_round(item: dict) -> int:
|
|
4019
4043
|
"""이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.
|
|
4020
4044
|
|
|
@@ -4031,18 +4055,25 @@ def _max_verdict_round(item: dict) -> int:
|
|
|
4031
4055
|
return max(rounds, default=1)
|
|
4032
4056
|
|
|
4033
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
|
+
|
|
4034
4074
|
def _is_unsettled_tie(item: dict) -> bool:
|
|
4035
|
-
"""아직
|
|
4036
|
-
return (
|
|
4037
|
-
_classify_plan_item_gate(item) == "needs-reverify"
|
|
4038
|
-
and _max_verdict_round(item) < _TIE_SETTLED_ROUND
|
|
4039
|
-
and len([
|
|
4040
|
-
verdict for verdict in (item.get("verdicts") or [])
|
|
4041
|
-
if isinstance(verdict, dict)
|
|
4042
|
-
and str(verdict.get("verdict") or "").strip().upper()
|
|
4043
|
-
not in ("", "VERIFICATION-ERROR")
|
|
4044
|
-
]) >= 2
|
|
4045
|
-
)
|
|
4075
|
+
"""분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
|
|
4076
|
+
return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)
|
|
4046
4077
|
|
|
4047
4078
|
|
|
4048
4079
|
def _disagree_breakage_kinds(item: dict) -> set[str]:
|
|
@@ -5346,6 +5377,7 @@ def _validate_approval_context(
|
|
|
5346
5377
|
activities = _approval_activities_by_id(data)
|
|
5347
5378
|
activity_timestamps = _canonical_activity_timestamps(run_manifest, report_path)
|
|
5348
5379
|
report_approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
5380
|
+
incorporated = incorporated_clarification_ids(data)
|
|
5349
5381
|
for row in data.get("clarificationItems") or []:
|
|
5350
5382
|
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
5351
5383
|
continue
|
|
@@ -5427,7 +5459,9 @@ def _validate_approval_context(
|
|
|
5427
5459
|
failures,
|
|
5428
5460
|
)
|
|
5429
5461
|
if report_approved and row_blocks_progress(
|
|
5430
|
-
str(row.get("status") or ""),
|
|
5462
|
+
str(row.get("status") or ""),
|
|
5463
|
+
clarification_disposition(row),
|
|
5464
|
+
incorporated=row_id in incorporated,
|
|
5431
5465
|
):
|
|
5432
5466
|
failures.append(
|
|
5433
5467
|
f"final-report data.json: approval is true while clarification `{row_id}` "
|
|
@@ -5491,6 +5525,7 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
|
5491
5525
|
activities = _approval_activities_by_id(data)
|
|
5492
5526
|
_validate_v3_plan_backlinks(data, activities, failures)
|
|
5493
5527
|
approved = (data.get("frontmatter") or {}).get("approved") is True
|
|
5528
|
+
incorporated = incorporated_clarification_ids(data)
|
|
5494
5529
|
for row in data.get("clarificationItems") or []:
|
|
5495
5530
|
if not isinstance(row, dict) or row.get("blocks") != "approval":
|
|
5496
5531
|
continue
|
|
@@ -5504,8 +5539,11 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
|
|
|
5504
5539
|
row, context, failures, schema_version="3.0"
|
|
5505
5540
|
)
|
|
5506
5541
|
_validate_v3_resolution_links(row, activities, failures)
|
|
5542
|
+
row_id = str(row.get("id") or "")
|
|
5507
5543
|
if approved and row_blocks_progress(
|
|
5508
|
-
str(row.get("status") or ""),
|
|
5544
|
+
str(row.get("status") or ""),
|
|
5545
|
+
clarification_disposition(row),
|
|
5546
|
+
incorporated=row_id in incorporated,
|
|
5509
5547
|
):
|
|
5510
5548
|
failures.append(
|
|
5511
5549
|
f"final-report data.json: approval is true while clarification "
|
|
@@ -5521,12 +5559,8 @@ def _validate_activity_contract_plan_limits(
|
|
|
5521
5559
|
if not _is_activity_contract_v1_planning(run_manifest):
|
|
5522
5560
|
return
|
|
5523
5561
|
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
failures.append(
|
|
5527
|
-
"final-report data.json: activity contract v1 selfFixRoundsApplied "
|
|
5528
|
-
"must be at most one automatic self-fix round"
|
|
5529
|
-
)
|
|
5562
|
+
# 리포트 칸은 이어진 런의 누적이다. 자동 자가수정 1회 상한은 이번 창의
|
|
5563
|
+
# 상태 파일이 세고, 세션 적합성이 그 횟수와 `self-fix-applied` 를 맞춘다.
|
|
5530
5564
|
if pbv.get("selfFixStopReason") == "cause-group-recurrence":
|
|
5531
5565
|
failures.append(
|
|
5532
5566
|
"final-report data.json: activity contract v1 cannot newly emit "
|
|
@@ -5778,6 +5812,14 @@ def _validate_self_fix_rewrite_scope(data: dict, failures: list[str]) -> None:
|
|
|
5778
5812
|
)
|
|
5779
5813
|
|
|
5780
5814
|
|
|
5815
|
+
def _analyser_key(worker: str) -> str:
|
|
5816
|
+
"""`codex` 와 `codex-worker` 는 같은 분석기다."""
|
|
5817
|
+
name = worker.strip()
|
|
5818
|
+
if name.endswith("-worker"):
|
|
5819
|
+
return name[: -len("-worker")]
|
|
5820
|
+
return name
|
|
5821
|
+
|
|
5822
|
+
|
|
5781
5823
|
def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
5782
5824
|
"""The gate's own arithmetic base, checked against the votes it ran on.
|
|
5783
5825
|
|
|
@@ -5809,12 +5851,13 @@ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
|
5809
5851
|
return
|
|
5810
5852
|
|
|
5811
5853
|
observed = {
|
|
5812
|
-
str(v.get("worker"))
|
|
5854
|
+
_analyser_key(str(v.get("worker")))
|
|
5813
5855
|
for item in (pbv.get("planItems") or [])
|
|
5814
5856
|
if isinstance(item, dict)
|
|
5815
5857
|
for v in (item.get("verdicts") or [])
|
|
5816
5858
|
if isinstance(v, dict) and str(v.get("verdict") or "") != "verification-error"
|
|
5817
5859
|
}
|
|
5860
|
+
observed.discard("")
|
|
5818
5861
|
if observed and voting != len(observed):
|
|
5819
5862
|
failures.append(
|
|
5820
5863
|
"final-report data.json: planBodyVerification.participatingAnalysers "
|
|
@@ -5917,7 +5960,9 @@ _EVIDENCE_NONE_RE = re.compile(r"^none\s*[—-]\s*\S")
|
|
|
5917
5960
|
_EVIDENCE_PATH_LINE_RE = re.compile(r"\S+\.\w+:\d+")
|
|
5918
5961
|
|
|
5919
5962
|
|
|
5920
|
-
def _validate_clarification_evidence_note(
|
|
5963
|
+
def _validate_clarification_evidence_note(
|
|
5964
|
+
data: dict, failures: list[str], *, carried: dict | None = None,
|
|
5965
|
+
) -> None:
|
|
5921
5966
|
"""Every clarification row must show its codebase-first work.
|
|
5922
5967
|
|
|
5923
5968
|
The profile requires any ambiguity answerable by `Read` / `Grep` to be
|
|
@@ -5930,10 +5975,13 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
|
|
|
5930
5975
|
"""
|
|
5931
5976
|
if (data.get("header") or {}).get("taskType") != "implementation-planning":
|
|
5932
5977
|
return
|
|
5978
|
+
carried_ids = set((carried or {}).keys())
|
|
5933
5979
|
for row in data.get("clarificationItems") or []:
|
|
5934
5980
|
if not isinstance(row, dict):
|
|
5935
5981
|
continue
|
|
5936
5982
|
row_id = str(row.get("id") or "<unknown>")
|
|
5983
|
+
if row_id in carried_ids:
|
|
5984
|
+
continue
|
|
5937
5985
|
statement = str(row.get("statement") or "")
|
|
5938
5986
|
match = _EVIDENCE_NOTE_RE.search(statement)
|
|
5939
5987
|
if not match:
|
|
@@ -6266,7 +6314,11 @@ def _next_step_texts(steps: object) -> list[str]:
|
|
|
6266
6314
|
|
|
6267
6315
|
def _has_unresolved_approval_blocker(data: dict) -> bool:
|
|
6268
6316
|
return bool(
|
|
6269
|
-
progress_blocking_ids(
|
|
6317
|
+
progress_blocking_ids(
|
|
6318
|
+
data.get("clarificationItems"),
|
|
6319
|
+
APPROVAL_BLOCKS,
|
|
6320
|
+
report_data=data,
|
|
6321
|
+
)
|
|
6270
6322
|
)
|
|
6271
6323
|
|
|
6272
6324
|
|
|
@@ -7299,21 +7351,12 @@ def _validate_unresolved_tie_was_reverified(
|
|
|
7299
7351
|
data: dict,
|
|
7300
7352
|
failures: list[str],
|
|
7301
7353
|
) -> None:
|
|
7302
|
-
"""A split panel
|
|
7354
|
+
"""A split panel goes to critic-worker before the gate is declared.
|
|
7303
7355
|
|
|
7304
7356
|
The gate needs a strict majority to block, so a panel splitting evenly on a
|
|
7305
7357
|
blocking kind reaches neither consensus nor `majority-disagree`. That state
|
|
7306
|
-
is classified `needs-reverify
|
|
7307
|
-
|
|
7308
|
-
peer that returned nothing) and wrong for this one: nothing failed here, two
|
|
7309
|
-
verifiers read the same plan and disagreed, and passing on that records a
|
|
7310
|
-
dissent nobody acted on.
|
|
7311
|
-
|
|
7312
|
-
So the round is not optional. Re-dispatch those items and record the votes
|
|
7313
|
-
with `--round 2`; a split that survives becomes `majority-disagree` and the
|
|
7314
|
-
user decides. This is satisfiable with the machinery the contract already
|
|
7315
|
-
defines — it is the same targeted re-verification step 7 runs after a
|
|
7316
|
-
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.
|
|
7317
7360
|
"""
|
|
7318
7361
|
ip = data.get("implementationPlanning")
|
|
7319
7362
|
if not isinstance(ip, dict):
|
|
@@ -7332,14 +7375,13 @@ def _validate_unresolved_tie_was_reverified(
|
|
|
7332
7375
|
if not unsettled:
|
|
7333
7376
|
return
|
|
7334
7377
|
failures.append(
|
|
7335
|
-
|
|
7336
|
-
"on a blocking breakage kind and
|
|
7337
|
-
"
|
|
7338
|
-
"
|
|
7339
|
-
"
|
|
7340
|
-
"
|
|
7341
|
-
"
|
|
7342
|
-
"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."
|
|
7343
7385
|
)
|
|
7344
7386
|
|
|
7345
7387
|
|
|
@@ -7347,7 +7389,7 @@ def _validate_tie_received_extra_vote(
|
|
|
7347
7389
|
data: dict,
|
|
7348
7390
|
failures: list[str],
|
|
7349
7391
|
) -> None:
|
|
7350
|
-
"""동수는 같은 둘을 다시 돌리는 것이 아니라
|
|
7392
|
+
"""동수는 같은 둘을 다시 돌리는 것이 아니라 critic 이 가른다."""
|
|
7351
7393
|
ip = data.get("implementationPlanning")
|
|
7352
7394
|
if not isinstance(ip, dict):
|
|
7353
7395
|
return
|
|
@@ -7363,17 +7405,17 @@ def _validate_tie_received_extra_vote(
|
|
|
7363
7405
|
and str(item.get("id") or "").strip() not in accepted
|
|
7364
7406
|
and _stage_scope_bucket(item, pbv) == "in-scope"
|
|
7365
7407
|
and _is_even_blocking_split(item)
|
|
7366
|
-
and
|
|
7408
|
+
and not _critic_non_error_verdicts(item)
|
|
7367
7409
|
})
|
|
7368
7410
|
if not missing:
|
|
7369
7411
|
return
|
|
7370
7412
|
failures.append(
|
|
7371
7413
|
f"final-report data.json: plan item(s) {missing} carry an even split "
|
|
7372
|
-
"on a blocking breakage kind and have no
|
|
7373
|
-
"original two does not settle a 1-1 split. Dispatch
|
|
7374
|
-
"whose prompt is those items only
|
|
7375
|
-
"--tie-vote`) and record the vote with
|
|
7376
|
-
"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`."
|
|
7377
7419
|
)
|
|
7378
7420
|
|
|
7379
7421
|
|
|
@@ -7390,14 +7432,6 @@ def _is_even_blocking_split(item: dict) -> bool:
|
|
|
7390
7432
|
return _is_unsettled_tie(forced)
|
|
7391
7433
|
|
|
7392
7434
|
|
|
7393
|
-
def _distinct_verdict_workers(item: dict) -> int:
|
|
7394
|
-
return len({
|
|
7395
|
-
str(row.get("worker") or "")
|
|
7396
|
-
for row in (item.get("verdicts") or [])
|
|
7397
|
-
if isinstance(row, dict) and str(row.get("worker") or "").strip()
|
|
7398
|
-
})
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
7435
|
def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
|
|
7402
7436
|
"""gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
|
|
7403
7437
|
ip = data.get("implementationPlanning")
|
|
@@ -8628,14 +8662,83 @@ _DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
|
|
|
8628
8662
|
_DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
|
|
8629
8663
|
|
|
8630
8664
|
|
|
8665
|
+
def _carried_decision_map(
|
|
8666
|
+
run_manifest: Mapping[str, Any] | None,
|
|
8667
|
+
*,
|
|
8668
|
+
project_root: Path | None,
|
|
8669
|
+
report_path: Path,
|
|
8670
|
+
) -> dict[str, dict]:
|
|
8671
|
+
"""이전 런에서 carry 한 결정. active `clarificationItems` 에 다시 올리지 않는다."""
|
|
8672
|
+
raw = (run_manifest or {}).get("approvalDecisionsPath")
|
|
8673
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
8674
|
+
return {}
|
|
8675
|
+
path = Path(raw.strip())
|
|
8676
|
+
if not path.is_absolute():
|
|
8677
|
+
root = project_root or _project_root_from_report(report_path)
|
|
8678
|
+
path = root / path
|
|
8679
|
+
if not path.is_file():
|
|
8680
|
+
return {}
|
|
8681
|
+
try:
|
|
8682
|
+
ledger = json.loads(path.read_text(encoding="utf-8"))
|
|
8683
|
+
except (OSError, json.JSONDecodeError):
|
|
8684
|
+
return {}
|
|
8685
|
+
if not isinstance(ledger, dict):
|
|
8686
|
+
return {}
|
|
8687
|
+
carried: dict[str, dict] = {}
|
|
8688
|
+
for row in ledger.get("carriedDecisions") or []:
|
|
8689
|
+
if not isinstance(row, dict):
|
|
8690
|
+
continue
|
|
8691
|
+
decision = row.get("decision")
|
|
8692
|
+
if not isinstance(decision, dict):
|
|
8693
|
+
continue
|
|
8694
|
+
cid = decision.get("id")
|
|
8695
|
+
if isinstance(cid, str) and cid:
|
|
8696
|
+
carried[cid] = decision
|
|
8697
|
+
return carried
|
|
8698
|
+
|
|
8699
|
+
|
|
8700
|
+
def _deviation_target(
|
|
8701
|
+
ref: str,
|
|
8702
|
+
clarifications: dict,
|
|
8703
|
+
decisions: dict,
|
|
8704
|
+
carried: dict,
|
|
8705
|
+
) -> dict | None:
|
|
8706
|
+
if ref.startswith("C-"):
|
|
8707
|
+
row = clarifications.get(ref) or carried.get(ref)
|
|
8708
|
+
return row if isinstance(row, dict) else None
|
|
8709
|
+
row = decisions.get(ref)
|
|
8710
|
+
return row if isinstance(row, dict) else None
|
|
8711
|
+
|
|
8712
|
+
|
|
8713
|
+
def _deviation_is_user_confirmed(
|
|
8714
|
+
ref: str, clarifications: dict, carried: dict,
|
|
8715
|
+
) -> bool:
|
|
8716
|
+
row = clarifications.get(ref)
|
|
8717
|
+
if (
|
|
8718
|
+
isinstance(row, dict)
|
|
8719
|
+
and row.get("status") in {"answered", "resolved"}
|
|
8720
|
+
and str(row.get("userInput") or "").strip()
|
|
8721
|
+
):
|
|
8722
|
+
return True
|
|
8723
|
+
carried_row = carried.get(ref)
|
|
8724
|
+
if not isinstance(carried_row, dict):
|
|
8725
|
+
return False
|
|
8726
|
+
resolution = carried_row.get("resolutionInput")
|
|
8727
|
+
if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
|
|
8728
|
+
return True
|
|
8729
|
+
return bool(str(carried_row.get("userConfirmation") or "").strip())
|
|
8730
|
+
|
|
8731
|
+
|
|
8631
8732
|
def _resolved_deviation_refs(
|
|
8632
8733
|
row_id: str,
|
|
8633
8734
|
refs: object,
|
|
8634
8735
|
clarifications: dict,
|
|
8635
8736
|
decisions: dict,
|
|
8636
8737
|
failures: list[str],
|
|
8738
|
+
carried: dict | None = None,
|
|
8637
8739
|
) -> list[str]:
|
|
8638
8740
|
valid_refs: list[str] = []
|
|
8741
|
+
carried_rows = carried or {}
|
|
8639
8742
|
for ref in refs if isinstance(refs, list) else []:
|
|
8640
8743
|
if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
|
|
8641
8744
|
failures.append(
|
|
@@ -8643,12 +8746,7 @@ def _resolved_deviation_refs(
|
|
|
8643
8746
|
f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
|
|
8644
8747
|
)
|
|
8645
8748
|
continue
|
|
8646
|
-
|
|
8647
|
-
clarifications.get(ref)
|
|
8648
|
-
if ref.startswith("C-")
|
|
8649
|
-
else decisions.get(ref)
|
|
8650
|
-
)
|
|
8651
|
-
if target is None:
|
|
8749
|
+
if _deviation_target(ref, clarifications, decisions, carried_rows) is None:
|
|
8652
8750
|
failures.append(
|
|
8653
8751
|
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
8654
8752
|
f"decisionRef `{ref}` does not exist in this report."
|
|
@@ -8664,12 +8762,13 @@ def _validate_deviation_disposition(
|
|
|
8664
8762
|
refs: list[str],
|
|
8665
8763
|
clarifications: dict,
|
|
8666
8764
|
failures: list[str],
|
|
8765
|
+
carried: dict | None = None,
|
|
8667
8766
|
) -> None:
|
|
8767
|
+
carried_rows = carried or {}
|
|
8668
8768
|
if disposition == "accepted":
|
|
8669
8769
|
confirmed = any(
|
|
8670
8770
|
ref.startswith("C-")
|
|
8671
|
-
and
|
|
8672
|
-
and bool(str(clarifications[ref].get("userInput") or "").strip())
|
|
8771
|
+
and _deviation_is_user_confirmed(ref, clarifications, carried_rows)
|
|
8673
8772
|
for ref in refs
|
|
8674
8773
|
)
|
|
8675
8774
|
if not confirmed:
|
|
@@ -8688,7 +8787,9 @@ def _validate_deviation_disposition(
|
|
|
8688
8787
|
if blocked is None:
|
|
8689
8788
|
return
|
|
8690
8789
|
clarification_id = blocked.group(1)
|
|
8691
|
-
clarification = clarifications.get(clarification_id)
|
|
8790
|
+
clarification = clarifications.get(clarification_id) or carried_rows.get(
|
|
8791
|
+
clarification_id
|
|
8792
|
+
)
|
|
8692
8793
|
if clarification is None:
|
|
8693
8794
|
failures.append(
|
|
8694
8795
|
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
@@ -8706,7 +8807,9 @@ def _validate_deviation_disposition(
|
|
|
8706
8807
|
)
|
|
8707
8808
|
|
|
8708
8809
|
|
|
8709
|
-
def _validate_requirement_deviations(
|
|
8810
|
+
def _validate_requirement_deviations(
|
|
8811
|
+
data: dict, failures: list[str], *, carried: dict | None = None,
|
|
8812
|
+
) -> None:
|
|
8710
8813
|
"""Require documented deviations to reference real decisions and approval."""
|
|
8711
8814
|
planning = data.get("implementationPlanning")
|
|
8712
8815
|
if not isinstance(planning, dict):
|
|
@@ -8721,15 +8824,26 @@ def _validate_requirement_deviations(data: dict, failures: list[str]) -> None:
|
|
|
8721
8824
|
for row in (planning.get("decisionDrafts") or [])
|
|
8722
8825
|
if isinstance(row, dict) and row.get("number")
|
|
8723
8826
|
}
|
|
8827
|
+
carried_rows = carried or {}
|
|
8724
8828
|
for row in planning.get("requirementCoverage") or []:
|
|
8725
8829
|
if not isinstance(row, dict) or row.get("status") != "documented-deviation":
|
|
8726
8830
|
continue
|
|
8727
8831
|
row_id = row.get("id") or "<row>"
|
|
8728
8832
|
refs = _resolved_deviation_refs(
|
|
8729
|
-
row_id,
|
|
8833
|
+
row_id,
|
|
8834
|
+
row.get("decisionRefs"),
|
|
8835
|
+
clarifications,
|
|
8836
|
+
decisions,
|
|
8837
|
+
failures,
|
|
8838
|
+
carried_rows,
|
|
8730
8839
|
)
|
|
8731
8840
|
_validate_deviation_disposition(
|
|
8732
|
-
row_id,
|
|
8841
|
+
row_id,
|
|
8842
|
+
row.get("approvalDisposition"),
|
|
8843
|
+
refs,
|
|
8844
|
+
clarifications,
|
|
8845
|
+
failures,
|
|
8846
|
+
carried_rows,
|
|
8733
8847
|
)
|
|
8734
8848
|
|
|
8735
8849
|
|
|
@@ -9518,15 +9632,37 @@ def _validate_convergence_rounds_match_manifest(
|
|
|
9518
9632
|
)
|
|
9519
9633
|
|
|
9520
9634
|
|
|
9521
|
-
def _validate_convergence_states(
|
|
9522
|
-
|
|
9635
|
+
def _validate_convergence_states(
|
|
9636
|
+
run_dir, failures, run_manifest: dict | None = None, project_root: Path | None = None,
|
|
9637
|
+
) -> None:
|
|
9638
|
+
"""이번 런이 가리키는 수렴 상태만 본다. 디렉터리의 옛 seq 파일은 건너뛴다.
|
|
9639
|
+
|
|
9640
|
+
매니페스트가 경로를 채워도 파일이 없으면 검사하지 않는다. render-only 와
|
|
9641
|
+
workflow 픽스처는 수렴을 돌리지 않아 파일이 없고, 없는 파일을 실패로 치면
|
|
9642
|
+
예전 glob 이 빈 결과를 내던 계약이 깨진다. 목적은 현재 런이 아닌 seq 를
|
|
9643
|
+
보지 않는 것이다.
|
|
9644
|
+
"""
|
|
9523
9645
|
from pathlib import Path as _Path
|
|
9524
9646
|
|
|
9525
|
-
|
|
9526
|
-
if
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9647
|
+
declared = (run_manifest or {}).get("convergenceStatePath")
|
|
9648
|
+
if isinstance(declared, str) and declared.strip():
|
|
9649
|
+
path = _Path(declared.strip())
|
|
9650
|
+
if not path.is_absolute():
|
|
9651
|
+
if project_root is None:
|
|
9652
|
+
return
|
|
9653
|
+
path = project_root / path
|
|
9654
|
+
paths = [path] if path.is_file() else []
|
|
9655
|
+
else:
|
|
9656
|
+
state_dir = _Path(run_dir) / "state"
|
|
9657
|
+
if not state_dir.is_dir():
|
|
9658
|
+
return
|
|
9659
|
+
paths = [
|
|
9660
|
+
state_path
|
|
9661
|
+
for state_path in sorted(state_dir.glob("convergence-*.json"))
|
|
9662
|
+
if not state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES)
|
|
9663
|
+
]
|
|
9664
|
+
for state_path in paths:
|
|
9665
|
+
if not state_path.is_file():
|
|
9530
9666
|
continue
|
|
9531
9667
|
try:
|
|
9532
9668
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
@@ -10355,7 +10491,9 @@ def main() -> int:
|
|
|
10355
10491
|
run_dir = report_path.parent.parent
|
|
10356
10492
|
_validate_requirements_discovery_fanout(run_dir, failures, brief_path)
|
|
10357
10493
|
# Phase-agnostic: convergence runs in every finding-producing phase.
|
|
10358
|
-
_validate_convergence_states(
|
|
10494
|
+
_validate_convergence_states(
|
|
10495
|
+
report_path.parent.parent, failures, run_manifest, project_root
|
|
10496
|
+
)
|
|
10359
10497
|
_validate_convergence_rounds_match_manifest(
|
|
10360
10498
|
report_path.parent.parent, run_manifest, failures
|
|
10361
10499
|
)
|