arkaos 4.0.2 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +2 -2
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +16 -22
  4. package/arka/skills/flow/SKILL.md +108 -234
  5. package/config/claude-agents/eduardo-copy.md +53 -0
  6. package/config/claude-agents/francisca-tech.md +52 -0
  7. package/config/claude-agents/marta-cqo.md +52 -0
  8. package/config/claude-agents/paulo-tech-lead.md +50 -0
  9. package/config/cognition/schedules.yaml +15 -0
  10. package/config/constitution.yaml +17 -21
  11. package/config/hooks/_lib/workflow-classifier.sh +1 -1
  12. package/config/hooks/post-tool-use.sh +52 -536
  13. package/config/hooks/pre-tool-use.sh +46 -280
  14. package/config/hooks/session-start.sh +8 -10
  15. package/config/hooks/stop.sh +44 -424
  16. package/config/hooks/user-prompt-submit.sh +55 -457
  17. package/core/__pycache__/__init__.cpython-314.pyc +0 -0
  18. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  19. package/core/cognition/dreaming.py +5 -0
  20. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  21. package/core/cognition/scheduler/daemon.py +10 -0
  22. package/core/forge/__init__.py +2 -0
  23. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  25. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  26. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  27. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  28. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  29. package/core/forge/orchestrator.py +50 -27
  30. package/core/forge/persistence.py +16 -0
  31. package/core/forge/renderer.py +16 -0
  32. package/core/forge/runtime_dispatcher.py +34 -19
  33. package/core/forge/schema.py +12 -0
  34. package/core/governance/__pycache__/__init__.cpython-312.pyc +0 -0
  35. package/core/governance/__pycache__/__init__.cpython-314.pyc +0 -0
  36. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  37. package/core/governance/__pycache__/closing_marker_check.cpython-314.pyc +0 -0
  38. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  39. package/core/governance/__pycache__/constitution.cpython-312.pyc +0 -0
  40. package/core/governance/__pycache__/constitution.cpython-314.pyc +0 -0
  41. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  42. package/core/governance/__pycache__/kb_cite_check.cpython-314.pyc +0 -0
  43. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  44. package/core/governance/__pycache__/meta_tag_check.cpython-314.pyc +0 -0
  45. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  46. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  47. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  48. package/core/governance/__pycache__/skill_proposer.cpython-314.pyc +0 -0
  49. package/core/governance/__pycache__/sycophancy_detector.cpython-314.pyc +0 -0
  50. package/core/governance/closing_marker_check.py +9 -5
  51. package/core/governance/compliance_telemetry.py +1 -1
  52. package/core/governance/compliance_telemetry_cli.py +1 -1
  53. package/core/governance/evidence_checks.py +421 -0
  54. package/core/governance/meta_tag_check.py +36 -0
  55. package/core/governance/qg_verdict.py +78 -0
  56. package/core/governance/review_workflow.py +34 -1
  57. package/core/governance/skill_proposer.py +2 -1
  58. package/core/hooks/__init__.py +12 -0
  59. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  60. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  61. package/core/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  62. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  63. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  64. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  65. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  66. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  67. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  68. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  69. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  70. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  71. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  72. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  73. package/core/hooks/__pycache__/user_prompt_submit.cpython-312.pyc +0 -0
  74. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  75. package/core/hooks/_shared.py +110 -0
  76. package/core/hooks/post_tool_use.py +689 -0
  77. package/core/hooks/pre_tool_use.py +267 -0
  78. package/core/hooks/stop.py +395 -0
  79. package/core/hooks/user_prompt_submit.py +600 -0
  80. package/core/jobs/__pycache__/__init__.cpython-312.pyc +0 -0
  81. package/core/jobs/__pycache__/__init__.cpython-314.pyc +0 -0
  82. package/core/jobs/__pycache__/auto_doc_worker.cpython-312.pyc +0 -0
  83. package/core/jobs/__pycache__/auto_doc_worker.cpython-314.pyc +0 -0
  84. package/core/jobs/__pycache__/manager.cpython-312.pyc +0 -0
  85. package/core/jobs/__pycache__/manager.cpython-314.pyc +0 -0
  86. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  87. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  88. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  89. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  90. package/core/knowledge/embedder.py +118 -11
  91. package/core/knowledge/vector_store.py +111 -14
  92. package/core/memory/__pycache__/session_store.cpython-314.pyc +0 -0
  93. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  94. package/core/obsidian/relator.py +18 -8
  95. package/core/runtime/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/__init__.cpython-314.pyc +0 -0
  98. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  99. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  100. package/core/runtime/__pycache__/base.cpython-314.pyc +0 -0
  101. package/core/runtime/__pycache__/capabilities_cli.cpython-312.pyc +0 -0
  102. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  103. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  104. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  105. package/core/runtime/__pycache__/claude_code.cpython-314.pyc +0 -0
  106. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  107. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  108. package/core/runtime/__pycache__/codex_cli.cpython-314.pyc +0 -0
  109. package/core/runtime/__pycache__/cost_governor.cpython-312.pyc +0 -0
  110. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  111. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  112. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  113. package/core/runtime/__pycache__/cursor.cpython-314.pyc +0 -0
  114. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  115. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  116. package/core/runtime/__pycache__/gemini_cli.cpython-314.pyc +0 -0
  117. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-314.pyc +0 -0
  118. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  119. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  120. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  121. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  122. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  123. package/core/runtime/__pycache__/llm_retry.cpython-314.pyc +0 -0
  124. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  125. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  126. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  127. package/core/runtime/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  128. package/core/runtime/__pycache__/path_resolver.cpython-312.pyc +0 -0
  129. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  130. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  131. package/core/runtime/__pycache__/pricing.cpython-314.pyc +0 -0
  132. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  133. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  134. package/core/runtime/__pycache__/registry.cpython-314.pyc +0 -0
  135. package/core/runtime/__pycache__/squad_orchestrator.cpython-312.pyc +0 -0
  136. package/core/runtime/base.py +29 -0
  137. package/core/runtime/capabilities_cli.py +74 -0
  138. package/core/runtime/claude_code.py +8 -0
  139. package/core/runtime/codex_cli.py +205 -37
  140. package/core/runtime/cost_governor.py +184 -0
  141. package/core/runtime/cursor.py +8 -0
  142. package/core/runtime/gemini_cli.py +8 -0
  143. package/core/runtime/llm_provider.py +32 -13
  144. package/core/runtime/llm_retry.py +233 -0
  145. package/core/runtime/native_usage.py +186 -0
  146. package/core/runtime/pricing.py +9 -0
  147. package/core/runtime/registry.py +3 -0
  148. package/core/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  149. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  150. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  152. package/core/shared/test_evidence.py +34 -0
  153. package/core/synapse/__pycache__/__init__.cpython-314.pyc +0 -0
  154. package/core/synapse/__pycache__/agent_experiences_layer.cpython-312.pyc +0 -0
  155. package/core/synapse/__pycache__/cache.cpython-314.pyc +0 -0
  156. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  157. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  158. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  159. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  160. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  161. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  162. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  163. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  164. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  165. package/core/synapse/__pycache__/layers.cpython-314.pyc +0 -0
  166. package/core/synapse/engine.py +7 -0
  167. package/core/synapse/graph_context_layer.py +212 -0
  168. package/core/synapse/kb_cache.py +3 -0
  169. package/core/synapse/layers.py +104 -20
  170. package/core/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  171. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  172. package/core/workflow/__pycache__/engine.cpython-314.pyc +0 -0
  173. package/core/workflow/__pycache__/flow_enforcer.cpython-312.pyc +0 -0
  174. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  175. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  176. package/core/workflow/__pycache__/gate_checkpoint.cpython-312.pyc +0 -0
  177. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  178. package/core/workflow/__pycache__/gate_checkpoint.cpython-314.pyc +0 -0
  179. package/core/workflow/__pycache__/loader.cpython-314.pyc +0 -0
  180. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  181. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  182. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  183. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  184. package/core/workflow/__pycache__/schema.cpython-314.pyc +0 -0
  185. package/core/workflow/__pycache__/specialist_enforcer.cpython-312.pyc +0 -0
  186. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  187. package/core/workflow/__pycache__/state.cpython-314.pyc +0 -0
  188. package/core/workflow/enforcer.py +5 -16
  189. package/core/workflow/flow_enforcer.py +36 -11
  190. package/core/workflow/gate_checkpoint.py +149 -0
  191. package/core/workflow/research_gate.py +15 -3
  192. package/core/workflow/rules_registry.py +137 -389
  193. package/core/workflow/specialist_enforcer.py +21 -4
  194. package/dashboard/app/pages/knowledge/index.vue +4 -1
  195. package/departments/ops/skills/n8n-flow/SKILL.md +13 -0
  196. package/departments/ops/skills/workflow-automate/SKILL.md +16 -0
  197. package/departments/quality/SKILL.md +45 -5
  198. package/departments/quality/agents/copy-director.yaml +3 -1
  199. package/departments/quality/agents/tech-director.yaml +3 -1
  200. package/installer/adapters/claude-code.js +46 -2
  201. package/installer/config-seed.js +39 -14
  202. package/installer/doctor.js +8 -0
  203. package/installer/graphify.js +153 -0
  204. package/installer/index.js +20 -0
  205. package/installer/init.js +11 -0
  206. package/installer/update.js +19 -0
  207. package/package.json +1 -1
  208. package/pyproject.toml +3 -1
  209. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  210. package/scripts/__pycache__/knowledge-index.cpython-313.pyc +0 -0
  211. package/scripts/__pycache__/synapse-bridge.cpython-312.pyc +0 -0
  212. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  213. package/scripts/knowledge-index.py +8 -2
  214. package/scripts/synapse-bridge.py +102 -26
@@ -34,6 +34,14 @@ class ClaudeCodeAdapter(RuntimeAdapter):
34
34
  max_context_tokens=1_000_000,
35
35
  )
36
36
 
37
+ def capabilities(self) -> dict[str, bool]:
38
+ return {
39
+ "agent_dispatch": True, # Agent tool — real subagent dispatch
40
+ "headless": True, # claude -p
41
+ "file_ops": True, # native Read/Write/Edit tools
42
+ "hooks": True, # 5 lifecycle hooks
43
+ }
44
+
37
45
  def inject_context(self, layers: dict[str, str]) -> str:
38
46
  """Claude Code receives context via UserPromptSubmit hook.
39
47
 
@@ -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 MCP servers.
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
- # TODO(llm-agnostic): Implement real headless completion.
104
- #
105
- # Status as of 2026-05-25 (PR60): Codex CLI still not verified
106
- # in any ArkaOS dev environment. headless_supported() now
107
- # auto-detects the binary on PATH so this lights up the moment
108
- # someone installs it — but the actual subprocess call below
109
- # still needs syntax verification before we can stop refusing.
110
- #
111
- # Verification checklist for whoever picks this up:
112
- # 1. Install: npm install -g @openai/codex-cli
113
- # 2. Discover: codex --help (confirm non-interactive flag)
114
- # 3. Pattern: likely `codex exec "<prompt>"` or
115
- # `codex --prompt "<prompt>" --format json`
116
- # 4. Wire the subprocess call (mirror the Gemini adapter —
117
- # list-form args, 60s timeout, stderr clipped, JSON parse
118
- # with plain-text fallback, token estimate on miss).
119
- #
120
- # SubagentProvider cleanly falls back to anthropic-direct or
121
- # stub when this raises, so the chain keeps working.
122
- raise NotImplementedError(
123
- "Codex CLI headless mode requires verified invocation syntax. "
124
- "The `codex` binary is on PATH but ArkaOS has not validated "
125
- "the non-interactive call shape locally. "
126
- "Verification steps: `codex --help`, then update "
127
- "core/runtime/codex_cli.py::headless_complete to call the "
128
- "discovered subprocess shape. "
129
- "SubagentProvider will cleanly fall back to anthropic-direct or stub."
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())
@@ -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
- try:
120
- response = adapter.headless_complete(
121
- prompt, max_tokens=max_tokens, system=system
122
- )
123
- except NotImplementedError as exc:
124
- raise LLMUnavailable(str(exc)) from exc
125
- except LLMUnavailable:
126
- raise
127
- except Exception as exc: # noqa: BLE001
128
- raise LLMUnavailable(
129
- f"headless_complete failed: {exc.__class__.__name__}: {exc}"
130
- ) from exc
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 = client.messages.create(**payload) # type: ignore[attr-defined]
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}"