okstra 0.178.0 → 0.179.1

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 (114) 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 +3 -3
  31. package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
  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/prompts/wizard/prompts.ko.json +1 -0
  43. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  45. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  46. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  47. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  48. package/runtime/python/okstra_ctl/agent_prompt_cli.py +77 -6
  49. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  50. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  51. package/runtime/python/okstra_ctl/backfill.py +12 -5
  52. package/runtime/python/okstra_ctl/consumers.py +70 -3
  53. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  54. package/runtime/python/okstra_ctl/convergence_store.py +13 -2
  55. package/runtime/python/okstra_ctl/dispatch_core.py +85 -14
  56. package/runtime/python/okstra_ctl/dispatch_state.py +34 -20
  57. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  58. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  59. package/runtime/python/okstra_ctl/execution_manifest.py +22 -3
  60. package/runtime/python/okstra_ctl/execution_mutation_audit.py +35 -1
  61. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  62. package/runtime/python/okstra_ctl/handoff.py +1 -2
  63. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  64. package/runtime/python/okstra_ctl/index.py +4 -4
  65. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  66. package/runtime/python/okstra_ctl/listing.py +4 -2
  67. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  68. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  69. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  70. package/runtime/python/okstra_ctl/paths.py +24 -15
  71. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  72. package/runtime/python/okstra_ctl/recap.py +3 -2
  73. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  74. package/runtime/python/okstra_ctl/render.py +22 -22
  75. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  76. package/runtime/python/okstra_ctl/rollup.py +1 -1
  77. package/runtime/python/okstra_ctl/run.py +170 -294
  78. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  79. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  80. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  81. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  82. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  83. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  84. package/runtime/python/okstra_ctl/user_response.py +97 -12
  85. package/runtime/python/okstra_ctl/wizard.py +102 -78
  86. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  87. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  88. package/runtime/python/okstra_ctl/workflow.py +1 -1
  89. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  90. package/runtime/python/okstra_ctl/write_policy.py +51 -9
  91. package/runtime/python/okstra_project/state.py +2 -2
  92. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  93. package/runtime/python/okstra_token_usage/cli.py +3 -3
  94. package/runtime/python/okstra_token_usage/report.py +7 -24
  95. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  96. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  97. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  98. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  99. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  100. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  101. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  102. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  103. package/runtime/skills/okstra-run/SKILL.md +1 -1
  104. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  105. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  106. package/runtime/templates/project-docs/task-index.template.md +1 -1
  107. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  108. package/runtime/validators/forbidden_actions.py +76 -5
  109. package/runtime/validators/lib/fixtures.sh +14 -10
  110. package/runtime/validators/lib/runners.sh +1 -1
  111. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  112. package/runtime/validators/validate-report-views.py +1 -1
  113. package/runtime/validators/validate-run.py +95 -37
  114. package/runtime/validators/validate_session_conformance.py +44 -8
@@ -87,8 +87,19 @@ def reserve_dynamic_verifier(
87
87
  input_digest: str,
88
88
  invocation_ref: str | None = None,
89
89
  artifact_paths: tuple[Path, ...],
90
+ worktree: Path | None = None,
90
91
  ) -> tuple[RoleExecution, Invocation]:
91
- """Reserve one provider-neutral verifier identity for a logical round."""
92
+ """Reserve one provider-neutral verifier identity for a logical round.
93
+
94
+ `worktree` 는 이 런의 워커가 실제로 서는 루트다. 디스패치가 정책을 다시
95
+ 계산할 때 쓰는 값(`dispatch_core._canonical_write_contract` 이 job 의
96
+ worktree 를 넘긴다)과 같아야 한다. 여기서 None 으로 고정하면 예약된 정책의
97
+ `sourcePolicy.allowedRoot` 는 프로젝트 루트, 디스패치가 계산한 정책은 스테이지
98
+ 워크트리가 되어 writePolicyDigest 가 갈리고, 같은 invocationRef 가
99
+ `invocationRef drift` 로 거부된다 — 워크트리를 쓰는 런(implementation stage)
100
+ 에서만 나타나고 워크트리가 없는 런(implementation-planning)에서는 안 나타나
101
+ 버전 문제로 보이기 쉽다.
102
+ """
92
103
  if round_number < 1:
93
104
  raise ExecutionManifestError("dynamic verifier round must be positive")
94
105
  manifest_path = Path(manifest_path).resolve()
@@ -128,7 +139,7 @@ def reserve_dynamic_verifier(
128
139
  policy, enforcement = build_invocation_write_contract(
129
140
  role="verifier",
130
141
  project_root=project_root,
131
- worktree=None,
142
+ worktree=worktree,
132
143
  artifact_paths=artifact_paths,
133
144
  maximum_precision=capability.max_boundary_precision,
134
145
  auxiliary_roots=verifier_extra_dirs("verifier"),
@@ -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
  )
@@ -356,7 +359,9 @@ def build_dispatch_plan(
356
359
  default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
357
360
  )
358
361
  if jobs_file:
359
- jobs = _jobs_from_file(project_root, workspace_root, jobs_file, manifest, options)
362
+ jobs = _jobs_from_file(
363
+ project_root, workspace_root, jobs_file, manifest, active_context, options
364
+ )
360
365
  else:
361
366
  jobs = _jobs_from_roster(
362
367
  project_root,
@@ -413,7 +418,10 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
413
418
  return 0
414
419
  round_artifact_paths = _round_artifact_paths(plan)
415
420
  handles = [
416
- _spawn_job(plan, job, 1, batch_artifact_paths=round_artifact_paths)
421
+ _spawn_job(
422
+ plan, job, _next_attempt(plan, job),
423
+ batch_artifact_paths=round_artifact_paths,
424
+ )
417
425
  for job in plan.jobs
418
426
  ]
419
427
  _record_dispatch_facts(plan.team_state_path, _mode_from_handles(handles))
@@ -1289,6 +1297,7 @@ def _jobs_from_file(
1289
1297
  workspace_root: Path,
1290
1298
  jobs_file: Path | None,
1291
1299
  manifest: Mapping[str, Any],
1300
+ active_context: Mapping[str, Any],
1292
1301
  options: _BuildOptions,
1293
1302
  ) -> list[WorkerJob]:
1294
1303
  if jobs_file is None:
@@ -1297,6 +1306,7 @@ def _jobs_from_file(
1297
1306
  project_root,
1298
1307
  jobs_file,
1299
1308
  manifest=manifest,
1309
+ active_context=active_context,
1300
1310
  backend=options.default_backend,
1301
1311
  idle_timeout_seconds=options.idle_timeout_seconds,
1302
1312
  default_dispatch_kind=options.dispatch_kind,
@@ -1387,6 +1397,34 @@ def _spawn_cli_job_nonblocking(
1387
1397
  )
1388
1398
 
1389
1399
 
1400
+ def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1401
+ """이 invocation 이 다음에 청구할 attempt 번호.
1402
+
1403
+ attempt 를 1 로 고정하면 같은 워커를 두 번째로 디스패치할 수 없다. 원장은
1404
+ 단조 증가를 요구하므로(`execution_manifest._validate_next_attempt`) 두 번째
1405
+ 호출이 항상 `next attempt must be 2` 로 거부되고, 재시도 예산이 계약에는
1406
+ 있는데 그 경로에는 쓸 수단이 없는 상태가 된다. 예산은 프로세스가 아니라
1407
+ invocation 에 붙어 있고, 그 잔액이 적힌 곳은 원장뿐이다.
1408
+ """
1409
+ if not job.has_execution_identity:
1410
+ return job.attempt
1411
+ manifest = read_execution_manifest(plan.manifest_path)
1412
+ prior = [
1413
+ row.attempt for row in manifest.attempts
1414
+ if row.invocation_ref == job.invocation_ref
1415
+ ]
1416
+ if not prior:
1417
+ return job.attempt
1418
+ spent = max(prior)
1419
+ if spent >= MAX_WORKER_ATTEMPTS:
1420
+ raise DispatchError(
1421
+ f"worker retry budget is spent: {job.invocation_ref} used "
1422
+ f"{spent} of {MAX_WORKER_ATTEMPTS} attempts. Materialize a new "
1423
+ "invocation to dispatch this worker again."
1424
+ )
1425
+ return spent + 1
1426
+
1427
+
1390
1428
  def _prepare_job_attempt(
1391
1429
  plan: DispatchPlan, job: WorkerJob, attempt: int
1392
1430
  ) -> WorkerJob:
@@ -1462,7 +1500,7 @@ def _mutation_snapshot(
1462
1500
 
1463
1501
 
1464
1502
  def _mutation_snapshot_path(job: WorkerJob) -> Path:
1465
- return job.prompt_path.with_suffix(job.prompt_path.suffix + ".mutation-audit.json")
1503
+ return mutation_snapshot_path_for_prompt(job.prompt_path)
1466
1504
 
1467
1505
 
1468
1506
  def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
@@ -1517,6 +1555,11 @@ def _canonical_write_contract(
1517
1555
  raise DispatchError("worker write policy has no runner write capability")
1518
1556
  artifacts = _worker_artifact_paths(plan, job)
1519
1557
  worktree = Path(job.worktree_path) if job.worktree_path else None
1558
+ planned = (
1559
+ planned_paths_from_run_manifest(plan.project_root, plan.manifest)
1560
+ if execution.role == "implementer"
1561
+ else ((), True)
1562
+ )
1520
1563
  try:
1521
1564
  return build_invocation_write_contract(
1522
1565
  role=execution.role,
@@ -1524,11 +1567,8 @@ def _canonical_write_contract(
1524
1567
  worktree=worktree,
1525
1568
  artifact_paths=artifacts,
1526
1569
  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
- ),
1570
+ planned_paths=planned[0],
1571
+ planned_paths_declared=planned[1],
1532
1572
  auxiliary_roots=verifier_extra_dirs(execution.role),
1533
1573
  validated_auxiliary_roots=verifier_extra_dirs(execution.role),
1534
1574
  )
@@ -1545,14 +1585,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
1545
1585
  job.worker_result_path,
1546
1586
  *job.completion_paths,
1547
1587
  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
1588
  # The prompt's three derived files are written together and belong in
1551
1589
  # one list. The audit snapshot used to be listed only for the job whose
1552
1590
  # snapshot it was, so a sibling's snapshot — written by okstra as that
1553
1591
  # sibling started — landed inside this worker's window as an
1554
1592
  # unauthorized artifact-root change.
1555
- _mutation_snapshot_path(job),
1593
+ *prompt_derived_paths(job.prompt_path),
1556
1594
  }
1557
1595
  error_logs = active_context.get("errorLogs")
1558
1596
  if isinstance(error_logs, Mapping):
@@ -1619,7 +1657,7 @@ def _dispatch_round(dispatch_kind: str) -> int:
1619
1657
 
1620
1658
 
1621
1659
  def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
1622
- for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
1660
+ for attempt in range(_next_attempt(plan, job), MAX_WORKER_ATTEMPTS + 1):
1623
1661
  handle = _spawn_job(plan, job, attempt)
1624
1662
  if handle.completed_process is None:
1625
1663
  return await_dispatches(plan, timeout_seconds=None)
@@ -1914,8 +1952,41 @@ def _audit_attempt(
1914
1952
  )
1915
1953
 
1916
1954
 
1955
+ def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1956
+ """워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
1957
+
1958
+ 감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
1959
+ 마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
1960
+ 리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
1961
+ 않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
1962
+ 판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
1963
+ 토큰이 경로다.
1964
+ """
1965
+ if not result_path.is_file() or result_path.suffix != ".md":
1966
+ return ()
1967
+ try:
1968
+ text = result_path.read_text(encoding="utf-8")
1969
+ except (OSError, UnicodeError):
1970
+ return ()
1971
+ paths: list[str] = []
1972
+ inside = False
1973
+ for line in text.splitlines():
1974
+ stripped = line.strip()
1975
+ if stripped.startswith("#"):
1976
+ inside = stripped.lstrip("#").strip().lower() == "out-of-plan edits"
1977
+ continue
1978
+ if not inside or not stripped.startswith("- "):
1979
+ continue
1980
+ quoted = re.findall(r"`([^`\n]+)`", stripped)
1981
+ if quoted and quoted[0].strip():
1982
+ paths.append(quoted[0].strip())
1983
+ return tuple(paths)
1984
+
1985
+
1917
1986
  def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
1918
- if not result_path.is_file() or result_path.suffix != ".json":
1987
+ if result_path.suffix != ".json":
1988
+ return _declared_out_of_plan_paths(result_path)
1989
+ if not result_path.is_file():
1919
1990
  return ()
1920
1991
  try:
1921
1992
  payload = json.loads(result_path.read_text(encoding="utf-8"))
@@ -2917,7 +2988,7 @@ def _reject_stale_schema_excerpt(
2917
2988
  )
2918
2989
  if writer is None:
2919
2990
  return
2920
- expected = _string_value(manifest.get("expectedReportPath"))
2991
+ expected = _string_value(manifest.get("expectedReportRecordPath"))
2921
2992
  if not expected:
2922
2993
  return
2923
2994
  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(
@@ -1762,6 +1763,7 @@ def worker_jobs_from_file(
1762
1763
  jobs_file: Path,
1763
1764
  *,
1764
1765
  manifest: Mapping[str, Any],
1766
+ active_context: Mapping[str, Any] | None = None,
1765
1767
  backend: str,
1766
1768
  idle_timeout_seconds: int,
1767
1769
  default_dispatch_kind: str,
@@ -1781,6 +1783,7 @@ def worker_jobs_from_file(
1781
1783
  project_root,
1782
1784
  item,
1783
1785
  manifest=manifest,
1786
+ active_context=active_context,
1784
1787
  backend=backend,
1785
1788
  idle_timeout_seconds=idle_timeout_seconds,
1786
1789
  dispatch_kind=dispatch_kind,
@@ -1799,6 +1802,7 @@ def _worker_job_from_file(
1799
1802
  item: Mapping[str, Any],
1800
1803
  *,
1801
1804
  manifest: Mapping[str, Any],
1805
+ active_context: Mapping[str, Any] | None = None,
1802
1806
  backend: str,
1803
1807
  idle_timeout_seconds: int,
1804
1808
  dispatch_kind: str,
@@ -1849,7 +1853,17 @@ def _worker_job_from_file(
1849
1853
  result_path=result_path,
1850
1854
  worker_result_path=worker_result_path,
1851
1855
  completion_paths=completion_paths,
1852
- worktree_path=string_value(item.get("worktreePath")),
1856
+ # 파일이 적지 않았으면 run authority 에서 해소한다. 로스터 경로는
1857
+ # 언제나 그렇게 하고(`dispatch_core._jobs_from_roster`), 예약 쪽
1858
+ # (`agent_prompt_cli._run_worktree`)도 같은 seam 을 읽는다. 이 자리만
1859
+ # 손으로 적은 값을 유일한 출처로 삼던 동안, 같은 run 이 어떤 경로로
1860
+ # 디스패치됐는지에 따라 워커의 소스 루트가 달라졌다 — 그 값은
1861
+ # writePolicy 의 `sourcePolicy.allowedRoot` 이므로, 예약본과 갈리면
1862
+ # 같은 invocationRef 가 `invocationRef drift` 로 거부된다.
1863
+ worktree_path=(
1864
+ string_value(item.get("worktreePath"))
1865
+ or worktree_path(manifest, active_context or {})
1866
+ ),
1853
1867
  role=require_string(item, "role"),
1854
1868
  idle_timeout_seconds=idle_timeout_seconds,
1855
1869
  dispatch_kind=dispatch_kind,
@@ -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),)
@@ -118,6 +118,22 @@ def append_role_execution(
118
118
  )
119
119
 
120
120
 
121
+ def _drift_detail(existing: Invocation, incoming: Invocation) -> str:
122
+ """어긋난 필드를 이름으로 돌려준다.
123
+
124
+ `invocationRef drift: <ref>` 만으로는 무엇이 달라졌는지 알 수 없어, 리드가
125
+ 새 invocation id 를 몇 개씩 만들어 보는 것 말고 할 수 있는 일이 없었다.
126
+ 같은 ref 로 두 번째 예약이 오는 것 자체는 정상 경로(재materialize)이고,
127
+ 거절해야 하는 것은 '내용이 달라진' 경우뿐이므로 그 내용을 이름 붙인다.
128
+ """
129
+ before = existing.to_payload()
130
+ after = incoming.to_payload()
131
+ changed = sorted(
132
+ key for key in (*before, *after) if before.get(key) != after.get(key)
133
+ )
134
+ return ", ".join(changed) if changed else "(no field differs)"
135
+
136
+
121
137
  def reserve_derived_role_invocation(
122
138
  path: Path,
123
139
  *,
@@ -169,7 +185,8 @@ def reserve_derived_role_invocation(
169
185
  if existing_invocation is not None:
170
186
  if existing_invocation != invocation:
171
187
  raise ExecutionManifestError(
172
- f"invocationRef drift: {invocation.invocation_ref}"
188
+ f"invocationRef drift: {invocation.invocation_ref} "
189
+ f"(differs in: {_drift_detail(existing_invocation, invocation)})"
173
190
  )
174
191
  if created:
175
192
  raise ExecutionManifestError(
@@ -242,7 +259,8 @@ def ensure_invocation(path: Path, row: Invocation, *, task_key: str) -> Invocati
242
259
  if existing is not None:
243
260
  if existing != row:
244
261
  raise ExecutionManifestError(
245
- f"invocationRef drift: {row.invocation_ref}"
262
+ f"invocationRef drift: {row.invocation_ref} "
263
+ f"(differs in: {_drift_detail(existing, row)})"
246
264
  )
247
265
  return existing
248
266
  _validate_next_invocation(manifest, row)
@@ -289,7 +307,8 @@ def record_invocation_attempt(
289
307
  )
290
308
  if existing is not None and existing != invocation:
291
309
  raise ExecutionManifestError(
292
- f"invocationRef drift: {invocation.invocation_ref}"
310
+ f"invocationRef drift: {invocation.invocation_ref} "
311
+ f"(differs in: {_drift_detail(existing, invocation)})"
293
312
  )
294
313
  invocations = manifest.invocations
295
314
  if existing is None:
@@ -528,6 +528,32 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
528
528
  }
529
529
 
530
530
 
531
+ def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
532
+ """이 정책의 경로 장부를 근거로 변경을 거절할 수 있는가.
533
+
534
+ 승인된 계획서에 `plannedPaths` 컬럼이 있으면 실행기는 그 목록에 묶이고,
535
+ 목록은 반드시 비어 있지 않다(`write_policy._planned_paths_from_report` 는
536
+ 선언된 경우에만 항목을 싣는다). 그 컬럼이 없는 옛 계획서에서는 실을 값이
537
+ 없어 장부가 빈 채로 온다 — 종전에는 산문에서 유도한 문장 조각(`28 rows)`,
538
+ `captured in Stage 1)`)을 실었고, 그래서 계획이 지시한 파일 전부가 미허가
539
+ 변경으로 읽혔다.
540
+
541
+ `project-mutation` 정책에서만 빈 장부가 "물을 수 없음" 을 뜻한다.
542
+ `source-readonly` 워커는 장부가 원래 비어 있고 그것이 "아무것도 바꾸지
543
+ 말라" 는 뜻이므로, 그쪽 집행은 건드리지 않는다.
544
+
545
+ 이 판정은 감사의 두 절반이 같은 함수를 읽는다. 종전에는 git 쪽만
546
+ `plannedPathsDeclared` 를 봤는데 그 키는 `build_write_policy` 가 만드는
547
+ sourcePolicy 에 아예 실리지 않아(4개 키 고정, `validate_write_policy_payload`
548
+ 가 그 집합을 강제) 어느 쪽에서도 참이 된 적이 없다.
549
+ """
550
+ source = policy.source_policy
551
+ return (
552
+ source.get("mode") == "project-mutation"
553
+ and not source.get("plannedPaths")
554
+ )
555
+
556
+
531
557
  def _source_policy_failures(
532
558
  policy: WritePolicy,
533
559
  changed: set[str],
@@ -537,7 +563,10 @@ def _source_policy_failures(
537
563
  declared = set(out_of_plan_edits)
538
564
  protected = set(policy.source_policy.get("protectedPaths", ()))
539
565
  failures: list[str] = []
540
- if not changed <= planned | declared:
566
+ if (
567
+ not _path_ledger_is_unenforceable(policy)
568
+ and not changed <= planned | declared
569
+ ):
541
570
  failures.append("source changes exceed planned and declared out-of-plan paths")
542
571
  if not declared <= changed:
543
572
  failures.append("declared out-of-plan path did not change")
@@ -571,6 +600,11 @@ def _git_policy_failures(
571
600
  if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
572
601
  failures.append("final HEAD is not a fast-forward descendant")
573
602
  allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
603
+ if _path_ledger_is_unenforceable(policy):
604
+ # The plan predates the declared path column, so there is no ledger
605
+ # anyone can be held to. Every other check above still applies; only
606
+ # the path comparison stands down.
607
+ return failures
574
608
  if any(
575
609
  not paths <= allowed
576
610
  for paths in _commit_paths_by_commit(