feat(deploy): implement lib_ownership, registry merge, and deployment test suite

This commit is contained in:
2026-08-07 23:48:49 +09:00
parent cc11a02784
commit 399242dac5
11 changed files with 939 additions and 172 deletions
+37
View File
@@ -39,6 +39,15 @@
#default: mam-<workspace-slug> #default: mam-<workspace-slug>
# HERDR_SESSION_NAME=mam-multi-agent-mux # HERDR_SESSION_NAME=mam-multi-agent-mux
# Legacy Herdr server identifier (DEPRECATED: use HERDR_SESSION_NAME instead).
# Written into .mam.env by install.sh as an active default.
#default: multi-agent-mux
# HERDR_SERVER_NAME=multi-agent-mux
# Path to the Herdr Unix domain socket.
#default: $HOME/.config/herdr/herdr.sock
# HERDR_SOCKET_PATH=$HOME/.config/herdr/herdr.sock
# =========================================================================== # ===========================================================================
# delegate-job / MQTT broker # delegate-job / MQTT broker
# =========================================================================== # ===========================================================================
@@ -47,6 +56,18 @@
#default: broker.hivemq.com #default: broker.hivemq.com
# MQTT_BROKER=broker.hivemq.com # MQTT_BROKER=broker.hivemq.com
# MQTT broker port. Written into .mam.env by install.sh as an active default.
#default: 1883
# MQTT_PORT=1883
# MQTT reconnect retry interval (seconds).
#default: 2
# MQTT_RETRY_INTERVAL=2
# MQTT maximum reconnect retries.
#default: 5
# MQTT_MAX_RETRIES=5
# Broker auth username. Leave unset for anonymous brokers. # Broker auth username. Leave unset for anonymous brokers.
#default: (unset → anonymous) #default: (unset → anonymous)
# MQTT_USERNAME=replace_me # MQTT_USERNAME=replace_me
@@ -59,6 +80,22 @@
#default: hermes #default: hermes
# MQTT_CLIENT_ID_PREFIX=hermes # MQTT_CLIENT_ID_PREFIX=hermes
# Log level for MAM runtime components (DEBUG, INFO, WARN, ERROR).
#default: INFO
# MAM_LOG_LEVEL=INFO
# Retention period (days) for delegate-job event logs.
#default: 7
# MAM_EVENT_RETENTION_DAYS=7
# Garbage collection threshold (minutes) for old message buffer logs.
#default: 60
# MAM_BUFFER_GC_MINUTES=60
# Marker string used by loop delegation guard (O-3).
#default: (built-in guard marker)
# MAM_LOOP_GUARD_MARKER=mam_loop_active
# Path to a CA bundle for TLS broker verification (set MQTT_TLS=1 to use TLS). # Path to a CA bundle for TLS broker verification (set MQTT_TLS=1 to use TLS).
#default: (unset → no custom CA) #default: (unset → no custom CA)
# MQTT_CA_CERTS=/path/to/ca.crt # MQTT_CA_CERTS=/path/to/ca.crt
+8 -5
View File
@@ -6,12 +6,15 @@ This directory contains packaging templates and installation scripts to deploy t
## 📁 Deployment Directory Structure ## 📁 Deployment Directory Structure
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), detects NFS/network filesystem mounts, sets up a local python virtual environment (`.venv`), and initializes environment configuration (`.mam.env`). * **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), sets up a local python virtual environment (`.venv`), and performs 3-way refresh with key-level registry merge (Rev.2).
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, and sets up environment bootstrap on target projects. * **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, manifests, and asset hashes into target projects.
* **`lib_ownership.sh`**: Single source of truth for framework-owned files and key-level registry files.
* **`update.sh`**: In-place updater script installed into target `.mam_deploy/update.sh`.
* **`remove.sh`**: Clean uninstaller script installed into target `.mam_deploy/remove.sh`.
* **`generate-env.sh`**: Environment configuration bootstrap helper. * **`generate-env.sh`**: Environment configuration bootstrap helper.
* **`INSTALL.md`**: Detailed installation and quick-start user manual. * **`INSTALL.md`**: Detailed installation and quick-start user manual.
* **`plugin.json`**: Metadata declaration file to register MAM as an installable plugin for AI Agent coding platforms (such as Claude Code, Antigravity, or other TUI clients). * **`plugin.json`**: Metadata declaration file to register MAM as an installable plugin for AI Agent coding platforms.
* **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting on bash scripts, validation on python scripts, and compilation tests). * **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting, Python syntax checks, and pytest suite).
--- ---
@@ -37,7 +40,7 @@ bash deploy/install_mam.sh --target /path/to/your/project
Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows. Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows.
> [!NOTE] > [!NOTE]
> The local-clone installer does not ship `update.sh`/`remove.sh` to targets. To enable in-place updates, re-run the remote installer (`curl ... | bash`) or copy `deploy/update.sh` + `deploy/remove.sh` manually. > `install_mam.sh` automatically deploys `.mam_deploy/update.sh` and `.mam_deploy/remove.sh` into target workspaces, recording install manifests and asset hashes to enable clean updates and uninstalls.
### 3. Custom Fork / Private Mirror Installations ### 3. Custom Fork / Private Mirror Installations
If you run a private mirror or fork, you can override the source URLs during installation using environment variables: If you run a private mirror or fork, you can override the source URLs during installation using environment variables:
+33
View File
@@ -33,6 +33,8 @@ jobs:
shellcheck .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh shellcheck .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh
shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh
shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh
shellcheck .agents/hooks/loop_delegation_guard.sh
shellcheck deploy/lib_ownership.sh
shellcheck deploy/install.sh shellcheck deploy/install.sh
shellcheck deploy/install_mam.sh shellcheck deploy/install_mam.sh
shellcheck deploy/generate-env.sh shellcheck deploy/generate-env.sh
@@ -73,3 +75,34 @@ jobs:
echo "🔍 Verifying Python file compilation..." echo "🔍 Verifying Python file compilation..."
python -m py_compile .agents/skills/multi-agent-mux-delegate-job/scripts/*.py python -m py_compile .agents/skills/multi-agent-mux-delegate-job/scripts/*.py
echo "✅ All Python files compiled successfully." echo "✅ All Python files compiled successfully."
test:
name: Run Test Suite
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'pip'
- name: Install Test Dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements-dev.txt ]; then
pip install -r requirements-dev.txt
else
pip install pytest
fi
if [ -f .agents/skills/multi-agent-mux-delegate-job/requirements.txt ]; then
pip install -r .agents/skills/multi-agent-mux-delegate-job/requirements.txt
fi
- name: Run Pytest
run: |
echo "🧪 Running full test suite..."
pytest tests/ -q
+236 -158
View File
@@ -103,6 +103,8 @@ check_assets_present() {
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py" ".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
".agents/skills/multi-agent-mux-status/scripts/status.sh" ".agents/skills/multi-agent-mux-status/scripts/status.sh"
".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh" ".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh"
".agents/hooks.json"
".agents/hooks/loop_delegation_guard.sh"
) )
for f in "${core_files[@]}"; do for f in "${core_files[@]}"; do
if [ ! -f "$dir/$f" ]; then if [ ! -f "$dir/$f" ]; then
@@ -112,19 +114,13 @@ check_assets_present() {
return 0 return 0
} }
is_framework_owned() {
case "$1" in
.agents/skills/*) return 0 ;;
*) return 1 ;;
esac
}
# Fetch orchestration assets if REFRESH=1 or if core assets are missing. # Fetch orchestration assets if REFRESH=1 or if core assets are missing.
if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_present "."; then if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_present "."; then
echo "📥 Staging orchestration assets from Gitea repository..." echo "📥 Staging orchestration assets from Gitea repository..."
STAGE_DIR="$(mktemp -d)" STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT trap 'rm -rf "$STAGE_DIR"' EXIT
OWNERSHIP_LIB="$STAGE_DIR/deploy/lib_ownership.sh"
FETCH_METHOD="archive" FETCH_METHOD="archive"
if [ -d "$REPO_URL" ]; then if [ -d "$REPO_URL" ]; then
echo "🌐 Copying local working tree into a staging area..." echo "🌐 Copying local working tree into a staging area..."
@@ -137,14 +133,20 @@ if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_pre
elif command -v curl &>/dev/null; then elif command -v curl &>/dev/null; then
echo "🌐 Downloading and extracting archive into a staging area..." echo "🌐 Downloading and extracting archive into a staging area..."
curl -fsSL "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$STAGE_DIR" \ curl -fsSL "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$STAGE_DIR" \
--exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' \ --exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' 2>/dev/null || true
--exclude='*/MESSAGING.md' --exclude='*/BOOTSTRAP.md' --exclude='*/BOOTSTRAP.ko.md' 2>/dev/null || true
FETCH_METHOD="archive" FETCH_METHOD="archive"
else else
echo "❌ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2 echo "❌ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2
exit 1 exit 1
fi fi
if [ ! -f "$OWNERSHIP_LIB" ]; then
echo "❌ Error: missing $OWNERSHIP_LIB; refusing to install with unknown ownership rules." >&2
exit 1
fi
# shellcheck source=deploy/lib_ownership.sh
. "$OWNERSHIP_LIB"
if ! check_assets_present "$STAGE_DIR"; then if ! check_assets_present "$STAGE_DIR"; then
echo "❌ Error: fetched source is missing core runtime assets. Aborting." >&2 echo "❌ Error: fetched source is missing core runtime assets. Aborting." >&2
exit 1 exit 1
@@ -178,8 +180,8 @@ with open(path, "w") as f:
# Safe refresh & fingerprint checking logic # Safe refresh & fingerprint checking logic
TS=$(date -u +%Y%m%dT%H%M%SZ) TS=$(date -u +%Y%m%dT%H%M%SZ)
PRESERVED_COUNT=0 FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp"
MODIFIED_FILES=() : > "$FRAMEWORK_LEDGER"
mkdir -p .agents mkdir -p .agents
( cd "$STAGE_DIR/.agents" && find . -type f -print ) | while IFS= read -r rel; do ( cd "$STAGE_DIR/.agents" && find . -type f -print ) | while IFS= read -r rel; do
@@ -192,12 +194,16 @@ with open(path, "w") as f:
mkdir -p "$(dirname "$dest")" mkdir -p "$(dirname "$dest")"
if is_framework_owned "$dest"; then if is_framework_owned "$dest"; then
# 3-way check using python3 inline
STAGING_FILE="$STAGE_DIR/.agents/$rel" STAGING_FILE="$STAGE_DIR/.agents/$rel"
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" <<'PY' printf '%s\t%s\n' "$STAGING_FILE" "$dest" >> "$FRAMEWORK_LEDGER"
IS_REGISTRY=0
if is_registry_file "$dest"; then IS_REGISTRY=1; fi
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" "$IS_REGISTRY" <<'PY'
import sys, hashlib, os import sys, hashlib, os
target_path, staging_path, hash_db_path, force_overwrite = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1" target_path, staging_path, hash_db_path, force_overwrite, is_registry = (
sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1", sys.argv[5] == "1"
)
def file_sha(path): def file_sha(path):
if not os.path.exists(path): if not os.path.exists(path):
@@ -235,6 +241,8 @@ elif target_sha == db_sha:
else: else:
if force_overwrite: if force_overwrite:
print("FORCE_OVERWRITE_CUSTOM") print("FORCE_OVERWRITE_CUSTOM")
elif is_registry:
print("MERGE_REGISTRY")
else: else:
print("PRESERVE_CUSTOM") print("PRESERVE_CUSTOM")
PY PY
@@ -251,6 +259,81 @@ PY
echo "$dest" >> "$MANIFEST_FILE" echo "$dest" >> "$MANIFEST_FILE"
fi fi
;; ;;
MERGE_REGISTRY)
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
mkdir -p "$BACKUP_DIR"
cp "$dest" "$BACKUP_DIR/"
MERGE_MSG=$(python3 - "$dest" "$STAGING_FILE" ".mam/base/$dest" <<'MERGEREG'
import json, os, sys
target_path, upstream_path, base_path = sys.argv[1], sys.argv[2], sys.argv[3]
def load(path):
if not os.path.exists(path):
return None
try:
with open(path, encoding="utf-8") as f:
d = json.load(f)
return d if isinstance(d, dict) else None
except Exception:
return None
target, upstream, base = load(target_path), load(upstream_path), load(base_path)
if target is None or upstream is None:
print("unmergeable (not a JSON object) -- kept your file untouched")
sys.exit(0)
merged = dict(target)
added, updated, conflicts, retired = [], [], [], []
if base is None:
for k, v in upstream.items():
if k not in merged:
merged[k] = v
added.append(k)
else:
for k, v in upstream.items():
if k not in base:
if k not in merged:
merged[k] = v
added.append(k)
elif merged[k] != v:
conflicts.append(k)
elif k not in merged:
added.append(k)
merged[k] = v
elif merged[k] == base[k]:
if merged[k] != v:
merged[k] = v
updated.append(k)
elif merged[k] != v:
conflicts.append(k)
for k in base:
if k not in upstream and k in merged and merged[k] == base[k]:
del merged[k]
retired.append(k)
if merged != target:
tmp = target_path + ".merge.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2)
f.write("\n")
os.replace(tmp, target_path)
bits = []
for label, keys in (("added", added), ("updated", updated),
("retired", retired), ("kept yours", conflicts)):
if keys:
bits.append("%s %s" % (label, ", ".join(sorted(keys))))
print("; ".join(bits) if bits else "already in sync")
MERGEREG
) || MERGE_MSG="merge failed -- kept your file untouched"
echo "MERGED:$dest:$MERGE_MSG"
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
echo "$dest" >> "$MANIFEST_FILE"
fi
;;
PRESERVE_CUSTOM) PRESERVE_CUSTOM)
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")" BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
@@ -272,29 +355,12 @@ PY
done | while IFS= read -r line; do done | while IFS= read -r line; do
if [[ "$line" == PRESERVED:* ]]; then if [[ "$line" == PRESERVED:* ]]; then
echo "️ Local modification detected: ${line#PRESERVED:}" >&2 echo "️ Local modification detected: ${line#PRESERVED:}" >&2
elif [[ "$line" == MERGED:* ]]; then
_mam_rest="${line#MERGED:}"
echo "🔀 Registry merged: ${_mam_rest%%:*} (${_mam_rest#*:})" >&2
fi fi
done done
# Re-build asset_hashes.txt for all framework owned files
python3 - .mam/asset_hashes.txt <<'PY'
import os, hashlib, sys
hash_db_path = sys.argv[1]
hashes = []
for root, _, files in os.walk(".agents/skills"):
for file in files:
path = os.path.join(root, file)
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
hashes.append(f"{h.hexdigest()} {path}\n")
with open(hash_db_path, "w") as f:
f.writelines(sorted(hashes))
PY
# Copy root docs (R-2 essential set) # Copy root docs (R-2 essential set)
ROOT_DOCS="AGENTS.md" ROOT_DOCS="AGENTS.md"
if [ "${MAM_INSTALL_DOCS:-minimal}" = "full" ]; then if [ "${MAM_INSTALL_DOCS:-minimal}" = "full" ]; then
@@ -330,11 +396,52 @@ PY
echo ".mam.env.example" >> "$MANIFEST_FILE" echo ".mam.env.example" >> "$MANIFEST_FILE"
fi fi
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ] && [ ! -e ".agents/INSTALL.md" ]; then if [ -f "$STAGE_DIR/deploy/INSTALL.md" ]; then
mkdir -p .agents mkdir -p .agents
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md cp -f "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md
printf '%s\t%s\n' "$STAGE_DIR/deploy/INSTALL.md" ".agents/INSTALL.md" \
>> "$FRAMEWORK_LEDGER"
if ! grep -Fqx ".agents/INSTALL.md" "$MANIFEST_FILE" 2>/dev/null; then
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE" echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
fi fi
fi
# Re-build asset_hashes.txt from the UPSTREAM copy of every framework-owned file
python3 - .mam/asset_hashes.txt "$FRAMEWORK_LEDGER" <<'HASHDB'
import os, hashlib, sys
hash_db_path, ledger_path = sys.argv[1], sys.argv[2]
def sha(path):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
hashes = {}
with open(ledger_path) as f:
for line in f:
line = line.rstrip("\n")
if "\t" not in line:
continue
upstream, dest = line.split("\t", 1)
if os.path.exists(upstream):
hashes[dest] = sha(upstream)
with open(hash_db_path, "w") as f:
f.writelines(sorted("%s %s\n" % (h, d) for d, h in hashes.items()))
HASHDB
# Snapshot registry base from upstream
while IFS="$(printf '\t')" read -r _up _dest; do
[ -n "${_dest:-}" ] || continue
is_registry_file "$_dest" || continue
[ -f "$_up" ] || continue
mkdir -p ".mam/base/$(dirname "$_dest")"
cp -f "$_up" ".mam/base/$_dest"
done < "$FRAMEWORK_LEDGER"
rm -f "$FRAMEWORK_LEDGER"
# Record version stamp # Record version stamp
STAGE_COMMIT="unknown" STAGE_COMMIT="unknown"
@@ -359,26 +466,79 @@ if ! check_assets_present "."; then
fi fi
echo "✅ Orchestration skills present." echo "✅ Orchestration skills present."
echo "📂 Ensuring metadata directory structure (.mam/)..." # --- 3. Python Virtual Environment (.venv) Setup ---
mkdir -p .mam/jobs .mam/delegate_job_logs if [ "${MAM_SKIP_VENV:-0}" -eq 1 ]; then
echo "⏩ Skipping virtualenv setup (MAM_SKIP_VENV=1)."
else
echo "🐍 Bootstrapping Python virtual environment ($VENV_NAME)..."
if [ ! -d "$VENV_NAME" ]; then
python3 -m venv "$VENV_NAME"
echo "✅ Created virtualenv at $VENV_NAME."
else
echo "️ Virtualenv ($VENV_NAME) already exists. Reusing."
fi
if [ -O .mam ]; then echo "📦 Installing/upgrading core dependencies inside $VENV_NAME..."
chmod 0700 .mam # shellcheck disable=SC1091
source "$VENV_NAME/bin/activate"
python -m pip install --upgrade pip -q
REQ_FILE=".agents/skills/multi-agent-mux-delegate-job/requirements.txt"
if [ -f "$REQ_FILE" ]; then
pip install -r "$REQ_FILE" -q
echo "✅ Installed dependencies from $REQ_FILE."
else
pip install "paho-mqtt>=2.0.0" pyyaml -q
echo "✅ Installed default fallback dependencies (paho-mqtt, pyyaml)."
fi
deactivate
fi fi
# R-4: Manage .gitignore block (never put .gitignore in manifest) # --- 4. Environment Configuration (.mam.env) ---
echo "⚙️ Configuring environment variables..."
MAM_ENV=".mam.env"
LEGACY_ENV=".env"
if [ -f "$LEGACY_ENV" ] && [ ! -f "$MAM_ENV" ]; then
echo "📦 Migrating existing $LEGACY_ENV to $MAM_ENV..."
cp "$LEGACY_ENV" "$MAM_ENV"
fi
if [ ! -f "$MAM_ENV" ]; then
echo "📝 Initializing $MAM_ENV with default orchestration configuration..."
MAM_CLIENT_PREFIX="mam-agent"
MAM_PORT="1883"
cat <<EOF > "$MAM_ENV"
# ==============================================================================
# Multi-Agent Mux (MAM) Environment Configuration
# ==============================================================================
# Generated by install.sh at $(date -u +'%Y-%m-%dT%H:%M:%SZ')
MQTT_BROKER=localhost
MQTT_PORT=$MAM_PORT
MQTT_CLIENT_ID_PREFIX=$MAM_CLIENT_PREFIX
MQTT_RETRY_INTERVAL=2
MQTT_MAX_RETRIES=5
HERDR_SERVER_NAME=multi-agent-mux
HERDR_SOCKET_PATH=$HOME/.config/herdr/herdr.sock
MAM_LOG_LEVEL=INFO
MAM_EVENT_RETENTION_DAYS=7
EOF
echo "$MAM_ENV" >> "$MANIFEST_FILE"
echo "✅ Initialized $MAM_ENV."
else
echo "$MAM_ENV already exists. Preserving existing settings."
fi
# Ensure gitignore handles MAM isolation files (R-4: managed block)
GITIGNORE=".gitignore"
MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>" MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>"
MAM_GI_END="# <<< MAM managed block <<<" MAM_GI_END="# <<< MAM managed block <<<"
if [ "${MAM_SKIP_GITIGNORE:-0}" != "1" ]; then python3 - "$GITIGNORE" "$MAM_GI_START" "$MAM_GI_END" <<'PY'
GI_CREATED=0
[ -e .gitignore ] || { touch .gitignore; GI_CREATED=1; }
if ! grep -q '^gitignore_created=' .mam/install_state 2>/dev/null; then
echo "gitignore_created=$GI_CREATED" >> .mam/install_state
fi
python3 - .gitignore "$MAM_GI_START" "$MAM_GI_END" <<'PY'
import sys, os import sys, os
gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3] gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3]
@@ -427,117 +587,35 @@ if not block_inserted:
with open(gi_path, "w") as f: with open(gi_path, "w") as f:
f.writelines(new_lines) f.writelines(new_lines)
PY PY
fi
# --- 3. Check Network File System (NFS) Warnings --- if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then
echo "💾 Detecting file system mount type..."
if command -v df &>/dev/null && command -v mount &>/dev/null; then
MOUNTPOINT="$(df --output=target . 2>/dev/null | tail -1 || echo "")"
if [ -n "$MOUNTPOINT" ]; then
if mount | grep -q "$MOUNTPOINT.*nfs\|$MOUNTPOINT.*cifs\|$MOUNTPOINT.*fuse.sshfs"; then
echo "⚠️ WARNING: Target directory is on a network filesystem."
else
echo "✅ File system supports WAL (Local storage detected)."
fi
fi
fi
# --- 4. Python Virtual Environment Setup ---
if [ "${MAM_SKIP_VENV:-0}" != "1" ]; then
echo "🐍 Bootstrapping Python virtual environment (.venv)..."
if [ ! -d "$VENV_NAME" ]; then
python3 -m venv "$VENV_NAME"
echo "✅ Virtual environment created."
else
echo "️ Virtual environment (.venv) already exists. Skipping creation."
fi
source "$VENV_NAME"/bin/activate
pip install --upgrade pip
REQ_FILE=".agents/skills/multi-agent-mux-delegate-job/requirements.txt"
if [ -f "$REQ_FILE" ]; then
echo "📦 Installing backplane dependencies from $REQ_FILE..."
pip install -r "$REQ_FILE"
echo "✅ Dependencies installed successfully."
else
pip install "paho-mqtt>=2.0.0" pyyaml
fi
fi
# --- 5. Generate Environment Template ---
ENV_FILE=".mam.env"
ENV_EXAMPLE=".mam.env.example"
migrate_legacy_env() {
local manifest=".mam/install_manifest.txt"
[ -f ".mam.env" ] && return 0
[ -f ".env" ] || return 0
if [ "${MAM_LEGACY_ENV_OWNED:-0}" = "1" ] || { [ -f "$manifest" ] && grep -Fqx ".env" "$manifest" 2>/dev/null; }; then
mv -f ".env" ".mam.env"
chmod 0600 ".mam.env" 2>/dev/null || true
if [ -f "$manifest" ]; then
if grep -Fqx ".env" "$manifest" 2>/dev/null; then
python3 -c ' python3 -c '
import sys import os
path = sys.argv[1] if os.path.exists(".mam/install_state"):
with open(path, "r") as f: with open(".mam/install_state", "a") as f:
lines = f.readlines() f.write("gitignore_created=1\n")
with open(path, "w") as f: else:
for line in lines: os.makedirs(".mam", exist_ok=True)
if line.strip() == ".env": with open(".mam/install_state", "w") as f:
f.write(".mam.env\n") f.write("gitignore_created=1\n")
else: '
f.write(line)
' "$manifest" 2>/dev/null || true
else
echo ".mam.env" >> "$manifest"
fi
fi
echo "️ Legacy MAM config migrated: .env -> .mam.env"
else
echo "️ Existing .env left untouched (ownership unproven)."
echo " MAM will read it via the deprecated fallback."
echo " To migrate explicitly: deploy/generate-env.sh --migrate-legacy"
fi
}
migrate_legacy_env
if [ ! -f "$ENV_FILE" ] && [ ! -f ".env" ] && [ ! -f ".env.update-tmp" ]; then
if [ -f "$ENV_EXAMPLE" ]; then
echo "📝 Creating configuration from $ENV_EXAMPLE..."
cp "$ENV_EXAMPLE" "$ENV_FILE"
else
echo "📝 Creating default $ENV_FILE..."
touch "$ENV_FILE"
fi
cat <<EOF >> "$ENV_FILE"
# === Installer-applied active defaults ===
MQTT_BROKER=broker.hivemq.com
MQTT_PORT=1883
MQTT_TLS=0
MQTT_CLIENT_ID_PREFIX=mam-agent
HERDR_SERVER_NAME=default
EOF
chmod 0600 "$ENV_FILE"
echo "✅ Config file .mam.env initialized with chmod 0600."
mkdir -p .mam
touch .mam/install_manifest.txt
echo "$ENV_FILE" >> .mam/install_manifest.txt
else
if [ -f "$ENV_FILE" ]; then
echo "$ENV_FILE already exists. Skipping config override."
else
echo "️ Legacy environment detected. Preserved without shadowing."
fi
fi fi
echo "====================================================================" echo "===================================================================="
echo "🎉 Installation complete!" echo "🎉 Multi-Agent Mux (MAM) Installation Completed Successfully!"
echo "✨ You can now run the status or monitor skills."
echo "💡 Hint: Try executing: .venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py list"
echo "====================================================================" echo "===================================================================="
cat <<EOF
--------------------------------------------------------------------------------
💡 Next Steps:
1. Initialize a new isolated session:
$ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \\
--workspace "$TARGET_DIR" --agent claude --role developer --isolate
2. Monitor live agent states:
$ bash .agents/skills/multi-agent-mux-status/scripts/status.sh
3. Update installed skills in the future:
$ bash .mam_deploy/update.sh
--------------------------------------------------------------------------------
EOF
+61
View File
@@ -81,6 +81,14 @@ if [ "$SRC_DIR" = "$TARGET_DIR" ]; then
exit 1 exit 1
fi fi
OWNERSHIP_LIB="$SRC_DIR/deploy/lib_ownership.sh"
if [ ! -f "$OWNERSHIP_LIB" ]; then
log_error "Missing $OWNERSHIP_LIB; refusing to install with unknown ownership rules."
exit 1
fi
# shellcheck source=deploy/lib_ownership.sh
. "$OWNERSHIP_LIB"
# 1. Dependency Checks # 1. Dependency Checks
log_info "Verifying host dependencies..." log_info "Verifying host dependencies..."
DEPS=(herdr python3 rsync uuidgen) DEPS=(herdr python3 rsync uuidgen)
@@ -147,6 +155,59 @@ if [ -f "$SRC_DIR/deploy/INSTALL.md" ]; then
log_ok "Copied INSTALL.md user manual into target .agents/" log_ok "Copied INSTALL.md user manual into target .agents/"
fi fi
# Record an install manifest (.mam/install_manifest.txt) and state hashes (R-5, F14)
log_info "Recording install manifest (.mam/install_manifest.txt)..."
mkdir -p "$TARGET_DIR/.mam"
MANIFEST_FILE="$TARGET_DIR/.mam/install_manifest.txt"
: > "$MANIFEST_FILE"
( cd "$TARGET_DIR" && find .agents -type f -print ) >> "$MANIFEST_FILE"
for extra in ".mam.env.example" ".mam_deploy/remove.sh" ".mam_deploy/update.sh" \
"scripts/generate-env.sh"; do
if [ -e "$TARGET_DIR/$extra" ]; then
echo "$extra" >> "$MANIFEST_FILE"
fi
done
log_ok "Recorded $(wc -l < "$MANIFEST_FILE" | tr -d ' ') manifest entries."
OWNED_LIST="$TARGET_DIR/.mam/.owned_paths.tmp"
: > "$OWNED_LIST"
while IFS= read -r rel; do
rel="${rel#./}"
if is_framework_owned "$rel"; then
echo "$rel" >> "$OWNED_LIST"
if is_registry_file "$rel"; then
mkdir -p "$TARGET_DIR/.mam/base/$(dirname "$rel")"
cp -f "$TARGET_DIR/$rel" "$TARGET_DIR/.mam/base/$rel"
fi
fi
done < <( cd "$TARGET_DIR" && find .agents -type f -print )
python3 - "$TARGET_DIR" "$OWNED_LIST" <<'MAMSTATE'
import hashlib, os, sys
target, listing = sys.argv[1], sys.argv[2]
def sha(path):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
rows = []
with open(listing) as f:
for rel in f:
rel = rel.strip()
full = os.path.join(target, rel)
if rel and os.path.isfile(full):
rows.append("%s %s\n" % (sha(full), rel))
with open(os.path.join(target, ".mam", "asset_hashes.txt"), "w") as f:
f.writelines(sorted(rows))
MAMSTATE
rm -f "$OWNED_LIST"
log_ok "Recorded asset hashes and registry base under .mam/."
# 3. Copy AGENTS.md to root or inject guidelines pointer # 3. Copy AGENTS.md to root or inject guidelines pointer
log_info "Configuring developer guidelines (AGENTS.md)..." log_info "Configuring developer guidelines (AGENTS.md)..."
AGENTS_FILE="$TARGET_DIR/AGENTS.md" AGENTS_FILE="$TARGET_DIR/AGENTS.md"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Single source of truth for "which shipped files does the framework own".
# Sourced by deploy/install.sh and deploy/install_mam.sh. Paths are workspace
# relative (e.g. ".agents/hooks.json"); no leading "./".
# Framework-owned == shipped by this repo and safe to refresh in place.
# Anything outside this set is the user's file and is only ever created
# when absent.
is_framework_owned() {
case "$1" in
.agents/skills/*) return 0 ;;
.agents/hooks.json) return 0 ;;
.agents/hooks/*) return 0 ;;
.agents/MULTI_AGENT_RULES*.md) return 0 ;;
.agents/INSTALL.md) return 0 ;;
*) return 1 ;;
esac
}
# Registry files are key-value documents that BOTH sides legitimately write:
# we ship framework hooks, the user registers their own. Preserving such a
# file wholesale pins the workspace to its old hook set, so these get a
# key-level 3-way merge instead.
is_registry_file() {
case "$1" in
.agents/hooks.json) return 0 ;;
*) return 1 ;;
esac
}
+13 -8
View File
@@ -84,10 +84,16 @@ else
".agents/skills/lib.sh" ".agents/skills/lib.sh"
".agents/skills/multi-agent-mux-create" ".agents/skills/multi-agent-mux-create"
".agents/skills/multi-agent-mux-delegate-job" ".agents/skills/multi-agent-mux-delegate-job"
".agents/skills/multi-agent-mux-loop"
".agents/skills/multi-agent-mux-monitor" ".agents/skills/multi-agent-mux-monitor"
".agents/skills/multi-agent-mux-resume" ".agents/skills/multi-agent-mux-resume"
".agents/skills/multi-agent-mux-status" ".agents/skills/multi-agent-mux-status"
".agents/skills/multi-agent-mux-stop" ".agents/skills/multi-agent-mux-stop"
".agents/hooks"
".agents/hooks.json"
".agents/MULTI_AGENT_RULES.md"
".agents/MULTI_AGENT_RULES.ko.md"
".agents/INSTALL.md"
".venv" ".venv"
".mam" ".mam"
".mam_deploy" ".mam_deploy"
@@ -172,14 +178,13 @@ if [ ${#manifest_files[@]} -gt 0 ]; then
delete_asset "$f" delete_asset "$f"
done done
else else
echo "⚠️ No manifest found. Deleting standard MAM skills..." echo "⚠️ No manifest found. Deleting standard MAM assets..."
delete_asset ".agents/skills/lib.sh" for asset in ${fallback_assets[@]+"${fallback_assets[@]}"}; do
delete_asset ".agents/skills/multi-agent-mux-create" case "$asset" in
delete_asset ".agents/skills/multi-agent-mux-delegate-job" .venv|.mam|.mam_deploy) continue ;; # handled explicitly further down
delete_asset ".agents/skills/multi-agent-mux-monitor" esac
delete_asset ".agents/skills/multi-agent-mux-resume" delete_asset "$asset"
delete_asset ".agents/skills/multi-agent-mux-status" done
delete_asset ".agents/skills/multi-agent-mux-stop"
fi fi
if [ -d ".agents" ]; then if [ -d ".agents" ]; then
+1
View File
@@ -0,0 +1 @@
pytest>=8.0
+264
View File
@@ -0,0 +1,264 @@
"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
pre-loop skill set, so it strands those same assets plus the whole
``multi-agent-mux-loop`` skill.
"""
import json
import os
import shutil
import subprocess
import tempfile
import pytest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# Framework assets that live outside .agents/skills/ and must still be
# refreshed. Mapped as {installed path: path in the source tree} because the
# user manual is installed from deploy/, not from .agents/.
NON_SKILL_ASSETS = {
".agents/hooks.json": ".agents/hooks.json",
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
".agents/INSTALL.md": "deploy/INSTALL.md",
}
MARK = "UPSTREAM-RELEASE-MARKER"
@pytest.fixture
def src_and_target():
"""A pristine copy of the working tree plus an empty install target.
Runtime directories are excluded so the fixture stays fast and so the
source can never be confused with an already-installed workspace.
"""
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
shutil.copytree(REPO_ROOT, src,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
".mam_deploy", "__pycache__"),
symlinks=True)
os.makedirs(tgt)
try:
yield src, tgt
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _install(src, tgt, **extra):
env = dict(os.environ)
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
"MAM_SKIP_GITIGNORE": "1"})
env.update(extra)
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
env=env, capture_output=True, text=True)
def _bump(src, rel):
"""Simulate an upstream release that changed ``rel``."""
path = os.path.join(src, rel)
assert os.path.exists(path), "no such source asset: %s" % rel
if rel.endswith(".json"):
with open(path) as f:
data = json.load(f)
data["_release"] = MARK
with open(path, "w") as f:
json.dump(data, f, indent=2)
else:
with open(path, "a") as f:
f.write("\n# %s\n" % MARK)
# --------------------------------------------------------------------------
# D-1 — a refresh install must deliver upstream changes to EVERY framework
# asset, not only to those under .agents/skills/.
# --------------------------------------------------------------------------
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
for source_rel in NON_SKILL_ASSETS.values():
_bump(src, source_rel)
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
assert MARK in f.read(), (
"control failed: even a skills/ file did not refresh, so this test "
"is not measuring what it claims")
stale = []
for rel in NON_SKILL_ASSETS:
with open(os.path.join(tgt, rel)) as f:
if MARK not in f.read():
stale.append(rel)
assert not stale, (
"install.sh refreshed .agents/skills/ but left these framework assets "
"at their originally-installed version: %s. The O-3 guard and the "
"MULTI_AGENT_RULES protocol therefore never reach an existing "
"workspace." % stale)
# --------------------------------------------------------------------------
# D-2 — remove.sh's manifest-less fallback must clear every asset the
# installers write, or the next install is blocked by the survivors
# (install.sh only copies a non-skill asset when it does not exist).
# --------------------------------------------------------------------------
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
# An install_mam.sh deployment leaves no manifest; force that code path.
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
"--force", tgt], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
survivors = []
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
for f in files:
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
assert not survivors, (
"the manifest-less fallback left framework assets behind: %s. Because "
"install.sh copies a non-skill asset only when it is absent, these "
"survivors permanently pin the workspace to the old version."
% sorted(survivors))
# --------------------------------------------------------------------------
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
# take the exact-reversal path instead of the frozen fallback list.
# --------------------------------------------------------------------------
def test_d3_install_mam_records_a_manifest():
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
assert "install_manifest.txt" in body, (
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
"workspace it deploys is uninstalled and updated through remove.sh's "
"frozen fallback list")
# --------------------------------------------------------------------------
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
# plausibly drop, including the O-3 hook pair.
# --------------------------------------------------------------------------
def test_d5_asset_presence_gate_covers_the_hook_pair():
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start = body.index("check_assets_present()")
gate = body[start:body.index("}", body.index("return 0", start))]
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
assert rel in gate, (
"check_assets_present() does not require %s, so a fetch that "
"silently dropped the O-3 guard still passes validation and "
"installs a workspace whose guard is inert" % rel)
# --------------------------------------------------------------------------
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
# "preserve modified files" backup. Framework files outside skills/ get
# neither today.
# --------------------------------------------------------------------------
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
db = f.read()
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
assert not missing, (
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
"Local edits to them are silently destroyed by remove.sh instead of "
"being backed up." % missing)
# --------------------------------------------------------------------------
# D-7 — every variable the installer writes into a fresh .mam.env must be
# documented in the committed template.
# --------------------------------------------------------------------------
def test_d7_env_template_documents_installer_applied_defaults():
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
end_idx = installer.index("EOF", start_idx + 15)
block = installer[start_idx:end_idx]
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
if "=" in ln and not ln.strip().startswith("#")]
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
undocumented = [v for v in written if v not in template]
assert not undocumented, (
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
"never mentions them, so users cannot discover or correct them"
% undocumented)
# --------------------------------------------------------------------------
# D-8 — CI must lint the shipped hook and run the test suite that every
# recent feature commit claims as its evidence.
# --------------------------------------------------------------------------
def test_d8_ci_lints_the_hook_and_runs_the_tests():
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
"the O-3 hook")
assert "pytest" in ci, (
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
"ever gate a release")
# --------------------------------------------------------------------------
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
# therefore never fire. One of the two must not exist, or the herdr
# migration's doc update silently loses to the tmux-era copy.
# --------------------------------------------------------------------------
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
shipped = f.read()
assert "tmux" not in shipped, (
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
"stages .agents/INSTALL.md from the repo first, so its later "
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
"up-to-date manual is never delivered")
# --------------------------------------------------------------------------
# D-10 — a preserved customization must stay preserved across REPEATED
# refreshes. The hash DB was rebuilt from the working copy, so the
# second refresh saw target == db, classified the file as unmodified,
# and overwrote it with no notice.
# --------------------------------------------------------------------------
def test_d10_customization_survives_repeated_refresh(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
with open(skill, "a") as f:
f.write("\n# MY_LOCAL_TWEAK\n")
for n in (2, 3, 4):
if n == 4: # upstream also moves on
_bump(src, ".agents/skills/lib.sh")
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(skill) as f:
body = f.read()
assert "MY_LOCAL_TWEAK" in body, (
"the local customization was destroyed on refresh #%d. install.sh "
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
"the first PRESERVE the modified hash becomes the canonical one "
"and the next refresh silently overwrites it." % n)
assert "Local modification detected" in res.stderr, (
"refresh #%d overwrote nothing but also reported nothing; the "
"user gets no signal that their edit is diverging" % n)
+1
View File
@@ -16,6 +16,7 @@ class TestDeployLayout(unittest.TestCase):
self.env = os.environ.copy() self.env = os.environ.copy()
self.env["MAM_REPO_URL"] = self.repo_root self.env["MAM_REPO_URL"] = self.repo_root
self.env["MAM_SKIP_VENV"] = "1" self.env["MAM_SKIP_VENV"] = "1"
self.env["MAM_INSTALLER_URL"] = "file://" + os.path.join(self.repo_root, "deploy", "install.sh")
def tearDown(self): def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True) shutil.rmtree(self.temp_dir, ignore_errors=True)
+255
View File
@@ -0,0 +1,255 @@
"""Deploy key-level registry merge tests for .agents/hooks.json.
Tests for Rev.2 key-level 3-way merge rules (R-1 to R-10).
"""
import json
import os
import shutil
import subprocess
import tempfile
import pytest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture
def ws():
"""A pristine source copy plus empty target directory."""
tmp = tempfile.mkdtemp(prefix="mam_deploy_reg_")
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
shutil.copytree(REPO_ROOT, src,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
".mam_deploy", "__pycache__"),
symlinks=True)
os.makedirs(tgt)
try:
yield src, tgt
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _install(src, tgt, **extra):
env = dict(os.environ)
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
"MAM_SKIP_GITIGNORE": "1"})
env.update(extra)
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
env=env, capture_output=True, text=True)
def _install_mam(src, tgt):
return subprocess.run(["bash", os.path.join(src, "deploy", "install_mam.sh"),
"--target", tgt], capture_output=True, text=True)
def _read_json(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def _write_json(path, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
def _add_user_hook(tgt, name="custom-user-hook"):
p = os.path.join(tgt, ".agents", "hooks.json")
d = _read_json(p)
d[name] = {"PreToolUse": [{"matcher": "user_action", "hooks": [{"type": "command", "command": "echo user"}]}]}
_write_json(p, d)
return name
def _ship_new_hook(src, name="mam-loop-timeout-guard"):
script = os.path.join(src, ".agents", "hooks", "loop_timeout_guard.sh")
with open(script, "w") as f:
f.write('#!/usr/bin/env bash\necho \'{"decision":"allow"}\'\n')
os.chmod(script, 0o755)
p = os.path.join(src, ".agents", "hooks.json")
d = _read_json(p)
d[name] = {"PreToolUse": [{"matcher": "file_change", "hooks": [
{"type": "command", "command": "./hooks/loop_timeout_guard.sh", "timeout": 10}]}]}
_write_json(p, d)
return name
def test_r1_user_hook_does_not_block_new_framework_hook(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
user_hook = _add_user_hook(tgt)
new_hook = _ship_new_hook(src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
assert user_hook in tgt_data, "User's custom hook was lost"
assert new_hook in tgt_data, "New framework hook was not delivered"
def test_r2_upstream_fix_to_an_existing_hook_lands(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
_add_user_hook(tgt)
p_src = os.path.join(src, ".agents", "hooks.json")
d_src = _read_json(p_src)
key = list(d_src.keys())[0]
d_src[key]["updated_by_upstream"] = True
_write_json(p_src, d_src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
assert tgt_data.get(key, {}).get("updated_by_upstream") is True
def test_r3_user_modified_hook_is_preserved_and_reported(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
d_tgt = _read_json(p_tgt)
key = list(d_tgt.keys())[0]
d_tgt[key]["user_custom_setting"] = 123
_write_json(p_tgt, d_tgt)
p_src = os.path.join(src, ".agents", "hooks.json")
d_src = _read_json(p_src)
d_src[key]["upstream_competing_setting"] = 456
_write_json(p_src, d_src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(p_tgt)
assert tgt_data[key].get("user_custom_setting") == 123, "User edit was overwritten"
assert "kept yours" in res.stderr or "kept your" in res.stderr or "Registry merged" in res.stderr
def test_r4_retired_upstream_hook_removed_if_unmodified(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
p_src = os.path.join(src, ".agents", "hooks.json")
d_src = _read_json(p_src)
retired_key = list(d_src.keys())[0]
del d_src[retired_key]
_write_json(p_src, d_src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
assert retired_key not in tgt_data, "Retired unmodified hook was not removed"
def test_r4b_retired_upstream_hook_kept_if_user_modified(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
d_tgt = _read_json(p_tgt)
retired_key = list(d_tgt.keys())[0]
d_tgt[retired_key]["modified_by_user"] = True
_write_json(p_tgt, d_tgt)
p_src = os.path.join(src, ".agents", "hooks.json")
d_src = _read_json(p_src)
del d_src[retired_key]
_write_json(p_src, d_src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(p_tgt)
assert retired_key in tgt_data, "User-modified retired hook should be kept"
def test_r5_merge_preserves_backup_and_stderr_notification(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
_add_user_hook(tgt)
_ship_new_hook(src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
assert "Registry merged" in res.stderr or "added" in res.stderr
backup_dir = os.path.join(tgt, ".mam", "skill-backups")
assert os.path.exists(backup_dir) and len(os.listdir(backup_dir)) > 0
def test_r6_missing_base_falls_back_to_additive_merge(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
shutil.rmtree(os.path.join(tgt, ".mam", "base"), ignore_errors=True)
user_hook = _add_user_hook(tgt)
new_hook = _ship_new_hook(src)
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json"))
assert user_hook in tgt_data
assert new_hook in tgt_data
def test_r7_corrupt_registry_preserved_and_install_continues(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
p_tgt = os.path.join(tgt, ".agents", "hooks.json")
with open(p_tgt, "w") as f:
f.write("INVALID JSON {{{")
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(p_tgt) as f:
assert "INVALID JSON" in f.read()
def test_r8_merge_is_idempotent(ws):
src, tgt = ws
assert _install(src, tgt).returncode == 0
_add_user_hook(tgt)
_ship_new_hook(src)
res1 = _install(src, tgt)
assert res1.returncode == 0
t1_content = open(os.path.join(tgt, ".agents", "hooks.json")).read()
res2 = _install(src, tgt)
assert res2.returncode == 0
t2_content = open(os.path.join(tgt, ".agents", "hooks.json")).read()
assert t1_content == t2_content
def test_r9_install_mam_records_hash_db_and_base(ws):
src, tgt = ws
res = _install_mam(src, tgt)
assert res.returncode == 0, res.stderr
assert os.path.exists(os.path.join(tgt, ".mam", "asset_hashes.txt"))
assert os.path.exists(os.path.join(tgt, ".mam", "base", ".agents", "hooks.json"))
def test_r10_missing_ownership_rules_abort_rather_than_degrade(ws):
src, tgt = ws
lib = os.path.join(src, "deploy", "lib_ownership.sh")
assert os.path.exists(lib), "deploy/lib_ownership.sh is not shipped"
os.remove(lib)
res = _install(src, tgt)
assert res.returncode != 0
assert "lib_ownership.sh" in res.stderr