okstra 0.120.0 → 0.121.1

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 CHANGED
@@ -169,6 +169,7 @@ Use these slash commands inside a Claude Code session:
169
169
  | `/okstra-memory` | Store, search, and archive global conversation memory in `~/.okstra/memory-book` |
170
170
  | `/okstra-inspect` | Unified read side. Subcommands: `status` (phase/state and workStatus updates), `history` (past tasks, reruns, resumes), `report` (find/read final reports), `time` (elapsed-time breakdown), `logs` (wrapper log sidecar inventory and cleanup suggestions), `cost` (task bundle context/read cost), `errors` (aggregate run error logs into a report), `error-zip` (collect cross-project error logs into an anonymized zip and summarize clusters), and `recap` (run-to-run before/after summary plus free-form Q&A over a task's `.okstra` artifacts) |
171
171
  | `/okstra-rollup` | Aggregate every task run in a task group or project, including per-task run counts, duration, errors, group totals, and a cross-task report digest |
172
+ | `/okstra-usage` | Show the current project's recent run coverage, raw and billable-equivalent tokens, known USD cost, CPU time, and wall-clock time grouped by task type (default: last 30 days) |
172
173
  | `/okstra-schedule` | Generate a work schedule for an entire task group |
173
174
  | `/okstra-container-build` | Deploy a verified task's code as a local Docker Compose group and monitor per-container logs (subcommands: `up` / `status` / `logs` / `stop-watcher` / `down`) |
174
175
  | `/okstra-graphify` | Build and query a knowledge graph over the project's own `.okstra/` memory—final reports, `decisions/*.md`, and `glossary.md`—with scope restricted to `.okstra/` and output under `.okstra/graph/` (subcommands: `build` / `query` / `path` / `explain` / `mcp` / `wiki`) |
@@ -147,9 +147,9 @@ Runtime entry points are consolidated in Python packages. Bash and skills only c
147
147
  - [`prompts/lead/okstra-lead-contract.md`](../prompts/lead/okstra-lead-contract.md) — main okstra lead contract. It is a runtime resource (`~/.okstra/prompts/lead/`), not an agent skill.
148
148
  - [`skills/okstra-setup/SKILL.md`](../skills/okstra-setup/SKILL.md) — **first-run bootstrap**. Runs `okstra install` and creates `project.json`.
149
149
  - [`skills/okstra-run/SKILL.md`](../skills/okstra-run/SKILL.md) — in-session entry point that **starts an okstra task in the current Claude session**. Calls `prepare_task_bundle` directly.
150
- - Only ten skills are user-invocable: `skills/okstra-setup/SKILL.md`, `skills/okstra-brief-gen/SKILL.md`, `skills/okstra-run/SKILL.md`, `skills/okstra-manager/SKILL.md`, `skills/okstra-memory/SKILL.md`, `skills/okstra-inspect/SKILL.md`, `skills/okstra-rollup/SKILL.md`, `skills/okstra-schedule/SKILL.md`, `skills/okstra-container-build/SKILL.md`, and `skills/okstra-graphify/SKILL.md`. Only these are copied into the agent skill home. They cover brief authoring, phase execution, cross-project manager task coordination, global Memory Book storage/search, read-side status/history/report/time/logs/cost/errors/recap, task-group-level aggregation of run results (rollup), schedule support, and `okstra-container-build`, a nonlinear container deployment/monitoring skill outside PHASE_SEQUENCE that deploys verified task code as a docker compose group and monitors it with per-container watchers. `okstra-manager` uses `okstra manager` CLI JSON/launch packets as the source of truth, and stores manager-owned plans, assignments, directives, snapshots, and events under `~/.okstra/managers/<manager-id>/`. `okstra-rollup` is a read-side layer that fans the single-task aggregators from `okstra-inspect` (time/errors/recap) out to a task group or the whole project catalog. The `okstra rollup` CLI owns deterministic aggregation, while the skill (LLM) writes only the synthesized report summary. The canonical definition of `okstra-inspect` read-side facets is the subcommand table in `skills/okstra-inspect/SKILL.md`. `okstra-inspect logs` provides a read-only inventory and cleanup guidance for the live-log sidecars that the Codex/Antigravity wrappers write on every dispatch at the resolved `<run-dir>/prompts/<worker>-prompt-<phase>-<seq>.log`; for stage executions, the stage-qualified `run_dir` includes `stage-<N>/`. `okstra-inspect cost` summarizes `okstra context-cost`; `okstra-inspect errors` collects a task's okstra-run error logs into a timestamped Markdown error report and prints a summary; and `okstra-inspect recap` answers free-form questions about `.okstra` artifacts in addition to summarizing phases before and after each task run.
150
+ - Thirteen skills are user-invocable: `skills/okstra-setup/SKILL.md`, `skills/okstra-brief-gen/SKILL.md`, `skills/okstra-run/SKILL.md`, `skills/okstra-manager/SKILL.md`, `skills/okstra-memory/SKILL.md`, `skills/okstra-inspect/SKILL.md`, `skills/okstra-rollup/SKILL.md`, `skills/okstra-usage/SKILL.md`, `skills/okstra-schedule/SKILL.md`, `skills/okstra-container-build/SKILL.md`, `skills/okstra-graphify/SKILL.md`, `skills/okstra-pr-gen/SKILL.md`, and `skills/okstra-user-response/SKILL.md`. Only these are copied into the agent skill home. They cover brief authoring, phase execution, cross-project manager task coordination, global Memory Book storage/search, read-side status/history/report/time/logs/cost/errors/recap, task-group-level aggregation of run results (rollup), project-wide historical resource usage, schedule support, local container deployment, `.okstra` knowledge graphs, PR description generation, and clarification-response submission. `okstra-manager` uses `okstra manager` CLI JSON/launch packets as the source of truth, and stores manager-owned plans, assignments, directives, snapshots, and events under `~/.okstra/managers/<manager-id>/`. `okstra-rollup` is a read-side layer that fans the single-task aggregators from `okstra-inspect` (time/errors/recap) out to a task group or the whole project catalog. The `okstra rollup` CLI owns deterministic aggregation, while the skill (LLM) writes only the synthesized report summary. `okstra-usage` is a separate read-only resource snapshot grouped by lifecycle task type; it does not replace single-task `okstra-inspect` detail or the status/report digest from `okstra-rollup`. The canonical definition of `okstra-inspect` read-side facets is the subcommand table in `skills/okstra-inspect/SKILL.md`. `okstra-inspect logs` provides a read-only inventory and cleanup guidance for the live-log sidecars that the Codex/Antigravity wrappers write on every dispatch at the resolved `<run-dir>/prompts/<worker>-prompt-<phase>-<seq>.log`; for stage executions, the stage-qualified `run_dir` includes `stage-<N>/`. `okstra-inspect cost` summarizes `okstra context-cost`; `okstra-inspect errors` collects a task's okstra-run error logs into a timestamped Markdown error report and prints a summary; and `okstra-inspect recap` answers free-form questions about `.okstra` artifacts in addition to summarizing phases before and after each task run.
151
151
  - Internal operating contracts—`context-loader` / `team-contract` / `convergence` / `report-writer` and the lead contract—have moved to `prompts/lead/*.md`. Language-specific coding preflight for implementation/verification workers has moved to `prompts/coding-preflight/*` (overview router + clean-code + three-stage language/framework/architecture selection). All are runtime resources installed under `~/.okstra/prompts/` and are not discoverable as skills. The generated launch prompt provides the lead with absolute paths, and reinstalling prunes the legacy exact-name skill directories `okstra-context-loader` / `okstra-team-contract` / `okstra-convergence` / `okstra-report-writer` / `okstra-coding-preflight` / `okstra`.
152
- - Plugin manifest: [`../../.claude-plugin/plugin.json`](../.claude-plugin/plugin.json) — referenced by the supplementary `npx skills@latest add Devonshin/okstra` channel. Use `npx okstra@latest install` for normal setup. The plugin manifest exposes only the ten user entry points (`okstra-setup`, `okstra-brief-gen`, `okstra-run`, `okstra-manager`, `okstra-memory`, `okstra-inspect`, `okstra-rollup`, `okstra-schedule`, `okstra-container-build`, `okstra-graphify`).
152
+ - Plugin manifest: [`../../.claude-plugin/plugin.json`](../.claude-plugin/plugin.json) — referenced by the supplementary `npx skills@latest add Devonshin/okstra` channel. Use `npx okstra@latest install` for normal setup. The plugin manifest exposes only the thirteen user entry points (`okstra-setup`, `okstra-brief-gen`, `okstra-run`, `okstra-manager`, `okstra-memory`, `okstra-inspect`, `okstra-rollup`, `okstra-usage`, `okstra-schedule`, `okstra-container-build`, `okstra-graphify`, `okstra-pr-gen`, `okstra-user-response`).
153
153
  - Installation location: `~/.claude/skills/<name>/SKILL.md` or `~/.agents/skills/<name>/SKILL.md`.
154
154
  - Release procedure: [`../../RELEASING.md`](../RELEASING.md) — npm publish flow and release-please / manual fallback.
155
155
 
@@ -711,6 +711,7 @@ Tokens used in each run are collected from lead/worker session transcripts and w
711
711
  - Antigravity CLI: per-message `tokens.total` in `~/.antigravity/tmp/*/chats/session-*.json`
712
712
  - Records billable-equivalent token math and USD cost estimates. It applies Anthropic billing ratios (`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`). When the transcript provides separate `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` values, they are counted separately.
713
713
  - Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py`. Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
714
+ - Project-wide historical usage is exposed through the read-only `okstra usage-report` command (`src/commands/inspect/usage-report.mjs` → `scripts/okstra_ctl/usage_report.py`) and the `okstra-usage` skill. It defaults to the whole current project's last 30 days and returns run coverage, raw and billable-equivalent tokens, known USD cost, CPU-sum milliseconds, and wall-clock milliseconds grouped by task type. Runs without usable Phase 7 usage are excluded from resource totals and reported through unavailable reason counts rather than treated as zero usage; unmatched model names remain visible when their tokens and time are included but their cost is not. Use `okstra-inspect` for one task's elapsed/context detail and `okstra-rollup` for task-group or project status/report digests.
714
715
  - **Incremental scan cache (P6)**: To avoid rescanning session jsonl files, a per-file byte cursor and the extracted usage events before windowing are stored in `$OKSTRA_HOME/cache/token-usage/<transcript-dir>/<sessionId>.json` (`scripts/okstra_token_usage/cursor.py`). The run window (since/until) is reevaluated over events on every invocation, so even if a rerun narrows the window, the total matches a full scan. The cache is derived data; identifier mismatch, truncation, or corruption automatically falls back to a full rescan, and `okstra-token-usage.py --no-cache` forces a bypass.
715
716
  - **Phase timeline (P0 instrumentation)**: The collector extracts `PROGRESS: phase-*` checkpoint lines (see "Progress reporting" in prompts/lead/okstra-lead-contract.md) from the lead session jsonl scoped to the run window, and records them in team-state as a `phaseTimeline` block (`{source, phases: [{phase, firstAt, lastAt, markerCount, wallMsToNext}]}`) (`scripts/okstra_token_usage/collect.py :: phase_timeline`). This provides measurement points for per-phase wall-clock time within the run and is consumed by the "Per-run phase breakdown" in the `okstra-inspect` time facet. Runs without markers explicitly report that measurement is unavailable with `phases: []`.
716
717
 
package/docs/cli.md CHANGED
@@ -637,6 +637,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
637
637
  | `okstra memory <add\|list\|search\|show\|archive>` | Manage global conversation memory in `~/.okstra/memory-book`, a user-home store separate from project `.okstra/` and the CLI basis of the `save this in okstra` natural-language skill |
638
638
  | `okstra manager <init\|discover-projects\|new\|task>` | Public CLI for grouping cross-project okstra tasks into manager-owned context. `new project`, `new task-group`, and `new task` create manager plans; `task assign`, `task note`, `task sync`, `task status`, and `task run` manage per-project assignments and snapshots. `new project --project-root` accepts only existing directories and performs setup-equivalent registration only if `.okstra/project.json` is absent. Public documentation uses the full `project-id:task-group:task-id` child task key; when child task IDs differ within the same manager task, select the exact child with `--child-task-id`. `task run` does not execute the child lead directly; it returns `prepared` launch metadata/event and a child launch-context packet as JSON |
639
639
  | `okstra rollup [--task-group <group>] [--project-root <dir>] [--cwd <dir>]` | Read-only backend for the okstra-rollup skill. For every catalog task, or one task group, it emits JSON with per-task run counts, raw duration in ms, error counts, latest report paths, group totals, and status/category/phase distributions. Omitting `--task-group` targets the whole project catalog. The caller skill formats raw ms as HH:MM:SS and synthesizes report prose. Use the `okstra inspect` family for a single-task drill-down |
640
+ | `okstra usage-report [--days <positive-int>] [--project-root <dir>] [--cwd <dir>] [--json]` | Read-only backend for the okstra-usage skill. Defaults to the whole current project's last 30 days and emits task-type run coverage, raw/billable tokens, known USD cost, CPU-sum milliseconds, wall-clock milliseconds, unavailable reason counts, and unmatched pricing models |
640
641
  | `okstra migrate [--apply] [--cwd <dir>] [--quiet]` | One-time migration of the project artifact root from `.project-docs/okstra/` to `.okstra/`. It is a dry run by default; `--apply` performs the move with `git mv` in a Git worktree, removes an empty `.project-docs/`, and synchronizes the `<PROJECT>/CLAUDE.md` import line, `.gitignore`, the project's rows in `~/.okstra/{recent,active}.jsonl`, and `~/.okstra/worktrees/registry.json`. It exits 1 if `.okstra/` already exists or the legacy directory is absent. Scheduled for removal by the end of v0.x |
641
642
  | `okstra task-list [--project-root <path>]` | Combine `list_project_tasks` and `read_latest_task` into JSON containing the task catalog and latest task |
642
643
  | `okstra task-show <task-key> [--project-root <path>]` | Summarize workflow, phase, status, and artifacts from the Task Read-Side Snapshot |
@@ -22,6 +22,7 @@
22
22
  | 여러 프로젝트에 걸친 okstra task 묶음, 할당, sync snapshot, child launch packet 관리 | `okstra-manager` | [`skills/okstra-manager.md`](skills/okstra-manager.md) |
23
23
  | 상태, history, report, time, logs, cost, errors, error-zip, recap 확인 | `okstra-inspect` | [`skills/okstra-inspect.md`](skills/okstra-inspect.md) |
24
24
  | task-group(또는 프로젝트 전체)의 여러 task run 결과를 모아 집계·종합 요약 | `okstra-rollup` | [`skills/okstra-rollup.md`](skills/okstra-rollup.md) |
25
+ | Project-wide recent run coverage, tokens, known cost, CPU, and wall-clock usage by task type | `okstra-usage` | [`skills/okstra-usage.md`](skills/okstra-usage.md) |
25
26
  | task-group 전체의 클라이언트용 작업 일정 생성 | `okstra-schedule` | [`skills/okstra-schedule.md`](skills/okstra-schedule.md) |
26
27
  | 대화/결정/선호/요구사항을 전역 Memory Book에 저장 또는 검색 | `okstra-memory` | [`skills/okstra-memory.md`](skills/okstra-memory.md) |
27
28
  | implementation task worktree 기반 docker compose 사용자 테스트 환경 관리 | `okstra-container-build` | [`skills/okstra-container-build.md`](skills/okstra-container-build.md) |
@@ -45,7 +46,7 @@
45
46
 
46
47
  ## 공개 스킬 목록
47
48
 
48
- `src/lib/skill-catalog.mjs` 기준 공개 스킬은 다음 9개다.
49
+ AI 매뉴얼에 나열된 공개 스킬은 다음 10개다.
49
50
 
50
51
  - `okstra-setup`
51
52
  - `okstra-brief-gen`
@@ -54,5 +55,6 @@
54
55
  - `okstra-memory`
55
56
  - `okstra-inspect`
56
57
  - `okstra-rollup`
58
+ - `okstra-usage`
57
59
  - `okstra-schedule`
58
60
  - `okstra-container-build`
@@ -0,0 +1,28 @@
1
+ # okstra-usage AI Manual
2
+
3
+ ## Purpose
4
+
5
+ Use `okstra-usage` for a project-wide historical resource snapshot. It is not a
6
+ single-task inspection (`okstra-inspect`) and not a cross-task status/report
7
+ digest (`okstra-rollup`).
8
+
9
+ ## Call
10
+
11
+ Run preflight, resolve a positive day count (default 30), then call exactly once:
12
+
13
+ ```bash
14
+ okstra usage-report --days 30 --project-root <projectRoot> --json
15
+ ```
16
+
17
+ ## Render contract
18
+
19
+ Render returned task-type rows and totals without recomputing them. Show Runs,
20
+ Coverage, Raw tokens, Billable, Cost, CPU, and Wall. Surface unavailable reason
21
+ counts and unmatched model names. Missing usage is excluded data, not zero usage.
22
+
23
+ ## Boundaries
24
+
25
+ - One task's elapsed/context detail: `okstra-inspect`.
26
+ - Task-group/project status and report digest: `okstra-rollup`.
27
+ - HTML dashboards, budgets, forecasts, and task/model/worker drill-downs are not
28
+ part of this MVP.
@@ -130,7 +130,7 @@ Runtime/install asset changes follow this checklist:
130
130
  - `runtime/python/*` → `~/.okstra/lib/python/`
131
131
  - `runtime/bin/*` → `~/.okstra/bin/`
132
132
  - `runtime/templates/*` → `~/.okstra/templates/`
133
- - `runtime/skills/<name>` (the eight user-facing skills only) → `~/.agents/skills` always, plus `~/.claude/skills` when `~/.claude` exists
133
+ - `runtime/skills/<name>` (the thirteen user-facing skills only) → `~/.agents/skills` always, plus `~/.claude/skills` when `~/.claude` exists
134
134
  - `runtime/prompts/*` → `~/.okstra/prompts/` (lead contracts under `prompts/lead/`, coding-preflight pack under `prompts/coding-preflight/`)
135
135
  - `runtime/agents/workers/*.md` → `~/.claude/agents/*-worker.md` when `~/.claude` exists
136
136
  - install manifests → `~/.okstra/installed-skills.json` (target-aware), `~/.okstra/installed-agents.json`
@@ -184,6 +184,7 @@ Runtime/install asset changes follow this checklist:
184
184
  | `pr` | `src/commands/pr/pr.mjs` | `okstra pr <template\|branches\|gen>` — PR body template store under `~/.okstra/template/pr/` (bundled fallback `src/commands/pr/default.md`), base-branch recommendation, and the `gen` JSON bundle (template + `<base>..HEAD` commits + `<base>...HEAD` diffstat) backing the okstra-pr-gen skill. Git-only; no project registration required |
185
185
  | `recap` | `src/commands/inspect/recap.mjs` | `okstra recap <assemble\|record>` Node wrapper backing the okstra-inspect `recap` facet |
186
186
  | `rollup` | `src/commands/inspect/rollup.mjs` | `okstra rollup` thin shim into `scripts/okstra_ctl/rollup.py` — read-only cross-task roll-up backing the okstra-rollup skill |
187
+ | `usage-report` | `src/commands/inspect/usage-report.mjs` | `okstra usage-report` thin shim into `scripts/okstra_ctl/usage_report.py` — read-only project usage snapshot backing the okstra-usage skill |
187
188
  | `container` | `src/commands/inspect/container.mjs` | `bin okstra container` thin shim into `scripts/okstra_ctl/container.py` for the okstra-container-build skill |
188
189
  | `manager` | `src/commands/manager.mjs` | Thin shim into `scripts/okstra_ctl/manager_cli.py` for cross-project manager state and child launch packets |
189
190
 
@@ -228,7 +229,7 @@ Important modules:
228
229
  | `recap.py` | okstra-inspect `recap` facet 의 결정론적 백엔드 — `assemble` 은 timeline 에서 run 간 phase 전이를 조립, `record` 는 `<task-root>/recap/recap-log.jsonl` 에 요약·Q&A append-only 기록 (다른 task 산출물 불변) |
229
230
  | `render.py` | task manifest, run manifest, timeline, task index, discovery, team-state, prompt/template render |
230
231
  | `workflow.py` | Phase sequence, allowed outputs, forbidden actions, next phase |
231
- | `workers.py`, `models.py` | Worker roster and model metadata resolution |
232
+ | `workers.py`, `models.py` | Worker roster; `models.py` is the model catalog SSOT (`ModelSpec` per alias + `ROLE_DEFAULTS`) — add-a-model single reference point from which picker options, codex pricing, and role defaults all derive |
232
233
  | `worktree.py`, `worktree_registry.py` | One worktree per task-key, branch registry, sync dirs/files/snapshots |
233
234
  | `project_meta.py`, `resolver.py`, `path_resolve.py` | Project/task/run resolution |
234
235
  | `clarification_items.py` | Unified §5 clarification table parser and approval blockers |
@@ -244,6 +245,7 @@ Important modules:
244
245
  | `error_report.py`, `error_log_core.py`, `error_zip.py` | okstra-inspect errors/error-zip facet 백엔드 — `error_log_core` 는 `errors-*.jsonl` 글롭·파싱·집계 read-only 코어, `error_report` 는 errors facet 렌더, `error_zip` 은 cross-project run 디렉터리 수집·allowlist 익명화·클러스터 집계·zip 생성 |
245
246
  | `log_report.py`, `time_report.py` | okstra-inspect logs/time facet 의 read-side 백엔드 (`okstra log-report` 는 wrapper sidecar 로그 인벤토리, `okstra time-report` 는 task 단위 시간 집계) |
246
247
  | `rollup.py` | okstra-rollup 스킬의 read-side 백엔드 — catalog 를 task-group(또는 프로젝트 전체) 단위로 fan-out 해 task 별 run 수·소요 시간(raw ms)·에러 수·최신 report 경로와 group 차원 합계/상태·카테고리·phase 분포를 deterministic 하게 집계. `time_report`/`error_log_core` 함수 재사용, report 본문 종합은 skill 에 위임 |
248
+ | `usage_report.py` | Read-only okstra-usage backend — scans the whole current project's recent run timelines, defaults to 30 days, and returns task-type coverage, raw/billable tokens, known USD cost, CPU-sum and wall-clock milliseconds, unavailable reason counts, and unmatched pricing models |
247
249
  | `json_registry.py` | 작은 okstra 레지스트리 공용 flock + atomic JSON persistence (`registry_lock`/`load_registry_json`/`save_registry_json`) — `container_registry`·`worktree_registry` 가 공유 |
248
250
  | `stage_integrate.py` | whole-task stage 통합(머지)+worktree teardown 코어 (`integrate_stages`) — whole-task final-verification 진입·`okstra integrate-stages`·container up 이 공유 |
249
251
  | `resolve_task_key.py` | bare task-id → `task-catalog.json` 후보 entry 해석 skill 공용 helper (`okstra resolve-task-key`) |
@@ -320,7 +322,7 @@ Optional (v1.0 하위호환) 최상위 키:
320
322
 
321
323
  ### 4.10 `skills/`
322
324
 
323
- 11 user-facing skills (the only skills `okstra install` copies). The list SSOT is `USER_SKILL_NAMES` in `src/lib/skill-catalog.mjs`; the Claude plugin manifest and the installer both derive from it:
325
+ 13 user-facing skills (the only skills `okstra install` copies). The list SSOT is `USER_SKILL_NAMES` in `src/lib/skill-catalog.mjs`; the Claude plugin manifest and the installer both derive from it:
324
326
 
325
327
  | Skill | User-invocable | Role |
326
328
  |---|---:|---|
@@ -329,12 +331,14 @@ Optional (v1.0 하위호환) 최상위 키:
329
331
  | `okstra-memory` | yes | Store/search/archive global conversation memory under `~/.okstra/memory-book` |
330
332
  | `okstra-inspect` | yes | Unified read-side — sub-commands `status` (lifecycle + workStatus), `history` (past runs / re-run / resume), `report` (find final-report), `time` (elapsed-time breakdown), `logs` (wrapper log inventory + cleanup), `cost` (task bundle context/read cost) |
331
333
  | `okstra-rollup` | yes | Cross-task roll-up — aggregate runs/time/errors across a task-group (or whole project) and synthesize a digest from the report files |
334
+ | `okstra-usage` | yes | Read-only project usage snapshot — aggregate recent run coverage, tokens, known cost, CPU, and wall-clock time by task type (default: 30 days) |
332
335
  | `okstra-schedule` | yes | Generate task-group schedule |
333
336
  | `okstra-container-build` | yes | Non-linear deploy tool — deploy a verified task's code as a docker compose group and watch each container (sub-commands `up` / `status` / `logs` / `stop-watcher` / `down`) |
334
337
  | `okstra-graphify` | yes | Knowledge graph over the project's own `.okstra/` memory — final reports, `decisions/*.md`, `glossary.md`; scope restricted to `.okstra/`, output under `.okstra/graph/` (sub-commands `build` / `query` / `path` / `explain` / `mcp` / `wiki`) |
335
338
  | `okstra-pr-gen` | yes | Register PR body templates under `~/.okstra/template/pr/` and generate a PR description from a branch diff (drives `okstra pr template` / `branches` / `gen`). **Global skill** — needs a Git repo, not `<PROJECT_ROOT>/.okstra/project.json` |
336
339
  | `okstra-manager` | yes | Coordinate cross-project okstra tasks through manager-owned plans, assignments, sync snapshots, status, and child launch context packets |
337
340
  | `okstra-setup` | yes | Install/check runtime and register project |
341
+ | `okstra-user-response` | yes | Submit answers to a run's open clarification items through the installed response helper without hand-editing artifacts |
338
342
 
339
343
  > The former internal skills — `context-loader`, `team-contract`, `convergence`, `report-writer`, `coding-preflight` — and the lead contract are no longer skills. They ship as runtime resources under `prompts/lead/*` and `prompts/coding-preflight/*` (installed to `~/.okstra/prompts/`); see §4.11.
340
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.120.0",
3
- "builtAt": "2026-07-15T13:14:37.088Z",
2
+ "package": "0.121.1",
3
+ "builtAt": "2026-07-15T16:40:10.638Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -159,10 +159,5 @@ ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE=""
159
159
  REPORT_WRITER_MODEL=""
160
160
  REPORT_WRITER_MODEL_EXECUTION_VALUE=""
161
161
  DEFAULT_WORKERS="claude,codex,report-writer"
162
- DEFAULT_LEAD_MODEL_NAME="${OKSTRA_DEFAULT_LEAD_MODEL:-opus}"
163
- DEFAULT_CLAUDE_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_CLAUDE_MODEL:-opus}"
164
- DEFAULT_CODEX_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_CODEX_MODEL:-gpt-5.6}"
165
- DEFAULT_ANTIGRAVITY_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_ANTIGRAVITY_MODEL:-gemini-3.1-pro}"
166
- DEFAULT_REPORT_WRITER_MODEL_NAME="${OKSTRA_DEFAULT_REPORT_WRITER_MODEL:-$DEFAULT_LEAD_MODEL_NAME}"
167
162
  DISPLAY_COMMAND_NAME="${OKSTRA_COMMAND_NAME:-$(basename "$0")}"
168
163
  DISPLAY_TOOL_NAME="${OKSTRA_TOOL_NAME:-okstra}"
@@ -91,7 +91,7 @@ options:
91
91
  --lead-model Model for Claude lead. Default: OKSTRA_DEFAULT_LEAD_MODEL or opus
92
92
  --claude-model Model for Claude worker. Default: OKSTRA_DEFAULT_CLAUDE_MODEL or opus
93
93
  --codex-model Model for Codex worker. Default: OKSTRA_DEFAULT_CODEX_MODEL or gpt-5.6
94
- --antigravity-model Model for Antigravity worker. Default: OKSTRA_DEFAULT_ANTIGRAVITY_MODEL or auto
94
+ --antigravity-model Model for Antigravity worker. Default: OKSTRA_DEFAULT_ANTIGRAVITY_MODEL or gemini-3.1-pro
95
95
  --report-writer-model
96
96
  Model for report writer worker. Default: OKSTRA_DEFAULT_REPORT_WRITER_MODEL or lead model default
97
97
  --lead-runtime Lead runtime adapter. Default: claude-code.
@@ -125,7 +125,7 @@ model defaults:
125
125
  Report writer worker: OKSTRA_DEFAULT_REPORT_WRITER_MODEL or Claude lead default
126
126
  Claude worker: OKSTRA_DEFAULT_CLAUDE_MODEL or opus
127
127
  Codex worker: OKSTRA_DEFAULT_CODEX_MODEL or gpt-5.6
128
- Antigravity worker: OKSTRA_DEFAULT_ANTIGRAVITY_MODEL or auto
128
+ Antigravity worker: OKSTRA_DEFAULT_ANTIGRAVITY_MODEL or gemini-3.1-pro
129
129
  Implementation executor: OKSTRA_DEFAULT_EXECUTOR or claude (one of: claude | codex | antigravity)
130
130
 
131
131
  output:
@@ -1,50 +1,66 @@
1
- """Model display / execution-value resolution for okstra workers.
1
+ """Model catalog: display / execution / pricing / picker for okstra workers.
2
2
 
3
- bash `resolve_model_metadata` 동등한 python 구현. 사용자가 모델 alias
4
- 또는 정식 이름을 `(display, execution_value)` 쌍으로 정규화한다.
3
+ Single reference point. Adding a model = one ModelSpec entry here. Changing a
4
+ role default = one ROLE_DEFAULTS line here.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
8
  from dataclasses import dataclass
9
+ from typing import Optional
9
10
 
10
- CLAUDE_MAPPING = {
11
- "fable": ("fable", "fable"),
12
- "fable-5": ("fable-5", "claude-fable-5"),
13
- "claude-fable-5": ("fable-5", "claude-fable-5"),
14
- "opus": ("opus", "opus"),
15
- "opus-4-8": ("opus-4-8", "claude-opus-4-8"),
16
- "claude-opus-4-8": ("opus-4-8", "claude-opus-4-8"),
17
- "opus-4-7": ("opus-4-7", "claude-opus-4-7"),
18
- "claude-opus-4-7": ("opus-4-7", "claude-opus-4-7"),
19
- "opus-4-6": ("opus-4-6", "claude-opus-4-6"),
20
- "claude-opus-4-6": ("opus-4-6", "claude-opus-4-6"),
21
- "sonnet": ("sonnet", "sonnet"),
22
- "sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
23
- "claude-sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
24
- "haiku": ("haiku", "haiku"),
25
- "haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
26
- "claude-haiku-4-5": ("haiku-4-5", "claude-haiku-4-5"),
27
- "claude-haiku-4-5-20251001": ("haiku-4-5", "claude-haiku-4-5-20251001"),
11
+
12
+ @dataclass(frozen=True)
13
+ class ModelSpec:
14
+ display: str
15
+ execution: str
16
+ pricing: Optional[tuple] = None # codex only: (input, cached_input, output) USD/1M
17
+ in_picker: bool = False # exposed in the wizard 3-option picker
18
+
19
+
20
+ CLAUDE = {
21
+ "fable": ModelSpec("fable", "fable", in_picker=True),
22
+ "fable-5": ModelSpec("fable-5", "claude-fable-5"),
23
+ "claude-fable-5": ModelSpec("fable-5", "claude-fable-5"),
24
+ "opus": ModelSpec("opus", "opus", in_picker=True),
25
+ "opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
26
+ "claude-opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
27
+ "opus-4-7": ModelSpec("opus-4-7", "claude-opus-4-7"),
28
+ "claude-opus-4-7": ModelSpec("opus-4-7", "claude-opus-4-7"),
29
+ "opus-4-6": ModelSpec("opus-4-6", "claude-opus-4-6"),
30
+ "claude-opus-4-6": ModelSpec("opus-4-6", "claude-opus-4-6"),
31
+ "sonnet": ModelSpec("sonnet", "sonnet", in_picker=True),
32
+ "sonnet-4-6": ModelSpec("sonnet-4-6", "claude-sonnet-4-6"),
33
+ "claude-sonnet-4-6": ModelSpec("sonnet-4-6", "claude-sonnet-4-6"),
34
+ "haiku": ModelSpec("haiku", "haiku", in_picker=True),
35
+ "haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
36
+ "claude-haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
37
+ "claude-haiku-4-5-20251001": ModelSpec("haiku-4-5", "claude-haiku-4-5-20251001"),
38
+ }
39
+ # Execution values stay `gemini-3.x`: the `agy` CLI runs Gemini-family models and
40
+ # accepts those slugs. Only the okstra provider/worker identity is "antigravity".
41
+ ANTIGRAVITY = {
42
+ "gemini-3.1-pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro", in_picker=True),
43
+ "gemini 3.1 pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro"),
44
+ "gemini-3.5-flash": ModelSpec("Gemini 3.5 Flash", "gemini-3.5-flash", in_picker=True),
45
+ "gemini 3.5 flash": ModelSpec("Gemini 3.5 Flash", "gemini-3.5-flash"),
28
46
  }
29
- # Antigravity worker runs the `agy` CLI, which executes Gemini-family models.
30
- # The execution values stay `gemini-3.x` (agy accepts those slugs); only the
31
- # okstra provider/worker identity is "antigravity".
32
- ANTIGRAVITY_MAPPING = {
33
- "gemini 3.1 pro": ("Gemini 3.1 Pro", "gemini-3.1-pro"),
34
- "gemini-3.1-pro": ("Gemini 3.1 Pro", "gemini-3.1-pro"),
35
- "gemini 3.5 flash": ("Gemini 3.5 Flash", "gemini-3.5-flash"),
36
- "gemini-3.5-flash": ("Gemini 3.5 Flash", "gemini-3.5-flash"),
47
+ CODEX = {
48
+ "gpt-5.6": ModelSpec("gpt-5.6", "gpt-5.6", (5.00, 0.50, 30.0), in_picker=True),
49
+ "gpt-5.5": ModelSpec("gpt-5.5", "gpt-5.5", (5.00, 0.50, 30.0), in_picker=True),
50
+ "gpt-5.4": ModelSpec("gpt-5.4", "gpt-5.4", (2.50, 0.25, 15.0), in_picker=True),
51
+ "gpt-5.4-mini": ModelSpec("gpt-5.4-mini", "gpt-5.4-mini", (0.75, 0.075, 4.50), in_picker=True),
52
+ "gpt-5.3-codex": ModelSpec("gpt-5.4", "gpt-5.4"),
53
+ "gpt-5.2": ModelSpec("gpt-5.2", "gpt-5.2", (1.75, 0.175, 14.0)),
54
+ "codex-auto-review": ModelSpec("codex-auto-review", "codex-auto-review"),
37
55
  }
38
- CODEX_MAPPING = {
39
- "gpt-5.6": ("gpt-5.6", "gpt-5.6"),
40
- "gpt-5.5": ("gpt-5.5", "gpt-5.5"),
41
- "gpt-5.4": ("gpt-5.4", "gpt-5.4"),
42
- "gpt-5.4-mini": ("gpt-5.4-mini", "gpt-5.4-mini"),
43
- "gpt-5.3-codex": ("gpt-5.4", "gpt-5.4"),
44
- "gpt-5.2": ("gpt-5.2", "gpt-5.2"),
45
- "codex-auto-review": ("codex-auto-review", "codex-auto-review"),
56
+ PROVIDER_MAPPINGS = {"claude": CLAUDE, "antigravity": ANTIGRAVITY, "codex": CODEX}
57
+
58
+ # ROLE-keyed (not provider): 1:1 with run.py recommended_role_models. "lead" uses
59
+ # the claude provider; "report-writer" is role-only (None → caller uses lead's).
60
+ ROLE_DEFAULTS = {
61
+ "lead": "opus", "claude": "opus", "codex": "gpt-5.6",
62
+ "antigravity": "gemini-3.1-pro", "report-writer": None,
46
63
  }
47
- PROVIDER_MAPPINGS = {"claude": CLAUDE_MAPPING, "antigravity": ANTIGRAVITY_MAPPING, "codex": CODEX_MAPPING}
48
64
 
49
65
 
50
66
  @dataclass
@@ -60,15 +76,8 @@ class UnknownModelError(ValueError):
60
76
  def resolve_model_metadata(
61
77
  *, provider: str, raw_value: str, default_display: str, default_execution: str,
62
78
  ) -> ModelAssignment:
63
- """alias → (display, execution_value).
64
-
65
- provider: "claude" | "codex" | "antigravity" (그 외는 mapping 미적용)
66
-
67
- Mapping 이 정의된 provider 에 대해 사용자가 빈 값이 아닌 raw_value 를 줬는데
68
- mapping 에 없는 경우 `UnknownModelError` 를 발생시킨다. 이는 manifest 에
69
- Codex 가 지원하지 않는 `gpt-5.5-high` 같은 유령 모델이 기록되어 실제
70
- 실행값과 contract 가 불일치하는 사고를 막기 위함이다.
71
- """
79
+ """alias → (display, execution). Unchanged contract; mapping values are now
80
+ ModelSpec instead of tuples."""
72
81
  raw_value = (raw_value or "").strip()
73
82
  mapping = PROVIDER_MAPPINGS.get(provider)
74
83
  if raw_value and mapping is not None:
@@ -84,5 +93,23 @@ def resolve_model_metadata(
84
93
  execution = raw_value or default_execution
85
94
  normalized = value.strip().lower()
86
95
  if mapping and normalized in mapping:
87
- display, execution = mapping[normalized]
96
+ spec = mapping[normalized]
97
+ display, execution = spec.display, spec.execution
88
98
  return ModelAssignment(display=display, execution=execution)
99
+
100
+
101
+ def picker_options(provider: str) -> list[str]:
102
+ """Picker-exposed aliases for a provider, in catalog insertion order."""
103
+ mapping = PROVIDER_MAPPINGS.get(provider, {})
104
+ return [alias for alias, spec in mapping.items() if spec.in_picker]
105
+
106
+
107
+ def default_model(role: str) -> Optional[str]:
108
+ """Role → default model alias (env override is the caller's concern)."""
109
+ return ROLE_DEFAULTS.get(role)
110
+
111
+
112
+ def catalog_pricing(provider: str) -> dict:
113
+ """Selectable-model pricing for a provider (codex only carries pricing)."""
114
+ mapping = PROVIDER_MAPPINGS.get(provider, {})
115
+ return {alias: spec.pricing for alias, spec in mapping.items() if spec.pricing is not None}
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
 
17
17
  from okstra_ctl.error_log_core import glob_error_logs, parse_records
18
- from okstra_ctl.time_report import _load_runs, aggregate_time
18
+ from okstra_ctl.time_report import aggregate_time, load_runs
19
19
  from okstra_project import (
20
20
  ResolverError,
21
21
  list_project_tasks,
@@ -30,7 +30,7 @@ def _collect_task(entry: dict, project_root: Path) -> dict:
30
30
  cpuSumMs/wallClockMs 는 durationMs 가 있는 run 만 반영하므로 0 일 수 있다.
31
31
  """
32
32
  task_root = Path(entry["_resolvedTaskRoot"])
33
- runs = _load_runs(task_root)
33
+ runs, _ = load_runs(task_root)
34
34
  time_agg = aggregate_time(runs, project_root)
35
35
  records, _ = parse_records(glob_error_logs(task_root))
36
36
  return {
@@ -46,7 +46,7 @@ from .final_report_schema import load_schema
46
46
  from .final_report_paths import final_report_data_path as _final_report_data_path
47
47
  from .lead_events import LeadEvent, append_lead_event
48
48
  from .lead_runtime import ALLOWED_LEAD_RUNTIMES
49
- from .models import ModelAssignment, resolve_model_metadata
49
+ from .models import ModelAssignment, default_model, resolve_model_metadata
50
50
  from .schema_excerpt import build_schema_excerpt
51
51
  from .path_resolve import relative_to_project_root, resolve_user_file
52
52
  from .render import (
@@ -1339,12 +1339,12 @@ class _ModelBindings:
1339
1339
  def recommended_role_models() -> dict[str, str]:
1340
1340
  """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1341
1341
  wizard 의 안내 표기가 공유하는 단일 기준점."""
1342
- lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
1342
+ lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", default_model("lead"))
1343
1343
  return {
1344
1344
  "lead": lead_default,
1345
- "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus"),
1346
- "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.6"),
1347
- "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "auto"),
1345
+ "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", default_model("claude")),
1346
+ "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", default_model("codex")),
1347
+ "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", default_model("antigravity")),
1348
1348
  "report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default),
1349
1349
  }
1350
1350
 
@@ -18,13 +18,16 @@ from okstra_ctl.paths import resolve_under_root
18
18
  from okstra_ctl.task_target import resolve_task_root
19
19
 
20
20
 
21
- def _load_runs(task_root: Path) -> list[dict]:
21
+ def load_runs(task_root: Path) -> tuple[list[dict], bool]:
22
+ path = task_root / "history" / "timeline.json"
22
23
  try:
23
- data = json.loads((task_root / "history" / "timeline.json").read_text(encoding="utf-8"))
24
+ data = json.loads(path.read_text(encoding="utf-8"))
24
25
  except (OSError, ValueError):
25
- return []
26
- runs = data.get("runs", [])
27
- return runs if isinstance(runs, list) else []
26
+ return [], True
27
+ runs = data.get("runs", []) if isinstance(data, dict) else []
28
+ if not isinstance(runs, list):
29
+ return [], True
30
+ return runs, False
28
31
 
29
32
 
30
33
  def _task_key(task_root: Path) -> str:
@@ -190,8 +193,9 @@ def main(argv: list[str] | None = None) -> int:
190
193
  args = parser.parse_args(argv)
191
194
 
192
195
  task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
196
+ runs, _ = load_runs(task_root)
193
197
  result = {"ok": True, "taskKey": _task_key(task_root)}
194
- result.update(aggregate_time(_load_runs(task_root), project_root))
198
+ result.update(aggregate_time(runs, project_root))
195
199
  print(json.dumps(result, ensure_ascii=False, indent=2))
196
200
  return 0
197
201
 
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import datetime as dt
5
+ import json
6
+ import math
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from okstra_ctl.time_report import (
12
+ _duration_ms,
13
+ _read_state,
14
+ _wall_clock_ms,
15
+ load_runs,
16
+ )
17
+ from okstra_project import ResolverError, list_project_tasks, resolve_project_root
18
+
19
+ UTC = dt.timezone.utc
20
+ UNAVAILABLE_KEYS = (
21
+ "invalidRunTimestamp",
22
+ "teamStateMissing",
23
+ "usageSummaryMissing",
24
+ "durationMissing",
25
+ )
26
+
27
+
28
+ def _iso_z(value: dt.datetime) -> str:
29
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
30
+
31
+
32
+ def _parse_timestamp(value: Any) -> dt.datetime | None:
33
+ if not isinstance(value, str) or not value:
34
+ return None
35
+ try:
36
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
37
+ if parsed.tzinfo is None:
38
+ parsed = parsed.replace(tzinfo=UTC)
39
+ return parsed.astimezone(UTC)
40
+ except (OverflowError, ValueError):
41
+ return None
42
+
43
+
44
+ def _project_id(project_root: Path) -> str:
45
+ try:
46
+ data = json.loads(
47
+ (project_root / ".okstra" / "project.json").read_text(encoding="utf-8")
48
+ )
49
+ except (OSError, ValueError):
50
+ return ""
51
+ return str(data.get("projectId") or "") if isinstance(data, dict) else ""
52
+
53
+
54
+ def _int_value(value: Any) -> int:
55
+ try:
56
+ return int(value or 0)
57
+ except (OverflowError, TypeError, ValueError):
58
+ return 0
59
+
60
+
61
+ def _float_value(value: Any) -> float:
62
+ try:
63
+ result = float(value or 0)
64
+ except (OverflowError, TypeError, ValueError):
65
+ return 0.0
66
+ return result if math.isfinite(result) else 0.0
67
+
68
+
69
+ def _usage_block(value: Any) -> dict:
70
+ if not isinstance(value, dict):
71
+ return {}
72
+ block = {"source": value["source"]} if "source" in value else {}
73
+ if "durationMs" in value:
74
+ block["durationMs"] = _int_value(value["durationMs"])
75
+ for key in ("startedAt", "endedAt"):
76
+ if isinstance(value.get(key), str):
77
+ block[key] = value[key]
78
+ return block
79
+
80
+
81
+ def _usage_blocks(state: dict) -> tuple[dict, list[dict]]:
82
+ workers = state.get("workers")
83
+ worker_entries = workers if isinstance(workers, list) else []
84
+ return _usage_block(state.get("leadUsage")), [
85
+ _usage_block(worker.get("usage"))
86
+ for worker in worker_entries
87
+ if isinstance(worker, dict)
88
+ ]
89
+
90
+
91
+ def _wall_clock_from_blocks(lead: dict, workers: list[dict]) -> int:
92
+ state = {
93
+ "leadUsage": lead,
94
+ "workers": [{"usage": usage} for usage in workers],
95
+ }
96
+ try:
97
+ return _wall_clock_ms(state)
98
+ except TypeError:
99
+ return 0
100
+
101
+
102
+ def _run_metrics(run: dict, project_root: Path) -> tuple[dict | None, str | None]:
103
+ state = _read_state(project_root, str(run.get("teamStatePath") or ""))
104
+ if state is None:
105
+ return None, "teamStateMissing"
106
+ summary = state.get("usageSummary")
107
+ if not isinstance(summary, dict) or _int_value(summary.get("grandTotalTokens")) <= 0:
108
+ return None, "usageSummaryMissing"
109
+ lead, workers = _usage_blocks(state)
110
+ durations = [_duration_ms(lead), *(_duration_ms(worker) for worker in workers)]
111
+ positive_durations = [duration for duration in durations if duration > 0]
112
+ if not positive_durations:
113
+ return None, "durationMissing"
114
+ costs = summary.get("estimatedCostUsd")
115
+ cost = costs.get("grandTotal", 0) if isinstance(costs, dict) else 0
116
+ unmatched = summary.get("unmatchedModels")
117
+ unmatched_models = unmatched if isinstance(unmatched, list) else []
118
+ return {
119
+ "rawTokens": _int_value(summary.get("grandTotalTokens")),
120
+ "billableTokens": _int_value(summary.get("grandBillableEquivalentTokens")),
121
+ "costUsd": _float_value(cost),
122
+ "cpuSumMs": sum(positive_durations),
123
+ "wallClockMs": _wall_clock_from_blocks(lead, workers),
124
+ "unmatchedModels": [
125
+ model for model in unmatched_models if isinstance(model, str) and model
126
+ ],
127
+ }, None
128
+
129
+
130
+ def _new_bucket(task_type: str, first_run_at: dt.datetime) -> dict:
131
+ return {
132
+ "taskType": task_type,
133
+ "firstRunAt": _iso_z(first_run_at),
134
+ "runs": 0,
135
+ "collectedRuns": 0,
136
+ "rawTokens": 0,
137
+ "billableTokens": 0,
138
+ "costUsd": 0.0,
139
+ "cpuSumMs": 0,
140
+ "wallClockMs": 0,
141
+ }
142
+
143
+
144
+ def _collect_usage(
145
+ project_root: Path,
146
+ start_at: dt.datetime,
147
+ end_at: dt.datetime,
148
+ unavailable: dict[str, int],
149
+ warnings: dict[str, int],
150
+ ) -> tuple[dict[str, dict], set[str], int, int]:
151
+ buckets: dict[str, dict] = {}
152
+ unmatched_models: set[str] = set()
153
+ total_runs = 0
154
+ collected_runs = 0
155
+ for entry in list_project_tasks(project_root):
156
+ runs, timeline_warning = load_runs(Path(entry["_resolvedTaskRoot"]))
157
+ warnings["missingOrInvalidTimelines"] += int(timeline_warning)
158
+ for run in runs:
159
+ if not isinstance(run, dict):
160
+ unavailable["invalidRunTimestamp"] += 1
161
+ continue
162
+ run_at = _parse_timestamp(run.get("runTimestamp"))
163
+ if run_at is None:
164
+ unavailable["invalidRunTimestamp"] += 1
165
+ continue
166
+ if run_at < start_at or run_at > end_at:
167
+ continue
168
+ task_type = str(run.get("taskType") or "unknown")
169
+ bucket = buckets.setdefault(task_type, _new_bucket(task_type, run_at))
170
+ run_at_text = _iso_z(run_at)
171
+ if run_at_text < bucket["firstRunAt"]:
172
+ bucket["firstRunAt"] = run_at_text
173
+ total_runs += 1
174
+ bucket["runs"] += 1
175
+ metrics, reason = _run_metrics(run, project_root)
176
+ if reason is not None:
177
+ unavailable[reason] += 1
178
+ continue
179
+ collected_runs += 1
180
+ bucket["collectedRuns"] += 1
181
+ for key in ("rawTokens", "billableTokens", "cpuSumMs", "wallClockMs"):
182
+ bucket[key] += metrics[key]
183
+ bucket["costUsd"] += metrics["costUsd"]
184
+ unmatched_models.update(metrics["unmatchedModels"])
185
+ return buckets, unmatched_models, total_runs, collected_runs
186
+
187
+
188
+ def build_usage_report(project_root: Path, days: int, now: dt.datetime) -> dict:
189
+ if days <= 0:
190
+ raise ValueError("days must be a positive integer")
191
+ project_root = Path(project_root).resolve()
192
+ end_at = now.astimezone(UTC)
193
+ try:
194
+ start_at = end_at - dt.timedelta(days=days)
195
+ except OverflowError:
196
+ start_at = dt.datetime.min.replace(tzinfo=UTC)
197
+ unavailable = {key: 0 for key in UNAVAILABLE_KEYS}
198
+ warnings = {"missingOrInvalidTimelines": 0}
199
+ buckets, unmatched_models, total_runs, collected_runs = _collect_usage(
200
+ project_root, start_at, end_at, unavailable, warnings
201
+ )
202
+
203
+ rows = sorted(buckets.values(), key=lambda row: row["firstRunAt"])
204
+ for row in rows:
205
+ row["costUsd"] = round(_float_value(row["costUsd"]), 4)
206
+ totals = {
207
+ "runs": total_runs,
208
+ "collectedRuns": collected_runs,
209
+ "rawTokens": sum(row["rawTokens"] for row in rows),
210
+ "billableTokens": sum(row["billableTokens"] for row in rows),
211
+ "costUsd": round(_float_value(sum(row["costUsd"] for row in rows)), 4),
212
+ "cpuSumMs": sum(row["cpuSumMs"] for row in rows),
213
+ "wallClockMs": sum(row["wallClockMs"] for row in rows),
214
+ }
215
+ return {
216
+ "ok": True,
217
+ "projectId": _project_id(project_root),
218
+ "range": {"days": days, "startAt": _iso_z(start_at), "endAt": _iso_z(end_at)},
219
+ "runs": {
220
+ "total": total_runs,
221
+ "collected": collected_runs,
222
+ "collectionRate": collected_runs / total_runs if total_runs else 0.0,
223
+ },
224
+ "byTaskType": rows,
225
+ "totals": totals,
226
+ "unavailable": unavailable,
227
+ "warnings": warnings,
228
+ "unmatchedModels": sorted(unmatched_models),
229
+ }
230
+
231
+
232
+ def utc_now() -> dt.datetime:
233
+ return dt.datetime.now(UTC)
234
+
235
+
236
+ def _positive_days(value: str) -> int:
237
+ try:
238
+ days = int(value)
239
+ except ValueError as exc:
240
+ raise argparse.ArgumentTypeError("--days must be a positive integer") from exc
241
+ if days <= 0:
242
+ raise argparse.ArgumentTypeError("--days must be a positive integer")
243
+ return days
244
+
245
+
246
+ def main(argv: list[str] | None = None) -> int:
247
+ parser = argparse.ArgumentParser(
248
+ prog="okstra usage-report",
249
+ description="Aggregate recent project usage by task type.",
250
+ )
251
+ parser.add_argument("--days", type=_positive_days, default=30)
252
+ parser.add_argument("--project-root", default="")
253
+ parser.add_argument("--cwd", default=".")
254
+ parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
255
+ args = parser.parse_args(argv)
256
+ try:
257
+ project_root = Path(
258
+ resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
259
+ ).resolve()
260
+ except ResolverError as exc:
261
+ print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
262
+ return 2
263
+ result = build_usage_report(project_root, args.days, utc_now())
264
+ print(json.dumps(result, ensure_ascii=False, indent=2))
265
+ return 0
266
+
267
+
268
+ if __name__ == "__main__":
269
+ raise SystemExit(main(sys.argv[1:]))
@@ -33,6 +33,7 @@ from okstra_ctl.ids import slugify_task_segment
33
33
  from okstra_ctl.models import (
34
34
  PROVIDER_MAPPINGS,
35
35
  UnknownModelError,
36
+ picker_options,
36
37
  resolve_model_metadata,
37
38
  )
38
39
  from okstra_ctl.clarification_items import scan_approval_gate, user_response_sidecars
@@ -128,9 +129,9 @@ _BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "improvement-discovery",
128
129
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
129
130
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
130
131
 
131
- CLAUDE_MODEL_OPTIONS = ["default", "fable", "opus", "sonnet", "haiku"]
132
- CODEX_MODEL_OPTIONS = ["default", "gpt-5.6", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
133
- ANTIGRAVITY_MODEL_OPTIONS = ["default", "gemini-3.1-pro", "gemini-3.5-flash"]
132
+ CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
133
+ CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
134
+ ANTIGRAVITY_MODEL_OPTIONS = ["default", *picker_options("antigravity")]
134
135
 
135
136
  # special pick value: start a brand-new task
136
137
  TASK_PICK_NEW_TOKEN = "__new__"
@@ -11,8 +11,9 @@ provider:
11
11
  * Google / Gemini — `gemini-3.5-flash*`, `gemini-3.1-pro*`, `gemini-3-flash*`,
12
12
  `gemini-2.5-pro*`, `gemini-2.5-flash*`, `gemini-2.0-flash*`.
13
13
 
14
- Insertion order is the match order, so list more specific keys first. Update
15
- when providers change list pricing.
14
+ Matching prefers the longest key that is a substring of the model id (most
15
+ specific wins), so table ordering does not affect the result. Update when
16
+ providers change list pricing.
16
17
 
17
18
  Sources (last verified 2026-05-17, public list prices, USD per 1M tokens):
18
19
  * Anthropic: https://www.anthropic.com/pricing
@@ -21,6 +22,8 @@ Sources (last verified 2026-05-17, public list prices, USD per 1M tokens):
21
22
  """
22
23
  from __future__ import annotations
23
24
 
25
+ from okstra_ctl.models import catalog_pricing
26
+
24
27
 
25
28
  # Anthropic billing ratios relative to base input: cache_creation (5m) = 1.25x,
26
29
  # cache_creation (1h) = 2x, cache_read = 0.1x, output = 5x. The CLAUDE_PRICING
@@ -29,8 +32,8 @@ from __future__ import annotations
29
32
  CLAUDE_PRICING = {
30
33
  # model substring -> (input, cache_creation_5m, cache_read, output) USD/1M.
31
34
  #
32
- # Order matters list more specific keys (e.g. `opus-4-7`, `3-7-sonnet`)
33
- # before the family fallbacks (`opus-4`, `3-5-sonnet`).
35
+ # _match_pricing picks the longest matching key (most specific), so ordering
36
+ # here is for readability, not correctness.
34
37
  #
35
38
  # For the newer 4.x point releases (Opus 4.7, Sonnet 4.6, Haiku 4.5),
36
39
  # Anthropic's public price page only lists input/output. Cache-write and
@@ -65,24 +68,17 @@ CLAUDE_PRICING = {
65
68
  # Anthropic 1h ephemeral cache_creation multiplier on the base input rate.
66
69
  CLAUDE_CACHE_CREATE_1H_MULT = 2.0
67
70
 
68
- CODEX_PRICING = {
71
+ # Legacy / non-selectable codex prices. Selectable-model prices (the aliases the
72
+ # picker offers) live in the model catalog and are merged in below via
73
+ # `catalog_pricing("codex")`. Substring match order is now longest-key-first
74
+ # (see `_match_pricing`), so this dict's ordering no longer affects matching.
75
+ # For models with no published cached-input rate (o1-pro, o3-pro), cached is set
76
+ # equal to input as a conservative no-discount default.
77
+ _LEGACY_CODEX_PRICING = {
69
78
  # model substring -> (input USD/1M, cached_input USD/1M, output USD/1M).
70
- # IMPORTANT: substring match order is insertion order. List the most
71
- # specific keys first (e.g. `gpt-5-mini` before `gpt-5`, `o3-mini` before
72
- # `o3`, `gpt-4o-mini` before `gpt-4o`, `gpt-4o` before the legacy `gpt-4`).
73
- # For models with no published cached-input rate (o1-pro, o3-pro), cached
74
- # is set equal to input as a conservative no-discount default.
75
79
 
76
80
  # GPT-5 series.
77
- # gpt-5.6 pricing assumed equal to gpt-5.5 (its flagship predecessor) until
78
- # OpenAI publishes rates; without this entry it prefix-matches base `gpt-5`
79
- # ($1.25) and undercounts cost.
80
- "gpt-5.6": (5.00, 0.50, 30.0),
81
- "gpt-5.5": (5.00, 0.50, 30.0),
82
- "gpt-5.4-mini": (0.75, 0.075, 4.50),
83
- "gpt-5.4": (2.50, 0.25, 15.0),
84
81
  "gpt-5.2-pro": (21.0, 2.10, 168.0),
85
- "gpt-5.2": (1.75, 0.175, 14.0),
86
82
  "gpt-5.1": (1.25, 0.125, 10.0),
87
83
  "gpt-5-mini": (0.25, 0.025, 2.00),
88
84
  "gpt-5-nano": (0.05, 0.005, 0.40),
@@ -105,6 +101,10 @@ CODEX_PRICING = {
105
101
  "gpt-4": (2.50, 0.625, 10.0), # legacy gpt-4 fallback
106
102
  }
107
103
 
104
+ # catalog + legacy: catalog owns selectable prices, legacy covers the rest. No
105
+ # key overlaps today; if one ever collided, the legacy (right) side would win.
106
+ CODEX_PRICING = {**catalog_pricing("codex"), **_LEGACY_CODEX_PRICING}
107
+
108
108
  GEMINI_PRICING = {
109
109
  # model substring -> (input USD/1M, output USD/1M).
110
110
  #
@@ -145,7 +145,7 @@ def _match_pricing(model: str | None, table: dict) -> tuple | None:
145
145
  if not model:
146
146
  return None
147
147
  m = model.lower()
148
- for key, val in table.items():
148
+ for key, val in sorted(table.items(), key=lambda kv: len(kv[0]), reverse=True):
149
149
  if key in m:
150
150
  return val
151
151
  return None
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: okstra-usage
3
+ description: >-
4
+ Show project-wide okstra resource usage over a recent day window. Use when the
5
+ user asks for okstra usage, token usage, estimated spend, CPU/wall time, usage
6
+ coverage, or which lifecycle task types consumed resources. Defaults to the
7
+ whole current project for the last 30 days. For one task's elapsed/context
8
+ detail use okstra-inspect; for status/report digests use okstra-rollup.
9
+ ---
10
+
11
+ # OKSTRA Usage
12
+
13
+ Read-only project usage snapshot. The CLI owns every sum; never re-add rows.
14
+
15
+ ## Step 0: Preflight
16
+
17
+ Run one literal Bash call:
18
+
19
+ ```bash
20
+ okstra preflight --runtime claude-code --json
21
+ ```
22
+
23
+ On `ok:true`, carry `projectRoot`. On `ok:false`, tell the user to run `/okstra-setup` first
24
+ and stop. On `unknown command: preflight`, tell the user to run
25
+ `npm i -g okstra@latest` and stop.
26
+
27
+ ## Step 1: Resolve the day window
28
+
29
+ - No period stated: `30`.
30
+ - “last N days”: use N only when it is a positive integer.
31
+ - A calendar range, task/group/model/worker breakdown, forecast, or budget is
32
+ outside this MVP; state that limitation instead of inventing flags.
33
+
34
+ ## Step 2: Fetch once
35
+
36
+ ```bash
37
+ okstra usage-report --days 30 --project-root <projectRoot> --json
38
+ ```
39
+
40
+ Replace `30` only with the resolved positive integer. Do not wrap the command in
41
+ `if`, `eval`, command substitution, variable assignment, `&&`, `||`, or `npx`.
42
+
43
+ ## Step 3: Render
44
+
45
+ If `ok:false`, report `stage` and `reason`, then stop. If `runs.total == 0`, say
46
+ `No okstra usage found in the last N days.`, omit the table, and continue to Step 4.
47
+
48
+ Otherwise, render `byTaskType` in returned order:
49
+
50
+ | Task type | Runs | Coverage | Raw tokens | Billable | Cost | CPU | Wall |
51
+ |---|---:|---:|---:|---:|---:|---:|---:|
52
+
53
+ - `Coverage` = returned `collectedRuns/runs`; never calculate a new aggregate.
54
+ - Format tokens with K/M suffixes, milliseconds as zero-padded `HH:MM:SS`, and
55
+ USD to two decimals. For tokens, use one decimal at `>= 1,000` (`K`) and
56
+ `>= 1,000,000` (`M`), trimming a trailing `.0`; below 1,000 render the integer.
57
+ - Render the `totals` object as the final bold row.
58
+ - Never treat unavailable usage as collected zero usage.
59
+
60
+ ## Step 4: Diagnostics
61
+
62
+ - Render non-zero `unavailable` and `warnings` counts after the Step 3 output.
63
+ - If `unmatchedModels` is non-empty, mark Cost with `*` when the table exists and
64
+ always say known-price cost excludes those exact model names.
@@ -138,6 +138,13 @@ export const COMMAND_REGISTRY = [
138
138
  category: "introspection",
139
139
  summary: ["Aggregate elapsed work time across a task's runs (raw ms)"],
140
140
  },
141
+ {
142
+ name: "usage-report",
143
+ module: "./commands/inspect/usage-report.mjs",
144
+ export: "run",
145
+ category: "introspection",
146
+ summary: ["Aggregate recent token, cost, and time usage by task type"],
147
+ },
141
148
  {
142
149
  name: "log-report",
143
150
  module: "./commands/inspect/log-report.mjs",
@@ -0,0 +1,25 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra usage-report — aggregate recent project usage by task type
4
+
5
+ Usage:
6
+ okstra usage-report [--days <positive-int>] [--project-root <dir>] [--cwd <dir>] [--json]
7
+
8
+ Read-only. Prints JSON containing run coverage, raw and billable-equivalent
9
+ tokens, known USD cost, CPU-sum time, and wall-clock time grouped by task type.
10
+ The default window is the current project's last 30 days.
11
+ `;
12
+
13
+ export async function run(args) {
14
+ if (args.includes("--help") || args.includes("-h")) {
15
+ process.stdout.write(USAGE);
16
+ return 0;
17
+ }
18
+
19
+ const result = await runPythonModule({
20
+ module: "okstra_ctl.usage_report",
21
+ args,
22
+ stdio: "inherit-stdout",
23
+ });
24
+ return result.code;
25
+ }
@@ -12,6 +12,7 @@ export const USER_SKILL_NAMES = Object.freeze([
12
12
  "okstra-memory",
13
13
  "okstra-inspect",
14
14
  "okstra-rollup",
15
+ "okstra-usage",
15
16
  "okstra-schedule",
16
17
  "okstra-container-build",
17
18
  "okstra-graphify",