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.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +14 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +10 -10
- package/docs/cli.md +11 -8
- package/docs/project-structure-overview.md +15 -6
- package/docs/task-process/implementation-planning.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/prompts/launch.template.md +6 -5
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
- package/runtime/prompts/lead/plan-body-verification.md +39 -18
- package/runtime/prompts/lead/report-writer.md +64 -423
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +13 -11
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +23 -3
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
- package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
- package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
- package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
- package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +12 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/render.py +48 -1
- package/runtime/python/okstra_ctl/render_final_report.py +7 -6
- package/runtime/python/okstra_ctl/report_assembly.py +354 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +60 -22
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_markdown.py +69 -8
- package/runtime/python/okstra_ctl/report_narrative.py +319 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/run.py +25 -9
- package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
- package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +70 -22
- package/runtime/python/okstra_ctl/team.py +1 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/templates/report-writer-prompt-preamble.md +15 -21
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- 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
|
|
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
|
|
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;
|
|
605
|
-
"
|
|
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
|
|
762
|
-
|
|
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
|