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
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  import subprocess
6
7
  import time
7
8
  from dataclasses import dataclass, field, replace
@@ -110,6 +111,8 @@ from .worker_prompt_headers import (
110
111
  from .worker_artifact_paths import audit_sidecar_rel
111
112
  from .wrapper_status import (
112
113
  log_path_for_prompt,
114
+ mutation_snapshot_path_for_prompt,
115
+ prompt_derived_paths,
113
116
  read_wrapper_status,
114
117
  status_path_for_prompt,
115
118
  )
@@ -1462,7 +1465,7 @@ def _mutation_snapshot(
1462
1465
 
1463
1466
 
1464
1467
  def _mutation_snapshot_path(job: WorkerJob) -> Path:
1465
- return job.prompt_path.with_suffix(job.prompt_path.suffix + ".mutation-audit.json")
1468
+ return mutation_snapshot_path_for_prompt(job.prompt_path)
1466
1469
 
1467
1470
 
1468
1471
  def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
@@ -1517,6 +1520,11 @@ def _canonical_write_contract(
1517
1520
  raise DispatchError("worker write policy has no runner write capability")
1518
1521
  artifacts = _worker_artifact_paths(plan, job)
1519
1522
  worktree = Path(job.worktree_path) if job.worktree_path else None
1523
+ planned = (
1524
+ planned_paths_from_run_manifest(plan.project_root, plan.manifest)
1525
+ if execution.role == "implementer"
1526
+ else ((), True)
1527
+ )
1520
1528
  try:
1521
1529
  return build_invocation_write_contract(
1522
1530
  role=execution.role,
@@ -1524,11 +1532,8 @@ def _canonical_write_contract(
1524
1532
  worktree=worktree,
1525
1533
  artifact_paths=artifacts,
1526
1534
  maximum_precision=capability.max_boundary_precision,
1527
- planned_paths=(
1528
- planned_paths_from_run_manifest(plan.project_root, plan.manifest)
1529
- if execution.role == "implementer"
1530
- else ()
1531
- ),
1535
+ planned_paths=planned[0],
1536
+ planned_paths_declared=planned[1],
1532
1537
  auxiliary_roots=verifier_extra_dirs(execution.role),
1533
1538
  validated_auxiliary_roots=verifier_extra_dirs(execution.role),
1534
1539
  )
@@ -1545,14 +1550,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
1545
1550
  job.worker_result_path,
1546
1551
  *job.completion_paths,
1547
1552
  Path(audit_sidecar_rel(str(job.worker_result_path))),
1548
- status_path_for_prompt(job.prompt_path),
1549
- log_path_for_prompt(job.prompt_path),
1550
1553
  # The prompt's three derived files are written together and belong in
1551
1554
  # one list. The audit snapshot used to be listed only for the job whose
1552
1555
  # snapshot it was, so a sibling's snapshot — written by okstra as that
1553
1556
  # sibling started — landed inside this worker's window as an
1554
1557
  # unauthorized artifact-root change.
1555
- _mutation_snapshot_path(job),
1558
+ *prompt_derived_paths(job.prompt_path),
1556
1559
  }
1557
1560
  error_logs = active_context.get("errorLogs")
1558
1561
  if isinstance(error_logs, Mapping):
@@ -1914,8 +1917,41 @@ def _audit_attempt(
1914
1917
  )
1915
1918
 
1916
1919
 
1920
+ def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1921
+ """워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
1922
+
1923
+ 감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
1924
+ 마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
1925
+ 리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
1926
+ 않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
1927
+ 판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
1928
+ 토큰이 경로다.
1929
+ """
1930
+ if not result_path.is_file() or result_path.suffix != ".md":
1931
+ return ()
1932
+ try:
1933
+ text = result_path.read_text(encoding="utf-8")
1934
+ except (OSError, UnicodeError):
1935
+ return ()
1936
+ paths: list[str] = []
1937
+ inside = False
1938
+ for line in text.splitlines():
1939
+ stripped = line.strip()
1940
+ if stripped.startswith("#"):
1941
+ inside = stripped.lstrip("#").strip().lower() == "out-of-plan edits"
1942
+ continue
1943
+ if not inside or not stripped.startswith("- "):
1944
+ continue
1945
+ quoted = re.findall(r"`([^`\n]+)`", stripped)
1946
+ if quoted and quoted[0].strip():
1947
+ paths.append(quoted[0].strip())
1948
+ return tuple(paths)
1949
+
1950
+
1917
1951
  def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
1918
- if not result_path.is_file() or result_path.suffix != ".json":
1952
+ if result_path.suffix != ".json":
1953
+ return _declared_out_of_plan_paths(result_path)
1954
+ if not result_path.is_file():
1919
1955
  return ()
1920
1956
  try:
1921
1957
  payload = json.loads(result_path.read_text(encoding="utf-8"))
@@ -2917,7 +2953,7 @@ def _reject_stale_schema_excerpt(
2917
2953
  )
2918
2954
  if writer is None:
2919
2955
  return
2920
- expected = _string_value(manifest.get("expectedReportPath"))
2956
+ expected = _string_value(manifest.get("expectedReportRecordPath"))
2921
2957
  if not expected:
2922
2958
  return
2923
2959
  excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
@@ -46,10 +46,7 @@ from .execution_manifest import (
46
46
  record_invocation_attempt,
47
47
  )
48
48
  from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
49
- from .final_report_paths import (
50
- final_report_data_path,
51
- final_report_markdown_path,
52
- )
49
+ from .final_report_paths import final_report_data_path
53
50
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
54
51
  from .worker_prompt_contract import (
55
52
  PromptRecord,
@@ -59,7 +56,11 @@ from .worker_prompt_contract import (
59
56
  from .worker_runner import LIVE, QUIET
60
57
  from .worker_request import verifier_extra_dirs
61
58
  from .worker_artifact_paths import audit_sidecar_rel
62
- from .wrapper_status import log_path_for_prompt, status_path_for_prompt
59
+ from .wrapper_status import (
60
+ log_path_for_prompt,
61
+ prompt_derived_paths,
62
+ status_path_for_prompt,
63
+ )
63
64
  from .write_policy import (
64
65
  build_invocation_write_contract,
65
66
  planned_paths_from_run_manifest,
@@ -812,6 +813,11 @@ def _agent_write_contract(
812
813
  maximum_precision = capability.max_boundary_precision
813
814
  else:
814
815
  raise DispatchError("agent write policy has no runner capability")
816
+ planned = (
817
+ planned_paths_from_run_manifest(project_root, authority)
818
+ if execution.role == "implementer"
819
+ else ((), True)
820
+ )
815
821
  try:
816
822
  return build_invocation_write_contract(
817
823
  role=execution.role,
@@ -819,11 +825,8 @@ def _agent_write_contract(
819
825
  worktree=worktree,
820
826
  artifact_paths=artifacts,
821
827
  maximum_precision=maximum_precision,
822
- planned_paths=(
823
- planned_paths_from_run_manifest(project_root, authority)
824
- if execution.role == "implementer"
825
- else ()
826
- ),
828
+ planned_paths=planned[0],
829
+ planned_paths_declared=planned[1],
827
830
  auxiliary_roots=verifier_extra_dirs(execution.role),
828
831
  validated_auxiliary_roots=verifier_extra_dirs(execution.role),
829
832
  )
@@ -927,8 +930,7 @@ def _prompt_write_paths(
927
930
  artifacts = {
928
931
  _project_or_absolute(project_root, value) for value in artifact_values
929
932
  }
930
- artifacts.add(status_path_for_prompt(prompt_path))
931
- artifacts.add(log_path_for_prompt(prompt_path))
933
+ artifacts.update(prompt_derived_paths(prompt_path))
932
934
  worktree_value = values.get("Worktree")
933
935
  worktree = (
934
936
  _project_or_absolute(project_root, worktree_value)
@@ -1467,7 +1469,7 @@ def dispatch_result_path(
1467
1469
  if worker_id != REPORT_WRITER_WORKER_ID:
1468
1470
  return worker_result_path
1469
1471
  return final_report_data_path(
1470
- resolve_required_path(project_root, manifest, "expectedReportPath")
1472
+ resolve_required_path(project_root, manifest, "expectedReportRecordPath")
1471
1473
  )
1472
1474
 
1473
1475
 
@@ -1479,17 +1481,16 @@ def dispatch_completion_paths(
1479
1481
  ) -> tuple[Path, ...]:
1480
1482
  """Every artifact that must exist before this worker counts as done.
1481
1483
 
1482
- The report writer's three are the data.json, its rendered Markdown sibling,
1483
- and the worker-result pointer (`prompts/lead/report-writer.md` §"Completion
1484
- detection"). Same reason as `dispatch_result_path`: both constructors need
1485
- the same answer.
1484
+ The report writer's two are the report record and the worker-result
1485
+ pointer. The full reading copy is rendered on demand and is not a
1486
+ completion artifact.
1486
1487
  """
1487
1488
  if worker_id != REPORT_WRITER_WORKER_ID:
1488
1489
  return (worker_result_path,)
1489
1490
  data_json = final_report_data_path(
1490
- resolve_required_path(project_root, manifest, "expectedReportPath")
1491
+ resolve_required_path(project_root, manifest, "expectedReportRecordPath")
1491
1492
  )
1492
- return (data_json, final_report_markdown_path(data_json), worker_result_path)
1493
+ return (data_json, worker_result_path)
1493
1494
 
1494
1495
 
1495
1496
  def validate_initial_prompts(
@@ -7,30 +7,16 @@ regardless of provider — see ``ExecutionPolicy``.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- from dataclasses import dataclass, field
10
+ from dataclasses import dataclass
11
11
  from pathlib import Path
12
- from collections.abc import Callable, Mapping
13
- from typing import Any, Literal, Protocol, runtime_checkable
12
+ from typing import Literal, Protocol, runtime_checkable
14
13
 
15
- from .worker_stream import Normalise, no_events
14
+ from .worker_presentation import Presentation
16
15
  from ..write_policy import WriteEnforcement, WritePolicy
17
16
 
18
- # What ``ExecCommand.stream_format`` may hold. The value decides whether the
19
- # runner pipes the CLI's output through the stream formatter or forwards it
20
- # unchanged, so it is part of the contract rather than a display hint.
21
- STREAM_JSON = "stream-json"
22
- TEXT = "text"
23
17
  SERVED_MODEL_MISMATCH_EXIT_CODE = 78
24
18
 
25
19
 
26
- def no_model_observation(_event: Mapping[str, Any]) -> str | None:
27
- """Truthful default for provider output that exposes no model identity."""
28
- return None
29
-
30
-
31
- ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
32
-
33
-
34
20
  @dataclass(frozen=True)
35
21
  class ExecutionPolicy:
36
22
  """How a non-interactive worker is allowed to act.
@@ -75,31 +61,24 @@ class WorkerExecRequest:
75
61
 
76
62
  @dataclass(frozen=True)
77
63
  class ExecCommand:
78
- """One provider invocation, fully resolved.
64
+ """공급자 호출 하나, 완전히 해소된 상태.
79
65
 
80
- ``stdin_text`` is None when the provider takes its prompt as an argument
81
- rather than on stdin.
66
+ ``stdin_text`` 는 프롬프트를 인자로 받는 CLI 에서 None 이다.
82
67
 
83
- ``cwd`` is part of the contract because it is not derivable from the argv:
84
- some CLIs name their working directory in a flag, others inherit the
85
- process's, and the two groups do not choose the same directory. A runner
86
- that had to re-derive it would be guessing at what the strategy already
87
- decided.
68
+ ``cwd`` 가 계약에 있는 이유는 argv 에서 유도할 수 없기 때문이다 — 어떤
69
+ CLI 는 작업 디렉터리를 플래그로 받고 어떤 CLI 는 프로세스의 것을 물려받는데,
70
+ 두 무리가 같은 디렉터리를 고르지 않는다.
88
71
 
89
- ``normalise`` is the companion of ``stream_format``: declaring a JSON stream
90
- without saying how to read it is what left one provider's pane blank for a
91
- whole run. The two are named together so a provider whose events are shaped
92
- differently cannot be silently handed a formatter that cannot see them.
72
+ ``presentation`` 은 이 CLI 의 출력을 무엇으로 볼 것인가를 정한다. 스트림
73
+ 형식을 따로 선언하고 읽는 법을 나중에 붙이던 구조에서는, 형식만 선언하고
74
+ 읽는 법이 어긋난 공급자의 화면이 런 내내 비어 있었다. 둘을 한 값으로 묶어
75
+ 그 상태를 표현 불가능하게 만든다.
93
76
  """
94
77
 
95
78
  argv: tuple[str, ...]
96
79
  stdin_text: str | None
97
- stream_format: str
98
80
  cwd: Path
99
- normalise: Normalise = field(default=no_events)
100
- observe_served_model: ObserveServedModel = field(
101
- default=no_model_observation
102
- )
81
+ presentation: Presentation
103
82
 
104
83
 
105
84
  @dataclass(frozen=True)
@@ -0,0 +1,128 @@
1
+ """워커 출력의 표현 전략.
2
+
3
+ 공급자 CLI 는 저마다 다른 것을 뱉는다 — 사람이 읽는 텍스트를 흘리는 CLI 도
4
+ 있고, 자기 어휘의 JSON 을 흘리는 CLI 도 있다. okstra 가 그 형식을 알아야만
5
+ 화면이 나오는 구조에서는 공급자가 형식을 바꿀 때마다 화면이 조용히 빈다.
6
+ 그래서 "해석하지 않는다" 를 1급 선택지로 둔다.
7
+
8
+ 전략은 두 가지를 답한다. stderr 를 stdout 에 합칠 것인가, 그리고 각 스트림의
9
+ 줄을 누가 받는가. 둘을 함께 두는 이유는 배치와 해석이 짝이기 때문이다 —
10
+ JSON 을 요청하면 한 스트림으로 합쳐 읽어야 하고, 결과와 진행을 나눠 내는
11
+ CLI 는 갈라 읽어야 한다.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
19
+
20
+ from .worker_stream import (
21
+ Normalise,
22
+ final_text,
23
+ format_live,
24
+ format_log,
25
+ )
26
+
27
+ Sink = Callable[[str], str | None]
28
+ Channel = Literal["stdout", "stderr"]
29
+ SinkSpec = tuple[Channel, Sink]
30
+ ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
31
+
32
+ WORKER = "worker"
33
+
34
+
35
+ class TranscriptWriter(Protocol):
36
+ """세션 기록에 한 줄을 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
37
+
38
+ def write(self, speaker: str, line: str) -> None: ...
39
+
40
+
41
+ @runtime_checkable
42
+ class Presentation(Protocol):
43
+ def merges_stderr(self) -> bool: ...
44
+
45
+ def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]: ...
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class MergedText:
50
+ """CLI 가 사람에게 보여주는 출력을 그대로 흘린다.
51
+
52
+ 한 스트림에 진행과 결과가 함께 온다. 해석하지 않으므로 공급자가 형식을
53
+ 바꿔도 화면이 깨지지 않는다.
54
+ """
55
+
56
+ served_model_at_exit: Callable[[str, Path], str | None] | None = None
57
+
58
+ def merges_stderr(self) -> bool:
59
+ return True
60
+
61
+ def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
62
+ def emit(line: str) -> str | None:
63
+ writer.write(WORKER, line)
64
+ return line
65
+
66
+ return (("stdout", emit),)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SplitText:
71
+ """결과와 진행을 다른 스트림으로 내는 CLI.
72
+
73
+ stdout 은 답이고 stderr 는 진행이다. 답은 어느 모드에서도 호출자에게
74
+ 가야 하므로 종결 텍스트로 돌려주고, 진행은 기록과 화면에만 남는다.
75
+ """
76
+
77
+ def merges_stderr(self) -> bool:
78
+ return False
79
+
80
+ def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
81
+ def result(line: str) -> str | None:
82
+ writer.write(WORKER, line)
83
+ return line
84
+
85
+ def progress(line: str) -> str | None:
86
+ writer.write(WORKER, line)
87
+ return None
88
+
89
+ return (("stdout", result), ("stderr", progress))
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class JsonEvents:
94
+ """공급자 어휘의 JSON 을 공통 이벤트로 옮겨 적는다.
95
+
96
+ 사람이 읽는 출력에 진행이 없는 CLI 를 위한 경로다. 어휘를 아는 대가로
97
+ 도구 호출을 구조로 보여줄 수 있다.
98
+ """
99
+
100
+ normalise: Normalise
101
+ observe: ObserveServedModel
102
+
103
+ def merges_stderr(self) -> bool:
104
+ return True
105
+
106
+ def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
107
+ def emit(line: str) -> str | None:
108
+ stripped = line.strip()
109
+ if not stripped:
110
+ return None
111
+ try:
112
+ event = json.loads(stripped)
113
+ except ValueError:
114
+ # 이벤트가 아니다. CLI 자신의 오류 텍스트가 이 스트림으로
115
+ # 오므로 삼키면 아무도 못 본다.
116
+ writer.write(WORKER, stripped)
117
+ return None
118
+ if not isinstance(event, dict):
119
+ return None
120
+ self.observe(event)
121
+ closing: str | None = None
122
+ for entry in self.normalise(event):
123
+ for row in (format_live(entry) if live else format_log(entry)):
124
+ writer.write(WORKER, row)
125
+ closing = final_text(entry) or closing
126
+ return closing
127
+
128
+ return (("stdout", emit),)
@@ -571,6 +571,11 @@ def _git_policy_failures(
571
571
  if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
572
572
  failures.append("final HEAD is not a fast-forward descendant")
573
573
  allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
574
+ if policy.source_policy.get("plannedPathsDeclared") is False:
575
+ # The plan predates the declared path column, so `allowed` was derived
576
+ # from prose and is not a ledger anyone can be held to. Every other
577
+ # check above still applies; only the path comparison stands down.
578
+ return failures
574
579
  if any(
575
580
  not paths <= allowed
576
581
  for paths in _commit_paths_by_commit(
@@ -14,9 +14,46 @@ def _stem(data_path: Path) -> str:
14
14
  return name[: -len(DATA_JSON_SUFFIX)] if name.endswith(DATA_JSON_SUFFIX) else data_path.stem
15
15
 
16
16
 
17
+ def require_approved_plan_record(path: Path) -> Path:
18
+ """`--approved-plan` accepts the report record only.
19
+
20
+ A full reading copy (`.md`) is rejected with the sibling record path.
21
+ Schema-v1 plans have no record and cannot enter this gate.
22
+ """
23
+ resolved = Path(path)
24
+ if is_full_reading_copy_path(resolved):
25
+ suggested = final_report_data_path(resolved)
26
+ raise ValueError(
27
+ f"--approved-plan must be the report record (.data.json), not the "
28
+ f"full reading copy: {path}\n"
29
+ " schema-v1 plans have no record and cannot be used here.\n"
30
+ f" for a schema-v2 plan use: {suggested}"
31
+ )
32
+ if not is_report_record_path(resolved):
33
+ raise ValueError(
34
+ f"--approved-plan must be a report record ending in .data.json: {path}"
35
+ )
36
+ if not resolved.is_file():
37
+ raise ValueError(f"approved plan record not found: {path}")
38
+ return resolved
39
+
40
+
41
+ def is_report_record_path(path: Path) -> bool:
42
+ return path.name.endswith(DATA_JSON_SUFFIX)
43
+
44
+
45
+ def is_full_reading_copy_path(path: Path) -> bool:
46
+ return path.name.endswith(MARKDOWN_SUFFIX)
47
+
48
+
17
49
  def final_report_data_path(report_path: Path) -> Path:
18
- """Return the data.json sibling for a final-report markdown path."""
50
+ """Return the report record path for a final-report pair.
51
+
52
+ Already a `.data.json` path is returned unchanged.
53
+ """
19
54
  name = report_path.name
55
+ if name.endswith(DATA_JSON_SUFFIX):
56
+ return report_path
20
57
  if name.endswith(MARKDOWN_SUFFIX):
21
58
  return report_path.with_name(name[: -len(MARKDOWN_SUFFIX)] + DATA_JSON_SUFFIX)
22
59
  return report_path.with_suffix(DATA_JSON_SUFFIX)
@@ -30,6 +67,45 @@ def final_report_markdown_path(data_path: Path) -> Path:
30
67
  return data_path.with_suffix(MARKDOWN_SUFFIX)
31
68
 
32
69
 
70
+ def report_record_rel_from_legacy_pointer(relative: str) -> str:
71
+ """Turn a stored pointer into a report-record relative path.
72
+
73
+ New rows store the record. Rows written before that stored the full
74
+ reading copy (`.md`); the record is the sibling `.data.json`.
75
+ """
76
+ relative = str(relative or "")
77
+ if not relative:
78
+ return ""
79
+ path = Path(relative)
80
+ if is_report_record_path(path):
81
+ return relative
82
+ return str(final_report_data_path(path))
83
+
84
+
85
+ def index_report_record_rel(row: dict) -> str:
86
+ """Report-record pointer from a run-index row.
87
+
88
+ New rows use `finalReportRecordRel`. Older rows used `finalReportRel`
89
+ for the full reading copy.
90
+ """
91
+ current = str(row.get("finalReportRecordRel") or "")
92
+ if current:
93
+ return current
94
+ return report_record_rel_from_legacy_pointer(str(row.get("finalReportRel") or ""))
95
+
96
+
97
+ def timeline_report_record_rel(run: dict) -> str:
98
+ """Report-record pointer from a timeline / recap run entry.
99
+
100
+ New entries use `reportRecordPath`. Older entries used `reportPath`
101
+ for the full reading copy.
102
+ """
103
+ current = str(run.get("reportRecordPath") or "")
104
+ if current:
105
+ return current
106
+ return report_record_rel_from_legacy_pointer(str(run.get("reportPath") or ""))
107
+
108
+
33
109
  def translation_source_path(data_path: Path) -> Path:
34
110
  """Return the translator's work list for a final-report data.json path."""
35
111
  return data_path.with_name(_stem(data_path) + TRANSLATION_SOURCE_SUFFIX)
@@ -81,8 +81,7 @@ def latest_whole_task_fv_release_ready(project_root, project_id: str,
81
81
  if ((data.get("header") or {}).get("taskType") == "final-verification"
82
82
  and data.get("verificationScope") == "whole-task"
83
83
  and release_handoff_allowed(data)):
84
- md = final_report_markdown_path(dj)
85
- return str(md if md.is_file() else dj)
84
+ return str(dj)
86
85
  return ""
87
86
 
88
87
 
@@ -212,7 +212,7 @@ def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
212
212
  entry["nextRecommendedPhase"] = next_phase.promote(
213
213
  workflow.get("nextRecommendedPhase")
214
214
  )
215
- entry["latestReportPath"] = manifest.get("latestReportPath", "")
215
+ entry["latestReportRecordPath"] = manifest.get("latestReportRecordPath", "")
216
216
  entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
217
217
  entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))
218
218
  entry["phaseOutcome"] = outcome
@@ -33,7 +33,7 @@ def _replace_or_append_row(path: Path, run_id: str, row: dict) -> None:
33
33
  def record_start(home: Path, *, project_id: str, project_root: str,
34
34
  task_group: str, task_id: str, task_type: str,
35
35
  run_seq: int, when: str, workers: list, lead_model: str,
36
- run_dir_rel: str, final_report_rel: str,
36
+ run_dir_rel: str, final_report_record_rel: str,
37
37
  argv: list, cwd: str,
38
38
  env_overrides: dict,
39
39
  okstra_version: str = "",
@@ -55,7 +55,7 @@ def record_start(home: Path, *, project_id: str, project_root: str,
55
55
  run_seq=run_seq, status=initial_status, started_at=when,
56
56
  finished_at=when if is_terminal else None,
57
57
  workers=workers, lead_model=lead_model, validation="not-run",
58
- run_dir_rel=run_dir_rel, final_report_rel=final_report_rel,
58
+ run_dir_rel=run_dir_rel, final_report_record_rel=final_report_record_rel,
59
59
  final_status_rel=final_status_rel,
60
60
  execution_manifest_path=execution_manifest_path,
61
61
  role_execution_refs=role_execution_refs)
@@ -115,7 +115,7 @@ def reserve_run_in_active(home: Path, *,
115
115
  project_id: str, project_root: str,
116
116
  task_group: str, task_id: str, task_type: str,
117
117
  run_seq: int, when: str,
118
- run_dir_rel: str, final_report_rel: str,
118
+ run_dir_rel: str, final_report_record_rel: str,
119
119
  final_status_rel: str = "") -> None:
120
120
  """예약 row 를 active.jsonl 에 즉시 기록한다. 중앙 락 보호 필수.
121
121
  record_start 가 나중에 같은 (project, group, task, task_type, seq) 를 만나면
@@ -126,7 +126,7 @@ def reserve_run_in_active(home: Path, *,
126
126
  task_group=task_group, task_id=task_id, task_type=task_type,
127
127
  run_seq=run_seq, status="reserving", started_at=when,
128
128
  finished_at=None, workers=[], lead_model="", validation="not-run",
129
- run_dir_rel=run_dir_rel, final_report_rel=final_report_rel,
129
+ run_dir_rel=run_dir_rel, final_report_record_rel=final_report_record_rel,
130
130
  final_status_rel=final_status_rel)
131
131
  append_jsonl(home / "active.jsonl", row)
132
132
  # project index 도 미리 한 줄 — record_start 가 나중에 update 하므로 동일 row 형태로.