arkaos 4.3.0 → 4.3.2

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 (62) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +4 -4
  4. package/arka/skills/costs/SKILL.md +1 -1
  5. package/arka/skills/fusion/SKILL.md +13 -6
  6. package/config/claude-agents/marta-cqo.md +1 -1
  7. package/config/claude-agents/paulo-tech-lead.md +1 -1
  8. package/config/constitution.yaml +5 -5
  9. package/config/hooks/_lib/arka_python.ps1 +43 -0
  10. package/config/hooks/_lib/arka_python.sh +63 -0
  11. package/config/hooks/agent-provision.sh +8 -1
  12. package/config/hooks/cwd-changed.sh +6 -2
  13. package/config/hooks/post-tool-use-v2.sh +6 -2
  14. package/config/hooks/post-tool-use.sh +8 -17
  15. package/config/hooks/pre-tool-use.ps1 +8 -6
  16. package/config/hooks/pre-tool-use.sh +8 -17
  17. package/config/hooks/session-start.sh +40 -15
  18. package/config/hooks/stop.ps1 +7 -5
  19. package/config/hooks/stop.sh +8 -17
  20. package/config/hooks/token-hygiene.sh +5 -1
  21. package/config/hooks/user-prompt-submit-v2.sh +13 -9
  22. package/config/hooks/user-prompt-submit.sh +8 -17
  23. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  24. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  25. package/core/fusion/cli.py +57 -6
  26. package/core/fusion/panel_builder.py +58 -0
  27. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  28. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  29. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  30. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  31. package/core/hooks/post_tool_use.py +26 -12
  32. package/core/hooks/pre_tool_use.py +4 -0
  33. package/core/hooks/user_prompt_submit.py +7 -0
  34. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  35. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  36. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  37. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  38. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  39. package/core/runtime/__pycache__/openrouter_provider.cpython-312.pyc +0 -0
  40. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  41. package/core/runtime/model_router.py +17 -0
  42. package/core/runtime/model_routing_context.py +82 -0
  43. package/core/runtime/runtime_models.py +51 -0
  44. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  45. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  46. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  48. package/core/workflow/flow_authorization.py +181 -0
  49. package/core/workflow/flow_enforcer.py +56 -11
  50. package/core/workflow/state_reader.sh +12 -5
  51. package/dashboard/app/pages/models.vue +23 -4
  52. package/departments/ops/skills/update/SKILL.md +2 -2
  53. package/departments/ops/skills/update/references/sync-engine.md +1 -1
  54. package/departments/quality/SKILL.md +1 -1
  55. package/installer/cli.js +21 -0
  56. package/installer/index.js +14 -0
  57. package/installer/update.js +13 -0
  58. package/package.json +5 -2
  59. package/pyproject.toml +1 -1
  60. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  61. package/scripts/dashboard-api.py +13 -2
  62. package/scripts/seed_initial_patterns.py +1 -1
@@ -13,6 +13,10 @@
13
13
 
14
14
  set +e # never fail
15
15
 
16
+ # ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
17
+ _ARKA_LIB="$(dirname "${BASH_SOURCE[0]:-$0}")/_lib/arka_python.sh"
18
+ if [ -f "$_ARKA_LIB" ]; then . "$_ARKA_LIB"; else ARKA_PY="python3"; fi
19
+
16
20
  _prompt="${ARKA_PROMPT:-}"
17
21
  _transcript="${ARKA_TRANSCRIPT_PATH:-}"
18
22
  _suggestions=""
@@ -64,7 +68,7 @@ if [ -n "$_transcript" ] && [ -f "$_transcript" ] && [ -n "$_prompt" ]; then
64
68
  _cur_kw=$(_kw "$_prompt" | head -20)
65
69
  # pull last 3 user messages from transcript jsonl
66
70
  _prior=$(tail -n 200 "$_transcript" 2>/dev/null \
67
- | python3 -c "
71
+ | "$ARKA_PY" -c "
68
72
  import sys, json
69
73
  msgs=[]
70
74
  for line in sys.stdin:
@@ -7,6 +7,10 @@
7
7
 
8
8
  input=$(cat)
9
9
 
10
+ # ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
11
+ _ARKA_LIB="$(dirname "${BASH_SOURCE[0]:-$0}")/_lib/arka_python.sh"
12
+ if [ -f "$_ARKA_LIB" ]; then . "$_ARKA_LIB"; else ARKA_PY="python3"; fi
13
+
10
14
  # ─── V1 Migration Detection ─────────────────────────────────────────────
11
15
  V1_PATHS=("$HOME/.claude/skills/arka-os" "$HOME/.claude/skills/arkaos")
12
16
  MIGRATION_MARKER="$HOME/.arkaos/migrated-from-v1"
@@ -27,12 +31,12 @@ if [ -f "$ARKAOS_VERSION_FILE" ]; then
27
31
  if [ -f "$_REPO_PATH/VERSION" ]; then
28
32
  _CURRENT_VERSION=$(cat "$_REPO_PATH/VERSION")
29
33
  elif [ -f "$_REPO_PATH/package.json" ]; then
30
- _CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('$_REPO_PATH/package.json'))['version'])" 2>/dev/null || echo "")
34
+ _CURRENT_VERSION=$("$ARKA_PY" -c "import json; print(json.load(open('$_REPO_PATH/package.json'))['version'])" 2>/dev/null || echo "")
31
35
  fi
32
36
 
33
37
  if [ -n "${_CURRENT_VERSION:-}" ]; then
34
38
  if [ -f "$SYNC_STATE" ]; then
35
- _SYNCED_VERSION=$(python3 -c "import json; print(json.load(open('$SYNC_STATE'))['version'])" 2>/dev/null || echo "none")
39
+ _SYNCED_VERSION=$("$ARKA_PY" -c "import json; print(json.load(open('$SYNC_STATE'))['version'])" 2>/dev/null || echo "none")
36
40
  else
37
41
  _SYNCED_VERSION="none"
38
42
  fi
@@ -53,9 +57,9 @@ if [ ! -f "$_SESSION_MARKER" ] && [ ! -f "/tmp/arkaos-greeted-today" ]; then
53
57
  _GREETING_COMPANY=""
54
58
  _GREETING_VERSION=""
55
59
 
56
- if [ -f "$HOME/.arkaos/profile.json" ] && command -v python3 &>/dev/null; then
57
- _GREETING_NAME=$(python3 -c "import json; p=json.load(open('$HOME/.arkaos/profile.json')); print(p.get('name', p.get('role', 'founder')))" 2>/dev/null)
58
- _GREETING_COMPANY=$(python3 -c "import json; print(json.load(open('$HOME/.arkaos/profile.json')).get('company', ''))" 2>/dev/null)
60
+ if [ -f "$HOME/.arkaos/profile.json" ] && command -v "$ARKA_PY" >/dev/null 2>&1; then
61
+ _GREETING_NAME=$("$ARKA_PY" -c "import json; p=json.load(open('$HOME/.arkaos/profile.json')); print(p.get('name', p.get('role', 'founder')))" 2>/dev/null)
62
+ _GREETING_COMPANY=$("$ARKA_PY" -c "import json; print(json.load(open('$HOME/.arkaos/profile.json')).get('company', ''))" 2>/dev/null)
59
63
  fi
60
64
 
61
65
  if [ -f "$HOME/.arkaos/.repo-path" ]; then
@@ -111,12 +115,12 @@ fi
111
115
  python_result=""
112
116
  BRIDGE_SCRIPT="${ARKAOS_ROOT}/scripts/synapse-bridge.py"
113
117
 
114
- if command -v python3 &>/dev/null && [ -f "$BRIDGE_SCRIPT" ]; then
115
- bridge_output=$(echo "{\"user_input\":$(echo "$user_input" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo '""')}" \
116
- | ARKAOS_ROOT="$ARKAOS_ROOT" python3 "$BRIDGE_SCRIPT" --root "$ARKAOS_ROOT" 2>/dev/null)
118
+ if command -v "$ARKA_PY" >/dev/null 2>&1 && [ -f "$BRIDGE_SCRIPT" ]; then
119
+ bridge_output=$(echo "{\"user_input\":$(echo "$user_input" | "$ARKA_PY" -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo '""')}" \
120
+ | ARKAOS_ROOT="$ARKAOS_ROOT" "$ARKA_PY" "$BRIDGE_SCRIPT" --root "$ARKAOS_ROOT" 2>/dev/null)
117
121
 
118
122
  if [ -n "$bridge_output" ]; then
119
- python_result=$(echo "$bridge_output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('context_string',''))" 2>/dev/null)
123
+ python_result=$(echo "$bridge_output" | "$ARKA_PY" -c "import sys,json; print(json.loads(sys.stdin.read()).get('context_string',''))" 2>/dev/null)
120
124
  fi
121
125
  fi
122
126
 
@@ -34,9 +34,13 @@ if [ -z "${ARKAOS_ROOT:-}" ]; then
34
34
  fi
35
35
  export ARKAOS_ROOT
36
36
 
37
+ # ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
38
+ _ARKA_LIB="$(dirname "${BASH_SOURCE[0]:-$0}")/_lib/arka_python.sh"
39
+ if [ -f "$_ARKA_LIB" ]; then . "$_ARKA_LIB"; else ARKA_PY="python3"; fi
40
+
37
41
  _ARKA_L0_FALLBACK='{"additionalContext": "[Constitution] NON-NEGOTIABLE: branch-isolation, obsidian-output, authority-boundaries, security-gate, context-first, solid-clean-code, spec-driven, human-writing, squad-routing, full-visibility, sequential-validation, mandatory-qa, arka-supremacy | QUALITY-GATE: marta-cqo, eduardo-copy, francisca-tech-ux | MUST: conventional-commits, test-coverage, pattern-matching, actionable-output, memory-persistence"}'
38
42
 
39
- if ! command -v python3 &>/dev/null; then
43
+ if ! command -v "$ARKA_PY" >/dev/null 2>&1; then
40
44
  echo "$_ARKA_L0_FALLBACK"
41
45
  exit 0
42
46
  fi
@@ -54,20 +58,7 @@ if [ ! -f "$ARKAOS_ROOT/core/hooks/user_prompt_submit.py" ]; then
54
58
  fi
55
59
  fi
56
60
 
57
- # Interpreter resolution: prefer the ArkaOS venv (has pyyaml/pydantic);
58
- # otherwise probe an unrelated project venv first on PATH would give a
59
- # python3 without yaml and silently degrade every gate.
60
- _PY=""
61
- for _cand in "$HOME/.arkaos/venv/bin/python3" "$HOME/.arkaos/.venv/bin/python3"; do
62
- if [ -x "$_cand" ]; then _PY="$_cand"; break; fi
63
- done
64
- if [ -z "$_PY" ]; then
65
- _PY="python3"
66
- if ! "$_PY" -c "import yaml" 2>/dev/null; then
67
- for _cand in /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/bin/python3; do
68
- if [ -x "$_cand" ] && "$_cand" -c "import yaml" 2>/dev/null; then _PY="$_cand"; break; fi
69
- done
70
- fi
71
- fi
61
+ # Interpreter resolution handled by the shared resolver (ARKA_PY): prefers
62
+ # the ArkaOS venv (has pyyaml/pydantic), falls back to a yaml-capable python3.
72
63
  PYTHONPATH="$ARKAOS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
73
- exec "$_PY" -m core.hooks.user_prompt_submit
64
+ exec "$ARKA_PY" -m core.hooks.user_prompt_submit
@@ -1,8 +1,15 @@
1
- """Fusion CLI — smoke path for the panel → judge pipeline.
1
+ """Fusion CLI — run the panel → judge → synthesis pipeline.
2
2
 
3
3
  Usage::
4
4
 
5
5
  python -m core.fusion.cli "your question"
6
+ python -m core.fusion.cli --save "your question" # persist the panel
7
+ python -m core.fusion.cli --show # show the panel only
8
+
9
+ When ``models.yaml`` has no ``fusion.panel``, a default is built from the
10
+ machine's models (runtime + local Ollama) so fusion works out of the box.
11
+ ``--save`` writes that panel into ``~/.arkaos/models.yaml`` so every
12
+ fusion consumer (and the dashboard) sees it.
6
13
  """
7
14
 
8
15
  from __future__ import annotations
@@ -10,16 +17,60 @@ from __future__ import annotations
10
17
  import sys
11
18
 
12
19
  from core.fusion.engine import FusionUnavailable, fuse
20
+ from core.fusion.panel_builder import default_panel, describe_panel
21
+ from core.runtime.model_router import load_config
22
+
23
+
24
+ def _save_panel(panel, judge) -> str:
25
+ import yaml
26
+ from core.runtime.model_router import USER_CONFIG_PATH, ensure_user_config
27
+ path = ensure_user_config()
28
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
29
+ data.setdefault("fusion", {})
30
+ data["fusion"]["judge"] = judge.model_dump()
31
+ data["fusion"]["panel"] = [c.model_dump() for c in panel]
32
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
33
+ return str(USER_CONFIG_PATH)
13
34
 
14
35
 
15
36
  def main(argv: list[str] | None = None) -> int:
16
- args = argv if argv is not None else sys.argv[1:]
17
- if not args:
18
- print("usage: python -m core.fusion.cli \"question\"", file=sys.stderr)
37
+ args = list(argv if argv is not None else sys.argv[1:])
38
+ save = "--save" in args
39
+ show = "--show" in args
40
+ args = [a for a in args if a not in ("--save", "--show")]
41
+
42
+ config, _ = load_config()
43
+ panel, judge = default_panel(config)
44
+
45
+ if show:
46
+ print(f"\n Fusion panel — {describe_panel(panel, judge)}")
47
+ if not config.fusion.panel:
48
+ print(" (default built from the machine; run --save to persist)")
49
+ print()
50
+ return 0
51
+
52
+ if len(panel) < 2:
53
+ print(" ✗ No panel available — configure fusion.panel in "
54
+ "models.yaml (try /arka-fusion) or start Ollama with 2+ "
55
+ "models for a local panel.", file=sys.stderr)
19
56
  return 1
20
- prompt = " ".join(args)
57
+
58
+ if save:
59
+ where = _save_panel(panel, judge)
60
+ print(f" ✓ Fusion panel saved to {where}")
61
+ config, _ = load_config() # reload so fuse() uses the saved panel
62
+
63
+ prompt = " ".join(args).strip()
64
+ if not prompt:
65
+ print("usage: python -m core.fusion.cli [--save] \"question\"",
66
+ file=sys.stderr)
67
+ return 1
68
+
69
+ # Build an effective config carrying the (possibly default) panel.
70
+ config.fusion.panel = panel
71
+ config.fusion.judge = judge
21
72
  try:
22
- result = fuse(prompt)
73
+ result = fuse(prompt, config=config)
23
74
  except FusionUnavailable as exc:
24
75
  print(f" ✗ {exc}", file=sys.stderr)
25
76
  return 1
@@ -0,0 +1,58 @@
1
+ """Build a fusion panel from what the machine can actually run.
2
+
3
+ Fusion needs a diverse panel + a judge. The user's ``models.yaml`` may
4
+ have an explicit ``fusion.panel`` (configured via ``/arka-fusion``); when
5
+ it is empty, this builds a sensible default from discovered models so
6
+ ``npx arkaos fusion "question"`` does something out of the box instead of
7
+ failing with FusionUnavailable.
8
+
9
+ Diversity is the point (OpenRouter's result: a diverse panel beats a
10
+ single model): pair the runtime model with up to two large, distinct
11
+ local Ollama models. The judge stays the configured frontier model.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from core.runtime.model_router import ModelsConfig, RoleChoice, load_config
17
+
18
+
19
+ def default_panel(
20
+ config: ModelsConfig | None = None,
21
+ ) -> tuple[list[RoleChoice], RoleChoice]:
22
+ """Return (panel, judge). Respects an explicit panel; else builds one.
23
+
24
+ An empty return panel means the machine offers nothing to fuse (no
25
+ local models and no explicit config) — the caller reports that rather
26
+ than fanning out to a single seat.
27
+ """
28
+ if config is None:
29
+ config, _ = load_config()
30
+ judge = config.fusion.judge
31
+ if config.fusion.panel:
32
+ return list(config.fusion.panel), judge
33
+
34
+ panel: list[RoleChoice] = [
35
+ RoleChoice(provider="runtime", model="default", effort="high"),
36
+ ]
37
+ try:
38
+ from core.runtime.ollama_discovery import discover
39
+ status = discover()
40
+ except Exception:
41
+ status = None
42
+ if status and status.running:
43
+ # Panel-grade locals: >= 4GB, or cloud-proxied (size 0). Sort by
44
+ # size desc so the strongest come first; take two distinct ones.
45
+ candidates = sorted(
46
+ (m for m in status.models if m.size_gb >= 4 or m.size_gb == 0),
47
+ key=lambda m: m.size_gb,
48
+ reverse=True,
49
+ )
50
+ for m in candidates[:2]:
51
+ panel.append(RoleChoice(provider="ollama", model=m.name, effort="high"))
52
+ return panel, judge
53
+
54
+
55
+ def describe_panel(panel: list[RoleChoice], judge: RoleChoice) -> str:
56
+ """Human summary of a panel for CLI output."""
57
+ seats = ", ".join(f"{c.provider}/{c.model}" for c in panel)
58
+ return f"judge {judge.provider}/{judge.model} | panel: {seats}"
@@ -111,19 +111,29 @@ def _unlocked(fh) -> None:
111
111
  # ─── Section 1: flow marker cache ────────────────────────────────────────
112
112
 
113
113
 
114
- def _write_flow_marker(session_id: str, assistant_msg: str) -> None:
115
- if not session_id or not assistant_msg:
114
+ def _confirm_flow_authorization(session_id: str, transcript_path: str) -> None:
115
+ """Confirm persistent flow authorization from the transcript.
116
+
117
+ Replaces the dead ``_write_flow_marker`` that read a non-existent
118
+ ``assistant_message`` payload field. Reads the same transcript the
119
+ enforcer trusts and, when a flow marker is present, writes a confirmed
120
+ authorization that survives compaction and the 20-message window.
121
+ """
122
+ if not session_id:
116
123
  return
117
- routing = _ROUTING_RE.search(assistant_msg)
118
- if routing:
119
- kind, dept, lead = "routing", routing.group(1), routing.group(2)
120
- elif _TRIVIAL_RE.search(assistant_msg):
121
- kind, dept, lead = "trivial", "", ""
122
- else:
124
+ try:
125
+ from core.workflow.flow_authorization import confirm
126
+ from core.workflow.flow_enforcer import (
127
+ _load_last_assistant_messages,
128
+ _scan_markers,
129
+ )
130
+ except Exception:
123
131
  return
124
132
  try:
125
- from core.workflow.marker_cache import write_marker
126
- write_marker(session_id, kind, dept, lead)
133
+ messages = _load_last_assistant_messages(transcript_path, 20)
134
+ marker_found, _ = _scan_markers(messages)
135
+ if marker_found is not None:
136
+ confirm(session_id, marker_found)
127
137
  except Exception:
128
138
  pass
129
139
 
@@ -614,9 +624,13 @@ def main(stdin_json: dict | None = None) -> int:
614
624
  exit_code = get_str(stdin_json, "exit_code") or "0"
615
625
  cwd = get_str(stdin_json, "cwd")
616
626
  session_id = get_str(stdin_json, "session_id")
617
- assistant_msg = get_str(stdin_json, "assistant_message")
627
+ transcript_path = get_str(stdin_json, "transcript_path")
618
628
 
619
- _write_flow_marker(session_id, assistant_msg)
629
+ # Claude Code sends no `assistant_message` field (confirmed against the
630
+ # hook docs, 2026-07-05) — the old read was dead and the marker cache
631
+ # was never written. Scan the transcript (the only source hooks have)
632
+ # and confirm persistent authorization when a marker is present.
633
+ _confirm_flow_authorization(session_id, transcript_path)
620
634
 
621
635
  if tool_name in ("Task", "Agent"):
622
636
  subagent_type = get_str(stdin_json, "tool_input", "subagent_type")
@@ -260,6 +260,10 @@ def _flow_gate(
260
260
  except Exception:
261
261
  pass
262
262
  if decision.allow:
263
+ # Grace path: allow, but surface the non-blocking warning so the
264
+ # operator sees the routing nudge instead of a silent pass.
265
+ if decision.warning:
266
+ print(decision.warning, file=sys.stderr)
263
267
  return 0
264
268
  return _deny(decision.to_stderr_message())
265
269
 
@@ -171,6 +171,13 @@ def _invalidate_turn_caches(session_id: str) -> None:
171
171
  invalidate_marker(session_id)
172
172
  except Exception:
173
173
  pass
174
+ try:
175
+ # Reset only the per-turn grace flag; confirmed authorization and
176
+ # the grace counter persist across turns (enforcer resilience).
177
+ from core.workflow.flow_authorization import reset_turn
178
+ reset_turn(session_id)
179
+ except Exception:
180
+ pass
174
181
  try:
175
182
  from core.synapse.kb_cache import invalidate_obsidian_query
176
183
  invalidate_obsidian_query(session_id)
@@ -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"})
@@ -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,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)