arkaos 4.2.0 → 4.3.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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/fusion/SKILL.md +98 -0
- package/config/hooks/session-start.sh +23 -0
- package/core/fusion/__init__.py +5 -0
- package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +88 -0
- package/core/fusion/engine.py +153 -0
- package/core/fusion/panel_builder.py +58 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +26 -12
- package/core/hooks/pre_tool_use.py +4 -0
- package/core/hooks/user_prompt_submit.py +7 -0
- package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
- package/core/runtime/model_router.py +17 -0
- package/core/runtime/model_router_cli.py +70 -1
- package/core/runtime/model_routing_context.py +82 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/runtime_models.py +51 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +181 -0
- package/core/workflow/flow_enforcer.py +56 -11
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +423 -0
- package/installer/cli.js +21 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +90 -0
|
@@ -33,6 +33,23 @@ USER_CONFIG_PATH = Path.home() / ".arkaos" / "models.yaml"
|
|
|
33
33
|
QUALITY_ROLES = frozenset({
|
|
34
34
|
"design", "review", "architecture", "strategy", "quality_gate",
|
|
35
35
|
})
|
|
36
|
+
|
|
37
|
+
# Plain-language explanation of each role, surfaced in the dashboard and
|
|
38
|
+
# `npx arkaos models` so the operator knows what "execution" etc. drive.
|
|
39
|
+
ROLE_DESCRIPTIONS: dict[str, str] = {
|
|
40
|
+
"design": "UI/UX and visual design — layout, typography, aesthetics.",
|
|
41
|
+
"review": "Code review, Quality Gate reviewers, adversarial verification.",
|
|
42
|
+
"architecture": "System design, API contracts, ADRs, technical decisions.",
|
|
43
|
+
"strategy": "Business and product strategy, market analysis, planning.",
|
|
44
|
+
"quality_gate": "The mandatory Quality Gate (Marta, Eduardo, Francisca).",
|
|
45
|
+
"execution": "Implementation — specialists writing code and running build tasks.",
|
|
46
|
+
"mechanical": "Rote work — commit messages, changelog, formatting, data fetch.",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def role_description(role: str) -> str:
|
|
51
|
+
"""Human-readable description of a role, or empty if unknown."""
|
|
52
|
+
return ROLE_DESCRIPTIONS.get(role, "")
|
|
36
53
|
KNOWN_EFFORTS = ("low", "medium", "high", "max")
|
|
37
54
|
|
|
38
55
|
_ALIAS_NAMES = frozenset({"best", "default", "fast"})
|
|
@@ -6,6 +6,7 @@ Usage::
|
|
|
6
6
|
python -m core.runtime.model_router_cli --json # machine-readable
|
|
7
7
|
python -m core.runtime.model_router_cli init # create user file
|
|
8
8
|
python -m core.runtime.model_router_cli set review anthropic/best --effort max
|
|
9
|
+
python -m core.runtime.model_router_cli usage --period week
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -14,6 +15,7 @@ import argparse
|
|
|
14
15
|
import json
|
|
15
16
|
import sys
|
|
16
17
|
|
|
18
|
+
from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
|
|
17
19
|
from core.runtime.model_router import (
|
|
18
20
|
USER_CONFIG_PATH,
|
|
19
21
|
ensure_user_config,
|
|
@@ -21,6 +23,7 @@ from core.runtime.model_router import (
|
|
|
21
23
|
resolve_all,
|
|
22
24
|
set_role,
|
|
23
25
|
)
|
|
26
|
+
from core.runtime.ollama_discovery import discover
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def _print_table() -> None:
|
|
@@ -37,7 +40,52 @@ def _print_table() -> None:
|
|
|
37
40
|
model = item.model or "(unset — configure alias)"
|
|
38
41
|
print(f" {item.role:<14} {item.provider:<12} {model:<34} {item.effort}")
|
|
39
42
|
print()
|
|
43
|
+
_print_ollama_line()
|
|
40
44
|
print(" Change: npx arkaos models set <role> <provider>/<model> [--effort max]")
|
|
45
|
+
print(" Usage: npx arkaos models usage [--period today|week|month|all]")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_ollama_line() -> None:
|
|
50
|
+
status = discover()
|
|
51
|
+
if status.running and status.models:
|
|
52
|
+
names = ", ".join(m.name for m in status.models[:5])
|
|
53
|
+
extra = f" (+{len(status.models) - 5} more)" if len(status.models) > 5 else ""
|
|
54
|
+
print(f" Ollama: running — {len(status.models)} local models: {names}{extra}")
|
|
55
|
+
print(" use them: npx arkaos models set mechanical ollama/<model>")
|
|
56
|
+
elif status.installed:
|
|
57
|
+
print(" Ollama: installed but not running — start it to unlock local models")
|
|
58
|
+
else:
|
|
59
|
+
print(" Ollama: not detected — https://ollama.com for free local models")
|
|
60
|
+
print()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_usage(period: str) -> None:
|
|
64
|
+
summary = summarise(period=period)
|
|
65
|
+
total = summary.total_cost_usd
|
|
66
|
+
total_s = f"${total:.4f}" if total is not None else "—"
|
|
67
|
+
print()
|
|
68
|
+
print(f" Model Fabric — usage ({period})")
|
|
69
|
+
print(f" calls: {summary.call_count} tokens in/out: "
|
|
70
|
+
f"{summary.total_tokens_in}/{summary.total_tokens_out} "
|
|
71
|
+
f"cached: {summary.total_cached_tokens} est. cost: {total_s}")
|
|
72
|
+
for title, group in (("BY MODEL", summary.by_model),
|
|
73
|
+
("BY PROVIDER", summary.by_provider)):
|
|
74
|
+
if not group:
|
|
75
|
+
continue
|
|
76
|
+
print()
|
|
77
|
+
print(f" {title:<28} {'CALLS':>6} {'IN':>10} {'OUT':>10} {'COST':>10}")
|
|
78
|
+
print(" " + "─" * 68)
|
|
79
|
+
ranked = sorted(group.items(),
|
|
80
|
+
key=lambda kv: kv[1].get("total_cost_usd") or 0,
|
|
81
|
+
reverse=True)
|
|
82
|
+
for name, bucket in ranked:
|
|
83
|
+
cost = bucket.get("total_cost_usd")
|
|
84
|
+
cost_s = f"${cost:.4f}" if cost is not None else "—"
|
|
85
|
+
print(f" {(name or '(unknown)'):<28} "
|
|
86
|
+
f"{bucket.get('call_count', 0):>6} "
|
|
87
|
+
f"{bucket.get('total_tokens_in', 0):>10} "
|
|
88
|
+
f"{bucket.get('total_tokens_out', 0):>10} {cost_s:>10}")
|
|
41
89
|
print()
|
|
42
90
|
|
|
43
91
|
|
|
@@ -49,13 +97,34 @@ def _print_json() -> None:
|
|
|
49
97
|
def main(argv: list[str] | None = None) -> int:
|
|
50
98
|
parser = argparse.ArgumentParser(prog="arkaos models")
|
|
51
99
|
parser.add_argument("action", nargs="?", default="list",
|
|
52
|
-
choices=["list", "init", "set"])
|
|
100
|
+
choices=["list", "init", "set", "usage", "discover"])
|
|
53
101
|
parser.add_argument("role", nargs="?")
|
|
54
102
|
parser.add_argument("target", nargs="?")
|
|
55
103
|
parser.add_argument("--effort", choices=["low", "medium", "high", "max"])
|
|
104
|
+
parser.add_argument("--period", choices=list(VALID_PERIODS), default="today")
|
|
56
105
|
parser.add_argument("--json", action="store_true", dest="as_json")
|
|
57
106
|
args = parser.parse_args(argv)
|
|
58
107
|
|
|
108
|
+
if args.action == "usage":
|
|
109
|
+
if args.as_json:
|
|
110
|
+
summary = summarise(period=args.period)
|
|
111
|
+
print(json.dumps({
|
|
112
|
+
"period": summary.period,
|
|
113
|
+
"call_count": summary.call_count,
|
|
114
|
+
"total_cost_usd": summary.total_cost_usd,
|
|
115
|
+
"total_tokens_in": summary.total_tokens_in,
|
|
116
|
+
"total_tokens_out": summary.total_tokens_out,
|
|
117
|
+
"total_cached_tokens": summary.total_cached_tokens,
|
|
118
|
+
"by_model": summary.by_model,
|
|
119
|
+
"by_provider": summary.by_provider,
|
|
120
|
+
"by_category": summary.by_category,
|
|
121
|
+
}, indent=2))
|
|
122
|
+
else:
|
|
123
|
+
_print_usage(args.period)
|
|
124
|
+
return 0
|
|
125
|
+
if args.action == "discover":
|
|
126
|
+
print(json.dumps(discover().to_dict(), indent=2))
|
|
127
|
+
return 0
|
|
59
128
|
if args.action == "init":
|
|
60
129
|
path = ensure_user_config()
|
|
61
130
|
print(f" ✓ Model Fabric config: {path}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Model Fabric → orchestrator bridge (the consumption layer).
|
|
2
|
+
|
|
3
|
+
The dashboard and ``npx arkaos models`` write ``~/.arkaos/models.yaml``.
|
|
4
|
+
Until now nothing fed that config back to the Claude Code orchestrator,
|
|
5
|
+
so changing a role's model changed nothing at dispatch time. This module
|
|
6
|
+
turns the config into a compact directive the SessionStart hook injects,
|
|
7
|
+
so the orchestrator resolves an agent's model from the user's config
|
|
8
|
+
instead of only the agent YAML default.
|
|
9
|
+
|
|
10
|
+
It is context injection — the same control surface ArkaOS uses for the
|
|
11
|
+
constitution, routing, and evidence flow — not a hard rewrite of the Task
|
|
12
|
+
`model` param (Claude Code exposes no hook for that). The PostToolUse
|
|
13
|
+
telemetry already records the model actually requested per dispatch, so
|
|
14
|
+
mismatches are observable (see model_routing_check).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# Which Model Fabric role governs each kind of agent work. Agents are a
|
|
20
|
+
# different taxonomy from roles, so we map by what the agent DOES. The
|
|
21
|
+
# orchestrator uses this to pick the role, then resolves the model.
|
|
22
|
+
AGENT_ROLE_HINTS: dict[str, str] = {
|
|
23
|
+
# quality-critical
|
|
24
|
+
"architect": "architecture",
|
|
25
|
+
"cto": "architecture",
|
|
26
|
+
"strategist": "strategy",
|
|
27
|
+
"brand-strategist": "strategy",
|
|
28
|
+
"creative-director": "design",
|
|
29
|
+
"visual-designer": "design",
|
|
30
|
+
"frontend-dev": "design",
|
|
31
|
+
"marta-cqo": "quality_gate",
|
|
32
|
+
"eduardo-copy": "quality_gate",
|
|
33
|
+
"francisca-tech": "quality_gate",
|
|
34
|
+
"code-reviewer": "review",
|
|
35
|
+
"security": "review",
|
|
36
|
+
"qa": "review",
|
|
37
|
+
# execution
|
|
38
|
+
"senior-dev": "execution",
|
|
39
|
+
"paulo-tech-lead": "execution",
|
|
40
|
+
"devops": "execution",
|
|
41
|
+
# mechanical
|
|
42
|
+
"analyst": "mechanical",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def routing_summary() -> str:
|
|
47
|
+
"""One-line role→provider/model@effort summary, or '' if unavailable."""
|
|
48
|
+
try:
|
|
49
|
+
from core.runtime.model_router import resolve_all
|
|
50
|
+
items = resolve_all()
|
|
51
|
+
except Exception:
|
|
52
|
+
return ""
|
|
53
|
+
parts = [
|
|
54
|
+
f"{i.role}={i.provider}/{i.model or '?'}@{i.effort}" for i in items
|
|
55
|
+
]
|
|
56
|
+
return " ".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def routing_directive() -> str:
|
|
60
|
+
"""Full SessionStart block: the config plus how to honor it.
|
|
61
|
+
|
|
62
|
+
Returns '' when the config cannot be read, so the hook can skip the
|
|
63
|
+
block entirely rather than inject an empty directive.
|
|
64
|
+
"""
|
|
65
|
+
summary = routing_summary()
|
|
66
|
+
if not summary:
|
|
67
|
+
return ""
|
|
68
|
+
# No backticks: this string is embedded in session-start.sh which
|
|
69
|
+
# passes it through $(echo -e "$MSG") — backticks would trigger shell
|
|
70
|
+
# command substitution. Use plain quotes.
|
|
71
|
+
return (
|
|
72
|
+
"[ARKA:MODEL-FABRIC] The operator's model routing (from "
|
|
73
|
+
"~/.arkaos/models.yaml — dashboard / npx arkaos models):\n"
|
|
74
|
+
f" {summary}\n"
|
|
75
|
+
"When dispatching a subagent via the Task tool, set its model "
|
|
76
|
+
"param from this table by the KIND of work it does (design, "
|
|
77
|
+
"review, architecture, strategy, quality_gate, execution, "
|
|
78
|
+
"mechanical) — this is the user's explicit choice and OVERRIDES "
|
|
79
|
+
"the agent YAML default. Quality roles never downgrade below the "
|
|
80
|
+
"configured model (excellence-mandate). Genuinely mechanical "
|
|
81
|
+
"dispatches (commit messages, formatting) use the mechanical role."
|
|
82
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Ollama discovery — is it installed, is it running, which models exist.
|
|
2
|
+
|
|
3
|
+
Model Fabric PR-C. Feeds three consumers: the `npx arkaos models` CLI
|
|
4
|
+
(show what the machine can actually run), the `/arka-fusion` advisor
|
|
5
|
+
(recommend local models for panel/mechanical roles), and the dashboard
|
|
6
|
+
Models page. Read-only: never starts the daemon, never pulls models.
|
|
7
|
+
|
|
8
|
+
Detection is two-layered on purpose: the binary can be installed while
|
|
9
|
+
the daemon is stopped (installed=True, running=False lets the advisor
|
|
10
|
+
say "start Ollama to unlock N local models" instead of pretending
|
|
11
|
+
Ollama does not exist).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
|
|
23
|
+
_DEFAULT_HOST = "http://localhost:11434"
|
|
24
|
+
_TAGS_TIMEOUT_S = 2.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class OllamaModel:
|
|
29
|
+
"""One locally available model as reported by /api/tags."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
size_gb: float
|
|
33
|
+
family: str
|
|
34
|
+
parameter_size: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class OllamaStatus:
|
|
39
|
+
"""Discovery result for the local Ollama installation."""
|
|
40
|
+
|
|
41
|
+
installed: bool
|
|
42
|
+
running: bool
|
|
43
|
+
host: str
|
|
44
|
+
models: list[OllamaModel] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return asdict(self)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _host() -> str:
|
|
51
|
+
return os.environ.get("OLLAMA_HOST", _DEFAULT_HOST).rstrip("/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def binary_installed() -> bool:
|
|
55
|
+
"""True when the `ollama` binary is on PATH."""
|
|
56
|
+
return shutil.which("ollama") is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fetch_tags(host: str) -> list[dict] | None:
|
|
60
|
+
"""GET /api/tags; None when the daemon is unreachable."""
|
|
61
|
+
try:
|
|
62
|
+
with urllib.request.urlopen(
|
|
63
|
+
f"{host}/api/tags", timeout=_TAGS_TIMEOUT_S
|
|
64
|
+
) as response:
|
|
65
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
66
|
+
except (urllib.error.URLError, TimeoutError, OSError,
|
|
67
|
+
json.JSONDecodeError):
|
|
68
|
+
return None
|
|
69
|
+
models = data.get("models")
|
|
70
|
+
return models if isinstance(models, list) else []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_model(record: dict) -> OllamaModel:
|
|
74
|
+
details = record.get("details") or {}
|
|
75
|
+
size_bytes = int(record.get("size", 0) or 0)
|
|
76
|
+
return OllamaModel(
|
|
77
|
+
name=str(record.get("name", "")),
|
|
78
|
+
size_gb=round(size_bytes / 1_000_000_000, 1),
|
|
79
|
+
family=str(details.get("family", "") or ""),
|
|
80
|
+
parameter_size=str(details.get("parameter_size", "") or ""),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover() -> OllamaStatus:
|
|
85
|
+
"""Full discovery: binary on PATH, daemon reachable, model list."""
|
|
86
|
+
host = _host()
|
|
87
|
+
tags = _fetch_tags(host)
|
|
88
|
+
if tags is None:
|
|
89
|
+
return OllamaStatus(
|
|
90
|
+
installed=binary_installed(), running=False, host=host
|
|
91
|
+
)
|
|
92
|
+
models = [_parse_model(r) for r in tags if r.get("name")]
|
|
93
|
+
models.sort(key=lambda m: m.name)
|
|
94
|
+
# Daemon answering means Ollama is effectively installed even if the
|
|
95
|
+
# binary is not on this shell's PATH (e.g. Docker deployment).
|
|
96
|
+
return OllamaStatus(installed=True, running=True, host=host, models=models)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Known models per runtime, for the Model Fabric UI + advisor.
|
|
2
|
+
|
|
3
|
+
The Model Fabric ``runtime`` provider shells out to the active CLI. This
|
|
4
|
+
module lists the concrete models that CLI accepts, with human labels and
|
|
5
|
+
tier, so the dashboard dropdown and ``/arka-fusion`` can offer real
|
|
6
|
+
choices (Fable 5, Opus, Sonnet, Haiku) instead of only the abstract
|
|
7
|
+
best/default/fast aliases.
|
|
8
|
+
|
|
9
|
+
Model IDs verified against the Claude API reference (2026-07). This is
|
|
10
|
+
the single place they are hand-listed — update HERE when the runtime
|
|
11
|
+
ships new models; the dashboard and CLI read from this module. Codex /
|
|
12
|
+
Gemini / Cursor models are left empty on purpose: we do not hardcode
|
|
13
|
+
another vendor's catalogue, and the UI falls back to a free-text field
|
|
14
|
+
so the user types the exact id their runtime accepts.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Claude Code accepts the short aliases opus/sonnet/haiku and full model
|
|
21
|
+
# IDs. `value` is what gets written to models.yaml as the role's model.
|
|
22
|
+
CLAUDE_CODE_MODELS: list[dict[str, str]] = [
|
|
23
|
+
{"value": "claude-fable-5", "label": "Fable 5", "tier": "frontier",
|
|
24
|
+
"note": "most capable"},
|
|
25
|
+
{"value": "opus", "label": "Opus 4.8", "tier": "frontier",
|
|
26
|
+
"note": "frontier"},
|
|
27
|
+
{"value": "sonnet", "label": "Sonnet 5", "tier": "balanced",
|
|
28
|
+
"note": "balanced speed/quality"},
|
|
29
|
+
{"value": "haiku", "label": "Haiku 4.5", "tier": "fast",
|
|
30
|
+
"note": "fast + cheap"},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_MODELS_BY_RUNTIME: dict[str, list[dict[str, str]]] = {
|
|
34
|
+
"claude-code": CLAUDE_CODE_MODELS,
|
|
35
|
+
# codex / gemini / cursor intentionally omitted — free-text in the UI.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def models_for_runtime(runtime_id: str) -> list[dict[str, str]]:
|
|
40
|
+
"""Return the known runtime models for the given runtime id."""
|
|
41
|
+
return _MODELS_BY_RUNTIME.get(runtime_id, [])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_runtime_models() -> tuple[str, list[dict[str, str]]]:
|
|
45
|
+
"""Best-effort (runtime_id, models) for the active runtime."""
|
|
46
|
+
try:
|
|
47
|
+
from core.runtime.registry import detect_runtime
|
|
48
|
+
runtime_id = detect_runtime()
|
|
49
|
+
except Exception:
|
|
50
|
+
runtime_id = "claude-code"
|
|
51
|
+
return runtime_id, models_for_runtime(runtime_id)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Persistent per-session flow authorization — enforcer resilience.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes NO hook access to the current assistant message
|
|
4
|
+
(confirmed against the hook docs, 2026-07-05: neither PreToolUse nor
|
|
5
|
+
PostToolUse carries the message text, and the transcript at
|
|
6
|
+
``transcript_path`` does not yet include the in-progress turn). So the
|
|
7
|
+
current turn's ``[arka:routing]`` marker is structurally invisible to
|
|
8
|
+
that turn's own tool calls, and the old PostToolUse marker cache read a
|
|
9
|
+
non-existent ``assistant_message`` field — it was never written, leaving
|
|
10
|
+
the enforcer with only a 20-message transcript window that a long
|
|
11
|
+
session or a context compaction empties (652 false-positive blocks
|
|
12
|
+
observed in one session).
|
|
13
|
+
|
|
14
|
+
This module rebuilds resilience on what hooks CAN see: markers observed
|
|
15
|
+
in the transcript (past turns) plus persistent ``/tmp`` state that
|
|
16
|
+
survives compaction and window-rolling.
|
|
17
|
+
|
|
18
|
+
Two tiers:
|
|
19
|
+
|
|
20
|
+
- **CONFIRMED** — written whenever a flow marker is actually observed in
|
|
21
|
+
the transcript. Valid for a session TTL, survives compaction. Grants
|
|
22
|
+
a silent allow. This is the normal steady state: route once, then
|
|
23
|
+
every subsequent turn is authorised.
|
|
24
|
+
- **TURN GRACE** — the current turn's marker cannot be seen, so the very
|
|
25
|
+
first turn of a session (before any marker reaches the transcript) has
|
|
26
|
+
no confirmed auth. Rather than a false-positive block, the turn is
|
|
27
|
+
allowed WITH A WARNING and a per-turn grace flag so the rest of the
|
|
28
|
+
turn proceeds. A never-routing session is warned every turn and, after
|
|
29
|
+
``GRACE_CAP`` consecutive graced turns with no confirmation, escalates
|
|
30
|
+
to a hard block — a normally-routing session confirms by turn 2 and
|
|
31
|
+
never reaches the cap.
|
|
32
|
+
|
|
33
|
+
State dir: ``/tmp/arkaos-flow-auth`` (override via ``ARKA_FLOW_AUTH_DIR``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import time
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
45
|
+
|
|
46
|
+
_safe_session_id = _safe_session_id_module.safe_session_id
|
|
47
|
+
|
|
48
|
+
# A working session; after this a stale /tmp record expires on its own.
|
|
49
|
+
DEFAULT_TTL_SECONDS = 12 * 60 * 60
|
|
50
|
+
# Consecutive graced turns with no confirmation before a hard block.
|
|
51
|
+
GRACE_CAP = 3
|
|
52
|
+
|
|
53
|
+
VALID_MARKER_TYPES: frozenset[str] = frozenset(
|
|
54
|
+
{"routing", "trivial", "gate", "phase"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class GraceState:
|
|
60
|
+
count: int
|
|
61
|
+
escalate: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _base_dir() -> Path:
|
|
65
|
+
override = os.environ.get("ARKA_FLOW_AUTH_DIR", "").strip()
|
|
66
|
+
return Path(override) if override else Path("/tmp/arkaos-flow-auth")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _auth_path(session_id: str) -> Path | None:
|
|
70
|
+
safe = _safe_session_id(session_id)
|
|
71
|
+
return (_base_dir() / f"{safe}.json") if safe else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read(path: Path | None) -> dict:
|
|
75
|
+
if path is None or not path.exists():
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (json.JSONDecodeError, OSError):
|
|
80
|
+
return {}
|
|
81
|
+
return data if isinstance(data, dict) else {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write(path: Path | None, data: dict) -> None:
|
|
85
|
+
if path is None:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass # authorization state must never block the hook
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def confirm(session_id: str, marker_type: str) -> None:
|
|
95
|
+
"""Persist a confirmed authorization (a marker was observed)."""
|
|
96
|
+
if marker_type not in VALID_MARKER_TYPES:
|
|
97
|
+
return
|
|
98
|
+
path = _auth_path(session_id)
|
|
99
|
+
if path is None:
|
|
100
|
+
return
|
|
101
|
+
_write(path, {
|
|
102
|
+
"marker_type": marker_type,
|
|
103
|
+
"confirmed_ts": time.time(),
|
|
104
|
+
"grace_count": 0, # confirmation clears accumulated grace
|
|
105
|
+
"turn_grace": False,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def confirmed_record(
|
|
110
|
+
session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
|
|
111
|
+
) -> dict | None:
|
|
112
|
+
"""Return the confirmed record if present and within TTL, else None."""
|
|
113
|
+
data = _read(_auth_path(session_id))
|
|
114
|
+
ts = data.get("confirmed_ts")
|
|
115
|
+
if ts is None:
|
|
116
|
+
return None
|
|
117
|
+
if time.time() - float(ts) > ttl_seconds:
|
|
118
|
+
return None
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_confirmed(
|
|
123
|
+
session_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS
|
|
124
|
+
) -> bool:
|
|
125
|
+
return confirmed_record(session_id, ttl_seconds) is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def grant_turn_grace(session_id: str) -> None:
|
|
129
|
+
"""Flag the current turn as graced so its remaining tools pass."""
|
|
130
|
+
path = _auth_path(session_id)
|
|
131
|
+
if path is None:
|
|
132
|
+
return
|
|
133
|
+
data = _read(path)
|
|
134
|
+
data["turn_grace"] = True
|
|
135
|
+
_write(path, data)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def has_turn_grace(session_id: str) -> bool:
|
|
139
|
+
return bool(_read(_auth_path(session_id)).get("turn_grace"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def reset_turn(session_id: str) -> None:
|
|
143
|
+
"""Clear the per-turn grace flag (called at each new user prompt).
|
|
144
|
+
|
|
145
|
+
Confirmed authorization and the grace counter are preserved — only
|
|
146
|
+
the within-turn flag resets, so the next turn re-evaluates.
|
|
147
|
+
"""
|
|
148
|
+
path = _auth_path(session_id)
|
|
149
|
+
if path is None:
|
|
150
|
+
return
|
|
151
|
+
data = _read(path)
|
|
152
|
+
if data.get("turn_grace"):
|
|
153
|
+
data["turn_grace"] = False
|
|
154
|
+
_write(path, data)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def grace_count(session_id: str) -> int:
|
|
158
|
+
return int(_read(_auth_path(session_id)).get("grace_count", 0) or 0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def register_grace(session_id: str) -> GraceState:
|
|
162
|
+
"""Increment the consecutive-grace counter; escalate past the cap."""
|
|
163
|
+
path = _auth_path(session_id)
|
|
164
|
+
if path is None:
|
|
165
|
+
return GraceState(count=0, escalate=False)
|
|
166
|
+
data = _read(path)
|
|
167
|
+
count = int(data.get("grace_count", 0) or 0) + 1
|
|
168
|
+
data["grace_count"] = count
|
|
169
|
+
_write(path, data)
|
|
170
|
+
return GraceState(count=count, escalate=count > GRACE_CAP)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def clear(session_id: str) -> None:
|
|
174
|
+
"""Remove all authorization state for a session (tests / session end)."""
|
|
175
|
+
path = _auth_path(session_id)
|
|
176
|
+
if path is None:
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
path.unlink()
|
|
180
|
+
except (FileNotFoundError, OSError):
|
|
181
|
+
pass
|
|
@@ -25,7 +25,7 @@ from datetime import datetime, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from core.shared import safe_session_id as _safe_session_id_module
|
|
28
|
-
from core.workflow import marker_cache
|
|
28
|
+
from core.workflow import flow_authorization, marker_cache
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
31
|
import fcntl # POSIX only
|
|
@@ -222,15 +222,24 @@ class Decision:
|
|
|
222
222
|
marker_found: str | None = None
|
|
223
223
|
phase_observed: str | None = None
|
|
224
224
|
bypass_used: bool = False
|
|
225
|
+
warning: str = ""
|
|
225
226
|
|
|
226
227
|
def to_stderr_message(self) -> str:
|
|
227
228
|
if self.allow:
|
|
228
|
-
return
|
|
229
|
+
return self.warning
|
|
230
|
+
# Recovery that actually works, given Claude Code exposes no
|
|
231
|
+
# current-message text to hooks (the inline `ARKA_BYPASS_FLOW=1`
|
|
232
|
+
# never reached this separate hook process — a documented trap).
|
|
229
233
|
return (
|
|
230
|
-
f"[ARKA:ENFORCEMENT]
|
|
231
|
-
f"
|
|
232
|
-
f"
|
|
233
|
-
f"
|
|
234
|
+
f"[ARKA:ENFORCEMENT] No routing marker for "
|
|
235
|
+
f"{flow_authorization.GRACE_CAP}+ turns. Emit "
|
|
236
|
+
f"`[arka:routing] <dept> -> <lead>` (or `[arka:trivial] "
|
|
237
|
+
f"<reason>`) at the START of your reply — it authorises this "
|
|
238
|
+
f"turn and every turn after. To turn enforcement off set "
|
|
239
|
+
f"hooks.hardEnforcement=false in ~/.arkaos/config.json; to "
|
|
240
|
+
f"bypass programmatically export ARKA_BYPASS_FLOW=1 in Claude "
|
|
241
|
+
f"Code's own environment (settings.json env), not inline on a "
|
|
242
|
+
f"command. Reason: {self.reason}."
|
|
234
243
|
)
|
|
235
244
|
|
|
236
245
|
|
|
@@ -414,18 +423,54 @@ def evaluate(
|
|
|
414
423
|
)
|
|
415
424
|
marker_found, phase_observed = _scan_markers(messages)
|
|
416
425
|
|
|
417
|
-
if marker_found is None:
|
|
426
|
+
if marker_found is not None:
|
|
427
|
+
# Persist a confirmed authorization so this survives compaction and
|
|
428
|
+
# the 20-message window rolling — the fix for the 652 false blocks.
|
|
429
|
+
flow_authorization.confirm(session_id, marker_found)
|
|
418
430
|
return Decision(
|
|
419
|
-
allow=
|
|
420
|
-
reason=f"
|
|
431
|
+
allow=True,
|
|
432
|
+
reason=f"marker-found:{marker_found}",
|
|
433
|
+
marker_found=marker_found,
|
|
421
434
|
phase_observed=phase_observed,
|
|
422
435
|
)
|
|
423
436
|
|
|
437
|
+
# No marker in the transcript window. The current turn's marker is
|
|
438
|
+
# structurally invisible to hooks, so fall back to persistent auth
|
|
439
|
+
# before ever blocking.
|
|
440
|
+
if flow_authorization.is_confirmed(session_id):
|
|
441
|
+
return Decision(
|
|
442
|
+
allow=True, reason="session-authorized", phase_observed=phase_observed
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Same turn, already graced: let the rest of the turn's tools through.
|
|
446
|
+
if flow_authorization.has_turn_grace(session_id):
|
|
447
|
+
return Decision(
|
|
448
|
+
allow=True, reason="turn-grace", phase_observed=phase_observed
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# First effect-tool of a turn with no confirmed auth. A hard deny here
|
|
452
|
+
# is a false positive (the assistant may have routed in this very
|
|
453
|
+
# message — invisible to us). Grace it with a warning; escalate to a
|
|
454
|
+
# real block only after GRACE_CAP consecutive graced turns without any
|
|
455
|
+
# confirmation (a normally-routing session confirms by turn 2).
|
|
456
|
+
grace = flow_authorization.register_grace(session_id)
|
|
457
|
+
if grace.escalate:
|
|
458
|
+
return Decision(
|
|
459
|
+
allow=False,
|
|
460
|
+
reason=f"no-marker-and-grace-exhausted-after-{grace.count}-turns",
|
|
461
|
+
phase_observed=phase_observed,
|
|
462
|
+
)
|
|
463
|
+
flow_authorization.grant_turn_grace(session_id)
|
|
424
464
|
return Decision(
|
|
425
465
|
allow=True,
|
|
426
|
-
reason=f"
|
|
427
|
-
marker_found=marker_found,
|
|
466
|
+
reason=f"first-tool-grace:{grace.count}",
|
|
428
467
|
phase_observed=phase_observed,
|
|
468
|
+
warning=(
|
|
469
|
+
f"[arka:suggest] Effect tool allowed without an observable "
|
|
470
|
+
f"routing marker (grace {grace.count}/{flow_authorization.GRACE_CAP}). "
|
|
471
|
+
f"Emit `[arka:routing] <dept> -> <lead>` at the start of your "
|
|
472
|
+
f"reply to authorise this session."
|
|
473
|
+
),
|
|
429
474
|
)
|
|
430
475
|
|
|
431
476
|
|