okstra 0.197.0 → 0.198.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 (37) hide show
  1. package/dist/cli-registry.mjs +9 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +4 -2
  4. package/docs/project-structure-overview.md +1 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/convergence.md +19 -4
  8. package/runtime/prompts/lead/report-writer.md +1 -1
  9. package/runtime/prompts/lead/team-contract.md +4 -3
  10. package/runtime/prompts/profiles/implementation-option-selection.md +4 -0
  11. package/runtime/prompts/wizard/prompts.ko.json +6 -2
  12. package/runtime/python/okstra_ctl/conformance.py +26 -5
  13. package/runtime/python/okstra_ctl/convergence.py +58 -3
  14. package/runtime/python/okstra_ctl/convergence_engine.py +71 -0
  15. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +14 -3
  16. package/runtime/python/okstra_ctl/convergence_store.py +36 -0
  17. package/runtime/python/okstra_ctl/dispatch_core.py +10 -3
  18. package/runtime/python/okstra_ctl/dispatch_state.py +30 -4
  19. package/runtime/python/okstra_ctl/group_context.py +96 -3
  20. package/runtime/python/okstra_ctl/implementation_options.py +123 -0
  21. package/runtime/python/okstra_ctl/option_votes.py +194 -0
  22. package/runtime/python/okstra_ctl/report_assembly.py +10 -0
  23. package/runtime/python/okstra_ctl/run.py +23 -0
  24. package/runtime/python/okstra_ctl/set_work_status.py +30 -1
  25. package/runtime/python/okstra_ctl/verdict_blocks.py +27 -0
  26. package/runtime/python/okstra_ctl/wizard/engine.py +4 -0
  27. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  28. package/runtime/python/okstra_ctl/wizard/registry.py +8 -0
  29. package/runtime/python/okstra_ctl/wizard/steps_options.py +37 -1
  30. package/runtime/python/okstra_ctl/worker_audit_check.py +38 -16
  31. package/runtime/python/okstra_ctl/worker_liveness.py +48 -2
  32. package/runtime/python/okstra_ctl/workflow.py +1 -1
  33. package/runtime/skills/okstra-brief-gen/SKILL.md +24 -7
  34. package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
  35. package/runtime/skills/okstra-run/SKILL.md +1 -0
  36. package/runtime/templates/reports/group-context.template.md +1 -1
  37. package/runtime/validators/validate-run.py +77 -3
@@ -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 attack.
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**: <2-3 sentences; for counter-evidence include the file:line you found>"""
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**: <2-3 sentences>"""
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(job.worker_id, job.result_path)
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(worker_id, path):
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), Path(_string_value(record.get("resultPath")))
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(worker_id: str, result_path: Path) -> str | None:
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 worker_id != REPORT_WRITER_WORKER_ID or not result_path.is_file():
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(job.worker_id, path):
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 가 갈라지면 워커는 다른 쪽
@@ -70,7 +70,8 @@ _NONE_MARKERS = {"_(none)_", ""}
70
70
  MEMORY_BEGIN = "<!-- okstra:task-memory:begin -->"
71
71
  MEMORY_END = "<!-- okstra:task-memory:end -->"
72
72
  MEMORY_NOTE = (
73
- "<!-- okstra rewrites this region at every report-finalize. "
73
+ "<!-- okstra redraws this region after every report-finalize, at "
74
+ "`okstra set-work-status`, and before each run copies this file. "
74
75
  "Edit the sections above it, not this one. -->"
75
76
  )
76
77
  MEMORY_HEADING = "## Task Memory"
@@ -323,6 +324,32 @@ def record_task_memory(
323
324
  return target, queue
324
325
 
325
326
 
327
+ def refresh_group_queue(project_root: Path, task_group: str) -> Path | None:
328
+ """그룹 문서의 시작 순서만 지금 값으로 다시 그린다. 문서가 없으면 아무것도 안 한다.
329
+
330
+ 기록된 항목과 사람 절은 바이트 그대로 두고 큐만 다시 계산한다. 이 투영을
331
+ 쓰는 곳은 원래 report-finalize 의 `record-group-memory` 하나뿐이라, 리포트
332
+ 없이 끝난 run 이나 사람이 손으로 바꾼 상태는 다음 finalize 까지 문서에
333
+ 닿지 않았다(2026-09-10 실측, cache 그룹 dev-10635: `workStatus` 를 done 으로
334
+ 적었는데 큐는 `[in progress] — requirements-discovery (blocked)` 그대로).
335
+ 같은 그룹의 finalize 와 겹칠 수 있으므로 같은 flock 아래에서 읽고-쓴다.
336
+ """
337
+ target = group_context_file(project_root, task_group)
338
+ if not target.is_file():
339
+ return None
340
+ with dir_flock(target.parent, LOCK_FILENAME):
341
+ text = target.read_text(encoding="utf-8")
342
+ before, region, after = split_memory_region(text)
343
+ if not region:
344
+ return None
345
+ entries = parse_memory_entries(region)
346
+ queue = group_queue(project_root, task_group, entries)
347
+ updated = before + render_memory_region(entries, queue) + after
348
+ if updated != text:
349
+ target.write_text(updated, encoding="utf-8")
350
+ return target
351
+
352
+
326
353
  # --- 시작 순서 ----------------------------------------------------------------
327
354
  #
328
355
  # 그룹의 task 는 순서가 있을 수 있다. 순서는 brief-gen 이 매긴 브리프 순번
@@ -332,6 +359,11 @@ def record_task_memory(
332
359
  # 15번 가드 티켓이 1번을 `blocks` 한다고 적었지만 사용자는 1번을 먼저 끝냈다.
333
360
  # 큐는 영역 머리에 실리고, 이 task 가 끝나는 closeout 은 큐의 다음 task 를 이름한다.
334
361
 
362
+ # `okstra set-work-status` 가 task-manifest 에 적는 값 중 큐 상태를 덮는 것.
363
+ # 나머지(`todo` / `in-progress` / `blocked`)는 큐의 세 상태로 옮길 때 파생값보다
364
+ # 나은 정보가 없어 덮지 않는다.
365
+ WORK_STATUS_DONE = "done"
366
+
335
367
  QUEUE_DONE = "done"
336
368
  QUEUE_IN_PROGRESS = "in progress"
337
369
  QUEUE_NOT_STARTED = "not started"
@@ -354,6 +386,9 @@ class QueueRow:
354
386
  status: str # QUEUE_*
355
387
  progress: str # "<task-type> #<seq>" for a recorded task, else ""
356
388
  waits_for: tuple[str, ...] = () # ticket ids the graph says come first and are not done
389
+ # 같은 `brief-id` 를 가진 다른 브리프 파일들. 하나의 task 를 두 파일이
390
+ # 주장하는 상태이므로 큐는 정본 한 줄만 싣고 나머지를 여기 이름한다.
391
+ duplicate_briefs: tuple[str, ...] = ()
357
392
 
358
393
 
359
394
  def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
@@ -417,6 +452,15 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
417
452
  state = str(entry.get("currentPhaseState") or entry.get("latestRunStatus") or "")
418
453
  progress = f"{phase} ({state})" if phase and state else phase
419
454
  status = QUEUE_DONE if pointer.get("status") == STATUS_TERMINAL else QUEUE_IN_PROGRESS
455
+ # 사용자가 `okstra set-work-status <task> done` 으로 끝났다고 선언한 task
456
+ # 는 끝난 것이다. 파생 포인터만 보면 리포트 없이 끝난 run 이 영원히
457
+ # `[in progress]` 로 남는다 — 실측(2026-09-10, fontsninja-v3-site cache
458
+ # 그룹 dev-10635): 사람이 AWS 콘솔에서 작업을 마치고 `done` 을 적었는데,
459
+ # 그 run 이 리포트 조립 실패로 끝나 포인터가 `blocked` 에 멈춰 있었다.
460
+ # 파생 표기는 지우지 않는다 — 왜 done 인지가 그 차이에 남는다.
461
+ if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
462
+ status = QUEUE_DONE
463
+ progress = f"{progress} · marked done" if progress else "marked done"
420
464
  out[task_id] = (status, progress)
421
465
  return out
422
466
 
@@ -439,6 +483,7 @@ def group_queue(
439
483
  for brief in briefs:
440
484
  for ticket, before in brief["waits_for"].items():
441
485
  waits.setdefault(ticket, set()).update((before & by_ticket.keys()) - {ticket})
486
+ briefs, duplicates = _fold_duplicate_briefs(briefs)
442
487
  ordered = sorted(briefs, key=lambda b: (b["ordinal"] is None, b["ordinal"] or 0, b["brief_id"]))
443
488
  by_task = {entry.task_id: entry for entry in entries}
444
489
  recorded = catalog_progress(project_root, task_group)
@@ -464,10 +509,50 @@ def group_queue(
464
509
  rows.append(QueueRow(
465
510
  slugify_task_segment(brief["brief_id"]), brief["brief_id"], brief["ticket_id"],
466
511
  brief["brief"], status, progress, open_waits,
512
+ duplicates.get(brief["brief_id"], ()),
467
513
  ))
468
514
  return rows
469
515
 
470
516
 
517
+ def _fold_duplicate_briefs(
518
+ briefs: list[dict[str, Any]],
519
+ ) -> tuple[list[dict[str, Any]], dict[str, tuple[str, ...]]]:
520
+ """같은 `brief-id` 를 주장하는 브리프들을 정본 하나로 접는다.
521
+
522
+ `brief-id` 는 task 디렉터리 이름(`task_id`)의 출처라, 두 파일이 같은 id 를
523
+ 달면 하나의 task 를 둘이 주장하는 상태다. 접지 않으면 큐가 같은 task 를 두
524
+ 줄로 싣고 번호가 브리프 수보다 커진다 — `next_in_group` 과 closeout 도 같은
525
+ task 를 두 번 가리킨다(2026-09-10 실측, fontsninja-v3-site `cache` 그룹:
526
+ 개정 전 브리프를 `.superseded-<날짜>.md` 로 같은 디렉터리에 남겨 15개 그룹의
527
+ 큐가 16번까지 갔다).
528
+
529
+ 정본은 파일 이름이 곧 `brief-id` 인 파일이다 — brief-gen 이 쓰는 이름이고,
530
+ 같은 id 를 단 다른 이름은 사본이다. 그런 파일이 없거나 여럿이면 경로 순서로
531
+ 첫 번째다. 거절하지 않는 이유는 이 큐를 report-finalize 와 위저드가 읽기
532
+ 때문이다: 브리프 디렉터리 정리가 안 됐다고 run 발행을 막을 일은 아니다.
533
+ 대신 남은 사본을 `duplicate_briefs` 로 실어 큐가 그 사실을 말한다.
534
+ """
535
+ by_id: dict[str, list[dict[str, Any]]] = {}
536
+ for brief in briefs:
537
+ by_id.setdefault(brief["brief_id"], []).append(brief)
538
+ kept: list[dict[str, Any]] = []
539
+ duplicates: dict[str, tuple[str, ...]] = {}
540
+ for brief_id, group in by_id.items():
541
+ if len(group) == 1:
542
+ kept.append(group[0])
543
+ continue
544
+ ordered_group = sorted(group, key=lambda b: b["brief"])
545
+ canonical = next(
546
+ (b for b in ordered_group if Path(b["brief"]).stem == brief_id),
547
+ ordered_group[0],
548
+ )
549
+ kept.append(canonical)
550
+ duplicates[brief_id] = tuple(
551
+ b["brief"] for b in ordered_group if b is not canonical
552
+ )
553
+ return kept, duplicates
554
+
555
+
471
556
  def next_in_group(queue: list[QueueRow]) -> QueueRow | None:
472
557
  """시작 순서에서 아직 시작하지 않은 첫 task."""
473
558
  return next((row for row in queue if row.status == QUEUE_NOT_STARTED), None)
@@ -482,6 +567,13 @@ def render_queue(queue: list[QueueRow]) -> str:
482
567
  if row.waits_for:
483
568
  tail += f" (waits for {', '.join(row.waits_for)})"
484
569
  lines.append(f"{number}. [{row.status}] {row.task_id}{tail}")
570
+ # 사본을 이름한다. 이 줄이 없으면 큐가 조용히 한 파일을 무시하고, 그
571
+ # 파일을 고친 사람은 자기 편집이 어디로 갔는지 알 길이 없다.
572
+ for extra in row.duplicate_briefs:
573
+ lines.append(
574
+ f" - ignored duplicate of this brief-id: `{extra}` — one task, "
575
+ "two brief files. Move the copy out of the briefs directory."
576
+ )
485
577
  return "\n".join(lines) + "\n"
486
578
 
487
579
 
@@ -657,8 +749,9 @@ def _init_command(args: argparse.Namespace) -> int:
657
749
  "line remains; delete the file if the group needs no context."
658
750
  )
659
751
  print(
660
- f"The trailing `{MEMORY_HEADING}` region is written by okstra at every "
661
- "report-finalize; leave it alone."
752
+ f"The trailing `{MEMORY_HEADING}` region is okstra's redrawn after every "
753
+ "report-finalize, at `okstra set-work-status`, and before each run copies "
754
+ "this file; leave it alone."
662
755
  )
663
756
  return 0
664
757
 
@@ -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]],