arkaos 4.3.0 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +4 -4
  4. package/arka/skills/costs/SKILL.md +1 -1
  5. package/arka/skills/fusion/SKILL.md +13 -6
  6. package/config/claude-agents/marta-cqo.md +1 -1
  7. package/config/claude-agents/paulo-tech-lead.md +1 -1
  8. package/config/constitution.yaml +5 -5
  9. package/config/hooks/_lib/arka_python.ps1 +43 -0
  10. package/config/hooks/_lib/arka_python.sh +63 -0
  11. package/config/hooks/agent-provision.sh +8 -1
  12. package/config/hooks/cwd-changed.sh +6 -2
  13. package/config/hooks/post-tool-use-v2.sh +6 -2
  14. package/config/hooks/post-tool-use.sh +8 -17
  15. package/config/hooks/pre-tool-use.ps1 +8 -6
  16. package/config/hooks/pre-tool-use.sh +8 -17
  17. package/config/hooks/session-start.sh +40 -15
  18. package/config/hooks/stop.ps1 +7 -5
  19. package/config/hooks/stop.sh +8 -17
  20. package/config/hooks/token-hygiene.sh +5 -1
  21. package/config/hooks/user-prompt-submit-v2.sh +13 -9
  22. package/config/hooks/user-prompt-submit.sh +8 -17
  23. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  24. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  25. package/core/fusion/cli.py +57 -6
  26. package/core/fusion/panel_builder.py +58 -0
  27. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  28. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  29. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  30. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  31. package/core/hooks/post_tool_use.py +26 -12
  32. package/core/hooks/pre_tool_use.py +4 -0
  33. package/core/hooks/user_prompt_submit.py +7 -0
  34. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  35. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  36. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  37. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  38. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  39. package/core/runtime/__pycache__/openrouter_provider.cpython-312.pyc +0 -0
  40. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  41. package/core/runtime/model_router.py +17 -0
  42. package/core/runtime/model_routing_context.py +82 -0
  43. package/core/runtime/runtime_models.py +51 -0
  44. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  45. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  46. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  48. package/core/workflow/flow_authorization.py +181 -0
  49. package/core/workflow/flow_enforcer.py +56 -11
  50. package/core/workflow/state_reader.sh +12 -5
  51. package/dashboard/app/pages/models.vue +23 -4
  52. package/departments/ops/skills/update/SKILL.md +2 -2
  53. package/departments/ops/skills/update/references/sync-engine.md +1 -1
  54. package/departments/quality/SKILL.md +1 -1
  55. package/installer/cli.js +21 -0
  56. package/installer/index.js +14 -0
  57. package/installer/update.js +13 -0
  58. package/package.json +5 -2
  59. package/pyproject.toml +1 -1
  60. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  61. package/scripts/dashboard-api.py +13 -2
  62. package/scripts/seed_initial_patterns.py +1 -1
@@ -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
 
@@ -5,6 +5,13 @@
5
5
  # Dependencies: jq (required)
6
6
  # ============================================================================
7
7
 
8
+ # ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
9
+ # The lib is not a sibling here; try the in-repo path then the installed tree.
10
+ for _l in "$(dirname "${BASH_SOURCE[0]:-$0}")/../../config/hooks/_lib/arka_python.sh" "$HOME/.arkaos/config/hooks/_lib/arka_python.sh"; do
11
+ if [ -f "$_l" ]; then . "$_l"; break; fi
12
+ done
13
+ : "${ARKA_PY:=python3}"
14
+
8
15
  STATE_FILE="$HOME/.arkaos/workflow-state.json"
9
16
 
10
17
  if [ ! -f "$STATE_FILE" ]; then
@@ -73,11 +80,11 @@ case "$CMD" in
73
80
  echo "none"
74
81
  exit 0
75
82
  fi
76
- if command -v python3 &>/dev/null; then
77
- _F_NAME=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('name',''))" 2>/dev/null)
78
- _F_STATUS=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('status',''))" 2>/dev/null)
79
- _F_PHASES=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(len(d.get('plan_phases',[])))" 2>/dev/null)
80
- _F_BRANCH=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('governance',{}).get('branch_strategy',''))" 2>/dev/null)
83
+ if command -v "$ARKA_PY" >/dev/null 2>&1; then
84
+ _F_NAME=$(FORGE_FILE="$_FORGE_FILE" "$ARKA_PY" -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('name',''))" 2>/dev/null)
85
+ _F_STATUS=$(FORGE_FILE="$_FORGE_FILE" "$ARKA_PY" -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('status',''))" 2>/dev/null)
86
+ _F_PHASES=$(FORGE_FILE="$_FORGE_FILE" "$ARKA_PY" -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(len(d.get('plan_phases',[])))" 2>/dev/null)
87
+ _F_BRANCH=$(FORGE_FILE="$_FORGE_FILE" "$ARKA_PY" -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('governance',{}).get('branch_strategy',''))" 2>/dev/null)
81
88
  echo "${_FORGE_ID}|${_F_NAME}|${_F_STATUS}|${_F_PHASES}|${_F_BRANCH}"
82
89
  else
83
90
  echo "${_FORGE_ID}|||0|"
@@ -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"
@@ -23,7 +23,7 @@ Hybrid sync engine: Python handles deterministic operations (MCPs, settings, des
23
23
 
24
24
  1. **One-stop: npm refresh + engine (PR61 v2.78.0 orchestrator).**
25
25
  ```bash
26
- cd $ARKAOS_ROOT && python -m core.sync.update_orchestrator --home ~/.arkaos --skills ~/.claude/skills --output json
26
+ cd $ARKAOS_ROOT && ~/.arkaos/bin/arka-py -m core.sync.update_orchestrator --home ~/.arkaos --skills ~/.claude/skills --output json
27
27
  ```
28
28
  The orchestrator detects whether the running ArkaOS is behind npm
29
29
  latest. When stale, it shells out to `npx arkaos@latest update`
@@ -38,7 +38,7 @@ Hybrid sync engine: Python handles deterministic operations (MCPs, settings, des
38
38
  silently skips the npm step and falls through to the engine.
39
39
 
40
40
  Fallback (no orchestrator): the underlying engine still runs the
41
- same way via `python -m core.sync.engine ...` for callers that
41
+ same way via `~/.arkaos/bin/arka-py -m core.sync.engine ...` for callers that
42
42
  don't need the version-drift gate.
43
43
 
44
44
  2. **Phase 4 (intelligent, AI subagent):** After the engine completes, dispatch ONE subagent with the engine's JSON report + the feature registry (`core/sync/features/*.yaml`). The subagent injects/removes feature sections in each `~/.claude/skills/arka-{ecosystem}/SKILL.md` while preserving all custom content.
@@ -7,7 +7,7 @@ Referenced from SKILL.md. Read only when needed.
7
7
  Run the sync engine from the ArkaOS repo root:
8
8
 
9
9
  ```bash
10
- cd $ARKAOS_ROOT && python -m core.sync.engine --home ~/.arkaos --skills ~/.claude/skills --output json
10
+ cd $ARKAOS_ROOT && ~/.arkaos/bin/arka-py -m core.sync.engine --home ~/.arkaos --skills ~/.claude/skills --output json
11
11
  ```
12
12
 
13
13
  The engine handles deterministic operations and returns a JSON report.
@@ -24,7 +24,7 @@ Any Department Workflow:
24
24
  ...
25
25
  Phase N-1: QUALITY GATE
26
26
  1. Run the evidence engine over the project/diff:
27
- python -m core.governance.evidence_checks <project_dir> \
27
+ ~/.arkaos/bin/arka-py -m core.governance.evidence_checks <project_dir> \
28
28
  [--changed-files f1,f2] [--test-command '...'] --json
29
29
  2. Marta dispatches Eduardo + Francisca to INTERPRET the report:
30
30
  - Eduardo: spellcheck section + prose review of changed copy
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(" ");
@@ -165,6 +165,12 @@ export async function install({ runtime, path, force, skipSystem, withOllama })
165
165
  copyFileSync(cmdSrc, join(binDir, "arka-claude.cmd"));
166
166
  installed = true;
167
167
  }
168
+ // arka-py — the ArkaOS Python entrypoint SKILL.md commands invoke.
169
+ const pyPsSrc = join(ARKAOS_ROOT, "bin", "arka-py.ps1");
170
+ const pyCmdSrc = join(ARKAOS_ROOT, "bin", "arka-py.cmd");
171
+ if (existsSync(pyPsSrc)) copyFileSync(pyPsSrc, join(binDir, "arka-py.ps1"));
172
+ if (existsSync(pyCmdSrc)) copyFileSync(pyCmdSrc, join(binDir, "arka-py.cmd"));
173
+ if (existsSync(pyPsSrc) || existsSync(pyCmdSrc)) ok("arka-py interpreter shim installed (.cmd + .ps1)");
168
174
  if (installed) {
169
175
  ok("arka-claude wrapper installed (.cmd + .ps1)");
170
176
  console.log(` Add to PATH: setx PATH "%PATH%;%USERPROFILE%\\.arkaos\\bin"`);
@@ -179,6 +185,14 @@ export async function install({ runtime, path, force, skipSystem, withOllama })
179
185
  console.log(` Add to PATH: export PATH="$HOME/.arkaos/bin:$PATH"`);
180
186
  console.log(` Optional alias: alias claude="arka-claude"`);
181
187
  }
188
+ // arka-py — the ArkaOS Python entrypoint SKILL.md commands invoke so the
189
+ // agent never runs a bare `python` that lacks ArkaOS deps (pyyaml, ...).
190
+ const arkaPySrc = join(ARKAOS_ROOT, "bin", "arka-py");
191
+ if (existsSync(arkaPySrc)) {
192
+ copyFileSync(arkaPySrc, join(binDir, "arka-py"));
193
+ try { chmodSync(join(binDir, "arka-py"), 0o755); } catch {}
194
+ ok("arka-py interpreter shim installed");
195
+ }
182
196
  }
183
197
  const claudeMdSrc = join(ARKAOS_ROOT, "config", "user-claude.md");
184
198
  const userClaudeMd = join(homedir(), ".claude", "CLAUDE.md");
@@ -248,6 +248,13 @@ export async function update() {
248
248
  if (existsSync(psSrc) || existsSync(cmdSrc)) {
249
249
  console.log(" ✓ arka-claude wrapper updated (.cmd + .ps1)");
250
250
  }
251
+ const pyPsSrc = join(ARKAOS_ROOT, "bin", "arka-py.ps1");
252
+ const pyCmdSrc = join(ARKAOS_ROOT, "bin", "arka-py.cmd");
253
+ if (existsSync(pyPsSrc)) copyFileSync(pyPsSrc, join(binDir, "arka-py.ps1"));
254
+ if (existsSync(pyCmdSrc)) copyFileSync(pyCmdSrc, join(binDir, "arka-py.cmd"));
255
+ if (existsSync(pyPsSrc) || existsSync(pyCmdSrc)) {
256
+ console.log(" ✓ arka-py interpreter shim updated (.cmd + .ps1)");
257
+ }
251
258
  } else {
252
259
  const wrapperSrc = join(ARKAOS_ROOT, "bin", "arka-claude");
253
260
  if (existsSync(wrapperSrc)) {
@@ -255,6 +262,12 @@ export async function update() {
255
262
  try { chmodSync(join(binDir, "arka-claude"), 0o755); } catch {}
256
263
  console.log(" ✓ arka-claude wrapper updated");
257
264
  }
265
+ const arkaPySrc = join(ARKAOS_ROOT, "bin", "arka-py");
266
+ if (existsSync(arkaPySrc)) {
267
+ copyFileSync(arkaPySrc, join(binDir, "arka-py"));
268
+ try { chmodSync(join(binDir, "arka-py"), 0o755); } catch {}
269
+ console.log(" ✓ arka-py interpreter shim updated");
270
+ }
258
271
  }
259
272
  const userClaudeMd = join(homedir(), ".claude", "CLAUDE.md");
260
273
  const claudeMdSrc = join(ARKAOS_ROOT, "config", "user-claude.md");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.3.0",
3
+ "version": "4.3.2",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,5 +58,8 @@
58
58
  "CONSTITUTION.md",
59
59
  "README.md",
60
60
  "LICENSE"
61
- ]
61
+ ],
62
+ "devDependencies": {
63
+ "eslint": "^8.57.1"
64
+ }
62
65
  }
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.2"
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(
@@ -175,7 +175,7 @@ def main() -> int:
175
175
  for card in _INITIAL_PATTERNS:
176
176
  record_pattern(card)
177
177
  print(f"Seeded {len(_INITIAL_PATTERNS)} pattern cards.")
178
- print("Inspect: python -m core.knowledge.pattern_cards_cli list")
178
+ print("Inspect: ~/.arkaos/bin/arka-py -m core.knowledge.pattern_cards_cli list")
179
179
  return 0
180
180
 
181
181