okstra 0.63.0 → 0.64.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 (29) hide show
  1. package/docs/kr/architecture.md +1 -1
  2. package/docs/superpowers/plans/2026-06-09-implementation-run-artifact-stage-isolation.md +320 -0
  3. package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling-PROBE.md +42 -0
  4. package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling.md +337 -0
  5. package/docs/superpowers/specs/2026-06-09-executor-model-custom-id-cascade-design.md +66 -0
  6. package/docs/superpowers/specs/2026-06-09-implementation-run-artifact-stage-isolation-design.md +87 -0
  7. package/docs/superpowers/specs/2026-06-10-lead-worker-completion-polling-design.md +113 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +5 -2
  11. package/runtime/agents/TODO.md +9 -2
  12. package/runtime/agents/workers/claude-worker.md +1 -1
  13. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +23 -4
  14. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  15. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  16. package/runtime/python/okstra_ctl/backfill.py +23 -4
  17. package/runtime/python/okstra_ctl/consumers.py +118 -1
  18. package/runtime/python/okstra_ctl/paths.py +11 -0
  19. package/runtime/python/okstra_ctl/run.py +147 -67
  20. package/runtime/python/okstra_ctl/run_context.py +2 -0
  21. package/runtime/python/okstra_ctl/wizard.py +127 -29
  22. package/runtime/skills/okstra-convergence/SKILL.md +3 -1
  23. package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
  24. package/runtime/skills/okstra-run/SKILL.md +1 -1
  25. package/runtime/skills/okstra-team-contract/SKILL.md +37 -0
  26. package/runtime/templates/reports/final-report.template.md +1 -1
  27. package/runtime/validators/validate-run.py +20 -3
  28. package/src/install.mjs +21 -0
  29. package/src/uninstall.mjs +17 -17
@@ -27,6 +27,7 @@ from datetime import datetime, timezone
27
27
  from pathlib import Path
28
28
 
29
29
  from okstra_project import project_json_path, upsert_project_json
30
+ from okstra_project.state import slugify
30
31
  from .analysis_packet import build_analysis_packet
31
32
  from .clarification_items import (
32
33
  section_1_present_but_unparsed,
@@ -1271,22 +1272,47 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1271
1272
  )
1272
1273
 
1273
1274
 
1274
- def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map: list) -> None:
1275
- """implementation run 의 첫 ready stage 를 결정하고 stage 격리 worktree 를
1276
- 발급해 ctx 의 EXECUTOR_WORKTREE_* 를 덮어쓴다. anchor base commit 을 1회
1277
- 고정하고 그 stage 를 consumers.jsonl 에 started 로 append.
1275
+ @dataclass
1276
+ class StageSelection:
1277
+ """Result of `_select_and_provision_implementation_stage`.
1278
+
1279
+ Path-independent: carries the chosen stage + its isolated worktree
1280
+ coordinates, separated from any run-artifact path so the caller can
1281
+ resolve run paths AFTER the stage is known. `worktree_*` are empty when
1282
+ the surrounding flow degraded (non-git / nested worktree); in that case
1283
+ `started_head_commit` is the project HEAD instead of the worktree base."""
1284
+ stage: int
1285
+ worktree_path: str
1286
+ worktree_branch: str
1287
+ worktree_base_ref: str
1288
+ worktree_status: str
1289
+ worktree_note: str
1290
+ started_head_commit: str
1278
1291
 
1279
- spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 는 backward
1280
- compat 로 batch 리스트를 반환하지만 첫 번째만 실행한다 — stage 마다 격리
1281
- worktree·branch 가 필요해 batch 가 의미를 잃기 때문(cost-aware-design 의
1282
- run-batch 와의 의도된 트레이드오프)."""
1283
- from .consumers import read_consumers, append_consumer
1292
+
1293
+ def _select_and_provision_implementation_stage(
1294
+ inp: PrepareInputs,
1295
+ ctx_stage_map: list,
1296
+ task_group_segment: str,
1297
+ task_id_segment: str,
1298
+ task_key: str,
1299
+ executor_worktree_status: str,
1300
+ ) -> StageSelection:
1301
+ """Resolve the first ready implementation stage and provision its isolated
1302
+ worktree, without touching any run-artifact path.
1303
+
1304
+ spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 가 backward compat
1305
+ 로 batch 를 반환하지만 첫 번째만 실행한다 — stage 마다 격리 worktree·branch
1306
+ 가 필요해 batch 가 의미를 잃기 때문. `executor_worktree_status` 가
1307
+ "skipped*" 면 worktree 없이 degrade 하고 project HEAD 만 기록한다."""
1308
+ from .consumers import read_consumers, backfill_done_from_carry
1284
1309
  from . import worktree as _worktree
1285
1310
  from . import worktree_registry as _reg
1286
- import datetime as _dt
1287
1311
 
1288
- ctx["parsed_stage_map"] = ctx_stage_map
1289
1312
  plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1313
+ # carry sidecars are the SSOT for stage completion; recover any `done` rows
1314
+ # the lead failed to append before the dependency gate reads them.
1315
+ backfill_done_from_carry(plan_run_root)
1290
1316
  consumed = read_consumers(plan_run_root)
1291
1317
  done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
1292
1318
  started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
@@ -1298,46 +1324,24 @@ def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map:
1298
1324
  ctx_stage_map, done_stages, inp.stage,
1299
1325
  started_stages=started_stages, reserved_stages=reserved_stages,
1300
1326
  )
1301
- # stage 격리: 한 run = 한 stage. 첫 ready stage 만 실행.
1302
1327
  selected = batch[0]
1303
- ctx["effective_stages"] = [selected]
1304
- csv = str(selected)
1305
- ctx["EFFECTIVE_STAGES"] = csv
1306
- ctx["STAGE_BATCH_DIRECTIVE"] = (
1307
- f"- **Stage for this implementation run:** `{csv}`. "
1308
- "Execute exactly this Stage Map stage — this is the authoritative scope. "
1309
- "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
1310
- "and reserved this stage."
1311
- )
1312
- inp.stage = csv
1313
- print(f"selected stages: {csv}", file=sys.stdout)
1314
1328
 
1315
1329
  # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1316
- # stage 격리도 동일하게 degrade — consumers 기록 (기존 평면 동작 보존).
1317
- wt_status = ctx.get("EXECUTOR_WORKTREE_STATUS", "")
1318
- if wt_status.startswith("skipped"):
1319
- head_proc = _subprocess.run(
1320
- ["git", "rev-parse", "HEAD"],
1321
- cwd=inp.project_root, capture_output=True, text=True,
1322
- )
1323
- head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
1324
- now = _dt.datetime.now(_dt.timezone.utc).isoformat()
1325
- append_consumer(
1326
- plan_run_root,
1327
- impl_task_key=ctx["TASK_KEY"],
1330
+ # stage 격리도 동일하게 degrade — worktree 없이 project HEAD 기록.
1331
+ if executor_worktree_status.startswith("skipped"):
1332
+ head = _git_out(inp.project_root, "rev-parse", "HEAD")
1333
+ return StageSelection(
1328
1334
  stage=selected,
1329
- status="started",
1330
- started_at=now,
1331
- head_commit=head_sha,
1335
+ worktree_path="",
1336
+ worktree_branch="",
1337
+ worktree_base_ref="",
1338
+ worktree_status=executor_worktree_status,
1339
+ worktree_note="",
1340
+ started_head_commit=head,
1332
1341
  )
1333
- return
1334
1342
 
1335
1343
  # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
1336
- head_proc = _subprocess.run(
1337
- ["git", "rev-parse", "HEAD"],
1338
- cwd=inp.project_root, capture_output=True, text=True,
1339
- )
1340
- head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
1344
+ head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
1341
1345
  if head_sha:
1342
1346
  _reg.set_implementation_base(
1343
1347
  inp.project_id, inp.task_group, inp.task_id, head_sha,
@@ -1356,11 +1360,11 @@ def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map:
1356
1360
  candidate_base=head_sha, project_root=Path(inp.project_root),
1357
1361
  )
1358
1362
  try:
1359
- provision = _worktree.provision_stage_worktree(
1363
+ prov = _worktree.provision_stage_worktree(
1360
1364
  project_root=Path(inp.project_root),
1361
1365
  project_id=inp.project_id,
1362
- task_group_segment=ctx["TASK_GROUP_SEGMENT"],
1363
- task_id_segment=ctx["TASK_ID_SEGMENT"],
1366
+ task_group_segment=task_group_segment,
1367
+ task_id_segment=task_id_segment,
1364
1368
  work_category=inp.work_category,
1365
1369
  stage_number=selected,
1366
1370
  base_commit=stage_base,
@@ -1368,21 +1372,59 @@ def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map:
1368
1372
  except RuntimeError as exc:
1369
1373
  raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
1370
1374
 
1371
- ctx["EXECUTOR_WORKTREE_PATH"] = provision.path
1372
- ctx["EXECUTOR_WORKTREE_BRANCH"] = provision.branch
1373
- ctx["EXECUTOR_WORKTREE_BASE_REF"] = provision.base_ref
1374
- ctx["EXECUTOR_WORKTREE_STATUS"] = provision.status
1375
- ctx["EXECUTOR_WORKTREE_NOTE"] = provision.note
1375
+ return StageSelection(
1376
+ stage=selected,
1377
+ worktree_path=prov.path,
1378
+ worktree_branch=prov.branch,
1379
+ worktree_base_ref=prov.base_ref,
1380
+ worktree_status=prov.status,
1381
+ worktree_note=prov.note,
1382
+ started_head_commit=prov.base_ref,
1383
+ )
1384
+
1385
+
1386
+ def _apply_implementation_stage(
1387
+ inp: PrepareInputs,
1388
+ ctx: dict,
1389
+ ctx_stage_map: list,
1390
+ sel: StageSelection,
1391
+ ) -> None:
1392
+ """Wire a resolved `StageSelection` into ctx + consumers.jsonl. ctx-dependent
1393
+ counterpart to `_select_and_provision_implementation_stage`."""
1394
+ from .consumers import append_consumer
1395
+ import datetime as _dt
1396
+
1397
+ ctx["parsed_stage_map"] = ctx_stage_map
1398
+ ctx["effective_stages"] = [sel.stage]
1399
+ csv = str(sel.stage)
1400
+ ctx["EFFECTIVE_STAGES"] = csv
1401
+ ctx["STAGE_BATCH_DIRECTIVE"] = (
1402
+ f"- **Stage for this implementation run:** `{csv}`. "
1403
+ "Execute exactly this Stage Map stage — this is the authoritative scope. "
1404
+ "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
1405
+ "and reserved this stage."
1406
+ )
1407
+ inp.stage = csv
1408
+ # Observable contract: callers (okstra.sh, e2e harness) scan stdout for the
1409
+ # resolved stage. Keep this line — it is the run's stage-selection receipt.
1410
+ print(f"selected stages: {csv}", file=sys.stdout)
1411
+
1412
+ if sel.worktree_status and not sel.worktree_status.startswith("skipped"):
1413
+ ctx["EXECUTOR_WORKTREE_PATH"] = sel.worktree_path
1414
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = sel.worktree_branch
1415
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = sel.worktree_base_ref
1416
+ ctx["EXECUTOR_WORKTREE_STATUS"] = sel.worktree_status
1417
+ ctx["EXECUTOR_WORKTREE_NOTE"] = sel.worktree_note
1376
1418
 
1377
- # consumers append — stage worktree base 를 head_commit 으로
1378
1419
  now = _dt.datetime.now(_dt.timezone.utc).isoformat()
1420
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1379
1421
  append_consumer(
1380
1422
  plan_run_root,
1381
1423
  impl_task_key=ctx["TASK_KEY"],
1382
- stage=selected,
1424
+ stage=sel.stage,
1383
1425
  status="started",
1384
1426
  started_at=now,
1385
- head_commit=provision.base_ref,
1427
+ head_commit=sel.started_head_commit,
1386
1428
  )
1387
1429
 
1388
1430
 
@@ -1413,10 +1455,13 @@ def _reserve_final_verification_target(
1413
1455
  """final-verification 의 검증 target 을 registry/consumers/git 에서
1414
1456
  해소하고 gate 를 강제한다. 위반 시 PrepareError. 결과를 ctx 의
1415
1457
  VERIFICATION_* 키로 주입한다."""
1416
- from .consumers import read_consumers
1458
+ from .consumers import read_consumers, backfill_done_from_carry
1417
1459
  from . import worktree_registry as _reg
1418
1460
 
1419
1461
  plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1462
+ # carry sidecars are the SSOT for stage completion — recover missing `done`
1463
+ # rows before the whole-task gate checks every stage.
1464
+ backfill_done_from_carry(plan_run_root)
1420
1465
  done_rows = [r for r in read_consumers(plan_run_root)
1421
1466
  if r.get("status") == "done"]
1422
1467
 
@@ -1683,24 +1728,30 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1683
1728
  # 한 seq 를 강제하는 user-knob 환경 변수.
1684
1729
  raw_override = os.environ.get("OKSTRA_RUN_SEQ_OVERRIDE", "").strip()
1685
1730
  run_seq_override = int(raw_override) if raw_override else None
1686
- ctx = compute_and_write_run_context(
1687
- workspace_root=workspace_root, project_root=project_root,
1688
- project_id=inp.project_id, task_group=inp.task_group, task_id=inp.task_id,
1689
- task_type=inp.task_type, run_seq_override=run_seq_override,
1690
- )
1731
+
1732
+ # Identity segments derived with the SAME slugify rule compute_run_paths
1733
+ # uses, so they match ctx["TASK_GROUP_SEGMENT"]/["TASK_ID_SEGMENT"]
1734
+ # byte-for-byte. We need them BEFORE run-path compute because
1735
+ # implementation stage selection depends on the task-worktree degrade
1736
+ # status, and the run path itself is stage-namespaced.
1737
+ task_group_segment = slugify(inp.task_group)
1738
+ task_id_segment = slugify(inp.task_id)
1739
+ task_key = f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
1691
1740
 
1692
1741
  # ---- task worktree provisioning (every phase, every task-type) ----
1693
1742
  # One worktree per task-key: requirements-discovery, error-analysis,
1694
1743
  # implementation-planning and implementation phases of the same task
1695
1744
  # all share this directory and branch. The global registry handles
1696
- # reservation across concurrent runs.
1745
+ # reservation across concurrent runs. Runs BEFORE run-path compute: its
1746
+ # degrade status (skipped-*) feeds implementation stage selection, and
1747
+ # the resolved stage namespaces the run path.
1697
1748
  try:
1698
1749
  worktree = provision_task_worktree(
1699
1750
  task_type=inp.task_type,
1700
1751
  project_root=project_root,
1701
1752
  project_id=inp.project_id,
1702
- task_group_segment=ctx["TASK_GROUP_SEGMENT"],
1703
- task_id_segment=ctx["TASK_ID_SEGMENT"],
1753
+ task_group_segment=task_group_segment,
1754
+ task_id_segment=task_id_segment,
1704
1755
  work_category=inp.work_category,
1705
1756
  base_ref=inp.base_ref,
1706
1757
  require_base_ref=True,
@@ -1708,6 +1759,28 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1708
1759
  except RuntimeError as exc:
1709
1760
  raise PrepareError(f"task worktree provisioning failed: {exc}") from exc
1710
1761
 
1762
+ # ---- implementation stage selection (path-independent, reserves once) ----
1763
+ # Resolve + provision the stage BEFORE run-path compute so RUN_DIR lands
1764
+ # in runs/implementation/stage-<N>. The registry stage-key is reserved
1765
+ # exactly once here (inside provision_stage_worktree). Non-implementation
1766
+ # task-types skip this entirely → stage_arg stays None → identical paths.
1767
+ if inp.task_type == "implementation":
1768
+ impl_stage_selection = _select_and_provision_implementation_stage(
1769
+ inp, ctx_stage_map, task_group_segment, task_id_segment,
1770
+ task_key, worktree.status,
1771
+ )
1772
+ stage_arg = impl_stage_selection.stage
1773
+ else:
1774
+ impl_stage_selection = None
1775
+ stage_arg = None
1776
+
1777
+ ctx = compute_and_write_run_context(
1778
+ workspace_root=workspace_root, project_root=project_root,
1779
+ project_id=inp.project_id, task_group=inp.task_group, task_id=inp.task_id,
1780
+ task_type=inp.task_type, run_seq_override=run_seq_override,
1781
+ stage=stage_arg,
1782
+ )
1783
+
1711
1784
  ctx.update({
1712
1785
  "EXECUTOR_WORKTREE_PATH": worktree.path,
1713
1786
  "EXECUTOR_WORKTREE_BRANCH": worktree.branch,
@@ -1723,6 +1796,15 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1723
1796
  ),
1724
1797
  })
1725
1798
 
1799
+ # implementation: override the task-worktree fields with the resolved
1800
+ # STAGE worktree and append the consumers.jsonl started row. Must run
1801
+ # AFTER the task-worktree fields above (mirrors the original ordering
1802
+ # where stage reservation overrode them post task-provision).
1803
+ if inp.task_type == "implementation":
1804
+ _apply_implementation_stage(
1805
+ inp, ctx, ctx_stage_map, impl_stage_selection,
1806
+ )
1807
+
1726
1808
  if inp.render_only:
1727
1809
  # render-only entry path (e.g. okstra-run skill, in-session takeover):
1728
1810
  # the calling Claude session itself becomes the lead, so we must NOT
@@ -1803,9 +1885,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1803
1885
  "OKSTRA_VERSION": installed_version(),
1804
1886
  **workflow_state,
1805
1887
  })
1806
- if inp.task_type == "implementation":
1807
- _reserve_implementation_stages(inp, ctx, ctx_stage_map)
1808
- elif inp.task_type == "final-verification":
1888
+ if inp.task_type == "final-verification":
1809
1889
  _reserve_final_verification_target(inp, ctx, ctx_stage_map)
1810
1890
 
1811
1891
  # ---- prepare directories + cleanup ----
@@ -151,6 +151,7 @@ def compute_and_write_run_context(
151
151
  task_id: str,
152
152
  task_type: str,
153
153
  run_seq_override: Optional[int] = None,
154
+ stage: Optional[int] = None,
154
155
  ) -> dict:
155
156
  """task per-mutex 안에서 run paths 를 계산하고 디스크에 박는다.
156
157
 
@@ -174,6 +175,7 @@ def compute_and_write_run_context(
174
175
  task_id=task_id,
175
176
  task_type=task_type,
176
177
  run_seq_override=run_seq_override,
178
+ stage=stage,
177
179
  )
178
180
  ctx["RUN_TIMESTAMP_ISO"] = _now_iso()
179
181
  ctx["TASK_DATE"] = _now_task_date()
@@ -39,7 +39,9 @@ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
39
39
  from okstra_ctl.run import (
40
40
  APPROVED_FRONTMATTER_PATTERN,
41
41
  _extract_frontmatter_block,
42
+ _load_final_report_data_if_present,
42
43
  _reject_blocking_plan_body_gate,
44
+ _set_data_json_approved_true_if_present,
43
45
  _validate_data_json_approval_consistency,
44
46
  )
45
47
  from okstra_ctl.workers import (
@@ -228,6 +230,7 @@ S_BASE_REF_PICK = "base_ref_pick"
228
230
  S_BASE_REF_TEXT = "base_ref_text"
229
231
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
230
232
  S_APPROVED_PLAN = "approved_plan"
233
+ S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
231
234
  S_STAGE_PICK = "stage_pick"
232
235
  S_EXECUTOR = "executor"
233
236
  S_CRITIC_PICK = "critic_pick"
@@ -318,6 +321,9 @@ class WizardState:
318
321
  # impl extras
319
322
  approved_plan_path: str = ""
320
323
  approved_plan_pending_text: bool = False
324
+ # A plan that is approvable (gate ok, no blockers) but not yet `approved`.
325
+ # Set when the user selects such a plan; the approve-confirm step reads it.
326
+ approve_plan_candidate: str = ""
321
327
  selected_stage: str = "auto"
322
328
  executor: str = ""
323
329
  critic: str = ""
@@ -436,7 +442,31 @@ def _require_file(path_str: str, project_root: Path, label: str) -> Path:
436
442
  return p
437
443
 
438
444
 
439
- def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
445
+ def _data_json_approved_state(plan_path: Path) -> Optional[bool]:
446
+ """`approved` flag of the sibling final-report data.json (the SSOT).
447
+
448
+ Returns the bool when present, or None when there is no data.json or the
449
+ flag is missing / non-bool (legacy report — markdown frontmatter governs)."""
450
+ loaded = _load_final_report_data_if_present(plan_path)
451
+ if loaded is None:
452
+ return None
453
+ frontmatter = loaded[1].get("frontmatter")
454
+ if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
455
+ return None
456
+ value = frontmatter.get("approved")
457
+ return value if isinstance(value, bool) else None
458
+
459
+
460
+ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bool]:
461
+ """Resolve the plan and classify it as fully-approved vs approvable.
462
+
463
+ Returns ``(resolved_path, already_fully_approved)``. Raises WizardError ONLY
464
+ for failures that approval cannot fix: missing frontmatter / `approved:`
465
+ field, a blocking plan-body gate, an unparseable §1, or unresolved
466
+ `Blocks=approval` rows. A plan that is merely not-yet-approved (markdown or
467
+ data.json `approved: false`, gate ok, no blockers) returns
468
+ ``already_fully_approved=False`` — the approve-confirm step offers to flip it.
469
+ """
440
470
  p = _require_file(path_str, project_root, "approved plan")
441
471
  body = p.read_text(encoding="utf-8", errors="replace")
442
472
  frontmatter = _extract_frontmatter_block(body)
@@ -449,19 +479,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
449
479
  if not m:
450
480
  raise WizardError(
451
481
  f"approved plan frontmatter has no `approved:` field: {p}\n"
452
- " expected `approved: true` (report-writer emits `approved: false` "
453
- "by default; flip it once approved)."
454
- )
455
- if m.group(1).lower() != "true":
456
- raise WizardError(
457
- f"approved plan is not yet approved (frontmatter `approved: {m.group(1)}`): {p}\n"
458
- " edit the report and change the line to `approved: true`, or re-run "
459
- "okstra with `--approve` to flip it from the CLI."
482
+ " expected `approved: true` / `approved: false`. Re-render the "
483
+ "report if the field is missing."
460
484
  )
485
+ # A blocking gate or an open Blocks=approval row makes the plan UN-approvable
486
+ # — these raise regardless of the current flag value.
461
487
  _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
462
- _validate_data_json_approval_consistency(p, markdown_approved=True)
463
- # frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
464
- # 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
465
488
  blockers = unresolved_approval_blockers(body)
466
489
  if blockers is None and section_1_present_but_unparsed(body):
467
490
  raise WizardError(
@@ -472,14 +495,51 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
472
495
  )
473
496
  if blockers:
474
497
  lines = [
475
- f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
476
- f"unresolved `Blocks=approval` row(s); resolve them or mark them obsolete first:",
498
+ f"approved plan §1 has {len(blockers)} unresolved `Blocks=approval` "
499
+ "row(s); resolve them or mark them obsolete before approving:",
477
500
  ]
478
501
  for b in blockers:
479
502
  lines.append(f" - {b.row_id} (Status={b.raw_status})")
480
503
  lines.append(f" file: {p}")
481
504
  raise WizardError("\n".join(lines))
482
- return p
505
+ markdown_approved = m.group(1).lower() == "true"
506
+ data_state = _data_json_approved_state(p)
507
+ fully_approved = markdown_approved and data_state is not False
508
+ return p, fully_approved
509
+
510
+
511
+ def _approve_plan_in_place(plan_path: Path) -> None:
512
+ """Flip the plan to approved at the source of truth and re-render.
513
+
514
+ data.json present → `_set_data_json_approved_true_if_present` sets
515
+ `frontmatter.approved=true` there and re-renders the markdown from it (so
516
+ both agree). data.json absent (legacy) → flip the markdown frontmatter line."""
517
+ rendered = _set_data_json_approved_true_if_present(plan_path)
518
+ if rendered:
519
+ return
520
+ body = plan_path.read_text(encoding="utf-8", errors="replace")
521
+ flipped = APPROVED_FRONTMATTER_PATTERN.sub("approved: true", body, count=1)
522
+ if flipped == body:
523
+ raise WizardError(
524
+ f"approve-plan: could not flip the markdown `approved:` line: {plan_path}"
525
+ )
526
+ plan_path.write_text(flipped, encoding="utf-8")
527
+
528
+
529
+ def _stage_plan_for_confirmation(
530
+ state: WizardState, path_str: str, *, suffix: str = ""
531
+ ) -> Optional[str]:
532
+ """Resolve + validate a selected plan, then stage it for the approve-confirm
533
+ step. Selection NEVER finalizes the plan — the confirm step always runs and
534
+ asks the user to proceed (approving the plan first if it is not yet approved).
535
+ `_classify_approved_plan` still raises for failures approval cannot fix."""
536
+ p, _ = _classify_approved_plan(path_str, Path(state.project_root))
537
+ state.approved_plan_pending_text = False
538
+ state.approved_plan_path = ""
539
+ state.approve_plan_candidate = str(p)
540
+ t = _p(state.workspace_root, "approve_plan_confirm", path=str(p))
541
+ msg = t["echo_variants"]["selected"].format(path=p)
542
+ return f"{msg} {suffix}".rstrip() if suffix else msg
483
543
 
484
544
 
485
545
  def _git_main_worktree(project_root: Path) -> Path:
@@ -1274,17 +1334,11 @@ def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
1274
1334
  default = _latest_implementation_planning_report(state)
1275
1335
  if default is None:
1276
1336
  raise WizardError(t["errors"]["default_not_found"])
1277
- p = _validate_approved_plan(str(default), Path(state.project_root))
1278
- state.approved_plan_path = str(p)
1279
- state.approved_plan_pending_text = False
1280
- return f"approved-plan: {p}"
1337
+ return _stage_plan_for_confirmation(state, str(default))
1281
1338
  if value.startswith(_REPORT_PREFIX):
1282
1339
  rel = value[len(_REPORT_PREFIX):]
1283
- p = _validate_approved_plan(rel, Path(state.project_root))
1284
- state.approved_plan_path = str(p)
1285
- state.approved_plan_pending_text = False
1286
- suffix = t["echo_suffixes"]["other_report"]
1287
- return f"approved-plan: {p} {suffix}"
1340
+ return _stage_plan_for_confirmation(
1341
+ state, rel, suffix=t["echo_suffixes"]["other_report"])
1288
1342
  if value == PICK_OTHER:
1289
1343
  state.approved_plan_pending_text = True
1290
1344
  state.approved_plan_path = ""
@@ -1305,10 +1359,44 @@ def _build_approved_plan(state: WizardState) -> Prompt:
1305
1359
 
1306
1360
 
1307
1361
  def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
1308
- p = _validate_approved_plan(value, Path(state.project_root))
1309
- state.approved_plan_path = str(p)
1310
- state.approved_plan_pending_text = False
1311
- return f"approved-plan: {p}"
1362
+ return _stage_plan_for_confirmation(state, value)
1363
+
1364
+
1365
+ def _build_approve_plan_confirm(state: WizardState) -> Prompt:
1366
+ t = _p(state.workspace_root, "approve_plan_confirm",
1367
+ path=state.approve_plan_candidate)
1368
+ return Prompt(
1369
+ step=S_APPROVE_PLAN_CONFIRM, kind="pick",
1370
+ label=t["label"],
1371
+ options=[_opt(k, v) for k, v in t["options"].items()],
1372
+ echo_template=t["echo_template"],
1373
+ )
1374
+
1375
+
1376
+ def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
1377
+ if value not in ("yes", "no"):
1378
+ raise WizardError(f"expected 'yes' or 'no', got: {value!r}")
1379
+ candidate = state.approve_plan_candidate
1380
+ if not candidate:
1381
+ raise WizardError("approve-plan: no candidate plan to approve")
1382
+ t = _p(state.workspace_root, "approve_plan_confirm", path=candidate)
1383
+ if value == "no":
1384
+ # Declining leaves the candidate set so the confirm step re-prompts;
1385
+ # implementation cannot proceed without choosing to proceed.
1386
+ raise WizardError(t["errors"]["declined"])
1387
+ p = Path(candidate)
1388
+ resolved, fully_approved = _classify_approved_plan(
1389
+ str(p), Path(state.project_root))
1390
+ if not fully_approved:
1391
+ # Not yet approved → flip data.json (SSOT) + re-render, then re-verify.
1392
+ _approve_plan_in_place(p)
1393
+ resolved, fully_approved = _classify_approved_plan(
1394
+ str(p), Path(state.project_root))
1395
+ if not fully_approved:
1396
+ raise WizardError(t["errors"]["still_unapproved"].format(path=resolved))
1397
+ state.approved_plan_path = str(resolved)
1398
+ state.approve_plan_candidate = ""
1399
+ return t["echo_variants"]["approved"].format(path=resolved)
1312
1400
 
1313
1401
 
1314
1402
  def _build_stage_pick(state: WizardState) -> Prompt:
@@ -1765,7 +1853,10 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
1765
1853
 
1766
1854
 
1767
1855
  def _build_defaults_or_custom(state: WizardState) -> Prompt:
1768
- t = _p(state.workspace_root, "defaults_or_custom")
1856
+ roster = (state.workers_override
1857
+ or ",".join(state.profile_workers)
1858
+ or "(profile default)")
1859
+ t = _p(state.workspace_root, "defaults_or_custom", workers=roster)
1769
1860
  return Prompt(
1770
1861
  step=S_DEFAULTS_OR_CUSTOM, kind="pick",
1771
1862
  label=t["label"],
@@ -2202,6 +2293,13 @@ STEPS: list[Step] = [
2202
2293
  or _latest_implementation_planning_report(s) is None)),
2203
2294
  build=_build_approved_plan, submit=_submit_approved_plan,
2204
2295
  owns=("approved_plan_path", "approved_plan_pending_text")),
2296
+ Step(S_APPROVE_PLAN_CONFIRM,
2297
+ applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
2298
+ and bool(s.approve_plan_candidate)
2299
+ and not s.approved_plan_path
2300
+ and S_APPROVE_PLAN_CONFIRM not in s.answered),
2301
+ build=_build_approve_plan_confirm, submit=_submit_approve_plan_confirm,
2302
+ owns=("approve_plan_candidate",)),
2205
2303
  Step(S_STAGE_PICK,
2206
2304
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
2207
2305
  and bool(s.approved_plan_path)
@@ -273,6 +273,8 @@ Agent(
273
273
  - Agent Teams mode: Spawn within an existing team
274
274
  - Fallback mode: Spawn with `run_in_background: true` and no `team_name`
275
275
 
276
+ **Completion detection per round (BLOCKING).** Each round dispatches a variable set (1..N) of reverify workers asynchronously; the `Agent(... team_name ...)` calls return `Spawned successfully` immediately, which is NOT completion. Lead MUST detect each round's completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", with the pending set reconstructed from that round's dispatched workers' Result Paths — do NOT restate the algorithm here. Lead MUST NOT treat the spawn ack as completion and MUST NOT end its turn with a prose "waiting" statement.
277
+
276
278
  ### Required reverify-prompt anchor headers (BLOCKING)
277
279
 
278
280
  Every reverify prompt MUST start with the same 5 anchor headers used in the initial Phase 4 dispatch — in this exact order, before any other content:
@@ -630,7 +632,7 @@ Default values are emitted into the manifest by `scripts/okstra_ctl/render.py` (
630
632
 
631
633
  ### Plan-item extraction (Round 0 equivalent)
632
634
 
633
- From the report-writer's draft of `## 5.5 Implementation Plan Deliverables`, lead extracts plan items with the following prefixes (see also `templates/reports/final-report.template.md` §5.5.9):
635
+ From the report-writer's draft of `## 5.4 Implementation Plan Deliverables`, lead extracts plan items with the following prefixes (see also `templates/reports/final-report.template.md` §5.5.9):
634
636
 
635
637
  | Prefix | Source sub-section | One row per |
636
638
  |--------|--------------------|-------------|
@@ -62,6 +62,8 @@ The prompt MUST include, in this order at the top:
62
62
  11. For implementation-planning runs: a literal block listing the 8 required English section headings the validator scans for (`Option Candidates`, `Trade-off`, `Recommended Option`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `User Approval Request`). The writer must use these exact substrings as section headings (Korean translation in parentheses is allowed).
63
63
  12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "python3 scripts/okstra-render-final-report.py <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
64
64
 
65
+ **Completion detection after dispatch (BLOCKING).** The `Agent(... team_name ...)` call returns `Spawned successfully` immediately; that ack is NOT completion. After dispatching the report-writer (async), Lead MUST detect its completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", polling for the appearance of the data.json (Result Path) and the worker-results file (Worker Result Path) — do NOT restate the algorithm here. Report-writer is a single worker, so the pending set has one entry; the SSOT protocol handles that naturally. Lead MUST NOT treat the `Spawned successfully` ack as completion and MUST NOT end its turn with a prose "waiting for the report" statement; that path stalls the run until the user manually nudges it.
66
+
65
67
  ### Resume-safe dispatch
66
68
 
67
69
  A resumed lead session can ALWAYS dispatch a fresh Report writer worker. The Agent tool does not require a previously created Team to be alive:
@@ -48,7 +48,7 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
48
48
 
49
49
  The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed.
50
50
 
51
- Never invent additional questions. Never reorder. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
51
+ Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. Do NOT collapse a multi-option pick into a "recommended + 직접 입력 / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: the `executor` step always emits `claude` / `codex` / `gemini` — show all three, never just `claude`. The run-prompt recommendation rule (1–2 추천 + 직접 입력) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
52
52
 
53
53
  ## Step 1: Verify okstra runtime + project setup
54
54
 
@@ -121,6 +121,43 @@ Terminal statuses that can be recorded for a worker:
121
121
  | `error` | Execution error, reason recorded; prompt history file must exist |
122
122
  | `not-run` | Not executed, reason recorded |
123
123
 
124
+ ## Worker-completion detection (self-scheduled polling)
125
+
126
+ **SSOT.** This section is the single source of truth for how Lead detects worker completion across all phases and all worker kinds (Claude teammate, Codex / Gemini wrappers). Other documents (`agents/SKILL.md`, `okstra-report-writer`, `okstra-convergence`) reference this section by name; they MUST NOT restate the algorithm.
127
+
128
+ Lead dispatches workers asynchronously: an `Agent` call carrying `team_name` returns `Spawned successfully` **immediately** — that ack is NOT a completion. Lead MUST NOT treat the spawn ack as completion, and MUST NOT end its turn with a prose "waiting for ..." statement (that path stalls the run — the Agent Teams idle-notification is experimental and can be dropped, leaving Lead parked until the user manually nudges it). Instead:
129
+
130
+ 1. Record the dispatched workers' Result Paths as the **pending set** (resolved to absolute from each launch prompt's `**Result Path:**` anchor header against `**Project Root:**`; the same paths recorded in run-manifest / team-state).
131
+ 2. Arm a SINGLE self-wakeup: one `Bash(run_in_background: true)` poll covering ALL dispatched workers (not one background task per worker):
132
+
133
+ ```bash
134
+ deadline=$((SECONDS + <per-worker-deadline-seconds>))
135
+ until [ -f "<result_A>" ] && [ -f "<result_B>" ]; do
136
+ [ $SECONDS -ge $deadline ] && { echo "POLL_TIMEOUT"; exit 1; }
137
+ sleep 5
138
+ done
139
+ echo "ALL_WORKERS_DONE"
140
+ ```
141
+
142
+ The `sleep 5` inside this `until` loop is legal ONLY because the poll runs under `run_in_background: true`. A foreground `sleep` of 5s or longer is blocked by the harness anti-circumvention rule (see the Codex / Gemini wrapper `BashOutput` polling contract above) — do NOT lift this loop into a foreground `Bash`.
143
+ 3. End the turn. The harness auto-resumes Lead when the background poll exits — on completion (`ALL_WORKERS_DONE`) OR timeout (`POLL_TIMEOUT`) — with no mailbox / idle-notification dependency and no user nudge.
144
+ 4. On resume, for every path still in the pending set: verify the file exists AND passes the standardized worker-result header check (see "Worker Result Header Standard" below). Move each passing worker to the **done set**.
145
+ 5. Termination:
146
+ - pending set empty → proceed to the next phase. The background task already self-terminated, so there is NO schedule to disarm — this self-terminating property is why a background poll is preferred over a cron schedule.
147
+ - the poll exited `POLL_TIMEOUT` for a worker past its deadline → record terminal status `timeout` for that worker, remove it from the pending set, then redispatch-once (per "Lead Redispatch Policy on Result-Missing" below) or proceed.
148
+ - any error / abort path → no zombie schedule exists, because the background task is self-terminating.
149
+ 6. **Per-worker soft timeout (BLOCKING).** Use 2× the task-type expected per-worker duration in the table below as `<per-worker-deadline-seconds>`. This supersedes the unimplemented "수정 B" in `agents/TODO.md` (Leader-side worker soft timeout): the background poll's `deadline` IS that safety net.
150
+
151
+ | Task type | Expected per-worker | Deadline (2×) |
152
+ |---|---|---|
153
+ | requirements-discovery | 10 min | 20 min |
154
+ | error-analysis | 15 min | 30 min |
155
+ | implementation-planning | 20 min | 40 min |
156
+ | implementation | 20 min | 40 min |
157
+ | final-verification | 10 min | 20 min |
158
+
159
+ Relationship to the Codex / Gemini wrapper polling contract: that contract (in the errors-sidecar section above) governs how a *wrapper subagent* waits on its own external CLI via `BashOutput`. This section governs how *Lead* waits on the worker subagents themselves. The two compose — Lead's background poll watches the result files; each wrapper independently watches its CLI — and neither imposes a timeout on the other (see "No external timeout on wrapper subagents").
160
+
124
161
  ## Lead Redispatch Policy on Result-Missing
125
162
 
126
163
  After each worker subagent returns (regardless of role), Lead MUST verify the canonical result file exists at the absolute path resolved from the `**Result Path:**` anchor header (against `**Project Root:**`). The check is identical for in-process workers (claude-worker) and CLI-wrapper workers (codex-worker / gemini-worker).