okstra 0.206.1 → 0.207.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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +7 -1
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/architecture/storage-model.md +1 -0
  5. package/docs/architecture.md +28 -4
  6. package/docs/cli.md +13 -11
  7. package/docs/project-structure-overview.md +4 -2
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/operations/code-review.json +1 -1
  11. package/runtime/bin/lib/okstra/usage.sh +3 -3
  12. package/runtime/bin/okstra-compact-reminder.sh +1 -1
  13. package/runtime/prompts/duties/direction-selection-worker.json +1 -1
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/adapters/cmux.md +4 -3
  16. package/runtime/prompts/lead/convergence.md +41 -9
  17. package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
  18. package/runtime/prompts/lead/report-writer.md +8 -6
  19. package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
  20. package/runtime/prompts/profiles/_common-contract.md +1 -1
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  23. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
  24. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  25. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
  26. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  27. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  28. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
  32. package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
  33. package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
  34. package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
  35. package/runtime/python/okstra_ctl/code_review_target.py +92 -6
  36. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
  37. package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
  38. package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
  39. package/runtime/python/okstra_ctl/domain/provider.py +5 -0
  40. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
  41. package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
  42. package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
  43. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
  44. package/runtime/python/okstra_ctl/lead_progress.py +33 -1
  45. package/runtime/python/okstra_ctl/manager_view.py +26 -19
  46. package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
  47. package/runtime/python/okstra_ctl/models.py +4 -1
  48. package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
  49. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  50. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
  51. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
  52. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  53. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
  54. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
  55. package/runtime/python/okstra_ctl/process_group.py +118 -0
  56. package/runtime/python/okstra_ctl/render.py +6 -2
  57. package/runtime/python/okstra_ctl/report_assembly.py +17 -2
  58. package/runtime/python/okstra_ctl/report_finalize.py +106 -2
  59. package/runtime/python/okstra_ctl/run.py +1 -1
  60. package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
  61. package/runtime/python/okstra_ctl/team.py +108 -9
  62. package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
  63. package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
  64. package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
  65. package/runtime/python/okstra_ctl/worker_runner.py +21 -3
  66. package/runtime/python/okstra_ctl/write_policy.py +57 -7
  67. package/runtime/python/okstra_project/dirs.py +14 -0
  68. package/runtime/python/okstra_project/resolver.py +2 -1
  69. package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
  70. package/runtime/skills/okstra-code-review/SKILL.md +70 -32
  71. package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
  72. package/runtime/skills/okstra-run/SKILL.md +2 -2
  73. package/runtime/templates/manager/view.template.html +18 -1
@@ -0,0 +1,121 @@
1
+ """워커를 띄우고 거두는 명령이 대신 기록하는 리드 체크포인트.
2
+
3
+ dispatch 를 정하고 결과를 확정하는 곳은 `team dispatch` / `team await`(pane)와
4
+ `worker-dispatch`(CLI, 블로킹)이다. 그래서 `phase-3-team-create`,
5
+ `phase-4-dispatch`, `phase-5-collect`, `phase-6-synthesis` 가 말하는 사실을 이
6
+ 명령들이 갖고 있다. 리드가 같은 사실을 `okstra lead-progress append` 로 다시
7
+ 적으면 체크포인트마다 전체 컨텍스트를 한 턴씩 쓰는데, 여기서 기록하면 턴이 들지 않는다.
8
+
9
+ 워커별 체크포인트는 `initial` dispatch 만 센다. reverify·critic·plan-verify 는
10
+ 리드가 따로 알리는 이후 라운드이고, 여기서 기록하면 activity-contract run 이
11
+ 존재하지 않는 `worker-dispatched` / `worker-completed` 행을 요구받게 된다.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .dispatch_state import (
19
+ TERMINAL_WORKER_STATUSES,
20
+ dispatch_worker_key,
21
+ has_recorded_team_create,
22
+ worker_dispatch_records,
23
+ )
24
+ from .lead_progress import record_checkpoint
25
+ from .worker_prompt_body import REPORT_WRITER_WORKER_ID
26
+
27
+ _INITIAL = "initial"
28
+
29
+
30
+ def record_dispatch_checkpoints(
31
+ project_root: Path,
32
+ run_manifest: str | Path,
33
+ team_state: Mapping[str, Any],
34
+ jobs: Sequence[Any],
35
+ *,
36
+ source: str,
37
+ ) -> list[str]:
38
+ """곧 띄울 배치의 체크포인트. 배치가 시작되기 전의 team-state 로 판정한다.
39
+
40
+ `phase-3-team-create` 는 이 dispatch 가 implicit-team 마커를 쓸 때만 기록한다
41
+ (`dispatch_state.record_dispatch_facts`). 리드가 미리 적은 마커(concurrent-run
42
+ `skipped` 결정)는 리드가 알린다. `phase-6-synthesis` 는 run 의 첫 report-writer
43
+ dispatch 에서만 기록해서, 교정 라운드가 Phase 6 을 다시 열지 않게 한다.
44
+ """
45
+ if not jobs:
46
+ return []
47
+ lines: list[str | None] = []
48
+ if not has_recorded_team_create(team_state):
49
+ lines.append(record_checkpoint(
50
+ project_root, run_manifest, "phase-3-team-create", source=source,
51
+ ))
52
+ for job in jobs:
53
+ if job.dispatch_kind == _INITIAL:
54
+ lines.append(record_checkpoint(
55
+ project_root, run_manifest, "phase-4-dispatch", source=source,
56
+ worker=job.worker_id,
57
+ fields=(("model", job.model_execution_value),),
58
+ ))
59
+ dispatches_writer = any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
60
+ writer_dispatched_before = bool(
61
+ worker_dispatch_records(team_state, REPORT_WRITER_WORKER_ID)
62
+ )
63
+ if dispatches_writer and not writer_dispatched_before:
64
+ lines.append(record_checkpoint(
65
+ project_root, run_manifest, "phase-6-synthesis", source=source,
66
+ ))
67
+ return [line for line in lines if line]
68
+
69
+
70
+ def settled_initial_dispatches(
71
+ team_state: Mapping[str, Any],
72
+ ) -> dict[str, Mapping[str, Any]]:
73
+ """최신 attempt 가 종결 상태에 이른 `initial` dispatch 들.
74
+
75
+ 재시도해도 바뀌지 않는 prompt 경로를 키로 쓴다. 실패 후 재시도 중인 attempt 는
76
+ 재시도까지 끝나야 거둔 결과로 친다.
77
+ """
78
+ latest: dict[str, Mapping[str, Any]] = {}
79
+ for record in worker_dispatch_records(team_state):
80
+ if record.get("kind") != _INITIAL:
81
+ continue
82
+ key = str(record.get("promptPath") or "")
83
+ current = latest.get(key)
84
+ if current is None or _attempt(record) >= _attempt(current):
85
+ latest[key] = record
86
+ return {
87
+ key: record
88
+ for key, record in latest.items()
89
+ if record.get("status") in TERMINAL_WORKER_STATUSES
90
+ }
91
+
92
+
93
+ def record_collect_checkpoints(
94
+ project_root: Path,
95
+ run_manifest: str | Path,
96
+ before: Mapping[str, Mapping[str, Any]],
97
+ after: Mapping[str, Mapping[str, Any]],
98
+ *,
99
+ source: str,
100
+ ) -> list[str]:
101
+ """이 명령이 확정한 dispatch 마다 `phase-5-collect` 를 기록한다.
102
+
103
+ 확정이 곧 체크포인트가 알리는 결과 검증이다: 필요한 산출물이 있고, 변경 감사를
104
+ 통과했고, 결과가 dispatch 에 연결됐거나(`dispatch_core._finish_attempt`),
105
+ attempt 가 `error` / `timeout` 으로 닫혔다.
106
+ """
107
+ lines = []
108
+ for key, record in after.items():
109
+ previous = before.get(key)
110
+ if previous is not None and _attempt(previous) == _attempt(record):
111
+ continue
112
+ lines.append(record_checkpoint(
113
+ project_root, run_manifest, "phase-5-collect", source=source,
114
+ worker=dispatch_worker_key(record),
115
+ fields=(("status", str(record.get("status"))),),
116
+ ))
117
+ return [line for line in lines if line]
118
+
119
+
120
+ def _attempt(record: Mapping[str, Any]) -> int:
121
+ return int(record.get("attempt", 1))
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace
9
9
  from datetime import datetime, timezone
10
10
  import os
11
11
  from pathlib import Path
12
- from typing import Any, Mapping, Sequence
12
+ from typing import Any, Callable, Mapping, Sequence
13
13
 
14
14
  from .dispatch_state import abandon_agent_dispatch_attempt
15
15
  from .agent.invocation import invocation_input_digest
@@ -33,7 +33,7 @@ from .dispatch_state import (
33
33
  link_agent_dispatch_result as _link_agent_dispatch_result,
34
34
  missing_completion_paths as _missing_completion_paths,
35
35
  unusable_result_defect,
36
- _dispatch_worker_key,
36
+ dispatch_worker_key,
37
37
  _plan_verify_result_aliases,
38
38
  mutate_team_state as _mutate_team_state,
39
39
  require_string as _require_string,
@@ -408,7 +408,17 @@ def build_dispatch_plan(
408
408
  )
409
409
 
410
410
 
411
- def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
411
+ BeforeStart = Callable[["DispatchPlan"], None]
412
+
413
+
414
+ def dispatch_plan(
415
+ plan: DispatchPlan, *, wait: bool = True, before_start: BeforeStart | None = None,
416
+ ) -> int:
417
+ """`before_start` 는 복구·검증을 통과한 plan 으로, 워커를 띄우기 직전에 한 번 불린다.
418
+
419
+ 거부된 배치와 증거 보완으로 빠진 작업은 여기까지 오지 않으므로, 띄울 워커에
420
+ 대한 사실(체크포인트)은 이 시점에 기록해야 실제로 뜬 워커만 남는다.
421
+ """
412
422
  plan = _recover_declaration_failures(plan)
413
423
  if not plan.jobs:
414
424
  return 0
@@ -416,21 +426,19 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
416
426
  _validate_report_writer_isolation(plan.jobs)
417
427
  _validate_implementation_phase_order(plan.jobs)
418
428
  _revalidate_plan_role_executions(plan)
419
- if wait:
420
- pane_backends = sorted(
421
- {
422
- job.backend
423
- for job in plan.jobs
424
- if job.backend == BACKEND_CMUX_PANE
425
- }
429
+ pane_backends = sorted(
430
+ {job.backend for job in plan.jobs if job.backend == BACKEND_CMUX_PANE}
431
+ )
432
+ if wait and pane_backends:
433
+ raise DispatchError(
434
+ f"wait=True dispatch does not support {'/'.join(pane_backends)} "
435
+ "workers: the per-job blocking loop would serialize panes "
436
+ "instead of running them concurrently; dispatch panes with "
437
+ "wait=False"
426
438
  )
427
- if pane_backends:
428
- raise DispatchError(
429
- f"wait=True dispatch does not support {'/'.join(pane_backends)} "
430
- "workers: the per-job blocking loop would serialize panes "
431
- "instead of running them concurrently; dispatch panes with "
432
- "wait=False"
433
- )
439
+ if before_start is not None:
440
+ before_start(plan)
441
+ if wait:
434
442
  _record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
435
443
  for job in plan.jobs:
436
444
  result = _dispatch_job_with_retry(plan, job)
@@ -509,7 +517,9 @@ def _round_artifact_paths(plan: DispatchPlan) -> tuple[Path, ...]:
509
517
  )
510
518
 
511
519
 
512
- def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
520
+ def dispatch_cli_wrapper_plan(
521
+ plan: DispatchPlan, *, before_start: BeforeStart | None = None,
522
+ ) -> int:
513
523
  """Start one dependency-free CLI batch before collecting any worker."""
514
524
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
515
525
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
@@ -520,6 +530,8 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
520
530
  _validate_report_writer_isolation(plan.jobs)
521
531
  _validate_implementation_phase_order(plan.jobs)
522
532
  _revalidate_plan_role_executions(plan)
533
+ if before_start is not None:
534
+ before_start(plan)
523
535
  _record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
524
536
  final_codes = _dispatch_cli_wrapper_batch(plan, plan.jobs)
525
537
  return next((final_codes[job.worker_id] for job in plan.jobs
@@ -1855,6 +1867,7 @@ def _canonical_write_contract(
1855
1867
  try:
1856
1868
  return build_invocation_write_contract(
1857
1869
  role=execution.role,
1870
+ task_type=str(plan.manifest.get("taskType") or ""),
1858
1871
  project_root=plan.project_root,
1859
1872
  worktree=worktree,
1860
1873
  artifact_paths=artifacts,
@@ -2269,14 +2282,23 @@ def _mutation_failure_reason(mutation, outcome: WorkerOutcome | None = None) ->
2269
2282
  failure = _wrapper_failure_text(outcome)
2270
2283
  if failure:
2271
2284
  reason = f"{reason}: {failure}"
2272
- paths = list(mutation.changed_paths)
2273
- if not paths:
2274
- return reason
2285
+ # 산출물 위반은 소스 변경 목록에 없다 — 그 목록만 보이면 정책 안에 있던
2286
+ # 소스 파일을 범인으로 읽는다 (실측 2026-09-26, dev-11054 `qa/baseline/`).
2287
+ for label, paths in (
2288
+ ("outside artifact policy", mutation.violating_artifact_paths),
2289
+ ("changed", mutation.changed_paths),
2290
+ ):
2291
+ if paths:
2292
+ reason += f" [{label}: {_capped_path_list(paths)}]"
2293
+ return reason
2294
+
2295
+
2296
+ def _capped_path_list(paths: Sequence[str]) -> str:
2275
2297
  shown = ", ".join(paths[:_MUTATION_REASON_PATH_LIMIT])
2276
2298
  remaining = len(paths) - _MUTATION_REASON_PATH_LIMIT
2277
2299
  if remaining > 0:
2278
2300
  shown += f", +{remaining} more"
2279
- return f"{reason} [changed: {shown}]"
2301
+ return shown
2280
2302
 
2281
2303
 
2282
2304
  def _finish_attempt(
@@ -2751,7 +2773,7 @@ def _record_wrapper_failure(
2751
2773
  out_path=out_path,
2752
2774
  task_key=_string_value(plan.manifest.get("taskKey")),
2753
2775
  phase=_workflow_phase(plan.manifest),
2754
- agent=_error_log_agent(job.worker_id),
2776
+ agent=_error_log_agent(job),
2755
2777
  agent_role=(
2756
2778
  "report-writer"
2757
2779
  if job.worker_id == REPORT_WRITER_WORKER_ID
@@ -2790,16 +2812,16 @@ def _job_stored_identity(job: WorkerJob, attempt: int) -> dict[str, Any]:
2790
2812
  }
2791
2813
 
2792
2814
 
2793
- def _error_log_agent(worker_id: str) -> str:
2794
- """The error log's `--agent` enum value for a worker id.
2815
+ def _error_log_agent(job: WorkerJob) -> str:
2816
+ """The error log's `--agent` enum value for a job.
2795
2817
 
2796
- The log's own allow-list is the authority on what it accepts; a worker whose
2797
- name is outside it is reported as such by `append_observed` rather than
2798
- silently rewritten into some other agent's records.
2818
+ The enum is keyed by provider (`codex-worker`), not by worker id: a role
2819
+ worker id such as `codex-verifier` produced `codex-verifier-worker`, which
2820
+ the allow-list rejected, so no wrapper failure reached the log.
2799
2821
  """
2800
- if worker_id == REPORT_WRITER_WORKER_ID:
2822
+ if job.worker_id == REPORT_WRITER_WORKER_ID:
2801
2823
  return REPORT_WRITER_WORKER_ID
2802
- return f"{worker_id}-worker"
2824
+ return f"{job.provider}-worker"
2803
2825
 
2804
2826
 
2805
2827
  def _workflow_phase(manifest: Mapping[str, Any]) -> str:
@@ -3349,7 +3371,7 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3349
3371
  result_path = Path(_string_value(record.get("resultPath")))
3350
3372
  worker_result = Path(_string_value(record.get("workerResultPath")))
3351
3373
  missing: list[Path] = []
3352
- worker_id = _dispatch_worker_key(record)
3374
+ worker_id = dispatch_worker_key(record)
3353
3375
  dispatch_kind = _string_value(record.get("dispatchKind"))
3354
3376
  for path in _record_completion_paths(record):
3355
3377
  if path.is_file():
@@ -3366,7 +3388,7 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3366
3388
 
3367
3389
  def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
3368
3390
  defect = unusable_result_defect(
3369
- _dispatch_worker_key(record),
3391
+ dispatch_worker_key(record),
3370
3392
  Path(_string_value(record.get("resultPath"))),
3371
3393
  _string_value(record.get("dispatchKind")),
3372
3394
  )
@@ -365,6 +365,12 @@ def resolve_required_path(
365
365
  def worktree_path(
366
366
  manifest: Mapping[str, Any], active_context: Mapping[str, Any]
367
367
  ) -> str:
368
+ # Must match the prompt's `**Worktree:**` anchor
369
+ # (`worker_prompt_headers._final_verification_target_headers`).
370
+ if manifest.get("taskType") == "final-verification":
371
+ target = active_context.get("verificationTarget")
372
+ if isinstance(target, Mapping) and target.get("worktreePath"):
373
+ return str(target["worktreePath"])
368
374
  worktree = active_context.get("executorWorktree")
369
375
  if not isinstance(worktree, Mapping):
370
376
  return ""
@@ -862,6 +868,7 @@ def _agent_write_contract(
862
868
  try:
863
869
  return build_invocation_write_contract(
864
870
  role=execution.role,
871
+ task_type=str(authority.get("taskType") or ""),
865
872
  project_root=project_root,
866
873
  worktree=worktree,
867
874
  artifact_paths=artifacts,
@@ -1626,12 +1633,12 @@ def record_dispatch_facts(team_state_path: Path, dispatch_mode: str) -> None:
1626
1633
  with _team_state_lock(team_state_path):
1627
1634
  payload = load_json_object(team_state_path, "team-state")
1628
1635
  payload["dispatchMode"] = dispatch_mode
1629
- if not _has_recorded_team_create(payload):
1636
+ if not has_recorded_team_create(payload):
1630
1637
  payload["teamCreate"] = {"attempted": False, "status": "implicit"}
1631
1638
  write_json(team_state_path, payload)
1632
1639
 
1633
1640
 
1634
- def _has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
1641
+ def has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
1635
1642
  existing = team_state.get("teamCreate")
1636
1643
  return (isinstance(existing, dict)
1637
1644
  and bool(str(existing.get("status") or "").strip()))
@@ -1771,7 +1778,7 @@ def worker_dispatch_records(
1771
1778
  집계, v2 명부 행 `scope` 는 `promptPath` 가 비어 0회).
1772
1779
 
1773
1780
  v1 행은 `workerId`, v2 행은 `assignmentRef` 마지막 마디로 워커에 묶인다
1774
- (`_dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
1781
+ (`dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
1775
1782
  와 같은 이유로 호출자를 깨지 않는다.
1776
1783
  """
1777
1784
  dispatches = team_state.get("workerDispatches")
@@ -1779,13 +1786,13 @@ def worker_dispatch_records(
1779
1786
  for record in dispatches if isinstance(dispatches, list) else []:
1780
1787
  if not isinstance(record, Mapping):
1781
1788
  continue
1782
- if worker_id is not None and _dispatch_worker_key(record) != worker_id:
1789
+ if worker_id is not None and dispatch_worker_key(record) != worker_id:
1783
1790
  continue
1784
1791
  records.append(record)
1785
1792
  return records
1786
1793
 
1787
1794
 
1788
- def _dispatch_worker_key(record: Mapping[str, Any]) -> str:
1795
+ def dispatch_worker_key(record: Mapping[str, Any]) -> str:
1789
1796
  """The `workers[]` row a dispatch record belongs to.
1790
1797
 
1791
1798
  A v1 record names it as `workerId`. A v2 record carries execution identity
@@ -65,6 +65,9 @@ class ModelSpec:
65
65
  supported_roles: frozenset[str] | None
66
66
  selectable: bool
67
67
  pricing: Optional[tuple[float, float, float]]
68
+ # 이 모델을 실행할 때마다 공급자 CLI 에 덧붙는 인자. 사용자 CLI 설정에 맡기면
69
+ # 기계마다 달라지는 값(예: reasoning effort)을 카탈로그가 고정한다.
70
+ cli_args: tuple[str, ...]
68
71
 
69
72
  def __init__(
70
73
  self,
@@ -79,6 +82,7 @@ class ModelSpec:
79
82
  pricing: Optional[tuple[float, float, float]] = None,
80
83
  *,
81
84
  in_picker: bool | None = None,
85
+ cli_args: tuple[str, ...] = (),
82
86
  ) -> None:
83
87
  """Accept the former display/execution form while adapters migrate."""
84
88
  is_legacy = execution_value is _MISSING or not isinstance(execution_value, str)
@@ -102,6 +106,7 @@ class ModelSpec:
102
106
  object.__setattr__(self, "supported_roles", supported_roles)
103
107
  object.__setattr__(self, "selectable", selectable if in_picker is None else in_picker)
104
108
  object.__setattr__(self, "pricing", resolved_pricing)
109
+ object.__setattr__(self, "cli_args", cli_args)
105
110
 
106
111
  @property
107
112
  def display(self) -> str:
@@ -93,13 +93,18 @@ class SplitText:
93
93
  가야 하므로 종결 텍스트로 돌려주고, 진행은 기록과 화면에만 남는다.
94
94
  """
95
95
 
96
+ _answer: list[str] = field(default_factory=list, repr=False, compare=False)
97
+
96
98
  def merges_stderr(self) -> bool:
97
99
  return False
98
100
 
99
101
  def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
100
102
  def result(line: str) -> str | None:
101
103
  writer.write(WORKER, line)
102
- return strip_terminal_colors(line)
104
+ # 러너는 마지막으로 돌려받은 값만 호출자에게 넘기므로, 그 줄이 아니라
105
+ # 지금까지의 답 전체를 돌려준다.
106
+ self._answer.append(strip_terminal_colors(line))
107
+ return "\n".join(self._answer).strip("\n") or None
103
108
 
104
109
  def progress(line: str) -> str | None:
105
110
  writer.write(WORKER, line)
@@ -140,6 +145,9 @@ class JsonEvents:
140
145
  _saw_conclusion: list[bool] = field(
141
146
  default_factory=list, repr=False, compare=False
142
147
  )
148
+ # 마지막 도구 호출 뒤에 온 산문. Result 없는 어휘에서는 이것이 최종 답이다.
149
+ _trailing_text: list[str] = field(default_factory=list, repr=False, compare=False)
150
+ _saw_result: list[bool] = field(default_factory=list, repr=False, compare=False)
143
151
 
144
152
  def merges_stderr(self) -> bool:
145
153
  return True
@@ -164,11 +172,17 @@ class JsonEvents:
164
172
  if not isinstance(event, dict):
165
173
  return None
166
174
  self.observe(event)
167
- if self.is_conclusion is not None and self.is_conclusion(event):
175
+ concludes = self.is_conclusion is not None and self.is_conclusion(event)
176
+ if concludes:
168
177
  self._saw_conclusion.append(True)
169
178
  closing: str | None = None
170
179
  for entry in self.normalise(event):
171
180
  closing = self._write_entry(writer, entry) or closing
181
+ if concludes and closing is None and not self._saw_result:
182
+ # 종결 표지가 본문을 싣지 않는 어휘다. 돌려줄 것이
183
+ # 없으면 quiet 호출자의 stdout 이 비고 답은 로그에만 남는다.
184
+ joiner = "" if self.join_adjacent_text else "\n"
185
+ closing = joiner.join(self._trailing_text).strip() or None
172
186
  return closing
173
187
 
174
188
  return (("stdout", emit),)
@@ -179,6 +193,10 @@ class JsonEvents:
179
193
  def _write_entry(
180
194
  self, writer: TranscriptWriter, entry: StreamEvent
181
195
  ) -> str | None:
196
+ if isinstance(entry, Text):
197
+ self._trailing_text.append(entry.body)
198
+ else:
199
+ self._trailing_text.clear()
182
200
  if isinstance(entry, Text) and self.join_adjacent_text:
183
201
  self._pending_text.append(entry.body)
184
202
  self._publish_complete_lines(writer)
@@ -197,6 +215,7 @@ class JsonEvents:
197
215
  # Result 이벤트는 어휘와 무관한 종결이다. 빈 결과 텍스트도 종결이므로
198
216
  # closing_text 의 진릿값과 따로 기록한다.
199
217
  self._saw_conclusion.append(True)
218
+ self._saw_result.append(True)
200
219
  return closing
201
220
 
202
221
  def _publish_complete_lines(self, writer: TranscriptWriter) -> None:
@@ -125,10 +125,11 @@ def validate_write_policy_payload(payload: Mapping[str, Any]) -> None:
125
125
  source = _mapping(payload.get("sourcePolicy"), "sourcePolicy")
126
126
  git = _mapping(payload.get("gitPolicy"), "gitPolicy")
127
127
  auxiliary = _mapping(payload.get("auxiliaryPolicy"), "auxiliaryPolicy")
128
- if set(artifact) != {"allowedRoot", "allowedPaths"}:
128
+ if set(artifact) - {"preservedPaths"} != {"allowedRoot", "allowedPaths"}:
129
129
  raise WritePolicyError("artifactPolicy is incomplete")
130
130
  _absolute_text(artifact.get("allowedRoot"), "artifactPolicy.allowedRoot")
131
131
  _relative_paths(artifact.get("allowedPaths", ()))
132
+ _relative_paths(artifact.get("preservedPaths", ()))
132
133
  if source.get("mode") not in {"source-readonly", "project-mutation"}:
133
134
  raise WritePolicyError("sourcePolicy mode is invalid")
134
135
  if set(source) - {"plannedPathsDeclared"} != {
@@ -123,6 +123,7 @@ class MutationAuditResult:
123
123
  declared_out_of_plan_paths: tuple[str, ...] = ()
124
124
  unobserved_artifact_paths: tuple[str, ...] = ()
125
125
  external_workspace_changes: tuple[str, ...] = ()
126
+ violating_artifact_paths: tuple[str, ...] = ()
126
127
 
127
128
  def change_summary(self) -> dict[str, Any]:
128
129
  return {
@@ -140,6 +141,7 @@ class MutationAuditResult:
140
141
  "declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
141
142
  "unobservedArtifactPaths": list(self.unobserved_artifact_paths),
142
143
  "externalWorkspaceChanges": list(self.external_workspace_changes),
144
+ "violatingArtifactPaths": list(self.violating_artifact_paths),
143
145
  "beforeDigest": self.before_digest,
144
146
  "afterDigest": self.after_digest,
145
147
  }
@@ -242,10 +244,11 @@ class ExecutionMutationAudit:
242
244
  rows,
243
245
  source_changes,
244
246
  )
245
- artifact_failures, untracked_artifact_changes, switched, external = (
247
+ violating_artifacts, untracked_artifact_changes, switched, external = (
246
248
  _artifact_policy_failures(before, rows, artifact_changed, after=after)
247
249
  )
248
- violations.extend(artifact_failures)
250
+ if violating_artifacts:
251
+ violations.append("artifact-root change exceeds batch policy union")
249
252
  worker_artifact_changes = {
250
253
  path for path in artifact_changed
251
254
  if path not in untracked_artifact_changes and path not in switched and path not in external
@@ -287,6 +290,7 @@ class ExecutionMutationAudit:
287
290
  declared_out_of_plan_paths=tuple(out_of_plan_edits),
288
291
  unobserved_artifact_paths=tuple(sorted(unobserved)),
289
292
  external_workspace_changes=tuple(sorted(external)),
293
+ violating_artifact_paths=tuple(sorted(violating_artifacts)),
290
294
  )
291
295
 
292
296
 
@@ -784,8 +788,8 @@ def _artifact_policy_failures(
784
788
  changed: set[str],
785
789
  *,
786
790
  after: MutationSnapshot | None = None,
787
- ) -> tuple[list[str], set[str], set[str], set[str]]:
788
- """위반·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
791
+ ) -> tuple[set[str], set[str], set[str], set[str]]:
792
+ """위반 경로·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
789
793
 
790
794
  작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
791
795
  전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
@@ -797,10 +801,18 @@ def _artifact_policy_failures(
797
801
  for value in snapshot.orchestrator_paths
798
802
  if _is_relative_to(Path(value), snapshot.artifact_root)
799
803
  }
804
+ preserved = {
805
+ path
806
+ for policy in policies
807
+ for path in policy.artifact_policy.get("preservedPaths", ())
808
+ }
800
809
  unauthorized = {
801
810
  path for path in changed
802
- if not any(_is_within(path, item) for item in allowed)
803
- and not any(_is_within(path, item) for item in orchestrator)
811
+ if path in preserved
812
+ or (
813
+ not any(_is_within(path, item) for item in allowed)
814
+ and not any(_is_within(path, item) for item in orchestrator)
815
+ )
804
816
  }
805
817
  if snapshot.artifact_root == snapshot.root:
806
818
  unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
@@ -826,8 +838,7 @@ def _artifact_policy_failures(
826
838
  and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
827
839
  }
828
840
  violating = unauthorized - untracked_outside - switched - external
829
- failures = ["artifact-root change exceeds batch policy union"] if violating else []
830
- return failures, untracked_outside, switched, external
841
+ return violating, untracked_outside, switched, external
831
842
 
832
843
 
833
844
  def _branch_switch_paths(
@@ -44,8 +44,10 @@ from .worker_prompt_headers import source_worktree_headers, worker_prompt_header
44
44
  from .worker_prompt_policy import (
45
45
  APPROVED_PLAN_HEADER,
46
46
  IMPLEMENTATION_STAGE_HEADER,
47
+ STAGE_QA_OWNER_HEADER,
47
48
  PromptPlan,
48
49
  resolve_prompt_plan_for_manifest,
50
+ stage_qa_owner,
49
51
  )
50
52
 
51
53
 
@@ -1469,6 +1471,9 @@ def _implementation_anchor_lines(
1469
1471
  lines.append(
1470
1472
  f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
1471
1473
  )
1474
+ owner = stage_qa_owner(context.manifest)
1475
+ if plan.audience == "implementation-verifier" and owner:
1476
+ lines.append(f"{STAGE_QA_OWNER_HEADER} {owner}")
1472
1477
  return lines
1473
1478
 
1474
1479
 
@@ -220,6 +220,38 @@ def record_progress(
220
220
  return event, warning
221
221
 
222
222
 
223
+ def record_checkpoint(
224
+ project_root: Path,
225
+ run_manifest: str | Path,
226
+ phase: str,
227
+ *,
228
+ source: str,
229
+ worker: str | None = None,
230
+ fields: Sequence[tuple[str, str]] = (),
231
+ ) -> str | None:
232
+ """단계를 수행하는 명령이 체크포인트를 기록하고 그 줄을 돌려준다.
233
+
234
+ 기록이 실패해도 그 기록이 붙은 dispatch·await·reclaim·finalize 는 실패시키지
235
+ 않는다. 장부 한 줄 때문에 작업 자체를 잃는 쪽이 더 나쁘다. 실패는 stderr 에
236
+ 출력하고, 이후 검증기가 체크포인트 누락으로 보고한다.
237
+ """
238
+ root = project_root.resolve()
239
+ try:
240
+ event, warning = record_progress(
241
+ root,
242
+ _resolve_project_path(root, run_manifest),
243
+ phase,
244
+ worker=worker,
245
+ fields=fields,
246
+ )
247
+ except (ProgressRecordError, OSError) as exc:
248
+ print(f"{source}: could not record `{phase}`: {exc}", file=sys.stderr)
249
+ return None
250
+ if warning:
251
+ print(f"{source}: {warning}", file=sys.stderr)
252
+ return str(event.details["line"])
253
+
254
+
223
255
  def _append(args: argparse.Namespace) -> int:
224
256
  fields = [_parse_field(raw) for raw in args.field]
225
257
  event, warning = record_progress(
@@ -243,7 +275,7 @@ def _append(args: argparse.Namespace) -> int:
243
275
 
244
276
  _CLI_EPILOG = r"""Usage:
245
277
  okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-1-intake
246
- okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-4-dispatch --worker codex-worker --field model=gpt-6-sol
278
+ okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-4-dispatch --worker codex-worker --field model=gpt-6.1-sol
247
279
  okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-5-collect --worker codex-worker --field status=completed
248
280
 
249
281
  `progressLine` in the output is the line to emit to the user, verbatim.