okstra 0.60.0 → 0.60.2

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.60.0",
3
+ "version": "0.60.2",
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.60.0",
3
- "builtAt": "2026-06-09T03:55:13.411Z",
2
+ "package": "0.60.2",
3
+ "builtAt": "2026-06-09T06:03:11.725Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -1457,11 +1457,14 @@ def _pick_snippet(value: str, style: str) -> str:
1457
1457
  def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
1458
1458
  suggestion = spec.suggest(state)
1459
1459
  t = _p(state.workspace_root, spec.prompt_key)
1460
- options: list[Option] = [_opt(PICK_SKIP, t["options"][PICK_SKIP])]
1460
+ # 추천(이전 directive·siblings·최근 리포트·프로젝트 기본)을 가장 먼저 노출하고,
1461
+ # '건너뛰기'는 중간, '직접 입력'은 항상 마지막에 둔다 (run-prompt 추천 규칙).
1462
+ options: list[Option] = []
1461
1463
  if suggestion:
1462
1464
  snippet = _pick_snippet(suggestion, spec.snippet_style)
1463
1465
  options.append(_opt(spec.recommend_token, t["labels"][spec.label_key].format(snippet=snippet)))
1464
1466
  setattr(state, spec.cache_attr, suggestion)
1467
+ options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1465
1468
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1466
1469
  return Prompt(
1467
1470
  step=spec.step, kind="pick",
@@ -1625,9 +1628,13 @@ def _critic_provider_label(provider: str, t: dict) -> str:
1625
1628
  def _build_critic_pick(state: WizardState) -> Prompt:
1626
1629
  t = _p(state.workspace_root, "critic_pick")
1627
1630
  off_label = t["options"].get("off", "off")
1628
- options = [_opt("off", off_label)]
1629
- for provider in _critic_provider_choices():
1630
- options.append(_opt(provider, _critic_provider_label(provider, t)))
1631
+ # 추천(claude critic)을 가장 먼저, 'off'(critic 미사용)를 마지막에 둔다
1632
+ # (run-prompt 추천 규칙: 추천이 항상 첫 옵션).
1633
+ options = [
1634
+ _opt(provider, _critic_provider_label(provider, t))
1635
+ for provider in _critic_provider_choices()
1636
+ ]
1637
+ options.append(_opt("off", off_label))
1631
1638
  return Prompt(
1632
1639
  step=S_CRITIC_PICK, kind="pick",
1633
1640
  label=t["label"],
@@ -2212,14 +2219,16 @@ STEPS: list[Step] = [
2212
2219
  and S_REPORT_WRITER_MODEL not in s.answered),
2213
2220
  build=_build_report_writer_model, submit=_submit_report_writer_model,
2214
2221
  owns=("report_writer_model",)),
2222
+ # directive(이번 run 의 추가 지시)는 기본값/커스터마이즈와 무관하게 항상
2223
+ # 묻는다 — 매 run 마다 줄 수 있는 입력이므로 'Use defaults' 분기 뒤에 숨기지
2224
+ # 않는다. (use_defaults is not None: defaults_or_custom 답 이후에만 등장)
2215
2225
  Step(S_DIRECTIVE_PICK,
2216
- applies=lambda s: (s.use_defaults is False
2217
- and S_DIRECTIVE_PICK not in s.answered),
2226
+ applies=lambda s: (S_DIRECTIVE_PICK not in s.answered
2227
+ and s.use_defaults is not None),
2218
2228
  build=_build_directive_pick, submit=_submit_directive_pick,
2219
2229
  owns=("directive", "directive_pending_text")),
2220
2230
  Step(S_DIRECTIVE,
2221
- applies=lambda s: (s.use_defaults is False
2222
- and s.directive_pending_text
2231
+ applies=lambda s: (s.directive_pending_text
2223
2232
  and S_DIRECTIVE not in s.answered),
2224
2233
  build=_build_directive, submit=_submit_directive,
2225
2234
  owns=("directive", "directive_pending_text")),
@@ -2516,6 +2525,7 @@ def confirmation_block(state: WizardState) -> str:
2516
2525
  lines: list[str] = [header]
2517
2526
  lines.append(f" task-type : {state.task_type}")
2518
2527
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
2528
+ lines.append(f" brief : {state.brief_path or '(none)'}")
2519
2529
  if state.reuse_worktree:
2520
2530
  lines.append(" base-ref : (reusing existing worktree)")
2521
2531
  else:
@@ -2538,6 +2548,8 @@ def confirmation_block(state: WizardState) -> str:
2538
2548
  if state.report_writer_model:
2539
2549
  lines.append(f" report-writer : {state.report_writer_model}")
2540
2550
  lines.append(f" directive : {state.directive or '(none)'}")
2551
+ if state.related_tasks_raw:
2552
+ lines.append(f" related-tasks : {state.related_tasks_raw}")
2541
2553
  if state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification"):
2542
2554
  lines.append(f" critic : {state.critic or '(off)'}")
2543
2555
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
@@ -7,8 +7,21 @@ from .jsonl_io import iter_jsonl
7
7
  from .paths import claude_project_dir
8
8
 
9
9
 
10
- def claude_session_totals(jsonl_path: Path) -> dict:
11
- """Return totals + agentName + assistant model + time window for a Claude session jsonl."""
10
+ def claude_session_totals(
11
+ jsonl_path: Path, *, since: str | None = None, until: str | None = None
12
+ ) -> dict:
13
+ """Return totals + agentName + assistant model + time window for a Claude session jsonl.
14
+
15
+ ``since`` / ``until`` are ISO-8601 timestamp strings (UTC ``...Z``). When
16
+ given, only records whose ``timestamp`` falls within ``[since, until]`` are
17
+ counted toward tokens / tool_uses / duration. This is the run-scoping seam:
18
+ an **in-session** lead writes its run into the user's whole-session jsonl,
19
+ so without a window the totals swallow every unrelated turn (observed:
20
+ lead billed 1.7억 tokens / $416 / 3h for a single requirements-discovery
21
+ run). ``agentName`` / ``model`` are session metadata and are read from the
22
+ whole file regardless of the window. Records without a timestamp are kept
23
+ (conservative — never silently drop usage when we can't place it in time).
24
+ """
12
25
  input_t = output_t = cache_create_t = cache_read_t = 0
13
26
  cache_create_5m_t = cache_create_1h_t = 0
14
27
  tool_uses = 0
@@ -20,6 +33,12 @@ def claude_session_totals(jsonl_path: Path) -> dict:
20
33
  if agent_name is None and rec.get("agentName"):
21
34
  agent_name = rec["agentName"]
22
35
  msg = rec.get("message") or {}
36
+ ts = rec.get("timestamp") or (msg.get("timestamp") if isinstance(msg, dict) else None)
37
+ in_window = not (ts and ((since and ts < since) or (until and ts > until)))
38
+ if rec.get("type") == "assistant" and model is None and msg.get("model"):
39
+ model = msg["model"]
40
+ if not in_window:
41
+ continue
23
42
  usage = msg.get("usage")
24
43
  if usage:
25
44
  input_t += usage.get("input_tokens", 0) or 0
@@ -39,12 +58,9 @@ def claude_session_totals(jsonl_path: Path) -> dict:
39
58
  else:
40
59
  cache_create_5m_t += cc_total
41
60
  if rec.get("type") == "assistant":
42
- if model is None and msg.get("model"):
43
- model = msg["model"]
44
61
  for block in (msg.get("content") or []):
45
62
  if isinstance(block, dict) and block.get("type") == "tool_use":
46
63
  tool_uses += 1
47
- ts = rec.get("timestamp") or (msg.get("timestamp") if isinstance(msg, dict) else None)
48
64
  if ts:
49
65
  if first_ts is None or ts < first_ts:
50
66
  first_ts = ts
@@ -58,7 +74,12 @@ def claude_session_totals(jsonl_path: Path) -> dict:
58
74
  duration_ms = max(0, int((b - a).total_seconds() * 1000))
59
75
  except ValueError:
60
76
  duration_ms = 0
61
- total = input_t + output_t + cache_create_t + cache_read_t
77
+ # '처리 토큰' total 에서 cache_read 제외한다. claude 턴 직전까지의
78
+ # 컨텍스트 전체를 캐시에서 재읽기(cache_read)하므로, 단순 합산하면 같은 토큰을
79
+ # 턴 수만큼 중복 카운트해 처리량이 비현실적으로 부풀려진다(예: in-session
80
+ # lead 가 1.7억으로 표시됨). cache_read 는 cacheReadTokens 로 따로 노출되고,
81
+ # 비용은 pricing 이 0.1x 단가로 별도 반영하므로 total 에서 빼도 비용은 불변.
82
+ total = input_t + output_t + cache_create_t
62
83
  return {
63
84
  "totalTokens": total,
64
85
  "inputTokens": input_t,
@@ -2,7 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
- from datetime import datetime
5
+ from datetime import datetime, timezone
6
6
  from pathlib import Path
7
7
  from okstra_project.dirs import OKSTRA_RELATIVE
8
8
 
@@ -86,9 +86,61 @@ def _aggregate_totals(items: list[dict]) -> dict:
86
86
  return aggregate
87
87
 
88
88
 
89
+ def _run_window_suffix(team_state_path: Path) -> str | None:
90
+ """``team-state-<task-type>-<seq>.json`` → ``<task-type>-<seq>``.
91
+
92
+ 이 접미사로 *같은 run* 의 run-manifest / status 를 정확히 짚는다. task 디렉토리
93
+ 한 곳에 여러 run(재시도·이전 phase·레거시 타임스탬프)의 산출물이 섞여 있어,
94
+ glob 으로 아무거나 집으면 엉뚱한 run 의 시각을 쓰게 된다(관측: 가장 오래된
95
+ 레거시 manifest 의 createdAt 을 집어 윈도우가 한 달로 벌어짐)."""
96
+ name = team_state_path.name
97
+ if not (name.startswith("team-state-") and name.endswith(".json")):
98
+ return None
99
+ return name[len("team-state-"):-len(".json")]
100
+
101
+
102
+ def _run_manifest_created_at(run_dir: Path, suffix: str) -> str | None:
103
+ p = run_dir / "manifests" / f"run-manifest-{suffix}.json"
104
+ try:
105
+ return json.loads(p.read_text()).get("createdAt")
106
+ except (OSError, json.JSONDecodeError):
107
+ return None
108
+
109
+
110
+ def _run_end_estimate(run_dir: Path, suffix: str) -> str | None:
111
+ """run 종료 근사 — 같은 run 의 status 산출물 mtime(reconcile 후 고정, Phase 7
112
+ 재렌더로도 바뀌지 않음). 완료 전(status 부재)이면 None."""
113
+ p = run_dir / "status" / f"final-{suffix}.status"
114
+ try:
115
+ mtime = p.stat().st_mtime
116
+ except OSError:
117
+ return None
118
+ return datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
119
+
120
+
121
+ def _resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
122
+ """이 run 의 [시작, 종료] ISO 윈도우.
123
+
124
+ in-session lead 는 자기 run 을 사용자의 *세션 전체* jsonl 에 기록하므로,
125
+ 윈도우 없이 합산하면 무관한 모든 턴(다른 작업·대화)이 lead 토큰·시간에
126
+ 섞여 폭증한다(관측: requirements-discovery 한 run 에 lead 1.7억 토큰 /
127
+ $416 / 3h). 토큰 집계를 이 윈도우로 스코핑해 그 run 분만 센다. 시작 =
128
+ 이 run 의 run-manifest createdAt, 종료 = team-state.runEndedAt → 이 run 의
129
+ status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
130
+ (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백."""
131
+ suffix = _run_window_suffix(team_state_path)
132
+ if not suffix:
133
+ return None, None
134
+ run_dir = team_state_path.parent.parent
135
+ since = _run_manifest_created_at(run_dir, suffix)
136
+ until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
137
+ return since, until
138
+
139
+
89
140
  def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
90
141
  state = json.loads(team_state_path.read_text())
91
142
  cwd = project_root or _infer_project_root(team_state_path, state)
143
+ run_since, run_until = _resolve_run_window(team_state_path, state)
92
144
  task_key = state.get("taskKey", "")
93
145
  # Prefer the team name actually persisted in team-state (set during Phase 3
94
146
  # when TeamCreate succeeded); only fall back to the `okstra-<task-id>`
@@ -130,7 +182,7 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
130
182
  if sid == lead_sid:
131
183
  lead_path = path
132
184
  continue
133
- totals = claude_session_totals(path)
185
+ totals = claude_session_totals(path, since=run_since, until=run_until)
134
186
  agent = totals.get("agentName")
135
187
  if agent:
136
188
  by_agent.setdefault(agent, []).append((sid, path, totals))
@@ -139,7 +191,7 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
139
191
 
140
192
  # Lead.
141
193
  if lead_path is not None:
142
- totals = claude_session_totals(lead_path)
194
+ totals = claude_session_totals(lead_path, since=run_since, until=run_until)
143
195
  state["leadUsage"] = usage_block(totals, source="claude-jsonl")
144
196
  state["leadUsage"]["sessionId"] = lead_sid
145
197
  else: