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
@@ -15,7 +15,9 @@ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
17
  from okstra_ctl.ids import slugify_task_segment
18
+ from okstra_ctl.fixed_text import line, scalar
18
19
  from okstra_ctl.paths import task_dir, task_manifest_file
20
+ from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic
19
21
  from okstra_project import (
20
22
  ResolverError,
21
23
  StateError,
@@ -26,8 +28,23 @@ from okstra_project import (
26
28
  ALLOWED_WORK_STATUSES = ("todo", "in-progress", "blocked", "done")
27
29
 
28
30
 
29
- def _emit(payload: dict) -> None:
30
- print(json.dumps(payload, ensure_ascii=False, indent=2))
31
+ def _emit(payload: dict, *, text: bool = False) -> None:
32
+ if not text:
33
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
34
+ return
35
+ lines = ["# Okstra Work Status Result", ""]
36
+ for key, label in (
37
+ ("ok", "OK"), ("stage", "Stage"), ("reason", "Reason"),
38
+ ("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
39
+ ("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
40
+ ("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
41
+ ):
42
+ if key in payload:
43
+ lines.append(line(label, payload.get(key)).rstrip("\n"))
44
+ for match in payload.get("matches", []) if isinstance(payload.get("matches"), list) else []:
45
+ if isinstance(match, dict):
46
+ lines.append(f"- Match: `{scalar(match.get('taskKey'))}`")
47
+ print("\n".join(lines))
31
48
 
32
49
 
33
50
  def _manifest_path(project_root: Path, entry: dict) -> Path:
@@ -44,7 +61,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
44
61
  return task_manifest_file(task_dir(project_root, group_seg, id_seg))
45
62
 
46
63
 
47
- def main(argv: list[str] | None = None) -> int:
64
+ def _parse_args(argv: list[str] | None):
48
65
  parser = argparse.ArgumentParser(
49
66
  description="Set a task's user-managed workStatus in task-manifest.json."
50
67
  )
@@ -64,13 +81,16 @@ def main(argv: list[str] | None = None) -> int:
64
81
  parser.add_argument("--project-root", default="", help="project root for catalog lookup")
65
82
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
66
83
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
67
- args = parser.parse_args(argv)
84
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
85
+ return parser.parse_args(argv)
86
+
68
87
 
88
+ def _resolve_entry(args):
69
89
  try:
70
90
  project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
71
91
  except ResolverError as exc:
72
- _emit({"ok": False, "stage": "resolve", "reason": str(exc)})
73
- return 2
92
+ _emit({"ok": False, "stage": "resolve", "reason": str(exc)}, text=args.text)
93
+ return None, None, 2
74
94
 
75
95
  token = args.token
76
96
  task_group = args.task_group
@@ -84,17 +104,42 @@ def main(argv: list[str] | None = None) -> int:
84
104
  project_root, token, task_group=task_group or None
85
105
  )
86
106
  except StateError as exc:
87
- _emit({"ok": False, "stage": "catalog", "reason": str(exc)})
88
- return 2
107
+ _emit({"ok": False, "stage": "catalog", "reason": str(exc)}, text=args.text)
108
+ return None, None, 2
89
109
 
90
110
  if not matches:
91
- _emit({"ok": False, "stage": "not-found", "token": args.token})
92
- return 1
111
+ _emit({"ok": False, "stage": "not-found", "token": args.token}, text=args.text)
112
+ return None, None, 1
93
113
  if len(matches) > 1:
94
- _emit({"ok": False, "stage": "ambiguous", "matches": matches})
95
- return 2
114
+ _emit({"ok": False, "stage": "ambiguous", "matches": matches}, text=args.text)
115
+ return None, None, 2
116
+ return project_root, matches[0], 0
117
+
118
+
119
+ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) -> dict:
120
+ previous = manifest.get("workStatus", "")
121
+ manifest["workStatus"] = args.status
122
+ manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
123
+ "%Y-%m-%dT%H:%M:%SZ"
124
+ )
125
+ if args.note is not None:
126
+ manifest["workStatusNote"] = args.note
127
+ write_owned_object_atomic(manifest_path, manifest, artifact="task manifest")
128
+ return {
129
+ "ok": True, "taskKey": entry.get("taskKey", ""),
130
+ "previousWorkStatus": previous, "workStatus": args.status,
131
+ "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
132
+ "workStatusNote": manifest.get("workStatusNote", ""),
133
+ "taskManifestPath": str(manifest_path),
134
+ }
96
135
 
97
- entry = matches[0]
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ args = _parse_args(argv)
139
+ project_root, entry, resolution_exit = _resolve_entry(args)
140
+ if resolution_exit:
141
+ return resolution_exit
142
+ assert project_root is not None and entry is not None
98
143
  manifest_path = _manifest_path(project_root, entry)
99
144
  if not manifest_path.exists():
100
145
  _emit(
@@ -103,11 +148,12 @@ def main(argv: list[str] | None = None) -> int:
103
148
  "stage": "manifest-missing",
104
149
  "taskKey": entry.get("taskKey", ""),
105
150
  "taskManifestPath": str(manifest_path),
106
- }
151
+ },
152
+ text=args.text,
107
153
  )
108
154
  return 1
109
155
  try:
110
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
156
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
111
157
  except ValueError as exc:
112
158
  _emit(
113
159
  {
@@ -115,32 +161,12 @@ def main(argv: list[str] | None = None) -> int:
115
161
  "stage": "manifest-invalid",
116
162
  "taskManifestPath": str(manifest_path),
117
163
  "reason": str(exc),
118
- }
164
+ },
165
+ text=args.text,
119
166
  )
120
167
  return 1
121
168
 
122
- previous = manifest.get("workStatus", "")
123
- manifest["workStatus"] = args.status
124
- manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
125
- "%Y-%m-%dT%H:%M:%SZ"
126
- )
127
- if args.note is not None:
128
- manifest["workStatusNote"] = args.note
129
- manifest_path.write_text(
130
- json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
131
- )
132
-
133
- _emit(
134
- {
135
- "ok": True,
136
- "taskKey": entry.get("taskKey", ""),
137
- "previousWorkStatus": previous,
138
- "workStatus": args.status,
139
- "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
140
- "workStatusNote": manifest.get("workStatusNote", ""),
141
- "taskManifestPath": str(manifest_path),
142
- }
143
- )
169
+ _emit(_updated_payload(manifest_path, manifest, entry, args), text=args.text)
144
170
  return 0
145
171
 
146
172
 
@@ -14,6 +14,8 @@ import subprocess
14
14
  from dataclasses import dataclass
15
15
  from pathlib import Path
16
16
 
17
+ from .json_boundary import JsonBoundaryError, load_owned_object
18
+
17
19
  _REPORT_RE = re.compile(r"final-report-implementation-(\d+)\.data\.json$")
18
20
 
19
21
 
@@ -60,8 +62,8 @@ def derive_stage_fix_carry(
60
62
  if report is None:
61
63
  return None
62
64
  try:
63
- data = json.loads(report.read_text(encoding="utf-8"))
64
- except (OSError, json.JSONDecodeError):
65
+ data = load_owned_object(report, artifact="implementation final report")
66
+ except (OSError, JsonBoundaryError):
65
67
  return None
66
68
  impl = data.get("implementation")
67
69
  if not isinstance(impl, dict):
@@ -8,6 +8,8 @@ from pathlib import Path
8
8
  from typing import Any, Iterable
9
9
 
10
10
  from .final_report_paths import final_report_data_path, is_full_reading_copy_path
11
+ from .json_boundary import JsonBoundaryError, load_owned_object
12
+ from .fixed_text import line, value_lines
11
13
  from .md_table import is_separator_row, split_pipe_row
12
14
  from .paths import RunRef
13
15
  from .plan_run_root import list_implementation_planning_reports
@@ -48,6 +50,24 @@ class StageMapSnapshot:
48
50
  stages: list[dict[str, Any]]
49
51
 
50
52
 
53
+ def render_stage_map_text(payload: dict[str, Any]) -> str:
54
+ """모델 소비용 Stage Map 스냅샷을 고정 텍스트로 투영한다."""
55
+ rows = ["Okstra stage map\n"]
56
+ rows.append(line("Status", "ready" if payload.get("ok") else "error"))
57
+ for label, key in (
58
+ ("Task key", "taskKey"), ("Task root", "taskRoot"),
59
+ ("State", "state"), ("Source plan path", "sourcePlanPath"),
60
+ ):
61
+ rows.append(line(label, payload.get(key)))
62
+ for label, key in (("Stages", "stages"), ("Done stages", "doneStages"),
63
+ ("Planning", "planning")):
64
+ rows.extend(value_lines(label, payload.get(key)))
65
+ if not payload.get("ok"):
66
+ rows.append(line("Failure stage", payload.get("stage")))
67
+ rows.append(line("Failure reason", payload.get("reason")))
68
+ return "".join(rows)
69
+
70
+
51
71
  # `stepwiseExecution` is what the stage DOES; the other three are what becomes
52
72
  # true when it is finished. A schedule carrying only the latter reads as an
53
73
  # analysis of a plan rather than the plan itself.
@@ -354,8 +374,8 @@ def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
354
374
  resolved = Path(plan_path).resolve()
355
375
  if is_report_record_path(resolved):
356
376
  try:
357
- data = json.loads(resolved.read_text(encoding="utf-8"))
358
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
377
+ data = load_owned_object(resolved, artifact="planning final report")
378
+ except (OSError, UnicodeError, JsonBoundaryError) as exc:
359
379
  raise StageMapError("stage_map", str(exc), str(resolved)) from exc
360
380
  if not isinstance(data, dict):
361
381
  raise StageMapError(
@@ -379,8 +399,8 @@ def structured_report(plan_path: Path) -> dict[str, Any]:
379
399
  if not is_report_record_path(resolved) or not resolved.exists():
380
400
  return {}
381
401
  try:
382
- data = json.loads(resolved.read_text(encoding="utf-8"))
383
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
402
+ data = load_owned_object(resolved, artifact="planning final report")
403
+ except (OSError, UnicodeError, JsonBoundaryError) as exc:
384
404
  raise StageMapError("stage_map", str(exc), str(resolved)) from exc
385
405
  if not isinstance(data, dict) or data.get("schemaVersion") not in {"2.0", "3.0"}:
386
406
  return {}
@@ -518,8 +538,8 @@ def _unique_carry_source_paths(task_root: Path) -> list[Path]:
518
538
  paths: set[Path] = set()
519
539
  for carry_path in sorted(carry_dir.glob("stage-*.json")):
520
540
  try:
521
- carry = json.loads(carry_path.read_text(encoding="utf-8"))
522
- except (OSError, UnicodeError, json.JSONDecodeError):
541
+ carry = load_owned_object(carry_path, artifact="implementation stage carry")
542
+ except (OSError, UnicodeError, JsonBoundaryError):
523
543
  continue
524
544
  value = carry.get("sourcePlanPath") if isinstance(carry, dict) else None
525
545
  if isinstance(value, str) and value:
@@ -853,18 +853,17 @@ def integrate_and_teardown_whole_task(
853
853
  stage_map 은 done 행에서 만든다. 정리 대상은 완료된 stage 뿐이고, 계획에만 있고
854
854
  완료되지 않은 stage 는 Phase A 가 어차피 건너뛰기 때문이다.
855
855
  """
856
- from json import loads as _loads
857
-
858
856
  from okstra_project.dirs import okstra_home, project_json_path
859
857
 
860
858
  from . import consumers, worktree_registry
859
+ from .json_boundary import load_owned_object
861
860
  from .locks import worktree_provision_mutex
862
861
  from .paths import task_runs_dir
863
862
  from .stage_integrate import integrate_stages
864
863
 
865
864
  try:
866
- project_id = _loads(
867
- project_json_path(project_root).read_text(encoding="utf-8")
865
+ project_id = load_owned_object(
866
+ project_json_path(project_root), artifact="project config"
868
867
  ).get("projectId", "")
869
868
  except (OSError, ValueError):
870
869
  project_id = ""
@@ -39,6 +39,7 @@ from .ports.worker_dispatch import WorkerDispatchRequest
39
39
  from .registry.host_registry import default_host_registry
40
40
  from .registry.provider_registry import default_provider_registry
41
41
  from .session import observe_lead_session
42
+ from .json_boundary import load_owned_object
42
43
 
43
44
 
44
45
  _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
@@ -417,7 +418,7 @@ def _load_manifest(project_root: str, run_manifest: str) -> dict[str, Any]:
417
418
  def _load_json(path: Path, label: str) -> dict[str, Any]:
418
419
  if not path.is_file():
419
420
  raise DispatchError(f"{label} not found: {path}")
420
- payload = json.loads(path.read_text(encoding="utf-8"))
421
+ payload = load_owned_object(path, artifact=label)
421
422
  if not isinstance(payload, dict):
422
423
  raise DispatchError(f"{label} must be a JSON object: {path}")
423
424
  return payload
@@ -36,6 +36,11 @@ import tempfile
36
36
  from pathlib import Path
37
37
 
38
38
  from okstra_ctl.session import resolve_inproc_lead_session_id
39
+ from okstra_ctl.json_boundary import (
40
+ JsonBoundaryError,
41
+ external_claude_team_json_source,
42
+ load_external_json,
43
+ )
39
44
 
40
45
 
41
46
  def live_pane_ids() -> tuple[bool, set[str]]:
@@ -148,8 +153,12 @@ def _reconcile_config(cfg_path: Path, dry_run: bool, print_members: bool) -> int
148
153
  line per non-lead member so the lead knows whom to ``shutdown_request``.
149
154
  """
150
155
  try:
151
- config = json.loads(cfg_path.read_text())
152
- except (OSError, json.JSONDecodeError) as exc:
156
+ # 외부 입력: Claude Code가 소유하는 team roster 설정이다.
157
+ config = load_external_json(
158
+ external_claude_team_json_source(cfg_path),
159
+ artifact="Claude team configuration",
160
+ )
161
+ except JsonBoundaryError as exc:
153
162
  print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
154
163
  file=sys.stderr)
155
164
  return 1
@@ -14,7 +14,10 @@ import json
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
+ from .fixed_text import line as _text_line
18
+
17
19
  from okstra_ctl.paths import resolve_under_root, task_timeline_file
20
+ from okstra_ctl.json_boundary import load_owned_object
18
21
  from okstra_project import read_task_key
19
22
  from okstra_ctl.task_target import resolve_task_root
20
23
 
@@ -25,7 +28,7 @@ def time_rows_with_identity(run_root: Path) -> list[dict]:
25
28
 
26
29
  state_path = run_root / "state" / "team-state.json"
27
30
  try:
28
- state = json.loads(state_path.read_text(encoding="utf-8"))
31
+ state = load_owned_object(state_path, artifact="team state")
29
32
  except (OSError, ValueError):
30
33
  return []
31
34
  return identity_rows_from_state(state if isinstance(state, dict) else {})
@@ -34,7 +37,7 @@ def time_rows_with_identity(run_root: Path) -> list[dict]:
34
37
  def load_runs(task_root: Path) -> tuple[list[dict], bool]:
35
38
  path = task_timeline_file(task_root)
36
39
  try:
37
- data = json.loads(path.read_text(encoding="utf-8"))
40
+ data = load_owned_object(path, artifact="task timeline")
38
41
  except (OSError, ValueError):
39
42
  return [], True
40
43
  runs = data.get("runs", []) if isinstance(data, dict) else []
@@ -96,7 +99,7 @@ def _read_state(project_root: Path, rel: str) -> dict | None:
96
99
  if target is None:
97
100
  return None
98
101
  try:
99
- state = json.loads(target.read_text(encoding="utf-8"))
102
+ state = load_owned_object(target, artifact="team state")
100
103
  except (OSError, ValueError):
101
104
  return None
102
105
  return state if isinstance(state, dict) else None
@@ -211,6 +214,48 @@ def aggregate_time(runs: list[dict], project_root: Path) -> dict:
211
214
  }
212
215
 
213
216
 
217
+ def _worker_text(result: dict) -> str:
218
+ parts: list[str] = []
219
+ workers = result.get("perWorker")
220
+ if not isinstance(workers, dict):
221
+ return ""
222
+ for task_type, rows in workers.items():
223
+ for row in rows if isinstance(rows, list) else []:
224
+ if isinstance(row, dict):
225
+ parts.extend(("\n## Worker\n\n", _text_line("Task type", task_type), _text_line("Worker ID", row.get("workerId")), _text_line("Agents", ",".join(row.get("agents", [])) if isinstance(row.get("agents"), list) else None), _text_line("Runs", row.get("runs")), _text_line("Total ms", row.get("totalMs")), _text_line("Average ms", row.get("avgMs"))))
226
+ return "".join(parts)
227
+
228
+
229
+ def _timeline_text(result: dict) -> str:
230
+ parts: list[str] = []
231
+ for row in result.get("perRunWallClock", []):
232
+ if isinstance(row, dict):
233
+ parts.extend(("\n## Run wall clock\n\n", _text_line("Run timestamp", row.get("runTimestamp")), _text_line("Task type", row.get("taskType")), _text_line("Wall clock ms", row.get("wallClockMs"))))
234
+ for timeline in result.get("phaseTimelines", []):
235
+ if not isinstance(timeline, dict):
236
+ continue
237
+ for phase in timeline.get("phases", []):
238
+ if isinstance(phase, dict):
239
+ parts.extend(("\n## Phase\n\n", _text_line("Run timestamp", timeline.get("runTimestamp")), _text_line("Task type", timeline.get("taskType")), _text_line("Phase", phase.get("phase")), _text_line("First at", phase.get("firstAt")), _text_line("Wall ms to next", phase.get("wallMsToNext"))))
240
+ return "".join(parts)
241
+
242
+
243
+ def render_result_text(result: dict) -> str:
244
+ parts = ["# Okstra Time Input\n\n", _text_line("Task key", result.get("taskKey"))]
245
+ total = result.get("grandTotal") if isinstance(result.get("grandTotal"), dict) else {}
246
+ for key, label in (("runs", "Total runs"), ("leadMs", "Total lead ms"), ("workersMs", "Total workers ms"), ("cpuSumMs", "Total CPU sum ms")):
247
+ parts.append(_text_line(label, total.get(key)))
248
+ for row in result.get("byTaskType", []):
249
+ if not isinstance(row, dict):
250
+ continue
251
+ parts.extend(("\n## Task type\n\n", _text_line("Task type", row.get("taskType")), _text_line("Runs", row.get("runs")), _text_line("Lead ms", row.get("leadMs")), _text_line("Workers ms", row.get("workersMs")), _text_line("CPU sum ms", row.get("cpuSumMs"))))
252
+ parts.extend((_worker_text(result), _timeline_text(result)))
253
+ for row in result.get("unavailable", []):
254
+ if isinstance(row, dict):
255
+ parts.extend(("\n## Unavailable run\n\n", _text_line("Run timestamp", row.get("runTimestamp")), _text_line("Task type", row.get("taskType")), _text_line("Reason", row.get("reason"))))
256
+ return "".join(parts)
257
+
258
+
214
259
  def main(argv: list[str] | None = None) -> int:
215
260
  parser = argparse.ArgumentParser(
216
261
  prog="okstra time-report",
@@ -219,13 +264,15 @@ def main(argv: list[str] | None = None) -> int:
219
264
  parser.add_argument("--project-root", default="")
220
265
  parser.add_argument("--cwd", default=".")
221
266
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
267
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
222
268
  args = parser.parse_args(argv)
223
269
 
224
270
  task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
225
271
  runs, _ = load_runs(task_root)
226
272
  result = {"ok": True, "taskKey": read_task_key(task_root)}
227
273
  result.update(aggregate_time(runs, project_root))
228
- print(json.dumps(result, ensure_ascii=False, indent=2))
274
+ output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
275
+ print(output, end="" if args.text else "\n")
229
276
  return 0
230
277
 
231
278
 
@@ -13,6 +13,7 @@ from .execution_identity import (
13
13
  stored_identity,
14
14
  )
15
15
  from .execution_manifest import read_execution_manifest
16
+ from .json_boundary import load_owned_object
16
17
 
17
18
 
18
19
  def run_execution_manifest(run_root: Path) -> ExecutionManifest:
@@ -83,4 +84,4 @@ def _manifest_path(run_root: Path) -> Path:
83
84
 
84
85
 
85
86
  def _load_json(path: Path) -> dict[str, Any]:
86
- return json.loads(path.read_text(encoding="utf-8"))
87
+ return load_owned_object(path, artifact="usage identity source")
@@ -14,6 +14,8 @@ from okstra_ctl.time_report import (
14
14
  wall_clock_ms,
15
15
  load_runs,
16
16
  )
17
+ from okstra_ctl.json_boundary import load_owned_object
18
+ from okstra_ctl.fixed_text import line
17
19
  from okstra_project import ResolverError, list_project_tasks, resolve_project_root
18
20
 
19
21
  UTC = dt.timezone.utc
@@ -43,8 +45,8 @@ def _parse_timestamp(value: Any) -> dt.datetime | None:
43
45
 
44
46
  def _project_id(project_root: Path) -> str:
45
47
  try:
46
- data = json.loads(
47
- (project_root / ".okstra" / "project.json").read_text(encoding="utf-8")
48
+ data = load_owned_object(
49
+ project_root / ".okstra" / "project.json", artifact="project config"
48
50
  )
49
51
  except (OSError, ValueError):
50
52
  return ""
@@ -214,6 +216,9 @@ def build_usage_report(project_root: Path, days: int, now: dt.datetime) -> dict:
214
216
  rows = sorted(buckets.values(), key=lambda row: row["firstRunAt"])
215
217
  for row in rows:
216
218
  row["costUsd"] = round(_float_value(row["costUsd"]), 4)
219
+ row["collectionRate"] = (
220
+ row["collectedRuns"] / row["runs"] if row["runs"] else 0.0
221
+ )
217
222
  totals = {
218
223
  "runs": total_runs,
219
224
  "collectedRuns": collected_runs,
@@ -264,18 +269,67 @@ def main(argv: list[str] | None = None) -> int:
264
269
  parser.add_argument("--project-root", default="")
265
270
  parser.add_argument("--cwd", default=".")
266
271
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
272
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
267
273
  args = parser.parse_args(argv)
268
274
  try:
269
275
  project_root = Path(
270
276
  resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
271
277
  ).resolve()
272
278
  except ResolverError as exc:
273
- print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
279
+ payload = {"ok": False, "stage": "resolve", "reason": str(exc)}
280
+ print(render_usage_text(payload) if args.text else json.dumps(payload))
274
281
  return 2
275
282
  result = build_usage_report(project_root, args.days, utc_now())
276
- print(json.dumps(result, ensure_ascii=False, indent=2))
283
+ print(
284
+ render_usage_text(result)
285
+ if args.text
286
+ else json.dumps(result, ensure_ascii=False, indent=2)
287
+ )
277
288
  return 0
278
289
 
279
290
 
291
+ def render_usage_text(payload: dict) -> str:
292
+ """usage 모델 표면에 승인된 집계만 고정 순서로 투영한다."""
293
+ rows = ["Okstra usage\n"]
294
+ rows.append(line("Status", "ready" if payload.get("ok") else "error"))
295
+ rows.append(line("Project ID", payload.get("projectId")))
296
+ period = payload.get("range") if isinstance(payload.get("range"), dict) else {}
297
+ rows.append(line("Range days", period.get("days")))
298
+ runs = payload.get("runs") if isinstance(payload.get("runs"), dict) else {}
299
+ rows.append(line("Runs total", runs.get("total")))
300
+ rows.append(line("Runs collected", runs.get("collected")))
301
+ rows.append(line("Runs collection rate", runs.get("collectionRate")))
302
+ task_types = payload.get("byTaskType") if isinstance(payload.get("byTaskType"), list) else []
303
+ for index, item in enumerate(task_types, 1):
304
+ if not isinstance(item, dict):
305
+ continue
306
+ rows.append(line(f"Task type {index} name", item.get("taskType")))
307
+ rows.append(line(f"Task type {index} runs", item.get("runs")))
308
+ rows.append(line(f"Task type {index} collected runs", item.get("collectedRuns")))
309
+ rows.append(line(f"Task type {index} collection rate", item.get("collectionRate")))
310
+ rows.append(line(f"Task type {index} raw tokens", item.get("rawTokens")))
311
+ rows.append(line(f"Task type {index} billable tokens", item.get("billableTokens")))
312
+ rows.append(line(f"Task type {index} cost USD", item.get("costUsd")))
313
+ rows.append(line(f"Task type {index} CPU sum ms", item.get("cpuSumMs")))
314
+ rows.append(line(f"Task type {index} wall clock ms", item.get("wallClockMs")))
315
+ totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
316
+ for label, key in (("Raw tokens", "rawTokens"), ("Billable tokens", "billableTokens"),
317
+ ("Cost USD", "costUsd"), ("CPU sum ms", "cpuSumMs"),
318
+ ("Wall clock ms", "wallClockMs")):
319
+ rows.append(line(label, totals.get(key)))
320
+ for prefix, key in (("Unavailable", "unavailable"), ("Warning", "warnings")):
321
+ values = payload.get(key) if isinstance(payload.get(key), dict) else {}
322
+ for index, (name, count) in enumerate(sorted(values.items()), 1):
323
+ rows.append(line(f"{prefix} {index} reason", name))
324
+ rows.append(line(f"{prefix} {index} count", count))
325
+ models = payload.get("unmatchedModels") if isinstance(payload.get("unmatchedModels"), list) else []
326
+ for index, model in enumerate(models, 1):
327
+ rows.append(line(f"Unmatched model {index} name", model))
328
+ if not payload.get("ok"):
329
+ rows.append(line("Failure stage", payload.get("stage")))
330
+ rows.append(line("Failure reason", payload.get("reason")))
331
+ return "".join(rows)
332
+
333
+
280
334
  if __name__ == "__main__":
281
335
  raise SystemExit(main(sys.argv[1:]))