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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
@@ -13,9 +14,18 @@ from .claude import (
|
|
|
13
14
|
find_claude_agent_sessions,
|
|
14
15
|
find_claude_team_sessions,
|
|
15
16
|
)
|
|
16
|
-
from .codex import
|
|
17
|
+
from .codex import (
|
|
18
|
+
codex_session_ids,
|
|
19
|
+
codex_session_is_worker,
|
|
20
|
+
codex_session_total,
|
|
21
|
+
find_codex_sessions,
|
|
22
|
+
)
|
|
17
23
|
from .antigravity import antigravity_session_total, find_antigravity_sessions
|
|
18
|
-
from .grok import
|
|
24
|
+
from .grok import (
|
|
25
|
+
find_grok_sessions,
|
|
26
|
+
grok_session_is_non_interactive,
|
|
27
|
+
grok_session_total,
|
|
28
|
+
)
|
|
19
29
|
from .paths import claude_project_dir, utc_now
|
|
20
30
|
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
21
31
|
from okstra_ctl.dispatch_state import worker_session_ids
|
|
@@ -94,13 +104,9 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
94
104
|
aggregate["startedAt"] = s
|
|
95
105
|
if e and (aggregate["endedAt"] is None or e > aggregate["endedAt"]):
|
|
96
106
|
aggregate["endedAt"] = e
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
b = datetime.fromisoformat(aggregate["endedAt"].replace("Z", "+00:00"))
|
|
101
|
-
aggregate["durationMs"] = max(0, int((b - a).total_seconds() * 1000))
|
|
102
|
-
except ValueError:
|
|
103
|
-
pass
|
|
107
|
+
wall = _wall_ms(aggregate["startedAt"], aggregate["endedAt"])
|
|
108
|
+
if wall is not None:
|
|
109
|
+
aggregate["durationMs"] = wall
|
|
104
110
|
return aggregate
|
|
105
111
|
|
|
106
112
|
|
|
@@ -557,9 +563,18 @@ def wrapper_execution(status_path: Path | None) -> dict:
|
|
|
557
563
|
}
|
|
558
564
|
if status.exit_code is not None:
|
|
559
565
|
execution["exitCode"] = status.exit_code
|
|
566
|
+
duration = _wrapper_duration_ms(status.raw.get("duration_ms"), execution)
|
|
567
|
+
if duration is not None:
|
|
568
|
+
execution["durationMs"] = duration
|
|
560
569
|
return execution
|
|
561
570
|
|
|
562
571
|
|
|
572
|
+
def _wrapper_duration_ms(raw_duration: object, execution: dict) -> int | None:
|
|
573
|
+
if isinstance(raw_duration, (int, float)) and not isinstance(raw_duration, bool):
|
|
574
|
+
return max(0, int(raw_duration))
|
|
575
|
+
return _wall_ms(execution.get("startedAt"), execution.get("endedAt"))
|
|
576
|
+
|
|
577
|
+
|
|
563
578
|
def _execution_note(execution: dict) -> str:
|
|
564
579
|
status = execution["status"]
|
|
565
580
|
if status == "exited":
|
|
@@ -603,6 +618,8 @@ def collect_cli_usage(
|
|
|
603
618
|
block = _cli_usage_block(provider, _aggregate_totals(totals), sessions)
|
|
604
619
|
|
|
605
620
|
block["cliExecutionStatus"] = execution["status"]
|
|
621
|
+
if "durationMs" in execution:
|
|
622
|
+
block["durationMs"] = execution["durationMs"]
|
|
606
623
|
if fallback_window_used:
|
|
607
624
|
fallback_note = "wrapper status sidecar unavailable; used aggregate wrapper window fallback"
|
|
608
625
|
prior_note = block.get("cliNote")
|
|
@@ -706,11 +723,10 @@ def _attach_cli_usage(
|
|
|
706
723
|
block[key] = cli[key]
|
|
707
724
|
|
|
708
725
|
|
|
709
|
-
def _collect_cli_runtime_usage(
|
|
726
|
+
def _collect_cli_runtime_usage(
|
|
727
|
+
state: dict, project_root: Path, team_state_path: Path | None = None,
|
|
728
|
+
) -> dict:
|
|
710
729
|
windows_by_worker = _codex_worker_windows(project_root, state)
|
|
711
|
-
state["leadUsage"] = na_block(
|
|
712
|
-
"Host lead token accounting is unavailable; CLI worker usage is collected only from attributable provider logs."
|
|
713
|
-
)
|
|
714
730
|
for worker in state.get("workers", []):
|
|
715
731
|
if not isinstance(worker, dict):
|
|
716
732
|
continue
|
|
@@ -728,11 +744,128 @@ def _collect_cli_runtime_usage(state: dict, project_root: Path) -> dict:
|
|
|
728
744
|
worker=worker,
|
|
729
745
|
fallback_windows=windows_by_worker.get(worker_id, []),
|
|
730
746
|
)
|
|
747
|
+
state["leadUsage"] = _cli_lead_usage(state, project_root, team_state_path)
|
|
731
748
|
_populate_usage_summary(state, team_name=resolve_team_name(state),
|
|
732
749
|
sessions_found=0, needle_source="none")
|
|
733
750
|
return state
|
|
734
751
|
|
|
735
752
|
|
|
753
|
+
def _cli_lead_provider(state: dict) -> str:
|
|
754
|
+
lead = state.get("lead") if isinstance(state.get("lead"), dict) else {}
|
|
755
|
+
return str(
|
|
756
|
+
lead.get("provider") or lead.get("agent") or state.get("leadRuntime") or ""
|
|
757
|
+
).strip()
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
_CLI_LEAD_PROVIDERS = frozenset({"grok", "codex"})
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _cli_lead_usage(
|
|
764
|
+
state: dict, project_root: Path, team_state_path: Path | None,
|
|
765
|
+
) -> dict:
|
|
766
|
+
"""호스트 리드 세션은 워커가 가져간 경로를 뺀 뒤 같은 트랜스크립트에서 읽는다."""
|
|
767
|
+
missing = (
|
|
768
|
+
"Host lead token accounting is unavailable; CLI worker usage is "
|
|
769
|
+
"collected only from attributable provider logs."
|
|
770
|
+
)
|
|
771
|
+
provider = _cli_lead_provider(state)
|
|
772
|
+
if team_state_path is None:
|
|
773
|
+
return na_block(missing)
|
|
774
|
+
if provider == "antigravity":
|
|
775
|
+
return na_block(
|
|
776
|
+
"Antigravity host lead has no registered session transcript; "
|
|
777
|
+
"worker stream-json logs are collected only."
|
|
778
|
+
)
|
|
779
|
+
if provider not in _CLI_LEAD_PROVIDERS:
|
|
780
|
+
return na_block(missing)
|
|
781
|
+
run_since, run_until = resolve_run_window(team_state_path, state)
|
|
782
|
+
if not run_since or not run_until:
|
|
783
|
+
return na_block(
|
|
784
|
+
f"{provider} lead usage accounting is unavailable because the run window is missing."
|
|
785
|
+
)
|
|
786
|
+
worker_paths = {
|
|
787
|
+
Path(path)
|
|
788
|
+
for worker in state.get("workers") or []
|
|
789
|
+
if isinstance(worker, dict)
|
|
790
|
+
for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
|
|
791
|
+
}
|
|
792
|
+
sessions = _select_cli_lead_sessions(
|
|
793
|
+
provider,
|
|
794
|
+
[
|
|
795
|
+
path
|
|
796
|
+
for path in _cli_sessions_for_windows(
|
|
797
|
+
provider, project_root, [(run_since, run_until)],
|
|
798
|
+
)
|
|
799
|
+
if path not in worker_paths
|
|
800
|
+
],
|
|
801
|
+
state,
|
|
802
|
+
)
|
|
803
|
+
totals = _cli_session_totals(provider, sessions)
|
|
804
|
+
if not totals:
|
|
805
|
+
return na_block(
|
|
806
|
+
f"{provider} lead usage accounting is unavailable because no host session "
|
|
807
|
+
"started in the run window."
|
|
808
|
+
)
|
|
809
|
+
return _cli_usage_block(provider, _aggregate_totals(totals), sessions)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _cli_worker_session(provider: str, path: Path) -> bool:
|
|
813
|
+
if provider == "grok":
|
|
814
|
+
return grok_session_is_non_interactive(path)
|
|
815
|
+
if provider == "codex":
|
|
816
|
+
return codex_session_is_worker(path)
|
|
817
|
+
return False
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _select_cli_lead_sessions(
|
|
821
|
+
provider: str, sessions: list[Path], state: dict,
|
|
822
|
+
) -> list[Path]:
|
|
823
|
+
"""워커 래퍼 세션을 빼고, 아이디가 있으면 그 세션, 없으면 벽시계가 가장 긴 대화."""
|
|
824
|
+
candidates = [
|
|
825
|
+
path for path in sessions if not _cli_worker_session(provider, path)
|
|
826
|
+
]
|
|
827
|
+
if not candidates:
|
|
828
|
+
return []
|
|
829
|
+
pinned = _pinned_cli_lead_session(provider, candidates, state)
|
|
830
|
+
if pinned is not None:
|
|
831
|
+
return [pinned]
|
|
832
|
+
if len(candidates) == 1:
|
|
833
|
+
return candidates
|
|
834
|
+
best = candidates[0]
|
|
835
|
+
best_ms = -1
|
|
836
|
+
for path in candidates:
|
|
837
|
+
totals = _cli_session_totals(provider, [path])
|
|
838
|
+
total = totals[0] if totals else {}
|
|
839
|
+
wall = _wall_ms(total.get("startedAt"), total.get("endedAt")) if total else None
|
|
840
|
+
if wall is None:
|
|
841
|
+
wall = -1
|
|
842
|
+
if wall > best_ms:
|
|
843
|
+
best = path
|
|
844
|
+
best_ms = wall
|
|
845
|
+
return [best]
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _pinned_cli_lead_session(
|
|
849
|
+
provider: str, candidates: list[Path], state: dict,
|
|
850
|
+
) -> Path | None:
|
|
851
|
+
lead = state.get("lead") if isinstance(state.get("lead"), dict) else {}
|
|
852
|
+
wanted = {str(lead.get("sessionId") or "").strip()}
|
|
853
|
+
if provider == "grok":
|
|
854
|
+
wanted.add(str(os.environ.get("GROK_SESSION_ID") or "").strip())
|
|
855
|
+
wanted.discard("")
|
|
856
|
+
for path in candidates:
|
|
857
|
+
names = {path.parent.name, path.name, path.stem}
|
|
858
|
+
if provider == "codex":
|
|
859
|
+
names.update(codex_session_ids(path))
|
|
860
|
+
if wanted & names:
|
|
861
|
+
return path
|
|
862
|
+
if provider == "codex" and any(
|
|
863
|
+
session_id and session_id in path.name for session_id in wanted
|
|
864
|
+
):
|
|
865
|
+
return path
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
|
|
736
869
|
def _populate_usage_summary(
|
|
737
870
|
state: dict,
|
|
738
871
|
*,
|
|
@@ -747,7 +880,7 @@ def _populate_usage_summary(
|
|
|
747
880
|
lead_total = lead.get("totalTokens", 0) or 0
|
|
748
881
|
lead_cache_read = lead.get("cacheReadTokens", 0) or 0
|
|
749
882
|
lead_billable = lead.get("billableEquivalentTokens", 0) or 0
|
|
750
|
-
lead_cost = lead.get("estimatedCostUsd"
|
|
883
|
+
lead_cost = lead.get("estimatedCostUsd") or lead.get("cliEstimatedCostUsd") or 0
|
|
751
884
|
worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
|
|
752
885
|
worker_cache_read = sum((w.get("usage") or {}).get("cacheReadTokens", 0) or 0 for w in workers)
|
|
753
886
|
worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
|
|
@@ -1063,7 +1196,7 @@ def collect_cli_runtime_usage(
|
|
|
1063
1196
|
) -> dict:
|
|
1064
1197
|
state = json.loads(team_state_path.read_text())
|
|
1065
1198
|
cwd = project_root or _infer_project_root(team_state_path, state)
|
|
1066
|
-
return _collect_cli_runtime_usage(state, cwd)
|
|
1199
|
+
return _collect_cli_runtime_usage(state, cwd, team_state_path)
|
|
1067
1200
|
|
|
1068
1201
|
|
|
1069
1202
|
def collect(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
@@ -87,11 +88,17 @@ def grok_session_total(updates_path: Path) -> dict:
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
|
|
90
|
-
def
|
|
91
|
+
def _session_started_in_window(
|
|
92
|
+
updates_path: Path, started_at: str, ended_at: str,
|
|
93
|
+
) -> bool:
|
|
94
|
+
"""첫 usage 시각이 창 안에 있을 때만 이 창의 세션이다.
|
|
95
|
+
|
|
96
|
+
창 중간의 갱신만 보면 같은 cwd 의 리드 대화가 워커 창에 섞인다.
|
|
97
|
+
"""
|
|
91
98
|
for record in iter_jsonl(updates_path):
|
|
92
99
|
iso = _iso_from_unix(record.get("timestamp"))
|
|
93
|
-
if iso
|
|
94
|
-
return
|
|
100
|
+
if iso:
|
|
101
|
+
return ts_in_window(iso, started_at, ended_at)
|
|
95
102
|
return False
|
|
96
103
|
|
|
97
104
|
|
|
@@ -102,7 +109,7 @@ def find_grok_sessions(
|
|
|
102
109
|
*,
|
|
103
110
|
session_root: Path | None = None,
|
|
104
111
|
) -> list[Path]:
|
|
105
|
-
"""cwd 로 인코딩된 세션 중 창
|
|
112
|
+
"""cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl."""
|
|
106
113
|
if not started_at or not ended_at:
|
|
107
114
|
return []
|
|
108
115
|
root = session_root or grok_sessions_root()
|
|
@@ -118,10 +125,22 @@ def find_grok_sessions(
|
|
|
118
125
|
continue
|
|
119
126
|
for session in child.iterdir():
|
|
120
127
|
updates = session / "updates.jsonl"
|
|
121
|
-
if updates.is_file() and
|
|
128
|
+
if updates.is_file() and _session_started_in_window(
|
|
129
|
+
updates, started_at, ended_at
|
|
130
|
+
):
|
|
122
131
|
matches.append(updates)
|
|
123
132
|
return sorted(matches)
|
|
124
133
|
|
|
125
134
|
|
|
126
135
|
def grok_session_dir_name(cwd: Path) -> str:
|
|
127
136
|
return quote(str(cwd), safe="")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def grok_session_is_non_interactive(updates_path: Path) -> bool:
|
|
140
|
+
"""워커 래퍼 세션은 prompt_context.is_non_interactive 가 true 이다."""
|
|
141
|
+
context_path = updates_path.parent / "prompt_context.json"
|
|
142
|
+
try:
|
|
143
|
+
payload = json.loads(context_path.read_text(encoding="utf-8"))
|
|
144
|
+
except (OSError, json.JSONDecodeError):
|
|
145
|
+
return False
|
|
146
|
+
return payload.get("is_non_interactive") is True
|
|
@@ -18,6 +18,7 @@ from okstra_ctl.design_prep import ( # noqa: E402
|
|
|
18
18
|
DesignPrepError,
|
|
19
19
|
materialize_design_prep_requests,
|
|
20
20
|
)
|
|
21
|
+
from okstra_ctl.usage_cells import duration_ms_from_bounds # noqa: E402
|
|
21
22
|
|
|
22
23
|
from .task_totals import task_cumulative_usage # noqa: E402
|
|
23
24
|
|
|
@@ -81,8 +82,11 @@ def _populate_execution_row(row: dict, source: dict) -> None:
|
|
|
81
82
|
"""
|
|
82
83
|
usage = source.get("usage") or {}
|
|
83
84
|
if usage.get("source") == "unavailable":
|
|
84
|
-
#
|
|
85
|
-
#
|
|
85
|
+
# Token cells stay null (`--`). Dispatch wall-clock still belongs
|
|
86
|
+
# on the row: a wrapper that failed still ran for some time.
|
|
87
|
+
duration = duration_ms_from_bounds(source.get("startedAt"), source.get("endedAt"))
|
|
88
|
+
if duration is not None:
|
|
89
|
+
row["durationMs"] = duration
|
|
86
90
|
return
|
|
87
91
|
if "totalTokens" in usage:
|
|
88
92
|
row["totalTokens"] = usage["totalTokens"]
|
|
@@ -98,6 +102,12 @@ def _populate_execution_row(row: dict, source: dict) -> None:
|
|
|
98
102
|
row["cliTotalTokens"] = usage["cliTotalTokens"]
|
|
99
103
|
if "cliEstimatedCostUsd" in usage:
|
|
100
104
|
row["cliCostUsd"] = usage["cliEstimatedCostUsd"]
|
|
105
|
+
if not row.get("durationMs"):
|
|
106
|
+
duration = duration_ms_from_bounds(source.get("startedAt"), source.get("endedAt"))
|
|
107
|
+
if duration is None:
|
|
108
|
+
duration = duration_ms_from_bounds(usage.get("startedAt"), usage.get("endedAt"))
|
|
109
|
+
if duration is not None:
|
|
110
|
+
row["durationMs"] = duration
|
|
101
111
|
|
|
102
112
|
|
|
103
113
|
def _worker_detail_label(worker: dict) -> str:
|
|
@@ -59,7 +59,9 @@ Read the absolute path in the fixed `Relay contract` line. In that file, take th
|
|
|
59
59
|
- When `native-single` is available and the option count fits `nativeLimits` (unique labels, within min/max): call `interactions.native-single.function` once with one question and every option as `{label, description}` in original order. Do not print a numbered list in chat while the native tool is available. Claude Code's function is `AskUserQuestion`, Grok's is `ask_user_question`, Codex's is `request_user_input` — copy the relay field; do not substitute one name for another.
|
|
60
60
|
- Otherwise render a 1-based numbered Markdown list and wait for the next message. Do not drop options to force the native tool.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
Copy the view's `Picker:` `- Label:` / `Description:` pairs into that function in that order. Do not rebuild labels from the `Options:` dump. `--option-number` is the 1-based `Option N:` index, which is the same order as `Picker:`. The HTML report's `<select>` uses the same `option.answer` values.
|
|
63
|
+
|
|
64
|
+
Pass only the choices this step already owns — the `list-view` `Picker:` rows, the report `Picker:` rows, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
|
|
63
65
|
|
|
64
66
|
Never invent a picker function. Never ask the user to type a number when the native tool is available.
|
|
65
67
|
|
|
@@ -79,7 +81,7 @@ Present up to three task choices through the host picker. A host free-text row o
|
|
|
79
81
|
okstra user-response show-view --report <reportPath> --project-root <projectRoot>
|
|
80
82
|
```
|
|
81
83
|
|
|
82
|
-
The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
|
|
84
|
+
The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, a `Picker:` block, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
|
|
83
85
|
|
|
84
86
|
Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order.
|
|
85
87
|
|
|
@@ -135,9 +135,7 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
135
135
|
align-items: flex-end;
|
|
136
136
|
gap: .4rem;
|
|
137
137
|
}
|
|
138
|
-
.back-to-top
|
|
139
|
-
.back-to-top,
|
|
140
|
-
.back-to-top-toggle {
|
|
138
|
+
.back-to-top {
|
|
141
139
|
padding: .55rem .9rem;
|
|
142
140
|
border-radius: 8px;
|
|
143
141
|
border: 1px solid color-mix(in srgb, CanvasText 40%, transparent);
|
|
@@ -148,12 +146,9 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
148
146
|
font-size: .9rem;
|
|
149
147
|
font-weight: 600;
|
|
150
148
|
cursor: pointer;
|
|
151
|
-
box-shadow: 0 2px 10px color-mix(in srgb, CanvasText 28%, transparent);
|
|
152
149
|
}
|
|
153
|
-
.back-to-top:hover,
|
|
154
|
-
.back-to-top-
|
|
155
|
-
.back-to-top:focus-visible,
|
|
156
|
-
.back-to-top-toggle:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
150
|
+
.back-to-top:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
|
|
151
|
+
.back-to-top:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
157
152
|
.back-to-top-index {
|
|
158
153
|
display: none;
|
|
159
154
|
box-sizing: border-box;
|
|
@@ -170,7 +165,6 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
170
165
|
.back-to-top-index ol { margin: 0; padding-left: 1.2rem; }
|
|
171
166
|
.back-to-top-index li { margin: .25em 0; }
|
|
172
167
|
.back-to-top-index a { color: inherit; }
|
|
173
|
-
.back-to-top-wrap.is-open .back-to-top-index { display: block; }
|
|
174
168
|
@media (hover: hover) {
|
|
175
169
|
.back-to-top-wrap:hover .back-to-top-index { display: block; }
|
|
176
170
|
}
|
|
@@ -2,25 +2,4 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
document.documentElement.classList.add("js-enabled");
|
|
5
|
-
|
|
6
|
-
// 목차는 hover 만으로는 안 열린다. 클릭으로 연다.
|
|
7
|
-
var wrap = document.querySelector(".back-to-top-wrap");
|
|
8
|
-
var toggle = wrap && wrap.querySelector(".back-to-top-toggle");
|
|
9
|
-
if (!wrap || !toggle) return;
|
|
10
|
-
|
|
11
|
-
function setOpen(open) {
|
|
12
|
-
wrap.classList.toggle("is-open", open);
|
|
13
|
-
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
toggle.addEventListener("click", function (event) {
|
|
17
|
-
event.stopPropagation();
|
|
18
|
-
setOpen(!wrap.classList.contains("is-open"));
|
|
19
|
-
});
|
|
20
|
-
document.addEventListener("click", function (event) {
|
|
21
|
-
if (!wrap.contains(event.target)) setOpen(false);
|
|
22
|
-
});
|
|
23
|
-
document.addEventListener("keydown", function (event) {
|
|
24
|
-
if (event.key === "Escape") setOpen(false);
|
|
25
|
-
});
|
|
26
5
|
})();
|
|
@@ -116,10 +116,7 @@
|
|
|
116
116
|
</footer>{% endif %}
|
|
117
117
|
<div class="back-to-top-wrap">
|
|
118
118
|
<nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
|
|
119
|
-
<
|
|
120
|
-
<button type="button" class="back-to-top-toggle" aria-expanded="false" aria-controls="back-to-top-index">{{ t('base.contents') }}</button>
|
|
121
|
-
<a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
|
|
122
|
-
</div>
|
|
119
|
+
<a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
|
|
123
120
|
</div>
|
|
124
121
|
<script id="run-meta" type="application/json">{{ {
|
|
125
122
|
"task-key": runMeta.task_key,
|
|
@@ -85,12 +85,14 @@
|
|
|
85
85
|
{% endif %}
|
|
86
86
|
|
|
87
87
|
{# The verification checklist, cross-project dependencies, migration risk,
|
|
88
|
-
rollback strategy
|
|
89
|
-
and the auditor's working material, not the
|
|
90
|
-
deciding whether to greenlight the plan, and none
|
|
91
|
-
decision. They stay in the markdown report, which
|
|
92
|
-
the audit trail read. Requirement coverage is the
|
|
93
|
-
actually do what the brief asked" is the
|
|
88
|
+
rollback strategy, plan-body verification rounds, and the agent-activity
|
|
89
|
+
ledger are the implementer's and the auditor's working material, not the
|
|
90
|
+
approver's: this reader is deciding whether to greenlight the plan, and none
|
|
91
|
+
of those tables move that decision. They stay in the markdown report, which
|
|
92
|
+
is what the next phase and the audit trail read. Requirement coverage is the
|
|
93
|
+
exception — "does this plan actually do what the brief asked" is the
|
|
94
|
+
approval question itself. A-NNN citations still need an id in this document
|
|
95
|
+
or the checkRef links land nowhere. #}
|
|
94
96
|
<section data-report-section="requirement-coverage" data-report-field="implementationPlanning.requirementCoverage">
|
|
95
97
|
<h2>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h2>
|
|
96
98
|
{% if planning.get("planningContract") == "selected-direction" %}
|
|
@@ -134,28 +136,7 @@
|
|
|
134
136
|
{% endif %}
|
|
135
137
|
|
|
136
138
|
{% if agentActivities %}
|
|
137
|
-
<
|
|
138
|
-
<h2>{{ t('tasks.implementation-planning.what-each-agent-did') }}</h2>
|
|
139
|
-
<div class="activity-list">
|
|
140
|
-
{% for row in agentActivities %}
|
|
141
|
-
<article class="activity-card" id="id-{{ row.activityId }}">
|
|
142
|
-
<p class="eyebrow">{{ row.activityId | inline_code }} · {{ row.agent }}</p>
|
|
143
|
-
<h3>{{ row.summary | inline_code }}</h3>
|
|
144
|
-
<p>{{ t('tasks.implementation-planning.result') }}: {{ row.outcome | inline_code }}</p>
|
|
145
|
-
<details>
|
|
146
|
-
<summary>{{ t('tasks.implementation-planning.commands-and-evidence') }}</summary>
|
|
147
|
-
<p><code>{{ row.kind }}</code></p>
|
|
148
|
-
{% if row.planItemIds %}<p>{{ row.planItemIds | join(', ') | inline_code }}</p>{% endif %}
|
|
149
|
-
{% for command in row.commands %}
|
|
150
|
-
<p><code>{{ command.command }}</code> · exit {{ command.exitCode }} · {{ command.outputSummary | inline_code }}</p>
|
|
151
|
-
{% endfor %}
|
|
152
|
-
{% if row.evidenceRefs %}<p>{{ row.evidenceRefs | join(', ') | inline_code }}</p>{% endif %}
|
|
153
|
-
{% if row.resultPath %}<p><code>{{ row.resultPath }}</code></p>{% endif %}
|
|
154
|
-
</details>
|
|
155
|
-
</article>
|
|
156
|
-
{% endfor %}
|
|
157
|
-
</div>
|
|
158
|
-
</section>
|
|
139
|
+
<div hidden>{% for row in agentActivities %}<span id="id-{{ row.activityId }}"></span>{% endfor %}</div>
|
|
159
140
|
{% endif %}
|
|
160
141
|
|
|
161
142
|
<section data-report-section="open-decisions">
|
|
@@ -102,8 +102,12 @@ if not process.stdout.strip():
|
|
|
102
102
|
payload = json.loads(process.stdout)
|
|
103
103
|
actual_status = payload.get("validationStatus")
|
|
104
104
|
if actual_status != expected_status:
|
|
105
|
+
extra = ""
|
|
106
|
+
if expected_status == "passed":
|
|
107
|
+
listed = payload.get("failures") or []
|
|
108
|
+
extra = "\n" + "\n".join(str(item) for item in listed)
|
|
105
109
|
raise SystemExit(
|
|
106
|
-
f"validator status mismatch: expected {expected_status}, got {actual_status}"
|
|
110
|
+
f"validator status mismatch: expected {expected_status}, got {actual_status}{extra}"
|
|
107
111
|
)
|
|
108
112
|
|
|
109
113
|
if expected_status == "passed" and process.returncode != 0:
|
|
@@ -40,6 +40,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
|
|
|
40
40
|
sys.path.insert(0, str(_ssot_dir))
|
|
41
41
|
|
|
42
42
|
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
43
|
+
STRUCTURED_REPORT_VERSIONS,
|
|
43
44
|
parse_clarification_items,
|
|
44
45
|
section_1_present_but_unparsed,
|
|
45
46
|
)
|
|
@@ -87,7 +88,7 @@ def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
|
|
|
87
88
|
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
88
89
|
except (OSError, json.JSONDecodeError):
|
|
89
90
|
return None
|
|
90
|
-
if isinstance(data, dict) and data.get("schemaVersion")
|
|
91
|
+
if isinstance(data, dict) and data.get("schemaVersion") in STRUCTURED_REPORT_VERSIONS:
|
|
91
92
|
return data_path, data
|
|
92
93
|
return None
|
|
93
94
|
|