okstra 0.67.0 → 0.69.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/bin/okstra +25 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +8 -7
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +5 -5
- package/runtime/python/okstra_ctl/conformance.py +17 -0
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +175 -44
- package/runtime/python/okstra_ctl/wizard.py +89 -22
- package/runtime/python/okstra_ctl/worktree.py +28 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/python/okstra_token_usage/collect.py +27 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +8 -8
- package/runtime/skills/okstra-run/SKILL.md +43 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -7
- package/runtime/validators/validate-run.py +51 -11
- package/src/_python-helper.mjs +52 -0
- package/src/error-log.mjs +19 -0
- package/src/git-reconcile.mjs +31 -0
- package/src/handoff.mjs +30 -0
- package/src/inject-report-index.mjs +22 -0
- package/src/render-final-report.mjs +22 -0
- package/src/render-views.mjs +9 -48
- package/src/spawn-followups.mjs +23 -0
- package/src/token-usage.mjs +3 -34
|
@@ -1641,8 +1641,11 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1641
1641
|
# - release-handoff (and any other phase that renders with no workers
|
|
1642
1642
|
# selected) is single-lead and MUST NOT call `TeamCreate`. Emit a
|
|
1643
1643
|
# short notice instead of the BLOCKING gate.
|
|
1644
|
+
# - concurrent implementation runs skip team creation to avoid racing the
|
|
1645
|
+
# shared `~/.claude/teams/` config.
|
|
1644
1646
|
# - All other phases keep the full team-creation contract.
|
|
1645
1647
|
task_type = ctx.get("TASK_TYPE", "")
|
|
1648
|
+
concurrent_stages = str(ctx.get("CONCURRENT_RUN_STAGES", "") or "").strip()
|
|
1646
1649
|
if task_type == "release-handoff" or not selected:
|
|
1647
1650
|
team_creation_gate_block = (
|
|
1648
1651
|
"## Single-Lead Phase (no team creation)\n"
|
|
@@ -1655,7 +1658,34 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1655
1658
|
"report). Do NOT call `TeamCreate` or dispatch any sub-agent\n"
|
|
1656
1659
|
"from this run — that would be a contract violation."
|
|
1657
1660
|
)
|
|
1661
|
+
elif task_type == "implementation" and concurrent_stages:
|
|
1662
|
+
team_creation_gate_block = (
|
|
1663
|
+
"## Concurrent-run: no-team background (BLOCKING)\n"
|
|
1664
|
+
"\n"
|
|
1665
|
+
"A concurrent implementation run of the same task-key holds stage(s)\n"
|
|
1666
|
+
f"`{concurrent_stages}` right now. Creating a team would race the shared\n"
|
|
1667
|
+
"`~/.claude/teams/` config and can make another stage's `config.json`\n"
|
|
1668
|
+
"unreadable. This run therefore does NOT create a team.\n"
|
|
1669
|
+
"\n"
|
|
1670
|
+
"Required actions, in order:\n"
|
|
1671
|
+
"\n"
|
|
1672
|
+
"1. Do NOT call `TeamCreate`, and do NOT dispatch any worker with\n"
|
|
1673
|
+
" `Agent(... team_name: ...)`.\n"
|
|
1674
|
+
"2. Before any dispatch, record in team-state:\n"
|
|
1675
|
+
' `teamCreate: { attempted: false, status: "skipped",'
|
|
1676
|
+
f' reason: "concurrent-run", concurrentStages: [{concurrent_stages}] }}`.\n'
|
|
1677
|
+
"3. Dispatch every worker with `run_in_background: true` and NO\n"
|
|
1678
|
+
" `team_name` (the Phase 5 fallback). Worker completion is detected by\n"
|
|
1679
|
+
" result-file polling, so analysis output is equivalent — only the\n"
|
|
1680
|
+
" Teams split-pane view is lost."
|
|
1681
|
+
)
|
|
1658
1682
|
else:
|
|
1683
|
+
team_name = f'okstra-{ctx.get("TASK_KEY", "")}'
|
|
1684
|
+
stage = str(ctx.get("EFFECTIVE_STAGES", "") or "").strip()
|
|
1685
|
+
if task_type == "implementation" and stage:
|
|
1686
|
+
# stage 격리 run 은 stage 별 team — 같은 task 의 다른 stage 가 남긴
|
|
1687
|
+
# team 과 이름이 충돌하지 않는다(worktree branch `-s<N>` 접미사와 동형).
|
|
1688
|
+
team_name = f"{team_name}-s{stage}"
|
|
1659
1689
|
team_creation_gate_block = (
|
|
1660
1690
|
"## Team Creation Gate (BLOCKING)\n"
|
|
1661
1691
|
"\n"
|
|
@@ -1671,14 +1701,26 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
|
|
|
1671
1701
|
"\n"
|
|
1672
1702
|
"1. Invoke the `okstra-team-contract` skill and verify the selected worker\n"
|
|
1673
1703
|
" roster against `task-manifest.json`'s `resultContract.requiredWorkerRoles`.\n"
|
|
1674
|
-
f'2. Call `TeamCreate(team_name: "
|
|
1704
|
+
f'2. Call `TeamCreate(team_name: "{team_name}", description: ...)`.\n'
|
|
1675
1705
|
"3. Record the outcome in team-state under\n"
|
|
1676
1706
|
' `teamCreate: { attempted: true, status: "ok" | "error", error?: <msg> }`\n'
|
|
1677
|
-
" BEFORE any `Agent(...)` worker
|
|
1707
|
+
" AND record the exact name as `teamName` BEFORE any `Agent(...)` worker\n"
|
|
1708
|
+
" dispatch.\n"
|
|
1678
1709
|
"4. Only after `teamCreate` is persisted may you dispatch workers — with\n"
|
|
1679
1710
|
" `team_name` on success, or with `run_in_background: true` and no\n"
|
|
1680
1711
|
' `team_name` ONLY when `teamCreate.status == "error"` was recorded.\n'
|
|
1681
1712
|
"\n"
|
|
1713
|
+
'If `TeamCreate` fails with "team already exists" (stale leftover from an\n'
|
|
1714
|
+
"earlier attempt): call `TeamList`; if the team is listed in this session,\n"
|
|
1715
|
+
"`TeamDelete` it and retry step 2 once. If it is NOT listed, do NOT remove\n"
|
|
1716
|
+
"`~/.claude/teams/...` / `~/.claude/tasks/...` on your own initiative —\n"
|
|
1717
|
+
"shell deletion of harness-internal state is destructive and `rm -rf` is\n"
|
|
1718
|
+
"commonly denied by user permission rules. Instead ask the user via\n"
|
|
1719
|
+
"AskUserQuestion (recommended option: quarantine), and on approval move\n"
|
|
1720
|
+
f"`~/.claude/teams/{team_name}` and `~/.claude/tasks/{team_name}` into\n"
|
|
1721
|
+
"`~/.okstra/trash/<UTC-timestamp>/` with `mv` (reversible, no delete),\n"
|
|
1722
|
+
"then retry step 2 once.\n"
|
|
1723
|
+
"\n"
|
|
1682
1724
|
'If the Agent tool rejects a dispatch with `"team must be created first"` /\n'
|
|
1683
1725
|
'`"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct\n'
|
|
1684
1726
|
"response is to go back to step 2 — NOT to strip `team_name` and retry."
|
|
@@ -81,7 +81,11 @@ from .workers import (
|
|
|
81
81
|
)
|
|
82
82
|
from .workflow import compute_workflow_state
|
|
83
83
|
from .locks import worktree_provision_mutex
|
|
84
|
-
from .worktree import
|
|
84
|
+
from .worktree import (
|
|
85
|
+
WorktreeProvision,
|
|
86
|
+
okstra_clean_gate_excludes,
|
|
87
|
+
provision_task_worktree,
|
|
88
|
+
)
|
|
85
89
|
|
|
86
90
|
# Frontmatter approval-flag matcher.
|
|
87
91
|
#
|
|
@@ -462,12 +466,41 @@ def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
|
|
|
462
466
|
return r.returncode == 0
|
|
463
467
|
|
|
464
468
|
|
|
469
|
+
def _check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
470
|
+
pred_commits: dict, candidate_base: str,
|
|
471
|
+
stage_n: int) -> None:
|
|
472
|
+
"""모든 선행 done commit 이 candidate 에 (내용 기준으로라도) 머지됐는지
|
|
473
|
+
검증. patch-equivalent 면 보정 row 를 자동 기록(다음 run 은 ancestor 로
|
|
474
|
+
바로 통과). 아니면 회복 안내를 담아 PrepareError."""
|
|
475
|
+
from .git_reconcile import content_merged, _record_reconciled
|
|
476
|
+
for d, head in pred_commits.items():
|
|
477
|
+
if _commit_is_ancestor(project_root, head, candidate_base):
|
|
478
|
+
continue
|
|
479
|
+
match = content_merged(project_root, head, candidate_base)
|
|
480
|
+
if match.status in ("ancestor", "patch-equivalent"):
|
|
481
|
+
if plan_run_root is not None:
|
|
482
|
+
_record_reconciled(
|
|
483
|
+
plan_run_root,
|
|
484
|
+
impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
|
|
485
|
+
stage=d, new_commit=match.matched_commit,
|
|
486
|
+
replaced=head, reason="auto-patch-id")
|
|
487
|
+
continue
|
|
488
|
+
raise PrepareError(
|
|
489
|
+
f"multi-dependency stage {stage_n}: predecessor stage {d} "
|
|
490
|
+
f"({head[:8]}) is not merged into the task worktree "
|
|
491
|
+
f"({candidate_base[:8]}). Merge stage branches "
|
|
492
|
+
f"(e.g. the `-s{d}` branches) into the task worktree "
|
|
493
|
+
"(or into main, then refresh the worktree) and retry."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
465
497
|
def _resolve_stage_base_commit(
|
|
466
498
|
stage: dict,
|
|
467
499
|
consumer_done_rows: list,
|
|
468
500
|
anchor_base_commit: str,
|
|
469
501
|
candidate_base: str = "",
|
|
470
502
|
project_root=None,
|
|
503
|
+
plan_run_root=None,
|
|
471
504
|
) -> str:
|
|
472
505
|
"""Pick the git base commit a stage's isolated worktree branches from.
|
|
473
506
|
|
|
@@ -479,18 +512,15 @@ def _resolve_stage_base_commit(
|
|
|
479
512
|
candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
|
|
480
513
|
|
|
481
514
|
Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
|
|
515
|
+
from .consumers import latest_done_by_stage
|
|
516
|
+
latest = latest_done_by_stage(consumer_done_rows)
|
|
482
517
|
deps = stage.get("depends_on") or []
|
|
483
518
|
if len(deps) >= 2:
|
|
484
519
|
n = stage["stage_number"]
|
|
485
520
|
# 1) 모든 선행의 done head_commit 수집
|
|
486
521
|
pred_commits = {}
|
|
487
522
|
for d in deps:
|
|
488
|
-
head =
|
|
489
|
-
(r.get("head_commit") for r in consumer_done_rows
|
|
490
|
-
if r.get("stage") == d and r.get("status") == "done"
|
|
491
|
-
and r.get("head_commit")),
|
|
492
|
-
None,
|
|
493
|
-
)
|
|
523
|
+
head = (latest.get(d) or {}).get("head_commit")
|
|
494
524
|
if not head:
|
|
495
525
|
raise PrepareError(
|
|
496
526
|
f"predecessor stage {d} has no done row with head_commit "
|
|
@@ -503,16 +533,10 @@ def _resolve_stage_base_commit(
|
|
|
503
533
|
f"candidate base missing for multi-dependency stage {n}; "
|
|
504
534
|
"task-key worktree HEAD could not be resolved"
|
|
505
535
|
)
|
|
506
|
-
# 3) 모든 선행 done 이 candidate
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
f"multi-dependency stage {n}: predecessor stage {d} "
|
|
511
|
-
f"({head[:8]}) is not merged into the task worktree "
|
|
512
|
-
f"({candidate_base[:8]}). Merge stage branches "
|
|
513
|
-
f"(e.g. the `-s{d}` branches) into the task worktree "
|
|
514
|
-
"(or into main, then refresh the worktree) and retry."
|
|
515
|
-
)
|
|
536
|
+
# 3) 모든 선행 done 이 candidate 에 (ancestor 또는 patch-equivalent 로)
|
|
537
|
+
# 머지됐는지 — patch-equivalent 면 보정 row 를 자동 기록한다.
|
|
538
|
+
_check_multi_dep_merged(project_root, plan_run_root, latest,
|
|
539
|
+
pred_commits, candidate_base, n)
|
|
516
540
|
return candidate_base
|
|
517
541
|
if not deps:
|
|
518
542
|
if not anchor_base_commit:
|
|
@@ -524,11 +548,9 @@ def _resolve_stage_base_commit(
|
|
|
524
548
|
return anchor_base_commit
|
|
525
549
|
# 단일 의존
|
|
526
550
|
pred = deps[0]
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if head:
|
|
531
|
-
return head
|
|
551
|
+
head = (latest.get(pred) or {}).get("head_commit") or ""
|
|
552
|
+
if head:
|
|
553
|
+
return head
|
|
532
554
|
raise PrepareError(
|
|
533
555
|
f"predecessor stage {pred} has no done row with head_commit in "
|
|
534
556
|
"consumers.jsonl; cannot derive base for stage "
|
|
@@ -553,7 +575,8 @@ def _resolve_whole_task_target(
|
|
|
553
575
|
) -> "_FVTarget":
|
|
554
576
|
"""전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
|
|
555
577
|
worktree clean 이어야 한다. 위반 시 PrepareError."""
|
|
556
|
-
|
|
578
|
+
from .consumers import latest_done_by_stage
|
|
579
|
+
done_by_stage = latest_done_by_stage(done_rows)
|
|
557
580
|
for s in stage_map:
|
|
558
581
|
n = s["stage_number"]
|
|
559
582
|
if n not in done_by_stage:
|
|
@@ -587,8 +610,9 @@ def _resolve_single_stage_target(
|
|
|
587
610
|
) -> "_FVTarget":
|
|
588
611
|
"""단독-stage 검증 target. stage N 만 done + stage worktree 존재 + clean.
|
|
589
612
|
다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
|
|
613
|
+
from .consumers import latest_done_by_stage
|
|
590
614
|
n = int(requested_stage)
|
|
591
|
-
done_by_stage =
|
|
615
|
+
done_by_stage = latest_done_by_stage(done_rows)
|
|
592
616
|
if n not in done_by_stage:
|
|
593
617
|
raise PrepareError(
|
|
594
618
|
f"final-verification(single-stage): stage {n} not done — "
|
|
@@ -1131,6 +1155,46 @@ def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> N
|
|
|
1131
1155
|
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
|
|
1132
1156
|
|
|
1133
1157
|
|
|
1158
|
+
def _clear_stale_stage_waiver(inp: "PrepareInputs", project_root: Path, stage: int) -> None:
|
|
1159
|
+
"""A fresh `implementation` run of stage N must not inherit a waiver left on
|
|
1160
|
+
its conformance entry by an earlier run (e.g. an all-gate run that pre-waived
|
|
1161
|
+
future stages, or an abandoned attempt). A stale waiver makes the verifier
|
|
1162
|
+
skip Tier 3 conformance and silently mask this stage, so clear it — UNLESS
|
|
1163
|
+
the user re-waived this exact stage for this run via `--qa-waiver` (already
|
|
1164
|
+
applied upstream in `_apply_qa_waiver_if_requested`)."""
|
|
1165
|
+
from .conformance import clear_qa_waiver, parse_qa_waiver_arg
|
|
1166
|
+
from .paths import task_dir
|
|
1167
|
+
manifest_path = (
|
|
1168
|
+
task_dir(project_root, inp.task_group, inp.task_id)
|
|
1169
|
+
/ "qa" / "conformance-manifest.json"
|
|
1170
|
+
)
|
|
1171
|
+
if not manifest_path.is_file():
|
|
1172
|
+
return
|
|
1173
|
+
manifest = json.loads(manifest_path.read_text())
|
|
1174
|
+
entries = manifest.get("entries") if isinstance(manifest, dict) else None
|
|
1175
|
+
if not isinstance(entries, list):
|
|
1176
|
+
return
|
|
1177
|
+
# The manifest stageKey is `<task-id>-stage-<N>` authored by planning; match
|
|
1178
|
+
# on the `-stage-<N>` suffix so we do not assume the task-id's exact form.
|
|
1179
|
+
suffix = f"-stage-{stage}"
|
|
1180
|
+
stage_key = next(
|
|
1181
|
+
(e["stageKey"] for e in entries
|
|
1182
|
+
if isinstance(e, dict) and isinstance(e.get("stageKey"), str)
|
|
1183
|
+
and e["stageKey"].endswith(suffix)),
|
|
1184
|
+
None,
|
|
1185
|
+
)
|
|
1186
|
+
if stage_key is None:
|
|
1187
|
+
return
|
|
1188
|
+
if inp.qa_waiver:
|
|
1189
|
+
parsed = parse_qa_waiver_arg(inp.qa_waiver)
|
|
1190
|
+
if parsed is not None and parsed[0] == stage_key:
|
|
1191
|
+
return # user intentionally waived this stage for this run
|
|
1192
|
+
if clear_qa_waiver(manifest, stage_key):
|
|
1193
|
+
manifest_path.write_text(
|
|
1194
|
+
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
|
|
1134
1198
|
def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
|
|
1135
1199
|
"""project.json self-registration + (implementation 한정) qaCommands gate 검증."""
|
|
1136
1200
|
from okstra_project import ResolverError
|
|
@@ -1290,6 +1354,7 @@ class StageSelection:
|
|
|
1290
1354
|
worktree_status: str
|
|
1291
1355
|
worktree_note: str
|
|
1292
1356
|
started_head_commit: str
|
|
1357
|
+
concurrent_stages: list = field(default_factory=list)
|
|
1293
1358
|
|
|
1294
1359
|
|
|
1295
1360
|
def _select_and_provision_implementation_stage(
|
|
@@ -1315,6 +1380,7 @@ def _select_and_provision_implementation_stage(
|
|
|
1315
1380
|
# carry sidecars are the SSOT for stage completion; recover any `done` rows
|
|
1316
1381
|
# the lead failed to append before the dependency gate reads them.
|
|
1317
1382
|
backfill_done_from_carry(plan_run_root)
|
|
1383
|
+
_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1318
1384
|
consumed = read_consumers(plan_run_root)
|
|
1319
1385
|
done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
|
|
1320
1386
|
started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
|
|
@@ -1327,6 +1393,7 @@ def _select_and_provision_implementation_stage(
|
|
|
1327
1393
|
started_stages=started_stages, reserved_stages=reserved_stages,
|
|
1328
1394
|
)
|
|
1329
1395
|
selected = batch[0]
|
|
1396
|
+
concurrent_stages = sorted(reserved_stages - {selected})
|
|
1330
1397
|
|
|
1331
1398
|
# spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
|
|
1332
1399
|
# stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
|
|
@@ -1340,6 +1407,7 @@ def _select_and_provision_implementation_stage(
|
|
|
1340
1407
|
worktree_status=executor_worktree_status,
|
|
1341
1408
|
worktree_note="",
|
|
1342
1409
|
started_head_commit=head,
|
|
1410
|
+
concurrent_stages=concurrent_stages,
|
|
1343
1411
|
)
|
|
1344
1412
|
|
|
1345
1413
|
# anchor base commit 1회 고정 (task-key worktree HEAD 기준)
|
|
@@ -1360,6 +1428,7 @@ def _select_and_provision_implementation_stage(
|
|
|
1360
1428
|
stage_base = _resolve_stage_base_commit(
|
|
1361
1429
|
selected_stage, consumer_done_rows, anchor_base_commit=anchor,
|
|
1362
1430
|
candidate_base=head_sha, project_root=Path(inp.project_root),
|
|
1431
|
+
plan_run_root=plan_run_root,
|
|
1363
1432
|
)
|
|
1364
1433
|
try:
|
|
1365
1434
|
prov = _worktree.provision_stage_worktree(
|
|
@@ -1372,7 +1441,12 @@ def _select_and_provision_implementation_stage(
|
|
|
1372
1441
|
base_commit=stage_base,
|
|
1373
1442
|
)
|
|
1374
1443
|
except RuntimeError as exc:
|
|
1375
|
-
|
|
1444
|
+
from .git_reconcile import guidance
|
|
1445
|
+
hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
|
|
1446
|
+
task_group=inp.task_group, task_id=inp.task_id,
|
|
1447
|
+
work_category=inp.work_category)
|
|
1448
|
+
raise PrepareError(
|
|
1449
|
+
f"stage worktree provisioning failed: {exc}\n{hint}") from exc
|
|
1376
1450
|
|
|
1377
1451
|
return StageSelection(
|
|
1378
1452
|
stage=selected,
|
|
@@ -1382,6 +1456,7 @@ def _select_and_provision_implementation_stage(
|
|
|
1382
1456
|
worktree_status=prov.status,
|
|
1383
1457
|
worktree_note=prov.note,
|
|
1384
1458
|
started_head_commit=prov.base_ref,
|
|
1459
|
+
concurrent_stages=concurrent_stages,
|
|
1385
1460
|
)
|
|
1386
1461
|
|
|
1387
1462
|
|
|
@@ -1400,6 +1475,7 @@ def _apply_implementation_stage(
|
|
|
1400
1475
|
ctx["effective_stages"] = [sel.stage]
|
|
1401
1476
|
csv = str(sel.stage)
|
|
1402
1477
|
ctx["EFFECTIVE_STAGES"] = csv
|
|
1478
|
+
ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
|
|
1403
1479
|
ctx["STAGE_BATCH_DIRECTIVE"] = (
|
|
1404
1480
|
f"- **Stage for this implementation run:** `{csv}`. "
|
|
1405
1481
|
"Execute exactly this Stage Map stage — this is the authoritative scope. "
|
|
@@ -1436,6 +1512,20 @@ def _git_out(cwd, *args) -> str:
|
|
|
1436
1512
|
return r.stdout.strip() if r.returncode == 0 else ""
|
|
1437
1513
|
|
|
1438
1514
|
|
|
1515
|
+
def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
|
|
1516
|
+
try:
|
|
1517
|
+
from .git_reconcile import auto_reconcile
|
|
1518
|
+
for it in auto_reconcile(
|
|
1519
|
+
project_root=Path(inp.project_root), plan_run_root=plan_run_root,
|
|
1520
|
+
project_id=inp.project_id, task_group=inp.task_group,
|
|
1521
|
+
task_id=inp.task_id, work_category=inp.work_category):
|
|
1522
|
+
print(f"git-reconcile: stage {it.stage} done commit "
|
|
1523
|
+
f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
|
|
1524
|
+
"(patch-equivalent)", file=sys.stdout)
|
|
1525
|
+
except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
|
|
1526
|
+
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
1527
|
+
|
|
1528
|
+
|
|
1439
1529
|
def _is_ancestor(cwd, commit, head) -> bool:
|
|
1440
1530
|
if not commit or not head:
|
|
1441
1531
|
return False
|
|
@@ -1447,10 +1537,22 @@ def _is_ancestor(cwd, commit, head) -> bool:
|
|
|
1447
1537
|
|
|
1448
1538
|
|
|
1449
1539
|
def _is_dirty_excluding_okstra(cwd) -> bool:
|
|
1450
|
-
|
|
1540
|
+
excludes = [f":(exclude){p}" for p in okstra_clean_gate_excludes(Path(cwd))]
|
|
1541
|
+
out = _git_out(cwd, "status", "--short", "--", ".", *excludes)
|
|
1451
1542
|
return bool(out.strip())
|
|
1452
1543
|
|
|
1453
1544
|
|
|
1545
|
+
def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
|
|
1546
|
+
"""Placeholder until the selected stage registry row is resolved."""
|
|
1547
|
+
return WorktreeProvision(
|
|
1548
|
+
status="deferred-final-verification",
|
|
1549
|
+
note=(
|
|
1550
|
+
"final-verification single-stage uses the selected implementation "
|
|
1551
|
+
"stage worktree from the registry"
|
|
1552
|
+
),
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
|
|
1454
1556
|
def _reserve_final_verification_target(
|
|
1455
1557
|
inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
|
|
1456
1558
|
) -> None:
|
|
@@ -1464,6 +1566,7 @@ def _reserve_final_verification_target(
|
|
|
1464
1566
|
# carry sidecars are the SSOT for stage completion — recover missing `done`
|
|
1465
1567
|
# rows before the whole-task gate checks every stage.
|
|
1466
1568
|
backfill_done_from_carry(plan_run_root)
|
|
1569
|
+
_auto_reconcile_best_effort(inp, plan_run_root)
|
|
1467
1570
|
done_rows = [r for r in read_consumers(plan_run_root)
|
|
1468
1571
|
if r.get("status") == "done"]
|
|
1469
1572
|
|
|
@@ -1472,6 +1575,7 @@ def _reserve_final_verification_target(
|
|
|
1472
1575
|
row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
|
|
1473
1576
|
wt_path = (row or {}).get("worktree_path", "")
|
|
1474
1577
|
stage_base = (row or {}).get("base_ref", "")
|
|
1578
|
+
stage_branch = (row or {}).get("branch", "")
|
|
1475
1579
|
head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
|
|
1476
1580
|
target = _resolve_single_stage_target(
|
|
1477
1581
|
requested_stage=inp.stage, done_rows=done_rows,
|
|
@@ -1480,13 +1584,20 @@ def _reserve_final_verification_target(
|
|
|
1480
1584
|
stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
|
|
1481
1585
|
)
|
|
1482
1586
|
ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
|
|
1587
|
+
ctx["EXECUTOR_WORKTREE_BRANCH"] = stage_branch
|
|
1588
|
+
ctx["EXECUTOR_WORKTREE_BASE_REF"] = stage_base
|
|
1589
|
+
ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
|
|
1590
|
+
ctx["EXECUTOR_WORKTREE_NOTE"] = (
|
|
1591
|
+
f"final-verification uses implementation stage {n} worktree"
|
|
1592
|
+
)
|
|
1483
1593
|
else:
|
|
1484
1594
|
wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
|
|
1485
1595
|
anchor = _reg.get_implementation_base(
|
|
1486
1596
|
inp.project_id, inp.task_group, inp.task_id) or ""
|
|
1487
1597
|
head = _git_out(wt_path, "rev-parse", "HEAD")
|
|
1488
|
-
|
|
1489
|
-
|
|
1598
|
+
from .consumers import latest_done_by_stage
|
|
1599
|
+
merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
|
|
1600
|
+
for s, r in latest_done_by_stage(done_rows).items()}
|
|
1490
1601
|
target = _resolve_whole_task_target(
|
|
1491
1602
|
stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
|
|
1492
1603
|
task_worktree_path=wt_path, task_head=head,
|
|
@@ -1755,21 +1866,24 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
1755
1866
|
with worktree_provision_mutex(
|
|
1756
1867
|
okstra_home(), inp.project_id, task_group_segment, task_id_segment,
|
|
1757
1868
|
):
|
|
1758
|
-
|
|
1759
|
-
worktree =
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1869
|
+
if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
|
|
1870
|
+
worktree = _single_stage_final_verification_worktree(inp)
|
|
1871
|
+
else:
|
|
1872
|
+
try:
|
|
1873
|
+
worktree = provision_task_worktree(
|
|
1874
|
+
task_type=inp.task_type,
|
|
1875
|
+
project_root=project_root,
|
|
1876
|
+
project_id=inp.project_id,
|
|
1877
|
+
task_group_segment=task_group_segment,
|
|
1878
|
+
task_id_segment=task_id_segment,
|
|
1879
|
+
work_category=inp.work_category,
|
|
1880
|
+
base_ref=inp.base_ref,
|
|
1881
|
+
require_base_ref=True,
|
|
1882
|
+
)
|
|
1883
|
+
except RuntimeError as exc:
|
|
1884
|
+
raise PrepareError(
|
|
1885
|
+
f"task worktree provisioning failed: {exc}"
|
|
1886
|
+
) from exc
|
|
1773
1887
|
|
|
1774
1888
|
# ---- implementation stage selection (path-independent) ----
|
|
1775
1889
|
# Resolve + provision the stage BEFORE run-path compute so RUN_DIR
|
|
@@ -1784,6 +1898,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
1784
1898
|
task_key, worktree.status,
|
|
1785
1899
|
)
|
|
1786
1900
|
stage_arg = impl_stage_selection.stage
|
|
1901
|
+
# Drop any stale waiver on this stage so the run actually verifies
|
|
1902
|
+
# conformance (kept inside the per-task-key mutex so concurrent
|
|
1903
|
+
# same-task runs don't race the manifest write).
|
|
1904
|
+
_clear_stale_stage_waiver(inp, project_root, impl_stage_selection.stage)
|
|
1787
1905
|
else:
|
|
1788
1906
|
impl_stage_selection = None
|
|
1789
1907
|
stage_arg = None
|
|
@@ -1959,10 +2077,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
1959
2077
|
if not inp.render_only:
|
|
1960
2078
|
_provision_settings_symlink(inp)
|
|
1961
2079
|
|
|
2080
|
+
concurrent_run: dict = {}
|
|
2081
|
+
if impl_stage_selection is not None and impl_stage_selection.concurrent_stages:
|
|
2082
|
+
concurrent_run = {
|
|
2083
|
+
"detected": True,
|
|
2084
|
+
"active_stages": impl_stage_selection.concurrent_stages,
|
|
2085
|
+
}
|
|
1962
2086
|
return PrepareOutputs(
|
|
1963
2087
|
ctx=ctx,
|
|
1964
2088
|
prompt_text=prompt_text,
|
|
1965
|
-
extras={
|
|
2089
|
+
extras={
|
|
2090
|
+
"profile_content": profile_content,
|
|
2091
|
+
"concurrent_run": concurrent_run,
|
|
2092
|
+
},
|
|
1966
2093
|
)
|
|
1967
2094
|
|
|
1968
2095
|
|
|
@@ -2132,6 +2259,10 @@ def main(argv: list[str]) -> int:
|
|
|
2132
2259
|
print(f"okstra instruction-set: {ctx['INSTRUCTION_SET_PATH']}")
|
|
2133
2260
|
print(f"okstra reference expectations: {ctx['REFERENCE_EXPECTATIONS_FILE']}")
|
|
2134
2261
|
print(f"okstra final report template: {ctx['FINAL_REPORT_TEMPLATE_PATH']}")
|
|
2262
|
+
cr = out.extras.get("concurrent_run", {})
|
|
2263
|
+
if cr.get("detected"):
|
|
2264
|
+
stages_csv = ",".join(str(s) for s in cr["active_stages"])
|
|
2265
|
+
print(f"okstra concurrent-run stages: {stages_csv}")
|
|
2135
2266
|
if inputs.render_only:
|
|
2136
2267
|
print()
|
|
2137
2268
|
print(out.prompt_text, end="")
|