arkaos 2.16.1 → 2.17.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 (42) hide show
  1. package/VERSION +1 -1
  2. package/config/agent-allowlists/_base.yaml +7 -0
  3. package/config/agent-allowlists/laravel.yaml +9 -0
  4. package/config/agent-allowlists/node.yaml +7 -0
  5. package/config/agent-allowlists/nuxt.yaml +7 -0
  6. package/config/agent-allowlists/python.yaml +7 -0
  7. package/config/hooks/agent-provision.sh +135 -0
  8. package/config/hooks/user-prompt-submit.ps1 +7 -1
  9. package/config/hooks/user-prompt-submit.sh +7 -1
  10. package/config/mcp-policy.yaml +36 -0
  11. package/config/settings-template.json +12 -0
  12. package/config/standards/claude-md-overlays/laravel.md +8 -0
  13. package/config/standards/claude-md-overlays/node.md +7 -0
  14. package/config/standards/claude-md-overlays/nuxt.md +8 -0
  15. package/config/standards/claude-md-overlays/python.md +8 -0
  16. package/core/runtime/__pycache__/context_compactor.cpython-313.pyc +0 -0
  17. package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
  18. package/core/runtime/context_compactor.py +63 -0
  19. package/core/runtime/subagent.py +13 -0
  20. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  21. package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
  22. package/core/sync/__pycache__/content_merger.cpython-313.pyc +0 -0
  23. package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
  24. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  25. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  26. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  27. package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
  28. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  29. package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
  30. package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
  31. package/core/sync/agent_provisioner.py +150 -0
  32. package/core/sync/ai_mcp_decider.py +86 -0
  33. package/core/sync/content_merger.py +100 -0
  34. package/core/sync/content_syncer.py +167 -0
  35. package/core/sync/engine.py +20 -0
  36. package/core/sync/mcp_optimizer.py +187 -0
  37. package/core/sync/policy_loader.py +94 -0
  38. package/core/sync/reporter.py +49 -1
  39. package/core/sync/schema.py +37 -0
  40. package/core/sync/self_healing.py +47 -0
  41. package/package.json +1 -1
  42. package/pyproject.toml +1 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.16.1
1
+ 2.17.1
@@ -0,0 +1,7 @@
1
+ # Baseline agents every project gets regardless of stack.
2
+ stack: _base
3
+ baseline:
4
+ - strategy-director
5
+ - cqo
6
+ - copy-director
7
+ - tech-ux-director
@@ -0,0 +1,9 @@
1
+ stack: laravel
2
+ baseline:
3
+ - backend-dev
4
+ - senior-dev
5
+ - architect
6
+ - qa
7
+ - devops
8
+ - security
9
+ - analyst
@@ -0,0 +1,7 @@
1
+ stack: node
2
+ baseline:
3
+ - backend-dev
4
+ - senior-dev
5
+ - architect
6
+ - qa
7
+ - devops
@@ -0,0 +1,7 @@
1
+ stack: nuxt
2
+ baseline:
3
+ - frontend-dev
4
+ - backend-dev
5
+ - architect
6
+ - qa
7
+ - devops
@@ -0,0 +1,7 @@
1
+ stack: python
2
+ baseline:
3
+ - senior-dev
4
+ - architect
5
+ - qa
6
+ - devops
7
+ - analyst
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # ArkaOS PreToolUse hook for dynamic agent provisioning.
3
+ # Intercepts Task tool calls: if subagent_type is not present in the
4
+ # project's .claude/agents/, copies it from ArkaOS core when available,
5
+ # or blocks with an approval-request message when the agent must be
6
+ # created via `/platform-arka agent provision <name>`.
7
+
8
+ set -euo pipefail
9
+
10
+ # Hook contract: stdin is a JSON payload with fields tool_name + tool_input.
11
+ payload="$(cat)"
12
+
13
+ tool_name="$(echo "$payload" | jq -r '.tool_name // ""')"
14
+ if [ "$tool_name" != "Task" ]; then
15
+ exit 0
16
+ fi
17
+
18
+ subagent_type="$(echo "$payload" | jq -r '.tool_input.subagent_type // ""')"
19
+ if [ -z "$subagent_type" ] || [ "$subagent_type" = "null" ]; then
20
+ exit 0
21
+ fi
22
+
23
+ # Strict allowlist: lowercase alphanumeric + hyphens, max 64 chars, must start with letter.
24
+ if [[ ! "$subagent_type" =~ ^[a-z][a-z0-9-]{0,63}$ ]]; then
25
+ # Invalid name — skip hook (do not block) so legitimate workflows continue;
26
+ # the Task dispatch itself will fail naturally if the agent truly doesn't exist.
27
+ exit 0
28
+ fi
29
+
30
+ project_root="$(pwd)"
31
+ project_agents_dir="$project_root/.claude/agents"
32
+ target="$project_agents_dir/${subagent_type}.md"
33
+
34
+ if [ -f "$target" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ # Agent missing locally — try copying from ArkaOS core.
39
+ core_root="${ARKAOS_CORE_ROOT:-}"
40
+ if [ -z "$core_root" ]; then
41
+ core_root="$(npm root -g 2>/dev/null)/arkaos"
42
+ fi
43
+
44
+ if [ -d "$core_root/departments" ]; then
45
+ mkdir -p "$project_agents_dir"
46
+ set +e
47
+ python3 - "$core_root" "$subagent_type" "$target" <<'PY'
48
+ import os, re, sys
49
+ from pathlib import Path
50
+
51
+ core = Path(sys.argv[1]).resolve()
52
+ name = sys.argv[2]
53
+ target = Path(sys.argv[3]).resolve()
54
+
55
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name):
56
+ sys.exit(2)
57
+
58
+ departments_root = (core / "departments").resolve()
59
+ if not departments_root.exists():
60
+ sys.exit(2)
61
+
62
+ # Ensure target stays within .claude/agents of the project
63
+ expected_parent = target.parent.resolve()
64
+ if target.suffix != ".md" or expected_parent.name != "agents":
65
+ sys.exit(2)
66
+
67
+ yaml_path = None
68
+ md_path = None
69
+ for dept in departments_root.iterdir():
70
+ agents = dept / "agents"
71
+ if not agents.is_dir():
72
+ continue
73
+ y = (agents / f"{name}.yaml").resolve()
74
+ m = (agents / f"{name}.md").resolve()
75
+ if y.exists() and yaml_path is None:
76
+ try:
77
+ y.relative_to(departments_root)
78
+ except ValueError:
79
+ continue
80
+ yaml_path = y
81
+ if m.exists() and md_path is None:
82
+ try:
83
+ m.relative_to(departments_root)
84
+ except ValueError:
85
+ continue
86
+ md_path = m
87
+
88
+ if yaml_path is None and md_path is None:
89
+ sys.exit(2)
90
+
91
+ parts = []
92
+ if yaml_path is not None:
93
+ parts.append("---")
94
+ parts.append(yaml_path.read_text().strip())
95
+ parts.append("---")
96
+ if md_path is not None:
97
+ parts.append(md_path.read_text().rstrip())
98
+
99
+ content = "\n".join(parts) + "\n"
100
+
101
+ # Atomic write: temp file in same dir, then os.replace.
102
+ tmp = target.with_suffix(".md.tmp")
103
+ tmp.write_text(content)
104
+ os.replace(tmp, target)
105
+ PY
106
+ rc=$?
107
+ set -e
108
+
109
+ case "$rc" in
110
+ 0)
111
+ echo "[arka:provisioned] Copied agent '$subagent_type' from ArkaOS core." >&2
112
+ exit 0
113
+ ;;
114
+ 2)
115
+ # Agent not found in core; fall through to approval-request message below.
116
+ ;;
117
+ *)
118
+ echo "[arka:provision-error] Unexpected error ($rc) while provisioning '$subagent_type'. Dispatch not blocked." >&2
119
+ exit 1
120
+ ;;
121
+ esac
122
+ fi
123
+
124
+ # Agent not in project and not in core — surface an approval-request.
125
+ cat >&2 <<MSG
126
+ [arka:provision-needed] Agent '$subagent_type' is not installed in this
127
+ project and does not exist in ArkaOS core. To create it, run:
128
+
129
+ /platform-arka agent provision $subagent_type
130
+
131
+ This opens the Skill Architect flow which drafts the agent YAML with
132
+ 4-framework DNA, goes through Quality Gate, and commits to core before
133
+ propagating to the project. Blocking dispatch until the agent exists.
134
+ MSG
135
+ exit 2
@@ -263,8 +263,14 @@ if (-not $pythonResult) {
263
263
  $pythonResult = (@($l0, $l4, $l7) | Where-Object { $_ }) -join ' '
264
264
  }
265
265
 
266
+ # --- Persistent Routing Reminder -------------------------------------------
267
+ # High-salience tag - ensures squad routing persists across conversation turns,
268
+ # and enforces visible KB citation when chunks are present.
269
+ # See: docs/superpowers/specs/2026-04-14-persistent-routing-reminder-design.md
270
+ $routeReminder = '[arka:route] Every response MUST route through a department squad. No generic assistant replies. Announce the squad before responding. When [knowledge:N chunks] is present in this context, you MUST cite at least one source and acknowledge KB was consulted; if absent on a non-trivial ArkaOS topic, query Obsidian before responding.'
271
+
266
272
  # --- Output ----------------------------------------------------------------
267
- $additionalContext = "$syncNotice$pythonResult"
273
+ $additionalContext = "$syncNotice$routeReminder $pythonResult"
268
274
  [pscustomobject]@{ additionalContext = $additionalContext } | ConvertTo-Json -Compress
269
275
 
270
276
  # --- Metrics (JSONL append) ------------------------------------------------
@@ -193,8 +193,14 @@ if [ -f "$_HYGIENE_SCRIPT" ]; then
193
193
  bash "$_HYGIENE_SCRIPT" 2>/dev/null)
194
194
  fi
195
195
 
196
+ # ─── Persistent Routing Reminder ────────────────────────────────────────
197
+ # High-salience tag — ensures squad routing persists across conversation turns,
198
+ # not just on turn 1 when /arka skill content is fresh. See spec:
199
+ # docs/superpowers/specs/2026-04-14-persistent-routing-reminder-design.md
200
+ _ROUTE_REMINDER="[arka:route] Every response MUST route through a department squad. No generic assistant replies. Announce the squad before responding. When [knowledge:N chunks] is present in this context, you MUST cite at least one source and acknowledge KB was consulted; if absent on a non-trivial ArkaOS topic, query Obsidian before responding."
201
+
196
202
  # ─── Output ──────────────────────────────────────────────────────────────
197
- _OUT_CONTEXT="${_ARKA_GREETING:-}${_SYNC_NOTICE:-}$python_result"
203
+ _OUT_CONTEXT="${_ARKA_GREETING:-}${_SYNC_NOTICE:-}${_ROUTE_REMINDER} $python_result"
198
204
  [ -n "$_HYGIENE" ] && _OUT_CONTEXT="$_OUT_CONTEXT $_HYGIENE"
199
205
  # Escape for JSON
200
206
  _OUT_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" <<< "$_OUT_CONTEXT" 2>/dev/null)
@@ -0,0 +1,36 @@
1
+ # ArkaOS MCP Policy Registry
2
+ # Controls which MCPs load eagerly vs deferred per project stack/ecosystem.
3
+ # Rules evaluated top-to-bottom; first match wins. "ambiguous: ['*']" means
4
+ # all other MCPs defer to AI (or fallback heuristic when AI unavailable).
5
+
6
+ version: 1
7
+ policies:
8
+ - match:
9
+ stack_includes: [laravel, php]
10
+ active: [context7, gh-grep, postgres, supabase]
11
+ deferred: [canva, clickup, firecrawl, chrome, gmail, calendar, claude-in-chrome]
12
+ ambiguous: []
13
+
14
+ - match:
15
+ stack_includes: [nuxt, vue, react, next]
16
+ active: [context7, gh-grep, playwright, claude-in-chrome]
17
+ deferred: [postgres, supabase, canva, clickup, gmail, calendar]
18
+ ambiguous: []
19
+
20
+ - match:
21
+ ecosystem: marketing
22
+ active: [canva, gmail, calendar, firecrawl, clickup]
23
+ deferred: [postgres, supabase, playwright]
24
+ ambiguous: []
25
+
26
+ - match:
27
+ ecosystem: content
28
+ active: [canva, firecrawl, youtube-transcript]
29
+ deferred: [postgres, clickup]
30
+ ambiguous: []
31
+
32
+ - match:
33
+ default: true
34
+ active: [context7]
35
+ deferred: []
36
+ ambiguous: ["*"]
@@ -59,6 +59,18 @@
59
59
  }
60
60
  ]
61
61
  }
62
+ ],
63
+ "PreToolUse": [
64
+ {
65
+ "matcher": "Task",
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "{{HOOKS_DIR}}/agent-provision.sh",
70
+ "timeout": 10
71
+ }
72
+ ]
73
+ }
62
74
  ]
63
75
  }
64
76
  }
@@ -0,0 +1,8 @@
1
+ ## Laravel Stack Conventions
2
+
3
+ - Services + Repositories pattern; no logic in controllers.
4
+ - Form Requests for all input validation.
5
+ - API Resources for response shaping.
6
+ - Feature Tests with RefreshDatabase trait.
7
+ - Eloquent relationships over raw joins.
8
+ - Conventional commits: `feat(scope): ...`, `fix(scope): ...`.
@@ -0,0 +1,7 @@
1
+ ## Node.js Stack Conventions
2
+
3
+ - ESM modules (import/export); no CommonJS `require()`.
4
+ - Support Node and Bun runtimes when writing CLI tooling.
5
+ - Graceful fallbacks when optional dependencies are missing.
6
+ - All paths via `os.homedir()` or `path.join`; never hardcoded.
7
+ - No interactive prompts in headless/CI runs.
@@ -0,0 +1,8 @@
1
+ ## Nuxt / Vue Stack Conventions
2
+
3
+ - Composition API only; no Options API.
4
+ - TypeScript everywhere; no plain JS Vue files.
5
+ - `composables/` for shared reactive logic.
6
+ - `useFetch`/`useAsyncData` for server-side data.
7
+ - `~` alias for project root imports.
8
+ - Tailwind for styling; avoid scoped styles unless necessary.
@@ -0,0 +1,8 @@
1
+ ## Python Stack Conventions
2
+
3
+ - Type hints on every function signature.
4
+ - Pydantic for validation; dataclasses for pure data.
5
+ - `pytest` with fixtures; no `unittest.TestCase`.
6
+ - Functions under 30 lines; one responsibility.
7
+ - Docstrings on public API only; self-documenting code elsewhere.
8
+ - Virtual environments; never global `pip install`.
@@ -0,0 +1,63 @@
1
+ """Rule-based context compactor for subagent handoff.
2
+
3
+ Builds a compact summary of recent conversation turns, files touched,
4
+ and decisions, sized to fit a token budget. No LLM calls — deterministic
5
+ and zero-latency. An LLM-backed variant may be added later behind a flag
6
+ (see spec: docs/superpowers/specs/2026-04-14-subagent-context-handoff-design.md).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class Turn:
15
+ """A single conversation turn for compaction."""
16
+ role: str # "user" | "assistant"
17
+ content: str
18
+ files_touched: list[str] = field(default_factory=list)
19
+
20
+
21
+ class ContextCompactor:
22
+ """Builds compact context summaries for subagent handoff."""
23
+
24
+ def build(self, turns: list[Turn], max_tokens: int = 600) -> str:
25
+ if not turns:
26
+ return ""
27
+
28
+ files: list[str] = []
29
+ for t in turns:
30
+ for f in t.files_touched:
31
+ if f not in files:
32
+ files.append(f)
33
+
34
+ # Walk turns from most recent backwards, fitting into budget.
35
+ budget = max_tokens
36
+ if files:
37
+ files_line = "Files touched: " + ", ".join(files[:20])
38
+ budget -= len(files_line.split())
39
+ else:
40
+ files_line = ""
41
+
42
+ recent_summaries: list[str] = []
43
+ for turn in reversed(turns):
44
+ snippet = turn.content.strip().replace("\n", " ")
45
+ words = snippet.split()
46
+ if not words:
47
+ continue
48
+ label = "USER" if turn.role == "user" else "ASSISTANT"
49
+ line_words = [label + ":"] + words
50
+ if len(line_words) > budget:
51
+ line_words = line_words[: max(1, budget)]
52
+ if len(line_words) <= 1:
53
+ break
54
+ recent_summaries.insert(0, " ".join(line_words))
55
+ budget -= len(line_words)
56
+ if budget <= 0:
57
+ break
58
+
59
+ parts = ["## Prior Context"]
60
+ if files_line:
61
+ parts.append(files_line)
62
+ parts.extend(recent_summaries)
63
+ return "\n".join(parts)
@@ -130,12 +130,25 @@ class SubagentDispatcher:
130
130
  task_description: What the subagent should do.
131
131
  relevant_files: File paths the subagent should read.
132
132
  context_summary: Compacted context from prior work.
133
+ Required when ARKAOS_STRICT_HANDOFF=1; otherwise emits a
134
+ warning. Use ContextCompactor.build() to construct.
133
135
  constraints: Boundaries for the subagent.
134
136
  expected_output: What format/content is expected back.
135
137
 
136
138
  Returns:
137
139
  HandoffArtifact ready for dispatch.
138
140
  """
141
+ import os, warnings
142
+ if not context_summary or not context_summary.strip():
143
+ msg = (
144
+ "create_handoff called without context_summary — subagent will "
145
+ "lack prior conversation context. Use ContextCompactor.build() "
146
+ "to construct one. Set ARKAOS_STRICT_HANDOFF=1 to enforce."
147
+ )
148
+ if os.environ.get("ARKAOS_STRICT_HANDOFF") == "1":
149
+ raise ValueError(msg)
150
+ warnings.warn(msg, DeprecationWarning, stacklevel=2)
151
+
139
152
  self._task_counter += 1
140
153
  task_id = f"task-{self._task_counter}"
141
154
 
@@ -0,0 +1,150 @@
1
+ """Agent provisioner — copies baseline agents into each project's .claude/agents/.
2
+
3
+ Resolves stack-based allowlists (plus the _base allowlist applied to every
4
+ project), locates source agent files under departments/**/agents/, and
5
+ materializes them as flat markdown files with YAML frontmatter the project's
6
+ Claude Code can consume.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from core.sync.schema import AgentProvisionResult, Project
18
+
19
+
20
+ def _core_root() -> Path:
21
+ env = os.environ.get("ARKAOS_CORE_ROOT")
22
+ if env:
23
+ return Path(env)
24
+ return Path(__file__).resolve().parents[2]
25
+
26
+
27
+ def resolve_allowlist(stack: list[str]) -> list[str]:
28
+ """Return the union of baseline agent names for the given stack tokens."""
29
+ core = _core_root()
30
+ allowlist_dir = core / "config" / "agent-allowlists"
31
+ agents: set[str] = set()
32
+
33
+ _extend_from_file(allowlist_dir / "_base.yaml", agents)
34
+ for stack_name in stack:
35
+ _extend_from_file(allowlist_dir / f"{stack_name.lower()}.yaml", agents)
36
+
37
+ return sorted(agents)
38
+
39
+
40
+ def sync_project_agents(project: Project) -> AgentProvisionResult:
41
+ """Materialize baseline agent markdown files in <project>/.claude/agents/."""
42
+ try:
43
+ return _do_sync(project)
44
+ except Exception as exc: # noqa: BLE001
45
+ return AgentProvisionResult(
46
+ path=project.path, status="error", error=str(exc)
47
+ )
48
+
49
+
50
+ def sync_all_agents(projects: list[Project]) -> list[AgentProvisionResult]:
51
+ return [sync_project_agents(p) for p in projects]
52
+
53
+
54
+ def _extend_from_file(path: Path, agents: set[str]) -> None:
55
+ if not path.exists():
56
+ return
57
+ try:
58
+ data = yaml.safe_load(path.read_text()) or {}
59
+ except yaml.YAMLError:
60
+ return
61
+ for name in data.get("baseline", []) or []:
62
+ if isinstance(name, str):
63
+ agents.add(name)
64
+
65
+
66
+ def _do_sync(project: Project) -> AgentProvisionResult:
67
+ core = _core_root()
68
+ agents_dir = Path(project.path) / ".claude" / "agents"
69
+ agents_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ allowlist = resolve_allowlist(project.stack)
72
+ added, unchanged, errored = _apply_allowlist(core, agents_dir, allowlist)
73
+
74
+ status = _status_from_counts(added, unchanged, errored)
75
+ return AgentProvisionResult(
76
+ path=project.path,
77
+ status=status,
78
+ agents_added=added,
79
+ agents_unchanged=unchanged,
80
+ agents_errored=errored,
81
+ )
82
+
83
+
84
+ def _apply_allowlist(
85
+ core: Path, agents_dir: Path, allowlist: list[str]
86
+ ) -> tuple[list[str], list[str], list[str]]:
87
+ added: list[str] = []
88
+ unchanged: list[str] = []
89
+ errored: list[str] = []
90
+
91
+ for name in allowlist:
92
+ rendered = _render_agent(core, name)
93
+ if rendered is None:
94
+ errored.append(name)
95
+ continue
96
+
97
+ target = agents_dir / f"{name}.md"
98
+ if target.exists() and target.read_text() == rendered:
99
+ unchanged.append(name)
100
+ continue
101
+ target.write_text(rendered)
102
+ added.append(name)
103
+
104
+ return added, unchanged, errored
105
+
106
+
107
+ def _status_from_counts(
108
+ added: list[str], unchanged: list[str], errored: list[str]
109
+ ) -> str:
110
+ if errored:
111
+ return "error"
112
+ if added:
113
+ return "updated"
114
+ return "unchanged"
115
+
116
+
117
+ def _render_agent(core: Path, name: str) -> str | None:
118
+ yaml_path = _find_agent_file(core, name, ".yaml")
119
+ md_path = _find_agent_file(core, name, ".md")
120
+
121
+ if yaml_path is None and md_path is None:
122
+ return None
123
+
124
+ parts: list[str] = []
125
+ if yaml_path is not None:
126
+ parts.append("---")
127
+ parts.append(yaml_path.read_text().strip())
128
+ parts.append("---")
129
+ if md_path is not None:
130
+ parts.append(md_path.read_text().rstrip())
131
+
132
+ return "\n".join(parts) + "\n"
133
+
134
+
135
+ def _find_agent_file(core: Path, name: str, suffix: str) -> Path | None:
136
+ # Defense in depth: reject names that could escape the agents dir.
137
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name):
138
+ return None
139
+ departments_root = (core / "departments").resolve()
140
+ if not departments_root.exists():
141
+ return None
142
+ for dept in departments_root.iterdir():
143
+ candidate = (dept / "agents" / f"{name}{suffix}").resolve()
144
+ try:
145
+ candidate.relative_to(departments_root)
146
+ except ValueError:
147
+ continue
148
+ if candidate.exists():
149
+ return candidate
150
+ return None
@@ -0,0 +1,86 @@
1
+ """AI-backed decider for MCPs the policy could not classify.
2
+
3
+ Falls back to deterministic heuristic (defer all unknowns) when the AI
4
+ is unavailable or a call fails. Decisions are cached on disk keyed by
5
+ (stack, ecosystem, mcp_name) to guarantee idempotence across runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+
16
+ class AIUnavailable(RuntimeError):
17
+ pass
18
+
19
+
20
+ AiCaller = Callable[[str, list[str], str | None], str]
21
+
22
+
23
+ def decide_ambiguous(
24
+ ambiguous: list[str],
25
+ stack: list[str],
26
+ ecosystem: str | None,
27
+ cache_path: Path,
28
+ call_ai: AiCaller | None,
29
+ ) -> dict[str, str]:
30
+ if not ambiguous:
31
+ return {}
32
+
33
+ cache = _load_cache(cache_path)
34
+ result: dict[str, str] = {}
35
+
36
+ for mcp in ambiguous:
37
+ key = _cache_key(mcp, stack, ecosystem)
38
+ cached = cache.get(key)
39
+ if cached in {"active", "deferred"}:
40
+ result[mcp] = cached
41
+ continue
42
+
43
+ decision = _resolve(mcp, stack, ecosystem, call_ai)
44
+ result[mcp] = decision
45
+ cache[key] = decision
46
+
47
+ _save_cache(cache_path, cache)
48
+ return result
49
+
50
+
51
+ def _resolve(
52
+ mcp: str,
53
+ stack: list[str],
54
+ ecosystem: str | None,
55
+ call_ai: AiCaller | None,
56
+ ) -> str:
57
+ if call_ai is None:
58
+ return "deferred"
59
+ try:
60
+ raw = call_ai(mcp, stack, ecosystem)
61
+ except AIUnavailable:
62
+ return "deferred"
63
+ return raw if raw in {"active", "deferred"} else "deferred"
64
+
65
+
66
+ def _cache_key(mcp: str, stack: list[str], ecosystem: str | None) -> str:
67
+ payload = json.dumps(
68
+ {"mcp": mcp, "stack": sorted(stack), "ecosystem": ecosystem},
69
+ sort_keys=True,
70
+ separators=(",", ":"),
71
+ )
72
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
73
+
74
+
75
+ def _load_cache(path: Path) -> dict[str, str]:
76
+ if not path.exists():
77
+ return {}
78
+ try:
79
+ return json.loads(path.read_text())
80
+ except (json.JSONDecodeError, OSError):
81
+ return {}
82
+
83
+
84
+ def _save_cache(path: Path, data: dict[str, str]) -> None:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(json.dumps(data, indent=2, sort_keys=True))