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.
- package/docs/kr/architecture.md +1 -1
- package/docs/superpowers/plans/2026-06-09-implementation-run-artifact-stage-isolation.md +320 -0
- package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling-PROBE.md +42 -0
- package/docs/superpowers/plans/2026-06-10-lead-worker-completion-polling.md +337 -0
- package/docs/superpowers/specs/2026-06-09-executor-model-custom-id-cascade-design.md +66 -0
- package/docs/superpowers/specs/2026-06-09-implementation-run-artifact-stage-isolation-design.md +87 -0
- package/docs/superpowers/specs/2026-06-10-lead-worker-completion-polling-design.md +113 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +5 -2
- package/runtime/agents/TODO.md +9 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +23 -4
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/backfill.py +23 -4
- package/runtime/python/okstra_ctl/consumers.py +118 -1
- package/runtime/python/okstra_ctl/paths.py +11 -0
- package/runtime/python/okstra_ctl/run.py +147 -67
- package/runtime/python/okstra_ctl/run_context.py +2 -0
- package/runtime/python/okstra_ctl/wizard.py +127 -29
- package/runtime/skills/okstra-convergence/SKILL.md +3 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +37 -0
- package/runtime/templates/reports/final-report.template.md +1 -1
- package/runtime/validators/validate-run.py +20 -3
- package/src/install.mjs +21 -0
- 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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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 —
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
1363
|
-
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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=
|
|
1424
|
+
stage=sel.stage,
|
|
1383
1425
|
status="started",
|
|
1384
1426
|
started_at=now,
|
|
1385
|
-
head_commit=
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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=
|
|
1703
|
-
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 == "
|
|
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
|
|
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`
|
|
453
|
-
"
|
|
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
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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.
|
|
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).
|