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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -1
  4. package/arka/skills/fusion/SKILL.md +98 -0
  5. package/config/hooks/session-start.sh +23 -0
  6. package/core/fusion/__init__.py +5 -0
  7. package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
  8. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  9. package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
  10. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  11. package/core/fusion/cli.py +88 -0
  12. package/core/fusion/engine.py +153 -0
  13. package/core/fusion/panel_builder.py +58 -0
  14. package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
  15. package/core/governance/redo_counter.py +81 -0
  16. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  17. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  18. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  19. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  20. package/core/hooks/post_tool_use.py +26 -12
  21. package/core/hooks/pre_tool_use.py +4 -0
  22. package/core/hooks/user_prompt_submit.py +7 -0
  23. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  24. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  25. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  30. package/core/runtime/model_router.py +17 -0
  31. package/core/runtime/model_router_cli.py +70 -1
  32. package/core/runtime/model_routing_context.py +82 -0
  33. package/core/runtime/ollama_discovery.py +96 -0
  34. package/core/runtime/runtime_models.py +51 -0
  35. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  37. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  39. package/core/workflow/flow_authorization.py +181 -0
  40. package/core/workflow/flow_enforcer.py +56 -11
  41. package/dashboard/app/layouts/default.vue +7 -0
  42. package/dashboard/app/pages/models.vue +423 -0
  43. package/installer/cli.js +21 -0
  44. package/package.json +1 -1
  45. package/pyproject.toml +1 -1
  46. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  47. package/scripts/dashboard-api.py +90 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The Operating System for AI Agent Teams.**
4
4
 
5
- 82 agents. 17 departments. 267 skills. Enterprise frameworks. Multi-runtime. One install.
5
+ 82 agents. 17 departments. 268 skills. Enterprise frameworks. Multi-runtime. One install.
6
6
 
7
7
  ```bash
8
8
  npx arkaos install
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.2.0
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. 267 skills. Multi-runtime. Dashboard. Knowledge RAG.
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
@@ -0,0 +1,5 @@
1
+ """Fusion engine — panel of models answers, a judge synthesises."""
2
+
3
+ from core.fusion.engine import FusionResult, FusionUnavailable, fuse
4
+
5
+ __all__ = ["FusionResult", "FusionUnavailable", "fuse"]
@@ -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}"
@@ -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)
@@ -111,19 +111,29 @@ def _unlocked(fh) -> None:
111
111
  # ─── Section 1: flow marker cache ────────────────────────────────────────
112
112
 
113
113
 
114
- def _write_flow_marker(session_id: str, assistant_msg: str) -> None:
115
- if not session_id or not assistant_msg:
114
+ def _confirm_flow_authorization(session_id: str, transcript_path: str) -> None:
115
+ """Confirm persistent flow authorization from the transcript.
116
+
117
+ Replaces the dead ``_write_flow_marker`` that read a non-existent
118
+ ``assistant_message`` payload field. Reads the same transcript the
119
+ enforcer trusts and, when a flow marker is present, writes a confirmed
120
+ authorization that survives compaction and the 20-message window.
121
+ """
122
+ if not session_id:
116
123
  return
117
- routing = _ROUTING_RE.search(assistant_msg)
118
- if routing:
119
- kind, dept, lead = "routing", routing.group(1), routing.group(2)
120
- elif _TRIVIAL_RE.search(assistant_msg):
121
- kind, dept, lead = "trivial", "", ""
122
- else:
124
+ try:
125
+ from core.workflow.flow_authorization import confirm
126
+ from core.workflow.flow_enforcer import (
127
+ _load_last_assistant_messages,
128
+ _scan_markers,
129
+ )
130
+ except Exception:
123
131
  return
124
132
  try:
125
- from core.workflow.marker_cache import write_marker
126
- write_marker(session_id, kind, dept, lead)
133
+ messages = _load_last_assistant_messages(transcript_path, 20)
134
+ marker_found, _ = _scan_markers(messages)
135
+ if marker_found is not None:
136
+ confirm(session_id, marker_found)
127
137
  except Exception:
128
138
  pass
129
139
 
@@ -614,9 +624,13 @@ def main(stdin_json: dict | None = None) -> int:
614
624
  exit_code = get_str(stdin_json, "exit_code") or "0"
615
625
  cwd = get_str(stdin_json, "cwd")
616
626
  session_id = get_str(stdin_json, "session_id")
617
- assistant_msg = get_str(stdin_json, "assistant_message")
627
+ transcript_path = get_str(stdin_json, "transcript_path")
618
628
 
619
- _write_flow_marker(session_id, assistant_msg)
629
+ # Claude Code sends no `assistant_message` field (confirmed against the
630
+ # hook docs, 2026-07-05) — the old read was dead and the marker cache
631
+ # was never written. Scan the transcript (the only source hooks have)
632
+ # and confirm persistent authorization when a marker is present.
633
+ _confirm_flow_authorization(session_id, transcript_path)
620
634
 
621
635
  if tool_name in ("Task", "Agent"):
622
636
  subagent_type = get_str(stdin_json, "tool_input", "subagent_type")
@@ -260,6 +260,10 @@ def _flow_gate(
260
260
  except Exception:
261
261
  pass
262
262
  if decision.allow:
263
+ # Grace path: allow, but surface the non-blocking warning so the
264
+ # operator sees the routing nudge instead of a silent pass.
265
+ if decision.warning:
266
+ print(decision.warning, file=sys.stderr)
263
267
  return 0
264
268
  return _deny(decision.to_stderr_message())
265
269
 
@@ -171,6 +171,13 @@ def _invalidate_turn_caches(session_id: str) -> None:
171
171
  invalidate_marker(session_id)
172
172
  except Exception:
173
173
  pass
174
+ try:
175
+ # Reset only the per-turn grace flag; confirmed authorization and
176
+ # the grace counter persist across turns (enforcer resilience).
177
+ from core.workflow.flow_authorization import reset_turn
178
+ reset_turn(session_id)
179
+ except Exception:
180
+ pass
174
181
  try:
175
182
  from core.synapse.kb_cache import invalidate_obsidian_query
176
183
  invalidate_obsidian_query(session_id)