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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -1
  4. package/arka/skills/fusion/SKILL.md +98 -0
  5. package/config/hooks/session-start.sh +23 -0
  6. package/core/fusion/__init__.py +5 -0
  7. package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
  8. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  9. package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
  10. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  11. package/core/fusion/cli.py +88 -0
  12. package/core/fusion/engine.py +153 -0
  13. package/core/fusion/panel_builder.py +58 -0
  14. package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
  15. package/core/governance/redo_counter.py +81 -0
  16. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  17. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  18. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  19. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  20. package/core/hooks/post_tool_use.py +26 -12
  21. package/core/hooks/pre_tool_use.py +4 -0
  22. package/core/hooks/user_prompt_submit.py +7 -0
  23. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  24. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  25. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  30. package/core/runtime/model_router.py +17 -0
  31. package/core/runtime/model_router_cli.py +70 -1
  32. package/core/runtime/model_routing_context.py +82 -0
  33. package/core/runtime/ollama_discovery.py +96 -0
  34. package/core/runtime/runtime_models.py +51 -0
  35. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  37. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  39. package/core/workflow/flow_authorization.py +181 -0
  40. package/core/workflow/flow_enforcer.py +56 -11
  41. package/dashboard/app/layouts/default.vue +7 -0
  42. package/dashboard/app/pages/models.vue +423 -0
  43. package/installer/cli.js +21 -0
  44. package/package.json +1 -1
  45. package/pyproject.toml +1 -1
  46. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  47. 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)
@@ -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] Missing `[arka:routing] <dept> -> <lead>` or "
231
- f"`[arka:trivial] <reason>` before "
232
- f"Write/Edit/MultiEdit/NotebookEdit/Task/Skill/Bash-effect. "
233
- f"Bypass once with `ARKA_BYPASS_FLOW=1`. Reason: {self.reason}."
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=False,
420
- reason=f"no-flow-marker-in-last-{ASSISTANT_WINDOW}-assistant-messages",
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"marker-found:{marker_found}",
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
 
@@ -52,6 +52,13 @@ const links = [[{
52
52
  onSelect: () => {
53
53
  open.value = false
54
54
  }
55
+ }, {
56
+ label: 'Models',
57
+ icon: 'i-lucide-layers',
58
+ to: '/models',
59
+ onSelect: () => {
60
+ open.value = false
61
+ }
55
62
  }, {
56
63
  label: 'Tasks',
57
64
  icon: 'i-lucide-list-checks',