okstra 0.196.0 → 0.197.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 (34) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +14 -1
  4. package/docs/project-structure-overview.md +1 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
  8. package/runtime/prompts/lead/plan-body-verification.md +21 -3
  9. package/runtime/prompts/profiles/implementation-planning.md +3 -2
  10. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  11. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
  13. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
  15. package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
  16. package/runtime/python/okstra_ctl/conformance.py +10 -0
  17. package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
  18. package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
  19. package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
  20. package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
  21. package/runtime/python/okstra_ctl/plan_items.py +14 -0
  22. package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
  23. package/runtime/python/okstra_ctl/run.py +54 -0
  24. package/runtime/python/okstra_ctl/session_transcript.py +4 -4
  25. package/runtime/python/okstra_ctl/stage_close.py +244 -0
  26. package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
  27. package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
  28. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
  29. package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
  31. package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
  32. package/runtime/skills/okstra-run/SKILL.md +63 -4
  33. package/runtime/validators/validate-implementation-plan-stages.py +109 -23
  34. package/runtime/validators/validate-run.py +30 -6
@@ -38,7 +38,6 @@ _MAX_SUMMARY = 120
38
38
  # 결과 하나가 pane 을 다 차지하면 직전 호출이 위로 밀려 나가므로 앞부분만
39
39
  # 보여주고, 나머지는 로그가 갖는다.
40
40
  _LIVE_BODY_ROWS = 4
41
- _BODY_INDENT = " "
42
41
 
43
42
 
44
43
  @dataclass(frozen=True)
@@ -197,8 +196,8 @@ def _rows(event: StreamEvent, *, limit: int | None, body_rows: int | None) -> li
197
196
  tail = (
198
197
  f"the tool reported: {report}" if report else "the tool reported nothing"
199
198
  )
200
- return [_truncate(f" ← {_outcome(event.failed)} — no body returned; {tail}", limit)]
201
- head = f" ← {_outcome(event.failed)} ({event.size_bytes} bytes)"
199
+ return [_truncate(f"← {_outcome(event.failed)} — no body returned; {tail}", limit)]
200
+ head = f"← {_outcome(event.failed)} ({event.size_bytes} bytes)"
202
201
  return [head, *_result_body(event.body, limit=limit, keep=body_rows)]
203
202
  if isinstance(event, Denial):
204
203
  return [_truncate(f"!! PERMISSION DENIED — {event.tool}: {event.reason}", limit)]
@@ -217,10 +216,10 @@ def _result_body(body: str, *, limit: int | None, keep: int | None) -> list[str]
217
216
  rows = _body_rows(body)
218
217
  if keep is None:
219
218
  return rows
220
- shown = [_truncate(_BODY_INDENT + row, limit) for row in rows[:keep]]
219
+ shown = [_truncate(row, limit) for row in rows[:keep]]
221
220
  dropped = len(rows) - keep
222
221
  if dropped > 0:
223
- shown.append(f"{_BODY_INDENT}… +{dropped} more line(s) — full body in the log")
222
+ shown.append(f"… +{dropped} more line(s) — full body in the log")
224
223
  return shown
225
224
 
226
225
 
@@ -202,7 +202,68 @@ class _Validator:
202
202
  elif schema.get("additionalProperties") is False:
203
203
  # Don't fire for keys we know are part of the conditional
204
204
  # branches (we still validate values when they appear).
205
- self._err(path, f"additional property '{name}' is not allowed")
205
+ self._err(
206
+ path,
207
+ f"additional property '{name}' is not allowed"
208
+ + self._misplaced_property_hint(name, path),
209
+ )
210
+
211
+ def _misplaced_property_hint(
212
+ self, name: str, path: tuple[str | int, ...],
213
+ ) -> str:
214
+ """이 키가 스키마의 다른 자리에서 정의된 필드면 "옮겨라" 를 덧붙인다.
215
+
216
+ `additional property 'X' is not allowed` 만 보면 "스키마에 그런 칸이
217
+ 없다" 로 읽힌다. 실제로는 X 가 다른 객체의 필드이고 작성자가 자리를
218
+ 틀린 경우가 있고, 그때 지우면 다음 라운드에서 `required property 'X'
219
+ is missing` 이 나온다 — 라운드 하나를 왕복에 쓴다(2026-09-09 실측,
220
+ dev-10642 implementation-planning: 최상위 필수 `summary` 를
221
+ `rationale` 안에 써서 지웠다가 되살렸다).
222
+ """
223
+ found = self._required_property_owners(name) - {_format_path(path)}
224
+ if not found:
225
+ return ""
226
+ # 최상위를 앞에 둔다. 잘못 놓인 키는 대개 최상위 필드가 한 단계 안으로
227
+ # 들어간 것이고, 나머지 소유자는 같은 이름을 쓰는 다른 행 타입이다.
228
+ owners = (
229
+ ["<root>"] if "<root>" in found else []
230
+ ) + sorted(found - {"<root>"})
231
+ return (
232
+ f" — '{name}' is a REQUIRED property of {', '.join(owners[:3])}, not "
233
+ f"of {_format_path(path)}; move it there rather than removing it"
234
+ )
235
+
236
+ def _required_property_owners(self, name: str) -> set[str]:
237
+ """`name` 을 **필수**로 요구하는 객체들의 이름.
238
+
239
+ 정의만 가진 자리까지 세면(예: `summary` 는 8곳에서 정의된다) 힌트가
240
+ 이름 목록이 돼 아무것도 가리키지 못한다. 지우면 다음 라운드에서
241
+ `required property ... is missing` 이 나오는 자리, 즉 필수인 곳만 센다.
242
+ """
243
+ owners: set[str] = set()
244
+
245
+ def walk(node: Any, label: str) -> None:
246
+ if isinstance(node, list):
247
+ for item in node:
248
+ walk(item, label)
249
+ return
250
+ if not isinstance(node, dict):
251
+ return
252
+ required = node.get("required")
253
+ if isinstance(required, list) and name in required:
254
+ owners.add(label)
255
+ for key, value in node.items():
256
+ if key == "properties" and isinstance(value, dict):
257
+ for child, sub in value.items():
258
+ walk(sub, f"{label}.{child}" if label else child)
259
+ elif key in ("definitions", "$defs") and isinstance(value, dict):
260
+ for child, sub in value.items():
261
+ walk(sub, child)
262
+ elif key in ("items", "allOf", "oneOf", "anyOf", "then", "else"):
263
+ walk(value, label)
264
+
265
+ walk(self.root, "<root>")
266
+ return owners
206
267
 
207
268
  def _validate_array(self, instance: list, schema: dict, path: tuple[str | int, ...]) -> None:
208
269
  min_items = schema.get("minItems")
@@ -790,6 +790,20 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
790
790
  )
791
791
 
792
792
 
793
+ # 계획 항목 큐의 렌더러 서명. 검증 계약(`worker_prompt_contract.
794
+ # validate_reverify_prompt`)이 `plan-verify-r<N>` 디스패치에 이 줄을 요구한다 —
795
+ # 번호 reverify 가 `okstra convergence reverify-prompt` 서명을 요구하는 것과
796
+ # 같은 이유다. 손으로 쓴 큐는 항목 id·직전 반대 의견·응답 형식이 임의로 빠지고,
797
+ # 그렇게 버려진 라운드가 실재한다(run 003 실측, `with_response_format` 주석).
798
+ RENDERED_BY_LINE = "**Rendered by:** okstra plan-items prompt"
799
+
800
+
801
+ def with_rendered_by(queue_markdown: str) -> str:
802
+ """렌더러 서명을 프롬프트 맨 앞에 붙인다. 디스패치는 이 출력을 그대로
803
+ `--instruction` 으로 받으므로 서명은 `## Task Instructions` 뒤에 놓인다."""
804
+ return f"{RENDERED_BY_LINE}\n\n{queue_markdown.lstrip()}"
805
+
806
+
793
807
  def with_response_format(queue_markdown: str) -> str:
794
808
  """큐 본문 뒤에 응답 형식 블록을 덧붙인다. run 003 실측: 리드가 손 조립
795
809
  중 이 블록을 빼먹어 검증자 2명의 판정 32건 전부가 형식 불일치로 버려졌다."""
@@ -43,6 +43,7 @@ from .plan_items import (
43
43
  reverify_prompt_text,
44
44
  tie_vote_item_ids,
45
45
  voting_analyser_keys,
46
+ with_rendered_by,
46
47
  with_response_format,
47
48
  )
48
49
  from .paths import RunRef
@@ -242,6 +243,14 @@ def _parser() -> argparse.ArgumentParser:
242
243
  metavar="<worker-id>=<path>",
243
244
  help="one worker's plan-verify Markdown result (repeatable)",
244
245
  )
246
+ apply_verdicts.add_argument(
247
+ "--items", type=Path,
248
+ metavar="<plan-items artifact>",
249
+ help="the plan-items artifact this round dispatched, when it dispatched "
250
+ "part of the queue (the `--tie-vote` artifact). Its dispatchQueue "
251
+ "is what each --result must answer; without it a result is checked "
252
+ "against the whole persisted queue",
253
+ )
245
254
  apply_verdicts.add_argument(
246
255
  "--run-manifest", type=Path,
247
256
  help="resolve the project a `fact` claim's probe runs against; without "
@@ -831,10 +840,10 @@ def _prompt(args: argparse.Namespace) -> str:
831
840
  rows.append(split)
832
841
  body = with_response_format("".join(rows))
833
842
  if envelope.get("dispatchKind") == "critic-tie":
834
- return critic_tie_prompt_text(body)
843
+ return with_rendered_by(critic_tie_prompt_text(body))
835
844
  if envelope.get("dispatchKind") == "reverify":
836
- return reverify_prompt_text(body)
837
- return body
845
+ return with_rendered_by(reverify_prompt_text(body))
846
+ return with_rendered_by(body)
838
847
 
839
848
 
840
849
  def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
@@ -1377,6 +1386,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1377
1386
  {item_id for item_id in queue if isinstance(item_id, str)}
1378
1387
  if isinstance(queue, list) else known
1379
1388
  )
1389
+ assigned = _narrow_assignment(args, assigned)
1380
1390
  rows = _incoming_verdict_rows(args, assigned)
1381
1391
  missing = sorted(item_id for item_id in rows if item_id not in known)
1382
1392
  if missing:
@@ -1872,6 +1882,38 @@ def _correction_prompt(args: argparse.Namespace) -> str:
1872
1882
  return correction_prompt_text(_prompt(args))
1873
1883
 
1874
1884
 
1885
+ def _narrow_assignment(
1886
+ args: argparse.Namespace, assigned: set[object],
1887
+ ) -> set[object]:
1888
+ """이 라운드가 실제로 배정한 항목들.
1889
+
1890
+ tie 라운드는 큐의 일부(7항목)만 critic 에게 보낸다. 그런데 `--result` 는
1891
+ 지금까지 state 에 남은 라운드 큐(44항목) 전체를 배정으로 보고 답 없는
1892
+ 37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
1893
+ tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
1894
+ `--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
1895
+ `--items` 로 그 라운드의 artifact 를 주면 그 `dispatchQueue` 가 배정이
1896
+ 된다. 미응답 검사 자체는 그대로다 — 좁힌 배정 안에서 여전히 전건을
1897
+ 요구하므로, 워커가 자기 몫을 조용히 빠뜨리는 것은 계속 잡힌다.
1898
+ """
1899
+ items_path = getattr(args, "items", None)
1900
+ if items_path is None:
1901
+ return assigned
1902
+ narrowed = {item_id for item_id in _assigned_item_ids(items_path)}
1903
+ if not narrowed:
1904
+ raise PlanItemContractError(
1905
+ f"items artifact dispatches nothing: {items_path}"
1906
+ )
1907
+ unknown = sorted(narrowed - {i for i in assigned if isinstance(i, str)})
1908
+ if unknown:
1909
+ raise PlanItemContractError(
1910
+ f"items artifact dispatches {unknown}, which this round's persisted "
1911
+ f"queue does not contain — pass the artifact this round dispatched, "
1912
+ f"not another round's"
1913
+ )
1914
+ return narrowed
1915
+
1916
+
1875
1917
  def _incoming_verdict_rows(
1876
1918
  args: argparse.Namespace, known: set[object],
1877
1919
  ) -> dict[str, list[dict[str, Any]]]:
@@ -551,6 +551,11 @@ class PrepareInputs:
551
551
  # implementation 전용: `--qa-waiver "<stageKey>:<reason>"` 사용자 확인형 우회.
552
552
  # prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
553
553
  qa_waiver: str = ""
554
+ # implementation-planning 전용: `--tdd-bypass "<stage>:<reason>"` 사용자
555
+ # 확인형 TDD 우회. prepare-time 에 `<task-root>/qa/tdd-bypass.json` 에
556
+ # 사유를 원문 그대로 남기고, S10e 는 그 원장이 있을 때만 계획서의
557
+ # `tddExemption: user-bypass` 를 인정한다.
558
+ tdd_bypass: str = ""
554
559
  stage: str = "auto"
555
560
  # release-handoff 전용: PR 로 내보낼 stage 묶음 (csv, 예: "2,3"). 빈 값 =
556
561
  # whole-task 모드. `--stage`(impl/fv 의 Stage Map 실행/검증 선택)와는
@@ -1915,6 +1920,40 @@ def validate_project_qa_commands(task_type: str, project_root: Path) -> None:
1915
1920
  raise PrepareError(_format_qa_errors(qa_errors))
1916
1921
 
1917
1922
 
1923
+ def _apply_tdd_bypass_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1924
+ """`--tdd-bypass` 가 있으면 task-level TDD 우회 원장에 사유를 남긴다.
1925
+
1926
+ 같은 task-key 의 다른 prepare 와 같은 파일을 read-modify-write 하므로
1927
+ `_apply_qa_waiver_if_requested` 와 동일한 per-task-key 락 안에서 쓴다.
1928
+ 원장이 없으면 만든다 — 사용자가 빈 파일을 손으로 만들 이유가 없다.
1929
+ """
1930
+ if not inp.tdd_bypass:
1931
+ return
1932
+ from .paths import task_dir
1933
+ from .tdd_bypass import (
1934
+ TddBypassError,
1935
+ bypass_file,
1936
+ parse_bypass_arg,
1937
+ record_bypass,
1938
+ )
1939
+ parsed = parse_bypass_arg(inp.tdd_bypass)
1940
+ if parsed is None:
1941
+ raise PrepareError(
1942
+ '--tdd-bypass must be "<stage>:<reason>" with a positive stage '
1943
+ f"number, got {inp.tdd_bypass!r}"
1944
+ )
1945
+ stage, reason = parsed
1946
+ path = bypass_file(task_dir(project_root, inp.task_group, inp.task_id))
1947
+ when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1948
+ with worktree_provision_mutex(
1949
+ okstra_home(), inp.project_id, slugify(inp.task_group), slugify(inp.task_id),
1950
+ ):
1951
+ try:
1952
+ record_bypass(path, stage, reason, at=when)
1953
+ except TddBypassError as exc:
1954
+ raise PrepareError(f"--tdd-bypass: {exc}") from exc
1955
+
1956
+
1918
1957
  def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1919
1958
  """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다.
1920
1959
 
@@ -2008,6 +2047,9 @@ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
2008
2047
  # waiver 는 stage 단위 Tier 3 면제라 implementation 진입에서만 적용한다.
2009
2048
  if inp.task_type == "implementation":
2010
2049
  _apply_qa_waiver_if_requested(inp, project_root)
2050
+ # TDD 우회는 계획서의 stage 를 면제하므로 계획 진입에서만 적용한다.
2051
+ if inp.task_type == "implementation-planning":
2052
+ _apply_tdd_bypass_if_requested(inp, project_root)
2011
2053
 
2012
2054
 
2013
2055
  def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str], str]:
@@ -5058,6 +5100,17 @@ def build_prepare_argument_parser():
5058
5100
  p.add_argument("--critic", default="")
5059
5101
  p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
5060
5102
  p.add_argument("--approved-plan", default="", dest="approved_plan_path")
5103
+ p.add_argument(
5104
+ "--tdd-bypass",
5105
+ default="",
5106
+ dest="tdd_bypass",
5107
+ help=(
5108
+ 'User-recorded TDD bypass for one plan stage: "<stage>:<reason>" '
5109
+ "(implementation-planning only). Records the reason verbatim into "
5110
+ "<task-root>/qa/tdd-bypass.json; S10e accepts a stage declaring "
5111
+ "tddExemption `user-bypass` only while that record exists."
5112
+ ),
5113
+ )
5061
5114
  p.add_argument(
5062
5115
  "--qa-waiver",
5063
5116
  default="",
@@ -5323,6 +5376,7 @@ def main(argv: list[str]) -> int:
5323
5376
  base_ref=args.base_ref,
5324
5377
  approved_plan_path=args.approved_plan_path,
5325
5378
  qa_waiver=args.qa_waiver,
5379
+ tdd_bypass=args.tdd_bypass,
5326
5380
  stage=args.stage,
5327
5381
  stages=args.stages,
5328
5382
  clarification_response_path=clarification_abs,
@@ -18,10 +18,10 @@ _RESET = "\x1b[0m"
18
18
  _MUTED = "\x1b[90m"
19
19
  _LIVE_COLORS = (
20
20
  ("→ ", "\x1b[36m"),
21
- (" ← ok", "\x1b[32m"),
22
- (" ← error", "\x1b[31m"),
21
+ ("← ok", "\x1b[32m"),
22
+ ("← error", "\x1b[31m"),
23
23
  ("!! PERMISSION DENIED", "\x1b[1;31m"),
24
- (" ← done", "\x1b[33m"),
24
+ ("← done", "\x1b[33m"),
25
25
  )
26
26
 
27
27
  # 파일 사본이 담는 워커 진행 줄의 상한. 진행은 워커 출력의 부피가 몰리는
@@ -99,7 +99,7 @@ class SessionTranscript:
99
99
  self._keep(self._row(speaker, line), capped=True)
100
100
 
101
101
  def _row(self, speaker: str, line: str) -> str:
102
- label = f"[{speaker}] "
102
+ label = f"[{speaker}]"
103
103
  return f"{self._clock()} {label}{line}".rstrip()
104
104
 
105
105
  def _show(self, row: str, line: str) -> None:
@@ -0,0 +1,244 @@
1
+ """`okstra stage-close` — 이미 랜딩한 stage 를 기록만 남겨 done 으로 닫는다.
2
+
3
+ stage 완료의 정본은 `runs/implementation-planning/consumers.jsonl` 의 `done`
4
+ 행이고, 그 행은 두 경로로만 생겼다: implementation run 이 정상 종료하거나,
5
+ `backfill_done_from_carry` 가 carry 사이드카에서 복원하거나. 두 경로 모두
6
+ 없는 상태가 실재한다 — 제품 변경은 커밋되고 conformance 결과도 PASS 인데
7
+ run 이 carry 를 쓰기 전에 끝난 경우다. 그러면 `stage-map` 은 `doneStages: []`
8
+ 을 보고하고, 다음 계획은 아무 일도 없었던 것처럼 그 stage 를 다시 쓰며, 거기서
9
+ 나오는 RED 기대는 전부 도달 불가다(실측 2026-09-10, fontsninja-v3-site
10
+ dev-10628-3: 마지막 행이 `started`, carry 디렉터리는 빈 폴더, stage 결과는
11
+ `overall: PASS`).
12
+
13
+ 이 명령은 그 복구 경로다. 커밋과 conformance 결과라는 두 증거를 확인한 뒤
14
+ done 행을 append 한다 — 증거 없이 닫는 수단은 아니다.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from okstra_project import (
24
+ ResolverError,
25
+ StateError,
26
+ resolve_project_root,
27
+ resolve_task_identity,
28
+ )
29
+
30
+ from .conformance import (
31
+ conformance_result_file,
32
+ decide_conformance_gate,
33
+ qa_result_from_dict,
34
+ )
35
+ from .consumers import append_consumer, last_lifecycle_status_by_stage, read_consumers
36
+ from .json_boundary import JsonBoundaryError, load_owned_object
37
+ from .paths import RunRef, task_conformance_manifest_file, task_qa_dir
38
+ from .stage_map import StageMapError, load_latest_plan_stage_map
39
+
40
+ # 이 status 를 이미 가진 stage 는 리드가 판정을 내린 것이다. 되쓰기는 그 판정을
41
+ # 덮는 일이므로 여기서 하지 않는다.
42
+ _SETTLED = ("done", "failed")
43
+
44
+
45
+ def _git_commit_exists(repo_root: Path, commit: str) -> bool:
46
+ probe = subprocess.run(
47
+ ["git", "-C", str(repo_root), "cat-file", "-e", f"{commit}^{{commit}}"],
48
+ capture_output=True,
49
+ )
50
+ return probe.returncode == 0
51
+
52
+
53
+ def _manifest_entry(task_root: Path, stage: int) -> dict | None:
54
+ """이 stage 의 conformance entry, 선언이 없으면 None.
55
+
56
+ stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라 디렉터리 segment 와 표기가
57
+ 다를 수 있으므로 `-stage-<N>` 접미사로 맞춘다 — `_clear_stale_stage_waiver`
58
+ 와 같은 규칙이다.
59
+ """
60
+ path = task_conformance_manifest_file(task_root)
61
+ if not path.is_file():
62
+ return None
63
+ try:
64
+ manifest = load_owned_object(path, artifact="conformance manifest")
65
+ except JsonBoundaryError as exc:
66
+ raise StateError(str(exc), stage="conformance") from exc
67
+ entries = manifest.get("entries")
68
+ if not isinstance(entries, list):
69
+ return None
70
+ suffix = f"-stage-{stage}"
71
+ return next(
72
+ (
73
+ entry for entry in entries
74
+ if isinstance(entry, dict)
75
+ and isinstance(entry.get("stageKey"), str)
76
+ and entry["stageKey"].endswith(suffix)
77
+ ),
78
+ None,
79
+ )
80
+
81
+
82
+ def _conformance_state(task_root: Path, stage: int) -> tuple[bool, str]:
83
+ """(닫아도 되는가, 사람이 읽는 근거).
84
+
85
+ 선언 자체가 없으면 게이트할 것이 없다. 있으면 그 stage 의 결과 사이드카로
86
+ `decide_conformance_gate` 를 그대로 돌린다 — 검증기가 쓰는 것과 같은 판정
87
+ 함수라, 여기서 통과한 stage 는 검증기에서도 통과한다.
88
+ """
89
+ entry = _manifest_entry(task_root, stage)
90
+ if entry is None:
91
+ return True, "no conformance entry declared for this stage"
92
+ key = str(entry.get("stageKey"))
93
+ sidecar = conformance_result_file(task_qa_dir(task_root), key)
94
+ result = None
95
+ if sidecar.is_file():
96
+ try:
97
+ result = qa_result_from_dict(
98
+ load_owned_object(sidecar, artifact="conformance result")
99
+ )
100
+ except JsonBoundaryError:
101
+ # 읽을 수 없는 결과는 결과가 아니다 — MISSING 으로 게이트에 넘겨
102
+ # 판정을 `decide_conformance_gate` 가 내리게 한다. 검증기도 같다.
103
+ result = qa_result_from_dict(None)
104
+ verdict = decide_conformance_gate(entry, result)
105
+ return verdict.ok, f"{verdict.status}: {verdict.message}"
106
+
107
+
108
+ def close_stage(
109
+ project_root: Path, task_key: str, stage: int, head_commit: str,
110
+ ) -> dict:
111
+ """stage 를 done 으로 닫고 그 근거를 함께 돌려준다."""
112
+ identity = resolve_task_identity(project_root, task_key)
113
+ task_root = Path(identity["taskRoot"])
114
+ plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
115
+ if not plan_run_root.is_dir():
116
+ raise StateError(
117
+ f"this task has no implementation-planning run at {plan_run_root} — "
118
+ "there is no Stage Map to close a stage of",
119
+ stage="plan-run",
120
+ )
121
+
122
+ try:
123
+ stage_map = load_latest_plan_stage_map(task_root)
124
+ except StageMapError as exc:
125
+ raise StateError(str(exc), stage=exc.code) from exc
126
+ known = sorted(
127
+ row["stage_number"] for row in stage_map.stages
128
+ if isinstance(row, dict) and isinstance(row.get("stage_number"), int)
129
+ )
130
+ if stage not in known:
131
+ raise StateError(
132
+ f"stage {stage} is not in this task's Stage Map (has {known or 'none'})",
133
+ stage="stage-map",
134
+ )
135
+
136
+ recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root))
137
+ if recorded.get(stage) in _SETTLED:
138
+ raise StateError(
139
+ f"stage {stage} is already recorded {recorded[stage]!r} — a lead's "
140
+ "ruling is not rewritten here",
141
+ stage="consumers",
142
+ )
143
+
144
+ if not _git_commit_exists(project_root, head_commit):
145
+ raise StateError(
146
+ f"{head_commit} is not a commit in {project_root} — pass the commit "
147
+ "the stage's work actually landed as",
148
+ stage="git",
149
+ )
150
+
151
+ ok, conformance = _conformance_state(task_root, stage)
152
+ if not ok:
153
+ raise StateError(
154
+ f"stage {stage} conformance does not permit closing it "
155
+ f"({conformance}) — run the stage's conformance script, or record a "
156
+ 'user waiver with prepare `--qa-waiver "<stageKey>:<reason>"`, '
157
+ "before closing the stage",
158
+ stage="conformance",
159
+ )
160
+
161
+ append_consumer(
162
+ plan_run_root,
163
+ impl_task_key=identity["taskKey"],
164
+ stage=stage,
165
+ status="done",
166
+ head_commit=head_commit,
167
+ closed_by="stage-close",
168
+ )
169
+ return {
170
+ "taskKey": identity["taskKey"],
171
+ "taskRoot": str(task_root),
172
+ "stage": stage,
173
+ "headCommit": head_commit,
174
+ "conformance": conformance,
175
+ "consumersPath": str(plan_run_root / "consumers.jsonl"),
176
+ }
177
+
178
+
179
+ _CLI_EPILOG = r"""Usage:
180
+ okstra stage-close <task-key> --stage <N> --from-commit <sha>
181
+ okstra stage-close <task-key> --stage <N> --from-commit <sha> --project <dir>
182
+
183
+ Records the `done` row an implementation run would have written, for a stage
184
+ whose work is already committed but which never registered as done (the run
185
+ ended before writing its carry sidecar). Refuses unless the Stage Map has that
186
+ stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a
187
+ commit in the project repo, and the stage's conformance gate permits progress.
188
+
189
+ Output: JSON { ok, taskKey, taskRoot, stage, headCommit, conformance,
190
+ consumersPath }. Exit 1 on a refusal (the reason names what to do), 2 when
191
+ PROJECT_ROOT cannot be resolved.
192
+ """
193
+
194
+
195
+ def main(argv: list[str] | None = None) -> int:
196
+ parser = argparse.ArgumentParser(
197
+ description="Close an already-landed implementation stage as done.",
198
+ epilog=_CLI_EPILOG,
199
+ formatter_class=argparse.RawDescriptionHelpFormatter,
200
+ prog="okstra stage-close")
201
+ parser.add_argument("task_key", metavar="task-key",
202
+ help="project-id:task-group:task-id")
203
+ parser.add_argument("--stage", type=int, required=True,
204
+ help="the Stage Map number to close")
205
+ parser.add_argument("--from-commit", required=True, dest="from_commit",
206
+ help="the commit the stage's work landed as")
207
+ parser.add_argument("--project-root", "--project", default="",
208
+ help="use this directory as PROJECT_ROOT")
209
+ parser.add_argument("--cwd", default=".",
210
+ help="resolve PROJECT_ROOT starting from here")
211
+ args = parser.parse_args(argv)
212
+
213
+ def emit(payload: dict) -> None:
214
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
215
+
216
+ try:
217
+ resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
218
+ except ResolverError as exc:
219
+ emit({"ok": False, "stage": "resolve", "reason": str(exc)})
220
+ return 2
221
+
222
+ if args.stage < 1:
223
+ emit({"ok": False, "stage": "stage-map",
224
+ "reason": f"--stage must be a positive integer, got {args.stage}"})
225
+ return 1
226
+
227
+ try:
228
+ closed = close_stage(
229
+ Path(resolved), args.task_key, args.stage, args.from_commit.strip(),
230
+ )
231
+ except StateError as exc:
232
+ emit({
233
+ "ok": False,
234
+ "stage": getattr(exc, "stage", None) or "stage-close",
235
+ "reason": str(exc),
236
+ })
237
+ return 1
238
+
239
+ emit({"ok": True, **closed})
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ raise SystemExit(main())