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.
- package/VERSION +1 -1
- package/config/agent-allowlists/_base.yaml +7 -0
- package/config/agent-allowlists/laravel.yaml +9 -0
- package/config/agent-allowlists/node.yaml +7 -0
- package/config/agent-allowlists/nuxt.yaml +7 -0
- package/config/agent-allowlists/python.yaml +7 -0
- package/config/hooks/agent-provision.sh +135 -0
- package/config/hooks/user-prompt-submit.ps1 +7 -1
- package/config/hooks/user-prompt-submit.sh +7 -1
- package/config/mcp-policy.yaml +36 -0
- package/config/settings-template.json +12 -0
- package/config/standards/claude-md-overlays/laravel.md +8 -0
- package/config/standards/claude-md-overlays/node.md +7 -0
- package/config/standards/claude-md-overlays/nuxt.md +8 -0
- package/config/standards/claude-md-overlays/python.md +8 -0
- package/core/runtime/__pycache__/context_compactor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
- package/core/runtime/context_compactor.py +63 -0
- package/core/runtime/subagent.py +13 -0
- package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/content_merger.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
- package/core/sync/agent_provisioner.py +150 -0
- package/core/sync/ai_mcp_decider.py +86 -0
- package/core/sync/content_merger.py +100 -0
- package/core/sync/content_syncer.py +167 -0
- package/core/sync/engine.py +20 -0
- package/core/sync/mcp_optimizer.py +187 -0
- package/core/sync/policy_loader.py +94 -0
- package/core/sync/reporter.py +49 -1
- package/core/sync/schema.py +37 -0
- package/core/sync/self_healing.py +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.17.1
|
|
@@ -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: ["*"]
|
|
@@ -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`.
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
package/core/runtime/subagent.py
CHANGED
|
@@ -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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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))
|