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,1051 @@
1
+ """목적별 Okstra 소유 JSON에서 고정 Markdown 입력을 렌더한다."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from okstra_project import (
11
+ StateError,
12
+ parse_task_key,
13
+ slugify,
14
+ )
15
+
16
+ from .convergence import (
17
+ ConvergenceContractError,
18
+ canonical_run_state_artifact,
19
+ validated_run_authority,
20
+ )
21
+ from .json_boundary import JsonBoundaryError, load_owned_object
22
+ from .error_zip import last_output_path
23
+ from .fixed_text import line as _line, scalar as _value
24
+ from .paths import okstra_home
25
+ from .recap import assemble_recap
26
+
27
+
28
+ def _mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]:
29
+ value = payload.get(key)
30
+ return value if isinstance(value, Mapping) else {}
31
+
32
+
33
+ def _project_policy_lines(project: Mapping[str, Any]) -> str:
34
+ packs = project.get("reviewRulePacks")
35
+ pack_lines = (
36
+ [
37
+ _line("Review rule pack", path)
38
+ for path in packs
39
+ if isinstance(path, str)
40
+ ]
41
+ if isinstance(packs, list)
42
+ else []
43
+ )
44
+ qa_lines: list[str] = []
45
+ qa_commands = project.get("qaCommands")
46
+ if isinstance(qa_commands, Mapping):
47
+ for category, entries in sorted(qa_commands.items()):
48
+ if not isinstance(entries, list):
49
+ continue
50
+ for entry in entries:
51
+ if not isinstance(entry, Mapping):
52
+ continue
53
+ qa_lines.append(
54
+ f"- QA `{_value(category)}` / `{_value(entry.get('label'))}`: "
55
+ f"`{_value(entry.get('cmd'))}`\n"
56
+ )
57
+ return (
58
+ "\n## Project Review Rule Packs\n\n"
59
+ + ("".join(pack_lines) or "- None\n")
60
+ + "\n## Project QA Commands\n\n"
61
+ + ("".join(qa_lines) or "- None\n")
62
+ )
63
+
64
+
65
+ def _assignment_line(prefix: str, assignment: Mapping[str, Any], identifier: object = None) -> str:
66
+ label = prefix if identifier is None else f"{prefix} `{_value(identifier)}`"
67
+ return (
68
+ f"- {label}: role `{_value(assignment.get('role'))}`; "
69
+ f"provider `{_value(assignment.get('provider'))}`; "
70
+ f"model `{_value(assignment.get('model'))}`\n"
71
+ )
72
+
73
+
74
+ def _worker_assignment_lines(run: Mapping[str, Any]) -> str:
75
+ assignments = run.get("workerAssignments")
76
+ if not isinstance(assignments, list):
77
+ return "- None\n"
78
+ lines = [
79
+ _assignment_line("Worker", assignment, assignment.get("workerId"))
80
+ for assignment in assignments
81
+ if isinstance(assignment, Mapping)
82
+ ]
83
+ return "".join(lines) or "- None\n"
84
+
85
+
86
+ def _worker_prompt_lines(run: Mapping[str, Any]) -> str:
87
+ paths = run.get("workerPromptPathByWorkerId")
88
+ if not isinstance(paths, Mapping):
89
+ return "- None\n"
90
+ lines = [
91
+ f"- Worker prompt `{_value(worker_id)}`: `{_value(path)}`\n"
92
+ for worker_id, path in sorted(paths.items(), key=lambda item: _value(item[0]))
93
+ ]
94
+ return "".join(lines) or "- None\n"
95
+
96
+
97
+ def _fixed_owned_artifact_path(
98
+ project_root: Path, relative_path: Path, artifact: str
99
+ ) -> Path:
100
+ resolved_project_root = project_root.resolve()
101
+ okstra_root = resolved_project_root / ".okstra"
102
+ if okstra_root.is_symlink():
103
+ raise JsonBoundaryError(okstra_root, artifact, "Okstra root is not a fixed directory")
104
+ resolved_okstra_root = okstra_root.resolve()
105
+ try:
106
+ resolved_okstra_root.relative_to(resolved_project_root)
107
+ except ValueError as exc:
108
+ raise JsonBoundaryError(okstra_root, artifact, "outside this project") from exc
109
+ path = okstra_root / relative_path
110
+ expected_path = resolved_okstra_root / relative_path
111
+ if path.resolve() != expected_path:
112
+ raise JsonBoundaryError(path, artifact, "not at its fixed Okstra path")
113
+ return path
114
+
115
+
116
+ def _owned_tasks_root(project_root: Path, artifact: str) -> Path:
117
+ path = _fixed_owned_artifact_path(project_root, Path("tasks"), artifact)
118
+ if not path.is_dir():
119
+ raise JsonBoundaryError(path, artifact, "tasks root is not a directory")
120
+ return path
121
+
122
+
123
+ def _load_project(project_root: Path) -> dict[str, Any]:
124
+ path = _fixed_owned_artifact_path(
125
+ project_root, Path("project.json"), "project metadata"
126
+ )
127
+ return load_owned_object(path, artifact="project metadata")
128
+
129
+
130
+ def _latest_task_pointer(project_root: Path) -> Mapping[str, Any]:
131
+ path = _fixed_owned_artifact_path(
132
+ project_root, Path("discovery") / "latest-task.json", "latest task pointer"
133
+ )
134
+ if not path.is_file():
135
+ return {}
136
+ pointer = load_owned_object(path, artifact="latest task pointer")
137
+ manifest_path = _owned_task_manifest_path(
138
+ project_root, pointer.get("taskManifestPath"), "latest task pointer"
139
+ )
140
+ return _task_pointer_from_manifest(
141
+ project_root,
142
+ manifest_path,
143
+ expected_task_key=pointer.get("taskKey"),
144
+ pointer_run_manifest=pointer.get("latestRunManifestPath"),
145
+ artifact="latest task pointer",
146
+ )
147
+
148
+
149
+ def _project_relative(project_root: Path, path: Path) -> str:
150
+ try:
151
+ return str(path.resolve().relative_to(project_root.resolve()))
152
+ except ValueError:
153
+ return str(path)
154
+
155
+
156
+ def _owned_project_path(project_root: Path, value: str, artifact: str) -> Path:
157
+ path = Path(value)
158
+ path = path if path.is_absolute() else project_root / path
159
+ try:
160
+ path.resolve().relative_to(project_root.resolve())
161
+ except ValueError as exc:
162
+ raise JsonBoundaryError(path, artifact, "outside this project") from exc
163
+ return path
164
+
165
+
166
+ def _task_reference_error(project_root: Path, reason: str) -> JsonBoundaryError:
167
+ return JsonBoundaryError(project_root, "task reference", reason)
168
+
169
+
170
+ def _catalog_tasks(project_root: Path) -> list[Mapping[str, Any]]:
171
+ path = _fixed_owned_artifact_path(
172
+ project_root, Path("discovery") / "task-catalog.json", "task catalog"
173
+ )
174
+ if not path.is_file():
175
+ return []
176
+ catalog = load_owned_object(path, artifact="task catalog")
177
+ tasks = catalog.get("tasks")
178
+ return [task for task in tasks if isinstance(task, Mapping)] if isinstance(tasks, list) else []
179
+
180
+
181
+ def _catalog_task_root(project_root: Path, task: Mapping[str, Any]) -> Path | None:
182
+ value = task.get("taskRootPath") or task.get("taskRoot")
183
+ if not isinstance(value, str) or not value:
184
+ return None
185
+ path = Path(value)
186
+ path = path if path.is_absolute() else project_root / path
187
+ return path if path.is_dir() else None
188
+
189
+
190
+ def _normal_task_root(project_root: Path, task_key: str) -> Path | None:
191
+ _, task_group, task_id = parse_task_key(task_key)
192
+ path = _owned_tasks_root(project_root, "task reference") / slugify(task_group) / slugify(task_id)
193
+ return path if path.is_dir() else None
194
+
195
+
196
+ def _catalog_task_reference(
197
+ project_root: Path, reference: str
198
+ ) -> tuple[Path | None, str | None]:
199
+ tasks = _catalog_tasks(project_root)
200
+ if ":" in reference:
201
+ parse_task_key(reference)
202
+ matches = [
203
+ task for task in tasks
204
+ if isinstance(task.get("taskKey"), str)
205
+ and task["taskKey"].lower() == reference.lower()
206
+ ]
207
+ task_key = matches[0]["taskKey"] if matches else reference
208
+ task_root = _catalog_task_root(project_root, matches[0]) if matches else None
209
+ return task_root or _normal_task_root(project_root, task_key), task_key
210
+
211
+ matches = [
212
+ task for task in tasks
213
+ if isinstance(task.get("taskId"), str)
214
+ and task["taskId"].lower() == reference.lower()
215
+ ]
216
+ if len(matches) != 1:
217
+ raise _task_reference_error(
218
+ project_root, "task reference must resolve to exactly one task"
219
+ )
220
+ task_key = matches[0].get("taskKey")
221
+ if not isinstance(task_key, str):
222
+ return None, None
223
+ return _catalog_task_root(project_root, matches[0]) or _normal_task_root(
224
+ project_root, task_key
225
+ ), task_key
226
+
227
+
228
+ def _owned_task_manifest_path(
229
+ project_root: Path, value: object, artifact: str
230
+ ) -> Path:
231
+ if not isinstance(value, str) or not value:
232
+ raise JsonBoundaryError(project_root, artifact, "task manifest path is missing")
233
+ path = Path(value)
234
+ path = path if path.is_absolute() else project_root / path
235
+ if path.name != "task-manifest.json":
236
+ raise JsonBoundaryError(path, artifact, "task manifest path is invalid")
237
+ tasks_root = _owned_tasks_root(project_root, artifact)
238
+ try:
239
+ path.resolve().relative_to(tasks_root.resolve())
240
+ except ValueError as exc:
241
+ raise JsonBoundaryError(path, artifact, "task manifest is outside this project") from exc
242
+ return path
243
+
244
+
245
+ def _selected_run_manifest_path(
246
+ project_root: Path, task_root: Path, value: str
247
+ ) -> Path:
248
+ path = Path(value)
249
+ path = path if path.is_absolute() else project_root / path
250
+ tasks_root = _owned_tasks_root(project_root, "run manifest")
251
+ resolved_task_root = task_root.resolve()
252
+ resolved_runs_root = (task_root / "runs").resolve()
253
+ try:
254
+ resolved_task_root.relative_to(tasks_root.resolve())
255
+ resolved_runs_root.relative_to(resolved_task_root)
256
+ path.resolve().relative_to(resolved_runs_root)
257
+ path.resolve().relative_to(resolved_task_root)
258
+ except ValueError as exc:
259
+ raise JsonBoundaryError(path, "run manifest", "outside selected task") from exc
260
+ if not path.name.startswith("run-manifest-") or path.suffix != ".json":
261
+ raise JsonBoundaryError(path, "run manifest", "not a normal run manifest path")
262
+ return path
263
+
264
+
265
+ def _owned_run_manifest_path(run_manifest: Path) -> Path:
266
+ for runs_root in run_manifest.parents:
267
+ if runs_root.name != "runs":
268
+ continue
269
+ task_root = runs_root.parent
270
+ tasks_root = task_root.parent.parent
271
+ if tasks_root.name != "tasks" or tasks_root.parent.name != ".okstra":
272
+ continue
273
+ project_root = tasks_root.parent.parent
274
+ return _selected_run_manifest_path(project_root, task_root, str(run_manifest))
275
+ raise JsonBoundaryError(
276
+ run_manifest, "run manifest", "outside owned task runs"
277
+ )
278
+
279
+
280
+ def _task_manifest_from_reference(project_root: Path, task_ref: str) -> tuple[Path, str | None]:
281
+ reference = task_ref.strip()
282
+ candidate = Path(reference).expanduser()
283
+ candidate = candidate if candidate.is_absolute() else project_root / candidate
284
+ if candidate.is_file():
285
+ try:
286
+ return (
287
+ _owned_task_manifest_path(project_root, str(candidate), "task reference"),
288
+ None,
289
+ )
290
+ except JsonBoundaryError as exc:
291
+ raise _task_reference_error(project_root, exc.reason) from exc
292
+ try:
293
+ task_root, task_key = _catalog_task_reference(project_root, reference)
294
+ except StateError as exc:
295
+ raise _task_reference_error(project_root, str(exc)) from exc
296
+ if task_root is None:
297
+ raise _task_reference_error(project_root, "task not found")
298
+ try:
299
+ manifest_path = _owned_task_manifest_path(
300
+ project_root, str(task_root / "task-manifest.json"), "task reference"
301
+ )
302
+ except JsonBoundaryError as exc:
303
+ raise _task_reference_error(project_root, exc.reason) from exc
304
+ return manifest_path, task_key if isinstance(task_key, str) else None
305
+
306
+
307
+ def _task_pointer_from_manifest(
308
+ project_root: Path,
309
+ manifest_path: Path,
310
+ *,
311
+ expected_task_key: object = None,
312
+ pointer_run_manifest: object = None,
313
+ artifact: str,
314
+ ) -> Mapping[str, Any]:
315
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
316
+ task_key = manifest.get("taskKey")
317
+ if not isinstance(task_key, str):
318
+ raise JsonBoundaryError(manifest_path, artifact, "task key is invalid")
319
+ try:
320
+ _, task_group, task_id = parse_task_key(task_key)
321
+ except StateError as exc:
322
+ raise JsonBoundaryError(manifest_path, artifact, "task key is invalid") from exc
323
+ normal_manifest_path = (
324
+ _owned_tasks_root(project_root, artifact)
325
+ / slugify(task_group)
326
+ / slugify(task_id)
327
+ / "task-manifest.json"
328
+ )
329
+ if manifest_path.resolve() != normal_manifest_path.resolve():
330
+ raise JsonBoundaryError(
331
+ manifest_path, artifact, "task key does not match task manifest path"
332
+ )
333
+ if expected_task_key is not None and task_key != expected_task_key:
334
+ raise JsonBoundaryError(manifest_path, artifact, "task key does not match task manifest")
335
+ task_root = manifest_path.parent
336
+ expected_timeline_path = task_root / "history" / "timeline.json"
337
+ try:
338
+ expected_timeline_path.resolve().relative_to(task_root.resolve())
339
+ except ValueError as exc:
340
+ raise JsonBoundaryError(
341
+ expected_timeline_path, "task timeline", "outside selected task"
342
+ ) from exc
343
+ timeline_value = manifest.get("historyTimelinePath")
344
+ if isinstance(timeline_value, str) and timeline_value:
345
+ timeline_path = _owned_project_path(
346
+ project_root, timeline_value, "task timeline"
347
+ )
348
+ if timeline_path.resolve() != expected_timeline_path.resolve():
349
+ raise JsonBoundaryError(
350
+ timeline_path, "task timeline", "outside selected task"
351
+ )
352
+ else:
353
+ timeline_path = expected_timeline_path
354
+ latest_run_manifest: object = None
355
+ latest_run_path: Path | None = None
356
+ if timeline_path.is_file():
357
+ timeline = load_owned_object(timeline_path, artifact="task timeline")
358
+ runs = timeline.get("runs")
359
+ if isinstance(runs, list):
360
+ for run in reversed(runs):
361
+ if isinstance(run, Mapping) and isinstance(run.get("runManifestPath"), str):
362
+ latest_run_path = _selected_run_manifest_path(
363
+ project_root, task_root, run["runManifestPath"]
364
+ )
365
+ latest_run_manifest = _project_relative(project_root, latest_run_path)
366
+ break
367
+ if pointer_run_manifest is not None:
368
+ if not isinstance(pointer_run_manifest, str):
369
+ raise JsonBoundaryError(manifest_path, artifact, "run manifest path is invalid")
370
+ pointer_run_path = _selected_run_manifest_path(
371
+ project_root, task_root, pointer_run_manifest
372
+ )
373
+ if latest_run_path is None or pointer_run_path.resolve() != latest_run_path.resolve():
374
+ raise JsonBoundaryError(
375
+ manifest_path, artifact, "run manifest does not match task timeline"
376
+ )
377
+ return {
378
+ "taskKey": task_key,
379
+ "taskManifestPath": _project_relative(project_root, manifest_path),
380
+ "latestRunManifestPath": latest_run_manifest,
381
+ }
382
+
383
+
384
+ def _selected_task_pointer(project_root: Path, task_ref: str) -> Mapping[str, Any]:
385
+ manifest_path, expected_task_key = _task_manifest_from_reference(project_root, task_ref)
386
+ return _task_pointer_from_manifest(
387
+ project_root,
388
+ manifest_path,
389
+ expected_task_key=expected_task_key,
390
+ artifact="task reference",
391
+ )
392
+
393
+
394
+ def render_project_context(project_root: Path, task_ref: str = "") -> str:
395
+ """모델의 프로젝트 문맥에 필요한 안정 필드를 렌더한다."""
396
+ project = _load_project(project_root)
397
+ architecture = _mapping(project, "architecture")
398
+ current_task = (
399
+ _selected_task_pointer(project_root, task_ref)
400
+ if task_ref
401
+ else _latest_task_pointer(project_root)
402
+ )
403
+ return (
404
+ "# Okstra Project Context\n\n"
405
+ + _line("Project ID", project.get("projectId"))
406
+ + _line("Project root", str(project_root))
407
+ + _line("Report language", project.get("reportLanguage"))
408
+ + _line("Architecture style", architecture.get("style"))
409
+ + _project_policy_lines(project)
410
+ + "\n## Current Task Pointer\n\n"
411
+ + _line("Task key", current_task.get("taskKey"))
412
+ + _line("Task manifest", current_task.get("taskManifestPath"))
413
+ + _line("Latest run manifest", current_task.get("latestRunManifestPath"))
414
+ )
415
+
416
+
417
+ def render_run_input(run_manifest: Path) -> str:
418
+ """런 매니페스트의 간결한 비밀 없는 실행 식별자를 렌더한다."""
419
+ run_manifest = _owned_run_manifest_path(run_manifest)
420
+ run = load_owned_object(run_manifest, artifact="run manifest")
421
+ scope = _mapping(run, "analysisScopeConfirmation")
422
+ workflow = _mapping(run, "workflowSnapshot")
423
+ lead = _mapping(run, "leadAssignment")
424
+ adapter = _mapping(run, "leadAdapter")
425
+ return (
426
+ "# Okstra Run Input\n\n"
427
+ "## Run Context\n\n"
428
+ + _line("Task key", run.get("taskKey"))
429
+ + _line("Task type", run.get("taskType"))
430
+ + _line("Run ID", run.get("runDateTimeSegment"))
431
+ + _line("Work category", run.get("workCategory"))
432
+ + _line("Lead runtime", run.get("leadRuntime"))
433
+ + _line("Lead adapter name", adapter.get("name"))
434
+ + _line("Lead dispatch mode", adapter.get("dispatchMode"))
435
+ + _line("Lead session accounting", adapter.get("sessionAccounting"))
436
+ + _line("Current phase", workflow.get("currentPhase"))
437
+ + _line("Current phase state", workflow.get("currentPhaseState"))
438
+ + _line("Scope confirmation", scope.get("status"))
439
+ + "\n## Worker Roster\n\n"
440
+ + _assignment_line("Lead", lead)
441
+ + _worker_assignment_lines(run)
442
+ + "\n## Artifact Paths\n\n"
443
+ + _line("Worker prompts directory", run.get("workerPromptsDirectoryPath"))
444
+ + _worker_prompt_lines(run)
445
+ + _line("Worker results directory", run.get("workerResultsDirectoryPath"))
446
+ + _line("Expected report", run.get("expectedReportRecordPath"))
447
+ + _line("Expected status", run.get("expectedStatusPath"))
448
+ + _line("Validator", run.get("validatorScriptPath"))
449
+ + _line("Resume command", run.get("resumeCommandPath"))
450
+ + "\n## Configuration References\n\n"
451
+ + _line("Task manifest", run.get("taskManifestPath"))
452
+ + _line("Instruction set", run.get("instructionSetPath"))
453
+ + _line("Reference expectations", run.get("referenceExpectationsPath"))
454
+ + _line("Report template", run.get("reportTemplatePath"))
455
+ )
456
+
457
+
458
+ def render_active_context_input(project_root: Path, run_manifest: Path) -> str:
459
+ """이전 run의 executor base ref만 목적별 텍스트로 투영한다."""
460
+ root = Path(project_root)
461
+ if not root.is_absolute() or root.absolute() != root.resolve():
462
+ raise JsonBoundaryError(root, "active run context", "project root is not canonical")
463
+ try:
464
+ authority = validated_run_authority(run_manifest)
465
+ if authority.project_root != root:
466
+ raise ConvergenceContractError(
467
+ "active run context project root does not match run authority"
468
+ )
469
+ path = canonical_run_state_artifact(
470
+ authority,
471
+ manifest_field="activeRunContextPath",
472
+ prefix="active-run-context",
473
+ label="active run context path",
474
+ )
475
+ except ConvergenceContractError as exc:
476
+ raise JsonBoundaryError(
477
+ run_manifest, "active run context", str(exc)
478
+ ) from exc
479
+ payload = load_owned_object(path, artifact="active run context")
480
+ executor = _mapping(payload, "executorWorktree")
481
+ return "# Okstra Active Context\n\n" + _line("Executor base ref", executor.get("baseRef"))
482
+
483
+
484
+ def _group_segment(value: str) -> str:
485
+ return "".join(character for character in value.lower() if character.isalnum())
486
+
487
+
488
+ def _catalog_task_path(project_root: Path, task: Mapping[str, Any]) -> tuple[Path, str]:
489
+ task_key = task.get("taskKey")
490
+ if not isinstance(task_key, str):
491
+ raise JsonBoundaryError(project_root, "task catalog", "task key is invalid")
492
+ try:
493
+ _, task_group, task_id = parse_task_key(task_key)
494
+ except StateError as exc:
495
+ raise JsonBoundaryError(project_root, "task catalog", "task key is invalid") from exc
496
+ expected_group_segment = slugify(task_group)
497
+ expected_task_segment = slugify(task_id)
498
+ group_segment = task.get("taskGroupPathSegment", expected_group_segment)
499
+ task_segment = task.get("taskIdPathSegment", expected_task_segment)
500
+ if group_segment != expected_group_segment or task_segment != expected_task_segment:
501
+ raise JsonBoundaryError(
502
+ project_root, "task catalog", "task path segment does not match task key"
503
+ )
504
+ path = (
505
+ _owned_tasks_root(project_root, "task catalog")
506
+ / expected_group_segment
507
+ / expected_task_segment
508
+ / "task-manifest.json"
509
+ )
510
+ return _owned_task_manifest_path(project_root, str(path), "task catalog"), task_key
511
+
512
+
513
+ def render_schedule_input(project_root: Path, task_group: str) -> str:
514
+ """한 그룹의 카탈로그 작업에 대한 일정용 메타데이터를 렌더한다."""
515
+ project = _load_project(project_root)
516
+ catalog_path = _fixed_owned_artifact_path(
517
+ project_root, Path("discovery") / "task-catalog.json", "task catalog"
518
+ )
519
+ catalog = load_owned_object(catalog_path, artifact="task catalog")
520
+ target_group = _group_segment(task_group)
521
+ task_blocks: list[str] = []
522
+ catalog_tasks = catalog.get("tasks")
523
+ if isinstance(catalog_tasks, list):
524
+ for entry in catalog_tasks:
525
+ if not isinstance(entry, Mapping):
526
+ continue
527
+ task_key = entry.get("taskKey")
528
+ if not isinstance(task_key, str):
529
+ continue
530
+ try:
531
+ _, entry_group, _ = parse_task_key(task_key)
532
+ except StateError:
533
+ continue
534
+ if _group_segment(entry_group) != target_group:
535
+ continue
536
+ manifest_path, expected_task_key = _catalog_task_path(project_root, entry)
537
+ _task_pointer_from_manifest(
538
+ project_root,
539
+ manifest_path,
540
+ expected_task_key=expected_task_key,
541
+ artifact="task catalog",
542
+ )
543
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
544
+ workflow = _mapping(manifest, "workflow")
545
+ task_blocks.append(
546
+ "### " + _value(manifest.get("taskId")) + "\n\n"
547
+ + _line("Task key", manifest.get("taskKey"))
548
+ + _line("Work status", manifest.get("workStatus"))
549
+ + _line("Work category", manifest.get("workCategory"))
550
+ + _line("Task type", manifest.get("taskType"))
551
+ + _line("Current phase", workflow.get("currentPhase"))
552
+ )
553
+ tasks = "".join(task_blocks) or "- None\n"
554
+ return (
555
+ "# Okstra Schedule Input\n\n"
556
+ + _line("Project ID", project.get("projectId"))
557
+ + _line("Task group", task_group)
558
+ + "\n## Tasks\n\n"
559
+ + tasks
560
+ )
561
+
562
+
563
+ def render_code_review_input(project_root: Path, base: str, head: str) -> str:
564
+ """리뷰에 필요한 고정 프로젝트 식별자와 git 범위를 렌더한다."""
565
+ project = _load_project(project_root)
566
+ return (
567
+ "# Okstra Code Review Input\n\n"
568
+ + _line("Project ID", project.get("projectId"))
569
+ + _line("Project root", str(project_root))
570
+ + _line("Base ref", base)
571
+ + _line("Head ref", head)
572
+ )
573
+
574
+
575
+ def _selected_task_data(
576
+ project_root: Path, task_ref: str
577
+ ) -> tuple[Mapping[str, Any], list[Mapping[str, Any]]]:
578
+ manifest_path, expected_key = _task_manifest_from_reference(project_root, task_ref)
579
+ _task_pointer_from_manifest(
580
+ project_root, manifest_path, expected_task_key=expected_key, artifact="task reference"
581
+ )
582
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
583
+ timeline_path = manifest_path.parent / "history" / "timeline.json"
584
+ timeline = (
585
+ load_owned_object(timeline_path, artifact="task timeline")
586
+ if timeline_path.is_file()
587
+ else {}
588
+ )
589
+ runs = timeline.get("runs")
590
+ return manifest, [run for run in runs if isinstance(run, Mapping)] if isinstance(runs, list) else []
591
+
592
+
593
+ def _next_phase_lines(workflow: Mapping[str, Any]) -> str:
594
+ pointer = _mapping(workflow, "nextRecommendedPhase")
595
+ return (
596
+ _line("Next phase", pointer.get("phase"))
597
+ + _line("Next phase status", pointer.get("status"))
598
+ + _line("Next phase rationale", pointer.get("rationale"))
599
+ )
600
+
601
+
602
+ def _latest_timeline_run(manifest_path: Path) -> Mapping[str, Any]:
603
+ timeline_path = manifest_path.parent / "history" / "timeline.json"
604
+ if not timeline_path.is_file():
605
+ return {}
606
+ timeline = load_owned_object(timeline_path, artifact="task timeline")
607
+ runs = timeline.get("runs")
608
+ if not isinstance(runs, list):
609
+ return {}
610
+ return next(
611
+ (run for run in reversed(runs) if isinstance(run, Mapping)),
612
+ {},
613
+ )
614
+
615
+
616
+ def _overview_rows(
617
+ project_root: Path,
618
+ *,
619
+ task_type: str = "",
620
+ latest_run_status: str = "",
621
+ task_group: str = "",
622
+ ) -> list[dict[str, object]]:
623
+ rows: list[dict[str, object]] = []
624
+ for task in _catalog_tasks(project_root):
625
+ manifest_path, task_key = _catalog_task_path(project_root, task)
626
+ pointer = _task_pointer_from_manifest(
627
+ project_root, manifest_path, expected_task_key=task_key, artifact="task catalog"
628
+ )
629
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
630
+ latest_run = _latest_timeline_run(manifest_path)
631
+ _, group, _ = parse_task_key(task_key)
632
+ workflow = _mapping(manifest, "workflow")
633
+ next_phase = _mapping(workflow, "nextRecommendedPhase")
634
+ row = {
635
+ "taskKey": task_key,
636
+ "taskGroup": manifest.get("taskGroup") or task.get("taskGroup") or group,
637
+ "taskType": manifest.get("taskType") or latest_run.get("taskType"),
638
+ "latestRunStatus": latest_run.get("status") or manifest.get("latestRunStatus"),
639
+ "updatedAt": manifest.get("updatedAt") or task.get("updatedAt") or latest_run.get("runTimestamp"),
640
+ "latestRunManifestPath": pointer.get("latestRunManifestPath"),
641
+ "workCategory": manifest.get("workCategory"),
642
+ "currentStatus": manifest.get("currentStatus"),
643
+ "currentPhase": workflow.get("currentPhase"),
644
+ "currentPhaseState": workflow.get("currentPhaseState"),
645
+ "nextPhase": next_phase.get("phase"),
646
+ "nextPhaseStatus": next_phase.get("status"),
647
+ "nextPhaseRationale": next_phase.get("rationale"),
648
+ "awaitingApproval": workflow.get("awaitingApproval"),
649
+ "latestReportRecordPath": latest_run.get("reportRecordPath") or manifest.get("latestReportRecordPath"),
650
+ "latestResumeCommandPath": latest_run.get("resumeCommandPath") or manifest.get("latestResumeCommandPath"),
651
+ "workStatus": manifest.get("workStatus"),
652
+ "lastRun": latest_run.get("runTimestamp"),
653
+ }
654
+ if task_type and row["taskType"] != task_type:
655
+ continue
656
+ if latest_run_status and row["latestRunStatus"] != latest_run_status:
657
+ continue
658
+ if task_group and row["taskGroup"] != task_group:
659
+ continue
660
+ rows.append(row)
661
+ rows.sort(key=lambda row: _value(row.get("taskKey")))
662
+ rows.sort(key=lambda row: _value(row.get("updatedAt")), reverse=True)
663
+ return rows
664
+
665
+
666
+ def _status_overview_text(rows: list[dict[str, object]]) -> str:
667
+ blocks = [_line("Task count", len(rows))]
668
+ for row in rows:
669
+ blocks.extend((
670
+ "\n## Task\n\n",
671
+ _line("Task key", row.get("taskKey")),
672
+ _line("Task group", row.get("taskGroup")),
673
+ _line("Task type", row.get("taskType")),
674
+ _line("Latest run status", row.get("latestRunStatus")),
675
+ _line("Updated at", row.get("updatedAt")),
676
+ _line("Latest run manifest", row.get("latestRunManifestPath")),
677
+ _line("Work category", row.get("workCategory")),
678
+ _line("Current status", row.get("currentStatus")),
679
+ _line("Current phase", row.get("currentPhase")),
680
+ _line("Current phase state", row.get("currentPhaseState")),
681
+ _line("Next phase", row.get("nextPhase")),
682
+ _line("Next phase status", row.get("nextPhaseStatus")),
683
+ _line("Next phase rationale", row.get("nextPhaseRationale")),
684
+ _line("Awaiting approval", row.get("awaitingApproval")),
685
+ _line("Latest report", row.get("latestReportRecordPath")),
686
+ _line("Latest resume command", row.get("latestResumeCommandPath")),
687
+ _line("Work status", row.get("workStatus")),
688
+ ))
689
+ return "# Okstra Status Input\n\n" + "".join(blocks)
690
+
691
+
692
+ def _history_overview_text(rows: list[dict[str, object]], limit: int) -> str:
693
+ shown = rows[:limit]
694
+ blocks = [
695
+ _line("Task count", len(rows)),
696
+ _line("Shown task count", len(shown)),
697
+ _line("Remaining task count", max(0, len(rows) - len(shown))),
698
+ ]
699
+ for row in shown:
700
+ blocks.extend((
701
+ "\n## Task\n\n", _line("Task key", row.get("taskKey")),
702
+ _line("Task group", row.get("taskGroup")),
703
+ _line("Task type", row.get("taskType")),
704
+ _line("Current status", row.get("currentStatus")),
705
+ _line("Latest run status", row.get("latestRunStatus")),
706
+ _line("Last run", row.get("lastRun")),
707
+ _line("Latest report", row.get("latestReportRecordPath")),
708
+ _line("Updated at", row.get("updatedAt")),
709
+ _line("Latest run manifest", row.get("latestRunManifestPath")),
710
+ ))
711
+ return "# Okstra History Input\n\n" + "".join(blocks)
712
+
713
+
714
+ def render_status_input(
715
+ project_root: Path,
716
+ task_ref: str,
717
+ *,
718
+ task_type: str = "",
719
+ latest_run_status: str = "",
720
+ task_group: str = "",
721
+ ) -> str:
722
+ if not task_ref:
723
+ return _status_overview_text(
724
+ _overview_rows(
725
+ project_root,
726
+ task_type=task_type,
727
+ latest_run_status=latest_run_status,
728
+ task_group=task_group,
729
+ ),
730
+ )
731
+ manifest, _ = _selected_task_data(project_root, task_ref)
732
+ workflow = _mapping(manifest, "workflow")
733
+ checkpoint = _mapping(workflow, "lastSafeCheckpoint")
734
+ phases = _mapping(workflow, "phaseStates")
735
+ phase_text = ", ".join(f"{key}={_value(value)}" for key, value in sorted(phases.items()))
736
+ return (
737
+ "# Okstra Status Input\n\n"
738
+ + _line("Task key", manifest.get("taskKey"))
739
+ + _line("Task type", manifest.get("taskType"))
740
+ + _line("Work category", manifest.get("workCategory"))
741
+ + _line("Current status", manifest.get("currentStatus"))
742
+ + _line("Latest run status", manifest.get("latestRunStatus"))
743
+ + _line("Current phase", workflow.get("currentPhase"))
744
+ + _line("Current phase state", workflow.get("currentPhaseState"))
745
+ + _line("Phase states", phase_text)
746
+ + _line("Last completed phase", workflow.get("lastCompletedPhase"))
747
+ + _next_phase_lines(workflow)
748
+ + _line("Awaiting approval", workflow.get("awaitingApproval"))
749
+ + _line("Work status", manifest.get("workStatus"))
750
+ + _line("Work status updated at", manifest.get("workStatusUpdatedAt"))
751
+ + _line("Work status note", manifest.get("workStatusNote"))
752
+ + _line("Latest report", manifest.get("latestReportRecordPath"))
753
+ + _line("Latest resume command", manifest.get("latestResumeCommandPath"))
754
+ + _line("History timeline", manifest.get("historyTimelinePath"))
755
+ + _line("Safe checkpoint label", checkpoint.get("label"))
756
+ + _line("Safe checkpoint run manifest", checkpoint.get("runManifestPath"))
757
+ + _line("Safe checkpoint team state", checkpoint.get("teamStatePath"))
758
+ + _line("Safe checkpoint report", checkpoint.get("reportRecordPath"))
759
+ + _line("Safe checkpoint resume command", checkpoint.get("resumeCommandPath"))
760
+ )
761
+
762
+
763
+ def render_history_input(
764
+ project_root: Path,
765
+ task_ref: str,
766
+ *,
767
+ task_type: str = "",
768
+ latest_run_status: str = "",
769
+ task_group: str = "",
770
+ limit: int = 20,
771
+ ) -> str:
772
+ if not task_ref:
773
+ return _history_overview_text(
774
+ _overview_rows(
775
+ project_root,
776
+ task_type=task_type,
777
+ latest_run_status=latest_run_status,
778
+ task_group=task_group,
779
+ ), limit,
780
+ )
781
+ manifest, runs = _selected_task_data(project_root, task_ref)
782
+ blocks: list[str] = []
783
+ for index, run in enumerate(runs, 1):
784
+ blocks.append(
785
+ f"\n## Run {index}\n\n"
786
+ + _line("Run timestamp", run.get("runTimestamp"))
787
+ + _line("Run date-time segment", run.get("runDateTimeSegment"))
788
+ + _line("Task type", run.get("taskType"))
789
+ + _line("Status", run.get("status"))
790
+ + _line("Run manifest", run.get("runManifestPath"))
791
+ + _line("Report", run.get("reportRecordPath"))
792
+ + _line("Resume command", run.get("resumeCommandPath"))
793
+ + _line("Related tasks", ",".join(run.get("relatedTasks", [])) if isinstance(run.get("relatedTasks"), list) else None)
794
+ )
795
+ return "# Okstra History Input\n\n" + _line("Task key", manifest.get("taskKey")) + "".join(blocks)
796
+
797
+
798
+ def render_recap_input(project_root: Path, task_ref: str) -> str:
799
+ manifest_path, _ = _task_manifest_from_reference(project_root, task_ref)
800
+ recap = assemble_recap(manifest_path.parent, project_root)
801
+ blocks = [
802
+ "# Okstra Recap Input\n\n",
803
+ _line("Task key", recap.get("taskKey")),
804
+ _line("Run count", recap.get("runCount")),
805
+ ]
806
+ transitions = recap.get("transitions")
807
+ for transition in transitions if isinstance(transitions, list) else []:
808
+ if not isinstance(transition, Mapping):
809
+ continue
810
+ pointer = _mapping(transition, "nextRecommendedPhase")
811
+ blocks.extend((
812
+ "\n## Transition\n\n",
813
+ _line("Run timestamp", transition.get("runTimestamp")),
814
+ _line("Task type", transition.get("taskType")),
815
+ _line("From phase", transition.get("fromPhase")),
816
+ _line("To phase", transition.get("toPhase")),
817
+ _line("Status", transition.get("status")),
818
+ _line("Last completed phase", transition.get("lastCompletedPhase")),
819
+ _line("Next phase", pointer.get("phase")),
820
+ _line("Next phase status", pointer.get("status")),
821
+ _line("Next phase rationale", pointer.get("rationale")),
822
+ _line("Report", transition.get("reportRecordPath")),
823
+ ))
824
+ return "".join(blocks)
825
+
826
+
827
+ def render_report_input(project_root: Path, task_ref: str) -> str:
828
+ manifest, _ = _selected_task_data(project_root, task_ref)
829
+ workflow = _mapping(manifest, "workflow")
830
+ return (
831
+ "# Okstra Report Input\n\n"
832
+ + _line("Task key", manifest.get("taskKey"))
833
+ + _line("Task type", manifest.get("taskType"))
834
+ + _line("Current status", manifest.get("currentStatus"))
835
+ + _line("Work status", manifest.get("workStatus"))
836
+ + _line("Latest report", manifest.get("latestReportRecordPath"))
837
+ + _next_phase_lines(workflow)
838
+ )
839
+
840
+
841
+ def _csv(value: object) -> str:
842
+ return ",".join(str(item) for item in value) if isinstance(value, list) else ""
843
+
844
+
845
+ def _provider_models(run: Mapping[str, Any]) -> dict[str, str]:
846
+ values: dict[str, set[str]] = {}
847
+ assignments = run.get("workerAssignments")
848
+ if isinstance(assignments, list):
849
+ for assignment in assignments:
850
+ if not isinstance(assignment, Mapping):
851
+ continue
852
+ provider = assignment.get("provider")
853
+ model = assignment.get("model")
854
+ if isinstance(provider, str) and isinstance(model, str) and model:
855
+ values.setdefault(provider, set()).add(model)
856
+ for provider, models in values.items():
857
+ if len(models) > 1:
858
+ raise JsonBoundaryError(
859
+ Path("workerAssignments"), "run manifest", f"conflicting models for provider {provider}"
860
+ )
861
+ return {provider: next(iter(models)) for provider, models in values.items()}
862
+
863
+
864
+ def render_rerun_input(run_manifest: Path) -> str:
865
+ run_manifest = _owned_run_manifest_path(run_manifest)
866
+ run = load_owned_object(run_manifest, artifact="run manifest")
867
+ models = _provider_models(run)
868
+ team = _mapping(run, "teamContract")
869
+ executor = _mapping(team, "executor")
870
+ executor_provider = executor.get("provider")
871
+ executor_model = executor.get("model")
872
+ if (
873
+ isinstance(executor_provider, str)
874
+ and isinstance(executor_model, str)
875
+ and executor_model
876
+ and models.get(executor_provider)
877
+ and models[executor_provider] != executor_model
878
+ ):
879
+ raise JsonBoundaryError(
880
+ run_manifest,
881
+ "run manifest",
882
+ f"executor model conflicts with provider {executor_provider}",
883
+ )
884
+ return (
885
+ "# Okstra Rerun Input\n\n"
886
+ + _line("Project ID", run.get("projectId"))
887
+ + _line("Task group", run.get("taskGroup"))
888
+ + _line("Task ID", run.get("taskId"))
889
+ + _line("Task type", run.get("taskType"))
890
+ + _line("Task brief", run.get("taskBriefPath"))
891
+ + _line("Recommended workers", _csv(run.get("recommendedWorkers")))
892
+ + _line("Related tasks", _csv(run.get("relatedTasks")))
893
+ + _line("Claude model", models.get("claude"))
894
+ + _line("Codex model", models.get("codex"))
895
+ + _line("Antigravity model", models.get("antigravity"))
896
+ + _line("Executor provider", executor.get("provider"))
897
+ + _line("Executor model", executor.get("model"))
898
+ )
899
+
900
+
901
+ def _symlink_ancestor(path: Path) -> Path | None:
902
+ lexical = Path(os.path.abspath(path))
903
+ current = Path(lexical.anchor)
904
+ for part in lexical.parts[1:-1]:
905
+ current /= part
906
+ if current.is_symlink():
907
+ return current
908
+ return None
909
+
910
+
911
+ def render_error_zip_input() -> str:
912
+ home = okstra_home()
913
+ linked_ancestor = _symlink_ancestor(home)
914
+ if linked_ancestor is not None:
915
+ raise JsonBoundaryError(
916
+ home,
917
+ "error zip configuration",
918
+ f"Okstra home contains a symbolic link ({linked_ancestor})",
919
+ )
920
+ if home.is_symlink():
921
+ raise JsonBoundaryError(home, "error zip configuration", "Okstra home is not a fixed directory")
922
+ configuration = home / "error-zip.json"
923
+ if configuration.is_symlink():
924
+ raise JsonBoundaryError(
925
+ configuration,
926
+ "error zip configuration",
927
+ "configuration is not at its fixed path",
928
+ )
929
+ return "# Okstra Error Zip Input\n\n" + _line(
930
+ "Previous output path", last_output_path(home.resolve())
931
+ )
932
+
933
+
934
+ def render_task_selection_input(project_root: Path, task_ref: str = "") -> str:
935
+ tasks = _catalog_tasks(project_root)
936
+ token = task_ref.casefold()
937
+ matches: list[tuple[Mapping[str, Any], str]] = []
938
+ for task in tasks:
939
+ task_id = str(task.get("taskId") or "")
940
+ task_group = str(task.get("taskGroup") or "")
941
+ via = "taskId" if token and task_id.casefold() == token else ""
942
+ via = "taskGroup" if token and task_group.casefold() == token else via
943
+ if not token or via:
944
+ matches.append((task, via or "catalog"))
945
+ matches.sort(key=lambda item: (_value(item[0].get("updatedAt")), _value(item[0].get("taskKey"))), reverse=True)
946
+ lines = ["# Okstra Task Selection Input\n\n", _line("Match count", len(matches))]
947
+ for task, via in matches[:10]:
948
+ lines.extend(
949
+ (
950
+ _line("Task", task.get("taskKey")),
951
+ _line("Updated at", task.get("updatedAt")),
952
+ _line("Matched via", via),
953
+ )
954
+ )
955
+ return "".join(lines)
956
+
957
+
958
+ def build_parser() -> argparse.ArgumentParser:
959
+ parser = argparse.ArgumentParser(
960
+ prog="okstra model-io",
961
+ description="Render fixed Markdown views of Okstra-owned JSON.",
962
+ )
963
+ commands = parser.add_subparsers(dest="command", required=True)
964
+ project = commands.add_parser("project-context")
965
+ project.add_argument("--project-root", required=True)
966
+ project.add_argument("--task-ref", default="")
967
+ run = commands.add_parser("run-input")
968
+ run.add_argument("--run-manifest", required=True)
969
+ active = commands.add_parser("active-context-input")
970
+ active.add_argument("--project-root", required=True)
971
+ active.add_argument("--run-manifest", required=True)
972
+ schedule = commands.add_parser("schedule-input")
973
+ schedule.add_argument("--project-root", required=True)
974
+ schedule.add_argument("--task-group", required=True)
975
+ review = commands.add_parser("code-review-input")
976
+ review.add_argument("--project-root", required=True)
977
+ review.add_argument("--base", required=True)
978
+ review.add_argument("--head", required=True)
979
+ for name in ("status-input", "history-input", "report-input"):
980
+ inspect = commands.add_parser(name)
981
+ inspect.add_argument("--project-root", required=True)
982
+ inspect.add_argument("--task-ref", required=name == "report-input", default="")
983
+ if name != "report-input":
984
+ inspect.add_argument("--task-type", default="")
985
+ inspect.add_argument("--latest-run-status", default="")
986
+ inspect.add_argument("--task-group", default="")
987
+ if name == "history-input":
988
+ inspect.add_argument("--limit", type=int, default=20)
989
+ recap = commands.add_parser("recap-input")
990
+ recap.add_argument("--project-root", required=True)
991
+ recap.add_argument("--task-ref", required=True)
992
+ rerun = commands.add_parser("rerun-input")
993
+ rerun.add_argument("--run-manifest", required=True)
994
+ commands.add_parser("error-zip-input")
995
+ selection = commands.add_parser("task-selection-input")
996
+ selection.add_argument("--project-root", required=True)
997
+ selection.add_argument("--task-ref", default="")
998
+ return parser
999
+
1000
+
1001
+ def main(argv: list[str] | None = None) -> int:
1002
+ args = build_parser().parse_args(argv)
1003
+ try:
1004
+ if args.command == "project-context":
1005
+ text = render_project_context(Path(args.project_root), args.task_ref)
1006
+ elif args.command == "run-input":
1007
+ text = render_run_input(Path(args.run_manifest))
1008
+ elif args.command == "active-context-input":
1009
+ text = render_active_context_input(
1010
+ Path(args.project_root), Path(args.run_manifest)
1011
+ )
1012
+ elif args.command == "schedule-input":
1013
+ text = render_schedule_input(Path(args.project_root), args.task_group)
1014
+ elif args.command == "code-review-input":
1015
+ text = render_code_review_input(Path(args.project_root), args.base, args.head)
1016
+ elif args.command == "status-input":
1017
+ text = render_status_input(
1018
+ Path(args.project_root), args.task_ref,
1019
+ task_type=args.task_type,
1020
+ latest_run_status=args.latest_run_status,
1021
+ task_group=args.task_group,
1022
+ )
1023
+ elif args.command == "history-input":
1024
+ text = render_history_input(
1025
+ Path(args.project_root), args.task_ref,
1026
+ task_type=args.task_type,
1027
+ latest_run_status=args.latest_run_status,
1028
+ task_group=args.task_group,
1029
+ limit=max(0, args.limit),
1030
+ )
1031
+ elif args.command == "recap-input":
1032
+ text = render_recap_input(Path(args.project_root), args.task_ref)
1033
+ elif args.command == "report-input":
1034
+ text = render_report_input(Path(args.project_root), args.task_ref)
1035
+ elif args.command == "rerun-input":
1036
+ text = render_rerun_input(Path(args.run_manifest))
1037
+ elif args.command == "error-zip-input":
1038
+ text = render_error_zip_input()
1039
+ elif args.command == "task-selection-input":
1040
+ text = render_task_selection_input(Path(args.project_root), args.task_ref)
1041
+ else: # argparse가 도달 불가로 만들지만 명시적 종료 경계를 유지한다.
1042
+ raise ValueError(f"unsupported command: {args.command}")
1043
+ except JsonBoundaryError as exc:
1044
+ print(f"model-io: {exc}", file=sys.stderr)
1045
+ return 2
1046
+ print(text, end="")
1047
+ return 0
1048
+
1049
+
1050
+ if __name__ == "__main__":
1051
+ raise SystemExit(main(sys.argv[1:]))