okstra 0.107.1 → 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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-team-reconcile.sh +10 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +8 -6
- package/runtime/python/okstra_ctl/render_final_report.py +4 -2
- package/runtime/python/okstra_ctl/session.py +44 -0
- package/runtime/python/okstra_ctl/team_reconcile.py +155 -25
- package/runtime/python/okstra_token_usage/claude.py +15 -9
- package/runtime/python/okstra_token_usage/cli.py +23 -0
- package/runtime/python/okstra_token_usage/collect.py +163 -4
- package/runtime/templates/reports/settings.template.json +4 -0
- package/runtime/validators/forbidden_actions.py +8 -2
- package/runtime/validators/validate-run.py +55 -3
- package/runtime/validators/validate_session_conformance.py +26 -5
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
# are left for graceful shutdown). It no-ops when tmux is unavailable or nothing
|
|
13
13
|
# is stale.
|
|
14
14
|
#
|
|
15
|
-
# Usage:
|
|
16
|
-
# --list
|
|
15
|
+
# Usage:
|
|
16
|
+
# okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>
|
|
17
|
+
# okstra-team-reconcile.sh [--list] --project-root <dir> [--fallback-team <label>]
|
|
18
|
+
# okstra-team-reconcile.sh [--list] --session-end # reads SessionEnd JSON on stdin
|
|
19
|
+
#
|
|
20
|
+
# --project-root resolve the CURRENT live session's roster (robust to the
|
|
21
|
+
# phase-3 lead.sessionId snapshot drifting across resume), then
|
|
22
|
+
# reconcile and print `dismissible-member: <name>` lines.
|
|
23
|
+
# --session-end SessionEnd-hook net: reconcile the ending session's roster.
|
|
24
|
+
# --list report what WOULD be deactivated; do not write (alias --dry-run).
|
|
17
25
|
#
|
|
18
26
|
# Failures are non-fatal to the run — teardown must never block on this.
|
|
19
27
|
set -u
|
|
@@ -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
|
|
|
@@ -218,7 +218,7 @@ These phases are governed by [team-contract](./team-contract.md). It is the cano
|
|
|
218
218
|
Claude Code **v2.1.178 removed the `TeamCreate` / `TeamDelete` tools and the `Agent(...)` `team_name` parameter**. With `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (seeded by `okstra install` into the user's `settings.json`), every session already owns **one implicit team** from the moment it starts — there is no creation step to perform and no `team_name` to pass on dispatch. Workers spawned with `Agent(name: ...)` auto-join that implicit team. Do NOT search for, probe, or call `TeamCreate`: its absence is the expected state on every current Claude Code, NOT an "Agent Teams unavailable" signal, and is never a reason to degrade the run. There is no `select:TeamCreate` probe and no doubly-empty-select branch anymore — those keyed off a tool that no longer exists.
|
|
219
219
|
|
|
220
220
|
1. Record team-state before any dispatch:
|
|
221
|
-
- `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`,
|
|
221
|
+
- `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`, but run-end teardown resolves it from the CURRENT live session (see `_common-contract.md` "Run-end teammate teardown"), NOT from the phase-3 `lead.sessionId` snapshot recorded here, which drifts across resume / compaction; it is never resolved from `teamName`.
|
|
222
222
|
- `teamCreate: { attempted: false, status: "implicit" }` — the audit marker that this run rides the session's implicit team (no explicit creation tool exists). A concurrent-run launch prompt instead pre-records `status: "skipped", reason: "concurrent-run"`; honor whichever the gate specifies.
|
|
223
223
|
2. Verify `team-state.lead.sessionId` is populated. The `okstra.sh` exec path fills it automatically (`generate_claude_session_id` → `claude --session-id ...`). The render-only / in-session takeover path (`okstra-run` skill) auto-detects via `resolve_inproc_lead_session_id`, which is best-effort and may return empty if `~/.claude/projects/<encoded-cwd>/` is unreadable or has no jsonl yet. If `lead.sessionId` is empty at this point, write the running session's id into team-state before proceeding — Phase 7 token-usage collection depends on it and will fail with `lead jsonl not found (sessionId=)` otherwise.
|
|
224
224
|
3. Decide split-pane capability, record it, then emit the phase-3 checkpoint and proceed to Phase 4. Split-pane teammates require a tmux session — this is a host capability, NOT an okstra success/failure gate, and is the only thing the removed "TeamCreate" probe should ever have been deciding:
|
|
@@ -277,7 +277,9 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
|
|
|
277
277
|
- `approach` — 그래서 어떤 작업이 필요한가 (택한 방향).
|
|
278
278
|
- `justification` — 왜 이게 합리적 선택인가 (대안 대비, 근거).
|
|
279
279
|
|
|
280
|
-
Every field MUST anchor its claim with at least one evidence reference — a `path:line`,
|
|
280
|
+
Every field MUST anchor its claim with at least one evidence reference — a `path:line`, an **in-report** ID (`C-001` / `E-006` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time.
|
|
281
|
+
|
|
282
|
+
**Reader-facing prose MUST NOT cite a bare brief/worker-internal ID that this report never surfaces** — `RC-*` (reporter confirmations, defined in the brief), `RF-*` / `F-*` (findings, defined in worker-results) have no anchor in the final report, so a reader hits an opaque token with nothing to click. Either expand it inline (`the confirmed version target 1.27.47→1.27.48`) or, for an audit trail, namespace it (`claude:F-005`). **Enforced:** `_validate_no_opaque_id_references` fails a bare `RC-*` / `RF-*` / non-namespaced `F-*` appearing in `verdictCard` / `finalVerdict` / `rationale` / `clarificationItems[].statement`. (The renderer anchors + links in-report IDs of any digit width, so a surfaced `RC-4`-style id does resolve.) Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
|
|
281
283
|
|
|
282
284
|
0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub.
|
|
283
285
|
1. **Cross Verification Results** — 4 categories (Full / Partial / Contested / Worker-Unique) when convergence is enabled, per `convergence`. Prepend the Round History sub-table (columns: `Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches | skippedWorkers`) plus a `round2SkippedReason: <value>` note, pulled verbatim from `convergence-<task-type>-<seq>.json`. Empty contested list renders as `- 합의 미달 항목 없음.`. Convergence-disabled runs use the legacy Consensus/Differences format and omit the round table.
|
|
@@ -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
|
-
-
|
|
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).
|
|
@@ -55,15 +56,16 @@ profile document.
|
|
|
55
56
|
- This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). Whether there is anything to ask about is decided by the `--list` output, NOT by the lead's reading of `lead-pane.id`: an empty list → skip the question (above); the lead MUST NOT fabricate a synthetic pane list.
|
|
56
57
|
- Run-end teammate teardown (shared — MUST run AFTER the pane disposition question and BEFORE `PROGRESS: complete`):
|
|
57
58
|
- CC v2.1.178 removed `TeamCreate` / `TeamDelete`: this run rode the session's **implicit team** (Phase 3 recorded `teamCreate.status: "implicit"` plus the `teamName` audit label). There is no tool to delete a team — the implicit team ends when the session ends. What teardown CAN do is gracefully dismiss the worker teammates the lead spawned, so they stop showing as live members in the FleetView roster.
|
|
58
|
-
- **Whether to run this step is decided by on-disk fact
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
59
|
+
- **Whether to run this step is decided by on-disk fact, resolved at teardown time by the CURRENT live session — NOT from `team-state.lead.sessionId`.** The harness names the team directory `session-<leadSessionId-prefix>` (the `teamName` label never becomes a directory name), but `team-state.lead.sessionId` is only a phase-3 snapshot: Claude Code re-issues the session id across resume / compaction mid-run, so the live roster usually sits under a DIFFERENT `session-*` dir than the snapshot names. Resolving from the snapshot is what made split-pane teardown silently miss its own live teammates. The resolver in step 1 below keys off the live session (falling back to the snapshot dir only when the live one is absent) and prints the members to dismiss.
|
|
60
|
+
- **Resolver printed one or more `dismissible-member: <name>` lines** → teammates exist; run the dismissal sequence below.
|
|
61
|
+
- **Resolver printed `no live roster for this session`, or split-pane was off** → nothing to dismiss → silent-skip.
|
|
61
62
|
- The pane disposition prompt above is the only user prompt for this cleanup decision. Do NOT ask a second teammate-only question.
|
|
62
63
|
- On `아니오` / `n` / `keep` → dismiss nothing. Tell the user the teammates remain visible in FleetView/Teams until the session ends, and can be removed earlier from the Teams UI.
|
|
63
64
|
- On `예` / `y` / `close` → emit `PROGRESS: phase-7-teardown dismissing teammates`, then run the sequence below. Token-usage collection MUST already be complete — it reads `~/.claude/projects/` jsonls, which dismissal never touches, but the read MUST precede teardown.
|
|
64
|
-
1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "<
|
|
65
|
-
2. Send each
|
|
65
|
+
1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"` exactly once. It resolves the live session's `~/.claude/teams/session-<live>/config.json` (falling back to the snapshot `session-<lead.sessionId-prefix>` dir only if the live one is absent), flips dead-pane stale-active members to inactive (no-op when tmux is unavailable or nothing is stale), and prints one `dismissible-member: <name>` line per non-lead member. Do NOT loop it, and do NOT hand-resolve a config path from `team-state.lead.sessionId`.
|
|
66
|
+
2. Send each printed `dismissible-member: <name>` **whose completion is confirmed** (its Result Path exists) a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field. NEVER target the lead's own session, and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
|
|
66
67
|
- Claude Code exposes no tool to delete the team or surgically remove a roster entry, so a dismissed teammate may still show as an idle pill until the session ends. Do not pretend the team was deleted. If the user wants the roster gone entirely, surface the manual action: `Teams/FleetView 에서 team <teamName> 삭제`, and if tmux panes were kept earlier also show `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"`.
|
|
68
|
+
- **Enforcement / safety net.** This teardown fires after the Phase 7 validator's window, so `validate_session_conformance.py` cannot check it (it is not a required checkpoint). The net is the `SessionEnd` hook `$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end` (seeded into `settings.template.json`): when the Claude session ends it reconciles that session's roster regardless of whether this step ran, so a dead-pane member never lingers as a live pill past session end.
|
|
67
69
|
- Report the outcome in one short line (e.g. `worker teammate 정리 완료`, or `stale 멤버 1명 reconcile 후 정리`) and proceed to the final `PROGRESS: complete ...` line.
|
|
68
70
|
- Brief handoff contract (shared — applies whenever the run consumes a task brief produced by `okstra-brief`):
|
|
69
71
|
- the brief is a **pre-discovery artifact**: it converts a domain-reporter's words (non-expert *or* developer) into expert-consumable form so this and later phases can run with zero fill-in questions to the operator. The brief is **not** authoritative on solution decisions; it is authoritative on the reporter's intent.
|
|
@@ -136,10 +136,12 @@ def _yaml_inline_list(values: list[str]) -> str:
|
|
|
136
136
|
# reference must not silently jump to the wrong row.
|
|
137
137
|
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
|
|
138
138
|
_FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
|
|
139
|
-
_FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{
|
|
139
|
+
_FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{1,})\b")
|
|
140
140
|
# A reference token: an ID not glued to a word char / `-` on either side and
|
|
141
141
|
# not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
|
|
142
|
-
|
|
142
|
+
# `\d{1,}` (not `{3,}`): short IDs like `RC-4` are valid — the old 3-digit floor
|
|
143
|
+
# silently dropped them, so they could never anchor or link.
|
|
144
|
+
_REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{1,}(?![\w-])")
|
|
143
145
|
|
|
144
146
|
|
|
145
147
|
def _slugify(text: str) -> str:
|
|
@@ -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,
|
|
@@ -16,6 +16,13 @@ CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
|
|
|
16
16
|
run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
|
|
17
17
|
keeps the roster honest so a dismissed-but-dead member does not linger as a
|
|
18
18
|
live pill — see _common-contract.md "Run-end teammate teardown".
|
|
19
|
+
|
|
20
|
+
Roster resolution (``--project-root``) exists because ``team-state.lead.sessionId``
|
|
21
|
+
is a phase-3 snapshot: Claude Code re-issues the session id across resume /
|
|
22
|
+
compaction mid-run, so the live on-disk roster often sits under a DIFFERENT
|
|
23
|
+
``session-*`` dir than the snapshot names. Resolving by the CURRENT live session
|
|
24
|
+
(the newest jsonl under the encoded-cwd projects dir) is what makes split-pane
|
|
25
|
+
teardown find the teammates it must dismiss.
|
|
19
26
|
"""
|
|
20
27
|
from __future__ import annotations
|
|
21
28
|
|
|
@@ -26,6 +33,8 @@ import sys
|
|
|
26
33
|
import tempfile
|
|
27
34
|
from pathlib import Path
|
|
28
35
|
|
|
36
|
+
from okstra_ctl.session import resolve_inproc_lead_session_id
|
|
37
|
+
|
|
29
38
|
|
|
30
39
|
def live_pane_ids() -> tuple[bool, set[str]]:
|
|
31
40
|
"""Return ``(tmux_available, live_pane_ids)``.
|
|
@@ -66,6 +75,44 @@ def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
|
|
|
66
75
|
return flipped
|
|
67
76
|
|
|
68
77
|
|
|
78
|
+
def dismissible_members(config: dict) -> list[str]:
|
|
79
|
+
"""Names of every non-lead member — the set the lead may ``shutdown_request``.
|
|
80
|
+
|
|
81
|
+
The lead further filters by confirmed-complete (result path present) before
|
|
82
|
+
dismissing; this just tells it which teammates the resolved roster holds.
|
|
83
|
+
"""
|
|
84
|
+
lead = config.get("leadAgentId")
|
|
85
|
+
names: list[str] = []
|
|
86
|
+
for member in config.get("members", []):
|
|
87
|
+
if member.get("agentId") == lead:
|
|
88
|
+
continue
|
|
89
|
+
names.append(member.get("name", member.get("agentId", "?")))
|
|
90
|
+
return names
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _team_config_for_session(session_id: str) -> Path:
|
|
94
|
+
"""``~/.claude/teams/session-<prefix>/config.json`` for a session id.
|
|
95
|
+
|
|
96
|
+
The harness names the team dir with the session id's first segment (the
|
|
97
|
+
8-hex stem before the first dash), never the ``teamName`` label.
|
|
98
|
+
"""
|
|
99
|
+
prefix = session_id.split("-", 1)[0]
|
|
100
|
+
return Path.home() / ".claude" / "teams" / f"session-{prefix}" / "config.json"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _live_team_config(project_root: Path) -> Path | None:
|
|
104
|
+
"""Resolve the CURRENT live session's team config, or None when absent.
|
|
105
|
+
|
|
106
|
+
Robust to the mid-run session-id drift that strands the phase-3
|
|
107
|
+
``lead.sessionId`` snapshot: keys off the live session, not the snapshot.
|
|
108
|
+
"""
|
|
109
|
+
sid = resolve_inproc_lead_session_id(project_root)
|
|
110
|
+
if not sid:
|
|
111
|
+
return None
|
|
112
|
+
cand = _team_config_for_session(sid)
|
|
113
|
+
return cand if cand.is_file() else None
|
|
114
|
+
|
|
115
|
+
|
|
69
116
|
def _config_path(team_arg: str) -> Path:
|
|
70
117
|
"""Resolve a team name, a team dir, or a config.json path to the config file."""
|
|
71
118
|
p = Path(team_arg)
|
|
@@ -92,43 +139,126 @@ def _atomic_write(path: Path, text: str) -> None:
|
|
|
92
139
|
raise
|
|
93
140
|
|
|
94
141
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
rest: list[str] = []
|
|
98
|
-
for arg in argv:
|
|
99
|
-
if arg in ("--list", "--dry-run"):
|
|
100
|
-
dry_run = True
|
|
101
|
-
else:
|
|
102
|
-
rest.append(arg)
|
|
103
|
-
if len(rest) != 1:
|
|
104
|
-
print("usage: okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>",
|
|
105
|
-
file=sys.stderr)
|
|
106
|
-
return 2
|
|
107
|
-
|
|
108
|
-
cfg_path = _config_path(rest[0])
|
|
109
|
-
if not cfg_path.is_file():
|
|
110
|
-
print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
|
|
111
|
-
return 1
|
|
142
|
+
def _reconcile_config(cfg_path: Path, dry_run: bool, print_members: bool) -> int:
|
|
143
|
+
"""Read, reconcile, and (unless dry-run) write back one config.json.
|
|
112
144
|
|
|
145
|
+
When ``print_members`` is set, also emit one ``dismissible-member: <name>``
|
|
146
|
+
line per non-lead member so the lead knows whom to ``shutdown_request``.
|
|
147
|
+
"""
|
|
113
148
|
try:
|
|
114
149
|
config = json.loads(cfg_path.read_text())
|
|
115
150
|
except (OSError, json.JSONDecodeError) as exc:
|
|
116
151
|
print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
|
|
117
152
|
file=sys.stderr)
|
|
118
153
|
return 1
|
|
154
|
+
|
|
119
155
|
available, live = live_pane_ids()
|
|
120
156
|
if not available:
|
|
121
157
|
print("team-reconcile: tmux unavailable — skipped (no flip)")
|
|
158
|
+
else:
|
|
159
|
+
flipped = reconcile_members(config, live)
|
|
160
|
+
if flipped and dry_run:
|
|
161
|
+
print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
|
|
162
|
+
elif flipped:
|
|
163
|
+
_atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
|
|
164
|
+
print(f"team-reconcile: deactivated {len(flipped)} stale member(s): "
|
|
165
|
+
+ ", ".join(flipped))
|
|
166
|
+
else:
|
|
167
|
+
print("team-reconcile: no stale-active members")
|
|
168
|
+
|
|
169
|
+
if print_members:
|
|
170
|
+
for name in dismissible_members(config):
|
|
171
|
+
print(f"dismissible-member: {name}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _session_id_from_stdin() -> str:
|
|
176
|
+
"""Extract ``session_id`` from a SessionEnd hook's JSON payload on stdin.
|
|
177
|
+
|
|
178
|
+
Returns "" when stdin is a terminal (manual invocation), empty, or not the
|
|
179
|
+
expected JSON — the caller then no-ops. The hook must never block or fail a
|
|
180
|
+
session teardown.
|
|
181
|
+
"""
|
|
182
|
+
if sys.stdin.isatty():
|
|
183
|
+
return ""
|
|
184
|
+
try:
|
|
185
|
+
raw = sys.stdin.read()
|
|
186
|
+
except OSError:
|
|
187
|
+
return ""
|
|
188
|
+
if not raw.strip():
|
|
189
|
+
return ""
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads(raw)
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
return ""
|
|
194
|
+
sid = data.get("session_id") or data.get("sessionId") or ""
|
|
195
|
+
return sid if isinstance(sid, str) else ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_resolve_and_reconcile(project_root: str, fallback_team: str, dry_run: bool) -> int:
|
|
199
|
+
"""``--project-root`` mode: resolve the live roster, reconcile, list members."""
|
|
200
|
+
cfg = _live_team_config(Path(project_root))
|
|
201
|
+
if cfg is None and fallback_team:
|
|
202
|
+
fb = _config_path(fallback_team)
|
|
203
|
+
cfg = fb if fb.is_file() else None
|
|
204
|
+
if cfg is None:
|
|
205
|
+
print("team-reconcile: no live roster for this session — nothing to dismiss")
|
|
122
206
|
return 0
|
|
207
|
+
print(f"team-reconcile: roster resolved at {cfg}")
|
|
208
|
+
return _reconcile_config(cfg, dry_run, print_members=True)
|
|
209
|
+
|
|
123
210
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
211
|
+
def _run_session_end(dry_run: bool) -> int:
|
|
212
|
+
"""``--session-end`` mode: reconcile the ending session's roster from the
|
|
213
|
+
SessionEnd hook payload. Safety net so a lead that skipped run-end teardown
|
|
214
|
+
still leaves no dead-pane member lingering as a live pill."""
|
|
215
|
+
sid = _session_id_from_stdin()
|
|
216
|
+
if not sid:
|
|
127
217
|
return 0
|
|
128
|
-
|
|
129
|
-
|
|
218
|
+
cfg = _team_config_for_session(sid)
|
|
219
|
+
if not cfg.is_file():
|
|
130
220
|
return 0
|
|
221
|
+
return _reconcile_config(cfg, dry_run, print_members=False)
|
|
131
222
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
223
|
+
|
|
224
|
+
def _parse_argv(argv: list[str]) -> tuple[bool, str, str, bool, list[str]]:
|
|
225
|
+
dry_run = False
|
|
226
|
+
project_root = ""
|
|
227
|
+
fallback_team = ""
|
|
228
|
+
session_end = False
|
|
229
|
+
rest: list[str] = []
|
|
230
|
+
it = iter(argv)
|
|
231
|
+
for arg in it:
|
|
232
|
+
if arg in ("--list", "--dry-run"):
|
|
233
|
+
dry_run = True
|
|
234
|
+
elif arg == "--project-root":
|
|
235
|
+
project_root = next(it, "")
|
|
236
|
+
elif arg == "--fallback-team":
|
|
237
|
+
fallback_team = next(it, "")
|
|
238
|
+
elif arg == "--session-end":
|
|
239
|
+
session_end = True
|
|
240
|
+
else:
|
|
241
|
+
rest.append(arg)
|
|
242
|
+
return dry_run, project_root, fallback_team, session_end, rest
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str]) -> int:
|
|
246
|
+
dry_run, project_root, fallback_team, session_end, rest = _parse_argv(argv)
|
|
247
|
+
|
|
248
|
+
if session_end:
|
|
249
|
+
return _run_session_end(dry_run)
|
|
250
|
+
if project_root:
|
|
251
|
+
return _run_resolve_and_reconcile(project_root, fallback_team, dry_run)
|
|
252
|
+
|
|
253
|
+
if len(rest) != 1:
|
|
254
|
+
print("usage: okstra-team-reconcile.sh [--list] "
|
|
255
|
+
"(<team-name|team-dir|config.json> | --project-root <dir> "
|
|
256
|
+
"[--fallback-team <label>] | --session-end)",
|
|
257
|
+
file=sys.stderr)
|
|
258
|
+
return 2
|
|
259
|
+
|
|
260
|
+
cfg_path = _config_path(rest[0])
|
|
261
|
+
if not cfg_path.is_file():
|
|
262
|
+
print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
return _reconcile_config(cfg_path, dry_run, print_members=False)
|
|
@@ -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
|
-
|
|
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 `
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
for
|
|
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
|
-
|
|
315
|
-
out[p.stem] = p
|
|
319
|
+
hit = _cached_needle_scan(p, cache, needle_lower)
|
|
316
320
|
save_cache(p, cache)
|
|
317
321
|
else:
|
|
318
|
-
|
|
319
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
+
team_needles,
|
|
132
138
|
lead_sid,
|
|
133
139
|
projects_root=claude_projects_dir,
|
|
134
140
|
)
|
|
@@ -1527,6 +1527,7 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
|
|
|
1527
1527
|
failures.append(f"final-report data.json: {err}")
|
|
1528
1528
|
|
|
1529
1529
|
_validate_rationale_evidence(data, failures)
|
|
1530
|
+
_validate_no_opaque_id_references(data, failures)
|
|
1530
1531
|
|
|
1531
1532
|
task_type = (data.get("header") or {}).get("taskType")
|
|
1532
1533
|
if task_type == "final-verification":
|
|
@@ -1541,8 +1542,10 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
|
|
|
1541
1542
|
_validate_requirement_coverage_covered_by(data, failures)
|
|
1542
1543
|
|
|
1543
1544
|
|
|
1544
|
-
# path:line (foo.service.ts:268), report ID (C-001 /
|
|
1545
|
-
# or a section reference (§5.4) — any one
|
|
1545
|
+
# path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
|
|
1546
|
+
# namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
|
|
1547
|
+
# satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
|
|
1548
|
+
# handled separately by `_validate_no_opaque_id_references`.
|
|
1546
1549
|
_EVIDENCE_TOKEN = re.compile(
|
|
1547
1550
|
r"[\w./-]+\.\w+:\d+|\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b|§\s*\d")
|
|
1548
1551
|
# Explicit "I don't know" escapes — anti-fabrication's other valid answer.
|
|
@@ -1573,12 +1576,61 @@ def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
|
|
|
1573
1576
|
continue
|
|
1574
1577
|
failures.append(
|
|
1575
1578
|
f"final-report data.json: rationale.{field} cites no evidence "
|
|
1576
|
-
f"(expected a path:line,
|
|
1579
|
+
f"(expected a path:line, an in-report ID like C-001, or a §"
|
|
1577
1580
|
f"section reference) and gives no explicit insufficiency marker "
|
|
1578
1581
|
f"(e.g. '근거 불충분'). Anchor the claim or state what is unknown."
|
|
1579
1582
|
)
|
|
1580
1583
|
|
|
1581
1584
|
|
|
1585
|
+
# Brief/worker-internal ID families that are NOT surfaced (anchored) in the
|
|
1586
|
+
# final report: `RC-` reporter confirmations live in the brief, `F-`/`RF-`
|
|
1587
|
+
# findings live in worker-results. A reader-facing sentence citing a bare one
|
|
1588
|
+
# of these has no in-report target to resolve — the reader is stuck. Namespaced
|
|
1589
|
+
# audit citations (`claude:F-005`) are exempt via the `:` negative lookbehind.
|
|
1590
|
+
_OPAQUE_ID_RE = re.compile(r"(?<![:\w-])(?:RC|RF|F)-\d+(?![\w-])")
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _reader_facing_narrative(data: dict) -> list[tuple[str, str]]:
|
|
1594
|
+
"""(label, text) for every reader-facing narrative string in the report —
|
|
1595
|
+
the fields a human reads to act, where an unresolvable ID is most harmful."""
|
|
1596
|
+
out: list[tuple[str, str]] = []
|
|
1597
|
+
for section in ("verdictCard", "finalVerdict"):
|
|
1598
|
+
block = data.get(section)
|
|
1599
|
+
if isinstance(block, dict):
|
|
1600
|
+
for field in ("finalConclusion", "nextStep"):
|
|
1601
|
+
value = block.get(field)
|
|
1602
|
+
if isinstance(value, str):
|
|
1603
|
+
out.append((f"{section}.{field}", value))
|
|
1604
|
+
rationale = data.get("rationale")
|
|
1605
|
+
if isinstance(rationale, dict):
|
|
1606
|
+
for field in _RATIONALE_FIELDS:
|
|
1607
|
+
value = rationale.get(field)
|
|
1608
|
+
if isinstance(value, str):
|
|
1609
|
+
out.append((f"rationale.{field}", value))
|
|
1610
|
+
for row in data.get("clarificationItems") or []:
|
|
1611
|
+
if isinstance(row, dict) and isinstance(row.get("statement"), str):
|
|
1612
|
+
out.append((f"clarificationItems[{row.get('id') or '?'}].statement", row["statement"]))
|
|
1613
|
+
return out
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _validate_no_opaque_id_references(data: dict, failures: list[str]) -> None:
|
|
1617
|
+
"""Reader-facing prose must not cite a bare brief/worker-internal ID
|
|
1618
|
+
(`RC-*`, `RF-*`, non-namespaced `F-*`) that the report never surfaces — the
|
|
1619
|
+
reader has nothing to click and no definition to find. Expand it inline
|
|
1620
|
+
(`the confirmed version target 1.27.47→1.27.48`) or namespace an audit
|
|
1621
|
+
citation (`claude:F-005`)."""
|
|
1622
|
+
for label, text in _reader_facing_narrative(data):
|
|
1623
|
+
offenders = sorted({m.group(0) for m in _OPAQUE_ID_RE.finditer(text)})
|
|
1624
|
+
if offenders:
|
|
1625
|
+
failures.append(
|
|
1626
|
+
f"final-report data.json: {label} cites brief/worker-internal "
|
|
1627
|
+
f"ID(s) {', '.join(offenders)} that this report never surfaces "
|
|
1628
|
+
"(no anchor to resolve them to). Expand them to plain language "
|
|
1629
|
+
"on the reference, or namespace an audit citation like "
|
|
1630
|
+
"`claude:F-005` — a reader cannot resolve a bare RC-/RF-/F- token."
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
|
|
1582
1634
|
def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
|
|
1583
1635
|
"""타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
|
|
1584
1636
|
`crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 / "
|