okstra 0.225.0 → 0.226.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 (75) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.mjs +5 -0
  4. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  5. package/dist/commands/pr/pr.mjs +42 -13
  6. package/dist/commands/pr/pr.mjs.map +1 -1
  7. package/docs/cli.md +19 -3
  8. package/docs/project-structure-overview.md +2 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/lib/okstra/cli.sh +5 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -0
  13. package/runtime/bin/lib/okstra/usage.sh +3 -0
  14. package/runtime/bin/okstra.sh +2 -0
  15. package/runtime/prompts/duties/code-reviewer.json +7 -4
  16. package/runtime/prompts/duties/technical-verification-worker.json +2 -1
  17. package/runtime/prompts/lead/convergence.md +4 -1
  18. package/runtime/prompts/lead/phase-routing.md +7 -3
  19. package/runtime/prompts/lead/team-contract.md +1 -1
  20. package/runtime/prompts/wizard/prompts.ko.json +15 -0
  21. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
  22. package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
  23. package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
  24. package/runtime/python/okstra_ctl/brief_sources.py +200 -0
  25. package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
  26. package/runtime/python/okstra_ctl/cmux.py +22 -5
  27. package/runtime/python/okstra_ctl/code_review_coverage.py +26 -2
  28. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
  29. package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
  30. package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
  31. package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
  32. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
  33. package/runtime/python/okstra_ctl/manager_split.py +185 -27
  34. package/runtime/python/okstra_ctl/next_phase.py +3 -1
  35. package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
  36. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
  37. package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
  38. package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
  39. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  40. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
  41. package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
  42. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
  43. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
  44. package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
  45. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
  46. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  47. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
  48. package/runtime/python/okstra_ctl/report_assembly.py +23 -2
  49. package/runtime/python/okstra_ctl/report_views.py +2 -1
  50. package/runtime/python/okstra_ctl/run.py +46 -1
  51. package/runtime/python/okstra_ctl/run_context.py +13 -8
  52. package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
  53. package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
  54. package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
  55. package/runtime/python/okstra_ctl/wizard/render.py +4 -0
  56. package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
  57. package/runtime/python/okstra_ctl/wizard/state.py +4 -0
  58. package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
  59. package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
  60. package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
  61. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +74 -0
  63. package/runtime/schemas/final-report-v3.0.schema.json +99 -2
  64. package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
  65. package/runtime/skills/okstra-code-review/SKILL.md +2 -0
  66. package/runtime/skills/okstra-code-review/references/census-rules.md +16 -2
  67. package/runtime/skills/okstra-code-review/references/review-calibration.md +7 -1
  68. package/runtime/skills/okstra-manager/SKILL.md +15 -2
  69. package/runtime/skills/okstra-pr-gen/SKILL.md +15 -1
  70. package/runtime/templates/reports/brief.template.md +10 -1
  71. package/runtime/templates/reports/html/i18n/en.json +8 -0
  72. package/runtime/templates/reports/html/i18n/ko.json +8 -0
  73. package/runtime/templates/worker-prompt-preamble.md +1 -1
  74. package/runtime/validators/validate-brief.py +39 -1
  75. package/runtime/validators/validate-run.py +5 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ from collections import Counter
4
5
  from pathlib import Path
5
6
  from typing import Any, cast
6
7
 
@@ -27,6 +28,7 @@ from .contracts import (
27
28
  from .source import candidate_projects, capture_project
28
29
 
29
30
  CONFIRMED_LEVELS = frozenset({"static", "execution-verified"})
31
+ PACKET_CLAIM_LIMIT = 40
30
32
 
31
33
 
32
34
  def task_link_path(project_root: Path, task_key: str) -> Path:
@@ -355,17 +357,22 @@ def knowledge_packet(
355
357
  result = query_knowledge(project_root, question, source_root)
356
358
  except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
357
359
  return f"## Shared Business Knowledge\n\nKnowledge unavailable: {exc}\n"
360
+ # 질문이 task-brief 전체라 토큰 매칭이 거의 모든 claim 을 고르므로, 선택 결과는
361
+ # 관련도 순위가 아니다. 이 프로젝트가 기록한 claim 을 앞세우고 나머지는 조회로 넘긴다.
362
+ own = str(project_root.resolve())
363
+ claims = sorted(result["claims"], key=lambda row: row["ownerRoot"] != own)
364
+ shown, omitted = claims[:PACKET_CLAIM_LIMIT], claims[PACKET_CLAIM_LIMIT:]
358
365
  lines = [
359
366
  "## Shared Business Knowledge",
360
367
  "",
361
368
  "Preserve level, freshness, conflict and provenance. Recheck historical facts before relying on them.",
362
369
  "",
363
370
  ]
364
- if not result["claims"]:
371
+ if not claims:
365
372
  lines.append(
366
373
  "No related stored claims were selected. Inspect source and use additional lookup when new business terms emerge."
367
374
  )
368
- for row in result["claims"]:
375
+ for row in shown:
369
376
  lines.extend(
370
377
  [
371
378
  f"- `{row['id']}`: {row['value']}",
@@ -377,6 +384,12 @@ def knowledge_packet(
377
384
  f" - Evidence: `{item['projectId']}:{item['path']}:{item['line']}`"
378
385
  for item in row["evidence"]
379
386
  )
387
+ if omitted:
388
+ counts = Counter(row["business"] for row in omitted)
389
+ lines.append(
390
+ f"- {len(omitted)} more selected claims are omitted; look them up by business: "
391
+ + ", ".join(f"`{name}` ({count})" for name, count in counts.most_common())
392
+ )
380
393
  lines.extend(f"- Knowledge read error: {error}" for error in result["errors"])
381
394
  lines.extend(
382
395
  [
@@ -12,6 +12,7 @@ import shlex
12
12
  import shutil
13
13
  import socket
14
14
  import subprocess
15
+ import time
15
16
  from dataclasses import dataclass
16
17
  from pathlib import Path
17
18
  from typing import Any, Collection, Mapping, Sequence
@@ -73,6 +74,13 @@ SOCKET_UNREACHABLE = "unreachable"
73
74
 
74
75
  SOCKET_PROBE_TIMEOUT_SECONDS = 2
75
76
 
77
+ # 막 분할한 창에 보낸 `respawn-pane` 이 답 없이 소켓이 닫히는 경우가 있다. 실측
78
+ # 2026-10-29, dev-11562 두 run: 탭 이름은 붙었는데 창에는 첫 로그인 셸만 남았다.
79
+ # 그 창은 워커를 시작하지 않았으므로 같은 명령을 다시 보내도 워커가 둘이 되지 않는다.
80
+ TRANSIENT_SOCKET_ERROR = "Socket closed before reply"
81
+ RESPAWN_ATTEMPTS = 3
82
+ RESPAWN_RETRY_DELAY_SECONDS = 0.5
83
+
76
84
  # cmux 가 돌려주는 식별자가 UUID 인지 ref 인지 가리는 자리. ref 는 번호가 다시
77
85
  # 매겨지므로 원장에 남길 수 없다.
78
86
  _UUID_PATTERN = re.compile(r"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}")
@@ -539,7 +547,7 @@ def spawn_worker_surface(
539
547
  # 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
540
548
  # 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
541
549
  try:
542
- closed = run_cmux(["close-surface", "--surface", surface_uuid])
550
+ closed = run_cmux(["close-surface", "--surface", surface_uuid, "--force"])
543
551
  if closed.returncode != 0:
544
552
  raise RuntimeError(closed.stderr.strip() or "cmux close failed")
545
553
  except (RuntimeError, OSError, subprocess.SubprocessError) as cleanup_error:
@@ -610,8 +618,10 @@ def rename_lead_surface(title: str) -> str:
610
618
 
611
619
  def close_surface(surface_uuid: str) -> None:
612
620
  """Close an okstra-created surface, killing whatever still runs inside it."""
621
+ # cmux 는 살아 있는 프로세스가 있는 창을 `--force` 없이 닫지 않는다
622
+ # (`confirmation_required`). 막 분할된 창의 로그인 셸도 그 프로세스로 센다.
613
623
  try:
614
- run_cmux(["close-surface", "--surface", surface_uuid])
624
+ run_cmux(["close-surface", "--surface", surface_uuid, "--force"])
615
625
  except (OSError, subprocess.SubprocessError):
616
626
  return
617
627
 
@@ -849,9 +859,16 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
849
859
  line = worker_command_line(
850
860
  cwd=cwd, argv=command, path_value=shim_free_worker_path()
851
861
  )
852
- started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
853
- if started.returncode != 0:
854
- raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
862
+ for attempt in range(1, RESPAWN_ATTEMPTS + 1):
863
+ started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
864
+ if started.returncode == 0:
865
+ return
866
+ error = started.stderr.strip()
867
+ if TRANSIENT_SOCKET_ERROR not in error or attempt == RESPAWN_ATTEMPTS:
868
+ raise RuntimeError(
869
+ f"respawn-pane: {error or 'cmux could not start the worker'}"
870
+ )
871
+ time.sleep(RESPAWN_RETRY_DELAY_SECONDS)
855
872
 
856
873
 
857
874
  def _size_lead_pane(
@@ -87,7 +87,7 @@ def _keyed_values(text: str) -> dict[str, str]:
87
87
 
88
88
 
89
89
  def _source_evidence(project_root: Path, cell: ReviewCell, evidence: str) -> str:
90
- match = re.fullmatch(r"(.+):([1-9][0-9]*)", evidence.strip("`"))
90
+ match = re.fullmatch(r"(.+?):([1-9][0-9]*)`?(?: — .+)?", evidence.strip("`"))
91
91
  target = cell.target.partition(":")[0]
92
92
  if match is None or match[1] != target:
93
93
  raise ValueError("evidence must cite the target source path:line")
@@ -103,6 +103,30 @@ def _source_evidence(project_root: Path, cell: ReviewCell, evidence: str) -> str
103
103
  return evidence
104
104
 
105
105
 
106
+ def _checked_rule_evidence(
107
+ project_root: Path, cell: ReviewCell, checked: Sequence[str], text: str,
108
+ ) -> str:
109
+ anchor, _, raw = text.partition(" :: ")
110
+ _source_evidence(project_root, cell, anchor)
111
+ observations = _keyed_values(raw.strip() or "-")
112
+ required = set(checked) & {"G2", "G3", "G5"} if cell.group == "general" else set()
113
+ missing = required - observations.keys()
114
+ if missing:
115
+ raise ValueError("missing clean check evidence: " + ", ".join(sorted(missing)))
116
+ if observations.keys() - set(checked):
117
+ raise ValueError("clean check evidence names an unchecked rule")
118
+ for observation in observations.values():
119
+ match = re.fullmatch(r"(`?.+?:[1-9][0-9]*`?) (.+)", observation)
120
+ if match is None:
121
+ raise ValueError("clean check evidence needs source path:line and an observation")
122
+ if re.fullmatch(r"(?:checked|clean|no issues?|none|n/?a)", match[2], re.I):
123
+ raise ValueError("clean check evidence needs an observation, not a verdict label")
124
+ citation = match[1].strip("`")
125
+ source_cell = ReviewCell(citation.partition(":")[0], cell.group)
126
+ _source_evidence(project_root, source_cell, citation)
127
+ return text
128
+
129
+
106
130
  def _group_verdict(
107
131
  fields: Sequence[str], expected: Sequence[str], cell: ReviewCell,
108
132
  project_root: Path, references: Sequence[str],
@@ -131,7 +155,7 @@ def _group_verdict(
131
155
  raise ValueError("invalid finding ID")
132
156
  if not any(id_occurs_wordbounded(body, item) for body in references):
133
157
  raise ValueError(f"finding {item} has no finding body")
134
- evidence = _source_evidence(project_root, cell, fields[4])
158
+ evidence = _checked_rule_evidence(project_root, cell, checked, fields[4])
135
159
  if findings:
136
160
  return "finding", "; ".join(findings.values())
137
161
  if checked:
@@ -296,7 +296,10 @@ verifiers may have missed:
296
296
  - requirements / acceptance points with no covering evidence,
297
297
  - DB / IO / SQL changes lacking real-execution evidence,
298
298
  - regressions or broken error paths,
299
- - scope / contract violations.
299
+ - scope / contract violations,
300
+ - other locations or cases of a mechanism the analysers already raised (for
301
+ example the same stale-copy defect in a sibling editor, or after the version
302
+ changed) — a cell of that class they did not judge is a new candidate.
300
303
  A finding that would not stop the release is not a blocker — say so, and it is
301
304
  recorded as a conditional-acceptance condition instead. Do NOT restate an existing
302
305
  Acceptance Blocker. If you find none, write `No candidate blockers.`
@@ -242,12 +242,24 @@ def _split_declared_paths(raw: str) -> list[str]:
242
242
  return parts
243
243
 
244
244
 
245
- def _step_files_cells(step: Mapping[str, Any]) -> list[str]:
245
+ def _step_path_cells(step: Mapping[str, Any]) -> list[str]:
246
+ """step 이 건드리는 경로를 담은 칸들 — 독자용 `files` 와 쓰기 원장 `plannedPaths`.
247
+
248
+ `files` 는 문장이어도 되는 칸이라(profile §Stepwise Execution Order), 경로를
249
+ `plannedPaths` 에만 적은 계획도 스테이지에 매핑돼야 한다.
250
+ """
246
251
  raw = step.get("files")
247
252
  if isinstance(raw, list):
248
- return [_normalise(item) for item in raw if item]
249
- text = _normalise(raw)
250
- return [text] if text else []
253
+ cells = [_normalise(item) for item in raw if item]
254
+ else:
255
+ text = _normalise(raw)
256
+ cells = [text] if text else []
257
+ planned = step.get("plannedPaths")
258
+ for item in planned if isinstance(planned, list) else ():
259
+ path = _normalise(item).strip().strip("`") if isinstance(item, str) else ""
260
+ if path:
261
+ cells.append(path)
262
+ return cells
251
263
 
252
264
 
253
265
  def _files_cell_tokens(cell: str) -> list[str]:
@@ -282,7 +294,7 @@ def _stages_touching_path(
282
294
  cell
283
295
  for step in stage.get("stepwiseExecution") or []
284
296
  if isinstance(step, Mapping)
285
- for cell in _step_files_cells(step)
297
+ for cell in _step_path_cells(step)
286
298
  ]
287
299
  if any(_cell_covers_path(cell, path) for cell in cells):
288
300
  hits.append(stage_number)
@@ -293,7 +305,7 @@ def _stages_for_selected_path(
293
305
  path: str,
294
306
  stages: Mapping[int, Mapping[str, Any]],
295
307
  ) -> list[int]:
296
- """Every stage whose stepwise ``files`` cells touch *path*.
308
+ """Every stage whose stepwise ``files`` or ``plannedPaths`` cells touch *path*.
297
309
 
298
310
  A file legitimately edited by more than one stage — the task-level
299
311
  ``qa/conformance-manifest.json`` is the standard case, since each
@@ -321,7 +333,7 @@ def _stages_for_selected_path(
321
333
  hits = _stages_touching_path(part, stages)
322
334
  if not hits and "qa/scripts/" in part:
323
335
  # 적합성 스크립트는 executor 가 역할 권한으로 쓴다(write_policy
324
- # `_role_qa_artifact_paths`). 계획 step 의 files 칸에는 원래 없다.
336
+ # `_role_qa_artifact_paths`). 계획 step 의 files·plannedPaths 칸에는 원래 없다.
325
337
  continue
326
338
  if not hits:
327
339
  raise DesignSurfaceError(
@@ -667,26 +667,34 @@ def _generated_paths(policies: Sequence[WritePolicy]) -> frozenset[str]:
667
667
 
668
668
 
669
669
  def _git_projection(root: Path) -> dict[str, Any]:
670
+ # 스냅샷은 워커 실행마다 두 번 찍힌다. rev-parse 는 여러 질의를 한 프로세스에서
671
+ # 줄 단위로 답하므로 질의마다 git 을 띄우지 않는다.
670
672
  probe = subprocess.run(
671
- ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
673
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree",
674
+ "--path-format=absolute", "--git-dir", "--git-common-dir",
675
+ "--git-path", "index"],
672
676
  capture_output=True,
673
677
  text=True,
674
678
  check=False,
675
679
  )
676
680
  if probe.returncode != 0:
677
681
  return {"available": False}
678
- git_dir = Path(_git_output(root, "rev-parse", "--path-format=absolute", "--git-dir"))
679
- common_dir = Path(
680
- _git_output(root, "rev-parse", "--path-format=absolute", "--git-common-dir")
681
- )
682
+ lines = probe.stdout.splitlines()
683
+ if len(lines) == 4:
684
+ _inside, git_dir, common_dir, index_path = (Path(line) for line in lines)
685
+ else:
686
+ # 경로 속 줄바꿈은 질의 사이의 줄 경계와 구별되지 않는다. 그때만 경로를 하나씩 묻는다.
687
+ git_dir, common_dir, index_path = (
688
+ Path(_git_output(root, "rev-parse", "--path-format=absolute", *query))
689
+ for query in (("--git-dir",), ("--git-common-dir",), ("--git-path", "index"))
690
+ )
682
691
  branch_ref = _git_output(root, "symbolic-ref", "-q", "HEAD")
683
- head = _git_output(root, "rev-parse", "HEAD")
684
- index_path = Path(_git_output(root, "rev-parse", "--path-format=absolute", "--git-path", "index"))
692
+ head, branch_commit = _git_output(root, "rev-parse", "HEAD", branch_ref).splitlines()
685
693
  return {
686
694
  "available": True,
687
695
  "head": head,
688
696
  "allowedBranchRef": branch_ref,
689
- "branchCommit": _git_output(root, "rev-parse", branch_ref),
697
+ "branchCommit": branch_commit,
690
698
  "gitDir": str(git_dir.resolve()),
691
699
  "gitCommonDir": str(common_dir.resolve()),
692
700
  "indexDigest": _optional_file_digest(index_path),
@@ -879,21 +887,15 @@ def _branch_switch_paths(
879
887
 
880
888
 
881
889
  def _git_head(root: Path) -> str | None:
882
- probe = subprocess.run(
883
- ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
884
- capture_output=True,
885
- text=True,
886
- check=False,
887
- )
888
- if probe.returncode != 0:
889
- return None
890
890
  head = subprocess.run(
891
- ["git", "-C", str(root), "rev-parse", "HEAD"],
891
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree", "HEAD"],
892
892
  capture_output=True,
893
893
  text=True,
894
894
  check=False,
895
895
  )
896
- return head.stdout.strip() or None if head.returncode == 0 else None
896
+ if head.returncode != 0:
897
+ return None
898
+ return head.stdout.splitlines()[-1].strip() or None
897
899
 
898
900
 
899
901
  def _allowed_artifact_paths(policies: Sequence[WritePolicy]) -> set[str]:
@@ -67,6 +67,8 @@ class SelectedDirection:
67
67
  direction: dict[str, Any]
68
68
  requirement_coverage: tuple[dict[str, Any], ...]
69
69
  planning_invariants: tuple[dict[str, Any], ...]
70
+ deferred_feasibility_facts: tuple[dict[str, Any], ...] = ()
71
+ split_acceptance: dict[str, Any] | None = None
70
72
 
71
73
 
72
74
  @dataclass(frozen=True)
@@ -400,6 +402,8 @@ def resolve_selected_direction(
400
402
  direction=_direction_payload(option),
401
403
  requirement_coverage=tuple(_copy_json(option.get("requirementCoverage") or ())),
402
404
  planning_invariants=tuple(_copy_json(option.get("planningInvariants") or ())),
405
+ deferred_feasibility_facts=tuple(_copy_json(option.get("unresolvedFeasibilityFacts") or ())),
406
+ split_acceptance=_copy_json(option["splitAcceptance"]) if "splitAcceptance" in option else None,
403
407
  )
404
408
 
405
409
 
@@ -424,6 +428,12 @@ def write_selected_direction_snapshot(
424
428
  "requirementCoverage": list(direction.requirement_coverage),
425
429
  "planningInvariants": list(direction.planning_invariants),
426
430
  }
431
+ # 방향이 유효한 채로 남긴 사실(이후 게이트에서 닫힐 사실, 사용자가 감수한
432
+ # 위험)은 계획이 일정에 올려야 하므로 스냅샷에 실어 보낸다.
433
+ if direction.deferred_feasibility_facts:
434
+ payload["deferredFeasibilityFacts"] = list(direction.deferred_feasibility_facts)
435
+ if direction.split_acceptance is not None:
436
+ payload["splitAcceptance"] = direction.split_acceptance
427
437
  write_owned_object_atomic(
428
438
  destination,
429
439
  payload,
@@ -40,8 +40,10 @@ from .worker_prompt_body import (
40
40
  )
41
41
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
42
42
  from .worker_prompt_headers import source_worktree_headers, worker_prompt_headers
43
+ from .approved_plan_stage_extract import StageExtractError, materialize_stage_extract
43
44
  from .worker_prompt_policy import (
44
45
  APPROVED_PLAN_HEADER,
46
+ APPROVED_PLAN_STAGE_EXTRACT_HEADER,
45
47
  IMPLEMENTATION_STAGE_HEADER,
46
48
  STAGE_QA_OWNER_HEADER,
47
49
  PromptPlan,
@@ -720,6 +722,11 @@ def _prompt_body(
720
722
  plan,
721
723
  _optional_approved_plan_path(context) if plan.include_approved_plan else "",
722
724
  _selected_direction_path(context) if plan.include_selected_direction else "",
725
+ (
726
+ _approved_plan_stage_extract(context)
727
+ if plan.audience == "implementation-executor"
728
+ else ""
729
+ ),
723
730
  )
724
731
 
725
732
 
@@ -1466,6 +1473,10 @@ def _implementation_anchor_lines(
1466
1473
  lines.append(
1467
1474
  f"{APPROVED_PLAN_HEADER} {_required_approved_plan_path(context)}"
1468
1475
  )
1476
+ if plan.audience == "implementation-executor":
1477
+ extract = _approved_plan_stage_extract(context)
1478
+ if extract:
1479
+ lines.append(f"{APPROVED_PLAN_STAGE_EXTRACT_HEADER} {extract}")
1469
1480
  lines.append(
1470
1481
  f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
1471
1482
  )
@@ -1526,6 +1537,36 @@ def _required_approved_plan_path(context: _MaterializationContext) -> str:
1526
1537
  return str(_resolve_input_path(context.project_root, Path(value)))
1527
1538
 
1528
1539
 
1540
+ def _approved_plan_stage_extract(context: _MaterializationContext) -> str:
1541
+ """Write this run's stage extract of the approved plan; its project path.
1542
+
1543
+ Empty when the plan is not a report record (see `materialize_stage_extract`),
1544
+ and the executor then reads the plan itself.
1545
+ """
1546
+ stage = _required_stage_number(context)
1547
+ if not stage.isdigit():
1548
+ raise InitialPromptMaterializationError(
1549
+ "required_input_missing",
1550
+ f"implementation stage is not a stage number: {stage}",
1551
+ )
1552
+ active_context_path = _resolve_input_path(
1553
+ context.project_root,
1554
+ Path(_required_string(context.manifest, "activeRunContextPath")),
1555
+ )
1556
+ suffix = active_context_path.name.removeprefix("active-run-context-")
1557
+ output = active_context_path.with_name(f"approved-plan-stage-{stage}-{suffix}")
1558
+ try:
1559
+ written = materialize_stage_extract(
1560
+ Path(_required_approved_plan_path(context)), int(stage), output
1561
+ )
1562
+ except (StageExtractError, JsonBoundaryError) as exc:
1563
+ raise InitialPromptMaterializationError(
1564
+ "required_input_missing",
1565
+ f"cannot project the approved plan onto stage {stage}: {exc}",
1566
+ ) from exc
1567
+ return _project_relative(context.project_root, output) if written else ""
1568
+
1569
+
1529
1570
  def _optional_approved_plan_path(context: _MaterializationContext) -> str:
1530
1571
  """The approved plan path when this run recorded one, otherwise empty.
1531
1572