okstra 0.199.1 → 0.199.3

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 (36) hide show
  1. package/docs/cli.md +4 -4
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/launch.template.md +3 -2
  5. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  6. package/runtime/prompts/lead/okstra-lead-contract.md +3 -2
  7. package/runtime/prompts/lead/plan-body-verification.md +9 -5
  8. package/runtime/prompts/lead/report-writer.md +12 -9
  9. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  10. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +5 -1
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +8 -0
  13. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +10 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +5 -1
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +7 -1
  16. package/runtime/python/okstra_ctl/blocking_checks.py +1 -1
  17. package/runtime/python/okstra_ctl/conformance.py +7 -0
  18. package/runtime/python/okstra_ctl/convergence_store.py +16 -1
  19. package/runtime/python/okstra_ctl/error_log_core.py +2 -1
  20. package/runtime/python/okstra_ctl/error_log_write.py +36 -0
  21. package/runtime/python/okstra_ctl/error_report.py +20 -1
  22. package/runtime/python/okstra_ctl/implementation_direction.py +22 -4
  23. package/runtime/python/okstra_ctl/plan_items.py +5 -2
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +94 -37
  25. package/runtime/python/okstra_ctl/qa_commands.py +26 -2
  26. package/runtime/python/okstra_ctl/report_assembly.py +13 -2
  27. package/runtime/python/okstra_ctl/report_finalize.py +108 -20
  28. package/runtime/python/okstra_ctl/report_synthesis_packet.py +7 -0
  29. package/runtime/python/okstra_ctl/run.py +10 -0
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +13 -0
  31. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -0
  32. package/runtime/skills/okstra-inspect/SKILL.md +4 -2
  33. package/runtime/skills/okstra-run/SKILL.md +1 -1
  34. package/runtime/validators/forbidden_actions.py +3 -0
  35. package/runtime/validators/validate-run.py +105 -27
  36. package/runtime/validators/validate_session_conformance.py +5 -0
@@ -31,6 +31,7 @@ from .json_boundary import (
31
31
  write_owned_object_atomic,
32
32
  )
33
33
  from .report_views import normalize_direction_selection_identity
34
+ from .qa_commands import find_denied_tokens
34
35
  from .scope_provenance import brief_end_state_id_sequence
35
36
  from .user_response import UserResponseError, parse_direction_selection
36
37
 
@@ -814,14 +815,31 @@ _MANUAL_VALIDATION_MARKERS = (
814
815
  )
815
816
 
816
817
 
817
- def _stage_validation_executability_errors(
818
+ def stage_validation_executability_errors(
818
819
  planning: Mapping[str, Any],
819
820
  ) -> list[str]:
820
821
  failures: list[str] = []
821
822
  for row in planning.get("validationChecklist") or ():
822
- if not isinstance(row, Mapping) or not row.get("stageRefs"):
823
+ if not isinstance(row, Mapping):
823
824
  continue
824
- lowered = str(row.get("commandOrObservation") or "").lower()
825
+ command = str(row.get("commandOrObservation") or "")
826
+ denied = find_denied_tokens(command)
827
+ if re.search(r"(?<![\w/])\.?/?\.okstra/tasks/", command):
828
+ denied.append(
829
+ "project-relative QA path (use the absolute task artifact path; keep worktree cwd)"
830
+ )
831
+ if re.search(r">\s*/(?:tmp|private/tmp|var/tmp)/", command):
832
+ denied.append(
833
+ "output outside the worktree (use a worktree-local output path)"
834
+ )
835
+ if denied:
836
+ failures.append(
837
+ f"validationChecklist {row.get('id')} conflicts with verifier command rules: "
838
+ f"{'; '.join(denied)}; correct and approve the plan before implementation"
839
+ )
840
+ if not row.get("stageRefs"):
841
+ continue
842
+ lowered = command.lower()
825
843
  matched = [
826
844
  marker for marker in _MANUAL_VALIDATION_MARKERS if marker in lowered
827
845
  ]
@@ -947,6 +965,6 @@ def validate_selected_direction_plan(
947
965
  planning, brief_end_state_id_sequence(Path(brief_path))
948
966
  )
949
967
  )
950
- failures.extend(_stage_validation_executability_errors(planning))
968
+ failures.extend(stage_validation_executability_errors(planning))
951
969
  failures.extend(_micro_stage_fold_errors(planning))
952
970
  return failures
@@ -643,7 +643,7 @@ CRITIC_WORKER_ID = "critic-worker"
643
643
 
644
644
 
645
645
  def is_critic_worker(worker: str) -> bool:
646
- """본문 동수를 가르는 critic 표인지."""
646
+ """본문 판정을 교정하는 비판 검토자 표인지."""
647
647
  name = str(worker or "").strip().lower()
648
648
  return name == CRITIC_WORKER_ID or name.endswith("-critic-worker")
649
649
 
@@ -738,7 +738,10 @@ CRITIC_TIE_PREAMBLE = (
738
738
  "You are the critic tie-break. Only the items below are in dispute. "
739
739
  "Each item already has one AGREE and one DISAGREE from the two plan-body "
740
740
  "verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
741
- "settles the split. Do not re-open items that are not listed.\n"
741
+ "settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
742
+ "Explain whether the earlier dissent is supported or mistaken; a corrected "
743
+ "dissent remains in the audit history without overriding your decision. "
744
+ "Do not re-open items that are not listed.\n"
742
745
  )
743
746
 
744
747
  # 라운드 2+ 는 직전 라운드의 반대 의견을 해결하려고 존재한다. 그 의견을 프롬프트가
@@ -34,6 +34,7 @@ from .plan_items import (
34
34
  content_hash,
35
35
  correction_prompt_text,
36
36
  critic_is_rostered,
37
+ is_critic_worker,
37
38
  critic_tie_prompt_text,
38
39
  dispatch_item_ids,
39
40
  extract_plan_items,
@@ -53,6 +54,8 @@ from .stage_ledger import build_stage_ledger
53
54
  from .claim_reproduction import NOT_RUNNABLE, reproduce
54
55
  from .final_report_schema import load_schema_version
55
56
  from .report_narrative import parse_narrative
57
+ from .report_assembly import validate_plan_draft
58
+ from .error_log_write import record_runtime_failure
56
59
  from .user_response import parse_user_response_entries
57
60
  from .verdict_blocks import (
58
61
  PLAN_ITEM_VERDICTS,
@@ -266,7 +269,8 @@ def _parser() -> argparse.ArgumentParser:
266
269
  )
267
270
  apply_verdicts.add_argument(
268
271
  "--append", action="store_true",
269
- help="add these votes to the existing rows (the critic's tie vote); "
272
+ help="add critic corrections while preserving analyser votes; a later "
273
+ "critic verdict updates that critic's current row after its earlier round is complete; "
270
274
  "without it every recorded verdict row is replaced, so a round "
271
275
  "that was never closed with complete-round is refused first",
272
276
  )
@@ -285,6 +289,11 @@ def _parser() -> argparse.ArgumentParser:
285
289
  complete.add_argument("--state", type=Path, required=True)
286
290
  complete.add_argument("--run-manifest", type=Path, required=True)
287
291
  complete.add_argument("--round", type=int, required=True, dest="round_number")
292
+ complete.add_argument(
293
+ "--items", type=Path,
294
+ help="restore this round's dispatched queue from its prepared items artifact; "
295
+ "earlier verdicts outside that queue remain unchanged",
296
+ )
288
297
  complete.add_argument("--self-fix-note", action="append", default=[], metavar="<item-id>=<markdown-file>")
289
298
  complete.add_argument(
290
299
  "--self-fix-group", action="append", default=[],
@@ -405,6 +414,15 @@ def _sync_task_manifest_gating(run_manifest: Path, gating: bool) -> None:
405
414
  def _prepare(args: argparse.Namespace) -> dict[str, Any]:
406
415
  output = _prepared_items_path(args.run_manifest)
407
416
  source = _plan_source(args)
417
+ authority = validated_run_authority(args.run_manifest)
418
+ if authority.payload.get("reportContractVersion") == "3.0":
419
+ failures = validate_plan_draft(source, authority.project_root, authority.payload)
420
+ if failures:
421
+ raise PlanItemContractError(
422
+ "owner=report-writer fieldPath=implementationPlanning: "
423
+ + "; ".join(failures)
424
+ + "; correct the narrative in this run, then retry plan-items prepare"
425
+ )
408
426
  envelope = _envelope(source)
409
427
  envelope["dispatchQueue"] = _queue_for(
410
428
  envelope["items"],
@@ -469,6 +487,18 @@ def _label(key: str) -> str:
469
487
  return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
470
488
 
471
489
 
490
+ def _render_literal(label: str, value: str, indent: str = "") -> list[str]:
491
+ """명령·코드의 줄바꿈을 보존하고 본문보다 긴 울타리로 감싼다."""
492
+ fence = "`" * max(
493
+ 3, 1 + max((len(part) for part in re.findall(r"`+", value)), default=0)
494
+ )
495
+ body = "".join(indent + " " + row for row in value.splitlines(keepends=True))
496
+ ending = "" if value.endswith("\n") else "\n"
497
+ return [
498
+ f"{indent}- {label}:\n\n{indent} {fence}text\n{body}{ending}{indent} {fence}\n"
499
+ ]
500
+
501
+
472
502
  def _render_object_list(key: str, value: object) -> list[str]:
473
503
  if not isinstance(value, list):
474
504
  raise PlanItemContractError(f"plan item {key} must be an array")
@@ -491,6 +521,8 @@ def _render_object_list(key: str, value: object) -> list[str]:
491
521
  raise PlanItemContractError(
492
522
  f"plan item {field_path} must be scalar"
493
523
  )
524
+ elif field == "details" and isinstance(field_value, str):
525
+ rows.extend(_render_literal(f"`{field_path}`", field_value, " "))
494
526
  else:
495
527
  rows.append(
496
528
  f" - `{field_path}`: `{scalar(field_value)}`\n"
@@ -542,6 +574,8 @@ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[st
542
574
  return rows
543
575
  if isinstance(value, Mapping):
544
576
  raise PlanItemContractError(f"plan item {key} must be scalar")
577
+ if key in {"command", "commandOrObservation"} and isinstance(value, str):
578
+ return _render_literal(_label(key), value, indent)
545
579
  return [indent + line(_label(key), value)]
546
580
 
547
581
 
@@ -1389,12 +1423,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1389
1423
  discard_open_rounds = bool(getattr(args, "discard_open_rounds", False))
1390
1424
  if discard_open_rounds and args.state is not None and args.result:
1391
1425
  _restore_queue_from_results(verification, args.result, recorded)
1392
- queue = verification.get("dispatchQueue") if isinstance(verification, Mapping) else None
1393
- assigned = (
1394
- {item_id for item_id in queue if isinstance(item_id, str)}
1395
- if isinstance(queue, list) else known
1396
- )
1397
- assigned = _narrow_assignment(args, assigned)
1426
+ assigned = _narrow_dispatch_queue(args, verification, known)
1398
1427
  rows = _incoming_verdict_rows(args, assigned)
1399
1428
  missing = sorted(item_id for item_id in rows if item_id not in known)
1400
1429
  if missing:
@@ -1405,21 +1434,14 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1405
1434
  )
1406
1435
  if args.round_number < 1:
1407
1436
  raise PlanItemContractError("--round must be 1 or greater")
1408
- if (
1409
- isinstance(verification, Mapping)
1410
- and verification.get("gating") is False
1411
- and args.round_number > 1
1412
- ):
1413
- # `complete-round` 가 같은 조건으로 거절하지만 그때는 이미 라운드 2 판정이
1414
- # 상태에 쓰인 뒤다 — 판정은 있는데 `roundHistory` 에 그 라운드가 없는
1415
- # 불일치가 남고, 상태 파일은 수렴 엔진 소유라 복구 경로가 없었다
1416
- # (2026-09-05 실측, dev-10626 planning). 쓰기 전에 같은 문장으로 거절한다.
1417
- raise PlanItemContractError(
1418
- "advisory plan-body gating allows one verification round"
1419
- )
1420
1437
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1421
1438
  append = bool(getattr(args, "append", False))
1422
- if not append:
1439
+ replaces_critic = append and any(
1440
+ is_critic_worker(row.get("worker", ""))
1441
+ and any(v.get("worker") == row.get("worker") for v in rows.get(item.get("id"), []))
1442
+ for item in recorded for row in item.get("verdicts", [])
1443
+ )
1444
+ if not append or replaces_critic:
1423
1445
  _reject_uncompleted_round_loss(
1424
1446
  recorded, rows, data.get("roundHistory"), args.round_number, target,
1425
1447
  discard_open_rounds=discard_open_rounds,
@@ -1428,6 +1450,7 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1428
1450
  for item in recorded:
1429
1451
  if isinstance(item, Mapping) and item.get("id") in rows:
1430
1452
  writer(item, rows[item["id"]], args.round_number, project_root)
1453
+ _validate_advisory_round(verification, args.round_number)
1431
1454
  write_json_atomic(target, data)
1432
1455
  return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
1433
1456
 
@@ -1462,23 +1485,26 @@ def _append_item_verdicts(
1462
1485
  round_number: int,
1463
1486
  project_root: Path | None,
1464
1487
  ) -> None:
1465
- """동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
1488
+ """분석자 표를 보존하고, 비판 검토자의 후속 판정을 현재 표로 갱신한다."""
1466
1489
  stamped = _stamped_verdicts(incoming, round_number, project_root)
1467
1490
  existing = item.get("verdicts")
1468
1491
  current = existing if isinstance(existing, list) else []
1469
- seen = {
1470
- row.get("worker") for row in current if isinstance(row, Mapping)
1492
+ seen = {row.get("worker"): row.get("round", 1) for row in current if isinstance(row, Mapping)}
1493
+ refreshed = {
1494
+ row.get("worker") for row in stamped
1495
+ if is_critic_worker(row.get("worker", ""))
1496
+ and row.get("worker") in seen and round_number > seen[row["worker"]]
1471
1497
  }
1472
- clash = [row.get("worker") for row in stamped if row.get("worker") in seen]
1498
+ clash = [row.get("worker") for row in stamped if row.get("worker") in seen and row.get("worker") not in refreshed]
1473
1499
  if clash:
1474
1500
  raise PlanItemContractError(
1475
1501
  f"plan item {item.get('id')} already has a vote from {clash} — "
1476
1502
  "the extra vote must come from a worker who has not voted on it. "
1477
- "--append is the critic's tie vote; a worker re-voting in a later "
1503
+ "--append accepts critic corrections; an analyser re-voting in a later "
1478
1504
  "round is recorded without --append, after the earlier round is "
1479
1505
  "closed with complete-round"
1480
1506
  )
1481
- item["verdicts"] = [*current, *stamped]
1507
+ item["verdicts"] = [row for row in current if row.get("worker") not in refreshed] + stamped
1482
1508
  _remember_verified_hash(item)
1483
1509
 
1484
1510
 
@@ -1586,7 +1612,7 @@ def _reject_uncompleted_round_loss(
1586
1612
  )
1587
1613
  raise PlanItemContractError(
1588
1614
  f"round {round_number} would replace verdicts of a round that was never "
1589
- f"completed ({detail}) — apply-verdicts replaces every recorded row, and "
1615
+ f"completed ({detail}) — replacing these verdicts would lose their history; "
1590
1616
  "only complete-round keeps a round's votes in planItems[].rounds. Close "
1591
1617
  f"the earlier round first: run {commands}, then re-run this command. "
1592
1618
  "If those rows are themselves being re-applied from their result files "
@@ -1699,7 +1725,11 @@ def _round_inputs(args: argparse.Namespace) -> tuple[dict[str, Any], list[dict[s
1699
1725
  audit, history = data.get("planItems"), data.get("roundHistory")
1700
1726
  if not isinstance(audit, list) or not isinstance(history, list):
1701
1727
  raise PlanItemContractError("state planItems and roundHistory must be arrays")
1702
- return data, _state_plan_body_items(data, args.state), audit, history
1728
+ current = _state_plan_body_items(data, args.state)
1729
+ _narrow_dispatch_queue(
1730
+ args, data["planBodyVerification"], {item.get("id") for item in current},
1731
+ )
1732
+ return data, current, audit, history
1703
1733
 
1704
1734
 
1705
1735
  def _round_snapshots(current: list[dict[str, Any]], verification: Mapping[str, Any], round_number: int) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
@@ -1782,16 +1812,27 @@ def _reject_round_gap(history: list[Any], round_number: int) -> None:
1782
1812
  )
1783
1813
 
1784
1814
 
1815
+ def _validate_advisory_round(verification: Mapping[str, Any], round_number: int) -> None:
1816
+ """분석자 검증 횟수 제한은 비판 검토자의 교정에 적용하지 않는다."""
1817
+ if verification.get("gating") is not False or round_number <= 1:
1818
+ return
1819
+ if any(
1820
+ row.get("round") == round_number and not is_critic_worker(row.get("worker", ""))
1821
+ for item in verification.get("planItems", []) for row in item.get("verdicts", [])
1822
+ ):
1823
+ raise PlanItemContractError(
1824
+ "advisory plan-body gating allows one verification round for analysers; "
1825
+ "critic corrections do not consume that limit"
1826
+ )
1827
+
1828
+
1785
1829
  def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
1786
1830
  if args.round_number < 1:
1787
1831
  raise PlanItemContractError("--round must be 1 or greater")
1788
1832
  data, current, audit, history = _round_inputs(args)
1789
1833
  verification = data["planBodyVerification"]
1834
+ _validate_advisory_round(verification, args.round_number)
1790
1835
  if verification.get("gating") is False:
1791
- if args.round_number > 1:
1792
- raise PlanItemContractError(
1793
- "advisory plan-body gating allows one verification round"
1794
- )
1795
1836
  if args.self_fix_group or args.self_fix_note or args.self_fix_stop_reason:
1796
1837
  raise PlanItemContractError(
1797
1838
  "advisory plan-body gating forbids the self-fix loop"
@@ -1936,8 +1977,8 @@ def _correction_prompt(args: argparse.Namespace) -> str:
1936
1977
  return correction_prompt_text(_prompt(args))
1937
1978
 
1938
1979
 
1939
- def _narrow_assignment(
1940
- args: argparse.Namespace, assigned: set[object],
1980
+ def _narrow_dispatch_queue(
1981
+ args: argparse.Namespace, verification: dict[str, Any], known: set[object],
1941
1982
  ) -> set[object]:
1942
1983
  """이 라운드가 실제로 배정한 항목들.
1943
1984
 
@@ -1946,10 +1987,15 @@ def _narrow_assignment(
1946
1987
  37항목을 미응답으로 거절했다 — 문서가 "model-facing" 이라고 적은 형식이
1947
1988
  tie 라운드에서는 쓸 수 없고, 우회로가 헬프 스스로 historical 이라 적은
1948
1989
  `--verdicts` 뿐이었다(실측 2026-09-10, fontsninja-v3-site dev-10628-3).
1949
- `--items` 라운드의 artifact 주면 `dispatchQueue` 배정이
1950
- 된다. 미응답 검사 자체는 그대로다 좁힌 배정 안에서 여전히 전건을
1951
- 요구하므로, 워커가 자기 몫을 조용히 빠뜨리는 것은 계속 잡힌다.
1990
+ 배정 범위는 판정 저장과 완료 기록이 함께 써야 한다. 지역 변수만 좁히면
1991
+ 저장은 성공해도 완료가 과거 전체에서 라운드의 표를 요구한다.
1992
+ 상태의 큐만 좁히고 기존 표와 라운드 이력은 보존한다.
1952
1993
  """
1994
+ queue = verification.get("dispatchQueue")
1995
+ assigned = (
1996
+ {item_id for item_id in queue if isinstance(item_id, str)}
1997
+ if isinstance(queue, list) else known
1998
+ )
1953
1999
  items_path = getattr(args, "items", None)
1954
2000
  if items_path is None:
1955
2001
  return assigned
@@ -1965,6 +2011,10 @@ def _narrow_assignment(
1965
2011
  f"queue does not contain — pass the artifact this round dispatched, "
1966
2012
  f"not another round's"
1967
2013
  )
2014
+ verification["dispatchQueue"] = [
2015
+ item_id for item_id in (queue if isinstance(queue, list) else sorted(assigned))
2016
+ if item_id in narrowed
2017
+ ]
1968
2018
  return narrowed
1969
2019
 
1970
2020
 
@@ -2011,6 +2061,13 @@ def main(argv: list[str] | None = None) -> int:
2011
2061
  result = _HANDLERS[args.command](args)
2012
2062
  except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
2013
2063
  print(f"plan-items: {exc}", file=sys.stderr)
2064
+ manifest = getattr(args, "run_manifest", None)
2065
+ if manifest:
2066
+ logged = record_runtime_failure(
2067
+ Path(manifest), command=f"plan-items {args.command}", exit_code=2, detail=str(exc)
2068
+ )
2069
+ if not logged["ok"]:
2070
+ print(f"error-log: {logged['reason']}", file=sys.stderr)
2014
2071
  return 2
2015
2072
  if isinstance(result, str):
2016
2073
  print(result, end="")
@@ -72,6 +72,31 @@ def _has_insta_update_set(cmd: str) -> bool:
72
72
  return match.group(1).lower() != "no"
73
73
 
74
74
 
75
+ def find_unfrozen_installs(cmd: str) -> list[str]:
76
+ """계획과 실행 기록에서 같은 잠금파일 고정 조건을 판정한다."""
77
+ found: list[str] = []
78
+ if _has_npm_install_without_ci(cmd):
79
+ found.append("npm install (use 'npm ci' instead)")
80
+ for match in re.finditer(r"\b(pnpm|yarn|bun)\s+(?:install|i)\b([^;&|\n]*)", cmd):
81
+ flags = match.group(2).split()
82
+ frozen = "--frozen-lockfile" in flags or (
83
+ match.group(1) == "yarn" and "--immutable" in flags
84
+ )
85
+ disabled = any(
86
+ flag
87
+ in {
88
+ "--no-frozen-lockfile",
89
+ "--frozen-lockfile=false",
90
+ "--no-immutable",
91
+ "--immutable=false",
92
+ }
93
+ for flag in flags
94
+ )
95
+ if not frozen or disabled:
96
+ found.append(f"{match.group(1)} install (requires a frozen lockfile)")
97
+ return found
98
+
99
+
75
100
  def find_denied_tokens(cmd: str) -> list[str]:
76
101
  """`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
77
102
  if not isinstance(cmd, str):
@@ -84,8 +109,7 @@ def find_denied_tokens(cmd: str) -> list[str]:
84
109
  for sub in _DENIED_SUBSTRINGS:
85
110
  if sub in cmd:
86
111
  found.append(sub)
87
- if _has_npm_install_without_ci(cmd):
88
- found.append("npm install (use 'npm ci' instead)")
112
+ found.extend(find_unfrozen_installs(cmd))
89
113
  if _has_insta_update_set(cmd):
90
114
  found.append("INSTA_UPDATE=<not-no>")
91
115
  return found
@@ -1,6 +1,7 @@
1
1
  """역할별 리포트 입력을 검증해 계약 3.0 정본을 한 번 게시한다."""
2
2
  from __future__ import annotations
3
3
 
4
+ import copy
4
5
  import json
5
6
  import os
6
7
  import tempfile
@@ -573,7 +574,7 @@ def _apply_selected_direction_snapshot(
573
574
  )
574
575
 
575
576
 
576
- def _selected_direction_publication_errors(
577
+ def selected_direction_plan_errors(
577
578
  data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
578
579
  ) -> list[str]:
579
580
  """게시 직전에 구현 진입 검증을 그대로 돌린다.
@@ -593,6 +594,16 @@ def _selected_direction_publication_errors(
593
594
  return validate_selected_direction_plan(data, brief_path, snapshot_path)
594
595
 
595
596
 
597
+ def validate_plan_draft(
598
+ data: Mapping[str, Any], project_root: Path, manifest: Mapping[str, Any]
599
+ ) -> list[str]:
600
+ """작성자 입력을 게시하지 않고 같은 기계 투영과 의미 검사로 검증한다."""
601
+ draft = copy.deepcopy(dict(data))
602
+ _apply_selected_direction_snapshot(draft, project_root, manifest)
603
+ _attach_metadata(draft, manifest)
604
+ return selected_direction_plan_errors(draft, project_root, manifest)
605
+
606
+
596
607
  def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
597
608
  task_key = str(manifest.get("taskKey") or "")
598
609
  parts = task_key.split(":")
@@ -872,7 +883,7 @@ def assemble_report(
872
883
  raise ReportAssemblyError(tuple(input_issues))
873
884
  data = _compose(project_root, manifest_path, manifest, inputs, schema)
874
885
  errors = validate(data, schema)
875
- direction_errors = _selected_direction_publication_errors(
886
+ direction_errors = selected_direction_plan_errors(
876
887
  data, project_root, manifest
877
888
  )
878
889
  value = manifest.get("expectedReportRecordPath")
@@ -44,7 +44,7 @@ import json
44
44
  from datetime import date, datetime, timezone
45
45
  import subprocess
46
46
  import sys
47
- from dataclasses import dataclass
47
+ from dataclasses import asdict, dataclass
48
48
  from pathlib import Path
49
49
  from typing import Any, Callable, Mapping, Sequence
50
50
 
@@ -73,6 +73,7 @@ from .stage_targets import (
73
73
  integrate_and_teardown_whole_task,
74
74
  )
75
75
  from .session import observe_lead_session
76
+ from .error_log_write import record_runtime_failure
76
77
 
77
78
  # 포인터 값 타입만 쓴다. `okstra_project.phase_pointer` 는 okstra 안의
78
79
  # 어떤 것도 import 하지 않는 leaf 라 순환이 없다 — 그 모듈 도크스트링.
@@ -93,6 +94,7 @@ STEP_SPAWN_FOLLOWUPS = "spawn-followups"
93
94
  STEP_VALIDATE_RUN = "validate-run"
94
95
  STEP_RECORD_GROUP_MEMORY = "record-group-memory"
95
96
  STEP_TEARDOWN_STAGES = "teardown-stages"
97
+ STEP_PREFLIGHT = "preflight"
96
98
 
97
99
  STEP_ORDER = (
98
100
  STEP_PROJECT_ACTIVITY,
@@ -112,6 +114,7 @@ STEP_ORDER = (
112
114
  V3_STEP_ORDER = (
113
115
  STEP_TOKEN_USAGE,
114
116
  STEP_PROJECT_ACTIVITY,
117
+ STEP_PREFLIGHT,
115
118
  # Before the render that overlays its sidecar.
116
119
  STEP_TRANSLATE,
117
120
  STEP_RENDER_VIEWS,
@@ -401,6 +404,11 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
401
404
  if ctx.report_contract_version != "3.0":
402
405
  return commands
403
406
  by_name = dict(commands)
407
+ by_name[STEP_PREFLIGHT] = [
408
+ *_validate_run_command(ctx, ctx.data_path),
409
+ "--section",
410
+ "preflight",
411
+ ]
404
412
  usage = by_name[STEP_TOKEN_USAGE]
405
413
  marker = usage.index("--substitute-data")
406
414
  by_name[STEP_TOKEN_USAGE] = usage[:marker]
@@ -553,13 +561,17 @@ def step_payload(
553
561
  command: Sequence[str],
554
562
  result: subprocess.CompletedProcess[str],
555
563
  ) -> dict[str, Any]:
556
- return {
564
+ payload = {
557
565
  "name": name,
558
566
  "command": list(command),
559
567
  "exitCode": result.returncode,
560
568
  "stdoutTail": tail(result.stdout),
561
569
  "stderrTail": tail(result.stderr),
562
570
  }
571
+ if name == STEP_PROJECT_ACTIVITY and result.returncode != 0 and result.stdout:
572
+ # 소유자 오류는 요약 문자열 길이에 잘리지 않은 전체 목록으로 전달한다.
573
+ payload["issues"] = json.loads(result.stdout).get("issues", [])
574
+ return payload
563
575
 
564
576
 
565
577
  def run_finalize(
@@ -585,6 +597,8 @@ def run_finalize(
585
597
 
586
598
  if only:
587
599
  selected = set(only)
600
+ if ctx.report_contract_version == "3.0" and STEP_TRANSLATE in selected:
601
+ selected.add(STEP_PREFLIGHT)
588
602
  contract_order = (
589
603
  V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
590
604
  )
@@ -601,23 +615,7 @@ def run_finalize(
601
615
  ctx, commands, selected, all_commands
602
616
  )
603
617
 
604
- first_failure = ""
605
- validated = False
606
- for name, command in commands:
607
- # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
608
- # 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
609
- if name in _SKIPPED_AFTER_FAILURE and first_failure:
610
- continue
611
- if before_step is not None:
612
- before_step(name)
613
- result = _run_finalize_step(ctx, name, command)
614
- steps.append(step_payload(name, command, result))
615
- if result.returncode != 0 and not first_failure:
616
- first_failure = f"{name} failed with exit code {result.returncode}"
617
- if name == STEP_VALIDATE_RUN and result.returncode == 0:
618
- validated = True
619
- if validated:
620
- _record_run_end(ctx.team_state_path)
618
+ steps, first_failure = _execute_finalize_steps(ctx, commands, before_step)
621
619
  pointer, pointer_error = _recorded_next_phase(ctx)
622
620
  payload: dict[str, Any] = {
623
621
  "ok": not first_failure,
@@ -630,12 +628,89 @@ def run_finalize(
630
628
  following = _next_in_group(steps)
631
629
  if following:
632
630
  payload["nextInGroup"] = following
631
+ if first_failure:
632
+ payload["recovery"] = _finalize_recovery(ctx, payload)
633
633
  # closeout 이 실제로 건넬 명령. 표를 리드의 기억에 맡기지 않는다.
634
634
  payload["nextCommand"] = closeout_command(payload)
635
635
  payload["reportPaths"] = _closeout_report_paths(ctx)
636
636
  return payload
637
637
 
638
638
 
639
+ def _execute_finalize_steps(
640
+ ctx: FinalizeContext, commands: Sequence[tuple[str, list[str]]],
641
+ before_step: Callable[[str], None] | None,
642
+ ) -> tuple[list[dict[str, Any]], str]:
643
+ """처리 순서를 지키며 각 실패를 기록하고, 전체 성공일 때 실행을 닫는다."""
644
+ steps: list[dict[str, Any]] = []
645
+ first_failure = ""
646
+ validated = False
647
+ preflight_failed = False
648
+ for name, command in commands:
649
+ # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
650
+ # 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
651
+ if name in _SKIPPED_AFTER_FAILURE and first_failure:
652
+ continue
653
+ if name == STEP_TRANSLATE and preflight_failed:
654
+ continue
655
+ if before_step is not None:
656
+ before_step(name)
657
+ result = _run_finalize_step(ctx, name, command)
658
+ step = step_payload(name, command, result)
659
+ if result.returncode != 0:
660
+ step["errorLogAppend"] = record_runtime_failure(
661
+ ctx.manifest_path, project_root=ctx.project_root,
662
+ command=f"report-finalize {name}", exit_code=result.returncode,
663
+ detail="\n".join(filter(None, [result.stderr, result.stdout])) or f"{name} exited {result.returncode}",
664
+ )
665
+ steps.append(step)
666
+ if name == STEP_PREFLIGHT:
667
+ preflight_failed = result.returncode != 0
668
+ if result.returncode != 0 and not first_failure:
669
+ first_failure = f"{name} failed with exit code {result.returncode}"
670
+ if name == STEP_VALIDATE_RUN and result.returncode == 0:
671
+ validated = True
672
+ if validated and not first_failure:
673
+ _record_run_end(ctx.team_state_path)
674
+ return steps, first_failure
675
+
676
+
677
+ def _finalize_recovery(ctx: FinalizeContext, result: Mapping[str, Any]) -> dict[str, Any]:
678
+ """새 실행 대신 실패한 처리부터 재개할 명령과 원본 소유자 오류를 전달한다."""
679
+ order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
680
+ failed = {step["name"] for step in result.get("steps") or [] if step.get("exitCode")}
681
+ pointer = promote_next_phase(result.get("nextRecommendedPhase"))
682
+ if (failed == {STEP_VALIDATE_RUN} and pointer["status"] == STATUS_BLOCKED
683
+ and pointer["phase"] and pointer["phase"] != ctx.task_type):
684
+ return {"mode": "phase-reentry", "phase": pointer["phase"], "instruction": pointer["rationale"]}
685
+ command = [
686
+ "okstra", "report-finalize", "--project-root", str(ctx.project_root),
687
+ "--run-manifest", str(ctx.manifest_path), "--report", str(ctx.data_path),
688
+ "--team-state", str(ctx.team_state_path),
689
+ ]
690
+ resume_steps = _recovery_step_names(result, order)
691
+ if ctx.report_contract_version == "3.0" and STEP_PROJECT_ACTIVITY not in resume_steps:
692
+ # 수정한 서사·판정 상태를 정본에 다시 조립해야 후속 검사가 새 입력을 읽는다.
693
+ resume_steps = list(order[order.index(STEP_PROJECT_ACTIVITY):])
694
+ for name in resume_steps:
695
+ command.extend(["--only", name])
696
+ issues = []
697
+ for step in result.get("steps") or []:
698
+ if step.get("name") != STEP_PROJECT_ACTIVITY or not step.get("exitCode"):
699
+ continue
700
+ issues.extend(step.get("issues") or [])
701
+ return {
702
+ "mode": "same-run", "resumeCommand": command, "issues": issues,
703
+ "instruction": (
704
+ "Repair the reported causes in this run using the owning input's correction command, "
705
+ "then execute resumeCommand. Preserve approvals, model choices and completed evidence. "
706
+ "If plan content changes, re-verify the affected items before finalizing. "
707
+ "After a narrative correction, check existing translations with report-translate check-data "
708
+ "and regenerate them if their source no longer matches. "
709
+ "Ask only for an unresolved user decision or an actual external prerequisite."
710
+ ),
711
+ }
712
+
713
+
639
714
  def _closeout_report_paths(ctx: FinalizeContext) -> dict[str, Any]:
640
715
  """closeout 이 인용할 task 한정 경로와, 그대로 답장에 붙일 링크.
641
716
 
@@ -838,7 +913,11 @@ def _run_project_activity(
838
913
  ctx.data_path,
839
914
  )
840
915
  count = len(rows)
841
- except (ActivityProjectionError, ReportAssemblyError) as exc:
916
+ except ReportAssemblyError as exc:
917
+ return subprocess.CompletedProcess(
918
+ command, 1, json.dumps({"issues": [asdict(issue) for issue in exc.issues]}), str(exc)
919
+ )
920
+ except ActivityProjectionError as exc:
842
921
  return subprocess.CompletedProcess(command, 1, "", str(exc))
843
922
  return subprocess.CompletedProcess(
844
923
  command, 0, json.dumps({"count": count}), ""
@@ -1076,12 +1155,21 @@ def closeout_command(result: Mapping[str, Any]) -> dict[str, str]:
1076
1155
  돌려주는 것은 `command` 와 `note` 두 칸이다. `command` 가 비면 근거
1077
1156
  문장이 그 자리를 대신한다.
1078
1157
  """
1158
+ recovery = result.get("recovery")
1159
+ if isinstance(recovery, Mapping) and recovery.get("mode") == "same-run":
1160
+ return {"command": "", "note": str(recovery["instruction"])}
1079
1161
  failed_validate = any(
1080
1162
  step.get("name") == STEP_VALIDATE_RUN and step.get("exitCode") != 0
1081
1163
  for step in (result.get("steps") or [])
1082
1164
  if isinstance(step, Mapping)
1083
1165
  )
1084
1166
  if failed_validate:
1167
+ recovery = promote_next_phase(result.get("nextRecommendedPhase"))
1168
+ if recovery["status"] == STATUS_BLOCKED and recovery["phase"]:
1169
+ return {
1170
+ "command": f"/okstra-run → {recovery['phase']}",
1171
+ "note": recovery["rationale"],
1172
+ }
1085
1173
  return {
1086
1174
  "command": "/okstra-run",
1087
1175
  "note": "validate-run failed — name the blocking cause in one line, "