okstra 0.69.0 → 0.71.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.
@@ -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:
@@ -1822,6 +1945,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1822
1945
  final_report_template = assets.final_report_template
1823
1946
  ctx_stage_map = _validate_prepare_inputs(project_root, inp)
1824
1947
 
1948
+ # release-handoff: 검증 보고서 인용 input 문서를 생성해 brief 자리에 채운다.
1949
+ # 이후의 모든 brief 소비 경로(material/instruction-set 복사)는 그대로 동작한다.
1950
+ handoff_tokens = {"HANDOFF_MODE": "", "HANDOFF_STAGES": ""}
1951
+ if inp.task_type == "release-handoff":
1952
+ handoff_tokens = _materialize_release_handoff_input(
1953
+ workspace_root, project_root, inp)
1954
+
1825
1955
  verify_installation(workspace_root)
1826
1956
  _register_and_check_project(project_root, inp)
1827
1957
 
@@ -1868,7 +1998,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1868
1998
  ):
1869
1999
  if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
1870
2000
  worktree = _single_stage_final_verification_worktree(inp)
2001
+ # Single-stage final-verification namespaces its run path under
2002
+ # runs/final-verification/stage-<N> (same isolation as
2003
+ # implementation) so concurrent per-stage verifications never
2004
+ # share state/reports/worker-results.
2005
+ fv_stage_arg = int(inp.stage)
1871
2006
  else:
2007
+ fv_stage_arg = None
1872
2008
  try:
1873
2009
  worktree = provision_task_worktree(
1874
2010
  task_type=inp.task_type,
@@ -1890,8 +2026,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1890
2026
  # lands in runs/implementation/stage-<N>. The registry stage-key is
1891
2027
  # reserved exactly once here (inside provision_stage_worktree), and
1892
2028
  # the surrounding mutex makes the registry read in stage selection
1893
- # and that reserve atomic. Non-implementation task-types skip this
1894
- # entirely stage_arg stays None identical paths.
2029
+ # and that reserve atomic. Other task-types skip this selection;
2030
+ # single-stage final-verification threads its explicit stage via
2031
+ # fv_stage_arg, everything else keeps stage_arg=None (flat paths).
1895
2032
  if inp.task_type == "implementation":
1896
2033
  impl_stage_selection = _select_and_provision_implementation_stage(
1897
2034
  inp, ctx_stage_map, task_group_segment, task_id_segment,
@@ -1904,7 +2041,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1904
2041
  _clear_stale_stage_waiver(inp, project_root, impl_stage_selection.stage)
1905
2042
  else:
1906
2043
  impl_stage_selection = None
1907
- stage_arg = None
2044
+ stage_arg = fv_stage_arg
1908
2045
 
1909
2046
  ctx = compute_and_write_run_context(
1910
2047
  workspace_root=workspace_root, project_root=project_root,
@@ -1982,6 +2119,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1982
2119
  "RECOMMENDED_ANALYSERS": selected_reviewers,
1983
2120
  "PR_TEMPLATE_PATH": pr_template_path_str,
1984
2121
  "PR_TEMPLATE_SOURCE": pr_template_source,
2122
+ **handoff_tokens,
1985
2123
  "CLAUDE_SESSION_ID": claude_session_id,
1986
2124
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
1987
2125
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
@@ -2106,7 +2244,14 @@ def main(argv: list[str]) -> int:
2106
2244
  p.add_argument("--task-group", required=True)
2107
2245
  p.add_argument("--task-id", required=True)
2108
2246
  p.add_argument("--task-type", required=True)
2109
- p.add_argument("--task-brief", required=True, dest="task_brief")
2247
+ p.add_argument(
2248
+ "--task-brief", default="", dest="task_brief",
2249
+ help=(
2250
+ "Required for every task-type except release-handoff (whose input "
2251
+ "document is generated by prepare from the cited "
2252
+ "final-verification reports)."
2253
+ ),
2254
+ )
2110
2255
  p.add_argument("--directive", default="")
2111
2256
  p.add_argument("--workers", default="", dest="workers_override")
2112
2257
  p.add_argument("--lead-model", default="")
@@ -2128,6 +2273,15 @@ def main(argv: list[str]) -> int:
2128
2273
  "consumers.jsonl status:done. Numeric '<N>' = force that stage."
2129
2274
  ),
2130
2275
  )
2276
+ p.add_argument(
2277
+ "--stages", default="", dest="stages",
2278
+ help=(
2279
+ "release-handoff only. Comma-separated stage numbers to bundle "
2280
+ "into one PR (stage-group mode); empty = whole-task mode. "
2281
+ "Distinct from --stage (implementation/final-verification "
2282
+ "Stage Map selection)."
2283
+ ),
2284
+ )
2131
2285
  p.add_argument(
2132
2286
  "--approve",
2133
2287
  action="store_true",
@@ -2199,10 +2353,22 @@ def main(argv: list[str]) -> int:
2199
2353
  args = p.parse_args(argv)
2200
2354
 
2201
2355
  project_root = Path(args.project_root).expanduser().resolve()
2202
- brief_abs = resolve_user_file(args.task_brief, project_root)
2203
- if brief_abs is None:
2204
- print(f"task brief not found: {args.task_brief}", file=__import__("sys").stderr)
2205
- return 1
2356
+ if args.task_type == "release-handoff":
2357
+ if args.task_brief:
2358
+ print(
2359
+ "--task-brief is not accepted for --task-type release-handoff "
2360
+ "— the input document is generated from the cited "
2361
+ "final-verification reports",
2362
+ file=__import__("sys").stderr,
2363
+ )
2364
+ return 1
2365
+ brief_abs = Path("") # prepare 가 input 문서를 생성해 채운다
2366
+ else:
2367
+ brief_abs = resolve_user_file(args.task_brief, project_root)
2368
+ if brief_abs is None:
2369
+ print(f"task brief not found: {args.task_brief}",
2370
+ file=__import__("sys").stderr)
2371
+ return 1
2206
2372
  clarification_abs = ""
2207
2373
  if args.clarification_response_path:
2208
2374
  cr = resolve_user_file(args.clarification_response_path, project_root)
@@ -2237,6 +2403,7 @@ def main(argv: list[str]) -> int:
2237
2403
  approved_plan_path=args.approved_plan_path,
2238
2404
  qa_waiver=args.qa_waiver,
2239
2405
  stage=args.stage,
2406
+ stages=args.stages,
2240
2407
  clarification_response_path=clarification_abs,
2241
2408
  pr_template_path=args.pr_template_path,
2242
2409
  render_only=args.render_only,