okstra 0.180.0 → 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/dist/cli-registry.mjs +16 -2
- package/dist/cli-registry.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/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.md +11 -0
- package/docs/cli.md +26 -13
- 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 +29 -26
- 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 +3 -3
- 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 +3 -7
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +8 -6
- package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
- package/runtime/prompts/lead/plan-body-verification.md +187 -14
- package/runtime/prompts/lead/report-writer.md +20 -1
- package/runtime/prompts/lead/team-contract.md +8 -53
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.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/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -2
- package/runtime/prompts/profiles/release-handoff.md +0 -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 +6 -1
- 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/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 +118 -35
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
- package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
- 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/dispatch_core.py +35 -65
- package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
- 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/final_report_schema.py +2 -2
- 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 +2 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
- 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/paths.py +9 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
- 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 +26 -15
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +172 -17
- package/runtime/python/okstra_ctl/report_finalize.py +7 -10
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +13 -1
- package/runtime/python/okstra_ctl/report_narrative.py +40 -8
- 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 +159 -56
- 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 +4 -2
- 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 +4 -2
- package/runtime/python/okstra_ctl/stage_map.py +26 -6
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +2 -1
- 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_prompt_body.py +18 -7
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- 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 +9 -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 +17 -1
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +379 -63
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -13,12 +13,14 @@ CLI 는 갈라 읽어야 한다.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
-
from dataclasses import dataclass
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
|
|
19
19
|
|
|
20
20
|
from .worker_stream import (
|
|
21
21
|
Normalise,
|
|
22
|
+
StreamEvent,
|
|
23
|
+
Text,
|
|
22
24
|
final_text,
|
|
23
25
|
format_live,
|
|
24
26
|
format_log,
|
|
@@ -33,16 +35,22 @@ WORKER = "worker"
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
class TranscriptWriter(Protocol):
|
|
36
|
-
"""세션 기록에
|
|
38
|
+
"""세션 기록에 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
|
|
37
39
|
|
|
38
40
|
def write(self, speaker: str, line: str) -> None: ...
|
|
39
41
|
|
|
42
|
+
def write_event(
|
|
43
|
+
self, speaker: str, *, screen: list[str], archive: list[str]
|
|
44
|
+
) -> None: ...
|
|
45
|
+
|
|
40
46
|
|
|
41
47
|
@runtime_checkable
|
|
42
48
|
class Presentation(Protocol):
|
|
43
49
|
def merges_stderr(self) -> bool: ...
|
|
44
50
|
|
|
45
|
-
def sinks(self, writer: TranscriptWriter
|
|
51
|
+
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]: ...
|
|
52
|
+
|
|
53
|
+
def flush(self, writer: TranscriptWriter) -> None: ...
|
|
46
54
|
|
|
47
55
|
|
|
48
56
|
@dataclass(frozen=True)
|
|
@@ -58,13 +66,16 @@ class MergedText:
|
|
|
58
66
|
def merges_stderr(self) -> bool:
|
|
59
67
|
return True
|
|
60
68
|
|
|
61
|
-
def sinks(self, writer: TranscriptWriter
|
|
69
|
+
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
62
70
|
def emit(line: str) -> str | None:
|
|
63
71
|
writer.write(WORKER, line)
|
|
64
72
|
return line
|
|
65
73
|
|
|
66
74
|
return (("stdout", emit),)
|
|
67
75
|
|
|
76
|
+
def flush(self, writer: TranscriptWriter) -> None:
|
|
77
|
+
return
|
|
78
|
+
|
|
68
79
|
|
|
69
80
|
@dataclass(frozen=True)
|
|
70
81
|
class SplitText:
|
|
@@ -77,7 +88,7 @@ class SplitText:
|
|
|
77
88
|
def merges_stderr(self) -> bool:
|
|
78
89
|
return False
|
|
79
90
|
|
|
80
|
-
def sinks(self, writer: TranscriptWriter
|
|
91
|
+
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
81
92
|
def result(line: str) -> str | None:
|
|
82
93
|
writer.write(WORKER, line)
|
|
83
94
|
return line
|
|
@@ -88,6 +99,9 @@ class SplitText:
|
|
|
88
99
|
|
|
89
100
|
return (("stdout", result), ("stderr", progress))
|
|
90
101
|
|
|
102
|
+
def flush(self, writer: TranscriptWriter) -> None:
|
|
103
|
+
return
|
|
104
|
+
|
|
91
105
|
|
|
92
106
|
@dataclass(frozen=True)
|
|
93
107
|
class JsonEvents:
|
|
@@ -99,11 +113,16 @@ class JsonEvents:
|
|
|
99
113
|
|
|
100
114
|
normalise: Normalise
|
|
101
115
|
observe: ObserveServedModel
|
|
116
|
+
# 어떤 CLI 의 streaming-json 은 문장이 아니라 토큰을 한 줄씩 보낸다. 붙이지
|
|
117
|
+
# 않으면 pane 이 한 글자 한 줄이 된다. 기본은 끄고, 그런 CLI 의 어댑터가 켠다 —
|
|
118
|
+
# 어느 CLI 가 그런지는 이 계층이 알 필요도, 알아서도 안 되는 사실이다.
|
|
119
|
+
join_adjacent_text: bool = False
|
|
120
|
+
_pending_text: list[str] = field(default_factory=list, repr=False, compare=False)
|
|
102
121
|
|
|
103
122
|
def merges_stderr(self) -> bool:
|
|
104
123
|
return True
|
|
105
124
|
|
|
106
|
-
def sinks(self, writer: TranscriptWriter
|
|
125
|
+
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
107
126
|
def emit(line: str) -> str | None:
|
|
108
127
|
stripped = line.strip()
|
|
109
128
|
if not stripped:
|
|
@@ -113,6 +132,7 @@ class JsonEvents:
|
|
|
113
132
|
except ValueError:
|
|
114
133
|
# 이벤트가 아니다. CLI 자신의 오류 텍스트가 이 스트림으로
|
|
115
134
|
# 오므로 삼키면 아무도 못 본다.
|
|
135
|
+
self._publish_text(writer)
|
|
116
136
|
writer.write(WORKER, stripped)
|
|
117
137
|
return None
|
|
118
138
|
if not isinstance(event, dict):
|
|
@@ -120,9 +140,50 @@ class JsonEvents:
|
|
|
120
140
|
self.observe(event)
|
|
121
141
|
closing: str | None = None
|
|
122
142
|
for entry in self.normalise(event):
|
|
123
|
-
|
|
124
|
-
writer.write(WORKER, row)
|
|
125
|
-
closing = final_text(entry) or closing
|
|
143
|
+
closing = self._write_entry(writer, entry) or closing
|
|
126
144
|
return closing
|
|
127
145
|
|
|
128
146
|
return (("stdout", emit),)
|
|
147
|
+
|
|
148
|
+
def flush(self, writer: TranscriptWriter) -> None:
|
|
149
|
+
self._publish_text(writer)
|
|
150
|
+
|
|
151
|
+
def _write_entry(
|
|
152
|
+
self, writer: TranscriptWriter, entry: StreamEvent
|
|
153
|
+
) -> str | None:
|
|
154
|
+
if isinstance(entry, Text) and self.join_adjacent_text:
|
|
155
|
+
self._pending_text.append(entry.body)
|
|
156
|
+
self._publish_complete_lines(writer)
|
|
157
|
+
return None
|
|
158
|
+
self._publish_text(writer)
|
|
159
|
+
# 두 투영을 고르는 것이 아니라 둘 다 보낸다. 화면은 요약, 기록은
|
|
160
|
+
# 본문까지. 고르던 동안에는 pane 이 붙은 run 의 기록에서 도구 결과
|
|
161
|
+
# 본문이 통째로 사라졌다.
|
|
162
|
+
writer.write_event(
|
|
163
|
+
WORKER,
|
|
164
|
+
screen=format_live(entry),
|
|
165
|
+
archive=format_log(entry),
|
|
166
|
+
)
|
|
167
|
+
return final_text(entry)
|
|
168
|
+
|
|
169
|
+
def _publish_complete_lines(self, writer: TranscriptWriter) -> None:
|
|
170
|
+
combined = "".join(self._pending_text)
|
|
171
|
+
if "\n" not in combined:
|
|
172
|
+
return
|
|
173
|
+
*complete, remainder = combined.split("\n")
|
|
174
|
+
self._pending_text.clear()
|
|
175
|
+
if remainder:
|
|
176
|
+
self._pending_text.append(remainder)
|
|
177
|
+
self._write_text_rows(writer, "\n".join(complete))
|
|
178
|
+
|
|
179
|
+
def _publish_text(self, writer: TranscriptWriter) -> None:
|
|
180
|
+
combined = "".join(self._pending_text)
|
|
181
|
+
self._pending_text.clear()
|
|
182
|
+
self._write_text_rows(writer, combined)
|
|
183
|
+
|
|
184
|
+
def _write_text_rows(self, writer: TranscriptWriter, body: str) -> None:
|
|
185
|
+
event = Text(body=body)
|
|
186
|
+
screen = format_live(event)
|
|
187
|
+
archive = format_log(event)
|
|
188
|
+
if screen or archive:
|
|
189
|
+
writer.write_event(WORKER, screen=screen, archive=archive)
|
|
@@ -16,6 +16,7 @@ from ..agent_invocation import (
|
|
|
16
16
|
verify_agent_invocation,
|
|
17
17
|
)
|
|
18
18
|
from ..dispatch_state import DispatchError, record_verified_agent_dispatch
|
|
19
|
+
from ..json_boundary import JsonBoundaryError, load_owned_object
|
|
19
20
|
|
|
20
21
|
from ..application.start_run import start_run
|
|
21
22
|
from ..application.resume_run import resume_run
|
|
@@ -108,7 +109,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
108
109
|
resolve = commands.add_parser("resolve")
|
|
109
110
|
resolve.add_argument("--requested", required=True)
|
|
110
111
|
resolve.add_argument("--command", dest="runtime_command", required=True)
|
|
111
|
-
resolve.add_argument("--
|
|
112
|
+
resolve.add_argument("--environment-host", default="")
|
|
113
|
+
resolve.add_argument(
|
|
114
|
+
"--capability",
|
|
115
|
+
action="append",
|
|
116
|
+
choices=("claude-skill-handoff", "tmux"),
|
|
117
|
+
default=[],
|
|
118
|
+
)
|
|
112
119
|
launch = commands.add_parser("launch-plan")
|
|
113
120
|
_add_session_plan_args(launch)
|
|
114
121
|
launch.add_argument("--prepared-launch-json", default="")
|
|
@@ -152,7 +159,9 @@ def _command_payload(
|
|
|
152
159
|
if args.command == "catalog":
|
|
153
160
|
return catalog_payload(registry)
|
|
154
161
|
if args.command == "resolve":
|
|
155
|
-
context =
|
|
162
|
+
context = HostResolutionContext(
|
|
163
|
+
args.environment_host, frozenset(args.capability)
|
|
164
|
+
)
|
|
156
165
|
try:
|
|
157
166
|
return resolve_payload(registry, args.requested, context)
|
|
158
167
|
except HostUnavailable as exc:
|
|
@@ -215,31 +224,6 @@ def _probe_paths(project_root: str, home_dir: str):
|
|
|
215
224
|
os.environ[key] = value
|
|
216
225
|
|
|
217
226
|
|
|
218
|
-
def _parse_resolution_context(raw_context: str) -> HostResolutionContext:
|
|
219
|
-
try:
|
|
220
|
-
raw = json.loads(raw_context)
|
|
221
|
-
except json.JSONDecodeError as exc:
|
|
222
|
-
raise ValueError("invalid host resolution context JSON") from exc
|
|
223
|
-
if not isinstance(raw, dict):
|
|
224
|
-
raise ValueError("invalid host resolution context JSON")
|
|
225
|
-
env = raw.get("env", {})
|
|
226
|
-
capabilities = raw.get("capabilities", {})
|
|
227
|
-
if not isinstance(env, dict) or not isinstance(capabilities, dict):
|
|
228
|
-
raise ValueError("invalid host resolution context JSON")
|
|
229
|
-
explicit_env_host = env.get("OKSTRA_RUNTIME_HOST", "")
|
|
230
|
-
if not isinstance(explicit_env_host, str):
|
|
231
|
-
raise ValueError("invalid host resolution context JSON")
|
|
232
|
-
signals = frozenset(
|
|
233
|
-
signal
|
|
234
|
-
for capability, signal in (
|
|
235
|
-
("claudeSkillHandoff", "claude-skill-handoff"),
|
|
236
|
-
("tmuxAvailable", "tmux"),
|
|
237
|
-
)
|
|
238
|
-
if capabilities.get(capability) is True
|
|
239
|
-
)
|
|
240
|
-
return HostResolutionContext(explicit_env_host, signals)
|
|
241
|
-
|
|
242
|
-
|
|
243
227
|
def _unclaimed_host_message(registry: HostAdapterRegistry, command: str) -> str:
|
|
244
228
|
runtime_flag = "--runtime" if command in {
|
|
245
229
|
"install", "ensure-installed", "doctor"
|
|
@@ -336,17 +320,19 @@ def _launch_request(
|
|
|
336
320
|
run_manifest_path = Path(run_manifest_value)
|
|
337
321
|
metadata_path = Path(metadata_value)
|
|
338
322
|
try:
|
|
339
|
-
manifest =
|
|
323
|
+
manifest = load_owned_object(run_manifest_path, artifact="run manifest")
|
|
340
324
|
resources = manifest["resources"]
|
|
341
325
|
assignment = agent_model_assignment_from_payload(
|
|
342
326
|
manifest["invocationAssignments"]["lead"]
|
|
343
327
|
)
|
|
344
328
|
prompt_path = root / resources["leadExecutionPromptPath"]
|
|
345
329
|
canonical_metadata = root / resources["leadPromptMetadataPath"]
|
|
346
|
-
metadata =
|
|
330
|
+
metadata = load_owned_object(
|
|
331
|
+
canonical_metadata, artifact="lead prompt metadata"
|
|
332
|
+
)
|
|
347
333
|
if metadata["prompt"]["path"] != resources["leadExecutionPromptPath"]:
|
|
348
334
|
raise KeyError("lead prompt resource does not match metadata")
|
|
349
|
-
except (OSError,
|
|
335
|
+
except (OSError, JsonBoundaryError, KeyError, TypeError, AgentInvocationError) as exc:
|
|
350
336
|
raise ValueError("lead invocation authority is incomplete") from exc
|
|
351
337
|
if metadata_path.resolve(strict=False) != canonical_metadata.resolve(strict=False):
|
|
352
338
|
raise ValueError("prepared lead metadata does not match run manifest")
|
|
@@ -15,6 +15,7 @@ import os
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from .models import provider_ids
|
|
18
|
+
from .json_boundary import external_error_sidecar_source, load_external_json
|
|
18
19
|
|
|
19
20
|
STDERR_EXCERPT_MAX_BYTES = 2048
|
|
20
21
|
TRUNCATION_SUFFIX = "...[truncated]"
|
|
@@ -240,6 +241,31 @@ def append_observed(
|
|
|
240
241
|
return rec
|
|
241
242
|
|
|
242
243
|
|
|
244
|
+
def _worker_sidecar_record(entry, *, recorded_at, task_key, agent, agent_role, model, identity):
|
|
245
|
+
error_type = entry.get("errorType")
|
|
246
|
+
if error_type not in ALLOWED_ERROR_TYPES:
|
|
247
|
+
raise ValueError(f"invalid errorType in sidecar: {error_type!r}")
|
|
248
|
+
rec = {
|
|
249
|
+
"ts": entry.get("ts"), "recordedAt": recorded_at,
|
|
250
|
+
"taskKey": task_key,
|
|
251
|
+
"phase": str(entry.get("phase")) if entry.get("phase") is not None else None,
|
|
252
|
+
"agent": agent, "agentRole": agent_role, "model": model,
|
|
253
|
+
"source": "worker-reported", "errorType": error_type,
|
|
254
|
+
"command": entry.get("command"), "commandKind": entry.get("commandKind"),
|
|
255
|
+
"exitCode": entry.get("exitCode"), "durationMs": entry.get("durationMs"),
|
|
256
|
+
"message": entry.get("message"),
|
|
257
|
+
"stderrExcerpt": truncate_stderr(entry.get("stderrExcerpt")),
|
|
258
|
+
"context": normalize_cause_context(
|
|
259
|
+
entry.get("context"), message=entry.get("message")
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
from okstra_ctl.execution_identity import stored_identity
|
|
263
|
+
|
|
264
|
+
rec.update(stored_identity(identity))
|
|
265
|
+
rec.update(stored_identity(entry))
|
|
266
|
+
return rec
|
|
267
|
+
|
|
268
|
+
|
|
243
269
|
def dump_from_worker_sidecar(
|
|
244
270
|
*,
|
|
245
271
|
sidecar_path,
|
|
@@ -276,42 +302,21 @@ def dump_from_worker_sidecar(
|
|
|
276
302
|
p = Path(sidecar_path)
|
|
277
303
|
if not p.exists():
|
|
278
304
|
return 0
|
|
279
|
-
|
|
305
|
+
# 외부 입력: 레거시 자동화가 제공하는 오류 sidecar 호환 경로다.
|
|
306
|
+
payload = load_external_json(
|
|
307
|
+
external_error_sidecar_source(p),
|
|
308
|
+
artifact="legacy error sidecar",
|
|
309
|
+
)
|
|
280
310
|
schema = payload.get("schemaVersion")
|
|
281
311
|
if schema not in SUPPORTED_SIDECAR_SCHEMA_VERSIONS:
|
|
282
312
|
raise ValueError(f"unsupported sidecar schemaVersion: {schema!r}")
|
|
283
|
-
entries = payload.get("errors") or []
|
|
284
313
|
recorded_at = _iso(now or _now_utc())
|
|
285
314
|
count = 0
|
|
286
|
-
for
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
entry_context = normalize_cause_context(
|
|
291
|
-
e.get("context"), message=e.get("message")
|
|
315
|
+
for entry in payload.get("errors") or []:
|
|
316
|
+
rec = _worker_sidecar_record(
|
|
317
|
+
entry, recorded_at=recorded_at, task_key=task_key, agent=agent,
|
|
318
|
+
agent_role=agent_role, model=model, identity=identity,
|
|
292
319
|
)
|
|
293
|
-
rec = {
|
|
294
|
-
"ts": e.get("ts"),
|
|
295
|
-
"recordedAt": recorded_at,
|
|
296
|
-
"taskKey": task_key,
|
|
297
|
-
"phase": str(e.get("phase")) if e.get("phase") is not None else None,
|
|
298
|
-
"agent": agent,
|
|
299
|
-
"agentRole": agent_role,
|
|
300
|
-
"model": model,
|
|
301
|
-
"source": "worker-reported",
|
|
302
|
-
"errorType": et,
|
|
303
|
-
"command": e.get("command"),
|
|
304
|
-
"commandKind": e.get("commandKind"),
|
|
305
|
-
"exitCode": e.get("exitCode"),
|
|
306
|
-
"durationMs": e.get("durationMs"),
|
|
307
|
-
"message": e.get("message"),
|
|
308
|
-
"stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
|
|
309
|
-
"context": entry_context,
|
|
310
|
-
}
|
|
311
|
-
from okstra_ctl.execution_identity import stored_identity
|
|
312
|
-
|
|
313
|
-
rec.update(stored_identity(identity))
|
|
314
|
-
rec.update(stored_identity(e))
|
|
315
320
|
append_jsonl_line(out_path, rec)
|
|
316
321
|
count += 1
|
|
317
322
|
return count
|
|
@@ -12,6 +12,8 @@ import json
|
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
from .fixed_text import line
|
|
16
|
+
|
|
15
17
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
16
18
|
from okstra_project import read_task_key
|
|
17
19
|
from okstra_ctl.error_log_core import (
|
|
@@ -187,6 +189,27 @@ def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> di
|
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
|
|
192
|
+
def render_result_text(result: dict) -> str:
|
|
193
|
+
totals = result.get("totals") if isinstance(result.get("totals"), dict) else {}
|
|
194
|
+
parts = ["# Okstra Error Report Input\n\n", line("Task key", result.get("taskKey")), line("Report path", result.get("reportPath")), line("Total errors", totals.get("errorCount")), line("Run count", totals.get("runCount")), line("Parse skipped", result.get("parseSkipped"))]
|
|
195
|
+
for key, label in (("byErrorType", "Error type"), ("bySource", "Source")):
|
|
196
|
+
values = totals.get(key)
|
|
197
|
+
if isinstance(values, dict):
|
|
198
|
+
for name, count in sorted(values.items()):
|
|
199
|
+
parts.append(line(f"{label} {name}", count))
|
|
200
|
+
for key, label in (("byPhase", "Phase"), ("byAgent", "Agent")):
|
|
201
|
+
values = result.get(key)
|
|
202
|
+
if isinstance(values, dict):
|
|
203
|
+
for name, count in sorted(values.items()):
|
|
204
|
+
parts.append(line(f"{label} {name}", count))
|
|
205
|
+
elif isinstance(values, list):
|
|
206
|
+
identity = "phase" if key == "byPhase" else "agent"
|
|
207
|
+
for row in values:
|
|
208
|
+
if isinstance(row, dict):
|
|
209
|
+
parts.append(line(f"{label} {row.get(identity)}", row.get("count")))
|
|
210
|
+
return "".join(parts)
|
|
211
|
+
|
|
212
|
+
|
|
190
213
|
def main(argv: list[str] | None = None) -> int:
|
|
191
214
|
parser = argparse.ArgumentParser(
|
|
192
215
|
description="Aggregate okstra-run error logs into a task-level report."
|
|
@@ -194,11 +217,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
194
217
|
parser.add_argument("target", help="task root path or task-key")
|
|
195
218
|
parser.add_argument("--project-root", default="", help="project root for task-key lookup")
|
|
196
219
|
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
220
|
+
parser.add_argument("--text", action="store_true", help="emit fixed text fields")
|
|
197
221
|
args = parser.parse_args(argv)
|
|
198
222
|
|
|
199
223
|
task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
|
|
200
224
|
result = build_and_write(task_root, project_root, dt.datetime.now(dt.timezone.utc))
|
|
201
|
-
|
|
225
|
+
output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
|
|
226
|
+
print(output, end="" if args.text else "\n")
|
|
202
227
|
return 0
|
|
203
228
|
|
|
204
229
|
|
|
@@ -13,10 +13,13 @@ import sys
|
|
|
13
13
|
import zipfile
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
from .fixed_text import line
|
|
17
|
+
|
|
16
18
|
from okstra_ctl.error_log_core import aggregate, parse_records
|
|
17
19
|
from okstra_ctl.jsonl import read_jsonl
|
|
18
20
|
from okstra_ctl.locks import central_lock
|
|
19
21
|
from okstra_ctl.paths import okstra_home, resolve_under_root
|
|
22
|
+
from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
def run_dirs(home: Path) -> list[tuple[str, Path, dict]]:
|
|
@@ -262,9 +265,10 @@ _CONFIG_NAME = "error-zip.json"
|
|
|
262
265
|
|
|
263
266
|
def remember_output_path(home: Path, out_path: Path) -> None:
|
|
264
267
|
cfg = home / _CONFIG_NAME
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
write_owned_object_atomic(
|
|
269
|
+
cfg,
|
|
270
|
+
{"lastOutputPath": str(out_path)},
|
|
271
|
+
artifact="error zip configuration",
|
|
268
272
|
)
|
|
269
273
|
|
|
270
274
|
|
|
@@ -273,7 +277,11 @@ def last_output_path(home: Path) -> str:
|
|
|
273
277
|
if not cfg.is_file():
|
|
274
278
|
return ""
|
|
275
279
|
try:
|
|
276
|
-
return str(
|
|
280
|
+
return str(
|
|
281
|
+
load_owned_object(cfg, artifact="error zip configuration").get(
|
|
282
|
+
"lastOutputPath", ""
|
|
283
|
+
)
|
|
284
|
+
)
|
|
277
285
|
except Exception:
|
|
278
286
|
return ""
|
|
279
287
|
|
|
@@ -315,16 +323,30 @@ def build_zip(home: Path, out_path: Path, now: dt.datetime) -> dict:
|
|
|
315
323
|
}
|
|
316
324
|
|
|
317
325
|
|
|
326
|
+
def render_result_text(result: dict) -> str:
|
|
327
|
+
labels = (
|
|
328
|
+
("outPath", "Output zip"), ("errorCount", "Total errors"),
|
|
329
|
+
("runCount", "Run count"), ("unreachableRuns", "Unreachable runs"),
|
|
330
|
+
("clusterCount", "Cluster count"), ("projectCount", "Project count"),
|
|
331
|
+
)
|
|
332
|
+
lines = ["# Okstra Error Zip Result\n\n"]
|
|
333
|
+
for key, label in labels:
|
|
334
|
+
lines.append(line(label, result.get(key)))
|
|
335
|
+
return "".join(lines)
|
|
336
|
+
|
|
337
|
+
|
|
318
338
|
def main(argv: list[str] | None = None) -> int:
|
|
319
339
|
parser = argparse.ArgumentParser(
|
|
320
340
|
prog="okstra error-zip",
|
|
321
341
|
description="cross-project okstra 에러를 수집·익명화해 zip 으로 묶는다.",
|
|
322
342
|
)
|
|
323
343
|
parser.add_argument("--out", required=True, help="출력 .zip 절대/상대 경로")
|
|
344
|
+
parser.add_argument("--text", action="store_true", help="emit fixed text fields")
|
|
324
345
|
args = parser.parse_args(argv)
|
|
325
346
|
home = okstra_home()
|
|
326
347
|
result = build_zip(home, Path(args.out), dt.datetime.now(dt.timezone.utc))
|
|
327
|
-
|
|
348
|
+
output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
|
|
349
|
+
print(output, end="" if args.text else "\n")
|
|
328
350
|
return 0
|
|
329
351
|
|
|
330
352
|
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
from .domain.provider import HostModelBinding, ServedModelAttestation
|
|
12
12
|
from .domain.worker_exec import WorkerWriteCapability
|
|
13
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
V2_SCHEMA_VERSION = "2.0"
|
|
@@ -479,8 +480,8 @@ def _capability_payload(
|
|
|
479
480
|
|
|
480
481
|
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
481
482
|
try:
|
|
482
|
-
payload =
|
|
483
|
-
except
|
|
483
|
+
payload = load_owned_object(path, artifact="execution identity")
|
|
484
|
+
except JsonBoundaryError as exc:
|
|
484
485
|
raise ExecutionManifestError(f"cannot read execution identity: {path}") from exc
|
|
485
486
|
if not isinstance(payload, dict):
|
|
486
487
|
raise ExecutionManifestError("execution identity surface must be an object")
|
|
@@ -27,6 +27,7 @@ from .execution_identity import (
|
|
|
27
27
|
validate_task_role_execution_index,
|
|
28
28
|
)
|
|
29
29
|
from .final_report_schema import SchemaError, load_schema, validate as validate_schema
|
|
30
|
+
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
30
31
|
from .run_context import task_mutex
|
|
31
32
|
from .write_policy import (
|
|
32
33
|
WritePolicyError,
|
|
@@ -1257,8 +1258,8 @@ def _required_text(row: Mapping[str, Any], key: str) -> str:
|
|
|
1257
1258
|
|
|
1258
1259
|
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
1259
1260
|
try:
|
|
1260
|
-
payload =
|
|
1261
|
-
except (OSError,
|
|
1261
|
+
payload = load_owned_object(path, artifact="execution manifest")
|
|
1262
|
+
except (OSError, JsonBoundaryError) as exc:
|
|
1262
1263
|
raise ExecutionManifestError(f"cannot read JSON object: {path}") from exc
|
|
1263
1264
|
if not isinstance(payload, dict):
|
|
1264
1265
|
raise ExecutionManifestError(f"JSON surface must be an object: {path}")
|
|
@@ -1272,14 +1273,16 @@ def _write_json_atomic(
|
|
|
1272
1273
|
create_only: bool = False,
|
|
1273
1274
|
) -> None:
|
|
1274
1275
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1276
|
+
serialized = serialize_owned_object(
|
|
1277
|
+
path, payload, artifact="execution manifest"
|
|
1278
|
+
)
|
|
1275
1279
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
1276
1280
|
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
1277
1281
|
)
|
|
1278
1282
|
temporary = Path(temporary_name)
|
|
1279
1283
|
try:
|
|
1280
1284
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
1281
|
-
|
|
1282
|
-
stream.write("\n")
|
|
1285
|
+
stream.write(serialized)
|
|
1283
1286
|
stream.flush()
|
|
1284
1287
|
os.fsync(stream.fileno())
|
|
1285
1288
|
if create_only:
|
|
@@ -274,7 +274,7 @@ SCHEMA_FILENAMES = {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
|
|
277
|
-
def
|
|
277
|
+
def load_named_schema(filename: str, start: Path | None = None) -> dict:
|
|
278
278
|
here = (start or Path(__file__)).resolve()
|
|
279
279
|
if here.is_file():
|
|
280
280
|
here = here.parent
|
|
@@ -291,7 +291,7 @@ def load_schema_version(version: str, start: Path | None = None) -> dict:
|
|
|
291
291
|
filename = SCHEMA_FILENAMES[version]
|
|
292
292
|
except KeyError as exc:
|
|
293
293
|
raise SchemaError(f"unsupported final-report schemaVersion: {version}") from exc
|
|
294
|
-
return
|
|
294
|
+
return load_named_schema(filename, start=start)
|
|
295
295
|
|
|
296
296
|
|
|
297
297
|
def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
|
|
@@ -20,6 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
from typing import Any, Dict, List, Optional
|
|
21
21
|
|
|
22
22
|
from .run_context import dir_flock
|
|
23
|
+
from .jsonl import append_jsonl
|
|
23
24
|
|
|
24
25
|
FIX_CYCLES_FILENAME = "fix-cycles.jsonl"
|
|
25
26
|
_LOCK_FILENAME = ".fix-cycles.lock"
|
|
@@ -122,8 +123,7 @@ def append_closed(task_root: Path, *, cycle: str, closed_by: str,
|
|
|
122
123
|
def _append_row(task_root: Path, record: Dict[str, Any]) -> None:
|
|
123
124
|
p = fix_cycles_path(task_root)
|
|
124
125
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
-
|
|
126
|
-
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
126
|
+
append_jsonl(p, record, ensure_ascii=False, compact=False)
|
|
127
127
|
|
|
128
128
|
|
|
129
129
|
def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""모델용 고정 텍스트 투영의 scalar 줄 형식을 공유한다."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def scalar(value: object) -> str:
|
|
8
|
+
"""복합 값과 Markdown 줄 삽입을 노출하지 않는 scalar 문자열이다."""
|
|
9
|
+
if value is None or value == "" or not isinstance(value, (str, int, float, bool)):
|
|
10
|
+
return "-"
|
|
11
|
+
text = str(value).replace("`", "\\`").replace("\n", " ").replace("\r", " ")
|
|
12
|
+
return re.sub(
|
|
13
|
+
r"[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029]",
|
|
14
|
+
lambda match: f"\\u{ord(match.group(0)):04X}",
|
|
15
|
+
text,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def line(label: str, value: object) -> str:
|
|
20
|
+
"""한 scalar를 Markdown 목록 한 줄로 렌더한다."""
|
|
21
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9 ()/.-]*", label):
|
|
22
|
+
raise ValueError("fixed text label must be a static single-line label")
|
|
23
|
+
return f"- {label}: `{scalar(value)}`\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def value_lines(label: str, value: object) -> list[str]:
|
|
27
|
+
"""JSON 모양의 복합 값을 이름/순서/값 고정 줄로 손실 없이 펼친다."""
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
rows = [line(f"{label} field count", len(value))]
|
|
30
|
+
for index, (name, child) in enumerate(value.items(), 1):
|
|
31
|
+
rows.append(line(f"{label} field {index} name", name))
|
|
32
|
+
rows.extend(value_lines(f"{label} field {index} value", child))
|
|
33
|
+
return rows
|
|
34
|
+
if isinstance(value, list):
|
|
35
|
+
rows = [line(f"{label} item count", len(value))]
|
|
36
|
+
for index, child in enumerate(value, 1):
|
|
37
|
+
rows.extend(value_lines(f"{label} item {index}", child))
|
|
38
|
+
return rows
|
|
39
|
+
return [line(label, value)]
|