okstra 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/bin/okstra +62 -0
- package/package.json +30 -0
- package/runtime/.gitkeep +0 -0
- package/runtime/BUILD.json +5 -0
- package/runtime/agents/SKILL.md +243 -0
- package/runtime/agents/TODO.md +168 -0
- package/runtime/agents/workers/claude-worker.md +106 -0
- package/runtime/agents/workers/codex-worker.md +179 -0
- package/runtime/agents/workers/gemini-worker.md +179 -0
- package/runtime/agents/workers/report-writer-worker.md +116 -0
- package/runtime/bin/okstra-central.sh +152 -0
- package/runtime/bin/okstra-codex-exec.sh +53 -0
- package/runtime/bin/okstra-error-log.py +295 -0
- package/runtime/bin/okstra-gemini-exec.sh +55 -0
- package/runtime/bin/okstra-token-usage.py +46 -0
- package/runtime/bin/okstra.sh +162 -0
- package/runtime/prompts/launch.template.md +52 -0
- package/runtime/prompts/profiles/error-analysis.md +43 -0
- package/runtime/prompts/profiles/final-verification.md +37 -0
- package/runtime/prompts/profiles/implementation-planning.md +85 -0
- package/runtime/prompts/profiles/implementation.md +71 -0
- package/runtime/prompts/profiles/requirements-discovery.md +43 -0
- package/runtime/python/lib/okstra/cli.sh +227 -0
- package/runtime/python/lib/okstra/globals.sh +157 -0
- package/runtime/python/lib/okstra/interactive.sh +411 -0
- package/runtime/python/lib/okstra/project-resolver.sh +57 -0
- package/runtime/python/lib/okstra/usage.sh +98 -0
- package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
- package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
- package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
- package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
- package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
- package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
- package/runtime/python/lib/okstra-ctl/main.sh +41 -0
- package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
- package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
- package/runtime/python/okstra_ctl/__init__.py +125 -0
- package/runtime/python/okstra_ctl/backfill.py +253 -0
- package/runtime/python/okstra_ctl/batch.py +62 -0
- package/runtime/python/okstra_ctl/ids.py +84 -0
- package/runtime/python/okstra_ctl/index.py +216 -0
- package/runtime/python/okstra_ctl/invocation.py +49 -0
- package/runtime/python/okstra_ctl/jsonl.py +84 -0
- package/runtime/python/okstra_ctl/listing.py +156 -0
- package/runtime/python/okstra_ctl/locks.py +42 -0
- package/runtime/python/okstra_ctl/material.py +62 -0
- package/runtime/python/okstra_ctl/models.py +63 -0
- package/runtime/python/okstra_ctl/path_resolve.py +40 -0
- package/runtime/python/okstra_ctl/paths.py +251 -0
- package/runtime/python/okstra_ctl/project_meta.py +51 -0
- package/runtime/python/okstra_ctl/reconcile.py +166 -0
- package/runtime/python/okstra_ctl/render.py +1065 -0
- package/runtime/python/okstra_ctl/resolver.py +54 -0
- package/runtime/python/okstra_ctl/run.py +674 -0
- package/runtime/python/okstra_ctl/run_context.py +166 -0
- package/runtime/python/okstra_ctl/seeding.py +97 -0
- package/runtime/python/okstra_ctl/sequence.py +53 -0
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/tmux.py +27 -0
- package/runtime/python/okstra_ctl/workers.py +64 -0
- package/runtime/python/okstra_ctl/workflow.py +182 -0
- package/runtime/python/okstra_project/__init__.py +41 -0
- package/runtime/python/okstra_project/resolver.py +126 -0
- package/runtime/python/okstra_project/state.py +170 -0
- package/runtime/python/okstra_token_usage/__init__.py +26 -0
- package/runtime/python/okstra_token_usage/blocks.py +62 -0
- package/runtime/python/okstra_token_usage/claude.py +97 -0
- package/runtime/python/okstra_token_usage/cli.py +84 -0
- package/runtime/python/okstra_token_usage/codex.py +80 -0
- package/runtime/python/okstra_token_usage/collect.py +161 -0
- package/runtime/python/okstra_token_usage/gemini.py +77 -0
- package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
- package/runtime/python/okstra_token_usage/paths.py +22 -0
- package/runtime/python/okstra_token_usage/pricing.py +71 -0
- package/runtime/python/okstra_token_usage/report.py +64 -0
- package/runtime/templates/prd/brief.template.md +273 -0
- package/runtime/templates/project-docs/task-index.template.md +65 -0
- package/runtime/templates/reports/error-analysis-input.template.md +80 -0
- package/runtime/templates/reports/final-report.template.md +167 -0
- package/runtime/templates/reports/final-verification-input.template.md +67 -0
- package/runtime/templates/reports/implementation-input.template.md +81 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
- package/runtime/templates/reports/quick-input.template.md +64 -0
- package/runtime/templates/reports/schedule.template.md +168 -0
- package/runtime/templates/reports/settings.template.json +101 -0
- package/runtime/templates/reports/task-brief.template.md +165 -0
- package/runtime/validators/lib/common.sh +44 -0
- package/runtime/validators/lib/fixtures.sh +322 -0
- package/runtime/validators/lib/paths.sh +44 -0
- package/runtime/validators/lib/runners.sh +140 -0
- package/runtime/validators/lib/summary.sh +15 -0
- package/runtime/validators/lib/validate-assets.sh +44 -0
- package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
- package/runtime/validators/lib/validate-tasks.sh +335 -0
- package/runtime/validators/validate-run.py +568 -0
- package/runtime/validators/validate-schedule.py +665 -0
- package/runtime/validators/validate-workflow.sh +190 -0
- package/src/doctor.mjs +127 -0
- package/src/install.mjs +355 -0
- package/src/paths.mjs +132 -0
- package/src/uninstall.mjs +122 -0
- package/src/version.mjs +20 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# okstra 중앙 컨트롤 센터의 write-side 헬퍼 모음.
|
|
3
|
+
# okstra.sh 와 okstra-ctl.sh 양쪽에서 source 하여 사용한다.
|
|
4
|
+
|
|
5
|
+
# 중앙 디렉터리 위치를 해석한다. OKSTRA_HOME 이 설정돼 있으면 그 값을, 아니면 $HOME/.okstra 를 사용한다.
|
|
6
|
+
okstra_central_home() {
|
|
7
|
+
if [[ -n "${OKSTRA_HOME:-}" ]]; then
|
|
8
|
+
printf '%s\n' "$OKSTRA_HOME"
|
|
9
|
+
else
|
|
10
|
+
printf '%s\n' "$HOME/.okstra"
|
|
11
|
+
fi
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# 중앙 디렉터리와 필수 파일을 생성한다. 멱등성이 보장된다.
|
|
15
|
+
okstra_central_bootstrap() {
|
|
16
|
+
local home
|
|
17
|
+
home="$(okstra_central_home)"
|
|
18
|
+
python3 - "$home" <<'PY'
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
home = Path(sys.argv[1])
|
|
26
|
+
# OKSTRA_HOME 은 사용자 지정 override 가 가능하며 ($XDG_STATE_HOME/okstra
|
|
27
|
+
# 처럼) 부모 디렉터리가 없는 nested 경로일 수 있다. parents=False 로
|
|
28
|
+
# mkdir 하면 모든 okstra-ctl 진입점과 record_start hook 이 bootstrap 단계
|
|
29
|
+
# 에서 FileNotFoundError 로 abort 하므로, 부모까지 함께 생성한다.
|
|
30
|
+
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
31
|
+
os.chmod(home, 0o700)
|
|
32
|
+
for sub in ("archive", ".locks", "batches", "projects"):
|
|
33
|
+
(home / sub).mkdir(exist_ok=True)
|
|
34
|
+
for f in ("active.jsonl", "recent.jsonl"):
|
|
35
|
+
(home / f).touch(exist_ok=True)
|
|
36
|
+
state_file = home / "state.json"
|
|
37
|
+
if not state_file.exists():
|
|
38
|
+
payload = {
|
|
39
|
+
"schemaVersion": "1",
|
|
40
|
+
"createdAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
41
|
+
"backfilledAt": None,
|
|
42
|
+
}
|
|
43
|
+
state_file.write_text(json.dumps(payload, indent=2) + "\n")
|
|
44
|
+
os.chmod(state_file, 0o600)
|
|
45
|
+
PY
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# 인자로 받은 명령을 ~/.okstra/.lock 위에 fcntl LOCK_EX 로 직렬화해 실행한다.
|
|
49
|
+
# 사용법: okstra_central_with_lock "<bash command string>"
|
|
50
|
+
# 주: macOS 는 util-linux 의 `flock` 바이너리를 제공하지 않아 python fcntl 을 사용한다.
|
|
51
|
+
okstra_central_with_lock() {
|
|
52
|
+
local home cmd lockfile
|
|
53
|
+
home="$(okstra_central_home)"
|
|
54
|
+
cmd="$1"
|
|
55
|
+
lockfile="$home/.lock"
|
|
56
|
+
: >> "$lockfile"
|
|
57
|
+
python3 - "$lockfile" "$cmd" <<'PY'
|
|
58
|
+
import fcntl, subprocess, sys
|
|
59
|
+
lockpath, cmd = sys.argv[1], sys.argv[2]
|
|
60
|
+
with open(lockpath, "r+") as lock:
|
|
61
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
62
|
+
rc = subprocess.run(["bash", "-c", cmd]).returncode
|
|
63
|
+
sys.exit(rc)
|
|
64
|
+
PY
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# okstra.sh 에서 호출되는 메인 hook. 환경변수에서 메타를 읽어
|
|
68
|
+
# active.jsonl, projects/<id>/index.jsonl, invocation, meta.json 을
|
|
69
|
+
# 단일 락 트랜잭션으로 갱신한다.
|
|
70
|
+
#
|
|
71
|
+
# 이전 구현은 OKSTRA_HOME / OKSTRA_CTL_LIB_DIR 값을 OKSTRA_HOME='$home'
|
|
72
|
+
# 형태로 bash -c 문자열에 박아 okstra_central_with_lock 에 넘겼는데,
|
|
73
|
+
# OKSTRA_HOME 이나 체크아웃 경로에 single quote 가 포함되면 syntax error
|
|
74
|
+
# 로 hook 이 silently 실패해 해당 run 이 중앙 인덱스에 남지 않았다.
|
|
75
|
+
# 이를 피하기 위해 값은 환경변수로 전달하고, 락은 python 안에서 직접
|
|
76
|
+
# fcntl.flock 으로 잡는다(okstra_central_with_lock 와 동일 lockfile).
|
|
77
|
+
okstra_central_record_start() {
|
|
78
|
+
local home script_dir payload_json
|
|
79
|
+
home="$(okstra_central_home)"
|
|
80
|
+
script_dir="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
81
|
+
# 빈 default 일 때 JSON 호환 문자열로 미리 정규화한다.
|
|
82
|
+
local _argv_json="${OKSTRA_INVOCATION_ARGV_JSON-}"
|
|
83
|
+
[[ -z "$_argv_json" ]] && _argv_json='[]'
|
|
84
|
+
local _env_json="${OKSTRA_INVOCATION_ENV_JSON-}"
|
|
85
|
+
[[ -z "$_env_json" ]] && _env_json='{}'
|
|
86
|
+
local _initial_status="${OKSTRA_INITIAL_STATUS-}"
|
|
87
|
+
[[ -z "$_initial_status" ]] && _initial_status='running'
|
|
88
|
+
local _run_seq="${OKSTRA_RUN_SEQ-}"
|
|
89
|
+
[[ -z "$_run_seq" ]] && _run_seq='0'
|
|
90
|
+
# Build the entire payload as a JSON string in bash (no env-var passthrough),
|
|
91
|
+
# then hand it to python via argv so this hook is callable inside any
|
|
92
|
+
# claude-session subprocess without leaking shell-globals into the parent
|
|
93
|
+
# process environment.
|
|
94
|
+
payload_json="$(python3 -c '
|
|
95
|
+
import json, sys
|
|
96
|
+
it = iter(sys.argv[1:])
|
|
97
|
+
print(json.dumps({k: v for k, v in zip(it, it)}, ensure_ascii=False))
|
|
98
|
+
' \
|
|
99
|
+
OKSTRA_CTL_LIB_DIR "$script_dir" \
|
|
100
|
+
OKSTRA_HOME "$home" \
|
|
101
|
+
PROJECT_ID "${PROJECT_ID-}" \
|
|
102
|
+
PROJECT_ROOT "${PROJECT_ROOT-}" \
|
|
103
|
+
TASK_GROUP "${TASK_GROUP-}" \
|
|
104
|
+
TASK_ID "${TASK_ID-}" \
|
|
105
|
+
ANALYSIS_TYPE "${ANALYSIS_TYPE-}" \
|
|
106
|
+
OKSTRA_RUN_SEQ "$_run_seq" \
|
|
107
|
+
RUN_TIMESTAMP_ISO "${RUN_TIMESTAMP_ISO-}" \
|
|
108
|
+
SELECTED_REVIEWERS "${SELECTED_REVIEWERS-}" \
|
|
109
|
+
LEAD_MODEL_DISPLAY "${LEAD_MODEL_DISPLAY-}" \
|
|
110
|
+
RUN_DIR_RELATIVE_PATH "${RUN_DIR_RELATIVE_PATH-}" \
|
|
111
|
+
FINAL_REPORT_RELATIVE_PATH "${FINAL_REPORT_RELATIVE_PATH-}" \
|
|
112
|
+
FINAL_STATUS_RELATIVE_PATH "${FINAL_STATUS_RELATIVE_PATH-}" \
|
|
113
|
+
OKSTRA_INVOCATION_ARGV_JSON "$_argv_json" \
|
|
114
|
+
OKSTRA_INVOCATION_CWD "${OKSTRA_INVOCATION_CWD-}" \
|
|
115
|
+
OKSTRA_INVOCATION_ENV_JSON "$_env_json" \
|
|
116
|
+
OKSTRA_INITIAL_STATUS "$_initial_status" \
|
|
117
|
+
OKSTRA_INVOCATION_BRIEF_SHA256 "${OKSTRA_INVOCATION_BRIEF_SHA256-}")"
|
|
118
|
+
python3 - "$payload_json" <<'PY'
|
|
119
|
+
import fcntl, json, sys
|
|
120
|
+
payload = json.loads(sys.argv[1])
|
|
121
|
+
sys.path.insert(0, payload["OKSTRA_CTL_LIB_DIR"])
|
|
122
|
+
from pathlib import Path
|
|
123
|
+
from okstra_ctl import record_start
|
|
124
|
+
|
|
125
|
+
home = Path(payload["OKSTRA_HOME"])
|
|
126
|
+
lockfile = home / ".lock"
|
|
127
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
lockfile.touch()
|
|
129
|
+
with lockfile.open("r+") as lock:
|
|
130
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
131
|
+
record_start(
|
|
132
|
+
home,
|
|
133
|
+
project_id=payload["PROJECT_ID"],
|
|
134
|
+
project_root=payload["PROJECT_ROOT"],
|
|
135
|
+
task_group=payload["TASK_GROUP"],
|
|
136
|
+
task_id=payload["TASK_ID"],
|
|
137
|
+
task_type=payload.get("ANALYSIS_TYPE", ""),
|
|
138
|
+
run_seq=int(payload["OKSTRA_RUN_SEQ"]),
|
|
139
|
+
when=payload["RUN_TIMESTAMP_ISO"],
|
|
140
|
+
workers=[w for w in payload.get("SELECTED_REVIEWERS", "").split(",") if w],
|
|
141
|
+
lead_model=payload.get("LEAD_MODEL_DISPLAY", ""),
|
|
142
|
+
run_dir_rel=payload.get("RUN_DIR_RELATIVE_PATH", ""),
|
|
143
|
+
final_report_rel=payload.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
144
|
+
final_status_rel=payload.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
145
|
+
argv=json.loads(payload.get("OKSTRA_INVOCATION_ARGV_JSON", "[]")),
|
|
146
|
+
cwd=payload.get("OKSTRA_INVOCATION_CWD", ""),
|
|
147
|
+
env_overrides=json.loads(payload.get("OKSTRA_INVOCATION_ENV_JSON", "{}") or "{}"),
|
|
148
|
+
initial_status=payload.get("OKSTRA_INITIAL_STATUS", "running"),
|
|
149
|
+
brief_sha256=payload.get("OKSTRA_INVOCATION_BRIEF_SHA256", ""),
|
|
150
|
+
)
|
|
151
|
+
PY
|
|
152
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# okstra-codex-exec.sh — wrapper around `codex exec` for okstra codex-worker
|
|
3
|
+
#
|
|
4
|
+
# Purpose: Claude Code's Bash permission matcher requires explicit approval for
|
|
5
|
+
# commands that contain shell metacharacters (stdin/stderr redirects, pipes).
|
|
6
|
+
# `codex exec ... - < <prompt-path> 2>/dev/null` therefore triggers a permission
|
|
7
|
+
# prompt every dispatch even when `Bash(codex exec:*)` is allowlisted, because
|
|
8
|
+
# the redirect tokens disqualify the simple-prefix match.
|
|
9
|
+
#
|
|
10
|
+
# This wrapper accepts positional arguments and performs the redirect inside
|
|
11
|
+
# the script body, so the caller can allowlist a single non-redirect form:
|
|
12
|
+
#
|
|
13
|
+
# Bash($HOME/.okstra/bin/okstra-codex-exec.sh:*)
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# okstra-codex-exec.sh <project-root> <model-execution-value> <prompt-path>
|
|
17
|
+
#
|
|
18
|
+
# All three arguments are required and must be absolute paths or literal model
|
|
19
|
+
# strings. The wrapper exits non-zero on any preflight failure.
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
if [[ $# -ne 3 ]]; then
|
|
23
|
+
printf 'usage: %s <project-root> <model-execution-value> <prompt-path>\n' "$(basename "$0")" >&2
|
|
24
|
+
exit 64
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
project_root="$1"
|
|
28
|
+
model="$2"
|
|
29
|
+
prompt_path="$3"
|
|
30
|
+
|
|
31
|
+
if [[ -z "$project_root" || ! -d "$project_root" ]]; then
|
|
32
|
+
printf 'okstra-codex-exec: project-root is missing or not a directory: %q\n' "$project_root" >&2
|
|
33
|
+
exit 65
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [[ -z "$model" ]]; then
|
|
37
|
+
printf 'okstra-codex-exec: model-execution-value is empty\n' >&2
|
|
38
|
+
exit 66
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [[ -z "$prompt_path" || ! -f "$prompt_path" ]]; then
|
|
42
|
+
printf 'okstra-codex-exec: prompt-path is missing or not a file: %q\n' "$prompt_path" >&2
|
|
43
|
+
exit 67
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
if ! command -v codex >/dev/null 2>&1; then
|
|
47
|
+
printf 'okstra-codex-exec: codex CLI is not installed on PATH\n' >&2
|
|
48
|
+
exit 127
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# stdin redirect and stderr suppression are intentionally inside the wrapper —
|
|
52
|
+
# this is the entire reason this script exists.
|
|
53
|
+
exec codex exec -C "$project_root" --model "$model" --full-auto - < "$prompt_path" 2>/dev/null
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""OKSTRA error log helper.
|
|
2
|
+
|
|
3
|
+
Single writer for runs/<task-type>/logs/errors-<task-type>-<seq>.jsonl.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
STDERR_EXCERPT_MAX_BYTES = 2048
|
|
14
|
+
TRUNCATION_SUFFIX = "...[truncated]"
|
|
15
|
+
PIPE_BUF_BYTES = 4096
|
|
16
|
+
|
|
17
|
+
ALLOWED_ERROR_TYPES = {"tool-failure", "cli-failure", "contract-violation"}
|
|
18
|
+
ALLOWED_AGENTS = {
|
|
19
|
+
"claude-lead", "claude-worker", "codex-worker",
|
|
20
|
+
"gemini-worker", "report-writer",
|
|
21
|
+
}
|
|
22
|
+
ALLOWED_AGENT_ROLES = {"lead", "worker", "report-writer"}
|
|
23
|
+
SUPPORTED_SIDECAR_SCHEMA_VERSIONS = {1}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_utc():
|
|
27
|
+
return dt.datetime.now(dt.timezone.utc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _iso(t):
|
|
31
|
+
return t.isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def truncate_stderr(s):
|
|
35
|
+
"""Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
|
|
36
|
+
if s is None:
|
|
37
|
+
return None
|
|
38
|
+
encoded = s.encode("utf-8")
|
|
39
|
+
if len(encoded) <= STDERR_EXCERPT_MAX_BYTES:
|
|
40
|
+
return s
|
|
41
|
+
cut = encoded[:STDERR_EXCERPT_MAX_BYTES]
|
|
42
|
+
# 멀티바이트 경계 보호: 디코드 가능해질 때까지 끝 바이트 제거
|
|
43
|
+
while cut:
|
|
44
|
+
try:
|
|
45
|
+
decoded = cut.decode("utf-8")
|
|
46
|
+
return decoded + TRUNCATION_SUFFIX
|
|
47
|
+
except UnicodeDecodeError:
|
|
48
|
+
cut = cut[:-1]
|
|
49
|
+
return TRUNCATION_SUFFIX
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def append_jsonl_line(path, record):
|
|
53
|
+
"""Append a single JSON record as one line to ``path``.
|
|
54
|
+
|
|
55
|
+
Atomicity guarantee (POSIX only):
|
|
56
|
+
With ``O_APPEND`` and a single ``write()`` syscall, the kernel
|
|
57
|
+
appends the entire payload as one indivisible operation as long as
|
|
58
|
+
the payload size is at most ``PIPE_BUF`` (4096 bytes on Linux and
|
|
59
|
+
macOS). Larger payloads may be split across syscalls and interleave
|
|
60
|
+
with concurrent writers, so this helper rejects them with
|
|
61
|
+
``ValueError`` rather than silently losing atomicity.
|
|
62
|
+
|
|
63
|
+
The atomicity contract holds only on POSIX filesystems with O_APPEND
|
|
64
|
+
semantics. Concurrent writers using ``O_TRUNC``, ``unlink``, or
|
|
65
|
+
non-append modes against the same path break the contract and are
|
|
66
|
+
out of scope for this helper.
|
|
67
|
+
|
|
68
|
+
Caller responsibilities:
|
|
69
|
+
- Keep records small (this module's stderr excerpt cap of
|
|
70
|
+
``STDERR_EXCERPT_MAX_BYTES`` exists to keep records well under
|
|
71
|
+
``PIPE_BUF_BYTES``).
|
|
72
|
+
- Handle ``TypeError`` from ``json.dumps`` for non-serializable values.
|
|
73
|
+
|
|
74
|
+
Creates parent directories as needed.
|
|
75
|
+
"""
|
|
76
|
+
p = Path(path)
|
|
77
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
# ensure_ascii=False keeps UTF-8 compact (no \uXXXX escapes).
|
|
79
|
+
# json.dumps escapes literal newlines inside string values, so the
|
|
80
|
+
# only unescaped newline is the record separator we append below.
|
|
81
|
+
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
82
|
+
data = line.encode("utf-8")
|
|
83
|
+
if len(data) > PIPE_BUF_BYTES:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"record too large for atomic append: {len(data)} bytes > "
|
|
86
|
+
f"PIPE_BUF ({PIPE_BUF_BYTES})"
|
|
87
|
+
)
|
|
88
|
+
# mode 0o644: owner read/write, group/world read-only.
|
|
89
|
+
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
90
|
+
try:
|
|
91
|
+
os.write(fd, data)
|
|
92
|
+
finally:
|
|
93
|
+
os.close(fd)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def append_observed(
|
|
97
|
+
*,
|
|
98
|
+
out_path,
|
|
99
|
+
task_key,
|
|
100
|
+
phase,
|
|
101
|
+
agent,
|
|
102
|
+
agent_role,
|
|
103
|
+
model,
|
|
104
|
+
error_type,
|
|
105
|
+
command,
|
|
106
|
+
command_kind,
|
|
107
|
+
exit_code,
|
|
108
|
+
duration_ms,
|
|
109
|
+
message,
|
|
110
|
+
stderr_excerpt,
|
|
111
|
+
context,
|
|
112
|
+
now=None,
|
|
113
|
+
):
|
|
114
|
+
"""Append a lead-observed error event to errors.jsonl."""
|
|
115
|
+
if error_type not in ALLOWED_ERROR_TYPES:
|
|
116
|
+
raise ValueError(f"invalid errorType: {error_type!r}")
|
|
117
|
+
if agent not in ALLOWED_AGENTS:
|
|
118
|
+
raise ValueError(f"invalid agent: {agent!r}")
|
|
119
|
+
if agent_role not in ALLOWED_AGENT_ROLES:
|
|
120
|
+
raise ValueError(f"invalid agentRole: {agent_role!r}")
|
|
121
|
+
ts = _iso(now or _now_utc())
|
|
122
|
+
rec = {
|
|
123
|
+
"ts": ts,
|
|
124
|
+
"recordedAt": ts,
|
|
125
|
+
"taskKey": task_key,
|
|
126
|
+
"phase": str(phase),
|
|
127
|
+
"agent": agent,
|
|
128
|
+
"agentRole": agent_role,
|
|
129
|
+
"model": model,
|
|
130
|
+
"source": "lead-observed",
|
|
131
|
+
"errorType": error_type,
|
|
132
|
+
"command": command,
|
|
133
|
+
"commandKind": command_kind,
|
|
134
|
+
"exitCode": exit_code,
|
|
135
|
+
"durationMs": duration_ms,
|
|
136
|
+
"message": message,
|
|
137
|
+
"stderrExcerpt": truncate_stderr(stderr_excerpt),
|
|
138
|
+
"context": context,
|
|
139
|
+
}
|
|
140
|
+
append_jsonl_line(out_path, rec)
|
|
141
|
+
return rec
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def dump_from_worker_sidecar(
|
|
145
|
+
*,
|
|
146
|
+
sidecar_path,
|
|
147
|
+
out_path,
|
|
148
|
+
task_key,
|
|
149
|
+
agent,
|
|
150
|
+
agent_role,
|
|
151
|
+
model,
|
|
152
|
+
now=None,
|
|
153
|
+
):
|
|
154
|
+
"""Read worker sidecar errors[] and append each to errors.jsonl with
|
|
155
|
+
Lead-side metadata filled in. Returns number of records appended.
|
|
156
|
+
|
|
157
|
+
Raises ValueError if:
|
|
158
|
+
- ``agent`` or ``agent_role`` is not in the allow-lists
|
|
159
|
+
- sidecar ``schemaVersion`` is not in ``SUPPORTED_SIDECAR_SCHEMA_VERSIONS``
|
|
160
|
+
- any entry's ``errorType`` is not in ``ALLOWED_ERROR_TYPES``
|
|
161
|
+
|
|
162
|
+
Returns 0 (no-op) if the sidecar file does not exist or its
|
|
163
|
+
``errors`` list is empty.
|
|
164
|
+
|
|
165
|
+
Partial-failure semantics: entries are validated and appended in
|
|
166
|
+
order. If entry N fails validation, entries 0..N-1 have already
|
|
167
|
+
been written to ``out_path`` and are NOT rolled back. Callers that
|
|
168
|
+
require atomicity must validate the sidecar payload before invoking
|
|
169
|
+
this function.
|
|
170
|
+
"""
|
|
171
|
+
if agent not in ALLOWED_AGENTS:
|
|
172
|
+
raise ValueError(f"invalid agent: {agent!r}")
|
|
173
|
+
if agent_role not in ALLOWED_AGENT_ROLES:
|
|
174
|
+
raise ValueError(f"invalid agentRole: {agent_role!r}")
|
|
175
|
+
p = Path(sidecar_path)
|
|
176
|
+
if not p.exists():
|
|
177
|
+
return 0
|
|
178
|
+
payload = json.loads(p.read_text())
|
|
179
|
+
schema = payload.get("schemaVersion")
|
|
180
|
+
if schema not in SUPPORTED_SIDECAR_SCHEMA_VERSIONS:
|
|
181
|
+
raise ValueError(f"unsupported sidecar schemaVersion: {schema!r}")
|
|
182
|
+
entries = payload.get("errors") or []
|
|
183
|
+
recorded_at = _iso(now or _now_utc())
|
|
184
|
+
count = 0
|
|
185
|
+
for e in entries:
|
|
186
|
+
et = e.get("errorType")
|
|
187
|
+
if et not in ALLOWED_ERROR_TYPES:
|
|
188
|
+
raise ValueError(f"invalid errorType in sidecar: {et!r}")
|
|
189
|
+
rec = {
|
|
190
|
+
"ts": e.get("ts"),
|
|
191
|
+
"recordedAt": recorded_at,
|
|
192
|
+
"taskKey": task_key,
|
|
193
|
+
"phase": str(e.get("phase")) if e.get("phase") is not None else None,
|
|
194
|
+
"agent": agent,
|
|
195
|
+
"agentRole": agent_role,
|
|
196
|
+
"model": model,
|
|
197
|
+
"source": "worker-reported",
|
|
198
|
+
"errorType": et,
|
|
199
|
+
"command": e.get("command"),
|
|
200
|
+
"commandKind": e.get("commandKind"),
|
|
201
|
+
"exitCode": e.get("exitCode"),
|
|
202
|
+
"durationMs": e.get("durationMs"),
|
|
203
|
+
"message": e.get("message"),
|
|
204
|
+
"stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
|
|
205
|
+
"context": e.get("context"),
|
|
206
|
+
}
|
|
207
|
+
append_jsonl_line(out_path, rec)
|
|
208
|
+
count += 1
|
|
209
|
+
return count
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_parser():
|
|
213
|
+
p = argparse.ArgumentParser(description="OKSTRA error log helper")
|
|
214
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
215
|
+
|
|
216
|
+
obs = sub.add_parser("append-observed",
|
|
217
|
+
help="Append a lead-observed error event")
|
|
218
|
+
obs.add_argument("--out", required=True)
|
|
219
|
+
obs.add_argument("--task-key", required=True)
|
|
220
|
+
obs.add_argument("--phase", required=True)
|
|
221
|
+
obs.add_argument("--agent", required=True, choices=sorted(ALLOWED_AGENTS))
|
|
222
|
+
obs.add_argument("--agent-role", required=True, choices=sorted(ALLOWED_AGENT_ROLES))
|
|
223
|
+
obs.add_argument("--model", required=True)
|
|
224
|
+
obs.add_argument("--error-type", required=True, choices=sorted(ALLOWED_ERROR_TYPES))
|
|
225
|
+
obs.add_argument("--command", required=True)
|
|
226
|
+
obs.add_argument("--command-kind", required=True)
|
|
227
|
+
obs.add_argument("--exit-code", type=int, default=None)
|
|
228
|
+
obs.add_argument("--duration-ms", type=int, default=None)
|
|
229
|
+
obs.add_argument("--message", required=True)
|
|
230
|
+
grp = obs.add_mutually_exclusive_group()
|
|
231
|
+
grp.add_argument("--stderr-excerpt", default=None)
|
|
232
|
+
grp.add_argument("--stderr-excerpt-file", default=None)
|
|
233
|
+
obs.add_argument("--context-json", default=None,
|
|
234
|
+
help="JSON object string for context")
|
|
235
|
+
|
|
236
|
+
dump = sub.add_parser("append-from-worker",
|
|
237
|
+
help="Dump worker sidecar errors[] into errors.jsonl")
|
|
238
|
+
dump.add_argument("--sidecar", required=True)
|
|
239
|
+
dump.add_argument("--out", required=True)
|
|
240
|
+
dump.add_argument("--task-key", required=True)
|
|
241
|
+
dump.add_argument("--agent", required=True, choices=sorted(ALLOWED_AGENTS))
|
|
242
|
+
dump.add_argument("--agent-role", required=True, choices=sorted(ALLOWED_AGENT_ROLES))
|
|
243
|
+
dump.add_argument("--model", required=True)
|
|
244
|
+
return p
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_stderr(args):
|
|
248
|
+
if args.stderr_excerpt_file:
|
|
249
|
+
return Path(args.stderr_excerpt_file).read_text()
|
|
250
|
+
return args.stderr_excerpt
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _read_context(args):
|
|
254
|
+
if args.context_json is None:
|
|
255
|
+
return None
|
|
256
|
+
return json.loads(args.context_json)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main(argv=None):
|
|
260
|
+
args = _build_parser().parse_args(argv)
|
|
261
|
+
if args.cmd == "append-observed":
|
|
262
|
+
append_observed(
|
|
263
|
+
out_path=args.out,
|
|
264
|
+
task_key=args.task_key,
|
|
265
|
+
phase=args.phase,
|
|
266
|
+
agent=args.agent,
|
|
267
|
+
agent_role=args.agent_role,
|
|
268
|
+
model=args.model,
|
|
269
|
+
error_type=args.error_type,
|
|
270
|
+
command=args.command,
|
|
271
|
+
command_kind=args.command_kind,
|
|
272
|
+
exit_code=args.exit_code,
|
|
273
|
+
duration_ms=args.duration_ms,
|
|
274
|
+
message=args.message,
|
|
275
|
+
stderr_excerpt=_read_stderr(args),
|
|
276
|
+
context=_read_context(args),
|
|
277
|
+
)
|
|
278
|
+
return 0
|
|
279
|
+
if args.cmd == "append-from-worker":
|
|
280
|
+
n = dump_from_worker_sidecar(
|
|
281
|
+
sidecar_path=args.sidecar,
|
|
282
|
+
out_path=args.out,
|
|
283
|
+
task_key=args.task_key,
|
|
284
|
+
agent=args.agent,
|
|
285
|
+
agent_role=args.agent_role,
|
|
286
|
+
model=args.model,
|
|
287
|
+
)
|
|
288
|
+
print(n)
|
|
289
|
+
return 0
|
|
290
|
+
# Unreachable: argparse `required=True` on subparsers raises SystemExit
|
|
291
|
+
# before this function runs if the subcommand is missing or invalid.
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# okstra-gemini-exec.sh — wrapper around `gemini -p -` for okstra gemini-worker
|
|
3
|
+
#
|
|
4
|
+
# Purpose: Claude Code's Bash permission matcher requires explicit approval for
|
|
5
|
+
# commands that contain shell metacharacters (stdin/stderr redirects, pipes).
|
|
6
|
+
# `gemini -p - -m <model> -o text --include-directories <root> < <prompt-path> 2>/dev/null`
|
|
7
|
+
# therefore triggers a permission prompt every dispatch even when `Bash(gemini:*)`
|
|
8
|
+
# is allowlisted, because the redirect tokens disqualify the simple-prefix match.
|
|
9
|
+
#
|
|
10
|
+
# This wrapper accepts positional arguments and performs the redirect inside
|
|
11
|
+
# the script body, so the caller can allowlist a single non-redirect form:
|
|
12
|
+
#
|
|
13
|
+
# Bash($HOME/.okstra/bin/okstra-gemini-exec.sh:*)
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# okstra-gemini-exec.sh <project-root> <model-execution-value> <prompt-path>
|
|
17
|
+
#
|
|
18
|
+
# All three arguments are required and must be absolute paths or literal model
|
|
19
|
+
# strings. The wrapper exits non-zero on any preflight failure.
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
if [[ $# -ne 3 ]]; then
|
|
23
|
+
printf 'usage: %s <project-root> <model-execution-value> <prompt-path>\n' "$(basename "$0")" >&2
|
|
24
|
+
exit 64
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
project_root="$1"
|
|
28
|
+
model="$2"
|
|
29
|
+
prompt_path="$3"
|
|
30
|
+
|
|
31
|
+
if [[ -z "$project_root" || ! -d "$project_root" ]]; then
|
|
32
|
+
printf 'okstra-gemini-exec: project-root is missing or not a directory: %q\n' "$project_root" >&2
|
|
33
|
+
exit 65
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [[ -z "$model" ]]; then
|
|
37
|
+
printf 'okstra-gemini-exec: model-execution-value is empty\n' >&2
|
|
38
|
+
exit 66
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [[ -z "$prompt_path" || ! -f "$prompt_path" ]]; then
|
|
42
|
+
printf 'okstra-gemini-exec: prompt-path is missing or not a file: %q\n' "$prompt_path" >&2
|
|
43
|
+
exit 67
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
if ! command -v gemini >/dev/null 2>&1; then
|
|
47
|
+
printf 'okstra-gemini-exec: gemini CLI is not installed on PATH\n' >&2
|
|
48
|
+
exit 127
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# stdin redirect and stderr suppression are intentionally inside the wrapper —
|
|
52
|
+
# this is the entire reason this script exists. Gemini CLI has no `--cd` flag,
|
|
53
|
+
# so workspace correctness is anchored via `--include-directories` plus the
|
|
54
|
+
# Project Root referenced in the prompt body itself.
|
|
55
|
+
exec gemini -p - -m "$model" -o text --include-directories "$project_root" < "$prompt_path" 2>/dev/null
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Collect token usage for an okstra run from agent session transcripts.
|
|
3
|
+
|
|
4
|
+
The implementation lives in the ``okstra_token_usage`` package alongside
|
|
5
|
+
this entry script. Public symbols are re-exported here so callers that
|
|
6
|
+
load this file dynamically (e.g. tests using
|
|
7
|
+
``importlib.util.spec_from_file_location``) keep working.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from okstra_token_usage.cli import main # noqa: E402
|
|
17
|
+
# Re-exports for callers that access attributes off this module directly.
|
|
18
|
+
from okstra_token_usage import ( # noqa: E402,F401
|
|
19
|
+
CLAUDE_PRICING,
|
|
20
|
+
CLAUDE_PROJECTS,
|
|
21
|
+
CODEX_PRICING,
|
|
22
|
+
CODEX_SESSIONS,
|
|
23
|
+
GEMINI_PRICING,
|
|
24
|
+
GEMINI_TMP,
|
|
25
|
+
claude_billable_equivalent,
|
|
26
|
+
claude_cost_usd,
|
|
27
|
+
claude_project_dir,
|
|
28
|
+
claude_session_totals,
|
|
29
|
+
codex_cost_usd,
|
|
30
|
+
codex_session_total,
|
|
31
|
+
collect,
|
|
32
|
+
find_claude_team_sessions,
|
|
33
|
+
find_codex_session,
|
|
34
|
+
find_gemini_session,
|
|
35
|
+
gemini_cost_usd,
|
|
36
|
+
gemini_session_total,
|
|
37
|
+
iter_jsonl,
|
|
38
|
+
na_block,
|
|
39
|
+
substitute_final_report,
|
|
40
|
+
usage_block,
|
|
41
|
+
utc_now,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|