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,351 @@
1
+ """보고서 작성자 전담 Markdown 서사의 손실 없는 읽기·쓰기 계약."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .final_report_schema import validate
11
+ from .report_markdown import SchemaIndex, humanise, label_key
12
+ from .json_boundary import load_owned_object
13
+
14
+
15
+ TITLE = "# OKSTRA Report Narrative"
16
+ EMPTY_MARKER = "_none_"
17
+ _FIELD_RE = re.compile(r"^(?P<indent> *)- \*\*(?P<label>.+)\*\*$")
18
+ _ITEM_RE = re.compile(r"^(?P<indent> *)- Item (?P<position>[1-9]\d*)$")
19
+ _VALUE_RE = re.compile(r"^(?P<indent> *)> ?(?P<value>.*)$")
20
+ _NESTED_FORBIDDEN = frozenset(
21
+ {
22
+ "implementationPlanning.designPreparation",
23
+ "implementationPlanning.planBodyVerification",
24
+ }
25
+ )
26
+
27
+
28
+ class NarrativeContractError(ValueError):
29
+ """서사 입력이 보고서 작성자 소유권이나 Markdown 문법을 위반했다."""
30
+
31
+
32
+ class _Node:
33
+ def __init__(self, kind: str, label: str, level: int) -> None:
34
+ self.kind = kind
35
+ self.label = label
36
+ self.level = level
37
+ self.values: list[str] = []
38
+ self.children: list[_Node] = []
39
+
40
+
41
+ def _narrative_schema_path() -> Path:
42
+ return Path(__file__).resolve().parents[2] / "schemas" / "report-narrative-v3.0.schema.json"
43
+
44
+
45
+ def _narrative_schema() -> dict[str, Any]:
46
+ return load_owned_object(_narrative_schema_path(), artifact="report narrative schema")
47
+
48
+
49
+ def _allowed_top_level() -> frozenset[str]:
50
+ return frozenset(_narrative_schema().get("properties", {}))
51
+
52
+
53
+ def writer_owned_data(data: Mapping[str, Any]) -> dict[str, Any]:
54
+ """완성 리포트에서 보고서 작성자 소유 필드만 복사한다."""
55
+ owned = {
56
+ key: deepcopy(value)
57
+ for key, value in data.items()
58
+ if key in _allowed_top_level()
59
+ }
60
+ planning = owned.get("implementationPlanning")
61
+ if isinstance(planning, dict):
62
+ planning.pop("designPreparation", None)
63
+ planning.pop("planBodyVerification", None)
64
+ for stage in planning.get("stages", ()):
65
+ if isinstance(stage, dict):
66
+ stage.pop("designSurfaceCoverage", None)
67
+ return owned
68
+
69
+
70
+ def _schema_branches(node: Any, index: SchemaIndex) -> list[dict[str, Any]]:
71
+ resolved = index.resolve(node)
72
+ branches = [resolved]
73
+ for keyword in ("allOf", "oneOf", "anyOf"):
74
+ branches.extend(index.resolve(branch) for branch in resolved.get(keyword, ()))
75
+ return branches
76
+
77
+
78
+ def _node_types(node: Any, index: SchemaIndex) -> set[str]:
79
+ result: set[str] = set()
80
+ for branch in _schema_branches(node, index):
81
+ value = branch.get("type")
82
+ if isinstance(value, str):
83
+ result.add(value)
84
+ elif isinstance(value, list):
85
+ result.update(item for item in value if isinstance(item, str))
86
+ if "properties" in branch:
87
+ result.add("object")
88
+ if "items" in branch:
89
+ result.add("array")
90
+ constant = branch.get("const")
91
+ if isinstance(constant, bool):
92
+ result.add("boolean")
93
+ elif isinstance(constant, int):
94
+ result.add("integer")
95
+ elif isinstance(constant, float):
96
+ result.add("number")
97
+ elif isinstance(constant, str):
98
+ result.add("string")
99
+ for item in branch.get("enum", ()):
100
+ if isinstance(item, bool):
101
+ result.add("boolean")
102
+ elif isinstance(item, int):
103
+ result.add("integer")
104
+ elif isinstance(item, float):
105
+ result.add("number")
106
+ elif isinstance(item, str):
107
+ result.add("string")
108
+ return result
109
+
110
+
111
+ def _scalar_lines(value: Any) -> list[str]:
112
+ if value is None:
113
+ return [EMPTY_MARKER]
114
+ if isinstance(value, bool):
115
+ return [str(value).lower()]
116
+ if isinstance(value, (int, float)):
117
+ return [str(value)]
118
+ return str(value).split("\n")
119
+
120
+
121
+ def _render_value(
122
+ value: Any, node: Any, index: SchemaIndex, level: int,
123
+ ) -> list[str]:
124
+ prefix = " " * level
125
+ if isinstance(value, Mapping):
126
+ if not value:
127
+ return [f"{prefix}> {EMPTY_MARKER}"]
128
+ return _render_fields(value, node, index, level)
129
+ if isinstance(value, list):
130
+ if not value:
131
+ return [f"{prefix}> {EMPTY_MARKER}"]
132
+ lines: list[str] = []
133
+ for position, item in enumerate(value, start=1):
134
+ lines.append(f"{prefix}- Item {position}")
135
+ lines.extend(_render_value(item, index.item(node), index, level + 1))
136
+ return lines
137
+ return [f"{prefix}> {line}" for line in _scalar_lines(value)]
138
+
139
+
140
+ def _render_fields(
141
+ value: Mapping[str, Any], node: Any, index: SchemaIndex, level: int,
142
+ ) -> list[str]:
143
+ order = index.key_order(node)
144
+ keys = [key for key in order if key in value]
145
+ keys.extend(key for key in value if key not in keys)
146
+ lines: list[str] = []
147
+ prefix = " " * level
148
+ for key in keys:
149
+ lines.append(f"{prefix}- **{humanise(key)}**")
150
+ lines.extend(_render_value(value[key], index.child(node, key), index, level + 1))
151
+ return lines
152
+
153
+
154
+ def render_narrative(data: Mapping[str, Any], schema: Mapping[str, Any]) -> str:
155
+ """작성자 소유 자료를 계층형 Markdown 목록으로 렌더한다."""
156
+ unknown = sorted(set(data) - _allowed_top_level())
157
+ if unknown:
158
+ raise NarrativeContractError(
159
+ f"owner=report-writer cannot author fields: {unknown}"
160
+ )
161
+ index = SchemaIndex(schema)
162
+ lines = [TITLE, ""]
163
+ lines.extend(_render_fields(data, schema, index, 0))
164
+ return "\n".join(lines).rstrip() + "\n"
165
+
166
+
167
+ def _line_level(indent: str, line_number: int) -> int:
168
+ if len(indent) % 2:
169
+ raise NarrativeContractError(
170
+ f"line {line_number}: indentation must use pairs of spaces"
171
+ )
172
+ return len(indent) // 2
173
+
174
+
175
+ def _parse_tree(markdown: str) -> _Node:
176
+ lines = markdown.splitlines()
177
+ if not lines or lines[0].strip() != TITLE:
178
+ raise NarrativeContractError(f"narrative must start with `{TITLE}`")
179
+ root = _Node("root", "", -1)
180
+ stack = [root]
181
+ for number, line in enumerate(lines[1:], start=2):
182
+ if not line.strip():
183
+ continue
184
+ field = _FIELD_RE.match(line)
185
+ item = _ITEM_RE.match(line)
186
+ value = _VALUE_RE.match(line)
187
+ if field or item:
188
+ match = field or item
189
+ level = _line_level(match.group("indent"), number)
190
+ _append_node(stack, field, item, level, number)
191
+ elif value:
192
+ level = _line_level(value.group("indent"), number)
193
+ _append_value(stack, value.group("value"), level, number)
194
+ else:
195
+ raise NarrativeContractError(
196
+ f"line {number}: unsupported Markdown syntax `{line.strip()}` — "
197
+ "a narrative line is only `- **Field Name**`, `- Item <N>`, or "
198
+ "`> value`. Headings, tables at column 0, code fences, bare "
199
+ "paragraphs, JSON and YAML are rejected; put such text inside a "
200
+ "`> ` value line instead"
201
+ )
202
+ return root
203
+
204
+
205
+ def _append_node(stack: list[_Node], field: Any, item: Any, level: int, number: int) -> None:
206
+ while stack[-1].level >= level:
207
+ stack.pop()
208
+ if stack[-1].level != level - 1:
209
+ raise NarrativeContractError(f"line {number}: skipped a nesting level")
210
+ node = _Node(
211
+ "field" if field else "item",
212
+ field.group("label") if field else item.group("position"),
213
+ level,
214
+ )
215
+ stack[-1].children.append(node)
216
+ stack.append(node)
217
+
218
+
219
+ def _append_value(stack: list[_Node], value: str, level: int, number: int) -> None:
220
+ if stack[-1].level != level - 1:
221
+ raise NarrativeContractError(f"line {number}: value is outside its field")
222
+ if stack[-1].children:
223
+ raise NarrativeContractError(f"line {number}: field mixes values and children")
224
+ stack[-1].values.append(value)
225
+
226
+
227
+ def _allowed_labels(node: Any, index: SchemaIndex, path: str) -> list[str]:
228
+ """이 위치에서 저작자가 쓸 수 있는 사람이 읽는 필드 표기."""
229
+ keys = index.key_order(node)
230
+ if not path:
231
+ keys = [key for key in keys if key in _allowed_top_level()]
232
+ # 파서가 뒤에서 거부할 필드를 여기서 권하면 안내가 다음 거부를 만든다.
233
+ keys = [key for key in keys if f"{path}.{key}".lstrip(".") not in _NESTED_FORBIDDEN]
234
+ return sorted(humanise(key) for key in keys)
235
+
236
+
237
+ def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
238
+ candidates: dict[str, list[str]] = {}
239
+ for key in index.key_order(node):
240
+ candidates.setdefault(label_key(humanise(key)), []).append(key)
241
+ matches = candidates.get(label_key(label), [])
242
+ if not matches:
243
+ matches = index.keys_for_label(label)
244
+ display_path = f"{path}.{label}" if path else label
245
+ # 거부만 알리면 다음 시도도 추측이 된다. 이 위치에서 허용되는 표기를
246
+ # 함께 실어, 재작성이 목록 대조로 끝나게 한다.
247
+ if not matches:
248
+ allowed = _allowed_labels(node, index, path)
249
+ listing = ", ".join(f"`{item}`" for item in allowed) or "(none)"
250
+ raise NarrativeContractError(
251
+ f"owner=report-writer field `{display_path}` is not an allowed unique "
252
+ f"field; allowed at this position: {listing}"
253
+ )
254
+ if len(matches) > 1:
255
+ collisions = ", ".join(f"`{key}`" for key in matches)
256
+ raise NarrativeContractError(
257
+ f"owner=report-writer field `{display_path}` is not an allowed unique "
258
+ f"field; the label matches more than one schema key: {collisions}"
259
+ )
260
+ return matches[0]
261
+
262
+
263
+ def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -> Any:
264
+ types = _node_types(node, index)
265
+ text = "\n".join(values)
266
+ if text == EMPTY_MARKER and "null" in types:
267
+ return None
268
+ if "boolean" in types and text in {"true", "false"}:
269
+ return text == "true"
270
+ if "integer" in types:
271
+ try:
272
+ return int(text)
273
+ except ValueError as exc:
274
+ raise NarrativeContractError(f"{path}: expected integer") from exc
275
+ if "number" in types:
276
+ try:
277
+ return float(text)
278
+ except ValueError as exc:
279
+ raise NarrativeContractError(f"{path}: expected number") from exc
280
+ return text
281
+
282
+
283
+ def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> Any:
284
+ types = _node_types(schema_node, index)
285
+ if node.values:
286
+ if node.values == [EMPTY_MARKER] and "null" in types:
287
+ return None
288
+ if node.values == [EMPTY_MARKER] and "object" in types:
289
+ return {}
290
+ if node.values == [EMPTY_MARKER] and "array" in types:
291
+ return []
292
+ return _parse_scalar(node.values, schema_node, index, path)
293
+ if "array" in types or all(child.kind == "item" for child in node.children):
294
+ return _parse_array(node, schema_node, index, path)
295
+ return _parse_object(node, schema_node, index, path)
296
+
297
+
298
+ def _parse_array(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> list[Any]:
299
+ if any(child.kind != "item" for child in node.children):
300
+ raise NarrativeContractError(f"{path}: array requires Item rows")
301
+ positions = [int(child.label) for child in node.children]
302
+ if positions != list(range(1, len(positions) + 1)):
303
+ raise NarrativeContractError(f"{path}: Item numbers must be 1..N")
304
+ item_schema = index.item(schema_node)
305
+ return [
306
+ _parse_value(child, item_schema, index, f"{path}[{position - 1}]")
307
+ for position, child in zip(positions, node.children, strict=True)
308
+ ]
309
+
310
+
311
+ def _parse_object(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> dict[str, Any]:
312
+ result: dict[str, Any] = {}
313
+ for child in node.children:
314
+ if child.kind != "field":
315
+ raise NarrativeContractError(f"{path}: object requires named fields")
316
+ key = _field_key(child.label, schema_node, index, path)
317
+ child_path = f"{path}.{key}" if path else key
318
+ if child_path in _NESTED_FORBIDDEN:
319
+ raise NarrativeContractError(
320
+ f"owner=report-writer cannot author `{path}.{child.label}`"
321
+ )
322
+ if key in result:
323
+ raise NarrativeContractError(f"duplicate field: {child_path}")
324
+ child_schema = index.child(schema_node, key)
325
+ if not _node_types(child_schema, index):
326
+ child_schema = index.schema_for_key(key)
327
+ result[key] = _parse_value(
328
+ child, child_schema, index, child_path
329
+ )
330
+ return result
331
+
332
+
333
+ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
334
+ """Markdown을 작성자 소유 자료로 읽고 소유권 표면을 검증한다."""
335
+ root = _parse_tree(markdown)
336
+ index = SchemaIndex(schema)
337
+ result = _parse_object(root, schema, index, "")
338
+ unknown = sorted(set(result) - _allowed_top_level())
339
+ if unknown:
340
+ labels = [humanise(key) for key in unknown]
341
+ owned = ", ".join(
342
+ f"`{humanise(key)}`" for key in sorted(_allowed_top_level())
343
+ )
344
+ raise NarrativeContractError(
345
+ f"owner=report-writer cannot author fields: {labels}; "
346
+ f"writer-owned top-level fields: {owned}"
347
+ )
348
+ errors = validate(result, _narrative_schema())
349
+ if errors:
350
+ raise NarrativeContractError("; ".join(errors))
351
+ return result
@@ -0,0 +1,265 @@
1
+ """역할별 실행 입력을 최종 리포트 조각으로 바꾸는 순수 투영."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from copy import deepcopy
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from .design_surfaces import DesignSurfaceTrigger, detect_design_surfaces
9
+ from .report_contract import execution_roles_from_manifest
10
+
11
+
12
+ class ReportProjectionError(ValueError):
13
+ """소유자 입력을 정본 조각으로 투영할 수 없다."""
14
+
15
+
16
+ _AGENT_LABELS = {
17
+ "claude": "Claude Code",
18
+ "claude-code": "Claude Code",
19
+ "codex": "Codex",
20
+ "antigravity": "Antigravity",
21
+ "grok": "Grok",
22
+ "kimi": "Kimi",
23
+ }
24
+ _STATUS = {
25
+ "done": "completed",
26
+ "passed": "completed",
27
+ "failed": "error",
28
+ "blocked": "error",
29
+ "pending": "not-run",
30
+ "prepared": "not-run",
31
+ }
32
+
33
+
34
+ def _text(value: object, fallback: str) -> str:
35
+ text = str(value or "").strip()
36
+ return text or fallback
37
+
38
+
39
+ def _status(value: object) -> str:
40
+ text = str(value or "not-run").strip().lower()
41
+ return _STATUS.get(text, text if text in {
42
+ "completed", "error", "timeout", "not-run", "synthesis-only"
43
+ } else "not-run")
44
+
45
+
46
+ def _agent_label(row: Mapping[str, Any]) -> str:
47
+ value = _text(row.get("agent") or row.get("provider"), "codex")
48
+ return _AGENT_LABELS.get(value.lower(), value)
49
+
50
+
51
+ def _execution_row(row: Mapping[str, Any], *, lead: bool = False) -> dict[str, Any]:
52
+ role = _text(row.get("role"), "Okstra lead" if lead else "Worker")
53
+ result = {
54
+ "agent": _agent_label(row),
55
+ "role": role,
56
+ "model": _text(row.get("model") or row.get("modelExecutionValue"), "unknown"),
57
+ "status": _status(row.get("status")),
58
+ "summary": _text(
59
+ row.get("summary") or row.get("reason"),
60
+ "Run coordination recorded by team state." if lead
61
+ else "Worker execution recorded by team state.",
62
+ ),
63
+ }
64
+ _populate_usage(result, row.get("usage") or {})
65
+ return result
66
+
67
+
68
+ def _populate_usage(row: dict[str, Any], usage: object) -> None:
69
+ source = usage if isinstance(usage, Mapping) else {}
70
+ mapping = {
71
+ "totalTokens": "totalTokens",
72
+ "cacheReadTokens": "cacheReadTokens",
73
+ "billableEquivalentTokens": "billableTokens",
74
+ "estimatedCostUsd": "costUsd",
75
+ "cliTotalTokens": "cliTotalTokens",
76
+ "cliEstimatedCostUsd": "cliCostUsd",
77
+ "durationMs": "durationMs",
78
+ }
79
+ for source_key, target_key in mapping.items():
80
+ if source_key in source and source.get("source") != "unavailable":
81
+ row[target_key] = source[source_key]
82
+
83
+
84
+ def project_execution(
85
+ manifest: Mapping[str, Any], team_state: Mapping[str, Any],
86
+ ) -> dict[str, Any]:
87
+ """실행 역할과 상태를 매니페스트·팀 상태에서만 만든다."""
88
+ lead = team_state.get("lead")
89
+ lead_row = lead if isinstance(lead, Mapping) else {
90
+ "agent": manifest.get("leadRuntime") or manifest.get("leadProvider"),
91
+ "model": manifest.get("leadModel") or "unknown",
92
+ "status": "completed",
93
+ "role": "Okstra lead",
94
+ }
95
+ workers = team_state.get("workers")
96
+ worker_rows = workers if isinstance(workers, list) else []
97
+ result: dict[str, Any] = {
98
+ "executionStatus": [
99
+ _execution_row(lead_row, lead=True),
100
+ *[
101
+ _execution_row(row)
102
+ for row in worker_rows
103
+ if isinstance(row, Mapping)
104
+ ],
105
+ ]
106
+ }
107
+ roles = execution_roles_from_manifest(dict(manifest))
108
+ if roles:
109
+ result.update(executionIdentityVersion=2, executionRoles=roles)
110
+ return result
111
+
112
+
113
+ def _usage_row(summary: Mapping[str, Any], prefix: str, cost: object) -> dict[str, Any]:
114
+ return {
115
+ "totalTokens": summary.get(f"{prefix}TotalTokens"),
116
+ "cacheReadTokens": summary.get(f"{prefix}CacheReadTokens"),
117
+ "billableTokens": summary.get(f"{prefix}BillableEquivalentTokens"),
118
+ "costUsd": cost,
119
+ }
120
+
121
+
122
+ def _worker_details(team_state: Mapping[str, Any]) -> list[dict[str, Any]]:
123
+ result = []
124
+ for worker in team_state.get("workers") or []:
125
+ if not isinstance(worker, Mapping):
126
+ continue
127
+ usage = worker.get("usage") if isinstance(worker.get("usage"), Mapping) else {}
128
+ row = {"label": _text(worker.get("role") or worker.get("workerId"), "Worker")}
129
+ _populate_usage(row, usage)
130
+ for key in ("totalTokens", "cacheReadTokens", "billableTokens", "costUsd", "cliTotalTokens", "cliCostUsd"):
131
+ row.setdefault(key, None)
132
+ result.append(row)
133
+ return result
134
+
135
+
136
+ def project_token_usage(team_state: Mapping[str, Any]) -> dict[str, Any]:
137
+ """팀 상태의 사용량 합계를 리포트 표 구조로 반환한다."""
138
+ summary = team_state.get("usageSummary")
139
+ summary = summary if isinstance(summary, Mapping) else {}
140
+ costs = summary.get("estimatedCostUsd")
141
+ costs = costs if isinstance(costs, Mapping) else {}
142
+ lead_cost = costs.get("lead")
143
+ worker_cost = costs.get("claudeWorkers")
144
+ return {
145
+ "lead": _usage_row(summary, "lead", lead_cost),
146
+ "worker": _usage_row(summary, "worker", worker_cost),
147
+ "grand": _usage_row(
148
+ summary,
149
+ "grand",
150
+ None if lead_cost is None or worker_cost is None else lead_cost + worker_cost,
151
+ ),
152
+ "workerDetails": _worker_details(team_state),
153
+ "cli": {"costUsd": costs.get("cliWorkers")},
154
+ }
155
+
156
+
157
+ def _trigger_rows(trigger: DesignSurfaceTrigger) -> list[dict[str, Any]]:
158
+ return [
159
+ {"step": item.step, "field": item.field, "match": item.match}
160
+ for item in trigger.evidence
161
+ ]
162
+
163
+
164
+ def project_design(
165
+ planning: Mapping[str, Any], detector_snapshot: Mapping[str, Any],
166
+ ) -> dict[str, Any]:
167
+ """탐지 결과가 실제 계획 표면과 일치할 때만 설계 블록을 반환한다."""
168
+ triggers = detect_design_surfaces(planning)
169
+ expected = {(row.stage, row.kind): _trigger_rows(row) for row in triggers}
170
+ coverage = detector_snapshot.get("stageCoverage")
171
+ coverage = coverage if isinstance(coverage, list) else []
172
+ actual: dict[tuple[int, str], list[dict[str, Any]]] = {}
173
+ for stage in coverage:
174
+ if not isinstance(stage, Mapping):
175
+ raise ReportProjectionError("owner=design-surface-detector invalid stageCoverage row")
176
+ for row in stage.get("rows") or []:
177
+ if isinstance(row, Mapping):
178
+ actual[(int(stage.get("stage") or 0), str(row.get("kind") or ""))] = list(row.get("triggerEvidence") or [])
179
+ if expected != actual:
180
+ raise ReportProjectionError("owner=design-surface-detector trigger coverage mismatch")
181
+ preparation = detector_snapshot.get("designPreparation")
182
+ if not isinstance(preparation, Mapping):
183
+ raise ReportProjectionError("owner=design-surface-detector missing designPreparation")
184
+ return {
185
+ "designPreparation": deepcopy(dict(preparation)),
186
+ "stageCoverage": deepcopy(coverage),
187
+ }
188
+
189
+
190
+ def _round_history(state: Mapping[str, Any]) -> dict[str, Any]:
191
+ if not (state.get("config") or {}).get("enabled", True):
192
+ return {"disabled": True}
193
+ rows = []
194
+ for row in state.get("roundHistory") or []:
195
+ if not isinstance(row, Mapping):
196
+ continue
197
+ rows.append({
198
+ "round": row.get("round", 0),
199
+ "inputQueueSize": row.get("inputQueueSize", 0),
200
+ "resolvedCount": row.get("resolvedCount", 0),
201
+ "carriedForwardCount": row.get("carriedForwardCount", 0),
202
+ "dispatches": json.dumps(row.get("dispatches") or [], ensure_ascii=False),
203
+ "skippedWorkers": json.dumps(row.get("skippedWorkers") or [], ensure_ascii=False),
204
+ })
205
+ return {"rounds": rows, "round2SkippedReason": state.get("round2SkippedReason", "not-skipped")}
206
+
207
+
208
+ def _source_items(finding: Mapping[str, Any]) -> list[str]:
209
+ finding_id = _text(finding.get("findingId"), "unknown")
210
+ workers = finding.get("consensusWorkers") or [finding.get("originWorker")]
211
+ return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
212
+
213
+
214
+ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
215
+ """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다."""
216
+ consensus = []
217
+ differences = []
218
+ promoted = []
219
+ for finding in state.get("findings") or []:
220
+ if not isinstance(finding, Mapping):
221
+ continue
222
+ evidence = _text(finding.get("originEvidence"), "")
223
+ ticket_ids = finding.get("ticketIds") or ["unknown"]
224
+ ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
225
+ if finding.get("classification") in {"full-consensus", "partial-consensus"}:
226
+ row = {
227
+ "id": f"C-{len(consensus) + 1:03d}",
228
+ "ticketId": ticket_id,
229
+ "statement": _text(finding.get("summary"), "Converged finding"),
230
+ "sourceItems": _source_items(finding),
231
+ "evidence": evidence or "convergence state",
232
+ }
233
+ consensus.append(row)
234
+ if evidence and ("/" in evidence or ":" in evidence):
235
+ promoted.append({
236
+ "findingId": finding.get("findingId"),
237
+ "ticketId": ticket_id,
238
+ "evidence": evidence,
239
+ "sourceItems": row["sourceItems"],
240
+ })
241
+ else:
242
+ positions = []
243
+ for worker in [finding.get("originWorker"), *(finding.get("dissentingWorkers") or [])]:
244
+ if worker:
245
+ positions.append({
246
+ "worker": str(worker),
247
+ "itemId": _text(finding.get("findingId"), ""),
248
+ "position": "origin" if worker == finding.get("originWorker") else "dissent",
249
+ })
250
+ differences.append({
251
+ "id": f"D-{len(differences) + 1:03d}",
252
+ "ticketId": ticket_id,
253
+ "disagreement": _text(finding.get("summary"), "Unresolved difference"),
254
+ "workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
255
+ "evidence": evidence or "convergence state",
256
+ })
257
+ result = {
258
+ "crossVerification": {
259
+ "roundHistory": _round_history(state),
260
+ "consensus": consensus,
261
+ "differences": differences,
262
+ },
263
+ "promotedEvidence": promoted,
264
+ }
265
+ return result