okstra 0.70.0 → 0.71.1

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.
@@ -291,6 +291,10 @@ class PrepareInputs:
291
291
  # prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
292
292
  qa_waiver: str = ""
293
293
  stage: str = "auto"
294
+ # release-handoff 전용: PR 로 내보낼 stage 묶음 (csv, 예: "2,3"). 빈 값 =
295
+ # whole-task 모드. `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와는
296
+ # 별개 채널이다.
297
+ stages: str = ""
294
298
  clarification_response_path: str = "" # absolute or empty
295
299
  # release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
296
300
  # project.json → global config → 스킬 디폴트 순으로 해석된다.
@@ -1064,7 +1068,17 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1064
1068
  를 검증하고, implementation 일 때 stage map 을 파싱해 돌려준다 (그 외엔 빈 리스트)."""
1065
1069
  if not project_root.is_dir():
1066
1070
  raise PrepareError(f"project root not found: {project_root}")
1067
- if not inp.brief_path.is_file():
1071
+ if inp.stages and inp.task_type != "release-handoff":
1072
+ raise PrepareError(
1073
+ "--stages is only meaningful with --task-type release-handoff; "
1074
+ f"got {inp.task_type}"
1075
+ )
1076
+ if inp.task_type == "release-handoff":
1077
+ # brief 는 entry phase 의 입력물 — release-handoff 는 검증 보고서를
1078
+ # 인용하는 input 문서를 prepare 가 직접 생성해 brief 자리에 채운다
1079
+ # (_materialize_release_handoff_input, 이 검증 직후 실행).
1080
+ pass
1081
+ elif not inp.brief_path.is_file():
1068
1082
  raise PrepareError(f"task brief not found: {inp.brief_path}")
1069
1083
  ctx_stage_map: list = []
1070
1084
  # implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
@@ -1121,7 +1135,9 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1121
1135
  "--implementation-option is only meaningful with --task-type "
1122
1136
  "implementation and --approved-plan <path>"
1123
1137
  )
1124
- if inp.stage != "auto":
1138
+ # wizard 는 모든 flag 를 빈 값 포함 항상 전달하므로(`--stage ""`),
1139
+ # 빈 문자열은 "stage 미지정" 으로 받아들인다.
1140
+ if inp.stage not in ("", "auto"):
1125
1141
  raise PrepareError(
1126
1142
  "--stage is only meaningful with --task-type implementation or "
1127
1143
  f"final-verification; got {inp.task_type}"
@@ -1133,6 +1149,113 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1133
1149
  return ctx_stage_map
1134
1150
 
1135
1151
 
1152
+ def _collect_handoff_source_report_rows(
1153
+ rows: list, nums: list,
1154
+ ) -> list:
1155
+ """선택 stage 들의 마지막 verified 행에서 인용 표 행을 만든다 (last-wins)."""
1156
+ last_verified: dict = {}
1157
+ for r in rows:
1158
+ if r.get("status") == "verified" and isinstance(r.get("stage"), int):
1159
+ last_verified[r["stage"]] = r
1160
+ return [
1161
+ f"| {n} | {last_verified[n].get('report_path', '')} "
1162
+ f"| `{last_verified[n].get('verdict', '')}` |"
1163
+ for n in nums
1164
+ ]
1165
+
1166
+
1167
+ def _materialize_release_handoff_input(
1168
+ workspace_root: Path, project_root: Path, inp: "PrepareInputs",
1169
+ ) -> dict:
1170
+ """release-handoff 의 입력 문서를 검증 보고서 인용으로 자동 생성한다.
1171
+
1172
+ brief 는 entry phase 의 입력물이라 release-handoff 에는 없다 — 이 run 의
1173
+ `--task-brief` 자리는 여기서 생성된 input 문서가 채운다. stage 자격은
1174
+ okstra_ctl.handoff 의 SSOT 판정을 prepare 에서도 그대로 강제한다.
1175
+ 반환: ctx 에 올릴 {"HANDOFF_MODE": ..., "HANDOFF_STAGES": ...}."""
1176
+ from .consumers import read_consumers
1177
+ from .handoff import (HandoffError, _require_eligible,
1178
+ latest_whole_task_fv_accepted)
1179
+ from .paths import task_dir
1180
+ from .render import render_template_with_ctx
1181
+ from .run_context import _now_task_date
1182
+
1183
+ # Path("") 는 "." 로 정규화된다 — 빈 brief 센티널은 둘 다로 들어올 수 있다.
1184
+ if str(inp.brief_path).strip() not in ("", "."):
1185
+ raise PrepareError(
1186
+ "--task-brief is not accepted for --task-type release-handoff — "
1187
+ "the input document is generated from the cited final-verification "
1188
+ "reports (pick stages via --stages, or leave it empty for "
1189
+ "whole-task)"
1190
+ )
1191
+ if not inp.approved_plan_path:
1192
+ raise PrepareError(
1193
+ "release-handoff requires --approved-plan "
1194
+ "<implementation-planning final-report> — the Stage Map and the "
1195
+ "consumers ledger drive stage eligibility"
1196
+ )
1197
+ plan = Path(inp.approved_plan_path)
1198
+ if not plan.is_file():
1199
+ raise PrepareError(f"approved plan not found: {plan}")
1200
+ stage_map = _parse_stage_map_into_ctx(str(plan))
1201
+ if not stage_map:
1202
+ raise PrepareError(f"approved plan has no parsable Stage Map: {plan}")
1203
+ rows = read_consumers(plan.resolve().parents[1])
1204
+
1205
+ if inp.stages:
1206
+ try:
1207
+ nums = sorted({int(x) for x in inp.stages.split(",") if x.strip()})
1208
+ except ValueError:
1209
+ raise PrepareError(
1210
+ f"--stages must be a comma-separated int list, got {inp.stages!r}"
1211
+ )
1212
+ if not nums:
1213
+ raise PrepareError("--stages must select at least one stage")
1214
+ try:
1215
+ _require_eligible(stage_map, rows, nums)
1216
+ except HandoffError as exc:
1217
+ raise PrepareError(str(exc)) from exc
1218
+ mode = "stage-group"
1219
+ stages_csv = ",".join(str(n) for n in nums)
1220
+ report_rows = _collect_handoff_source_report_rows(rows, nums)
1221
+ else:
1222
+ report = latest_whole_task_fv_accepted(
1223
+ project_root, inp.project_id, inp.task_group, inp.task_id)
1224
+ if not report:
1225
+ raise PrepareError(
1226
+ "whole-task release-handoff requires an accepted whole-task "
1227
+ "final-verification report — run final-verification first, "
1228
+ "or pass --stages <csv> for a stage-group PR"
1229
+ )
1230
+ mode = "whole-task"
1231
+ stages_csv = ""
1232
+ report_relative = relative_to_project_root(Path(report), project_root)
1233
+ report_rows = [f"| (all) | {report_relative or report} | `accepted` |"]
1234
+
1235
+ template = (workspace_root / "templates" / "reports"
1236
+ / "release-handoff-input.template.md")
1237
+ if not template.is_file():
1238
+ raise PrepareError(
1239
+ f"release-handoff input template missing: {template}.{_INSTALL_HINT}"
1240
+ )
1241
+ out = (task_dir(project_root, inp.task_group, inp.task_id)
1242
+ / "release-handoff-input.md")
1243
+ out.parent.mkdir(parents=True, exist_ok=True)
1244
+ render_template_with_ctx(str(template), str(out), {
1245
+ "TASK_KEY": f"{inp.project_id}:{inp.task_group}:{inp.task_id}",
1246
+ "TASK_ID": inp.task_id,
1247
+ "TASK_GROUP": inp.task_group,
1248
+ "PROJECT_ID": inp.project_id,
1249
+ "TASK_TYPE": inp.task_type,
1250
+ "TASK_DATE": _now_task_date(),
1251
+ "HANDOFF_MODE": mode,
1252
+ "HANDOFF_STAGES": stages_csv or "(all)",
1253
+ "HANDOFF_SOURCE_REPORTS": "\n".join(report_rows),
1254
+ })
1255
+ inp.brief_path = out
1256
+ return {"HANDOFF_MODE": mode, "HANDOFF_STAGES": stages_csv}
1257
+
1258
+
1136
1259
  def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1137
1260
  """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다."""
1138
1261
  if not inp.qa_waiver:
@@ -1393,7 +1516,10 @@ def _select_and_provision_implementation_stage(
1393
1516
  started_stages=started_stages, reserved_stages=reserved_stages,
1394
1517
  )
1395
1518
  selected = batch[0]
1396
- concurrent_stages = sorted(reserved_stages - {selected})
1519
+ # done stage 동시 run 이 아니다 — done 기록 시 점유는 해제되지만
1520
+ # (consumers._release_stage_reservation), crash·구버전 기록의 잔존
1521
+ # 예약이 남을 수 있어 읽기에서도 차감한다.
1522
+ concurrent_stages = sorted(reserved_stages - done_stages - {selected})
1397
1523
 
1398
1524
  # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1399
1525
  # stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
@@ -1822,6 +1948,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1822
1948
  final_report_template = assets.final_report_template
1823
1949
  ctx_stage_map = _validate_prepare_inputs(project_root, inp)
1824
1950
 
1951
+ # release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
1952
+ # 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
1953
+ handoff_tokens = {"HANDOFF_MODE": "", "HANDOFF_STAGES": ""}
1954
+ if inp.task_type == "release-handoff":
1955
+ handoff_tokens = _materialize_release_handoff_input(
1956
+ workspace_root, project_root, inp)
1957
+
1825
1958
  verify_installation(workspace_root)
1826
1959
  _register_and_check_project(project_root, inp)
1827
1960
 
@@ -1989,6 +2122,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1989
2122
  "RECOMMENDED_ANALYSERS": selected_reviewers,
1990
2123
  "PR_TEMPLATE_PATH": pr_template_path_str,
1991
2124
  "PR_TEMPLATE_SOURCE": pr_template_source,
2125
+ **handoff_tokens,
1992
2126
  "CLAUDE_SESSION_ID": claude_session_id,
1993
2127
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
1994
2128
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
@@ -2113,7 +2247,14 @@ def main(argv: list[str]) -> int:
2113
2247
  p.add_argument("--task-group", required=True)
2114
2248
  p.add_argument("--task-id", required=True)
2115
2249
  p.add_argument("--task-type", required=True)
2116
- p.add_argument("--task-brief", required=True, dest="task_brief")
2250
+ p.add_argument(
2251
+ "--task-brief", default="", dest="task_brief",
2252
+ help=(
2253
+ "Required for every task-type except release-handoff (whose input "
2254
+ "document is generated by prepare from the cited "
2255
+ "final-verification reports)."
2256
+ ),
2257
+ )
2117
2258
  p.add_argument("--directive", default="")
2118
2259
  p.add_argument("--workers", default="", dest="workers_override")
2119
2260
  p.add_argument("--lead-model", default="")
@@ -2135,6 +2276,15 @@ def main(argv: list[str]) -> int:
2135
2276
  "consumers.jsonl status:done. Numeric '<N>' = force that stage."
2136
2277
  ),
2137
2278
  )
2279
+ p.add_argument(
2280
+ "--stages", default="", dest="stages",
2281
+ help=(
2282
+ "release-handoff only. Comma-separated stage numbers to bundle "
2283
+ "into one PR (stage-group mode); empty = whole-task mode. "
2284
+ "Distinct from --stage (implementation/final-verification "
2285
+ "Stage Map selection)."
2286
+ ),
2287
+ )
2138
2288
  p.add_argument(
2139
2289
  "--approve",
2140
2290
  action="store_true",
@@ -2206,10 +2356,22 @@ def main(argv: list[str]) -> int:
2206
2356
  args = p.parse_args(argv)
2207
2357
 
2208
2358
  project_root = Path(args.project_root).expanduser().resolve()
2209
- brief_abs = resolve_user_file(args.task_brief, project_root)
2210
- if brief_abs is None:
2211
- print(f"task brief not found: {args.task_brief}", file=__import__("sys").stderr)
2212
- return 1
2359
+ if args.task_type == "release-handoff":
2360
+ if args.task_brief:
2361
+ print(
2362
+ "--task-brief is not accepted for --task-type release-handoff "
2363
+ "— the input document is generated from the cited "
2364
+ "final-verification reports",
2365
+ file=__import__("sys").stderr,
2366
+ )
2367
+ return 1
2368
+ brief_abs = Path("") # prepare 가 input 문서를 생성해 채운다
2369
+ else:
2370
+ brief_abs = resolve_user_file(args.task_brief, project_root)
2371
+ if brief_abs is None:
2372
+ print(f"task brief not found: {args.task_brief}",
2373
+ file=__import__("sys").stderr)
2374
+ return 1
2213
2375
  clarification_abs = ""
2214
2376
  if args.clarification_response_path:
2215
2377
  cr = resolve_user_file(args.clarification_response_path, project_root)
@@ -2244,6 +2406,7 @@ def main(argv: list[str]) -> int:
2244
2406
  approved_plan_path=args.approved_plan_path,
2245
2407
  qa_waiver=args.qa_waiver,
2246
2408
  stage=args.stage,
2409
+ stages=args.stages,
2247
2410
  clarification_response_path=clarification_abs,
2248
2411
  pr_template_path=args.pr_template_path,
2249
2412
  render_only=args.render_only,