arkaos 4.3.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 (34) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/fusion/SKILL.md +10 -3
  3. package/config/hooks/session-start.sh +23 -0
  4. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  5. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  6. package/core/fusion/cli.py +57 -6
  7. package/core/fusion/panel_builder.py +58 -0
  8. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  9. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  10. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  11. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  12. package/core/hooks/post_tool_use.py +26 -12
  13. package/core/hooks/pre_tool_use.py +4 -0
  14. package/core/hooks/user_prompt_submit.py +7 -0
  15. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  16. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  17. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  18. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  19. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  20. package/core/runtime/model_router.py +17 -0
  21. package/core/runtime/model_routing_context.py +82 -0
  22. package/core/runtime/runtime_models.py +51 -0
  23. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  24. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  25. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  26. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  27. package/core/workflow/flow_authorization.py +181 -0
  28. package/core/workflow/flow_enforcer.py +56 -11
  29. package/dashboard/app/pages/models.vue +23 -4
  30. package/installer/cli.js +21 -0
  31. package/package.json +1 -1
  32. package/pyproject.toml +1 -1
  33. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  34. package/scripts/dashboard-api.py +13 -2
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.3.0
1
+ 4.3.1
@@ -75,11 +75,18 @@ Produce a table: role → provider/model + ONE-line rationale each. Rules:
75
75
 
76
76
  ```bash
77
77
  npx arkaos models set <role> <provider>/<model> --effort <effort>
78
+ npx arkaos fusion --save "<a first question>" # persist a panel + run it
78
79
  ```
79
80
 
80
- For fusion panel/judge and aliases, edit `~/.arkaos/models.yaml` directly
81
- (keep comments). Show the final `npx arkaos models` table as proof and
82
- remind: the dashboard Models page shows the same state + live usage.
81
+ `npx arkaos fusion --show` prints the panel that would run (a sensible
82
+ default is built from the machine's models when none is configured);
83
+ `npx arkaos fusion --save "question"` writes the panel into
84
+ `~/.arkaos/models.yaml` and runs it; `npx arkaos fusion "question"` runs
85
+ without persisting. For fine-grained panel/judge edits, edit
86
+ `~/.arkaos/models.yaml` directly (keep comments). Show the final
87
+ `npx arkaos models` table as proof and remind: the dashboard Models page
88
+ shows the same state + live usage, and the SessionStart hook now injects
89
+ the routing so it governs agent dispatch.
83
90
 
84
91
  ## Hard rules
85
92
 
@@ -105,6 +105,29 @@ MSG+="\\nFields: kb=N (Obsidian/KB notes consulted), research=X (MCPs invoked: p
105
105
  MSG+="\\nMandatory after: EFFECT tool calls, plan/recommendation outputs, QG verdicts. Optional for pure read-only status replies."
106
106
  MSG+="\\nAbsence is measured by the Stop hook (warn-only in v2.34.0) before promotion to hard enforcement."
107
107
 
108
+ # ─── Model Fabric routing (consumption layer) ───────────────────────────
109
+ # Feed the operator's ~/.arkaos/models.yaml back to the orchestrator so
110
+ # dashboard/CLI model choices actually govern agent dispatch. Cheap
111
+ # (single python read); skipped silently if the config can't be resolved.
112
+ if [ -n "$REPO" ]; then
113
+ # Prefer the ArkaOS venv python (has pydantic + yaml); the ambient
114
+ # python3 usually lacks them and routing_directive() would return "".
115
+ _MF_PY="python3"
116
+ [ -x "$HOME/.arkaos/venv/bin/python3" ] && _MF_PY="$HOME/.arkaos/venv/bin/python3"
117
+ if command -v "$_MF_PY" &>/dev/null || [ -x "$_MF_PY" ]; then
118
+ _MF_DIRECTIVE=$(cd "$REPO" && PYTHONPATH="$REPO" "$_MF_PY" -c "
119
+ try:
120
+ from core.runtime.model_routing_context import routing_directive
121
+ print(routing_directive())
122
+ except Exception:
123
+ pass
124
+ " 2>/dev/null)
125
+ if [ -n "$_MF_DIRECTIVE" ]; then
126
+ MSG+="\\n\\n${_MF_DIRECTIVE}"
127
+ fi
128
+ fi
129
+ fi
130
+
108
131
  # ─── Stale-aware reorganizer trigger (PR24 v2.46.0) ─────────────────────
109
132
  # If today's proposal file is missing, fire the reorganizer in the
110
133
  # background with a 30s timeout. Best-effort, never blocks session
@@ -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)
@@ -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
 
@@ -8,6 +8,14 @@ interface ResolvedRole {
8
8
  model: string
9
9
  effort: string
10
10
  source: string
11
+ description?: string
12
+ }
13
+
14
+ interface RuntimeModel {
15
+ value: string
16
+ label: string
17
+ tier: string
18
+ note: string
11
19
  }
12
20
 
13
21
  interface OllamaModel {
@@ -24,6 +32,7 @@ interface ModelsOverview {
24
32
  providers: Record<string, { type: string }>
25
33
  aliases: Record<string, Record<string, string>>
26
34
  fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
35
+ runtime: { id: string, models: RuntimeModel[] }
27
36
  ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
28
37
  keys: { openrouter: boolean, anthropic: boolean }
29
38
  }
@@ -119,8 +128,13 @@ const targetOptions = computed(() => {
119
128
  const o = overview.value
120
129
  if (!o) return []
121
130
  const options: { label: string, value: string }[] = []
131
+ // Concrete runtime models first (Fable 5, Opus, Sonnet, Haiku) so the
132
+ // operator picks a real model, not just an abstract alias.
133
+ for (const m of o.runtime?.models ?? []) {
134
+ options.push({ label: `runtime / ${m.label} · ${m.note}`, value: `runtime/${m.value}` })
135
+ }
122
136
  for (const alias of ['best', 'default', 'fast']) {
123
- options.push({ label: `runtime / ${alias}`, value: `runtime/${alias}` })
137
+ options.push({ label: `runtime / ${alias} (alias)`, value: `runtime/${alias}` })
124
138
  }
125
139
  for (const m of o.ollama?.models ?? []) {
126
140
  options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
@@ -148,7 +162,7 @@ async function saveEdit(role: string) {
148
162
  }
149
163
  saving.value = true
150
164
  try {
151
- await $fetch(`${apiBase.value}/api/models/role`, {
165
+ await $fetch(`${apiBase}/api/models/role`, {
152
166
  method: 'POST',
153
167
  body: { role, target: editTarget.value },
154
168
  })
@@ -269,10 +283,15 @@ function formatCost(value: number | null): string {
269
283
  >
270
284
  <UIcon
271
285
  :name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
272
- class="size-4 shrink-0"
286
+ class="size-4 shrink-0 mt-0.5 self-start"
273
287
  :class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
274
288
  />
275
- <span class="w-32 shrink-0 text-sm font-medium">{{ row.role }}</span>
289
+ <div class="w-56 shrink-0">
290
+ <p class="text-sm font-medium">{{ row.role }}</p>
291
+ <p v-if="row.description" class="text-xs text-muted leading-snug">
292
+ {{ row.description }}
293
+ </p>
294
+ </div>
276
295
  <template v-if="editingRole === row.role">
277
296
  <UInputMenu
278
297
  v-model="editTarget"
package/installer/cli.js CHANGED
@@ -28,6 +28,9 @@ const { values, positionals } = parseArgs({
28
28
  // Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
29
29
  json: { type: "boolean" },
30
30
  effort: { type: "string" },
31
+ // Fusion — `npx arkaos fusion [--save|--show] "question"`
32
+ save: { type: "boolean" },
33
+ show: { type: "boolean" },
31
34
  },
32
35
  allowPositionals: true,
33
36
  strict: false,
@@ -177,6 +180,24 @@ async function main() {
177
180
  break;
178
181
  }
179
182
 
183
+ case "fusion": {
184
+ const { execSync } = await import("node:child_process");
185
+ const repoRootFusion = join(__dirname, "..");
186
+ const pyFusion = getArkaosPython();
187
+ if (!pyFusion) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
188
+ const fusionArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
189
+ const saveFlag = values.save ? " --save" : "";
190
+ const showFlag = values.show ? " --show" : "";
191
+ try {
192
+ execSync(`"${pyFusion}" -m core.fusion.cli${saveFlag}${showFlag} ${fusionArgs}`, {
193
+ stdio: "inherit",
194
+ cwd: repoRootFusion,
195
+ env: { ...process.env, ARKAOS_ROOT: repoRootFusion, PYTHONPATH: repoRootFusion },
196
+ });
197
+ } catch { process.exit(1); }
198
+ break;
199
+ }
200
+
180
201
  case "index": {
181
202
  const { execSync } = await import("node:child_process");
182
203
  const indexArgs = positionals.slice(1).join(" ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.3.0"
3
+ version = "4.3.1"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -867,8 +867,14 @@ def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
867
867
  @app.get("/api/models")
868
868
  def models_overview():
869
869
  """Model Fabric: roles, providers, Ollama discovery, key presence."""
870
- from core.runtime.model_router import USER_CONFIG_PATH, load_config, resolve_all
870
+ from core.runtime.model_router import (
871
+ USER_CONFIG_PATH,
872
+ load_config,
873
+ resolve_all,
874
+ role_description,
875
+ )
871
876
  from core.runtime.ollama_discovery import discover
877
+ from core.runtime.runtime_models import detect_runtime_models
872
878
 
873
879
  config, source = load_config()
874
880
  keys_path = Path.home() / ".arkaos" / "keys.json"
@@ -876,16 +882,21 @@ def models_overview():
876
882
  keys = json.loads(keys_path.read_text(encoding="utf-8"))
877
883
  except (OSError, json.JSONDecodeError):
878
884
  keys = {}
885
+ runtime_id, runtime_models = detect_runtime_models()
879
886
  return {
880
887
  "source": source,
881
888
  "config_path": str(USER_CONFIG_PATH),
882
- "roles": [item.model_dump() for item in resolve_all()],
889
+ "roles": [
890
+ {**item.model_dump(), "description": role_description(item.role)}
891
+ for item in resolve_all()
892
+ ],
883
893
  "providers": {
884
894
  name: {"type": spec.get("type", "")}
885
895
  for name, spec in config.providers.items()
886
896
  },
887
897
  "aliases": config.aliases,
888
898
  "fusion": config.fusion.model_dump(),
899
+ "runtime": {"id": runtime_id, "models": runtime_models},
889
900
  "ollama": discover().to_dict(),
890
901
  "keys": {
891
902
  "openrouter": bool(