okstra 0.179.2 → 0.180.0

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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +14 -0
  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/report/approval-decision.d.mts +1 -0
  9. package/dist/commands/report/approval-decision.mjs +21 -0
  10. package/dist/commands/report/approval-decision.mjs.map +1 -0
  11. package/dist/commands/report/design-snapshot.d.mts +1 -0
  12. package/dist/commands/report/design-snapshot.mjs +19 -0
  13. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  14. package/docs/architecture/storage-model.md +1 -1
  15. package/docs/architecture.md +10 -10
  16. package/docs/cli.md +11 -8
  17. package/docs/project-structure-overview.md +15 -6
  18. package/docs/task-process/implementation-planning.md +2 -2
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/agents/workers/report-writer-worker.md +15 -164
  22. package/runtime/prompts/launch.template.md +6 -5
  23. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  24. package/runtime/prompts/lead/convergence.md +2 -2
  25. package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
  26. package/runtime/prompts/lead/plan-body-verification.md +39 -18
  27. package/runtime/prompts/lead/report-writer.md +64 -423
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  30. package/runtime/prompts/profiles/_common-contract.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  32. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  33. package/runtime/prompts/profiles/error-analysis.md +1 -1
  34. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  35. package/runtime/prompts/profiles/implementation-planning.md +13 -11
  36. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  37. package/runtime/prompts/profiles/project-analysis.md +1 -1
  38. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
  40. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  41. package/runtime/python/okstra_ctl/agent_activity.py +23 -3
  42. package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
  43. package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
  44. package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
  45. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  46. package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
  47. package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
  48. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  49. package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
  50. package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
  51. package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
  52. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  53. package/runtime/python/okstra_ctl/paths.py +12 -0
  54. package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
  55. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  56. package/runtime/python/okstra_ctl/render.py +48 -1
  57. package/runtime/python/okstra_ctl/render_final_report.py +7 -6
  58. package/runtime/python/okstra_ctl/report_assembly.py +354 -0
  59. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  60. package/runtime/python/okstra_ctl/report_finalize.py +60 -22
  61. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  62. package/runtime/python/okstra_ctl/report_markdown.py +69 -8
  63. package/runtime/python/okstra_ctl/report_narrative.py +319 -0
  64. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  65. package/runtime/python/okstra_ctl/run.py +25 -9
  66. package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
  67. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
  68. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  69. package/runtime/python/okstra_ctl/stage_map.py +70 -22
  70. package/runtime/python/okstra_ctl/team.py +1 -1
  71. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  72. package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  74. package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
  75. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  76. package/runtime/templates/report-writer-prompt-preamble.md +15 -21
  77. package/runtime/templates/reports/html/macros/forms.html +6 -4
  78. package/runtime/validators/validate-run.py +258 -10
@@ -46,6 +46,7 @@ from .registry.host_registry import default_host_registry
46
46
  from .registry.provider_registry import default_provider_registry
47
47
  from .path_hints import compact_active_run_context, hydrate_run_context
48
48
  from .paths import task_timeline_file
49
+ from .report_contract import CURRENT_REPORT_SCHEMA_VERSION
49
50
  from .worker_artifacts import artifacts_from_context
50
51
  from .workflow import PHASE_SEQUENCE
51
52
 
@@ -1686,12 +1687,16 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1686
1687
  execution_identity = _execution_identity(ctx)
1687
1688
  payload = {
1688
1689
  "schemaVersion": execution_identity.get("schemaVersion", "1.0"),
1690
+ "reportContractVersion": CURRENT_REPORT_SCHEMA_VERSION,
1689
1691
  "okstraVersion": ctx.get("OKSTRA_VERSION", ""),
1690
1692
  "projectId": ctx.get("PROJECT_ID", ""),
1691
1693
  "taskGroup": ctx.get("TASK_GROUP", ""),
1692
1694
  "taskId": ctx.get("TASK_ID", ""),
1693
1695
  "taskKey": ctx.get("TASK_KEY", ""),
1694
1696
  "taskType": ctx.get("TASK_TYPE", ""),
1697
+ "runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
1698
+ "reportLanguage": ctx.get("REPORT_LANGUAGE", "en") or "en",
1699
+ "leadModel": ctx.get("LEAD_MODEL", ""),
1695
1700
  # Every other path in this manifest is project-relative; this is the
1696
1701
  # anchor they resolve against. Convergence reads it to place run
1697
1702
  # artifacts, so a manifest without it cannot seed a round.
@@ -1763,6 +1768,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1763
1768
  "workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
1764
1769
  "workerPromptPathByWorkerId": worker_prompt_paths,
1765
1770
  "expectedReportRecordPath": ctx.get("FINAL_REPORT_RECORD_RELATIVE_PATH", ""),
1771
+ "reportNarrativePath": ctx.get("REPORT_WRITER_NARRATIVE_RELATIVE_PATH", ""),
1772
+ "approvalDecisionsPath": ctx.get("APPROVAL_DECISIONS_RELATIVE_PATH", ""),
1773
+ "designPreparationPath": ctx.get("DESIGN_PREPARATION_RELATIVE_PATH", ""),
1774
+ "planBodyVerificationPath": ctx.get("PLAN_BODY_VERIFICATION_STATE_RELATIVE_PATH", ""),
1766
1775
  "expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
1767
1776
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
1768
1777
  "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
@@ -1847,7 +1856,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1847
1856
  payload["analysisScopeConfirmation"] = scope_confirmation
1848
1857
  if ctx.get("FIX_CYCLE_ID"):
1849
1858
  payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1850
- if ctx.get("TASK_TYPE") == "implementation-planning":
1859
+ if (
1860
+ CURRENT_REPORT_SCHEMA_VERSION == "3.0"
1861
+ or ctx.get("TASK_TYPE") == "implementation-planning"
1862
+ ):
1851
1863
  if not run_manifest_exists:
1852
1864
  payload["activityContractVersion"] = 1
1853
1865
  elif "activityContractVersion" in existing_run_manifest:
@@ -1864,6 +1876,27 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1864
1876
  payload.update(execution_identity)
1865
1877
  _guard_invocation_manifest_rewrite(existing_run_manifest, payload)
1866
1878
  _write_json(Path(run_manifest_path), payload)
1879
+ if CURRENT_REPORT_SCHEMA_VERSION == "3.0":
1880
+ _initialize_report_ledgers(ctx, payload)
1881
+
1882
+
1883
+ def _initialize_report_ledgers(ctx: Mapping[str, Any], manifest: Mapping[str, Any]) -> None:
1884
+ approval_value = str(ctx.get("APPROVAL_DECISIONS_PATH") or "")
1885
+ approval_path = Path(approval_value)
1886
+ if approval_value and not approval_path.is_file():
1887
+ _write_json(approval_path, {
1888
+ "schemaVersion": "1.0",
1889
+ "owner": "lead",
1890
+ "taskKey": manifest.get("taskKey"),
1891
+ "taskType": manifest.get("taskType"),
1892
+ "runSeq": (manifest.get("runSequencesByCategory") or {}).get("manifests"),
1893
+ "activeClarifications": [],
1894
+ "carriedDecisions": [],
1895
+ })
1896
+ activity_value = str(ctx.get("LEAD_EVENTS_PATH") or "")
1897
+ activity_path = Path(activity_value)
1898
+ if activity_value and not activity_path.is_file():
1899
+ _write_text(activity_path, "")
1867
1900
 
1868
1901
 
1869
1902
  _MODEL_ASSIGNMENT_KEYS = {
@@ -1904,8 +1937,18 @@ _IMMUTABLE_INVOCATION_KEYS = (
1904
1937
  "workerPromptPathByWorkerId",
1905
1938
  "workerResultsDirectoryPath",
1906
1939
  "expectedReportRecordPath",
1940
+ "reportNarrativePath",
1941
+ "approvalDecisionsPath",
1942
+ "designPreparationPath",
1943
+ "planBodyVerificationPath",
1907
1944
  "reportTemplatePath",
1908
1945
  )
1946
+ _V3_REPORT_PATH_KEYS = frozenset({
1947
+ "reportNarrativePath",
1948
+ "approvalDecisionsPath",
1949
+ "designPreparationPath",
1950
+ "planBodyVerificationPath",
1951
+ })
1909
1952
 
1910
1953
 
1911
1954
  def _guard_invocation_manifest_rewrite(
@@ -1951,6 +1994,10 @@ def _validate_invocation_manifest(payload: Mapping[str, object]) -> None:
1951
1994
  item
1952
1995
  for item in _IMMUTABLE_INVOCATION_KEYS[4:]
1953
1996
  if item != "workerPromptPathByWorkerId"
1997
+ and (
1998
+ item not in _V3_REPORT_PATH_KEYS
1999
+ or payload.get("reportContractVersion") == "3.0"
2000
+ )
1954
2001
  ):
1955
2002
  if not payload.get(key):
1956
2003
  raise ValueError(f"incomplete invocation manifest path: {key}")
@@ -2,7 +2,7 @@
2
2
 
3
3
  The JSON SSOT lives next to the rendered markdown as
4
4
  ``final-report-<task-type>-<seq>.data.json``. Its ``schemaVersion`` selects the
5
- matching final-report schema. Report-writer-worker writes the data.json in
5
+ matching final-report schema. Report assembly writes the data.json in
6
6
  Phase 6; this renderer + the matching Jinja2 template deterministically
7
7
  produce the canonical AI-facing markdown.
8
8
 
@@ -66,6 +66,7 @@ from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
66
66
 
67
67
  TEMPLATE_BY_SCHEMA_VERSION = {
68
68
  "2.0": ("templates", "reports", "final-report-v2.template.md"),
69
+ "3.0": ("templates", "reports", "final-report-v2.template.md"),
69
70
  }
70
71
  DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[CURRENT_REPORT_SCHEMA_VERSION]
71
72
 
@@ -601,8 +602,8 @@ def validate_report_language(data: dict) -> str:
601
602
  if candidate == "auto":
602
603
  raise FinalReportRenderError(
603
604
  "reportLanguage 'auto' must be resolved by the lead before "
604
- "the renderer runs; the report-writer worker writes the "
605
- "resolved 'en' or 'ko' into data.json.meta.reportLanguage."
605
+ "the renderer runs; report assembly copies the resolved 'en' or "
606
+ "'ko' from the run manifest into data.json.meta.reportLanguage."
606
607
  )
607
608
  if candidate not in SUPPORTED_LANGS:
608
609
  raise FinalReportRenderError(
@@ -757,9 +758,9 @@ def _with_excerpt_drift_hint(
757
758
  ) -> FinalReportRenderError:
758
759
  """Name the contract drift behind a schema failure, when that is the cause.
759
760
 
760
- A long run straddles its own runtime upgrade: the report-writer authors
761
- against the excerpt frozen into the bundle at prep time, while the renderer
762
- validates against the installed schema. Without this the author sees only
761
+ A long run straddles its own runtime upgrade: the report-writer authors its
762
+ narrative against the excerpt frozen into the bundle at prep time, while
763
+ assembly and the renderer validate against the installed schema. Without this the author sees only
763
764
  `additional property ... not allowed` for a field the excerpt told it to
764
765
  write, and has no way to tell a real mistake from a stale bundle.
765
766
 
@@ -0,0 +1,354 @@
1
+ """역할별 리포트 입력을 검증해 계약 3.0 정본을 한 번 게시한다."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Mapping
10
+
11
+ from .agent_activity import agent_activity_rows
12
+ from .final_report_schema import load_schema_version, validate
13
+ from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
14
+ from .report_narrative import parse_narrative
15
+ from .report_projections import (
16
+ ReportProjectionError,
17
+ project_convergence,
18
+ project_design,
19
+ project_execution,
20
+ project_token_usage,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AssemblyIssue:
26
+ owner: str
27
+ artifact_path: str
28
+ field_path: str
29
+ reason: str
30
+
31
+
32
+ class ReportAssemblyError(ValueError):
33
+ """게시 전 입력 검증 실패와 실제 소유자를 전달한다."""
34
+
35
+ def __init__(self, issue: AssemblyIssue) -> None:
36
+ self.issue = issue
37
+ super().__init__(
38
+ f"owner={issue.owner} artifactPath={issue.artifact_path} "
39
+ f"fieldPath={issue.field_path} reason={issue.reason}"
40
+ )
41
+
42
+
43
+ def _fail(owner: str, path: Path, field: str, reason: object) -> None:
44
+ raise ReportAssemblyError(AssemblyIssue(owner, str(path), field, str(reason)))
45
+
46
+
47
+ def _read_json(row: ReportInputPath) -> dict[str, Any]:
48
+ if not row.path.is_file():
49
+ _fail(row.owner, row.path, "$", "required input is missing")
50
+ try:
51
+ value = json.loads(row.path.read_text(encoding="utf-8"))
52
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
53
+ _fail(row.owner, row.path, "$", exc)
54
+ if not isinstance(value, dict):
55
+ _fail(row.owner, row.path, "$", "input must be a JSON object")
56
+ return value
57
+
58
+
59
+ def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
60
+ try:
61
+ return {row.key: row for row in report_input_paths(project_root, manifest)}
62
+ except ValueError as exc:
63
+ _fail("orchestrator", project_root, "manifest", exc)
64
+
65
+
66
+ def _narrative(row: ReportInputPath, schema: Mapping[str, Any]) -> dict[str, Any]:
67
+ if not row.path.is_file():
68
+ _fail(row.owner, row.path, "$", "required input is missing")
69
+ try:
70
+ return parse_narrative(row.path.read_text(encoding="utf-8"), schema)
71
+ except (OSError, UnicodeError, ValueError) as exc:
72
+ _fail(row.owner, row.path, "$", exc)
73
+
74
+
75
+ def _clarifications(
76
+ ledger: Mapping[str, Any], activities: list[dict[str, Any]], path: Path,
77
+ ) -> list[dict[str, Any]]:
78
+ if ledger.get("owner") != "lead":
79
+ _fail("lead", path, "owner", "approval ledger owner must be lead")
80
+ active = ledger.get("activeClarifications")
81
+ if not isinstance(active, list):
82
+ _fail("lead", path, "activeClarifications", "must be an array")
83
+ for index, row in enumerate(active):
84
+ if not isinstance(row, Mapping):
85
+ _fail("lead", path, f"activeClarifications[{index}]", "must be an object")
86
+ activity_by_id = {row.get("activityId"): row for row in activities}
87
+ return [
88
+ _clarification_row(row, activity_by_id, path)
89
+ for row in active
90
+ ]
91
+
92
+
93
+ def _clarification_row(
94
+ source: Mapping[str, Any], activities: Mapping[object, Mapping[str, Any]], path: Path,
95
+ ) -> dict[str, Any]:
96
+ row = {
97
+ key: source[key]
98
+ for key in (
99
+ "id", "ticketId", "kind", "statement", "expectedForm", "blocks",
100
+ "origin", "userConfirmation", "options",
101
+ )
102
+ if key in source
103
+ }
104
+ if "approval" in source:
105
+ row["approvalContext"] = source["approval"]
106
+ resolution = source.get("resolutionInput")
107
+ if not isinstance(resolution, Mapping):
108
+ row["status"] = "open"
109
+ return row
110
+ clarification_id = source.get("id")
111
+ check_refs = resolution.get("checkRefs")
112
+ if not isinstance(check_refs, list) or not check_refs:
113
+ _fail("lead", path, f"{clarification_id}.resolutionInput.checkRefs", "must not be empty")
114
+ for activity_id in check_refs:
115
+ activity = activities.get(activity_id)
116
+ if activity is None:
117
+ _fail("lead", path, f"{clarification_id}.resolutionInput.checkRefs", f"unknown activity {activity_id}")
118
+ if clarification_id not in (activity.get("clarificationRefs") or []):
119
+ _fail("activity-ledger", path, str(activity_id), f"missing clarificationRefs {clarification_id}")
120
+ row.update(
121
+ status="resolved",
122
+ userInput=resolution.get("userText", ""),
123
+ resolution={
124
+ "disposition": resolution.get("disposition"),
125
+ "userText": resolution.get("userText"),
126
+ "checkRefs": list(check_refs),
127
+ },
128
+ )
129
+ return row
130
+
131
+
132
+ def _attach_plan_backlinks(data: dict[str, Any], activities: list[dict[str, Any]]) -> None:
133
+ planning = data.get("implementationPlanning")
134
+ if not isinstance(planning, dict):
135
+ return
136
+ verification = planning.get("planBodyVerification")
137
+ if not isinstance(verification, dict):
138
+ return
139
+ refs_by_item: dict[str, set[str]] = {}
140
+ for activity in activities:
141
+ for item_id in activity.get("planItemIds") or []:
142
+ refs_by_item.setdefault(str(item_id), set()).update(
143
+ str(ref) for ref in activity.get("clarificationRefs") or []
144
+ )
145
+ for item in verification.get("planItems") or []:
146
+ if isinstance(item, dict):
147
+ item["clarificationRefs"] = sorted(refs_by_item.get(str(item.get("id")), set()))
148
+
149
+
150
+ def _compose(
151
+ project_root: Path, manifest_path: Path, manifest: Mapping[str, Any],
152
+ ) -> dict[str, Any]:
153
+ schema = load_schema_version("3.0")
154
+ inputs = _input_map(project_root, manifest)
155
+ narrative = _narrative(inputs["narrative"], schema)
156
+ ledger = _read_json(inputs["approval-decisions"])
157
+ team_state = _read_json(inputs["execution-status"])
158
+ convergence = _read_json(inputs["convergence"])
159
+ if not inputs["agent-activity"].path.is_file():
160
+ _fail("activity-ledger", inputs["agent-activity"].path, "$", "required input is missing")
161
+ try:
162
+ activities = list(agent_activity_rows(project_root, manifest_path))
163
+ except ValueError as exc:
164
+ _fail("activity-ledger", inputs["agent-activity"].path, "$", exc)
165
+ try:
166
+ convergence_data = project_convergence(convergence)
167
+ except ReportProjectionError as exc:
168
+ _fail("convergence", inputs["convergence"].path, "$", exc)
169
+ try:
170
+ execution = project_execution(manifest, team_state)
171
+ usage = project_token_usage(team_state)
172
+ except (TypeError, ValueError) as exc:
173
+ _fail("team-state", inputs["execution-status"].path, "$", exc)
174
+ data = {"schemaVersion": "3.0", **narrative, **execution}
175
+ data["tokenUsage"] = usage
176
+ data["crossVerification"] = convergence_data["crossVerification"]
177
+ data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
178
+ data["agentActivity"] = activities
179
+ data["evidence"] = {"primary": _promoted_evidence(convergence_data)}
180
+ data["missingInformation"] = []
181
+ _attach_metadata(data, manifest)
182
+ _attach_machine_planning(data, inputs)
183
+ _attach_plan_backlinks(data, activities)
184
+ return data
185
+
186
+
187
+ def _identity(manifest: Mapping[str, Any]) -> tuple[str, str, str]:
188
+ task_key = str(manifest.get("taskKey") or "")
189
+ parts = task_key.split(":")
190
+ if len(parts) >= 3:
191
+ return parts[0], parts[-2], parts[-1]
192
+ return (
193
+ str(manifest.get("projectId") or "unknown"),
194
+ str(manifest.get("taskGroup") or "unknown"),
195
+ str(manifest.get("taskId") or task_key or "unknown"),
196
+ )
197
+
198
+
199
+ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
200
+ project_id, task_group, task_id = _identity(manifest)
201
+ task_type = str(manifest.get("taskType") or "")
202
+ task_key = str(manifest.get("taskKey") or "")
203
+ created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
204
+ data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
205
+ clarifications = data.get("clarificationItems") or []
206
+ blocked = any(
207
+ isinstance(row, Mapping) and row.get("status") in {"open", "answered"}
208
+ for row in clarifications
209
+ )
210
+ frontmatter = {
211
+ "title": f"OKSTRA Final Report - {task_key}",
212
+ "id": task_key.replace(":", "-"),
213
+ "tags": ["final-report"],
214
+ "status": "in-progress" if blocked else "completed",
215
+ "aliases": [f"{task_id}-{task_type}"],
216
+ "date": created,
217
+ "taskId": task_id,
218
+ "taskGroup": task_group,
219
+ "projectId": project_id,
220
+ "taskType": task_type,
221
+ "workerId": "report-writer",
222
+ "approved": False,
223
+ }
224
+ planning = data.get("implementationPlanning")
225
+ if isinstance(planning, Mapping) and planning.get("planningContract") == "selected-direction":
226
+ selected = planning.get("selectedDirectionRef")
227
+ frontmatter["selectedDirectionRef"] = str(
228
+ selected.get("snapshotPath") if isinstance(selected, Mapping) else ""
229
+ )
230
+ else:
231
+ frontmatter["implementationOption"] = ""
232
+ data["frontmatter"] = frontmatter
233
+ data["header"] = {
234
+ "taskKey": task_key,
235
+ "createdAt": created,
236
+ "taskType": task_type,
237
+ "reportOwner": "Okstra lead",
238
+ "reportAuthor": "Report writer worker",
239
+ "leadModel": str(manifest.get("leadModel") or "unknown"),
240
+ "okstraVersion": str(manifest.get("okstraVersion") or "unknown"),
241
+ }
242
+
243
+
244
+ def _promoted_evidence(convergence: Mapping[str, Any]) -> list[dict[str, Any]]:
245
+ return [
246
+ {
247
+ "id": f"E-{index:03d}",
248
+ "ticketId": row["ticketId"],
249
+ "evidence": row["evidence"],
250
+ "sourceItems": row["sourceItems"],
251
+ "source": "convergence",
252
+ }
253
+ for index, row in enumerate(convergence.get("promotedEvidence") or [], start=1)
254
+ ]
255
+
256
+
257
+ def _attach_machine_planning(
258
+ data: dict[str, Any], inputs: Mapping[str, ReportInputPath],
259
+ ) -> None:
260
+ planning = data.get("implementationPlanning")
261
+ if not isinstance(planning, dict):
262
+ return
263
+ design_row = inputs.get("design-preparation")
264
+ if design_row is None:
265
+ return
266
+ try:
267
+ design = project_design(planning, _read_json(design_row))
268
+ except ReportProjectionError as exc:
269
+ _fail(design_row.owner, design_row.path, "$", exc)
270
+ planning["designPreparation"] = design["designPreparation"]
271
+ stages = {row.get("stage"): row for row in planning.get("stages") or [] if isinstance(row, dict)}
272
+ for coverage in design["stageCoverage"]:
273
+ stage = stages.get(coverage.get("stage"))
274
+ if stage is not None:
275
+ stage["designSurfaceCoverage"] = coverage.get("rows") or []
276
+ verification_row = inputs.get("plan-body-verification")
277
+ if verification_row is None:
278
+ return
279
+ verification = _read_json(verification_row)
280
+ if verification.get("owner") != "convergence":
281
+ _fail(
282
+ verification_row.owner, verification_row.path, "owner",
283
+ "plan-body verification owner must be convergence",
284
+ )
285
+ projection = verification.get("planBodyVerification")
286
+ if not isinstance(projection, Mapping):
287
+ _fail(
288
+ verification_row.owner, verification_row.path,
289
+ "planBodyVerification", "must be an object",
290
+ )
291
+ planning["planBodyVerification"] = dict(projection)
292
+
293
+
294
+ def _schema_owner(error: str) -> str:
295
+ field = error.split(" ", 1)[0]
296
+ owners = {
297
+ "executionStatus": "team-state",
298
+ "tokenUsage": "team-state",
299
+ "crossVerification": "convergence",
300
+ "clarificationItems": "lead",
301
+ "agentActivity": "activity-ledger",
302
+ "executionRoles": "run-manifest",
303
+ "frontmatter": "orchestrator",
304
+ "header": "orchestrator",
305
+ "meta": "orchestrator",
306
+ }
307
+ if "designPreparation" in error or "designSurfaceCoverage" in error:
308
+ return "design-surface-detector"
309
+ if "planBodyVerification" in error:
310
+ return "convergence"
311
+ if "selectedDirectionRef" in error:
312
+ return "report-writer"
313
+ return owners.get(field.split(".", 1)[0], "report-writer")
314
+
315
+
316
+ def _publish(
317
+ target: Path, data: Mapping[str, Any], replace: Callable[[str, str], None],
318
+ ) -> None:
319
+ target.parent.mkdir(parents=True, exist_ok=True)
320
+ descriptor, name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
321
+ try:
322
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
323
+ json.dump(data, handle, ensure_ascii=False, indent=2)
324
+ handle.write("\n")
325
+ handle.flush()
326
+ os.fsync(handle.fileno())
327
+ replace(name, str(target))
328
+ finally:
329
+ if os.path.exists(name):
330
+ os.unlink(name)
331
+
332
+
333
+ def assemble_report(
334
+ project_root: Path, manifest_path: Path, *,
335
+ replace: Callable[[str, str], None] = os.replace,
336
+ ) -> dict[str, Any]:
337
+ """모든 입력이 유효할 때만 계약 3.0 정본을 한 번 교체한다."""
338
+ manifest_row = ReportInputPath("run-manifest", "orchestrator", manifest_path)
339
+ manifest = _read_json(manifest_row)
340
+ if not uses_report_contract_v3(manifest):
341
+ _fail("orchestrator", manifest_path, "reportContractVersion", "requires 3.0")
342
+ data = _compose(project_root, manifest_path, manifest)
343
+ errors = validate(data, load_schema_version("3.0"))
344
+ if errors:
345
+ target = Path(str(manifest.get("expectedReportRecordPath") or "data.json"))
346
+ target = target if target.is_absolute() else project_root / target
347
+ _fail(_schema_owner(errors[0]), target, errors[0].split(" ", 1)[0], errors[0])
348
+ value = manifest.get("expectedReportRecordPath")
349
+ if not isinstance(value, str) or not value:
350
+ _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
351
+ target = Path(value)
352
+ target = target if target.is_absolute() else project_root / target
353
+ _publish(target, data, replace)
354
+ return data
@@ -2,7 +2,8 @@
2
2
  from __future__ import annotations
3
3
 
4
4
 
5
- CURRENT_REPORT_SCHEMA_VERSION = "2.0"
5
+ CURRENT_REPORT_SCHEMA_VERSION = "3.0"
6
+ LATEST_REPORT_SCHEMA_VERSION = "3.0"
6
7
 
7
8
  PUBLIC_REPORT_TASK_TYPES = (
8
9
  "requirements-discovery",