okstra 0.152.0 → 0.154.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 (30) hide show
  1. package/README.md +1 -1
  2. package/bin/okstra +7 -0
  3. package/docs/cli.md +5 -1
  4. package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
  5. package/docs/project-structure-overview.md +2 -2
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/bin/okstra-antigravity-exec.sh +11 -6
  9. package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
  10. package/runtime/prompts/lead/convergence.md +3 -2
  11. package/runtime/prompts/lead/plan-body-verification.md +30 -1
  12. package/runtime/python/okstra_ctl/container.py +9 -10
  13. package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
  14. package/runtime/python/okstra_ctl/handoff.py +4 -8
  15. package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
  16. package/runtime/python/okstra_ctl/model_discovery.py +22 -1
  17. package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
  18. package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
  19. package/runtime/python/okstra_ctl/run.py +8 -54
  20. package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
  21. package/runtime/python/okstra_ctl/stage_map.py +288 -0
  22. package/runtime/python/okstra_ctl/wizard.py +24 -35
  23. package/runtime/python/okstra_project/state.py +19 -5
  24. package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
  25. package/runtime/templates/reports/schedule.template.md +9 -9
  26. package/runtime/validators/detect_self_mock.py +27 -2
  27. package/runtime/validators/validate-implementation-plan-stages.py +24 -63
  28. package/runtime/validators/validate-run.py +110 -0
  29. package/runtime/validators/validate-schedule.py +78 -10
  30. package/src/commands/inspect/stage-map.mjs +1 -1
@@ -61,16 +61,22 @@ branches and would pass by falling between them.
61
61
 
62
62
  `ADAPTERS` is keyed by the `self_mock_signals.EXT_TO_LANG` vocabulary — the same
63
63
  lang names gate A resolves a changed file to — so both gates answer to one set
64
- of language keys: `ts_js` (Stryker), `rust` (cargo-mutants), and `java`/`kotlin`
65
- (PIT, whose diff scoping is not wired up yet — see `PitAdapter`).
64
+ of language keys: `ts_js` (Stryker), `python` (Cosmic Ray), `rust`
65
+ (cargo-mutants), and `java`/`kotlin` (PIT, whose diff scoping is not wired up
66
+ yet — see `PitAdapter`).
66
67
  """
67
68
  from __future__ import annotations
68
69
 
70
+ import fnmatch
69
71
  import json
72
+ import os
70
73
  import re
71
74
  import shutil
75
+ import stat
72
76
  import subprocess
73
77
  import sys
78
+ import tempfile
79
+ import tomllib
74
80
  from pathlib import Path
75
81
  from typing import NamedTuple, Protocol
76
82
 
@@ -413,6 +419,414 @@ class StrykerAdapter:
413
419
  return None
414
420
 
415
421
 
422
+ COSMIC_RAY_CONFIG_PATH = Path("cosmic-ray.toml")
423
+
424
+ _COSMIC_WORKER_OUTCOMES = (
425
+ "normal",
426
+ "abnormal",
427
+ "exception",
428
+ "no-test",
429
+ "skipped",
430
+ )
431
+ _COSMIC_TEST_OUTCOMES = ("killed", "survived", "incompetent", None)
432
+ _COSMIC_CONCLUSIVE = ("killed", "survived")
433
+ _COSMIC_SESSION_FAILURES = (
434
+ "init-failed",
435
+ "baseline-failed",
436
+ "exec-failed",
437
+ "dump-failed",
438
+ )
439
+
440
+
441
+ class SourceState(NamedTuple):
442
+ """Bytes and permissions Cosmic Ray must leave unchanged."""
443
+
444
+ data: bytes
445
+ mode: int
446
+
447
+
448
+ class CosmicRayScope(NamedTuple):
449
+ """Validated configuration and every Python source it may mutate."""
450
+
451
+ config_path: Path
452
+ sources: tuple[Path, ...]
453
+
454
+
455
+ class CosmicRayConfig(NamedTuple):
456
+ """The source roots and exclusions declared by ``cosmic-ray.toml``."""
457
+
458
+ module_paths: tuple[Path, ...]
459
+ excluded_patterns: tuple[str, ...]
460
+
461
+
462
+ class CosmicRaySession(NamedTuple):
463
+ """The external CLI's dump, or the command that prevented one."""
464
+
465
+ dump_text: str | None
466
+ failure_reason: str | None
467
+
468
+
469
+ class CosmicRayScopeError(ValueError):
470
+ """A stable unsupported reason for an unsafe or incomplete config scope."""
471
+
472
+ def __init__(self, reason: str):
473
+ super().__init__(reason)
474
+ self.reason = reason
475
+
476
+
477
+ def _cosmic_ray_executable(worktree: Path | None) -> Path | None:
478
+ """Find an installed Cosmic Ray executable without starting a process."""
479
+ if worktree is None:
480
+ return None
481
+ root = Path(worktree)
482
+ local_paths = (
483
+ Path(".venv/bin/cosmic-ray"),
484
+ Path("venv/bin/cosmic-ray"),
485
+ Path(".venv/Scripts/cosmic-ray.exe"),
486
+ Path("venv/Scripts/cosmic-ray.exe"),
487
+ )
488
+ for relative in local_paths:
489
+ candidate = root / relative
490
+ if candidate.is_file() and os.access(candidate, os.X_OK):
491
+ return candidate
492
+ found = shutil.which("cosmic-ray")
493
+ return Path(found) if found else None
494
+
495
+
496
+ def _is_within(path: Path, parent: Path) -> bool:
497
+ try:
498
+ path.relative_to(parent)
499
+ return True
500
+ except ValueError:
501
+ return False
502
+
503
+
504
+ def _cosmic_config(config_path: Path, root: Path) -> CosmicRayConfig:
505
+ try:
506
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
507
+ except (OSError, tomllib.TOMLDecodeError) as exc:
508
+ raise CosmicRayScopeError("config-unreadable") from exc
509
+ config = data.get("cosmic-ray")
510
+ raw = config.get("module-path") if isinstance(config, dict) else None
511
+ values = [raw] if isinstance(raw, str) else raw
512
+ if not isinstance(values, list) or not values:
513
+ raise CosmicRayScopeError("config-unreadable")
514
+ if any(not isinstance(value, str) or not value.strip() for value in values):
515
+ raise CosmicRayScopeError("config-unreadable")
516
+ paths = tuple(
517
+ (root / value).resolve()
518
+ if not Path(value).is_absolute()
519
+ else Path(value).resolve()
520
+ for value in values
521
+ )
522
+ if any(not _is_within(path, root) for path in paths):
523
+ raise CosmicRayScopeError("config-unreadable")
524
+ exclusions: list[str] = []
525
+ for key in ("excluded-modules", "exclude-modules"):
526
+ raw_exclusions = config.get(key, [])
527
+ if not isinstance(raw_exclusions, list) or any(
528
+ not isinstance(pattern, str) or not pattern.strip()
529
+ for pattern in raw_exclusions
530
+ ):
531
+ raise CosmicRayScopeError("config-unreadable")
532
+ exclusions.extend(raw_exclusions)
533
+ return CosmicRayConfig(paths, tuple(exclusions))
534
+
535
+
536
+ def _cosmic_target_is_excluded(
537
+ target: Path,
538
+ root: Path,
539
+ patterns: tuple[str, ...],
540
+ ) -> bool:
541
+ relative = target.relative_to(root).as_posix()
542
+ return any(
543
+ fnmatch.fnmatchcase(relative, pattern) or Path(relative).match(pattern)
544
+ for pattern in patterns
545
+ )
546
+
547
+
548
+ def _configured_cosmic_sources(
549
+ module_paths: tuple[Path, ...],
550
+ root: Path,
551
+ ) -> tuple[Path, ...]:
552
+ sources: set[Path] = set()
553
+ for module_path in module_paths:
554
+ if module_path.is_file() and module_path.suffix == ".py":
555
+ candidates = (module_path,)
556
+ elif module_path.is_dir():
557
+ candidates = tuple(module_path.rglob("*.py"))
558
+ else:
559
+ raise CosmicRayScopeError("config-unreadable")
560
+ for candidate in candidates:
561
+ if candidate.is_symlink():
562
+ raise CosmicRayScopeError("config-unreadable")
563
+ resolved = candidate.resolve()
564
+ if not _is_within(resolved, root) or not resolved.is_file():
565
+ raise CosmicRayScopeError("config-unreadable")
566
+ sources.add(resolved)
567
+ return tuple(sorted(sources))
568
+
569
+
570
+ def _read_cosmic_ray_scope(worktree: Path, targets: list[Path]) -> CosmicRayScope:
571
+ root = Path(worktree).resolve()
572
+ config_path = root / COSMIC_RAY_CONFIG_PATH
573
+ config = _cosmic_config(config_path, root)
574
+ resolved_targets = tuple(
575
+ (root / target).resolve() if not target.is_absolute() else target.resolve()
576
+ for target in targets
577
+ )
578
+ if any(
579
+ not _is_within(target, root)
580
+ or _cosmic_target_is_excluded(target, root, config.excluded_patterns)
581
+ for target in resolved_targets
582
+ ):
583
+ raise CosmicRayScopeError("config-target-mismatch")
584
+ if any(
585
+ not any(
586
+ target == module_path
587
+ or (module_path.is_dir() and _is_within(target, module_path))
588
+ for module_path in config.module_paths
589
+ )
590
+ for target in resolved_targets
591
+ ):
592
+ raise CosmicRayScopeError("config-target-mismatch")
593
+ sources = _configured_cosmic_sources(config.module_paths, root)
594
+ if any(target not in sources for target in resolved_targets):
595
+ raise CosmicRayScopeError("config-target-mismatch")
596
+ return CosmicRayScope(config_path, sources)
597
+
598
+
599
+ def _snapshot_source_state(sources: tuple[Path, ...]) -> dict[Path, SourceState]:
600
+ return {
601
+ path: SourceState(
602
+ path.read_bytes(),
603
+ stat.S_IMODE(path.stat(follow_symlinks=False).st_mode),
604
+ )
605
+ for path in sources
606
+ }
607
+
608
+
609
+ def _restore_source_state(states: dict[Path, SourceState]) -> bool:
610
+ restored = True
611
+ for path, expected in states.items():
612
+ try:
613
+ if path.is_symlink() or (path.exists() and not path.is_file()):
614
+ restored = False
615
+ continue
616
+ if not path.exists() or path.read_bytes() != expected.data:
617
+ path.write_bytes(expected.data)
618
+ path.chmod(expected.mode)
619
+ actual = SourceState(
620
+ path.read_bytes(),
621
+ stat.S_IMODE(path.stat(follow_symlinks=False).st_mode),
622
+ )
623
+ restored = restored and actual == expected
624
+ except OSError:
625
+ restored = False
626
+ return restored
627
+
628
+
629
+ def _run_cosmic_ray_session(
630
+ executable: Path,
631
+ config_path: Path,
632
+ session_path: Path,
633
+ worktree: Path,
634
+ ) -> CosmicRaySession:
635
+ root = Path(worktree)
636
+ try:
637
+ config_arg = str(config_path.relative_to(root))
638
+ except ValueError:
639
+ config_arg = str(config_path)
640
+ commands = (
641
+ ("init", [str(executable), "init", config_arg, str(session_path)]),
642
+ (
643
+ "baseline",
644
+ [str(executable), "baseline", "--report", config_arg, str(session_path)],
645
+ ),
646
+ ("exec", [str(executable), "exec", config_arg, str(session_path)]),
647
+ ("dump", [str(executable), "dump", str(session_path)]),
648
+ )
649
+ for verb, argv in commands:
650
+ try:
651
+ completed = subprocess.run(
652
+ argv,
653
+ cwd=str(root),
654
+ capture_output=True,
655
+ text=True,
656
+ check=False,
657
+ )
658
+ except OSError:
659
+ return CosmicRaySession(None, f"{verb}-failed")
660
+ if completed.returncode != 0:
661
+ return CosmicRaySession(None, f"{verb}-failed")
662
+ if verb == "dump":
663
+ return CosmicRaySession(completed.stdout, None)
664
+ return CosmicRaySession(None, "dump-failed")
665
+
666
+
667
+ def _cosmic_diff_file(diff: object, worktree: Path) -> str | None:
668
+ if not isinstance(diff, list) or any(not isinstance(line, str) for line in diff):
669
+ return None
670
+ new_paths = [line[4:].split("\t", 1)[0] for line in diff if line.startswith("+++ ")]
671
+ if len(new_paths) != 1 or new_paths[0] == "/dev/null":
672
+ return None
673
+ raw = new_paths[0]
674
+ if raw.startswith("b/"):
675
+ raw = raw[2:]
676
+ candidate = Path(raw)
677
+ root = Path(worktree).resolve()
678
+ if candidate.is_absolute():
679
+ resolved = candidate.resolve()
680
+ else:
681
+ if ".." in candidate.parts:
682
+ return None
683
+ absolute_style = (Path("/") / candidate).resolve()
684
+ resolved = (
685
+ absolute_style if _is_within(absolute_style, root) else root / candidate
686
+ )
687
+ if not _is_within(resolved, root):
688
+ return None
689
+ return str(resolved.relative_to(root))
690
+
691
+
692
+ def _parse_cosmic_ray_dump(
693
+ dump_text: str,
694
+ diff_path: Path | None,
695
+ worktree: Path,
696
+ ) -> ParsedReport | None:
697
+ scope = read_diff(diff_path)
698
+ if scope is None:
699
+ return None
700
+ survivors: list[dict] = []
701
+ observed = 0
702
+ conclusive = 0
703
+ for raw in dump_text.splitlines():
704
+ if not raw.strip():
705
+ continue
706
+ try:
707
+ row = json.loads(raw)
708
+ except json.JSONDecodeError:
709
+ return None
710
+ if not isinstance(row, dict):
711
+ return None
712
+ worker = row.get("worker_outcome")
713
+ outcome = row.get("test_outcome")
714
+ file = _cosmic_diff_file(row.get("diff"), worktree)
715
+ line = _coerce_line(row.get("line_number"))
716
+ operator = row.get("operator")
717
+ occurrence = row.get("occurrence")
718
+ if (
719
+ worker not in _COSMIC_WORKER_OUTCOMES
720
+ or outcome not in _COSMIC_TEST_OUTCOMES
721
+ ):
722
+ return None
723
+ if worker == "normal" and outcome is None:
724
+ return None
725
+ if file is None or line is None or line < 1:
726
+ return None
727
+ if not isinstance(operator, str) or not operator.strip():
728
+ return None
729
+ if not isinstance(occurrence, int) or isinstance(occurrence, bool):
730
+ return None
731
+ if line not in scope.touched.get(selfmock_path_key(file), ()):
732
+ continue
733
+ observed += 1
734
+ if worker == "normal" and outcome in _COSMIC_CONCLUSIVE:
735
+ conclusive += 1
736
+ if worker == "normal" and outcome == "survived":
737
+ survivors.append(
738
+ {
739
+ "file": file,
740
+ "line": line,
741
+ "mutant": f"{operator}#{occurrence}",
742
+ "status": outcome,
743
+ }
744
+ )
745
+ return ParsedReport(survivors, conclusive, observed)
746
+
747
+
748
+ def _cosmic_session_verdict(
749
+ session: CosmicRaySession,
750
+ diff_path: Path | None,
751
+ worktree: Path,
752
+ tool: str,
753
+ ) -> ProbeResult:
754
+ if not isinstance(session, CosmicRaySession):
755
+ return unsupported("report-unparsed", tool=tool)
756
+ if session.failure_reason is not None:
757
+ if session.failure_reason not in _COSMIC_SESSION_FAILURES:
758
+ return unsupported("report-unparsed", tool=tool)
759
+ return unsupported(session.failure_reason, tool=tool)
760
+ if not isinstance(session.dump_text, str):
761
+ return unsupported("report-unparsed", tool=tool)
762
+ parsed = _parse_cosmic_ray_dump(session.dump_text, diff_path, worktree)
763
+ if parsed is None:
764
+ return unsupported("report-unparsed", tool=tool)
765
+ if parsed.observed == 0:
766
+ return unsupported("no-mutable-changed-lines", tool=tool)
767
+ return _verdict_from_parsed(parsed, diff_path, worktree, tool)
768
+
769
+
770
+ class CosmicRayAdapter:
771
+ """Gate B for Python, wrapping an explicitly configured Cosmic Ray CLI."""
772
+
773
+ name = "cosmic-ray"
774
+
775
+ def __init__(self, runner=_run_cosmic_ray_session):
776
+ self._runner = runner
777
+
778
+ def is_declared(self, worktree: Path | None) -> bool:
779
+ if worktree is None:
780
+ return False
781
+ root = Path(worktree)
782
+ return (root / COSMIC_RAY_CONFIG_PATH).is_file() and (
783
+ _cosmic_ray_executable(root) is not None
784
+ )
785
+
786
+ def run(
787
+ self,
788
+ targets: list[Path],
789
+ diff_path: Path | None,
790
+ worktree: Path | None,
791
+ ) -> ProbeResult:
792
+ if worktree is None:
793
+ return unsupported("config-unreadable", tool=self.name)
794
+ root = Path(worktree)
795
+ executable = _cosmic_ray_executable(root)
796
+ if executable is None:
797
+ return unsupported("tool-not-declared", tool=self.name)
798
+ try:
799
+ scope = _read_cosmic_ray_scope(root, targets)
800
+ states = _snapshot_source_state(scope.sources)
801
+ except CosmicRayScopeError as exc:
802
+ return unsupported(exc.reason, tool=self.name)
803
+ except OSError:
804
+ return unsupported("source-integrity-failed", tool=self.name)
805
+ result: ProbeResult
806
+ try:
807
+ with tempfile.TemporaryDirectory(prefix="okstra-cosmic-ray-") as temp_dir:
808
+ session_path = Path(temp_dir) / "session.sqlite"
809
+ session = self._runner(
810
+ executable,
811
+ scope.config_path,
812
+ session_path,
813
+ root,
814
+ )
815
+ result = _cosmic_session_verdict(
816
+ session,
817
+ diff_path,
818
+ root,
819
+ self.name,
820
+ )
821
+ except OSError:
822
+ result = unsupported("exec-failed", tool=self.name)
823
+ finally:
824
+ sources_restored = _restore_source_state(states)
825
+ if not sources_restored:
826
+ return unsupported("source-integrity-failed", tool=self.name)
827
+ return result
828
+
829
+
416
830
  CARGO_OUTCOMES_PATH = Path("mutants.out/outcomes.json")
417
831
 
418
832
  # cargo-mutants' own vocabulary: `caught` (a test failed, good), `missed` (no
@@ -665,6 +1079,7 @@ class PitAdapter:
665
1079
  _PIT = PitAdapter()
666
1080
  ADAPTERS: dict[str, Adapter] = {
667
1081
  "ts_js": StrykerAdapter(),
1082
+ "python": CosmicRayAdapter(),
668
1083
  "rust": CargoMutantsAdapter(),
669
1084
  "java": _PIT,
670
1085
  "kotlin": _PIT,
@@ -1107,6 +1522,7 @@ CAPABILITY_GAP_REASONS = frozenset(
1107
1522
  NOTHING_TO_VERIFY_REASONS = frozenset(
1108
1523
  {
1109
1524
  "no-mutants-generated",
1525
+ "no-mutable-changed-lines",
1110
1526
  "diff-adds-no-line",
1111
1527
  }
1112
1528
  )
@@ -1127,6 +1543,13 @@ INTEGRITY_INSPECTION_REASONS = frozenset(
1127
1543
  "report-unparsed",
1128
1544
  "adapter-malformed-status",
1129
1545
  "no-conclusive-mutants",
1546
+ "config-unreadable",
1547
+ "config-target-mismatch",
1548
+ "init-failed",
1549
+ "baseline-failed",
1550
+ "exec-failed",
1551
+ "dump-failed",
1552
+ "source-integrity-failed",
1130
1553
  }
1131
1554
  )
1132
1555
 
@@ -26,6 +26,17 @@ class PlanRun(NamedTuple):
26
26
  approved_plan_path: str
27
27
 
28
28
 
29
+ def list_implementation_planning_reports(reports_dir: Path) -> list[Path]:
30
+ """Return numbered implementation-planning reports in latest-first order."""
31
+ numbered: list[tuple[int, Path]] = []
32
+ for report in reports_dir.glob("final-report-implementation-planning-*.md"):
33
+ match = _FINAL_REPORT_RE.fullmatch(report.name)
34
+ if match:
35
+ numbered.append((int(match.group(1)), report))
36
+ numbered.sort(key=lambda item: item[0], reverse=True)
37
+ return [report for _, report in numbered]
38
+
39
+
29
40
  def plan_run_root_from_approved_plan(approved_plan_path: str | Path) -> Path:
30
41
  """approved-plan(final-report) 경로에서 plan_run_root 를 역산한다.
31
42
 
@@ -58,21 +69,17 @@ def resolve_plan_run_root_by_task_key(
58
69
  f"({reports_dir} 없음). 먼저 implementation-planning 을 완료하거나 "
59
70
  "approved-plan 경로를 직접 지정하세요."
60
71
  )
61
- candidates: list[tuple[int, Path, Path]] = []
62
- for report in reports_dir.glob("final-report-implementation-planning-*.md"):
63
- m = _FINAL_REPORT_RE.search(report.name)
64
- if not m:
65
- continue
72
+ candidates: list[tuple[Path, Path]] = []
73
+ for report in list_implementation_planning_reports(reports_dir):
66
74
  run_root = plan_run_root_from_approved_plan(report)
67
75
  done = [r for r in read_consumers(run_root) if r.get("status") == "done"]
68
76
  if done:
69
- candidates.append((int(m.group(1)), run_root, report))
77
+ candidates.append((run_root, report))
70
78
  if not candidates:
71
79
  raise PrepareError(
72
80
  "container up: done 상태의 implementation-planning run 이 없습니다. "
73
81
  "stage 를 완료(implementation done)한 뒤 다시 시도하거나 approved-plan "
74
82
  "경로를 직접 지정하세요."
75
83
  )
76
- candidates.sort(key=lambda c: c[0])
77
- _, run_root, report = candidates[-1]
84
+ run_root, report = candidates[0]
78
85
  return PlanRun(run_root=run_root, approved_plan_path=str(report))
@@ -15,7 +15,6 @@ state passing, and are read once at the start.
15
15
  """
16
16
  from __future__ import annotations
17
17
 
18
- import importlib.util
19
18
  import hashlib
20
19
  import json
21
20
  import os
@@ -475,62 +474,17 @@ def _validate_stage_structure(plan_path: str) -> None:
475
474
  RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
476
475
 
477
476
 
478
- def _stage_map_reject_detail(stages, errs):
479
- """빈/손상 Stage Map 거부 사유 문자열을 돌려준다. 정상이면 None.
480
-
481
- 빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
482
- (`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
483
- 통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
484
- stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
485
- 게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
486
- if stages and not errs:
487
- return None
488
- return (
489
- "; ".join(e.message for e in errs) if errs
490
- else "'## 5.5 Stage Map' heading 이 없거나 표가 비었습니다")
491
-
492
-
493
- def _load_parsed_stage_map(text: str):
494
- """validator 모듈을 로드해 `_parse_stage_map(text)` 의 (stages, errs) 를 돌려준다.
477
+ def _parse_stage_map_into_ctx(plan_path: str) -> list:
478
+ """Parse the approved plan into context records for execution consumers."""
479
+ from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
495
480
 
496
- run.py 와 wizard 가 공유하는 단일 validator-load 경로 — 둘 다 `_STAGE_VALIDATOR_PATH`
497
- (설치 시 `~/.okstra/lib/validators` 로 배치돼 `parents[2]/validators` 로 해소)로
498
- 수렴해 경로 해소가 갈라지지 않게 한다."""
499
- spec = importlib.util.spec_from_file_location(
500
- "_ip_stage_validator", _STAGE_VALIDATOR_PATH
501
- )
502
- if spec is None or spec.loader is None:
503
- raise PrepareError(f"cannot load stage validator at {_STAGE_VALIDATOR_PATH}")
504
- mod = importlib.util.module_from_spec(spec)
505
- # Register before exec_module so dataclass field-type resolution can find
506
- # the module in sys.modules (required on Python 3.9).
507
- sys.modules["_ip_stage_validator"] = mod
508
481
  try:
509
- spec.loader.exec_module(mod)
510
- return mod._parse_stage_map(text)
511
- finally:
512
- sys.modules.pop("_ip_stage_validator", None)
513
-
514
-
515
- def _parse_stage_map_into_ctx(plan_path: str) -> list:
516
- """Reuse the validator's parser to extract StageMeta dicts for the ctx."""
517
- text = Path(plan_path).read_text(encoding="utf-8")
518
- stages, errs = _load_parsed_stage_map(text)
519
- detail = _stage_map_reject_detail(stages, errs)
520
- if detail is not None:
482
+ return stage_map_records(parse_stage_map_file(Path(plan_path)))
483
+ except StageMapError as exc:
521
484
  raise PrepareError(
522
- f"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 ({plan_path}): "
523
- f"{detail}. plan 의 Stage Map 을 점검하세요.")
524
- return [
525
- {
526
- "stage_number": s.stage_number,
527
- "title": s.title,
528
- "depends_on": list(s.depends_on),
529
- "step_count": s.step_count,
530
- "exit_contract_summary": s.exit_contract_summary,
531
- }
532
- for s in stages
533
- ]
485
+ "approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 "
486
+ f"({plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
487
+ ) from exc
534
488
 
535
489
 
536
490
  def _apply_cli_approval(path: str) -> str: