arkaos 4.0.2 → 4.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 +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +16 -22
- package/arka/skills/flow/SKILL.md +108 -234
- package/config/claude-agents/eduardo-copy.md +53 -0
- package/config/claude-agents/francisca-tech.md +52 -0
- package/config/claude-agents/marta-cqo.md +52 -0
- package/config/claude-agents/paulo-tech-lead.md +50 -0
- package/config/cognition/schedules.yaml +15 -0
- package/config/constitution.yaml +17 -21
- package/config/hooks/_lib/workflow-classifier.sh +1 -1
- package/config/hooks/post-tool-use.sh +40 -539
- package/config/hooks/pre-tool-use.sh +32 -281
- package/config/hooks/session-start.sh +8 -10
- package/config/hooks/stop.sh +30 -425
- package/config/hooks/user-prompt-submit.sh +42 -459
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/dreaming.py +5 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +10 -0
- package/core/forge/__init__.py +2 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/orchestrator.py +50 -27
- package/core/forge/persistence.py +16 -0
- package/core/forge/renderer.py +16 -0
- package/core/forge/runtime_dispatcher.py +34 -19
- package/core/forge/schema.py +12 -0
- package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
- package/core/governance/closing_marker_check.py +9 -5
- package/core/governance/compliance_telemetry.py +1 -1
- package/core/governance/compliance_telemetry_cli.py +1 -1
- package/core/governance/evidence_checks.py +421 -0
- package/core/governance/meta_tag_check.py +36 -0
- package/core/governance/qg_verdict.py +78 -0
- package/core/governance/review_workflow.py +34 -1
- package/core/governance/skill_proposer.py +2 -1
- package/core/hooks/__init__.py +12 -0
- package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/_shared.py +110 -0
- package/core/hooks/post_tool_use.py +689 -0
- package/core/hooks/pre_tool_use.py +267 -0
- package/core/hooks/stop.py +395 -0
- package/core/hooks/user_prompt_submit.py +600 -0
- package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
- package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/knowledge/embedder.py +118 -11
- package/core/knowledge/vector_store.py +111 -14
- package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
- package/core/runtime/base.py +29 -0
- package/core/runtime/capabilities_cli.py +74 -0
- package/core/runtime/claude_code.py +8 -0
- package/core/runtime/codex_cli.py +205 -37
- package/core/runtime/cost_governor.py +184 -0
- package/core/runtime/cursor.py +8 -0
- package/core/runtime/gemini_cli.py +8 -0
- package/core/runtime/llm_provider.py +32 -13
- package/core/runtime/llm_retry.py +233 -0
- package/core/runtime/native_usage.py +186 -0
- package/core/runtime/pricing.py +9 -0
- package/core/runtime/registry.py +3 -0
- package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
- package/core/shared/test_evidence.py +34 -0
- package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +7 -0
- package/core/synapse/graph_context_layer.py +212 -0
- package/core/synapse/kb_cache.py +3 -0
- package/core/synapse/layers.py +104 -20
- package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/enforcer.py +5 -16
- package/core/workflow/flow_enforcer.py +36 -11
- package/core/workflow/gate_checkpoint.py +149 -0
- package/core/workflow/research_gate.py +15 -3
- package/core/workflow/rules_registry.py +137 -389
- package/core/workflow/specialist_enforcer.py +21 -4
- package/dashboard/app/pages/knowledge/index.vue +4 -1
- package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
- package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
- package/departments/quality/SKILL.md +45 -5
- package/departments/quality/agents/copy-director.yaml +3 -1
- package/departments/quality/agents/tech-director.yaml +3 -1
- package/installer/adapters/claude-code.js +46 -2
- package/installer/config-seed.js +39 -14
- package/installer/doctor.js +8 -0
- package/installer/graphify.js +153 -0
- package/installer/index.js +20 -0
- package/installer/init.js +11 -0
- package/installer/update.js +19 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/knowledge-index.py +8 -2
- package/scripts/synapse-bridge.py +102 -26
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
"""Codex CLI runtime adapter.
|
|
2
2
|
|
|
3
3
|
OpenAI's Codex CLI. Supports sandboxed execution and file operations.
|
|
4
|
-
More limited than Claude Code: no native hooks, no
|
|
4
|
+
More limited than Claude Code: no native hooks, no ArkaOS agent dispatch.
|
|
5
|
+
|
|
6
|
+
Headless invocation (verified against the installed binary,
|
|
7
|
+
codex-cli 0.142.5, `codex exec --help`, 2026-07-04):
|
|
8
|
+
|
|
9
|
+
codex exec --json --skip-git-repo-check --sandbox read-only "<prompt>"
|
|
10
|
+
|
|
11
|
+
``--json`` prints events to stdout as JSONL. Live-verified event shapes:
|
|
12
|
+
|
|
13
|
+
{"type":"item.completed","item":{"type":"agent_message","text":"..."}}
|
|
14
|
+
{"type":"turn.completed","usage":{"input_tokens":N,
|
|
15
|
+
"cached_input_tokens":N,"output_tokens":N,
|
|
16
|
+
"reasoning_output_tokens":N}}
|
|
17
|
+
|
|
18
|
+
Older Codex CLI builds emitted ``{"msg":{"type":"agent_message",
|
|
19
|
+
"message":"..."}}`` — parsed defensively too. When no JSONL parses at
|
|
20
|
+
all, stdout is treated as raw text with a ``len(text) // 4`` token
|
|
21
|
+
estimate (same fallback as the Gemini adapter). stdin is closed
|
|
22
|
+
explicitly: with a prompt argument AND piped stdin, ``codex exec``
|
|
23
|
+
appends stdin as a ``<stdin>`` block, which a hook/daemon caller must
|
|
24
|
+
never trigger.
|
|
5
25
|
"""
|
|
6
26
|
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
7
30
|
import shutil
|
|
31
|
+
import subprocess
|
|
8
32
|
from pathlib import Path
|
|
9
33
|
from os.path import expanduser
|
|
10
34
|
from typing import TYPE_CHECKING
|
|
@@ -15,6 +39,14 @@ if TYPE_CHECKING:
|
|
|
15
39
|
from core.runtime.llm_provider import LLMResponse
|
|
16
40
|
|
|
17
41
|
|
|
42
|
+
# codex exec runs a full agent turn (reasoning + sandbox bootstrap) — the
|
|
43
|
+
# live-verified trivial prompt took ~15s, so 60s (the Gemini budget) is
|
|
44
|
+
# too tight a ceiling for real cognitive-layer prompts.
|
|
45
|
+
_TIMEOUT_SECONDS = 120
|
|
46
|
+
_TOKEN_ESTIMATE_DIVISOR = 4 # Rough chars-per-token heuristic.
|
|
47
|
+
_STDERR_CLIP = 200
|
|
48
|
+
|
|
49
|
+
|
|
18
50
|
class CodexCliAdapter(RuntimeAdapter):
|
|
19
51
|
"""Adapter for OpenAI's Codex CLI."""
|
|
20
52
|
|
|
@@ -32,6 +64,14 @@ class CodexCliAdapter(RuntimeAdapter):
|
|
|
32
64
|
max_context_tokens=200_000,
|
|
33
65
|
)
|
|
34
66
|
|
|
67
|
+
def capabilities(self) -> dict[str, bool]:
|
|
68
|
+
return {
|
|
69
|
+
"agent_dispatch": False, # descriptor only — no ArkaOS agents
|
|
70
|
+
"headless": True, # codex exec --json (real, PR-6)
|
|
71
|
+
"file_ops": True, # native, sandboxed, interactive use
|
|
72
|
+
"hooks": False,
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
def inject_context(self, layers: dict[str, str]) -> str:
|
|
36
76
|
"""Codex receives context via AGENTS.md instruction file."""
|
|
37
77
|
parts = []
|
|
@@ -76,15 +116,6 @@ class CodexCliAdapter(RuntimeAdapter):
|
|
|
76
116
|
raise NotImplementedError("Use Codex CLI's native content search")
|
|
77
117
|
|
|
78
118
|
def headless_supported(self) -> bool:
|
|
79
|
-
# Auto-detect: headless is supported iff the `codex` binary is
|
|
80
|
-
# on PATH. When the operator installs Codex CLI later, this
|
|
81
|
-
# lights up without any code change (the headless_complete()
|
|
82
|
-
# method below already gates on shutil.which() too, so a missing
|
|
83
|
-
# binary will raise cleanly).
|
|
84
|
-
#
|
|
85
|
-
# Note: even when the binary is present, headless_complete()
|
|
86
|
-
# still raises until the invocation syntax is verified locally.
|
|
87
|
-
# See TODO(llm-agnostic) below for the verification checklist.
|
|
88
119
|
return shutil.which("codex") is not None
|
|
89
120
|
|
|
90
121
|
def headless_complete(
|
|
@@ -94,37 +125,174 @@ class CodexCliAdapter(RuntimeAdapter):
|
|
|
94
125
|
max_tokens: int = 2000,
|
|
95
126
|
system: str = "",
|
|
96
127
|
) -> "LLMResponse":
|
|
128
|
+
"""One-shot completion via `codex exec --json`.
|
|
129
|
+
|
|
130
|
+
Verified against Codex CLI 0.142.5 (`codex exec --help` + a live
|
|
131
|
+
JSONL probe) on 2026-07-04. Sandbox is read-only and the git-repo
|
|
132
|
+
check is skipped so the call works from any cwd. Raises
|
|
133
|
+
LLMUnavailable on non-zero exit, timeout, or error-only streams.
|
|
134
|
+
"""
|
|
135
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
136
|
+
|
|
97
137
|
binary = shutil.which("codex")
|
|
98
138
|
if binary is None:
|
|
99
139
|
raise NotImplementedError(
|
|
100
140
|
"codex CLI not found on PATH — install Codex CLI to "
|
|
101
141
|
"enable headless completion."
|
|
102
142
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
143
|
+
effective_prompt = _merge_system_prompt(prompt, system)
|
|
144
|
+
cmd = [
|
|
145
|
+
binary, "exec", "--json", "--skip-git-repo-check",
|
|
146
|
+
"--sandbox", "read-only", effective_prompt,
|
|
147
|
+
]
|
|
148
|
+
proc = _run_codex_cli(cmd)
|
|
149
|
+
if proc.returncode != 0:
|
|
150
|
+
stderr_tail = proc.stderr.strip()[:_STDERR_CLIP]
|
|
151
|
+
raise LLMUnavailable(
|
|
152
|
+
f"codex CLI exited {proc.returncode}: {stderr_tail}"
|
|
153
|
+
)
|
|
154
|
+
return _parse_codex_cli_output(proc.stdout)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _merge_system_prompt(prompt: str, system: str) -> str:
|
|
158
|
+
# codex exec takes a single prompt argument; prepend the system text
|
|
159
|
+
# when provided so downstream behaviour matches the other adapters.
|
|
160
|
+
if not system:
|
|
161
|
+
return prompt
|
|
162
|
+
return f"{system}\n\n---\n\n{prompt}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _run_codex_cli(cmd: list[str]) -> subprocess.CompletedProcess:
|
|
166
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return subprocess.run(
|
|
170
|
+
cmd,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
timeout=_TIMEOUT_SECONDS,
|
|
174
|
+
check=False,
|
|
175
|
+
# CRITICAL: with a prompt arg AND piped stdin, codex exec
|
|
176
|
+
# appends stdin as a <stdin> block — close it explicitly.
|
|
177
|
+
stdin=subprocess.DEVNULL,
|
|
130
178
|
)
|
|
179
|
+
except subprocess.TimeoutExpired as exc:
|
|
180
|
+
raise LLMUnavailable(
|
|
181
|
+
f"codex CLI timed out after {_TIMEOUT_SECONDS}s"
|
|
182
|
+
) from exc
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
raise LLMUnavailable(f"codex CLI subprocess failed: {exc}") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _agent_message_from_event(event: dict) -> str | None:
|
|
188
|
+
"""Extract agent message text from a JSONL event (both shapes)."""
|
|
189
|
+
item = event.get("item")
|
|
190
|
+
if isinstance(item, dict) and item.get("type") == "agent_message":
|
|
191
|
+
return str(item.get("text") or "")
|
|
192
|
+
msg = event.get("msg")
|
|
193
|
+
if isinstance(msg, dict) and msg.get("type") == "agent_message":
|
|
194
|
+
return str(msg.get("message") or "")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _usage_from_event(event: dict) -> dict | None:
|
|
199
|
+
"""Extract the usage block from a turn.completed / token_count event."""
|
|
200
|
+
if event.get("type") == "turn.completed":
|
|
201
|
+
usage = event.get("usage")
|
|
202
|
+
if isinstance(usage, dict):
|
|
203
|
+
return usage
|
|
204
|
+
msg = event.get("msg")
|
|
205
|
+
if isinstance(msg, dict) and msg.get("type") == "token_count":
|
|
206
|
+
info = msg.get("info")
|
|
207
|
+
if isinstance(info, dict):
|
|
208
|
+
total = info.get("total_token_usage")
|
|
209
|
+
if isinstance(total, dict):
|
|
210
|
+
return total
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _error_from_event(event: dict) -> str | None:
|
|
215
|
+
if event.get("type") == "error":
|
|
216
|
+
return str(event.get("message") or "codex CLI error")
|
|
217
|
+
item = event.get("item")
|
|
218
|
+
if isinstance(item, dict) and item.get("type") == "error":
|
|
219
|
+
return str(item.get("message") or "codex CLI error")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_codex_cli_output(stdout: str) -> "LLMResponse":
|
|
224
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
225
|
+
|
|
226
|
+
stripped = stdout.strip()
|
|
227
|
+
if not stripped:
|
|
228
|
+
return LLMResponse(
|
|
229
|
+
text="", tokens_in=0, tokens_out=0, cached_tokens=0, model=""
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
text: str | None = None
|
|
233
|
+
usage: dict | None = None
|
|
234
|
+
error: str | None = None
|
|
235
|
+
parsed_any = False
|
|
236
|
+
for line in stripped.splitlines():
|
|
237
|
+
line = line.strip()
|
|
238
|
+
if not line:
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
event = json.loads(line)
|
|
242
|
+
except (json.JSONDecodeError, ValueError):
|
|
243
|
+
continue
|
|
244
|
+
if not isinstance(event, dict):
|
|
245
|
+
continue
|
|
246
|
+
parsed_any = True
|
|
247
|
+
message = _agent_message_from_event(event)
|
|
248
|
+
if message is not None:
|
|
249
|
+
text = message # last agent message wins
|
|
250
|
+
found_usage = _usage_from_event(event)
|
|
251
|
+
if found_usage is not None:
|
|
252
|
+
usage = found_usage
|
|
253
|
+
found_error = _error_from_event(event)
|
|
254
|
+
if found_error is not None:
|
|
255
|
+
error = found_error
|
|
256
|
+
|
|
257
|
+
if not parsed_any:
|
|
258
|
+
# Non-JSONL fallback: treat stdout as raw text, estimate tokens.
|
|
259
|
+
return _response_from_plain_text(stripped)
|
|
260
|
+
if text is None:
|
|
261
|
+
# Error items can coexist with a successful agent message (e.g.
|
|
262
|
+
# skills-budget warnings) — only fail when NO message arrived.
|
|
263
|
+
raise LLMUnavailable(
|
|
264
|
+
f"codex CLI returned no agent message"
|
|
265
|
+
f"{': ' + error[:_STDERR_CLIP] if error else ''}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
tokens_in, tokens_out, cached = _token_counts(usage, text)
|
|
269
|
+
return LLMResponse(
|
|
270
|
+
text=text,
|
|
271
|
+
tokens_in=tokens_in,
|
|
272
|
+
tokens_out=tokens_out,
|
|
273
|
+
cached_tokens=cached,
|
|
274
|
+
model="", # the JSONL stream does not report the model id
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _response_from_plain_text(text: str) -> "LLMResponse":
|
|
279
|
+
from core.runtime.llm_provider import LLMResponse
|
|
280
|
+
|
|
281
|
+
estimate = max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR)
|
|
282
|
+
return LLMResponse(
|
|
283
|
+
text=text, tokens_in=0, tokens_out=estimate, cached_tokens=0, model=""
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _token_counts(usage: dict | None, text: str) -> tuple[int, int, int]:
|
|
288
|
+
if not isinstance(usage, dict):
|
|
289
|
+
return 0, max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR), 0
|
|
290
|
+
try:
|
|
291
|
+
tokens_in = int(usage.get("input_tokens") or 0)
|
|
292
|
+
tokens_out = int(usage.get("output_tokens") or 0)
|
|
293
|
+
cached = int(usage.get("cached_input_tokens") or 0)
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return 0, max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR), 0
|
|
296
|
+
if tokens_in == 0 and tokens_out == 0:
|
|
297
|
+
return 0, max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR), cached
|
|
298
|
+
return tokens_in, tokens_out, cached
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""CostGovernor — enforceable, opt-in budget caps over LLM cost telemetry.
|
|
2
|
+
|
|
3
|
+
Extends ADR-011 ("token budgets are informational, not restrictive")
|
|
4
|
+
WITHOUT revoking it: when no cap is configured in `~/.arkaos/config.json`
|
|
5
|
+
the governor always allows (advisory stance preserved). Operators opt in
|
|
6
|
+
via:
|
|
7
|
+
|
|
8
|
+
{"budget": {"hardCapUsd": 20.0, "dailyCapUsd": 50.0, "hardDeny": false}}
|
|
9
|
+
|
|
10
|
+
- `budget.hardCapUsd` — per-session cap (matched on `session_id`)
|
|
11
|
+
- `budget.dailyCapUsd` — cap over today's total spend
|
|
12
|
+
- `budget.hardDeny` — when true, an exceeded cap DENIES (hook exit 2);
|
|
13
|
+
default false = WARN-only (never blocks)
|
|
14
|
+
|
|
15
|
+
Spend is read from `core.runtime.llm_cost_telemetry`: daily totals via
|
|
16
|
+
`summarise(period="today")`, session totals by summing that session's
|
|
17
|
+
rows (the summary's `by_session` list is top-10-capped, so a direct sum
|
|
18
|
+
is the correct per-session aggregate). Missing telemetry, missing config
|
|
19
|
+
or unparsable values NEVER block — the governor fails open.
|
|
20
|
+
|
|
21
|
+
CLI: python -m core.runtime.cost_governor <session_id> [--json]
|
|
22
|
+
Exit: 0 allow / 3 deny.
|
|
23
|
+
|
|
24
|
+
ADR: docs/adr/2026-07-04-cost-governor.md
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from core.runtime.llm_cost_telemetry import read_entries, summarise
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class GovernorDecision:
|
|
43
|
+
"""Outcome of one budget check."""
|
|
44
|
+
|
|
45
|
+
allow: bool
|
|
46
|
+
reason: str
|
|
47
|
+
spent_usd: float
|
|
48
|
+
cap_usd: float | None
|
|
49
|
+
hard_deny: bool = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def exceeded(self) -> bool:
|
|
53
|
+
return self.reason.endswith("cap-exceeded")
|
|
54
|
+
|
|
55
|
+
def to_warning(self) -> str:
|
|
56
|
+
"""WARN string for hook additionalContext/stderr; "" when under cap."""
|
|
57
|
+
if not self.exceeded or self.cap_usd is None:
|
|
58
|
+
return ""
|
|
59
|
+
return (
|
|
60
|
+
f"[arka:warn] budget cap exceeded "
|
|
61
|
+
f"(${self.spent_usd:.2f} of ${self.cap_usd:.2f}) — {self.reason}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ─── Config ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_budget_config(config_path: Path | None) -> dict[str, Any]:
|
|
69
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except (OSError, json.JSONDecodeError):
|
|
73
|
+
return {}
|
|
74
|
+
budget = data.get("budget")
|
|
75
|
+
return budget if isinstance(budget, dict) else {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _as_cap(raw: Any) -> float | None:
|
|
79
|
+
"""Parse a cap value; absent/null/non-positive/invalid → no cap."""
|
|
80
|
+
if raw is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
value = float(raw)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
return value if value > 0 else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ─── Spend aggregation (fails open) ───────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _session_spend(session_id: str, telemetry_path: Path | None) -> float:
|
|
93
|
+
total = 0.0
|
|
94
|
+
for entry in read_entries(telemetry_path):
|
|
95
|
+
if str(entry.get("session_id") or "") != session_id:
|
|
96
|
+
continue
|
|
97
|
+
cost = entry.get("estimated_cost_usd")
|
|
98
|
+
if cost is None:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
total += float(cost)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
continue
|
|
104
|
+
return total
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _today_spend(telemetry_path: Path | None) -> float:
|
|
108
|
+
summary = summarise(period="today", path=telemetry_path)
|
|
109
|
+
return float(summary.total_cost_usd or 0.0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ─── Public check ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check(
|
|
116
|
+
session_id: str,
|
|
117
|
+
config_path: Path | None = None,
|
|
118
|
+
telemetry_path: Path | None = None,
|
|
119
|
+
) -> GovernorDecision:
|
|
120
|
+
"""Evaluate the configured caps against recorded spend.
|
|
121
|
+
|
|
122
|
+
No caps configured → allow ("no-cap"). Telemetry unreadable →
|
|
123
|
+
allow ("telemetry-unavailable") — never block on missing data.
|
|
124
|
+
Exceeded cap → allow with warning unless `budget.hardDeny` is true.
|
|
125
|
+
"""
|
|
126
|
+
budget = _read_budget_config(config_path)
|
|
127
|
+
session_cap = _as_cap(budget.get("hardCapUsd"))
|
|
128
|
+
daily_cap = _as_cap(budget.get("dailyCapUsd"))
|
|
129
|
+
hard_deny = bool(budget.get("hardDeny", False))
|
|
130
|
+
if session_cap is None and daily_cap is None:
|
|
131
|
+
return GovernorDecision(True, "no-cap", 0.0, None)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
session_spent = _session_spend(str(session_id or ""), telemetry_path)
|
|
135
|
+
today_spent = _today_spend(telemetry_path)
|
|
136
|
+
except Exception: # noqa: BLE001 — fail open, per ADR
|
|
137
|
+
return GovernorDecision(True, "telemetry-unavailable", 0.0, None)
|
|
138
|
+
|
|
139
|
+
if session_cap is not None and session_spent > session_cap:
|
|
140
|
+
return GovernorDecision(
|
|
141
|
+
allow=not hard_deny, reason="session-cap-exceeded",
|
|
142
|
+
spent_usd=round(session_spent, 6), cap_usd=session_cap,
|
|
143
|
+
hard_deny=hard_deny,
|
|
144
|
+
)
|
|
145
|
+
if daily_cap is not None and today_spent > daily_cap:
|
|
146
|
+
return GovernorDecision(
|
|
147
|
+
allow=not hard_deny, reason="daily-cap-exceeded",
|
|
148
|
+
spent_usd=round(today_spent, 6), cap_usd=daily_cap,
|
|
149
|
+
hard_deny=hard_deny,
|
|
150
|
+
)
|
|
151
|
+
return GovernorDecision(
|
|
152
|
+
allow=True, reason="under-cap",
|
|
153
|
+
spent_usd=round(session_spent, 6),
|
|
154
|
+
cap_usd=session_cap if session_cap is not None else daily_cap,
|
|
155
|
+
hard_deny=hard_deny,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ─── CLI ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> int:
|
|
163
|
+
parser = argparse.ArgumentParser(
|
|
164
|
+
prog="python -m core.runtime.cost_governor",
|
|
165
|
+
description="Check session/daily budget caps. Exit 0 allow, 3 deny.",
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument("session_id", help="session id to check")
|
|
168
|
+
parser.add_argument("--json", action="store_true", dest="as_json")
|
|
169
|
+
args = parser.parse_args(argv)
|
|
170
|
+
decision = check(args.session_id)
|
|
171
|
+
if args.as_json:
|
|
172
|
+
print(json.dumps(asdict(decision)))
|
|
173
|
+
else:
|
|
174
|
+
cap = f"${decision.cap_usd:.2f}" if decision.cap_usd is not None else "none"
|
|
175
|
+
verdict = "ALLOW" if decision.allow else "DENY"
|
|
176
|
+
print(
|
|
177
|
+
f"{verdict} {decision.reason} "
|
|
178
|
+
f"(spent ${decision.spent_usd:.2f}, cap {cap})"
|
|
179
|
+
)
|
|
180
|
+
return 0 if decision.allow else 3
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__": # pragma: no cover — thin CLI shim
|
|
184
|
+
raise SystemExit(main())
|
package/core/runtime/cursor.py
CHANGED
|
@@ -31,6 +31,14 @@ class CursorAdapter(RuntimeAdapter):
|
|
|
31
31
|
max_context_tokens=200_000,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
def capabilities(self) -> dict[str, bool]:
|
|
35
|
+
return {
|
|
36
|
+
"agent_dispatch": False, # single-agent mode only
|
|
37
|
+
"headless": False, # no headless CLI as of 2026-07
|
|
38
|
+
"file_ops": True, # IDE agent mode edits files
|
|
39
|
+
"hooks": False, # no PreToolUse/Stop equivalent
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
def inject_context(self, layers: dict[str, str]) -> str:
|
|
35
43
|
"""Cursor receives context via .cursorrules files."""
|
|
36
44
|
parts = []
|
|
@@ -53,6 +53,14 @@ class GeminiCliAdapter(RuntimeAdapter):
|
|
|
53
53
|
max_context_tokens=1_000_000,
|
|
54
54
|
)
|
|
55
55
|
|
|
56
|
+
def capabilities(self) -> dict[str, bool]:
|
|
57
|
+
return {
|
|
58
|
+
"agent_dispatch": False, # descriptor only — no ArkaOS agents
|
|
59
|
+
"headless": True, # gemini -p --output-format json
|
|
60
|
+
"file_ops": True, # native, interactive use
|
|
61
|
+
"hooks": False,
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
def inject_context(self, layers: dict[str, str]) -> str:
|
|
57
65
|
"""Gemini receives context via GEMINI.md instruction file."""
|
|
58
66
|
parts = []
|
|
@@ -27,6 +27,7 @@ from typing import Protocol, runtime_checkable
|
|
|
27
27
|
from core.runtime import registry
|
|
28
28
|
from core.runtime.base import RuntimeAdapter
|
|
29
29
|
from core.runtime.llm_cost_telemetry import record_cost
|
|
30
|
+
from core.runtime.llm_retry import retry_completion
|
|
30
31
|
from core.runtime.pricing import estimate_cost_usd
|
|
31
32
|
|
|
32
33
|
|
|
@@ -116,18 +117,27 @@ class SubagentProvider:
|
|
|
116
117
|
system: str = "",
|
|
117
118
|
) -> LLMResponse:
|
|
118
119
|
adapter = self._resolve_adapter()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
|
|
121
|
+
def _attempt() -> LLMResponse:
|
|
122
|
+
try:
|
|
123
|
+
return adapter.headless_complete(
|
|
124
|
+
prompt, max_tokens=max_tokens, system=system
|
|
125
|
+
)
|
|
126
|
+
except NotImplementedError as exc:
|
|
127
|
+
raise LLMUnavailable(str(exc)) from exc
|
|
128
|
+
except LLMUnavailable:
|
|
129
|
+
raise
|
|
130
|
+
except Exception as exc: # noqa: BLE001
|
|
131
|
+
raise LLMUnavailable(
|
|
132
|
+
f"headless_complete failed: {exc.__class__.__name__}: {exc}"
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
# Rate-limit-looking CLI failures (non-zero exit with 429/rate
|
|
136
|
+
# limit/overloaded stderr) surface as LLMUnavailable messages the
|
|
137
|
+
# default classifier recognises as retryable; everything else
|
|
138
|
+
# fails fast. Exhaustion re-raises LLMUnavailable (attempt count
|
|
139
|
+
# + last error) preserving the fallback-chain semantics.
|
|
140
|
+
response = retry_completion(_attempt, provider=self.name())
|
|
131
141
|
|
|
132
142
|
_record(
|
|
133
143
|
session_id=os.environ.get("ARKA_SESSION_ID", ""),
|
|
@@ -227,8 +237,17 @@ class AnthropicDirectProvider:
|
|
|
227
237
|
)
|
|
228
238
|
client = self._build_client()
|
|
229
239
|
payload = self._build_anthropic_payload(prompt, system, max_tokens, model)
|
|
240
|
+
# Retry the raw SDK call (not a pre-wrapped LLMUnavailable) so the
|
|
241
|
+
# classifier can inspect status_code / response.headers for 429 +
|
|
242
|
+
# retry-after. Non-retryable SDK errors convert to LLMUnavailable
|
|
243
|
+
# below; exhaustion already raises LLMUnavailable inside the layer.
|
|
230
244
|
try:
|
|
231
|
-
raw =
|
|
245
|
+
raw = retry_completion(
|
|
246
|
+
lambda: client.messages.create(**payload), # type: ignore[attr-defined]
|
|
247
|
+
provider=self.name(),
|
|
248
|
+
)
|
|
249
|
+
except LLMUnavailable:
|
|
250
|
+
raise
|
|
232
251
|
except Exception as exc: # noqa: BLE001
|
|
233
252
|
raise LLMUnavailable(
|
|
234
253
|
f"anthropic.messages.create failed: {exc.__class__.__name__}: {exc}"
|