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.
- package/README.md +2 -2
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.d.mts +1 -0
- package/dist/commands/chat/chat.mjs +385 -0
- package/dist/commands/chat/chat.mjs.map +1 -0
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture.md +10 -8
- package/docs/cli.md +9 -5
- package/docs/for-ai/README.md +4 -2
- package/docs/for-ai/skills/okstra-chat.md +28 -0
- package/docs/for-ai/skills/okstra-inspect.md +1 -1
- package/docs/for-ai/skills/okstra-run.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +10 -8
- package/docs/project-structure-overview.md +6 -5
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +2 -3
- package/docs/task-process/error-analysis.md +3 -4
- package/docs/task-process/final-verification.md +2 -3
- package/docs/task-process/implementation-planning.md +3 -4
- package/docs/task-process/implementation.md +2 -3
- package/docs/task-process/release-handoff.md +3 -4
- package/docs/task-process/requirements-discovery.md +3 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +8 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +27 -19
- package/runtime/prompts/lead/report-writer.md +4 -4
- package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +11 -12
- package/runtime/prompts/wizard/prompts.ko.json +9 -10
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/conformance.py +37 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
- package/runtime/python/okstra_ctl/next_phase.py +67 -4
- package/runtime/python/okstra_ctl/plan_items.py +410 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
- package/runtime/python/okstra_ctl/render.py +4 -0
- package/runtime/python/okstra_ctl/user_response.py +147 -37
- package/runtime/python/okstra_ctl/wizard.py +52 -73
- package/runtime/schemas/final-report-v2.0.schema.json +12 -0
- package/runtime/schemas/final-report-v3.0.schema.json +12 -0
- package/runtime/skills/okstra-chat/SKILL.md +104 -0
- package/runtime/skills/okstra-inspect/facets/status.md +6 -5
- package/runtime/skills/okstra-run/SKILL.md +4 -4
- package/runtime/skills/okstra-user-response/SKILL.md +50 -16
- package/runtime/validators/validate-run.py +254 -81
- 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
|
-
|
|
712
|
-
if not isinstance(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
"""
|
|
2171
|
-
|
|
2172
|
-
`
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
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``.
|
|
3898
|
-
``dissent-isolated``
|
|
3899
|
-
|
|
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
|
-
|
|
4061
|
-
`
|
|
4062
|
-
|
|
4063
|
-
|
|
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
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
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
|
|
6090
|
-
id that traces to no stage
|
|
6091
|
-
|
|
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
|
|
6096
|
-
position in their own array, so they
|
|
6097
|
-
coverage row whose `coveredBy` is prose
|
|
6098
|
-
|
|
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
|
|
6121
|
-
"next re-run
|
|
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
|
|
6135
|
-
"re-
|
|
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 =
|
|
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 = [
|
|
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
|
|
837
|
+
def _plan_body_verification(report_data: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
833
838
|
planning = report_data.get("implementationPlanning")
|
|
834
|
-
|
|
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
|
|
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(
|
|
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 "
|