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.
- package/README.md +36 -0
- package/bin/okstra +62 -0
- package/package.json +30 -0
- package/runtime/.gitkeep +0 -0
- package/runtime/BUILD.json +5 -0
- package/runtime/agents/SKILL.md +243 -0
- package/runtime/agents/TODO.md +168 -0
- package/runtime/agents/workers/claude-worker.md +106 -0
- package/runtime/agents/workers/codex-worker.md +179 -0
- package/runtime/agents/workers/gemini-worker.md +179 -0
- package/runtime/agents/workers/report-writer-worker.md +116 -0
- package/runtime/bin/okstra-central.sh +152 -0
- package/runtime/bin/okstra-codex-exec.sh +53 -0
- package/runtime/bin/okstra-error-log.py +295 -0
- package/runtime/bin/okstra-gemini-exec.sh +55 -0
- package/runtime/bin/okstra-token-usage.py +46 -0
- package/runtime/bin/okstra.sh +162 -0
- package/runtime/prompts/launch.template.md +52 -0
- package/runtime/prompts/profiles/error-analysis.md +43 -0
- package/runtime/prompts/profiles/final-verification.md +37 -0
- package/runtime/prompts/profiles/implementation-planning.md +85 -0
- package/runtime/prompts/profiles/implementation.md +71 -0
- package/runtime/prompts/profiles/requirements-discovery.md +43 -0
- package/runtime/python/lib/okstra/cli.sh +227 -0
- package/runtime/python/lib/okstra/globals.sh +157 -0
- package/runtime/python/lib/okstra/interactive.sh +411 -0
- package/runtime/python/lib/okstra/project-resolver.sh +57 -0
- package/runtime/python/lib/okstra/usage.sh +98 -0
- package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
- package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
- package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
- package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
- package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
- package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
- package/runtime/python/lib/okstra-ctl/main.sh +41 -0
- package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
- package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
- package/runtime/python/okstra_ctl/__init__.py +125 -0
- package/runtime/python/okstra_ctl/backfill.py +253 -0
- package/runtime/python/okstra_ctl/batch.py +62 -0
- package/runtime/python/okstra_ctl/ids.py +84 -0
- package/runtime/python/okstra_ctl/index.py +216 -0
- package/runtime/python/okstra_ctl/invocation.py +49 -0
- package/runtime/python/okstra_ctl/jsonl.py +84 -0
- package/runtime/python/okstra_ctl/listing.py +156 -0
- package/runtime/python/okstra_ctl/locks.py +42 -0
- package/runtime/python/okstra_ctl/material.py +62 -0
- package/runtime/python/okstra_ctl/models.py +63 -0
- package/runtime/python/okstra_ctl/path_resolve.py +40 -0
- package/runtime/python/okstra_ctl/paths.py +251 -0
- package/runtime/python/okstra_ctl/project_meta.py +51 -0
- package/runtime/python/okstra_ctl/reconcile.py +166 -0
- package/runtime/python/okstra_ctl/render.py +1065 -0
- package/runtime/python/okstra_ctl/resolver.py +54 -0
- package/runtime/python/okstra_ctl/run.py +674 -0
- package/runtime/python/okstra_ctl/run_context.py +166 -0
- package/runtime/python/okstra_ctl/seeding.py +97 -0
- package/runtime/python/okstra_ctl/sequence.py +53 -0
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/tmux.py +27 -0
- package/runtime/python/okstra_ctl/workers.py +64 -0
- package/runtime/python/okstra_ctl/workflow.py +182 -0
- package/runtime/python/okstra_project/__init__.py +41 -0
- package/runtime/python/okstra_project/resolver.py +126 -0
- package/runtime/python/okstra_project/state.py +170 -0
- package/runtime/python/okstra_token_usage/__init__.py +26 -0
- package/runtime/python/okstra_token_usage/blocks.py +62 -0
- package/runtime/python/okstra_token_usage/claude.py +97 -0
- package/runtime/python/okstra_token_usage/cli.py +84 -0
- package/runtime/python/okstra_token_usage/codex.py +80 -0
- package/runtime/python/okstra_token_usage/collect.py +161 -0
- package/runtime/python/okstra_token_usage/gemini.py +77 -0
- package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
- package/runtime/python/okstra_token_usage/paths.py +22 -0
- package/runtime/python/okstra_token_usage/pricing.py +71 -0
- package/runtime/python/okstra_token_usage/report.py +64 -0
- package/runtime/templates/prd/brief.template.md +273 -0
- package/runtime/templates/project-docs/task-index.template.md +65 -0
- package/runtime/templates/reports/error-analysis-input.template.md +80 -0
- package/runtime/templates/reports/final-report.template.md +167 -0
- package/runtime/templates/reports/final-verification-input.template.md +67 -0
- package/runtime/templates/reports/implementation-input.template.md +81 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
- package/runtime/templates/reports/quick-input.template.md +64 -0
- package/runtime/templates/reports/schedule.template.md +168 -0
- package/runtime/templates/reports/settings.template.json +101 -0
- package/runtime/templates/reports/task-brief.template.md +165 -0
- package/runtime/validators/lib/common.sh +44 -0
- package/runtime/validators/lib/fixtures.sh +322 -0
- package/runtime/validators/lib/paths.sh +44 -0
- package/runtime/validators/lib/runners.sh +140 -0
- package/runtime/validators/lib/summary.sh +15 -0
- package/runtime/validators/lib/validate-assets.sh +44 -0
- package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
- package/runtime/validators/lib/validate-tasks.sh +335 -0
- package/runtime/validators/validate-run.py +568 -0
- package/runtime/validators/validate-schedule.py +665 -0
- package/runtime/validators/validate-workflow.sh +190 -0
- package/src/doctor.mjs +127 -0
- package/src/install.mjs +355 -0
- package/src/paths.mjs +132 -0
- package/src/uninstall.mjs +122 -0
- 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
|
+
|