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.
- 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 +8 -2
- 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 +178 -26
- 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-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
|
@@ -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,
|
|
@@ -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
|
-
추천은 권장 수만큼의
|
|
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
|
-
|
|
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
|
|
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
|