okstra 0.1.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.
Files changed (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,126 @@
1
+ """PROJECT_ROOT 해석과 project.json 갱신 로직."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ PROJECT_JSON_RELATIVE = Path(".project-docs/okstra/project.json")
12
+
13
+
14
+ class ResolverError(Exception):
15
+ """PROJECT_ROOT 해석 또는 project.json 충돌 실패."""
16
+
17
+
18
+ def project_json_path(project_root: Path) -> Path:
19
+ return Path(project_root) / PROJECT_JSON_RELATIVE
20
+
21
+
22
+ def _now_iso() -> str:
23
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
24
+
25
+
26
+ def _ancestor_with_project_json(start: Path) -> Optional[Path]:
27
+ """start 부터 위로 올라가며 `.project-docs/okstra/project.json` 보유 디렉터리를 찾는다."""
28
+ cur = Path(start).resolve()
29
+ while True:
30
+ if (cur / PROJECT_JSON_RELATIVE).is_file():
31
+ return cur
32
+ if cur.parent == cur:
33
+ return None
34
+ cur = cur.parent
35
+
36
+
37
+ def _git_toplevel(cwd: Path) -> Optional[Path]:
38
+ try:
39
+ rc = subprocess.run(
40
+ ["git", "rev-parse", "--show-toplevel"],
41
+ cwd=str(cwd), capture_output=True, text=True, check=False,
42
+ )
43
+ except (OSError, FileNotFoundError):
44
+ return None
45
+ if rc.returncode != 0:
46
+ return None
47
+ line = rc.stdout.strip()
48
+ if not line:
49
+ return None
50
+ p = Path(line)
51
+ return p if p.is_dir() else None
52
+
53
+
54
+ def resolve_project_root(*, explicit_root: str = "",
55
+ cwd: Optional[str] = None) -> Path:
56
+ """PROJECT_ROOT 를 해석한다.
57
+
58
+ 우선순위:
59
+ 1. explicit_root (CLI `--project-root`) — 비어있지 않으면 그대로 절대화.
60
+ 2. cwd 또는 그 조상 중 `.project-docs/okstra/project.json` 보유 디렉터리.
61
+ 3. cwd 의 `git rev-parse --show-toplevel`.
62
+ 셋 다 실패하면 ResolverError.
63
+ """
64
+ if explicit_root:
65
+ p = Path(explicit_root).expanduser().resolve()
66
+ if not p.is_dir():
67
+ raise ResolverError(
68
+ f"--project-root path does not exist or is not a directory: {p}")
69
+ return p
70
+ cwd_path = Path(cwd or os.getcwd()).resolve()
71
+ ancestor = _ancestor_with_project_json(cwd_path)
72
+ if ancestor is not None:
73
+ return ancestor
74
+ git_top = _git_toplevel(cwd_path)
75
+ if git_top is not None:
76
+ return git_top.resolve()
77
+ raise ResolverError(
78
+ "PROJECT_ROOT 를 해석할 수 없습니다. "
79
+ "--project-root 를 명시하거나, 프로젝트 루트(또는 그 하위)에서 실행하거나, "
80
+ "git 작업 트리 안에서 실행해 주십시오.")
81
+
82
+
83
+ def upsert_project_json(project_root: Path, project_id: str, *,
84
+ now: Optional[str] = None) -> dict:
85
+ """project.json 을 읽거나 새로 만든다.
86
+
87
+ - 파일이 있으면 projectId 가 인자와 일치해야 한다(불일치 시 ResolverError).
88
+ projectRoot 는 현재 절대경로로 갱신, updatedAt 도 갱신.
89
+ - 파일이 없으면 디렉터리를 만들고 4-필드 JSON 을 작성한다.
90
+ 반환값은 결과 dict.
91
+ """
92
+ if not project_id:
93
+ raise ResolverError("project_id is required for upsert_project_json")
94
+ target = project_json_path(project_root)
95
+ target.parent.mkdir(parents=True, exist_ok=True)
96
+ when = now or _now_iso()
97
+ abs_root = str(Path(project_root).resolve())
98
+ if target.is_file():
99
+ try:
100
+ data = json.loads(target.read_text())
101
+ except (OSError, json.JSONDecodeError) as exc:
102
+ raise ResolverError(
103
+ f"project.json 을 읽을 수 없습니다: {target} ({exc})") from exc
104
+ existing_id = str(data.get("projectId") or "")
105
+ if existing_id and existing_id != project_id:
106
+ raise ResolverError(
107
+ f"projectId 불일치: project.json={existing_id!r}, "
108
+ f"인자={project_id!r}. "
109
+ f"동일 PROJECT_ROOT 에서는 하나의 projectId 만 사용할 수 있습니다.")
110
+ result = {
111
+ "projectId": project_id,
112
+ "projectRoot": abs_root,
113
+ "createdAt": str(data.get("createdAt") or when),
114
+ "updatedAt": when,
115
+ }
116
+ else:
117
+ result = {
118
+ "projectId": project_id,
119
+ "projectRoot": abs_root,
120
+ "createdAt": when,
121
+ "updatedAt": when,
122
+ }
123
+ tmp = target.with_suffix(".json.tmp")
124
+ tmp.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
125
+ os.replace(tmp, target)
126
+ return result
@@ -0,0 +1,170 @@
1
+ """Read-only state accessors over okstra's on-disk authority files.
2
+
3
+ Skills, okstra.sh, and okstra-ctl all read the same files through this module
4
+ so identity / discovery values are derived from disk on every call instead of
5
+ being passed via process environment. This keeps concurrent claude-code skill
6
+ invocations isolated — each call reads the authoritative state at the moment
7
+ it runs and never sees stale snapshots inherited from a parent process.
8
+
9
+ 권위 파일 매핑:
10
+ - <PROJECT_ROOT>/.project-docs/okstra/project.json
11
+ -> {projectId, projectRoot, ...}
12
+ - <PROJECT_ROOT>/.project-docs/okstra/discovery/task-catalog.json
13
+ -> tasks[]: 각 task 의 stable identity 와 phase pointer
14
+ - <PROJECT_ROOT>/.project-docs/okstra/discovery/latest-task.json
15
+ -> 가장 최근에 prepare/run 된 task 의 포인터
16
+ - <task-root>/task-manifest.json
17
+ -> 한 task 의 manifest (workflow.* phase 정보 포함)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ DISCOVERY_RELATIVE = Path(".project-docs/okstra/discovery")
27
+ TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
28
+ LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
29
+ TASKS_RELATIVE = Path(".project-docs/okstra/tasks")
30
+
31
+
32
+ class StateError(Exception):
33
+ """state file 읽기/파싱 실패 — 호출자가 surface 해야 할 오류."""
34
+
35
+
36
+ def _load_json(path: Path) -> Optional[dict]:
37
+ if not path.is_file():
38
+ return None
39
+ try:
40
+ return json.loads(path.read_text(encoding="utf-8"))
41
+ except json.JSONDecodeError as exc:
42
+ raise StateError(f"failed to parse {path}: {exc}") from exc
43
+
44
+
45
+ def slugify(value: str) -> str:
46
+ """task-group / task-id segment 를 디스크 경로용 slug 로 정규화한다.
47
+
48
+ 소문자화 + 알파넘 외 문자 → '-' 로 collapse.
49
+ okstra.sh 의 path-resolve.sh slug 규칙과 일치해야 한다.
50
+ """
51
+ value = (value or "").lower()
52
+ value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
53
+ return value
54
+
55
+
56
+ def read_task_catalog(project_root: Path) -> list[dict]:
57
+ """project-level task-catalog.json 을 list[dict] 로 돌려준다.
58
+
59
+ 파일이 없으면 빈 리스트(아직 task 가 prepare 되지 않은 신규 프로젝트).
60
+ 파싱 실패 시 StateError.
61
+ """
62
+ catalog = _load_json(Path(project_root) / TASK_CATALOG_RELATIVE)
63
+ if not isinstance(catalog, dict):
64
+ return []
65
+ tasks = catalog.get("tasks")
66
+ return [t for t in tasks if isinstance(t, dict)] if isinstance(tasks, list) else []
67
+
68
+
69
+ def read_latest_task(project_root: Path) -> Optional[dict]:
70
+ """latest-task.json 을 dict 로. 파일 없으면 None."""
71
+ return _load_json(Path(project_root) / LATEST_TASK_RELATIVE)
72
+
73
+
74
+ def read_task_manifest(task_root: Path) -> Optional[dict]:
75
+ """<task-root>/task-manifest.json 을 dict 로. 파일 없으면 None."""
76
+ return _load_json(Path(task_root) / "task-manifest.json")
77
+
78
+
79
+ def parse_task_key(task_key: str) -> tuple[str, str, str]:
80
+ """`project-id:task-group:task-id` 를 3-tuple 로 분해.
81
+
82
+ 형식 불일치 시 StateError.
83
+ """
84
+ parts = (task_key or "").split(":")
85
+ if len(parts) != 3 or not all(parts):
86
+ raise StateError(
87
+ f"invalid task-key: {task_key!r} (expected project-id:task-group:task-id)")
88
+ return parts[0], parts[1], parts[2]
89
+
90
+
91
+ def find_task_root(project_root: Path, task_key: str) -> Optional[Path]:
92
+ """task-key 로 task root 디렉터리를 해석한다.
93
+
94
+ 해석 우선순위:
95
+ 1. task-catalog.json 의 같은 taskKey 항목의 taskRootPath
96
+ 2. <PROJECT_ROOT>/.project-docs/okstra/tasks/<slug-group>/<slug-id>/
97
+
98
+ 어느 쪽도 디렉터리로 존재하지 않으면 None.
99
+ """
100
+ project_root = Path(project_root)
101
+ _, task_group, task_id = parse_task_key(task_key)
102
+ requested_ci = task_key.lower()
103
+
104
+ for entry in read_task_catalog(project_root):
105
+ entry_key = entry.get("taskKey") or ""
106
+ if not isinstance(entry_key, str) or entry_key.lower() != requested_ci:
107
+ continue
108
+ rel = entry.get("taskRootPath") or entry.get("taskRoot") or ""
109
+ if isinstance(rel, str) and rel:
110
+ abs_path = project_root / rel if not Path(rel).is_absolute() else Path(rel)
111
+ if abs_path.is_dir():
112
+ return abs_path
113
+
114
+ slug_path = project_root / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
115
+ if slug_path.is_dir():
116
+ return slug_path
117
+ return None
118
+
119
+
120
+ def list_project_tasks(project_root: Path) -> list[dict]:
121
+ """skill UI 에 보여줄 task 후보 목록을 돌려준다.
122
+
123
+ 각 항목은 task-catalog.json 의 entry 그대로 + 디스크 존재 여부 확인.
124
+ 존재하지 않는 task root 는 skip(stale catalog 항목)한다.
125
+ """
126
+ project_root = Path(project_root)
127
+ out = []
128
+ for entry in read_task_catalog(project_root):
129
+ entry_key = entry.get("taskKey") or ""
130
+ if not isinstance(entry_key, str) or not entry_key:
131
+ continue
132
+ root = find_task_root(project_root, entry_key)
133
+ if root is None:
134
+ continue
135
+ out.append({**entry, "_resolvedTaskRoot": str(root)})
136
+ return out
137
+
138
+
139
+ def resolve_task_identity(project_root: Path, task_key: str) -> dict:
140
+ """task-key 한 줄로 manifest + 경로를 한 번에 반환.
141
+
142
+ skill / okstra.sh / okstra-ctl 모두가 이 함수를 호출해 같은 dict 를 받는다.
143
+ StateError: task root 또는 manifest 없음.
144
+ """
145
+ task_root = find_task_root(project_root, task_key)
146
+ if task_root is None:
147
+ raise StateError(f"task root not found for {task_key!r}")
148
+ manifest = read_task_manifest(task_root)
149
+ if manifest is None:
150
+ raise StateError(f"task-manifest.json missing under {task_root}")
151
+ workflow = manifest.get("workflow") or {}
152
+ if not isinstance(workflow, dict):
153
+ workflow = {}
154
+ project_id, task_group, task_id = parse_task_key(task_key)
155
+ return {
156
+ "projectId": project_id,
157
+ "projectRoot": str(project_root),
158
+ "taskGroup": task_group,
159
+ "taskId": task_id,
160
+ "taskKey": task_key,
161
+ "taskRoot": str(task_root),
162
+ "taskType": manifest.get("taskType") or "",
163
+ "currentPhase": workflow.get("currentPhase") or "",
164
+ "currentPhaseState": workflow.get("currentPhaseState") or "",
165
+ "nextRecommendedPhase": workflow.get("nextRecommendedPhase") or "",
166
+ "lastCompletedPhase": workflow.get("lastCompletedPhase") or "",
167
+ "awaitingApproval": bool(workflow.get("awaitingApproval", False)),
168
+ "taskBriefPath": manifest.get("taskBriefPath") or "",
169
+ "manifest": manifest,
170
+ }
@@ -0,0 +1,26 @@
1
+ """okstra token usage collector — package form of the legacy okstra-token-usage.py script."""
2
+ from __future__ import annotations
3
+
4
+ from .blocks import na_block, usage_block
5
+ from .claude import claude_session_totals, find_claude_team_sessions
6
+ from .codex import codex_session_total, find_codex_session
7
+ from .collect import collect
8
+ from .gemini import find_gemini_session, gemini_session_total
9
+ from .jsonl_io import iter_jsonl
10
+ from .paths import (
11
+ CLAUDE_PROJECTS,
12
+ CODEX_SESSIONS,
13
+ GEMINI_TMP,
14
+ claude_project_dir,
15
+ utc_now,
16
+ )
17
+ from .pricing import (
18
+ CLAUDE_PRICING,
19
+ CODEX_PRICING,
20
+ GEMINI_PRICING,
21
+ claude_billable_equivalent,
22
+ claude_cost_usd,
23
+ codex_cost_usd,
24
+ gemini_cost_usd,
25
+ )
26
+ from .report import substitute_final_report
@@ -0,0 +1,62 @@
1
+ """Per-source usage block constructors (consumed by collect())."""
2
+ from __future__ import annotations
3
+
4
+ from .paths import utc_now
5
+ from .pricing import (
6
+ claude_billable_equivalent,
7
+ claude_cost_usd,
8
+ codex_cost_usd,
9
+ gemini_cost_usd,
10
+ )
11
+
12
+
13
+ def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
14
+ block = {
15
+ "totalTokens": totals.get("totalTokens", 0) or 0,
16
+ "inputTokens": totals.get("inputTokens", 0) or 0,
17
+ "outputTokens": totals.get("outputTokens", 0) or 0,
18
+ "toolUses": totals.get("toolUses", 0) or 0,
19
+ "durationMs": totals.get("durationMs", 0) or 0,
20
+ "source": source,
21
+ "collectedAt": utc_now(),
22
+ }
23
+ for key in ("cacheCreationTokens", "cacheReadTokens", "cachedInputTokens",
24
+ "reasoningOutputTokens", "cachedTokens", "thoughtsTokens", "toolTokens"):
25
+ if totals.get(key):
26
+ block[key] = totals[key]
27
+
28
+ # Billable-equivalent + cost.
29
+ if source == "claude-jsonl":
30
+ be = claude_billable_equivalent(
31
+ totals.get("inputTokens", 0) or 0,
32
+ totals.get("cacheCreationTokens", 0) or 0,
33
+ totals.get("cacheReadTokens", 0) or 0,
34
+ totals.get("outputTokens", 0) or 0,
35
+ )
36
+ block["billableEquivalentTokens"] = be
37
+ cost = claude_cost_usd(
38
+ totals.get("model"),
39
+ totals.get("inputTokens", 0) or 0,
40
+ totals.get("cacheCreationTokens", 0) or 0,
41
+ totals.get("cacheReadTokens", 0) or 0,
42
+ totals.get("outputTokens", 0) or 0,
43
+ )
44
+ if cost is not None:
45
+ block["estimatedCostUsd"] = cost
46
+ if totals.get("model"):
47
+ block["model"] = totals["model"]
48
+ if note:
49
+ block["note"] = note
50
+ return block
51
+
52
+
53
+ def na_block(reason: str) -> dict:
54
+ return {
55
+ "totalTokens": 0,
56
+ "toolUses": 0,
57
+ "durationMs": 0,
58
+ "source": "unavailable",
59
+ "collectedAt": utc_now(),
60
+ "note": reason,
61
+ }
62
+
@@ -0,0 +1,97 @@
1
+ """Claude Code transcript collectors."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from .jsonl_io import iter_jsonl
7
+ from .paths import claude_project_dir
8
+
9
+
10
+ def claude_session_totals(jsonl_path: Path) -> dict:
11
+ """Return totals + agentName + assistant model + time window for a Claude session jsonl."""
12
+ input_t = output_t = cache_create_t = cache_read_t = 0
13
+ tool_uses = 0
14
+ agent_name: str | None = None
15
+ model: str | None = None
16
+ first_ts: str | None = None
17
+ last_ts: str | None = None
18
+ started_at: str | None = None
19
+ ended_at: str | None = None
20
+ for rec in iter_jsonl(jsonl_path):
21
+ if agent_name is None and rec.get("agentName"):
22
+ agent_name = rec["agentName"]
23
+ msg = rec.get("message") or {}
24
+ usage = msg.get("usage")
25
+ if usage:
26
+ input_t += usage.get("input_tokens", 0) or 0
27
+ output_t += usage.get("output_tokens", 0) or 0
28
+ cache_create_t += usage.get("cache_creation_input_tokens", 0) or 0
29
+ cache_read_t += usage.get("cache_read_input_tokens", 0) or 0
30
+ if rec.get("type") == "assistant":
31
+ if model is None and msg.get("model"):
32
+ model = msg["model"]
33
+ for block in (msg.get("content") or []):
34
+ if isinstance(block, dict) and block.get("type") == "tool_use":
35
+ tool_uses += 1
36
+ ts = rec.get("timestamp") or (msg.get("timestamp") if isinstance(msg, dict) else None)
37
+ if ts:
38
+ if first_ts is None or ts < first_ts:
39
+ first_ts = ts
40
+ if last_ts is None or ts > last_ts:
41
+ last_ts = ts
42
+ duration_ms = 0
43
+ if first_ts and last_ts:
44
+ try:
45
+ a = datetime.fromisoformat(first_ts.replace("Z", "+00:00"))
46
+ b = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
47
+ duration_ms = max(0, int((b - a).total_seconds() * 1000))
48
+ except ValueError:
49
+ duration_ms = 0
50
+ total = input_t + output_t + cache_create_t + cache_read_t
51
+ return {
52
+ "totalTokens": total,
53
+ "inputTokens": input_t,
54
+ "outputTokens": output_t,
55
+ "cacheCreationTokens": cache_create_t,
56
+ "cacheReadTokens": cache_read_t,
57
+ "toolUses": tool_uses,
58
+ "durationMs": duration_ms,
59
+ "agentName": agent_name,
60
+ "model": model,
61
+ "startedAt": first_ts,
62
+ "endedAt": last_ts,
63
+ }
64
+
65
+
66
+ def find_claude_team_sessions(cwd: Path, team_name: str, lead_sid: str | None = None) -> dict[str, Path]:
67
+ """Map sessionId -> jsonl path for all jsonls tagged with `team_name`.
68
+
69
+ Matching is case-insensitive on the teamName needle to tolerate runs where
70
+ the lead recorded `team.teamName` with a different case than the harness
71
+ serialised into the transcript (e.g. `okstra-DEV-6827` vs `okstra-dev-6827`).
72
+
73
+ If `lead_sid` is provided and exists in the project dir, it is always
74
+ included even when no teamName needle matches — this lets us recover lead
75
+ usage in fallback runs that never wrote `team.teamName` into team-state.
76
+ """
77
+ proj_dir = claude_project_dir(cwd)
78
+ out: dict[str, Path] = {}
79
+ if not proj_dir.is_dir():
80
+ return out
81
+ needle_lower = f'"teamname":"{(team_name or "").lower()}"'
82
+ have_team = bool(team_name)
83
+ for p in proj_dir.glob("*.jsonl"):
84
+ try:
85
+ with p.open() as fh:
86
+ for chunk in fh:
87
+ if have_team and needle_lower in chunk.lower():
88
+ out[p.stem] = p
89
+ break
90
+ except OSError:
91
+ continue
92
+ if lead_sid:
93
+ direct = proj_dir / f"{lead_sid}.jsonl"
94
+ if direct.is_file():
95
+ out.setdefault(lead_sid, direct)
96
+ return out
97
+
@@ -0,0 +1,84 @@
1
+ """Command-line entry point for the package."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from .collect import collect
9
+ from .report import substitute_final_report
10
+
11
+
12
+ def main() -> int:
13
+ parser = argparse.ArgumentParser(description=__doc__)
14
+ parser.add_argument("team_state", type=Path, help="Path to team-state JSON file")
15
+ parser.add_argument("--project-root", type=Path, default=None,
16
+ help="Override project root (default: inferred from team-state path)")
17
+ parser.add_argument("--write", action="store_true",
18
+ help="Write the updated team-state back to the same path (default: print to stdout)")
19
+ parser.add_argument("--summary", action="store_true",
20
+ help="Also print a one-line summary to stderr")
21
+ parser.add_argument("--substitute-final-report", type=Path, default=None,
22
+ help="After collecting usage, substitute {{LEAD_TOTAL_TOKENS}} / {{LEAD_BILLABLE_TOKENS}} / "
23
+ "{{LEAD_COST_USD}} / {{WORKER_TOTAL_TOKENS}} / {{WORKER_BILLABLE_TOKENS}} / "
24
+ "{{WORKER_COST_USD}} / {{GRAND_TOTAL_TOKENS}} / {{GRAND_BILLABLE_TOKENS}} / "
25
+ "{{GRAND_COST_USD}} / {{CLI_COST_USD}} placeholders in the given final-report file "
26
+ "with concrete values from the freshly computed usageSummary.")
27
+ args = parser.parse_args()
28
+
29
+ if not args.team_state.is_file():
30
+ print(f"team-state not found: {args.team_state}", file=sys.stderr)
31
+ return 2
32
+
33
+ updated = collect(args.team_state, args.project_root)
34
+
35
+ if args.write:
36
+ args.team_state.write_text(json.dumps(updated, indent=2, ensure_ascii=False) + "\n")
37
+ else:
38
+ json.dump(updated, sys.stdout, indent=2, ensure_ascii=False)
39
+ sys.stdout.write("\n")
40
+
41
+ if args.summary:
42
+ s = updated.get("usageSummary") or {}
43
+ cost = s.get("estimatedCostUsd") or {}
44
+ print(
45
+ f"raw: lead={s.get('leadTotalTokens', 0):,} workers={s.get('workerTotalTokens', 0):,} "
46
+ f"grand={s.get('grandTotalTokens', 0):,}",
47
+ file=sys.stderr,
48
+ )
49
+ print(
50
+ f"billable-equiv: lead={s.get('leadBillableEquivalentTokens', 0):,} "
51
+ f"workers={s.get('workerBillableEquivalentTokens', 0):,} "
52
+ f"grand={s.get('grandBillableEquivalentTokens', 0):,}",
53
+ file=sys.stderr,
54
+ )
55
+ print(
56
+ f"cost USD: lead=${cost.get('lead', 0):.2f} claude-workers=${cost.get('claudeWorkers', 0):.2f} "
57
+ f"cli-workers=${cost.get('cliWorkers', 0):.2f} grand=${cost.get('grandTotal', 0):.2f}",
58
+ file=sys.stderr,
59
+ )
60
+ print(f"sessions={s.get('sessionsFound', 0)} team={s.get('teamName', '')}", file=sys.stderr)
61
+
62
+ if args.substitute_final_report is not None:
63
+ replaced = substitute_final_report(args.substitute_final_report, updated)
64
+ if replaced < 0:
65
+ print(
66
+ f"final-report substitution skipped: file not found at {args.substitute_final_report}",
67
+ file=sys.stderr,
68
+ )
69
+ elif replaced == 0:
70
+ print(
71
+ f"final-report substitution: no token placeholders found at {args.substitute_final_report}",
72
+ file=sys.stderr,
73
+ )
74
+ else:
75
+ print(
76
+ f"final-report substitution: replaced {replaced} placeholder occurrence(s) in {args.substitute_final_report}",
77
+ file=sys.stderr,
78
+ )
79
+
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,80 @@
1
+ """Codex CLI rollout collectors."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from .jsonl_io import iter_jsonl
7
+ from .paths import CODEX_SESSIONS
8
+
9
+
10
+ def codex_session_total(jsonl_path: Path) -> dict:
11
+ """Return last token_count snapshot from a codex rollout jsonl."""
12
+ last: dict | None = None
13
+ cwd_val: str | None = None
14
+ model_val: str | None = None
15
+ started: str | None = None
16
+ ended: str | None = None
17
+ for rec in iter_jsonl(jsonl_path):
18
+ if rec.get("type") == "session_meta":
19
+ payload = rec.get("payload") or {}
20
+ cwd_val = payload.get("cwd")
21
+ started = payload.get("timestamp")
22
+ elif rec.get("type") == "turn_context":
23
+ payload = rec.get("payload") or {}
24
+ if model_val is None and payload.get("model"):
25
+ model_val = payload["model"]
26
+ elif rec.get("type") == "event_msg":
27
+ payload = rec.get("payload") or {}
28
+ if payload.get("type") == "token_count":
29
+ last = payload
30
+ ended = rec.get("timestamp")
31
+ if last is None:
32
+ return {"totalTokens": 0, "cwd": cwd_val, "model": model_val, "available": False}
33
+ info = last.get("info") or {}
34
+ total = info.get("total_token_usage") or {}
35
+ return {
36
+ "totalTokens": total.get("total_tokens", 0) or 0,
37
+ "inputTokens": total.get("input_tokens", 0) or 0,
38
+ "cachedInputTokens": total.get("cached_input_tokens", 0) or 0,
39
+ "outputTokens": total.get("output_tokens", 0) or 0,
40
+ "reasoningOutputTokens": total.get("reasoning_output_tokens", 0) or 0,
41
+ "cwd": cwd_val,
42
+ "model": model_val,
43
+ "startedAt": started,
44
+ "endedAt": ended,
45
+ "available": True,
46
+ }
47
+
48
+
49
+ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None:
50
+ """Find the codex rollout jsonl whose meta.cwd matches and timestamp falls in window."""
51
+ if not CODEX_SESSIONS.is_dir() or not started_at or not ended_at:
52
+ return None
53
+ target_cwd = str(cwd)
54
+ candidates: list[tuple[str, Path]] = []
55
+ for p in CODEX_SESSIONS.rglob("rollout-*.jsonl"):
56
+ try:
57
+ with p.open() as fh:
58
+ first = fh.readline()
59
+ except OSError:
60
+ continue
61
+ if not first:
62
+ continue
63
+ try:
64
+ rec = json.loads(first)
65
+ except json.JSONDecodeError:
66
+ continue
67
+ if rec.get("type") != "session_meta":
68
+ continue
69
+ payload = rec.get("payload") or {}
70
+ if payload.get("cwd") != target_cwd:
71
+ continue
72
+ ts = payload.get("timestamp") or rec.get("timestamp") or ""
73
+ if not (started_at <= ts <= ended_at):
74
+ continue
75
+ candidates.append((ts, p))
76
+ if not candidates:
77
+ return None
78
+ candidates.sort()
79
+ return candidates[-1][1]
80
+