okstra 0.99.1 → 0.100.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.
@@ -57,7 +57,7 @@
57
57
  - The final report MUST include section headings containing each of the following exact strings: `Option Candidates`, `Trade-off`, `Recommended Option`, `Stage Map`, `Stage Exit Contract`, `Stage Validation`, `Dependency`, `Validation Checklist`, `Rollback`, `Requirement Coverage`. (Approval is no longer a body section — it is the YAML frontmatter `approved` field.)
58
58
  - Korean translations are allowed in parentheses (e.g. `### Recommended Option (권장 옵션)`), but the English keyword must be present verbatim in the heading line.
59
59
  - The shape and ordering follow `final-report-template.md` sections 5.4 (`Implementation Plan Deliverables`) + 5.5 (`Stage Map`). `validators/validate-run.py` substring-matches the raw report text, so a Korean-only heading fails the gate — the cause of repeated observed failures.
60
- - Beyond substring matching, when the Plan Body Verification gate result is `passed` / `passed-with-dissent`, `validators/validate-run.py` runs the **structural** Stage Map validator (`validators/validate-implementation-plan-stages.py`) at the planning boundary — not deferred to the `implementation` entry gate. It enforces: the exact `## 5.5 Stage Map` heading, each `## 5.5.<i> Stage <i>:` section with its four required subsections, the per-stage effective step count (≤6), the `depends-on` DAG, and the per-stage vertical-slice contract (S10). S10 scans for the literal in-section strings `Slice value:`, `Acceptance:`, the three `Test case (success):` / `Test case (boundary):` / `Test case (failure):` lines (S10d), and the Stepwise `action`-cell prefixes `RED:` / `GREEN:` (or a `TDD exemption:` line, which waives both the test-case lines and the RED/GREEN check) — keep these tokens verbatim for the same reason as the heading keywords above.
60
+ - Beyond substring matching, when the Plan Body Verification gate result is `passed` / `passed-with-dissent`, `validators/validate-run.py` runs the **structural** Stage Map validator (`validators/validate-implementation-plan-stages.py`) at the planning boundary — not deferred to the `implementation` entry gate. It enforces: the exact `## 5.5 Stage Map` heading, each `## 5.5.<i> Stage <i>:` section with its four required subsections, the per-stage effective step count (≤8), the `depends-on` DAG, and the per-stage vertical-slice contract (S10). S10 scans for the literal in-section strings `Slice value:`, `Acceptance:`, the three `Test case (success):` / `Test case (boundary):` / `Test case (failure):` lines (S10d), and the Stepwise `action`-cell prefixes `RED:` / `GREEN:` (or a `TDD exemption:` line, which waives both the test-case lines and the RED/GREEN check) — keep these tokens verbatim for the same reason as the heading keywords above.
61
61
  - Required deliverable shape (final report, in addition to the standard sections):
62
62
  - at least two implementation options. **Each option must include**:
63
63
  - **File Structure**: an explicit list of files to create / modify / delete with each file's responsibility (one-line each). Use the form `Create: path — responsibility` / `Modify: path:line-range — change summary` / `Delete: path — reason`.
@@ -78,15 +78,15 @@
78
78
  Validator S10d rejects a missing/empty line in any of the three categories (skipped only when a `TDD exemption:` line is present). The `RED:` step below must encode these cases, not a single assertion.
79
79
  - **Per-stage subsections** (`## 5.5.<i> Stage <i>: <title>` for each `i`), each containing the four required subsections:
80
80
  - `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
81
- - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | expected`. **Effective row count ≤ 6** (excluding header / divider / blank). Each step is one action completable in 2–5 minutes; for code steps include actual code or diff sketch. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`expected` = FAIL) — the RED step encodes the case set, not a single happy-path assertion; at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`expected` = PASS); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section (mirrors the executor's per-step exemption in `_implementation-executor.md`). Validator S10c enforces RED-first + GREEN, or the exemption line.
81
+ - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | expected`. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change (시간 하한 없음; 함께 바뀌는 여러 파일을 포함할 수 있다); for code steps include actual code or diff sketch. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`expected` = FAIL) — the RED step encodes the case set, not a single happy-path assertion; at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`expected` = PASS); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section (mirrors the executor's per-step exemption in `_implementation-executor.md`). Validator S10c enforces RED-first + GREEN, or the exemption line.
82
82
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
83
83
  - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — a Tier3 verification script that proves this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. When you emit this line you MUST also (a) write the script to `<task_root>/qa/scripts/stage-<N>.<ext>` and (b) add a matching entry to `<task_root>/qa/conformance-manifest.json` with fields `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (subset of `{db, io, http, external}`), `passContract`, `exemption: null`, `waiver: null`. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <근거>` line per requirement. When the verification body is a test spec, author it with the project's own test framework (devDependency) invoked via a discovery override at `<task_root>/qa/scripts/` (jest: `--config <project config> --roots <task_root>/qa/scripts`) — never hand-roll `describe`/`expect` and never widen the project's own test config; for TypeScript specs also write `<task_root>/qa/scripts/tsconfig.json` extending the project tsconfig with the runner's `types` entry so editors resolve the file.
84
84
  - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
85
85
  The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. Layout split: executable scripts (conformance + any real-IO test) live under `<task_root>/qa/scripts/`; data sidecars (`conformance-manifest.json`, `result-*.json`) stay at the `qa/` root. This declaration is enforced at three layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; the manifest JSON structure — including each entry's `script` living under `qa/scripts/` — is enforced by `validate_conformance_manifest` (called from both the run path and validate-run); and the result gate (each script's `QA-RESULT`) is enforced by the verifier Tier3 + validate-run.
86
86
  - `### Stage Exit Contract` — predicted added/modified files, newly exposed identifiers/types/endpoints, downstream-usable resources.
87
87
  - `### Stage Validation` — pre / mid / post exact commands or observable outcomes for this stage only.
88
- - **Vertical-slice-first partition rule (1st-class):** the grouping anchor is a **thin end-to-end vertical slice** — one stage delivers a single user-observable increment, crossing whatever layers are needed (data → service → API → UI) to make that one increment work. File/module proximity is demoted to the **intra-slice grouping rule**: within a slice, keep steps touching the same file/directory/module together so the diff, PR, and rollback unit stay cohesive. **Horizontal layer-splitting is forbidden** — never carve "the DB layer" into one stage and "the service layer" into the next; that produces stages that ship no standalone user value. A stage is split ONLY when (a) a real `depends-on` data/contract dependency exists, (b) effective steps would exceed 6, or (c) it is a distinct vertical slice (a different user-value increment). Maximising the number of parallel stages is NOT a reason to split — parallelism is an emergent property of independent stages, never a partitioning goal.
89
- - **Project-boundary partition rule (hard boundary):** a *project* boundary is either (a) a different repository / `PROJECT_ROOT`, or (b) a different top-level independently-deployable module within one repo. A stage maps to a single worktree on one repo/branch, so **no stage may contain edits belonging to more than one project** — this is a hard split that overrides the ≤6-step merging allowance; never co-locate two projects' changes in one stage to save a stage. Two cases:
88
+ - **Vertical-slice-first partition rule (1st-class):** the grouping anchor is a **thin end-to-end vertical slice** — one stage delivers a single user-observable increment, crossing whatever layers are needed (data → service → API → UI) to make that one increment work. File/module proximity is demoted to the **intra-slice grouping rule**: within a slice, keep steps touching the same file/directory/module together so the diff, PR, and rollback unit stay cohesive. **Horizontal layer-splitting is forbidden** — never carve "the DB layer" into one stage and "the service layer" into the next; that produces stages that ship no standalone user value. A stage is split ONLY when (a) a real `depends-on` data/contract dependency exists, (b) effective steps would exceed 8, or (c) it is a distinct vertical slice (a different user-value increment). Maximising the number of parallel stages is NOT a reason to split — parallelism is an emergent property of independent stages, never a partitioning goal.
89
+ - **Project-boundary partition rule (hard boundary):** a *project* boundary is either (a) a different repository / `PROJECT_ROOT`, or (b) a different top-level independently-deployable module within one repo. A stage maps to a single worktree on one repo/branch, so **no stage may contain edits belonging to more than one project** — this is a hard split that overrides the ≤8-step merging allowance; never co-locate two projects' changes in one stage to save a stage. Two cases:
90
90
  - **Same repo, different modules** — put each project/module's increment in its own stage. Their file sets are disjoint, so they default to `depends-on (none)` and run in parallel; add a `depends-on` link ONLY when a real cross-module contract / shared-schema / deploy-order dependency exists.
91
91
  - **Different repos** — a single okstra task **cannot** span repos: every stage worktree is a `git worktree add` in one repo's main checkout (one `<project-id>`), the run-index / manifests / registry are keyed to that one project, and the edit allowlist only covers that project's tree plus `~/.okstra/worktrees/**`. This is a structural limit, not a style preference. Therefore cross-repo work MUST be split into **a separate okstra run per repo** — never modelled as stages of one task. State, in the Stage Map `Cross-project parallelism:` line, whether those per-repo runs can proceed in parallel. To avoid re-deriving shared analysis, the second repo's run should consume the first run's relevant plan/decision artifacts as brief Source Material (see the cross-repo carry note below).
92
92
  - **Parallel-feasibility check (mandatory for every multi-project plan):** disjoint files (S9 below) is necessary but NOT sufficient for parallelism — a cross-project API/contract/schema/deploy-order dependency forces sequencing even when no file overlaps. For each pair of projects, explicitly determine and record (in the `Cross-project parallelism:` line) whether they are independent (run in parallel) or sequenced (and the exact dependency that forces the order).
@@ -127,4 +127,4 @@
127
127
  5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan.
128
128
  6. **Review-rule preflight check** — if a project review rule pack exists, map each relevant rule to the recommended option. Reject the draft if it knowingly creates a violation that the later PR reviewer would flag, unless the plan records a specific rationale and follow-up. In particular, scan for repeated helper stacks across planned files, tests that assert delegation to the same calculator/helper they exercise, public names that hide side effects, domain rules placed in repositories/adapters, and APIs made dead by this change.
129
129
  7. **Plan-body verification reconciliation (BLOCKING for implementation-planning).** Inspect the `### 5.5.9 Plan Body Verification` verdict table. For every plan-item row classified as `majority-disagree → C-<N>`, the corresponding `C-<N>` row MUST exist in `## 1. Clarification Items` with `Kind` chosen per the standard policy and `Blocks=approval`. Do NOT create a parallel `Open Questions` block under the implementation plan body — the unified table is the single home. Conversely, the `Classification` column's `C-<N>` reference and the `## 1. Clarification Items` `ID` column MUST match 1:1; an orphan on either side is a contract violation. For `partial-consensus` and `worker-unique` plan-items, the dissenting opinion lives in §5.5.9 `Dissent log` and is NOT promoted to §5.
130
- 8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 6. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
130
+ 8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 8. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
@@ -234,14 +234,32 @@
234
234
  }
235
235
  },
236
236
  "stage_pick": {
237
- "label": "stage 범위를 선택하세요. auto 전체 task(모든 stage)를, 특정 번호는 해당 stage 대상으로 합니다.",
237
+ "label": "진행할 stage 선택하세요(여러 선택 가능). '전체' 남은 미완료 stage 모두, 개별 선택 시 의존하는 미완료 stage 자동 포함됩니다.",
238
238
  "label_final_verification": "검증할 implementation stage 를 선택하세요.",
239
239
  "echo_template": "stage: {value}",
240
240
  "options": {
241
241
  "auto": "auto (다음 미완료 stage)",
242
242
  "whole_task": "전체 task 검증 (모든 stage)",
243
+ "all_stages": "전체 (남은 미완료 stage 모두)",
243
244
  "done_mark": "[done]",
244
- "undone_mark": "[미완]"
245
+ "undone_mark": "[미완]",
246
+ "mark_done": "[완료]",
247
+ "mark_active": "[진행중]",
248
+ "mark_ready": "[준비됨]",
249
+ "mark_blocked": "[대기]"
250
+ },
251
+ "errors": {
252
+ "none_selected": "stage 를 하나 이상 선택하세요.",
253
+ "whole_task_impl": "전체 task 검증은 final-verification 에서만 선택할 수 있습니다.",
254
+ "all_exclusive": "'전체' 는 단독으로만 선택할 수 있습니다.",
255
+ "nothing_selectable": "선택 가능한 stage 가 없습니다 (모두 완료/진행중).",
256
+ "bad_number": "stage 번호가 올바르지 않습니다: {answer}",
257
+ "unknown_stage": "Stage Map 에 없는 stage 입니다: {bad}",
258
+ "occupied": "이미 완료/진행중인 stage 는 선택할 수 없습니다: {bad}"
259
+ },
260
+ "echo_variants": {
261
+ "plain": "stages: {stages}",
262
+ "with_closure": "stages: {stages} (의존성 자동 포함: {added})"
245
263
  }
246
264
  },
247
265
  "directive_pick": {
@@ -7,6 +7,7 @@ the stage lifecycle rules behind one interface instead of leaking raw
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import heapq
10
11
  from dataclasses import dataclass
11
12
  from pathlib import Path
12
13
  from typing import Any
@@ -97,6 +98,44 @@ def resolve_effective_stages(
97
98
  return batch
98
99
 
99
100
 
101
+ def order_stage_closure(
102
+ stages, selected: set[int], done: set[int],
103
+ ) -> list[int]:
104
+ """selected ∪ (미완료 depends-on closure) 를 의존성 위상순서로 정렬해 반환.
105
+
106
+ `want` 부분그래프에 Kahn 위상정렬을 적용한다(타이브레이커: stage 번호
107
+ 오름차순). done 인 의존성은 want 밖이므로 이미 충족된 것으로 보고 간선에서
108
+ 제외한다. 어떤 비순환 Stage Map(전방 참조 포함)에서도 정확하다 — 정렬이
109
+ stage 번호 순서와 일치한다는 가정에 의존하지 않는다."""
110
+ deps = {n: list(d) for n, d in stages}
111
+ want = set(selected)
112
+ frontier = list(selected)
113
+ while frontier:
114
+ n = frontier.pop()
115
+ for d in deps.get(n, []):
116
+ if d not in done and d not in want:
117
+ want.add(d)
118
+ frontier.append(d)
119
+ indeg = {n: 0 for n in want}
120
+ adj: dict[int, list[int]] = {n: [] for n in want}
121
+ for n in want:
122
+ for d in deps.get(n, []):
123
+ if d in want:
124
+ adj[d].append(n)
125
+ indeg[n] += 1
126
+ ready = [n for n in want if indeg[n] == 0]
127
+ heapq.heapify(ready)
128
+ out: list[int] = []
129
+ while ready:
130
+ n = heapq.heappop(ready)
131
+ out.append(n)
132
+ for m in adj[n]:
133
+ indeg[m] -= 1
134
+ if indeg[m] == 0:
135
+ heapq.heappush(ready, m)
136
+ return out
137
+
138
+
100
139
  def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bool:
101
140
  """True iff ``ancestor`` is an ancestor of ``descendant`` in git history."""
102
141
  from .worktree import is_ancestor
@@ -365,6 +365,7 @@ class WizardState:
365
365
  html_approval_sidecar: str = ""
366
366
  html_approval_option: str = ""
367
367
  selected_stage: str = "auto"
368
+ selected_stages: str = "" # implementation 다중선택 위상정렬 CSV
368
369
  executor: str = ""
369
370
  critic: str = ""
370
371
  critic_pending_text: bool = False
@@ -930,10 +931,6 @@ def _fix_cycle_confirm_required(state: WizardState) -> bool:
930
931
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
931
932
 
932
933
 
933
- def _stage_auto_allowed(state: WizardState) -> bool:
934
- return state.task_type == "implementation"
935
-
936
-
937
934
  def _parse_stage_objects(state: WizardState) -> list:
938
935
  """승인 plan 의 Stage Map stage 객체 목록. validator 의 _parse_stage_map 재사용.
939
936
  `_build_stage_pick` 과 `_whole_task_allowed` 가 공유한다.
@@ -975,6 +972,28 @@ def _done_stage_numbers(state: WizardState) -> set:
975
972
  return set(latest_done_by_stage(rows).keys())
976
973
 
977
974
 
975
+ def _started_stage_numbers(state: WizardState) -> set:
976
+ """approved plan 을 소비한 run 들의 consumers.jsonl 에서 status:started 인
977
+ stage 번호 집합. _done_stage_numbers 와 같은 순수 읽기 — carry backfill 금지."""
978
+ if not state.approved_plan_path:
979
+ return set()
980
+ from .consumers import read_consumers
981
+ plan_run_root = Path(state.approved_plan_path).resolve().parents[1]
982
+ rows = read_consumers(plan_run_root)
983
+ return {r["stage"] for r in rows
984
+ if r.get("status") == "started" and isinstance(r.get("stage"), int)}
985
+
986
+
987
+ def _reserved_stage_numbers(state: WizardState) -> set:
988
+ """worktree-registry 가 active 로 잡고 있는 stage 번호 집합(점유 SSOT).
989
+ implementation prepare 와 동일 인자로 list_active_stage_numbers 를 호출한다."""
990
+ if not (state.project_id and state.task_group and state.task_id):
991
+ return set()
992
+ from .worktree_registry import list_active_stage_numbers
993
+ return list_active_stage_numbers(
994
+ state.project_id, state.task_group, state.task_id)
995
+
996
+
978
997
  def _whole_task_allowed(
979
998
  state: WizardState,
980
999
  *,
@@ -1634,6 +1653,7 @@ _SIBLINGS_TOKEN = "__siblings__"
1634
1653
  _LATEST_REPORT_TOKEN = "__latest_report__"
1635
1654
  _PROJECT_DEFAULT_TOKEN = "__project_default__"
1636
1655
  WHOLE_TASK_STAGE = "__whole_task__"
1656
+ ALL_STAGES = "__all_stages__"
1637
1657
 
1638
1658
 
1639
1659
  def _list_implementation_planning_reports(
@@ -1814,60 +1834,118 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1814
1834
  t = _p(state.workspace_root, "stage_pick")
1815
1835
  stages = _parse_stage_objects(state)
1816
1836
  is_fv = state.task_type == "final-verification"
1837
+ is_impl = state.task_type == "implementation"
1817
1838
  label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
1818
- done = _done_stage_numbers(state) if is_fv else set()
1839
+ done = _done_stage_numbers(state)
1840
+ started = _started_stage_numbers(state) if is_impl else set()
1841
+ reserved = _reserved_stage_numbers(state) if is_impl else set()
1819
1842
  options = []
1820
- if _stage_auto_allowed(state):
1821
- options.append(_opt("auto", t["options"]["auto"]))
1822
1843
  if _whole_task_allowed(state, stages=stages, done=done):
1823
1844
  options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
1845
+ if is_impl:
1846
+ options.append(_opt(ALL_STAGES, t["options"]["all_stages"]))
1824
1847
  for s in stages:
1825
1848
  depends = ",".join(map(str, s.depends_on)) or "(none)"
1826
1849
  suffix = ""
1827
1850
  if is_fv:
1828
- mark = (t["options"]["done_mark"]
1829
- if s.stage_number in done
1830
- else t["options"]["undone_mark"])
1831
- suffix = f" {mark}"
1851
+ suffix = " " + (t["options"]["done_mark"]
1852
+ if s.stage_number in done
1853
+ else t["options"]["undone_mark"])
1854
+ elif is_impl:
1855
+ suffix = " " + _impl_stage_marker(t, s, done, started, reserved)
1832
1856
  options.append(_opt(
1833
1857
  str(s.stage_number),
1834
1858
  f"{s.stage_number}: {s.title} "
1835
1859
  f"[depends-on: {depends} | steps: {s.step_count}]{suffix}",
1836
1860
  ))
1837
1861
  return Prompt(
1838
- step=S_STAGE_PICK, kind="pick",
1862
+ step=S_STAGE_PICK, kind="pick", multi=is_impl,
1839
1863
  label=label,
1840
1864
  options=options,
1841
1865
  echo_template=t["echo_template"],
1842
1866
  )
1843
1867
 
1844
1868
 
1869
+ def _impl_stage_marker(t, stage, done, started, reserved) -> str:
1870
+ n = stage.stage_number
1871
+ if n in done:
1872
+ return t["options"]["mark_done"]
1873
+ if n in started or n in reserved:
1874
+ return t["options"]["mark_active"]
1875
+ if all(d in done for d in stage.depends_on):
1876
+ return t["options"]["mark_ready"]
1877
+ return t["options"]["mark_blocked"]
1878
+
1879
+
1845
1880
  def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1881
+ if state.task_type == "implementation":
1882
+ return _submit_impl_stage_pick(state, answer)
1883
+ # final-verification: 단일선택 유지 (whole-task 또는 단일 정수; auto 불가)
1846
1884
  if not answer:
1847
1885
  raise WizardError("value required")
1848
- if answer == "auto":
1849
- if not _stage_auto_allowed(state):
1850
- raise WizardError(
1851
- "final-verification requires an explicit stage number"
1852
- )
1853
- elif answer == WHOLE_TASK_STAGE:
1886
+ if answer == WHOLE_TASK_STAGE:
1854
1887
  if not _whole_task_allowed(state):
1855
1888
  raise WizardError(
1856
1889
  "whole-task verification requires final-verification "
1857
- "with all stages done"
1858
- )
1890
+ "with all stages done")
1859
1891
  else:
1860
1892
  try:
1861
1893
  int(answer)
1862
1894
  except ValueError:
1863
1895
  raise WizardError(
1864
- f"answer must be 'auto', whole-task, or a stage number, "
1865
- f"got {answer!r}"
1866
- )
1896
+ f"answer must be whole-task or a stage number, got {answer!r}")
1867
1897
  state.selected_stage = answer
1868
1898
  return f"stage: {answer}"
1869
1899
 
1870
1900
 
1901
+ def _submit_impl_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1902
+ from .stage_targets import order_stage_closure
1903
+ t = _p(state.workspace_root, "stage_pick")
1904
+ picks = [v.strip() for v in (answer or "").split(",") if v.strip()]
1905
+ if not picks:
1906
+ raise WizardError(t["errors"]["none_selected"])
1907
+ if WHOLE_TASK_STAGE in picks:
1908
+ raise WizardError(t["errors"]["whole_task_impl"])
1909
+ stages = _parse_stage_objects(state)
1910
+ all_nums = {s.stage_number for s in stages}
1911
+ done = _done_stage_numbers(state)
1912
+ occupied = (done | _started_stage_numbers(state)
1913
+ | _reserved_stage_numbers(state))
1914
+ chosen = _impl_chosen_stages(t, picks, answer, all_nums, occupied)
1915
+ ordered = order_stage_closure(
1916
+ [(s.stage_number, s.depends_on) for s in stages], chosen, done)
1917
+ state.selected_stages = ",".join(map(str, ordered))
1918
+ state.selected_stage = str(ordered[0]) if ordered else "auto"
1919
+ added = [n for n in ordered if n not in chosen]
1920
+ if added:
1921
+ return t["echo_variants"]["with_closure"].format(
1922
+ stages=state.selected_stages, added=", ".join(map(str, added)))
1923
+ return t["echo_variants"]["plain"].format(stages=state.selected_stages)
1924
+
1925
+
1926
+ def _impl_chosen_stages(t, picks, answer, all_nums, occupied) -> set:
1927
+ if ALL_STAGES in picks:
1928
+ if len(picks) > 1:
1929
+ raise WizardError(t["errors"]["all_exclusive"])
1930
+ chosen = {n for n in all_nums if n not in occupied}
1931
+ if not chosen:
1932
+ raise WizardError(t["errors"]["nothing_selectable"])
1933
+ return chosen
1934
+ try:
1935
+ nums = {int(p) for p in picks}
1936
+ except ValueError:
1937
+ raise WizardError(t["errors"]["bad_number"].format(answer=answer))
1938
+ unknown = sorted(nums - all_nums)
1939
+ if unknown:
1940
+ raise WizardError(t["errors"]["unknown_stage"].format(
1941
+ bad=", ".join(map(str, unknown))))
1942
+ bad = sorted(nums & occupied)
1943
+ if bad:
1944
+ raise WizardError(t["errors"]["occupied"].format(
1945
+ bad=", ".join(map(str, bad))))
1946
+ return nums
1947
+
1948
+
1871
1949
  def _handoff_msgs(state: WizardState) -> dict:
1872
1950
  """handoff_stage_pick 의 JSON 텍스트 묶음 (label 미사용 조회용)."""
1873
1951
  return _p(state.workspace_root, "handoff_stage_pick", blocked="")
@@ -2933,7 +3011,7 @@ STEPS: list[Step] = [
2933
3011
  and bool(s.approved_plan_path)
2934
3012
  and S_STAGE_PICK not in s.answered),
2935
3013
  build=_build_stage_pick, submit=_submit_stage_pick,
2936
- owns=("selected_stage",)),
3014
+ owns=("selected_stage", "selected_stages")),
2937
3015
  Step(S_HANDOFF_STAGE_PICK,
2938
3016
  applies=lambda s: (s.task_type == "release-handoff"
2939
3017
  and bool(s.task_group)
@@ -3187,6 +3265,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3187
3265
  "approved_plan_pending_text": False, "approve_plan_candidate": "",
3188
3266
  "html_approval_sidecar": "", "html_approval_option": "",
3189
3267
  "selected_stage": "auto",
3268
+ "selected_stages": "",
3190
3269
  "handoff_mode": "", "handoff_stages": "",
3191
3270
  "executor": "", "critic": "", "critic_pending_text": False,
3192
3271
  "reuse_previous": None,
@@ -3409,8 +3488,10 @@ def render_args(state: WizardState) -> dict[str, str]:
3409
3488
  if state.reuse_worktree or state.task_type == "final-verification"
3410
3489
  else state.base_ref
3411
3490
  )
3491
+ chain_stages = ""
3412
3492
  if state.task_type == "implementation":
3413
3493
  stage = state.selected_stage or "auto"
3494
+ chain_stages = state.selected_stages or stage
3414
3495
  elif state.task_type == "final-verification":
3415
3496
  if state.selected_stage == WHOLE_TASK_STAGE:
3416
3497
  stage = "" # prepare 가 빈 stage 를 whole-task 로 해석
@@ -3439,6 +3520,7 @@ def render_args(state: WizardState) -> dict[str, str]:
3439
3520
  "approved-plan": state.approved_plan_path,
3440
3521
  "stage": stage,
3441
3522
  "stages": state.handoff_stages,
3523
+ "chain-stages": chain_stages,
3442
3524
  "base-ref": base_ref,
3443
3525
  "workers": workers,
3444
3526
  "directive": state.directive,
@@ -253,6 +253,46 @@ Then proceed through the phases exactly as the lead prompt directs (Phase 1 cont
253
253
  Inform the user with one short line:
254
254
  > Took over as Claude lead for `<taskKey>` (`<task-type>`). Run dir: `<RUN_DIR_RELATIVE_PATH>`. Beginning Phase 1 (context loading).
255
255
 
256
+ ## Step 7: implementation 무인 연쇄 (chain-stages)
257
+
258
+ `task-type == implementation` 이고 Step 5 render-args 의 `chain-stages` CSV 가 원소 2개
259
+ 이상이면, 현재 세션이 오케스트레이터로서 stage 를 의존성 순서대로 무인 연쇄
260
+ 실행한다(단일 원소면 기존 단일 run 과 동일하므로 이 절은 건너뛴다 — Step 6 종료가 곧
261
+ run 종료다).
262
+
263
+ 큐 = `chain-stages` 를 `,` 로 분해한 위상정렬 stage 리스트(Task 5 가 의존성
264
+ closure 를 위상정렬해 내보낸 순서). 큐의 각 stage `N` 에 대해 순서대로:
265
+
266
+ 1. Step 5 의 `render-bundle` 을 동일 인자로, 단 `--stage N` 으로 호출한다(base
267
+ commit 은 prepare 가 predecessor 의 done `head_commit` 으로 자동 계산하므로 손으로
268
+ 넘기지 않는다). Step 5 의 conformance waiver offer·동시-run 감지·git-reconcile
269
+ 게이트는 매 stage 의 `render-bundle` 마다 동일하게 적용된다.
270
+ 2. Step 6 대로 Claude lead 가 되어 그 stage 의 Phase 1~7 을 인라인 실행한다. Phase 6
271
+ 의 lead post-stage persistence 가 `runs/<plan-task-key>/consumers.jsonl` 에 그
272
+ stage 의 `status:"done"` 행을 append 한다(implementation 프로파일 지시).
273
+ 3. 그 `done` 행이 기록됐는지 확인한 뒤 다음 stage 로 넘어간다. 매 stage 경계에서
274
+ 컨텍스트를 정리한다(이전 배치의 잔여 pane·완료 teammate).
275
+ 4. 각 stage 시작/완료마다 한 줄 보고: `stage N/<총개수> 시작` / `stage N done → 다음 K`.
276
+
277
+ 큐를 모두 소화하면 연쇄를 종료하고 사용자에게 완료를 보고한다.
278
+
279
+ ### 다음 stage 가 아직 ready 아님 — 정상 종료 (예외 게이트 아님)
280
+ 연쇄 큐에는 의존성 closure 때문에 **다른 implementation run 이 started/reserved 로
281
+ 점유한 stage 가 포함될 수 있다.** 그 stage 의 `render-bundle` 은
282
+ `--stage N already in progress or reserved by another run`(StageTargetError)으로
283
+ 거부된다. 이건 사람 판단이 필요한 예외 게이트가 **아니라** "다음 stage 가 아직
284
+ ready 아님" 상황이다. 이 거부를 만나면 연쇄를 **정상 종료**하고 남은 큐를
285
+ 사용자에게 보고한다(예: `남은 큐: stage 4, 5 — 점유 해제 후 okstra-run 으로 재개`).
286
+ 아래 예외 게이트(데이터 손상·동시 점유 충돌 확인)와는 다른 분기다.
287
+
288
+ ### 연쇄 중 예외 게이트
289
+ `render-bundle` 이 Step 5 의 동시-run 충돌 감지(concurrent-run 분기)나 git
290
+ stale-SHA 재조정(git-reconcile 분기)을 띄우면, **그 stage 에서 연쇄를 멈추고**
291
+ 게이트를 Step 5 의 절차대로 사용자에게 그대로 제시한다. 사용자가 게이트를
292
+ 해소하면 그 자리에서 연쇄를 재개한다(남은 큐를 이어서 처리). 데이터 손상·동시
293
+ 점유 충돌은 사람이 확인한다 — 이것이 무인 연쇄의 안전 경계다. (위 "ready 아님"
294
+ 거부와 달리, 이 두 분기는 큐를 버리지 않고 사용자 해소를 기다린다.)
295
+
256
296
  ## Persisting the PR template scope (release-handoff)
257
297
 
258
298
  When `wizard render-args` returns a non-empty `pr-template-path` AND the state has `pr_template_scope == "project"` or `"global"`, run the matching config command BEFORE `render-bundle`:
@@ -290,4 +330,4 @@ The scope is held in the wizard state but is not yet exposed by any `okstra wiza
290
330
 
291
331
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
292
332
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
293
- - After Step 6, begin the lead workflow without re-summarizing the skill itself.
333
+ - After Step 6, begin the lead workflow without re-summarizing the skill itself. 단일 run 은 Step 6 종료가 곧 run 종료다 — 단, `chain-stages` 가 원소 2개 이상인 무인 연쇄에서는 Step 7 의 큐가 빌 때까지(또는 "ready 아님"/예외 게이트로 멈출 때까지) Step 6 을 stage 마다 반복한 뒤 종료한다.
@@ -20,6 +20,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
20
20
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
21
21
 
22
22
  STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
23
+ HARD_STEP_CAP = 8
23
24
  REQUIRED_SUBSECTIONS = (
24
25
  "Carry-In",
25
26
  "Stepwise Execution Order",
@@ -166,9 +167,9 @@ def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[Valida
166
167
 
167
168
  # S5: effective step count
168
169
  steps = _count_effective_steps(section)
169
- if steps > 6:
170
+ if steps > HARD_STEP_CAP:
170
171
  errs.append(ValidationError("S5", s.stage_number,
171
- f"effective step count {steps} exceeds 6"))
172
+ f"effective step count {steps} exceeds {HARD_STEP_CAP}"))
172
173
 
173
174
  # S7: step-count cell vs. real count
174
175
  if s.step_count >= 0 and s.step_count != steps: