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.
Files changed (96) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -5
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. 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
- workers = state.get("workers")
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 not isinstance(run, dict):
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", "billableTokens", "cpuSumMs", "wallClockMs"):
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())
@@ -141,8 +141,8 @@ from .engine import (
141
141
  from .render import (
142
142
  render_args,
143
143
  render_role_args,
144
- wizard_outcome,
145
144
  )
145
+ from .outcome import wizard_outcome
146
146
  from .cli import (
147
147
  main,
148
148
  )
@@ -17,7 +17,8 @@ from .engine import (
17
17
  prompt_payload,
18
18
  submit,
19
19
  )
20
- from .render import render_args, wizard_outcome
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=f"{confirmation_block(state)}\n\n{t['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
- 네이티브에 못 실린다. 추천은 원래 목록의 앞머리 run 이라 어느 조각에서도
38
- 앞머리 run 이 된다(`Prompt._check_recommendations`). `max_questions` 도
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,