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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Strict JSON boundaries for okstra-owned artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .final_report_schema import SchemaError, validate as validate_schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonBoundaryError(ValueError):
|
|
16
|
+
"""An owned JSON artifact could not satisfy its persistence contract."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path, artifact: str, reason: str) -> None:
|
|
19
|
+
self.path = path
|
|
20
|
+
self.artifact = artifact
|
|
21
|
+
self.reason = reason
|
|
22
|
+
super().__init__(f"{artifact}: {reason}: {path}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_EXTERNAL_SOURCE_SEAL = object()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, init=False)
|
|
29
|
+
class ExternalJsonSource:
|
|
30
|
+
"""검증된 외부 생산자가 소유한 JSON 입력 경로다."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
producer: str
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: Path, producer: str, seal: object = None) -> None:
|
|
36
|
+
if seal is not _EXTERNAL_SOURCE_SEAL:
|
|
37
|
+
raise TypeError("ExternalJsonSource is sealed")
|
|
38
|
+
object.__setattr__(self, "path", path)
|
|
39
|
+
object.__setattr__(self, "producer", producer)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class OwnedJsonSnapshot:
|
|
44
|
+
"""한 번 읽은 원시 바이트와 그 바이트에서 검증한 객체다."""
|
|
45
|
+
|
|
46
|
+
value: dict[str, Any]
|
|
47
|
+
raw_bytes: bytes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _issue_external_source(path: Path, producer: str) -> ExternalJsonSource:
|
|
51
|
+
if not isinstance(path, Path):
|
|
52
|
+
raise TypeError("external JSON source requires Path")
|
|
53
|
+
return ExternalJsonSource(path, producer, _EXTERNAL_SOURCE_SEAL)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_okstra_owned_path(path: Path) -> bool:
|
|
57
|
+
lexical = Path(os.path.abspath(path))
|
|
58
|
+
resolved = path.resolve(strict=False)
|
|
59
|
+
return ".okstra" in lexical.parts or ".okstra" in resolved.parts
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def external_user_json_source(path: Path) -> ExternalJsonSource:
|
|
63
|
+
"""사용자 제공 JSON이 Okstra 소유 트리 밖에 있을 때만 승인한다."""
|
|
64
|
+
if _is_okstra_owned_path(path):
|
|
65
|
+
raise JsonBoundaryError(path, "external JSON", "Okstra-owned path")
|
|
66
|
+
return _issue_external_source(path, "user")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def external_worker_json_source(
|
|
70
|
+
path: Path, *, trusted_root: Path, lane_root: Path
|
|
71
|
+
) -> ExternalJsonSource:
|
|
72
|
+
"""신뢰 루트 아래의 정확한 worker 결과 lane만 승인한다."""
|
|
73
|
+
lexical_root = Path(os.path.abspath(trusted_root))
|
|
74
|
+
lexical_lane = Path(os.path.abspath(lane_root))
|
|
75
|
+
lexical_path = Path(os.path.abspath(path))
|
|
76
|
+
try:
|
|
77
|
+
lane_parts = lexical_lane.relative_to(lexical_root).parts
|
|
78
|
+
except ValueError:
|
|
79
|
+
lane_parts = ()
|
|
80
|
+
is_worker_lane = (
|
|
81
|
+
len(lane_parts) in {7, 8}
|
|
82
|
+
and lane_parts[:2] == (".okstra", "tasks")
|
|
83
|
+
and lane_parts[4] == "runs"
|
|
84
|
+
and lane_parts[-1] == "worker-results"
|
|
85
|
+
and (len(lane_parts) == 7 or lane_parts[6].startswith("stage-"))
|
|
86
|
+
)
|
|
87
|
+
if not is_worker_lane or lexical_path.parent != lexical_lane:
|
|
88
|
+
raise JsonBoundaryError(
|
|
89
|
+
path, "worker JSON", "outside worker result lanes"
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
lexical_lane.relative_to(lexical_root)
|
|
93
|
+
lexical_path.relative_to(lexical_lane)
|
|
94
|
+
resolved_lane = lane_root.resolve(strict=False)
|
|
95
|
+
resolved_lane.relative_to(trusted_root.resolve(strict=False))
|
|
96
|
+
path.resolve(strict=False).relative_to(resolved_lane)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise JsonBoundaryError(
|
|
99
|
+
path, "worker JSON", "outside worker result lanes"
|
|
100
|
+
) from exc
|
|
101
|
+
if _has_symlink_component(lexical_lane, lexical_root) or _has_symlink_component(
|
|
102
|
+
lexical_path, lexical_lane
|
|
103
|
+
):
|
|
104
|
+
raise JsonBoundaryError(path, "worker JSON", "outside worker result lanes")
|
|
105
|
+
return _issue_external_source(path, "worker")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def external_invocation_json_source(
|
|
109
|
+
path: Path, *, project_root: Path, purpose: str
|
|
110
|
+
) -> ExternalJsonSource:
|
|
111
|
+
"""정확한 standalone purpose lane의 직접 결과만 승인한다."""
|
|
112
|
+
if not purpose or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in purpose):
|
|
113
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
114
|
+
lane = project_root / ".okstra" / "agent-invocations" / purpose
|
|
115
|
+
lexical_path = Path(os.path.abspath(path))
|
|
116
|
+
lexical_lane = Path(os.path.abspath(lane))
|
|
117
|
+
lexical_root = Path(os.path.abspath(project_root))
|
|
118
|
+
if lexical_path.parent != lexical_lane or path.is_symlink():
|
|
119
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
120
|
+
try:
|
|
121
|
+
lane.resolve(strict=False).relative_to(project_root.resolve(strict=False))
|
|
122
|
+
path.resolve(strict=False).relative_to(lane.resolve(strict=False))
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise JsonBoundaryError(
|
|
125
|
+
path, "invocation JSON", "outside invocation result lane"
|
|
126
|
+
) from exc
|
|
127
|
+
if _has_symlink_component(lexical_lane, lexical_root):
|
|
128
|
+
raise JsonBoundaryError(path, "invocation JSON", "outside invocation result lane")
|
|
129
|
+
return _issue_external_source(path, "standalone worker")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _has_symlink_component(path: Path, root: Path) -> bool:
|
|
133
|
+
current = path
|
|
134
|
+
while current != root:
|
|
135
|
+
if current.is_symlink():
|
|
136
|
+
return True
|
|
137
|
+
if current.parent == current:
|
|
138
|
+
return True
|
|
139
|
+
current = current.parent
|
|
140
|
+
return root.is_symlink()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def external_claude_global_json_source(
|
|
144
|
+
path: Path, home_dir: Path
|
|
145
|
+
) -> ExternalJsonSource:
|
|
146
|
+
"""호스트 어댑터가 지정한 home의 전역 Claude 설정만 승인한다."""
|
|
147
|
+
expected = home_dir / ".claude.json"
|
|
148
|
+
if Path(os.path.abspath(path)) != Path(os.path.abspath(expected)) or path.is_symlink():
|
|
149
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside fixed adapter path")
|
|
150
|
+
return _issue_external_source(path, "Claude Code")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def external_claude_team_json_source(path: Path) -> ExternalJsonSource:
|
|
154
|
+
"""현재 사용자의 Claude team roster 설정만 승인한다."""
|
|
155
|
+
root = Path.home() / ".claude" / "teams"
|
|
156
|
+
try:
|
|
157
|
+
Path(os.path.abspath(path)).relative_to(Path(os.path.abspath(root)))
|
|
158
|
+
path.resolve(strict=False).relative_to(root.resolve(strict=False))
|
|
159
|
+
except ValueError as exc:
|
|
160
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root") from exc
|
|
161
|
+
if path.name != "config.json" or path.is_symlink():
|
|
162
|
+
raise JsonBoundaryError(path, "Claude JSON", "outside Claude team root")
|
|
163
|
+
return _issue_external_source(path, "Claude Code")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def external_tool_json_source(path: Path, workspace_root: Path) -> ExternalJsonSource:
|
|
167
|
+
"""외부 도구가 workspace의 비-Okstra 경로에 쓴 보고서만 승인한다."""
|
|
168
|
+
try:
|
|
169
|
+
path.resolve(strict=False).relative_to(workspace_root.resolve(strict=False))
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
raise JsonBoundaryError(path, "tool JSON", "outside tool workspace") from exc
|
|
172
|
+
if _is_okstra_owned_path(path):
|
|
173
|
+
raise JsonBoundaryError(path, "tool JSON", "Okstra-owned path")
|
|
174
|
+
return _issue_external_source(path, "external tool")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def external_error_sidecar_source(path: Path) -> ExternalJsonSource:
|
|
178
|
+
"""worker-results lane 또는 Okstra 밖 레거시 sidecar만 승인한다."""
|
|
179
|
+
if _is_okstra_owned_path(path):
|
|
180
|
+
raise JsonBoundaryError(
|
|
181
|
+
path, "worker error sidecar", "trusted worker lane is required"
|
|
182
|
+
)
|
|
183
|
+
return _issue_external_source(path, "legacy automation")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _NonstandardJsonConstant(ValueError):
|
|
187
|
+
"""Raised by ``json.loads`` when a non-standard numeric token appears."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_owned_object(
|
|
191
|
+
path: Path,
|
|
192
|
+
*,
|
|
193
|
+
artifact: str,
|
|
194
|
+
schema: Mapping[str, Any] | None = None,
|
|
195
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
"""Load and validate one okstra-owned JSON object."""
|
|
198
|
+
return load_owned_object_snapshot(
|
|
199
|
+
path,
|
|
200
|
+
artifact=artifact,
|
|
201
|
+
schema=schema,
|
|
202
|
+
validate_cross_fields=validate_cross_fields,
|
|
203
|
+
).value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load_owned_object_snapshot(
|
|
207
|
+
path: Path,
|
|
208
|
+
*,
|
|
209
|
+
artifact: str,
|
|
210
|
+
schema: Mapping[str, Any] | None = None,
|
|
211
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
212
|
+
) -> OwnedJsonSnapshot:
|
|
213
|
+
"""한 번 읽은 바이트에서 객체를 파싱·검증해 함께 반환한다."""
|
|
214
|
+
try:
|
|
215
|
+
raw_bytes = path.read_bytes()
|
|
216
|
+
value = json.loads(
|
|
217
|
+
raw_bytes.decode("utf-8"),
|
|
218
|
+
parse_constant=_reject_nonstandard_json_constant,
|
|
219
|
+
)
|
|
220
|
+
except FileNotFoundError as exc:
|
|
221
|
+
raise JsonBoundaryError(path, artifact, "missing") from exc
|
|
222
|
+
except UnicodeDecodeError as exc:
|
|
223
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
|
|
224
|
+
except _NonstandardJsonConstant as exc:
|
|
225
|
+
raise JsonBoundaryError(path, artifact, str(exc)) from exc
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
|
|
230
|
+
return OwnedJsonSnapshot(
|
|
231
|
+
value=_validate_owned(
|
|
232
|
+
value,
|
|
233
|
+
path=path,
|
|
234
|
+
artifact=artifact,
|
|
235
|
+
schema=schema,
|
|
236
|
+
validate_cross_fields=validate_cross_fields,
|
|
237
|
+
),
|
|
238
|
+
raw_bytes=raw_bytes,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_external_json(
|
|
243
|
+
source: ExternalJsonSource,
|
|
244
|
+
*,
|
|
245
|
+
artifact: str,
|
|
246
|
+
object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None,
|
|
247
|
+
) -> Any:
|
|
248
|
+
"""Load external JSON without applying an owned-artifact contract."""
|
|
249
|
+
if not isinstance(source, ExternalJsonSource):
|
|
250
|
+
raise TypeError("load_external_json requires ExternalJsonSource")
|
|
251
|
+
path = source.path
|
|
252
|
+
try:
|
|
253
|
+
return json.loads(
|
|
254
|
+
path.read_text(encoding="utf-8"),
|
|
255
|
+
object_pairs_hook=object_pairs_hook,
|
|
256
|
+
)
|
|
257
|
+
except FileNotFoundError as exc:
|
|
258
|
+
raise JsonBoundaryError(path, artifact, "missing") from exc
|
|
259
|
+
except UnicodeDecodeError as exc:
|
|
260
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON encoding") from exc
|
|
261
|
+
except json.JSONDecodeError as exc:
|
|
262
|
+
raise JsonBoundaryError(path, artifact, "invalid JSON") from exc
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while reading ({exc})") from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def write_owned_object_atomic(
|
|
268
|
+
path: Path,
|
|
269
|
+
payload: Mapping[str, Any],
|
|
270
|
+
*,
|
|
271
|
+
artifact: str,
|
|
272
|
+
schema: Mapping[str, Any] | None = None,
|
|
273
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Validate a payload before atomically replacing an owned artifact."""
|
|
276
|
+
serialized = serialize_owned_object(
|
|
277
|
+
path,
|
|
278
|
+
payload,
|
|
279
|
+
artifact=artifact,
|
|
280
|
+
schema=schema,
|
|
281
|
+
validate_cross_fields=validate_cross_fields,
|
|
282
|
+
)
|
|
283
|
+
try:
|
|
284
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
descriptor, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
286
|
+
except OSError as exc:
|
|
287
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while preparing write ({exc})") from exc
|
|
288
|
+
temporary = Path(raw_temp)
|
|
289
|
+
try:
|
|
290
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
291
|
+
handle.write(serialized)
|
|
292
|
+
handle.flush()
|
|
293
|
+
os.fsync(handle.fileno())
|
|
294
|
+
os.replace(temporary, path)
|
|
295
|
+
except (TypeError, ValueError) as exc:
|
|
296
|
+
raise JsonBoundaryError(path, artifact, f"JSON serialization failed ({exc})") from exc
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
raise JsonBoundaryError(path, artifact, f"I/O error while writing ({exc})") from exc
|
|
299
|
+
finally:
|
|
300
|
+
temporary.unlink(missing_ok=True)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def serialize_owned_object(
|
|
304
|
+
path: Path,
|
|
305
|
+
payload: Mapping[str, Any],
|
|
306
|
+
*,
|
|
307
|
+
artifact: str,
|
|
308
|
+
schema: Mapping[str, Any] | None = None,
|
|
309
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None = None,
|
|
310
|
+
) -> str:
|
|
311
|
+
"""전용 게시 프로토콜이 사용할 검증된 canonical 객체 문자열이다."""
|
|
312
|
+
owned = _validate_owned(
|
|
313
|
+
payload, path=path, artifact=artifact, schema=schema,
|
|
314
|
+
validate_cross_fields=validate_cross_fields,
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
return json.dumps(
|
|
318
|
+
owned, ensure_ascii=False, indent=2, allow_nan=False
|
|
319
|
+
) + "\n"
|
|
320
|
+
except (TypeError, ValueError) as exc:
|
|
321
|
+
raise JsonBoundaryError(
|
|
322
|
+
path, artifact, f"JSON serialization failed ({exc})"
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def mutate_owned_object_atomic(
|
|
327
|
+
path: Path,
|
|
328
|
+
mutation: Callable[[dict[str, Any]], dict[str, Any]],
|
|
329
|
+
**contract: Any,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""Load, mutate, validate, and atomically replace one owned object."""
|
|
332
|
+
payload = load_owned_object(path, **contract)
|
|
333
|
+
updated = mutation(dict(payload))
|
|
334
|
+
write_owned_object_atomic(path, updated, **contract)
|
|
335
|
+
return updated
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _reject_nonstandard_json_constant(constant: str) -> None:
|
|
339
|
+
raise _NonstandardJsonConstant(f"non-standard JSON constant: {constant}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_owned(
|
|
343
|
+
value: object,
|
|
344
|
+
*,
|
|
345
|
+
path: Path,
|
|
346
|
+
artifact: str,
|
|
347
|
+
schema: Mapping[str, Any] | None,
|
|
348
|
+
validate_cross_fields: Callable[[dict[str, Any]], object] | None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
if not isinstance(value, Mapping):
|
|
351
|
+
raise JsonBoundaryError(path, artifact, "top-level JSON value must be an object")
|
|
352
|
+
owned = dict(value)
|
|
353
|
+
if schema is not None:
|
|
354
|
+
try:
|
|
355
|
+
schema_errors = validate_schema(owned, dict(schema))
|
|
356
|
+
except SchemaError as exc:
|
|
357
|
+
raise JsonBoundaryError(path, artifact, f"invalid schema ({exc})") from exc
|
|
358
|
+
if schema_errors:
|
|
359
|
+
raise JsonBoundaryError(path, artifact, f"schema validation failed ({schema_errors[0]})")
|
|
360
|
+
if validate_cross_fields is not None:
|
|
361
|
+
errors = validate_cross_fields(owned)
|
|
362
|
+
if isinstance(errors, str):
|
|
363
|
+
errors = [errors]
|
|
364
|
+
if errors:
|
|
365
|
+
raise JsonBoundaryError(path, artifact, f"cross-field validation failed ({next(iter(errors))})")
|
|
366
|
+
return owned
|
|
@@ -3,11 +3,15 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import fcntl
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
6
|
from collections.abc import Callable, Iterator
|
|
9
7
|
from pathlib import Path
|
|
10
8
|
|
|
9
|
+
from .json_boundary import (
|
|
10
|
+
JsonBoundaryError,
|
|
11
|
+
load_owned_object,
|
|
12
|
+
write_owned_object_atomic,
|
|
13
|
+
)
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
@contextlib.contextmanager
|
|
13
17
|
def registry_lock(lock_path: Path) -> Iterator[None]:
|
|
@@ -26,18 +30,12 @@ def registry_lock(lock_path: Path) -> Iterator[None]:
|
|
|
26
30
|
|
|
27
31
|
def load_registry_json(path: Path, default_factory: Callable[[], dict]) -> dict:
|
|
28
32
|
"""Load a registry dict, or a fresh default when absent/corrupt."""
|
|
29
|
-
if not path.exists():
|
|
30
|
-
return default_factory()
|
|
31
33
|
try:
|
|
32
|
-
|
|
33
|
-
except
|
|
34
|
+
return load_owned_object(path, artifact="registry")
|
|
35
|
+
except JsonBoundaryError:
|
|
34
36
|
return default_factory()
|
|
35
|
-
return data if isinstance(data, dict) else default_factory()
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def save_registry_json(path: Path, data: dict) -> None:
|
|
39
|
-
"""Persist registry JSON
|
|
40
|
-
path
|
|
41
|
-
tmp = path.with_suffix(".json.tmp")
|
|
42
|
-
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
43
|
-
os.replace(tmp, path)
|
|
40
|
+
"""Persist registry JSON through the owned-artifact boundary."""
|
|
41
|
+
write_owned_object_atomic(path, data, artifact="registry")
|
|
@@ -8,14 +8,31 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Callable, List, Optional
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def append_jsonl(
|
|
11
|
+
def append_jsonl(
|
|
12
|
+
path: Path, row: dict, *, ensure_ascii: bool = True, compact: bool = True
|
|
13
|
+
) -> None:
|
|
12
14
|
"""jsonl 파일 끝에 한 줄 append. 파일이 없으면 생성."""
|
|
13
15
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
-
line = json.dumps(
|
|
16
|
+
line = json.dumps(
|
|
17
|
+
row, separators=(",", ":") if compact else None,
|
|
18
|
+
ensure_ascii=ensure_ascii,
|
|
19
|
+
) + "\n"
|
|
15
20
|
with path.open("a") as f:
|
|
16
21
|
f.write(line)
|
|
17
22
|
|
|
18
23
|
|
|
24
|
+
def rewrite_jsonl(path: Path, rows: List[dict], *, ensure_ascii: bool = True) -> None:
|
|
25
|
+
"""JSONL 행 전체를 임시 파일에 쓴 뒤 원자 교체한다."""
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
28
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
29
|
+
for row in rows:
|
|
30
|
+
handle.write(json.dumps(
|
|
31
|
+
row, separators=(",", ":"), ensure_ascii=ensure_ascii
|
|
32
|
+
) + "\n")
|
|
33
|
+
os.replace(temporary, path)
|
|
34
|
+
|
|
35
|
+
|
|
19
36
|
def read_jsonl(path: Path) -> List[dict]:
|
|
20
37
|
"""jsonl 파일을 모두 읽어 dict 목록으로 반환. 파싱 실패 라인은 스킵."""
|
|
21
38
|
if not path.is_file():
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import hashlib
|
|
5
6
|
import re
|
|
7
|
+
import uuid
|
|
6
8
|
from contextlib import contextmanager
|
|
7
9
|
from dataclasses import dataclass, field, replace
|
|
8
10
|
from pathlib import Path
|
|
@@ -107,14 +109,44 @@ def _next_activity_id(events: list[LeadEvent]) -> str:
|
|
|
107
109
|
return f"A-{max(numbers, default=0) + 1:03d}"
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def _activity_request_digest(details: Mapping[str, Any]) -> str:
|
|
113
|
+
payload = {
|
|
114
|
+
key: value for key, value in details.items()
|
|
115
|
+
if key not in {"activityId", "activityRequestDigest", "activityRequestRef", "activityVersion"}
|
|
116
|
+
}
|
|
117
|
+
encoded = json.dumps(_stable_json_value(payload), ensure_ascii=False, separators=(",", ":"))
|
|
118
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
110
121
|
def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
|
|
111
122
|
"""Assign the next activity ID and append the event under one lock."""
|
|
112
123
|
if event.event_type != "activity":
|
|
113
124
|
raise ValueError("activity append requires eventType=activity")
|
|
114
125
|
with _event_log_lock(path):
|
|
115
126
|
details = dict(event.details)
|
|
127
|
+
request_ref = details.get("activityRequestRef")
|
|
128
|
+
events = read_lead_events(path)
|
|
129
|
+
digest = _activity_request_digest(details)
|
|
130
|
+
if request_ref is not None:
|
|
131
|
+
if not isinstance(request_ref, str) or not request_ref:
|
|
132
|
+
raise ValueError("activityRequestRef must be a non-empty string")
|
|
133
|
+
for previous in events:
|
|
134
|
+
if (
|
|
135
|
+
previous.event_type == event.event_type
|
|
136
|
+
and previous.lead_runtime == event.lead_runtime
|
|
137
|
+
and previous.task_key == event.task_key
|
|
138
|
+
and previous.task_type == event.task_type
|
|
139
|
+
and previous.run_seq == event.run_seq
|
|
140
|
+
and previous.details.get("activityRequestRef") == request_ref
|
|
141
|
+
):
|
|
142
|
+
if previous.details.get("activityRequestDigest") != digest:
|
|
143
|
+
raise ValueError("activity request reference has different payload")
|
|
144
|
+
return previous
|
|
145
|
+
raise ValueError("unknown activity request reference")
|
|
146
|
+
details["activityRequestRef"] = f"activity-{uuid.uuid4().hex}"
|
|
147
|
+
details["activityRequestDigest"] = digest
|
|
116
148
|
details["activityVersion"] = 1
|
|
117
|
-
details["activityId"] = _next_activity_id(
|
|
149
|
+
details["activityId"] = _next_activity_id(events)
|
|
118
150
|
stored = replace(event, details=details)
|
|
119
151
|
_append_unlocked(path, stored)
|
|
120
152
|
return stored
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""list/format/show."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
import json
|
|
5
4
|
import re as _re
|
|
6
5
|
from datetime import datetime, timedelta, timezone
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from typing import List, Optional
|
|
9
8
|
|
|
10
9
|
from .paths import resolve_under_root
|
|
10
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
11
11
|
from .reconcile import _parse_iso
|
|
12
12
|
from .run_index_row import read_run_index
|
|
13
13
|
|
|
@@ -24,8 +24,8 @@ def list_projects(home: Path, *, only_active: bool = False,
|
|
|
24
24
|
if not meta_file.is_file():
|
|
25
25
|
continue
|
|
26
26
|
try:
|
|
27
|
-
meta =
|
|
28
|
-
except
|
|
27
|
+
meta = load_owned_object(meta_file, artifact="project metadata")
|
|
28
|
+
except JsonBoundaryError:
|
|
29
29
|
# 동시 쓰기/이전 크래시로 깨진 meta.json 하나가 전체 listing 을
|
|
30
30
|
# 죽이지 않도록 해당 프로젝트만 건너뛴다(다른 JSON 리더와 동일 정책).
|
|
31
31
|
continue
|
|
@@ -13,12 +13,18 @@ import json
|
|
|
13
13
|
import sys
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
from .fixed_text import line
|
|
17
|
+
|
|
16
18
|
from okstra_project import resolve_project_root
|
|
19
|
+
from okstra_ctl.json_boundary import load_owned_object
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def _project_id(project_root: Path) -> str:
|
|
20
23
|
try:
|
|
21
|
-
return
|
|
24
|
+
return load_owned_object(
|
|
25
|
+
project_root / ".okstra" / "project.json",
|
|
26
|
+
artifact="project configuration",
|
|
27
|
+
).get("projectId", "")
|
|
22
28
|
except (OSError, ValueError):
|
|
23
29
|
return ""
|
|
24
30
|
|
|
@@ -120,6 +126,20 @@ def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
|
|
129
|
+
def render_result_text(result: dict) -> str:
|
|
130
|
+
parts = ["# Okstra Log Inventory\n\n", line("Project root", result.get("projectRoot")), line("Logs root", result.get("logsRoot"))]
|
|
131
|
+
totals = result.get("totals") if isinstance(result.get("totals"), dict) else {}
|
|
132
|
+
for key, label in (("fileCount", "File count"), ("totalBytes", "Total bytes"), ("taskCount", "Task count"), ("promptBytes", "Prompt bytes"), ("transcriptBytes", "Transcript bytes"), ("pairedFileCount", "Paired file count")):
|
|
133
|
+
parts.append(line(label, totals.get(key)))
|
|
134
|
+
for row in result.get("topLargest", []):
|
|
135
|
+
if isinstance(row, dict):
|
|
136
|
+
parts.extend(("\n## Log\n\n", line("Task key", row.get("taskKey")), line("Phase", row.get("phase")), line("Worker", row.get("worker")), line("Sequence", row.get("seq")), line("Size bytes", row.get("sizeBytes")), line("Modified epoch", row.get("mtimeEpoch")), line("Path", row.get("path"))))
|
|
137
|
+
for row in result.get("perTask", []):
|
|
138
|
+
if isinstance(row, dict):
|
|
139
|
+
parts.extend(("\n## Task total\n\n", line("Task key", row.get("taskKey")), line("File count", row.get("fileCount")), line("Total bytes", row.get("totalBytes")), line("Oldest epoch", row.get("oldestEpoch")), line("Newest epoch", row.get("newestEpoch"))))
|
|
140
|
+
return "".join(parts)
|
|
141
|
+
|
|
142
|
+
|
|
123
143
|
def main(argv: list[str] | None = None) -> int:
|
|
124
144
|
parser = argparse.ArgumentParser(
|
|
125
145
|
prog="okstra log-report",
|
|
@@ -128,12 +148,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
128
148
|
parser.add_argument("--cwd", default=".")
|
|
129
149
|
parser.add_argument("--top", type=int, default=20, help="topLargest entry count")
|
|
130
150
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
151
|
+
parser.add_argument("--text", action="store_true", help="emit fixed text fields")
|
|
131
152
|
args = parser.parse_args(argv)
|
|
132
153
|
|
|
133
154
|
project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
134
155
|
result = {"ok": True, "projectRoot": str(project_root)}
|
|
135
156
|
result.update(scan_logs(project_root / ".okstra" / "tasks", project_root, top=args.top))
|
|
136
|
-
|
|
157
|
+
output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
|
|
158
|
+
print(output, end="" if args.text else "\n")
|
|
137
159
|
return 0
|
|
138
160
|
|
|
139
161
|
|