okstra 0.107.2 → 0.108.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.107.2",
3
+ "version": "0.108.0",
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.107.2",
3
- "builtAt": "2026-07-01T09:21:00.281Z",
2
+ "package": "0.108.0",
3
+ "builtAt": "2026-07-02T01:43:53.766Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -87,7 +87,7 @@ User-utterance interpretation rule:
87
87
 
88
88
  ## Progress reporting (BLOCKING)
89
89
 
90
- A single okstra run frequently spans 30–120 minutes of wall-clock time with multi-minute silent windows while workers run. Without explicit progress signals the user cannot distinguish "still working" from "hung", so Lead MUST emit a single short progress line at each of the checkpoints below — as plain user-facing text in a separate brief message (not buried inside a tool call). One line per checkpoint, format: `PROGRESS: <phase-id> <verb-phrase>`.
90
+ A single okstra run frequently spans 30–120 minutes of wall-clock time with multi-minute silent windows while workers run. Without explicit progress signals the user cannot distinguish "still working" from "hung", so Lead MUST emit a single short progress line at each of the checkpoints below — as plain user-facing text in a separate brief message (not buried inside a tool call). One line per checkpoint, format: `PROGRESS: <phase-id> <verb-phrase>`. Emit the line raw — the literal `PROGRESS:` token must begin the line. Do NOT wrap it in inline-code backticks (`` `PROGRESS: ...` ``) or a ```` ``` ```` code fence; markdown wrapping is what the post-hoc conformance validator scrapes around, and raw emit keeps the signal unambiguous.
91
91
 
92
92
  Required checkpoints:
93
93
 
@@ -40,7 +40,8 @@ profile document.
40
40
  - **Only confirmed-complete teammates, never the lead.** NEVER send a shutdown_request to a teammate whose result file has not yet appeared. In particular the critic runs CONCURRENTLY with the first reverify round (see `convergence` "Coverage critic pass"), so it MUST NOT be shut down until its own result is collected. NEVER target the lead's own session.
41
41
  - Only individual completed teammates are dismissed here; the rest of the roster stays for the remaining batches. There is no `TeamDelete` tool (removed in CC v2.1.178) — the implicit team itself ends with the session; teardown only dismisses teammates.
42
42
  - Silent-skip in `tmux-pane` backend runs (external-lead workers are not session teammates); otherwise the on-disk existence check at run-end teardown is the authority on whether a roster exists.
43
- - Both (a) and (b) run **automatically without any interactive prompt**. After running them, emit the checkpoint line `PROGRESS: phase-batch-cleanup panes=<n> teammates=<m>` (counts only NEVER expose `%NNN`, `lead-pane.id`, or raw Agent names) and proceed; this is the BLOCKING checkpoint the Phase 7 validator requires before each convergence round and before the report-writer dispatch (`prompts/lead/okstra-lead-contract.md` "Progress reporting (BLOCKING)"). **Effect caveat:** Claude Code exposes no tool to surgically delete a roster entry mid-run, so a dismissed teammate may still show as an idle pill; the shutdown still reclaims its session and prevents the roster from growing with live members. On the FIRST batch of a run nothing has been dispatched yetboth steps no-op.
43
+ - **(c) record the current session into team-state (축1 re-issued session generation capture).** batch cleanup 직후(그리고 아래 `PROGRESS: phase-batch-cleanup` 방출 전), lead 재발급된 세션 세대가 Phase 7 토큰·PROGRESS 수집에서 누락되지 않도록 현재 live 세션을 team-state 기록한다: `okstra token-usage "<TEAM_STATE_PATH>" --record-observed-session --project-root "<PROJECT_ROOT>"` 1회 호출한다(`<TEAM_STATE_PATH>` `<PROJECT_ROOT>` shell 변수·`$(...)` 없이 리터럴 절대경로로 치환 — `Bash(okstra:*)` 매칭 유지). `--project-root` **필수**다: 생략하면 collector team-state 상위 디렉터리(run/state 경로)를 project root 잘못 추론해 엉뚱한 `~/.claude/projects/<encoded-cwd>/` 뒤지고 조용히 no-op 하므로 축1 꺼진다. 실패는 무시 가능한 best-effort worker 세션이거나 이미 기록된 세대이면 no-op 다. `python3 "$HOME/..."` 직접 호출은 금지(`$HOME` 확장이 리터럴-토큰 permission 매칭을 깨고 호출 확인 프롬프트를 유발) 반드시 `okstra token-usage` 서브커맨드를 쓴다.
44
+ - Both (a), (b), and (c) run **automatically without any interactive prompt**. After running them, emit the checkpoint line `PROGRESS: phase-batch-cleanup panes=<n> teammates=<m>` (counts only — NEVER expose `%NNN`, `lead-pane.id`, or raw Agent names) and proceed; this is the BLOCKING checkpoint the Phase 7 validator requires before each convergence round and before the report-writer dispatch (`prompts/lead/okstra-lead-contract.md` "Progress reporting (BLOCKING)"). **Effect caveat:** Claude Code exposes no tool to surgically delete a roster entry mid-run, so a dismissed teammate may still show as an idle pill; the shutdown still reclaims its session and prevents the roster from growing with live members. On the FIRST batch of a run nothing has been dispatched yet — the pane/teammate steps (a)/(b) no-op; step (c) still records the current session.
44
45
  - Phase wrap-up — okstra pane disposition (shared, runs AFTER Phase 7 persistence/token collection and BEFORE teammate teardown):
45
46
  - At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/antigravity trace pane). `okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` returns one tab-separated `<pane_id>\t<pane_title>` line per residual okstra pane (worker-agent + trace) for this run.
46
47
  - After the final-report file has been written and the routing recommendation has been issued, the lead MUST ALWAYS run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` exactly once — NEVER gating this call on its own reading of `lead-pane.id`. The output lists every residual okstra pane (worker-agent + trace) for this run, never the lead's own pane; outside tmux it is a safe no-op (empty output).
@@ -5,6 +5,7 @@ bash session.sh 의 python 구현. claude session id 생성, resume command
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import json
8
9
  import os
9
10
  import uuid
10
11
  from pathlib import Path
@@ -48,6 +49,49 @@ def resolve_inproc_lead_session_id(project_root: Path) -> str:
48
49
  return ""
49
50
 
50
51
 
52
+ def _session_has_agent_name(jsonl_path: Path) -> bool:
53
+ """세션 jsonl 에 agentName 이 있으면 worker 세션(lead 아님)."""
54
+ try:
55
+ with jsonl_path.open(encoding="utf-8") as fh:
56
+ for raw in fh:
57
+ try:
58
+ rec = json.loads(raw)
59
+ except (json.JSONDecodeError, UnicodeDecodeError):
60
+ continue
61
+ if rec.get("agentName"):
62
+ return True
63
+ except OSError:
64
+ return False
65
+ return False
66
+
67
+
68
+ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> str:
69
+ """현재 live lead 세션을 관측해 team-state 에 append(멱등). 워커 세션이거나
70
+ 관측 실패·중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이 여기에 모은다.
71
+ """
72
+ sid = resolve_inproc_lead_session_id(project_root)
73
+ if not sid:
74
+ return ""
75
+ proj_dir = _claude_projects_dir_for(project_root)
76
+ jsonl = proj_dir / f"{sid}.jsonl"
77
+ if _session_has_agent_name(jsonl):
78
+ return ""
79
+ try:
80
+ state = json.loads(team_state_path.read_text(encoding="utf-8"))
81
+ except (OSError, json.JSONDecodeError):
82
+ return ""
83
+ lead_ids = state.setdefault("leadSessionIds", [])
84
+ if sid in lead_ids:
85
+ return ""
86
+ lead_ids.append(sid)
87
+ team = f"session-{sid[:8]}"
88
+ observed = state.setdefault("observedTeamNames", [])
89
+ if team not in observed:
90
+ observed.append(team)
91
+ team_state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
92
+ return sid
93
+
94
+
51
95
  def write_claude_resume_command_file(
52
96
  *,
53
97
  resume_command_path: Path,
@@ -280,13 +280,18 @@ def _cached_needle_scan(jsonl_path: Path, cache: dict, needle_lower: str) -> boo
280
280
 
281
281
  def find_claude_team_sessions(
282
282
  cwd: Path,
283
- team_name: str,
283
+ team_needles,
284
284
  lead_sid: str | None = None,
285
285
  projects_root: Path | None = None,
286
286
  *,
287
287
  incremental: bool = False,
288
288
  ) -> dict[str, Path]:
289
- """Map sessionId -> jsonl path for all jsonls tagged with `team_name`.
289
+ """Map sessionId -> jsonl path for all jsonls tagged with any of `team_needles`.
290
+
291
+ `team_needles` is an ``Iterable[str]`` of teamName generations; a jsonl is
292
+ included when it matches any one of them (union). The lead re-issue
293
+ fragments an implicit-team session into multiple `session-<leadSid>`
294
+ generations, so a single string is no longer sufficient.
290
295
 
291
296
  Matching is case-insensitive on the teamName needle to tolerate runs where
292
297
  the lead recorded `team.teamName` with a different case than the harness
@@ -306,17 +311,18 @@ def find_claude_team_sessions(
306
311
  out: dict[str, Path] = {}
307
312
  if not proj_dir.is_dir():
308
313
  return out
309
- needle_lower = f'"teamname":"{(team_name or "").lower()}"'
310
- if team_name:
311
- for p in proj_dir.glob("*.jsonl"):
314
+ needles_lower = [f'"teamname":"{n.lower()}"' for n in team_needles if n]
315
+ for p in proj_dir.glob("*.jsonl"):
316
+ for needle_lower in needles_lower:
312
317
  if incremental:
313
318
  cache = load_cache(p)
314
- if _cached_needle_scan(p, cache, needle_lower):
315
- out[p.stem] = p
319
+ hit = _cached_needle_scan(p, cache, needle_lower)
316
320
  save_cache(p, cache)
317
321
  else:
318
- if _needle_scan(p, {"offset": 0, "found": False}, needle_lower):
319
- out[p.stem] = p
322
+ hit = _needle_scan(p, {"offset": 0, "found": False}, needle_lower)
323
+ if hit:
324
+ out[p.stem] = p
325
+ break
320
326
  if lead_sid:
321
327
  direct = proj_dir / f"{lead_sid}.jsonl"
322
328
  if direct.is_file():
@@ -60,12 +60,35 @@ def main() -> int:
60
60
  "See schemas/final-report-v1.0.schema.json for the data shape."
61
61
  ),
62
62
  )
63
+ parser.add_argument(
64
+ "--record-observed-session",
65
+ action="store_true",
66
+ help=(
67
+ "Observe the current live lead session and append it to the "
68
+ "team-state's leadSessionIds/observedTeamNames (idempotent), "
69
+ "print the appended sid, then exit"
70
+ ),
71
+ )
63
72
  args = parser.parse_args()
64
73
 
65
74
  if not args.team_state.is_file():
66
75
  print(f"team-state not found: {args.team_state}", file=sys.stderr)
67
76
  return 2
68
77
 
78
+ if args.record_observed_session:
79
+ from okstra_ctl.session import record_observed_lead_session
80
+
81
+ from .collect import _infer_project_root
82
+
83
+ if args.project_root is not None:
84
+ project_root = args.project_root
85
+ else:
86
+ state = json.loads(args.team_state.read_text())
87
+ project_root = _infer_project_root(args.team_state, state)
88
+ sid = record_observed_lead_session(project_root, args.team_state)
89
+ print(sid)
90
+ return 0
91
+
69
92
  updated = collect(args.team_state, args.project_root,
70
93
  incremental=not args.no_cache)
71
94
 
@@ -131,6 +131,53 @@ def _run_end_estimate(run_dir: Path, suffix: str) -> str | None:
131
131
  return datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
132
132
 
133
133
 
134
+ def _project_root_from_team_state(team_state_path: Path) -> Path | None:
135
+ """team-state path(`<root>/.okstra/tasks/.../state/team-state-*.json`)에서
136
+ 프로젝트 루트(`.okstra` 를 담은 디렉토리)를 되짚는다. 못 찾으면 None."""
137
+ for parent in team_state_path.resolve().parents:
138
+ if parent.name == OKSTRA_RELATIVE.name:
139
+ return parent.parent
140
+ return None
141
+
142
+
143
+ def _session_first_ts(jsonl_path: Path) -> str | None:
144
+ """세션 jsonl 의 최소 timestamp — reconstruct_needles_from_workers 와 동일한
145
+ top-level `timestamp` 규칙. 파일 부재/파싱 실패 레코드는 건너뛴다."""
146
+ first_ts: str | None = None
147
+ try:
148
+ with jsonl_path.open(encoding="utf-8") as fh:
149
+ for raw in fh:
150
+ try:
151
+ rec = json.loads(raw)
152
+ except (json.JSONDecodeError, UnicodeDecodeError):
153
+ continue
154
+ ts = rec.get("timestamp")
155
+ if ts and (first_ts is None or ts < first_ts):
156
+ first_ts = ts
157
+ except OSError:
158
+ return None
159
+ return first_ts
160
+
161
+
162
+ def _earliest_lead_session_ts(state: dict, team_state_path: Path) -> str | None:
163
+ """`state["leadSessionIds"]` 각 lead 세션 jsonl 첫 ts 중 최소값. 부재하거나
164
+ 어느 세션에서도 ts 를 못 찾으면 None. proj_dir 은 team-state path 에서 되짚은
165
+ 프로젝트 루트에 `claude_project_dir` 규칙을 적용해 해소한다."""
166
+ lead_ids = [sid for sid in (state.get("leadSessionIds") or []) if sid]
167
+ if not lead_ids:
168
+ return None
169
+ project_root = _project_root_from_team_state(team_state_path)
170
+ if project_root is None:
171
+ return None
172
+ proj_dir = claude_project_dir(project_root)
173
+ earliest: str | None = None
174
+ for sid in lead_ids:
175
+ ts = _session_first_ts(proj_dir / f"{sid}.jsonl")
176
+ if ts and (earliest is None or ts < earliest):
177
+ earliest = ts
178
+ return earliest
179
+
180
+
134
181
  def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None, str | None]:
135
182
  """이 run 의 [시작, 종료] ISO 윈도우.
136
183
 
@@ -140,12 +187,21 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
140
187
  $416 / 3h). 토큰 집계를 이 윈도우로 스코핑해 그 run 분만 센다. 시작 =
141
188
  이 run 의 run-manifest createdAt, 종료 = team-state.runEndedAt → 이 run 의
142
189
  status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
143
- (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백."""
190
+ (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백.
191
+
192
+ 시작 완화: run-manifest createdAt 이 이 run 의 실제 lead/worker 세션보다
193
+ 늦게 찍히면(관측: dev-9902 — createdAt 11:43Z 가 05:34Z 시작 세션보다 늦음)
194
+ since 가 앞선 세션을 잘라낸다. Task 3 이 기록한 `leadSessionIds[]` 세션의
195
+ 첫 ts 최소값이 createdAt 보다 앞서면 그 값으로 since 를 앞당긴다.
196
+ `leadSessionIds` 부재(legacy)면 createdAt 그대로 — 기존 동작 불변."""
144
197
  suffix = run_artifact_suffix(team_state_path)
145
198
  if not suffix:
146
199
  return None, None
147
200
  run_dir = team_state_path.parent.parent
148
201
  since = _run_manifest_created_at(run_dir, suffix)
202
+ earliest = _earliest_lead_session_ts(state, team_state_path)
203
+ if earliest and (since is None or earliest < since):
204
+ since = earliest
149
205
  until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
150
206
  return since, until
151
207
 
@@ -214,6 +270,55 @@ def resolve_team_name(state: dict) -> str:
214
270
  return team_name
215
271
 
216
272
 
273
+ def resolve_team_needles(state: dict) -> list[str]:
274
+ """이 run 에 속한 harness teamName(`session-<leadSid-prefix>`) 전체.
275
+
276
+ lead 재발급마다 세대가 갈리므로 team-state.teamName 단일값으로는 후속
277
+ 세대 워커를 놓친다. 축1 이 기록한 `observedTeamNames[]` 를 정본으로 쓰고,
278
+ 수동 정정으로 teamName 에 직접 박힌 `session-*` 값도 흡수한다.
279
+ audit label(`okstra-<task-key>`) 은 harness needle 이 아니므로 제외한다.
280
+ """
281
+ needles: set[str] = set()
282
+ for name in state.get("observedTeamNames") or []:
283
+ if isinstance(name, str) and name.startswith("session-"):
284
+ needles.add(name)
285
+ label = resolve_team_name(state)
286
+ if label.startswith("session-"):
287
+ needles.add(label)
288
+ return sorted(needles)
289
+
290
+
291
+ def resolve_team_needles_with_source(
292
+ state: dict, cwd, since, until, projects_root=None,
293
+ ) -> tuple[list[str], str]:
294
+ """세 소비처(collect + 두 validator) 공통 needle 조립 규칙.
295
+
296
+ needle 은 `resolve_team_needles(state)` 만으로 조립한다 — 축1 의
297
+ `observedTeamNames[]` 세대들 + `session-*` 형태의 수동 정정 라벨. audit
298
+ label(`okstra-<task-key>`)은 needle 로 절대 쓰지 않는다: 하네스 jsonl 은
299
+ `session-*` teamName 만 실어(okstra-lead-contract.md), 라벨은 여러 run 이
300
+ 공유하는 task-key 파생값이라 needle 로 쓰면 같은 task-key 의 다른 run 세션을
301
+ 끌어와 cross-run mis-attribution 을 일으킨다(spec §2.1 이 배제하는 바로 그
302
+ 실패). `resolve_team_name` 은 표시 라벨(`usageSummary.teamName`) 전용.
303
+
304
+ `resolve_team_needles` 가 비면(observedTeamNames 미기록 legacy run) 축4 의
305
+ `reconstruct_needles_from_workers` 로 run window 안 워커 jsonl 의 `session-*`
306
+ teamName 을 best-effort 복구하고 `reconstructed-from-workers` provenance 를
307
+ 돌려준다 — window 로 스코핑되므로 타 run 을 안전하게 배제한다. 그마저 비면
308
+ `none`. 반환한 source 는 각 소비처가 자기 관례대로 표면화한다
309
+ (collect → usageSummary.needleSource).
310
+ """
311
+ needles = resolve_team_needles(state)
312
+ if needles:
313
+ return needles, "observed-team-names"
314
+ reconstructed = reconstruct_needles_from_workers(
315
+ cwd, since, until, projects_root=projects_root
316
+ )
317
+ if reconstructed:
318
+ return reconstructed, "reconstructed-from-workers"
319
+ return [], "none"
320
+
321
+
217
322
  def _resolve_project_path(project_root: Path, raw_path: str) -> Path | None:
218
323
  if not raw_path:
219
324
  return None
@@ -407,7 +512,8 @@ def _collect_codex_runtime_usage(state: dict, project_root: Path) -> dict:
407
512
  _aggregate_totals(totals),
408
513
  session_paths,
409
514
  )
410
- _populate_usage_summary(state, team_name=resolve_team_name(state), sessions_found=0)
515
+ _populate_usage_summary(state, team_name=resolve_team_name(state),
516
+ sessions_found=0, needle_source="none")
411
517
  return state
412
518
 
413
519
 
@@ -418,6 +524,7 @@ def _populate_usage_summary(
418
524
  sessions_found: int,
419
525
  unattributed_sessions: list[str] | None = None,
420
526
  unattributed_usage: dict[str, Any] | None = None,
527
+ needle_source: str = "observed-team-names",
421
528
  ) -> None:
422
529
  workers = state.get("workers", [])
423
530
  lead = state.get("leadUsage") or {}
@@ -462,6 +569,7 @@ def _populate_usage_summary(
462
569
  },
463
570
  "collectedAt": utc_now(),
464
571
  "teamName": team_name,
572
+ "needleSource": needle_source,
465
573
  "sessionsFound": sessions_found,
466
574
  "unmatchedModels": sorted(set(unmatched_models)),
467
575
  "unattributedTeamSessions": unattributed_sessions or [],
@@ -490,9 +598,24 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
490
598
 
491
599
  # 1) Claude sessions (lead + claude-side workers). Cache totals at scan
492
600
  # time so we don't re-read the jsonl when a worker matches multiple
493
- # sessions.
494
- claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid,
601
+ # sessions. needle 집합(observedTeamNames 여러 세대)으로 스캔하고, 없으면
602
+ # 워커 jsonl 에서 복구한다 — team_name 은 표시 라벨 전용.
603
+ team_needles, needle_source = resolve_team_needles_with_source(
604
+ state, cwd, run_since, run_until
605
+ )
606
+ claude_sessions = find_claude_team_sessions(cwd, team_needles, lead_sid,
495
607
  incremental=incremental)
608
+ # Task 9: 축1 기록(observed-team-names)으로 발견한 세션들의 min first-ts 로
609
+ # run_since 를 완화한다. session-* needle 은 세션 고유라 발견 세션은 이 run
610
+ # 것이므로 안전. 불확실한 reconstruct/none source 는 완화하지 않는다(오귀속 방지).
611
+ if needle_source == "observed-team-names":
612
+ found_earliest = None
613
+ for path in claude_sessions.values():
614
+ ts = _session_first_ts(path)
615
+ if ts and (found_earliest is None or ts < found_earliest):
616
+ found_earliest = ts
617
+ if found_earliest and (run_since is None or found_earliest < run_since):
618
+ run_since = found_earliest
496
619
  by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
497
620
  lead_path: Path | None = None
498
621
  # Team-tagged non-lead sessions that carry no agentName. These are almost
@@ -623,6 +746,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
623
746
  sessions_found=len(claude_sessions),
624
747
  unattributed_sessions=unattributed_sessions,
625
748
  unattributed_usage=unattributed_usage,
749
+ needle_source=needle_source,
626
750
  )
627
751
  return state
628
752
 
@@ -637,3 +761,38 @@ def _infer_project_root(team_state_path: Path, state: dict) -> Path:
637
761
  return p
638
762
  p = p.parent
639
763
  raise SystemExit(f"could not infer project root from {team_state_path}")
764
+
765
+
766
+ def reconstruct_needles_from_workers(cwd, since, until, projects_root=None) -> list[str]:
767
+ """observedTeamNames 가 없는 과거 run 의 best-effort 복구: run window 안
768
+ 워커 jsonl(agentName 존재)이 달고 있는 session-* teamName variant 집합.
769
+ """
770
+ from okstra_token_usage.paths import claude_project_dir, ts_in_window
771
+ proj_dir = claude_project_dir(cwd, projects_root)
772
+ found: set[str] = set()
773
+ if not proj_dir.is_dir():
774
+ return []
775
+ for p in proj_dir.glob("*.jsonl"):
776
+ agent = team = first_ts = None
777
+ try:
778
+ with p.open(encoding="utf-8") as fh:
779
+ for raw in fh:
780
+ try:
781
+ rec = json.loads(raw)
782
+ except (json.JSONDecodeError, UnicodeDecodeError):
783
+ continue
784
+ if agent is None and rec.get("agentName"):
785
+ agent = rec["agentName"]
786
+ if team is None and rec.get("teamName"):
787
+ team = rec["teamName"]
788
+ ts = rec.get("timestamp")
789
+ if ts and (first_ts is None or ts < first_ts):
790
+ first_ts = ts
791
+ except OSError:
792
+ continue
793
+ # 의도적 divergence: 이 untrusted best-effort 경로에서는 ts 를 못 구한
794
+ # 워커를 제외한다 (ts_in_window 의 파싱 실패→포함 정책과 반대).
795
+ if agent and team and str(team).startswith("session-") \
796
+ and first_ts and ts_in_window(first_ts, since, until):
797
+ found.add(team)
798
+ return sorted(found)
@@ -122,13 +122,19 @@ def scan_forbidden_actions(
122
122
  """Return human-readable violation strings (empty = clean)."""
123
123
  _ensure_token_usage_importable()
124
124
  from okstra_token_usage.claude import find_claude_team_sessions
125
- from okstra_token_usage.collect import resolve_run_window, resolve_team_name
125
+ from okstra_token_usage.collect import (
126
+ resolve_run_window,
127
+ resolve_team_needles_with_source,
128
+ )
126
129
 
127
130
  since, until = resolve_run_window(team_state_path, team_state)
128
131
  lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
132
+ team_needles, _needle_source = resolve_team_needles_with_source(
133
+ team_state, project_root, since, until, projects_root=claude_projects_dir
134
+ )
129
135
  sessions = find_claude_team_sessions(
130
136
  project_root,
131
- resolve_team_name(team_state),
137
+ team_needles,
132
138
  lead_sid,
133
139
  projects_root=claude_projects_dir,
134
140
  )
@@ -33,7 +33,13 @@ _WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
33
33
  _ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
34
34
 
35
35
  # lead 의 체크포인트 라인 — assistant text 블록 안에서 line-anchored 로만 인정.
36
- _PROGRESS_LINE_RE = re.compile(r"^PROGRESS:[ \t]+(?P<phase>\S+)(?P<rest>.*)$", re.MULTILINE)
36
+ # lead 는 계약상 raw 로 emit 해야 하지만 실측(dev-9902)에서 `` `PROGRESS: ...` ``
37
+ # 인라인 코드나 코드펜스로 감싸 emit 하는 경우가 있어, 라인 양끝의 backtick 과
38
+ # 선행 들여쓰기를 허용한다. phase 는 backtick 을 포함하지 않는다.
39
+ _PROGRESS_LINE_RE = re.compile(
40
+ r"^[ \t]*`*[ \t]*PROGRESS:[ \t]+(?P<phase>[^\s`]+)(?P<rest>[^`\n]*)`*[ \t]*$",
41
+ re.MULTILINE,
42
+ )
37
43
 
38
44
  # claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
39
45
  _HEARTBEAT_LINE_RE = re.compile(
@@ -139,7 +145,8 @@ def _scan_one_jsonl(
139
145
  continue
140
146
  if block.get("type") == "text":
141
147
  for m in _PROGRESS_LINE_RE.finditer(block.get("text") or ""):
142
- progress.append((ts, m.group("phase"), m.group(0).strip()))
148
+ line = f"PROGRESS: {m.group('phase')}{m.group('rest')}".rstrip()
149
+ progress.append((ts, m.group("phase"), line))
143
150
  elif block.get("type") == "tool_use" and block.get("name") == "Read":
144
151
  base = Path(str((block.get("input") or {}).get("file_path") or "")).name
145
152
  if base in _SIDECAR_BASENAMES:
@@ -160,14 +167,29 @@ def _collect_lead_evidence(
160
167
  agentName 없음)를 흡수한다 — worker 세션은 agentName 이 있어 자연 배제된다.
161
168
  """
162
169
  from okstra_token_usage.claude import find_claude_team_sessions
163
- from okstra_token_usage.collect import resolve_run_window, resolve_team_name
170
+ from okstra_token_usage.collect import (
171
+ resolve_run_window,
172
+ resolve_team_needles_with_source,
173
+ )
164
174
  from okstra_token_usage.paths import claude_project_dir
165
175
 
166
176
  since, until = resolve_run_window(team_state_path, team_state)
167
177
  lead_sid = (team_state.get("lead") or {}).get("sessionId") or ""
178
+ team_needles, _needle_source = resolve_team_needles_with_source(
179
+ team_state, project_root, since, until, projects_root=projects_root
180
+ )
168
181
  sessions = find_claude_team_sessions(
169
- project_root, resolve_team_name(team_state), lead_sid, projects_root=projects_root
182
+ project_root, team_needles, lead_sid, projects_root=projects_root
170
183
  )
184
+ # `claude --resume` 로 fork 되거나 leadSessionIds[] 에 기록된 lead 세대는
185
+ # team 태그 needle 로 안 잡힐 수 있어 명시적으로 후보에 추가한다.
186
+ proj_dir = claude_project_dir(project_root, projects_root)
187
+ for sid in (team_state.get("leadSessionIds") or []):
188
+ if not isinstance(sid, str) or not sid:
189
+ continue
190
+ candidate = proj_dir / f"{sid}.jsonl"
191
+ if candidate.is_file():
192
+ sessions.setdefault(sid, candidate)
171
193
  evidence = _LeadEvidence(window=(since, until))
172
194
  for sid, path in sorted(sessions.items()):
173
195
  progress, reads, agent_name = _scan_one_jsonl(path, since, until)
@@ -178,7 +200,6 @@ def _collect_lead_evidence(
178
200
  for base, ts_list in reads.items():
179
201
  evidence.sidecar_reads.setdefault(base, []).extend(ts_list)
180
202
  if not evidence.scanned_files:
181
- proj_dir = claude_project_dir(project_root, projects_root)
182
203
  return None, (
183
204
  f"lead session jsonl not found under {proj_dir} "
184
205
  f"(lead.sessionId={lead_sid or '<empty>'}) — PROGRESS checkpoint / "