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
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"""Deterministic extraction of implementation-planning verification items."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
4
6
|
import re
|
|
5
|
-
from collections.abc import Mapping
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
6
8
|
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
7
10
|
from typing import Any
|
|
8
11
|
|
|
12
|
+
from .build_tools import command_invokes_build_tool
|
|
13
|
+
from .design_snapshot import build_design_snapshot
|
|
9
14
|
from .design_surfaces import (
|
|
10
15
|
DesignSurfaceError,
|
|
11
16
|
detect_design_surfaces,
|
|
@@ -398,3 +403,407 @@ def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[
|
|
|
398
403
|
def expected_plan_item_ids(implementation_planning: Mapping[str, Any]) -> list[str]:
|
|
399
404
|
"""Return the exact ordered IDs produced by extract_plan_items()."""
|
|
400
405
|
return [item["id"] for item in extract_plan_items(implementation_planning)]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
_STARTABLE_STATUSES = frozenset({"ready", "active"})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def content_hash(item: Mapping[str, Any]) -> str:
|
|
412
|
+
"""이 항목 본문의 지문. 문구가 같으면 라운드가 달라도 같은 판정이다.
|
|
413
|
+
|
|
414
|
+
라운드 번호만 보면 고치지 않은 항목까지 재검증해야 한다. subject·payload·
|
|
415
|
+
block·stageScope 가 같으면 그 항목이 가리키는 텍스트는 재작성 전과 같다.
|
|
416
|
+
"""
|
|
417
|
+
payload = {
|
|
418
|
+
"block": item.get("block"),
|
|
419
|
+
"payload": item.get("payload"),
|
|
420
|
+
"stageScope": item.get("stageScope"),
|
|
421
|
+
"subject": item.get("subject"),
|
|
422
|
+
}
|
|
423
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
|
|
424
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def stage_scope_bucket(
|
|
428
|
+
item: Mapping[str, Any],
|
|
429
|
+
ledger: Mapping[str, str] | None,
|
|
430
|
+
) -> str:
|
|
431
|
+
"""게이트·디스패치가 공유하는 범위. ``in-scope`` / ``observed`` / ``deferred``.
|
|
432
|
+
|
|
433
|
+
원장이 없거나 항목에 스테이지가 없으면 계획 전체로 읽는다. 검증기
|
|
434
|
+
``_stage_scope_bucket`` 과 같은 판정이어야 한다 — 디스패치와 게이트가
|
|
435
|
+
다른 통을 쓰면 워커가 본 항목과 승인을 막는 항목이 갈라진다.
|
|
436
|
+
"""
|
|
437
|
+
if not isinstance(ledger, Mapping) or not ledger:
|
|
438
|
+
return "in-scope"
|
|
439
|
+
scope = item.get("stageScope")
|
|
440
|
+
stages = [
|
|
441
|
+
value for value in scope
|
|
442
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
443
|
+
] if isinstance(scope, list) else []
|
|
444
|
+
if not stages:
|
|
445
|
+
return "in-scope"
|
|
446
|
+
statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
|
|
447
|
+
if statuses & _STARTABLE_STATUSES:
|
|
448
|
+
return "in-scope"
|
|
449
|
+
return "observed" if "done" in statuses else "deferred"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _depends_on(value: object) -> tuple[int, ...]:
|
|
453
|
+
if value in (None, "", "(none)", []):
|
|
454
|
+
return ()
|
|
455
|
+
if isinstance(value, list):
|
|
456
|
+
return tuple(
|
|
457
|
+
entry for entry in value
|
|
458
|
+
if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 1
|
|
459
|
+
)
|
|
460
|
+
if isinstance(value, str):
|
|
461
|
+
stripped = value.strip()
|
|
462
|
+
if stripped in {"", "(none)"}:
|
|
463
|
+
return ()
|
|
464
|
+
numbers: list[int] = []
|
|
465
|
+
for token in stripped.split(","):
|
|
466
|
+
piece = token.strip()
|
|
467
|
+
if piece.isdigit() and int(piece) >= 1:
|
|
468
|
+
numbers.append(int(piece))
|
|
469
|
+
return tuple(numbers)
|
|
470
|
+
return ()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _planning_stage_rows(planning: Mapping[str, Any]) -> list[tuple[int, tuple[int, ...]]]:
|
|
474
|
+
stage_map = planning.get("stageMap")
|
|
475
|
+
if isinstance(stage_map, list) and stage_map:
|
|
476
|
+
rows: list[tuple[int, tuple[int, ...]]] = []
|
|
477
|
+
for row in stage_map:
|
|
478
|
+
if not isinstance(row, Mapping):
|
|
479
|
+
continue
|
|
480
|
+
number = row.get("stage")
|
|
481
|
+
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
|
482
|
+
continue
|
|
483
|
+
rows.append((number, _depends_on(row.get("dependsOn"))))
|
|
484
|
+
return rows
|
|
485
|
+
stages = planning.get("stages")
|
|
486
|
+
if not isinstance(stages, list):
|
|
487
|
+
return []
|
|
488
|
+
rows = []
|
|
489
|
+
for row in stages:
|
|
490
|
+
if not isinstance(row, Mapping):
|
|
491
|
+
continue
|
|
492
|
+
number = row.get("stage")
|
|
493
|
+
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
|
494
|
+
continue
|
|
495
|
+
rows.append((number, _depends_on(row.get("dependsOn"))))
|
|
496
|
+
return rows
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def planning_stage_ledger(
|
|
500
|
+
planning: Mapping[str, Any],
|
|
501
|
+
disk_status: Mapping[str, str] | None = None,
|
|
502
|
+
) -> dict[str, str]:
|
|
503
|
+
"""지금 계획의 스테이지를 ``done`` / ``active`` / ``ready`` / ``blocked`` 로.
|
|
504
|
+
|
|
505
|
+
디스크 원장은 이미 구현된 것만 안다. 첫 계획 run 에는 원장이 없어서
|
|
506
|
+
게이트가 전 항목을 in-scope 로 읽었다. 이 함수는 현재 계획의 depends-on 으로
|
|
507
|
+
그 빈칸을 채운다. 디스크에 ``done`` / ``active`` 가 있으면 그쪽이 이긴다.
|
|
508
|
+
"""
|
|
509
|
+
recorded = {
|
|
510
|
+
key: value
|
|
511
|
+
for key, value in (disk_status or {}).items()
|
|
512
|
+
if value in {"done", "active", "ready", "blocked"}
|
|
513
|
+
}
|
|
514
|
+
done = {key for key, value in recorded.items() if value == "done"}
|
|
515
|
+
ledger: dict[str, str] = {}
|
|
516
|
+
for number, depends in _planning_stage_rows(planning):
|
|
517
|
+
key = str(number)
|
|
518
|
+
status = recorded.get(key)
|
|
519
|
+
if status in {"done", "active"}:
|
|
520
|
+
ledger[key] = status
|
|
521
|
+
continue
|
|
522
|
+
ready = all(str(dep) in done for dep in depends)
|
|
523
|
+
ledger[key] = "ready" if ready else "blocked"
|
|
524
|
+
return ledger
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def dispatch_item_ids(
|
|
528
|
+
items: Sequence[Mapping[str, Any]],
|
|
529
|
+
ledger: Mapping[str, str] | None,
|
|
530
|
+
) -> list[str]:
|
|
531
|
+
"""워커에게 보낼 항목. 게이트가 묻는 범위와 같다."""
|
|
532
|
+
ids: list[str] = []
|
|
533
|
+
for item in items:
|
|
534
|
+
item_id = item.get("id")
|
|
535
|
+
if not isinstance(item_id, str) or not item_id:
|
|
536
|
+
continue
|
|
537
|
+
if stage_scope_bucket(item, ledger) == "in-scope":
|
|
538
|
+
ids.append(item_id)
|
|
539
|
+
return ids
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def reverify_item_ids(
|
|
543
|
+
items: Sequence[Mapping[str, Any]],
|
|
544
|
+
previous_hashes: Mapping[str, str],
|
|
545
|
+
ledger: Mapping[str, str] | None,
|
|
546
|
+
) -> list[str]:
|
|
547
|
+
"""self-fix 뒤 다시 볼 항목. 해시가 바뀐 것과 같은 스테이지, 계획 전체.
|
|
548
|
+
|
|
549
|
+
이전이 없으면 첫 라운드라 디스패치 큐 전부다. 해시가 같은 다른 스테이지
|
|
550
|
+
항목은 보내지 않는다 — 그게 96개 일소 배치를 만들던 규칙이다.
|
|
551
|
+
"""
|
|
552
|
+
dispatched = dispatch_item_ids(items, ledger)
|
|
553
|
+
by_id = {
|
|
554
|
+
item["id"]: item
|
|
555
|
+
for item in items
|
|
556
|
+
if isinstance(item.get("id"), str)
|
|
557
|
+
}
|
|
558
|
+
if not previous_hashes:
|
|
559
|
+
return dispatched
|
|
560
|
+
changed = [
|
|
561
|
+
item_id for item_id in dispatched
|
|
562
|
+
if previous_hashes.get(item_id) != content_hash(by_id[item_id])
|
|
563
|
+
]
|
|
564
|
+
changed_stages: set[int] = set()
|
|
565
|
+
for item_id in changed:
|
|
566
|
+
scope = by_id[item_id].get("stageScope")
|
|
567
|
+
if isinstance(scope, list):
|
|
568
|
+
changed_stages.update(
|
|
569
|
+
value for value in scope
|
|
570
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
571
|
+
)
|
|
572
|
+
queue: list[str] = []
|
|
573
|
+
for item_id in dispatched:
|
|
574
|
+
item = by_id[item_id]
|
|
575
|
+
scope = item.get("stageScope")
|
|
576
|
+
stages = [
|
|
577
|
+
value for value in scope
|
|
578
|
+
if isinstance(value, int) and not isinstance(value, bool)
|
|
579
|
+
] if isinstance(scope, list) else []
|
|
580
|
+
if not stages:
|
|
581
|
+
if changed:
|
|
582
|
+
queue.append(item_id)
|
|
583
|
+
continue
|
|
584
|
+
if item_id in changed or changed_stages.intersection(stages):
|
|
585
|
+
queue.append(item_id)
|
|
586
|
+
return queue
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def advisory_plan_body_gating(
|
|
590
|
+
planning: Mapping[str, Any],
|
|
591
|
+
extracted: Sequence[Mapping[str, Any]] | None = None,
|
|
592
|
+
) -> bool:
|
|
593
|
+
"""검출 표면 0 + 스테이지 1이면 본문 검증은 자문만 한다.
|
|
594
|
+
|
|
595
|
+
준비 시점에는 계획이 없어 ``gating`` 을 false 로 둘 수 없다. 작성
|
|
596
|
+
초안과 탐지기 스냅샷이 생긴 뒤에만 이 판정을 쓴다. 다단계이거나
|
|
597
|
+
PREP 항목이 있으면 지금 게이트 계약 그대로다.
|
|
598
|
+
"""
|
|
599
|
+
if len(_planning_stage_rows(planning)) != 1:
|
|
600
|
+
return False
|
|
601
|
+
if extracted is not None and any(
|
|
602
|
+
str(item.get("id") or "").startswith("P-Prep-") for item in extracted
|
|
603
|
+
):
|
|
604
|
+
return False
|
|
605
|
+
preparation = planning.get("designPreparation")
|
|
606
|
+
if not isinstance(preparation, Mapping):
|
|
607
|
+
preparation = build_design_snapshot(planning).get("designPreparation")
|
|
608
|
+
if not isinstance(preparation, Mapping):
|
|
609
|
+
return False
|
|
610
|
+
items = preparation.get("items")
|
|
611
|
+
if preparation.get("mode") != "no-design-inputs":
|
|
612
|
+
return False
|
|
613
|
+
return not (isinstance(items, list) and items)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def tie_vote_item_ids(
|
|
617
|
+
items: Sequence[Mapping[str, Any]],
|
|
618
|
+
ledger: Mapping[str, str] | None,
|
|
619
|
+
tied_ids: Sequence[str],
|
|
620
|
+
) -> list[str]:
|
|
621
|
+
"""needs-reverify 동수 항목만 세 번째 표에 보낸다.
|
|
622
|
+
|
|
623
|
+
첫 라운드 큐나 self-fix 재검증 큐와 섞지 않는다. 동수가 없으면 빈 목록이고
|
|
624
|
+
세 번째 워커는 뜨지 않는다.
|
|
625
|
+
"""
|
|
626
|
+
allowed = set(dispatch_item_ids(items, ledger))
|
|
627
|
+
queue: list[str] = []
|
|
628
|
+
seen: set[str] = set()
|
|
629
|
+
for item_id in tied_ids:
|
|
630
|
+
if item_id in allowed and item_id not in seen:
|
|
631
|
+
queue.append(item_id)
|
|
632
|
+
seen.add(item_id)
|
|
633
|
+
return queue
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
_ERROR_VERDICTS = frozenset({
|
|
637
|
+
"verification-error", "UNVERIFIABLE", "VERIFICATION-ERROR",
|
|
638
|
+
})
|
|
639
|
+
_COMMAND_KEYS = ("command", "commandOrObservation")
|
|
640
|
+
|
|
641
|
+
ENVIRONMENT_CORRECTION_PREAMBLE = (
|
|
642
|
+
"Planning-time environment gap: this worktree has no build/test "
|
|
643
|
+
"dependencies installed. A command that is declared in package.json / "
|
|
644
|
+
"Makefile / the task runner but fails here on a missing module, binary, "
|
|
645
|
+
"exit 127, or command-not-found is UNVERIFIABLE, not DISAGREE(b). A "
|
|
646
|
+
"referenced path that does not exist is still DISAGREE(b). Whether a "
|
|
647
|
+
"path exists, whether a command is declared, and whether the plan is "
|
|
648
|
+
"internally consistent are all checkable without installing dependencies. "
|
|
649
|
+
"A blanket \"capability constraints prevent workspace resolution\" is not "
|
|
650
|
+
"a valid answer to any of them.\n"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def item_cites_build_command(item: Mapping[str, Any]) -> bool:
|
|
655
|
+
"""이 항목이 계획 워크트리에서 실행 불가한 빌드/테스트 명령을 인용하는가."""
|
|
656
|
+
payload = item.get("payload") if isinstance(item.get("payload"), Mapping) else item
|
|
657
|
+
if not isinstance(payload, Mapping):
|
|
658
|
+
return False
|
|
659
|
+
for key in _COMMAND_KEYS:
|
|
660
|
+
value = payload.get(key)
|
|
661
|
+
if isinstance(value, str) and command_invokes_build_tool(value):
|
|
662
|
+
return True
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _error_verdict(token: str) -> bool:
|
|
667
|
+
stripped = token.strip()
|
|
668
|
+
return stripped in _ERROR_VERDICTS or stripped.upper() == "UNVERIFIABLE"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _item_votes(item: Mapping[str, Any]) -> list[tuple[str, str]]:
|
|
672
|
+
rows: list[tuple[str, str]] = []
|
|
673
|
+
for row in item.get("verdicts") or []:
|
|
674
|
+
if not isinstance(row, Mapping):
|
|
675
|
+
continue
|
|
676
|
+
worker = str(row.get("worker") or "").strip()
|
|
677
|
+
token = str(row.get("verdict") or "").strip()
|
|
678
|
+
if worker and token:
|
|
679
|
+
rows.append((worker, token))
|
|
680
|
+
return rows
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _worker_vote_map(
|
|
684
|
+
items: Sequence[Mapping[str, Any]],
|
|
685
|
+
) -> dict[str, list[tuple[str, str]]]:
|
|
686
|
+
votes: dict[str, list[tuple[str, str]]] = {}
|
|
687
|
+
for item in items:
|
|
688
|
+
item_id = str(item.get("id") or "")
|
|
689
|
+
if not item_id:
|
|
690
|
+
continue
|
|
691
|
+
for worker, token in _item_votes(item):
|
|
692
|
+
votes.setdefault(worker, []).append((item_id, token))
|
|
693
|
+
return votes
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _with_payload(
|
|
697
|
+
item: Mapping[str, Any],
|
|
698
|
+
payloads: Mapping[str, Mapping[str, Any]] | None,
|
|
699
|
+
) -> Mapping[str, Any]:
|
|
700
|
+
extra = payloads.get(str(item.get("id") or "")) if payloads else None
|
|
701
|
+
if extra is None or "payload" not in extra:
|
|
702
|
+
return item
|
|
703
|
+
merged = dict(item)
|
|
704
|
+
merged["payload"] = extra["payload"]
|
|
705
|
+
return merged
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _worker_is_blanket(
|
|
709
|
+
votes: Sequence[tuple[str, str]],
|
|
710
|
+
items_by_id: Mapping[str, Mapping[str, Any]],
|
|
711
|
+
payloads: Mapping[str, Mapping[str, Any]] | None,
|
|
712
|
+
) -> bool:
|
|
713
|
+
"""전 표가 오류이고, 빌드 명령이 아닌 확인 가능 항목이 하나라도 있다."""
|
|
714
|
+
if not votes or any(not _error_verdict(token) for _item_id, token in votes):
|
|
715
|
+
return False
|
|
716
|
+
return any(
|
|
717
|
+
not item_cites_build_command(_with_payload(items_by_id[item_id], payloads))
|
|
718
|
+
for item_id, _token in votes
|
|
719
|
+
if item_id in items_by_id
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def blanket_unverifiable_workers(
|
|
724
|
+
items: Sequence[Mapping[str, Any]],
|
|
725
|
+
payloads: Mapping[str, Mapping[str, Any]] | None = None,
|
|
726
|
+
) -> list[str]:
|
|
727
|
+
"""전 배정 표가 UNVERIFIABLE/verification-error 인 워커. 경로 확인은 예외가 아니다."""
|
|
728
|
+
by_id = {
|
|
729
|
+
str(item["id"]): item
|
|
730
|
+
for item in items
|
|
731
|
+
if isinstance(item.get("id"), str) and item["id"]
|
|
732
|
+
}
|
|
733
|
+
return sorted(
|
|
734
|
+
worker
|
|
735
|
+
for worker, votes in _worker_vote_map(items).items()
|
|
736
|
+
if _worker_is_blanket(votes, by_id, payloads)
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _is_tie(item: Mapping[str, Any]) -> bool:
|
|
741
|
+
tokens = [
|
|
742
|
+
token for _worker, token in _item_votes(item) if not _error_verdict(token)
|
|
743
|
+
]
|
|
744
|
+
if len(tokens) < 2:
|
|
745
|
+
return False
|
|
746
|
+
disagree = sum(1 for token in tokens if token.upper().startswith("DISAGREE"))
|
|
747
|
+
agree = sum(1 for token in tokens if token.upper() in {"AGREE", "SUPPLEMENT"})
|
|
748
|
+
return disagree == agree and disagree > 0
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@dataclass(frozen=True)
|
|
752
|
+
class NextDispatch:
|
|
753
|
+
"""다음 워커 배치. ``none`` 은 배치를 열지 않는다."""
|
|
754
|
+
|
|
755
|
+
kind: str
|
|
756
|
+
workers: tuple[str, ...]
|
|
757
|
+
item_ids: tuple[str, ...]
|
|
758
|
+
reason: str
|
|
759
|
+
|
|
760
|
+
def as_dict(self) -> dict[str, Any]:
|
|
761
|
+
return {
|
|
762
|
+
"kind": self.kind,
|
|
763
|
+
"workers": list(self.workers),
|
|
764
|
+
"itemIds": list(self.item_ids),
|
|
765
|
+
"reason": self.reason,
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def next_dispatch(
|
|
770
|
+
items: Sequence[Mapping[str, Any]],
|
|
771
|
+
payloads: Mapping[str, Mapping[str, Any]] | None = None,
|
|
772
|
+
) -> NextDispatch:
|
|
773
|
+
"""환경 전용 UNVERIFIABLE 은 전체 라운드를 만들지 않는다. 일괄 오류만 그 워커."""
|
|
774
|
+
assigned = tuple(
|
|
775
|
+
str(item["id"]) for item in items
|
|
776
|
+
if isinstance(item.get("id"), str) and item["id"]
|
|
777
|
+
)
|
|
778
|
+
blanket = tuple(blanket_unverifiable_workers(items, payloads))
|
|
779
|
+
if blanket:
|
|
780
|
+
return NextDispatch(
|
|
781
|
+
kind="worker-correction",
|
|
782
|
+
workers=blanket,
|
|
783
|
+
item_ids=assigned,
|
|
784
|
+
reason=(
|
|
785
|
+
"blanket UNVERIFIABLE/verification-error; "
|
|
786
|
+
"correct those workers only"
|
|
787
|
+
),
|
|
788
|
+
)
|
|
789
|
+
ties = tuple(
|
|
790
|
+
str(item["id"]) for item in items
|
|
791
|
+
if isinstance(item.get("id"), str) and _is_tie(item)
|
|
792
|
+
)
|
|
793
|
+
if ties:
|
|
794
|
+
return NextDispatch(
|
|
795
|
+
kind="queue-reverify",
|
|
796
|
+
workers=tuple(sorted(_worker_vote_map(items))),
|
|
797
|
+
item_ids=ties,
|
|
798
|
+
reason="unsettled tie on a blocking kind",
|
|
799
|
+
)
|
|
800
|
+
return NextDispatch(
|
|
801
|
+
kind="none", workers=(), item_ids=(),
|
|
802
|
+
reason="no worker batch",
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def correction_prompt_text(queue_markdown: str) -> str:
|
|
807
|
+
"""시정 프롬프트. 환경 예외 단락이 큐보다 앞이다."""
|
|
808
|
+
return f"{ENVIRONMENT_CORRECTION_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
|
|
809
|
+
|