arkaos 4.1.1 → 4.2.0
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/VERSION +1 -1
- package/arka/skills/flow/SKILL.md +15 -0
- package/config/constitution.yaml +15 -7
- package/config/hooks/session-start.sh +22 -0
- package/config/models.yaml +70 -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/pre_tool_use.py +55 -3
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-314.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__/openrouter_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
- package/core/runtime/llm_provider.py +10 -1
- package/core/runtime/model_router.py +204 -0
- package/core/runtime/model_router_cli.py +82 -0
- package/core/runtime/openrouter_provider.py +172 -0
- package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/frontend_gate.py +162 -0
- package/installer/autostart.js +16 -2
- package/installer/cli.js +23 -0
- package/installer/keys.js +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/start-dashboard.sh +25 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.2.0
|
|
@@ -87,6 +87,21 @@ resumes at the right gate.
|
|
|
87
87
|
coverage read from the report file, security grep, spell-check for
|
|
88
88
|
copy. Reviewers (Quality Gate personas) interpret tool output; they do
|
|
89
89
|
not replace it. APPROVED/REJECTED derives from evidence.
|
|
90
|
+
- **Excellence check (`excellence-mandate`, mandatory):** before closing,
|
|
91
|
+
answer three questions with evidence, not narration:
|
|
92
|
+
1. What is **unfinished** in this delivery (trimmed scope, TODO left
|
|
93
|
+
behind, parity skipped)?
|
|
94
|
+
2. What is **default** (template look, boilerplate copy, unstyled
|
|
95
|
+
component, generic strategy)?
|
|
96
|
+
3. What would a **top-tier lead reject** here? Name the benchmark
|
|
97
|
+
(`reference_companies.application` — e.g. Linear/Stripe for
|
|
98
|
+
frontend) and judge against it.
|
|
99
|
+
A non-empty answer loops the work back into Gate 3 or is escalated to
|
|
100
|
+
the operator as an explicit open-items decision. Shipping with a
|
|
101
|
+
non-empty list and no operator decision is a constitution breach.
|
|
102
|
+
Time and token cost are not acceptable answers to any of the three.
|
|
103
|
+
- Quality Gate REJECTED loops back at most twice; a third REJECTED
|
|
104
|
+
escalates to the operator with the full verdict.
|
|
90
105
|
- Close with an honest summary: what changed, where, how it was
|
|
91
106
|
verified (real commands + results), what remains open.
|
|
92
107
|
|
package/config/constitution.yaml
CHANGED
|
@@ -117,14 +117,18 @@ enforcement_levels:
|
|
|
117
117
|
rule: "When a squad lead dispatches a specialist via the Agent tool, the lead MUST emit `[arka:dispatch] <from> -> <to>` on a line of its own immediately before the dispatch call. The marker identifies the specialist to the PreToolUse specialist-enforcer so writes from the specialist pass `owner-match` instead of falling through as `no-routing-tag` or blocking the lead. The format is identical to `[arka:routing]` but uses the verb `dispatch` and points from the calling persona to the receiving specialist (e.g., `[arka:dispatch] paulo -> frontend-dev`)."
|
|
118
118
|
enforcement: "PreToolUse hook (config/hooks/pre-tool-use.sh) reads the dispatch marker via core.workflow.specialist_enforcer._resolve_persona. Dispatch tag overrides routing tag because it is more specific. Without it, lead writes to specialist-owned files are blocked when hooks.specialistEnforcement=true. See ADR docs/adr/2026-05-28-specialist-dispatch-subagent-blindspot.md for the architectural constraint this rule mitigates."
|
|
119
119
|
|
|
120
|
+
# ─── Rule added in Excellence Reform (2026-07-05, operator mandate) ──
|
|
121
|
+
- id: excellence-mandate
|
|
122
|
+
rule: "Every deliverable targets excellence, not acceptance. Supervisors (leads, C-Suite, Quality Gate) and specialists are REQUIRED to be maximally critical and meticulous: no default-looking output, no unfinished edges, no lazy implementations, no delivery-for-the-sake-of-delivering. Latency and token cost are NEVER arguments against quality work — the CostGovernor hard budget is the only ceiling. Before any gate closes, the closing agent answers with evidence: 'what is unfinished, what is default, what would a top-tier lead (see reference_companies.application) reject here?' — a non-empty answer loops the work back or escalates to the operator; it is never silently shipped. UI work MUST load the frontend design skills (frontend-design, ui-ux-pro-max, project design system) at maximum effort and pass visual review against a named benchmark before reaching the Quality Gate."
|
|
123
|
+
enforcement: "G4 excellence check in arka/skills/flow/SKILL.md; frontend gate core/workflow/frontend_gate.py (flag hooks.frontendGate); QG reviewers must cite the named benchmark and enumerate concrete rejections; REJECTED redo loop capped at 2 cycles then operator escalation. Operator mandate 2026-07-05: 'não interessa se demora — o goal é excelência'."
|
|
124
|
+
|
|
120
125
|
quality_gate:
|
|
121
126
|
description: "Mandatory pre-delivery review. Nothing ships without APPROVED verdict."
|
|
122
127
|
trigger: "After the last execution phase, before delivery to user"
|
|
123
128
|
frequency: "Once per workflow execution, not per phase"
|
|
124
129
|
model_policy:
|
|
125
|
-
default:
|
|
126
|
-
|
|
127
|
-
note: "Marta's veto is model-independent: the verdict derives from the evidence report (core.governance.evidence_checks), not from model size."
|
|
130
|
+
default: best-available
|
|
131
|
+
note: "Quality Gate reviews run the best model available (frontier tier) at maximum effort by default — the Excellence Reform (2026-07-05) inverted the former sonnet-default cost posture; review quality is never the place to save tokens. User overrides per role live in ~/.arkaos/models.yaml (Model Fabric). Marta's veto is model-independent: the verdict derives from the evidence report (core.governance.evidence_checks), not from model size."
|
|
128
132
|
agents:
|
|
129
133
|
orchestrator:
|
|
130
134
|
id: cqo-marta
|
|
@@ -140,9 +144,10 @@ enforcement_levels:
|
|
|
140
144
|
process:
|
|
141
145
|
- "Evidence engine runs first: python -m core.governance.evidence_checks — the verdict derives from its report"
|
|
142
146
|
- "Marta receives all output from execution phase plus the evidence report"
|
|
143
|
-
- "Marta dispatches Eduardo (text) and Francisca (technical) in parallel
|
|
144
|
-
- "Each reviewer returns a structured QGVerdict (APPROVED or REJECTED) with specific issues; evidence overall=fail forces REJECTED"
|
|
147
|
+
- "Marta dispatches Eduardo (text) and Francisca (technical) in parallel, each in an INDEPENDENT subagent context (clean context, no stake in the work being approved)"
|
|
148
|
+
- "Each reviewer returns a structured QGVerdict (APPROVED or REJECTED) with specific issues; the verdict MUST name the benchmark used (reference_companies.application) and enumerate the concrete rejections a top-tier lead would raise; evidence overall=fail forces REJECTED"
|
|
145
149
|
- "If ANY reviewer rejects: work loops back to execution with issue list"
|
|
150
|
+
- "Redo loop is capped at 2 cycles: a third REJECTED escalates to the operator with the full verdict instead of another silent retry (excellence-mandate)"
|
|
146
151
|
- "If ALL approve: Marta issues final APPROVED verdict"
|
|
147
152
|
- "Nothing reaches the user without APPROVED from all three"
|
|
148
153
|
|
|
@@ -182,11 +187,11 @@ enforcement_levels:
|
|
|
182
187
|
enforcement: "persistence.py exports on status change; audit detects missing exports"
|
|
183
188
|
|
|
184
189
|
- id: model-routing
|
|
185
|
-
rule: "
|
|
190
|
+
rule: "Quality-first model routing: design, review, architecture, strategy, and Quality Gate phases run the BEST model available for the task (frontier tier) at maximum effort BY DEFAULT. Cheaper/smaller models (haiku, local) are reserved for genuinely mechanical work: commit messages, keyword extraction, data fetching, formatting. Cost optimisation NEVER downgrades a quality-critical phase — the CostGovernor hard budget is the only ceiling. User-owned overrides live in ~/.arkaos/models.yaml (Model Fabric): the user decides which provider/model serves each role; this rule sets the default posture, not a fixed vendor table. Replaced the cost-optimised per-tier table in the Excellence Reform (2026-07-05)."
|
|
186
191
|
enforcement: "warning"
|
|
187
192
|
|
|
188
193
|
- id: subagent-discipline
|
|
189
|
-
rule: "Dispatch subagents only when task requires >3 Reads or >5 Greps or isolated context. Never parallel subagents sharing state. Prefer main thread for trivial tasks."
|
|
194
|
+
rule: "Dispatch subagents only when task requires >3 Reads or >5 Greps or isolated context. Never parallel subagents sharing state. Prefer main thread for trivial tasks. EXEMPT: quality dispatches (Quality Gate reviewers, adversarial verification, design review) are never suppressed by this rule — independent review context is a correctness requirement under excellence-mandate, not an optimisation choice."
|
|
190
195
|
enforcement: "warning"
|
|
191
196
|
|
|
192
197
|
# ─── Rule added in PR3 Squad Intelligence Upgrade (2026-05-28) ───────
|
|
@@ -436,3 +441,6 @@ amendments:
|
|
|
436
441
|
- version: "2.32.0"
|
|
437
442
|
date: "2026-05-13"
|
|
438
443
|
changes: "PR10 Conclave Phase 5 — added 7 NON-NEGOTIABLE rules (quality-over-speed, always-research, project-design-system-prerequisite, definition-of-done-per-domain, arkaos-not-yes-man, inter-agent-checkpoints, hybrid-learning) and 3 new top-level sections (definition_of_done per domain, reference_companies, tone_guide)."
|
|
444
|
+
- version: "4.2.0"
|
|
445
|
+
date: "2026-07-05"
|
|
446
|
+
changes: "Excellence Reform (operator mandate) — added excellence-mandate NON-NEGOTIABLE rule; inverted model-routing from cost-optimised tiers to quality-first (best model for design/review/architecture/strategy, user overrides in ~/.arkaos/models.yaml); QG model_policy sonnet-default → best-available; QG reviewers independent + benchmark-cited verdicts + redo loop capped at 2; subagent-discipline exempts quality dispatches."
|
|
@@ -120,6 +120,28 @@ if [ -n "$REPO" ] && command -v python3 &>/dev/null; then
|
|
|
120
120
|
fi
|
|
121
121
|
fi
|
|
122
122
|
|
|
123
|
+
# ─── Dashboard ensure (v4.1.2) ──────────────────────────────────────────
|
|
124
|
+
# Safety net for the login autostart (`npx arkaos autostart enable`): if the
|
|
125
|
+
# dashboard is healthy this is two curl health checks (~50ms); if not, it
|
|
126
|
+
# starts API+UI. Background + disown so it never touches the 5s budget.
|
|
127
|
+
# Toggle off: ~/.arkaos/config.json -> {"dashboard": {"ensure_on_session": false}}
|
|
128
|
+
if [ -n "$REPO" ] && [ -f "$REPO/scripts/start-dashboard.sh" ]; then
|
|
129
|
+
_DASH_ENSURE=$(python3 -c "import json; print(json.load(open('$HOME/.arkaos/config.json')).get('dashboard',{}).get('ensure_on_session', True))" 2>/dev/null || echo "True")
|
|
130
|
+
if [ "$_DASH_ENSURE" = "True" ] || [ "$_DASH_ENSURE" = "true" ]; then
|
|
131
|
+
mkdir -p "$HOME/.arkaos/logs"
|
|
132
|
+
(
|
|
133
|
+
# GNU `timeout` is absent on stock macOS — degrade to an unbounded
|
|
134
|
+
# run; the launcher itself is bounded (health wait + start).
|
|
135
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
136
|
+
ARKAOS_NO_BROWSER=1 timeout 90s bash "$REPO/scripts/start-dashboard.sh" ensure >> "$HOME/.arkaos/logs/dashboard-ensure.log" 2>&1
|
|
137
|
+
else
|
|
138
|
+
ARKAOS_NO_BROWSER=1 bash "$REPO/scripts/start-dashboard.sh" ensure >> "$HOME/.arkaos/logs/dashboard-ensure.log" 2>&1
|
|
139
|
+
fi
|
|
140
|
+
) &
|
|
141
|
+
disown 2>/dev/null || true
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
|
|
123
145
|
# --- Session Memory Resume Context ---
|
|
124
146
|
if command -v python3 &>/dev/null && [ -n "$REPO" ]; then
|
|
125
147
|
_SESSION_CTX=$(cd "$REPO" && python3 -c "
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
# ArkaOS Model Fabric — who runs what.
|
|
3
|
+
#
|
|
4
|
+
# Your copy lives at ~/.arkaos/models.yaml (created by `npx arkaos models
|
|
5
|
+
# init`; this file is the packaged default). Edit it directly or use:
|
|
6
|
+
#
|
|
7
|
+
# npx arkaos models show every role and what it resolves to
|
|
8
|
+
# npx arkaos models set review anthropic/best
|
|
9
|
+
# npx arkaos models set mechanical ollama/qwen3:8b
|
|
10
|
+
#
|
|
11
|
+
# The interactive runtime (Claude Code, Codex, Gemini, Cursor) keeps its
|
|
12
|
+
# own main-loop model — roles govern everything ArkaOS itself dispatches:
|
|
13
|
+
# subagents, Quality Gate, Forge, fusion, cognition, daemons.
|
|
14
|
+
#
|
|
15
|
+
# Constitution `model-routing` (Excellence Reform 2026-07-05): quality-
|
|
16
|
+
# critical roles run the BEST model available by default; only genuinely
|
|
17
|
+
# mechanical work economises. Change the mapping, not the posture.
|
|
18
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
19
|
+
version: 1
|
|
20
|
+
|
|
21
|
+
# ─── Providers ──────────────────────────────────────────────────────────
|
|
22
|
+
# type must match a provider in core/runtime/llm_provider.py.
|
|
23
|
+
providers:
|
|
24
|
+
runtime:
|
|
25
|
+
type: subagent # shell out to the active runtime CLI
|
|
26
|
+
anthropic:
|
|
27
|
+
type: anthropic-direct # SDK; needs ANTHROPIC_API_KEY
|
|
28
|
+
ollama:
|
|
29
|
+
type: ollama # local models, http://localhost:11434
|
|
30
|
+
# openrouter: # one key -> hundreds of models (Model Fabric PR-B)
|
|
31
|
+
# type: openrouter
|
|
32
|
+
|
|
33
|
+
# ─── Aliases ────────────────────────────────────────────────────────────
|
|
34
|
+
# `best` / `default` / `fast` resolve per provider, so roles stay readable.
|
|
35
|
+
aliases:
|
|
36
|
+
runtime:
|
|
37
|
+
best: opus
|
|
38
|
+
default: sonnet
|
|
39
|
+
fast: haiku
|
|
40
|
+
anthropic:
|
|
41
|
+
best: claude-opus-4-8
|
|
42
|
+
default: claude-sonnet-5
|
|
43
|
+
fast: claude-haiku-4-5-20251001
|
|
44
|
+
ollama:
|
|
45
|
+
best: "" # set your strongest local model, e.g. kimi-k2.6
|
|
46
|
+
default: ""
|
|
47
|
+
fast: ""
|
|
48
|
+
|
|
49
|
+
# ─── Roles ──────────────────────────────────────────────────────────────
|
|
50
|
+
# provider/model per kind of work. `model` is an alias above or a literal
|
|
51
|
+
# model id. `effort`: low | medium | high | max.
|
|
52
|
+
roles:
|
|
53
|
+
design: {provider: runtime, model: best, effort: max}
|
|
54
|
+
review: {provider: runtime, model: best, effort: max}
|
|
55
|
+
architecture: {provider: runtime, model: best, effort: max}
|
|
56
|
+
strategy: {provider: runtime, model: best, effort: max}
|
|
57
|
+
quality_gate: {provider: runtime, model: best, effort: max}
|
|
58
|
+
execution: {provider: runtime, model: default, effort: high}
|
|
59
|
+
mechanical: {provider: runtime, model: fast, effort: low}
|
|
60
|
+
|
|
61
|
+
# ─── Fusion (Model Fabric PR-D) ─────────────────────────────────────────
|
|
62
|
+
# Panel answers in parallel; the judge analyses consensus, contradictions
|
|
63
|
+
# and blind spots, then synthesises. Empty panel = fusion disabled.
|
|
64
|
+
fusion:
|
|
65
|
+
judge: {provider: runtime, model: best, effort: max}
|
|
66
|
+
panel: []
|
|
67
|
+
# panel:
|
|
68
|
+
# - {provider: ollama, model: kimi-k2.6}
|
|
69
|
+
# - {provider: openrouter, model: deepseek/deepseek-v4-pro}
|
|
70
|
+
# - {provider: anthropic, model: best}
|
|
Binary file
|
|
Binary file
|
|
@@ -5,10 +5,12 @@ with ONE python process. Gate order is preserved exactly:
|
|
|
5
5
|
|
|
6
6
|
1. KB-first research gate (core/workflow/research_gate.py)
|
|
7
7
|
2. Specialist-dispatch gate (core/workflow/specialist_enforcer.py)
|
|
8
|
-
3.
|
|
9
|
-
|
|
8
|
+
3. Frontend excellence gate (core/workflow/frontend_gate.py — v4.2.0
|
|
9
|
+
excellence-mandate: UI edits require [arka:design] evidence)
|
|
10
|
+
4. Fast allow for non-flow-gated tools
|
|
11
|
+
5. CostGovernor budget check (stdlib-only — MUST run even when the
|
|
10
12
|
yaml-needing enforcers degrade; see ADR 2026-07-04-cost-governor)
|
|
11
|
-
|
|
13
|
+
6. Evidence-flow gate (core/workflow/flow_enforcer.py)
|
|
12
14
|
|
|
13
15
|
Behavior contract (unchanged from the bash version):
|
|
14
16
|
- allow → no stdout, exit 0 (nudges/warnings on stderr)
|
|
@@ -165,6 +167,50 @@ def _specialist_gate(
|
|
|
165
167
|
return None
|
|
166
168
|
|
|
167
169
|
|
|
170
|
+
def _frontend_gate(
|
|
171
|
+
root: str,
|
|
172
|
+
tool_name: str,
|
|
173
|
+
transcript_path: str,
|
|
174
|
+
session_id: str,
|
|
175
|
+
cwd: str,
|
|
176
|
+
tool_input: dict,
|
|
177
|
+
messages: _MessagesOnce,
|
|
178
|
+
) -> int | None:
|
|
179
|
+
"""Frontend excellence gate (excellence-mandate). 2 on deny, None to continue."""
|
|
180
|
+
if tool_name not in _SPECIALIST_TOOLS:
|
|
181
|
+
return None
|
|
182
|
+
if not (Path(root) / "core" / "workflow" / "frontend_gate.py").is_file():
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
from core.workflow.frontend_gate import (
|
|
186
|
+
evaluate,
|
|
187
|
+
is_ui_file,
|
|
188
|
+
record_telemetry,
|
|
189
|
+
)
|
|
190
|
+
except Exception:
|
|
191
|
+
return None # frontend-gate-import-failed → allow
|
|
192
|
+
# Zero-read fast path: only UI files ever need the transcript.
|
|
193
|
+
if not is_ui_file(str(tool_input.get("file_path", ""))):
|
|
194
|
+
return None
|
|
195
|
+
decision = evaluate(
|
|
196
|
+
tool_name=tool_name,
|
|
197
|
+
transcript_path=transcript_path,
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
cwd=cwd,
|
|
200
|
+
tool_input=tool_input,
|
|
201
|
+
messages=messages.load(),
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
if not decision.allow:
|
|
208
|
+
return _deny(decision.to_stderr_message())
|
|
209
|
+
if decision.to_stderr_message():
|
|
210
|
+
print(decision.to_stderr_message(), file=sys.stderr)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
168
214
|
def _budget_check(session_id: str) -> tuple[bool, str]:
|
|
169
215
|
"""CostGovernor gate (stdlib-only). Returns (allow, warning)."""
|
|
170
216
|
try:
|
|
@@ -244,6 +290,12 @@ def main(stdin_json: dict | None = None) -> int:
|
|
|
244
290
|
if code is not None:
|
|
245
291
|
return code
|
|
246
292
|
|
|
293
|
+
code = _frontend_gate(
|
|
294
|
+
root, tool_name, transcript_path, session_id, cwd, tool_input, messages
|
|
295
|
+
)
|
|
296
|
+
if code is not None:
|
|
297
|
+
return code
|
|
298
|
+
|
|
247
299
|
# Fast allow: not a flow-gated tool (Bash stays — classified per-command
|
|
248
300
|
# by the enforcer via bash_is_effect()).
|
|
249
301
|
if tool_name not in _FLOW_GATED_TOOLS:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -32,7 +32,9 @@ from core.runtime.pricing import estimate_cost_usd
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
_DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
35
|
-
_FALLBACK_ORDER: tuple[str, ...] = (
|
|
35
|
+
_FALLBACK_ORDER: tuple[str, ...] = (
|
|
36
|
+
"subagent", "ollama", "openrouter", "anthropic-direct", "stub"
|
|
37
|
+
)
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
# ─── Public dataclass ─────────────────────────────────────────────────
|
|
@@ -330,9 +332,16 @@ def _import_ollama_provider() -> type:
|
|
|
330
332
|
return OllamaProvider
|
|
331
333
|
|
|
332
334
|
|
|
335
|
+
def _import_openrouter_provider() -> type:
|
|
336
|
+
"""Lazy import to keep the OpenRouter provider optional at runtime."""
|
|
337
|
+
from core.runtime.openrouter_provider import OpenRouterProvider
|
|
338
|
+
return OpenRouterProvider
|
|
339
|
+
|
|
340
|
+
|
|
333
341
|
_PROVIDERS: dict[str, type] = {
|
|
334
342
|
"subagent": SubagentProvider,
|
|
335
343
|
"ollama": _import_ollama_provider(),
|
|
344
|
+
"openrouter": _import_openrouter_provider(),
|
|
336
345
|
"anthropic-direct": AnthropicDirectProvider,
|
|
337
346
|
"stub": StubProvider,
|
|
338
347
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Model Fabric router — resolve a work role to (provider, model, effort).
|
|
2
|
+
|
|
3
|
+
The user owns ``~/.arkaos/models.yaml``; the packaged default ships at
|
|
4
|
+
``config/models.yaml``. Roles govern everything ArkaOS dispatches
|
|
5
|
+
(subagents, Quality Gate, Forge, fusion, cognition, daemons) — the
|
|
6
|
+
interactive runtime keeps its own main-loop model.
|
|
7
|
+
|
|
8
|
+
Constitution ``model-routing`` (Excellence Reform 2026-07-05): quality-
|
|
9
|
+
critical roles (design, review, architecture, strategy, quality_gate)
|
|
10
|
+
default to the best model available at maximum effort; only genuinely
|
|
11
|
+
mechanical work economises. The built-in fallback used when no YAML can
|
|
12
|
+
be read encodes the same posture — a missing file never silently
|
|
13
|
+
downgrades a quality role.
|
|
14
|
+
|
|
15
|
+
Public API::
|
|
16
|
+
|
|
17
|
+
resolve("review") -> ResolvedModel(provider, model, effort)
|
|
18
|
+
load_config() -> ModelsConfig
|
|
19
|
+
ensure_user_config() -> Path (creates ~/.arkaos/models.yaml)
|
|
20
|
+
set_role("review", "anthropic/best", effort="max")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shutil
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
from pydantic import BaseModel, Field, field_validator
|
|
30
|
+
|
|
31
|
+
USER_CONFIG_PATH = Path.home() / ".arkaos" / "models.yaml"
|
|
32
|
+
|
|
33
|
+
QUALITY_ROLES = frozenset({
|
|
34
|
+
"design", "review", "architecture", "strategy", "quality_gate",
|
|
35
|
+
})
|
|
36
|
+
KNOWN_EFFORTS = ("low", "medium", "high", "max")
|
|
37
|
+
|
|
38
|
+
_ALIAS_NAMES = frozenset({"best", "default", "fast"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RoleChoice(BaseModel):
|
|
42
|
+
"""One role entry as written in models.yaml."""
|
|
43
|
+
|
|
44
|
+
provider: str = "runtime"
|
|
45
|
+
model: str = "best"
|
|
46
|
+
effort: str = "high"
|
|
47
|
+
|
|
48
|
+
@field_validator("effort")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _known_effort(cls, value: str) -> str:
|
|
51
|
+
if value not in KNOWN_EFFORTS:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"effort must be one of {KNOWN_EFFORTS}, got {value!r}"
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FusionConfig(BaseModel):
|
|
59
|
+
"""Judge + panel for the fusion engine (PR-D consumes this)."""
|
|
60
|
+
|
|
61
|
+
judge: RoleChoice = Field(default_factory=lambda: RoleChoice(effort="max"))
|
|
62
|
+
panel: list[RoleChoice] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ModelsConfig(BaseModel):
|
|
66
|
+
"""Validated shape of models.yaml."""
|
|
67
|
+
|
|
68
|
+
version: int = 1
|
|
69
|
+
providers: dict[str, dict] = Field(default_factory=dict)
|
|
70
|
+
aliases: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
71
|
+
roles: dict[str, RoleChoice] = Field(default_factory=dict)
|
|
72
|
+
fusion: FusionConfig = Field(default_factory=FusionConfig)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ResolvedModel(BaseModel):
|
|
76
|
+
"""A role after alias resolution — what a dispatcher actually uses."""
|
|
77
|
+
|
|
78
|
+
role: str
|
|
79
|
+
provider: str
|
|
80
|
+
model: str
|
|
81
|
+
effort: str
|
|
82
|
+
source: str # "user" | "packaged" | "builtin"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _packaged_config_path() -> Path:
|
|
86
|
+
return Path(__file__).resolve().parents[2] / "config" / "models.yaml"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _builtin_config() -> ModelsConfig:
|
|
90
|
+
"""Quality-first posture used when no YAML is readable at all."""
|
|
91
|
+
roles = {
|
|
92
|
+
role: RoleChoice(model="best", effort="max") for role in QUALITY_ROLES
|
|
93
|
+
}
|
|
94
|
+
roles["execution"] = RoleChoice(model="default", effort="high")
|
|
95
|
+
roles["mechanical"] = RoleChoice(model="fast", effort="low")
|
|
96
|
+
return ModelsConfig(
|
|
97
|
+
providers={"runtime": {"type": "subagent"}},
|
|
98
|
+
aliases={"runtime": {"best": "opus", "default": "sonnet", "fast": "haiku"}},
|
|
99
|
+
roles=roles,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _read_yaml(path: Path) -> ModelsConfig | None:
|
|
104
|
+
try:
|
|
105
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, yaml.YAMLError):
|
|
107
|
+
return None
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return ModelsConfig.model_validate(data)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_config(user_path: Path | None = None) -> tuple[ModelsConfig, str]:
|
|
117
|
+
"""Load config with provenance: user file, packaged default, builtin."""
|
|
118
|
+
path = user_path or USER_CONFIG_PATH
|
|
119
|
+
config = _read_yaml(path) if path.is_file() else None
|
|
120
|
+
if config is not None:
|
|
121
|
+
return config, "user"
|
|
122
|
+
packaged = _packaged_config_path()
|
|
123
|
+
config = _read_yaml(packaged) if packaged.is_file() else None
|
|
124
|
+
if config is not None:
|
|
125
|
+
return config, "packaged"
|
|
126
|
+
return _builtin_config(), "builtin"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_alias(config: ModelsConfig, provider: str, model: str) -> str:
|
|
130
|
+
if model not in _ALIAS_NAMES:
|
|
131
|
+
return model
|
|
132
|
+
return config.aliases.get(provider, {}).get(model, "") or model
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def resolve(role: str, user_path: Path | None = None) -> ResolvedModel:
|
|
136
|
+
"""Resolve a role name to a concrete (provider, model, effort).
|
|
137
|
+
|
|
138
|
+
Unknown roles resolve conservatively: quality posture, not economy —
|
|
139
|
+
a typo must never silently land on the cheap tier
|
|
140
|
+
(``excellence-mandate``).
|
|
141
|
+
"""
|
|
142
|
+
config, source = load_config(user_path)
|
|
143
|
+
choice = config.roles.get(role)
|
|
144
|
+
if choice is None:
|
|
145
|
+
fallback = "execution" if role not in QUALITY_ROLES else role
|
|
146
|
+
choice = config.roles.get(fallback, RoleChoice(model="best", effort="max"))
|
|
147
|
+
return ResolvedModel(
|
|
148
|
+
role=role,
|
|
149
|
+
provider=choice.provider,
|
|
150
|
+
model=_resolve_alias(config, choice.provider, choice.model),
|
|
151
|
+
effort=choice.effort,
|
|
152
|
+
source=source,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_all(user_path: Path | None = None) -> list[ResolvedModel]:
|
|
157
|
+
"""Resolve every configured role — the CLI table."""
|
|
158
|
+
config, _ = load_config(user_path)
|
|
159
|
+
return [resolve(role, user_path) for role in sorted(config.roles)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ensure_user_config(user_path: Path | None = None) -> Path:
|
|
163
|
+
"""Create ~/.arkaos/models.yaml from the packaged default if absent."""
|
|
164
|
+
path = user_path or USER_CONFIG_PATH
|
|
165
|
+
if path.is_file():
|
|
166
|
+
return path
|
|
167
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
packaged = _packaged_config_path()
|
|
169
|
+
if packaged.is_file():
|
|
170
|
+
shutil.copy(packaged, path)
|
|
171
|
+
else:
|
|
172
|
+
path.write_text(
|
|
173
|
+
yaml.safe_dump(_builtin_config().model_dump(), sort_keys=False),
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
)
|
|
176
|
+
return path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def set_role(
|
|
180
|
+
role: str,
|
|
181
|
+
target: str,
|
|
182
|
+
effort: str | None = None,
|
|
183
|
+
user_path: Path | None = None,
|
|
184
|
+
) -> ResolvedModel:
|
|
185
|
+
"""Point a role at ``provider/model`` (e.g. ``anthropic/best``).
|
|
186
|
+
|
|
187
|
+
Writes the user file (creating it first when needed) and returns the
|
|
188
|
+
new resolution. Raises ValueError on a malformed target or effort.
|
|
189
|
+
"""
|
|
190
|
+
if "/" not in target:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"target must be provider/model (e.g. runtime/best), got {target!r}"
|
|
193
|
+
)
|
|
194
|
+
provider, model = target.split("/", 1)
|
|
195
|
+
path = ensure_user_config(user_path)
|
|
196
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
197
|
+
entry = {"provider": provider, "model": model}
|
|
198
|
+
entry["effort"] = effort or data.get("roles", {}).get(role, {}).get(
|
|
199
|
+
"effort", "max" if role in QUALITY_ROLES else "high"
|
|
200
|
+
)
|
|
201
|
+
RoleChoice.model_validate(entry) # fail before writing anything
|
|
202
|
+
data.setdefault("roles", {})[role] = entry
|
|
203
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
|
|
204
|
+
return resolve(role, path)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""CLI for the Model Fabric router — `npx arkaos models` delegates here.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
python -m core.runtime.model_router_cli # table of roles
|
|
6
|
+
python -m core.runtime.model_router_cli --json # machine-readable
|
|
7
|
+
python -m core.runtime.model_router_cli init # create user file
|
|
8
|
+
python -m core.runtime.model_router_cli set review anthropic/best --effort max
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from core.runtime.model_router import (
|
|
18
|
+
USER_CONFIG_PATH,
|
|
19
|
+
ensure_user_config,
|
|
20
|
+
load_config,
|
|
21
|
+
resolve_all,
|
|
22
|
+
set_role,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _print_table() -> None:
|
|
27
|
+
resolved = resolve_all()
|
|
28
|
+
_, source = load_config()
|
|
29
|
+
print()
|
|
30
|
+
print(" ArkaOS Model Fabric — role routing")
|
|
31
|
+
print(f" config: {USER_CONFIG_PATH if source == 'user' else source}")
|
|
32
|
+
print()
|
|
33
|
+
header = f" {'ROLE':<14} {'PROVIDER':<12} {'MODEL':<34} EFFORT"
|
|
34
|
+
print(header)
|
|
35
|
+
print(" " + "─" * (len(header) - 2))
|
|
36
|
+
for item in resolved:
|
|
37
|
+
model = item.model or "(unset — configure alias)"
|
|
38
|
+
print(f" {item.role:<14} {item.provider:<12} {model:<34} {item.effort}")
|
|
39
|
+
print()
|
|
40
|
+
print(" Change: npx arkaos models set <role> <provider>/<model> [--effort max]")
|
|
41
|
+
print()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_json() -> None:
|
|
45
|
+
payload = [item.model_dump() for item in resolve_all()]
|
|
46
|
+
print(json.dumps(payload, indent=2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(prog="arkaos models")
|
|
51
|
+
parser.add_argument("action", nargs="?", default="list",
|
|
52
|
+
choices=["list", "init", "set"])
|
|
53
|
+
parser.add_argument("role", nargs="?")
|
|
54
|
+
parser.add_argument("target", nargs="?")
|
|
55
|
+
parser.add_argument("--effort", choices=["low", "medium", "high", "max"])
|
|
56
|
+
parser.add_argument("--json", action="store_true", dest="as_json")
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if args.action == "init":
|
|
60
|
+
path = ensure_user_config()
|
|
61
|
+
print(f" ✓ Model Fabric config: {path}")
|
|
62
|
+
return 0
|
|
63
|
+
if args.action == "set":
|
|
64
|
+
if not args.role or not args.target:
|
|
65
|
+
parser.error("set requires: <role> <provider>/<model>")
|
|
66
|
+
try:
|
|
67
|
+
item = set_role(args.role, args.target, effort=args.effort)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
print(f" ✗ {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
print(f" ✓ {item.role} -> {item.provider}/{item.model} "
|
|
72
|
+
f"(effort {item.effort})")
|
|
73
|
+
return 0
|
|
74
|
+
if args.as_json:
|
|
75
|
+
_print_json()
|
|
76
|
+
else:
|
|
77
|
+
_print_table()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OpenRouter provider — one API key, hundreds of models.
|
|
2
|
+
|
|
3
|
+
Model Fabric PR-B. One of the backends behind the ``LLMProvider``
|
|
4
|
+
Protocol in ``core/runtime/llm_provider.py``; callers go through
|
|
5
|
+
``get_llm_provider()`` or pass an explicit model resolved by
|
|
6
|
+
``core/runtime/model_router.py`` (e.g. role ``review`` →
|
|
7
|
+
``openrouter/deepseek/deepseek-v4-pro``).
|
|
8
|
+
|
|
9
|
+
OpenAI-compatible ``/api/v1/chat/completions`` via stdlib ``urllib`` —
|
|
10
|
+
no extra dependencies, mirroring ``ollama_provider.py``. Key resolution:
|
|
11
|
+
``OPENROUTER_API_KEY`` env, then ``~/.arkaos/keys.json``. Model from
|
|
12
|
+
constructor, then ``OPENROUTER_MODEL`` env, then
|
|
13
|
+
``models.yaml`` alias ``openrouter.default``.
|
|
14
|
+
|
|
15
|
+
OpenRouter returns token usage (and native cached-token counts for
|
|
16
|
+
providers that support caching) in the ``usage`` block; both are
|
|
17
|
+
forwarded so cost telemetry stays accurate.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
29
|
+
|
|
30
|
+
_API_BASE = "https://openrouter.ai/api/v1"
|
|
31
|
+
_TIMEOUT_S = 180 # frontier models on long prompts; fusion panels wait
|
|
32
|
+
_KEYS_PATH = Path.home() / ".arkaos" / "keys.json"
|
|
33
|
+
|
|
34
|
+
# Attribution headers — OpenRouter ranks apps by these; harmless otherwise.
|
|
35
|
+
_APP_HEADERS = {
|
|
36
|
+
"HTTP-Referer": "https://github.com/andreagroferreira/arka-os",
|
|
37
|
+
"X-Title": "ArkaOS",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_key_from_keys_json() -> str:
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(_KEYS_PATH.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return ""
|
|
46
|
+
value = data.get("OPENROUTER_API_KEY", "")
|
|
47
|
+
return value.strip() if isinstance(value, str) else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_model_from_fabric() -> str:
|
|
51
|
+
"""Resolve the `openrouter.default` alias from models.yaml, if set."""
|
|
52
|
+
try:
|
|
53
|
+
from core.runtime.model_router import load_config
|
|
54
|
+
config, _ = load_config()
|
|
55
|
+
except Exception:
|
|
56
|
+
return ""
|
|
57
|
+
return config.aliases.get("openrouter", {}).get("default", "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OpenRouterProvider:
|
|
61
|
+
"""Provider that hits the OpenRouter chat-completions API."""
|
|
62
|
+
|
|
63
|
+
ENV_KEY = "OPENROUTER_API_KEY"
|
|
64
|
+
ENV_MODEL = "OPENROUTER_MODEL"
|
|
65
|
+
|
|
66
|
+
def __init__(self, model: str | None = None, api_base: str | None = None) -> None:
|
|
67
|
+
self._model_override = model
|
|
68
|
+
self._api_base = (api_base or _API_BASE).rstrip("/")
|
|
69
|
+
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
return "openrouter"
|
|
72
|
+
|
|
73
|
+
def _resolve_key(self) -> str:
|
|
74
|
+
env_value = os.environ.get(self.ENV_KEY, "").strip()
|
|
75
|
+
return env_value or _read_key_from_keys_json()
|
|
76
|
+
|
|
77
|
+
def _resolve_model(self) -> str:
|
|
78
|
+
if self._model_override:
|
|
79
|
+
return self._model_override
|
|
80
|
+
env_value = os.environ.get(self.ENV_MODEL, "").strip()
|
|
81
|
+
return env_value or _default_model_from_fabric()
|
|
82
|
+
|
|
83
|
+
def is_available(self) -> bool:
|
|
84
|
+
"""Static capability check: a key and a model are configured.
|
|
85
|
+
|
|
86
|
+
No network probe — OpenRouter is a paid remote API and this is
|
|
87
|
+
called on every provider-chain walk; a dead network surfaces as
|
|
88
|
+
``LLMUnavailable`` at call time and the chain moves on.
|
|
89
|
+
"""
|
|
90
|
+
return bool(self._resolve_key() and self._resolve_model())
|
|
91
|
+
|
|
92
|
+
def complete(
|
|
93
|
+
self,
|
|
94
|
+
prompt: str,
|
|
95
|
+
*,
|
|
96
|
+
max_tokens: int = 2000,
|
|
97
|
+
system: str = "",
|
|
98
|
+
) -> LLMResponse:
|
|
99
|
+
key = self._resolve_key()
|
|
100
|
+
if not key:
|
|
101
|
+
raise LLMUnavailable(
|
|
102
|
+
"OpenRouter key not configured (set OPENROUTER_API_KEY or "
|
|
103
|
+
"add it via `npx arkaos keys`)"
|
|
104
|
+
)
|
|
105
|
+
model = self._resolve_model()
|
|
106
|
+
if not model:
|
|
107
|
+
raise LLMUnavailable(
|
|
108
|
+
"OpenRouter model not configured (set OPENROUTER_MODEL, pass "
|
|
109
|
+
"model=, or set aliases.openrouter.default in models.yaml)"
|
|
110
|
+
)
|
|
111
|
+
data = self._post_chat(key, self._build_payload(model, prompt, system, max_tokens))
|
|
112
|
+
return self._to_response(data, model)
|
|
113
|
+
|
|
114
|
+
def _build_payload(
|
|
115
|
+
self, model: str, prompt: str, system: str, max_tokens: int
|
|
116
|
+
) -> dict:
|
|
117
|
+
messages: list[dict] = []
|
|
118
|
+
if system:
|
|
119
|
+
messages.append({"role": "system", "content": system})
|
|
120
|
+
messages.append({"role": "user", "content": prompt})
|
|
121
|
+
return {
|
|
122
|
+
"model": model,
|
|
123
|
+
"messages": messages,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"stream": False,
|
|
126
|
+
"usage": {"include": True}, # cost + cached tokens in response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _post_chat(self, key: str, payload: dict) -> dict:
|
|
130
|
+
request = urllib.request.Request(
|
|
131
|
+
f"{self._api_base}/chat/completions",
|
|
132
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
133
|
+
headers={
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Authorization": f"Bearer {key}",
|
|
136
|
+
**_APP_HEADERS,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_S) as response:
|
|
141
|
+
raw = response.read().decode("utf-8")
|
|
142
|
+
except urllib.error.HTTPError as exc:
|
|
143
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
144
|
+
raise LLMUnavailable(
|
|
145
|
+
f"OpenRouter HTTP {exc.code}: {detail}"
|
|
146
|
+
) from exc
|
|
147
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
148
|
+
raise LLMUnavailable(f"OpenRouter request failed: {exc}") from exc
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(raw)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise LLMUnavailable(f"OpenRouter returned invalid JSON: {exc}") from exc
|
|
153
|
+
|
|
154
|
+
def _to_response(self, data: dict, model: str) -> LLMResponse:
|
|
155
|
+
if data.get("error"):
|
|
156
|
+
message = data["error"].get("message", "unknown error")
|
|
157
|
+
raise LLMUnavailable(f"OpenRouter error: {message}")
|
|
158
|
+
choices = data.get("choices") or []
|
|
159
|
+
text = ""
|
|
160
|
+
if choices:
|
|
161
|
+
text = ((choices[0].get("message") or {}).get("content") or "").strip()
|
|
162
|
+
usage = data.get("usage") or {}
|
|
163
|
+
cached = int(
|
|
164
|
+
(usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0
|
|
165
|
+
)
|
|
166
|
+
return LLMResponse(
|
|
167
|
+
text=text,
|
|
168
|
+
tokens_in=int(usage.get("prompt_tokens", 0) or 0),
|
|
169
|
+
tokens_out=int(usage.get("completion_tokens", 0) or 0),
|
|
170
|
+
cached_tokens=cached,
|
|
171
|
+
model=str(data.get("model") or model),
|
|
172
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Frontend excellence gate — PreToolUse enforcement for UI file edits.
|
|
2
|
+
|
|
3
|
+
Constitution: ``excellence-mandate`` (NON-NEGOTIABLE, Excellence Reform
|
|
4
|
+
2026-07-05). UI work must load the frontend design skills and judge
|
|
5
|
+
against a named benchmark BEFORE code is written. This gate makes that
|
|
6
|
+
duty mechanical: a Write/Edit/MultiEdit touching a UI file requires an
|
|
7
|
+
``[arka:design] <skills/benchmark>`` marker in the recent assistant
|
|
8
|
+
messages — the same ceremony contract as ``[arka:routing]``
|
|
9
|
+
(flow_enforcer) and ``[arka:dispatch]`` (specialist_enforcer).
|
|
10
|
+
|
|
11
|
+
Modes via ``hooks.frontendGate`` in ``~/.arkaos/config.json``:
|
|
12
|
+
|
|
13
|
+
absent / "warn" → nudge on stderr, allow (rollout default)
|
|
14
|
+
true / "hard" → deny UI edits without the marker
|
|
15
|
+
false / "off" → gate disabled
|
|
16
|
+
|
|
17
|
+
Bypasses: ``[arka:trivial] <reason>`` marker (same contract as the
|
|
18
|
+
evidence flow — single-file edits under 10 lines) and
|
|
19
|
+
``ARKA_BYPASS_DESIGN=1`` env (logged for accountability).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from core.shared import safe_session_id as _safe_session_id_module
|
|
32
|
+
from core.workflow.flow_enforcer import (
|
|
33
|
+
TRIVIAL_RE,
|
|
34
|
+
_load_last_assistant_messages,
|
|
35
|
+
)
|
|
36
|
+
from core.workflow.specialist_enforcer import _locked_append
|
|
37
|
+
|
|
38
|
+
CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
39
|
+
TELEMETRY_PATH = Path.home() / ".arkaos" / "telemetry" / "frontend-gate.jsonl"
|
|
40
|
+
|
|
41
|
+
ASSISTANT_WINDOW = 20
|
|
42
|
+
|
|
43
|
+
DESIGN_RE = re.compile(r"\[arka:design\]\s*\S+", re.IGNORECASE)
|
|
44
|
+
|
|
45
|
+
_GATED_TOOLS = frozenset({"Write", "Edit", "MultiEdit"})
|
|
46
|
+
|
|
47
|
+
# Component + stylesheet extensions. Plain .ts/.js are excluded: they are
|
|
48
|
+
# ambiguous (stores, utils, configs) and would flood the gate with false
|
|
49
|
+
# positives — the component file is where the visual decision lands.
|
|
50
|
+
UI_SUFFIXES = frozenset({
|
|
51
|
+
".vue", ".tsx", ".jsx", ".svelte", ".astro",
|
|
52
|
+
".css", ".scss", ".sass", ".less",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Decision:
|
|
58
|
+
"""Outcome of frontend-gate evaluation."""
|
|
59
|
+
|
|
60
|
+
allow: bool
|
|
61
|
+
reason: str
|
|
62
|
+
mode: str = "warn"
|
|
63
|
+
target_file: str = ""
|
|
64
|
+
marker_found: str | None = None
|
|
65
|
+
|
|
66
|
+
def to_stderr_message(self) -> str:
|
|
67
|
+
if self.reason not in ("no-design-marker",):
|
|
68
|
+
return ""
|
|
69
|
+
head = "[arka:suggest]" if self.allow else "[ARKA:DESIGN]"
|
|
70
|
+
verb = "should" if self.allow else "MUST"
|
|
71
|
+
return (
|
|
72
|
+
f"{head} UI edit to {self.target_file or 'this file'} without "
|
|
73
|
+
f"design evidence. Frontend work {verb} load the design skills "
|
|
74
|
+
f"(frontend-design, ui-ux-pro-max, project design system) at "
|
|
75
|
+
f"maximum effort and judge against a named benchmark FIRST "
|
|
76
|
+
f"(constitution `excellence-mandate`). Emit "
|
|
77
|
+
f"`[arka:design] <skills> benchmark=<name>` before UI edits, "
|
|
78
|
+
f"or `[arka:trivial] <reason>` for sub-10-line fixes."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mode() -> str:
|
|
83
|
+
"""Resolve ``hooks.frontendGate`` to 'off' | 'warn' | 'hard'."""
|
|
84
|
+
if not CONFIG_PATH.exists():
|
|
85
|
+
return "warn"
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
88
|
+
except (json.JSONDecodeError, OSError):
|
|
89
|
+
return "warn"
|
|
90
|
+
raw = data.get("hooks", {}).get("frontendGate", "warn")
|
|
91
|
+
if raw in (False, "off", "false"):
|
|
92
|
+
return "off"
|
|
93
|
+
if raw in (True, "hard", "true"):
|
|
94
|
+
return "hard"
|
|
95
|
+
return "warn"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_ui_file(file_path: str) -> bool:
|
|
99
|
+
"""True when the path carries a gated UI extension."""
|
|
100
|
+
return Path(file_path).suffix.lower() in UI_SUFFIXES
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scan(messages: list[str]) -> str | None:
|
|
104
|
+
"""Return the matched evidence marker, or None."""
|
|
105
|
+
for message in messages:
|
|
106
|
+
match = DESIGN_RE.search(message) or TRIVIAL_RE.search(message)
|
|
107
|
+
if match:
|
|
108
|
+
return match.group(0)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def evaluate(
|
|
113
|
+
tool_name: str,
|
|
114
|
+
transcript_path: str,
|
|
115
|
+
session_id: str,
|
|
116
|
+
cwd: str,
|
|
117
|
+
tool_input: dict,
|
|
118
|
+
messages: list[str] | None = None,
|
|
119
|
+
) -> Decision:
|
|
120
|
+
"""Evaluate one tool call against the frontend excellence gate."""
|
|
121
|
+
file_path = str(tool_input.get("file_path", ""))
|
|
122
|
+
if tool_name not in _GATED_TOOLS or not is_ui_file(file_path):
|
|
123
|
+
return Decision(allow=True, reason="not-ui-scope", target_file=file_path)
|
|
124
|
+
mode = _mode()
|
|
125
|
+
if mode == "off":
|
|
126
|
+
return Decision(allow=True, reason="flag-off", mode=mode,
|
|
127
|
+
target_file=file_path)
|
|
128
|
+
if os.environ.get("ARKA_BYPASS_DESIGN") == "1":
|
|
129
|
+
return Decision(allow=True, reason="env-bypass", mode=mode,
|
|
130
|
+
target_file=file_path)
|
|
131
|
+
if messages is None:
|
|
132
|
+
messages = _load_last_assistant_messages(
|
|
133
|
+
transcript_path, ASSISTANT_WINDOW
|
|
134
|
+
)
|
|
135
|
+
marker = _scan(messages)
|
|
136
|
+
if marker is not None:
|
|
137
|
+
return Decision(allow=True, reason="design-evidence", mode=mode,
|
|
138
|
+
target_file=file_path, marker_found=marker)
|
|
139
|
+
return Decision(allow=(mode != "hard"), reason="no-design-marker",
|
|
140
|
+
mode=mode, target_file=file_path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def record_telemetry(session_id: str, tool: str, decision: Decision) -> None:
|
|
144
|
+
"""Append a structured record to the frontend-gate telemetry log.
|
|
145
|
+
|
|
146
|
+
Drops the record silently when session_id fails the safe-id check
|
|
147
|
+
(path-traversal mitigation, CWE-22). Never blocks the hook.
|
|
148
|
+
"""
|
|
149
|
+
safe = _safe_session_id_module.safe_session_id(session_id)
|
|
150
|
+
if safe is None:
|
|
151
|
+
return
|
|
152
|
+
entry = {
|
|
153
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
154
|
+
"session_id": safe,
|
|
155
|
+
"tool": tool,
|
|
156
|
+
**asdict(decision),
|
|
157
|
+
}
|
|
158
|
+
try:
|
|
159
|
+
with _locked_append(TELEMETRY_PATH) as fh:
|
|
160
|
+
fh.write(json.dumps(entry) + "\n")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
package/installer/autostart.js
CHANGED
|
@@ -29,6 +29,9 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
29
29
|
if (os === "darwin") {
|
|
30
30
|
const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
|
|
31
31
|
const log = `${home}/.arkaos/logs/dashboard-autostart.log`;
|
|
32
|
+
// AbandonProcessGroup: the launcher backgrounds the API/UI and exits;
|
|
33
|
+
// without it launchd kills the whole process group on exit and the
|
|
34
|
+
// dashboard dies seconds after boot. `ensure` keeps re-loads idempotent.
|
|
32
35
|
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
33
36
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
37
|
<plist version="1.0">
|
|
@@ -39,14 +42,19 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
39
42
|
<array>
|
|
40
43
|
<string>/bin/bash</string>
|
|
41
44
|
<string>${startScript}</string>
|
|
45
|
+
<string>ensure</string>
|
|
42
46
|
</array>
|
|
43
47
|
<key>EnvironmentVariables</key>
|
|
44
48
|
<dict>
|
|
45
49
|
<key>ARKAOS_ROOT</key>
|
|
46
50
|
<string>${repoRoot}</string>
|
|
51
|
+
<key>ARKAOS_NO_BROWSER</key>
|
|
52
|
+
<string>1</string>
|
|
47
53
|
</dict>
|
|
48
54
|
<key>RunAtLoad</key>
|
|
49
55
|
<true/>
|
|
56
|
+
<key>AbandonProcessGroup</key>
|
|
57
|
+
<true/>
|
|
50
58
|
<key>StandardOutPath</key>
|
|
51
59
|
<string>${log}</string>
|
|
52
60
|
<key>StandardErrorPath</key>
|
|
@@ -63,14 +71,19 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
63
71
|
|
|
64
72
|
if (os === "linux") {
|
|
65
73
|
const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
|
|
74
|
+
// oneshot + RemainAfterExit: the launcher backgrounds the API/UI and
|
|
75
|
+
// exits; Type=simple would mark the unit dead on exit and systemd would
|
|
76
|
+
// reap the children with the cgroup. `ensure` keeps restarts idempotent.
|
|
66
77
|
const content = `[Unit]
|
|
67
78
|
Description=ArkaOS Dashboard (Python API + UI)
|
|
68
79
|
After=network.target
|
|
69
80
|
|
|
70
81
|
[Service]
|
|
71
|
-
Type=
|
|
82
|
+
Type=oneshot
|
|
83
|
+
RemainAfterExit=yes
|
|
72
84
|
Environment=ARKAOS_ROOT=${repoRoot}
|
|
73
|
-
|
|
85
|
+
Environment=ARKAOS_NO_BROWSER=1
|
|
86
|
+
ExecStart=/bin/bash ${startScript} ensure
|
|
74
87
|
Restart=on-failure
|
|
75
88
|
|
|
76
89
|
[Install]
|
|
@@ -87,6 +100,7 @@ WantedBy=default.target
|
|
|
87
100
|
const ps1 = join(repoRoot, "scripts", "start-dashboard.ps1");
|
|
88
101
|
const content = `@echo off
|
|
89
102
|
rem ArkaOS dashboard autostart (login).
|
|
103
|
+
set ARKAOS_NO_BROWSER=1
|
|
90
104
|
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${ps1}"
|
|
91
105
|
`;
|
|
92
106
|
return {
|
package/installer/cli.js
CHANGED
|
@@ -25,6 +25,9 @@ const { values, positionals } = parseArgs({
|
|
|
25
25
|
// `values.fix` rather than as a free positional under strict:false.
|
|
26
26
|
// Eliminates the dead-branch fallback flagged by Marta in PR2's QG.
|
|
27
27
|
fix: { type: "boolean" },
|
|
28
|
+
// Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
|
|
29
|
+
json: { type: "boolean" },
|
|
30
|
+
effort: { type: "string" },
|
|
28
31
|
},
|
|
29
32
|
allowPositionals: true,
|
|
30
33
|
strict: false,
|
|
@@ -51,6 +54,8 @@ Usage:
|
|
|
51
54
|
npx arkaos dashboard Start monitoring dashboard
|
|
52
55
|
npx arkaos autostart <enable|disable|status> Start dashboard on boot
|
|
53
56
|
npx arkaos keys Manage API keys (OpenAI, fal.ai, etc.)
|
|
57
|
+
npx arkaos models Model Fabric: which model runs each role
|
|
58
|
+
npx arkaos models set <role> <provider>/<model> Re-route a role
|
|
54
59
|
npx arkaos doctor Run health checks
|
|
55
60
|
npx arkaos uninstall Remove ArkaOS
|
|
56
61
|
|
|
@@ -154,6 +159,24 @@ async function main() {
|
|
|
154
159
|
break;
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
case "models": {
|
|
163
|
+
const { execSync } = await import("node:child_process");
|
|
164
|
+
const repoRootModels = join(__dirname, "..");
|
|
165
|
+
const pyModels = getArkaosPython();
|
|
166
|
+
if (!pyModels) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
|
|
167
|
+
const modelArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
|
|
168
|
+
const effortFlag = values.effort ? ` --effort "${values.effort}"` : "";
|
|
169
|
+
const jsonFlag = values.json ? " --json" : "";
|
|
170
|
+
try {
|
|
171
|
+
execSync(`"${pyModels}" -m core.runtime.model_router_cli ${modelArgs}${effortFlag}${jsonFlag}`, {
|
|
172
|
+
stdio: "inherit",
|
|
173
|
+
cwd: repoRootModels,
|
|
174
|
+
env: { ...process.env, ARKAOS_ROOT: repoRootModels, PYTHONPATH: repoRootModels },
|
|
175
|
+
});
|
|
176
|
+
} catch { process.exit(1); }
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
case "index": {
|
|
158
181
|
const { execSync } = await import("node:child_process");
|
|
159
182
|
const indexArgs = positionals.slice(1).join(" ");
|
package/installer/keys.js
CHANGED
|
@@ -6,6 +6,7 @@ const KEYS_PATH = join(homedir(), ".arkaos", "keys.json");
|
|
|
6
6
|
|
|
7
7
|
const PROVIDERS = {
|
|
8
8
|
OPENAI_API_KEY: { name: "OpenAI", used_for: "Whisper transcription, embeddings, GPT" },
|
|
9
|
+
OPENROUTER_API_KEY: { name: "OpenRouter", used_for: "Model Fabric: one key, hundreds of models (Kimi, DeepSeek, GPT, Gemini) for roles + fusion" },
|
|
9
10
|
GOOGLE_API_KEY: { name: "Google", used_for: "Gemini API, Nano Banana, Google Cloud AI" },
|
|
10
11
|
FAL_API_KEY: { name: "fal.ai", used_for: "Image generation, video generation" },
|
|
11
12
|
MAGIC_API_KEY: { name: "21st.dev Magic", used_for: "Frontend UI/UX component generation (Magic MCP)" },
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -10,6 +10,30 @@ VENV_PYTHON="$HOME/.arkaos/venv/bin/python"
|
|
|
10
10
|
|
|
11
11
|
mkdir -p "$HOME/.arkaos"
|
|
12
12
|
|
|
13
|
+
# ── Args ──
|
|
14
|
+
# `ensure` — idempotent mode: exit 0 if API+UI already healthy,
|
|
15
|
+
# otherwise fall through to a full start. Safe to call from
|
|
16
|
+
# SessionStart hooks and launchd/systemd boot units.
|
|
17
|
+
# `--no-browser` — do not open the browser after start (also via
|
|
18
|
+
# ARKAOS_NO_BROWSER=1 env, for boot/hook contexts).
|
|
19
|
+
MODE="start"
|
|
20
|
+
for arg in "$@"; do
|
|
21
|
+
case "$arg" in
|
|
22
|
+
ensure) MODE="ensure" ;;
|
|
23
|
+
--no-browser) ARKAOS_NO_BROWSER=1 ;;
|
|
24
|
+
esac
|
|
25
|
+
done
|
|
26
|
+
|
|
27
|
+
if [ "$MODE" = "ensure" ] && [ -f "$PORT_FILE" ]; then
|
|
28
|
+
# shellcheck disable=SC1090
|
|
29
|
+
source "$PORT_FILE"
|
|
30
|
+
if curl -sf --max-time 2 "http://localhost:${API_PORT:-0}/api/overview" >/dev/null 2>&1 \
|
|
31
|
+
&& curl -sf --max-time 2 "http://localhost:${UI_PORT:-0}/" >/dev/null 2>&1; then
|
|
32
|
+
echo " ✓ Dashboard already running (API :${API_PORT}, UI :${UI_PORT})"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
|
|
13
37
|
# ── Venv guard (PR2 v3.73.1 — Force Specialist Dispatch dogfood) ──
|
|
14
38
|
# Previously the dashboard fell back to ambient `python3` when the venv
|
|
15
39
|
# wasn't available. That hid broken-venv conditions (Homebrew patch
|
|
@@ -133,7 +157,7 @@ echo " or kill \$(cat $PID_FILE)"
|
|
|
133
157
|
echo ""
|
|
134
158
|
|
|
135
159
|
# Wait for UI to be ready
|
|
136
|
-
if [ -n "$UI_PID" ]; then
|
|
160
|
+
if [ -n "$UI_PID" ] && [ -z "${ARKAOS_NO_BROWSER:-}" ]; then
|
|
137
161
|
sleep 5
|
|
138
162
|
# Open browser
|
|
139
163
|
if command -v open >/dev/null 2>&1; then
|