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.
Files changed (44) 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 +57 -3
  24. package/runtime/python/okstra_ctl/render_final_report.py +3 -1
  25. package/runtime/python/okstra_ctl/report_assembly.py +65 -3
  26. package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
  27. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  28. package/runtime/python/okstra_ctl/run.py +21 -6
  29. package/runtime/python/okstra_ctl/usage_cells.py +15 -0
  30. package/runtime/python/okstra_ctl/user_response.py +72 -6
  31. package/runtime/python/okstra_ctl/wizard.py +1 -5
  32. package/runtime/python/okstra_token_usage/codex.py +32 -3
  33. package/runtime/python/okstra_token_usage/collect.py +148 -15
  34. package/runtime/python/okstra_token_usage/grok.py +24 -5
  35. package/runtime/python/okstra_token_usage/report.py +12 -2
  36. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  37. package/runtime/templates/reports/html/assets/base.css +3 -9
  38. package/runtime/templates/reports/html/assets/base.js +0 -21
  39. package/runtime/templates/reports/html/base.template.html +1 -4
  40. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +9 -28
  41. package/runtime/validators/lib/runners.sh +5 -1
  42. package/runtime/validators/validate-report-views.py +2 -1
  43. package/runtime/validators/validate-run.py +240 -102
  44. 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
- if errors:
3496
- return data
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(data, failures)
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
- # A tie is not consensus, and until now it read as one. The majority test is
3985
- # strict, so an even panel splitting 1-AGREE / 1-DISAGREE on a blocking kind
3986
- # fell through to `has-dissent` and the gate passed — the dissent recorded
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 ""), clarification_disposition(row)
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 ""), clarification_disposition(row)
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
- rounds_applied = pbv.get("selfFixRoundsApplied", 0)
5525
- if isinstance(rounds_applied, int) and rounds_applied > 1:
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(data: dict, failures: list[str]) -> None:
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(data.get("clarificationItems"), APPROVAL_BLOCKS)
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 is sent back once before the gate is declared.
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`, and `needs-reverify` folds into
7307
- `passed-with-dissent` — which is correct for the shape it was built for (a
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
- f"final-report data.json: plan item(s) {unsettled} carry an even split "
7336
- "on a blocking breakage kind and were never re-verified. A tie is not "
7337
- "consensus: the gate's majority test is strict, so this split neither "
7338
- "blocks nor resolves, and declaring the gate on it passes a dissent "
7339
- "nobody settled. Re-dispatch those items in a plan-body round and "
7340
- "record the votes with `okstra plan-items apply-verdicts --data "
7341
- "<data.json> --verdicts <verdicts.json> --round 2`. A split that "
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 _distinct_verdict_workers(item) < 3
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 third vote. Re-running the "
7373
- "original two does not settle a 1-1 split. Dispatch one extra analyser "
7374
- "whose prompt is those items only (`okstra plan-items prepare "
7375
- "--tie-vote`) and record the vote with `okstra plan-items "
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
- target = (
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 clarifications[ref].get("status") in {"answered", "resolved"}
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(data: dict, failures: list[str]) -> None:
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, row.get("decisionRefs"), clarifications, decisions, failures
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, row.get("approvalDisposition"), refs, clarifications, failures
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(run_dir, failures) -> None:
9522
- """Replay shared engine invariants for every public final state artifact."""
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
- state_dir = _Path(run_dir) / "state"
9526
- if not state_dir.is_dir():
9527
- return
9528
- for state_path in sorted(state_dir.glob("convergence-*.json")):
9529
- if state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES):
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(report_path.parent.parent, failures)
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
  )