okstra 0.186.4 → 0.186.6
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/docs/architecture.md +1 -1
- package/docs/cli.md +3 -3
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +6 -5
- package/runtime/prompts/launch.template.md +14 -0
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -14
- package/runtime/prompts/lead/plan-body-verification.md +4 -3
- package/runtime/prompts/lead/report-writer.md +3 -1
- package/runtime/prompts/profiles/_coverage-critic.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +3 -3
- package/runtime/prompts/profiles/requirements-discovery.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +2 -4
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -5
- package/runtime/python/okstra_ctl/agent_activity.py +6 -0
- package/runtime/python/okstra_ctl/clarification_items.py +67 -11
- package/runtime/python/okstra_ctl/next_phase.py +6 -3
- package/runtime/python/okstra_ctl/plan_items.py +32 -6
- package/runtime/python/okstra_ctl/plan_items_cli.py +57 -3
- package/runtime/python/okstra_ctl/render_final_report.py +3 -1
- package/runtime/python/okstra_ctl/report_assembly.py +65 -3
- package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
- package/runtime/python/okstra_ctl/report_projections.py +45 -4
- package/runtime/python/okstra_ctl/run.py +21 -6
- package/runtime/python/okstra_ctl/usage_cells.py +15 -0
- package/runtime/python/okstra_ctl/user_response.py +72 -6
- package/runtime/python/okstra_ctl/wizard.py +1 -5
- package/runtime/python/okstra_token_usage/codex.py +32 -3
- package/runtime/python/okstra_token_usage/collect.py +148 -15
- package/runtime/python/okstra_token_usage/grok.py +24 -5
- package/runtime/python/okstra_token_usage/report.py +12 -2
- package/runtime/skills/okstra-user-response/SKILL.md +4 -2
- package/runtime/templates/reports/html/assets/base.css +3 -9
- package/runtime/templates/reports/html/assets/base.js +0 -21
- package/runtime/templates/reports/html/base.template.html +1 -4
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +9 -28
- package/runtime/validators/lib/runners.sh +5 -1
- package/runtime/validators/validate-report-views.py +2 -1
- package/runtime/validators/validate-run.py +240 -102
- package/runtime/validators/validate_session_conformance.py +71 -18
|
@@ -649,15 +649,24 @@ def advisory_plan_body_gating(
|
|
|
649
649
|
return not (isinstance(items, list) and items)
|
|
650
650
|
|
|
651
651
|
|
|
652
|
+
CRITIC_WORKER_ID = "critic-worker"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def is_critic_worker(worker: str) -> bool:
|
|
656
|
+
"""본문 동수를 가르는 critic 표인지."""
|
|
657
|
+
name = str(worker or "").strip().lower()
|
|
658
|
+
return name == CRITIC_WORKER_ID or name.endswith("-critic-worker")
|
|
659
|
+
|
|
660
|
+
|
|
652
661
|
def tie_vote_item_ids(
|
|
653
662
|
items: Sequence[Mapping[str, Any]],
|
|
654
663
|
ledger: Mapping[str, str] | None,
|
|
655
664
|
tied_ids: Sequence[str],
|
|
656
665
|
) -> list[str]:
|
|
657
|
-
"""needs-reverify 동수 항목만
|
|
666
|
+
"""needs-reverify 동수 항목만 critic 에 보낸다.
|
|
658
667
|
|
|
659
668
|
첫 라운드 큐나 self-fix 재검증 큐와 섞지 않는다. 동수가 없으면 빈 목록이고
|
|
660
|
-
|
|
669
|
+
critic 은 이 배치를 띄우지 않는다.
|
|
661
670
|
"""
|
|
662
671
|
allowed = set(dispatch_item_ids(items, ledger))
|
|
663
672
|
queue: list[str] = []
|
|
@@ -686,6 +695,13 @@ ENVIRONMENT_CORRECTION_PREAMBLE = (
|
|
|
686
695
|
"a valid answer to any of them.\n"
|
|
687
696
|
)
|
|
688
697
|
|
|
698
|
+
CRITIC_TIE_PREAMBLE = (
|
|
699
|
+
"You are the critic tie-break. Only the items below are in dispute. "
|
|
700
|
+
"Each item already has one AGREE and one DISAGREE from the two plan-body "
|
|
701
|
+
"verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
|
|
702
|
+
"settles the split. Do not re-open items that are not listed.\n"
|
|
703
|
+
)
|
|
704
|
+
|
|
689
705
|
|
|
690
706
|
def item_cites_build_command(item: Mapping[str, Any]) -> bool:
|
|
691
707
|
"""이 항목이 계획 워크트리에서 실행 불가한 빌드/테스트 명령을 인용하는가."""
|
|
@@ -775,10 +791,15 @@ def blanket_unverifiable_workers(
|
|
|
775
791
|
|
|
776
792
|
def _is_tie(item: Mapping[str, Any]) -> bool:
|
|
777
793
|
tokens = [
|
|
778
|
-
token
|
|
794
|
+
token
|
|
795
|
+
for worker, token in _item_votes(item)
|
|
796
|
+
if not _error_verdict(token) and not is_critic_worker(worker)
|
|
779
797
|
]
|
|
780
798
|
if len(tokens) < 2:
|
|
781
799
|
return False
|
|
800
|
+
if any(is_critic_worker(worker) and not _error_verdict(token)
|
|
801
|
+
for worker, token in _item_votes(item)):
|
|
802
|
+
return False
|
|
782
803
|
disagree = sum(1 for token in tokens if token.upper().startswith("DISAGREE"))
|
|
783
804
|
agree = sum(1 for token in tokens if token.upper() in {"AGREE", "SUPPLEMENT"})
|
|
784
805
|
return disagree == agree and disagree > 0
|
|
@@ -828,10 +849,10 @@ def next_dispatch(
|
|
|
828
849
|
)
|
|
829
850
|
if ties:
|
|
830
851
|
return NextDispatch(
|
|
831
|
-
kind="
|
|
832
|
-
workers=
|
|
852
|
+
kind="critic-tie",
|
|
853
|
+
workers=(CRITIC_WORKER_ID,),
|
|
833
854
|
item_ids=ties,
|
|
834
|
-
reason="unsettled tie
|
|
855
|
+
reason="unsettled analyser tie; critic settles",
|
|
835
856
|
)
|
|
836
857
|
return NextDispatch(
|
|
837
858
|
kind="none", workers=(), item_ids=(),
|
|
@@ -843,3 +864,8 @@ def correction_prompt_text(queue_markdown: str) -> str:
|
|
|
843
864
|
"""시정 프롬프트. 환경 예외 단락이 큐보다 앞이다."""
|
|
844
865
|
return f"{ENVIRONMENT_CORRECTION_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
|
|
845
866
|
|
|
867
|
+
|
|
868
|
+
def critic_tie_prompt_text(queue_markdown: str) -> str:
|
|
869
|
+
"""동수 critic 프롬프트. 가르는 지시가 큐보다 앞이다."""
|
|
870
|
+
return f"{CRITIC_TIE_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
|
|
871
|
+
|
|
@@ -30,6 +30,7 @@ from .plan_items import (
|
|
|
30
30
|
advisory_plan_body_gating,
|
|
31
31
|
content_hash,
|
|
32
32
|
correction_prompt_text,
|
|
33
|
+
critic_tie_prompt_text,
|
|
33
34
|
dispatch_item_ids,
|
|
34
35
|
extract_plan_items,
|
|
35
36
|
next_dispatch,
|
|
@@ -154,7 +155,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
154
155
|
)
|
|
155
156
|
prepare.add_argument(
|
|
156
157
|
"--tie-vote", action="store_true",
|
|
157
|
-
help="dispatch queue is the needs-reverify ties in --state
|
|
158
|
+
help="dispatch queue is the needs-reverify ties in --state for critic-worker",
|
|
158
159
|
)
|
|
159
160
|
prompt = commands.add_parser("prompt")
|
|
160
161
|
prompt.add_argument("--run-manifest", type=Path, required=True)
|
|
@@ -348,6 +349,11 @@ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
348
349
|
args.run_manifest,
|
|
349
350
|
**_queue_kwargs(args),
|
|
350
351
|
)
|
|
352
|
+
if getattr(args, "tie_vote", False):
|
|
353
|
+
envelope["dispatchKind"] = "critic-tie"
|
|
354
|
+
envelope["tieSplits"] = _tie_splits_from_state(
|
|
355
|
+
getattr(args, "state", None), envelope["dispatchQueue"],
|
|
356
|
+
)
|
|
351
357
|
gating = not advisory_plan_body_gating(_planning(source), envelope["items"])
|
|
352
358
|
_sync_task_manifest_gating(args.run_manifest, gating)
|
|
353
359
|
write_json_atomic(output, envelope)
|
|
@@ -500,6 +506,44 @@ def _render_payload(item_id: str, payload: object) -> list[str]:
|
|
|
500
506
|
return rows
|
|
501
507
|
|
|
502
508
|
|
|
509
|
+
def _render_analyser_split(verdicts: object) -> str:
|
|
510
|
+
"""동수 항목에 이미 찍힌 분석자 표를 critic 프롬프트에 붙인다."""
|
|
511
|
+
if not isinstance(verdicts, list):
|
|
512
|
+
return ""
|
|
513
|
+
rows: list[str] = []
|
|
514
|
+
for verdict in verdicts:
|
|
515
|
+
if not isinstance(verdict, Mapping):
|
|
516
|
+
continue
|
|
517
|
+
worker = str(verdict.get("worker") or "").strip()
|
|
518
|
+
token = str(verdict.get("verdict") or "").strip()
|
|
519
|
+
if not worker or not token:
|
|
520
|
+
continue
|
|
521
|
+
kind = str(verdict.get("breakageKind") or "").strip()
|
|
522
|
+
label = f"{token}({kind})" if kind else token
|
|
523
|
+
rows.append(f"- `{worker}`: `{label}`\n")
|
|
524
|
+
if not rows:
|
|
525
|
+
return ""
|
|
526
|
+
return "Analyser split:\n" + "".join(rows)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _tie_splits_from_state(
|
|
530
|
+
state_path: Path | None, queue: Sequence[str],
|
|
531
|
+
) -> dict[str, list[Any]]:
|
|
532
|
+
if state_path is None or not state_path.is_file():
|
|
533
|
+
return {}
|
|
534
|
+
items = _state_plan_body_items(_load_json_object(state_path), state_path)
|
|
535
|
+
allowed = set(queue)
|
|
536
|
+
splits: dict[str, list[Any]] = {}
|
|
537
|
+
for row in items:
|
|
538
|
+
if not isinstance(row, Mapping):
|
|
539
|
+
continue
|
|
540
|
+
item_id = str(row.get("id") or "")
|
|
541
|
+
verdicts = row.get("verdicts")
|
|
542
|
+
if item_id in allowed and isinstance(verdicts, list):
|
|
543
|
+
splits[item_id] = verdicts
|
|
544
|
+
return splits
|
|
545
|
+
|
|
546
|
+
|
|
503
547
|
def _prompt(args: argparse.Namespace) -> str:
|
|
504
548
|
envelope = _load_json_object(
|
|
505
549
|
_prepared_items_path(args.run_manifest, require_regular=True)
|
|
@@ -520,7 +564,17 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
520
564
|
rows.extend((f"\n## Plan item {index}\n", line("Item ID", item_id),
|
|
521
565
|
line("Subject", item.get("subject"))))
|
|
522
566
|
rows.extend(_render_payload(item_id, item.get("payload")))
|
|
523
|
-
|
|
567
|
+
split = _render_analyser_split(
|
|
568
|
+
(envelope.get("tieSplits") or {}).get(item_id) if isinstance(
|
|
569
|
+
envelope.get("tieSplits"), Mapping
|
|
570
|
+
) else None
|
|
571
|
+
)
|
|
572
|
+
if split:
|
|
573
|
+
rows.append(split)
|
|
574
|
+
body = "".join(rows)
|
|
575
|
+
if envelope.get("dispatchKind") == "critic-tie":
|
|
576
|
+
return critic_tie_prompt_text(body)
|
|
577
|
+
return body
|
|
524
578
|
|
|
525
579
|
|
|
526
580
|
def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
|
|
@@ -988,7 +1042,7 @@ def _append_item_verdicts(
|
|
|
988
1042
|
round_number: int,
|
|
989
1043
|
project_root: Path | None,
|
|
990
1044
|
) -> None:
|
|
991
|
-
"""동수 항목의
|
|
1045
|
+
"""동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
|
|
992
1046
|
stamped = _stamped_verdicts(incoming, round_number, project_root)
|
|
993
1047
|
existing = item.get("verdicts")
|
|
994
1048
|
current = existing if isinstance(existing, list) else []
|
|
@@ -557,7 +557,9 @@ def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
|
|
|
557
557
|
context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
|
|
558
558
|
context["aiTaskTemplate"] = _markdown_task_template(task_type)
|
|
559
559
|
context["aiBlockingIds"] = progress_blocking_ids(
|
|
560
|
-
data.get("clarificationItems", []),
|
|
560
|
+
data.get("clarificationItems", []),
|
|
561
|
+
USER_INPUT_BLOCKS,
|
|
562
|
+
report_data=data,
|
|
561
563
|
)
|
|
562
564
|
sections = ReportSections(data, schema or {})
|
|
563
565
|
context["md"] = sections.section
|
|
@@ -9,7 +9,11 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Callable, Mapping, Sequence
|
|
10
10
|
|
|
11
11
|
from .agent_activity import agent_activity_rows
|
|
12
|
-
from .clarification_items import
|
|
12
|
+
from .clarification_items import (
|
|
13
|
+
clarification_disposition,
|
|
14
|
+
incorporated_clarification_ids,
|
|
15
|
+
row_blocks_progress,
|
|
16
|
+
)
|
|
13
17
|
from .final_report_schema import load_schema_version, validate
|
|
14
18
|
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
|
|
15
19
|
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
@@ -131,10 +135,65 @@ def _clarifications(
|
|
|
131
135
|
if not isinstance(row, Mapping):
|
|
132
136
|
_fail("lead", path, f"activeClarifications[{index}]", "must be an object")
|
|
133
137
|
activity_by_id = {row.get("activityId"): row for row in activities}
|
|
134
|
-
|
|
138
|
+
rows = [
|
|
135
139
|
_clarification_row(row, activity_by_id, path)
|
|
136
140
|
for row in active
|
|
137
141
|
]
|
|
142
|
+
rows.extend(_carried_clarification_rows(ledger, {row.get("id") for row in rows}, path))
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _carried_clarification_rows(
|
|
147
|
+
ledger: Mapping[str, Any], seen_ids: set[object], path: Path,
|
|
148
|
+
) -> list[dict[str, Any]]:
|
|
149
|
+
"""이월 결정은 active 질문이 아니다. 이번 런 활동 원장을 요구하지 않는다."""
|
|
150
|
+
carried = ledger.get("carriedDecisions")
|
|
151
|
+
if not isinstance(carried, list):
|
|
152
|
+
return []
|
|
153
|
+
rows: list[dict[str, Any]] = []
|
|
154
|
+
seen = set(seen_ids)
|
|
155
|
+
for index, entry in enumerate(carried):
|
|
156
|
+
if not isinstance(entry, Mapping):
|
|
157
|
+
_fail("lead", path, f"carriedDecisions[{index}]", "must be an object")
|
|
158
|
+
decision = entry.get("decision")
|
|
159
|
+
if not isinstance(decision, Mapping):
|
|
160
|
+
_fail(
|
|
161
|
+
"lead", path, f"carriedDecisions[{index}].decision",
|
|
162
|
+
"must be an object",
|
|
163
|
+
)
|
|
164
|
+
cid = decision.get("id")
|
|
165
|
+
if cid in seen:
|
|
166
|
+
continue
|
|
167
|
+
rows.append(_carried_clarification_row(decision))
|
|
168
|
+
seen.add(cid)
|
|
169
|
+
return rows
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _carried_clarification_row(source: Mapping[str, Any]) -> dict[str, Any]:
|
|
173
|
+
row = {
|
|
174
|
+
key: source[key]
|
|
175
|
+
for key in (
|
|
176
|
+
"id", "ticketId", "kind", "statement", "expectedForm", "blocks",
|
|
177
|
+
"origin", "userConfirmation", "options",
|
|
178
|
+
)
|
|
179
|
+
if key in source
|
|
180
|
+
}
|
|
181
|
+
if "approval" in source:
|
|
182
|
+
row["approvalContext"] = source["approval"]
|
|
183
|
+
resolution = source.get("resolutionInput")
|
|
184
|
+
if not isinstance(resolution, Mapping):
|
|
185
|
+
row["status"] = "resolved"
|
|
186
|
+
return row
|
|
187
|
+
row.update(
|
|
188
|
+
status="resolved",
|
|
189
|
+
userInput=resolution.get("userText", ""),
|
|
190
|
+
resolution={
|
|
191
|
+
"disposition": resolution.get("disposition"),
|
|
192
|
+
"userText": resolution.get("userText"),
|
|
193
|
+
"checkRefs": list(resolution.get("checkRefs") or []),
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
return row
|
|
138
197
|
|
|
139
198
|
|
|
140
199
|
def _project_relative(project_root: Path, path: Path) -> str:
|
|
@@ -330,10 +389,13 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
330
389
|
created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
|
|
331
390
|
data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
|
|
332
391
|
clarifications = data.get("clarificationItems") or []
|
|
392
|
+
incorporated = incorporated_clarification_ids(data)
|
|
333
393
|
blocked = any(
|
|
334
394
|
isinstance(row, Mapping)
|
|
335
395
|
and row_blocks_progress(
|
|
336
|
-
str(row.get("status") or ""),
|
|
396
|
+
str(row.get("status") or ""),
|
|
397
|
+
clarification_disposition(row),
|
|
398
|
+
incorporated=str(row.get("id") or "") in incorporated,
|
|
337
399
|
)
|
|
338
400
|
for row in clarifications
|
|
339
401
|
)
|
|
@@ -38,6 +38,10 @@ def _agent_row(row: dict) -> dict[str, str]:
|
|
|
38
38
|
"""
|
|
39
39
|
cli_tokens = _number(row.get("cliTotalTokens")) or 0
|
|
40
40
|
cli_cost = _number(row.get("cliCostUsd")) or 0
|
|
41
|
+
cost = _number(row.get("costUsd"))
|
|
42
|
+
if cost is None and cli_cost:
|
|
43
|
+
cost = cli_cost
|
|
44
|
+
cli_cost = 0
|
|
41
45
|
return {
|
|
42
46
|
"agent": str(row.get("agent") or ""),
|
|
43
47
|
"role": str(row.get("role") or ""),
|
|
@@ -46,7 +50,7 @@ def _agent_row(row: dict) -> dict[str, str]:
|
|
|
46
50
|
"rawTokens": format_int(row.get("totalTokens")),
|
|
47
51
|
"cacheReadTokens": format_int(row.get("cacheReadTokens")),
|
|
48
52
|
"billableTokens": format_int(row.get("billableTokens")),
|
|
49
|
-
"cost": format_usd(
|
|
53
|
+
"cost": format_usd(cost),
|
|
50
54
|
"duration": format_duration_ms(row.get("durationMs")),
|
|
51
55
|
"cliTokens": format_int(cli_tokens) if cli_tokens else "",
|
|
52
56
|
"cliCost": format_usd(cli_cost) if cli_cost else "",
|
|
@@ -7,6 +7,7 @@ from typing import Any, Mapping, Sequence
|
|
|
7
7
|
|
|
8
8
|
from .design_surfaces import DesignSurfaceTrigger, detect_design_surfaces
|
|
9
9
|
from .report_contract import execution_roles_from_manifest
|
|
10
|
+
from .usage_cells import duration_ms_from_bounds
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ReportProjectionError(ValueError):
|
|
@@ -48,23 +49,58 @@ def _agent_label(row: Mapping[str, Any]) -> str:
|
|
|
48
49
|
return _AGENT_LABELS.get(value.lower(), value)
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
def _execution_row(
|
|
52
|
+
def _execution_row(
|
|
53
|
+
row: Mapping[str, Any],
|
|
54
|
+
*,
|
|
55
|
+
lead: bool = False,
|
|
56
|
+
usage: Mapping[str, Any] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
52
58
|
role = _text(row.get("role"), "Okstra lead" if lead else "Worker")
|
|
59
|
+
source = usage if usage is not None else (
|
|
60
|
+
row.get("usage") if isinstance(row.get("usage"), Mapping) else {}
|
|
61
|
+
)
|
|
62
|
+
status = _status(row.get("status"))
|
|
63
|
+
if lead and status == "not-run" and _usage_ran(source):
|
|
64
|
+
status = "completed"
|
|
53
65
|
result = {
|
|
54
66
|
"agent": _agent_label(row),
|
|
55
67
|
"role": role,
|
|
56
68
|
"model": _text(row.get("model") or row.get("modelExecutionValue"), "unknown"),
|
|
57
|
-
"status":
|
|
69
|
+
"status": status,
|
|
58
70
|
"summary": _text(
|
|
59
71
|
row.get("summary") or row.get("reason"),
|
|
60
72
|
"Run coordination recorded by team state." if lead
|
|
61
73
|
else "Worker execution recorded by team state.",
|
|
62
74
|
),
|
|
63
75
|
}
|
|
64
|
-
_populate_usage(result,
|
|
76
|
+
_populate_usage(result, source)
|
|
77
|
+
if not result.get("durationMs"):
|
|
78
|
+
duration = _row_duration_ms(row, source)
|
|
79
|
+
if duration is not None:
|
|
80
|
+
result["durationMs"] = duration
|
|
65
81
|
return result
|
|
66
82
|
|
|
67
83
|
|
|
84
|
+
def _usage_ran(usage: Mapping[str, Any]) -> bool:
|
|
85
|
+
if usage.get("source") == "unavailable":
|
|
86
|
+
return False
|
|
87
|
+
tokens = usage.get("totalTokens") or usage.get("cliTotalTokens") or 0
|
|
88
|
+
duration = usage.get("durationMs") or 0
|
|
89
|
+
return bool(tokens or duration)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _row_duration_ms(row: Mapping[str, Any], usage: Mapping[str, Any]) -> int | None:
|
|
93
|
+
value = usage.get("durationMs") if usage.get("source") != "unavailable" else None
|
|
94
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
|
95
|
+
return int(value)
|
|
96
|
+
duration = duration_ms_from_bounds(row.get("startedAt"), row.get("endedAt"))
|
|
97
|
+
if duration is not None:
|
|
98
|
+
return duration
|
|
99
|
+
if usage.get("source") == "unavailable":
|
|
100
|
+
return None
|
|
101
|
+
return duration_ms_from_bounds(usage.get("startedAt"), usage.get("endedAt"))
|
|
102
|
+
|
|
103
|
+
|
|
68
104
|
def _populate_usage(row: dict[str, Any], usage: object) -> None:
|
|
69
105
|
source = usage if isinstance(usage, Mapping) else {}
|
|
70
106
|
mapping = {
|
|
@@ -92,11 +128,16 @@ def project_execution(
|
|
|
92
128
|
"status": "completed",
|
|
93
129
|
"role": "Okstra lead",
|
|
94
130
|
}
|
|
131
|
+
lead_usage = team_state.get("leadUsage")
|
|
95
132
|
workers = team_state.get("workers")
|
|
96
133
|
worker_rows = workers if isinstance(workers, list) else []
|
|
97
134
|
result: dict[str, Any] = {
|
|
98
135
|
"executionStatus": [
|
|
99
|
-
_execution_row(
|
|
136
|
+
_execution_row(
|
|
137
|
+
lead_row,
|
|
138
|
+
lead=True,
|
|
139
|
+
usage=lead_usage if isinstance(lead_usage, Mapping) else None,
|
|
140
|
+
),
|
|
100
141
|
*[
|
|
101
142
|
_execution_row(row)
|
|
102
143
|
for row in worker_rows
|
|
@@ -304,7 +304,7 @@ def _blocking_gate_survives_user_decision(data: dict, gate: str) -> bool:
|
|
|
304
304
|
for row in rows
|
|
305
305
|
)
|
|
306
306
|
return (not has_approval_row) or bool(
|
|
307
|
-
progress_blocking_ids(rows, APPROVAL_BLOCKS)
|
|
307
|
+
progress_blocking_ids(rows, APPROVAL_BLOCKS, report_data=data)
|
|
308
308
|
)
|
|
309
309
|
|
|
310
310
|
|
|
@@ -1723,7 +1723,7 @@ def _normalize_prepare_model_selection(
|
|
|
1723
1723
|
profile_file: Path,
|
|
1724
1724
|
) -> tuple[CanonicalModelSelection, RoleProfile]:
|
|
1725
1725
|
try:
|
|
1726
|
-
_validate_critic_choice(inp.critic)
|
|
1726
|
+
_validate_critic_choice(inp.critic, inp.task_type)
|
|
1727
1727
|
profile = load_role_profile(profile_file)
|
|
1728
1728
|
profile_workers = resolve_profile_workers(profile_file)
|
|
1729
1729
|
has_worker_models = bool(
|
|
@@ -2502,7 +2502,7 @@ def _resolve_model_bindings(
|
|
|
2502
2502
|
) -> _ModelBindings:
|
|
2503
2503
|
"""worker 모델 + critic 선택 + executor 바인딩을 한 묶음으로 해소·검증한다."""
|
|
2504
2504
|
m = _resolve_worker_models(inp, workers, context)
|
|
2505
|
-
critic_choice = _validate_critic_choice(inp.critic)
|
|
2505
|
+
critic_choice = _validate_critic_choice(inp.critic, inp.task_type)
|
|
2506
2506
|
critic_model_execution = ""
|
|
2507
2507
|
critic_meta = None
|
|
2508
2508
|
if critic_choice in provider_ids("critic"):
|
|
@@ -2581,12 +2581,27 @@ def _resolve_model_bindings(
|
|
|
2581
2581
|
)
|
|
2582
2582
|
|
|
2583
2583
|
|
|
2584
|
-
|
|
2584
|
+
_CRITIC_REQUIRED_TASK_TYPES = frozenset({
|
|
2585
|
+
"requirements-discovery",
|
|
2586
|
+
"error-analysis",
|
|
2587
|
+
"implementation-planning",
|
|
2588
|
+
"final-verification",
|
|
2589
|
+
})
|
|
2590
|
+
|
|
2591
|
+
|
|
2592
|
+
def _validate_critic_choice(raw_value: str, task_type: str = "") -> str:
|
|
2585
2593
|
critic_choice = (raw_value or "").strip().lower()
|
|
2586
|
-
allowed_critics = ["",
|
|
2594
|
+
allowed_critics = ["", *provider_ids("critic")]
|
|
2595
|
+
if critic_choice == "off":
|
|
2596
|
+
if task_type in _CRITIC_REQUIRED_TASK_TYPES:
|
|
2597
|
+
raise PrepareError(
|
|
2598
|
+
"--critic off is not allowed; critic is required "
|
|
2599
|
+
f"for {task_type}"
|
|
2600
|
+
)
|
|
2601
|
+
return "off"
|
|
2587
2602
|
if critic_choice not in allowed_critics:
|
|
2588
2603
|
raise PrepareError(
|
|
2589
|
-
f"--critic must be one of: {', '.join(
|
|
2604
|
+
f"--critic must be one of: {', '.join(provider_ids('critic'))} "
|
|
2590
2605
|
f"(got: {critic_choice!r})"
|
|
2591
2606
|
)
|
|
2592
2607
|
return critic_choice
|
|
@@ -7,6 +7,7 @@ nothing would look like, which is a different claim from "not measured".
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from datetime import datetime
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
|
|
@@ -28,6 +29,20 @@ def format_usd(value: Any) -> str:
|
|
|
28
29
|
return "--"
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def duration_ms_from_bounds(started_at: object, ended_at: object) -> int | None:
|
|
33
|
+
"""두 ISO 시각 사이의 벽시계 ms. 없거나 파싱 불가면 None."""
|
|
34
|
+
if not isinstance(started_at, str) or not isinstance(ended_at, str):
|
|
35
|
+
return None
|
|
36
|
+
if not started_at or not ended_at:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
40
|
+
end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None
|
|
43
|
+
return max(0, int((end - start).total_seconds() * 1000))
|
|
44
|
+
|
|
45
|
+
|
|
31
46
|
def format_duration_ms(value: Any) -> str:
|
|
32
47
|
if value is None or not isinstance(value, (str, int, float)):
|
|
33
48
|
return "--"
|
|
@@ -1960,21 +1960,33 @@ def finalize_response(transaction_id: str) -> Path:
|
|
|
1960
1960
|
|
|
1961
1961
|
def format_list_view(rows: list[dict[str, Any]]) -> str:
|
|
1962
1962
|
lines = ["USER RESPONSE TASKS", f"Count: {len(rows)}"]
|
|
1963
|
+
picker: list[str] = ["", "Picker:"]
|
|
1963
1964
|
for index, row in enumerate(rows, start=1):
|
|
1964
1965
|
status = "unreadable" if row.get("unreadable") else "ready"
|
|
1966
|
+
task_key = row.get("canonicalTaskKey", row.get("taskKey", ""))
|
|
1967
|
+
task_type = row.get("taskType", "")
|
|
1968
|
+
seq = row.get("seq", "")
|
|
1969
|
+
report = row.get("normalizedReportPath", row.get("reportPath", ""))
|
|
1970
|
+
open_items = row.get("openBlockerCount", 0)
|
|
1965
1971
|
lines.extend([
|
|
1966
1972
|
"",
|
|
1967
1973
|
f"[{index}]",
|
|
1968
|
-
f"Task key: {
|
|
1969
|
-
f"Task type: {
|
|
1970
|
-
f"Run sequence: {
|
|
1971
|
-
f"Report: {
|
|
1972
|
-
f"Open items: {
|
|
1974
|
+
f"Task key: {task_key}",
|
|
1975
|
+
f"Task type: {task_type}",
|
|
1976
|
+
f"Run sequence: {seq}",
|
|
1977
|
+
f"Report: {report}",
|
|
1978
|
+
f"Open items: {open_items}",
|
|
1973
1979
|
f"Open approval items: {row.get('openApprovalCount', 0)}",
|
|
1974
1980
|
f"Plan decision required: {'yes' if row.get('planDecisionRequired') else 'no'}",
|
|
1975
1981
|
f"Status: {status}",
|
|
1976
1982
|
])
|
|
1977
|
-
|
|
1983
|
+
picker.extend([
|
|
1984
|
+
f"- Label: {task_key} · {task_type} · seq {seq}",
|
|
1985
|
+
f" Description: Open items: {open_items}. Report: {report}",
|
|
1986
|
+
])
|
|
1987
|
+
if len(rows) == 0:
|
|
1988
|
+
return "\n".join(lines) + "\n"
|
|
1989
|
+
return "\n".join(lines + picker) + "\n"
|
|
1978
1990
|
|
|
1979
1991
|
|
|
1980
1992
|
def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
|
|
@@ -1996,6 +2008,59 @@ def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
|
|
|
1996
2008
|
]
|
|
1997
2009
|
|
|
1998
2010
|
|
|
2011
|
+
def _picker_scope(option: Mapping[str, Any]) -> str:
|
|
2012
|
+
"""HTML `<select>` 와 같은 범위 축. reach 가 있으면 그걸, 없으면 scopeImpact."""
|
|
2013
|
+
reach = str(option.get("reach") or "").strip()
|
|
2014
|
+
effects = option.get("scopeEffects")
|
|
2015
|
+
extra = (
|
|
2016
|
+
", ".join(str(item) for item in effects)
|
|
2017
|
+
if isinstance(effects, list) and effects
|
|
2018
|
+
else ""
|
|
2019
|
+
)
|
|
2020
|
+
if reach:
|
|
2021
|
+
return f"{reach}, {extra}" if extra else reach
|
|
2022
|
+
scope = option.get("scopeImpact")
|
|
2023
|
+
if isinstance(scope, list) and scope:
|
|
2024
|
+
return ", ".join(str(item) for item in scope)
|
|
2025
|
+
return "not stated in the report"
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def _picker_label(option: Mapping[str, Any]) -> str:
|
|
2029
|
+
answer = str(option.get("answer") or "").strip()
|
|
2030
|
+
if option.get("role") == "recommended":
|
|
2031
|
+
return f"{answer} (Recommended)"
|
|
2032
|
+
return answer
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
def _picker_description(option: Mapping[str, Any]) -> str:
|
|
2036
|
+
added = option.get("addedWork") or "not stated in the report"
|
|
2037
|
+
reverse = option.get("directionChange") or "not stated in the report"
|
|
2038
|
+
rationale = option.get("rationale") or "not stated in the report"
|
|
2039
|
+
return (
|
|
2040
|
+
f"If you pick this: {added}. What it reverses: {reverse}. "
|
|
2041
|
+
f"Scope: {_picker_scope(option)}. Why it is on the board: {rationale}."
|
|
2042
|
+
)
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def _format_picker(options: list[Any]) -> list[str]:
|
|
2046
|
+
"""호스트 네이티브 픽커에 그대로 넣을 칸. 순서는 `Option N:` 과 같다.
|
|
2047
|
+
|
|
2048
|
+
HTML 리포트의 `<select>` 값도 `option.answer` 다. 스킬이 이 블록을 카드로
|
|
2049
|
+
옮기면 브라우저 선택과 in-session 선택이 같은 답을 고른다.
|
|
2050
|
+
"""
|
|
2051
|
+
lines = ["Picker:"]
|
|
2052
|
+
for option in options:
|
|
2053
|
+
if not isinstance(option, Mapping):
|
|
2054
|
+
continue
|
|
2055
|
+
lines.extend([
|
|
2056
|
+
f"- Label: {_picker_label(option)}",
|
|
2057
|
+
f" Description: {_picker_description(option)}",
|
|
2058
|
+
])
|
|
2059
|
+
if len(lines) == 1:
|
|
2060
|
+
return ["Picker: none"]
|
|
2061
|
+
return lines
|
|
2062
|
+
|
|
2063
|
+
|
|
1999
2064
|
def _option_probe_texts(options: list[Any]) -> list[str]:
|
|
2000
2065
|
texts: list[str] = []
|
|
2001
2066
|
for option in options:
|
|
@@ -2114,6 +2179,7 @@ def _format_open_row_view(
|
|
|
2114
2179
|
])
|
|
2115
2180
|
for index, option in enumerate(row["options"], start=1):
|
|
2116
2181
|
lines.extend(_option_view(option, index))
|
|
2182
|
+
lines.extend(_format_picker(list(row["options"] or [])))
|
|
2117
2183
|
linked = _linked_plan_items(record, item.row_id)
|
|
2118
2184
|
lines.extend(_format_ref_list(
|
|
2119
2185
|
"Linked plan items",
|
|
@@ -4312,7 +4312,7 @@ def _critic_provider_choices() -> list[str]:
|
|
|
4312
4312
|
|
|
4313
4313
|
|
|
4314
4314
|
def _critic_choices() -> list[str]:
|
|
4315
|
-
return
|
|
4315
|
+
return list(_critic_provider_choices())
|
|
4316
4316
|
|
|
4317
4317
|
|
|
4318
4318
|
def _critic_provider_label(provider: str, t: dict) -> str:
|
|
@@ -4326,14 +4326,10 @@ def _critic_provider_label(provider: str, t: dict) -> str:
|
|
|
4326
4326
|
|
|
4327
4327
|
def _build_critic_pick(state: WizardState) -> Prompt:
|
|
4328
4328
|
t = _p(state.workspace_root, "critic_pick")
|
|
4329
|
-
off_label = t["options"].get("off", "off")
|
|
4330
|
-
# 추천(claude critic)을 가장 먼저, 'off'(critic 미사용)를 마지막에 둔다
|
|
4331
|
-
# (run-prompt 추천 규칙: 추천이 항상 첫 옵션).
|
|
4332
4329
|
options = [
|
|
4333
4330
|
_opt(provider, _critic_provider_label(provider, t))
|
|
4334
4331
|
for provider in _critic_provider_choices()
|
|
4335
4332
|
]
|
|
4336
|
-
options.append(_opt("off", off_label))
|
|
4337
4333
|
return Prompt(
|
|
4338
4334
|
step=S_CRITIC_PICK, kind="pick",
|
|
4339
4335
|
label=t["label"],
|
|
@@ -56,7 +56,7 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
|
|
|
56
56
|
return sessions[-1] if sessions else None
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def _session_meta_payload(path: Path) -> dict | None:
|
|
60
60
|
try:
|
|
61
61
|
with path.open() as fh:
|
|
62
62
|
first = fh.readline()
|
|
@@ -70,9 +70,38 @@ def _session_metadata(path: Path) -> tuple[str, str] | None:
|
|
|
70
70
|
return None
|
|
71
71
|
if record.get("type") != "session_meta":
|
|
72
72
|
return None
|
|
73
|
-
payload = record.get("payload")
|
|
73
|
+
payload = record.get("payload")
|
|
74
|
+
if not isinstance(payload, dict):
|
|
75
|
+
payload = {}
|
|
74
76
|
timestamp = payload.get("timestamp") or record.get("timestamp") or ""
|
|
75
|
-
return
|
|
77
|
+
return {**payload, "timestamp": timestamp}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _session_metadata(path: Path) -> tuple[str, str] | None:
|
|
81
|
+
payload = _session_meta_payload(path)
|
|
82
|
+
if payload is None:
|
|
83
|
+
return None
|
|
84
|
+
return str(payload.get("cwd") or ""), str(payload.get("timestamp") or "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def codex_session_is_worker(path: Path) -> bool:
|
|
88
|
+
"""exec 래퍼 세션. 대화형 리드는 originator=codex-tui / source=cli 이다."""
|
|
89
|
+
payload = _session_meta_payload(path)
|
|
90
|
+
if not payload:
|
|
91
|
+
return False
|
|
92
|
+
originator = str(payload.get("originator") or "").strip()
|
|
93
|
+
source = str(payload.get("source") or "").strip()
|
|
94
|
+
return originator == "codex_exec" or source == "exec"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def codex_session_ids(path: Path) -> set[str]:
|
|
98
|
+
ids = {path.name, path.stem}
|
|
99
|
+
payload = _session_meta_payload(path) or {}
|
|
100
|
+
for key in ("session_id", "id"):
|
|
101
|
+
value = str(payload.get(key) or "").strip()
|
|
102
|
+
if value:
|
|
103
|
+
ids.add(value)
|
|
104
|
+
return {item for item in ids if item}
|
|
76
105
|
|
|
77
106
|
|
|
78
107
|
def find_codex_sessions(
|