okstra 0.152.0 → 0.154.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 (30) hide show
  1. package/README.md +1 -1
  2. package/bin/okstra +7 -0
  3. package/docs/cli.md +5 -1
  4. package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
  5. package/docs/project-structure-overview.md +2 -2
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/bin/okstra-antigravity-exec.sh +11 -6
  9. package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
  10. package/runtime/prompts/lead/convergence.md +3 -2
  11. package/runtime/prompts/lead/plan-body-verification.md +30 -1
  12. package/runtime/python/okstra_ctl/container.py +9 -10
  13. package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
  14. package/runtime/python/okstra_ctl/handoff.py +4 -8
  15. package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
  16. package/runtime/python/okstra_ctl/model_discovery.py +22 -1
  17. package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
  18. package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
  19. package/runtime/python/okstra_ctl/run.py +8 -54
  20. package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
  21. package/runtime/python/okstra_ctl/stage_map.py +288 -0
  22. package/runtime/python/okstra_ctl/wizard.py +24 -35
  23. package/runtime/python/okstra_project/state.py +19 -5
  24. package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
  25. package/runtime/templates/reports/schedule.template.md +9 -9
  26. package/runtime/validators/detect_self_mock.py +27 -2
  27. package/runtime/validators/validate-implementation-plan-stages.py +24 -63
  28. package/runtime/validators/validate-run.py +110 -0
  29. package/runtime/validators/validate-schedule.py +78 -10
  30. package/src/commands/inspect/stage-map.mjs +1 -1
@@ -0,0 +1,288 @@
1
+ """Strict Stage Map parsing shared by planning consumers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ from .md_table import is_separator_row, split_pipe_row
11
+ from .paths import RunRef
12
+ from .plan_run_root import list_implementation_planning_reports
13
+ from .task_target import infer_project_root
14
+
15
+
16
+ STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.MULTILINE)
17
+ _STAGE_MAP_HEADER = (
18
+ "stage",
19
+ "title",
20
+ "depends-on",
21
+ "step-count",
22
+ "exit-contract-summary",
23
+ )
24
+ _LEGACY_STAGE_MAP_HEADER = (
25
+ "stage",
26
+ "title",
27
+ "depends_on",
28
+ "step_count",
29
+ "exit_contract",
30
+ )
31
+ _STAGE_MAP_HEADERS = {_STAGE_MAP_HEADER, _LEGACY_STAGE_MAP_HEADER}
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class StageMapStage:
36
+ stage_number: int
37
+ title: str
38
+ depends_on: tuple[int, ...]
39
+ step_count: int
40
+ exit_contract_summary: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class StageMapSnapshot:
45
+ state: str
46
+ source_plan_path: str
47
+ stages: list[dict[str, Any]]
48
+
49
+
50
+ class StageMapError(Exception):
51
+ def __init__(
52
+ self,
53
+ code: str,
54
+ reason: str,
55
+ source_plan_path: str = "",
56
+ conflicting_paths: tuple[str, ...] = (),
57
+ ) -> None:
58
+ self.code = code
59
+ self.reason = reason
60
+ self.source_plan_path = source_plan_path
61
+ self.conflicting_paths = conflicting_paths
62
+ details = reason
63
+ if source_plan_path:
64
+ details += f"; source={source_plan_path}"
65
+ if conflicting_paths:
66
+ details += "; conflicts=" + ", ".join(conflicting_paths)
67
+ super().__init__(details)
68
+
69
+
70
+ def _stage_map_table_lines(text: str, source_plan_path: str) -> list[str]:
71
+ heading = STAGE_MAP_HEADING.search(text)
72
+ if heading is None:
73
+ raise StageMapError(
74
+ "stage_map", "section '## 5.5 Stage Map' is missing", source_plan_path
75
+ )
76
+ body = text[heading.end():]
77
+ next_heading = re.search(r"^##\s", body, re.MULTILINE)
78
+ if next_heading is not None:
79
+ body = body[:next_heading.start()]
80
+ lines = body.splitlines()
81
+ headers = [
82
+ index
83
+ for index, line in enumerate(lines)
84
+ if tuple(cell.lower() for cell in split_pipe_row(line))
85
+ in _STAGE_MAP_HEADERS
86
+ ]
87
+ if len(headers) != 1:
88
+ raise StageMapError(
89
+ "stage_map",
90
+ "Stage Map requires exactly one canonical 5-column header",
91
+ source_plan_path,
92
+ )
93
+ header_index = headers[0]
94
+ if header_index + 1 >= len(lines) or not is_separator_row(lines[header_index + 1]):
95
+ raise StageMapError(
96
+ "stage_map", "Stage Map requires a separator row", source_plan_path
97
+ )
98
+ return [
99
+ line for line in lines[header_index + 2:]
100
+ if line.strip().startswith("|") and not is_separator_row(line)
101
+ ]
102
+
103
+
104
+ def _parse_depends_on(
105
+ value: str, row_number: int, source_plan_path: str,
106
+ ) -> tuple[int, ...]:
107
+ if value in {"", "(none)"}:
108
+ return ()
109
+ dependencies: list[int] = []
110
+ for token in value.split(","):
111
+ normalized = token.strip()
112
+ if not normalized.isdigit() or int(normalized) < 1:
113
+ raise StageMapError(
114
+ "stage_map",
115
+ f"Stage Map row {row_number} has invalid depends-on token "
116
+ f"{normalized!r}",
117
+ source_plan_path,
118
+ )
119
+ dependencies.append(int(normalized))
120
+ return tuple(dependencies)
121
+
122
+
123
+ def _parse_stage_map_row(
124
+ line: str, row_number: int, source_plan_path: str,
125
+ ) -> StageMapStage:
126
+ cells = split_pipe_row(line)
127
+ if len(cells) != 5:
128
+ raise StageMapError(
129
+ "stage_map",
130
+ f"Stage Map row {row_number} requires 5 columns, got {len(cells)}",
131
+ source_plan_path,
132
+ )
133
+ try:
134
+ stage_number = int(cells[0])
135
+ except ValueError as exc:
136
+ raise StageMapError(
137
+ "stage_map",
138
+ f"Stage Map row {row_number} has invalid stage number {cells[0]!r}",
139
+ source_plan_path,
140
+ ) from exc
141
+ try:
142
+ step_count = int(cells[3])
143
+ except ValueError as exc:
144
+ raise StageMapError(
145
+ "stage_map",
146
+ f"Stage Map row {row_number} has invalid step-count {cells[3]!r}",
147
+ source_plan_path,
148
+ ) from exc
149
+ if stage_number < 1 or step_count < 1:
150
+ field = "stage number" if stage_number < 1 else "step-count"
151
+ value = stage_number if stage_number < 1 else step_count
152
+ raise StageMapError(
153
+ "stage_map",
154
+ f"Stage Map row {row_number} has invalid {field} {value!r}",
155
+ source_plan_path,
156
+ )
157
+ return StageMapStage(
158
+ stage_number,
159
+ cells[1],
160
+ _parse_depends_on(cells[2].strip(), row_number, source_plan_path),
161
+ step_count,
162
+ cells[4],
163
+ )
164
+
165
+
166
+ def _validate_stage_numbers(
167
+ stages: list[StageMapStage], source_plan_path: str,
168
+ ) -> None:
169
+ numbers = [stage.stage_number for stage in stages]
170
+ duplicates = sorted({number for number in numbers if numbers.count(number) > 1})
171
+ if duplicates:
172
+ raise StageMapError(
173
+ "stage_map",
174
+ f"Stage Map has duplicate stage {duplicates[0]}",
175
+ source_plan_path,
176
+ )
177
+ for row_number, stage in enumerate(stages, start=1):
178
+ if stage.stage_number != row_number:
179
+ raise StageMapError(
180
+ "stage_map",
181
+ "stage numbers must be 1..N monotonic, "
182
+ f"got {stage.stage_number} at row {row_number}",
183
+ source_plan_path,
184
+ )
185
+
186
+
187
+ def parse_stage_map_text(
188
+ text: str, *, source_plan_path: str = "",
189
+ ) -> list[StageMapStage]:
190
+ if not isinstance(text, str):
191
+ raise StageMapError(
192
+ "stage_map", "Stage Map text must be a string", source_plan_path
193
+ )
194
+ try:
195
+ lines = _stage_map_table_lines(text, source_plan_path)
196
+ if not lines:
197
+ raise StageMapError(
198
+ "stage_map", "Stage Map table is empty", source_plan_path
199
+ )
200
+ stages = [
201
+ _parse_stage_map_row(line, row_number, source_plan_path)
202
+ for row_number, line in enumerate(lines, start=1)
203
+ ]
204
+ _validate_stage_numbers(stages, source_plan_path)
205
+ return stages
206
+ except StageMapError:
207
+ raise
208
+ except (AttributeError, OSError, TypeError, UnicodeError, ValueError) as exc:
209
+ raise StageMapError("stage_map", str(exc), source_plan_path) from exc
210
+
211
+
212
+ def parse_stage_map_file(path: Path) -> list[StageMapStage]:
213
+ resolved = Path(path).resolve()
214
+ try:
215
+ text = resolved.read_text(encoding="utf-8")
216
+ except (OSError, UnicodeError) as exc:
217
+ raise StageMapError("stage_map", str(exc), str(resolved)) from exc
218
+ return parse_stage_map_text(text, source_plan_path=str(resolved))
219
+
220
+
221
+ def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
222
+ return [
223
+ {
224
+ "stage_number": stage.stage_number,
225
+ "title": stage.title,
226
+ "depends_on": list(stage.depends_on),
227
+ "step_count": stage.step_count,
228
+ "exit_contract_summary": stage.exit_contract_summary,
229
+ }
230
+ for stage in stages
231
+ ]
232
+
233
+
234
+ def load_task_stage_map(
235
+ task_root: Path, manifest: dict[str, Any],
236
+ ) -> StageMapSnapshot:
237
+ carried = _unique_carry_source_paths(task_root)
238
+ if len(carried) > 1:
239
+ paths = tuple(str(path) for path in carried)
240
+ raise StageMapError(
241
+ "plan-source-conflict",
242
+ "implementation carries reference different source plans",
243
+ conflicting_paths=paths,
244
+ )
245
+ if carried:
246
+ source = carried[0]
247
+ else:
248
+ reports_dir = RunRef.from_task_root(
249
+ task_root, "implementation-planning"
250
+ ).reports_dir
251
+ reports = list_implementation_planning_reports(reports_dir)
252
+ source = reports[0] if reports else None
253
+ if source is None:
254
+ return StageMapSnapshot("missing", "", [])
255
+ resolved = source.resolve()
256
+ return StageMapSnapshot(
257
+ "ready",
258
+ str(resolved),
259
+ stage_map_records(parse_stage_map_file(resolved)),
260
+ )
261
+
262
+
263
+ def _unique_carry_source_paths(task_root: Path) -> list[Path]:
264
+ carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
265
+ paths: set[Path] = set()
266
+ for carry_path in sorted(carry_dir.glob("stage-*.json")):
267
+ try:
268
+ carry = json.loads(carry_path.read_text(encoding="utf-8"))
269
+ except (OSError, UnicodeError, json.JSONDecodeError):
270
+ continue
271
+ value = carry.get("sourcePlanPath") if isinstance(carry, dict) else None
272
+ if isinstance(value, str) and value:
273
+ paths.add(_resolve_plan_path(task_root, value).resolve())
274
+ return sorted(paths, key=str)
275
+
276
+
277
+ def _resolve_plan_path(task_root: Path, value: str) -> Path:
278
+ path = Path(value)
279
+ if path.is_absolute():
280
+ return path
281
+ project_root = infer_project_root(task_root)
282
+ project_relative = project_root / path
283
+ if project_relative.exists():
284
+ return project_relative
285
+ task_relative = task_root / path
286
+ if task_relative.exists():
287
+ return task_relative
288
+ return project_relative
@@ -62,6 +62,7 @@ from okstra_ctl.design_prep import (
62
62
  write_design_prep_input,
63
63
  )
64
64
  from okstra_ctl.final_report_paths import final_report_data_path
65
+ from okstra_ctl.plan_run_root import list_implementation_planning_reports
65
66
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
66
67
  from okstra_ctl.run import (
67
68
  APPROVED_FRONTMATTER_PATTERN,
@@ -69,12 +70,16 @@ from okstra_ctl.run import (
69
70
  _apply_cli_implementation_option,
70
71
  _extract_frontmatter_block,
71
72
  _load_final_report_data_if_present,
72
- _load_parsed_stage_map,
73
73
  _reject_blocking_plan_body_gate,
74
74
  _set_data_json_approved_true_if_present,
75
- _stage_map_reject_detail,
76
75
  recommended_role_models,
77
76
  )
77
+ from okstra_ctl.stage_map import (
78
+ StageMapError,
79
+ parse_stage_map_file,
80
+ parse_stage_map_text,
81
+ stage_map_records,
82
+ )
78
83
  from okstra_ctl.user_response import (
79
84
  UserResponseApprovalRecord,
80
85
  parse_user_response_approval,
@@ -1027,27 +1032,23 @@ def _fix_cycle_confirm_required(state: WizardState) -> bool:
1027
1032
 
1028
1033
 
1029
1034
  def _parse_stage_objects(state: WizardState) -> list:
1030
- """승인 plan Stage Map stage 객체 목록. validator 의 _parse_stage_map 재사용.
1031
- `_build_stage_pick` 과 `_whole_task_allowed` 가 공유한다.
1032
-
1033
- 빈/손상 Stage Map 은 prepare 게이트(`_parse_stage_map_into_ctx`)와 같은 기준
1034
- (`_stage_map_reject_detail`)으로 거부한다 — 안 그러면 손상된 맵이 stage 일부만
1035
- 파싱돼 picker 가 '전체 task' 검증을 제안·수락하지만 prepare 가 같은 맵을 하드
1036
- 거부하는 불일치가 생긴다.
1037
-
1038
- validator 로드는 prepare 와 같은 단일 경로(`_load_parsed_stage_map`)로 위임하고,
1039
- PrepareError 는 picker 디스패처가 처리하는 WizardError 로 변환한다."""
1040
- plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
1035
+ """Return the approved plan's strict Stage Map objects for the picker."""
1041
1036
  try:
1042
- stages, errs = _load_parsed_stage_map(plan_text)
1043
- except PrepareError as exc:
1044
- raise WizardError(str(exc)) from exc
1045
- detail = _stage_map_reject_detail(stages, errs)
1046
- if detail is not None:
1037
+ plan_text = Path(state.approved_plan_path).read_text(encoding="utf-8")
1038
+ return parse_stage_map_text(
1039
+ plan_text,
1040
+ source_plan_path=str(Path(state.approved_plan_path).resolve()),
1041
+ )
1042
+ except OSError as exc:
1043
+ raise WizardError(
1044
+ f"approved plan 의 Stage Map 을 읽을 수 없습니다 "
1045
+ f"({state.approved_plan_path}): {exc}"
1046
+ ) from exc
1047
+ except StageMapError as exc:
1047
1048
  raise WizardError(
1048
1049
  f"approved plan 의 Stage Map 을 신뢰할 수 없습니다 "
1049
- f"({state.approved_plan_path}): {detail}. plan 의 Stage Map 을 점검하세요.")
1050
- return stages
1050
+ f"({state.approved_plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
1051
+ ) from exc
1051
1052
 
1052
1053
 
1053
1054
  def _stage_lifecycle_snapshot(
@@ -2153,16 +2154,8 @@ def _list_implementation_planning_reports(
2153
2154
  ).reports_dir
2154
2155
  if not reports_dir.is_dir():
2155
2156
  return []
2156
- pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
2157
- found: list[tuple[int, Path]] = []
2158
- for child in reports_dir.iterdir():
2159
- m = pat.match(child.name)
2160
- if not m:
2161
- continue
2162
- found.append((int(m.group(1)), child))
2163
- found.sort(key=lambda x: -x[0])
2164
2157
  out: list[Path] = []
2165
- for _, p in found[:limit]:
2158
+ for p in list_implementation_planning_reports(reports_dir)[:limit]:
2166
2159
  try:
2167
2160
  out.append(p.relative_to(Path(state.project_root)))
2168
2161
  except ValueError:
@@ -2691,15 +2684,11 @@ def _resolve_handoff_plan(state: WizardState) -> Path:
2691
2684
  def _handoff_eligibility(state: WizardState) -> list:
2692
2685
  """stage 별 PR 자격 — okstra_ctl.handoff 의 SSOT 판정을 그대로 재사용한다."""
2693
2686
  from okstra_ctl.handoff import compute_eligibility
2694
- from okstra_ctl.run import _parse_stage_map_into_ctx
2695
2687
  from okstra_ctl.consumers import read_consumers
2696
2688
  plan = _resolve_handoff_plan(state)
2697
2689
  try:
2698
- stage_map = _parse_stage_map_into_ctx(str(plan))
2699
- except PrepareError as exc:
2700
- # PrepareError 가 비어있음(heading rename)·손상(비단조 등)을 이미 구체적으로
2701
- # 구분해 전달하므로 그 메시지를 그대로 surface 한다 — 'no_stage_map' 으로
2702
- # 덮으면 손상된 맵을 '맵 없음'으로 오표기하고 어느 행이 깨졌는지 detail 을 잃는다.
2690
+ stage_map = stage_map_records(parse_stage_map_file(plan))
2691
+ except StageMapError as exc:
2703
2692
  raise WizardError(str(exc)) from exc
2704
2693
  rows = read_consumers(plan.resolve().parents[1])
2705
2694
  return compute_eligibility(stage_map, rows)
@@ -383,12 +383,15 @@ def stage_map_read_side_snapshot(project_root: Path, task_key: str) -> dict:
383
383
  직접 조립하지 않도록 하는 어댑터다.
384
384
  """
385
385
  from okstra_ctl.consumers import read_stage_consumer_state
386
- from okstra_ctl.implementation_outcome import load_stage_map
387
386
  from okstra_ctl.paths import RunRef
387
+ from okstra_ctl.stage_map import StageMapError, load_task_stage_map
388
388
 
389
389
  identity = resolve_task_identity(project_root, task_key)
390
390
  task_root = Path(identity["taskRoot"])
391
- stages = load_stage_map(task_root, identity["manifest"])
391
+ try:
392
+ stage_snapshot = load_task_stage_map(task_root, identity["manifest"])
393
+ except StageMapError as exc:
394
+ raise StateError(str(exc), stage=exc.code) from exc
392
395
  plan_run_root = RunRef.from_task_root(
393
396
  task_root, "implementation-planning"
394
397
  ).run_dir
@@ -402,7 +405,9 @@ def stage_map_read_side_snapshot(project_root: Path, task_key: str) -> dict:
402
405
  return {
403
406
  "taskKey": identity["taskKey"],
404
407
  "taskRoot": identity["taskRoot"],
405
- "stages": stages,
408
+ "state": stage_snapshot.state,
409
+ "sourcePlanPath": stage_snapshot.source_plan_path,
410
+ "stages": stage_snapshot.stages,
406
411
  "doneStages": done,
407
412
  }
408
413
 
@@ -424,11 +429,20 @@ def code_review_target_snapshot(
424
429
  비거나 뒤집히고, 단일의존 stage 는 선행 stage 가 재실행되면 어긋난다.
425
430
  """
426
431
  from okstra_ctl import code_review_paths, worktree_registry
427
- from okstra_ctl.implementation_outcome import load_stage_map
432
+ from okstra_ctl.stage_map import StageMapError, load_task_stage_map
428
433
 
429
434
  identity = resolve_task_identity(project_root, task_key)
430
435
  task_root = Path(identity["taskRoot"])
431
- stages = load_stage_map(task_root, identity["manifest"])
436
+ try:
437
+ stage_snapshot = load_task_stage_map(task_root, identity["manifest"])
438
+ except StageMapError as exc:
439
+ raise StateError(str(exc), stage=exc.code) from exc
440
+ if stage_snapshot.state != "ready":
441
+ raise StateError(
442
+ "implementation-planning Stage Map is missing",
443
+ stage="missing",
444
+ )
445
+ stages = stage_snapshot.stages
432
446
  selected = next((s for s in stages if s["stage_number"] == stage), None)
433
447
  if selected is None:
434
448
  raise StateError(
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: okstra-schedule-gen
3
- description: Use when the user asks for a task-group work schedule, a consolidated implementation plan across multiple tasks in a task-group, or wants to generate a "schedule" / "work plan" for non-done tasks. Trigger words include "okstra schedule", "make a schedule for <task-group>", "generate a <task-group> schedule", "task-group work plan".
3
+ description: Use when the user asks for a task-group work schedule, a consolidated implementation plan across multiple tasks in a task-group, or wants to generate a schedule or work plan for non-done tasks.
4
4
  model: opus
5
5
  ---
6
6
 
7
7
  # OKSTRA Schedule Gen
8
8
 
9
- Generate a consolidated work schedule for the selected `implementation-planning` stages of every non-done task in a given `task-group` (or a single `task-id`). For each task the skill reads its `implementation-planning` final-report **Stage Map** as the authoritative forward-looking decomposition, asks the user which stages to include, drafts a single Markdown plan, and only writes the final file after an **independent verifier subagent** confirms the draft covers exactly the selected stages. It runs as a **lead + verifier** flow; the frontmatter `model: opus` switches supporting harnesses to Opus-class for the turn — stage-level cross-task synthesis needs that reasoning depth.
9
+ Generate a consolidated work schedule for the selected `implementation-planning` stages of every non-done task in a given `task-group` (or a single `task-id`). For each task the skill reads the source-aware **Stage Map**, records the user's stage choices in a temporary selection contract, and gates the same draft through deterministic validation followed by an independent narrative review. It runs as a **lead + verifier** flow; the frontmatter `model: opus` switches supporting harnesses to Opus-class for the turn — stage-level cross-task synthesis needs that reasoning depth.
10
10
 
11
11
  ## When to Use
12
12
 
@@ -15,7 +15,7 @@ Generate a consolidated work schedule for the selected `implementation-planning`
15
15
 
16
16
  **Do NOT use** for single-task analysis (use `okstra-inspect status`) or to execute one task (use `okstra-run`).
17
17
 
18
- Explicit command form: `okstra schedule <task-group> [--title "<custom title>"] [--directive-file <abs-path>]`. If `--title` is omitted, derive a default from `task-group` (e.g. `uploadFont` → `uploadFont — Work Schedule`).
18
+ Public invocation: `/okstra-schedule-gen [task-group]`. A title or directive may be supplied in the host conversation. If no title is supplied, derive a default from `task-group` (e.g. `uploadFont` → `uploadFont — Work Schedule`).
19
19
 
20
20
  ## Step 0: Preflight
21
21
 
@@ -63,7 +63,7 @@ One computation rule the template scaffold cannot carry inline:
63
63
 
64
64
  1. Read `.okstra/discovery/task-catalog.json`.
65
65
  2. **Resolve which task-group to schedule — never silently guess.**
66
- - The user **explicitly named a task-group** (as the `okstra schedule <task-group>` argument or unambiguously in the request) → use that token; skip the picker and go to sub-step 3.
66
+ - The user **explicitly named a task-group** (as the `/okstra-schedule-gen [task-group]` argument or unambiguously in the request) → use that token; skip the picker and go to sub-step 3.
67
67
  - The user **named no task-group, or the named token matches 0 or ≥2 groups** → present a 3-option picker via `AskUserQuestion` and do NOT proceed until the user chooses. Build the options from the catalog: walk `tasks[]` in catalog order (already `updatedAt` desc — see `scripts/okstra_ctl/render.py:654`), collect distinct `taskGroupPathSegment` values that have ≥1 entry whose resolved `workStatus` is **non-done** (defer to Step 2's inference table), and offer the newest **1–2** such groups as recommendations. The **last option is always `Enter directly`** (free-text group token, fed into sub-step 3). Label each recommendation with its non-done task count (e.g. `uploadFont (non-done 3)`).
68
68
  - If **zero groups have a non-done task** (or the catalog is empty), do NOT open a picker — emit `All tasks in this task-group are done. There is no schedule to generate.` (or `That task-group could not be found.` when the catalog has no tasks at all) and stop **without creating a file**.
69
69
  3. **Normalise the resolved `<task-group>`:** lowercase it, then strip every character that is not `[a-z0-9]`. Apply the same transform to each entry's `taskGroupPathSegment`. Match on equality — this is the single comparison rule; do NOT also fall back to the raw `taskGroup` field.
@@ -79,17 +79,23 @@ If 0 tasks remain, output `All tasks in this task-group are done. There is no sc
79
79
 
80
80
  ### Step 3: Per-task stage extraction (Stage Map source)
81
81
 
82
- For each in-scope task, the **authoritative source is its `implementation-planning` final-report**, NOT `latestReportPath`. Resolve and parse it as follows:
82
+ For each in-scope task, the **authoritative source is its `implementation-planning` Stage Map resolved by the CLI**. Do not hand-parse it or use `latestReportPath` to choose its source. Call:
83
83
 
84
- 1. Resolve `<task-root>` from the manifest's `taskRootPath`. The planning report lives under `<task-root>/runs/implementation-planning/**/reports/final-report-*.md`. If several exist, pick the **newest by mtime**.
85
- 2. Do NOT hand-parse the Stage Map markdown. Call `okstra stage-map <task-key> --json` (Task 0). It returns `{ok, taskRoot, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int]}` by wrapping `_load_stage_map` + `read_stage_consumer_state().done_stages`. `stages: []` means no planning Stage Map exists → treat as **no planning report** below.
86
- 3. From each stage row keep `stage_number`, `title`, `depends_on`, `step_count`. Compute `remainingStages = stages − doneStages`.
87
- 4. From `task-manifest.json` still read task-level metadata for the header and phase bucket: `taskId`, `taskGroup`, `taskKey`, `workCategory`, `workStatus`, `taskType`, `workflow.currentPhase`.
88
- 5. From the planning report also read task-level Priority / Risk / Scope / Repos for phase classification and the At a Glance row. Blocking / approval items are **not** extracted (see Audience & authority).
84
+ ```bash
85
+ okstra stage-map <task-key> --json
86
+ ```
87
+
88
+ The response is source-aware: `{ok, taskKey, taskRoot, state, sourcePlanPath, stages:[{stage_number,title,depends_on,step_count}], doneStages:[int]}`. Branch on it explicitly:
89
+
90
+ The resolved source, when ready, is a report under `runs/implementation-planning`; the CLI owns choosing exactly one report from that domain.
91
+
92
+ - `ok: true, state: "ready"` — preserve `sourcePlanPath`, stage rows, and `doneStages`. Compute unfinished stages as `stages − doneStages`. Only unfinished stages can be selected. Every selected stage's transitive prerequisites must be either selected or present in `doneStages`; the completed prerequisite closure is retained as evidence, never rendered as forward work.
93
+ - `ok: true, state: "missing"` — record an empty source and empty stage sets. Tag the task `[NEEDS-PLANNING]`, skip the stage picker, and render task-level metadata only, with no forward Work Breakdown row, Gantt row, or day total.
94
+ - `ok: false` or any state other than `ready` and `missing` — this is a structured Stage Map error, such as a corrupt or conflicting plan source. Stop before drafting, preserve the CLI `stage` and `reason` in the user-facing error, and do not guess from another report.
89
95
 
90
- **No planning Stage Map** (`stage-map` returned `stages: []`) this task cannot be stage-scheduled. Tag it `[NEEDS-PLANNING]`, skip the Step 3.5 stage picker for it, and render it under its phase section as a single banner line with task-level metadata only (no Gantt bars, no day total). Continue with the remaining tasks.
96
+ For `ready`, read task-level Priority / Risk / Scope / Repos from `sourcePlanPath`. Read header and phase-bucket metadata from `task-manifest.json`: `taskId`, `taskGroup`, `taskKey`, `workCategory`, `workStatus`, `taskType`, and `workflow.currentPhase`. Blocking and approval items are not extracted (see Audience & authority).
91
97
 
92
- **`remainingStages` is empty** (every stage done but `workStatus` not `done`) not a scheduling target. Render the task as `_Complete — no remaining stage_` under its phase section; contribute no forward day total.
98
+ If unfinished stages are empty while `workStatus` is not `done`, render `_Complete — no remaining stage_` under the task's phase section and contribute no forward day total.
93
99
 
94
100
  ### Step 3.5: Stage selection (per task, user input)
95
101
 
@@ -105,28 +111,32 @@ Run this **once per in-scope task that has a non-empty `remainingStages`**, sequ
105
111
  - Options = the distinct bundles + a final `"All remaining stages"` option. `AskUserQuestion`'s built-in Other slot serves the `Enter directly` (arbitrary stage subset) case; when the user supplies a custom subset, close it under `depends_on` before accepting.
106
112
  - **Degenerate skip:** if only one distinct bundle exists AND it already equals all remaining stages, skip the picker for this task and set `selectedStages = remainingStages` (log `> _Stage picker skipped: remaining stages form a single dependency chain._`).
107
113
 
108
- Record the chosen `selectedStages` for this task. Any custom selection that breaks `depends_on` closure is rejected re-prompt the same task.
114
+ Record the chosen `selectedStages` for this task. Reject and re-prompt any custom selection whose transitive `depends_on` closure is not covered by `selectedStages doneStages`.
109
115
 
110
116
  ### Step 4: Phase classification
111
117
 
112
- `workCategory` keys come from `scripts/okstra_ctl/worktree.py::_WORK_CATEGORY_PREFIX` (code source) plus the `unknown` fallback from `render.py`. When a category is added there, add a row here — otherwise it falls through to `unknown` → Phase 2.
118
+ The canonical categories come from `scripts/okstra_ctl/work_categories.py::WORK_CATEGORIES`. Do not infer or publish another category.
113
119
 
114
120
  | workCategory | Default phase |
115
121
  |--------------|---------------|
116
122
  | `bugfix` | Phase 1 when risk is High/Med-High; otherwise Phase 2 |
117
- | `feature` / `improvement` / `docs` / `doc` | Phase 2 |
123
+ | `feature` / `improvement` | Phase 2 |
118
124
  | `refactor` / `ops` | Phase 3 |
119
- | `unknown` (or unmatched / missing) | Phase 2, with rationale `> _workCategory '<raw-value>' undefined — defaulting to Phase 2._` at the top of that phase section |
125
+ | unmatched or missing | Phase 2, with rationale `> _workCategory '<raw-value>' undefined — defaulting to Phase 2._` at the top of that phase section |
120
126
 
121
127
  Priority overrides category: `P0` → Phase 1; `P1`/`P2` → Phase 2; `P3` or multi-repo + infrastructure scope → Phase 3. When still ambiguous, place the task in the closest phase and add a one-line rationale at the top of that phase section (not validator-enforced — emit it yourself).
122
128
 
123
- Phase bucketing stays **task-level** (a task lands in one Phase by its `workCategory`/Priority). Within a task's per-task section, the **selected stages become the Work Breakdown rows**: one row per `selectedStages` entry with its `title`, `step_count`-derived effort, and `depends_on`. Stages excluded from `selectedStages` because they are already done are listed once as `> _Done stages: stage <n>, …_` and carry no forward effort. This keeps the mandatory heading skeleton unchanged while moving the unit of work to the stage.
129
+ Phase bucketing stays **task-level** (a task lands in one Phase by its `workCategory`/Priority). `workStatus` is only a Step 2 candidate filter. In the per-task `Item / Detail` table, render `Status` as `<taskType> / <currentPhase>`.
130
+
131
+ Within a task's per-task section, selected stages become the Work Breakdown rows under the exact header `| Stage | Title | Steps | Depends On | Days |`. Render one row per selected stage using its Stage Map `title`, `step_count`, and `depends_on`, plus its proportional day range. Stages excluded because they are already done are listed once as `> _Done stages: stage <n>, …_` and carry no forward effort.
124
132
 
125
133
  ### Step 5: Gantt decision (render by default)
126
134
 
127
135
  `## Gantt Chart` is **rendered by default** — skip ONLY when literally no day signal exists (every task is effort=XXL with no visible decomposition, or all tasks lack both effort sizing and decomposition). Render whenever any of these hold: 2+ tasks with effort sizing; 1 task whose effort yields a range (mid-point bar, or `lo`/`hi` two-bar form); 1 task with Part/Phase/Step decomposition in the source (bars at decomposition-unit level); total estimated effort ≥ 3 days. When per-unit day allocations aren't itemized, split the parent range across the visible units yourself and append the `est` annotation (or add `> Per-day allocation is an estimate; refresh recommended after blocking items are resolved.`). "Range is wide", "single task", "user decisions pending" are NOT skip reasons — render an estimate-tagged chart instead.
128
136
 
129
- When the source is a Stage Map, the Gantt **bars are the selected stages** (one bar per `selectedStages` entry), day length split from `step_count` (or the effort range across the stage's steps), and cross-stage `(after stage <n>)` / `(after <TASK-ID>)` edges follow `depends_on`. Already-done stages never get a bar. A task tagged `[NEEDS-PLANNING]` contributes no bars.
137
+ When the source is a Stage Map, the Gantt bars are selected stages. Every row identifier is `<TASK-ID>/S<stage-number>` and carries `days=<lower>~<upper>` matching the Work Breakdown row. Split the task effort range proportionally by `step_count`; round every stage except the last to 0.5 day and let the last stage absorb the remainder. Cross-stage dependency annotations follow `depends_on`. Already-done and non-selected stages never get a bar. A task tagged `[NEEDS-PLANNING]` contributes no bars.
138
+
139
+ An unrepresentable half-day allocation is a validation error. Do not switch to a fallback allocation algorithm or publish that draft.
130
140
 
131
141
  When you do skip, insert in the section's position exactly: `> _Gantt Chart skipped: <concrete reason referencing the actual data>._`
132
142
 
@@ -134,20 +144,51 @@ When you do skip, insert in the section's position exactly: `> _Gantt Chart skip
134
144
 
135
145
  ### Step 5.5: Draft, verify, gate
136
146
 
137
- 1. **Draft to staging (never the final path yet).** Write the assembled schedule to `.okstra/tasks/<task-group-segment>/schedule/.draft/<YYYY-MM-DD_HH-MM-SS>.md`. Auto-create the parent.
138
- 2. **Dispatch an independent verifier subagent** with the `Agent` tool. It receives, per task: the `selectedStages` list (stage numbers + titles) and `depends_on` edges, plus the draft file path. It must NOT see the lead's reasoning — only these inputs and the draft. It returns a structured verdict:
139
- - `coverage`: every `selectedStages` entry appears as a forward Work Breakdown / Gantt unit in the draft (nothing missing).
140
- - `noExcess`: no non-selected stage and no done stage appears as forward work.
141
- - `depOrder`: stage ordering and `(after …)` edges honor `depends_on`.
142
- - `pass = coverage && noExcess && depOrder`; on fail it lists the offending stages as `missing[]` / `extra[]` / `orderViolations[]`.
143
- 3. **Gate:**
144
- - `pass` → promote the draft to the final path (Step 6) and continue.
145
- - `fail` → hand the verifier's `missing`/`extra`/`orderViolations` back to the lead, revise the draft in place, and re-dispatch the verifier. **Max 2 revise cycles.**
146
- - Still failing after the 2nd cycle → do **NOT** write the final file. Remove the staging draft, and report the residual `missing`/`extra`/`orderViolations` to the user in Korean. Stop.
147
+ 1. **Write paired staging artifacts.** Write the schedule to `.okstra/tasks/<task-group-segment>/schedule/.draft/<YYYY-MM-DD_HH-MM-SS>.md` and its temporary selection contract to the sibling `.draft/<YYYY-MM-DD_HH-MM-SS>.selection.json`. Use the same timestamp and auto-create the parent.
148
+ 2. **Write schema version 1 selection JSON.** Include every in-scope task, including `missing` and ready tasks with no unfinished stage. Map CLI stage fields to the validator's camel-case boundary:
149
+
150
+ ```json
151
+ {
152
+ "schemaVersion": 1,
153
+ "tasks": [{
154
+ "taskKey": "<project>:<group>:<task>",
155
+ "taskId": "<TASK-ID>",
156
+ "state": "ready",
157
+ "sourcePlanPath": "<resolved planning report>",
158
+ "selectedStages": [2, 3],
159
+ "doneStages": [1],
160
+ "stages": [
161
+ {
162
+ "stageNumber": 1,
163
+ "title": "Prepare port",
164
+ "dependsOn": [],
165
+ "stepCount": 2
166
+ },
167
+ {
168
+ "stageNumber": 2,
169
+ "title": "Build adapter",
170
+ "dependsOn": [1],
171
+ "stepCount": 3
172
+ },
173
+ {
174
+ "stageNumber": 3,
175
+ "title": "Wire consumer",
176
+ "dependsOn": [2],
177
+ "stepCount": 2
178
+ }
179
+ ]
180
+ }]
181
+ }
182
+ ```
183
+
184
+ 3. **Run the deterministic gate first.** Execute `python3 ~/.okstra/lib/validators/validate-schedule.py <draft> --selection-json <selection>`. Do not dispatch the narrative verifier when this exits non-zero.
185
+ 4. **Run the independent LLM verifier second.** Only after the deterministic gate passes, dispatch an independent verifier subagent with the draft and selection JSON, but without the lead's reasoning. It checks client-facing coherence, phase rationale, executable ordering, engineering-only scope, and whether the prose contradicts the structured schedule. It returns `pass` plus concrete findings.
186
+ 5. **Revise from the first gate after every change.** If either gate requests a change, the lead revises the same draft in place, then starts again at the deterministic `--selection-json` gate. Allow **Max 2 revise cycles** total across both gates.
187
+ 6. **Gate publication.** Only the same draft that passes both gates may be promoted in Step 6. If it still fails after the second revision, do not write the final file; remove the paired staging artifacts and report the residual findings in Korean.
147
188
 
148
189
  ### Step 6: Write the schedule file
149
190
 
150
- Reached only after Step 5.5 returns `pass`. **Promote** the verified staging draft to the final path (do not re-render from scratch):
191
+ Reached only after both Step 5.5 gates return `pass`. Promote the verified same draft to the final path; do not re-render or regenerate it:
151
192
 
152
193
  ```
153
194
  .okstra/tasks/<task-group-segment>/schedule/<task-group-segment>-plan-<YYYY-MM-DD_HH-MM-SS>.md
@@ -157,10 +198,9 @@ Reached only after Step 5.5 returns `pass`. **Promote** the verified staging dra
157
198
 
158
199
  ### Step 7: Self-validate before reporting completion
159
200
 
160
- 1. Re-read the file you just wrote.
161
- 2. Run `python3 ~/.okstra/lib/validators/validate-schedule.py <output-path>`.
162
- 3. If it exits non-zero, fix the file and re-validate. Do not print the Step 8 message until it passes.
163
- 4. If the validator is not installed, fall back to a manual check against the template: every template `##` heading present with exact spelling, title ends with `— Work Schedule`, `> Generated:` block present.
201
+ 1. Re-read the file you just promoted and confirm it is byte-identical to the gate-passing draft content.
202
+ 2. Run `python3 ~/.okstra/lib/validators/validate-schedule.py <output-path>` as a final format check; use the repository validator only when the installed validator is absent.
203
+ 3. Delete the temporary `.selection.json` only after that final check passes. Do not print the Step 8 message while the selection contract still exists or validation is failing.
164
204
 
165
205
  ### Step 8: Completion message (Korean)
166
206
 
@@ -181,7 +221,7 @@ Reached only after Step 5.5 returns `pass`. **Promote** the verified staging dra
181
221
  | No implementation-planning report | List with a `[NEEDS-PLANNING]` banner only; no stage picker / Gantt / day total |
182
222
  | 0 remaining stages (all done, workStatus unmarked) | `_Complete — no remaining stage_`, no forward computation |
183
223
  | ≤2 remaining stages | Emit only as many cumulative bundles as arise (1–2); a single chain skips the picker and proceeds with all |
184
- | verifier fails twice | Final file not written; report residual missing/extra/order to the user |
224
+ | either validation gate still fails after two revisions | Final file not written; report residual findings to the user |
185
225
  | `task-group` matches no tasks | "That task-group could not be found." and stop |
186
226
  | Catalog and manifest disagree on `workStatus` | Manifest wins (catalog may be stale) |
187
227
  | task-group casing / punctuation variants | Normalise both sides (lowercase + strip non-`[a-z0-9]`), compare against `taskGroupPathSegment` only; use the manifest's segment verbatim for path output |
@@ -190,5 +230,5 @@ Reached only after Step 5.5 returns `pass`. **Promote** the verified staging dra
190
230
 
191
231
  - All user-facing messages in Korean; schedule body prose Korean, identifiers/headings/field labels English (template literals).
192
232
  - Use project-relative paths in completion messages.
193
- - Use `taskType` (not `workStatus`) for the At a Glance status column — it carries lifecycle phase information.
233
+ - Use `<taskType> / <currentPhase>` (not `workStatus`) for each detail `Status`; `workStatus` only filters candidates.
194
234
  - Per-task section Work Breakdown rows are **selected stages**, not free-form items; done stages are summarized, never scheduled forward.