okstra 0.180.0 → 0.183.2

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 (223) hide show
  1. package/dist/cli-registry.mjs +16 -2
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.d.mts +4 -2
  4. package/dist/commands/execute/render-bundle.mjs +46 -5
  5. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  6. package/dist/commands/execute/run.mjs +11 -3
  7. package/dist/commands/execute/run.mjs.map +1 -1
  8. package/dist/commands/inspect/model-io.d.mts +1 -0
  9. package/dist/commands/inspect/model-io.mjs +25 -0
  10. package/dist/commands/inspect/model-io.mjs.map +1 -0
  11. package/dist/commands/inspect/stage-map.mjs +29 -8
  12. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  13. package/dist/commands/inspect/task-list.mjs +52 -6
  14. package/dist/commands/inspect/task-list.mjs.map +1 -1
  15. package/dist/commands/inspect/user-response.mjs +14 -4
  16. package/dist/commands/inspect/user-response.mjs.map +1 -1
  17. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  18. package/dist/commands/lifecycle/check-project.mjs +69 -50
  19. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  20. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  21. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  22. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  23. package/dist/commands/lifecycle/preflight.mjs +154 -51
  24. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  25. package/dist/commands/pr/pr.d.mts +1 -0
  26. package/dist/commands/pr/pr.mjs +19 -1
  27. package/dist/commands/pr/pr.mjs.map +1 -1
  28. package/dist/commands/report/agent-activity.mjs +2 -2
  29. package/dist/commands/report/translate.mjs +3 -0
  30. package/dist/commands/report/translate.mjs.map +1 -1
  31. package/dist/lib/host-registry-client.mjs +13 -9
  32. package/dist/lib/host-registry-client.mjs.map +1 -1
  33. package/docs/architecture.md +11 -0
  34. package/docs/cli.md +26 -13
  35. package/docs/container.md +6 -4
  36. package/docs/contributor-change-matrix.md +1 -1
  37. package/docs/for-ai/README.md +2 -2
  38. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  39. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  40. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  41. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  42. package/docs/for-ai/skills/okstra-manager.md +19 -18
  43. package/docs/for-ai/skills/okstra-memory.md +2 -2
  44. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  45. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  46. package/docs/for-ai/skills/okstra-run.md +7 -3
  47. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  48. package/docs/for-ai/skills/okstra-setup.md +7 -7
  49. package/docs/for-ai/skills/okstra-usage.md +5 -4
  50. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  51. package/docs/project-structure-overview.md +29 -26
  52. package/docs/task-process/implementation.md +7 -4
  53. package/package.json +1 -1
  54. package/runtime/BUILD.json +2 -2
  55. package/runtime/agents/workers/claude-worker.md +4 -4
  56. package/runtime/agents/workers/report-writer-worker.md +3 -3
  57. package/runtime/agents/workers/translator-worker.md +5 -13
  58. package/runtime/bin/okstra-error-log.py +51 -11
  59. package/runtime/bin/okstra-report-translate.py +210 -23
  60. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  61. package/runtime/prompts/launch.template.md +3 -7
  62. package/runtime/prompts/lead/context-loader.md +41 -141
  63. package/runtime/prompts/lead/convergence.md +8 -6
  64. package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
  65. package/runtime/prompts/lead/plan-body-verification.md +187 -14
  66. package/runtime/prompts/lead/report-writer.md +20 -1
  67. package/runtime/prompts/lead/team-contract.md +8 -53
  68. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  69. package/runtime/prompts/profiles/_common-contract.md +1 -1
  70. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  71. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  72. package/runtime/prompts/profiles/final-verification.md +1 -1
  73. package/runtime/prompts/profiles/implementation-planning.md +8 -2
  74. package/runtime/prompts/profiles/release-handoff.md +0 -1
  75. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  76. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  77. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  78. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +6 -1
  79. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  80. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  81. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  82. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  83. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  84. package/runtime/python/okstra_ctl/agent_activity.py +118 -35
  85. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  86. package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
  87. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  88. package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
  89. package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
  90. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  91. package/runtime/python/okstra_ctl/backfill.py +13 -10
  92. package/runtime/python/okstra_ctl/batch.py +2 -4
  93. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  94. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  95. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  96. package/runtime/python/okstra_ctl/cmux.py +130 -52
  97. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  98. package/runtime/python/okstra_ctl/consumers.py +5 -4
  99. package/runtime/python/okstra_ctl/container.py +103 -8
  100. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  101. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  102. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  103. package/runtime/python/okstra_ctl/convergence.py +338 -17
  104. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  105. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  106. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  107. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  108. package/runtime/python/okstra_ctl/dispatch_core.py +35 -65
  109. package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
  110. package/runtime/python/okstra_ctl/doctor.py +6 -3
  111. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  112. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  113. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  114. package/runtime/python/okstra_ctl/error_report.py +26 -1
  115. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  116. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  117. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  118. package/runtime/python/okstra_ctl/final_report_schema.py +2 -2
  119. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  120. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  121. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  122. package/runtime/python/okstra_ctl/handoff.py +5 -4
  123. package/runtime/python/okstra_ctl/i18n.py +4 -2
  124. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  125. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  126. package/runtime/python/okstra_ctl/incremental_carry.py +2 -1
  127. package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
  128. package/runtime/python/okstra_ctl/index.py +8 -11
  129. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  130. package/runtime/python/okstra_ctl/invocation.py +3 -6
  131. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  132. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  133. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  134. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  135. package/runtime/python/okstra_ctl/listing.py +3 -3
  136. package/runtime/python/okstra_ctl/log_report.py +24 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  138. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  139. package/runtime/python/okstra_ctl/material.py +5 -1
  140. package/runtime/python/okstra_ctl/migrate.py +29 -25
  141. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  142. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  143. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  144. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  145. package/runtime/python/okstra_ctl/paths.py +9 -0
  146. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  147. package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
  148. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  149. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  150. package/runtime/python/okstra_ctl/recap.py +5 -4
  151. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  152. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  153. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  154. package/runtime/python/okstra_ctl/render.py +26 -15
  155. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  156. package/runtime/python/okstra_ctl/report_assembly.py +172 -17
  157. package/runtime/python/okstra_ctl/report_finalize.py +7 -10
  158. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  159. package/runtime/python/okstra_ctl/report_language.py +3 -2
  160. package/runtime/python/okstra_ctl/report_markdown.py +13 -1
  161. package/runtime/python/okstra_ctl/report_narrative.py +40 -8
  162. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  163. package/runtime/python/okstra_ctl/report_views.py +3 -2
  164. package/runtime/python/okstra_ctl/rollup.py +65 -4
  165. package/runtime/python/okstra_ctl/run.py +159 -56
  166. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  167. package/runtime/python/okstra_ctl/run_context.py +6 -9
  168. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  169. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  170. package/runtime/python/okstra_ctl/schema_excerpt.py +4 -2
  171. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  172. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  173. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -2
  174. package/runtime/python/okstra_ctl/stage_map.py +26 -6
  175. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  176. package/runtime/python/okstra_ctl/team.py +2 -1
  177. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  178. package/runtime/python/okstra_ctl/time_report.py +51 -4
  179. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  180. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  181. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  182. package/runtime/python/okstra_ctl/wizard.py +11 -44
  183. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  184. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -7
  185. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  186. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  187. package/runtime/python/okstra_ctl/workflow.py +2 -1
  188. package/runtime/python/okstra_ctl/worktree.py +3 -2
  189. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  190. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  191. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  192. package/runtime/python/okstra_token_usage/collect.py +90 -38
  193. package/runtime/python/okstra_token_usage/grok.py +127 -0
  194. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  195. package/runtime/schemas/final-report-v3.0.schema.json +9 -0
  196. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  197. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  198. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  199. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  200. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  201. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  202. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  203. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  204. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  205. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  206. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  207. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  208. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  209. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  210. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  211. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  212. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  213. package/runtime/skills/okstra-run/SKILL.md +7 -7
  214. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  215. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  216. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  217. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  218. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  219. package/runtime/templates/report-writer-prompt-preamble.md +17 -1
  220. package/runtime/templates/reports/schedule.template.md +4 -4
  221. package/runtime/templates/worker-error-contract.md +17 -29
  222. package/runtime/validators/validate-run.py +379 -63
  223. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -94,6 +94,7 @@ from okstra_ctl.clarification_items import (
94
94
  user_response_sidecars,
95
95
  )
96
96
  from okstra_ctl.convergence_store import write_json_atomic
97
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
97
98
  from okstra_ctl.incremental_scope import (
98
99
  parse_stage_graph,
99
100
  preview_link_availability_for_report,
@@ -869,13 +870,13 @@ def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict)
869
870
  candidates: list[str] = []
870
871
  if data_path.is_file():
871
872
  try:
872
- data = json.loads(data_path.read_text(encoding="utf-8"))
873
+ data = load_owned_object(data_path, artifact="planning final report")
873
874
  planning = data.get("implementationPlanning") or {}
874
875
  candidates = [
875
876
  c.get("name", "") for c in planning.get("optionCandidates") or []
876
877
  if isinstance(c, dict) and c.get("name")
877
878
  ]
878
- except (OSError, json.JSONDecodeError):
879
+ except (OSError, JsonBoundaryError):
879
880
  candidates = []
880
881
  if option_name not in candidates:
881
882
  raise WizardError(
@@ -2002,8 +2003,8 @@ def _load_wizard_root(workspace_root: str) -> dict:
2002
2003
  "Re-run `okstra install` or check the workspace_root."
2003
2004
  )
2004
2005
  try:
2005
- raw = json.loads(path.read_text(encoding="utf-8"))
2006
- except json.JSONDecodeError as exc:
2006
+ raw = load_owned_object(path, artifact="wizard prompts")
2007
+ except JsonBoundaryError as exc:
2007
2008
  raise WizardError(f"wizard prompt JSON malformed: {path}: {exc}") from exc
2008
2009
  if not isinstance(raw, dict):
2009
2010
  raise WizardError(f"wizard prompt JSON root must be an object: {path}")
@@ -4217,7 +4218,7 @@ def _prior_stage_numbers(state: WizardState) -> set[int]:
4217
4218
  )
4218
4219
  data_path = final_report_data_path(report)
4219
4220
  try:
4220
- data = json.loads(data_path.read_text(encoding="utf-8"))
4221
+ data = load_owned_object(data_path, artifact="planning final report")
4221
4222
  stages = {num for num, _ in parse_stage_graph(data)}
4222
4223
  except (OSError, ValueError, KeyError, TypeError) as exc:
4223
4224
  raise WizardError(
@@ -4300,8 +4301,8 @@ def _suggest_project_pr_template(state: WizardState) -> str:
4300
4301
  if not project_json.is_file():
4301
4302
  return ""
4302
4303
  try:
4303
- data = json.loads(project_json.read_text(encoding="utf-8"))
4304
- except (OSError, json.JSONDecodeError):
4304
+ data = load_owned_object(project_json, artifact="project config")
4305
+ except (OSError, JsonBoundaryError):
4305
4306
  return ""
4306
4307
  val = data.get("prTemplatePath") or ""
4307
4308
  return val if isinstance(val, str) else ""
@@ -5138,39 +5139,6 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
5138
5139
  stage=stage, path=str(decision.path), branch=decision.branch)
5139
5140
 
5140
5141
 
5141
- def _critic_collision_lines(state: WizardState) -> list[str]:
5142
- """critic provider 가 분석 로스터와 겹칠 때 그 대가를 확인 화면에서 말한다.
5143
-
5144
- gap 검증 투표자는 `convergence_engine._is_critic_worker` 가 critic **provider
5145
- 이름**으로 걸러낸다 — critic 워커 id 가 아니라 provider 다. 그래서 같은
5146
- provider 의 분석 워커가 투표에서 빠지고, 분석 워커가 둘뿐인 로스터에서는
5147
- 정족수가 1로 준다. 그 사실이 선택 시점에는 어디에도 드러나지 않아, critic 도
5148
- 돌고 gap 검증 라운드도 완료된 뒤 `apply-critic-gaps` 가 거부할 때에야 알려졌다
5149
- — 이미 워커 실행 비용을 쓴 다음이다.
5150
-
5151
- 막지는 않는다. 겹치는 provider 를 고르는 것이 잘못은 아니고, 그 대가를 알고
5152
- 고르는 것과 모르고 고르는 것의 차이만 없앤다.
5153
- """
5154
- if not state.critic or state.critic == "off":
5155
- return []
5156
- analysers = [
5157
- worker for worker in _resolved_roster(state) if worker != "report-writer"
5158
- ]
5159
- excluded = [
5160
- worker for worker in analysers
5161
- if worker in {state.critic, f"{state.critic}-worker"}
5162
- ]
5163
- if not excluded:
5164
- return []
5165
- return [
5166
- _msg(
5167
- state.workspace_root, "confirmation", "critic_shares_analyser_provider",
5168
- worker=excluded[0],
5169
- remaining=len(analysers) - len(excluded),
5170
- )
5171
- ]
5172
-
5173
-
5174
5142
  def _build_confirm(state: WizardState) -> Prompt:
5175
5143
  t = _p(state.workspace_root, "confirm")
5176
5144
  return Prompt(
@@ -6496,7 +6464,6 @@ def confirmation_block(state: WizardState) -> str:
6496
6464
  if (not role_selection
6497
6465
  and state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification")):
6498
6466
  lines.append(f" critic : {state.critic or '(off)'}")
6499
- lines.extend(_critic_collision_lines(state))
6500
6467
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
6501
6468
  lines.append(f" approved-plan : {state.approved_plan_path}")
6502
6469
  stage = wizard_stage_confirmation_label(
@@ -6631,8 +6598,8 @@ def _matching_v2_companion(
6631
6598
  source_metadata: dict[str, str],
6632
6599
  ) -> dict[str, Any] | None:
6633
6600
  try:
6634
- payload = json.loads(path.read_text(encoding="utf-8"))
6635
- except (OSError, UnicodeError, json.JSONDecodeError):
6601
+ payload = load_owned_object(path, artifact="wizard state")
6602
+ except (OSError, UnicodeError, JsonBoundaryError):
6636
6603
  return None
6637
6604
  if not isinstance(payload, dict):
6638
6605
  return None
@@ -6661,7 +6628,7 @@ def _load_matching_v2_state(
6661
6628
  def load_state_file(path: Path) -> WizardState:
6662
6629
  source_path = Path(path)
6663
6630
  source = source_path.read_bytes()
6664
- data = json.loads(source)
6631
+ data = load_owned_object(source_path, artifact="wizard state")
6665
6632
  source_version = data.get("executionIdentityVersion", 1)
6666
6633
  source_metadata = _legacy_source_metadata(source_path, source)
6667
6634
  source_digest = source_metadata["sha256"]
@@ -12,6 +12,7 @@ import json
12
12
  from pathlib import Path
13
13
 
14
14
  from .paths import task_dir, task_manifest_file
15
+ from .json_boundary import JsonBoundaryError, load_owned_object
15
16
 
16
17
  WORK_CATEGORIES: tuple[str, ...] = (
17
18
  "bugfix",
@@ -32,8 +33,8 @@ def recorded_work_category(project_root: Path, task_group: str, task_id: str) ->
32
33
  """task-manifest.json 에 이미 기록된 work-category. 없거나 enum 밖이면 ""."""
33
34
  manifest = task_manifest_file(task_dir(project_root, task_group, task_id))
34
35
  try:
35
- data = json.loads(manifest.read_text(encoding="utf-8"))
36
- except (OSError, json.JSONDecodeError):
36
+ data = load_owned_object(manifest, artifact="task manifest")
37
+ except JsonBoundaryError:
37
38
  return ""
38
39
  value = str(data.get("workCategory") or "").strip()
39
40
  return value if is_valid_category(value) else ""
@@ -89,11 +89,17 @@ def report_writer_prompt_body(
89
89
  team_state: Mapping[str, Any],
90
90
  model: str,
91
91
  report_language: str,
92
+ synthesis_packet_path: str = "",
92
93
  ) -> list[str]:
93
94
  """Render the provider-neutral body for the report-writer audience."""
94
95
  if str(manifest.get("reportContractVersion") or "") == "3.0":
95
96
  return _report_writer_v3_body(
96
- manifest, active_context, team_state, model, report_language
97
+ manifest,
98
+ active_context,
99
+ team_state,
100
+ model,
101
+ report_language,
102
+ synthesis_packet_path,
97
103
  )
98
104
  return [
99
105
  f"**Model:** Report writer worker, {model}",
@@ -106,9 +112,11 @@ def report_writer_prompt_body(
106
112
  f"- Task type: `{_require_string(manifest, 'taskType')}`",
107
113
  "",
108
114
  "## Inputs",
109
- *report_writer_input_lines(manifest, active_context, team_state),
110
- "",
111
- mcp_pointer_line(),
115
+ *(
116
+ [f"- Report synthesis packet: `{synthesis_packet_path}`"]
117
+ if synthesis_packet_path
118
+ else report_writer_input_lines(manifest, active_context, team_state)
119
+ ),
112
120
  "",
113
121
  "## Output Contract",
114
122
  "You are the author of TWO files:",
@@ -134,6 +142,7 @@ def _report_writer_v3_body(
134
142
  team_state: Mapping[str, Any],
135
143
  model: str,
136
144
  report_language: str,
145
+ synthesis_packet_path: str,
137
146
  ) -> list[str]:
138
147
  return [
139
148
  f"**Model:** Report writer worker, {model}",
@@ -146,9 +155,11 @@ def _report_writer_v3_body(
146
155
  f"- Task type: `{_require_string(manifest, 'taskType')}`",
147
156
  "",
148
157
  "## Inputs",
149
- *report_writer_input_lines(manifest, active_context, team_state),
150
- "",
151
- mcp_pointer_line(),
158
+ *(
159
+ [f"- Report synthesis packet: `{synthesis_packet_path}`"]
160
+ if synthesis_packet_path
161
+ else report_writer_input_lines(manifest, active_context, team_state)
162
+ ),
152
163
  "",
153
164
  "## Output Contract",
154
165
  "You are the author of the report narrative Markdown at Result Path.",
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import Any, Iterable, Mapping, Sequence
9
9
 
10
10
  from .worker_prompt_body import analysis_worker_label
11
+ from .json_boundary import load_owned_object
11
12
  from .worker_prompt_policy import (
12
13
  ERRORS_PATH_HEADERS,
13
14
  IMPLEMENTATION_HEADERS,
@@ -389,8 +390,8 @@ def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
389
390
  if record.metadata_path is None:
390
391
  return [*errors, "agent invocation metadata path is required"]
391
392
  try:
392
- metadata = json.loads(
393
- record.metadata_path.read_text(encoding="utf-8")
393
+ metadata = load_owned_object(
394
+ record.metadata_path, artifact="agent invocation metadata"
394
395
  )
395
396
  except (OSError, UnicodeError, ValueError):
396
397
  return [*errors, "agent invocation metadata is invalid"]
@@ -28,6 +28,7 @@ from .domain.worker_exec import (
28
28
  )
29
29
  from .domain.worker_presentation import JsonEvents, Presentation
30
30
  from .session_transcript import SessionTranscript
31
+ from .json_boundary import JsonBoundaryError, write_owned_object_atomic
31
32
 
32
33
  LIVE = "live"
33
34
  QUIET = "quiet"
@@ -225,7 +226,11 @@ def _with_served_model_observation(
225
226
  observation.record(observed)
226
227
  return observed
227
228
 
228
- return JsonEvents(normalise=presentation.normalise, observe=observe)
229
+ return JsonEvents(
230
+ normalise=presentation.normalise,
231
+ observe=observe,
232
+ join_adjacent_text=presentation.join_adjacent_text,
233
+ )
229
234
 
230
235
 
231
236
  class _ServedModelObservation:
@@ -350,9 +355,7 @@ def _pump(
350
355
  stdin_text: str | None = None,
351
356
  ) -> tuple[int, bool, int]:
352
357
  selector = selectors.DefaultSelector()
353
- readers = _register_streams(
354
- selector, process, strategy, transcript, presentation == LIVE
355
- )
358
+ readers = _register_streams(selector, process, strategy, transcript)
356
359
  outgoing = _register_prompt(selector, process, stdin_text)
357
360
  closing_text: str | None = None
358
361
  last_output = time.monotonic()
@@ -387,6 +390,7 @@ def _pump(
387
390
 
388
391
  for reader in readers.values():
389
392
  closing_text = reader.flush() or closing_text
393
+ strategy.flush(transcript)
390
394
 
391
395
  exit_code = process.wait()
392
396
  if closing_text is None and isinstance(strategy, JsonEvents):
@@ -441,13 +445,12 @@ def _register_streams(
441
445
  process: subprocess.Popen[bytes],
442
446
  presentation: Presentation,
443
447
  transcript: SessionTranscript,
444
- live: bool,
445
448
  ) -> dict[int, _LineReader]:
446
449
  """이 자식이 말하는 스트림마다 전략이 지정한 싱크를 붙인다."""
447
450
  assert process.stdout is not None
448
451
  streams = {"stdout": process.stdout, "stderr": process.stderr}
449
452
  readers: dict[int, _LineReader] = {}
450
- for channel, sink in presentation.sinks(transcript, live):
453
+ for channel, sink in presentation.sinks(transcript):
451
454
  stream = streams[channel]
452
455
  assert stream is not None, channel
453
456
  selector.register(stream, selectors.EVENT_READ)
@@ -599,12 +602,11 @@ def _write_status(path: Path, status: Mapping[str, Any]) -> None:
599
602
  Replaced whole rather than rewritten in place: readers poll this file while
600
603
  the worker is still running and must never see a half-written document.
601
604
  """
602
- temporary = Path(f"{path}.tmp")
603
605
  try:
604
- path.parent.mkdir(parents=True, exist_ok=True)
605
- temporary.write_text(
606
- json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
606
+ write_owned_object_atomic(
607
+ path,
608
+ status,
609
+ artifact="worker wrapper status",
607
610
  )
608
- os.replace(temporary, path)
609
- except OSError:
611
+ except (OSError, JsonBoundaryError):
610
612
  return
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  from pathlib import Path
17
+ from .json_boundary import load_owned_object
17
18
 
18
19
  PHASE_SEQUENCE = [
19
20
  "requirements-discovery",
@@ -160,7 +161,7 @@ def load_phase_forbidden(workspace_root: Path) -> dict[str, str]:
160
161
  `prompts/profiles/forbidden-actions.json`.
161
162
  """
162
163
  path = Path(workspace_root) / "prompts" / "profiles" / "forbidden-actions.json"
163
- data = json.loads(path.read_text(encoding="utf-8"))
164
+ data = load_owned_object(path, artifact="forbidden actions profile")
164
165
  if "unknown" not in data:
165
166
  # compute_workflow_state falls back to the "unknown" entry for any
166
167
  # unmapped task_type; without it a missing/renamed key would surface as
@@ -44,6 +44,7 @@ from typing import Optional
44
44
  from okstra_project.dirs import okstra_home, project_json_path
45
45
 
46
46
  from .ids import _safe_fs_segment
47
+ from .json_boundary import JsonBoundaryError, load_owned_object
47
48
  from . import worktree_registry
48
49
  from .seeding import (
49
50
  SettingsLinkError,
@@ -415,8 +416,8 @@ def _read_project_json_field(project_root: Path, field: str) -> Optional[tuple[s
415
416
  if not target.is_file():
416
417
  return None
417
418
  try:
418
- data = json.loads(target.read_text(encoding="utf-8"))
419
- except (OSError, json.JSONDecodeError):
419
+ data = load_owned_object(target, artifact="project config")
420
+ except (OSError, JsonBoundaryError):
420
421
  return None
421
422
  if not isinstance(data, dict):
422
423
  return None
@@ -6,6 +6,8 @@ from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from typing import Any, Mapping
8
8
 
9
+ from .json_boundary import JsonBoundaryError, load_owned_object
10
+
9
11
 
10
12
  @dataclass(frozen=True)
11
13
  class WrapperStatus:
@@ -65,8 +67,8 @@ def prompt_derived_paths(prompt_path: Path) -> tuple[Path, ...]:
65
67
 
66
68
  def read_wrapper_status(path: Path) -> WrapperStatus | None:
67
69
  try:
68
- raw = json.loads(path.read_text(encoding="utf-8"))
69
- except (OSError, json.JSONDecodeError):
70
+ raw = load_owned_object(path, artifact="worker wrapper status")
71
+ except (OSError, JsonBoundaryError):
70
72
  return None
71
73
  if not isinstance(raw, dict):
72
74
  return None
@@ -9,6 +9,8 @@ from dataclasses import dataclass
9
9
  from pathlib import Path, PurePosixPath
10
10
  from typing import Any, Literal
11
11
 
12
+ from .json_boundary import JsonBoundaryError, load_owned_object
13
+
12
14
  BoundaryPrecision = Literal["exact-path", "directory-boundary", "none"]
13
15
 
14
16
  _PRECISION_RANK = {"none": 0, "directory-boundary": 1, "exact-path": 2}
@@ -538,8 +540,8 @@ def _rooted(project_root: Path, value: str) -> Path:
538
540
 
539
541
  def _read_json(path: Path, label: str) -> Mapping[str, Any]:
540
542
  try:
541
- value = json.loads(path.read_text(encoding="utf-8"))
542
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
543
+ value = load_owned_object(path, artifact=label)
544
+ except (OSError, UnicodeError, JsonBoundaryError) as exc:
543
545
  raise WritePolicyError(f"{label} is invalid: {path}") from exc
544
546
  if not isinstance(value, Mapping):
545
547
  raise WritePolicyError(f"{label} must be an object: {path}")
@@ -1,29 +1,56 @@
1
1
  """Antigravity (`agy`) CLI usage collector.
2
2
 
3
- okstra dispatches the antigravity-worker via `agy --print` (see
4
- `okstra-antigravity-exec.sh`). That non-interactive print mode does not persist
5
- a parseable transcript with per-call token counts to any known location, so
6
- per-run token/cost usage cannot be attributed to the antigravity worker. These
7
- collectors therefore report "unavailable" rather than fabricating numbers — the
8
- worker's usage block degrades to `na` and `/okstra-inspect context-cost` shows
9
- N/A for antigravity. If a future `agy` build exposes a transcript, wire its
10
- parser here (model ids stay `gemini-*`, so `antigravity_cost_usd` already prices it).
3
+ 공식 headless 문서는 ``--output-format json`` / ``stream-json`` 의 최종
4
+ ``usage`` 객체에 토큰을 둔다. okstra 이미 ``stream-json`` 으로 호출하고
5
+ 줄을 워커 로그에 남긴다. 정본은 마지막 ``result.usage`` 이다.
11
6
  """
12
7
  from __future__ import annotations
13
8
 
14
9
  from pathlib import Path
15
10
 
16
-
17
- PRINT_MODE_USAGE_NOTE = "N/A — print mode exposes no transcript"
11
+ from .jsonl_io import iter_jsonl
18
12
 
19
13
 
20
14
  def antigravity_session_total(json_path: Path) -> dict:
21
- return {"totalTokens": 0, "available": False}
15
+ """stream-json 로그에서 마지막 result.usage 를 읽는다."""
16
+ result_usage: dict | None = None
17
+ step_usage: dict | None = None
18
+ model: str | None = None
19
+ for record in iter_jsonl(json_path):
20
+ kind = record.get("event")
21
+ if kind == "init":
22
+ init = record.get("init")
23
+ if isinstance(init, dict) and isinstance(init.get("model"), str):
24
+ model = init["model"]
25
+ continue
26
+ if kind == "result":
27
+ result = record.get("result")
28
+ if isinstance(result, dict) and isinstance(result.get("usage"), dict):
29
+ result_usage = result["usage"]
30
+ continue
31
+ if kind == "step_update":
32
+ step = record.get("step_update")
33
+ if isinstance(step, dict) and isinstance(step.get("usage"), dict):
34
+ step_usage = step["usage"]
35
+ usage = result_usage or step_usage
36
+ if usage is None:
37
+ return {"totalTokens": 0, "available": False}
38
+ return {
39
+ "totalTokens": usage.get("total_tokens", 0) or 0,
40
+ "inputTokens": usage.get("input_tokens", 0) or 0,
41
+ "outputTokens": usage.get("output_tokens", 0) or 0,
42
+ "thoughtsTokens": usage.get("thinking_tokens", 0) or 0,
43
+ "cacheReadTokens": usage.get("cache_read_tokens", 0) or 0,
44
+ "model": model,
45
+ "available": True,
46
+ }
22
47
 
23
48
 
24
49
  def find_antigravity_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
25
- return None
50
+ sessions = find_antigravity_sessions(project_root, started_at, ended_at)
51
+ return sessions[-1] if sessions else None
26
52
 
27
53
 
28
54
  def find_antigravity_sessions(project_root: Path, started_at: str, ended_at: str) -> list[Path]:
55
+ # 세션 파일은 홈 디렉터리가 아니라 워커 로그다. 경로는 호출부가 prompt 에서 붙인다.
29
56
  return []
@@ -14,16 +14,17 @@ from .claude import (
14
14
  find_claude_team_sessions,
15
15
  )
16
16
  from .codex import codex_session_total, find_codex_sessions
17
- from .antigravity import (
18
- PRINT_MODE_USAGE_NOTE,
19
- antigravity_session_total,
20
- find_antigravity_sessions,
21
- )
17
+ from .antigravity import antigravity_session_total, find_antigravity_sessions
18
+ from .grok import find_grok_sessions, grok_session_total
22
19
  from .paths import claude_project_dir, utc_now
23
20
  from .pricing import antigravity_cost_usd, provider_cost_usd
24
21
  from okstra_ctl.dispatch_state import worker_session_ids
25
22
  from okstra_ctl.models import provider_wrappers
26
- from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
23
+ from okstra_ctl.wrapper_status import (
24
+ log_path_for_prompt,
25
+ read_wrapper_status,
26
+ status_path_for_prompt,
27
+ )
27
28
 
28
29
 
29
30
  def match_prefixes(worker_id: str) -> list[str]:
@@ -472,6 +473,8 @@ def _cli_sessions_for_windows(
472
473
  matches = find_codex_sessions(project_root, started_at, ended_at)
473
474
  elif provider == "antigravity":
474
475
  matches = find_antigravity_sessions(project_root, started_at, ended_at)
476
+ elif provider == "grok":
477
+ matches = find_grok_sessions(project_root, started_at, ended_at)
475
478
  else:
476
479
  matches = []
477
480
  for path in matches:
@@ -488,6 +491,8 @@ def _cli_session_totals(provider: str, session_paths: list[Path]) -> list[dict]:
488
491
  total = codex_session_total(session_path)
489
492
  elif provider == "antigravity":
490
493
  total = antigravity_session_total(session_path)
494
+ elif provider == "grok":
495
+ total = grok_session_total(session_path)
491
496
  else:
492
497
  continue
493
498
  if total.get("available"):
@@ -582,24 +587,20 @@ def collect_cli_usage(
582
587
  execution = wrapper_execution(status_path)
583
588
  execution_note = _execution_note(execution)
584
589
 
585
- if provider == "antigravity":
586
- block = na_block(f"{PRINT_MODE_USAGE_NOTE}; {execution_note}")
590
+ totals = _cli_session_totals(provider, sessions)
591
+ if not sessions:
592
+ block = na_block(
593
+ f"{execution_note}; CLI usage attribution unavailable because no transcript was found"
594
+ )
587
595
  block["cliNote"] = block["note"]
596
+ elif not totals:
597
+ block = na_block(
598
+ f"{execution_note}; transcript found but no final token snapshot was recorded"
599
+ )
600
+ block["cliNote"] = block["note"]
601
+ block["cliSessionPaths"] = [str(path) for path in sessions]
588
602
  else:
589
- totals = _cli_session_totals(provider, sessions)
590
- if not sessions:
591
- block = na_block(
592
- f"{execution_note}; CLI usage attribution unavailable because no transcript was found"
593
- )
594
- block["cliNote"] = block["note"]
595
- elif not totals:
596
- block = na_block(
597
- f"{execution_note}; transcript found but no final token snapshot was recorded"
598
- )
599
- block["cliNote"] = block["note"]
600
- block["cliSessionPaths"] = [str(path) for path in sessions]
601
- else:
602
- block = _cli_usage_block(provider, _aggregate_totals(totals), sessions)
603
+ block = _cli_usage_block(provider, _aggregate_totals(totals), sessions)
603
604
 
604
605
  block["cliExecutionStatus"] = execution["status"]
605
606
  if fallback_window_used:
@@ -624,6 +625,51 @@ def _worker_cli_windows(
624
625
  return fallback_windows, status_path, True
625
626
 
626
627
 
628
+ def _antigravity_log_paths(
629
+ project_root: Path,
630
+ worker: dict,
631
+ status_path: Path | None,
632
+ ) -> list[Path]:
633
+ """agy stream-json 은 워커 로그에 남는다. 홈 세션 디렉터리가 아니다."""
634
+ paths: list[Path] = []
635
+ prompt_path = _resolve_project_path(project_root, str(worker.get("promptPath") or ""))
636
+ if prompt_path is not None:
637
+ log_path = log_path_for_prompt(prompt_path)
638
+ if log_path.is_file():
639
+ paths.append(log_path)
640
+ status = read_wrapper_status(status_path) if status_path else None
641
+ if status is not None and status.log_path is not None and status.log_path.is_file():
642
+ if status.log_path not in paths:
643
+ paths.append(status.log_path)
644
+ return paths
645
+
646
+
647
+ def _worker_cli_usage_block(
648
+ *,
649
+ provider: str,
650
+ project_root: Path,
651
+ worker: dict,
652
+ fallback_windows: list[tuple[str, str]],
653
+ ) -> dict:
654
+ """공급자 프로세스 트랜스크립트와 실행 증거로 워커 사용량 블록을 만든다."""
655
+ windows, status_path, used_fallback = _worker_cli_windows(
656
+ project_root,
657
+ worker,
658
+ fallback_windows,
659
+ )
660
+ session_paths = _cli_sessions_for_windows(provider, project_root, windows)
661
+ if provider == "antigravity":
662
+ for log_path in _antigravity_log_paths(project_root, worker, status_path):
663
+ if log_path not in session_paths:
664
+ session_paths.append(log_path)
665
+ return collect_cli_usage(
666
+ provider=provider,
667
+ status_path=status_path,
668
+ sessions=session_paths,
669
+ fallback_window_used=used_fallback,
670
+ )
671
+
672
+
627
673
  def _attach_cli_usage(
628
674
  block: dict,
629
675
  provider: str,
@@ -638,8 +684,8 @@ def _attach_cli_usage(
638
684
  window produces several rollout jsonls; pricing only the latest (the old
639
685
  ``find_codex_session`` -> ``sessions[-1]`` behavior) dropped the earlier
640
686
  attempts' tokens. We sum every in-window session here so the redispatched
641
- CLI spend is fully reported. (agy print mode leaves no transcript, so the
642
- antigravity path still resolves to "session not found" → na cost.)
687
+ CLI spend is fully reported. antigravity 워커 로그의 stream-json
688
+ ``result.usage`` 같은 합산 경로에 붙인다.
643
689
  """
644
690
  session_paths = _cli_sessions_for_windows(provider, project_root, windows)
645
691
  cli = collect_cli_usage(
@@ -676,17 +722,11 @@ def _collect_cli_runtime_usage(state: dict, project_root: Path) -> dict:
676
722
  )
677
723
  continue
678
724
  worker_id = str(worker.get("workerId") or "").strip()
679
- windows, status_path, used_fallback = _worker_cli_windows(
680
- project_root,
681
- worker,
682
- windows_by_worker.get(worker_id, []),
683
- )
684
- session_paths = _cli_sessions_for_windows(provider, project_root, windows)
685
- worker["usage"] = collect_cli_usage(
725
+ worker["usage"] = _worker_cli_usage_block(
686
726
  provider=provider,
687
- status_path=status_path,
688
- sessions=session_paths,
689
- fallback_window_used=used_fallback,
727
+ project_root=project_root,
728
+ worker=worker,
729
+ fallback_windows=windows_by_worker.get(worker_id, []),
690
730
  )
691
731
  _populate_usage_summary(state, team_name=resolve_team_name(state),
692
732
  sessions_found=0, needle_source="none")
@@ -871,6 +911,7 @@ def collect_claude_runtime_usage(
871
911
  sessions_dir = claude_project_dir(cwd)
872
912
  # sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
873
913
  attributed_sids: set[str] = set()
914
+ cli_windows_by_worker = _codex_worker_windows(cwd, state)
874
915
  for worker in state.get("workers", []):
875
916
  worker_id = worker.get("workerId")
876
917
  agent = worker.get("agent")
@@ -922,10 +963,21 @@ def collect_claude_runtime_usage(
922
963
  matched_sids.add(entry[0])
923
964
 
924
965
  if not matched:
925
- worker["usage"] = na_block(
926
- "no Claude session jsonl in the run window for dispatched "
927
- f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
928
- )
966
+ provider = _cli_assignment_provider(worker)
967
+ if provider and str(worker.get("runner") or "") == "cli-wrapper":
968
+ worker["usage"] = _worker_cli_usage_block(
969
+ provider=provider,
970
+ project_root=cwd,
971
+ worker=worker,
972
+ fallback_windows=cli_windows_by_worker.get(
973
+ str(worker_id or "").strip(), []
974
+ ),
975
+ )
976
+ else:
977
+ worker["usage"] = na_block(
978
+ "no Claude session jsonl in the run window for dispatched "
979
+ f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
980
+ )
929
981
  continue
930
982
 
931
983
  # Stable order by startedAt so the "primary" session is the first one.