okstra 0.51.0 → 0.53.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 (44) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/docs/kr/architecture.md +1 -0
  4. package/docs/kr/cli.md +2 -1
  5. package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +993 -0
  6. package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +93 -0
  7. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +447 -0
  8. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +289 -0
  9. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +774 -0
  10. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +303 -0
  11. package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +387 -0
  12. package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +126 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +180 -0
  14. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/report-writer-worker.md +1 -0
  18. package/runtime/bin/lib/okstra/cli.sh +5 -1
  19. package/runtime/bin/okstra.sh +1 -0
  20. package/runtime/prompts/launch.template.md +1 -0
  21. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  22. package/runtime/prompts/profiles/_implementation-executor.md +16 -9
  23. package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
  24. package/runtime/prompts/profiles/final-verification.md +7 -7
  25. package/runtime/prompts/profiles/implementation-planning.md +14 -7
  26. package/runtime/prompts/wizard/prompts.ko.json +3 -2
  27. package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
  28. package/runtime/python/okstra_ctl/render.py +3 -0
  29. package/runtime/python/okstra_ctl/run.py +541 -41
  30. package/runtime/python/okstra_ctl/wizard.py +25 -7
  31. package/runtime/python/okstra_ctl/worktree.py +126 -9
  32. package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
  33. package/runtime/schemas/final-report-v1.0.schema.json +36 -0
  34. package/runtime/skills/okstra-convergence/SKILL.md +14 -3
  35. package/runtime/skills/okstra-memory/SKILL.md +28 -5
  36. package/runtime/skills/okstra-run/SKILL.md +1 -1
  37. package/runtime/templates/reports/final-report.template.md +12 -0
  38. package/runtime/templates/reports/final-verification-input.template.md +8 -5
  39. package/runtime/templates/reports/i18n/en.json +3 -1
  40. package/runtime/templates/reports/i18n/ko.json +3 -1
  41. package/runtime/validators/validate-implementation-plan-stages.py +57 -11
  42. package/runtime/validators/validate-run.py +143 -1
  43. package/runtime/validators/validate-workflow.sh +6 -1
  44. package/src/memory.mjs +50 -11
@@ -88,6 +88,12 @@ APPROVED_FRONTMATTER_PATTERN = re.compile(
88
88
  re.IGNORECASE | re.MULTILINE,
89
89
  )
90
90
 
91
+ PLAN_BODY_GATE_PATTERN = re.compile(
92
+ r"Gate result[^A-Za-z\n]+(?P<value>[a-z][a-z\-]+)",
93
+ re.IGNORECASE,
94
+ )
95
+ BLOCKING_PLAN_BODY_GATES = {"blocked-by-disagreement", "aborted-non-result"}
96
+
91
97
  # Frontmatter implementation-option matcher.
92
98
  #
93
99
  # `approved:` 바로 아래 줄의 `implementation-option:` 한 줄을 식별한다.
@@ -112,6 +118,138 @@ def _extract_frontmatter_block(body: str) -> str | None:
112
118
  return m.group(1) if m else None
113
119
 
114
120
 
121
+ def _final_report_data_path(report_path: Path) -> Path:
122
+ name = report_path.name
123
+ if name.endswith(".md"):
124
+ return report_path.with_name(name[:-3] + ".data.json")
125
+ return report_path.with_suffix(".data.json")
126
+
127
+
128
+ def _load_final_report_data_if_present(report_path: Path) -> tuple[Path, dict] | None:
129
+ data_path = _final_report_data_path(report_path)
130
+ if not data_path.is_file():
131
+ return None
132
+ try:
133
+ data = json.loads(data_path.read_text(encoding="utf-8"))
134
+ except json.JSONDecodeError as exc:
135
+ raise PrepareError(
136
+ f"approved plan data.json is invalid JSON: {data_path}: {exc}"
137
+ ) from exc
138
+ if not isinstance(data, dict):
139
+ raise PrepareError(f"approved plan data.json must be a JSON object: {data_path}")
140
+ return data_path, data
141
+
142
+
143
+ def _data_json_gate_result(data: dict) -> str:
144
+ planning = data.get("implementationPlanning")
145
+ if not isinstance(planning, dict):
146
+ return ""
147
+ verification = planning.get("planBodyVerification")
148
+ if not isinstance(verification, dict):
149
+ return ""
150
+ return str(verification.get("gateResult") or "").strip().lower()
151
+
152
+
153
+ def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> None:
154
+ gate_match = PLAN_BODY_GATE_PATTERN.search(body)
155
+ if gate_match:
156
+ gate_value = gate_match.group("value").strip().lower()
157
+ if gate_value in BLOCKING_PLAN_BODY_GATES:
158
+ raise PrepareError(
159
+ f"{action} rejected because approved plan Gate result is "
160
+ f"`{gate_value}`: {path}\n"
161
+ " resolve the plan-body verification disagreement and regenerate "
162
+ "the implementation-planning report before approval."
163
+ )
164
+
165
+ loaded = _load_final_report_data_if_present(path)
166
+ if loaded is None:
167
+ return
168
+ data_path, data = loaded
169
+ data_gate = _data_json_gate_result(data)
170
+ if data_gate in BLOCKING_PLAN_BODY_GATES:
171
+ raise PrepareError(
172
+ f"{action} rejected because approved plan data.json Gate result is "
173
+ f"`{data_gate}`: {data_path}\n"
174
+ " data.json is the final-report source of truth; regenerate the "
175
+ "report after resolving the verification disagreement."
176
+ )
177
+
178
+
179
+ def _validate_data_json_approval_consistency(
180
+ path: Path,
181
+ *,
182
+ markdown_approved: bool,
183
+ ) -> None:
184
+ loaded = _load_final_report_data_if_present(path)
185
+ if loaded is None:
186
+ return
187
+ data_path, data = loaded
188
+ frontmatter = data.get("frontmatter")
189
+ if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
190
+ raise PrepareError(
191
+ f"approved plan data.json has no frontmatter.approved field: {data_path}"
192
+ )
193
+ data_approved = frontmatter.get("approved")
194
+ if not isinstance(data_approved, bool):
195
+ raise PrepareError(
196
+ f"approved plan data.json frontmatter.approved is not boolean: {data_path}"
197
+ )
198
+ if data_approved != markdown_approved:
199
+ raise PrepareError(
200
+ "approved plan markdown frontmatter and data.json disagree on "
201
+ f"`approved`: markdown={str(markdown_approved).lower()}, "
202
+ f"data.json={str(data_approved).lower()}\n"
203
+ f" markdown: {path}\n"
204
+ f" data.json: {data_path}\n"
205
+ " regenerate the report from data.json or use `okstra --approve` "
206
+ "so the source of truth is updated first."
207
+ )
208
+
209
+
210
+ def _set_data_json_approved_true_if_present(path: Path) -> bool:
211
+ loaded = _load_final_report_data_if_present(path)
212
+ if loaded is None:
213
+ return False
214
+ data_path, data = loaded
215
+ data_gate = _data_json_gate_result(data)
216
+ if data_gate in BLOCKING_PLAN_BODY_GATES:
217
+ raise PrepareError(
218
+ f"--approve rejected because approved plan data.json Gate result is "
219
+ f"`{data_gate}`: {data_path}"
220
+ )
221
+ frontmatter = data.setdefault("frontmatter", {})
222
+ if not isinstance(frontmatter, dict):
223
+ raise PrepareError(
224
+ f"approved plan data.json frontmatter must be an object: {data_path}"
225
+ )
226
+ frontmatter["approved"] = True
227
+
228
+ try:
229
+ from .render_final_report import (
230
+ FinalReportRenderError,
231
+ find_default_template,
232
+ render,
233
+ )
234
+
235
+ rendered = render(data, template_path=find_default_template())
236
+ except FinalReportRenderError as exc:
237
+ raise PrepareError(
238
+ f"--approve could not re-render approved plan from data.json: {exc}"
239
+ ) from exc
240
+
241
+ data_tmp = data_path.with_suffix(data_path.suffix + f".tmp.{os.getpid()}")
242
+ md_tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
243
+ data_tmp.write_text(
244
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n",
245
+ encoding="utf-8",
246
+ )
247
+ md_tmp.write_text(rendered, encoding="utf-8")
248
+ data_tmp.replace(data_path)
249
+ md_tmp.replace(path)
250
+ return True
251
+
252
+
115
253
  class PrepareError(Exception):
116
254
  """surface to caller — task bundle prepare failed."""
117
255
 
@@ -195,6 +333,8 @@ def _validate_approved_plan(path: str) -> None:
195
333
  "or re-run okstra with `--approve` to flip it from the CLI.\n"
196
334
  " resolve any `Blocks=approval` rows in `## 1. Clarification Items` first."
197
335
  )
336
+ _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
337
+ _validate_data_json_approval_consistency(p, markdown_approved=True)
198
338
  # frontmatter approved == true 상태. §1 Clarification Items 의
199
339
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
200
340
  blockers = unresolved_approval_blockers(body)
@@ -235,13 +375,21 @@ def _resolve_effective_stages(
235
375
  done_stages: set,
236
376
  requested: str,
237
377
  budget: int = RUN_STEP_BUDGET,
378
+ started_stages: set = None,
379
+ reserved_stages: set = None,
238
380
  ) -> list:
239
381
  """Return the ordered list of stage numbers this run executes.
240
382
 
241
383
  `requested` is "auto" or a decimal string. For "auto" the run batches all
242
- ready stages (depends-on all done, itself not done) in stage-number order up
243
- to `budget` effective steps — but always at least one. A numeric request is a
244
- single forced stage. Raises PrepareError on rejection cases."""
384
+ ready stages (depends-on all done, itself neither done nor occupied) in
385
+ stage-number order up to `budget` effective steps. A numeric request is a
386
+ single forced stage. `started_stages`/`reserved_stages` are stages a
387
+ concurrent run already holds (consumers.jsonl `started` and the registry
388
+ stage reservations); they are excluded so two simultaneous runs never pick
389
+ the same stage. Raises PrepareError on rejection cases."""
390
+ started_stages = started_stages or set()
391
+ reserved_stages = reserved_stages or set()
392
+ occupied = done_stages | started_stages | reserved_stages
245
393
  if requested != "auto":
246
394
  try:
247
395
  n = int(requested)
@@ -259,11 +407,15 @@ def _resolve_effective_stages(
259
407
  raise PrepareError(
260
408
  f"--stage {n} already completed (consumers.jsonl status:done exists)"
261
409
  )
410
+ if n in started_stages or n in reserved_stages:
411
+ raise PrepareError(
412
+ f"--stage {n} already in progress or reserved by another run"
413
+ )
262
414
  return [n]
263
415
 
264
416
  ready = [
265
417
  s for s in stages
266
- if s["stage_number"] not in done_stages
418
+ if s["stage_number"] not in occupied
267
419
  and all(d in done_stages for d in s["depends_on"])
268
420
  ]
269
421
  if not ready:
@@ -281,6 +433,166 @@ def _resolve_effective_stages(
281
433
  return batch
282
434
 
283
435
 
436
+ def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
437
+ """True iff `ancestor` is an ancestor of `descendant` in project_root's git
438
+ history (`git merge-base --is-ancestor`, exit 0). Used to detect whether a
439
+ predecessor stage's done commit has been merged into the task worktree HEAD."""
440
+ r = _subprocess.run(
441
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
442
+ cwd=str(project_root), capture_output=True, text=True,
443
+ )
444
+ return r.returncode == 0
445
+
446
+
447
+ def _resolve_stage_base_commit(
448
+ stage: dict,
449
+ consumer_done_rows: list,
450
+ anchor_base_commit: str,
451
+ candidate_base: str = "",
452
+ project_root=None,
453
+ ) -> str:
454
+ """Pick the git base commit a stage's isolated worktree branches from.
455
+
456
+ - 독립 (`depends-on (none)`): anchor (= implementation_base_commit fixed
457
+ at first stage entry).
458
+ - 단일 의존 (`depends-on X`): predecessor X 의 `done.head_commit`.
459
+ - 다중 의존 (`depends-on X,Y…`): spec §9 옵션 A 자동 감지. candidate_base
460
+ (= task-key worktree HEAD) 에 모든 선행 done commit 이 ancestor 면
461
+ candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
462
+
463
+ Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
464
+ deps = stage.get("depends_on") or []
465
+ if len(deps) >= 2:
466
+ n = stage["stage_number"]
467
+ # 1) 모든 선행의 done head_commit 수집
468
+ pred_commits = {}
469
+ for d in deps:
470
+ head = next(
471
+ (r.get("head_commit") for r in consumer_done_rows
472
+ if r.get("stage") == d and r.get("status") == "done"
473
+ and r.get("head_commit")),
474
+ None,
475
+ )
476
+ if not head:
477
+ raise PrepareError(
478
+ f"predecessor stage {d} has no done row with head_commit "
479
+ f"in consumers.jsonl; multi-dependency stage {n} cannot start"
480
+ )
481
+ pred_commits[d] = head
482
+ # 2) candidate (task-key worktree HEAD) 필요
483
+ if not candidate_base or project_root is None:
484
+ raise PrepareError(
485
+ f"candidate base missing for multi-dependency stage {n}; "
486
+ "task-key worktree HEAD could not be resolved"
487
+ )
488
+ # 3) 모든 선행 done 이 candidate 의 ancestor 인지 (=사용자가 머지함)
489
+ for d, head in pred_commits.items():
490
+ if not _commit_is_ancestor(project_root, head, candidate_base):
491
+ raise PrepareError(
492
+ f"multi-dependency stage {n}: predecessor stage {d} "
493
+ f"({head[:8]}) is not merged into the task worktree "
494
+ f"({candidate_base[:8]}). Merge stage branches "
495
+ f"(e.g. the `-s{d}` branches) into the task worktree "
496
+ "(or into main, then refresh the worktree) and retry."
497
+ )
498
+ return candidate_base
499
+ if not deps:
500
+ if not anchor_base_commit:
501
+ raise PrepareError(
502
+ f"anchor base commit missing for independent stage "
503
+ f"{stage['stage_number']}; first-stage prepare should have "
504
+ "fixed it via worktree_registry.set_implementation_base"
505
+ )
506
+ return anchor_base_commit
507
+ # 단일 의존
508
+ pred = deps[0]
509
+ for row in consumer_done_rows:
510
+ if row.get("stage") == pred and row.get("status") == "done":
511
+ head = row.get("head_commit") or ""
512
+ if head:
513
+ return head
514
+ raise PrepareError(
515
+ f"predecessor stage {pred} has no done row with head_commit in "
516
+ "consumers.jsonl; cannot derive base for stage "
517
+ f"{stage['stage_number']}"
518
+ )
519
+
520
+
521
+ @dataclass
522
+ class _FVTarget:
523
+ scope: str # "whole-task" | "single-stage"
524
+ base: str
525
+ head: str
526
+ worktree_path: str
527
+ stages: list # list[int]
528
+ reports: list # list[str], report_path 값(없으면 "")
529
+
530
+
531
+ def _resolve_whole_task_target(
532
+ *, stage_map: list, done_rows: list, anchor_base: str,
533
+ task_worktree_path: str, task_head: str, task_dirty: bool,
534
+ merged: dict,
535
+ ) -> "_FVTarget":
536
+ """전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
537
+ worktree clean 이어야 한다. 위반 시 PrepareError."""
538
+ done_by_stage = {r["stage"]: r for r in done_rows}
539
+ for s in stage_map:
540
+ n = s["stage_number"]
541
+ if n not in done_by_stage:
542
+ raise PrepareError(
543
+ f"final-verification(whole-task): stage {n} not done — "
544
+ f"run implementation --stage {n} first"
545
+ )
546
+ if not merged.get(n, False):
547
+ sha = done_by_stage[n].get("head_commit", "")
548
+ raise PrepareError(
549
+ f"final-verification(whole-task): stage {n} done commit "
550
+ f"{sha} not merged into task worktree HEAD — merge stage "
551
+ "branches then retry"
552
+ )
553
+ if task_dirty:
554
+ raise PrepareError(
555
+ "final-verification: worktree has uncommitted source changes "
556
+ "(outside .okstra/) — commit or stash before verifying"
557
+ )
558
+ stages = [s["stage_number"] for s in stage_map]
559
+ reports = [done_by_stage[n].get("report_path", "") for n in stages]
560
+ return _FVTarget(
561
+ scope="whole-task", base=anchor_base, head=task_head,
562
+ worktree_path=task_worktree_path, stages=stages, reports=reports,
563
+ )
564
+
565
+
566
+ def _resolve_single_stage_target(
567
+ *, requested_stage: str, done_rows: list, stage_base: str,
568
+ stage_worktree_path: str, stage_head: str, stage_dirty: bool,
569
+ ) -> "_FVTarget":
570
+ """단독-stage 검증 target. stage N 만 done + stage worktree 존재 + clean.
571
+ 다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
572
+ n = int(requested_stage)
573
+ done_by_stage = {r["stage"]: r for r in done_rows}
574
+ if n not in done_by_stage:
575
+ raise PrepareError(
576
+ f"final-verification(single-stage): stage {n} not done — "
577
+ f"run implementation --stage {n} first"
578
+ )
579
+ if not stage_worktree_path:
580
+ raise PrepareError(
581
+ f"final-verification(single-stage): stage worktree not found for "
582
+ f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
583
+ )
584
+ if stage_dirty:
585
+ raise PrepareError(
586
+ "final-verification: worktree has uncommitted source changes "
587
+ "(outside .okstra/) — commit or stash before verifying"
588
+ )
589
+ return _FVTarget(
590
+ scope="single-stage", base=stage_base, head=stage_head,
591
+ worktree_path=stage_worktree_path, stages=[n],
592
+ reports=[done_by_stage[n].get("report_path", "")],
593
+ )
594
+
595
+
284
596
  def _parse_stage_map_into_ctx(plan_path: str) -> list:
285
597
  """Reuse the validator's parser to extract StageMeta dicts for the ctx."""
286
598
  text = Path(plan_path).read_text(encoding="utf-8")
@@ -340,6 +652,7 @@ def _apply_cli_approval(path: str) -> str:
340
652
  " expected a single line of the form `approved: false` "
341
653
  "(report-writer worker emits this by default)."
342
654
  )
655
+ _reject_blocking_plan_body_gate(p, body, action="--approve")
343
656
 
344
657
  audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
345
658
  audit_line = (
@@ -347,11 +660,26 @@ def _apply_cli_approval(path: str) -> str:
347
660
  "(user CLI invocation treated as approval signal)"
348
661
  )
349
662
 
663
+ rendered_from_data = False
664
+ if m.group(1).lower() != "true":
665
+ rendered_from_data = _set_data_json_approved_true_if_present(p)
666
+ if rendered_from_data:
667
+ body = p.read_text(encoding="utf-8", errors="replace")
668
+ frontmatter = _extract_frontmatter_block(body) or ""
669
+ m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
670
+ if not m:
671
+ raise PrepareError(
672
+ f"--approve re-rendered the approved-plan but the markdown "
673
+ f"frontmatter has no `approved:` field: {path}"
674
+ )
675
+
350
676
  if m.group(1).lower() == "true":
351
677
  if audit_line.split(" — ")[1] in body:
352
678
  return "already-approved"
353
679
  new_body = body.rstrip("\n") + "\n" + audit_line + "\n"
354
680
  p.write_text(new_body, encoding="utf-8")
681
+ if rendered_from_data:
682
+ return "frontmatter-flipped"
355
683
  return "already-approved-audit-appended"
356
684
 
357
685
  flipped_frontmatter = APPROVED_FRONTMATTER_PATTERN.sub(
@@ -698,25 +1026,44 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
698
1026
  if not inp.brief_path.is_file():
699
1027
  raise PrepareError(f"task brief not found: {inp.brief_path}")
700
1028
  ctx_stage_map: list = []
701
- if inp.task_type == "implementation":
1029
+ # implementation final-verification 은 둘 다 승인된 plan 의 Stage Map 을
1030
+ # 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope). plan-presence +
1031
+ # stage-map 파싱은 공유하고, frontmatter 승인/option 주입/구조 검증 같은
1032
+ # implementation 전용 단계만 따로 게이트한다.
1033
+ if inp.task_type in ("implementation", "final-verification"):
702
1034
  if not inp.approved_plan_path:
703
1035
  raise PrepareError(
704
- "task-type implementation requires --approved-plan <path-to-final-report.md>"
1036
+ f"task-type {inp.task_type} requires "
1037
+ "--approved-plan <path-to-final-report.md>"
705
1038
  )
706
- if inp.approve_plan_ack:
707
- # 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로 모델링한다.
708
- # 파일의 frontmatter approvedtrue toggle 동일한 검증
709
- # 경로(`_validate_approved_plan`)그대로 통과시킨다.
710
- _apply_cli_approval(inp.approved_plan_path)
711
- if inp.implementation_option:
712
- # 유저가 고른 Option Candidate 이름을 approved-plan frontmatter 의
713
- # `implementation-option:` 라인에 기록한다. 값이면 implementation
714
- # plan `Recommended Option` 으로 폴백하므로 호출하지 않는다.
715
- _apply_cli_implementation_option(
716
- inp.approved_plan_path, inp.implementation_option
717
- )
718
- _validate_approved_plan(inp.approved_plan_path)
719
- _validate_stage_structure(inp.approved_plan_path)
1039
+ if inp.task_type == "implementation":
1040
+ if inp.approve_plan_ack:
1041
+ # 사용자가 직접 `--approve`입력한 행위 자체를 승인 의사로 모델링한다.
1042
+ # 파일의 frontmatter approved true 로 toggle 한 뒤 동일한 검증
1043
+ # 경로(`_validate_approved_plan`) 를 그대로 통과시킨다.
1044
+ _apply_cli_approval(inp.approved_plan_path)
1045
+ if inp.implementation_option:
1046
+ # 유저가 고른 Option Candidate 이름을 approved-plan frontmatter 의
1047
+ # `implementation-option:` 라인에 기록한다. 값이면 implementation
1048
+ # plan 의 `Recommended Option` 으로 폴백하므로 호출하지 않는다.
1049
+ _apply_cli_implementation_option(
1050
+ inp.approved_plan_path, inp.implementation_option
1051
+ )
1052
+ _validate_approved_plan(inp.approved_plan_path)
1053
+ _validate_stage_structure(inp.approved_plan_path)
1054
+ else:
1055
+ # final-verification 에서 --approve / --implementation-option 은
1056
+ # 의미가 없다 (승인은 implementation 진입 시 이미 끝났다).
1057
+ if inp.approve_plan_ack:
1058
+ raise PrepareError(
1059
+ "--approve is only meaningful with --task-type implementation "
1060
+ "and --approved-plan <path>"
1061
+ )
1062
+ if inp.implementation_option:
1063
+ raise PrepareError(
1064
+ "--implementation-option is only meaningful with --task-type "
1065
+ "implementation and --approved-plan <path>"
1066
+ )
720
1067
  ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
721
1068
  else:
722
1069
  if inp.approve_plan_ack:
@@ -735,8 +1082,8 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
735
1082
  )
736
1083
  if inp.stage != "auto":
737
1084
  raise PrepareError(
738
- f"--stage is only meaningful with --task-type implementation; "
739
- f"got {inp.task_type}"
1085
+ "--stage is only meaningful with --task-type implementation or "
1086
+ f"final-verification; got {inp.task_type}"
740
1087
  )
741
1088
  if inp.clarification_response_path and not Path(inp.clarification_response_path).is_file():
742
1089
  raise PrepareError(
@@ -888,44 +1235,195 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
888
1235
 
889
1236
 
890
1237
  def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map: list) -> None:
891
- """implementation run 의 ready-set 배치를 선택하고 consumers.jsonl stage
892
- `started` 행을 예약한다. ctx effective_stages / STAGE_BATCH_DIRECTIVE 채우고
893
- inp.stage 를 확정된 배치 CSV덮어쓴다."""
1238
+ """implementation run 의 ready stage 결정하고 stage 격리 worktree 를
1239
+ 발급해 ctx EXECUTOR_WORKTREE_* 덮어쓴다. anchor base commit 을 1회
1240
+ 고정하고 그 stage 를 consumers.jsonl startedappend.
1241
+
1242
+ spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 는 backward
1243
+ compat 로 batch 리스트를 반환하지만 첫 번째만 실행한다 — stage 마다 격리
1244
+ worktree·branch 가 필요해 batch 가 의미를 잃기 때문(cost-aware-design 의
1245
+ run-batch 와의 의도된 트레이드오프)."""
894
1246
  from .consumers import read_consumers, append_consumer
1247
+ from . import worktree as _worktree
1248
+ from . import worktree_registry as _reg
895
1249
  import datetime as _dt
896
1250
 
897
1251
  ctx["parsed_stage_map"] = ctx_stage_map
898
1252
  plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
899
1253
  consumed = read_consumers(plan_run_root)
900
1254
  done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
901
- effective = _resolve_effective_stages(ctx["parsed_stage_map"], done_stages, inp.stage)
902
- ctx["effective_stages"] = effective
903
- csv = ",".join(str(n) for n in effective)
1255
+ started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
1256
+ reserved_stages = _reg.list_active_stage_numbers(
1257
+ inp.project_id, inp.task_group, inp.task_id,
1258
+ )
1259
+
1260
+ batch = _resolve_effective_stages(
1261
+ ctx_stage_map, done_stages, inp.stage,
1262
+ started_stages=started_stages, reserved_stages=reserved_stages,
1263
+ )
1264
+ # stage 격리: 한 run = 한 stage. 첫 ready stage 만 실행.
1265
+ selected = batch[0]
1266
+ ctx["effective_stages"] = [selected]
1267
+ csv = str(selected)
904
1268
  ctx["EFFECTIVE_STAGES"] = csv
905
1269
  ctx["STAGE_BATCH_DIRECTIVE"] = (
906
- f"- **Stage batch for this implementation run:** `{csv}` "
907
- "(comma-separated stage numbers, ascending). Execute exactly these "
908
- "Stage Map stages in this order this is the authoritative scope. "
909
- "Do NOT recompute the start stage from `consumers.jsonl`; the runtime "
910
- "already selected and reserved this batch."
1270
+ f"- **Stage for this implementation run:** `{csv}`. "
1271
+ "Execute exactly this Stage Map stage this is the authoritative scope. "
1272
+ "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
1273
+ "and reserved this stage."
911
1274
  )
912
1275
  inp.stage = csv
913
1276
  print(f"selected stages: {csv}", file=sys.stdout)
914
- head_proc = _subprocess.run(
915
- ["git", "rev-parse", "HEAD"],
916
- cwd=inp.project_root, capture_output=True, text=True,
917
- )
918
- head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
919
- now = _dt.datetime.now(_dt.timezone.utc).isoformat()
920
- for stage_n in effective:
1277
+
1278
+ # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1279
+ # stage 격리도 동일하게 degrade — consumers 만 기록 (기존 평면 동작 보존).
1280
+ wt_status = ctx.get("EXECUTOR_WORKTREE_STATUS", "")
1281
+ if wt_status.startswith("skipped"):
1282
+ head_proc = _subprocess.run(
1283
+ ["git", "rev-parse", "HEAD"],
1284
+ cwd=inp.project_root, capture_output=True, text=True,
1285
+ )
1286
+ head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
1287
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
921
1288
  append_consumer(
922
1289
  plan_run_root,
923
1290
  impl_task_key=ctx["TASK_KEY"],
924
- stage=stage_n,
1291
+ stage=selected,
925
1292
  status="started",
926
1293
  started_at=now,
927
1294
  head_commit=head_sha,
928
1295
  )
1296
+ return
1297
+
1298
+ # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
1299
+ head_proc = _subprocess.run(
1300
+ ["git", "rev-parse", "HEAD"],
1301
+ cwd=inp.project_root, capture_output=True, text=True,
1302
+ )
1303
+ head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
1304
+ if head_sha:
1305
+ _reg.set_implementation_base(
1306
+ inp.project_id, inp.task_group, inp.task_id, head_sha,
1307
+ )
1308
+ anchor = _reg.get_implementation_base(
1309
+ inp.project_id, inp.task_group, inp.task_id,
1310
+ ) or ""
1311
+
1312
+ # stage 격리 worktree 발급 — 의존 종류별 base 결정
1313
+ selected_stage = next(
1314
+ s for s in ctx_stage_map if s["stage_number"] == selected
1315
+ )
1316
+ consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
1317
+ stage_base = _resolve_stage_base_commit(
1318
+ selected_stage, consumer_done_rows, anchor_base_commit=anchor,
1319
+ candidate_base=head_sha, project_root=Path(inp.project_root),
1320
+ )
1321
+ try:
1322
+ provision = _worktree.provision_stage_worktree(
1323
+ project_root=Path(inp.project_root),
1324
+ project_id=inp.project_id,
1325
+ task_group_segment=ctx["TASK_GROUP_SEGMENT"],
1326
+ task_id_segment=ctx["TASK_ID_SEGMENT"],
1327
+ work_category=inp.work_category,
1328
+ stage_number=selected,
1329
+ base_commit=stage_base,
1330
+ )
1331
+ except RuntimeError as exc:
1332
+ raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
1333
+
1334
+ ctx["EXECUTOR_WORKTREE_PATH"] = provision.path
1335
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = provision.branch
1336
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = provision.base_ref
1337
+ ctx["EXECUTOR_WORKTREE_STATUS"] = provision.status
1338
+ ctx["EXECUTOR_WORKTREE_NOTE"] = provision.note
1339
+
1340
+ # consumers append — stage worktree base 를 head_commit 으로
1341
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
1342
+ append_consumer(
1343
+ plan_run_root,
1344
+ impl_task_key=ctx["TASK_KEY"],
1345
+ stage=selected,
1346
+ status="started",
1347
+ started_at=now,
1348
+ head_commit=provision.base_ref,
1349
+ )
1350
+
1351
+
1352
+ def _git_out(cwd, *args) -> str:
1353
+ r = _subprocess.run(["git", "-C", str(cwd), *args],
1354
+ capture_output=True, text=True)
1355
+ return r.stdout.strip() if r.returncode == 0 else ""
1356
+
1357
+
1358
+ def _is_ancestor(cwd, commit, head) -> bool:
1359
+ if not commit or not head:
1360
+ return False
1361
+ r = _subprocess.run(
1362
+ ["git", "-C", str(cwd), "merge-base", "--is-ancestor", commit, head],
1363
+ capture_output=True, text=True,
1364
+ )
1365
+ return r.returncode == 0
1366
+
1367
+
1368
+ def _is_dirty_excluding_okstra(cwd) -> bool:
1369
+ out = _git_out(cwd, "status", "--short", "--", ".", ":(exclude).okstra")
1370
+ return bool(out.strip())
1371
+
1372
+
1373
+ def _reserve_final_verification_target(
1374
+ inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
1375
+ ) -> None:
1376
+ """final-verification 의 검증 target 을 registry/consumers/git 에서
1377
+ 해소하고 gate 를 강제한다. 위반 시 PrepareError. 결과를 ctx 의
1378
+ VERIFICATION_* 키로 주입한다."""
1379
+ from .consumers import read_consumers
1380
+ from . import worktree_registry as _reg
1381
+
1382
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1383
+ done_rows = [r for r in read_consumers(plan_run_root)
1384
+ if r.get("status") == "done"]
1385
+
1386
+ if inp.stage and inp.stage != "auto":
1387
+ n = int(inp.stage)
1388
+ row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
1389
+ wt_path = (row or {}).get("worktree_path", "")
1390
+ stage_base = (row or {}).get("base_ref", "")
1391
+ head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
1392
+ target = _resolve_single_stage_target(
1393
+ requested_stage=inp.stage, done_rows=done_rows,
1394
+ stage_base=stage_base, stage_worktree_path=wt_path,
1395
+ stage_head=head,
1396
+ stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
1397
+ )
1398
+ ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
1399
+ else:
1400
+ wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1401
+ anchor = _reg.get_implementation_base(
1402
+ inp.project_id, inp.task_group, inp.task_id) or ""
1403
+ head = _git_out(wt_path, "rev-parse", "HEAD")
1404
+ merged = {r["stage"]: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1405
+ for r in done_rows}
1406
+ target = _resolve_whole_task_target(
1407
+ stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
1408
+ task_worktree_path=wt_path, task_head=head,
1409
+ task_dirty=_is_dirty_excluding_okstra(wt_path), merged=merged,
1410
+ )
1411
+
1412
+ diff_stat = _git_out(target.worktree_path, "diff", "--stat",
1413
+ f"{target.base}..{target.head}")
1414
+ reports = "\n".join(
1415
+ f" - stage {s}: `{rp or '(report_path unrecorded)'}`"
1416
+ for s, rp in zip(target.stages, target.reports)
1417
+ )
1418
+ ctx["VERIFICATION_TARGET"] = (
1419
+ f"- **Verification scope:** `{target.scope}`\n"
1420
+ f"- **Worktree:** `{target.worktree_path}`\n"
1421
+ f"- **Verification base ref:** `{target.base}`\n"
1422
+ f"- **Verification head SHA:** `{target.head}`\n"
1423
+ f"- **Stages under verification:** {target.stages}\n"
1424
+ f"- **Source implementation reports:**\n{reports}\n"
1425
+ f"- **Verification diff stat:**\n```\n{diff_stat}\n```"
1426
+ )
929
1427
 
930
1428
 
931
1429
  def _write_instruction_set_sources(
@@ -1270,6 +1768,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1270
1768
  })
1271
1769
  if inp.task_type == "implementation":
1272
1770
  _reserve_implementation_stages(inp, ctx, ctx_stage_map)
1771
+ elif inp.task_type == "final-verification":
1772
+ _reserve_final_verification_target(inp, ctx, ctx_stage_map)
1273
1773
 
1274
1774
  # ---- prepare directories + cleanup ----
1275
1775
  _ensure_task_directories(ctx)