okstra 0.52.0 → 0.54.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 (40) 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/package.json +1 -1
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/report-writer-worker.md +1 -0
  17. package/runtime/bin/lib/okstra/cli.sh +5 -1
  18. package/runtime/bin/okstra.sh +1 -0
  19. package/runtime/prompts/launch.template.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +16 -9
  22. package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
  23. package/runtime/prompts/profiles/final-verification.md +8 -7
  24. package/runtime/prompts/profiles/implementation-planning.md +8 -4
  25. package/runtime/prompts/wizard/prompts.ko.json +3 -2
  26. package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
  27. package/runtime/python/okstra_ctl/render.py +3 -0
  28. package/runtime/python/okstra_ctl/run.py +541 -41
  29. package/runtime/python/okstra_ctl/wizard.py +25 -7
  30. package/runtime/python/okstra_ctl/worktree.py +126 -9
  31. package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
  32. package/runtime/schemas/final-report-v1.0.schema.json +36 -0
  33. package/runtime/skills/okstra-convergence/SKILL.md +14 -3
  34. package/runtime/skills/okstra-run/SKILL.md +1 -1
  35. package/runtime/templates/reports/final-report.template.md +12 -0
  36. package/runtime/templates/reports/final-verification-input.template.md +8 -5
  37. package/runtime/templates/reports/i18n/en.json +3 -1
  38. package/runtime/templates/reports/i18n/ko.json +3 -1
  39. package/runtime/validators/validate-run.py +143 -1
  40. package/runtime/validators/validate-workflow.sh +6 -1
@@ -36,6 +36,8 @@ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
36
36
  from okstra_ctl.run import (
37
37
  APPROVED_FRONTMATTER_PATTERN,
38
38
  _extract_frontmatter_block,
39
+ _reject_blocking_plan_body_gate,
40
+ _validate_data_json_approval_consistency,
39
41
  )
40
42
  from okstra_ctl.workers import (
41
43
  ALLOWED_WORKERS,
@@ -77,6 +79,11 @@ TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
77
79
 
78
80
  EXECUTORS = ["claude", "codex", "gemini"]
79
81
 
82
+ # Task types that consume an approved plan and need a stage-scope pick:
83
+ # implementation executes a stage, final-verification verifies a stage
84
+ # (or the whole task via `auto`). Both gate the approved-plan + stage steps.
85
+ _STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
86
+
80
87
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
81
88
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
82
89
 
@@ -410,6 +417,8 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
410
417
  " edit the report and change the line to `approved: true`, or re-run "
411
418
  "okstra with `--approve` to flip it from the CLI."
412
419
  )
420
+ _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
421
+ _validate_data_json_approval_consistency(p, markdown_approved=True)
413
422
  # frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
414
423
  # 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
415
424
  blockers = unresolved_approval_blockers(body)
@@ -1220,7 +1229,12 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1220
1229
  stages, _errs = mod._parse_stage_map(plan_text)
1221
1230
  finally:
1222
1231
  _sys.modules.pop("_ip_stage_v_wizard", None)
1223
- options = [_opt(k, v) for k, v in t["options"].items()]
1232
+ auto_label = (
1233
+ t["options"].get("auto_final_verification", t["options"]["auto"])
1234
+ if state.task_type == "final-verification"
1235
+ else t["options"]["auto"]
1236
+ )
1237
+ options = [_opt("auto", auto_label)]
1224
1238
  for s in stages:
1225
1239
  depends = ",".join(map(str, s.depends_on)) or "(none)"
1226
1240
  options.append(_opt(
@@ -1969,7 +1983,7 @@ STEPS: list[Step] = [
1969
1983
  build=_build_base_ref_text, submit=_submit_base_ref_text,
1970
1984
  owns=("base_ref", "base_ref_pending_text")),
1971
1985
  Step(S_APPROVED_PLAN_PICK,
1972
- applies=lambda s: (s.task_type == "implementation"
1986
+ applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
1973
1987
  and not s.approved_plan_path
1974
1988
  and not s.approved_plan_pending_text
1975
1989
  and S_APPROVED_PLAN_PICK not in s.answered
@@ -1981,7 +1995,7 @@ STEPS: list[Step] = [
1981
1995
  build=_build_approved_plan_pick, submit=_submit_approved_plan_pick,
1982
1996
  owns=("approved_plan_path", "approved_plan_pending_text")),
1983
1997
  Step(S_APPROVED_PLAN,
1984
- applies=lambda s: (s.task_type == "implementation"
1998
+ applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
1985
1999
  and not s.approved_plan_path
1986
2000
  and bool(s.brief_path)
1987
2001
  and (s.reuse_worktree is True
@@ -1992,7 +2006,7 @@ STEPS: list[Step] = [
1992
2006
  build=_build_approved_plan, submit=_submit_approved_plan,
1993
2007
  owns=("approved_plan_path", "approved_plan_pending_text")),
1994
2008
  Step(S_STAGE_PICK,
1995
- applies=lambda s: (s.task_type == "implementation"
2009
+ applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
1996
2010
  and bool(s.approved_plan_path)
1997
2011
  and S_STAGE_PICK not in s.answered),
1998
2012
  build=_build_stage_pick, submit=_submit_stage_pick,
@@ -2155,8 +2169,11 @@ def _identity_ready(s: WizardState) -> bool:
2155
2169
  return False
2156
2170
  if s.base_ref_pending_text:
2157
2171
  return False
2172
+ if s.task_type in _STAGE_SCOPED_TASK_TYPES:
2173
+ if not s.approved_plan_path:
2174
+ return False
2158
2175
  if s.task_type == "implementation":
2159
- if not s.approved_plan_path or not s.executor:
2176
+ if not s.executor:
2160
2177
  return False
2161
2178
  return True
2162
2179
 
@@ -2350,7 +2367,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2350
2367
  "executor": state.executor,
2351
2368
  "critic": state.critic,
2352
2369
  "approved-plan": state.approved_plan_path,
2353
- "stage": (state.selected_stage or "auto") if state.task_type == "implementation" else "",
2370
+ "stage": (state.selected_stage or "auto") if state.task_type in _STAGE_SCOPED_TASK_TYPES else "",
2354
2371
  "base-ref": base_ref,
2355
2372
  "workers": workers,
2356
2373
  "directive": state.directive,
@@ -2395,8 +2412,9 @@ def confirmation_block(state: WizardState) -> str:
2395
2412
  lines.append(f" directive : {state.directive or '(none)'}")
2396
2413
  if state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification"):
2397
2414
  lines.append(f" critic : {state.critic or '(off)'}")
2398
- if state.task_type == "implementation":
2415
+ if state.task_type in _STAGE_SCOPED_TASK_TYPES:
2399
2416
  lines.append(f" approved-plan : {state.approved_plan_path}")
2417
+ lines.append(f" stage : {state.selected_stage or 'auto'}")
2400
2418
  if state.clarification_response_path:
2401
2419
  lines.append(f" clarification : {state.clarification_response_path}")
2402
2420
  if state.task_type == "release-handoff" and state.pr_template_path:
@@ -491,30 +491,37 @@ def compute_worktree_path(
491
491
  project_id: str,
492
492
  task_group_segment: str,
493
493
  task_id_segment: str,
494
+ stage_number: Optional[int] = None,
494
495
  ) -> Path:
495
- """Pure path computation. One worktree dir per task-key.
496
-
497
- Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`. Note
498
- there is NO run-seq segment — every phase of the same task-key
499
- shares this dir.
500
- """
496
+ """Pure path computation. One worktree dir per task-key, or per
497
+ `<task-key>/stage-<N>` when stage_number is given (implementation
498
+ stage isolation). Uses `OKSTRA_HOME` when set (test hook), else
499
+ `~/.okstra`."""
501
500
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
502
501
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
503
- return (
502
+ path = (
504
503
  base / "worktrees"
505
504
  / _safe_segment(project_id)
506
505
  / _safe_segment(task_group_segment)
507
506
  / _safe_segment(task_id_segment)
508
507
  )
508
+ if stage_number is not None:
509
+ path = path / f"stage-{stage_number}"
510
+ return path
509
511
 
510
512
 
511
513
  def compute_branch_name(
512
514
  *,
513
515
  work_category: str,
514
516
  task_id_segment: str,
517
+ stage_number: Optional[int] = None,
515
518
  ) -> str:
516
- """One branch per task-key. No run-seq phases share the branch."""
517
- return f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
519
+ """One branch per task-key, or `<prefix>-<task-id>-s<N>` for an
520
+ implementation stage worktree."""
521
+ name = f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
522
+ if stage_number is not None:
523
+ name = f"{name}-s{stage_number}"
524
+ return name
518
525
 
519
526
 
520
527
  def provision_task_worktree(
@@ -693,3 +700,113 @@ def provision_task_worktree(
693
700
  f"(base {base_label}; phase {task_type}){linked_suffix}"
694
701
  ),
695
702
  )
703
+
704
+
705
+ def provision_stage_worktree(
706
+ *,
707
+ project_root: Path,
708
+ project_id: str,
709
+ task_group_segment: str,
710
+ task_id_segment: str,
711
+ work_category: str,
712
+ stage_number: int,
713
+ base_commit: str,
714
+ ) -> WorktreeProvision:
715
+ """Materialise an isolated worktree for one implementation stage.
716
+
717
+ Unlike `provision_task_worktree` (one worktree per task-key shared
718
+ across phases), this provisions a per-stage worktree branched from
719
+ `base_commit` at `<task-key>/stage-<N>/` on branch `<prefix>-<task>-s<N>`.
720
+ The stage-key (`<task-key>#stage-<N>`) is reserved atomically through
721
+ `worktree_registry`; re-entry of the same stage-key returns the
722
+ existing entry. Branch / on-disk conflicts roll back the worktree
723
+ before re-raising so a retry is not blocked.
724
+ """
725
+ if not base_commit:
726
+ raise RuntimeError("provision_stage_worktree requires a base_commit")
727
+
728
+ safe_project = _safe_segment(project_id)
729
+ safe_group = _safe_segment(task_group_segment)
730
+ safe_task = _safe_segment(task_id_segment)
731
+ worktree_path = compute_worktree_path(
732
+ project_id=safe_project, task_group_segment=safe_group,
733
+ task_id_segment=safe_task, stage_number=stage_number,
734
+ )
735
+ branch = compute_branch_name(
736
+ work_category=work_category, task_id_segment=safe_task,
737
+ stage_number=stage_number,
738
+ )
739
+
740
+ existing = worktree_registry.lookup(
741
+ safe_project, safe_group, safe_task, stage_number=stage_number)
742
+ if existing is not None and existing.status == "active":
743
+ return WorktreeProvision(
744
+ status="reused", path=existing.worktree_path,
745
+ branch=existing.branch, base_ref=existing.base_ref,
746
+ note=(
747
+ f"stage {stage_number} worktree reused at "
748
+ f"{existing.worktree_path} on branch {existing.branch} "
749
+ f"(base {existing.base_ref[:12]})"
750
+ ),
751
+ )
752
+
753
+ if worktree_path.exists():
754
+ raise RuntimeError(
755
+ f"stage worktree path already exists but is not in the registry: "
756
+ f"{worktree_path}. Remove it before retrying."
757
+ )
758
+ if _branch_exists(project_root, branch):
759
+ raise RuntimeError(
760
+ f"stage worktree branch already exists: {branch}. "
761
+ "Delete it (`git branch -D <branch>`) before retrying."
762
+ )
763
+
764
+ main_root = main_worktree_path(project_root)
765
+ resolved_sha = _resolve_commit_sha(main_root, base_commit)
766
+ if not resolved_sha:
767
+ raise RuntimeError(
768
+ f"could not resolve base_commit `{base_commit}` in main worktree "
769
+ f"({main_root}); ensure the commit exists locally"
770
+ )
771
+
772
+ worktree_path.parent.mkdir(parents=True, exist_ok=True)
773
+ res = _git(
774
+ main_root,
775
+ "worktree", "add", "-b", branch, str(worktree_path), resolved_sha,
776
+ )
777
+ if res.returncode != 0:
778
+ raise RuntimeError(
779
+ f"`git worktree add` failed (exit={res.returncode}): "
780
+ f"{(res.stderr or res.stdout).strip()}"
781
+ )
782
+
783
+ _link_sync_dirs(main_root, worktree_path)
784
+ _link_sync_files(main_root, worktree_path)
785
+ _copy_snapshot_files(main_root, worktree_path)
786
+
787
+ try:
788
+ worktree_registry.reserve(
789
+ project_id=safe_project,
790
+ task_group=safe_group,
791
+ task_id=safe_task,
792
+ worktree_path=str(worktree_path),
793
+ branch=branch,
794
+ base_ref=resolved_sha,
795
+ phase="implementation",
796
+ stage_number=stage_number,
797
+ )
798
+ except RuntimeError:
799
+ _git(main_root, "worktree", "remove", "--force", str(worktree_path))
800
+ _git(main_root, "branch", "-D", branch)
801
+ raise
802
+
803
+ _seed_worktree_settings_symlink(worktree_path)
804
+
805
+ return WorktreeProvision(
806
+ status="created", path=str(worktree_path),
807
+ branch=branch, base_ref=resolved_sha,
808
+ note=(
809
+ f"stage {stage_number} worktree created at {worktree_path} "
810
+ f"on branch {branch} (base {resolved_sha[:12]})"
811
+ ),
812
+ )
@@ -43,15 +43,17 @@ def _okstra_worktrees_dir() -> Path:
43
43
  return base / "worktrees"
44
44
 
45
45
 
46
- def task_key(project_id: str, task_group: str, task_id: str) -> str:
47
- """Canonical task-key string used as the registry primary key.
48
-
49
- Segments are NOT re-slugified here — callers must pass already
50
- sanitised segments (see `worktree._safe_segment`). The key form
51
- `<project>/<group>/<task>` is the same shape used for filesystem
52
- paths so a key can be visually correlated with the worktree dir.
53
- """
54
- return f"{project_id}/{task_group}/{task_id}"
46
+ def task_key(
47
+ project_id: str, task_group: str, task_id: str,
48
+ stage_number: Optional[int] = None,
49
+ ) -> str:
50
+ """Canonical task-key. With stage_number, returns the stage-scoped
51
+ key `<proj>/<group>/<task>#stage-<N>` used to reserve a per-stage
52
+ worktree independently of the task-key entry."""
53
+ base = f"{project_id}/{task_group}/{task_id}"
54
+ if stage_number is not None:
55
+ return f"{base}#stage-{stage_number}"
56
+ return base
55
57
 
56
58
 
57
59
  @dataclass
@@ -66,6 +68,8 @@ class WorktreeEntry:
66
68
  created_at: str
67
69
  last_phase: str = ""
68
70
  status: str = "active" # "active" | "released"
71
+ stage: Optional[int] = None
72
+ implementation_base_commit: str = ""
69
73
 
70
74
 
71
75
  @contextlib.contextmanager
@@ -112,13 +116,11 @@ def _save(data: dict) -> None:
112
116
  os.replace(tmp, p)
113
117
 
114
118
 
115
- def lookup(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
116
- """Return the registered entry for this task-key, or None.
117
-
118
- Does not validate that `worktree_path` still exists on disk — that
119
- is the caller's responsibility (so reclaim logic can decide policy).
120
- """
121
- key = task_key(project_id, task_group, task_id)
119
+ def lookup(
120
+ project_id: str, task_group: str, task_id: str,
121
+ stage_number: Optional[int] = None,
122
+ ) -> Optional[WorktreeEntry]:
123
+ key = task_key(project_id, task_group, task_id, stage_number)
122
124
  with _registry_lock():
123
125
  data = _load()
124
126
  row = data["tasks"].get(key)
@@ -136,13 +138,14 @@ def reserve(
136
138
  branch: str,
137
139
  base_ref: str,
138
140
  phase: str = "",
141
+ stage_number: Optional[int] = None,
139
142
  ) -> WorktreeEntry:
140
143
  """Atomically insert a new entry. Raises RuntimeError if the
141
144
  task-key already exists or the branch is already owned by a
142
145
  different task-key. Callers should `lookup()` first when re-entry
143
146
  is expected.
144
147
  """
145
- key = task_key(project_id, task_group, task_id)
148
+ key = task_key(project_id, task_group, task_id, stage_number)
146
149
  now = time.strftime("%Y-%m-%dT%H:%M:%S%z") or time.strftime("%Y-%m-%dT%H:%M:%S")
147
150
  with _registry_lock():
148
151
  data = _load()
@@ -170,6 +173,7 @@ def reserve(
170
173
  "created_at": now,
171
174
  "last_phase": phase,
172
175
  "status": "active",
176
+ "stage": stage_number,
173
177
  }
174
178
  data["tasks"][key] = row
175
179
  data["branches"][branch] = key
@@ -191,6 +195,73 @@ def touch_phase(project_id: str, task_group: str, task_id: str, phase: str) -> N
191
195
  _save(data)
192
196
 
193
197
 
198
+ def set_implementation_base(
199
+ project_id: str, task_group: str, task_id: str, commit: str,
200
+ ) -> str:
201
+ """Fix the shared base commit for this task's implementation stages,
202
+ once. Idempotent: if already set, the existing value is returned and
203
+ `commit` is ignored (so two concurrent first-stage runs converge).
204
+ Raises RuntimeError when the task-key entry does not exist."""
205
+ key = task_key(project_id, task_group, task_id)
206
+ with _registry_lock():
207
+ data = _load()
208
+ row = data["tasks"].get(key)
209
+ if row is None:
210
+ raise RuntimeError(
211
+ f"no task-key entry to anchor implementation base: {key}"
212
+ )
213
+ already = row.get("implementation_base_commit")
214
+ if already:
215
+ return already
216
+ row["implementation_base_commit"] = commit
217
+ _save(data)
218
+ return commit
219
+
220
+
221
+ def get_implementation_base(
222
+ project_id: str, task_group: str, task_id: str,
223
+ ) -> Optional[str]:
224
+ """Return the fixed implementation base commit, or None when unset /
225
+ task-key missing."""
226
+ key = task_key(project_id, task_group, task_id)
227
+ with _registry_lock():
228
+ data = _load()
229
+ row = data["tasks"].get(key)
230
+ if row is None:
231
+ return None
232
+ return row.get("implementation_base_commit") or None
233
+
234
+
235
+ def get_stage_row(
236
+ project_id: str, task_group: str, task_id: str, stage: int,
237
+ ) -> Optional[dict]:
238
+ """Return the stage-key registry row (worktree_path / base_ref / branch)
239
+ for `<task-key>#stage-<stage>`, or None when no such reservation exists."""
240
+ key = task_key(project_id, task_group, task_id, stage)
241
+ with _registry_lock():
242
+ data = _load()
243
+ return data["tasks"].get(key)
244
+
245
+
246
+ def list_active_stage_numbers(
247
+ project_id: str, task_group: str, task_id: str,
248
+ ) -> set:
249
+ """Return the set of stage numbers with an active stage-key reservation
250
+ for this task. Used by the stage resolver to exclude stages a concurrent
251
+ run already holds (the occupancy SSOT). Excludes the task-key entry
252
+ (stage is None) and released entries."""
253
+ prefix = task_key(project_id, task_group, task_id) + "#stage-"
254
+ with _registry_lock():
255
+ data = _load()
256
+ out = set()
257
+ for key, row in data["tasks"].items():
258
+ if (key.startswith(prefix)
259
+ and row.get("status") == "active"
260
+ and row.get("stage") is not None):
261
+ out.add(row["stage"])
262
+ return out
263
+
264
+
194
265
  def release(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
195
266
  """Mark the entry as `released` (worktree dir intact — preservation
196
267
  is the project's policy). The branch index is freed so future
@@ -117,6 +117,8 @@
117
117
  }
118
118
  },
119
119
 
120
+ "verificationScope": { "enum": ["whole-task", "single-stage"] },
121
+
120
122
  "clarificationCarryIn": {
121
123
  "type": "object",
122
124
  "description": "RENDER_IF non-empty. Carry-in clarifications from the previous run.",
@@ -337,6 +339,7 @@
337
339
  "dependencyMigrationRisk",
338
340
  "validationChecklist",
339
341
  "rollbackStrategy",
342
+ "requirementCoverage",
340
343
  "planBodyVerification"
341
344
  ],
342
345
  "additionalProperties": false,
@@ -371,6 +374,11 @@
371
374
  "minItems": 1,
372
375
  "items": { "$ref": "#/$defs/RollbackRow" }
373
376
  },
377
+ "requirementCoverage": {
378
+ "type": "array",
379
+ "minItems": 1,
380
+ "items": { "$ref": "#/$defs/ImplementationRequirementCoverageRow" }
381
+ },
374
382
  "planBodyVerification": { "$ref": "#/$defs/PlanBodyVerification" }
375
383
  }
376
384
  },
@@ -565,6 +573,18 @@
565
573
  "gitDiffStat": { "type": "string" }
566
574
  }
567
575
  },
576
+ "stageReports": {
577
+ "type": "array",
578
+ "items": {
579
+ "type": "object",
580
+ "additionalProperties": false,
581
+ "required": ["stage", "reportPath"],
582
+ "properties": {
583
+ "stage": { "type": "integer" },
584
+ "reportPath": { "type": "string" }
585
+ }
586
+ }
587
+ },
568
588
  "acceptanceBlockers": {
569
589
  "type": "array",
570
590
  "items": { "$ref": "#/$defs/AcceptanceBlockerRow" }
@@ -1294,6 +1314,22 @@
1294
1314
  }
1295
1315
  },
1296
1316
 
1317
+ "ImplementationRequirementCoverageRow": {
1318
+ "type": "object",
1319
+ "required": ["id", "source", "requirement", "coveredBy", "status"],
1320
+ "additionalProperties": false,
1321
+ "properties": {
1322
+ "id": { "type": "string", "pattern": "^R-\\d{3,}$" },
1323
+ "source": { "type": "string", "minLength": 1 },
1324
+ "requirement": { "type": "string", "minLength": 1 },
1325
+ "coveredBy": { "type": "string", "minLength": 1 },
1326
+ "status": {
1327
+ "type": "string",
1328
+ "pattern": "^(covered|gap|blocked C-\\d{3,})$"
1329
+ }
1330
+ }
1331
+ },
1332
+
1297
1333
  "ReadonlyCommandRow": {
1298
1334
  "type": "object",
1299
1335
  "required": ["number", "tier", "command", "status", "exitCode", "outputTail"],
@@ -639,6 +639,7 @@ From the report-writer's draft of `## 5.5 Implementation Plan Deliverables`, lea
639
639
  | `P-Dep-<N>` | `4.5.5 Dependency / Migration Risk` | one dependency row |
640
640
  | `P-Val-<N>` | `4.5.6 Validation Checklist` | one checklist item |
641
641
  | `P-Rb-<N>` | `4.5.7 Rollback Strategy` | one rollback path |
642
+ | `P-Req-<N>` | `4.5.8 Requirement Coverage` | one requirement coverage row |
642
643
 
643
644
  `4.5.2 Trade-off Matrix` and `4.5.3 Recommended Option` are NOT extracted as standalone plan items — the trade-off matrix is evaluated implicitly through each option's `P-Opt-*` verification, and the recommended option is one of those `P-Opt-*` rows.
644
645
 
@@ -655,6 +656,7 @@ The verdict tokens `AGREE` / `DISAGREE` / `SUPPLEMENT` are reused, but their mea
655
656
  - `c` — validation signal is not observable
656
657
  - `d` — rollback violates commit / dependency order
657
658
  - `e` — item contradicts the trade-off matrix
659
+ - `f` — requirement coverage row cites no concrete option / stage / step, cites a non-existent option / stage / step, or marks a requirement `covered` while the cited plan item does not satisfy the row's stated requirement
658
660
  - **SUPPLEMENT**: the item is sound but is missing a dependency / edge case / precondition.
659
661
 
660
662
  Worker non-result handling (`timeout`, `error`, no result file, wrapper `cli-failure`) is identical to finding convergence: do NOT aggregate as DISAGREE, record `contract-violation`, and apply the round-level abort rule below.
@@ -663,6 +665,8 @@ Worker non-result handling (`timeout`, `error`, no result file, wrapper `cli-fai
663
665
 
664
666
  Plan-body verification only supports **lightweight mode** (defined in §"Verification Mode" above). `full-reanalysis` is not meaningful here because the "original source materials" for a plan item are the worker's own analysis plus the lead-mediated synthesis — there is no independent ground truth to re-read. The manifest's top-level `verificationMode` is ignored for this round; lightweight is always used.
665
667
 
668
+ Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement text embedded in the `Requirement Coverage` row with the cited Option / Stage / Step in the draft plan. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the stated requirement.
669
+
666
670
  ### Adversarial plan-body posture
667
671
 
668
672
  When `config.adversarial == true` (the default for `implementation-planning`; see the top-level §"Configuration" table), the plan-body round runs with an **adversarial posture**. The classification rules and gate arithmetic in §"Round protocol" are UNCHANGED — `majority-disagree` (a *majority* of analysers DISAGREE) remains the only classification that blocks the Approval marker, and `dissent-isolated` still passes the gate. Adversarial mode changes only *how each verifier evaluates an item*:
@@ -670,6 +674,7 @@ When `config.adversarial == true` (the default for `implementation-planning`; se
670
674
  - The burden of proof sits on the plan: an item earns `AGREE` only if the verifier actively tried to break it and could not.
671
675
  - The verifier MUST open the file paths / symbols / commands the item cites and confirm they exist and are executable as written. This is the one allowed widening of the lightweight "judge from internal consistency and stated commands / paths" rule — confirming the existence of cited paths is not "re-analyzing the original requirements".
672
676
  - If a cited path / command / validation signal cannot be confirmed, the verifier responds `DISAGREE(<kind>)` with the applicable breakage kind (a–e); uncertainty resolves toward DISAGREE, not AGREE.
677
+ - For `P-Req-*` items, a single `DISAGREE(f)` is approval-blocking even in a two-worker roster. Requirement coverage is a hard gate: one verified contradiction between a requirement row and its cited plan item creates a `majority-disagree` classification for gate purposes and MUST become a `Blocks=approval` clarification row.
673
678
 
674
679
  Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden) without changing the gate *threshold* — a single dissent still does not block approval; a majority is required (deliberate design decision).
675
680
 
@@ -682,7 +687,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
682
687
  - `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
683
688
  - `partial-consensus` — majority `AGREE`, dissenting `DISAGREE` recorded.
684
689
  - `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE` — treat as `partial-consensus` for gate purposes; record dissent. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs. Plan-body classifications use this dedicated label to avoid the collision.)
685
- - `majority-disagree` — majority of analysers `DISAGREE` on this item. This is the only classification that **blocks the Approval marker**.
690
+ - `majority-disagree` — majority of analysers `DISAGREE` on this item, OR any analyser emits `DISAGREE(f)` for a `P-Req-*` item. This classification **blocks the Approval marker**.
686
691
  - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
687
692
  5. Gate result resolution:
688
693
  - any `majority-disagree` item present AND `gating=true` → `blocked-by-disagreement`
@@ -737,7 +742,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
737
742
 
738
743
  `planItems[].classification` enum: `full-consensus | partial-consensus | dissent-isolated | majority-disagree | contested`. `contested` only appears when `maxRounds > 1`; at default `maxRounds=1` any otherwise-unresolved item folds into `partial-consensus` per the round protocol above.
739
744
 
740
- `planItems[].votes.<worker>` is the verbatim verdict token emitted by the worker — `AGREE | DISAGREE(<a|b|c|d|e>) | SUPPLEMENT` — or `verification-error` for terminal non-result dispatches. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.
745
+ `planItems[].votes.<worker>` is the verbatim verdict token emitted by the worker — `AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT` — or `verification-error` for terminal non-result dispatches. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.
741
746
 
742
747
  ### Plan-body reverify prompt
743
748
 
@@ -759,7 +764,8 @@ verdict:
759
764
  (b) command is not executable or is ambiguous,
760
765
  (c) validation signal is not observable,
761
766
  (d) rollback violates commit / dependency order,
762
- (e) item contradicts the trade-off matrix.
767
+ (e) item contradicts the trade-off matrix,
768
+ (f) requirement coverage row does not actually map the stated requirement to a concrete satisfying option / stage / step.
763
769
  - **SUPPLEMENT**: The item is sound but a dependency / edge case / precondition
764
770
  is missing.
765
771
 
@@ -767,6 +773,11 @@ Do NOT re-analyze the original requirements. Judge solely from plan internal
767
773
  consistency and stated commands / paths. Do NOT inspect the original task brief
768
774
  or worker analyses for this round.
769
775
 
776
+ For `P-Req-*` items, compare only the requirement text embedded in the row
777
+ against the cited plan item(s). Do not open the original brief, but do reject
778
+ coverage rows that cite no concrete option/stage/step or cite a plan item that
779
+ does not satisfy the row's own requirement.
780
+
770
781
  ## Plan items to verify
771
782
 
772
783
  ### P-Step-3 [TICKETID: <id>]: <one-line summary>
@@ -122,7 +122,7 @@ That is the entire interactive flow. The wizard handles:
122
122
  - task-type pick (with `nextRecommendedPhase` surfaced as recommended for existing tasks),
123
123
  - brief path (with `유지 / 변경` for existing tasks),
124
124
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
125
- - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + executor pick,
125
+ - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
126
126
  - `Use defaults / Customize` branch with profile-aware worker/model questions,
127
127
  - `release-handoff` PR template override + persist scope,
128
128
  - final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
@@ -211,6 +211,14 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
211
211
  | {{ row.id }} | {{ row.step }} | `{{ row.action }}` | {{ row.triggerSignal }} | `{{ row.verificationMethod }}` |
212
212
  {% endfor %}
213
213
 
214
+ ### 5.5.8 Requirement Coverage
215
+
216
+ | ID | Source | Requirement | Covered by option / stage / step | Status |
217
+ |----|--------|-------------|-----------------------------------|--------|
218
+ {% for row in implementationPlanning.requirementCoverage -%}
219
+ | {{ row.id }} | `{{ row.source }}` | {{ row.requirement }} | {{ row.coveredBy }} | `{{ row.status }}` |
220
+ {% endfor %}
221
+
214
222
  ### 5.5.9 Plan Body Verification{% if t("sectionAside.planBodyVerification") != "Plan Body Verification" %} ({{ t("sectionAside.planBodyVerification") }}){% endif %}
215
223
 
216
224
  {{ t("sectionIntro.planBodyVerification") }}
@@ -416,6 +424,10 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
416
424
  ```
417
425
  {{ finalVerification.sourceImplementationReport.gitDiffStat }}
418
426
  ```
427
+ {% if verificationScope in ['whole-task', 'single-stage'] %}- {{ t("finalVerification.verificationScope") }}: `{{ verificationScope }}`
428
+ {% endif %}{% if finalVerification.stageReports %}- {{ t("finalVerification.stageReportsLabel") }}:
429
+ {% for row in finalVerification.stageReports %} - stage {{ row.stage }}: `{{ row.reportPath }}`
430
+ {% endfor %}{% endif %}
419
431
 
420
432
  ### 5.8.2 Acceptance Blockers
421
433
 
@@ -29,15 +29,18 @@ taskType: "{{FM_TASK_TYPE}}"
29
29
  - What was supposed to be delivered?
30
30
  - What is the intended acceptance decision?
31
31
 
32
+ ## 검증 모드
33
+
34
+ - 기본은 **전체-task** 검증입니다(`--stage auto`): 모든 Stage Map stage 가 구현·머지된 뒤 한 번 실행합니다.
35
+ - 특정 stage 만 격리 검증하려면 `--stage N` 으로 **단독-stage** 모드를 씁니다(release-handoff 진입 불가, 부분 검증).
36
+ - worktree / base / head 는 okstra 가 registry 와 `consumers.jsonl` 에서 자동 해소하므로 이 입력서에 수동 기입하지 않습니다.
37
+
32
38
  ## Source Implementation Report
33
39
 
34
40
  - Path (project-relative) to the originating `implementation` final-report:
35
- - Worktree / checkout path that final-verification must inspect:
36
- - Implementation base ref (`<base>` for `git diff --stat <base>..HEAD`):
37
- - Implementation head SHA expected at verification start:
38
41
  - Quoted `Commit list` / `Diff summary` excerpt from the implementation report:
39
42
 
40
- > If this section is empty, points to a missing report, or names a checkout that does not match the implementation report's commit list / diff summary, final-verification MUST end with status `blocked` and route back to `implementation` or `implementation-planning`. Do not verify an ambiguous target.
43
+ > 보고서 경로가 비거나 누락된 보고서를 가리키면 final-verification status `blocked` 으로 끝내고 `implementation` 또는 `implementation-planning` 으로 라우팅합니다. 검증 대상(worktree/base/head)은 okstra 가 자동 해소하므로 수동 기입이 어긋나 막히는 일은 없습니다.
41
44
 
42
45
  ## Requirement Coverage Source
43
46
 
@@ -87,7 +90,7 @@ taskType: "{{FM_TASK_TYPE}}"
87
90
 
88
91
  ## Questions for Analysers
89
92
 
90
- 1. Does the verification target (head SHA / diff stat) match the implementation report's commit list and diff summary?
93
+ 1. Did your analysis run against the injected `VERIFICATION_TARGET` (base / head SHA / worktree), and does the diff at that target fully cover the stage(s) under verification? (A head you cannot confirm against the injected target is a `tool-failure`, not a silent proceed.)
91
94
  2. For each requirement / acceptance criterion, what exact artifact (commit SHA, test output, log line, config value) proves coverage?
92
95
  3. Are there any acceptance blockers?
93
96
  4. What residual risks remain?
@@ -134,6 +134,8 @@
134
134
  },
135
135
  "finalVerification": {
136
136
  "validationEvidenceAside": "requirements coverage",
137
- "columnRequirement": "Requirement (plan/brief citation)"
137
+ "columnRequirement": "Requirement (plan/brief citation)",
138
+ "verificationScope": "Verification scope",
139
+ "stageReportsLabel": "Source implementation reports (per stage)"
138
140
  }
139
141
  }
@@ -134,6 +134,8 @@
134
134
  },
135
135
  "finalVerification": {
136
136
  "validationEvidenceAside": "요구사항 커버리지",
137
- "columnRequirement": "Requirement (plan/brief 인용)"
137
+ "columnRequirement": "Requirement (plan/brief 인용)",
138
+ "verificationScope": "검증 범위",
139
+ "stageReportsLabel": "stage 별 구현 리포트"
138
140
  }
139
141
  }