okstra 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,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())