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
@@ -0,0 +1,366 @@
1
+ """Strict JSON boundaries for okstra-owned artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ import tempfile
10
+ from typing import Any
11
+
12
+ from .final_report_schema import SchemaError, validate as validate_schema
13
+
14
+
15
+ class JsonBoundaryError(ValueError):
16
+ """An owned JSON artifact could not satisfy its persistence contract."""
17
+
18
+ def __init__(self, path: Path, artifact: str, reason: str) -> None:
19
+ self.path = path
20
+ self.artifact = artifact
21
+ self.reason = reason
22
+ super().__init__(f"{artifact}: {reason}: {path}")
23
+
24
+
25
+ _EXTERNAL_SOURCE_SEAL = object()
26
+
27
+
28
+ @dataclass(frozen=True, init=False)
29
+ class ExternalJsonSource:
30
+ """검증된 외부 생산자가 소유한 JSON 입력 경로다."""
31
+
32
+ path: Path
33
+ producer: str
34
+
35
+ def __init__(self, path: Path, producer: str, seal: object = None) -> None:
36
+ if seal is not _EXTERNAL_SOURCE_SEAL:
37
+ raise TypeError("ExternalJsonSource is sealed")
38
+ object.__setattr__(self, "path", path)
39
+ object.__setattr__(self, "producer", producer)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class OwnedJsonSnapshot:
44
+ """한 번 읽은 원시 바이트와 그 바이트에서 검증한 객체다."""
45
+
46
+ value: dict[str, Any]
47
+ raw_bytes: bytes
48
+
49
+
50
+ def _issue_external_source(path: Path, producer: str) -> ExternalJsonSource:
51
+ if not isinstance(path, Path):
52
+ raise TypeError("external JSON source requires Path")
53
+ return ExternalJsonSource(path, producer, _EXTERNAL_SOURCE_SEAL)
54
+
55
+
56
+ def _is_okstra_owned_path(path: Path) -> bool:
57
+ lexical = Path(os.path.abspath(path))
58
+ resolved = path.resolve(strict=False)
59
+ return ".okstra" in lexical.parts or ".okstra" in resolved.parts
60
+
61
+
62
+ def external_user_json_source(path: Path) -> ExternalJsonSource:
63
+ """사용자 제공 JSON이 Okstra 소유 트리 밖에 있을 때만 승인한다."""
64
+ if _is_okstra_owned_path(path):
65
+ raise JsonBoundaryError(path, "external JSON", "Okstra-owned path")
66
+ return _issue_external_source(path, "user")
67
+
68
+
69
+ def external_worker_json_source(
70
+ path: Path, *, trusted_root: Path, lane_root: Path
71
+ ) -> ExternalJsonSource:
72
+ """신뢰 루트 아래의 정확한 worker 결과 lane만 승인한다."""
73
+ lexical_root = Path(os.path.abspath(trusted_root))
74
+ lexical_lane = Path(os.path.abspath(lane_root))
75
+ lexical_path = Path(os.path.abspath(path))
76
+ try:
77
+ lane_parts = lexical_lane.relative_to(lexical_root).parts
78
+ except ValueError:
79
+ lane_parts = ()
80
+ is_worker_lane = (
81
+ len(lane_parts) in {7, 8}
82
+ and lane_parts[:2] == (".okstra", "tasks")
83
+ and lane_parts[4] == "runs"
84
+ and lane_parts[-1] == "worker-results"
85
+ and (len(lane_parts) == 7 or lane_parts[6].startswith("stage-"))
86
+ )
87
+ if not is_worker_lane or lexical_path.parent != lexical_lane:
88
+ raise JsonBoundaryError(
89
+ path, "worker JSON", "outside worker result lanes"
90
+ )
91
+ try:
92
+ lexical_lane.relative_to(lexical_root)
93
+ lexical_path.relative_to(lexical_lane)
94
+ resolved_lane = lane_root.resolve(strict=False)
95
+ resolved_lane.relative_to(trusted_root.resolve(strict=False))
96
+ path.resolve(strict=False).relative_to(resolved_lane)
97
+ except ValueError as exc:
98
+ raise JsonBoundaryError(
99
+ path, "worker JSON", "outside worker result lanes"
100
+ ) from exc
101
+ if _has_symlink_component(lexical_lane, lexical_root) or _has_symlink_component(
102
+ lexical_path, lexical_lane
103
+ ):
104
+ raise JsonBoundaryError(path, "worker JSON", "outside worker result lanes")
105
+ return _issue_external_source(path, "worker")
106
+
107
+
108
+ def external_invocation_json_source(
109
+ path: Path, *, project_root: Path, purpose: str
110
+ ) -> ExternalJsonSource:
111
+ """정확한 standalone purpose lane의 직접 결과만 승인한다."""
112
+ if not purpose or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in purpose):
113
+ raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
114
+ lane = project_root / ".okstra" / "agent-invocations" / purpose
115
+ lexical_path = Path(os.path.abspath(path))
116
+ lexical_lane = Path(os.path.abspath(lane))
117
+ lexical_root = Path(os.path.abspath(project_root))
118
+ if lexical_path.parent != lexical_lane or path.is_symlink():
119
+ raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
120
+ try:
121
+ lane.resolve(strict=False).relative_to(project_root.resolve(strict=False))
122
+ path.resolve(strict=False).relative_to(lane.resolve(strict=False))
123
+ except ValueError as exc:
124
+ raise JsonBoundaryError(
125
+ path, "invocation JSON", "outside invocation result lane"
126
+ ) from exc
127
+ if _has_symlink_component(lexical_lane, lexical_root):
128
+ raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
129
+ return _issue_external_source(path, "standalone worker")
130
+
131
+
132
+ def _has_symlink_component(path: Path, root: Path) -> bool:
133
+ current = path
134
+ while current != root:
135
+ if current.is_symlink():
136
+ return True
137
+ if current.parent == current:
138
+ return True
139
+ current = current.parent
140
+ return root.is_symlink()
141
+
142
+
143
+ def external_claude_global_json_source(
144
+ path: Path, home_dir: Path
145
+ ) -> ExternalJsonSource:
146
+ """호스트 어댑터가 지정한 home의 전역 Claude 설정만 승인한다."""
147
+ expected = home_dir / ".claude.json"
148
+ if Path(os.path.abspath(path)) != Path(os.path.abspath(expected)) or path.is_symlink():
149
+ raise JsonBoundaryError(path, "Claude JSON", "outside fixed adapter path")
150
+ return _issue_external_source(path, "Claude Code")
151
+
152
+
153
+ def external_claude_team_json_source(path: Path) -> ExternalJsonSource:
154
+ """현재 사용자의 Claude team roster 설정만 승인한다."""
155
+ root = Path.home() / ".claude" / "teams"
156
+ try:
157
+ Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root)))
158
+ path.resolve(strict=False).relative_to(root.resolve(strict=False))
159
+ except ValueError as exc:
160
+ raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root") from exc
161
+ if path.name != "config.json" or path.is_symlink():
162
+ raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root")
163
+ return _issue_external_source(path, "Claude Code")
164
+
165
+
166
+ def external_tool_json_source(path: Path, workspace_root: Path) -> ExternalJsonSource:
167
+ """외부 도구가 workspace의 비-Okstra 경로에 쓴 보고서만 승인한다."""
168
+ try:
169
+ path.resolve(strict=False).relative_to(workspace_root.resolve(strict=False))
170
+ except ValueError as exc:
171
+ raise JsonBoundaryError(path, "tool JSON", "outside tool workspace") from exc
172
+ if _is_okstra_owned_path(path):
173
+ raise JsonBoundaryError(path, "tool JSON", "Okstra-owned path")
174
+ return _issue_external_source(path, "external tool")
175
+
176
+
177
+ def external_error_sidecar_source(path: Path) -> ExternalJsonSource:
178
+ """worker-results lane 또는 Okstra 밖 레거시 sidecar만 승인한다."""
179
+ if _is_okstra_owned_path(path):
180
+ raise JsonBoundaryError(
181
+ path, "worker error sidecar", "trusted worker lane is required"
182
+ )
183
+ return _issue_external_source(path, "legacy automation")
184
+
185
+
186
+ class _NonstandardJsonConstant(ValueError):
187
+ """Raised by ``json.loads`` when a non-standard numeric token appears."""
188
+
189
+
190
+ def load_owned_object(
191
+ path: Path,
192
+ *,
193
+ artifact: str,
194
+ schema: Mapping[str, Any] | None = None,
195
+ validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
196
+ ) -> dict[str, Any]:
197
+ """Load and validate one okstra-owned JSON object."""
198
+ return load_owned_object_snapshot(
199
+ path,
200
+ artifact=artifact,
201
+ schema=schema,
202
+ validate_cross_fields=validate_cross_fields,
203
+ ).value
204
+
205
+
206
+ def load_owned_object_snapshot(
207
+ path: Path,
208
+ *,
209
+ artifact: str,
210
+ schema: Mapping[str, Any] | None = None,
211
+ validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
212
+ ) -> OwnedJsonSnapshot:
213
+ """한 번 읽은 바이트에서 객체를 파싱·검증해 함께 반환한다."""
214
+ try:
215
+ raw_bytes = path.read_bytes()
216
+ value = json.loads(
217
+ raw_bytes.decode("utf-8"),
218
+ parse_constant=_reject_nonstandard_json_constant,
219
+ )
220
+ except FileNotFoundError as exc:
221
+ raise JsonBoundaryError(path, artifact, "missing") from exc
222
+ except UnicodeDecodeError as exc:
223
+ raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
224
+ except _NonstandardJsonConstant as exc:
225
+ raise JsonBoundaryError(path, artifact, str(exc)) from exc
226
+ except json.JSONDecodeError as exc:
227
+ raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
228
+ except OSError as exc:
229
+ raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
230
+ return OwnedJsonSnapshot(
231
+ value=_validate_owned(
232
+ value,
233
+ path=path,
234
+ artifact=artifact,
235
+ schema=schema,
236
+ validate_cross_fields=validate_cross_fields,
237
+ ),
238
+ raw_bytes=raw_bytes,
239
+ )
240
+
241
+
242
+ def load_external_json(
243
+ source: ExternalJsonSource,
244
+ *,
245
+ artifact: str,
246
+ object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None,
247
+ ) -> Any:
248
+ """Load external JSON without applying an owned-artifact contract."""
249
+ if not isinstance(source, ExternalJsonSource):
250
+ raise TypeError("load_external_json requires ExternalJsonSource")
251
+ path = source.path
252
+ try:
253
+ return json.loads(
254
+ path.read_text(encoding="utf-8"),
255
+ object_pairs_hook=object_pairs_hook,
256
+ )
257
+ except FileNotFoundError as exc:
258
+ raise JsonBoundaryError(path, artifact, "missing") from exc
259
+ except UnicodeDecodeError as exc:
260
+ raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
261
+ except json.JSONDecodeError as exc:
262
+ raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
263
+ except OSError as exc:
264
+ raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
265
+
266
+
267
+ def write_owned_object_atomic(
268
+ path: Path,
269
+ payload: Mapping[str, Any],
270
+ *,
271
+ artifact: str,
272
+ schema: Mapping[str, Any] | None = None,
273
+ validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
274
+ ) -> None:
275
+ """Validate a payload before atomically replacing an owned artifact."""
276
+ serialized = serialize_owned_object(
277
+ path,
278
+ payload,
279
+ artifact=artifact,
280
+ schema=schema,
281
+ validate_cross_fields=validate_cross_fields,
282
+ )
283
+ try:
284
+ path.parent.mkdir(parents=True, exist_ok=True)
285
+ descriptor, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
286
+ except OSError as exc:
287
+ raise JsonBoundaryError(path, artifact, f"I/O error while preparing write ({exc})") from exc
288
+ temporary = Path(raw_temp)
289
+ try:
290
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
291
+ handle.write(serialized)
292
+ handle.flush()
293
+ os.fsync(handle.fileno())
294
+ os.replace(temporary, path)
295
+ except (TypeError, ValueError) as exc:
296
+ raise JsonBoundaryError(path, artifact, f"JSON serialization failed ({exc})") from exc
297
+ except OSError as exc:
298
+ raise JsonBoundaryError(path, artifact, f"I/O error while writing ({exc})") from exc
299
+ finally:
300
+ temporary.unlink(missing_ok=True)
301
+
302
+
303
+ def serialize_owned_object(
304
+ path: Path,
305
+ payload: Mapping[str, Any],
306
+ *,
307
+ artifact: str,
308
+ schema: Mapping[str, Any] | None = None,
309
+ validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
310
+ ) -> str:
311
+ """전용 게시 프로토콜이 사용할 검증된 canonical 객체 문자열이다."""
312
+ owned = _validate_owned(
313
+ payload, path=path, artifact=artifact, schema=schema,
314
+ validate_cross_fields=validate_cross_fields,
315
+ )
316
+ try:
317
+ return json.dumps(
318
+ owned, ensure_ascii=False, indent=2, allow_nan=False
319
+ ) + "\n"
320
+ except (TypeError, ValueError) as exc:
321
+ raise JsonBoundaryError(
322
+ path, artifact, f"JSON serialization failed ({exc})"
323
+ ) from exc
324
+
325
+
326
+ def mutate_owned_object_atomic(
327
+ path: Path,
328
+ mutation: Callable[[dict[str, Any]], dict[str, Any]],
329
+ **contract: Any,
330
+ ) -> dict[str, Any]:
331
+ """Load, mutate, validate, and atomically replace one owned object."""
332
+ payload = load_owned_object(path, **contract)
333
+ updated = mutation(dict(payload))
334
+ write_owned_object_atomic(path, updated, **contract)
335
+ return updated
336
+
337
+
338
+ def _reject_nonstandard_json_constant(constant: str) -> None:
339
+ raise _NonstandardJsonConstant(f"non-standard JSON constant: {constant}")
340
+
341
+
342
+ def _validate_owned(
343
+ value: object,
344
+ *,
345
+ path: Path,
346
+ artifact: str,
347
+ schema: Mapping[str, Any] | None,
348
+ validate_cross_fields: Callable[[dict[str, Any]], object] | None,
349
+ ) -> dict[str, Any]:
350
+ if not isinstance(value, Mapping):
351
+ raise JsonBoundaryError(path, artifact, "top-level JSON value must be an object")
352
+ owned = dict(value)
353
+ if schema is not None:
354
+ try:
355
+ schema_errors = validate_schema(owned, dict(schema))
356
+ except SchemaError as exc:
357
+ raise JsonBoundaryError(path, artifact, f"invalid schema ({exc})") from exc
358
+ if schema_errors:
359
+ raise JsonBoundaryError(path, artifact, f"schema validation failed ({schema_errors[0]})")
360
+ if validate_cross_fields is not None:
361
+ errors = validate_cross_fields(owned)
362
+ if isinstance(errors, str):
363
+ errors = [errors]
364
+ if errors:
365
+ raise JsonBoundaryError(path, artifact, f"cross-field validation failed ({next(iter(errors))})")
366
+ return owned
@@ -3,11 +3,15 @@ from __future__ import annotations
3
3
 
4
4
  import contextlib
5
5
  import fcntl
6
- import json
7
- import os
8
6
  from collections.abc import Callable, Iterator
9
7
  from pathlib import Path
10
8
 
9
+ from .json_boundary import (
10
+ JsonBoundaryError,
11
+ load_owned_object,
12
+ write_owned_object_atomic,
13
+ )
14
+
11
15
 
12
16
  @contextlib.contextmanager
13
17
  def registry_lock(lock_path: Path) -> Iterator[None]:
@@ -26,18 +30,12 @@ def registry_lock(lock_path: Path) -> Iterator[None]:
26
30
 
27
31
  def load_registry_json(path: Path, default_factory: Callable[[], dict]) -> dict:
28
32
  """Load a registry dict, or a fresh default when absent/corrupt."""
29
- if not path.exists():
30
- return default_factory()
31
33
  try:
32
- data = json.loads(path.read_text(encoding="utf-8"))
33
- except (OSError, json.JSONDecodeError):
34
+ return load_owned_object(path, artifact="registry")
35
+ except JsonBoundaryError:
34
36
  return default_factory()
35
- return data if isinstance(data, dict) else default_factory()
36
37
 
37
38
 
38
39
  def save_registry_json(path: Path, data: dict) -> None:
39
- """Persist registry JSON via temp file + ``os.replace``."""
40
- path.parent.mkdir(parents=True, exist_ok=True)
41
- tmp = path.with_suffix(".json.tmp")
42
- tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
43
- os.replace(tmp, path)
40
+ """Persist registry JSON through the owned-artifact boundary."""
41
+ write_owned_object_atomic(path, data, artifact="registry")
@@ -8,14 +8,31 @@ from pathlib import Path
8
8
  from typing import Callable, List, Optional
9
9
 
10
10
 
11
- def append_jsonl(path: Path, row: dict) -> None:
11
+ def append_jsonl(
12
+ path: Path, row: dict, *, ensure_ascii: bool = True, compact: bool = True
13
+ ) -> None:
12
14
  """jsonl 파일 끝에 한 줄 append. 파일이 없으면 생성."""
13
15
  path.parent.mkdir(parents=True, exist_ok=True)
14
- line = json.dumps(row, separators=(",", ":")) + "\n"
16
+ line = json.dumps(
17
+ row, separators=(",", ":") if compact else None,
18
+ ensure_ascii=ensure_ascii,
19
+ ) + "\n"
15
20
  with path.open("a") as f:
16
21
  f.write(line)
17
22
 
18
23
 
24
+ def rewrite_jsonl(path: Path, rows: List[dict], *, ensure_ascii: bool = True) -> None:
25
+ """JSONL 행 전체를 임시 파일에 쓴 뒤 원자 교체한다."""
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ temporary = path.with_suffix(path.suffix + ".tmp")
28
+ with temporary.open("w", encoding="utf-8") as handle:
29
+ for row in rows:
30
+ handle.write(json.dumps(
31
+ row, separators=(",", ":"), ensure_ascii=ensure_ascii
32
+ ) + "\n")
33
+ os.replace(temporary, path)
34
+
35
+
19
36
  def read_jsonl(path: Path) -> List[dict]:
20
37
  """jsonl 파일을 모두 읽어 dict 목록으로 반환. 파싱 실패 라인은 스킵."""
21
38
  if not path.is_file():
@@ -2,7 +2,9 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import hashlib
5
6
  import re
7
+ import uuid
6
8
  from contextlib import contextmanager
7
9
  from dataclasses import dataclass, field, replace
8
10
  from pathlib import Path
@@ -107,14 +109,44 @@ def _next_activity_id(events: list[LeadEvent]) -> str:
107
109
  return f"A-{max(numbers, default=0) + 1:03d}"
108
110
 
109
111
 
112
+ def _activity_request_digest(details: Mapping[str, Any]) -> str:
113
+ payload = {
114
+ key: value for key, value in details.items()
115
+ if key not in {"activityId", "activityRequestDigest", "activityRequestRef", "activityVersion"}
116
+ }
117
+ encoded = json.dumps(_stable_json_value(payload), ensure_ascii=False, separators=(",", ":"))
118
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
119
+
120
+
110
121
  def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
111
122
  """Assign the next activity ID and append the event under one lock."""
112
123
  if event.event_type != "activity":
113
124
  raise ValueError("activity append requires eventType=activity")
114
125
  with _event_log_lock(path):
115
126
  details = dict(event.details)
127
+ request_ref = details.get("activityRequestRef")
128
+ events = read_lead_events(path)
129
+ digest = _activity_request_digest(details)
130
+ if request_ref is not None:
131
+ if not isinstance(request_ref, str) or not request_ref:
132
+ raise ValueError("activityRequestRef must be a non-empty string")
133
+ for previous in events:
134
+ if (
135
+ previous.event_type == event.event_type
136
+ and previous.lead_runtime == event.lead_runtime
137
+ and previous.task_key == event.task_key
138
+ and previous.task_type == event.task_type
139
+ and previous.run_seq == event.run_seq
140
+ and previous.details.get("activityRequestRef") == request_ref
141
+ ):
142
+ if previous.details.get("activityRequestDigest") != digest:
143
+ raise ValueError("activity request reference has different payload")
144
+ return previous
145
+ raise ValueError("unknown activity request reference")
146
+ details["activityRequestRef"] = f"activity-{uuid.uuid4().hex}"
147
+ details["activityRequestDigest"] = digest
116
148
  details["activityVersion"] = 1
117
- details["activityId"] = _next_activity_id(read_lead_events(path))
149
+ details["activityId"] = _next_activity_id(events)
118
150
  stored = replace(event, details=details)
119
151
  _append_unlocked(path, stored)
120
152
  return stored
@@ -1,13 +1,13 @@
1
1
  """list/format/show."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
4
  import re as _re
6
5
  from datetime import datetime, timedelta, timezone
7
6
  from pathlib import Path
8
7
  from typing import List, Optional
9
8
 
10
9
  from .paths import resolve_under_root
10
+ from .json_boundary import JsonBoundaryError, load_owned_object
11
11
  from .reconcile import _parse_iso
12
12
  from .run_index_row import read_run_index
13
13
 
@@ -24,8 +24,8 @@ def list_projects(home: Path, *, only_active: bool = False,
24
24
  if not meta_file.is_file():
25
25
  continue
26
26
  try:
27
- meta = json.loads(meta_file.read_text())
28
- except (OSError, json.JSONDecodeError):
27
+ meta = load_owned_object(meta_file, artifact="project metadata")
28
+ except JsonBoundaryError:
29
29
  # 동시 쓰기/이전 크래시로 깨진 meta.json 하나가 전체 listing 을
30
30
  # 죽이지 않도록 해당 프로젝트만 건너뛴다(다른 JSON 리더와 동일 정책).
31
31
  continue
@@ -13,12 +13,18 @@ import json
13
13
  import sys
14
14
  from pathlib import Path
15
15
 
16
+ from .fixed_text import line
17
+
16
18
  from okstra_project import resolve_project_root
19
+ from okstra_ctl.json_boundary import load_owned_object
17
20
 
18
21
 
19
22
  def _project_id(project_root: Path) -> str:
20
23
  try:
21
- return json.loads((project_root / ".okstra" / "project.json").read_text(encoding="utf-8")).get("projectId", "")
24
+ return load_owned_object(
25
+ project_root / ".okstra" / "project.json",
26
+ artifact="project configuration",
27
+ ).get("projectId", "")
22
28
  except (OSError, ValueError):
23
29
  return ""
24
30
 
@@ -120,6 +126,20 @@ def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
120
126
  }
121
127
 
122
128
 
129
+ def render_result_text(result: dict) -> str:
130
+ parts = ["# Okstra Log Inventory\n\n", line("Project root", result.get("projectRoot")), line("Logs root", result.get("logsRoot"))]
131
+ totals = result.get("totals") if isinstance(result.get("totals"), dict) else {}
132
+ for key, label in (("fileCount", "File count"), ("totalBytes", "Total bytes"), ("taskCount", "Task count"), ("promptBytes", "Prompt bytes"), ("transcriptBytes", "Transcript bytes"), ("pairedFileCount", "Paired file count")):
133
+ parts.append(line(label, totals.get(key)))
134
+ for row in result.get("topLargest", []):
135
+ if isinstance(row, dict):
136
+ parts.extend(("\n## Log\n\n", line("Task key", row.get("taskKey")), line("Phase", row.get("phase")), line("Worker", row.get("worker")), line("Sequence", row.get("seq")), line("Size bytes", row.get("sizeBytes")), line("Modified epoch", row.get("mtimeEpoch")), line("Path", row.get("path"))))
137
+ for row in result.get("perTask", []):
138
+ if isinstance(row, dict):
139
+ parts.extend(("\n## Task total\n\n", line("Task key", row.get("taskKey")), line("File count", row.get("fileCount")), line("Total bytes", row.get("totalBytes")), line("Oldest epoch", row.get("oldestEpoch")), line("Newest epoch", row.get("newestEpoch"))))
140
+ return "".join(parts)
141
+
142
+
123
143
  def main(argv: list[str] | None = None) -> int:
124
144
  parser = argparse.ArgumentParser(
125
145
  prog="okstra log-report",
@@ -128,12 +148,14 @@ def main(argv: list[str] | None = None) -> int:
128
148
  parser.add_argument("--cwd", default=".")
129
149
  parser.add_argument("--top", type=int, default=20, help="topLargest entry count")
130
150
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
151
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
131
152
  args = parser.parse_args(argv)
132
153
 
133
154
  project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
134
155
  result = {"ok": True, "projectRoot": str(project_root)}
135
156
  result.update(scan_logs(project_root / ".okstra" / "tasks", project_root, top=args.top))
136
- print(json.dumps(result, ensure_ascii=False, indent=2))
157
+ output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
158
+ print(output, end="" if args.text else "\n")
137
159
  return 0
138
160
 
139
161