arkaos 2.16.1 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/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/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/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.0
|
|
@@ -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
|
|
@@ -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
|
|
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))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Managed-region content merger for the ArkaOS Sync Engine.
|
|
2
|
+
|
|
3
|
+
Merges core-owned content into project files while preserving any
|
|
4
|
+
project-authored content outside the managed region. Uses HTML comment
|
|
5
|
+
markers to delimit the managed block.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
_START_RE = re.compile(
|
|
16
|
+
r"<!--\s*arkaos:managed:start(?:\s+version=(?P<version>\S+))?"
|
|
17
|
+
r"(?:\s+hash=(?P<hash>[0-9a-f]{12}))?\s*-->",
|
|
18
|
+
)
|
|
19
|
+
_END_RE = re.compile(r"<!--\s*arkaos:managed:end\s*-->")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MergeResult:
|
|
24
|
+
"""Outcome of a managed-region merge operation."""
|
|
25
|
+
|
|
26
|
+
status: Literal["updated", "unchanged", "error"]
|
|
27
|
+
new_text: str
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_managed_hash(content: str) -> str:
|
|
32
|
+
"""Return the first 12 hex chars of sha256 over managed content."""
|
|
33
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def merge_managed_content(
|
|
37
|
+
target_text: str, managed_content: str, version: str
|
|
38
|
+
) -> MergeResult:
|
|
39
|
+
"""Merge managed_content into target_text inside the managed region.
|
|
40
|
+
|
|
41
|
+
Returns status "updated" when the file changes, "unchanged" when the
|
|
42
|
+
new hash matches the existing one, or "error" when markers are
|
|
43
|
+
unbalanced.
|
|
44
|
+
"""
|
|
45
|
+
starts = list(_START_RE.finditer(target_text))
|
|
46
|
+
ends = list(_END_RE.finditer(target_text))
|
|
47
|
+
|
|
48
|
+
if len(starts) != len(ends):
|
|
49
|
+
return MergeResult(
|
|
50
|
+
status="error",
|
|
51
|
+
new_text=target_text,
|
|
52
|
+
error=f"unbalanced markers: {len(starts)} starts, {len(ends)} ends",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if len(starts) > 1:
|
|
56
|
+
return MergeResult(
|
|
57
|
+
status="error",
|
|
58
|
+
new_text=target_text,
|
|
59
|
+
error="multiple managed blocks are not supported",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
new_hash = compute_managed_hash(managed_content)
|
|
63
|
+
new_block = _render_block(managed_content, version, new_hash)
|
|
64
|
+
|
|
65
|
+
if not starts:
|
|
66
|
+
return _prepend_block(target_text, new_block)
|
|
67
|
+
|
|
68
|
+
start_match = starts[0]
|
|
69
|
+
end_match = ends[0]
|
|
70
|
+
if end_match.start() < start_match.end():
|
|
71
|
+
return MergeResult(
|
|
72
|
+
status="error",
|
|
73
|
+
new_text=target_text,
|
|
74
|
+
error="end marker appears before start marker",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
existing_hash = start_match.group("hash")
|
|
78
|
+
if existing_hash == new_hash:
|
|
79
|
+
return MergeResult(status="unchanged", new_text=target_text)
|
|
80
|
+
|
|
81
|
+
new_text = (
|
|
82
|
+
target_text[: start_match.start()]
|
|
83
|
+
+ new_block
|
|
84
|
+
+ target_text[end_match.end() :]
|
|
85
|
+
)
|
|
86
|
+
return MergeResult(status="updated", new_text=new_text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_block(content: str, version: str, content_hash: str) -> str:
|
|
90
|
+
return (
|
|
91
|
+
f"<!-- arkaos:managed:start version={version} hash={content_hash} -->\n"
|
|
92
|
+
f"{content}\n"
|
|
93
|
+
"<!-- arkaos:managed:end -->"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _prepend_block(target_text: str, new_block: str) -> MergeResult:
|
|
98
|
+
separator = "\n\n" if target_text else "\n"
|
|
99
|
+
new_text = f"{new_block}{separator}{target_text}" if target_text else f"{new_block}\n"
|
|
100
|
+
return MergeResult(status="updated", new_text=new_text)
|