okstra 0.18.2 → 0.18.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.18.2",
3
- "builtAt": "2026-05-13T13:51:20.597Z",
2
+ "package": "0.18.3",
3
+ "builtAt": "2026-05-13T13:59:58.844Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ from datetime import datetime
5
6
  from pathlib import Path
6
7
  from .blocks import na_block, usage_block
7
8
  from .claude import claude_session_totals, find_claude_team_sessions
@@ -11,6 +12,76 @@ from .paths import claude_project_dir, utc_now
11
12
  from .pricing import codex_cost_usd, gemini_cost_usd
12
13
 
13
14
 
15
+ def match_prefixes(worker_id: str) -> list[str]:
16
+ """Return the agentName prefixes that should be attributed to ``worker_id``.
17
+
18
+ The Agent harness records the `name` arg on every dispatch as `agentName`
19
+ in the subagent jsonl. Lead frequently appends suffixes (`-002`,
20
+ `-reverify-r1`, `-impl`, `-2`) when it dispatches the same role multiple
21
+ times or in different sub-flows. We treat every `agentName` matching one of
22
+ these prefixes — either exactly or as `<prefix>-<suffix>` — as belonging
23
+ to this worker so its tokens get aggregated. For implementation runs the
24
+ executor variant `<provider>-executor` is also attributed back to the
25
+ matching provider worker.
26
+ """
27
+ if not worker_id:
28
+ return []
29
+ if worker_id == "report-writer":
30
+ return ["report-writer"]
31
+ prefixes = [worker_id]
32
+ if not worker_id.endswith("-worker"):
33
+ prefixes.append(f"{worker_id}-worker")
34
+ prefixes.append(f"{worker_id}-executor")
35
+ return prefixes
36
+
37
+
38
+ def agent_matches(agent_name: str, prefixes: list[str]) -> bool:
39
+ if not agent_name:
40
+ return False
41
+ for prefix in prefixes:
42
+ if agent_name == prefix or agent_name.startswith(f"{prefix}-"):
43
+ return True
44
+ return False
45
+
46
+
47
+ def _aggregate_totals(items: list[dict]) -> dict:
48
+ """Sum token + tool counters across multiple session totals dicts.
49
+
50
+ `startedAt` / `endedAt` collapse to the union window; `durationMs` is
51
+ recomputed from that window so re-tries and convergence rounds count
52
+ against a single contiguous span. `model` and `agentName` keep the first
53
+ non-empty value (the canonical role identity).
54
+ """
55
+ aggregate: dict = {
56
+ "totalTokens": 0, "inputTokens": 0, "outputTokens": 0,
57
+ "cacheCreationTokens": 0, "cacheReadTokens": 0,
58
+ "toolUses": 0, "durationMs": 0,
59
+ "agentName": None, "model": None,
60
+ "startedAt": None, "endedAt": None,
61
+ }
62
+ for t in items:
63
+ for k in ("totalTokens", "inputTokens", "outputTokens",
64
+ "cacheCreationTokens", "cacheReadTokens", "toolUses"):
65
+ aggregate[k] += t.get(k, 0) or 0
66
+ if aggregate["agentName"] is None and t.get("agentName"):
67
+ aggregate["agentName"] = t["agentName"]
68
+ if aggregate["model"] is None and t.get("model"):
69
+ aggregate["model"] = t["model"]
70
+ s, e = t.get("startedAt"), t.get("endedAt")
71
+ if s and (aggregate["startedAt"] is None or s < aggregate["startedAt"]):
72
+ aggregate["startedAt"] = s
73
+ if e and (aggregate["endedAt"] is None or e > aggregate["endedAt"]):
74
+ aggregate["endedAt"] = e
75
+ if aggregate["startedAt"] and aggregate["endedAt"]:
76
+ try:
77
+ a = datetime.fromisoformat(aggregate["startedAt"].replace("Z", "+00:00"))
78
+ b = datetime.fromisoformat(aggregate["endedAt"].replace("Z", "+00:00"))
79
+ aggregate["durationMs"] = max(0, int((b - a).total_seconds() * 1000))
80
+ except ValueError:
81
+ pass
82
+ return aggregate
83
+
84
+
14
85
  def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
15
86
  state = json.loads(team_state_path.read_text())
16
87
  cwd = project_root or _infer_project_root(team_state_path, state)
@@ -26,19 +97,20 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
26
97
  team_name = f"okstra-{task_id}" if task_id else ""
27
98
  lead_sid = (state.get("lead") or {}).get("sessionId")
28
99
 
29
- # 1) Claude sessions (lead + claude-side workers).
100
+ # 1) Claude sessions (lead + claude-side workers). Cache totals at scan
101
+ # time so we don't re-read the jsonl when a worker matches multiple
102
+ # sessions.
30
103
  claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid)
31
- by_agent: dict[str, tuple[str, Path]] = {}
104
+ by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
32
105
  lead_path: Path | None = None
33
106
  for sid, path in claude_sessions.items():
34
107
  if sid == lead_sid:
35
108
  lead_path = path
36
109
  continue
37
- # Read agentName lazily.
38
110
  totals = claude_session_totals(path)
39
111
  agent = totals.get("agentName")
40
112
  if agent:
41
- by_agent[agent] = (sid, path)
113
+ by_agent.setdefault(agent, []).append((sid, path, totals))
42
114
 
43
115
  # Lead.
44
116
  if lead_path is not None:
@@ -50,35 +122,39 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
50
122
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
51
123
  )
52
124
 
53
- # Workers.
125
+ # Workers — match by prefix and aggregate every session that belongs to
126
+ # the same role (re-dispatches with `-002`, convergence `-reverify-r1`,
127
+ # implementation `-executor`, report-writer `-impl` / `-2`, etc.).
54
128
  for worker in state.get("workers", []):
55
129
  worker_id = worker.get("workerId")
56
130
  agent = worker.get("agent")
57
- # Subagent agentName convention: workerId itself is "claude" but agentName is "claude-worker"; report-writer == "report-writer".
58
- agent_name_candidates = []
59
- if worker_id:
60
- agent_name_candidates.append(worker_id)
61
- if not worker_id.endswith("-worker") and worker_id != "report-writer":
62
- agent_name_candidates.append(f"{worker_id}-worker")
63
-
64
- # Claude wrapper jsonl (also for codex/gemini-worker, since these are Claude subagents).
65
- wrapper = None
66
- for cand in agent_name_candidates:
67
- if cand in by_agent:
68
- wrapper = by_agent[cand]
69
- break
70
- if wrapper is None:
71
- worker["usage"] = na_block(f"no Claude subagent jsonl found with agentName matching {agent_name_candidates}")
131
+ prefixes = match_prefixes(worker_id) if worker_id else []
132
+
133
+ matched: list[tuple[str, Path, dict]] = []
134
+ for agent_name, entries in by_agent.items():
135
+ if agent_matches(agent_name, prefixes):
136
+ matched.extend(entries)
137
+
138
+ if not matched:
139
+ worker["usage"] = na_block(
140
+ f"no Claude subagent jsonl found with agentName matching prefixes {prefixes}"
141
+ )
72
142
  continue
73
- sid, path = wrapper
74
- totals = claude_session_totals(path)
75
- block = usage_block(totals, source="claude-jsonl")
76
- block["sessionId"] = sid
77
143
 
78
- # For codex/gemini workers, also try to find the underlying CLI session
79
- # within the wrapper subagent's time window.
80
- wrapper_start = totals.get("startedAt") or ""
81
- wrapper_end = totals.get("endedAt") or ""
144
+ # Stable order by startedAt so the "primary" session is the first one.
145
+ matched.sort(key=lambda x: x[2].get("startedAt") or "")
146
+ primary_sid, _primary_path, _primary_totals = matched[0]
147
+ aggregate = _aggregate_totals([t for _, _, t in matched])
148
+ block = usage_block(aggregate, source="claude-jsonl")
149
+ block["sessionId"] = primary_sid
150
+ if len(matched) > 1:
151
+ block["additionalSessionIds"] = [sid for sid, _, _ in matched[1:]]
152
+ block["matchedAgentNames"] = sorted({t.get("agentName") for _, _, t in matched if t.get("agentName")})
153
+
154
+ # For codex/gemini workers, find every CLI session that fell inside the
155
+ # aggregated wrapper window.
156
+ wrapper_start = aggregate.get("startedAt") or ""
157
+ wrapper_end = aggregate.get("endedAt") or ""
82
158
  if agent in ("codex", "gemini"):
83
159
  if agent == "codex":
84
160
  cli = find_codex_session(cwd, wrapper_start, wrapper_end)
@@ -90,7 +90,7 @@ Behaviour contract:
90
90
  After the spawner completes, the report-writer worker MUST update Section 6 ("Recommended Next Steps") to list every newly created task-key together with its entry command, so the user can pick the follow-up up immediately:
91
91
 
92
92
  ```
93
- - Follow-up: `<task-group>/<new-task-id>` — `okstra --task-key <task-group>/<new-task-id> --task-type <suggested>`
93
+ - Follow-up: `<task-group>/<new-task-id>` — Claude Code 세션 안 `/okstra-run task-key=<task-group>/<new-task-id> task-type=<suggested>` / 별도 터미널 `scripts/okstra.sh --task-key <task-group>/<new-task-id> --task-type <suggested>`
94
94
  ```
95
95
 
96
96
  ## Phase 7 token-usage collector (BLOCKING)
@@ -13,8 +13,12 @@
13
13
  > 다음 `implementation` run은 아래 체크박스가 `[x]`로 표시되어 있을 때에만 진입할 수 있습니다 (`okstra_ctl.run._validate_approved_plan` 가 이 마커를 line-anchored 정규식으로 검사하여 통과/거부합니다). 본문(`Sections 1`–`4.5`)을 끝까지 읽고, `4.5.9 Open Questions`가 비어 있거나 모두 해소된 뒤 승인해 주세요.
14
14
 
15
15
  - 승인 여부 (사용자가 직접 편집): `- [ ] Approved` ← 승인하려면 `[ ]` 를 `[x]` 로 변경하여 저장하세요.
16
- - 승인 후 다음 단계 명령어 (방법 A — 수동 편집): `okstra --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로>`
17
- - 승인 + 실행 번에 (방법 B — CLI 자체를 승인 의사로): `okstra --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로> --approve`
16
+ - 승인 후 다음 단계 명령어 (방법 A — 수동 편집):
17
+ - Claude Code 세션 안: `/okstra-run task-key={{TASK_KEY}} task-type=implementation approved-plan=<이 보고서 경로>`
18
+ - 별도 터미널: `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로>`
19
+ - 승인 + 실행 한 번에 (방법 B — 진입 명령 자체를 승인 의사로):
20
+ - Claude Code 세션 안: `/okstra-run task-key={{TASK_KEY}} task-type=implementation approved-plan=<이 보고서 경로> approve`
21
+ - 별도 터미널: `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로> --approve`
18
22
  - 방법 B 는 `--approve` 입력 행위 자체를 승인 의사로 모델링합니다. 런타임이 본 블록의 체크박스를 자동으로 `[x]` 로 바꾸고, 본 섹션 하단에 `승인 일시 (CLI ack): <ISO8601>` audit 라인을 한 줄 덧붙입니다.
19
23
  - 승인을 보류하거나 거부하려면 체크박스는 `[ ]` 로 두고 `--approve` 도 사용하지 마세요. 필요한 변경 사항은 `4.5.9 Open Questions` 또는 `Section 5 Clarification Requests` 에 기록한 뒤 같은 phase 를 재실행해 주세요.
20
24
 
@@ -270,7 +274,11 @@ H1 이 `skip` 이거나 H3 가 `cancel` 인 경우, 본 섹션 다음의 4.6.4 ~
270
274
  - `resolved`: 다음 run에서 lead가 답변을 받아 검증을 마쳤습니다.
271
275
  - `obsolete`: 이후 분석 결과로 더 이상 필요 없어진 항목입니다.
272
276
 
273
- 이 보고서에 답을 채우신 뒤에는 `okstra --resume-clarification --task-key {{TASK_KEY}}` 한 줄로 같은 phase를 다시 실행하실 수 있습니다(자동으로 `$EDITOR`가 이 파일을 열고, 저장하면 같은 phase가 `--clarification-response`로 carry-in 되어 재실행됩니다). 스크립트로 자동화하실 때는 기존 형식 `okstra --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}} --clarification-response <이 파일 경로>`도 그대로 사용하실 수 있습니다.
277
+ 이 보고서에 답을 채우신 뒤에는 한 줄로 같은 phase를 다시 실행하실 수 있습니다(자동으로 `$EDITOR`가 이 파일을 열고, 저장하면 같은 phase가 `--clarification-response`로 carry-in 되어 재실행됩니다).
278
+ - Claude Code 세션 안: `/okstra-run resume-clarification task-key={{TASK_KEY}}`
279
+ - 별도 터미널: `scripts/okstra.sh --resume-clarification --task-key {{TASK_KEY}}`
280
+
281
+ 스크립트로 자동화하실 때는 셸 형식 `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}} --clarification-response <이 파일 경로>`도 그대로 사용하실 수 있습니다. Node `okstra` admin CLI 는 `--task-key`/`--task-type`/`--resume-clarification` 을 받지 않으므로 위 두 진입점 중 하나를 사용하세요.
274
282
 
275
283
  ### 5.1 추가 자료 요청 (Additional Materials Requested)
276
284
 
@@ -298,16 +306,22 @@ H1 이 `skip` 이거나 H3 가 `cancel` 인 경우, 본 섹션 다음의 4.6.4 ~
298
306
 
299
307
  This section is **always present** in every final report — never omit the heading. If there are no concrete actions to take, write the single line `- No further action required. Final verdict in section 2 stands.` under the heading and stop.
300
308
 
301
- When concrete actions exist, list them as a numbered list using the rules below. Each item must include the exact command(s) the user can copy-paste. Prefer the `--task-key` shorthand for follow-up runs and `--resume-clarification` for clarification answer turn-arounds; show the equivalent full-args form only when useful.
309
+ When concrete actions exist, list them as a numbered list using the rules below. Each item must include the exact command(s) the user can copy-paste. Show **both** the Claude Code in-session form (`/okstra-run …`) and the external-terminal shell form (`scripts/okstra.sh …`) — the Node `okstra` admin CLI does NOT accept `--task-key` / `--task-type` / `--resume-clarification`. Prefer the `task-key` shorthand for follow-up runs and `resume-clarification` for clarification answer turn-arounds; show the equivalent full-args form only when useful.
302
310
 
303
311
  1. **Highest-priority next action.** State what to do and why in one sentence, then the command. Example shortcut forms:
304
- - Same phase rerun: `okstra --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}}`
305
- - Next phase: `okstra --task-key {{TASK_KEY}} --task-type <next-phase>` (omit `--task-type` to use the manifest's `workflow.nextRecommendedPhase` automatically when it is a concrete phase, not `pending-routing-decision` / `done-or-follow-up`).
312
+ - Same phase rerun:
313
+ - Claude Code 세션 안: `/okstra-run task-key={{TASK_KEY}} task-type={{TASK_TYPE}}`
314
+ - 별도 터미널: `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}}`
315
+ - Next phase (omit `task-type` to use the manifest's `workflow.nextRecommendedPhase` automatically when it is a concrete phase, not `pending-routing-decision` / `done-or-follow-up`):
316
+ - Claude Code 세션 안: `/okstra-run task-key={{TASK_KEY}} task-type=<next-phase>`
317
+ - 별도 터미널: `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type <next-phase>`
306
318
  2. **Additional verification needed before implementation or release.** List read-only checks (test commands, log queries, dashboard URLs) that the user should run before moving to the next phase. No state-mutating commands here.
307
319
  3. **Follow-up tasks or related tasks if needed.** Reference them by `task-key` when they already exist; otherwise describe the new brief to draft.
308
320
  4. **If section 5 has any `open` rows**, the highest-priority next step MUST be the clarification turn-around. Show both forms:
309
- - Preferred (interactive): `okstra --resume-clarification --task-key {{TASK_KEY}}` — opens this file in `$EDITOR`, then auto-reruns the same phase with `--clarification-response` carry-in.
310
- - Scripted: `okstra --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}} --clarification-response <path-to-this-file-after-editing>`.
321
+ - Preferred (interactive) — opens this file in `$EDITOR`, then auto-reruns the same phase with `--clarification-response` carry-in:
322
+ - Claude Code 세션 안: `/okstra-run resume-clarification task-key={{TASK_KEY}}`
323
+ - 별도 터미널: `scripts/okstra.sh --resume-clarification --task-key {{TASK_KEY}}`
324
+ - Scripted: `scripts/okstra.sh --task-key {{TASK_KEY}} --task-type {{TASK_TYPE}} --clarification-response <path-to-this-file-after-editing>`.
311
325
 
312
326
  Empty-state placeholder, copy verbatim when nothing else applies:
313
327