okstra 0.179.2 → 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 (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -0,0 +1,518 @@
1
+ """보고서 작성자에게 전달할 동결된 합성 입력 묶음."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Mapping
12
+
13
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ReportSynthesisPacketIssue:
18
+ owner: str
19
+ label: str
20
+ path: Path
21
+ reason: str
22
+
23
+
24
+ class ReportSynthesisPacketError(ValueError):
25
+ def __init__(self, issues: tuple[ReportSynthesisPacketIssue, ...]) -> None:
26
+ self.issues = issues
27
+ super().__init__("; ".join(issue.reason for issue in issues))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ReportSynthesisSource:
32
+ label: str
33
+ owner: str
34
+ path: str
35
+ digest: str
36
+ content: str
37
+
38
+ def to_dict(self) -> dict[str, str]:
39
+ return {
40
+ "label": self.label,
41
+ "owner": self.owner,
42
+ "path": self.path,
43
+ "digest": self.digest,
44
+ "content": self.content,
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ReportSynthesisPacket:
50
+ task_key: str
51
+ task_type: str
52
+ result_path: str
53
+ sources: tuple[ReportSynthesisSource, ...]
54
+ accounting_snapshot: dict[str, Any]
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "schemaVersion": "1.0",
59
+ "taskKey": self.task_key,
60
+ "taskType": self.task_type,
61
+ "authoringContract": {
62
+ "resultPath": self.result_path,
63
+ "format": "report-narrative-v3.0",
64
+ "sourcePolicy": "read-only-synthesis-packet",
65
+ "instructions": [
66
+ "Write the complete human-readable report narrative.",
67
+ "Preserve settled source values, identities, dissent, and user responses.",
68
+ "Do not invent a value when a source is missing or contradictory.",
69
+ "Write only the narrative, pointer, and audit artifacts named by the dispatch.",
70
+ ],
71
+ "runtimeOwnedContent": [
72
+ "session identifiers",
73
+ "token usage",
74
+ "estimated cost",
75
+ "user response carry-in",
76
+ ],
77
+ "validationRules": [
78
+ "source-digest-match",
79
+ "writer-owned-fields-only",
80
+ "all-defects-collected",
81
+ ],
82
+ },
83
+ "accountingSnapshot": self.accounting_snapshot,
84
+ "sources": [source.to_dict() for source in self.sources],
85
+ }
86
+
87
+ def to_markdown(self) -> str:
88
+ lines = [
89
+ f"# OKSTRA Report Synthesis Packet - {self.task_key}",
90
+ "",
91
+ "## Authoring Contract",
92
+ "",
93
+ f"- Task type: `{self.task_type}`",
94
+ f"- Result path: `{self.result_path}`",
95
+ "- Output format: `report-narrative-v3.0`",
96
+ "- Input policy: read this synthesis packet as the dispatched source set",
97
+ "- Responsibility: write the complete human-readable narrative while preserving settled values",
98
+ "- Runtime-owned values: session identifiers, token usage, estimated cost, "
99
+ "user response carry-in",
100
+ "- Validation: source digest match, writer-owned fields, all defects collected",
101
+ ]
102
+ lines.extend(_accounting_markdown(self.accounting_snapshot))
103
+ for source in self.sources:
104
+ lines.extend(_source_markdown(source))
105
+ return "\n".join(lines).rstrip() + "\n"
106
+
107
+
108
+ _SOURCE_FIELDS = (
109
+ ("Analysis packet", "orchestrator", "instructionSet", "analysisPacketPath", True),
110
+ ("Task brief", "reporter", "instructionSet", "taskBriefPath", True),
111
+ ("Analysis profile", "orchestrator", "instructionSet", "analysisProfilePath", False),
112
+ ("Analysis material", "reporter", "instructionSet", "analysisMaterialPath", False),
113
+ ("Reference expectations", "reporter", "instructionSet", "referenceExpectationsPath", False),
114
+ ("Clarification response", "user", "instructionSet", "clarificationResponsePath", False),
115
+ ("Final report template", "orchestrator", "instructionSet", "reportTemplatePath", True),
116
+ ("Final report schema", "orchestrator", "instructionSet", "finalReportSchemaPath", True),
117
+ ("Convergence state", "convergence", "run", "convergenceStatePath", True),
118
+ )
119
+
120
+
121
+ def _source_markdown(source: ReportSynthesisSource) -> list[str]:
122
+ content = source.content.rstrip("\n")
123
+ longest = max((len(run) for run in re.findall(r"`+", content)), default=0)
124
+ fence = "`" * max(3, longest + 1)
125
+ return [
126
+ "",
127
+ f"## Source: {source.label}",
128
+ "",
129
+ f"- Owner: `{source.owner}`",
130
+ f"- Path: `{source.path}`",
131
+ f"- Digest: `{source.digest}`",
132
+ "",
133
+ f"{fence}text",
134
+ content,
135
+ fence,
136
+ ]
137
+
138
+
139
+ def _accounting_markdown(snapshot: Mapping[str, Any]) -> list[str]:
140
+ summary = snapshot.get("usageSummary")
141
+ usage_summary = summary if isinstance(summary, Mapping) else {}
142
+ estimated = usage_summary.get("estimatedCostUsd")
143
+ estimated_cost = estimated if isinstance(estimated, Mapping) else {}
144
+ sessions = snapshot.get("leadSessionIds")
145
+ lead_sessions = sessions if isinstance(sessions, list) else []
146
+ lines = [
147
+ "",
148
+ "## Runtime-owned accounting snapshot",
149
+ "",
150
+ "- Lead sessions: " + (", ".join(map(str, lead_sessions)) or "not recorded"),
151
+ f"- Total tokens: {usage_summary.get('grandTotalTokens', 'not recorded')}",
152
+ f"- Estimated cost USD: {estimated_cost.get('grandTotal', 'not recorded')}",
153
+ ]
154
+ workers = snapshot.get("workerUsage")
155
+ if not isinstance(workers, list):
156
+ return lines
157
+ for worker in workers:
158
+ if not isinstance(worker, Mapping):
159
+ continue
160
+ usage = worker.get("usage")
161
+ usage_value = usage if isinstance(usage, Mapping) else {}
162
+ cost = usage_value.get(
163
+ "estimatedCostUsd",
164
+ usage_value.get("cliEstimatedCostUsd", "not recorded"),
165
+ )
166
+ lines.append(
167
+ "- Worker "
168
+ f"{worker.get('workerId', 'unknown')}: "
169
+ f"tokens={usage_value.get('totalTokens', 'not recorded')}, "
170
+ f"costUsd={cost}"
171
+ )
172
+ return lines
173
+
174
+
175
+ def _string(value: object) -> str:
176
+ return value.strip() if isinstance(value, str) else ""
177
+
178
+
179
+ def _context_path(
180
+ manifest: Mapping[str, Any],
181
+ active_context: Mapping[str, Any],
182
+ section: str,
183
+ key: str,
184
+ ) -> str:
185
+ block = active_context.get(section)
186
+ if isinstance(block, Mapping):
187
+ value = _string(block.get(key))
188
+ if value:
189
+ return value
190
+ return _string(manifest.get(key))
191
+
192
+
193
+ def _source_specs(
194
+ project_root: Path,
195
+ manifest: Mapping[str, Any],
196
+ active_context: Mapping[str, Any],
197
+ team_state: Mapping[str, Any],
198
+ narrative_path: Path,
199
+ ) -> list[tuple[str, str, str, bool]]:
200
+ specs: list[tuple[str, str, str, bool]] = []
201
+ for label, owner, section, key, required in _SOURCE_FIELDS:
202
+ path = _context_path(manifest, active_context, section, key)
203
+ if label == "Final report template" and not path:
204
+ path = _context_path(
205
+ manifest, active_context, section, "finalReportTemplatePath"
206
+ )
207
+ if label == "Final report schema" and not path:
208
+ instruction_set = _context_path(
209
+ manifest, active_context, section, "instructionSetPath"
210
+ )
211
+ if instruction_set:
212
+ path = str(Path(instruction_set) / "final-report-schema.json")
213
+ if path or required:
214
+ specs.append((label, owner, path, required))
215
+ specs.extend(_user_response_specs(project_root, narrative_path))
216
+ attempt_specs = _attempt_result_specs(manifest)
217
+ if attempt_specs:
218
+ specs.extend(attempt_specs)
219
+ return specs
220
+ workers = team_state.get("workers")
221
+ if not isinstance(workers, list):
222
+ return specs
223
+ for worker in workers:
224
+ if not isinstance(worker, Mapping) or worker.get("workerId") == "report-writer":
225
+ continue
226
+ path = _string(worker.get("resultPath"))
227
+ if path:
228
+ worker_id = _string(worker.get("workerId")) or "unknown"
229
+ specs.append((f"Analysis result ({worker_id})", worker_id, path, True))
230
+ return specs
231
+
232
+
233
+ def _user_response_specs(
234
+ project_root: Path,
235
+ narrative_path: Path,
236
+ ) -> list[tuple[str, str, str, bool]]:
237
+ response_dir = narrative_path.parent.parent / "user-responses"
238
+ if not response_dir.is_dir():
239
+ return []
240
+ return [
241
+ (
242
+ f"User response ({path.name})",
243
+ "user",
244
+ _relative(project_root, path),
245
+ True,
246
+ )
247
+ for path in sorted(response_dir.glob("user-response-*.md"))
248
+ if path.is_file()
249
+ ]
250
+
251
+
252
+ def _attempt_result_specs(
253
+ manifest: Mapping[str, Any],
254
+ ) -> list[tuple[str, str, str, bool]]:
255
+ invocations_value = manifest.get("invocations")
256
+ attempts_value = manifest.get("attempts")
257
+ if not isinstance(invocations_value, list) or not isinstance(attempts_value, list):
258
+ return []
259
+ invocations = {
260
+ _string(row.get("invocationRef")): row
261
+ for row in invocations_value
262
+ if isinstance(row, Mapping) and _string(row.get("invocationRef"))
263
+ }
264
+ excluded_duties = {"lead", "report-writer", "translator"}
265
+ specs: list[tuple[str, str, str, bool]] = []
266
+ seen: set[str] = set()
267
+ for attempt in attempts_value:
268
+ if not isinstance(attempt, Mapping) or attempt.get("status") != "ok":
269
+ continue
270
+ path = _string(attempt.get("resultPath"))
271
+ invocation_ref = _string(attempt.get("invocationRef"))
272
+ invocation = invocations.get(invocation_ref, {})
273
+ duty = _string(invocation.get("dutyId"))
274
+ if not path or path in seen or duty in excluded_duties:
275
+ continue
276
+ seen.add(path)
277
+ attempt_number = attempt.get("attempt")
278
+ specs.append(
279
+ (
280
+ f"Settled result ({invocation_ref}, attempt {attempt_number})",
281
+ duty or invocation_ref,
282
+ path,
283
+ True,
284
+ )
285
+ )
286
+ return specs
287
+
288
+
289
+ def _accounting_snapshot(team_state: Mapping[str, Any]) -> dict[str, Any]:
290
+ workers = team_state.get("workers")
291
+ worker_usage = []
292
+ if isinstance(workers, list):
293
+ worker_usage = [
294
+ {
295
+ "workerId": _string(worker.get("workerId")) or "unknown",
296
+ "role": _string(worker.get("role")),
297
+ "usage": copy.deepcopy(worker.get("usage"))
298
+ if isinstance(worker.get("usage"), Mapping)
299
+ else {},
300
+ }
301
+ for worker in workers
302
+ if isinstance(worker, Mapping)
303
+ ]
304
+ lead_sessions = team_state.get("leadSessionIds")
305
+ return {
306
+ "leadSessionIds": copy.deepcopy(lead_sessions)
307
+ if isinstance(lead_sessions, list)
308
+ else [],
309
+ "leadUsage": copy.deepcopy(team_state.get("leadUsage"))
310
+ if isinstance(team_state.get("leadUsage"), Mapping)
311
+ else {},
312
+ "workerUsage": worker_usage,
313
+ "usageSummary": copy.deepcopy(team_state.get("usageSummary"))
314
+ if isinstance(team_state.get("usageSummary"), Mapping)
315
+ else {},
316
+ }
317
+
318
+
319
+ def _resolve(project_root: Path, value: str) -> Path:
320
+ path = Path(value)
321
+ return path if path.is_absolute() else project_root / path
322
+
323
+
324
+ def _relative(project_root: Path, path: Path) -> str:
325
+ try:
326
+ return path.relative_to(project_root).as_posix()
327
+ except ValueError:
328
+ return str(path)
329
+
330
+
331
+ def _read_sources(
332
+ project_root: Path,
333
+ specs: list[tuple[str, str, str, bool]],
334
+ ) -> tuple[ReportSynthesisSource, ...]:
335
+ issues: list[ReportSynthesisPacketIssue] = []
336
+ sources: list[ReportSynthesisSource] = []
337
+ for label, owner, value, required in specs:
338
+ if not value:
339
+ if required:
340
+ issues.append(
341
+ ReportSynthesisPacketIssue(
342
+ owner,
343
+ label,
344
+ project_root,
345
+ "required source path is not configured",
346
+ )
347
+ )
348
+ continue
349
+ path = _resolve(project_root, value)
350
+ if not path.is_file():
351
+ issues.append(
352
+ ReportSynthesisPacketIssue(
353
+ owner, label, path, "required source is missing"
354
+ )
355
+ )
356
+ continue
357
+ try:
358
+ content = path.read_text(encoding="utf-8")
359
+ except (OSError, UnicodeError) as exc:
360
+ issues.append(ReportSynthesisPacketIssue(owner, label, path, str(exc)))
361
+ continue
362
+ digest = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
363
+ sources.append(
364
+ ReportSynthesisSource(
365
+ label=label,
366
+ owner=owner,
367
+ path=_relative(project_root, path),
368
+ digest=digest,
369
+ content=content,
370
+ )
371
+ )
372
+ if issues:
373
+ raise ReportSynthesisPacketError(tuple(issues))
374
+ return tuple(sources)
375
+
376
+
377
+ def build_report_synthesis_packet(
378
+ *,
379
+ project_root: Path,
380
+ manifest: Mapping[str, Any],
381
+ active_context: Mapping[str, Any],
382
+ team_state: Mapping[str, Any],
383
+ narrative_path: Path,
384
+ ) -> ReportSynthesisPacket:
385
+ sources = _read_sources(
386
+ project_root,
387
+ _source_specs(
388
+ project_root,
389
+ manifest,
390
+ active_context,
391
+ team_state,
392
+ narrative_path,
393
+ ),
394
+ )
395
+ return ReportSynthesisPacket(
396
+ task_key=_string(manifest.get("taskKey")),
397
+ task_type=_string(manifest.get("taskType")),
398
+ result_path=_relative(project_root, narrative_path),
399
+ sources=sources,
400
+ accounting_snapshot=_accounting_snapshot(team_state),
401
+ )
402
+
403
+
404
+ def report_synthesis_packet_paths(narrative_path: Path) -> tuple[Path, Path]:
405
+ name = narrative_path.name
406
+ prefix = "report-writer-narrative-"
407
+ if name == "report-writer-narrative.md":
408
+ packet_name = "report-writer-synthesis-packet.md"
409
+ elif name.startswith(prefix) and name.endswith(".md"):
410
+ packet_name = "report-writer-synthesis-packet-" + name[len(prefix):]
411
+ else:
412
+ raise ValueError(f"report narrative path has an unsupported name: {narrative_path}")
413
+ markdown_path = narrative_path.with_name(packet_name)
414
+ data_path = markdown_path.with_suffix(".data.json")
415
+ return data_path, markdown_path
416
+
417
+
418
+ def materialize_report_synthesis_packet(
419
+ *,
420
+ project_root: Path,
421
+ manifest: Mapping[str, Any],
422
+ active_context: Mapping[str, Any],
423
+ team_state: Mapping[str, Any],
424
+ narrative_path: Path,
425
+ ) -> tuple[Path, Path]:
426
+ packet = build_report_synthesis_packet(
427
+ project_root=project_root,
428
+ manifest=manifest,
429
+ active_context=active_context,
430
+ team_state=team_state,
431
+ narrative_path=narrative_path,
432
+ )
433
+ data_path, markdown_path = report_synthesis_packet_paths(narrative_path)
434
+ write_owned_object_atomic(
435
+ data_path,
436
+ packet.to_dict(),
437
+ artifact="report synthesis packet",
438
+ )
439
+ _write_text_atomic(markdown_path, packet.to_markdown())
440
+ return data_path, markdown_path
441
+
442
+
443
+ def _write_text_atomic(path: Path, text: str) -> None:
444
+ path.parent.mkdir(parents=True, exist_ok=True)
445
+ descriptor, temporary = tempfile.mkstemp(
446
+ prefix=f".{path.name}.",
447
+ suffix=".tmp",
448
+ dir=path.parent,
449
+ )
450
+ try:
451
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
452
+ handle.write(text)
453
+ handle.flush()
454
+ os.fsync(handle.fileno())
455
+ os.replace(temporary, path)
456
+ finally:
457
+ if os.path.exists(temporary):
458
+ os.unlink(temporary)
459
+
460
+
461
+ def verify_report_synthesis_packet_sources(
462
+ project_root: Path,
463
+ data_path: Path,
464
+ ) -> tuple[ReportSynthesisPacketIssue, ...]:
465
+ try:
466
+ payload = load_owned_object(data_path, artifact="report synthesis packet")
467
+ except JsonBoundaryError as exc:
468
+ return (
469
+ ReportSynthesisPacketIssue(
470
+ "orchestrator",
471
+ "Report synthesis packet",
472
+ data_path,
473
+ str(exc),
474
+ ),
475
+ )
476
+ sources = payload.get("sources") if isinstance(payload, Mapping) else None
477
+ if not isinstance(sources, list):
478
+ return (
479
+ ReportSynthesisPacketIssue(
480
+ "orchestrator",
481
+ "Report synthesis packet",
482
+ data_path,
483
+ "sources must be an array",
484
+ ),
485
+ )
486
+ issues: list[ReportSynthesisPacketIssue] = []
487
+ for source in sources:
488
+ if not isinstance(source, Mapping):
489
+ continue
490
+ label = _string(source.get("label")) or "Unknown source"
491
+ owner = _string(source.get("owner")) or "orchestrator"
492
+ path = _resolve(project_root, _string(source.get("path")))
493
+ if not path.is_file():
494
+ issues.append(
495
+ ReportSynthesisPacketIssue(
496
+ owner,
497
+ label,
498
+ path,
499
+ "frozen source is missing",
500
+ )
501
+ )
502
+ continue
503
+ try:
504
+ content = path.read_text(encoding="utf-8")
505
+ except (OSError, UnicodeError) as exc:
506
+ issues.append(ReportSynthesisPacketIssue(owner, label, path, str(exc)))
507
+ continue
508
+ digest = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
509
+ if digest != source.get("digest"):
510
+ issues.append(
511
+ ReportSynthesisPacketIssue(
512
+ owner,
513
+ label,
514
+ path,
515
+ "source value changed after packet materialization",
516
+ )
517
+ )
518
+ return tuple(issues)
@@ -35,6 +35,7 @@ from pathlib import Path
35
35
  from typing import Iterable, Optional
36
36
 
37
37
  from .final_report_paths import final_report_data_path
38
+ from .json_boundary import JsonBoundaryError, load_owned_object
38
39
  from .report_html.view_models.implementation_planning import (
39
40
  PlanApprovalState,
40
41
  plan_approval_state,
@@ -1349,8 +1350,8 @@ def _load_report_data(src_md_path: Path) -> dict | None:
1349
1350
  if not data_path.is_file():
1350
1351
  return None
1351
1352
  try:
1352
- data = json.loads(data_path.read_text(encoding="utf-8"))
1353
- except (OSError, json.JSONDecodeError):
1353
+ data = load_owned_object(data_path, artifact="final report record")
1354
+ except JsonBoundaryError:
1354
1355
  return None
1355
1356
  return data if isinstance(data, dict) else None
1356
1357
 
@@ -17,6 +17,8 @@ from pathlib import Path
17
17
  from okstra_ctl import next_phase
18
18
  from okstra_ctl.error_log_core import glob_error_logs, parse_records
19
19
  from okstra_ctl.time_report import aggregate_time, load_runs
20
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
21
+ from okstra_ctl.fixed_text import line
20
22
  from okstra_project import (
21
23
  ResolverError,
22
24
  list_project_tasks,
@@ -42,8 +44,8 @@ def _collect_critic(task_root: Path) -> dict:
42
44
  totals = {"runsWithCritic": 0} | {name: 0 for name in _CRITIC_COUNTERS}
43
45
  for state_path in sorted(task_root.glob("runs/*/state/convergence-*.json")):
44
46
  try:
45
- payload = json.loads(state_path.read_text(encoding="utf-8"))
46
- except (OSError, json.JSONDecodeError):
47
+ payload = load_owned_object(state_path, artifact="convergence state")
48
+ except JsonBoundaryError:
47
49
  continue
48
50
  critic = (payload.get("config") or {}).get("critic")
49
51
  if not isinstance(critic, dict):
@@ -146,6 +148,7 @@ def main(argv: list[str] | None = None) -> int:
146
148
  parser.add_argument("--project-root", default="")
147
149
  parser.add_argument("--cwd", default=".")
148
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")
149
152
  args = parser.parse_args(argv)
150
153
 
151
154
  try:
@@ -153,13 +156,71 @@ def main(argv: list[str] | None = None) -> int:
153
156
  resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
154
157
  ).resolve()
155
158
  except ResolverError as exc:
156
- print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
159
+ payload = {"ok": False, "stage": "resolve", "reason": str(exc)}
160
+ print(render_rollup_text(payload) if args.text else json.dumps(payload))
157
161
  return 2
158
162
 
159
163
  result = build_rollup(project_root, args.task_group)
160
- print(json.dumps(result, ensure_ascii=False, indent=2))
164
+ print(
165
+ render_rollup_text(result)
166
+ if args.text
167
+ else json.dumps(result, ensure_ascii=False, indent=2)
168
+ )
161
169
  return 0
162
170
 
163
171
 
172
+ def render_rollup_text(payload: dict) -> str:
173
+ """rollup 모델 표면에 승인된 필드만 고정 순서로 투영한다."""
174
+ rows = ["Okstra rollup\n"]
175
+ rows.append(line("Status", "ready" if payload.get("ok") else "error"))
176
+ rows.append(line("Task group", payload.get("taskGroup") or "all"))
177
+ rows.append(line("Task count", payload.get("taskCount")))
178
+ totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
179
+ rows.append(line("Total runs", totals.get("runs")))
180
+ rows.append(line("Total errors", totals.get("errors")))
181
+ rows.append(line("CPU sum ms", totals.get("cpuSumMs")))
182
+ rows.append(line("Wall clock ms", totals.get("wallClockMs")))
183
+ rows.extend(_distribution_lines("Work status", totals.get("byWorkStatus")))
184
+ rows.extend(_distribution_lines("Work category", totals.get("byWorkCategory")))
185
+ rows.extend(_distribution_lines("Current phase", totals.get("byCurrentPhase")))
186
+ rows.extend(_distribution_lines("Task type", totals.get("byTaskType")))
187
+ tasks = payload.get("tasks") if isinstance(payload.get("tasks"), list) else []
188
+ for index, task in enumerate(tasks, 1):
189
+ if not isinstance(task, dict):
190
+ continue
191
+ rows.extend(_task_lines(index, task))
192
+ if not payload.get("ok"):
193
+ rows.append(line("Failure stage", payload.get("stage")))
194
+ rows.append(line("Failure reason", payload.get("reason")))
195
+ return "".join(rows)
196
+
197
+
198
+ def _distribution_lines(title: str, raw_values: object) -> list[str]:
199
+ values = raw_values if isinstance(raw_values, dict) else {}
200
+ rows: list[str] = []
201
+ for index, (name, count) in enumerate(sorted(values.items()), 1):
202
+ rows.append(line(f"{title} {index} name", name))
203
+ rows.append(line(f"{title} {index} count", count))
204
+ return rows
205
+
206
+
207
+ def _task_lines(index: int, task: dict) -> list[str]:
208
+ fields = (
209
+ ("key", "taskKey"), ("type", "taskType"), ("category", "workCategory"),
210
+ ("status", "workStatus"), ("phase", "currentPhase"),
211
+ ("phase state", "currentPhaseState"), ("latest run status", "latestRunStatus"),
212
+ ("updated at", "updatedAt"), ("runs", "runCount"),
213
+ ("CPU sum ms", "cpuSumMs"), ("wall clock ms", "wallClockMs"),
214
+ ("errors", "errorCount"), ("report path", "reportPath"),
215
+ )
216
+ rows = [line(f"Task {index} {label}", task.get(key)) for label, key in fields]
217
+ next_phase = task.get("nextRecommendedPhase")
218
+ values = next_phase if isinstance(next_phase, dict) else {}
219
+ for label, key in (("next phase", "phase"), ("next phase status", "status"),
220
+ ("next phase rationale", "rationale")):
221
+ rows.append(line(f"Task {index} {label}", values.get(key)))
222
+ return rows
223
+
224
+
164
225
  if __name__ == "__main__":
165
226
  raise SystemExit(main(sys.argv[1:]))