okstra 0.200.1 → 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 (95) 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 +8 -2
  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 +178 -26
  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-writer-corrections-v1.0.schema.json +30 -3
  87. package/runtime/skills/okstra-run/SKILL.md +10 -2
  88. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  89. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  90. package/runtime/templates/reports/html/i18n/en.json +11 -0
  91. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  92. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  93. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  94. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  95. package/runtime/validators/validate-run.py +9 -4
@@ -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,
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from typing import Any
5
+ import json
5
6
 
6
7
  from okstra_ctl.legacy_model_selection import serialize_host_session_context
7
8
  from okstra_ctl.wizard_stage_intent import (
@@ -17,7 +18,6 @@ from .state import (
17
18
  _role_selection_enabled,
18
19
  )
19
20
  from .roles import _host_session_context, _selectable_static_requirements
20
- from .confirmation import confirmation_block
21
21
 
22
22
 
23
23
  def _stage_intent(state: WizardState) -> WizardStageIntent:
@@ -132,58 +132,11 @@ def render_args(state: WizardState) -> dict[str, Any]:
132
132
  for index, token in enumerate(role_argv)
133
133
  if token == "--role-model"
134
134
  ]
135
+ if state.user_authorization:
136
+ stages = _stage_intent(state).chain_stages.split(",") if _stage_intent(state).chain_stages else []
137
+ if state.user_authorization.get("stageScope", []) != stages:
138
+ raise WizardError("confirmed stage scope changed; obtain confirmation for the changed stages")
139
+ if state.user_authorization.get("scope") != rendered:
140
+ raise WizardError("confirmed scope changed; obtain confirmation for the changed inputs")
141
+ rendered["user-authorization-json"] = json.dumps(state.user_authorization, ensure_ascii=False)
135
142
  return rendered
136
-
137
-
138
- def _render_argv(rendered: dict[str, Any], *, host_runtime: str) -> list[str]:
139
- """Flatten render arguments into the canonical render-bundle argv."""
140
- argv = ["--lead-runtime", host_runtime]
141
- for name, raw_value in rendered.items():
142
- values = raw_value if isinstance(raw_value, list) else [raw_value]
143
- for value in values:
144
- if not isinstance(value, str):
145
- raise WizardError(
146
- f"wizard render arg --{name} must be a string"
147
- )
148
- argv.extend([f"--{name}", value])
149
- return argv
150
-
151
-
152
- def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
153
- if state.task_type != "release-handoff":
154
- return []
155
- if not state.pr_template_path:
156
- return []
157
- if state.pr_template_scope not in ("project", "global"):
158
- return []
159
- return [
160
- {
161
- "command": "config.set",
162
- "key": "pr-template-path",
163
- "scope": state.pr_template_scope,
164
- "value": state.pr_template_path,
165
- }
166
- ]
167
-
168
-
169
- def wizard_outcome(state: WizardState) -> dict[str, Any]:
170
- """Public outcome for callers that need launch data and follow-up writes.
171
-
172
- `renderArgs` carries only what `okstra render-bundle` accepts, so a caller
173
- can pass every entry through unfiltered — which is exactly what the
174
- okstra-run skill is told to do. Signals the skill consumes itself, like the
175
- unattended stage chain, live under `orchestration`; mixing them into
176
- `renderArgs` made the renderer reject the wizard's own output.
177
- """
178
- if state.aborted:
179
- raise WizardError("wizard was aborted by the user — outcome is unavailable")
180
- if state.confirmed is not True:
181
- raise WizardError("wizard is not complete — outcome is unavailable")
182
- rendered = render_args(state)
183
- return {
184
- "renderArgs": rendered,
185
- "renderArgv": _render_argv(rendered, host_runtime=state.host_runtime),
186
- "orchestration": {"chainStages": _stage_intent(state).chain_stages},
187
- "persistActions": _wizard_persist_actions(state),
188
- "confirmationText": confirmation_block(state),
189
- }
@@ -8,8 +8,9 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
8
8
  고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
9
9
 
10
10
  모델 화면(`role-models:<role>`, `role-model:<role>:1`)은 실행 가능한 전체 후보를
11
- 한 번에 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가
12
- 앞이고 권장 수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도
11
+ 한 번에 제공자별로 묶어 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면
12
+ 카탈로그 기본값)에서 권장 수만큼 추천하고 제공자 안에서는 기본 후보가 앞이다.
13
+ 호스트 네이티브 선택기의 옵션 한도
13
14
  (claude-code 4, codex 3, grok 15)를 넘으면 체크박스든 단일 선택이든 네이티브
14
15
  질문 묶음에 실리는 크기(claude-code 4×4)까지는 같은 화면의 체크박스 질문 여러
15
16
  개로 자르고(`picker_navigation.split_picker`), 그것도 넘으면 체크박스는
@@ -31,6 +32,7 @@ from okstra_ctl.assignment_resolver import (
31
32
  resolve_model_assignment,
32
33
  )
33
34
  from okstra_ctl.domain.host import CurrentSessionModelAttestation, HostSessionContext
35
+ from okstra_ctl.registry.provider_registry import provider_display_order
34
36
  from okstra_ctl.dispatch_state import detect_terminal_backend
35
37
  from okstra_ctl.registry.host_registry import default_host_registry
36
38
  from okstra_ctl.model_defaults import ModelDefaultScopes, default_candidates
@@ -221,9 +223,9 @@ def _role_models_prompt(
221
223
  context: AssignmentContext,
222
224
  scopes: ModelDefaultScopes,
223
225
  ) -> Prompt:
224
- """역할 하나의 모델 화면 — 실행 가능한 전체 후보, 기본 후보가 앞이다.
226
+ """역할 하나의 모델 화면 — 실행 가능한 전체 후보를 제공자별로 묶는다.
225
227
 
226
- 추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
228
+ 추천은 정렬 전 권장 수만큼의 후보다 — 프로젝트 `modelDefaults`(없으면 카탈로그
227
229
  기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
228
230
  """
229
231
  role = requirement.role
@@ -245,8 +247,6 @@ def _role_models_prompt(
245
247
  optional = requirement.min_count == 0
246
248
  skip_first = optional and requirement.recommended_count == 0
247
249
  options: list[Option] = []
248
- if skip_first:
249
- options.append(_skip_option(requirement, t, recommended=True))
250
250
  for index, model_ref in enumerate(everything):
251
251
  model = pool.resolve(model_ref)
252
252
  options.append(_opt(
@@ -254,6 +254,9 @@ def _role_models_prompt(
254
254
  t["options"]["model"].format(model_ref=model_ref, display=model.display_name),
255
255
  recommended=index < requirement.recommended_count,
256
256
  ))
257
+ options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
258
+ if skip_first:
259
+ options.insert(0, _skip_option(requirement, t, recommended=True))
257
260
  if optional and not skip_first:
258
261
  options.append(_skip_option(requirement, t, recommended=False))
259
262
  return Prompt(
@@ -273,7 +276,7 @@ def _single_model_options(
273
276
  context: AssignmentContext,
274
277
  scopes: ModelDefaultScopes,
275
278
  ) -> list[Option]:
276
- """고정 단일 역할의 후보 전체. 기본 후보가 앞이고 첫 줄이 추천이다."""
279
+ """고정 단일 역할의 후보를 제공자별로 묶고 기존 추천을 유지한다."""
277
280
  pool = context.pool
278
281
  requirement = next(row for row in profile.roles if row.role == role)
279
282
  defaults, everything = _available_role_models(
@@ -308,6 +311,7 @@ def _single_model_options(
308
311
  if not options:
309
312
  _validate_role_selection_feasibility(state, profile, context, scopes)
310
313
  raise WizardError(f"role {role!r} has no executable model candidates")
314
+ options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
311
315
  return options
312
316
 
313
317
 
@@ -533,6 +533,30 @@ def _same_file(a: Path, b_str: str) -> bool:
533
533
  return False
534
534
 
535
535
 
536
+
537
+ def _technical_evidence_for_comparison(
538
+ state: WizardState, source: Path | None, task_root: Path,
539
+ ) -> Path | None:
540
+ """현재 비교 보고서를 검증한 결과만 다음 재비교의 입력으로 추천한다."""
541
+ if state.task_type != "implementation-option-selection" or source is None:
542
+ return source
543
+ project_root = Path(state.project_root)
544
+ report = _newest_contained_final_report(
545
+ task_root / "runs", task_root,
546
+ "technical-verification/reports/final-report-*.data.json", project_root,
547
+ )
548
+ if report is None:
549
+ return source
550
+ try:
551
+ data = load_owned_object(report, artifact="technical verification report")
552
+ except (OSError, ValueError):
553
+ return source
554
+ block = data.get("technicalVerification") or {}
555
+ if block.get("sourceReport") != str(source.relative_to(project_root)):
556
+ return source
557
+ return report
558
+
559
+
536
560
  def _suggest_latest_final_report(state: WizardState) -> str:
537
561
  """clarification carry-in 으로 추천할 직전 final-report 의 relpath.
538
562
 
@@ -561,7 +585,8 @@ def _suggest_latest_final_report(state: WizardState) -> str:
561
585
  revision = _latest_revision_requested_analysis_report(state)
562
586
  best = revision
563
587
  if best is None and state.task_type:
564
- seg = slugify_task_segment(state.task_type)
588
+ source_type = "implementation-option-selection" if state.task_type == "technical-verification" else state.task_type
589
+ seg = slugify_task_segment(source_type)
565
590
  best = _newest_contained_final_report(
566
591
  runs_base,
567
592
  task_root,
@@ -573,13 +598,14 @@ def _suggest_latest_final_report(state: WizardState) -> str:
573
598
  # 리포트가 없으면 이 런은 새 계획이고, 답은 `--selected-direction` 으로
574
599
  # 들어간다 — 전체 phase 폴백은 여기서 직전 후보비교 리포트를 추천했고,
575
600
  # 그것을 고른 사용자는 두 상호 배타 입력을 동시에 갖게 됐다.
576
- if best is None and state.task_type != "implementation-planning":
601
+ if best is None and state.task_type not in {"implementation-planning", "technical-verification"}:
577
602
  best = _newest_contained_final_report(
578
603
  runs_base,
579
604
  task_root,
580
605
  "*/reports/final-report-*.data.json",
581
606
  Path(state.project_root),
582
607
  )
608
+ best = _technical_evidence_for_comparison(state, best, task_root)
583
609
  if best is None:
584
610
  return ""
585
611
  # The approved plan is already wired via --approved-plan. On the first run