okstra 0.200.0 → 0.201.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +7 -3
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/technical-verification-worker.md +44 -0
- package/runtime/prompts/launch.template.md +7 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
- package/runtime/prompts/lead/plan-body-verification.md +3 -1
- package/runtime/prompts/lead/report-writer.md +11 -5
- package/runtime/prompts/lead/team-contract.md +6 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/final-verification.md +5 -0
- package/runtime/prompts/profiles/forbidden-actions.json +6 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/technical-verification.md +53 -0
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
- package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
- package/runtime/python/okstra_ctl/conformance.py +2 -20
- package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
- package/runtime/python/okstra_ctl/domain/provider.py +0 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
- package/runtime/python/okstra_ctl/implementation_options.py +58 -45
- package/runtime/python/okstra_ctl/model_pool.py +2 -5
- package/runtime/python/okstra_ctl/next_phase.py +3 -0
- package/runtime/python/okstra_ctl/plan_items.py +15 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
- package/runtime/python/okstra_ctl/qa_commands.py +30 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_assembly.py +8 -2
- package/runtime/python/okstra_ctl/report_contract.py +3 -0
- package/runtime/python/okstra_ctl/report_corrections.py +209 -93
- package/runtime/python/okstra_ctl/report_finalize.py +25 -8
- package/runtime/python/okstra_ctl/report_html/router.py +2 -0
- package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
- package/runtime/python/okstra_ctl/report_projections.py +4 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
- package/runtime/python/okstra_ctl/run.py +82 -0
- package/runtime/python/okstra_ctl/team.py +4 -1
- package/runtime/python/okstra_ctl/technical_verification.py +195 -0
- package/runtime/python/okstra_ctl/usage_identity.py +54 -0
- package/runtime/python/okstra_ctl/usage_report.py +22 -8
- package/runtime/python/okstra_ctl/verification_target.py +74 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/render.py +8 -55
- package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
- package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
- package/runtime/python/okstra_ctl/wizard/state.py +13 -6
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -0
- package/runtime/python/okstra_ctl/write_policy.py +23 -0
- package/runtime/python/okstra_token_usage/blocks.py +50 -1
- package/runtime/python/okstra_token_usage/claude.py +42 -21
- package/runtime/python/okstra_token_usage/codex.py +17 -0
- package/runtime/python/okstra_token_usage/collect.py +299 -162
- package/runtime/python/okstra_token_usage/cursor.py +2 -3
- package/runtime/python/okstra_token_usage/report.py +35 -30
- package/runtime/python/okstra_token_usage/task_totals.py +3 -12
- package/runtime/schemas/final-report-v2.0.schema.json +298 -7
- package/runtime/schemas/final-report-v3.0.schema.json +298 -7
- package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
- package/runtime/skills/okstra-run/SKILL.md +10 -2
- package/runtime/skills/okstra-setup/SKILL.md +42 -7
- package/runtime/templates/report-writer-prompt-preamble.md +7 -3
- package/runtime/templates/reports/html/i18n/en.json +11 -0
- package/runtime/templates/reports/html/i18n/ko.json +11 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
- package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
- package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
- package/runtime/validators/validate-run.py +9 -4
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""미확정 사실의 시험 입력과 관측 결과를 연결하며 도입 승인은 만들지 않는다."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
from .clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
|
|
12
|
+
from .final_report_schema import load_schema_for_data, validate
|
|
13
|
+
from .implementation_direction import validate_task_artifact_path
|
|
14
|
+
from .json_boundary import load_owned_object_snapshot, serialize_owned_object
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VerificationFact(TypedDict):
|
|
18
|
+
id: str
|
|
19
|
+
candidateId: str
|
|
20
|
+
factIndex: int
|
|
21
|
+
fact: str
|
|
22
|
+
whyItMatters: str
|
|
23
|
+
evidence: list[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VerificationInput(TypedDict):
|
|
27
|
+
sourceReport: str
|
|
28
|
+
sourceDataSha256: str
|
|
29
|
+
scope: str
|
|
30
|
+
facts: list[VerificationFact]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TechnicalVerificationError(ValueError):
|
|
34
|
+
"""시험 입력 또는 관측 결과가 원본 사실과 일치하지 않는다."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def technical_verification_facts(data: Mapping[str, Any]) -> list[VerificationFact]:
|
|
38
|
+
"""안전 차단이 없는 후보의 명시적인 기술 검증 사실만 추출한다."""
|
|
39
|
+
blockers = progress_blocking_ids(
|
|
40
|
+
data.get("clarificationItems"), USER_INPUT_BLOCKS, report_data=data
|
|
41
|
+
)
|
|
42
|
+
if blockers:
|
|
43
|
+
raise TechnicalVerificationError(
|
|
44
|
+
"unresolved user decisions: " + ", ".join(blockers)
|
|
45
|
+
)
|
|
46
|
+
selection = data.get("implementationOptionSelection") or {}
|
|
47
|
+
facts: list[VerificationFact] = []
|
|
48
|
+
for candidate in selection.get("candidateAudit") or []:
|
|
49
|
+
if candidate.get("safetyBlockers"):
|
|
50
|
+
continue
|
|
51
|
+
for index, fact in enumerate(candidate.get("unresolvedFeasibilityFacts") or []):
|
|
52
|
+
if fact.get("resolutionKind") != "technical-verification":
|
|
53
|
+
continue
|
|
54
|
+
facts.append(
|
|
55
|
+
{
|
|
56
|
+
"id": f"TV-{len(facts) + 1:03d}",
|
|
57
|
+
"candidateId": candidate["id"],
|
|
58
|
+
"factIndex": index,
|
|
59
|
+
"fact": fact["fact"],
|
|
60
|
+
"whyItMatters": fact["whyItMatters"],
|
|
61
|
+
"evidence": fact["evidence"],
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
if not facts:
|
|
65
|
+
raise TechnicalVerificationError("no eligible technical-verification facts")
|
|
66
|
+
return facts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_technical_verification_input(
|
|
70
|
+
report: Path, project_root: Path, task_root: Path, task_key: str
|
|
71
|
+
) -> VerificationInput:
|
|
72
|
+
"""같은 작업의 후보 비교 기록을 시험 입력으로 고정한다."""
|
|
73
|
+
report = report if report.is_absolute() else project_root / report
|
|
74
|
+
validate_task_artifact_path(report, task_root, "technical verification source")
|
|
75
|
+
expected_parent = task_root / "runs/implementation-option-selection/reports"
|
|
76
|
+
if report.parent != expected_parent or not re.fullmatch(
|
|
77
|
+
r"final-report-implementation-option-selection-\d{3,}\.data\.json", report.name
|
|
78
|
+
):
|
|
79
|
+
raise TechnicalVerificationError(
|
|
80
|
+
"source must be this task's option-selection record"
|
|
81
|
+
)
|
|
82
|
+
snapshot = load_owned_object_snapshot(
|
|
83
|
+
report, artifact="technical verification source"
|
|
84
|
+
)
|
|
85
|
+
data = snapshot.value
|
|
86
|
+
errors = validate(data, load_schema_for_data(data))
|
|
87
|
+
if errors:
|
|
88
|
+
raise TechnicalVerificationError("invalid source report: " + "; ".join(errors))
|
|
89
|
+
if data["header"]["taskKey"] != task_key:
|
|
90
|
+
raise TechnicalVerificationError(
|
|
91
|
+
"source taskKey does not match verification task"
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"sourceReport": report.relative_to(project_root).as_posix(),
|
|
95
|
+
"sourceDataSha256": hashlib.sha256(snapshot.raw_bytes).hexdigest(),
|
|
96
|
+
"scope": "technical-evidence-only",
|
|
97
|
+
"facts": technical_verification_facts(data),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_technical_verification_input(
|
|
102
|
+
payload: VerificationInput, run_root: Path, seq: str
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""실행 순번별 입력을 저장해 후속 실행이 이전 시험 범위를 덮지 않게 한다."""
|
|
105
|
+
path = run_root / "state" / f"technical-verification-input-{seq}.json"
|
|
106
|
+
serialized = serialize_owned_object(path, payload, artifact="technical verification input")
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with path.open("x", encoding="utf-8") as output:
|
|
109
|
+
output.write(serialized)
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_execution_evidence(
|
|
114
|
+
check: Mapping[str, Any], project_root: Path, run_root: Path, seq: str
|
|
115
|
+
) -> list[str]:
|
|
116
|
+
"""관측 판정은 실행 기록과 실행별 시험 디렉터리를 요구한다."""
|
|
117
|
+
errors = []
|
|
118
|
+
commands = check.get("commands") or []
|
|
119
|
+
if check.get("status") != "not-run" and not commands:
|
|
120
|
+
errors.append(f"{check['id']}: an observed result requires command evidence")
|
|
121
|
+
for command in commands:
|
|
122
|
+
try:
|
|
123
|
+
log = project_root / command["logPath"]
|
|
124
|
+
experiment_root = run_root / "experiments" / seq
|
|
125
|
+
validate_task_artifact_path(log, experiment_root, "verification log")
|
|
126
|
+
if not log.read_text(encoding="utf-8").strip():
|
|
127
|
+
errors.append(f"{check['id']}: verification log is empty")
|
|
128
|
+
cwd = project_root / command["cwd"]
|
|
129
|
+
cwd.resolve(strict=True).relative_to(experiment_root.resolve(strict=True))
|
|
130
|
+
relative = cwd.relative_to(experiment_root)
|
|
131
|
+
current = run_root
|
|
132
|
+
for part in ("experiments", seq, *relative.parts):
|
|
133
|
+
current = current / part
|
|
134
|
+
if current.is_symlink():
|
|
135
|
+
raise TechnicalVerificationError(
|
|
136
|
+
"experiment cwd contains a symlink"
|
|
137
|
+
)
|
|
138
|
+
if not cwd.is_dir() or cwd.is_symlink():
|
|
139
|
+
errors.append(f"{check['id']}: experiment cwd must be a directory")
|
|
140
|
+
except (OSError, ValueError) as exc:
|
|
141
|
+
errors.append(f"{check['id']}: invalid execution evidence: {exc}")
|
|
142
|
+
if check.get("status") == "supported" and command.get("exitCode") != 0:
|
|
143
|
+
errors.append(f"{check['id']}: a supported result has a failed command")
|
|
144
|
+
return errors
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_technical_verification_report(
|
|
148
|
+
data: Mapping[str, Any], report_path: Path, project_root: Path
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
"""발행·최종 검증에서 같은 사실 집합과 실제 시험 산출물을 대조한다."""
|
|
151
|
+
if (data.get("header") or {}).get("taskType") != "technical-verification":
|
|
152
|
+
return []
|
|
153
|
+
match = re.fullmatch(
|
|
154
|
+
r"final-report-technical-verification-(\d{3,})\.data\.json", report_path.name
|
|
155
|
+
)
|
|
156
|
+
if match is None:
|
|
157
|
+
return ["technical verification report must use its canonical record path"]
|
|
158
|
+
run_root = report_path.parent.parent
|
|
159
|
+
path = run_root / "state" / f"technical-verification-input-{match[1]}.json"
|
|
160
|
+
try:
|
|
161
|
+
validate_task_artifact_path(path, run_root, "technical verification input")
|
|
162
|
+
source = load_owned_object_snapshot(
|
|
163
|
+
path, artifact="technical verification input"
|
|
164
|
+
).value
|
|
165
|
+
except (OSError, ValueError) as exc:
|
|
166
|
+
return [str(exc)]
|
|
167
|
+
block = data.get("technicalVerification") or {}
|
|
168
|
+
errors = []
|
|
169
|
+
for key in ("sourceReport", "sourceDataSha256", "scope"):
|
|
170
|
+
if block.get(key) != source.get(key):
|
|
171
|
+
errors.append(f"technicalVerification.{key} must match the run input")
|
|
172
|
+
expected = {fact["id"]: fact for fact in source["facts"]}
|
|
173
|
+
checks = block.get("checks") or []
|
|
174
|
+
ids = [check.get("id") for check in checks]
|
|
175
|
+
if len(ids) != len(set(ids)) or set(ids) != set(expected):
|
|
176
|
+
errors.append(
|
|
177
|
+
"technicalVerification.checks must cover each input fact exactly once"
|
|
178
|
+
)
|
|
179
|
+
for check in checks:
|
|
180
|
+
fact = expected.get(check.get("id"))
|
|
181
|
+
if fact is None:
|
|
182
|
+
continue
|
|
183
|
+
for key in ("candidateId", "factIndex", "fact"):
|
|
184
|
+
if check.get(key) != fact[key]:
|
|
185
|
+
errors.append(f"{check['id']}: {key} must preserve the input fact")
|
|
186
|
+
errors.extend(
|
|
187
|
+
_check_execution_evidence(check, project_root, run_root, match[1])
|
|
188
|
+
)
|
|
189
|
+
if (block.get("routing") or {}).get(
|
|
190
|
+
"nextTaskType"
|
|
191
|
+
) != "implementation-option-selection":
|
|
192
|
+
errors.append(
|
|
193
|
+
"technical verification returns only to implementation-option-selection"
|
|
194
|
+
)
|
|
195
|
+
return errors
|
|
@@ -16,6 +16,60 @@ from .execution_manifest import read_execution_manifest
|
|
|
16
16
|
from .json_boundary import load_owned_object
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def unrostered_usage_workers(state: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
20
|
+
"""초기 명부에 없는 번역·비평 실행을 사용량 전용 행으로 투영한다."""
|
|
21
|
+
from .dispatch_state import v2_worker_state_key
|
|
22
|
+
|
|
23
|
+
known = {str(row.get("workerId")) for row in state.get("workers") or []
|
|
24
|
+
if isinstance(row, Mapping)}
|
|
25
|
+
additional: dict[str, dict[str, Any]] = {}
|
|
26
|
+
for record in usage_dispatch_records(state):
|
|
27
|
+
key = str(record.get("workerId") or "")
|
|
28
|
+
if not key and record.get("assignmentRef"):
|
|
29
|
+
key = v2_worker_state_key(record)
|
|
30
|
+
if not key or key in known:
|
|
31
|
+
continue
|
|
32
|
+
row = additional.setdefault(key, {
|
|
33
|
+
"workerId": key, "role": record.get("role") or record.get("audience") or key,
|
|
34
|
+
"provider": record.get("provider"), "agent": record.get("provider"),
|
|
35
|
+
"runner": record.get("runner") or "cli-wrapper", "usageAttempts": [],
|
|
36
|
+
**stored_identity(record),
|
|
37
|
+
})
|
|
38
|
+
row.update(status=record.get("status"), promptPath=record.get("promptPath"),
|
|
39
|
+
model=record.get("modelExecutionValue") or record.get("model"))
|
|
40
|
+
attempt = {name: record.get(name) for name in ("invocationRef", "attempt", "status")}
|
|
41
|
+
if attempt not in row["usageAttempts"]:
|
|
42
|
+
row["usageAttempts"].append(attempt)
|
|
43
|
+
return list(additional.values())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def usage_dispatch_records(
|
|
47
|
+
state: Mapping[str, Any], worker_id: str | None = None,
|
|
48
|
+
) -> list[Mapping[str, Any]]:
|
|
49
|
+
from .dispatch_state import worker_dispatch_records
|
|
50
|
+
|
|
51
|
+
records: list[Mapping[str, Any]] = []
|
|
52
|
+
seen: set[tuple] = set()
|
|
53
|
+
for collection in ("workerDispatches", "agentDispatches"):
|
|
54
|
+
source = state.get(collection)
|
|
55
|
+
for record in source if isinstance(source, list) else []:
|
|
56
|
+
if not isinstance(record, Mapping):
|
|
57
|
+
continue
|
|
58
|
+
reference = record.get("invocationRef") or record.get("promptPath")
|
|
59
|
+
key = (reference, record.get("attempt", 1))
|
|
60
|
+
if reference and key in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(key)
|
|
63
|
+
records.append(record)
|
|
64
|
+
return worker_dispatch_records({"workerDispatches": records}, worker_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def usage_session_ids(state: Mapping[str, Any], worker_id: str) -> list[str]:
|
|
68
|
+
from .dispatch_state import worker_session_ids
|
|
69
|
+
|
|
70
|
+
return worker_session_ids({"workerDispatches": usage_dispatch_records(state)}, worker_id)
|
|
71
|
+
|
|
72
|
+
|
|
19
73
|
def run_execution_manifest(run_root: Path) -> ExecutionManifest:
|
|
20
74
|
"""Load the first run-manifest under ``run_root/manifests``."""
|
|
21
75
|
return read_execution_manifest(_manifest_path(run_root))
|
|
@@ -17,6 +17,7 @@ from okstra_ctl.time_report import (
|
|
|
17
17
|
from okstra_ctl.json_boundary import load_owned_object
|
|
18
18
|
from okstra_ctl.fixed_text import line
|
|
19
19
|
from okstra_project import ResolverError, list_project_tasks, resolve_project_root
|
|
20
|
+
from okstra_token_usage.blocks import accounting_workers, normalize_usage_block, usage_blocks
|
|
20
21
|
|
|
21
22
|
UTC = dt.timezone.utc
|
|
22
23
|
UNAVAILABLE_KEYS = (
|
|
@@ -81,8 +82,7 @@ def _usage_block(value: Any) -> dict:
|
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
def _usage_blocks(state: dict) -> tuple[dict, list[dict]]:
|
|
84
|
-
|
|
85
|
-
worker_entries = workers if isinstance(workers, list) else []
|
|
85
|
+
worker_entries = accounting_workers(state)
|
|
86
86
|
return _usage_block(state.get("leadUsage")), [
|
|
87
87
|
_usage_block(worker.get("usage"))
|
|
88
88
|
for worker in worker_entries
|
|
@@ -117,8 +117,15 @@ def _run_metrics(run: dict, project_root: Path) -> tuple[dict | None, str | None
|
|
|
117
117
|
cost = costs.get("grandTotal", 0) if isinstance(costs, dict) else 0
|
|
118
118
|
unmatched = summary.get("unmatchedModels")
|
|
119
119
|
unmatched_models = unmatched if isinstance(unmatched, list) else []
|
|
120
|
+
original_blocks = usage_blocks(state)
|
|
121
|
+
normalized = [normalize_usage_block(block) for block in original_blocks]
|
|
122
|
+
cache_adjustment = sum(_int_value(block.get("totalTokens")) for block in original_blocks)
|
|
123
|
+
cache_adjustment -= sum(_int_value(block.get("totalTokens")) for block in normalized)
|
|
120
124
|
return {
|
|
121
|
-
"rawTokens": _int_value(summary.get("grandTotalTokens")),
|
|
125
|
+
"rawTokens": _int_value(summary.get("grandTotalTokens")) - cache_adjustment,
|
|
126
|
+
"reportedRawTokens": _int_value(summary.get("grandTotalTokens")),
|
|
127
|
+
"legacyCacheReadAdjustmentTokens": cache_adjustment,
|
|
128
|
+
"cacheReadTokens": sum(_int_value(block.get("cacheReadTokens")) for block in normalized),
|
|
122
129
|
"billableTokens": _int_value(summary.get("grandBillableEquivalentTokens")),
|
|
123
130
|
"costUsd": _float_value(cost),
|
|
124
131
|
"cpuSumMs": sum(positive_durations),
|
|
@@ -136,6 +143,9 @@ def _new_bucket(task_type: str, first_run_at: dt.datetime) -> dict:
|
|
|
136
143
|
"runs": 0,
|
|
137
144
|
"collectedRuns": 0,
|
|
138
145
|
"rawTokens": 0,
|
|
146
|
+
"reportedRawTokens": 0,
|
|
147
|
+
"legacyCacheReadAdjustmentTokens": 0,
|
|
148
|
+
"cacheReadTokens": 0,
|
|
139
149
|
"billableTokens": 0,
|
|
140
150
|
"costUsd": 0.0,
|
|
141
151
|
"cpuSumMs": 0,
|
|
@@ -162,10 +172,7 @@ def _collect_usage(
|
|
|
162
172
|
runs, timeline_warning = load_runs(Path(entry["_resolvedTaskRoot"]))
|
|
163
173
|
warnings["missingOrInvalidTimelines"] += int(timeline_warning)
|
|
164
174
|
for run in runs:
|
|
165
|
-
if
|
|
166
|
-
unavailable["invalidRunTimestamp"] += 1
|
|
167
|
-
continue
|
|
168
|
-
run_at = _parse_timestamp(run.get("runTimestamp"))
|
|
175
|
+
run_at = _parse_timestamp(run.get("runTimestamp") if isinstance(run, dict) else None)
|
|
169
176
|
if run_at is None:
|
|
170
177
|
unavailable["invalidRunTimestamp"] += 1
|
|
171
178
|
continue
|
|
@@ -191,7 +198,8 @@ def _collect_usage(
|
|
|
191
198
|
continue
|
|
192
199
|
collected_runs += 1
|
|
193
200
|
bucket["collectedRuns"] += 1
|
|
194
|
-
for key in ("rawTokens", "
|
|
201
|
+
for key in ("rawTokens", "reportedRawTokens", "legacyCacheReadAdjustmentTokens",
|
|
202
|
+
"cacheReadTokens", "billableTokens", "cpuSumMs", "wallClockMs"):
|
|
195
203
|
bucket[key] += metrics[key]
|
|
196
204
|
bucket["costUsd"] += metrics["costUsd"]
|
|
197
205
|
unmatched_models.update(metrics["unmatchedModels"])
|
|
@@ -223,6 +231,9 @@ def build_usage_report(project_root: Path, days: int, now: dt.datetime) -> dict:
|
|
|
223
231
|
"runs": total_runs,
|
|
224
232
|
"collectedRuns": collected_runs,
|
|
225
233
|
"rawTokens": sum(row["rawTokens"] for row in rows),
|
|
234
|
+
"reportedRawTokens": sum(row["reportedRawTokens"] for row in rows),
|
|
235
|
+
"legacyCacheReadAdjustmentTokens": sum(row["legacyCacheReadAdjustmentTokens"] for row in rows),
|
|
236
|
+
"cacheReadTokens": sum(row["cacheReadTokens"] for row in rows),
|
|
226
237
|
"billableTokens": sum(row["billableTokens"] for row in rows),
|
|
227
238
|
"costUsd": round(_float_value(sum(row["costUsd"] for row in rows)), 4),
|
|
228
239
|
"cpuSumMs": sum(row["cpuSumMs"] for row in rows),
|
|
@@ -325,6 +336,9 @@ def render_usage_text(payload: dict) -> str:
|
|
|
325
336
|
rows.append(line(f"Task type {index} wall clock ms", item.get("wallClockMs")))
|
|
326
337
|
totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
|
|
327
338
|
for label, key in (("Raw tokens", "rawTokens"), ("Billable tokens", "billableTokens"),
|
|
339
|
+
("Reported raw tokens", "reportedRawTokens"),
|
|
340
|
+
("Legacy cache read adjustment tokens", "legacyCacheReadAdjustmentTokens"),
|
|
341
|
+
("Cache read tokens", "cacheReadTokens"),
|
|
328
342
|
("Cost USD", "costUsd"), ("CPU sum ms", "cpuSumMs"),
|
|
329
343
|
("Wall clock ms", "wallClockMs")):
|
|
330
344
|
rows.append(line(label, totals.get(key)))
|
|
@@ -10,9 +10,17 @@ both readers ask the same question.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import hashlib
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
13
15
|
import re
|
|
16
|
+
import subprocess
|
|
14
17
|
from pathlib import Path
|
|
15
18
|
|
|
19
|
+
from .execution_mutation_audit import source_content_snapshot
|
|
20
|
+
from .json_boundary import load_owned_object
|
|
21
|
+
from .path_hints import hydrate_active_run_context
|
|
22
|
+
from .qa_commands import verification_command_defects
|
|
23
|
+
|
|
16
24
|
TARGET_FIELD_RES = {
|
|
17
25
|
"scope": re.compile(r"\*\*Verification scope:\*\*\s*`([^`]*)`"),
|
|
18
26
|
"worktree": re.compile(r"\*\*Worktree:\*\*\s*`([^`]*)`"),
|
|
@@ -66,3 +74,69 @@ def read_verification_target(project_root: Path, relative: str) -> dict | None:
|
|
|
66
74
|
else set()
|
|
67
75
|
)
|
|
68
76
|
return parsed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def capture_verification_target(
|
|
80
|
+
project_root: Path, manifest_path: Path, expected_head: str, command: str,
|
|
81
|
+
) -> dict:
|
|
82
|
+
"""실제 검사 명령은 실행하지 않고 대상과 소스 지문만 확인한다."""
|
|
83
|
+
root = project_root.resolve()
|
|
84
|
+
manifest_path = (root / manifest_path).resolve()
|
|
85
|
+
manifest = load_owned_object(manifest_path, artifact="run manifest")
|
|
86
|
+
context = hydrate_active_run_context(load_owned_object(
|
|
87
|
+
root / manifest["activeRunContextPath"], artifact="active run context",
|
|
88
|
+
))
|
|
89
|
+
if manifest.get("taskType") == "implementation":
|
|
90
|
+
target = context["executorWorktree"]["path"]
|
|
91
|
+
elif manifest.get("taskType") == "final-verification":
|
|
92
|
+
target = context["verificationTarget"]["worktreePath"]
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError("verification-target requires implementation or final-verification")
|
|
95
|
+
if not target or not Path(target).is_absolute():
|
|
96
|
+
raise ValueError("active run context has no absolute verification worktree")
|
|
97
|
+
worktree = Path(target).resolve()
|
|
98
|
+
defects = verification_command_defects(command)
|
|
99
|
+
if defects:
|
|
100
|
+
raise ValueError("; ".join(defects))
|
|
101
|
+
git = ["git", "-C", str(worktree), "rev-parse"]
|
|
102
|
+
head = subprocess.check_output([*git, "HEAD"], text=True).strip()
|
|
103
|
+
actual_root = subprocess.check_output([*git, "--show-toplevel"], text=True).strip()
|
|
104
|
+
if head != expected_head or Path(actual_root).resolve() != worktree:
|
|
105
|
+
raise ValueError(f"verification target mismatch: expected HEAD {expected_head}, actual {head}, root {actual_root}")
|
|
106
|
+
files = source_content_snapshot(worktree, frozenset({".okstra"}))
|
|
107
|
+
digest = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
|
|
108
|
+
return {
|
|
109
|
+
"schemaVersion": "1.0", "runManifest": str(manifest_path),
|
|
110
|
+
"taskKey": manifest["taskKey"], "cwd": str(worktree),
|
|
111
|
+
"head": head, "sourceDigest": digest, "command": command,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> int:
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
prog="okstra verification-target",
|
|
118
|
+
description="Check the recorded verification target without executing the command",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument("--project-root", type=Path, required=True)
|
|
121
|
+
parser.add_argument("--run-manifest", type=Path, required=True)
|
|
122
|
+
parser.add_argument("--expected-head", required=True)
|
|
123
|
+
parser.add_argument("--command", required=True, help="declared command to check, never executed")
|
|
124
|
+
parser.add_argument("--baseline", type=Path, help="compare against a prior verification-target JSON result")
|
|
125
|
+
args = parser.parse_args(argv)
|
|
126
|
+
try:
|
|
127
|
+
snapshot = capture_verification_target(
|
|
128
|
+
args.project_root, args.run_manifest, args.expected_head, args.command,
|
|
129
|
+
)
|
|
130
|
+
if args.baseline:
|
|
131
|
+
baseline = load_owned_object(args.baseline, artifact="verification target baseline")
|
|
132
|
+
if baseline.get("ok") is not True or baseline.get("target") != snapshot:
|
|
133
|
+
raise ValueError("verification target changed; do not reuse the previous verification result")
|
|
134
|
+
result = {"ok": True, "target": snapshot}
|
|
135
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc:
|
|
136
|
+
result = {"ok": False, "reason": str(exc)}
|
|
137
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
138
|
+
return 0 if result["ok"] else 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
|
@@ -17,7 +17,8 @@ from .engine import (
|
|
|
17
17
|
prompt_payload,
|
|
18
18
|
submit,
|
|
19
19
|
)
|
|
20
|
-
from .render import render_args
|
|
20
|
+
from .render import render_args
|
|
21
|
+
from .outcome import wizard_outcome
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
# ---- CLI entrypoint -----------------------------------------------------
|
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
|
7
7
|
from okstra_ctl.registry.host_registry import default_host_registry
|
|
8
8
|
from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
|
|
9
9
|
from okstra_ctl.incremental_scope import CARRY_ALL_SCOPE
|
|
10
|
-
from okstra_ctl.wizard_stage_intent import wizard_stage_confirmation_label
|
|
10
|
+
from okstra_ctl.wizard_stage_intent import wizard_stage_confirmation_label, resolve_wizard_stage_intent
|
|
11
11
|
from okstra_ctl.worktree import (
|
|
12
12
|
compute_worktree_path,
|
|
13
13
|
preview_worktree_decision,
|
|
@@ -15,6 +15,7 @@ from okstra_ctl.worktree import (
|
|
|
15
15
|
)
|
|
16
16
|
from okstra_ctl.work_categories import resolve_work_category
|
|
17
17
|
|
|
18
|
+
from .render import render_args
|
|
18
19
|
from .ids import S_CONFIRM, _STAGE_SCOPED_TASK_TYPES
|
|
19
20
|
from .state import (
|
|
20
21
|
Prompt,
|
|
@@ -107,9 +108,16 @@ def _build_confirm(state: WizardState) -> Prompt:
|
|
|
107
108
|
같은 블록을 낸다.
|
|
108
109
|
"""
|
|
109
110
|
t = _p(state.workspace_root, "confirm")
|
|
111
|
+
if not state.confirmation_prompt:
|
|
112
|
+
state.confirmation_stages = resolve_wizard_stage_intent(
|
|
113
|
+
task_type=state.task_type, selected_stage=state.selected_stage,
|
|
114
|
+
selected_stages=state.selected_stages,
|
|
115
|
+
).chain_stages
|
|
116
|
+
state.confirmation_scope = render_args(state)
|
|
117
|
+
state.confirmation_prompt = f"{confirmation_block(state)}\n\n{t['label']}"
|
|
110
118
|
return Prompt(
|
|
111
119
|
step=S_CONFIRM, kind="pick",
|
|
112
|
-
label=
|
|
120
|
+
label=state.confirmation_prompt,
|
|
113
121
|
options=[_opt(k, v) for k, v in t["options"].items()],
|
|
114
122
|
echo_template=t["echo_template"],
|
|
115
123
|
)
|
|
@@ -118,11 +126,31 @@ def _build_confirm(state: WizardState) -> Prompt:
|
|
|
118
126
|
def _submit_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
119
127
|
if value == "abort":
|
|
120
128
|
state.aborted = True
|
|
129
|
+
state.user_authorization = {}
|
|
121
130
|
return "confirm: abort"
|
|
122
131
|
if value not in ("proceed", "edit"):
|
|
123
132
|
raise WizardError(
|
|
124
133
|
f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
|
|
125
134
|
)
|
|
135
|
+
if value == "proceed":
|
|
136
|
+
stages = resolve_wizard_stage_intent(
|
|
137
|
+
task_type=state.task_type, selected_stage=state.selected_stage,
|
|
138
|
+
selected_stages=state.selected_stages,
|
|
139
|
+
).chain_stages
|
|
140
|
+
if (not state.confirmation_prompt or state.confirmation_scope != render_args(state)
|
|
141
|
+
or state.confirmation_stages != stages):
|
|
142
|
+
raise WizardError("confirmation scope changed; display the confirmation again before proceeding")
|
|
143
|
+
state.user_authorization = {
|
|
144
|
+
"schemaVersion": "1.0", "source": "wizard-confirmation",
|
|
145
|
+
"response": value, "prompt": state.confirmation_prompt,
|
|
146
|
+
"scope": state.confirmation_scope.copy(),
|
|
147
|
+
"stageScope": state.confirmation_stages.split(",") if state.confirmation_stages else [],
|
|
148
|
+
}
|
|
149
|
+
else:
|
|
150
|
+
state.user_authorization = {}
|
|
151
|
+
state.confirmation_prompt = ""
|
|
152
|
+
state.confirmation_scope = {}
|
|
153
|
+
state.confirmation_stages = ""
|
|
126
154
|
state.confirmed = value == "proceed"
|
|
127
155
|
return f"confirm: {value}"
|
|
128
156
|
|
|
@@ -340,6 +368,14 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
340
368
|
lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
|
|
341
369
|
if state.fix_cycle:
|
|
342
370
|
lines.append(f" fix-cycle : {state.fix_cycle}")
|
|
371
|
+
report_writer_models = state.role_models.get("report-writer", [])
|
|
372
|
+
translator_model = (
|
|
373
|
+
report_writer_models[0] if report_writer_models
|
|
374
|
+
else f"{state.report_writer_provider or 'claude'}/{state.report_writer_model or 'default'}"
|
|
375
|
+
)
|
|
376
|
+
lines.append(_msg(
|
|
377
|
+
state.workspace_root, "confirmation", "translation_scope", model=translator_model,
|
|
378
|
+
))
|
|
343
379
|
lines.append(_msg(
|
|
344
380
|
state.workspace_root, "confirmation", "provider_data_scope",
|
|
345
381
|
project_root=state.project_root,
|
|
@@ -395,11 +395,14 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
395
395
|
Returns {"echo": "...", "next": <Prompt JSON>}. Raises WizardError on
|
|
396
396
|
validation failure (caller may re-prompt).
|
|
397
397
|
"""
|
|
398
|
+
confirmation_was_displayed = bool(state.confirmation_prompt)
|
|
398
399
|
prompt = next_prompt(state)
|
|
399
400
|
if prompt.kind in ("done", "aborted"):
|
|
400
401
|
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
401
402
|
plan = _interaction_plan(state, prompt)
|
|
402
403
|
value = _normalize_interaction_answer(state, prompt, plan, value)
|
|
404
|
+
if prompt.step == S_CONFIRM and value == "proceed" and not confirmation_was_displayed:
|
|
405
|
+
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
403
406
|
if is_split_picker(prompt):
|
|
404
407
|
# 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
|
|
405
408
|
# 경로로 보낸다. 원본의 선택지로 검증한다.
|
|
@@ -21,6 +21,7 @@ TASK_TYPES: list[tuple[str, str]] = [
|
|
|
21
21
|
("improvement-discovery", "Find improvement candidates within a codebase scope and lens whitelist"),
|
|
22
22
|
*zip(ANALYSIS_TASK_TYPES, _ANALYSIS_TASK_TYPE_DESCRIPTIONS),
|
|
23
23
|
("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
|
|
24
|
+
("technical-verification", "Test unresolved technical facts in isolated experiment copies"),
|
|
24
25
|
("implementation-option-selection", "Compare implementation options (read-only)"),
|
|
25
26
|
("implementation-planning", "Plan options + request user approval"),
|
|
26
27
|
("implementation", "Execute approved plan (requires approved final-report)"),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""승인된 상태에서 실행 인자와 확인 화면을 조립한다."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .confirmation import confirmation_block
|
|
7
|
+
from .render import _stage_intent, render_args
|
|
8
|
+
from .state import WizardError, WizardState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _render_argv(rendered: dict[str, Any], *, host_runtime: str) -> list[str]:
|
|
12
|
+
"""Flatten render arguments into the canonical render-bundle argv."""
|
|
13
|
+
argv = ["--lead-runtime", host_runtime]
|
|
14
|
+
for name, raw_value in rendered.items():
|
|
15
|
+
values = raw_value if isinstance(raw_value, list) else [raw_value]
|
|
16
|
+
for value in values:
|
|
17
|
+
if not isinstance(value, str):
|
|
18
|
+
raise WizardError(
|
|
19
|
+
f"wizard render arg --{name} must be a string"
|
|
20
|
+
)
|
|
21
|
+
argv.extend([f"--{name}", value])
|
|
22
|
+
return argv
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
|
|
26
|
+
if state.task_type != "release-handoff":
|
|
27
|
+
return []
|
|
28
|
+
if not state.pr_template_path:
|
|
29
|
+
return []
|
|
30
|
+
if state.pr_template_scope not in ("project", "global"):
|
|
31
|
+
return []
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
"command": "config.set",
|
|
35
|
+
"key": "pr-template-path",
|
|
36
|
+
"scope": state.pr_template_scope,
|
|
37
|
+
"value": state.pr_template_path,
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def wizard_outcome(state: WizardState) -> dict[str, Any]:
|
|
43
|
+
"""Public outcome for callers that need launch data and follow-up writes.
|
|
44
|
+
|
|
45
|
+
`renderArgs` carries only what `okstra render-bundle` accepts, so a caller
|
|
46
|
+
can pass every entry through unfiltered — which is exactly what the
|
|
47
|
+
okstra-run skill is told to do. Signals the skill consumes itself, like the
|
|
48
|
+
unattended stage chain, live under `orchestration`; mixing them into
|
|
49
|
+
`renderArgs` made the renderer reject the wizard's own output.
|
|
50
|
+
"""
|
|
51
|
+
if state.aborted:
|
|
52
|
+
raise WizardError("wizard was aborted by the user — outcome is unavailable")
|
|
53
|
+
if state.confirmed is not True:
|
|
54
|
+
raise WizardError("wizard is not complete — outcome is unavailable")
|
|
55
|
+
rendered = render_args(state)
|
|
56
|
+
return {
|
|
57
|
+
"renderArgs": rendered,
|
|
58
|
+
"renderArgv": _render_argv(rendered, host_runtime=state.host_runtime),
|
|
59
|
+
"orchestration": {"chainStages": _stage_intent(state).chain_stages},
|
|
60
|
+
"persistActions": _wizard_persist_actions(state),
|
|
61
|
+
"confirmationText": state.user_authorization.get("prompt") or confirmation_block(state),
|
|
62
|
+
"userAuthorization": state.user_authorization or None,
|
|
63
|
+
}
|
|
@@ -34,8 +34,8 @@ def split_picker(
|
|
|
34
34
|
|
|
35
35
|
질문 수는 옵션이 들어가는 최소 개수이고 옵션은 질문에 고르게 나눈다 —
|
|
36
36
|
마지막 질문이 한 줄짜리가 되면 호스트 최소 옵션 수(2)에 걸려 묶음 전체가
|
|
37
|
-
네이티브에 못 실린다.
|
|
38
|
-
|
|
37
|
+
네이티브에 못 실린다. 모델 선택은 제공자 순서와 추천 표시를 각 조각에서도
|
|
38
|
+
보존한다(`Prompt._check_recommendations`). `max_questions` 도
|
|
39
39
|
넘으면 자르지 않고 그대로 돌려준다.
|
|
40
40
|
"""
|
|
41
41
|
if prompt.kind != "pick" or len(prompt.options) <= max_options:
|
|
@@ -752,7 +752,7 @@ STEPS: list[Step] = [
|
|
|
752
752
|
or bool(s.fix_cycle))
|
|
753
753
|
and s.confirmed is None),
|
|
754
754
|
build=_build_confirm, submit=_submit_confirm,
|
|
755
|
-
owns=("confirmed", "edit_target")),
|
|
755
|
+
owns=("confirmed", "edit_target", "confirmation_prompt", "confirmation_stages", "confirmation_scope", "user_authorization")),
|
|
756
756
|
Step(S_EDIT_TARGET,
|
|
757
757
|
applies=lambda s: s.confirmed is False and not s.edit_target,
|
|
758
758
|
build=_build_edit_target, submit=_submit_edit_target,
|