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.
- package/README.md +1 -1
- package/bin/okstra +7 -0
- package/docs/cli.md +5 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
- package/docs/project-structure-overview.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +11 -6
- package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
- package/runtime/prompts/lead/convergence.md +3 -2
- package/runtime/prompts/lead/plan-body-verification.md +30 -1
- package/runtime/python/okstra_ctl/container.py +9 -10
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
- package/runtime/python/okstra_ctl/handoff.py +4 -8
- package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
- package/runtime/python/okstra_ctl/model_discovery.py +22 -1
- package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
- package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
- package/runtime/python/okstra_ctl/run.py +8 -54
- package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
- package/runtime/python/okstra_ctl/stage_map.py +288 -0
- package/runtime/python/okstra_ctl/wizard.py +24 -35
- package/runtime/python/okstra_project/state.py +19 -5
- package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
- package/runtime/templates/reports/schedule.template.md +9 -9
- package/runtime/validators/detect_self_mock.py +27 -2
- package/runtime/validators/validate-implementation-plan-stages.py +24 -63
- package/runtime/validators/validate-run.py +110 -0
- package/runtime/validators/validate-schedule.py +78 -10
- 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
|
-
"""
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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}): {
|
|
1050
|
-
|
|
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
|
|
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 =
|
|
2699
|
-
except
|
|
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
|
-
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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`
|
|
123
|
+
| `feature` / `improvement` | Phase 2 |
|
|
118
124
|
| `refactor` / `ops` | Phase 3 |
|
|
119
|
-
|
|
|
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).
|
|
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
|
|
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. **
|
|
138
|
-
2. **
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
161
|
-
2. Run `python3 ~/.okstra/lib/validators/validate-schedule.py <output-path
|
|
162
|
-
3.
|
|
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
|
-
|
|
|
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
|
|
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.
|