okstra 0.183.2 → 0.184.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 (33) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture.md +2 -2
  3. package/docs/cli.md +7 -4
  4. package/docs/project-structure-overview.md +1 -1
  5. package/docs/task-process/README.md +1 -1
  6. package/docs/task-process/common-flow.md +2 -3
  7. package/docs/task-process/error-analysis.md +3 -4
  8. package/docs/task-process/final-verification.md +2 -3
  9. package/docs/task-process/implementation-planning.md +2 -3
  10. package/docs/task-process/implementation.md +2 -3
  11. package/docs/task-process/release-handoff.md +3 -4
  12. package/docs/task-process/requirements-discovery.md +3 -4
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/prompts/launch.template.md +8 -7
  16. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  17. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  18. package/runtime/prompts/lead/report-writer.md +3 -3
  19. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  21. package/runtime/prompts/profiles/implementation-planning.md +9 -11
  22. package/runtime/prompts/wizard/prompts.ko.json +7 -10
  23. package/runtime/python/okstra_ctl/conformance.py +37 -1
  24. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  25. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  26. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  27. package/runtime/python/okstra_ctl/render.py +4 -0
  28. package/runtime/python/okstra_ctl/wizard.py +39 -73
  29. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  30. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  31. package/runtime/skills/okstra-run/SKILL.md +2 -2
  32. package/runtime/validators/validate-run.py +164 -66
  33. 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,
@@ -2167,14 +2172,11 @@ def _planning_conformance_declarations(
2167
2172
 
2168
2173
 
2169
2174
  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:`.
2175
+ """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식만 본다.
2176
+
2177
+ 스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
2178
+ 선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
2179
+ `conformanceTests` 는 여전히 실패한다.
2178
2180
  """
2179
2181
  data_path = report_path.with_suffix(".data.json")
2180
2182
  if not data_path.is_file():
@@ -2186,28 +2188,7 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
2186
2188
  ip = data.get("implementationPlanning")
2187
2189
  if not isinstance(ip, dict):
2188
2190
  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
- )
2191
+ _planning_conformance_declarations(ip.get("stages"), failures)
2211
2192
 
2212
2193
 
2213
2194
  def _validate_conformance_surfaces(
@@ -2280,6 +2261,12 @@ def _validate_conformance(
2280
2261
  )
2281
2262
  if declared_manifest is None:
2282
2263
  return warnings
2264
+ if declared_manifest is not None:
2265
+ scoped_declared = _scope_manifest_entries(declared_manifest, stage_name)
2266
+ for error in missing_declared_scripts(
2267
+ scoped_declared.get("entries"), task_root
2268
+ ):
2269
+ failures.append(f"conformance gate BLOCKING: {error}")
2283
2270
  if not manifest_path.is_file():
2284
2271
  empty_scoped_manifest = {"entries": []}
2285
2272
  if declared_manifest is not None:
@@ -3894,9 +3881,11 @@ def _classify_plan_item_gate(item: dict) -> str:
3894
3881
  """Recompute one plan item's gate class from its per-worker verdicts,
3895
3882
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
3896
3883
  ``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.
3884
+ ``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
3885
+ (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
3886
+ ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
3887
+ advisory-only, rollback items, and a single-vote kind that lost its
3888
+ reproduction.
3900
3889
  """
3901
3890
  tokens = [
3902
3891
  (
@@ -3967,6 +3956,18 @@ def _classify_plan_item_gate(item: dict) -> str:
3967
3956
  if _max_verdict_round(item) >= _TIE_SETTLED_ROUND:
3968
3957
  return "majority-disagree"
3969
3958
  return "needs-reverify"
3959
+ if (
3960
+ len(non_error) >= 2
3961
+ and blocking_disagree
3962
+ and (
3963
+ not (blocking_kinds & single_vote_kinds)
3964
+ or _is_variation_point_item(item)
3965
+ )
3966
+ ):
3967
+ # 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
3968
+ # 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
3969
+ # 근거를 잃었으므로 이 분기에 안 들어온다.
3970
+ return "majority-disagree"
3970
3971
  return "has-dissent"
3971
3972
 
3972
3973
 
@@ -4057,11 +4058,10 @@ def _self_fix_budget_exhausted(pbv: dict) -> bool:
4057
4058
  def _state_classification(item: dict, gate_class: str) -> str:
4058
4059
  """This item's `planItems[].rounds[].classification` for the state file.
4059
4060
 
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.
4061
+ Blocking-kind `dissent-isolated` / `partial-consensus` is already
4062
+ `majority-disagree` at the gate. `has-dissent` that remains is advisory
4063
+ or a single-vote kind that lost reproduction; the state file then splits
4064
+ that remainder into `dissent-isolated` vs `partial-consensus`.
4065
4065
 
4066
4066
  *gate_class* is passed in rather than recomputed so that the caller's
4067
4067
  effective classification — which may have been downgraded by
@@ -4135,9 +4135,6 @@ def _is_dissent_downgraded(
4135
4135
  )
4136
4136
 
4137
4137
 
4138
- _STARTABLE_STAGE_STATUSES = frozenset({"ready", "active"})
4139
-
4140
-
4141
4138
  def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4142
4139
  """Whether this item has standing to block the stage about to start.
4143
4140
 
@@ -4154,21 +4151,14 @@ def _stage_scope_bucket(item: dict, pbv: dict) -> str:
4154
4151
  `stageScope` belongs to the plan as a whole — `P-Opt-*` and `P-Var-*` live
4155
4152
  there permanently, and scoping them out would stop an unrequested-work
4156
4153
  verdict from blocking a start.
4154
+
4155
+ 디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
4156
+ 항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
4157
4157
  """
4158
4158
  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"
4159
+ return _item_stage_scope_bucket(
4160
+ item, ledger if isinstance(ledger, dict) else None,
4161
+ )
4172
4162
 
4173
4163
 
4174
4164
  def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
@@ -4244,11 +4234,19 @@ def _recompute_plan_body_gate(
4244
4234
  _plan_item_gate_class(it, pbv, accepted)
4245
4235
  for it in (pbv.get("planItems") or [])
4246
4236
  if isinstance(it, dict)
4237
+ and (
4238
+ _stage_scope_bucket(it, pbv) == "in-scope"
4239
+ or it.get("verdicts")
4240
+ )
4247
4241
  ]
4248
4242
  if not classes:
4249
4243
  return None
4250
4244
  if all(c == "all-non-result" for c in classes):
4251
4245
  return "aborted-non-result"
4246
+ if pbv.get("gating") is False:
4247
+ if any(c in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result") for c in classes):
4248
+ return "passed-with-dissent"
4249
+ return "passed"
4252
4250
  if any(c == "majority-disagree" for c in classes):
4253
4251
  return "blocked-by-disagreement"
4254
4252
  if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
@@ -4433,6 +4431,10 @@ def _gate_blocking_causes(
4433
4431
  """Which inputs actually block approval, as `gateBlockedBy` enum values."""
4434
4432
  causes = set()
4435
4433
  recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
4434
+ if pbv.get("gating") is False:
4435
+ if recomputed == "aborted-non-result":
4436
+ causes.add("non-result")
4437
+ return causes
4436
4438
  if recomputed == "blocked-by-disagreement":
4437
4439
  causes.add("majority-disagree")
4438
4440
  elif recomputed == "aborted-non-result":
@@ -6086,16 +6088,16 @@ def _validate_approval_clarification_backtrace(
6086
6088
  direction — a majority-disagree plan item must cite a `blocks: approval`
6087
6089
  row. Nothing walked this way, so a row could withhold approval while
6088
6090
  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.
6091
+ `incremental-scope` resolves impacted stages from these links and will not
6092
+ silently narrow past an id that traces to no stage, so this report fails
6093
+ rather than forcing a full re-run.
6092
6094
 
6093
6095
  The link must also *resolve to a stage*, which is the thing the re-run
6094
6096
  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.
6097
+ gate while the next re-run still could not place the answer: `P-Req-*`
6098
+ and `P-Val-*` ids are numbered by position in their own array, so they
6099
+ carry no stage, and a blocked coverage row whose `coveredBy` is prose
6100
+ cites none either.
6099
6101
  """
6100
6102
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
6101
6103
  return
@@ -6117,8 +6119,9 @@ def _validate_approval_clarification_backtrace(
6117
6119
  "but has no back-trace into the plan — no plan item carries it as "
6118
6120
  "`clarificationId`, and no requirement-coverage row is `blocked "
6119
6121
  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."
6122
+ "withholds approval without recording what it affects cannot "
6123
+ "place the next re-run's scope; this report fails rather than "
6124
+ "forcing a full re-run."
6122
6125
  )
6123
6126
  continue
6124
6127
  if stages_for_clarification(data, row_id):
@@ -6131,8 +6134,8 @@ def _validate_approval_clarification_backtrace(
6131
6134
  f"blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` id "
6132
6135
  "carries no stage number, so a row linked only that way must cite "
6133
6136
  "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."
6137
+ "no stage cannot auto-narrow the next re-run; this report fails "
6138
+ "rather than forcing a full re-run."
6136
6139
  )
6137
6140
 
6138
6141
 
@@ -7098,7 +7101,10 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
7098
7101
  round_count = pbv.get("roundCount")
7099
7102
  if not isinstance(round_count, int) or round_count < 1:
7100
7103
  return
7101
- items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
7104
+ items = [
7105
+ it for it in (pbv.get("planItems") or [])
7106
+ if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
7107
+ ]
7102
7108
  if not items:
7103
7109
  return
7104
7110
  empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
@@ -7178,6 +7184,85 @@ def _validate_unresolved_tie_was_reverified(
7178
7184
  )
7179
7185
 
7180
7186
 
7187
+ def _validate_tie_received_extra_vote(
7188
+ data: dict,
7189
+ failures: list[str],
7190
+ ) -> None:
7191
+ """동수는 같은 둘을 다시 돌리는 것이 아니라 세 번째 표로 가른다."""
7192
+ ip = data.get("implementationPlanning")
7193
+ if not isinstance(ip, dict):
7194
+ return
7195
+ pbv = ip.get("planBodyVerification")
7196
+ if not isinstance(pbv, dict):
7197
+ return
7198
+ missing = sorted({
7199
+ str(item.get("id") or "").strip()
7200
+ for item in pbv.get("planItems") or []
7201
+ if isinstance(item, dict)
7202
+ and not item.get("carriedForwardFromSeq")
7203
+ and _is_even_blocking_split(item)
7204
+ and _distinct_verdict_workers(item) < 3
7205
+ })
7206
+ if not missing:
7207
+ return
7208
+ failures.append(
7209
+ f"final-report data.json: plan item(s) {missing} carry an even split "
7210
+ "on a blocking breakage kind and have no third vote. Re-running the "
7211
+ "original two does not settle a 1-1 split. Dispatch one extra analyser "
7212
+ "whose prompt is those items only (`okstra plan-items prepare "
7213
+ "--tie-vote`) and record the vote with `okstra plan-items "
7214
+ "apply-verdicts --append --round 2`."
7215
+ )
7216
+
7217
+
7218
+ def _is_even_blocking_split(item: dict) -> bool:
7219
+ """라운드 승격 없이 차단 kind 의 짝수 분할인지."""
7220
+ forced = {
7221
+ **item,
7222
+ "verdicts": [
7223
+ {**row, "round": 1}
7224
+ for row in (item.get("verdicts") or [])
7225
+ if isinstance(row, dict)
7226
+ ],
7227
+ }
7228
+ return _is_unsettled_tie(forced)
7229
+
7230
+
7231
+ def _distinct_verdict_workers(item: dict) -> int:
7232
+ return len({
7233
+ str(row.get("worker") or "")
7234
+ for row in (item.get("verdicts") or [])
7235
+ if isinstance(row, dict) and str(row.get("worker") or "").strip()
7236
+ })
7237
+
7238
+
7239
+ def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
7240
+ """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
7241
+ ip = data.get("implementationPlanning")
7242
+ if not isinstance(ip, dict):
7243
+ return
7244
+ pbv = ip.get("planBodyVerification")
7245
+ if not isinstance(pbv, dict) or pbv.get("gating") is not False:
7246
+ return
7247
+ facts = ip.get("designPreparation") is not None or ip.get("stageMap") or ip.get("stages")
7248
+ if facts and not advisory_plan_body_gating(ip):
7249
+ failures.append(
7250
+ "final-report data.json: implementationPlanning.planBodyVerification "
7251
+ "`gating` is false, but that is only legal when "
7252
+ "designPreparation.mode is `no-design-inputs` (empty items) and the "
7253
+ "Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
7254
+ "non-empty designPreparation items keep the gating contract."
7255
+ )
7256
+ applied = pbv.get("selfFixRoundsApplied")
7257
+ if isinstance(applied, int) and applied > 0:
7258
+ failures.append(
7259
+ "final-report data.json: implementationPlanning.planBodyVerification "
7260
+ "`gating` is false, so the self-fix loop must not run "
7261
+ f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
7262
+ "verification round."
7263
+ )
7264
+
7265
+
7181
7266
  def _validate_verdict_rounds_outlive_self_fix(
7182
7267
  data: dict,
7183
7268
  failures: list[str],
@@ -7219,7 +7304,18 @@ def _validate_verdict_rounds_outlive_self_fix(
7219
7304
  for item in pbv.get("planItems") or []:
7220
7305
  if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
7221
7306
  continue
7307
+ if _stage_scope_bucket(item, pbv) != "in-scope":
7308
+ continue
7222
7309
  item_id = str(item.get("id") or "").strip()
7310
+ verified = item.get("verifiedContentHash")
7311
+ current = item.get("contentHash")
7312
+ if (
7313
+ isinstance(verified, str)
7314
+ and isinstance(current, str)
7315
+ and verified == current
7316
+ ):
7317
+ # 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
7318
+ continue
7223
7319
  for verdict in item.get("verdicts") or []:
7224
7320
  if not isinstance(verdict, dict):
7225
7321
  continue
@@ -8291,6 +8387,8 @@ def validate_plan_body_section(
8291
8387
  _validate_set_aside_register(data, failures, accepted_item_ids)
8292
8388
  _validate_verdict_rounds_outlive_self_fix(data, failures)
8293
8389
  _validate_unresolved_tie_was_reverified(data, failures)
8390
+ _validate_tie_received_extra_vote(data, failures)
8391
+ _validate_advisory_plan_body_gating(data, failures)
8294
8392
  _validate_plan_item_extraction_completeness(data, failures)
8295
8393
  _validate_plan_item_subject_substance(data, failures)
8296
8394
  _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 "