arkaos 4.3.5 → 4.4.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 (113) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +1 -1
  3. package/bin/arka-claude +35 -4
  4. package/bin/arka-py +22 -7
  5. package/config/hooks/_lib/arka_python.ps1 +31 -0
  6. package/config/hooks/_lib/arka_python.sh +38 -0
  7. package/config/hooks/post-tool-use.ps1 +8 -0
  8. package/config/hooks/post-tool-use.sh +11 -5
  9. package/config/hooks/pre-tool-use.ps1 +11 -6
  10. package/config/hooks/pre-tool-use.sh +11 -6
  11. package/config/hooks/session-start.ps1 +10 -0
  12. package/config/hooks/session-start.sh +5 -0
  13. package/config/hooks/stop.ps1 +9 -1
  14. package/config/hooks/stop.sh +11 -5
  15. package/config/hooks/user-prompt-submit.ps1 +18 -0
  16. package/config/hooks/user-prompt-submit.sh +11 -5
  17. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  18. package/core/cognition/scheduler/daemon.py +7 -3
  19. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  20. package/core/forge/orchestrator.py +2 -1
  21. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  22. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  23. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  24. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  25. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  26. package/core/hooks/_shared.py +29 -9
  27. package/core/hooks/stop.py +4 -3
  28. package/core/hooks/user_prompt_submit.py +5 -4
  29. package/core/knowledge/__pycache__/indexer.cpython-313.pyc +0 -0
  30. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  31. package/core/knowledge/indexer.py +1 -1
  32. package/core/knowledge/vector_store.py +10 -3
  33. package/core/runtime/__pycache__/model_router.cpython-312.pyc +0 -0
  34. package/core/runtime/__pycache__/model_router.cpython-314.pyc +0 -0
  35. package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
  36. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  37. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  38. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  39. package/core/runtime/gateway/__init__.py +28 -0
  40. package/core/runtime/gateway/__main__.py +30 -0
  41. package/core/runtime/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  42. package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/core/runtime/gateway/__pycache__/__init__.cpython-314.pyc +0 -0
  44. package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
  45. package/core/runtime/gateway/__pycache__/litellm_config.cpython-312.pyc +0 -0
  46. package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
  47. package/core/runtime/gateway/__pycache__/litellm_config.cpython-314.pyc +0 -0
  48. package/core/runtime/gateway/litellm_config.py +205 -0
  49. package/core/runtime/model_routing_check.py +71 -0
  50. package/core/runtime/model_routing_context.py +22 -1
  51. package/core/runtime/native_usage.py +2 -1
  52. package/core/shared/__pycache__/temp_paths.cpython-313.pyc +0 -0
  53. package/core/shared/__pycache__/temp_paths.cpython-314.pyc +0 -0
  54. package/core/shared/temp_paths.py +40 -0
  55. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  56. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  57. package/core/synapse/kb_cache.py +3 -2
  58. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  59. package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
  60. package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
  61. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  62. package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
  63. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  64. package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
  65. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  66. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  67. package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
  68. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  69. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  70. package/core/sync/agent_provisioner.py +5 -5
  71. package/core/sync/ai_mcp_decider.py +2 -2
  72. package/core/sync/content_syncer.py +14 -14
  73. package/core/sync/descriptor_syncer.py +2 -2
  74. package/core/sync/discovery.py +4 -4
  75. package/core/sync/engine.py +4 -4
  76. package/core/sync/manifest.py +1 -1
  77. package/core/sync/mcp_optimizer.py +5 -5
  78. package/core/sync/mcp_syncer.py +3 -3
  79. package/core/sync/policy_loader.py +1 -1
  80. package/core/sync/reporter.py +1 -1
  81. package/core/sync/settings_syncer.py +2 -2
  82. package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
  83. package/core/terminal/__pycache__/session_windows.cpython-313.pyc +0 -0
  84. package/core/terminal/session.py +66 -11
  85. package/core/terminal/session_windows.py +211 -0
  86. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  87. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  88. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  89. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  90. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  91. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  92. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  93. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  94. package/core/workflow/flow_authorization.py +2 -1
  95. package/core/workflow/flow_enforcer.py +2 -1
  96. package/core/workflow/marker_cache.py +2 -1
  97. package/core/workflow/research_gate.py +2 -1
  98. package/dashboard/app/pages/index.vue +6 -1
  99. package/installer/cli.js +5 -4
  100. package/installer/core-snapshot.js +53 -0
  101. package/installer/doctor.js +8 -3
  102. package/installer/index.js +29 -6
  103. package/installer/migrate.js +7 -4
  104. package/installer/update.js +13 -0
  105. package/package.json +1 -1
  106. package/pyproject.toml +8 -1
  107. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  108. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  109. package/scripts/dashboard-api.py +69 -33
  110. package/scripts/gateway-ensure.sh +93 -0
  111. package/scripts/knowledge-index.py +35 -2
  112. package/scripts/start-dashboard.ps1 +34 -14
  113. package/scripts/synapse-bridge.py +3 -1
@@ -56,7 +56,7 @@ def index_directory(
56
56
 
57
57
  fhash = file_hash(filepath)
58
58
 
59
- if skip_indexed and store.is_file_indexed(fhash):
59
+ if skip_indexed and store.is_file_indexed(str(filepath), fhash):
60
60
  skipped += 1
61
61
  continue
62
62
 
@@ -347,10 +347,17 @@ class VectorStore:
347
347
  for r in rows
348
348
  ]
349
349
 
350
- def is_file_indexed(self, file_hash: str) -> bool:
351
- """Check if a file has already been indexed."""
350
+ def is_file_indexed(self, source: str, file_hash: str) -> bool:
351
+ """Check if a file (identified by source path + content hash) is indexed.
352
+
353
+ The source path is part of the key so identical content in two
354
+ different locations (e.g. two npx cache directories) each gets
355
+ indexed — a hash-only check would skip every subsequent cache
356
+ and leave the new location unsearchable.
357
+ """
352
358
  row = self._db.execute(
353
- "SELECT COUNT(*) as cnt FROM chunks WHERE file_hash = ?", (file_hash,)
359
+ "SELECT COUNT(*) as cnt FROM chunks WHERE source = ? AND file_hash = ?",
360
+ (source, file_hash),
354
361
  ).fetchone()
355
362
  return row["cnt"] > 0
356
363
 
@@ -0,0 +1,28 @@
1
+ """ArkaOS model-routing gateway.
2
+
3
+ Turns the operator's ``~/.arkaos/models.yaml`` role routing into a running
4
+ LiteLLM proxy so per-role model choices actually take effect at dispatch
5
+ time. Claude Code points ``ANTHROPIC_BASE_URL`` at the proxy; the proxy
6
+ fans each request out to the right upstream (Anthropic-direct or local
7
+ Ollama) by the model name the alias slot carries.
8
+
9
+ See ``docs/adr`` and the plan for why a gateway is required: Claude Code's
10
+ ``ANTHROPIC_BASE_URL`` is one endpoint per process, so honouring mixed
11
+ providers per role in a single session needs a fan-out proxy.
12
+ """
13
+
14
+ from core.runtime.gateway.litellm_config import (
15
+ GatewayPlan,
16
+ build_gateway_plan,
17
+ build_launch_env,
18
+ build_litellm_config,
19
+ role_alias,
20
+ )
21
+
22
+ __all__ = [
23
+ "GatewayPlan",
24
+ "build_gateway_plan",
25
+ "build_launch_env",
26
+ "build_litellm_config",
27
+ "role_alias",
28
+ ]
@@ -0,0 +1,30 @@
1
+ """CLI: render the LiteLLM gateway config from models.yaml.
2
+
3
+ arka-py -m core.runtime.gateway [--local] # print config.yaml
4
+ arka-py -m core.runtime.gateway [--local] --env KEY # print launch env
5
+
6
+ ``--local`` renders the keyless local-only variant (every route → local
7
+ Ollama) for subscription users with no ANTHROPIC_API_KEY.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+ from core.runtime.gateway.litellm_config import build_launch_env, render_config_yaml
15
+
16
+
17
+ def main(argv: list[str]) -> int:
18
+ local_only = "--local" in argv
19
+ argv = [a for a in argv if a != "--local"]
20
+ if len(argv) >= 2 and argv[0] == "--env":
21
+ env = build_launch_env(master_key=argv[1])
22
+ for key, value in env.items():
23
+ print(f"{key}={value}")
24
+ return 0
25
+ sys.stdout.write(render_config_yaml(local_only=local_only))
26
+ return 0
27
+
28
+
29
+ if __name__ == "__main__":
30
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,205 @@
1
+ """Render a LiteLLM proxy config + Claude Code launch env from models.yaml.
2
+
3
+ Claude Code exposes at most three subagent alias slots (``opus``,
4
+ ``sonnet``, ``haiku``) plus the interactive main-loop model. The seven
5
+ Model Fabric roles therefore collapse onto those slots by tier:
6
+
7
+ quality tier (design/review/architecture/quality_gate) -> opus slot
8
+ execution -> haiku slot
9
+ mechanical -> sonnet slot
10
+ strategy / orchestrator -> main model
11
+
12
+ Each slot maps to a LiteLLM route (``arka-<slot>``) that fans out to the
13
+ role's real upstream — Anthropic-direct or local Ollama. The launch env
14
+ points ``ANTHROPIC_DEFAULT_<TIER>_MODEL`` at those routes, so a subagent
15
+ dispatched with ``model: haiku`` reaches Ollama while ``model: opus``
16
+ reaches Anthropic — in the same session. The Anthropic API key never
17
+ enters the client env; it stays server-side in the proxy's environment.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pydantic import BaseModel
23
+
24
+ from core.runtime.model_router import ModelsConfig, load_config, resolve
25
+
26
+ # The three Claude Code subagent alias slots we can address, plus a
27
+ # sentinel for the interactive main-loop model (not an alias).
28
+ _SLOTS = ("opus", "sonnet", "haiku")
29
+ _MAIN = "main"
30
+
31
+ # Which alias slot each work-role dispatches through. The orchestrator
32
+ # uses this to choose the Task `model` param per role; the launch env
33
+ # points each slot's default model at the matching gateway route.
34
+ ROLE_ALIAS: dict[str, str] = {
35
+ "design": "opus",
36
+ "review": "opus",
37
+ "architecture": "opus",
38
+ "quality_gate": "opus",
39
+ "strategy": _MAIN,
40
+ "execution": "haiku",
41
+ "mechanical": "sonnet",
42
+ }
43
+
44
+ # Representative role whose configured upstream defines each slot's route.
45
+ # All quality roles share the opus slot, so one of them stands in.
46
+ _SLOT_ROLE: dict[str, str] = {
47
+ "opus": "quality_gate",
48
+ "sonnet": "mechanical",
49
+ "haiku": "execution",
50
+ }
51
+
52
+ # runtime-tier tokens -> real Anthropic model ids the proxy calls upstream.
53
+ # Real ids (claude-fable-5, claude-*) pass straight through.
54
+ _RUNTIME_TO_ANTHROPIC: dict[str, str] = {
55
+ "opus": "claude-opus-4-8",
56
+ "best": "claude-opus-4-8",
57
+ "sonnet": "claude-sonnet-5",
58
+ "default": "claude-sonnet-5",
59
+ "haiku": "claude-haiku-4-5-20251001",
60
+ "fast": "claude-haiku-4-5-20251001",
61
+ }
62
+
63
+ _DEFAULT_OLLAMA_BASE = "http://localhost:11434"
64
+ _DEFAULT_PORT = 4000
65
+ _ANTHROPIC_KEY_REF = "os.environ/ANTHROPIC_API_KEY"
66
+
67
+
68
+ def role_alias(role: str) -> str:
69
+ """Which Claude Code alias slot a role dispatches through.
70
+
71
+ Unknown roles fall back to the opus slot — never the cheap tier —
72
+ consistent with the model-router's quality-first posture.
73
+ """
74
+ return ROLE_ALIAS.get(role, "opus")
75
+
76
+
77
+ class Upstream(BaseModel):
78
+ """A concrete backend a slot routes to."""
79
+
80
+ kind: str # "anthropic" | "ollama"
81
+ model_id: str
82
+ api_base: str | None = None
83
+
84
+
85
+ class GatewayPlan(BaseModel):
86
+ """Resolved routing: one upstream per subagent alias slot.
87
+
88
+ The strategy role rides the interactive main-loop model (set by
89
+ ``--model`` / the operator's ``/model``), which reaches Anthropic via
90
+ the wildcard passthrough route — it is not a gateway-controlled slot.
91
+ """
92
+
93
+ slots: dict[str, Upstream] # keyed by "opus"/"sonnet"/"haiku"
94
+ source: str
95
+
96
+
97
+ def _upstream_for(provider: str, model: str, ollama_base: str) -> Upstream:
98
+ if provider == "ollama":
99
+ return Upstream(kind="ollama", model_id=model, api_base=ollama_base)
100
+ # runtime + anthropic both terminate at Anthropic-direct.
101
+ return Upstream(kind="anthropic", model_id=_RUNTIME_TO_ANTHROPIC.get(model, model))
102
+
103
+
104
+ def _ollama_base(config: ModelsConfig) -> str:
105
+ return (config.providers.get("ollama", {}) or {}).get("base_url") or _DEFAULT_OLLAMA_BASE
106
+
107
+
108
+ def build_gateway_plan(user_path=None, local_only: bool = False) -> GatewayPlan:
109
+ """Resolve every alias slot to its upstream from the operator's config.
110
+
111
+ ``local_only`` (for subscription users with no ANTHROPIC_API_KEY):
112
+ every slot that would hit Anthropic is redirected to the local Ollama
113
+ model instead, so the whole session runs keyless on local models. Raises
114
+ if the config has no Ollama route to fall back to.
115
+ """
116
+ config, source = load_config(user_path)
117
+ ollama_base = _ollama_base(config)
118
+ slots: dict[str, Upstream] = {}
119
+ for slot, role in _SLOT_ROLE.items():
120
+ r = resolve(role, user_path)
121
+ slots[slot] = _upstream_for(r.provider, r.model, ollama_base)
122
+ if local_only:
123
+ fallback = _local_fallback(slots)
124
+ if fallback is None:
125
+ raise ValueError(
126
+ "local-only gateway requested but models.yaml has no Ollama "
127
+ "route to fall back to — point at least one role at ollama/<model>"
128
+ )
129
+ slots = {
130
+ slot: (up if up.kind == "ollama" else fallback)
131
+ for slot, up in slots.items()
132
+ }
133
+ return GatewayPlan(slots=slots, source=source)
134
+
135
+
136
+ def _local_fallback(slots: dict[str, Upstream]) -> Upstream | None:
137
+ """The local Ollama upstream to redirect Anthropic routes to (prefer
138
+ execution/haiku), or None when no slot is local."""
139
+ if slots.get("haiku", Upstream(kind="anthropic", model_id="")).kind == "ollama":
140
+ return slots["haiku"]
141
+ for up in slots.values():
142
+ if up.kind == "ollama":
143
+ return up
144
+ return None
145
+
146
+
147
+ def _litellm_params(up: Upstream) -> dict:
148
+ if up.kind == "ollama":
149
+ # ollama_chat/ enables tool-use translation; api_base points at the
150
+ # local daemon (which forwards :cloud models to Ollama Cloud).
151
+ return {"model": f"ollama_chat/{up.model_id}", "api_base": up.api_base}
152
+ return {"model": up.model_id, "api_key": _ANTHROPIC_KEY_REF}
153
+
154
+
155
+ def build_litellm_config(user_path=None, local_only: bool = False) -> dict:
156
+ """The LiteLLM proxy config.yaml structure (regenerated per launch).
157
+
158
+ One ``arka-<slot>`` route per alias slot, plus a wildcard route for the
159
+ interactive main-loop model and any raw model id. In the default mixed
160
+ mode the wildcard forwards to Anthropic (key via ``os.environ``, never
161
+ inlined); in ``local_only`` mode it forwards to the local Ollama model
162
+ so the whole session is keyless. The client authenticates with the
163
+ gateway master key either way.
164
+ """
165
+ plan = build_gateway_plan(user_path, local_only=local_only)
166
+ model_list = [
167
+ {"model_name": f"arka-{slot}", "litellm_params": _litellm_params(plan.slots[slot])}
168
+ for slot in _SLOTS
169
+ ]
170
+ if local_only:
171
+ wildcard = _litellm_params(_local_fallback(plan.slots))
172
+ else:
173
+ # LiteLLM matches explicit names first; this catches the main-loop
174
+ # model and any raw claude-* id and forwards it to Anthropic.
175
+ wildcard = {"model": "anthropic/*", "api_key": _ANTHROPIC_KEY_REF}
176
+ model_list.append({"model_name": "*", "litellm_params": wildcard})
177
+ return {
178
+ "model_list": model_list,
179
+ "litellm_settings": {"drop_params": True},
180
+ "general_settings": {"master_key": "os.environ/ARKA_GATEWAY_KEY"},
181
+ }
182
+
183
+
184
+ def build_launch_env(master_key: str, port: int = _DEFAULT_PORT, user_path=None) -> dict[str, str]:
185
+ """Env vars Claude Code launches with so subagent aliases hit the gateway.
186
+
187
+ ``ANTHROPIC_API_KEY`` is deliberately absent — it belongs to the proxy,
188
+ not the client. The client presents only the gateway master key.
189
+ """
190
+ return {
191
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
192
+ "ANTHROPIC_AUTH_TOKEN": master_key,
193
+ "ANTHROPIC_DEFAULT_OPUS_MODEL": "arka-opus",
194
+ "ANTHROPIC_DEFAULT_SONNET_MODEL": "arka-sonnet",
195
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "arka-haiku",
196
+ }
197
+
198
+
199
+ def render_config_yaml(user_path=None, local_only: bool = False) -> str:
200
+ """The LiteLLM config as a YAML string (what arka-claude writes to disk)."""
201
+ import yaml
202
+
203
+ return yaml.safe_dump(
204
+ build_litellm_config(user_path, local_only=local_only), sort_keys=False
205
+ )
@@ -0,0 +1,71 @@
1
+ """Model-routing telemetry — answer "is the Model Fabric actually used?".
2
+
3
+ The gateway makes per-role routing real; this module makes it *observable*.
4
+ It reports the resolved route table, whether the LiteLLM proxy is live, and
5
+ a best-effort count of which ``arka-<slot>`` routes actually served traffic
6
+ (parsed from the proxy log). Everything degrades to a plain summary when the
7
+ gateway is off, so ``/arka status`` always has a truthful line to show.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from core.runtime.gateway.litellm_config import build_gateway_plan
16
+
17
+ _GATEWAY_LOG = Path.home() / ".arkaos" / "gateway" / "litellm.log"
18
+ _DEFAULT_PORT = 4000
19
+ _ROUTE_RE = re.compile(r"\barka-(opus|sonnet|haiku)\b")
20
+
21
+
22
+ def resolved_routes(user_path=None) -> dict[str, str]:
23
+ """Alias slot -> human 'kind:model' the gateway would route it to."""
24
+ plan = build_gateway_plan(user_path)
25
+ return {
26
+ slot: f"{up.kind}:{up.model_id}" for slot, up in plan.slots.items()
27
+ }
28
+
29
+
30
+ def gateway_healthy(port: int = _DEFAULT_PORT, timeout: float = 1.0) -> bool:
31
+ """True when the LiteLLM proxy answers its liveness probe."""
32
+ import urllib.error
33
+ import urllib.request
34
+
35
+ try:
36
+ with urllib.request.urlopen(
37
+ f"http://127.0.0.1:{port}/health/liveliness", timeout=timeout
38
+ ) as resp:
39
+ return resp.status == 200
40
+ except (urllib.error.URLError, OSError, ValueError):
41
+ return False
42
+
43
+
44
+ def served_counts(log_path: Path | None = None) -> dict[str, int]:
45
+ """Best-effort count of arka-<slot> routes seen in the proxy log."""
46
+ path = log_path or _GATEWAY_LOG
47
+ counts: dict[str, int] = {}
48
+ try:
49
+ text = path.read_text(encoding="utf-8", errors="ignore")
50
+ except OSError:
51
+ return counts
52
+ for match in _ROUTE_RE.finditer(text):
53
+ slot = match.group(1)
54
+ counts[slot] = counts.get(slot, 0) + 1
55
+ return counts
56
+
57
+
58
+ def status_summary(port: int = _DEFAULT_PORT, user_path=None, log_path=None) -> str:
59
+ """One compact block for /arka status — reality, not intent."""
60
+ live = gateway_healthy(port)
61
+ routes = resolved_routes(user_path)
62
+ header = (
63
+ f"Gateway: {'LIVE' if live else 'off'} (:{port}) — per-role model routing "
64
+ f"{'active' if live else 'not active; honour-system context injection only'}"
65
+ )
66
+ lines = [f" {slot} → {target}" for slot, target in sorted(routes.items())]
67
+ counts = served_counts(log_path)
68
+ if counts:
69
+ served = " ".join(f"{slot}={n}" for slot, n in sorted(counts.items()))
70
+ lines.append(f" served: {served}")
71
+ return header + "\n" + "\n".join(lines)
@@ -57,7 +57,7 @@ def routing_summary() -> str:
57
57
 
58
58
 
59
59
  def routing_directive() -> str:
60
- """Full SessionStart block: the config plus how to honor it.
60
+ """Full SessionStart block: the config plus how to honour it.
61
61
 
62
62
  Returns '' when the config cannot be read, so the hook can skip the
63
63
  block entirely rather than inject an empty directive.
@@ -65,6 +65,26 @@ def routing_directive() -> str:
65
65
  summary = routing_summary()
66
66
  if not summary:
67
67
  return ""
68
+
69
+ # When the LiteLLM gateway is live, the alias a subagent is dispatched
70
+ # with is what physically selects the upstream (opus→Anthropic,
71
+ # haiku→Ollama for execution), so the directive names the concrete
72
+ # alias per role. Off, it stays advisory context injection.
73
+ gateway_line = ""
74
+ try:
75
+ from core.runtime.model_routing_check import gateway_healthy
76
+
77
+ if gateway_healthy():
78
+ gateway_line = (
79
+ "\nGateway LIVE: the Task model param physically routes the "
80
+ "upstream. Dispatch execution with model=haiku (→ Ollama), "
81
+ "mechanical with model=sonnet, and design/review/architecture/"
82
+ "quality_gate with model=opus (→ Anthropic). strategy is the "
83
+ "main loop model."
84
+ )
85
+ except Exception:
86
+ gateway_line = ""
87
+
68
88
  # No backticks: this string is embedded in session-start.sh which
69
89
  # passes it through $(echo -e "$MSG") — backticks would trigger shell
70
90
  # command substitution. Use plain quotes.
@@ -79,4 +99,5 @@ def routing_directive() -> str:
79
99
  "the agent YAML default. Quality roles never downgrade below the "
80
100
  "configured model (excellence-mandate). Genuinely mechanical "
81
101
  "dispatches (commit messages, formatting) use the mechanical role."
102
+ f"{gateway_line}"
82
103
  )
@@ -33,9 +33,10 @@ from typing import Any
33
33
  from core.runtime.llm_cost_telemetry import record_cost
34
34
  from core.runtime.pricing import estimate_cost_usd
35
35
  from core.shared.safe_session_id import safe_session_id
36
+ from core.shared.temp_paths import arkaos_temp_dir
36
37
 
37
38
 
38
- DEFAULT_CURSOR_DIR = Path("/tmp/arkaos-native-cost")
39
+ DEFAULT_CURSOR_DIR = arkaos_temp_dir("arkaos-native-cost")
39
40
 
40
41
  _USAGE_KEYS = (
41
42
  "input_tokens",
@@ -0,0 +1,40 @@
1
+ """Cross-platform base directory for ArkaOS inter-process coordination files.
2
+
3
+ Several core modules and the bash hooks coordinate through small marker
4
+ and cache files: workflow-required markers (``stop.sh`` ->
5
+ ``flow_enforcer``), the turn-scoped KB query cache
6
+ (``user-prompt-submit.sh`` -> ``kb_cache``), hook metrics
7
+ (``user-prompt-submit.sh`` -> ``dashboard-api``). The hooks write them
8
+ under ``/tmp/arkaos-*``; the Python side MUST resolve to the *same*
9
+ directory or the coordination silently breaks.
10
+
11
+ Why this lives in ``core.shared``: every module that coordinates through
12
+ these files (flow_enforcer, marker_cache, research_gate, kb_cache,
13
+ forge.orchestrator, the consolidated hooks, native_usage, the synapse
14
+ bridge, the dashboard API, …) used to hardcode the literal string
15
+ ``/tmp``. ``/tmp`` does not exist on Windows, so every one of those
16
+ features failed there. A single helper keeps the resolution in one place.
17
+
18
+ POSIX keeps the literal ``/tmp`` the bash hooks use (avoiding a
19
+ ``$TMPDIR`` divergence on macOS where ``tempfile.gettempdir()`` returns
20
+ ``/var/folders/...`` but the hooks still write ``/tmp``). On Windows
21
+ there is no ``/tmp``: Git-for-Windows maps a hook's ``/tmp`` to
22
+ ``%TEMP%``, which is exactly what ``tempfile.gettempdir()`` returns, so
23
+ both sides still meet.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+
33
+ def arkaos_temp_dir(*parts: str) -> Path:
34
+ """Return ``<coordination base>/<parts...>`` (the path is not created).
35
+
36
+ POSIX -> ``/tmp/<parts>`` (matches the bash hooks verbatim).
37
+ Windows -> ``<%TEMP%>/<parts>`` (matches Git-for-Windows ``/tmp``).
38
+ """
39
+ base = Path(tempfile.gettempdir()) if os.name == "nt" else Path("/tmp")
40
+ return base.joinpath(*parts)
@@ -30,11 +30,12 @@ from pathlib import Path
30
30
  from typing import Any, Optional
31
31
 
32
32
  from core.shared import safe_session_id as _safe_session_id_module
33
+ from core.shared.temp_paths import arkaos_temp_dir
33
34
 
34
35
 
35
36
  # Re-export for backward compatibility with any external importers.
36
37
  SAFE_SESSION_ID_RE = _safe_session_id_module.SAFE_SESSION_ID_RE
37
- KB_QUERY_MARKER_DIR = Path("/tmp/arkaos-kb-query")
38
+ KB_QUERY_MARKER_DIR = arkaos_temp_dir("arkaos-kb-query")
38
39
  _MAX_QUERIES_PER_TURN = 32
39
40
  _MAX_QUERY_LEN = 512
40
41
 
@@ -159,7 +160,7 @@ class KBSessionCache:
159
160
  self._cache_dir = Path(cache_dir)
160
161
  else:
161
162
  project_hash = self._hash_project(project_path or "")
162
- self._cache_dir = Path("/tmp") / f"arkaos-kb-{project_hash}"
163
+ self._cache_dir = arkaos_temp_dir(f"arkaos-kb-{project_hash}")
163
164
 
164
165
  self._cache_file = self._cache_dir / f"{session_id}.json"
165
166
  self._ensure_dir()
@@ -55,7 +55,7 @@ def _extend_from_file(path: Path, agents: set[str]) -> None:
55
55
  if not path.exists():
56
56
  return
57
57
  try:
58
- data = yaml.safe_load(path.read_text()) or {}
58
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
59
59
  except yaml.YAMLError:
60
60
  return
61
61
  for name in data.get("baseline", []) or []:
@@ -95,10 +95,10 @@ def _apply_allowlist(
95
95
  continue
96
96
 
97
97
  target = agents_dir / f"{name}.md"
98
- if target.exists() and target.read_text() == rendered:
98
+ if target.exists() and target.read_text(encoding="utf-8") == rendered:
99
99
  unchanged.append(name)
100
100
  continue
101
- target.write_text(rendered)
101
+ target.write_text(rendered, encoding="utf-8")
102
102
  added.append(name)
103
103
 
104
104
  return added, unchanged, errored
@@ -124,10 +124,10 @@ def _render_agent(core: Path, name: str) -> str | None:
124
124
  parts: list[str] = []
125
125
  if yaml_path is not None:
126
126
  parts.append("---")
127
- parts.append(yaml_path.read_text().strip())
127
+ parts.append(yaml_path.read_text(encoding="utf-8").strip())
128
128
  parts.append("---")
129
129
  if md_path is not None:
130
- parts.append(md_path.read_text().rstrip())
130
+ parts.append(md_path.read_text(encoding="utf-8").rstrip())
131
131
 
132
132
  return "\n".join(parts) + "\n"
133
133
 
@@ -76,11 +76,11 @@ def _load_cache(path: Path) -> dict[str, str]:
76
76
  if not path.exists():
77
77
  return {}
78
78
  try:
79
- return json.loads(path.read_text())
79
+ return json.loads(path.read_text(encoding="utf-8"))
80
80
  except (json.JSONDecodeError, OSError):
81
81
  return {}
82
82
 
83
83
 
84
84
  def _save_cache(path: Path, data: dict[str, str]) -> None:
85
85
  path.parent.mkdir(parents=True, exist_ok=True)
86
- path.write_text(json.dumps(data, indent=2, sort_keys=True))
86
+ path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")