okstra 0.76.0 → 0.78.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.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +1 -117
- package/docs/contributor-change-matrix.md +12 -0
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +22 -5
- package/docs/pr-template-usage.md +3 -3
- package/docs/project-structure-overview.md +1 -1
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
- package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
- package/package.json +5 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/DO_NOT_EDIT.md +21 -0
- package/runtime/agents/SKILL.md +3 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra-trace-cleanup.sh +16 -12
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/forbidden-actions.json +69 -0
- package/runtime/prompts/profiles/implementation.md +1 -8
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +3 -17
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
- package/runtime/python/okstra_ctl/context_cost.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
- package/runtime/python/okstra_ctl/doctor.py +292 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +211 -29
- package/runtime/python/okstra_ctl/run.py +89 -18
- package/runtime/python/okstra_ctl/team.py +267 -0
- package/runtime/python/okstra_ctl/tmux.py +181 -10
- package/runtime/python/okstra_ctl/workflow.py +30 -71
- package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
- package/runtime/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- package/runtime/schemas/final-report-v1.0.schema.json +3 -1
- package/runtime/skills/okstra-convergence/SKILL.md +3 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
- package/runtime/validators/forbidden_actions.py +135 -0
- package/runtime/validators/validate-run.py +112 -22
- package/runtime/validators/validate_session_conformance.py +127 -5
- package/src/cli-registry.mjs +277 -0
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +130 -5
- package/src/install.mjs +123 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +29 -0
- package/src/team.mjs +63 -0
- package/src/uninstall.mjs +27 -4
- /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Reader for okstra worker wrapper status sidecars."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class WrapperStatus:
|
|
12
|
+
path: Path
|
|
13
|
+
stage: str
|
|
14
|
+
exit_code: int | None
|
|
15
|
+
timeout: bool
|
|
16
|
+
log_path: Path | None
|
|
17
|
+
raw: Mapping[str, Any]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_terminal(self) -> bool:
|
|
21
|
+
return self.stage == "exited"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def status_path_for_prompt(prompt_path: Path) -> Path:
|
|
25
|
+
return prompt_path.with_suffix(prompt_path.suffix + ".status.json")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_wrapper_status(path: Path) -> WrapperStatus | None:
|
|
29
|
+
try:
|
|
30
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
except (OSError, json.JSONDecodeError):
|
|
32
|
+
return None
|
|
33
|
+
if not isinstance(raw, dict):
|
|
34
|
+
return None
|
|
35
|
+
stage = raw.get("stage")
|
|
36
|
+
if not isinstance(stage, str) or not stage:
|
|
37
|
+
return None
|
|
38
|
+
return WrapperStatus(
|
|
39
|
+
path=path,
|
|
40
|
+
stage=stage,
|
|
41
|
+
exit_code=_optional_int(raw.get("exit_code")),
|
|
42
|
+
timeout=raw.get("timeout") is True,
|
|
43
|
+
log_path=_optional_path(raw.get("log_path")),
|
|
44
|
+
raw=raw,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _optional_int(value: object) -> int | None:
|
|
49
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _optional_path(value: object) -> Path | None:
|
|
53
|
+
if not isinstance(value, str) or not value.strip():
|
|
54
|
+
return None
|
|
55
|
+
return Path(value)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from .jsonl_io import iter_jsonl
|
|
7
|
-
from .paths import CODEX_SESSIONS
|
|
7
|
+
from .paths import CODEX_SESSIONS, ts_in_window
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def codex_session_total(jsonl_path: Path) -> dict:
|
|
@@ -47,9 +47,15 @@ def codex_session_total(jsonl_path: Path) -> dict:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
|
|
50
|
-
"""Find the codex rollout jsonl
|
|
50
|
+
"""Find the latest codex rollout jsonl in the requested window."""
|
|
51
|
+
sessions = find_codex_sessions(cwd, started_at, ended_at)
|
|
52
|
+
return sessions[-1] if sessions else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_codex_sessions(cwd: Path, started_at: str, ended_at: str) -> list[Path]:
|
|
56
|
+
"""Find codex rollout jsonls whose meta.cwd matches the window."""
|
|
51
57
|
if not CODEX_SESSIONS.is_dir() or not started_at or not ended_at:
|
|
52
|
-
return
|
|
58
|
+
return []
|
|
53
59
|
target_cwd = str(cwd)
|
|
54
60
|
candidates: list[tuple[str, Path]] = []
|
|
55
61
|
for p in CODEX_SESSIONS.rglob("rollout-*.jsonl"):
|
|
@@ -70,11 +76,10 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
|
|
|
70
76
|
if payload.get("cwd") != target_cwd:
|
|
71
77
|
continue
|
|
72
78
|
ts = payload.get("timestamp") or rec.get("timestamp") or ""
|
|
73
|
-
if not (
|
|
79
|
+
if not ts_in_window(ts, started_at, ended_at):
|
|
74
80
|
continue
|
|
75
81
|
candidates.append((ts, p))
|
|
76
82
|
if not candidates:
|
|
77
|
-
return
|
|
83
|
+
return []
|
|
78
84
|
candidates.sort()
|
|
79
|
-
return candidates
|
|
80
|
-
|
|
85
|
+
return [path for _ts, path in candidates]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
from okstra_project.dirs import OKSTRA_RELATIVE
|
|
8
9
|
|
|
9
10
|
from .blocks import na_block, usage_block
|
|
@@ -12,8 +13,8 @@ from .claude import (
|
|
|
12
13
|
find_claude_agent_sessions,
|
|
13
14
|
find_claude_team_sessions,
|
|
14
15
|
)
|
|
15
|
-
from .codex import codex_session_total, find_codex_session
|
|
16
|
-
from .gemini import find_gemini_session, gemini_session_total
|
|
16
|
+
from .codex import codex_session_total, find_codex_session, find_codex_sessions
|
|
17
|
+
from .gemini import find_gemini_session, find_gemini_sessions, gemini_session_total
|
|
17
18
|
from .paths import claude_project_dir, utc_now
|
|
18
19
|
from .pricing import codex_cost_usd, gemini_cost_usd
|
|
19
20
|
|
|
@@ -64,6 +65,8 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
64
65
|
"totalTokens": 0, "inputTokens": 0, "outputTokens": 0,
|
|
65
66
|
"cacheCreationTokens": 0, "cacheCreation5mTokens": 0, "cacheCreation1hTokens": 0,
|
|
66
67
|
"cacheReadTokens": 0,
|
|
68
|
+
"cachedInputTokens": 0, "reasoningOutputTokens": 0,
|
|
69
|
+
"cachedTokens": 0, "thoughtsTokens": 0, "toolTokens": 0,
|
|
67
70
|
"toolUses": 0, "durationMs": 0,
|
|
68
71
|
"agentName": None, "model": None,
|
|
69
72
|
"startedAt": None, "endedAt": None,
|
|
@@ -71,7 +74,8 @@ def _aggregate_totals(items: list[dict]) -> dict:
|
|
|
71
74
|
for t in items:
|
|
72
75
|
for k in ("totalTokens", "inputTokens", "outputTokens",
|
|
73
76
|
"cacheCreationTokens", "cacheCreation5mTokens", "cacheCreation1hTokens",
|
|
74
|
-
"cacheReadTokens", "
|
|
77
|
+
"cacheReadTokens", "cachedInputTokens", "reasoningOutputTokens",
|
|
78
|
+
"cachedTokens", "thoughtsTokens", "toolTokens", "toolUses"):
|
|
75
79
|
aggregate[k] += t.get(k, 0) or 0
|
|
76
80
|
if aggregate["agentName"] is None and t.get("agentName"):
|
|
77
81
|
aggregate["agentName"] = t["agentName"]
|
|
@@ -207,6 +211,233 @@ def resolve_team_name(state: dict) -> str:
|
|
|
207
211
|
return team_name
|
|
208
212
|
|
|
209
213
|
|
|
214
|
+
def _resolve_project_path(project_root: Path, raw_path: str) -> Path | None:
|
|
215
|
+
if not raw_path:
|
|
216
|
+
return None
|
|
217
|
+
path = Path(raw_path)
|
|
218
|
+
return path if path.is_absolute() else project_root / path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_lead_event_records(project_root: Path, state: dict) -> list[dict]:
|
|
222
|
+
path = _resolve_project_path(project_root, state.get("leadEventsPath", ""))
|
|
223
|
+
if path is None or not path.is_file():
|
|
224
|
+
return []
|
|
225
|
+
records = []
|
|
226
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
227
|
+
if not line.strip():
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
record = json.loads(line)
|
|
231
|
+
except json.JSONDecodeError:
|
|
232
|
+
continue
|
|
233
|
+
if isinstance(record, dict):
|
|
234
|
+
records.append(record)
|
|
235
|
+
return records
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _codex_worker_windows(project_root: Path, state: dict) -> dict[str, list[tuple[str, str]]]:
|
|
239
|
+
active: dict[tuple[str, str], str] = {}
|
|
240
|
+
windows: dict[str, list[tuple[str, str]]] = {}
|
|
241
|
+
for event in _load_lead_event_records(project_root, state):
|
|
242
|
+
details = event.get("details") or {}
|
|
243
|
+
if not isinstance(details, dict):
|
|
244
|
+
continue
|
|
245
|
+
worker_id = str(details.get("workerId") or "").strip()
|
|
246
|
+
if not worker_id:
|
|
247
|
+
continue
|
|
248
|
+
attempt = str(details.get("attempt") or "")
|
|
249
|
+
key = (worker_id, attempt)
|
|
250
|
+
event_type = event.get("eventType")
|
|
251
|
+
timestamp = str(event.get("timestamp") or "").strip()
|
|
252
|
+
if not timestamp:
|
|
253
|
+
continue
|
|
254
|
+
if event_type == "worker-dispatched":
|
|
255
|
+
active[key] = timestamp
|
|
256
|
+
elif event_type in {"worker-result-collected", "worker-failed"}:
|
|
257
|
+
started_at = active.pop(key, "")
|
|
258
|
+
if started_at:
|
|
259
|
+
windows.setdefault(worker_id, []).append((started_at, timestamp))
|
|
260
|
+
running_workers = {
|
|
261
|
+
str(worker.get("workerId") or "").strip()
|
|
262
|
+
for worker in state.get("workers", [])
|
|
263
|
+
if isinstance(worker, dict) and worker.get("status") == "running"
|
|
264
|
+
}
|
|
265
|
+
if running_workers:
|
|
266
|
+
open_until = utc_now()
|
|
267
|
+
for (worker_id, _attempt), started_at in active.items():
|
|
268
|
+
if worker_id in running_workers:
|
|
269
|
+
windows.setdefault(worker_id, []).append((started_at, open_until))
|
|
270
|
+
return windows
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _codex_runtime_provider(worker: dict) -> str:
|
|
274
|
+
worker_id = str(worker.get("workerId") or "").strip()
|
|
275
|
+
agent = str(worker.get("agent") or "").strip()
|
|
276
|
+
if worker_id == "report-writer":
|
|
277
|
+
return "codex"
|
|
278
|
+
if agent in {"codex", "gemini"}:
|
|
279
|
+
return agent
|
|
280
|
+
return ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cli_sessions_for_windows(
|
|
284
|
+
provider: str,
|
|
285
|
+
project_root: Path,
|
|
286
|
+
windows: list[tuple[str, str]],
|
|
287
|
+
) -> list[Path]:
|
|
288
|
+
sessions: list[Path] = []
|
|
289
|
+
seen: set[Path] = set()
|
|
290
|
+
for started_at, ended_at in windows:
|
|
291
|
+
if provider == "codex":
|
|
292
|
+
matches = find_codex_sessions(project_root, started_at, ended_at)
|
|
293
|
+
elif provider == "gemini":
|
|
294
|
+
matches = find_gemini_sessions(project_root, started_at, ended_at)
|
|
295
|
+
else:
|
|
296
|
+
matches = []
|
|
297
|
+
for path in matches:
|
|
298
|
+
if path not in seen:
|
|
299
|
+
seen.add(path)
|
|
300
|
+
sessions.append(path)
|
|
301
|
+
return sessions
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _cli_session_totals(provider: str, session_paths: list[Path]) -> list[dict]:
|
|
305
|
+
totals = []
|
|
306
|
+
for session_path in session_paths:
|
|
307
|
+
if provider == "codex":
|
|
308
|
+
total = codex_session_total(session_path)
|
|
309
|
+
else:
|
|
310
|
+
total = gemini_session_total(session_path)
|
|
311
|
+
if total.get("available"):
|
|
312
|
+
totals.append(total)
|
|
313
|
+
return totals
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _cli_usage_block(provider: str, totals: dict, session_paths: list[Path]) -> dict:
|
|
317
|
+
block = usage_block(totals, source=f"{provider}-cli")
|
|
318
|
+
block["cliTotalTokens"] = totals.get("totalTokens", 0) or 0
|
|
319
|
+
block["cliSessionPaths"] = [str(path) for path in session_paths]
|
|
320
|
+
if totals.get("model"):
|
|
321
|
+
block["model"] = totals["model"]
|
|
322
|
+
block["cliModel"] = totals["model"]
|
|
323
|
+
if provider == "codex":
|
|
324
|
+
cost = codex_cost_usd(
|
|
325
|
+
totals.get("model"),
|
|
326
|
+
totals.get("inputTokens", 0) or 0,
|
|
327
|
+
totals.get("cachedInputTokens", 0) or 0,
|
|
328
|
+
totals.get("outputTokens", 0) or 0,
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
cost = gemini_cost_usd(
|
|
332
|
+
totals.get("model"),
|
|
333
|
+
totals.get("inputTokens", 0) or 0,
|
|
334
|
+
totals.get("outputTokens", 0) or 0,
|
|
335
|
+
)
|
|
336
|
+
if cost is not None:
|
|
337
|
+
block["cliEstimatedCostUsd"] = cost
|
|
338
|
+
return block
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _collect_codex_runtime_usage(state: dict, project_root: Path) -> dict:
|
|
342
|
+
windows_by_worker = _codex_worker_windows(project_root, state)
|
|
343
|
+
state["leadUsage"] = na_block(
|
|
344
|
+
"Codex lead token accounting is not available yet; worker CLI usage is collected from provider logs."
|
|
345
|
+
)
|
|
346
|
+
for worker in state.get("workers", []):
|
|
347
|
+
if not isinstance(worker, dict):
|
|
348
|
+
continue
|
|
349
|
+
provider = _codex_runtime_provider(worker)
|
|
350
|
+
if not provider:
|
|
351
|
+
worker["usage"] = na_block(
|
|
352
|
+
f"worker provider is not CLI-backed in Codex runtime: {worker.get('agent') or worker.get('workerId')}"
|
|
353
|
+
)
|
|
354
|
+
continue
|
|
355
|
+
worker_id = str(worker.get("workerId") or "").strip()
|
|
356
|
+
windows = windows_by_worker.get(worker_id, [])
|
|
357
|
+
if not windows:
|
|
358
|
+
worker["usage"] = na_block(
|
|
359
|
+
f"no lead-events dispatch window found for workerId={worker_id}"
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
session_paths = _cli_sessions_for_windows(provider, project_root, windows)
|
|
363
|
+
totals = _cli_session_totals(provider, session_paths)
|
|
364
|
+
if not totals:
|
|
365
|
+
worker["usage"] = na_block(
|
|
366
|
+
f"no {provider} CLI session found in lead-events dispatch window"
|
|
367
|
+
)
|
|
368
|
+
continue
|
|
369
|
+
worker["usage"] = _cli_usage_block(
|
|
370
|
+
provider,
|
|
371
|
+
_aggregate_totals(totals),
|
|
372
|
+
session_paths,
|
|
373
|
+
)
|
|
374
|
+
_populate_usage_summary(state, team_name=resolve_team_name(state), sessions_found=0)
|
|
375
|
+
return state
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _populate_usage_summary(
|
|
379
|
+
state: dict,
|
|
380
|
+
*,
|
|
381
|
+
team_name: str,
|
|
382
|
+
sessions_found: int,
|
|
383
|
+
unattributed_sessions: list[str] | None = None,
|
|
384
|
+
unattributed_usage: dict[str, Any] | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
workers = state.get("workers", [])
|
|
387
|
+
lead = state.get("leadUsage") or {}
|
|
388
|
+
lead_total = lead.get("totalTokens", 0) or 0
|
|
389
|
+
lead_billable = lead.get("billableEquivalentTokens", 0) or 0
|
|
390
|
+
lead_cost = lead.get("estimatedCostUsd", 0) or 0
|
|
391
|
+
worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
|
|
392
|
+
worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
|
|
393
|
+
worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
|
|
394
|
+
cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
|
|
395
|
+
if unattributed_usage is not None:
|
|
396
|
+
worker_total += unattributed_usage.get("totalTokens", 0) or 0
|
|
397
|
+
worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
|
|
398
|
+
worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
|
|
399
|
+
|
|
400
|
+
unmatched_models: list[str] = []
|
|
401
|
+
if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
|
|
402
|
+
unmatched_models.append(lead["model"])
|
|
403
|
+
for w in workers:
|
|
404
|
+
u = w.get("usage") or {}
|
|
405
|
+
if (
|
|
406
|
+
u.get("source") not in {"codex-cli", "gemini-cli"}
|
|
407
|
+
and u.get("model")
|
|
408
|
+
and u.get("estimatedCostUsd") is None
|
|
409
|
+
and (u.get("totalTokens") or 0) > 0
|
|
410
|
+
):
|
|
411
|
+
unmatched_models.append(u["model"])
|
|
412
|
+
if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
|
|
413
|
+
unmatched_models.append(u["cliModel"])
|
|
414
|
+
state["usageSummary"] = {
|
|
415
|
+
"leadTotalTokens": lead_total,
|
|
416
|
+
"workerTotalTokens": worker_total,
|
|
417
|
+
"grandTotalTokens": lead_total + worker_total,
|
|
418
|
+
"leadBillableEquivalentTokens": lead_billable,
|
|
419
|
+
"workerBillableEquivalentTokens": worker_billable,
|
|
420
|
+
"grandBillableEquivalentTokens": lead_billable + worker_billable,
|
|
421
|
+
"estimatedCostUsd": {
|
|
422
|
+
"lead": round(lead_cost, 4),
|
|
423
|
+
"claudeWorkers": round(worker_cost, 4),
|
|
424
|
+
"cliWorkers": round(cli_cost, 4),
|
|
425
|
+
"grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
|
|
426
|
+
},
|
|
427
|
+
"collectedAt": utc_now(),
|
|
428
|
+
"teamName": team_name,
|
|
429
|
+
"sessionsFound": sessions_found,
|
|
430
|
+
"unmatchedModels": sorted(set(unmatched_models)),
|
|
431
|
+
"unattributedTeamSessions": unattributed_sessions or [],
|
|
432
|
+
"unattributedWorkerUsage": unattributed_usage,
|
|
433
|
+
"definitions": {
|
|
434
|
+
"totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
|
|
435
|
+
"billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
|
|
436
|
+
"estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
210
441
|
def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
211
442
|
incremental: bool = True) -> dict:
|
|
212
443
|
# incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
|
|
@@ -218,6 +449,8 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
218
449
|
task_key = state.get("taskKey", "")
|
|
219
450
|
team_name = resolve_team_name(state)
|
|
220
451
|
lead_sid = (state.get("lead") or {}).get("sessionId")
|
|
452
|
+
if state.get("leadRuntime") == "codex":
|
|
453
|
+
return _collect_codex_runtime_usage(state, cwd)
|
|
221
454
|
|
|
222
455
|
# 1) Claude sessions (lead + claude-side workers). Cache totals at scan
|
|
223
456
|
# time so we don't re-read the jsonl when a worker matches multiple
|
|
@@ -372,56 +605,13 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
372
605
|
"cannot be mapped to a specific workerId."
|
|
373
606
|
)
|
|
374
607
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
|
|
383
|
-
worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
|
|
384
|
-
cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
|
|
385
|
-
if unattributed_usage is not None:
|
|
386
|
-
worker_total += unattributed_usage.get("totalTokens", 0) or 0
|
|
387
|
-
worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
|
|
388
|
-
worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
|
|
389
|
-
|
|
390
|
-
# Surface models whose pricing lookup failed so the silent-zero case is visible.
|
|
391
|
-
unmatched_models: list[str] = []
|
|
392
|
-
if lead.get("model") and lead.get("estimatedCostUsd") is None and (lead.get("totalTokens") or 0) > 0:
|
|
393
|
-
unmatched_models.append(lead["model"])
|
|
394
|
-
for w in workers:
|
|
395
|
-
u = w.get("usage") or {}
|
|
396
|
-
if u.get("model") and u.get("estimatedCostUsd") is None and (u.get("totalTokens") or 0) > 0:
|
|
397
|
-
unmatched_models.append(u["model"])
|
|
398
|
-
if u.get("cliModel") and u.get("cliEstimatedCostUsd") is None and (u.get("cliTotalTokens") or 0) > 0:
|
|
399
|
-
unmatched_models.append(u["cliModel"])
|
|
400
|
-
state["usageSummary"] = {
|
|
401
|
-
"leadTotalTokens": lead_total,
|
|
402
|
-
"workerTotalTokens": worker_total,
|
|
403
|
-
"grandTotalTokens": lead_total + worker_total,
|
|
404
|
-
"leadBillableEquivalentTokens": lead_billable,
|
|
405
|
-
"workerBillableEquivalentTokens": worker_billable,
|
|
406
|
-
"grandBillableEquivalentTokens": lead_billable + worker_billable,
|
|
407
|
-
"estimatedCostUsd": {
|
|
408
|
-
"lead": round(lead_cost, 4),
|
|
409
|
-
"claudeWorkers": round(worker_cost, 4),
|
|
410
|
-
"cliWorkers": round(cli_cost, 4),
|
|
411
|
-
"grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
|
|
412
|
-
},
|
|
413
|
-
"collectedAt": utc_now(),
|
|
414
|
-
"teamName": team_name,
|
|
415
|
-
"sessionsFound": len(claude_sessions),
|
|
416
|
-
"unmatchedModels": sorted(set(unmatched_models)),
|
|
417
|
-
"unattributedTeamSessions": unattributed_sessions,
|
|
418
|
-
"unattributedWorkerUsage": unattributed_usage,
|
|
419
|
-
"definitions": {
|
|
420
|
-
"totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
|
|
421
|
-
"billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
|
|
422
|
-
"estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
|
|
423
|
-
},
|
|
424
|
-
}
|
|
608
|
+
_populate_usage_summary(
|
|
609
|
+
state,
|
|
610
|
+
team_name=team_name,
|
|
611
|
+
sessions_found=len(claude_sessions),
|
|
612
|
+
unattributed_sessions=unattributed_sessions,
|
|
613
|
+
unattributed_usage=unattributed_usage,
|
|
614
|
+
)
|
|
425
615
|
return state
|
|
426
616
|
|
|
427
617
|
|
|
@@ -435,4 +625,3 @@ def _infer_project_root(team_state_path: Path, state: dict) -> Path:
|
|
|
435
625
|
return p
|
|
436
626
|
p = p.parent
|
|
437
627
|
raise SystemExit(f"could not infer project root from {team_state_path}")
|
|
438
|
-
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from .paths import GEMINI_TMP
|
|
6
|
+
from .paths import GEMINI_TMP, ts_in_window
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def gemini_session_total(json_path: Path) -> dict:
|
|
@@ -40,9 +40,15 @@ def gemini_session_total(json_path: Path) -> dict:
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
|
|
43
|
-
"""Find the gemini chat session
|
|
43
|
+
"""Find the latest gemini chat session in the requested window."""
|
|
44
|
+
sessions = find_gemini_sessions(project_root, started_at, ended_at)
|
|
45
|
+
return sessions[-1] if sessions else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_gemini_sessions(project_root: Path, started_at: str, ended_at: str) -> list[Path]:
|
|
49
|
+
"""Find gemini chat sessions whose first user message references the project."""
|
|
44
50
|
if not GEMINI_TMP.is_dir() or not started_at or not ended_at:
|
|
45
|
-
return
|
|
51
|
+
return []
|
|
46
52
|
project_str = str(project_root)
|
|
47
53
|
candidates: list[tuple[str, Path]] = []
|
|
48
54
|
for p in GEMINI_TMP.glob("*/chats/session-*.json"):
|
|
@@ -51,7 +57,7 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
|
|
|
51
57
|
except (OSError, json.JSONDecodeError):
|
|
52
58
|
continue
|
|
53
59
|
start = data.get("startTime") or ""
|
|
54
|
-
if not (
|
|
60
|
+
if not ts_in_window(start, started_at, ended_at):
|
|
55
61
|
continue
|
|
56
62
|
# Match by content: first user message should mention project root or task path.
|
|
57
63
|
msgs = data.get("messages") or []
|
|
@@ -71,7 +77,6 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
|
|
|
71
77
|
candidates.append((start, p))
|
|
72
78
|
break
|
|
73
79
|
if not candidates:
|
|
74
|
-
return
|
|
80
|
+
return []
|
|
75
81
|
candidates.sort()
|
|
76
|
-
return candidates
|
|
77
|
-
|
|
82
|
+
return [path for _start, path in candidates]
|
|
@@ -745,7 +745,7 @@
|
|
|
745
745
|
}
|
|
746
746
|
},
|
|
747
747
|
{
|
|
748
|
-
"description": "final-verification verdict token must be one of accepted / conditional-accept / blocked.",
|
|
748
|
+
"description": "final-verification verdict token must be one of accepted / conditional-accept / blocked and must explicitly list conditional acceptance conditions.",
|
|
749
749
|
"if": {
|
|
750
750
|
"properties": { "header": { "properties": { "taskType": { "const": "final-verification" } } } },
|
|
751
751
|
"required": ["header"]
|
|
@@ -753,6 +753,7 @@
|
|
|
753
753
|
"then": {
|
|
754
754
|
"properties": {
|
|
755
755
|
"finalVerdict": {
|
|
756
|
+
"required": ["conditionalAcceptanceConditions"],
|
|
756
757
|
"properties": {
|
|
757
758
|
"verdictToken": { "enum": ["accepted", "conditional-accept", "blocked"] }
|
|
758
759
|
}
|
|
@@ -775,6 +776,7 @@
|
|
|
775
776
|
"then": {
|
|
776
777
|
"properties": {
|
|
777
778
|
"finalVerdict": {
|
|
779
|
+
"not": { "required": ["conditionalAcceptanceConditions"] },
|
|
778
780
|
"properties": {
|
|
779
781
|
"verdictToken": { "const": "not-applicable" }
|
|
780
782
|
}
|
|
@@ -273,6 +273,9 @@ Agent(
|
|
|
273
273
|
- Agent Teams mode: Spawn within an existing team
|
|
274
274
|
- Fallback mode: Spawn with `run_in_background: true` and no `team_name`
|
|
275
275
|
|
|
276
|
+
For `tmux-pane` backend runs, do not use the Agent snippet. For each reverify round, write a jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json` with `dispatchKind: "reverify-r<N>"` and worker entries containing `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`; then run `okstra team dispatch --project-root <dir> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`.
|
|
277
|
+
|
|
278
|
+
|
|
276
279
|
**Completion detection per round (BLOCKING).** Each round dispatches a variable set (1..N) of reverify workers asynchronously; the `Agent(... team_name ...)` calls return `Spawned successfully` immediately, which is NOT completion. Lead MUST detect each round's completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", with the pending set reconstructed from that round's dispatched workers' Result Paths — do NOT restate the algorithm here. Lead MUST NOT treat the spawn ack as completion and MUST NOT end its turn with a prose "waiting" statement.
|
|
277
280
|
|
|
278
281
|
### Required reverify-prompt anchor headers (BLOCKING)
|
|
@@ -82,6 +82,9 @@ Except for `release-handoff` (which is single-lead by design and never dispatche
|
|
|
82
82
|
|
|
83
83
|
Speculative reasons such as "session resume constraint", "team object no longer exists", or "lead can do it faster" are NOT valid.
|
|
84
84
|
|
|
85
|
+
For `tmux-pane` backend runs, do not use the Agent template. Create a one-job jobs file for the `report-writer` with `dispatchKind: "report-writer"` and the same `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths` fields used by reverify jobs. Then call `okstra team dispatch --project-root <dir> --run-manifest <path> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`. Completion requires both the report data.json and the report-writer worker-results audit file.
|
|
86
|
+
|
|
87
|
+
|
|
85
88
|
## Phase 6 → Phase 7 execution sequence (BLOCKING order)
|
|
86
89
|
|
|
87
90
|
The four steps below MUST execute in this exact order. Reordering them is the recurring root cause of reports shipping with `--` token cells (Phase 7 not run yet), Section 3 missing follow-up entries, or Section 4 rows never spawning.
|
|
@@ -251,7 +251,7 @@ back it up as `.bak.<timestamp>` rather than overwriting silently.
|
|
|
251
251
|
|
|
252
252
|
`release-handoff` fills the PR body from a template. By default it uses the
|
|
253
253
|
bundled skill template at
|
|
254
|
-
`~/.claude/skills/
|
|
254
|
+
`~/.claude/skills/templates/prd/pr-body.template.md`. Most projects
|
|
255
255
|
want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
|
|
256
256
|
keep PRs consistent with what the team already merges manually.
|
|
257
257
|
|
|
@@ -127,6 +127,18 @@ Terminal statuses that can be recorded for a worker:
|
|
|
127
127
|
|
|
128
128
|
Lead dispatches workers asynchronously: an `Agent` call carrying `team_name` returns `Spawned successfully` **immediately** — that ack is NOT a completion. Lead MUST NOT treat the spawn ack as completion, and MUST NOT end its turn with a prose "waiting for ..." statement (that path stalls the run — the Agent Teams idle-notification is experimental and can be dropped, leaving Lead parked until the user manually nudges it). Instead:
|
|
129
129
|
|
|
130
|
+
For `tmux-pane` backend runs, Lead MUST use exactly one background wait command instead of raw result-file polling:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
okstra team await --project-root <dir> --run-manifest <path>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This command checks both result files and wrapper status sidecars. Raw result-file-only polling is forbidden for `tmux-pane` because pane creation and file presence alone are not terminal worker completion.
|
|
137
|
+
|
|
138
|
+
For the Claude Code `team` backend, use the self-scheduled polling protocol below.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
130
142
|
1. Record the dispatched workers' Result Paths as the **pending set** (resolved to absolute from each launch prompt's `**Result Path:**` anchor header against `**Project Root:**`; the same paths recorded in run-manifest / team-state).
|
|
131
143
|
2. Arm a SINGLE self-wakeup: one `Bash(run_in_background: true)` poll covering ALL dispatched workers (not one background task per worker):
|
|
132
144
|
|