okstra 0.206.1 → 0.207.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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +7 -1
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/architecture/storage-model.md +1 -0
  5. package/docs/architecture.md +28 -4
  6. package/docs/cli.md +13 -11
  7. package/docs/project-structure-overview.md +4 -2
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/operations/code-review.json +1 -1
  11. package/runtime/bin/lib/okstra/usage.sh +3 -3
  12. package/runtime/bin/okstra-compact-reminder.sh +1 -1
  13. package/runtime/prompts/duties/direction-selection-worker.json +1 -1
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/adapters/cmux.md +4 -3
  16. package/runtime/prompts/lead/convergence.md +41 -9
  17. package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
  18. package/runtime/prompts/lead/report-writer.md +8 -6
  19. package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
  20. package/runtime/prompts/profiles/_common-contract.md +1 -1
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  23. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
  24. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  25. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
  26. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  27. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  28. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
  32. package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
  33. package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
  34. package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
  35. package/runtime/python/okstra_ctl/code_review_target.py +92 -6
  36. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
  37. package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
  38. package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
  39. package/runtime/python/okstra_ctl/domain/provider.py +5 -0
  40. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
  41. package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
  42. package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
  43. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
  44. package/runtime/python/okstra_ctl/lead_progress.py +33 -1
  45. package/runtime/python/okstra_ctl/manager_view.py +26 -19
  46. package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
  47. package/runtime/python/okstra_ctl/models.py +4 -1
  48. package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
  49. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  50. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  51. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
  52. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  53. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
  54. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
  55. package/runtime/python/okstra_ctl/process_group.py +118 -0
  56. package/runtime/python/okstra_ctl/render.py +6 -2
  57. package/runtime/python/okstra_ctl/report_assembly.py +17 -2
  58. package/runtime/python/okstra_ctl/report_finalize.py +106 -2
  59. package/runtime/python/okstra_ctl/run.py +1 -1
  60. package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
  61. package/runtime/python/okstra_ctl/team.py +108 -9
  62. package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
  63. package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
  64. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
  65. package/runtime/python/okstra_ctl/worker_runner.py +21 -3
  66. package/runtime/python/okstra_ctl/write_policy.py +57 -7
  67. package/runtime/python/okstra_project/dirs.py +14 -0
  68. package/runtime/python/okstra_project/resolver.py +2 -1
  69. package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
  70. package/runtime/skills/okstra-code-review/SKILL.md +70 -32
  71. package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
  72. package/runtime/skills/okstra-run/SKILL.md +2 -2
  73. package/runtime/templates/manager/view.template.html +18 -1
@@ -62,7 +62,10 @@ from .final_report_paths import (
62
62
  final_report_markdown_path,
63
63
  sidecar_source_rel,
64
64
  )
65
- from .paths import task_dir, task_manifest_file
65
+ from .run_artifact_prune import prune_run_artifacts
66
+ from .handoff import record_verified
67
+ from .handoff_error import HandoffError
68
+ from .paths import RunRef, task_dir, task_manifest_file
66
69
  from .report_view_artifacts import html_view_path
67
70
  from .release_gate import release_handoff_allowed
68
71
  from .report_translation_dispatch import TranslateOutcome, translate_report
@@ -73,6 +76,7 @@ from .stage_targets import (
73
76
  integrate_and_teardown_whole_task,
74
77
  )
75
78
  from .session import observe_lead_session
79
+ from .lead_progress import record_checkpoint
76
80
  from .error_log_write import record_runtime_failure
77
81
 
78
82
  # 포인터 값 타입만 쓴다. `okstra_project.phase_pointer` 는 okstra 안의
@@ -91,10 +95,12 @@ STEP_TRANSLATE = "translate"
91
95
  STEP_TOKEN_USAGE = "token-usage"
92
96
  STEP_RENDER_VIEWS = "render-views"
93
97
  STEP_SPAWN_FOLLOWUPS = "spawn-followups"
98
+ STEP_RECORD_VERIFIED = "record-verified"
94
99
  STEP_VALIDATE_RUN = "validate-run"
95
100
  STEP_RECORD_GROUP_MEMORY = "record-group-memory"
96
101
  STEP_TEARDOWN_STAGES = "teardown-stages"
97
102
  STEP_PREFLIGHT = "preflight"
103
+ STEP_PRUNE_RUN_ARTIFACTS = "prune-run-artifacts"
98
104
 
99
105
  STEP_ORDER = (
100
106
  STEP_PROJECT_ACTIVITY,
@@ -102,13 +108,15 @@ STEP_ORDER = (
102
108
  STEP_TOKEN_USAGE,
103
109
  STEP_RENDER_VIEWS,
104
110
  STEP_SPAWN_FOLLOWUPS,
111
+ STEP_RECORD_VERIFIED,
105
112
  STEP_VALIDATE_RUN,
106
113
  # After validation: the group's sibling tasks read this run's conclusion
107
114
  # from `group-context.md`, and only a validated record is worth handing on.
108
115
  STEP_RECORD_GROUP_MEMORY,
109
- # Last, and only after the run validated: it removes the stage worktrees a
116
+ # Only after the run validated: it removes the stage worktrees a
110
117
  # blocked verdict would send the user straight back to.
111
118
  STEP_TEARDOWN_STAGES,
119
+ STEP_PRUNE_RUN_ARTIFACTS,
112
120
  )
113
121
 
114
122
  V3_STEP_ORDER = (
@@ -119,9 +127,12 @@ V3_STEP_ORDER = (
119
127
  STEP_TRANSLATE,
120
128
  STEP_RENDER_VIEWS,
121
129
  STEP_SPAWN_FOLLOWUPS,
130
+ # Needs the assembled record; `validate-run` requires the row it writes.
131
+ STEP_RECORD_VERIFIED,
122
132
  STEP_VALIDATE_RUN,
123
133
  STEP_RECORD_GROUP_MEMORY,
124
134
  STEP_TEARDOWN_STAGES,
135
+ STEP_PRUNE_RUN_ARTIFACTS,
125
136
  )
126
137
 
127
138
  # 앞 단계가 실패하면 건너뛰는 단계. 하나는 worktree 를 거두고, 하나는 검증 안 된
@@ -391,6 +402,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
391
402
  ctx.task_key,
392
403
  ],
393
404
  ),
405
+ (
406
+ STEP_RECORD_VERIFIED,
407
+ ["<in-process>", "record-verified", str(ctx.data_path)],
408
+ ),
394
409
  (STEP_VALIDATE_RUN, _validate_run_command(ctx, ctx.data_path)),
395
410
  (
396
411
  STEP_RECORD_GROUP_MEMORY,
@@ -400,6 +415,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
400
415
  STEP_TEARDOWN_STAGES,
401
416
  ["<in-process>", "teardown-stages", str(ctx.data_path)],
402
417
  ),
418
+ (
419
+ STEP_PRUNE_RUN_ARTIFACTS,
420
+ ["<in-process>", "prune-run-artifacts", str(ctx.data_path)],
421
+ ),
403
422
  ]
404
423
  if ctx.report_contract_version != "3.0":
405
424
  return commands
@@ -502,6 +521,51 @@ def _next_in_group(steps: Sequence[Mapping[str, Any]]) -> dict[str, str] | None:
502
521
  return None
503
522
 
504
523
 
524
+ def _record_verified_stages(
525
+ ctx: FinalizeContext,
526
+ command: list[str],
527
+ ) -> subprocess.CompletedProcess:
528
+ """Write a `verified` row for every stage a release-ready verdict clears.
529
+
530
+ `handoff record-verified` needs the assembled `data.json` and `validate-run`
531
+ requires its rows, and both happen inside this one call, so the lead had no
532
+ point at which to run it (observed 2026-09-26, dev-11054). Re-recording the
533
+ same report is a no-op, so a resumed finalize may run this again.
534
+ """
535
+ def _done(payload: Mapping[str, Any]) -> subprocess.CompletedProcess:
536
+ return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")
537
+
538
+ if ctx.task_type != "final-verification":
539
+ return _done({"skipped": "not a final-verification run"})
540
+ try:
541
+ data = load_owned_object(Path(ctx.data_path), artifact="final report record")
542
+ except JsonBoundaryError as exc:
543
+ return subprocess.CompletedProcess(
544
+ command, 1, "", f"cannot read final-report data.json: {exc}")
545
+ if data.get("verificationScope") not in ("single-stage", "whole-task"):
546
+ return _done({"skipped": "report names no verification scope"})
547
+ if not release_handoff_allowed(data):
548
+ return _done({"skipped": "verdict does not clear the work for release"})
549
+ stages = sorted({
550
+ row["stage"]
551
+ for row in (data.get("finalVerification") or {}).get("stageReports") or ()
552
+ if isinstance(row, Mapping) and isinstance(row.get("stage"), int)
553
+ })
554
+ try:
555
+ plan_run_root = (
556
+ RunRef.from_report_path(Path(ctx.data_path).resolve())
557
+ .sibling("implementation-planning").run_dir
558
+ )
559
+ for stage in stages:
560
+ record_verified(
561
+ plan_run_root=plan_run_root, stage=stage,
562
+ report_path=str(ctx.data_path), data_json=str(ctx.data_path),
563
+ )
564
+ except (HandoffError, ValueError, OSError) as exc:
565
+ return subprocess.CompletedProcess(command, 1, "", str(exc))
566
+ return _done({"recorded": stages})
567
+
568
+
505
569
  def _teardown_stage_worktrees(
506
570
  ctx: FinalizeContext,
507
571
  command: list[str],
@@ -538,6 +602,27 @@ def _teardown_stage_worktrees(
538
602
  return _done(result)
539
603
 
540
604
 
605
+ def _prune_run_artifacts(
606
+ ctx: FinalizeContext,
607
+ command: list[str],
608
+ ) -> subprocess.CompletedProcess:
609
+ """이 run 디렉터리에서 run 종료 뒤 읽는 곳이 없는 산출물을 지운다.
610
+
611
+ 실패한 run 에서도 돈다. 빌드 디렉터리는 남은 lockfile 로 다시 만들고, dispatch
612
+ 스냅샷은 검증을 통과한 run 의 것만 지운다(`run_artifact_prune`).
613
+ """
614
+ reports_dir = Path(ctx.data_path).resolve().parent
615
+ if reports_dir.name != "reports":
616
+ return subprocess.CompletedProcess(
617
+ command, 1, "", f"report record is not under a run's reports/: {ctx.data_path}")
618
+ run_dir = reports_dir.parent
619
+ try:
620
+ result = prune_run_artifacts(ctx.project_root, run_dir, apply=True)
621
+ except OSError as exc:
622
+ return subprocess.CompletedProcess(command, 1, "", str(exc))
623
+ return subprocess.CompletedProcess(command, 0, json.dumps(result), "")
624
+
625
+
541
626
  def _validate_run_command(ctx: FinalizeContext, report_record_path: Path) -> list[str]:
542
627
  command = [
543
628
  sys.executable,
@@ -875,8 +960,12 @@ def _run_finalize_step(
875
960
  return _run_translate(ctx, command)
876
961
  if name == STEP_TEARDOWN_STAGES:
877
962
  return _teardown_stage_worktrees(ctx, command)
963
+ if name == STEP_PRUNE_RUN_ARTIFACTS:
964
+ return _prune_run_artifacts(ctx, command)
878
965
  if name == STEP_RECORD_GROUP_MEMORY:
879
966
  return _record_group_memory(ctx, command)
967
+ if name == STEP_RECORD_VERIFIED:
968
+ return _record_verified_stages(ctx, command)
880
969
  if name == STEP_VALIDATE_RUN:
881
970
  try:
882
971
  _link_lead_result_for_validation(ctx)
@@ -1066,6 +1155,9 @@ Runs the Phase 7 steps in their contractual order against one final-report:
1066
1155
  5. spawn-followups turn section 4 rows into task stubs
1067
1156
  6. validate-run validate the finished run artifacts
1068
1157
  7. record-group-memory / 8. teardown-stages after a clean validation
1158
+ 9. prune-run-artifacts remove node_modules and .next under this run
1159
+ directory, and the dispatch snapshots and publication
1160
+ locks of a validated run; runs after a failure too
1069
1161
 
1070
1162
  Every step is idempotent, so re-running after a fixed failure is safe. The
1071
1163
  sequence stops at the first non-zero exit and reports which step failed, except
@@ -1075,6 +1167,10 @@ exits non-zero the command prints the --only flags that resume the sequence
1075
1167
  from the earliest failing step — follow those rather than rerunning the failing
1076
1168
  step alone, which would leave the html rendered before the tokens landed.
1077
1169
 
1170
+ A full run (no --only) first records the lead's `phase-7-persist` checkpoint
1171
+ and returns it as `progressLines`; the lead emits that line instead of calling
1172
+ `okstra lead-progress append` for it.
1173
+
1078
1174
  This is the same code path the Codex lead adapter runs automatically after its
1079
1175
  report-writer completes, so a Claude-led and a Codex-led run finalize
1080
1176
  identically.
@@ -1264,7 +1360,15 @@ def main(argv: Sequence[str] | None = None) -> int:
1264
1360
  # the generations resume and compaction split it into. It runs ahead of the
1265
1361
  # sequence because the token-usage step below reads `leadSessionIds`.
1266
1362
  observe_lead_session(ctx.project_root, ctx.team_state_path)
1363
+ # 재개(`--only`)는 Phase 7 을 새로 시작하지 않고 다시 들어가는 것이다. 이 행은
1364
+ # 그것을 요구하는 `validate-run` 보다 먼저 있어야 한다.
1365
+ progress_line = None if args.only else record_checkpoint(
1366
+ ctx.project_root, ctx.manifest_path, "phase-7-persist",
1367
+ source="okstra report-finalize",
1368
+ )
1267
1369
  result = run_finalize(ctx, only=args.only or None)
1370
+ if progress_line:
1371
+ result["progressLines"] = [progress_line]
1268
1372
  order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
1269
1373
  print(json.dumps(result, indent=2, ensure_ascii=False))
1270
1374
  print("finalize steps:", file=sys.stderr)
@@ -2498,7 +2498,7 @@ def recommended_role_models(
2498
2498
  codex 이고 다른 provider 요청은 거부된다(`resolve_lead_provider`). 그래서
2499
2499
  `lead_provider` 를 받아 그 provider 의 기본값으로 해소한다. 받지 않으면
2500
2500
  role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
2501
- 화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-6-sol` 을 배정한다.
2501
+ 화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-6.1-sol` 을 배정한다.
2502
2502
  """
2503
2503
  lead_provider_id = lead_provider or "claude"
2504
2504
  report_writer_provider_id = report_writer_provider or "claude"
@@ -0,0 +1,200 @@
1
+ """run 이 끝난 뒤 읽는 곳이 없는 산출물을 찾고 지운다.
2
+
3
+ - 의존성·빌드 디렉터리(`node_modules`, `.next`): technical-verification 워커는
4
+ `experiments/<seq>/<worker-id>/` 에 사이트 복사본을 만들고 그 안에서 설치·빌드한다.
5
+ 증거는 계획·로그·lockfile·diff 이고, 설치·빌드 결과는 lockfile 로 다시 만든다. 태스크
6
+ 하나에 `node_modules` 46개와 `.next` 16개(약 22GB)가 남은 사례가 있다(2026-09-27).
7
+ - dispatch 스냅샷(`*.mutation-audit.json`)과 프롬프트 발행 잠금(`*.publish.lock`): 워커
8
+ 시도의 종결 판정과 같은 run 의 증거 복구만 읽는다. 결과는 시도 기록의 변경 요약에
9
+ 남고, `validate-run` 은 경로 문자열만 본다. 검증을 통과한 run 과, 같은 run 디렉터리에서
10
+ 더 뒤의 run 이 검증을 통과한 run 의 dispatch 기록이 가리키는 것만 지운다 — 가장 최근의
11
+ 실패한 run 은 같은 run 안에서 워커를 다시 보낼 수 있다.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from okstra_project import resolve_project_root
24
+
25
+ from .fixed_text import line
26
+ from .json_boundary import JsonBoundaryError, load_owned_object
27
+ from .paths import task_dir
28
+
29
+ PRUNED_DIR_NAMES = frozenset({"node_modules", ".next"})
30
+ _DISPATCH_ARRAYS = ("agentDispatches", "workerDispatches")
31
+ _MANIFEST_SEQ = re.compile(r"^run-manifest-.+-(\d{3,})\.json$")
32
+
33
+
34
+ def _tree_bytes(root: Path) -> int:
35
+ total = 0
36
+ for current, _dirs, files in os.walk(root):
37
+ for name in files:
38
+ try:
39
+ total += os.lstat(os.path.join(current, name)).st_size
40
+ except OSError:
41
+ continue
42
+ return total
43
+
44
+
45
+ def find_build_dirs(root: Path) -> list[dict[str, Any]]:
46
+ """`root` 아래 의존성·빌드 디렉터리. 심볼릭 링크는 따라가지도 지우지도 않는다."""
47
+ found: list[dict[str, Any]] = []
48
+ # `.okstra` 아래는 `Path.glob("**")` 이 숨김 디렉터리를 건너뛰므로 os.walk 로 훑는다.
49
+ for current, dirs, _files in os.walk(root):
50
+ kept = []
51
+ for name in dirs:
52
+ path = Path(current) / name
53
+ if name in PRUNED_DIR_NAMES and not path.is_symlink():
54
+ found.append({"path": str(path), "bytes": _tree_bytes(path)})
55
+ else:
56
+ kept.append(name)
57
+ dirs[:] = kept
58
+ return sorted(found, key=lambda row: row["path"])
59
+
60
+
61
+ def _resolve(project_root: Path, value: str) -> Path:
62
+ path = Path(value)
63
+ return path if path.is_absolute() else project_root / path
64
+
65
+
66
+ def _settled_manifests(manifests_dir: Path, names: list[str]) -> list[dict[str, Any]]:
67
+ """검증을 통과했거나, 같은 디렉터리의 더 뒤 run 이 통과한 run 매니페스트.
68
+
69
+ 한 `manifests/` 의 seq 는 한 카운터에서 나오므로 숫자 순서가 실행 순서다.
70
+ """
71
+ loaded: list[tuple[int, dict[str, Any]]] = []
72
+ for name in names:
73
+ match = _MANIFEST_SEQ.match(name)
74
+ if match is None:
75
+ continue
76
+ try:
77
+ manifest = load_owned_object(manifests_dir / name, artifact="run manifest")
78
+ except (OSError, JsonBoundaryError):
79
+ continue
80
+ loaded.append((int(match[1]), manifest))
81
+ passed = [
82
+ seq for seq, manifest in loaded
83
+ if (manifest.get("validation") or {}).get("status") == "passed"
84
+ ]
85
+ if not passed:
86
+ return []
87
+ return [manifest for seq, manifest in loaded if seq <= max(passed)]
88
+
89
+
90
+ def find_dispatch_leftovers(project_root: Path, root: Path) -> list[dict[str, Any]]:
91
+ """`root` 아래 끝난 run(`_settled_manifests`)의 dispatch 스냅샷과 발행 잠금."""
92
+ found: dict[str, int] = {}
93
+ for current, dirs, files in os.walk(root):
94
+ dirs[:] = [name for name in dirs if name not in PRUNED_DIR_NAMES]
95
+ if Path(current).name != "manifests":
96
+ continue
97
+ for manifest in _settled_manifests(Path(current), files):
98
+ try:
99
+ team_state = load_owned_object(
100
+ _resolve(project_root, str(manifest.get("teamStatePath") or "")),
101
+ artifact="team state",
102
+ )
103
+ except (OSError, JsonBoundaryError):
104
+ continue
105
+ for key in _DISPATCH_ARRAYS:
106
+ for row in team_state.get(key) or ():
107
+ if not isinstance(row, dict):
108
+ continue
109
+ candidates = [row.get("mutationAuditSnapshotPath")]
110
+ if row.get("promptPath"):
111
+ candidates.append(f"{row['promptPath']}.publish.lock")
112
+ for value in candidates:
113
+ if not isinstance(value, str) or not value:
114
+ continue
115
+ path = _resolve(project_root, value)
116
+ if path.is_file() and not path.is_symlink():
117
+ found[str(path)] = path.stat().st_size
118
+ return [{"path": path, "bytes": size} for path, size in sorted(found.items())]
119
+
120
+
121
+ def prune_run_artifacts(project_root: Path, root: Path, *, apply: bool) -> dict[str, Any]:
122
+ dirs = find_build_dirs(root) if root.is_dir() else []
123
+ files = find_dispatch_leftovers(project_root, root) if root.is_dir() else []
124
+ if apply:
125
+ for row in dirs:
126
+ shutil.rmtree(row["path"])
127
+ for row in files:
128
+ Path(row["path"]).unlink(missing_ok=True)
129
+ return {
130
+ "root": str(root),
131
+ "applied": apply,
132
+ "dirs": dirs,
133
+ "files": files,
134
+ "totalBytes": sum(row["bytes"] for row in (*dirs, *files)),
135
+ }
136
+
137
+
138
+ def render_result_text(result: dict[str, Any]) -> str:
139
+ parts = [
140
+ "# Okstra Run Artifact Prune\n\n",
141
+ line("Root", result.get("root")),
142
+ line("Applied", result.get("applied")),
143
+ line("Directory count", len(result.get("dirs") or [])),
144
+ line("File count", len(result.get("files") or [])),
145
+ line("Total bytes", result.get("totalBytes")),
146
+ ]
147
+ for row in result.get("dirs") or []:
148
+ parts.extend(("\n## Directory\n\n", line("Path", row["path"]), line("Bytes", row["bytes"])))
149
+ return "".join(parts)
150
+
151
+
152
+ _CLI_EPILOG = r"""Usage:
153
+ okstra prune-run-artifacts [--project-root <dir>] [--cwd <dir>]
154
+ [--task-group <group> --task-id <id>] [--apply] [--text]
155
+
156
+ Lists what no reader needs once a run has ended, under
157
+ <project-root>/.okstra/tasks (or one task root), with sizes and a total:
158
+ - every node_modules and .next directory (symbolic links are neither
159
+ followed nor removed);
160
+ - the dispatch snapshots (*.mutation-audit.json) and prompt publication
161
+ locks (*.publish.lock) recorded by a run whose validation passed, or by
162
+ an earlier run in the same run directory as a run that passed.
163
+ Nothing is removed without --apply. Source copies, logs, lockfiles, diffs,
164
+ prompts and results stay. --text lists directories only; files are counted.
165
+ """
166
+
167
+
168
+ def main(argv: list[str] | None = None) -> int:
169
+ parser = argparse.ArgumentParser(
170
+ epilog=_CLI_EPILOG,
171
+ formatter_class=argparse.RawDescriptionHelpFormatter,
172
+ prog="okstra prune-run-artifacts",
173
+ description="List, and with --apply remove, run artifacts no reader needs after the run.")
174
+ parser.add_argument("--project-root", default="")
175
+ parser.add_argument("--cwd", default=".")
176
+ parser.add_argument("--task-group", default="")
177
+ parser.add_argument("--task-id", default="")
178
+ parser.add_argument("--apply", action="store_true", help="remove the listed paths")
179
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
180
+ args = parser.parse_args(argv)
181
+ if bool(args.task_group) != bool(args.task_id):
182
+ parser.error("--task-group and --task-id go together")
183
+
184
+ project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
185
+ root = (
186
+ task_dir(project_root, args.task_group, args.task_id)
187
+ if args.task_group else project_root / ".okstra" / "tasks"
188
+ )
189
+ result = {
190
+ "ok": True,
191
+ "projectRoot": str(project_root),
192
+ **prune_run_artifacts(project_root, root, apply=args.apply),
193
+ }
194
+ output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
195
+ print(output, end="" if args.text else "\n")
196
+ return 0
197
+
198
+
199
+ if __name__ == "__main__":
200
+ raise SystemExit(main())
@@ -36,6 +36,12 @@ from .dispatch_core import (
36
36
  await_dispatches,
37
37
  dispatch_plan,
38
38
  )
39
+ from .dispatch_checkpoints import (
40
+ record_collect_checkpoints,
41
+ record_dispatch_checkpoints,
42
+ settled_initial_dispatches,
43
+ )
44
+ from .lead_progress import record_checkpoint, render_progress_line
39
45
  from .ports.worker_dispatch import WorkerDispatchPort, WorkerDispatchRequest
40
46
  from .registry.host_registry import default_host_registry
41
47
  from .registry.provider_registry import default_provider_registry
@@ -44,6 +50,7 @@ from .json_boundary import load_owned_object
44
50
 
45
51
 
46
52
  _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
53
+ _SOURCE = "okstra team"
47
54
 
48
55
 
49
56
  def main(argv: Sequence[str] | None = None) -> int:
@@ -73,7 +80,7 @@ _CLI_EPILOG = r"""Usage:
73
80
  [--poll-interval-seconds <n>] [--timeout-seconds <n>] \
74
81
  [--heartbeat-seconds <n>] [--json]
75
82
  okstra team reclaim --project-root <dir> --run-manifest <path> \
76
- [--dry-run] [--json]
83
+ [--dry-run] [--gate] [--json]
77
84
  okstra team teardown --project-root <dir> --run-manifest <path> \
78
85
  [--dry-run] [--json]
79
86
 
@@ -84,11 +91,23 @@ descriptor owns the team lifecycle (launch_mode=team) — a worker there is a CL
84
91
  wrapper subprocess holding no pane, so there is nothing to close.
85
92
 
86
93
  reclaim round boundary: closes the panes of dispatches that have finished and
87
- leaves the in-progress ones open. Run it with --dry-run first to
88
- count, then again to close.
94
+ leaves the in-progress ones open, then on a cmux-pane run records
95
+ `PROGRESS: phase-batch-cleanup panes=<n>`. With --gate it is the
96
+ cleanup before a user gate and prints
97
+ `PROGRESS: phase-gate-cleanup panes=<n>` without recording it.
98
+
89
99
  teardown end of run: closes every recorded pane and writes off any dispatch
90
100
  that never reached a terminal status.
91
101
 
102
+ On a cmux-pane run, dispatch records `phase-3-team-create` when it writes the
103
+ implicit-team marker, `phase-4-dispatch` for each `initial` job and
104
+ `phase-6-synthesis` on the first report-writer dispatch; await records
105
+ `phase-5-poll` on entry and again when the counts changed, and
106
+ `phase-5-collect` for each `initial` dispatch it settled. The lead does not
107
+ call `okstra lead-progress append` for those checkpoints; each command prints
108
+ the `PROGRESS:` lines to emit (`progressLines` in JSON output). On any other
109
+ backend the team commands record nothing.
110
+
92
111
  --workspace-root and --okstra-bin are owned by this command.
93
112
  """
94
113
  _CLI_DESCRIPTION = "Dispatch, await, and close okstra-owned worker panes."
@@ -148,6 +167,10 @@ def _add_reclaim_parser(sub) -> None:
148
167
  )
149
168
  _add_run_args(parser)
150
169
  parser.add_argument("--dry-run", action="store_true")
170
+ parser.add_argument(
171
+ "--gate", action="store_true",
172
+ help="cleanup before a user gate: print phase-gate-cleanup, record nothing",
173
+ )
151
174
  parser.add_argument("--json", action="store_true")
152
175
 
153
176
 
@@ -179,11 +202,31 @@ def _dispatch(args) -> int:
179
202
  if args.dry_run:
180
203
  _print_json(backend_plan.to_payload(dry_run=True))
181
204
  return 0
182
- result = dispatch_plan(backend_plan, wait=False)
183
- _print_json(backend_plan.to_payload(dry_run=False))
205
+ lines: list[str] = []
206
+ result = dispatch_plan(
207
+ backend_plan, wait=False,
208
+ before_start=lambda prepared: lines.extend(
209
+ _record_dispatch(args, manifest, prepared)
210
+ ),
211
+ )
212
+ payload = backend_plan.to_payload(dry_run=False)
213
+ payload["progressLines"] = lines
214
+ _print_json(payload)
184
215
  return result
185
216
 
186
217
 
218
+ def _record_dispatch(args, manifest: Mapping[str, Any], prepared) -> list[str]:
219
+ # cmux-pane run 만 체크포인트를 team 명령에 맡긴다. external 리드는 CLI 워커도
220
+ # `team await` 로 기다리지만 체크포인트는 직접 append 하므로, 여기서 쓰면 행이 둘이 된다.
221
+ if not _is_cmux_run(manifest):
222
+ return []
223
+ return record_dispatch_checkpoints(
224
+ prepared.project_root, args.run_manifest,
225
+ _load_json(prepared.team_state_path, "team-state"), prepared.jobs,
226
+ source=_SOURCE,
227
+ )
228
+
229
+
187
230
  def team_dispatch_port(manifest: Mapping[str, Any]) -> WorkerDispatchPort:
188
231
  """이 run 의 워커를 띄울 포트. cmux run 이면 pane, 아니면 리드 호스트의 포트.
189
232
 
@@ -209,19 +252,59 @@ def _await(args) -> int:
209
252
  _validate_team_manifest(manifest)
210
253
  _observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
211
254
  plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
255
+ should_record = _is_cmux_run(manifest)
256
+ entry_state = _load_json(plan.team_state_path, "team-state")
257
+ entry = _dispatch_counts(entry_state)
258
+ lines = [_record_poll(args, entry)] if should_record else []
212
259
  code = await_dispatches(
213
260
  plan,
214
261
  poll_interval_seconds=args.poll_interval_seconds,
215
262
  timeout_seconds=args.timeout_seconds,
216
263
  heartbeat_seconds=0 if args.json else args.heartbeat_seconds,
217
264
  )
265
+ if should_record:
266
+ exit_state = _load_json(plan.team_state_path, "team-state")
267
+ lines.extend(record_collect_checkpoints(
268
+ plan.project_root, args.run_manifest,
269
+ settled_initial_dispatches(entry_state),
270
+ settled_initial_dispatches(exit_state),
271
+ source=_SOURCE,
272
+ ))
273
+ exit_counts = _dispatch_counts(exit_state)
274
+ if exit_counts != entry:
275
+ lines.append(_record_poll(args, exit_counts))
276
+ lines = [line for line in lines if line]
218
277
  if args.json:
219
- _print_json(_await_payload(plan, code == 0))
278
+ payload = _await_payload(plan, code == 0)
279
+ payload["progressLines"] = lines
280
+ _print_json(payload)
220
281
  else:
282
+ for line in lines:
283
+ print(line)
221
284
  print("ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
222
285
  return code
223
286
 
224
287
 
288
+ def _dispatch_counts(team_state: Mapping[str, Any]) -> tuple[int, int]:
289
+ """이 run 의 dispatch 행 기준 (pending, done) — `phase-5-poll` 의 두 값."""
290
+ statuses = [
291
+ record.get("status")
292
+ for record in team_state.get("workerDispatches", [])
293
+ if isinstance(record, dict)
294
+ ]
295
+ done = sum(1 for status in statuses if status in TERMINAL_WORKER_STATUSES)
296
+ return len(statuses) - done, done
297
+
298
+
299
+ def _record_poll(args, counts: tuple[int, int]) -> str | None:
300
+ pending, done = counts
301
+ return record_checkpoint(
302
+ Path(args.project_root), args.run_manifest, "phase-5-poll",
303
+ source=_SOURCE,
304
+ fields=(("pending", str(pending)), ("done", str(done))),
305
+ )
306
+
307
+
225
308
  def _teardown(args) -> int:
226
309
  manifest = _load_manifest(args.project_root, args.run_manifest)
227
310
  _validate_team_manifest(manifest)
@@ -259,7 +342,16 @@ def _reclaim(args) -> int:
259
342
  _emit_panes(args.json, panes)
260
343
  return 0
261
344
  _close_panes(manifest, panes)
262
- _emit_panes(args.json, panes)
345
+ count = (("panes", str(len(panes))),)
346
+ line: str | None = None
347
+ if args.gate:
348
+ line = render_progress_line("phase-gate-cleanup", count)
349
+ elif _is_cmux_run(manifest):
350
+ line = record_checkpoint(
351
+ Path(args.project_root), args.run_manifest, "phase-batch-cleanup",
352
+ source=_SOURCE, fields=count,
353
+ )
354
+ _emit_panes(args.json, panes, line)
263
355
  return 0
264
356
 
265
357
 
@@ -434,12 +526,19 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
434
526
  mutate_team_state(team_state_path, mark)
435
527
 
436
528
 
437
- def _emit_panes(as_json: bool, panes: list[dict[str, str]]) -> None:
529
+ def _emit_panes(
530
+ as_json: bool, panes: list[dict[str, str]], progress_line: str | None = None
531
+ ) -> None:
438
532
  if as_json:
439
- _print_json({"panes": panes})
533
+ payload: dict[str, Any] = {"panes": panes}
534
+ if progress_line:
535
+ payload["progressLines"] = [progress_line]
536
+ _print_json(payload)
440
537
  return
441
538
  for pane in panes:
442
539
  print(f"{pane['paneId']}\t{pane['kind']}")
540
+ if progress_line:
541
+ print(progress_line)
443
542
 
444
543
 
445
544
  def _manifest_backend(manifest: Mapping[str, Any]) -> str:
@@ -206,12 +206,20 @@ def _suggest_sibling_task_ids(state: WizardState) -> str:
206
206
  return ",".join(siblings)
207
207
 
208
208
 
209
+ def _sibling_count_note(state: WizardState, suggestion: str) -> str:
210
+ """라벨은 CSV 앞부분만 보여 주므로, 선택하면 몇 개가 들어가는지 따로 적는다."""
211
+ count = len([tid for tid in suggestion.split(",") if tid])
212
+ note = _p(state.workspace_root, "related_tasks_pick")["labels"]["siblings_count"]
213
+ return note.format(count=count)
214
+
215
+
209
216
  _RELATED_TASKS_PICK_SPEC = _OptionalCachedPickSpec(
210
217
  step=S_RELATED_TASKS_PICK, prompt_key="related_tasks_pick",
211
218
  recommend_token=_SIBLINGS_TOKEN, label_key="siblings", echo_suffix_key="siblings",
212
219
  suggest=_suggest_sibling_task_ids, snippet_style="prefix",
213
220
  cache_attr="last_siblings_cached", target_attr="related_tasks_raw",
214
221
  pending_attr="related_tasks_pending_text",
222
+ recommend_note=_sibling_count_note,
215
223
  )
216
224
 
217
225