okstra 0.97.1 → 0.98.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.kr.md +4 -3
- package/README.md +4 -3
- package/docs/kr/architecture.md +2 -2
- package/docs/kr/cli.md +2 -0
- package/docs/kr/container.md +122 -0
- package/docs/project-structure-overview.md +12 -2
- package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +714 -0
- package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +125 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +2 -18
- package/runtime/agents/workers/claude-worker.md +10 -33
- package/runtime/agents/workers/codex-worker.md +2 -18
- package/runtime/agents/workers/report-writer-worker.md +2 -10
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +5 -2
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -0
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_coverage-critic.md +17 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/_stage-discipline.md +5 -8
- package/runtime/prompts/profiles/error-analysis.md +5 -5
- package/runtime/prompts/profiles/final-verification.md +4 -4
- package/runtime/prompts/profiles/implementation-planning.md +7 -7
- package/runtime/prompts/profiles/implementation.md +5 -5
- package/runtime/prompts/profiles/improvement-discovery.md +6 -6
- package/runtime/prompts/profiles/release-handoff.md +3 -3
- package/runtime/prompts/profiles/requirements-discovery.md +4 -4
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/__init__.py +4 -1
- package/runtime/python/okstra_ctl/container.py +970 -0
- package/runtime/python/okstra_ctl/container_registry.py +93 -0
- package/runtime/python/okstra_ctl/error_zip.py +4 -4
- package/runtime/python/okstra_ctl/handoff.py +7 -1
- package/runtime/python/okstra_ctl/ids.py +40 -1
- package/runtime/python/okstra_ctl/listing.py +3 -5
- package/runtime/python/okstra_ctl/log_report.py +102 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +5 -4
- package/runtime/python/okstra_ctl/paths.py +40 -0
- package/runtime/python/okstra_ctl/plan_run_root.py +78 -0
- package/runtime/python/okstra_ctl/reconcile.py +4 -2
- package/runtime/python/okstra_ctl/resolve_task_key.py +54 -0
- package/runtime/python/okstra_ctl/run.py +48 -30
- package/runtime/python/okstra_ctl/stage_integrate.py +8 -2
- package/runtime/python/okstra_ctl/stage_targets.py +79 -0
- package/runtime/python/okstra_ctl/time_report.py +200 -0
- package/runtime/python/okstra_ctl/tmux.py +67 -0
- package/runtime/python/okstra_ctl/wizard.py +35 -20
- package/runtime/python/okstra_project/__init__.py +2 -0
- package/runtime/python/okstra_project/state.py +50 -2
- package/runtime/skills/okstra-brief/SKILL.md +17 -7
- package/runtime/skills/okstra-container/SKILL.md +169 -0
- package/runtime/skills/okstra-inspect/SKILL.md +64 -178
- package/runtime/skills/okstra-memory/SKILL.md +8 -6
- package/runtime/skills/okstra-run/SKILL.md +4 -4
- package/runtime/skills/okstra-schedule/SKILL.md +9 -16
- package/runtime/skills/okstra-setup/SKILL.md +10 -9
- package/runtime/templates/reports/brief.template.md +1 -1
- package/runtime/templates/reports/final-report.template.md +1 -1
- package/runtime/templates/reports/settings.template.json +3 -1
- package/runtime/validators/validate-implementation-plan-stages.py +11 -3
- package/src/cli-registry.mjs +28 -0
- package/src/commands/inspect/container.mjs +27 -0
- package/src/commands/inspect/log-report.mjs +26 -0
- package/src/commands/inspect/resolve-task-key.mjs +26 -0
- package/src/commands/inspect/task-list.mjs +26 -15
- package/src/commands/inspect/time-report.mjs +26 -0
- package/src/commands/lifecycle/check-project.mjs +7 -1
- package/src/commands/lifecycle/doctor.mjs +16 -0
- package/src/lib/skill-catalog.mjs +1 -0
- 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
|
-
|
|
291
|
-
|
|
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
|
|
547
|
-
"""
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
3616
|
-
|
|
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",
|