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.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +385 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +10 -8
  10. package/docs/cli.md +9 -5
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +28 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +2 -2
  18. package/docs/task-process/common-flow.md +2 -3
  19. package/docs/task-process/error-analysis.md +3 -4
  20. package/docs/task-process/final-verification.md +2 -3
  21. package/docs/task-process/implementation-planning.md +3 -4
  22. package/docs/task-process/implementation.md +2 -3
  23. package/docs/task-process/release-handoff.md +3 -4
  24. package/docs/task-process/requirements-discovery.md +3 -4
  25. package/package.json +1 -1
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/prompts/launch.template.md +8 -7
  28. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  29. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  30. package/runtime/prompts/lead/report-writer.md +4 -4
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  32. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  33. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  34. package/runtime/prompts/profiles/implementation-planning.md +11 -12
  35. package/runtime/prompts/wizard/prompts.ko.json +9 -10
  36. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/conformance.py +37 -1
  40. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  41. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  42. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  43. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  44. package/runtime/python/okstra_ctl/render.py +4 -0
  45. package/runtime/python/okstra_ctl/user_response.py +147 -37
  46. package/runtime/python/okstra_ctl/wizard.py +52 -73
  47. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  48. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  49. package/runtime/skills/okstra-chat/SKILL.md +104 -0
  50. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  51. package/runtime/skills/okstra-run/SKILL.md +4 -4
  52. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  53. package/runtime/validators/validate-run.py +254 -81
  54. 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
+