okstra 0.178.0 → 0.179.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 (110) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/execute/plan-verify.mjs +1 -1
  3. package/dist/commands/execute/worktree-status.mjs +8 -2
  4. package/dist/commands/execute/worktree-status.mjs.map +1 -1
  5. package/dist/commands/lifecycle/install.mjs +1 -1
  6. package/dist/commands/lifecycle/install.mjs.map +1 -1
  7. package/dist/commands/report/render-final-report.mjs +3 -3
  8. package/docs/architecture/storage-model.md +3 -3
  9. package/docs/architecture.md +10 -9
  10. package/docs/cli.md +11 -13
  11. package/docs/for-ai/skills/okstra-inspect.md +3 -3
  12. package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
  13. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  14. package/docs/project-structure-overview.md +10 -11
  15. package/docs/task-process/implementation-planning.md +1 -1
  16. package/docs/task-process/implementation.md +1 -1
  17. package/package.json +1 -1
  18. package/runtime/BUILD.json +2 -2
  19. package/runtime/agents/workers/report-writer-worker.md +11 -12
  20. package/runtime/bin/lib/okstra/globals.sh +2 -2
  21. package/runtime/bin/lib/okstra/interactive.sh +1 -1
  22. package/runtime/bin/lib/okstra/usage.sh +11 -9
  23. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  24. package/runtime/bin/okstra-central.sh +2 -2
  25. package/runtime/bin/okstra-render-final-report.py +1 -1
  26. package/runtime/bin/okstra-token-usage.py +1 -1
  27. package/runtime/prompts/launch.template.md +1 -1
  28. package/runtime/prompts/lead/adapters/cmux.md +6 -1
  29. package/runtime/prompts/lead/context-loader.md +3 -2
  30. package/runtime/prompts/lead/convergence.md +1 -1
  31. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  32. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  33. package/runtime/prompts/lead/report-writer.md +21 -20
  34. package/runtime/prompts/lead/team-contract.md +1 -1
  35. package/runtime/prompts/profiles/_common-contract.md +5 -4
  36. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  37. package/runtime/prompts/profiles/_implementation-executor.md +2 -1
  38. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  39. package/runtime/prompts/profiles/implementation-planning.md +9 -5
  40. package/runtime/prompts/profiles/implementation.md +4 -4
  41. package/runtime/prompts/profiles/improvement-discovery.md +2 -2
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  43. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  45. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  46. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  47. package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
  48. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  49. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  50. package/runtime/python/okstra_ctl/backfill.py +12 -5
  51. package/runtime/python/okstra_ctl/consumers.py +70 -3
  52. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  53. package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
  54. package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
  55. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  56. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  57. package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
  58. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  59. package/runtime/python/okstra_ctl/handoff.py +1 -2
  60. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  61. package/runtime/python/okstra_ctl/index.py +4 -4
  62. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  63. package/runtime/python/okstra_ctl/listing.py +4 -2
  64. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  65. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  66. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  67. package/runtime/python/okstra_ctl/paths.py +13 -10
  68. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  69. package/runtime/python/okstra_ctl/recap.py +3 -2
  70. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  71. package/runtime/python/okstra_ctl/render.py +22 -22
  72. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  73. package/runtime/python/okstra_ctl/rollup.py +1 -1
  74. package/runtime/python/okstra_ctl/run.py +139 -284
  75. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  76. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  77. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  78. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  79. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  80. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  81. package/runtime/python/okstra_ctl/user_response.py +97 -12
  82. package/runtime/python/okstra_ctl/wizard.py +43 -65
  83. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  84. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  85. package/runtime/python/okstra_ctl/workflow.py +1 -1
  86. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  87. package/runtime/python/okstra_ctl/write_policy.py +45 -6
  88. package/runtime/python/okstra_project/state.py +2 -2
  89. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  90. package/runtime/python/okstra_token_usage/cli.py +3 -3
  91. package/runtime/python/okstra_token_usage/report.py +7 -24
  92. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  93. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  94. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  95. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  96. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  97. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  98. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  99. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  100. package/runtime/skills/okstra-run/SKILL.md +1 -1
  101. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  102. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  103. package/runtime/templates/project-docs/task-index.template.md +1 -1
  104. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  105. package/runtime/validators/forbidden_actions.py +76 -5
  106. package/runtime/validators/lib/fixtures.sh +14 -10
  107. package/runtime/validators/lib/runners.sh +1 -1
  108. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  109. package/runtime/validators/validate-report-views.py +1 -1
  110. package/runtime/validators/validate-run.py +95 -37
@@ -119,6 +119,7 @@ from okstra_ctl.run import (
119
119
  _extract_frontmatter_block,
120
120
  _load_final_report_data_if_present,
121
121
  _assignment_resolution_message,
122
+ _record_approved_flag,
122
123
  _reject_blocking_plan_body_gate,
123
124
  _set_data_json_approved_true_if_present,
124
125
  _model_default_scopes,
@@ -711,7 +712,7 @@ def _planning_rerun_selected(state: WizardState) -> bool:
711
712
  path.is_file()
712
713
  and not path.is_symlink()
713
714
  and re.fullmatch(
714
- r"final-report-implementation-planning-\d{3,}\.md", path.name
715
+ r"final-report-implementation-planning-\d{3,}\.(?:md|data\.json)", path.name
715
716
  )
716
717
  is not None
717
718
  and path.parent == reports
@@ -746,32 +747,23 @@ def _submit_selected_direction_pick(
746
747
  return f"selected-direction: {value}"
747
748
 
748
749
 
749
- def _data_json_approved_state(plan_path: Path) -> Optional[bool]:
750
- """`approved` flag of the sibling final-report data.json (the SSOT).
751
-
752
- Returns the bool when present, or None when there is no data.json or the
753
- flag is missing / non-bool (legacy report — markdown frontmatter governs)."""
754
- loaded = _load_final_report_data_if_present(plan_path)
755
- if loaded is None:
756
- return None
757
- frontmatter = loaded[1].get("frontmatter")
758
- if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
759
- return None
760
- value = frontmatter.get("approved")
761
- return value if isinstance(value, bool) else None
762
-
763
-
764
750
  def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bool]:
765
751
  """Resolve the plan and classify it as fully-approved vs approvable.
766
752
 
767
753
  Returns ``(resolved_path, already_fully_approved)``. Raises WizardError ONLY
768
- for failures that approval cannot fix: missing frontmatter / `approved:`
769
- field, a blocking plan-body gate, an unparseable §1, or unresolved
770
- `Blocks=approval` rows. A plan that is merely not-yet-approved (markdown or
771
- data.json `approved: false`, gate ok, no blockers) returns
754
+ for failures that approval cannot fix: missing `approved` on the report
755
+ record (or schema-v1 frontmatter), a blocking plan-body gate, an unparseable
756
+ §1, or unresolved `Blocks=approval` rows. A plan that is merely
757
+ not-yet-approved (record `approved: false`, gate ok, no blockers) returns
772
758
  ``already_fully_approved=False`` — the approve-confirm step offers to flip it.
773
759
  """
774
- p = _require_file(path_str, project_root, "approved plan")
760
+ from okstra_ctl.final_report_paths import require_approved_plan_record
761
+
762
+ resolved = _require_file(path_str, project_root, "approved plan")
763
+ try:
764
+ p = require_approved_plan_record(resolved)
765
+ except ValueError as exc:
766
+ raise WizardError(str(exc)) from exc
775
767
  loaded = _load_final_report_data_if_present(p)
776
768
  if loaded is not None:
777
769
  planning = loaded[1].get("implementationPlanning")
@@ -784,23 +776,9 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
784
776
  "direction-invalidated planning reports are not approvable; "
785
777
  "re-enter implementation-option-selection"
786
778
  )
787
- body = p.read_text(encoding="utf-8", errors="replace")
788
- frontmatter = _extract_frontmatter_block(body)
789
- if frontmatter is None:
790
- raise WizardError(
791
- f"approved plan has no YAML frontmatter block: {p}\n"
792
- " expected the report to begin with `---\\n...\\n---\\n`."
793
- )
794
- m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
795
- if not m:
796
- raise WizardError(
797
- f"approved plan frontmatter has no `approved:` field: {p}\n"
798
- " expected `approved: true` / `approved: false`. Re-render the "
799
- "report if the field is missing."
800
- )
801
779
  # A blocking gate or an open Blocks=approval row makes the plan UN-approvable
802
780
  # — these raise regardless of the current flag value.
803
- _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
781
+ _reject_blocking_plan_body_gate(p, "", action="approved plan validation")
804
782
  scan = scan_approval_gate(p)
805
783
  if scan.unreadable_reason:
806
784
  raise WizardError(
@@ -819,28 +797,19 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
819
797
  lines.append(f" - {b.row_id} (Status={b.raw_status})")
820
798
  lines.append(f" file: {p}")
821
799
  raise WizardError("\n".join(lines))
822
- markdown_approved = m.group(1).lower() == "true"
823
- data_state = _data_json_approved_state(p)
824
- fully_approved = markdown_approved and data_state is not False
825
- return p, fully_approved
800
+ try:
801
+ record_approved = _record_approved_flag(p)
802
+ except PrepareError as exc:
803
+ raise WizardError(str(exc)) from exc
804
+ return p, record_approved is True
826
805
 
827
806
 
828
807
  def _approve_plan_in_place(plan_path: Path) -> None:
829
- """Flip the plan to approved at the source of truth and re-render.
830
-
831
- data.json present → `_set_data_json_approved_true_if_present` sets
832
- `frontmatter.approved=true` there and re-renders the markdown from it (so
833
- both agree). data.json absent (legacy) → flip the markdown frontmatter line."""
834
- rendered = _set_data_json_approved_true_if_present(plan_path)
835
- if rendered:
836
- return
837
- body = plan_path.read_text(encoding="utf-8", errors="replace")
838
- flipped = APPROVED_FRONTMATTER_PATTERN.sub("approved: true", body, count=1)
839
- if flipped == body:
808
+ """Flip the report record `frontmatter.approved` to true and re-render."""
809
+ if not _set_data_json_approved_true_if_present(plan_path):
840
810
  raise WizardError(
841
- f"approve-plan: could not flip the markdown `approved:` line: {plan_path}"
811
+ f"approve-plan: report record could not be updated: {plan_path}"
842
812
  )
843
- plan_path.write_text(flipped, encoding="utf-8")
844
813
 
845
814
 
846
815
  def _find_html_approval_sidecar(
@@ -864,7 +833,7 @@ def _find_html_approval_sidecar(
864
833
  responses_dir = plan_path.parent.parent / "user-responses"
865
834
  if not responses_dir.is_dir():
866
835
  return None
867
- m = re.search(r"-(\d+)\.md$", plan_path.name)
836
+ m = re.search(r"-(\d+)\.(?:md|data\.json)$", plan_path.name)
868
837
  plan_seq = m.group(1) if m else ""
869
838
  best: Optional[tuple[float, Path, PlanDecisionRecord]] = None
870
839
  for f in sorted(responses_dir.glob("user-response-*.md")):
@@ -875,7 +844,17 @@ def _find_html_approval_sidecar(
875
844
  rec = parse_plan_decision(text)
876
845
  if rec is None or not rec.approved or rec.seq != plan_seq:
877
846
  continue
878
- if Path(rec.source_report).name != plan_path.name:
847
+ from okstra_ctl.final_report_paths import (
848
+ final_report_markdown_path,
849
+ is_report_record_path,
850
+ )
851
+
852
+ expected_name = (
853
+ final_report_markdown_path(plan_path).name
854
+ if is_report_record_path(plan_path)
855
+ else plan_path.name
856
+ )
857
+ if Path(rec.source_report).name != expected_name:
879
858
  continue
880
859
  mtime = f.stat().st_mtime
881
860
  if best is None or mtime > best[0]:
@@ -909,10 +888,12 @@ def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict)
909
888
 
910
889
  def _plan_short_label(candidate: str) -> str:
911
890
  """plan 파일명에서 사용자용 짧은 식별자를 뽑는다.
912
- final-report-implementation-planning-002.md → implementation-planning-002"""
891
+ final-report-implementation-planning-002.data.json → implementation-planning-002"""
913
892
  if not candidate:
914
893
  return ""
915
- return Path(candidate).stem.removeprefix("final-report-")
894
+ name = Path(candidate).name
895
+ stem = name[: -len(".data.json")] if name.endswith(".data.json") else Path(name).stem
896
+ return stem.removeprefix("final-report-")
916
897
 
917
898
 
918
899
  def _stage_plan_for_confirmation(
@@ -2782,7 +2763,7 @@ def _latest_revision_requested_analysis_report(
2782
2763
  return None
2783
2764
  expected_task_key = f"{state.project_id}:{state.task_group}:{state.task_id}"
2784
2765
  candidates: list[tuple[int, Path]] = []
2785
- for report in reports.glob("final-report-*.md"):
2766
+ for report in reports.glob("final-report-*.data.json"):
2786
2767
  candidate = _analysis_revision_candidate(
2787
2768
  state,
2788
2769
  report,
@@ -3498,14 +3479,11 @@ def _ensure_design_prep_queue(state: WizardState) -> None:
3498
3479
  return
3499
3480
  report_path = Path(state.approved_plan_path)
3500
3481
  if not re.fullmatch(
3501
- r"final-report-implementation-planning-\d+\.md",
3482
+ r"final-report-implementation-planning-\d+\.data\.json",
3502
3483
  report_path.name,
3503
3484
  ):
3504
3485
  return
3505
- data_path = report_path.with_name(
3506
- report_path.name.removesuffix(".md") + ".data.json"
3507
- )
3508
- if not data_path.is_file():
3486
+ if not report_path.is_file():
3509
3487
  return
3510
3488
  try:
3511
3489
  items = load_design_prep_items(report_path)
@@ -3976,14 +3954,14 @@ def _suggest_latest_final_report(state: WizardState) -> str:
3976
3954
  best = _newest_contained_final_report(
3977
3955
  runs_base,
3978
3956
  task_root,
3979
- f"{seg}/reports/final-report-*.md",
3957
+ f"{seg}/reports/final-report-*.data.json",
3980
3958
  Path(state.project_root),
3981
3959
  )
3982
3960
  if best is None:
3983
3961
  best = _newest_contained_final_report(
3984
3962
  runs_base,
3985
3963
  task_root,
3986
- "*/reports/final-report-*.md",
3964
+ "*/reports/final-report-*.data.json",
3987
3965
  Path(state.project_root),
3988
3966
  )
3989
3967
  if best is None:
@@ -107,18 +107,17 @@ def report_writer_prompt_body(
107
107
  mcp_pointer_line(),
108
108
  "",
109
109
  "## Output Contract",
110
- "You are the author of THREE files:",
111
- "- The final-report data.json at Result Path.",
112
- "- The rendered Markdown sibling produced through okstra render-final-report.",
110
+ "You are the author of TWO files:",
111
+ "- The report record (data.json) at Result Path.",
113
112
  "- The worker-result pointer at Worker Result Path.",
114
113
  (
115
- "Keep the pointer to three entries: the data.json path, rendered "
116
- "Markdown path, and Convergence state input path."
114
+ "Keep the pointer to two entries: the data.json path and the "
115
+ "Convergence state input path."
117
116
  ),
118
117
  "Maintain the separate audit sidecar at Audit sidecar path.",
119
118
  (
120
- 'After writing the data.json, invoke "okstra render-final-report '
121
- '<Result Path>" so the markdown sibling is rendered before you return.'
119
+ "Do not invoke okstra render-final-report; the full reading copy "
120
+ "is on-demand."
122
121
  ),
123
122
  "Do not return the report inline.",
124
123
  "Copy Report Language verbatim into data.json.meta.reportLanguage.",
@@ -15,21 +15,19 @@ import os
15
15
  import selectors
16
16
  import signal
17
17
  import subprocess
18
- import sys
19
18
  import time
20
- from functools import partial
21
19
  from pathlib import Path
22
20
  from typing import Any, Callable, Mapping
23
21
 
24
22
  from .domain.provider import ServedModelAttestation, ServedModelNormalizer
25
23
  from .domain.worker_exec import (
26
24
  SERVED_MODEL_MISMATCH_EXIT_CODE,
27
- STREAM_JSON,
28
25
  ExecCommand,
29
26
  ExecutionStrategy,
30
27
  WorkerExecRequest,
31
28
  )
32
- from .domain.worker_stream import Normalise, final_text, format_live, format_log
29
+ from .domain.worker_presentation import JsonEvents, Presentation
30
+ from .session_transcript import SessionTranscript
33
31
 
34
32
  LIVE = "live"
35
33
  QUIET = "quiet"
@@ -45,16 +43,6 @@ _READ_SIZE = 8192
45
43
  _WRITE_SIZE = 8192
46
44
  _NO_STATUS_EXTRA: Mapping[str, Any] = {}
47
45
 
48
- # How many progress lines reach the log copy before it starts eliding. Progress
49
- # is where a text CLI's bulk is — a dispatch that only reads one file already
50
- # puts kilobytes of tool echo on stderr, and observed sidecars reach 8MB and
51
- # dominate a project's `.okstra/` bytes. The cap is run-wide rather than
52
- # per-block because block boundaries are a provider's own vocabulary and this
53
- # runner has none; the cost is that a very long run keeps its opening rather
54
- # than a sample throughout, which the elision notices make visible.
55
- _LOG_PROGRESS_LINE_CAP = 5000
56
- _ELISION_NOTICE_EVERY = 500
57
-
58
46
  # The signals that end this process without raising anything Python can catch on
59
47
  # the way out. SIGINT is absent on purpose: it arrives as KeyboardInterrupt and
60
48
  # the exception path already closes the sidecar.
@@ -89,6 +77,9 @@ def run_worker(
89
77
  idle_timeout_seconds=request.idle_timeout_seconds,
90
78
  on_spawn=guard.watch,
91
79
  )
80
+ at_exit = getattr(command.presentation, "served_model_at_exit", None)
81
+ if raw_model is None and at_exit is not None and request.session_id:
82
+ raw_model = at_exit(request.session_id, command.cwd)
92
83
  except BaseException as exc:
93
84
  # Whatever ended this run — an OS error, a Ctrl-C, a bug in this file —
94
85
  # the sidecar has to stop saying `started`. Nothing downstream rewrites
@@ -179,33 +170,62 @@ def _launch(
179
170
  idle_timeout_seconds: int,
180
171
  on_spawn: Callable[[subprocess.Popen[bytes]], None],
181
172
  ) -> tuple[int, bool, int, str | None]:
182
- log_path.parent.mkdir(parents=True, exist_ok=True)
183
- with log_path.open("w", encoding="utf-8") as log_file:
173
+ live = presentation == LIVE
174
+ transcript = SessionTranscript(log_path, live=live)
175
+ observation = _ServedModelObservation()
176
+ strategy = _with_served_model_observation(command.presentation, observation)
177
+ try:
184
178
  # The strategy decided where this provider runs — some CLIs work in the
185
179
  # stage tree, others in the project root and reach the tree by flag.
186
180
  process = subprocess.Popen(
187
181
  list(command.argv),
188
182
  cwd=str(command.cwd),
189
- stdin=subprocess.PIPE if command.stdin_text is not None else None,
183
+ # 보낼 것이 없으면 DEVNULL 이다. `None` 은 부모의 stdin 을 물려주는
184
+ # 것이라, stdin 을 읽는 CLI 가 터미널에서 실행됐을 때 EOF 를 못 받고
185
+ # 영원히 기다린다 — 워커는 대화형이 아니므로 물려줄 이유가 없다.
186
+ stdin=(
187
+ subprocess.PIPE
188
+ if command.stdin_text is not None
189
+ else subprocess.DEVNULL
190
+ ),
190
191
  stdout=subprocess.PIPE,
191
- stderr=_stderr_target(command.stream_format),
192
+ stderr=_stderr_target(strategy),
192
193
  start_new_session=True,
193
194
  env=_child_env(),
194
195
  )
195
196
  on_spawn(process)
196
- observation = _ServedModelObservation()
197
197
  exit_code, timed_out, idle_seconds = _pump(
198
198
  process,
199
- log_file,
200
- stream_format=command.stream_format,
201
- normalise=command.normalise,
199
+ transcript,
200
+ strategy=strategy,
202
201
  presentation=presentation,
203
202
  idle_timeout_seconds=idle_timeout_seconds,
204
203
  stdin_text=command.stdin_text,
205
- observe_served_model=command.observe_served_model,
206
- model_observation=observation,
207
204
  )
208
205
  return exit_code, timed_out, idle_seconds, observation.raw_model
206
+ finally:
207
+ transcript.close()
208
+
209
+
210
+ def _with_served_model_observation(
211
+ presentation: Presentation,
212
+ observation: _ServedModelObservation,
213
+ ) -> Presentation:
214
+ """JSON 경로의 서빙 모델 관측을 러너가 모아 둔다.
215
+
216
+ 해석 전략은 이벤트를 보고 모델 문자열만 돌려준다. 그 값을 사이드카에
217
+ 적는 일은 러너의 것이라, 여기서 한 번 감싼다.
218
+ """
219
+ if not isinstance(presentation, JsonEvents):
220
+ return presentation
221
+ original = presentation.observe
222
+
223
+ def observe(event: Mapping[str, Any]) -> str | None:
224
+ observed = original(event)
225
+ observation.record(observed)
226
+ return observed
227
+
228
+ return JsonEvents(normalise=presentation.normalise, observe=observe)
209
229
 
210
230
 
211
231
  class _ServedModelObservation:
@@ -311,39 +331,27 @@ def _started_status(status_extra: Mapping[str, Any], log_path: Path) -> dict[str
311
331
  }
312
332
 
313
333
 
314
- def _stderr_target(stream_format: str) -> int:
315
- """Whether the child's two streams stay apart.
334
+ def _stderr_target(presentation: Presentation) -> int:
335
+ """stderr 를 stdout 에 합칠지는 전략이 정한다.
316
336
 
317
- A JSON-stream CLI puts events on stdout and only its own error text on
318
- stderr, so folding the two loses nothing and leaves one reader. A text CLI
319
- splits meaning across them — the result on stdout, progress on stderr — and
320
- merging destroys the only way to tell the answer from the noise.
337
+ 한 스트림에 진행과 결과가 함께 오는 CLI 는 합쳐 읽어야 순서가 보존되고,
338
+ 둘을 갈라 내는 CLI 는 갈라 읽어야 답과 진행이 섞이지 않는다.
321
339
  """
322
- return subprocess.STDOUT if stream_format == STREAM_JSON else subprocess.PIPE
340
+ return subprocess.STDOUT if presentation.merges_stderr() else subprocess.PIPE
323
341
 
324
342
 
325
343
  def _pump(
326
344
  process: subprocess.Popen[bytes],
327
- log_file,
345
+ transcript: SessionTranscript,
328
346
  *,
329
- stream_format: str,
330
- normalise: Normalise,
347
+ strategy: Presentation,
331
348
  presentation: str,
332
349
  idle_timeout_seconds: int,
333
350
  stdin_text: str | None = None,
334
- observe_served_model: Callable[[Mapping[str, Any]], str | None],
335
- model_observation: _ServedModelObservation,
336
351
  ) -> tuple[int, bool, int]:
337
352
  selector = selectors.DefaultSelector()
338
- readers, finalize_log = _register_output(
339
- selector,
340
- process,
341
- log_file,
342
- stream_format=stream_format,
343
- normalise=normalise,
344
- observe_served_model=observe_served_model,
345
- model_observation=model_observation,
346
- presentation=presentation,
353
+ readers = _register_streams(
354
+ selector, process, strategy, transcript, presentation == LIVE
347
355
  )
348
356
  outgoing = _register_prompt(selector, process, stdin_text)
349
357
  closing_text: str | None = None
@@ -379,23 +387,19 @@ def _pump(
379
387
 
380
388
  for reader in readers.values():
381
389
  closing_text = reader.flush() or closing_text
382
- finalize_log()
383
390
 
384
391
  exit_code = process.wait()
385
- if closing_text is None:
386
- # A JSON stream is supposed to end in a result event. Ending without one
387
- # means the CLI stopped without saying what it concluded, and the caller
388
- # would otherwise read an empty stdout under `quiet` as a run that
389
- # simply had nothing to report. A text CLI has no result event to miss.
390
- if stream_format == STREAM_JSON:
391
- print(
392
- f"okstra worker: no result event in the CLI's output — "
393
- f"see {log_file.name}",
394
- file=sys.stderr,
395
- flush=True,
396
- )
397
- elif presentation == QUIET:
398
- print(closing_text, flush=True)
392
+ if closing_text is None and isinstance(strategy, JsonEvents):
393
+ # JSON 스트림은 결과 이벤트로 끝나야 한다. 텍스트를 흘리는 CLI 에는
394
+ # 놓칠 결과 이벤트가 없다.
395
+ transcript.note("no result event in the CLI's output")
396
+ elif closing_text is not None:
397
+ # Result 는 format_* 가 줄을 만들지 않는다. 아카이브 결론은 러너가 적는다.
398
+ # 상한 밖에 둔다 — 잘린 결론은 사후 분석 전체를 잃게 한다.
399
+ if isinstance(strategy, JsonEvents):
400
+ transcript.write("worker", closing_text, capped=False)
401
+ if presentation == QUIET:
402
+ print(closing_text, flush=True)
399
403
  return (_TIMEOUT_EXIT_CODE if timed_out else exit_code), timed_out, idle_seconds
400
404
 
401
405
 
@@ -432,55 +436,23 @@ def _stop_reading(selector: selectors.BaseSelector) -> None:
432
436
  selector.unregister(key.fileobj)
433
437
 
434
438
 
435
- def _register_output(
439
+ def _register_streams(
436
440
  selector: selectors.BaseSelector,
437
441
  process: subprocess.Popen[bytes],
438
- log_file,
439
- *,
440
- stream_format: str,
441
- normalise: Normalise,
442
- observe_served_model: Callable[[Mapping[str, Any]], str | None],
443
- model_observation: _ServedModelObservation,
444
- presentation: str,
445
- ) -> tuple[dict[int, _LineReader], Callable[[], None]]:
446
- """Register every stream this child speaks, each with its own destination.
447
-
448
- Returns the readers plus the finalizer a capped sink needs to report what it
449
- dropped once the streams are done. A JSON stream caps nothing.
450
- """
442
+ presentation: Presentation,
443
+ transcript: SessionTranscript,
444
+ live: bool,
445
+ ) -> dict[int, _LineReader]:
446
+ """이 자식이 말하는 스트림마다 전략이 지정한 싱크를 붙인다."""
451
447
  assert process.stdout is not None
452
- if stream_format == STREAM_JSON:
453
- sinks = [
454
- (
455
- process.stdout,
456
- partial(
457
- _emit_event,
458
- log_file,
459
- presentation,
460
- normalise,
461
- observe_served_model,
462
- model_observation,
463
- ),
464
- )
465
- ]
466
- finalize_log: Callable[[], None] = _nothing_to_finalize
467
- else:
468
- assert process.stderr is not None
469
- progress = _ProgressSink(log_file, presentation)
470
- sinks = [
471
- (process.stdout, partial(_emit_result, log_file)),
472
- (process.stderr, progress),
473
- ]
474
- finalize_log = progress.report_elided
448
+ streams = {"stdout": process.stdout, "stderr": process.stderr}
475
449
  readers: dict[int, _LineReader] = {}
476
- for stream, emit in sinks:
450
+ for channel, sink in presentation.sinks(transcript, live):
451
+ stream = streams[channel]
452
+ assert stream is not None, channel
477
453
  selector.register(stream, selectors.EVENT_READ)
478
- readers[stream.fileno()] = _LineReader(emit)
479
- return readers, finalize_log
480
-
481
-
482
- def _nothing_to_finalize() -> None:
483
- """A JSON stream elides nothing, so it has nothing to report at the end."""
454
+ readers[stream.fileno()] = _LineReader(sink)
455
+ return readers
484
456
 
485
457
 
486
458
  class _LineReader:
@@ -557,109 +529,6 @@ def _close_prompt(selector: selectors.BaseSelector, key: selectors.SelectorKey)
557
529
  key.fileobj.close()
558
530
 
559
531
 
560
- def _emit_result(log_file, line: str) -> None:
561
- """A text CLI's stdout is its answer, so the caller gets it in either mode.
562
-
563
- ``quiet`` withholds progress, not the result — and unlike a JSON stream
564
- there is no result event to hold back and print at the end, so the answer
565
- passes through as it arrives.
566
- """
567
- _write_log(log_file, [line])
568
- print(line, flush=True)
569
-
570
-
571
- class _ProgressSink:
572
- """A text CLI's stderr: shown live, archived up to a cap.
573
-
574
- Progress goes to this process's stderr rather than its stdout so the
575
- caller's stdout stays the result alone. A pane shows both, and a pane is
576
- exactly where ``live`` lands.
577
-
578
- Only the log copy is capped, never the screen and never the result stream.
579
- That asymmetry is the point: a truncated tool echo costs detail, while a
580
- truncated answer costs the whole post-mortem — and the caller's streams are
581
- read by a person or a subagent that was promised the CLI's output verbatim.
582
- """
583
-
584
- def __init__(self, log_file, presentation: str) -> None:
585
- self._log_file = log_file
586
- self._presentation = presentation
587
- self._archived = 0
588
- self._elided = 0
589
-
590
- def __call__(self, line: str) -> None:
591
- if self._presentation == LIVE:
592
- print(line, file=sys.stderr, flush=True)
593
- if self._archived < _LOG_PROGRESS_LINE_CAP:
594
- self._archived += 1
595
- _write_log(self._log_file, [line])
596
- return
597
- self._elided += 1
598
- # Periodic rather than only at the end: a reader tailing the log has to
599
- # see that the run is still producing progress, not a file that stopped.
600
- if self._elided % _ELISION_NOTICE_EVERY == 0:
601
- self._note_elision()
602
-
603
- def report_elided(self) -> None:
604
- """Close the archive with the exact total, if the last notice missed some."""
605
- if self._elided % _ELISION_NOTICE_EVERY:
606
- self._note_elision()
607
-
608
- def _note_elision(self) -> None:
609
- _write_log(
610
- self._log_file,
611
- [f" [okstra log-cap] {self._elided} progress line(s) elided"],
612
- )
613
-
614
-
615
- def _emit_event(
616
- log_file,
617
- presentation: str,
618
- normalise: Normalise,
619
- observe_served_model: Callable[[Mapping[str, Any]], str | None],
620
- model_observation: _ServedModelObservation,
621
- line: str,
622
- ) -> str | None:
623
- """Write one line of a JSON stream out; return closing text if this is it.
624
-
625
- The provider's own wire shape gets no further than ``normalise`` — what
626
- reaches the projections is the normalised vocabulary, which is the only
627
- thing this runner and the formatter are allowed to know.
628
- """
629
- stripped = line.strip()
630
- if not stripped:
631
- return None
632
- try:
633
- event = json.loads(stripped)
634
- except ValueError:
635
- # Not an event. The CLI's stderr is folded into this stream, so this is
636
- # where its own error text arrives — the caller has to see it, and the
637
- # archive alone does not show it to anyone. stderr rather than stdout so
638
- # it reaches the caller without posing as progress, in either mode.
639
- _write_log(log_file, [stripped])
640
- print(stripped, file=sys.stderr, flush=True)
641
- return None
642
- if not isinstance(event, dict):
643
- return None
644
- model_observation.record(observe_served_model(event))
645
-
646
- closing: str | None = None
647
- for entry in normalise(event):
648
- _write_log(log_file, format_log(entry))
649
- if presentation == LIVE:
650
- for row in format_live(entry):
651
- print(row, flush=True)
652
- text = final_text(entry)
653
- if text is not None:
654
- closing = text
655
- if closing is not None:
656
- # Whatever the presentation, the archive ends with the worker's own
657
- # conclusion — otherwise it stops at the last tool call and the reader
658
- # never learns what the run decided.
659
- _write_log(log_file, closing.splitlines())
660
- return closing
661
-
662
-
663
532
  def _attestation_payload(attestation: ServedModelAttestation) -> dict[str, Any]:
664
533
  return {
665
534
  "observedModel": attestation.observed_model,
@@ -669,12 +538,6 @@ def _attestation_payload(attestation: ServedModelAttestation) -> dict[str, Any]:
669
538
  }
670
539
 
671
540
 
672
- def _write_log(log_file, lines) -> None:
673
- for line in lines:
674
- log_file.write(line + "\n")
675
- log_file.flush()
676
-
677
-
678
541
  def _terminate(process: subprocess.Popen[bytes]) -> None:
679
542
  """SIGTERM the worker's process group, then SIGKILL whatever survives.
680
543
 
@@ -102,7 +102,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
102
102
  " - bite-sized stepwise execution order for the selected direction or legacy recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
103
103
  " - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
104
104
  " - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
105
- " - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
105
+ " - report record `frontmatter.approved: false` awaiting `--approve` or the in-session wizard\n"
106
106
  " - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)\n"
107
107
  " - one endStateCoverage row per brief end-state id, each mapped to the R-NNN row that carries it"
108
108
  ),
@@ -40,6 +40,29 @@ def log_path_for_prompt(prompt_path: Path) -> Path:
40
40
  return Path(f"{prompt_path}.log")
41
41
 
42
42
 
43
+ def mutation_snapshot_path_for_prompt(prompt_path: Path) -> Path:
44
+ """Where okstra writes this prompt's pre-dispatch mutation snapshot."""
45
+ return prompt_path.with_suffix(prompt_path.suffix + ".mutation-audit.json")
46
+
47
+
48
+ def prompt_derived_paths(prompt_path: Path) -> tuple[Path, ...]:
49
+ """The three files okstra writes beside a prompt: status, log, snapshot.
50
+
51
+ They are written together and must be authorised together. Listing them by
52
+ hand let the two sides drift: dispatch added the snapshot to its write
53
+ policy while `agent-prompt materialize` kept its own five-path list, so the
54
+ two policies hashed differently and `record_invocation_attempt` refused
55
+ every dynamically materialized invocation as `invocationRef drift` — which
56
+ took Phase 5.5 reverify with it. One function, so a fourth derived file
57
+ cannot reach one caller and miss the other.
58
+ """
59
+ return (
60
+ status_path_for_prompt(prompt_path),
61
+ log_path_for_prompt(prompt_path),
62
+ mutation_snapshot_path_for_prompt(prompt_path),
63
+ )
64
+
65
+
43
66
  def read_wrapper_status(path: Path) -> WrapperStatus | None:
44
67
  try:
45
68
  raw = json.loads(path.read_text(encoding="utf-8"))