okstra 0.183.2 → 0.185.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +385 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +10 -8
  10. package/docs/cli.md +9 -5
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +28 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +2 -2
  18. package/docs/task-process/common-flow.md +2 -3
  19. package/docs/task-process/error-analysis.md +3 -4
  20. package/docs/task-process/final-verification.md +2 -3
  21. package/docs/task-process/implementation-planning.md +3 -4
  22. package/docs/task-process/implementation.md +2 -3
  23. package/docs/task-process/release-handoff.md +3 -4
  24. package/docs/task-process/requirements-discovery.md +3 -4
  25. package/package.json +1 -1
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/prompts/launch.template.md +8 -7
  28. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  29. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  30. package/runtime/prompts/lead/report-writer.md +4 -4
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  32. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  33. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  34. package/runtime/prompts/profiles/implementation-planning.md +11 -12
  35. package/runtime/prompts/wizard/prompts.ko.json +9 -10
  36. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/conformance.py +37 -1
  40. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  41. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  42. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  43. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  44. package/runtime/python/okstra_ctl/render.py +4 -0
  45. package/runtime/python/okstra_ctl/user_response.py +147 -37
  46. package/runtime/python/okstra_ctl/wizard.py +52 -73
  47. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  48. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  49. package/runtime/skills/okstra-chat/SKILL.md +104 -0
  50. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  51. package/runtime/skills/okstra-run/SKILL.md +4 -4
  52. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  53. package/runtime/validators/validate-run.py +254 -81
  54. package/runtime/validators/validate_session_conformance.py +24 -5
@@ -46,6 +46,7 @@ from okstra_ctl.conformance import ( # noqa: E402
46
46
  detect_surfaces,
47
47
  evaluate_conformance,
48
48
  manifest_required_surfaces,
49
+ missing_declared_scripts,
49
50
  normalize_conformance_script as _normalize_conformance_script,
50
51
  parse_conformance_tests as _parse_conformance_tests,
51
52
  qa_result_from_dict,
@@ -78,6 +79,10 @@ from okstra_ctl.report_translation import ( # noqa: E402
78
79
  hangul_share,
79
80
  )
80
81
  from okstra_ctl.stage_citations import enumerated_stage_numbers # noqa: E402
82
+ from okstra_ctl.plan_items import ( # noqa: E402
83
+ advisory_plan_body_gating,
84
+ stage_scope_bucket as _item_stage_scope_bucket,
85
+ )
81
86
  from okstra_ctl.incremental_scope import ( # noqa: E402
82
87
  coverage_row_blocked_on,
83
88
  stages_for_clarification,
@@ -637,6 +642,35 @@ def write_json(path: Path, payload: dict) -> None:
637
642
  path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
638
643
 
639
644
 
645
+ def _report_already_approved(report_data: Mapping[str, Any] | None) -> bool:
646
+ if not isinstance(report_data, Mapping):
647
+ return False
648
+ frontmatter = report_data.get("frontmatter")
649
+ return isinstance(frontmatter, Mapping) and frontmatter.get("approved") is True
650
+
651
+
652
+ def _derive_awaiting_approval(
653
+ *,
654
+ existing: bool,
655
+ validation_status: str,
656
+ current_phase: str,
657
+ pointer: Mapping[str, str],
658
+ report_data: Mapping[str, Any] | None,
659
+ ) -> bool:
660
+ """planning 이 승인 가능한 plan-ready 를 남기면 올리고, implementation
661
+ 이 그 승인을 소비하면 내린다. 차단 게이트나 열린 Blocks=approval 은
662
+ 포인터가 blocked 라 올리지 않는다."""
663
+ if validation_status == "passed" and current_phase == "implementation":
664
+ return False
665
+ if validation_status == "passed" and current_phase == "implementation-planning":
666
+ return (
667
+ pointer.get("phase") == "implementation"
668
+ and pointer.get("status") == next_phase.STATUS_READY
669
+ and not _report_already_approved(report_data)
670
+ )
671
+ return existing
672
+
673
+
640
674
  def update_workflow_metadata(
641
675
  run_manifest: dict,
642
676
  task_manifest: dict,
@@ -688,7 +722,8 @@ def update_workflow_metadata(
688
722
  next_recommended_phase = next_phase.make(
689
723
  phase=projected["phase"],
690
724
  status=projected["status"],
691
- rationale=(
725
+ rationale=projected["rationale"]
726
+ or (
692
727
  "리포트 라우팅에서 투영됨. 리드가 쓴 값과 근거는 "
693
728
  "nextRecommendedPhaseCorrection.authored 에 있다."
694
729
  ),
@@ -708,16 +743,16 @@ def update_workflow_metadata(
708
743
  status=next_phase.STATUS_BLOCKED, rationale=authored["rationale"]
709
744
  )
710
745
 
711
- awaiting_approval = workflow.get("awaitingApproval")
712
- if not isinstance(awaiting_approval, bool):
713
- awaiting_approval = False
714
- # 승인 게이트(`frontmatter approved`)는 implementation 진입 직전에 한 번만 의미를 가진다.
715
- # implementation run 이 검증을 통과했다는 것은 `_validate_approved_plan` 이 이미 사용자
716
- # 승인 플래그(frontmatter `approved: true`)를 소비했다는 뜻이므로, 이 시점에
717
- # awaitingApproval 플래그를 명시적으로 내려 다음 phase 의 status 뷰에서 stale 상태로
718
- # 남지 않게 한다.
719
- if validation_status == "passed" and current_phase == "implementation":
720
- awaiting_approval = False
746
+ awaiting_existing = workflow.get("awaitingApproval")
747
+ if not isinstance(awaiting_existing, bool):
748
+ awaiting_existing = False
749
+ awaiting_approval = _derive_awaiting_approval(
750
+ existing=awaiting_existing,
751
+ validation_status=validation_status,
752
+ current_phase=current_phase,
753
+ pointer=next_recommended_phase,
754
+ report_data=report_data,
755
+ )
721
756
 
722
757
  last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
723
758
  if not isinstance(last_safe_checkpoint, dict):
@@ -2167,14 +2202,11 @@ def _planning_conformance_declarations(
2167
2202
 
2168
2203
 
2169
2204
  def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
2170
- """H4-c at planning time, every stage that DECLARES `Conformance tests:`
2171
- must already carry a matching entry in the shared task-level
2172
- `qa/conformance-manifest.json`. The profile mandates writing the script +
2173
- manifest entry as part of emitting that line
2174
- (implementation-planning.md:83/85); without this check a declaration that
2175
- was never materialized only surfaces much later at the `implementation`
2176
- entry gate. Matches by stageKey suffix (`-stage-<N>`) like
2177
- `_scope_manifest_entries`; skips stages that took a `Conformance exemption:`.
2205
+ """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식만 본다.
2206
+
2207
+ 스크립트 파일과 `runCommand` 매칭 implementation stage 만든다.
2208
+ 선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
2209
+ `conformanceTests` 여전히 실패한다.
2178
2210
  """
2179
2211
  data_path = report_path.with_suffix(".data.json")
2180
2212
  if not data_path.is_file():
@@ -2186,28 +2218,7 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
2186
2218
  ip = data.get("implementationPlanning")
2187
2219
  if not isinstance(ip, dict):
2188
2220
  return
2189
- declarations = _planning_conformance_declarations(ip.get("stages"), failures)
2190
- if not declarations:
2191
- return
2192
- task_root = _task_root_from_run_dir(report_path.parent.parent)
2193
- manifest_path = task_root / "qa" / "conformance-manifest.json"
2194
- entries = []
2195
- if manifest_path.is_file():
2196
- try:
2197
- manifest = json.loads(manifest_path.read_text())
2198
- entries = manifest.get("entries") or [] if isinstance(manifest, dict) else []
2199
- except (OSError, json.JSONDecodeError):
2200
- entries = []
2201
- for error in _declared_conformance_errors(
2202
- {"entries": declarations},
2203
- {"entries": entries},
2204
- None,
2205
- ):
2206
- failures.append(
2207
- f"final-report data.json: conformance declaration {error} in "
2208
- f"{manifest_path}. Emitting `Conformance tests:` MUST also write "
2209
- "one matching manifest entry (implementation-planning.md §Conformance)."
2210
- )
2221
+ _planning_conformance_declarations(ip.get("stages"), failures)
2211
2222
 
2212
2223
 
2213
2224
  def _validate_conformance_surfaces(
@@ -2280,6 +2291,12 @@ def _validate_conformance(
2280
2291
  )
2281
2292
  if declared_manifest is None:
2282
2293
  return warnings
2294
+ if declared_manifest is not None:
2295
+ scoped_declared = _scope_manifest_entries(declared_manifest, stage_name)
2296
+ for error in missing_declared_scripts(
2297
+ scoped_declared.get("entries"), task_root
2298
+ ):
2299
+ failures.append(f"conformance gate BLOCKING: {error}")
2283
2300
  if not manifest_path.is_file():
2284
2301
  empty_scoped_manifest = {"entries": []}
2285
2302
  if declared_manifest is not None:
@@ -3575,6 +3592,7 @@ def validate_final_report_data(
3575
3592
  _validate_clarification_evidence_note(data, failures)
3576
3593
  _validate_approval_clarification_backtrace(data, failures)
3577
3594
  _validate_rerun_guidance(data, failures)
3595
+ _validate_approval_guidance(data, failures)
3578
3596
  _validate_variation_point_analysis(
3579
3597
  (data.get("implementationPlanning") or {}).get("variationPointAnalysis"),
3580
3598
  resolve_architecture(_project_root_from_report(report_path)),
@@ -3894,9 +3912,11 @@ def _classify_plan_item_gate(item: dict) -> str:
3894
3912
  """Recompute one plan item's gate class from its per-worker verdicts,
3895
3913
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
3896
3914
  ``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
3897
- ``full-consensus`` / ``all-non-result``. Collapses ``partial-consensus`` and
3898
- ``dissent-isolated`` into ``has-dissent`` because they resolve to the
3899
- same gate value; only the majority-disagree boundary changes the gate.
3915
+ ``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
3916
+ (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
3917
+ ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
3918
+ advisory-only, rollback items, and a single-vote kind that lost its
3919
+ reproduction.
3900
3920
  """
3901
3921
  tokens = [
3902
3922
  (
@@ -3967,6 +3987,18 @@ def _classify_plan_item_gate(item: dict) -> str:
3967
3987
  if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
3968
3988
  return "majority-disagree"
3969
3989
  return "needs-reverify"
3990
+ if (
3991
+ len(non_error) >= 2
3992
+ and blocking_disagree
3993
+ and (
3994
+ not (blocking_kinds & single_vote_kinds)
3995
+ or _is_variation_point_item(item)
3996
+ )
3997
+ ):
3998
+ # 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
3999
+ # 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
4000
+ # 근거를 잃었으므로 이 분기에 안 들어온다.
4001
+ return "majority-disagree"
3970
4002
  return "has-dissent"
3971
4003
 
3972
4004
 
@@ -4057,11 +4089,10 @@ def _self_fix_budget_exhausted(pbv: dict) -> bool:
4057
4089
  def _state_classification(item: dict, gate_class: str) -> str:
4058
4090
  """This item's `planItems[].rounds[].classification` for the state file.
4059
4091
 
4060
- The gate classifier deliberately folds `partial-consensus` and
4061
- `dissent-isolated` into `has-dissent` only the majority-disagree boundary
4062
- moves the gate. The state file records the finer label, and the information
4063
- to recover it is in the same verdicts, so the mapping lives beside the
4064
- classifier rather than being re-invented by each lead.
4092
+ Blocking-kind `dissent-isolated` / `partial-consensus` is already
4093
+ `majority-disagree` at the gate. `has-dissent` that remains is advisory
4094
+ or a single-vote kind that lost reproduction; the state file then splits
4095
+ that remainder into `dissent-isolated` vs `partial-consensus`.
4065
4096
 
4066
4097
  *gate_class* is passed in rather than recomputed so that the caller's
4067
4098
  effective classification — which may have been downgraded by
@@ -4135,9 +4166,6 @@ def _is_dissent_downgraded(
4135
4166
  )
4136
4167
 
4137
4168
 
4138
- _STARTABLE_STAGE_STATUSES = frozenset({"ready", "active"})
4139
-
4140
-
4141
4169
  def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4142
4170
  """Whether this item has standing to block the stage about to start.
4143
4171
 
@@ -4154,21 +4182,14 @@ def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4154
4182
  `stageScope` belongs to the plan as a whole — `P-Opt-*` and `P-Var-*` live
4155
4183
  there permanently, and scoping them out would stop an unrequested-work
4156
4184
  verdict from blocking a start.
4185
+
4186
+ 디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
4187
+ 항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
4157
4188
  """
4158
4189
  ledger = pbv.get("stageLedger")
4159
- if not isinstance(ledger, dict) or not ledger:
4160
- return "in-scope"
4161
- scope = item.get("stageScope")
4162
- stages = [
4163
- value for value in scope
4164
- if isinstance(value, int) and not isinstance(value, bool)
4165
- ] if isinstance(scope, list) else []
4166
- if not stages:
4167
- return "in-scope"
4168
- statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
4169
- if statuses & _STARTABLE_STAGE_STATUSES:
4170
- return "in-scope"
4171
- return "observed" if "done" in statuses else "deferred"
4190
+ return _item_stage_scope_bucket(
4191
+ item, ledger if isinstance(ledger, dict) else None,
4192
+ )
4172
4193
 
4173
4194
 
4174
4195
  def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
@@ -4244,11 +4265,19 @@ def _recompute_plan_body_gate(
4244
4265
  _plan_item_gate_class(it, pbv, accepted)
4245
4266
  for it in (pbv.get("planItems") or [])
4246
4267
  if isinstance(it, dict)
4268
+ and (
4269
+ _stage_scope_bucket(it, pbv) == "in-scope"
4270
+ or it.get("verdicts")
4271
+ )
4247
4272
  ]
4248
4273
  if not classes:
4249
4274
  return None
4250
4275
  if all(c == "all-non-result" for c in classes):
4251
4276
  return "aborted-non-result"
4277
+ if pbv.get("gating") is False:
4278
+ if any(c in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result") for c in classes):
4279
+ return "passed-with-dissent"
4280
+ return "passed"
4252
4281
  if any(c == "majority-disagree" for c in classes):
4253
4282
  return "blocked-by-disagreement"
4254
4283
  if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
@@ -4433,6 +4462,10 @@ def _gate_blocking_causes(
4433
4462
  """Which inputs actually block approval, as `gateBlockedBy` enum values."""
4434
4463
  causes = set()
4435
4464
  recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
4465
+ if pbv.get("gating") is False:
4466
+ if recomputed == "aborted-non-result":
4467
+ causes.add("non-result")
4468
+ return causes
4436
4469
  if recomputed == "blocked-by-disagreement":
4437
4470
  causes.add("majority-disagree")
4438
4471
  elif recomputed == "aborted-non-result":
@@ -6086,16 +6119,16 @@ def _validate_approval_clarification_backtrace(
6086
6119
  direction — a majority-disagree plan item must cite a `blocks: approval`
6087
6120
  row. Nothing walked this way, so a row could withhold approval while
6088
6121
  recording no blast radius at all. The cost lands on the re-run:
6089
- `incremental-scope` resolves impacted stages from these links and treats an
6090
- id that traces to no stage as grounds to re-verify everything, so one
6091
- unlinked blocker turns a narrow re-run into a full one.
6122
+ `incremental-scope` resolves impacted stages from these links and will not
6123
+ silently narrow past an id that traces to no stage, so this report fails
6124
+ rather than forcing a full re-run.
6092
6125
 
6093
6126
  The link must also *resolve to a stage*, which is the thing the re-run
6094
6127
  actually reads. Checking only that a link exists let a row satisfy this
6095
- gate and still force full: `P-Req-*` and `P-Val-*` ids are numbered by
6096
- position in their own array, so they carry no stage, and a blocked
6097
- coverage row whose `coveredBy` is prose cites none either. Both shapes
6098
- passed while the re-run they were meant to narrow re-verified everything.
6128
+ gate while the next re-run still could not place the answer: `P-Req-*`
6129
+ and `P-Val-*` ids are numbered by position in their own array, so they
6130
+ carry no stage, and a blocked coverage row whose `coveredBy` is prose
6131
+ cites none either.
6099
6132
  """
6100
6133
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
6101
6134
  return
@@ -6117,8 +6150,9 @@ def _validate_approval_clarification_backtrace(
6117
6150
  "but has no back-trace into the plan — no plan item carries it as "
6118
6151
  "`clarificationId`, and no requirement-coverage row is `blocked "
6119
6152
  f"{row_id}` in its `status` or `approvalDisposition`. An item that "
6120
- "withholds approval without recording what it affects forces the "
6121
- "next re-run to re-verify everything."
6153
+ "withholds approval without recording what it affects cannot "
6154
+ "place the next re-run's scope; this report fails rather than "
6155
+ "forcing a full re-run."
6122
6156
  )
6123
6157
  continue
6124
6158
  if stages_for_clarification(data, row_id):
@@ -6131,12 +6165,13 @@ def _validate_approval_clarification_backtrace(
6131
6165
  f"blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` id "
6132
6166
  "carries no stage number, so a row linked only that way must cite "
6133
6167
  "the stage in `coveredBy`. A blocker whose blast radius resolves to "
6134
- "no stage costs exactly what an unlinked one does — the next re-run "
6135
- "re-verifies every stage."
6168
+ "no stage cannot auto-narrow the next re-run; this report fails "
6169
+ "rather than forcing a full re-run."
6136
6170
  )
6137
6171
 
6138
6172
 
6139
6173
  _RERUN_FLAG = "--answered-clarifications"
6174
+ _APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)
6140
6175
 
6141
6176
 
6142
6177
  def _next_step_texts(steps: object) -> list[str]:
@@ -6153,6 +6188,24 @@ def _next_step_texts(steps: object) -> list[str]:
6153
6188
  return texts
6154
6189
 
6155
6190
 
6191
+ def _has_blocks_approval_row(data: dict) -> bool:
6192
+ return any(
6193
+ isinstance(row, dict) and row.get("blocks") == "approval"
6194
+ for row in data.get("clarificationItems") or []
6195
+ )
6196
+
6197
+
6198
+ def _planning_gate_blocks_approval(data: dict) -> bool:
6199
+ planning = data.get("implementationPlanning")
6200
+ if not isinstance(planning, dict):
6201
+ return False
6202
+ verification = planning.get("planBodyVerification")
6203
+ if not isinstance(verification, dict):
6204
+ return False
6205
+ gate = str(verification.get("gateResult") or "").strip().lower()
6206
+ return gate in {"blocked-by-disagreement", "aborted-non-result"}
6207
+
6208
+
6156
6209
  def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
6157
6210
  """A report that withholds approval must say how to come back from it.
6158
6211
 
@@ -6165,10 +6218,7 @@ def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
6165
6218
  """
6166
6219
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
6167
6220
  return
6168
- has_blocker = any(
6169
- isinstance(row, dict) and row.get("blocks") == "approval"
6170
- for row in data.get("clarificationItems") or []
6171
- )
6221
+ has_blocker = _has_blocks_approval_row(data)
6172
6222
  if not has_blocker:
6173
6223
  return
6174
6224
  if any(_RERUN_FLAG in text for text in _next_step_texts(
@@ -6184,6 +6234,34 @@ def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
6184
6234
  )
6185
6235
 
6186
6236
 
6237
+ def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
6238
+ """승인 가능한 plan-ready 는 사용자에게 승인하라고 말해야 한다.
6239
+
6240
+ 포인터가 implementation/ready 여도 승인은 사용자만 뒤집는다. 다음 단계
6241
+ 안내가 계획 재실행이면 승인 칸을 건너뛰고 같은 단계를 다시 돈다.
6242
+ """
6243
+ if (data.get("header") or {}).get("taskType") != "implementation-planning":
6244
+ return
6245
+ planning = data.get("implementationPlanning")
6246
+ if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
6247
+ return
6248
+ if _has_blocks_approval_row(data) or _planning_gate_blocks_approval(data):
6249
+ return
6250
+ if _report_already_approved(data):
6251
+ return
6252
+ if any(_APPROVE_HINT.search(text) for text in _next_step_texts(
6253
+ data.get("recommendedNextSteps")
6254
+ )):
6255
+ return
6256
+ failures.append(
6257
+ "final-report data.json: this plan is ready for the user to approve, "
6258
+ "but no `recommendedNextSteps` entry tells the reader to approve — "
6259
+ "name `--approve` or the in-session wizard in a step's `text` or "
6260
+ "one of its `commands`. Do not recommend another "
6261
+ "implementation-planning run."
6262
+ )
6263
+
6264
+
6187
6265
  def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
6188
6266
  """A self-fix round must be instructed by cause, not as a flat item list.
6189
6267
 
@@ -7098,7 +7176,10 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
7098
7176
  round_count = pbv.get("roundCount")
7099
7177
  if not isinstance(round_count, int) or round_count < 1:
7100
7178
  return
7101
- items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
7179
+ items = [
7180
+ it for it in (pbv.get("planItems") or [])
7181
+ if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
7182
+ ]
7102
7183
  if not items:
7103
7184
  return
7104
7185
  empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
@@ -7178,6 +7259,85 @@ def _validate_unresolved_tie_was_reverified(
7178
7259
  )
7179
7260
 
7180
7261
 
7262
+ def _validate_tie_received_extra_vote(
7263
+ data: dict,
7264
+ failures: list[str],
7265
+ ) -> None:
7266
+ """동수는 같은 둘을 다시 돌리는 것이 아니라 세 번째 표로 가른다."""
7267
+ ip = data.get("implementationPlanning")
7268
+ if not isinstance(ip, dict):
7269
+ return
7270
+ pbv = ip.get("planBodyVerification")
7271
+ if not isinstance(pbv, dict):
7272
+ return
7273
+ missing = sorted({
7274
+ str(item.get("id") or "").strip()
7275
+ for item in pbv.get("planItems") or []
7276
+ if isinstance(item, dict)
7277
+ and not item.get("carriedForwardFromSeq")
7278
+ and _is_even_blocking_split(item)
7279
+ and _distinct_verdict_workers(item) < 3
7280
+ })
7281
+ if not missing:
7282
+ return
7283
+ failures.append(
7284
+ f"final-report data.json: plan item(s) {missing} carry an even split "
7285
+ "on a blocking breakage kind and have no third vote. Re-running the "
7286
+ "original two does not settle a 1-1 split. Dispatch one extra analyser "
7287
+ "whose prompt is those items only (`okstra plan-items prepare "
7288
+ "--tie-vote`) and record the vote with `okstra plan-items "
7289
+ "apply-verdicts --append --round 2`."
7290
+ )
7291
+
7292
+
7293
+ def _is_even_blocking_split(item: dict) -> bool:
7294
+ """라운드 승격 없이 차단 kind 의 짝수 분할인지."""
7295
+ forced = {
7296
+ **item,
7297
+ "verdicts": [
7298
+ {**row, "round": 1}
7299
+ for row in (item.get("verdicts") or [])
7300
+ if isinstance(row, dict)
7301
+ ],
7302
+ }
7303
+ return _is_unsettled_tie(forced)
7304
+
7305
+
7306
+ def _distinct_verdict_workers(item: dict) -> int:
7307
+ return len({
7308
+ str(row.get("worker") or "")
7309
+ for row in (item.get("verdicts") or [])
7310
+ if isinstance(row, dict) and str(row.get("worker") or "").strip()
7311
+ })
7312
+
7313
+
7314
+ def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
7315
+ """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
7316
+ ip = data.get("implementationPlanning")
7317
+ if not isinstance(ip, dict):
7318
+ return
7319
+ pbv = ip.get("planBodyVerification")
7320
+ if not isinstance(pbv, dict) or pbv.get("gating") is not False:
7321
+ return
7322
+ facts = ip.get("designPreparation") is not None or ip.get("stageMap") or ip.get("stages")
7323
+ if facts and not advisory_plan_body_gating(ip):
7324
+ failures.append(
7325
+ "final-report data.json: implementationPlanning.planBodyVerification "
7326
+ "`gating` is false, but that is only legal when "
7327
+ "designPreparation.mode is `no-design-inputs` (empty items) and the "
7328
+ "Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
7329
+ "non-empty designPreparation items keep the gating contract."
7330
+ )
7331
+ applied = pbv.get("selfFixRoundsApplied")
7332
+ if isinstance(applied, int) and applied > 0:
7333
+ failures.append(
7334
+ "final-report data.json: implementationPlanning.planBodyVerification "
7335
+ "`gating` is false, so the self-fix loop must not run "
7336
+ f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
7337
+ "verification round."
7338
+ )
7339
+
7340
+
7181
7341
  def _validate_verdict_rounds_outlive_self_fix(
7182
7342
  data: dict,
7183
7343
  failures: list[str],
@@ -7219,7 +7379,18 @@ def _validate_verdict_rounds_outlive_self_fix(
7219
7379
  for item in pbv.get("planItems") or []:
7220
7380
  if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
7221
7381
  continue
7382
+ if _stage_scope_bucket(item, pbv) != "in-scope":
7383
+ continue
7222
7384
  item_id = str(item.get("id") or "").strip()
7385
+ verified = item.get("verifiedContentHash")
7386
+ current = item.get("contentHash")
7387
+ if (
7388
+ isinstance(verified, str)
7389
+ and isinstance(current, str)
7390
+ and verified == current
7391
+ ):
7392
+ # 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
7393
+ continue
7223
7394
  for verdict in item.get("verdicts") or []:
7224
7395
  if not isinstance(verdict, dict):
7225
7396
  continue
@@ -8291,6 +8462,8 @@ def validate_plan_body_section(
8291
8462
  _validate_set_aside_register(data, failures, accepted_item_ids)
8292
8463
  _validate_verdict_rounds_outlive_self_fix(data, failures)
8293
8464
  _validate_unresolved_tie_was_reverified(data, failures)
8465
+ _validate_tie_received_extra_vote(data, failures)
8466
+ _validate_advisory_plan_body_gating(data, failures)
8294
8467
  _validate_plan_item_extraction_completeness(data, failures)
8295
8468
  _validate_plan_item_subject_substance(data, failures)
8296
8469
  _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
@@ -809,7 +809,7 @@ def _check_activity_worker_agents(
809
809
  )
810
810
 
811
811
 
812
- def _allowed_automatic_rounds(self_fix_rounds: int) -> int:
812
+ def _allowed_automatic_rounds(self_fix_rounds: int, *, gating: bool = True) -> int:
813
813
  """이 run 이 돌아도 되는 자동 plan-body 라운드 수.
814
814
 
815
815
  상한이 2 로 고정돼 있던 동안 다른 규칙과 정면으로 충돌했다.
@@ -825,25 +825,42 @@ def _allowed_automatic_rounds(self_fix_rounds: int) -> int:
825
825
  로 그 라운드에 실린다. 자가수정은 여기에 배치를 하나씩 더한다. 표적 재검증은
826
826
  기준선의 두 번째 배치가 받고, 늘어나는 것은 그 라운드가 덮지 못한 잔여 항목을
827
827
  일소하는 배치다.
828
+
829
+ ``gating=false`` 자문 경로는 추출과 1라운드만 남긴다. self-fix 와 일소
830
+ 배치는 돌리지 않는다.
828
831
  """
832
+ if not gating:
833
+ return 1
829
834
  return 2 + max(self_fix_rounds, 0)
830
835
 
831
836
 
832
- def _self_fix_rounds_applied(report_data: Mapping[str, Any]) -> int:
837
+ def _plan_body_verification(report_data: Mapping[str, Any]) -> Mapping[str, Any] | None:
833
838
  planning = report_data.get("implementationPlanning")
834
- plan_verification = (
839
+ verification = (
835
840
  planning.get("planBodyVerification")
836
841
  if isinstance(planning, Mapping)
837
842
  else None
838
843
  )
844
+ return verification if isinstance(verification, Mapping) else None
845
+
846
+
847
+ def _self_fix_rounds_applied(report_data: Mapping[str, Any]) -> int:
848
+ plan_verification = _plan_body_verification(report_data)
839
849
  value = (
840
850
  plan_verification.get("selfFixRoundsApplied")
841
- if isinstance(plan_verification, Mapping)
851
+ if plan_verification is not None
842
852
  else 0
843
853
  )
844
854
  return value if isinstance(value, int) and value >= 0 else 0
845
855
 
846
856
 
857
+ def _plan_body_gating(report_data: Mapping[str, Any]) -> bool:
858
+ plan_verification = _plan_body_verification(report_data)
859
+ if plan_verification is None:
860
+ return True
861
+ return plan_verification.get("gating") is not False
862
+
863
+
847
864
  def _matching_user_reverification_round(
848
865
  event: LeadEvent | None,
849
866
  clarification_id: str,
@@ -945,7 +962,9 @@ def _check_activity_round_counts(
945
962
  )
946
963
  automatic_rounds = verification_rounds - len(user_reverification_rounds)
947
964
  self_fix_rounds = _self_fix_rounds_applied(report_data)
948
- allowed_rounds = _allowed_automatic_rounds(self_fix_rounds)
965
+ allowed_rounds = _allowed_automatic_rounds(
966
+ self_fix_rounds, gating=_plan_body_gating(report_data),
967
+ )
949
968
  if automatic_rounds > allowed_rounds:
950
969
  errors.append(
951
970
  f"activity contract: at most {allowed_rounds} plan verification "