okstra 0.186.5 → 0.186.7
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 +58 -3
- package/runtime/python/okstra_ctl/render_final_report.py +3 -1
- package/runtime/python/okstra_ctl/report_assembly.py +9 -2
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_html/common.py +2 -7
- package/runtime/python/okstra_ctl/report_html/render.py +3 -0
- package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
- 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/schemas/final-report-v2.0.schema.json +4 -0
- package/runtime/schemas/final-report-v3.0.schema.json +4 -0
- 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 +14 -4
- package/runtime/templates/reports/html/i18n/en.json +19 -0
- package/runtime/templates/reports/html/i18n/ko.json +19 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +20 -29
- package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
- package/runtime/validators/lib/runners.sh +5 -1
- package/runtime/validators/validate-report-views.py +2 -1
- package/runtime/validators/validate-run.py +97 -82
- 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]:
|
|
@@ -587,6 +641,7 @@ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
|
|
|
587
641
|
("breakageKind", block.breakage_kind),
|
|
588
642
|
("fixability", block.fixability),
|
|
589
643
|
("note", block.note),
|
|
644
|
+
("explanation", block.explanation),
|
|
590
645
|
):
|
|
591
646
|
if value:
|
|
592
647
|
row[key] = value
|
|
@@ -988,7 +1043,7 @@ def _append_item_verdicts(
|
|
|
988
1043
|
round_number: int,
|
|
989
1044
|
project_root: Path | None,
|
|
990
1045
|
) -> None:
|
|
991
|
-
"""동수 항목의
|
|
1046
|
+
"""동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
|
|
992
1047
|
stamped = _stamped_verdicts(incoming, round_number, project_root)
|
|
993
1048
|
existing = item.get("verdicts")
|
|
994
1049
|
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
|
|
@@ -385,10 +389,13 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
385
389
|
created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
|
|
386
390
|
data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
|
|
387
391
|
clarifications = data.get("clarificationItems") or []
|
|
392
|
+
incorporated = incorporated_clarification_ids(data)
|
|
388
393
|
blocked = any(
|
|
389
394
|
isinstance(row, Mapping)
|
|
390
395
|
and row_blocks_progress(
|
|
391
|
-
str(row.get("status") or ""),
|
|
396
|
+
str(row.get("status") or ""),
|
|
397
|
+
clarification_disposition(row),
|
|
398
|
+
incorporated=str(row.get("id") or "") in incorporated,
|
|
392
399
|
)
|
|
393
400
|
for row in clarifications
|
|
394
401
|
)
|
|
@@ -130,6 +130,7 @@ IMPLEMENTATION_PLANNING_LEGACY_REQUIRED_HUMAN_FIELDS = (
|
|
|
130
130
|
"implementationPlanning.tradeoffMatrix",
|
|
131
131
|
"implementationPlanning.recommendedOption",
|
|
132
132
|
"implementationPlanning.stageMap",
|
|
133
|
+
"implementationPlanning.planBodyVerification",
|
|
133
134
|
)
|
|
134
135
|
|
|
135
136
|
IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS = (
|
|
@@ -138,6 +139,7 @@ IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS = (
|
|
|
138
139
|
"implementationPlanning.coverageSummary",
|
|
139
140
|
"implementationPlanning.stageMap",
|
|
140
141
|
"implementationPlanning.requirementCoverage",
|
|
142
|
+
"implementationPlanning.planBodyVerification",
|
|
141
143
|
)
|
|
142
144
|
|
|
143
145
|
IMPLEMENTATION_PLANNING_INVALIDATED_REQUIRED_HUMAN_FIELDS = (
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# key hunt across all of them would misread `crossVerification.consensus`, whose
|
|
8
|
-
# `evidence` key holds provenance while `evidence.primary` uses the same word
|
|
9
|
-
# for content — so the block a row came from is named here rather than guessed.
|
|
5
|
+
# 본문이 인용하지만 전용 섹션이 없는 행만 대장에 넣는다. 키 이름만으로
|
|
6
|
+
# 블록을 고르면 `evidence.primary` 의 내용과 다른 블록의 출처 칸이 섞인다.
|
|
10
7
|
#
|
|
11
8
|
# Source and confidence are separate columns because most blocks carry only one
|
|
12
9
|
# of the two: `evidence.primary` cites a file and never rates itself, while
|
|
@@ -29,8 +26,6 @@ _LEDGER_BLOCKS = (
|
|
|
29
26
|
(("analysisCommon", "confirmedFacts"), "statement", "", "", "confirmed-fact"),
|
|
30
27
|
(("analysisCommon", "inferences"), "statement", "", "confidence", "inference"),
|
|
31
28
|
(("analysisCommon", "unknowns"), "question", "reason", "", "unknown"),
|
|
32
|
-
(("crossVerification", "consensus"), "statement", "evidence", "", "cross-check-consensus"),
|
|
33
|
-
(("crossVerification", "differences"), "disagreement", "workersPosition", "", "cross-check-dissent"),
|
|
34
29
|
(("missingInformation",), "item", "risk", "", "missing-information"),
|
|
35
30
|
(("followUpTasks",), "title", "reason", "", "follow-up"),
|
|
36
31
|
)
|
|
@@ -157,6 +157,9 @@ def render_v2_html_view(
|
|
|
157
157
|
"sourceData": source_data,
|
|
158
158
|
"dataSha256": _sha256(data_path),
|
|
159
159
|
"clarificationItems": data.get("clarificationItems", []),
|
|
160
|
+
# 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
|
|
161
|
+
# 감사 모드에만 두면 판정이 사용자에게 안 보인다.
|
|
162
|
+
"crossVerification": data.get("crossVerification") or {},
|
|
160
163
|
# Every task type ends with the same run-cost section, so it is bound
|
|
161
164
|
# here rather than in ten view models that would each rebuild it.
|
|
162
165
|
"runUsage": run_usage(data),
|
|
@@ -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 "",
|
|
@@ -89,16 +89,13 @@ def _stage_figure(planning: dict):
|
|
|
89
89
|
return stage_map_figure(nodes=nodes, edges=edges, title="Implementation stage dependencies")
|
|
90
90
|
|
|
91
91
|
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
# needs, and the verification rounds the audit trail keeps. Declared here so the
|
|
95
|
-
# ids they carry stop being anchor targets in this document.
|
|
92
|
+
# 승인자가 보지 않는 구현자·사고 대응 표. 계획 본문 검증은 판정과 근거가
|
|
93
|
+
# 승인 판단이라 여기 두지 않는다.
|
|
96
94
|
_OMITTED_FIELDS = (
|
|
97
95
|
"validationChecklist",
|
|
98
96
|
"crossProjectDependencies",
|
|
99
97
|
"dependencyMigrationRisk",
|
|
100
98
|
"rollbackStrategy",
|
|
101
|
-
"planBodyVerification",
|
|
102
99
|
)
|
|
103
100
|
|
|
104
101
|
|
|
@@ -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"],
|