arkaos 4.2.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/fusion/SKILL.md +98 -0
- package/config/hooks/session-start.sh +23 -0
- package/core/fusion/__init__.py +5 -0
- package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +88 -0
- package/core/fusion/engine.py +153 -0
- package/core/fusion/panel_builder.py +58 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +26 -12
- package/core/hooks/pre_tool_use.py +4 -0
- package/core/hooks/user_prompt_submit.py +7 -0
- package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
- package/core/runtime/model_router.py +17 -0
- package/core/runtime/model_router_cli.py +70 -1
- package/core/runtime/model_routing_context.py +82 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/runtime_models.py +51 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +181 -0
- package/core/workflow/flow_enforcer.py +56 -11
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +423 -0
- package/installer/cli.js +21 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +90 -0
package/README.md
CHANGED
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.3.1
|
package/arka/SKILL.md
CHANGED
|
@@ -24,7 +24,7 @@ does not replace the vault.
|
|
|
24
24
|
# ArkaOS — Main Orchestrator
|
|
25
25
|
|
|
26
26
|
> **The Operating System for AI Agent Teams**
|
|
27
|
-
> 82 agents. 17 departments.
|
|
27
|
+
> 82 agents. 17 departments. 268 skills. Multi-runtime. Dashboard. Knowledge RAG.
|
|
28
28
|
|
|
29
29
|
## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
|
|
30
30
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arka-fusion
|
|
3
|
+
description: >
|
|
4
|
+
Model Fabric advisor and fusion configurator. Discovers what the machine
|
|
5
|
+
can run (Ollama local models, OpenRouter/Anthropic keys), reads the
|
|
6
|
+
current role routing, interviews the user about goals and constraints,
|
|
7
|
+
and recommends — with rationale — which model should run each role and
|
|
8
|
+
which panel+judge combination to use for fusion. Applies the approved
|
|
9
|
+
configuration via `npx arkaos models`. Trigger: "/arka-fusion", "fusion",
|
|
10
|
+
"que modelos usar", "configurar modelos", "model routing".
|
|
11
|
+
allowed-tools: [Read, Bash, AskUserQuestion]
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# /arka-fusion — Model Fabric Advisor
|
|
15
|
+
|
|
16
|
+
You are the Model Fabric advisor. Your job: leave the user with a role →
|
|
17
|
+
model mapping and a fusion panel that fit THEIR machine, keys, and goals —
|
|
18
|
+
never a generic default. Constitution `model-routing` sets the posture:
|
|
19
|
+
quality-critical roles get the best model available; only genuinely
|
|
20
|
+
mechanical work economises.
|
|
21
|
+
|
|
22
|
+
## Phase 1 — Discover (never skip, never assume)
|
|
23
|
+
|
|
24
|
+
Run and read all three:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m core.runtime.model_router_cli discover # Ollama + local models
|
|
28
|
+
python -m core.runtime.model_router_cli --json # current routing
|
|
29
|
+
python -m core.runtime.model_router_cli usage --period week --json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Also check key presence (never print values): `OPENROUTER_API_KEY` /
|
|
33
|
+
`ANTHROPIC_API_KEY` in env or `~/.arkaos/keys.json`.
|
|
34
|
+
|
|
35
|
+
Interpret the discovery honestly:
|
|
36
|
+
- Ollama **not installed** → local lane unavailable; say what installing
|
|
37
|
+
it would unlock (free mechanical/panel calls, privacy).
|
|
38
|
+
- Ollama installed but **not running** → tell the user to start it; count
|
|
39
|
+
its models as one `ollama serve` away.
|
|
40
|
+
- Ollama running → classify each local model by size/family: coder models
|
|
41
|
+
(qwen-coder, deepseek-coder) suit `mechanical` + code review panels;
|
|
42
|
+
large general models (30B+, kimi cloud) qualify for panel seats;
|
|
43
|
+
sub-8B models are mechanical-only. `:cloud` models are Ollama-proxied
|
|
44
|
+
remote models — panel-grade, not privacy-local.
|
|
45
|
+
|
|
46
|
+
## Phase 2 — Interview (one AskUserQuestion, then dialogue)
|
|
47
|
+
|
|
48
|
+
Ask about, at most 4 questions, adapting to what discovery found:
|
|
49
|
+
1. **Prioridade** — máxima qualidade custe o que custar / equilíbrio /
|
|
50
|
+
privacidade local primeiro?
|
|
51
|
+
2. **Fusion** — ativar painel multi-modelo para trabalho crítico
|
|
52
|
+
(2-5× chamadas por resposta, qualidade validada acima de qualquer
|
|
53
|
+
modelo solo) ou manter single-model por agora?
|
|
54
|
+
3. **OpenRouter** — se não há chave: quer criar uma? (1 chave → Kimi,
|
|
55
|
+
DeepSeek, GPT, Gemini como participantes de painel.)
|
|
56
|
+
4. Qualquer ambiguidade real que a discovery levantou.
|
|
57
|
+
|
|
58
|
+
## Phase 3 — Recommend (framework-backed, named tradeoffs)
|
|
59
|
+
|
|
60
|
+
Produce a table: role → provider/model + ONE-line rationale each. Rules:
|
|
61
|
+
- `design/review/architecture/strategy/quality_gate` → best frontier
|
|
62
|
+
model available. NEVER a local small model, even if the user leans
|
|
63
|
+
cost-sensitive — offer fusion-with-budget-panel instead (OpenRouter
|
|
64
|
+
evidence: budget panel + frontier judge beats frontier solo at ~50%
|
|
65
|
+
cost).
|
|
66
|
+
- `mechanical` → cheapest competent lane: local coder model if Ollama
|
|
67
|
+
runs one, else haiku-class.
|
|
68
|
+
- Fusion panel: 2-3 DIVERSE models (different families — e.g. one
|
|
69
|
+
Anthropic, one Kimi/DeepSeek, one local large) + frontier judge.
|
|
70
|
+
Homogeneous panels waste the fan-out.
|
|
71
|
+
- Cite `usage` data when it argues for a change ("80% of your tokens are
|
|
72
|
+
mechanical → moving them local saves X").
|
|
73
|
+
|
|
74
|
+
## Phase 4 — Apply (only after explicit approval)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx arkaos models set <role> <provider>/<model> --effort <effort>
|
|
78
|
+
npx arkaos fusion --save "<a first question>" # persist a panel + run it
|
|
79
|
+
```
|
|
80
|
+
|
|
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.
|
|
90
|
+
|
|
91
|
+
## Hard rules
|
|
92
|
+
|
|
93
|
+
- Discovery before advice — recommending a model the machine cannot run
|
|
94
|
+
is a constitution breach (`context-verification`).
|
|
95
|
+
- Never print key values. Presence only.
|
|
96
|
+
- Quality roles never downgrade below frontier (`model-routing`,
|
|
97
|
+
`excellence-mandate`). Push back if asked; offer budget fusion instead.
|
|
98
|
+
- Every recommendation carries a rationale the user can disagree with.
|
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Fusion CLI — run the panel → judge → synthesis pipeline.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
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.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
|
|
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)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
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)
|
|
56
|
+
return 1
|
|
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
|
|
72
|
+
try:
|
|
73
|
+
result = fuse(prompt, config=config)
|
|
74
|
+
except FusionUnavailable as exc:
|
|
75
|
+
print(f" ✗ {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
seats = ", ".join(
|
|
78
|
+
f"{a.provider}/{a.model}" + (" (failed)" if a.failed else "")
|
|
79
|
+
for a in result.answers
|
|
80
|
+
)
|
|
81
|
+
print(f"\n Fusion — judge {result.judge_provider}/{result.judge_model} "
|
|
82
|
+
f"| panel: {seats}\n")
|
|
83
|
+
print(result.text)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Fusion engine — panel → judge → synthesis (Model Fabric PR-D).
|
|
2
|
+
|
|
3
|
+
Pattern validated by OpenRouter's DRACO results: a panel of diverse
|
|
4
|
+
models answers the same prompt in parallel; a judge model receives every
|
|
5
|
+
answer, analyses consensus, contradictions and blind spots, and writes
|
|
6
|
+
the final synthesis. A budget panel + frontier judge beats frontier solo
|
|
7
|
+
at roughly half the cost; self-fusion alone gains several points.
|
|
8
|
+
|
|
9
|
+
Configuration lives in ``models.yaml`` (``fusion.judge`` +
|
|
10
|
+
``fusion.panel`` — see ``core/runtime/model_router.py``). An empty panel
|
|
11
|
+
means fusion is disabled; callers get ``FusionUnavailable`` and fall
|
|
12
|
+
back to single-model completion.
|
|
13
|
+
|
|
14
|
+
Consumers: Forge complex/super tiers, QG adversarial review, and the
|
|
15
|
+
``python -m core.fusion.cli`` smoke path used by ``/arka-fusion``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
24
|
+
from core.runtime.model_router import ModelsConfig, RoleChoice, load_config
|
|
25
|
+
|
|
26
|
+
_PANEL_MAX_TOKENS = 2000
|
|
27
|
+
_JUDGE_MAX_TOKENS = 3000
|
|
28
|
+
|
|
29
|
+
_JUDGE_SYSTEM = (
|
|
30
|
+
"You are the judge in a model-fusion panel. You receive one question "
|
|
31
|
+
"and several independent answers from different models. Analyse them: "
|
|
32
|
+
"consensus points, contradictions, partial coverage, unique insights, "
|
|
33
|
+
"blind spots. Then write the best possible final answer grounded in "
|
|
34
|
+
"that analysis. Output ONLY the final answer — no meta-commentary "
|
|
35
|
+
"about the panel."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FusionUnavailable(RuntimeError):
|
|
40
|
+
"""Fusion cannot run: empty panel or no reachable participant."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class PanelAnswer:
|
|
45
|
+
provider: str
|
|
46
|
+
model: str
|
|
47
|
+
text: str
|
|
48
|
+
failed: bool = False
|
|
49
|
+
error: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class FusionResult:
|
|
54
|
+
text: str
|
|
55
|
+
judge_provider: str
|
|
56
|
+
judge_model: str
|
|
57
|
+
answers: list[PanelAnswer] = field(default_factory=list)
|
|
58
|
+
tokens_in: int = 0
|
|
59
|
+
tokens_out: int = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _provider_for(choice: RoleChoice, config: ModelsConfig):
|
|
63
|
+
"""Instantiate the LLM provider for one panel/judge seat."""
|
|
64
|
+
from core.runtime.model_router import _resolve_alias
|
|
65
|
+
model = _resolve_alias(config, choice.provider, choice.model)
|
|
66
|
+
if choice.provider == "ollama":
|
|
67
|
+
from core.runtime.ollama_provider import OllamaProvider
|
|
68
|
+
return OllamaProvider(model=model), model
|
|
69
|
+
if choice.provider == "openrouter":
|
|
70
|
+
from core.runtime.openrouter_provider import OpenRouterProvider
|
|
71
|
+
return OpenRouterProvider(model=model), model
|
|
72
|
+
# runtime / anthropic / anything else: provider chain decides; the
|
|
73
|
+
# model override is advisory (subagent uses the session model).
|
|
74
|
+
from core.runtime.llm_provider import get_llm_provider
|
|
75
|
+
return get_llm_provider(), model
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ask_participant(
|
|
79
|
+
choice: RoleChoice, config: ModelsConfig, prompt: str, system: str
|
|
80
|
+
) -> PanelAnswer:
|
|
81
|
+
try:
|
|
82
|
+
provider, model = _provider_for(choice, config)
|
|
83
|
+
response: LLMResponse = provider.complete(
|
|
84
|
+
prompt, max_tokens=_PANEL_MAX_TOKENS, system=system
|
|
85
|
+
)
|
|
86
|
+
if not response.text.strip():
|
|
87
|
+
return PanelAnswer(choice.provider, model, "", failed=True,
|
|
88
|
+
error="empty response")
|
|
89
|
+
return PanelAnswer(choice.provider, model, response.text)
|
|
90
|
+
except LLMUnavailable as exc:
|
|
91
|
+
return PanelAnswer(choice.provider, choice.model, "", failed=True,
|
|
92
|
+
error=str(exc))
|
|
93
|
+
except Exception as exc: # noqa: BLE001 — one dead seat must not kill the panel
|
|
94
|
+
return PanelAnswer(choice.provider, choice.model, "", failed=True,
|
|
95
|
+
error=f"unexpected: {exc}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _judge_prompt(prompt: str, answers: list[PanelAnswer]) -> str:
|
|
99
|
+
blocks = []
|
|
100
|
+
for i, answer in enumerate(answers, 1):
|
|
101
|
+
blocks.append(
|
|
102
|
+
f"--- Answer {i} (model: {answer.model}) ---\n{answer.text}"
|
|
103
|
+
)
|
|
104
|
+
return (
|
|
105
|
+
f"QUESTION:\n{prompt}\n\n"
|
|
106
|
+
f"PANEL ANSWERS ({len(answers)}):\n\n" + "\n\n".join(blocks)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fuse(
|
|
111
|
+
prompt: str,
|
|
112
|
+
*,
|
|
113
|
+
system: str = "",
|
|
114
|
+
config: ModelsConfig | None = None,
|
|
115
|
+
) -> FusionResult:
|
|
116
|
+
"""Run the full panel → judge → synthesis pipeline.
|
|
117
|
+
|
|
118
|
+
Raises FusionUnavailable when the panel is empty (fusion disabled in
|
|
119
|
+
models.yaml) or when every participant fails — the caller falls back
|
|
120
|
+
to a single-model completion, never to silence.
|
|
121
|
+
"""
|
|
122
|
+
if config is None:
|
|
123
|
+
config, _ = load_config()
|
|
124
|
+
panel = config.fusion.panel
|
|
125
|
+
if not panel:
|
|
126
|
+
raise FusionUnavailable(
|
|
127
|
+
"fusion panel is empty — configure fusion.panel in models.yaml "
|
|
128
|
+
"(try /arka-fusion for guided setup)"
|
|
129
|
+
)
|
|
130
|
+
with ThreadPoolExecutor(max_workers=max(len(panel), 1)) as pool:
|
|
131
|
+
answers = list(pool.map(
|
|
132
|
+
lambda choice: _ask_participant(choice, config, prompt, system),
|
|
133
|
+
panel,
|
|
134
|
+
))
|
|
135
|
+
alive = [a for a in answers if not a.failed]
|
|
136
|
+
if not alive:
|
|
137
|
+
details = "; ".join(f"{a.provider}/{a.model}: {a.error}" for a in answers)
|
|
138
|
+
raise FusionUnavailable(f"every panel participant failed — {details}")
|
|
139
|
+
|
|
140
|
+
judge_provider, judge_model = _provider_for(config.fusion.judge, config)
|
|
141
|
+
verdict = judge_provider.complete(
|
|
142
|
+
_judge_prompt(prompt, alive),
|
|
143
|
+
max_tokens=_JUDGE_MAX_TOKENS,
|
|
144
|
+
system=_JUDGE_SYSTEM,
|
|
145
|
+
)
|
|
146
|
+
return FusionResult(
|
|
147
|
+
text=verdict.text,
|
|
148
|
+
judge_provider=config.fusion.judge.provider,
|
|
149
|
+
judge_model=judge_model or verdict.model,
|
|
150
|
+
answers=answers,
|
|
151
|
+
tokens_in=verdict.tokens_in,
|
|
152
|
+
tokens_out=verdict.tokens_out,
|
|
153
|
+
)
|
|
@@ -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}"
|
|
Binary file
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Quality Gate redo-loop counter (excellence-mandate, v4.2.0).
|
|
2
|
+
|
|
3
|
+
The constitution caps REJECTED redo cycles at 2 — a third REJECTED
|
|
4
|
+
escalates to the operator with the full verdict instead of another
|
|
5
|
+
silent retry. This module is the mechanical counter behind that rule
|
|
6
|
+
(previously declarative only).
|
|
7
|
+
|
|
8
|
+
State: ``~/.arkaos/quality-gate/redo-counters.json`` keyed by session id.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
REDO_CAP = 2
|
|
18
|
+
|
|
19
|
+
_STATE_PATH = Path.home() / ".arkaos" / "quality-gate" / "redo-counters.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RedoState:
|
|
24
|
+
session_id: str
|
|
25
|
+
count: int
|
|
26
|
+
escalate: bool
|
|
27
|
+
|
|
28
|
+
def to_message(self) -> str:
|
|
29
|
+
if not self.escalate:
|
|
30
|
+
return (
|
|
31
|
+
f"[arka:qg] REJECTED — redo cycle {self.count}/{REDO_CAP}. "
|
|
32
|
+
f"Looping back to execution with the issue list."
|
|
33
|
+
)
|
|
34
|
+
return (
|
|
35
|
+
f"[arka:qg:escalate] REJECTED {self.count} times — cap of "
|
|
36
|
+
f"{REDO_CAP} redo cycles exceeded (excellence-mandate). "
|
|
37
|
+
f"STOP: present the full verdict to the operator and wait for "
|
|
38
|
+
f"a decision. Do not retry silently."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load(path: Path) -> dict:
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, json.JSONDecodeError):
|
|
46
|
+
return {}
|
|
47
|
+
return data if isinstance(data, dict) else {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def record_rejected(session_id: str, path: Path | None = None) -> RedoState:
|
|
51
|
+
"""Increment the REJECTED counter; escalate above the cap."""
|
|
52
|
+
state_path = path or _STATE_PATH
|
|
53
|
+
data = _load(state_path)
|
|
54
|
+
count = int(data.get(session_id, 0) or 0) + 1
|
|
55
|
+
data[session_id] = count
|
|
56
|
+
try:
|
|
57
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
59
|
+
except OSError:
|
|
60
|
+
pass # counter must never block the gate itself
|
|
61
|
+
return RedoState(session_id=session_id, count=count,
|
|
62
|
+
escalate=count > REDO_CAP)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset(session_id: str, path: Path | None = None) -> None:
|
|
66
|
+
"""Clear the counter — called on APPROVED."""
|
|
67
|
+
state_path = path or _STATE_PATH
|
|
68
|
+
data = _load(state_path)
|
|
69
|
+
if session_id in data:
|
|
70
|
+
del data[session_id]
|
|
71
|
+
try:
|
|
72
|
+
state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def current(session_id: str, path: Path | None = None) -> RedoState:
|
|
78
|
+
"""Read-only view of the counter."""
|
|
79
|
+
count = int(_load(path or _STATE_PATH).get(session_id, 0) or 0)
|
|
80
|
+
return RedoState(session_id=session_id, count=count,
|
|
81
|
+
escalate=count > REDO_CAP)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -111,19 +111,29 @@ def _unlocked(fh) -> None:
|
|
|
111
111
|
# ─── Section 1: flow marker cache ────────────────────────────────────────
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
627
|
+
transcript_path = get_str(stdin_json, "transcript_path")
|
|
618
628
|
|
|
619
|
-
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|