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 +1 -0
- package/docs/architecture.md +3 -2
- package/docs/cli.md +1 -0
- package/docs/for-ai/README.md +3 -1
- package/docs/for-ai/skills/okstra-usage.md +28 -0
- package/docs/project-structure-overview.md +7 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/globals.sh +0 -5
- package/runtime/bin/lib/okstra/usage.sh +2 -2
- package/runtime/python/okstra_ctl/models.py +75 -48
- package/runtime/python/okstra_ctl/rollup.py +2 -2
- package/runtime/python/okstra_ctl/run.py +5 -5
- package/runtime/python/okstra_ctl/time_report.py +10 -6
- package/runtime/python/okstra_ctl/usage_report.py +269 -0
- package/runtime/python/okstra_ctl/wizard.py +4 -3
- package/runtime/python/okstra_token_usage/pricing.py +19 -19
- package/runtime/skills/okstra-usage/SKILL.md +64 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/usage-report.mjs +25 -0
- package/src/lib/skill-catalog.mjs +1 -0
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`) |
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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 |
|
package/docs/for-ai/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
1
|
+
"""Model catalog: display / execution / pricing / picker for okstra workers.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"claude-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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", "
|
|
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", "
|
|
1346
|
-
"codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", "
|
|
1347
|
-
"antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "
|
|
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
|
|
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(
|
|
24
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
24
25
|
except (OSError, ValueError):
|
|
25
|
-
return []
|
|
26
|
-
runs = data.get("runs", [])
|
|
27
|
-
|
|
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(
|
|
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", "
|
|
132
|
-
CODEX_MODEL_OPTIONS = ["default", "
|
|
133
|
-
ANTIGRAVITY_MODEL_OPTIONS = ["default", "
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
33
|
-
#
|
|
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
|
-
|
|
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.
|
package/src/cli-registry.mjs
CHANGED
|
@@ -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
|
+
}
|