okstra 0.97.1 → 0.98.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.
Files changed (77) hide show
  1. package/README.kr.md +4 -3
  2. package/README.md +4 -3
  3. package/docs/kr/architecture.md +2 -2
  4. package/docs/kr/cli.md +2 -0
  5. package/docs/kr/container.md +122 -0
  6. package/docs/project-structure-overview.md +12 -2
  7. package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +714 -0
  8. package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +125 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/antigravity-worker.md +2 -18
  12. package/runtime/agents/workers/claude-worker.md +10 -33
  13. package/runtime/agents/workers/codex-worker.md +2 -18
  14. package/runtime/agents/workers/report-writer-worker.md +2 -10
  15. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +5 -2
  16. package/runtime/bin/okstra-antigravity-exec.sh +6 -1
  17. package/runtime/bin/okstra-claude-exec.sh +6 -1
  18. package/runtime/bin/okstra-codex-exec.sh +6 -1
  19. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -0
  20. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  21. package/runtime/prompts/profiles/_common-contract.md +3 -3
  22. package/runtime/prompts/profiles/_coverage-critic.md +17 -0
  23. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  24. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  25. package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
  26. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  27. package/runtime/prompts/profiles/_stage-discipline.md +5 -8
  28. package/runtime/prompts/profiles/error-analysis.md +5 -5
  29. package/runtime/prompts/profiles/final-verification.md +4 -4
  30. package/runtime/prompts/profiles/implementation-planning.md +7 -7
  31. package/runtime/prompts/profiles/implementation.md +5 -5
  32. package/runtime/prompts/profiles/improvement-discovery.md +6 -6
  33. package/runtime/prompts/profiles/release-handoff.md +3 -3
  34. package/runtime/prompts/profiles/requirements-discovery.md +4 -4
  35. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  36. package/runtime/python/okstra_ctl/__init__.py +4 -1
  37. package/runtime/python/okstra_ctl/container.py +970 -0
  38. package/runtime/python/okstra_ctl/container_registry.py +93 -0
  39. package/runtime/python/okstra_ctl/error_zip.py +4 -4
  40. package/runtime/python/okstra_ctl/handoff.py +7 -1
  41. package/runtime/python/okstra_ctl/ids.py +40 -1
  42. package/runtime/python/okstra_ctl/listing.py +3 -5
  43. package/runtime/python/okstra_ctl/log_report.py +102 -0
  44. package/runtime/python/okstra_ctl/pane_reclaim.py +5 -4
  45. package/runtime/python/okstra_ctl/paths.py +40 -0
  46. package/runtime/python/okstra_ctl/plan_run_root.py +78 -0
  47. package/runtime/python/okstra_ctl/reconcile.py +4 -2
  48. package/runtime/python/okstra_ctl/resolve_task_key.py +54 -0
  49. package/runtime/python/okstra_ctl/run.py +48 -30
  50. package/runtime/python/okstra_ctl/stage_integrate.py +8 -2
  51. package/runtime/python/okstra_ctl/stage_targets.py +79 -0
  52. package/runtime/python/okstra_ctl/time_report.py +200 -0
  53. package/runtime/python/okstra_ctl/tmux.py +67 -0
  54. package/runtime/python/okstra_ctl/wizard.py +35 -20
  55. package/runtime/python/okstra_project/__init__.py +2 -0
  56. package/runtime/python/okstra_project/state.py +50 -2
  57. package/runtime/skills/okstra-brief/SKILL.md +17 -7
  58. package/runtime/skills/okstra-container/SKILL.md +169 -0
  59. package/runtime/skills/okstra-inspect/SKILL.md +64 -178
  60. package/runtime/skills/okstra-memory/SKILL.md +8 -6
  61. package/runtime/skills/okstra-run/SKILL.md +4 -4
  62. package/runtime/skills/okstra-schedule/SKILL.md +9 -16
  63. package/runtime/skills/okstra-setup/SKILL.md +10 -9
  64. package/runtime/templates/reports/brief.template.md +1 -1
  65. package/runtime/templates/reports/final-report.template.md +1 -1
  66. package/runtime/templates/reports/settings.template.json +3 -1
  67. package/runtime/validators/validate-implementation-plan-stages.py +11 -3
  68. package/src/cli-registry.mjs +28 -0
  69. package/src/commands/inspect/container.mjs +27 -0
  70. package/src/commands/inspect/log-report.mjs +26 -0
  71. package/src/commands/inspect/resolve-task-key.mjs +26 -0
  72. package/src/commands/inspect/task-list.mjs +26 -15
  73. package/src/commands/inspect/time-report.mjs +26 -0
  74. package/src/commands/lifecycle/check-project.mjs +7 -1
  75. package/src/commands/lifecycle/doctor.mjs +16 -0
  76. package/src/lib/skill-catalog.mjs +1 -0
  77. package/runtime/agents/TODO.md +0 -226
@@ -91,6 +91,7 @@ from .workers import (
91
91
  from .workflow import compute_workflow_state, load_phase_forbidden
92
92
  from .locks import worktree_provision_mutex
93
93
  from . import stage_targets as _stage_targets
94
+ from .plan_run_root import plan_run_root_from_approved_plan
94
95
  from . import stage_integrate as _stage_integrate
95
96
  from . import implementation_stage as _implementation_stage
96
97
  from .stage_reconcile import (
@@ -287,8 +288,9 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
287
288
  return True
288
289
 
289
290
 
290
- class PrepareError(Exception):
291
- """surface to caller task bundle prepare failed."""
291
+ # PrepareError 는 stage_targets(저수준)에 정의돼 final-verification 과 container
292
+ # whole-task 통합 실패를 동일 타입으로 받는다. run.py 는 재노출만 한다.
293
+ PrepareError = _stage_targets.PrepareError
292
294
 
293
295
 
294
296
  _ALLOWED_LEAD_RUNTIMES = ALLOWED_LEAD_RUNTIMES
@@ -543,9 +545,27 @@ def _resolve_single_stage_target(
543
545
  raise _stage_target_prepare_error(exc) from exc
544
546
 
545
547
 
546
- def _parse_stage_map_into_ctx(plan_path: str) -> list:
547
- """Reuse the validator's parser to extract StageMeta dicts for the ctx."""
548
- text = Path(plan_path).read_text(encoding="utf-8")
548
+ def _stage_map_reject_detail(stages, errs):
549
+ """빈/손상 Stage Map 거부 사유 문자열을 돌려준다. 정상이면 None.
550
+
551
+ 빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
552
+ (resolve_whole_task_target 의 `for stage in stage_map`)가 0회 순회로 vacuous
553
+ 통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
554
+ stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
555
+ 게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
556
+ if stages and not errs:
557
+ return None
558
+ return (
559
+ "; ".join(e.message for e in errs) if errs
560
+ else "'## 5.5 Stage Map' heading 이 없거나 표가 비었습니다")
561
+
562
+
563
+ def _load_parsed_stage_map(text: str):
564
+ """validator 모듈을 로드해 `_parse_stage_map(text)` 의 (stages, errs) 를 돌려준다.
565
+
566
+ run.py 와 wizard 가 공유하는 단일 validator-load 경로 — 둘 다 `_STAGE_VALIDATOR_PATH`
567
+ (설치 시 `~/.okstra/lib/validators` 로 배치돼 `parents[2]/validators` 로 해소)로
568
+ 수렴해 경로 해소가 갈라지지 않게 한다."""
549
569
  spec = importlib.util.spec_from_file_location(
550
570
  "_ip_stage_validator", _STAGE_VALIDATOR_PATH
551
571
  )
@@ -557,9 +577,20 @@ def _parse_stage_map_into_ctx(plan_path: str) -> list:
557
577
  sys.modules["_ip_stage_validator"] = mod
558
578
  try:
559
579
  spec.loader.exec_module(mod)
580
+ return mod._parse_stage_map(text)
560
581
  finally:
561
582
  sys.modules.pop("_ip_stage_validator", None)
562
- stages, _errs = mod._parse_stage_map(text)
583
+
584
+
585
+ def _parse_stage_map_into_ctx(plan_path: str) -> list:
586
+ """Reuse the validator's parser to extract StageMeta dicts for the ctx."""
587
+ text = Path(plan_path).read_text(encoding="utf-8")
588
+ stages, errs = _load_parsed_stage_map(text)
589
+ detail = _stage_map_reject_detail(stages, errs)
590
+ if detail is not None:
591
+ raise PrepareError(
592
+ f"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 ({plan_path}): "
593
+ f"{detail}. plan 의 Stage Map 을 점검하세요.")
563
594
  return [
564
595
  {
565
596
  "stage_number": s.stage_number,
@@ -1105,9 +1136,7 @@ def _materialize_release_handoff_input(
1105
1136
  if not plan.is_file():
1106
1137
  raise PrepareError(f"approved plan not found: {plan}")
1107
1138
  stage_map = _parse_stage_map_into_ctx(str(plan))
1108
- if not stage_map:
1109
- raise PrepareError(f"approved plan has no parsable Stage Map: {plan}")
1110
- rows = read_consumers(plan.resolve().parents[1])
1139
+ rows = read_consumers(plan_run_root_from_approved_plan(plan))
1111
1140
 
1112
1141
  if inp.stages:
1113
1142
  try:
@@ -1488,7 +1517,7 @@ def _reserve_final_verification_target(
1488
1517
  from .consumers import read_consumers, backfill_done_from_carry
1489
1518
  from . import worktree_registry as _reg
1490
1519
 
1491
- plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1520
+ plan_run_root = plan_run_root_from_approved_plan(inp.approved_plan_path)
1492
1521
  # carry sidecars are the SSOT for stage completion — recover missing `done`
1493
1522
  # rows before the whole-task gate checks every stage.
1494
1523
  backfill_done_from_carry(plan_run_root)
@@ -1520,27 +1549,16 @@ def _reserve_final_verification_target(
1520
1549
  wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1521
1550
  anchor = _reg.get_implementation_base(
1522
1551
  inp.project_id, inp.task_group, inp.task_id) or ""
1523
- try:
1524
- integ = _stage_integrate.integrate_stages(
1525
- project_id=inp.project_id, task_group=inp.task_group,
1526
- task_id=inp.task_id, task_worktree_path=wt_path,
1527
- stage_map=ctx_stage_map, done_rows=done_rows,
1528
- )
1529
- except _stage_integrate.IntegrateError as exc:
1530
- raise PrepareError(str(exc)) from exc
1531
- ctx["STAGE_INTEGRATION"] = _format_integration(integ)
1532
- head = _git_out(wt_path, "rev-parse", "HEAD")
1533
- # integrate 가 registry 기반으로 머지/skip 한 것과 별개로, 각 stage done
1534
- # commit 이 실제 task HEAD 의 ancestor 인지 ground-truth 로 확인한다 —
1535
- # stage-key 가 이미 teardown 된 stage 라도 커밋이 HEAD 에 남아있으면 통과.
1536
- from .consumers import latest_done_by_stage
1537
- merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1538
- for s, r in latest_done_by_stage(done_rows).items()}
1539
- target = _resolve_whole_task_target(
1540
- stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
1541
- task_worktree_path=wt_path, task_head=head,
1542
- task_dirty=_is_dirty_excluding_okstra(wt_path), merged=merged,
1552
+ # whole-task 통합(머지+teardown)+게이트 강제는 container 와 공유하는
1553
+ # 순수 헬퍼가 단일 책임. ctx 주입만 run.py 측에 남긴다.
1554
+ whole = _stage_targets.resolve_and_integrate_whole_task(
1555
+ project_id=inp.project_id, task_group=inp.task_group,
1556
+ task_id=inp.task_id, task_worktree_path=wt_path,
1557
+ stage_map=ctx_stage_map, done_rows=done_rows,
1558
+ anchor_base=anchor, teardown=True,
1543
1559
  )
1560
+ ctx["STAGE_INTEGRATION"] = _format_integration(whole["integrate_result"])
1561
+ target = whole["target"]
1544
1562
 
1545
1563
  diff_stat = _git_out(target.worktree_path, "diff", "--stat",
1546
1564
  f"{target.base}..{target.head}")
@@ -60,8 +60,13 @@ def integrate_stages(
60
60
  *, project_id: str, task_group: str, task_id: str,
61
61
  task_worktree_path: str, stage_map: list[dict[str, Any]],
62
62
  done_rows: list[dict[str, Any]],
63
+ teardown: bool = True,
63
64
  ) -> IntegrateResult:
64
- """whole-task 통합: Phase A 머지 → Phase B teardown."""
65
+ """whole-task 통합: Phase A 머지 → Phase B teardown.
66
+
67
+ teardown=False 면 Phase B(stage worktree·브랜치·registry 키 정리)를 건너뛴다 —
68
+ container 는 머지만 하고 stage 트리를 보존한다(스펙 §10). final-verification
69
+ 호출은 기본값(True)으로 기존 행위를 유지한다."""
65
70
  from .consumers import latest_done_by_stage
66
71
 
67
72
  res = IntegrateResult()
@@ -91,7 +96,8 @@ def integrate_stages(
91
96
  raise
92
97
  (res.merged if outcome == "merged" else res.already_merged).append(n)
93
98
 
94
- _teardown_all(project_id, task_group, task_id, task_worktree_path, res)
99
+ if teardown:
100
+ _teardown_all(project_id, task_group, task_id, task_worktree_path, res)
95
101
  return res
96
102
 
97
103
 
@@ -19,6 +19,14 @@ class StageTargetError(Exception):
19
19
  """Stage target selection or verification precondition failed."""
20
20
 
21
21
 
22
+ class PrepareError(Exception):
23
+ """surface to caller — task bundle prepare failed.
24
+
25
+ Defined here(저수준 모듈)so both run.py(final-verification)와
26
+ container.py 가 whole-task 통합 헬퍼의 실패를 동일 타입으로 받을 수 있다.
27
+ run.py 는 이 클래스를 그대로 재노출한다(단일 참조점)."""
28
+
29
+
22
30
  @dataclass
23
31
  class FinalVerificationTarget:
24
32
  scope: str # "whole-task" | "single-stage"
@@ -232,6 +240,77 @@ def resolve_whole_task_target(
232
240
  )
233
241
 
234
242
 
243
+ def _merged_stage_map(
244
+ task_worktree_path: str, done_rows: list[dict[str, Any]], head: str,
245
+ ) -> dict[int, bool]:
246
+ """각 stage 의 done commit 이 실제 task HEAD 의 ancestor 인지 ground-truth 로
247
+ 판정한다 — stage-key 가 teardown 됐어도 커밋이 HEAD 에 남아있으면 merged.
248
+ run.py 의 기존 패턴을 그대로 옮긴 것."""
249
+ from .consumers import latest_done_by_stage
250
+ from .worktree import is_ancestor
251
+
252
+ return {
253
+ s: is_ancestor(task_worktree_path, r.get("head_commit", ""), head)
254
+ for s, r in latest_done_by_stage(done_rows).items()
255
+ }
256
+
257
+
258
+ def resolve_and_integrate_whole_task(
259
+ *, project_id: str, task_group: str, task_id: str,
260
+ task_worktree_path: str, stage_map: list[dict[str, Any]],
261
+ done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
262
+ ) -> dict[str, Any]:
263
+ """whole-task stage 통합(머지[+선택 teardown]) → 게이트 강제 → target 해소.
264
+
265
+ final-verification(teardown=True)과 container(teardown=False)의 단일 참조점.
266
+ ctx/PrepareInputs 결합 없는 헬퍼. 머지 충돌·미머지·dirty(.okstra 외)면
267
+ `PrepareError`. 반환: head_commit / merged_stage_map / target_ref / target /
268
+ integrate_result.
269
+
270
+ 머지(`git merge`)+teardown 은 task-key 단위 `worktree_provision_mutex` 로
271
+ 감싼다 — 동시 `container up` 둘 또는 container up × whole-task
272
+ final-verification 이 같은 공유 worktree 의 index/HEAD 에 동시에 머지하면
273
+ index.lock 충돌·반쯤 적용된 머지로 트리가 깨진다. 두 호출 지점(container 의
274
+ `_integrate_source_tree`, run.py 의 `_reserve_final_verification_target`)
275
+ 모두 이 락 밖에서 진입하므로 self-deadlock 없이 여기서 1회만 잡는다."""
276
+ from .stage_integrate import IntegrateError, integrate_stages
277
+ from .worktree import _git, is_dirty_excluding_okstra
278
+ from .locks import worktree_provision_mutex
279
+ from okstra_project.dirs import okstra_home
280
+ from okstra_project.state import slugify
281
+
282
+ with worktree_provision_mutex(
283
+ okstra_home(), project_id, slugify(task_group), slugify(task_id),
284
+ ):
285
+ try:
286
+ integ = integrate_stages(
287
+ project_id=project_id, task_group=task_group, task_id=task_id,
288
+ task_worktree_path=task_worktree_path, stage_map=stage_map,
289
+ done_rows=done_rows, teardown=teardown,
290
+ )
291
+ except IntegrateError as exc:
292
+ raise PrepareError(str(exc)) from exc
293
+
294
+ head = _git(task_worktree_path, "rev-parse", "HEAD").stdout.strip()
295
+ merged = _merged_stage_map(task_worktree_path, done_rows, head)
296
+ try:
297
+ target = resolve_whole_task_target(
298
+ stage_map=stage_map, done_rows=done_rows, anchor_base=anchor_base,
299
+ task_worktree_path=task_worktree_path, task_head=head,
300
+ task_dirty=is_dirty_excluding_okstra(task_worktree_path),
301
+ merged=merged,
302
+ )
303
+ except StageTargetError as exc:
304
+ raise PrepareError(str(exc)) from exc
305
+ return {
306
+ "head_commit": head,
307
+ "merged_stage_map": merged,
308
+ "target_ref": target.head,
309
+ "target": target,
310
+ "integrate_result": integ,
311
+ }
312
+
313
+
235
314
  def resolve_single_stage_target(
236
315
  *,
237
316
  requested_stage: str,
@@ -0,0 +1,200 @@
1
+ """Read-side time aggregation for a task.
2
+
3
+ timeline.json 의 runs[] 를 순회하며 각 run 의 team-state(leadUsage/workers
4
+ usage)를 읽어, task-type·worker 별 cross-run roll-up 과 per-run wall-clock /
5
+ phase timeline 을 만든다. skill markdown 이 LLM 에게 시키던 집계(byTaskType /
6
+ perWorker)를 코드 SSOT 로 옮긴 것 — 출력은 raw ms 이고 HH:MM:SS 포맷·Markdown
7
+ 표 렌더는 호출자(skill)에 남긴다.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from okstra_ctl.paths import resolve_under_root
18
+ from okstra_ctl.task_target import resolve_task_root
19
+
20
+
21
+ def _load_runs(task_root: Path) -> list[dict]:
22
+ try:
23
+ data = json.loads((task_root / "history" / "timeline.json").read_text(encoding="utf-8"))
24
+ except (OSError, ValueError):
25
+ return []
26
+ runs = data.get("runs", [])
27
+ return runs if isinstance(runs, list) else []
28
+
29
+
30
+ def _task_key(task_root: Path) -> str:
31
+ try:
32
+ return json.loads((task_root / "task-manifest.json").read_text(encoding="utf-8")).get("taskKey", "")
33
+ except (OSError, ValueError):
34
+ return ""
35
+
36
+
37
+ def _duration_ms(block) -> int:
38
+ """usage / leadUsage block 의 durationMs — na_block·missing·None 은 0."""
39
+ block = block or {}
40
+ if block.get("source") == "unavailable":
41
+ return 0
42
+ try:
43
+ return int(block.get("durationMs", 0) or 0)
44
+ except (TypeError, ValueError):
45
+ return 0
46
+
47
+
48
+ def _parse_iso(value: str) -> dt.datetime:
49
+ return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
50
+
51
+
52
+ def _wall_clock_ms(state: dict) -> int:
53
+ """run 의 실제 경과(벽시계) = max(endedAt) − min(startedAt). lead·worker 윈도가
54
+ 겹치므로 cpuSum 과 다르다. timestamp 부족하면 0."""
55
+ times: list[dt.datetime] = []
56
+ blocks = [state.get("leadUsage")] + [
57
+ w.get("usage") for w in (state.get("workers") or []) if isinstance(w, dict)]
58
+ for block in blocks:
59
+ block = block or {}
60
+ if block.get("source") == "unavailable":
61
+ continue
62
+ for key in ("startedAt", "endedAt"):
63
+ raw = block.get(key)
64
+ if not raw:
65
+ continue
66
+ try:
67
+ times.append(_parse_iso(raw))
68
+ except (TypeError, ValueError):
69
+ pass
70
+ if len(times) < 2:
71
+ return 0
72
+ return int((max(times) - min(times)).total_seconds() * 1000)
73
+
74
+
75
+ def _phase_rows(state: dict) -> list[dict]:
76
+ pt = state.get("phaseTimeline")
77
+ if not isinstance(pt, dict):
78
+ return []
79
+ return [{"phase": ph.get("phase", ""), "firstAt": ph.get("firstAt", ""),
80
+ "wallMsToNext": ph.get("wallMsToNext")}
81
+ for ph in (pt.get("phases") or []) if isinstance(ph, dict)]
82
+
83
+
84
+ def _read_state(project_root: Path, rel: str) -> dict | None:
85
+ target = resolve_under_root(project_root, rel)
86
+ if target is None:
87
+ return None
88
+ try:
89
+ state = json.loads(target.read_text(encoding="utf-8"))
90
+ except (OSError, ValueError):
91
+ return None
92
+ return state if isinstance(state, dict) else None
93
+
94
+
95
+ def _collect_run(run: dict, project_root: Path) -> dict:
96
+ """timeline run + team-state → 정규화된 측정. 결과에 `unavailable` reason 또는
97
+ 측정 필드(leadMs/workers/wallClockMs/phases)를 담는다."""
98
+ ts, task_type = run.get("runTimestamp", ""), run.get("taskType", "")
99
+ base = {"runTimestamp": ts, "taskType": task_type}
100
+ state = _read_state(project_root, run.get("teamStatePath", ""))
101
+ if state is None:
102
+ return {**base, "reason": "team-state missing"}
103
+ lead = _duration_ms(state.get("leadUsage"))
104
+ workers = [{"workerId": w.get("workerId", ""), "agent": w.get("agent", ""),
105
+ "durationMs": _duration_ms(w.get("usage"))}
106
+ for w in (state.get("workers") or []) if isinstance(w, dict)]
107
+ if lead == 0 and all(w["durationMs"] == 0 for w in workers):
108
+ return {**base, "reason": "no durationMs (Phase 7 not reached)"}
109
+ return {**base, "leadMs": lead, "workers": workers,
110
+ "wallClockMs": _wall_clock_ms(state), "phases": _phase_rows(state)}
111
+
112
+
113
+ def _by_task_type(collected: list[dict]) -> list[dict]:
114
+ order: list[str] = []
115
+ acc: dict[str, dict] = {}
116
+ for run in collected:
117
+ tt = run["taskType"]
118
+ if tt not in acc:
119
+ acc[tt] = {"taskType": tt, "runs": 0, "leadMs": 0, "workersMs": 0}
120
+ order.append(tt)
121
+ acc[tt]["runs"] += 1
122
+ acc[tt]["leadMs"] += run["leadMs"]
123
+ acc[tt]["workersMs"] += sum(w["durationMs"] for w in run["workers"])
124
+ for tt in order:
125
+ acc[tt]["cpuSumMs"] = acc[tt]["leadMs"] + acc[tt]["workersMs"]
126
+ return [acc[tt] for tt in order]
127
+
128
+
129
+ def _add_worker(agg: dict, order: list, wid: str, agent: str, ms: int) -> None:
130
+ if wid not in agg:
131
+ agg[wid] = {"runs": 0, "totalMs": 0, "agents": set()}
132
+ order.append(wid)
133
+ if ms > 0:
134
+ agg[wid]["runs"] += 1
135
+ agg[wid]["totalMs"] += ms
136
+ if agent:
137
+ agg[wid]["agents"].add(agent)
138
+
139
+
140
+ def _per_worker(collected: list[dict]) -> dict:
141
+ out: dict[str, list[dict]] = {}
142
+ for tt in dict.fromkeys(r["taskType"] for r in collected):
143
+ agg: dict[str, dict] = {}
144
+ order: list[str] = []
145
+ for run in (r for r in collected if r["taskType"] == tt):
146
+ _add_worker(agg, order, "lead", "", run["leadMs"])
147
+ for w in run["workers"]:
148
+ _add_worker(agg, order, w["workerId"], w["agent"], w["durationMs"])
149
+ rows = []
150
+ for wid in order:
151
+ a = agg[wid]
152
+ if a["runs"] == 0:
153
+ continue
154
+ agents = sorted(x for x in a["agents"] if x and x != wid)
155
+ rows.append({"workerId": wid, "agents": agents, "runs": a["runs"],
156
+ "totalMs": a["totalMs"], "avgMs": a["totalMs"] // a["runs"]})
157
+ out[tt] = rows
158
+ return out
159
+
160
+
161
+ def aggregate_time(runs: list[dict], project_root: Path) -> dict:
162
+ collected, unavailable = [], []
163
+ for run in runs:
164
+ if not isinstance(run, dict):
165
+ continue
166
+ c = _collect_run(run, project_root)
167
+ (unavailable if "reason" in c else collected).append(c)
168
+ by_tt = _by_task_type(collected)
169
+ grand = {k: sum(r[k] for r in by_tt) for k in ("runs", "leadMs", "workersMs", "cpuSumMs")}
170
+ return {
171
+ "byTaskType": by_tt,
172
+ "grandTotal": grand,
173
+ "perWorker": _per_worker(collected),
174
+ "perRunWallClock": [{"runTimestamp": c["runTimestamp"], "taskType": c["taskType"],
175
+ "wallClockMs": c["wallClockMs"]} for c in collected],
176
+ "phaseTimelines": [{"runTimestamp": c["runTimestamp"], "taskType": c["taskType"],
177
+ "phases": c["phases"]} for c in collected if c["phases"]],
178
+ "unavailable": unavailable,
179
+ }
180
+
181
+
182
+ def main(argv: list[str] | None = None) -> int:
183
+ parser = argparse.ArgumentParser(
184
+ prog="okstra time-report",
185
+ description="Aggregate elapsed work time across a task's runs (raw ms).")
186
+ parser.add_argument("target", help="task root path or task-key")
187
+ parser.add_argument("--project-root", default="")
188
+ parser.add_argument("--cwd", default=".")
189
+ parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
190
+ args = parser.parse_args(argv)
191
+
192
+ task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
193
+ result = {"ok": True, "taskKey": _task_key(task_root)}
194
+ result.update(aggregate_time(_load_runs(task_root), project_root))
195
+ print(json.dumps(result, ensure_ascii=False, indent=2))
196
+ return 0
197
+
198
+
199
+ if __name__ == "__main__":
200
+ raise SystemExit(main(sys.argv[1:]))
@@ -10,6 +10,13 @@ from pathlib import Path
10
10
  from typing import Optional, Sequence
11
11
 
12
12
 
13
+ # container watcher/tail pane 전용 태그. SessionEnd `--reap`
14
+ # (scripts/okstra-trace-cleanup.sh `_tag_in_scope`)는 `@okstra_trace_run` /
15
+ # `@okstra_worker_run` 만 스캔하므로, 이 태그가 붙은 pane 은 세션 종료 후에도
16
+ # 생존한다 — watcher/tail 의 "세션 후 생존" 불변식의 핵심.
17
+ CONTAINER_TAG_OPTION = "@okstra_container_run"
18
+
19
+
13
20
  @dataclass(frozen=True)
14
21
  class TmuxPane:
15
22
  pane_id: str
@@ -135,6 +142,66 @@ def split_worker_pane(
135
142
  return pane_id
136
143
 
137
144
 
145
+ def new_detached_session(
146
+ session_name: str, cwd: str, first_cmd: str | None = None
147
+ ) -> str:
148
+ """detached tmux 세션을 띄우고 첫 pane id 를 반환한다.
149
+
150
+ cmd-rerun.sh 의 detached spawn 패턴을 python 으로 옮긴 것
151
+ (`tmux new-session -d -s <name> -c <cwd> [cmd]`). `-P -F #{pane_id}` 로
152
+ 생성된 pane id 를 받아 이후 split_container_pane 의 `-t` 대상으로 쓴다.
153
+ first_cmd 미지정 시 기본 셸이 첫 pane 을 점유한다 — `true` 처럼 즉시 끝나는
154
+ 명령을 주면 단일-윈도우 세션이 split 전에 무너질 수 있으므로(remain-on-exit
155
+ off), holder pane 은 오래 사는 명령이어야 한다.
156
+ """
157
+ args = ["new-session", "-d", "-s", session_name, "-c", cwd,
158
+ "-P", "-F", "#{pane_id}"]
159
+ if first_cmd:
160
+ args.append(first_cmd)
161
+ result = run_tmux(args)
162
+ if result.returncode != 0:
163
+ raise RuntimeError(result.stderr.strip() or "tmux new-session failed")
164
+ return result.stdout.strip()
165
+
166
+
167
+ def split_container_pane(
168
+ *,
169
+ session_pane: Optional[str],
170
+ cwd: str,
171
+ command: str,
172
+ title: str,
173
+ scope_value: str,
174
+ kind: str,
175
+ ) -> Optional[str]:
176
+ """container watcher/tail pane 을 split 하고 container 전용 태그만 부착한다.
177
+
178
+ reap 가 스캔하는 두 worker/trace 태그는 절대 부착하지 않는다 — tag_pane 의
179
+ 기본 경로를 거치지 않고 CONTAINER_TAG_OPTION 으로 직접 set-option 한다.
180
+ session_pane 가 빈값/None(tmux 미사용 경로)이면 raise 없이 None 반환(degrade).
181
+ """
182
+ if not session_pane:
183
+ return None
184
+ result = run_tmux(
185
+ ["split-window", "-h", "-P", "-F", "#{pane_id}",
186
+ "-c", cwd, "-t", session_pane, command]
187
+ )
188
+ if result.returncode != 0:
189
+ raise RuntimeError(result.stderr.strip() or "tmux split-window failed")
190
+ pane_id = result.stdout.strip()
191
+ set_pane_title(pane_id, title)
192
+ tag_container_pane(pane_id, scope_value)
193
+ return pane_id
194
+
195
+
196
+ def tag_container_pane(pane_id: str, scope_value: str) -> None:
197
+ """container 전용 태그(CONTAINER_TAG_OPTION)만 부착한다.
198
+
199
+ reap 가 스캔하는 worker/trace 태그(tag_pane 기본 경로)는 절대 거치지 않는다 —
200
+ 이 태그가 붙은 pane 은 세션 종료 후에도 생존하고 `down`/`stop-watcher` 의
201
+ 스코프 reap 로만 회수된다. holder pane 도 이 태그로 묶어 회수 대상에 포함한다."""
202
+ run_tmux(["set-option", "-p", "-t", pane_id, CONTAINER_TAG_OPTION, scope_value])
203
+
204
+
138
205
  def set_pane_title(pane_id: str, title: str) -> None:
139
206
  result = run_tmux(["select-pane", "-t", pane_id, "-T", title])
140
207
  if result.returncode != 0:
@@ -41,8 +41,10 @@ from okstra_ctl.run import (
41
41
  _apply_cli_implementation_option,
42
42
  _extract_frontmatter_block,
43
43
  _load_final_report_data_if_present,
44
+ _load_parsed_stage_map,
44
45
  _reject_blocking_plan_body_gate,
45
46
  _set_data_json_approved_true_if_present,
47
+ _stage_map_reject_detail,
46
48
  recommended_role_models,
47
49
  )
48
50
  from okstra_ctl.user_response import (
@@ -934,22 +936,25 @@ def _stage_auto_allowed(state: WizardState) -> bool:
934
936
 
935
937
  def _parse_stage_objects(state: WizardState) -> list:
936
938
  """승인 plan 의 Stage Map stage 객체 목록. validator 의 _parse_stage_map 재사용.
937
- `_build_stage_pick` 과 `_whole_task_allowed` 가 공유한다."""
938
- import importlib.util as _ilu
939
- import sys as _sys
939
+ `_build_stage_pick` 과 `_whole_task_allowed` 가 공유한다.
940
+
941
+ 빈/손상 Stage Map 은 prepare 게이트(`_parse_stage_map_into_ctx`)와 같은 기준
942
+ (`_stage_map_reject_detail`)으로 거부한다 — 안 그러면 손상된 맵이 stage 일부만
943
+ 파싱돼 picker 가 '전체 task' 검증을 제안·수락하지만 prepare 가 같은 맵을 하드
944
+ 거부하는 불일치가 생긴다.
945
+
946
+ validator 로드는 prepare 와 같은 단일 경로(`_load_parsed_stage_map`)로 위임하고,
947
+ PrepareError 는 picker 디스패처가 처리하는 WizardError 로 변환한다."""
940
948
  plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
941
- validator_path = (Path(state.workspace_root) / "validators"
942
- / "validate-implementation-plan-stages.py")
943
- spec = _ilu.spec_from_file_location("_ip_stage_v_wizard", str(validator_path))
944
- if spec is None or spec.loader is None:
945
- raise WizardError(f"cannot load stage validator at {validator_path}")
946
- mod = _ilu.module_from_spec(spec)
947
- _sys.modules["_ip_stage_v_wizard"] = mod
948
949
  try:
949
- spec.loader.exec_module(mod)
950
- stages, _errs = mod._parse_stage_map(plan_text)
951
- finally:
952
- _sys.modules.pop("_ip_stage_v_wizard", None)
950
+ stages, errs = _load_parsed_stage_map(plan_text)
951
+ except PrepareError as exc:
952
+ raise WizardError(str(exc)) from exc
953
+ detail = _stage_map_reject_detail(stages, errs)
954
+ if detail is not None:
955
+ raise WizardError(
956
+ f"approved plan 의 Stage Map 을 신뢰할 수 없습니다 "
957
+ f"({state.approved_plan_path}): {detail}. plan 의 Stage Map 을 점검하세요.")
953
958
  return stages
954
959
 
955
960
 
@@ -1893,10 +1898,13 @@ def _handoff_eligibility(state: WizardState) -> list:
1893
1898
  from okstra_ctl.run import _parse_stage_map_into_ctx
1894
1899
  from okstra_ctl.consumers import read_consumers
1895
1900
  plan = _resolve_handoff_plan(state)
1896
- stage_map = _parse_stage_map_into_ctx(str(plan))
1897
- if not stage_map:
1898
- raise WizardError(
1899
- _handoff_msgs(state)["errors"]["no_stage_map"].format(plan=plan))
1901
+ try:
1902
+ stage_map = _parse_stage_map_into_ctx(str(plan))
1903
+ except PrepareError as exc:
1904
+ # PrepareError 가 비어있음(heading rename)·손상(비단조 등)을 이미 구체적으로
1905
+ # 구분해 전달하므로 그 메시지를 그대로 surface 한다 — 'no_stage_map' 으로
1906
+ # 덮으면 손상된 맵을 '맵 없음'으로 오표기하고 어느 행이 깨졌는지 detail 을 잃는다.
1907
+ raise WizardError(str(exc)) from exc
1900
1908
  rows = read_consumers(plan.resolve().parents[1])
1901
1909
  return compute_eligibility(stage_map, rows)
1902
1910
 
@@ -3612,8 +3620,15 @@ def _cli(argv: list[str]) -> int:
3612
3620
  result = submit(state, args.answer)
3613
3621
  except WizardError as exc:
3614
3622
  save_state_file(state_path, state)
3615
- print(json.dumps({"ok": False, "error": str(exc),
3616
- "current": prompt_payload(state, next_prompt(state))},
3623
+ try:
3624
+ current = prompt_payload(state, next_prompt(state))
3625
+ except WizardError:
3626
+ # 현재 step 의 build 자체가 실패하면(예: 손상된 Stage Map 으로
3627
+ # _build_stage_pick·_build_handoff_stage_pick 가 raise) recovery 재렌더가
3628
+ # 같은 예외를 다시 던져 이중 실패한다 — try 밖이라 _cli 를 탈출해
3629
+ # traceback 으로 죽는다. current 를 비워 첫 예외를 깨끗한 envelope 로 낸다.
3630
+ current = None
3631
+ print(json.dumps({"ok": False, "error": str(exc), "current": current},
3617
3632
  ensure_ascii=False, indent=2))
3618
3633
  return 0
3619
3634
  save_state_file(state_path, state)
@@ -34,6 +34,7 @@ from .state import (
34
34
  read_latest_task,
35
35
  read_task_catalog,
36
36
  read_task_manifest,
37
+ resolve_task_id,
37
38
  resolve_task_identity,
38
39
  slugify,
39
40
  )
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "read_task_catalog",
59
60
  "read_task_manifest",
60
61
  "resolve_project_root",
62
+ "resolve_task_id",
61
63
  "resolve_task_identity",
62
64
  "slugify",
63
65
  "tasks_root",