okstra 0.197.1 → 0.198.1
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/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/docs/cli.md +4 -2
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +19 -4
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +4 -3
- package/runtime/prompts/profiles/implementation-option-selection.md +4 -0
- package/runtime/prompts/wizard/prompts.ko.json +6 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +5 -3
- package/runtime/python/okstra_ctl/cmux.py +20 -5
- package/runtime/python/okstra_ctl/convergence.py +58 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +71 -0
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +14 -3
- package/runtime/python/okstra_ctl/convergence_store.py +36 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -3
- package/runtime/python/okstra_ctl/dispatch_state.py +30 -4
- package/runtime/python/okstra_ctl/implementation_options.py +123 -0
- package/runtime/python/okstra_ctl/option_votes.py +194 -0
- package/runtime/python/okstra_ctl/render.py +4 -11
- package/runtime/python/okstra_ctl/report_assembly.py +10 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +27 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +6 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +8 -0
- package/runtime/python/okstra_ctl/wizard/state.py +1 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +14 -3
- package/runtime/python/okstra_ctl/wizard/steps_options.py +37 -1
- package/runtime/python/okstra_ctl/worker_audit_check.py +38 -16
- package/runtime/python/okstra_ctl/worker_liveness.py +48 -2
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/skills/okstra-run/SKILL.md +8 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/validators/validate-run.py +51 -11
|
@@ -579,6 +579,77 @@ def apply_critic_gap_results(
|
|
|
579
579
|
return updated
|
|
580
580
|
|
|
581
581
|
|
|
582
|
+
def apply_acceptance_critic_results(
|
|
583
|
+
state: Mapping[str, Any],
|
|
584
|
+
results: Mapping[str, Any],
|
|
585
|
+
) -> dict[str, Any]:
|
|
586
|
+
"""Record one acceptance-critic batch's confirm-or-downgrade accounting.
|
|
587
|
+
|
|
588
|
+
`apply_critic_gap_results` 는 coverage 의 병합/기각 의미를 구현하므로 이 모드를
|
|
589
|
+
입구에서 거부한다. 그 결과 계약이 요구하는 `config.critic` 회계를 산출할 경로가
|
|
590
|
+
없었고, 실측(2026-09-10 dev-10642-15 final-verification 001)에서 후보 0건 +
|
|
591
|
+
조건부 조건 4건이라는 사실이 수렴 상태 어디에도 남지 않았다.
|
|
592
|
+
|
|
593
|
+
회계는 여기서 센다. 리드가 세어 넘기면 v1.4 등식
|
|
594
|
+
(`candidatesProposed = confirmedBlockers + downgradedToResidual`)을 리드가 다시
|
|
595
|
+
구현하는 꼴이 되고, 그 재구현이 틀려도 등식은 통과한다.
|
|
596
|
+
"""
|
|
597
|
+
current = _object(state, "working state")
|
|
598
|
+
errors = validate_working_state(current)
|
|
599
|
+
if errors:
|
|
600
|
+
raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
|
|
601
|
+
if current.get("config", {}).get("critic") is not None:
|
|
602
|
+
raise ConvergenceContractError("critic summary was already applied")
|
|
603
|
+
if plan_next_round(current).get("action") != "finalize":
|
|
604
|
+
raise ConvergenceContractError("main finding queue must be terminal")
|
|
605
|
+
|
|
606
|
+
payload = _object(results, "acceptance critic results")
|
|
607
|
+
if payload.get("mode") != "acceptance-devils-advocate":
|
|
608
|
+
raise ConvergenceContractError(
|
|
609
|
+
"acceptance critic batch must declare mode acceptance-devils-advocate"
|
|
610
|
+
)
|
|
611
|
+
if payload.get("taskKey") != current.get("taskKey"):
|
|
612
|
+
raise ConvergenceContractError("acceptance critic batch is for another task")
|
|
613
|
+
candidates = payload.get("candidates")
|
|
614
|
+
if not isinstance(candidates, list):
|
|
615
|
+
raise ConvergenceContractError("acceptance critic candidates must be an array")
|
|
616
|
+
seen: set[str] = set()
|
|
617
|
+
confirmed = 0
|
|
618
|
+
for candidate in candidates:
|
|
619
|
+
if not isinstance(candidate, Mapping):
|
|
620
|
+
raise ConvergenceContractError("acceptance critic candidate must be an object")
|
|
621
|
+
candidate_id = candidate.get("candidateId")
|
|
622
|
+
if not _nonempty_string(candidate_id):
|
|
623
|
+
raise ConvergenceContractError("candidateId must be a non-empty string")
|
|
624
|
+
if candidate_id in seen:
|
|
625
|
+
raise ConvergenceContractError(f"duplicate candidateId: {candidate_id}")
|
|
626
|
+
seen.add(candidate_id)
|
|
627
|
+
verdict = candidate.get("verdict")
|
|
628
|
+
if verdict not in {"confirmed", "downgraded"}:
|
|
629
|
+
raise ConvergenceContractError(
|
|
630
|
+
f"candidate {candidate_id} verdict must be confirmed or downgraded"
|
|
631
|
+
)
|
|
632
|
+
confirmed += verdict == "confirmed"
|
|
633
|
+
|
|
634
|
+
updated = deepcopy(dict(current))
|
|
635
|
+
updated["config"]["critic"] = {
|
|
636
|
+
"mode": "acceptance-devils-advocate",
|
|
637
|
+
"provider": payload["provider"],
|
|
638
|
+
"modelExecutionValue": payload["modelExecutionValue"],
|
|
639
|
+
"candidatesProposed": len(candidates),
|
|
640
|
+
"confirmedBlockers": confirmed,
|
|
641
|
+
# 확인되지 않은 후보는 버리지 않고 Residual Risk 로 내린다. 그래서 이 값은
|
|
642
|
+
# 잔차가 아니라 기록된 판정의 수다.
|
|
643
|
+
"downgradedToResidual": len(candidates) - confirmed,
|
|
644
|
+
}
|
|
645
|
+
updated_errors = validate_working_state(updated)
|
|
646
|
+
if updated_errors:
|
|
647
|
+
raise ConvergenceContractError(
|
|
648
|
+
"invalid updated working state: " + "; ".join(updated_errors)
|
|
649
|
+
)
|
|
650
|
+
return updated
|
|
651
|
+
|
|
652
|
+
|
|
582
653
|
def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
583
654
|
"""Return the public schema-v1.3 convergence artifact."""
|
|
584
655
|
errors = validate_working_state(state)
|
|
@@ -61,12 +61,17 @@ is wrong, overstated, or unproven. Then respond with exactly one verdict:
|
|
|
61
61
|
- counter-evidence — you found contradicting evidence (give file:line or log line), OR
|
|
62
62
|
- burden-not-met — you re-inspected the cited evidence and could neither confirm
|
|
63
63
|
nor refute it (the claim has not proven itself).
|
|
64
|
-
- **SURVIVES**: You actively tried to refute it and failed — the claim withstood the
|
|
64
|
+
- **SURVIVES**: You actively tried to refute it and failed — the claim withstood the
|
|
65
|
+
attack. Name the attack you tried and why it failed.
|
|
65
66
|
- **SURVIVES-WITH-CAVEAT**: It holds, but a scope limit / extra condition / missing
|
|
66
67
|
precondition exists (state it).
|
|
67
68
|
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
|
|
68
69
|
from opening or reproducing the cited evidence. Do not use REFUTED as a substitute.
|
|
69
70
|
|
|
71
|
+
Every verdict carries an `**Explanation**`, SURVIVES included — it is what you did,
|
|
72
|
+
not what the verdict already says. Only `**Basis**` is conditional. A block with a
|
|
73
|
+
verdict and no explanation is not collected and the whole response is refused.
|
|
74
|
+
|
|
70
75
|
The burden of proof is on the claim. If after inspecting the cited evidence you remain
|
|
71
76
|
uncertain, your verdict is REFUTED with basis = burden-not-met.
|
|
72
77
|
|
|
@@ -82,6 +87,10 @@ For EACH finding, respond with exactly one verdict:
|
|
|
82
87
|
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
|
|
83
88
|
from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
|
|
84
89
|
|
|
90
|
+
Every verdict carries an `**Explanation**`, AGREE included — name the evidence you
|
|
91
|
+
checked. A block with a verdict and no explanation is not collected and the whole
|
|
92
|
+
response is refused.
|
|
93
|
+
|
|
85
94
|
Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
|
|
86
95
|
|
|
87
96
|
# 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
|
|
@@ -98,11 +107,13 @@ the summary line alone."""
|
|
|
98
107
|
ADVERSARIAL_RESPONSE = """### <finding-id>
|
|
99
108
|
**Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
|
|
100
109
|
**Basis** (only if REFUTED): counter-evidence | burden-not-met
|
|
101
|
-
**Explanation
|
|
110
|
+
**Explanation** (required for every verdict, SURVIVES included): <2-3 sentences; for
|
|
111
|
+
SURVIVES say what you attacked and why the attack failed; for counter-evidence include
|
|
112
|
+
the file:line you found>"""
|
|
102
113
|
|
|
103
114
|
_COLLABORATIVE_RESPONSE = """### <finding-id>
|
|
104
115
|
**Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
|
|
105
|
-
**Explanation
|
|
116
|
+
**Explanation** (required for every verdict, AGREE included): <2-3 sentences>"""
|
|
106
117
|
|
|
107
118
|
|
|
108
119
|
def _nonempty_string(value: Any) -> str:
|
|
@@ -42,6 +42,38 @@ _CRITIC_BATCH_SCHEMA = {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# acceptance 모드는 coverage 의 gap 어휘를 쓰지 않는다 — 후보 하나에 대한 판정이
|
|
46
|
+
# `confirmed`/`downgraded` 둘뿐이고, 회계는 이 배치를 읽는 쪽이 센다.
|
|
47
|
+
_ACCEPTANCE_BATCH_SCHEMA = {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": [
|
|
50
|
+
"schemaVersion", "taskKey", "mode", "provider",
|
|
51
|
+
"modelExecutionValue", "candidates",
|
|
52
|
+
],
|
|
53
|
+
"additionalProperties": False,
|
|
54
|
+
"properties": {
|
|
55
|
+
"schemaVersion": {"const": "1.0"},
|
|
56
|
+
"taskKey": {"type": "string", "pattern": "\\S"},
|
|
57
|
+
"mode": {"const": "acceptance-devils-advocate"},
|
|
58
|
+
"provider": {"type": "string", "pattern": "\\S"},
|
|
59
|
+
"modelExecutionValue": {"type": "string", "pattern": "\\S"},
|
|
60
|
+
"candidates": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["candidateId", "verdict"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
"properties": {
|
|
67
|
+
"candidateId": {"type": "string", "pattern": "\\S"},
|
|
68
|
+
"verdict": {"enum": ["confirmed", "downgraded"]},
|
|
69
|
+
"statement": {"type": "string"},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
45
77
|
def load_owned_json_object(path: Path) -> dict[str, Any]:
|
|
46
78
|
try:
|
|
47
79
|
return load_owned_object(path, artifact="convergence artifact")
|
|
@@ -71,6 +103,10 @@ def load_convergence_critic_batch(path: Path) -> dict[str, Any]:
|
|
|
71
103
|
return _load_convergence_result(path, schema=_CRITIC_BATCH_SCHEMA)
|
|
72
104
|
|
|
73
105
|
|
|
106
|
+
def load_acceptance_critic_batch(path: Path) -> dict[str, Any]:
|
|
107
|
+
return _load_convergence_result(path, schema=_ACCEPTANCE_BATCH_SCHEMA)
|
|
108
|
+
|
|
109
|
+
|
|
74
110
|
def load_json_object(path: Path) -> dict[str, Any]:
|
|
75
111
|
"""Compatibility alias for convergence artifacts owned by okstra."""
|
|
76
112
|
return load_owned_json_object(path)
|
|
@@ -1988,7 +1988,9 @@ def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
|
|
|
1988
1988
|
|
|
1989
1989
|
|
|
1990
1990
|
def _artifact_defects(job: WorkerJob) -> tuple[str, ...]:
|
|
1991
|
-
defect = unusable_result_defect(
|
|
1991
|
+
defect = unusable_result_defect(
|
|
1992
|
+
job.worker_id, job.result_path, job.dispatch_kind,
|
|
1993
|
+
)
|
|
1992
1994
|
return (defect,) if defect else ()
|
|
1993
1995
|
|
|
1994
1996
|
|
|
@@ -3228,9 +3230,12 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3228
3230
|
worker_result = Path(_string_value(record.get("workerResultPath")))
|
|
3229
3231
|
missing: list[Path] = []
|
|
3230
3232
|
worker_id = _dispatch_worker_key(record)
|
|
3233
|
+
dispatch_kind = _string_value(record.get("dispatchKind"))
|
|
3231
3234
|
for path in _record_completion_paths(record):
|
|
3232
3235
|
if path.is_file():
|
|
3233
|
-
if path == result_path and unusable_result_defect(
|
|
3236
|
+
if path == result_path and unusable_result_defect(
|
|
3237
|
+
worker_id, path, dispatch_kind,
|
|
3238
|
+
):
|
|
3234
3239
|
missing.append(path)
|
|
3235
3240
|
continue
|
|
3236
3241
|
if path in {result_path, worker_result} and any(alias.is_file() for alias in aliases):
|
|
@@ -3241,7 +3246,9 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3241
3246
|
|
|
3242
3247
|
def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
|
|
3243
3248
|
defect = unusable_result_defect(
|
|
3244
|
-
_dispatch_worker_key(record),
|
|
3249
|
+
_dispatch_worker_key(record),
|
|
3250
|
+
Path(_string_value(record.get("resultPath"))),
|
|
3251
|
+
_string_value(record.get("dispatchKind")),
|
|
3245
3252
|
)
|
|
3246
3253
|
return (defect,) if defect else ()
|
|
3247
3254
|
|
|
@@ -54,6 +54,7 @@ from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
|
|
|
54
54
|
from .final_report_paths import final_report_data_path
|
|
55
55
|
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
56
56
|
from .report_narrative import narrative_structure_defect
|
|
57
|
+
from .verdict_blocks import finding_vote_defect
|
|
57
58
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
58
59
|
from .worker_prompt_contract import (
|
|
59
60
|
PromptRecord,
|
|
@@ -1642,16 +1643,39 @@ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
|
|
|
1642
1643
|
return BACKEND_MIXED
|
|
1643
1644
|
|
|
1644
1645
|
|
|
1645
|
-
def unusable_result_defect(
|
|
1646
|
-
|
|
1646
|
+
def unusable_result_defect(
|
|
1647
|
+
worker_id: str, result_path: Path, dispatch_kind: str = "",
|
|
1648
|
+
) -> str | None:
|
|
1649
|
+
"""산출물이 있어도 소비자가 읽을 수 없으면 없는 것이다 — 서사와 재검증 표.
|
|
1647
1650
|
|
|
1648
1651
|
report-writer 의 서사가 줄 문법을 어기면(frontmatter·헤딩으로 된 보통
|
|
1649
1652
|
보고서) 조립이 Phase 7 에서 거절하고, 그때는 배치의 재시도가 이미 지나
|
|
1650
1653
|
리드가 손으로 재저작을 띄워야 한다 — 실측(2026-09-09, jobs implementation
|
|
1651
1654
|
stage-2)에서 리드는 그것을 하지 않고 run 을 닫았다. 수집 시점에 "없는
|
|
1652
1655
|
산출물" 로 세면 `_should_retry` 가 같은 배치 안에서 다시 띄운다.
|
|
1656
|
+
|
|
1657
|
+
재검증(`reverify-r<N>`) 결과도 같은 자리에 있다. `okstra convergence
|
|
1658
|
+
collect-results` 는 표로 읽히지 않는 결과를 거절하는데, 원장은 그 attempt 를
|
|
1659
|
+
`ok` 로 닫아 두므로 그 워커를 빼고 수집하면 `apply-round` 가 "missing vote
|
|
1660
|
+
for completed worker" 로 막는다. 즉 리드에게 남는 수가 없다 — 실측
|
|
1661
|
+
(2026-09-10, fontsninja-v3-site dev-10631 implementation-option-selection):
|
|
1662
|
+
antigravity 가 35건 중 34건의 `**Explanation**` 을 빼먹었고 run 이 그 자리에
|
|
1663
|
+
멈췄다. 여기서 결함으로 세면 재시도가 배치 안에서 돌고, 그마저 실패하면
|
|
1664
|
+
attempt 가 실패로 닫혀 `collect-results` 가 그 워커를 `error` 로 적는다 —
|
|
1665
|
+
엔진은 그 표를 `verification-error` 로 기록하고 라운드는 진행한다.
|
|
1653
1666
|
"""
|
|
1654
|
-
if
|
|
1667
|
+
if not result_path.is_file():
|
|
1668
|
+
return None
|
|
1669
|
+
if dispatch_kind.startswith("reverify-r"):
|
|
1670
|
+
try:
|
|
1671
|
+
text = result_path.read_text(encoding="utf-8")
|
|
1672
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1673
|
+
return f"reverify result is unreadable: {exc}"
|
|
1674
|
+
defect = finding_vote_defect(text)
|
|
1675
|
+
if defect is None:
|
|
1676
|
+
return None
|
|
1677
|
+
return f"reverify result does not parse: {defect}"
|
|
1678
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1655
1679
|
return None
|
|
1656
1680
|
try:
|
|
1657
1681
|
text = result_path.read_text(encoding="utf-8")
|
|
@@ -1667,7 +1691,9 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
|
1667
1691
|
missing: list[Path] = []
|
|
1668
1692
|
for path in job.completion_paths:
|
|
1669
1693
|
if path.is_file():
|
|
1670
|
-
if path == job.result_path and unusable_result_defect(
|
|
1694
|
+
if path == job.result_path and unusable_result_defect(
|
|
1695
|
+
job.worker_id, path, job.dispatch_kind,
|
|
1696
|
+
):
|
|
1671
1697
|
missing.append(path)
|
|
1672
1698
|
continue
|
|
1673
1699
|
# reports seq 와 workerResults seq 가 갈라지면 워커는 다른 쪽
|
|
@@ -6,9 +6,14 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
from collections.abc import Mapping, Sequence
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
13
|
+
from .clarification_items.dispositions import (
|
|
14
|
+
USER_INPUT_BLOCKS,
|
|
15
|
+
progress_blocking_ids,
|
|
16
|
+
)
|
|
12
17
|
from .exact_coverage import ExactCoverageError, calculate_exact_coverage
|
|
13
18
|
|
|
14
19
|
|
|
@@ -361,6 +366,124 @@ def _validate_option_count_and_routing(
|
|
|
361
366
|
errors.append("recommendedOptionId must name the first ranked option")
|
|
362
367
|
|
|
363
368
|
|
|
369
|
+
@dataclass(frozen=True)
|
|
370
|
+
class VoteGap:
|
|
371
|
+
"""전원 투표만 모자란 후보 하나와, 표를 받아야 할 분석자."""
|
|
372
|
+
|
|
373
|
+
option_id: str
|
|
374
|
+
missing: tuple[str, ...]
|
|
375
|
+
feasible_votes: int
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def vote_gaps(
|
|
379
|
+
selection: Mapping[str, object],
|
|
380
|
+
participating_analysers: Sequence[str],
|
|
381
|
+
) -> list[VoteGap]:
|
|
382
|
+
"""표만 채우면 살아날 후보 — 유효성 규칙을 거꾸로 읽는다.
|
|
383
|
+
|
|
384
|
+
1라운드의 설계자들은 병렬로 돌아 서로의 후보를 보지 못한다. 그래서 자기가
|
|
385
|
+
낸 후보에만 표를 남기고, 병합된 집합에는 분석자마다 다른 구멍이 생긴다.
|
|
386
|
+
`_validate_option_feasibility` 의 전원 투표 조항은 그 상태를 조립 시점에
|
|
387
|
+
거절할 뿐 메우지 못한다 — 실측(2026-09-10, dev-10629-4): 설계자 3명 로스터에서
|
|
388
|
+
IO-001·IO-002·IO-003 이 각각 `feasible` 2표를 받고도 빠진 분석자가 하나씩
|
|
389
|
+
달라 전부 탈락했고, 그 run 은 후보 0건으로 차단됐다.
|
|
390
|
+
|
|
391
|
+
여기서 세는 것은 **표만 모자란** 후보다. `safetyBlockers` 나
|
|
392
|
+
`unresolvedFeasibilityFacts` 가 있거나 베낀 표가 있으면 표를 더 받아도
|
|
393
|
+
유효해지지 않으므로 제외한다. 남은 표를 다 받아도 `feasible` 이
|
|
394
|
+
`MIN_FEASIBLE_VOTES` 에 못 미치는 후보도 제외한다 — 부쳐 봐야 결과가
|
|
395
|
+
같다.
|
|
396
|
+
"""
|
|
397
|
+
roster = list(dict.fromkeys(str(name) for name in participating_analysers))
|
|
398
|
+
gaps: list[VoteGap] = []
|
|
399
|
+
for candidate in (
|
|
400
|
+
row
|
|
401
|
+
for key in ("rankedOptions", "candidateAudit")
|
|
402
|
+
for row in (selection.get(key) or ())
|
|
403
|
+
if isinstance(row, Mapping)
|
|
404
|
+
):
|
|
405
|
+
if candidate.get("safetyBlockers") or candidate.get(
|
|
406
|
+
"unresolvedFeasibilityFacts"
|
|
407
|
+
):
|
|
408
|
+
continue
|
|
409
|
+
votes = candidate.get("feasibilityVotes") or ()
|
|
410
|
+
if _copied_votes(votes):
|
|
411
|
+
continue
|
|
412
|
+
voted = [str(vote.get("worker")) for vote in votes if isinstance(vote, Mapping)]
|
|
413
|
+
if len(voted) != len(set(voted)):
|
|
414
|
+
continue
|
|
415
|
+
missing = tuple(name for name in roster if name not in set(voted))
|
|
416
|
+
if not missing or set(voted) - set(roster):
|
|
417
|
+
continue
|
|
418
|
+
feasible = sum(
|
|
419
|
+
isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
|
|
420
|
+
for vote in votes
|
|
421
|
+
)
|
|
422
|
+
if feasible + len(missing) < MIN_FEASIBLE_VOTES:
|
|
423
|
+
continue
|
|
424
|
+
gaps.append(
|
|
425
|
+
VoteGap(
|
|
426
|
+
option_id=str(candidate.get("id") or "?"),
|
|
427
|
+
missing=missing,
|
|
428
|
+
feasible_votes=feasible,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
return gaps
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def validate_blocked_answer_channel(
|
|
435
|
+
report_data: Mapping[str, object],
|
|
436
|
+
) -> list[str]:
|
|
437
|
+
"""차단된 run 이 사용자가 답할 자리를 남겼는지.
|
|
438
|
+
|
|
439
|
+
`routing: blocked` 는 목적지가 없는 유일한 종료 상태다. 그 차단이
|
|
440
|
+
`unresolvedFeasibilityFacts` 때문이면 — 값이 미정이다, 계약이 없다,
|
|
441
|
+
리포터 확인이 필요하다 — 푸는 사람은 사용자인데, 답변 채널은
|
|
442
|
+
`clarificationItems[]` 하나뿐이다. 조립은 그 배열을 승인 결정 원장에서만
|
|
443
|
+
읽고(`report_assembly._clarifications`), `okstra user-response` 는 발행된
|
|
444
|
+
리포트의 그 배열만 읽는다(`user_response._record_clarification_rows`).
|
|
445
|
+
그래서 미해결 사실이 산문으로만 남으면 run 은 "사용자를 기다림" 상태로
|
|
446
|
+
발행되고 사용자에게는 답할 항목이 0건으로 보인다(2026-09-10 실측,
|
|
447
|
+
dev-10630: 후보 5개 전부 미해결 사실을 달고 원장은 빈 배열).
|
|
448
|
+
|
|
449
|
+
앞으로 가는 길은 원장에 행을 여는 것이다 —
|
|
450
|
+
`okstra approval-decision open --ledger <approvalDecisionsPath>` 를 행마다
|
|
451
|
+
한 번. 미해결 사실이 없는 차단(워커가 결과를 못 냈다 같은 실행 차단)은
|
|
452
|
+
사용자가 답할 것이 없으므로 이 검사에 걸리지 않는다.
|
|
453
|
+
|
|
454
|
+
`clarificationItems[]` 는 리드 소유라 작성자 서사에는 없다. 그래서 이
|
|
455
|
+
검사는 조립이 끝난 리포트 레코드 전체를 받고
|
|
456
|
+
`validate_implementation_option_selection`(작성자 소유 의미론) 과 따로
|
|
457
|
+
선다 — 교정 루프의 semantic validator 에 묶으면 원장에 행이 있어도 매번
|
|
458
|
+
발화한다.
|
|
459
|
+
"""
|
|
460
|
+
selection = report_data.get("implementationOptionSelection")
|
|
461
|
+
if not isinstance(selection, Mapping):
|
|
462
|
+
return []
|
|
463
|
+
if selection.get("routing") != NO_VALID_OPTIONS_ROUTING:
|
|
464
|
+
return []
|
|
465
|
+
unresolved = [
|
|
466
|
+
str(candidate.get("id"))
|
|
467
|
+
for key in ("rankedOptions", "candidateAudit")
|
|
468
|
+
for candidate in (selection.get(key) or ())
|
|
469
|
+
if isinstance(candidate, Mapping)
|
|
470
|
+
and candidate.get("unresolvedFeasibilityFacts")
|
|
471
|
+
]
|
|
472
|
+
if not unresolved:
|
|
473
|
+
return []
|
|
474
|
+
if progress_blocking_ids(
|
|
475
|
+
report_data.get("clarificationItems"), USER_INPUT_BLOCKS
|
|
476
|
+
):
|
|
477
|
+
return []
|
|
478
|
+
return [
|
|
479
|
+
"blocked routing leaves the user no answer channel: "
|
|
480
|
+
f"{', '.join(unresolved)} carry unresolvedFeasibilityFacts and no open "
|
|
481
|
+
"clarification row asks them — open one decision row per answerable "
|
|
482
|
+
"fact with `okstra approval-decision open --ledger "
|
|
483
|
+
"<approvalDecisionsPath>`, then reassemble"
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
|
|
364
487
|
def _validate_candidate_audit(
|
|
365
488
|
data: Mapping[str, object],
|
|
366
489
|
options: Sequence[Mapping[str, object]],
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""전원 투표만 모자란 구현 후보와, 그 표를 받아야 할 분석자를 낸다.
|
|
2
|
+
|
|
3
|
+
`implementation-option-selection` 의 1라운드는 설계자들이 병렬로 돌아 서로의
|
|
4
|
+
후보를 보지 못한다. 그래서 자기가 낸 후보에만 실현 가능성 표를 남기고, 병합된
|
|
5
|
+
집합에는 분석자마다 다른 구멍이 생긴다. 유효성 규칙은 전원 투표를 요구하므로
|
|
6
|
+
(`implementation_options._validate_option_feasibility`) 그런 후보는 순위표에
|
|
7
|
+
오르지 못하고, 남는 후보가 하나도 없으면 run 이 `routing: blocked` 로 끝난다 —
|
|
8
|
+
실측(2026-09-10, dev-10629-4): 설계자 3명 로스터에서 IO-001·IO-002·IO-003 이
|
|
9
|
+
각각 `feasible` 2표를 받고도 빠진 분석자가 하나씩 달라 전부 탈락했다.
|
|
10
|
+
|
|
11
|
+
거절은 그 상태를 알려 줄 뿐 메우지 못한다. 이 명령이 앞으로 가는 길이다:
|
|
12
|
+
표만 모자란 후보를 세고, 어느 분석자에게 어떤 후보를 부쳐야 하는지 말한다.
|
|
13
|
+
재검증(reverify) 라운드는 주장을 반박하는 라운드이지 후보에 표를 남기는
|
|
14
|
+
라운드가 아니므로, 그 구멍을 메우는 디스패치는 리드가 이 목록을 보고 연다.
|
|
15
|
+
|
|
16
|
+
읽는 자리는 두 가지다. 조립 전이면 작성자 서사(`--narrative`), 이미 발행된
|
|
17
|
+
run 이면 리포트 레코드(`--report`). 로스터는 task-manifest 의
|
|
18
|
+
`recommendedWorkers` 에서 `report-writer` 를 뺀 것이고, 그것이 검증기가
|
|
19
|
+
`participating analysers` 로 쓰는 값과 같은 정의다(`validators/validate-run.py`).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from okstra_ctl.final_report_schema import load_schema_version
|
|
31
|
+
from okstra_ctl.implementation_options import VoteGap, vote_gaps
|
|
32
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
33
|
+
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
|
|
34
|
+
from okstra_ctl.report_narrative import parse_narrative_structure
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OptionVotesError(ValueError):
|
|
38
|
+
"""투표 구멍을 셀 입력이 없거나 읽히지 않는다."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _record_from_report(path: Path) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
return load_owned_object(path, artifact="final-report data.json")
|
|
44
|
+
except (JsonBoundaryError, OSError) as exc:
|
|
45
|
+
raise OptionVotesError(f"report record is unreadable: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _record_from_narrative(path: Path) -> dict[str, Any]:
|
|
49
|
+
"""작성자 서사를 레코드 모양으로 읽는다.
|
|
50
|
+
|
|
51
|
+
값 결함은 무시한다 — 교정 원장이 고칠 자리이고, 표 구멍을 세는 데에는
|
|
52
|
+
후보 id 와 `feasibilityVotes` 만 있으면 된다. 여기서 서사 전체를 거절하면
|
|
53
|
+
아직 교정 중인 run 은 이 명령을 쓸 수 없다.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
markdown = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise OptionVotesError(f"narrative is unreadable: {exc}") from exc
|
|
59
|
+
schema = load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
|
|
60
|
+
record, _defects = parse_narrative_structure(markdown, schema)
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def participating_analysers(manifest_path: Path) -> tuple[str, ...]:
|
|
65
|
+
try:
|
|
66
|
+
manifest = load_owned_object(manifest_path, artifact="task-manifest")
|
|
67
|
+
except (JsonBoundaryError, OSError) as exc:
|
|
68
|
+
raise OptionVotesError(f"task manifest is unreadable: {exc}") from exc
|
|
69
|
+
roster = manifest.get("recommendedWorkers")
|
|
70
|
+
if not isinstance(roster, list):
|
|
71
|
+
raise OptionVotesError("task manifest has no recommendedWorkers roster")
|
|
72
|
+
return tuple(
|
|
73
|
+
str(worker) for worker in roster if str(worker) != "report-writer"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _selection(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
78
|
+
selection = record.get("implementationOptionSelection")
|
|
79
|
+
if not isinstance(selection, Mapping):
|
|
80
|
+
raise OptionVotesError(
|
|
81
|
+
"the source has no implementationOptionSelection block — "
|
|
82
|
+
"this command reads an implementation-option-selection run"
|
|
83
|
+
)
|
|
84
|
+
return selection
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dispatch_lines(gaps: list[VoteGap]) -> list[str]:
|
|
88
|
+
"""분석자별로 부칠 후보 목록. 디스패치 단위가 분석자이기 때문이다."""
|
|
89
|
+
by_analyser: dict[str, list[str]] = {}
|
|
90
|
+
for gap in gaps:
|
|
91
|
+
for analyser in gap.missing:
|
|
92
|
+
by_analyser.setdefault(analyser, []).append(gap.option_id)
|
|
93
|
+
return [
|
|
94
|
+
f" {analyser}: {', '.join(options)}"
|
|
95
|
+
for analyser, options in sorted(by_analyser.items())
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render(gaps: list[VoteGap]) -> str:
|
|
100
|
+
if not gaps:
|
|
101
|
+
return (
|
|
102
|
+
"No candidate is short of votes alone. A blocked run here is "
|
|
103
|
+
"blocked by something a vote cannot settle — safety blockers, "
|
|
104
|
+
"unresolved feasibility facts, or too few feasible verdicts."
|
|
105
|
+
)
|
|
106
|
+
lines = [
|
|
107
|
+
f"{len(gaps)} candidate(s) need only the missing feasibility votes:",
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
lines += [
|
|
111
|
+
f" {gap.option_id}: {gap.feasible_votes} feasible so far, "
|
|
112
|
+
f"missing {', '.join(gap.missing)}"
|
|
113
|
+
for gap in gaps
|
|
114
|
+
]
|
|
115
|
+
lines += ["", "Dispatch one vote-completion assignment per analyser:"]
|
|
116
|
+
lines += _dispatch_lines(gaps)
|
|
117
|
+
lines += [
|
|
118
|
+
"",
|
|
119
|
+
"Each assignment asks that analyser for its own feasibility verdict, "
|
|
120
|
+
"rationale, and counterevidence on the named candidate — nothing else. "
|
|
121
|
+
"It generates no candidate, so the run stays in `candidate-comparison` "
|
|
122
|
+
"mode; `preselected-validation` is a whole-run mode that would collapse "
|
|
123
|
+
"the comparison to one direction.",
|
|
124
|
+
]
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _gaps(args: argparse.Namespace) -> int:
|
|
129
|
+
if bool(args.report) == bool(args.narrative):
|
|
130
|
+
raise OptionVotesError("pass exactly one of --report or --narrative")
|
|
131
|
+
record = (
|
|
132
|
+
_record_from_report(args.report)
|
|
133
|
+
if args.report
|
|
134
|
+
else _record_from_narrative(args.narrative)
|
|
135
|
+
)
|
|
136
|
+
gaps = vote_gaps(
|
|
137
|
+
_selection(record), participating_analysers(args.task_manifest)
|
|
138
|
+
)
|
|
139
|
+
if args.json:
|
|
140
|
+
print(json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"gaps": [
|
|
143
|
+
{
|
|
144
|
+
"optionId": gap.option_id,
|
|
145
|
+
"missing": list(gap.missing),
|
|
146
|
+
"feasibleVotes": gap.feasible_votes,
|
|
147
|
+
}
|
|
148
|
+
for gap in gaps
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
ensure_ascii=False,
|
|
152
|
+
indent=2,
|
|
153
|
+
))
|
|
154
|
+
else:
|
|
155
|
+
print(_render(gaps))
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_CLI_DESCRIPTION = (
|
|
160
|
+
"Report the implementation candidates that only lack feasibility votes, "
|
|
161
|
+
"and which analyser owes each one."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parser() -> argparse.ArgumentParser:
|
|
166
|
+
parser = argparse.ArgumentParser(
|
|
167
|
+
description=_CLI_DESCRIPTION, prog="okstra option-votes"
|
|
168
|
+
)
|
|
169
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
170
|
+
gaps_parser = subparsers.add_parser("gaps")
|
|
171
|
+
gaps_parser.add_argument(
|
|
172
|
+
"--task-manifest", type=Path, required=True,
|
|
173
|
+
help="the task's task-manifest.json — its roster names the analysers")
|
|
174
|
+
gaps_parser.add_argument(
|
|
175
|
+
"--report", type=Path,
|
|
176
|
+
help="a published final-report `.data.json`")
|
|
177
|
+
gaps_parser.add_argument(
|
|
178
|
+
"--narrative", type=Path,
|
|
179
|
+
help="the report writer's narrative markdown, before assembly")
|
|
180
|
+
gaps_parser.add_argument("--json", action="store_true")
|
|
181
|
+
return parser
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv: list[str] | None = None) -> int:
|
|
185
|
+
args = _parser().parse_args(argv)
|
|
186
|
+
try:
|
|
187
|
+
return _gaps(args)
|
|
188
|
+
except OptionVotesError as exc:
|
|
189
|
+
print(f"okstra option-votes: {exc}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -1188,17 +1188,10 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
|
|
|
1188
1188
|
|
|
1189
1189
|
|
|
1190
1190
|
def _optional_worker_roles(ctx: dict) -> list[dict]:
|
|
1191
|
-
"""
|
|
1191
|
+
"""선택해 배정한 비평 역할. 선택 후에는 실행 또는 미실행 사유가 필요하다.
|
|
1192
1192
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
the liveness reader locate a worker by its team-state row: dispatching a
|
|
1196
|
-
critic failed with `team-state has no workerId=acceptance`, while adding
|
|
1197
|
-
the row by hand failed validation as an `unexpected worker role`. Declaring
|
|
1198
|
-
it here makes the roster say what the run may run, so both sides agree.
|
|
1199
|
-
|
|
1200
|
-
Keyed off `invocationAssignments`, which is where the run records the
|
|
1201
|
-
critic it actually resolved — an absent `critic/*` entry means no critic.
|
|
1193
|
+
`invocationAssignments`의 `critic/*` 배정만 선언하므로 역할을 선택하지
|
|
1194
|
+
않은 실행에는 행이 없다. 완료 검증은 선언된 행의 결과와 사유를 검사한다.
|
|
1202
1195
|
"""
|
|
1203
1196
|
assignments = _invocation_assignments(ctx)
|
|
1204
1197
|
roles: list[dict] = []
|
|
@@ -1220,7 +1213,7 @@ def _optional_worker_roles(ctx: dict) -> list[dict]:
|
|
|
1220
1213
|
# critic has no prompt or result until it is dispatched.
|
|
1221
1214
|
"resultPath": "",
|
|
1222
1215
|
"promptPath": "",
|
|
1223
|
-
"attemptRequired":
|
|
1216
|
+
"attemptRequired": True,
|
|
1224
1217
|
})
|
|
1225
1218
|
return roles
|
|
1226
1219
|
|
|
@@ -24,6 +24,7 @@ from .report_synthesis_packet import (
|
|
|
24
24
|
)
|
|
25
25
|
from .conformance import strip_stage_declaration_label
|
|
26
26
|
from .design_prep import DesignPrepError, materialize_design_prep_requests
|
|
27
|
+
from .implementation_options import validate_blocked_answer_channel
|
|
27
28
|
from .implementation_direction import (
|
|
28
29
|
load_selected_direction_snapshot,
|
|
29
30
|
validate_selected_direction_plan,
|
|
@@ -453,6 +454,15 @@ def _compose(
|
|
|
453
454
|
data["tokenUsage"] = usage
|
|
454
455
|
data["crossVerification"] = convergence_data["crossVerification"]
|
|
455
456
|
data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
|
|
457
|
+
# 차단된 option-selection 은 발행 전에 답변 채널을 갖춰야 한다. 발행 뒤에는
|
|
458
|
+
# 이 원장을 다시 읽는 경로가 없어 사용자에게 질문이 0건으로 보인다.
|
|
459
|
+
for reason in validate_blocked_answer_channel(data):
|
|
460
|
+
_fail(
|
|
461
|
+
"lead",
|
|
462
|
+
inputs["approval-decisions"].path,
|
|
463
|
+
"activeClarifications",
|
|
464
|
+
reason,
|
|
465
|
+
)
|
|
456
466
|
carry_in = _clarification_carry_in(project_root, manifest, manifest_path)
|
|
457
467
|
if carry_in is not None:
|
|
458
468
|
data["clarificationCarryIn"] = carry_in
|