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