feat(deploy): implement lib_ownership, registry merge, and deployment test suite
This commit is contained in:
@@ -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
@@ -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,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
@@ -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)."
|
||||||
if [ -O .mam ]; then
|
else
|
||||||
chmod 0700 .mam
|
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
|
fi
|
||||||
|
|
||||||
# R-4: Manage .gitignore block (never put .gitignore in manifest)
|
echo "📦 Installing/upgrading core dependencies inside $VENV_NAME..."
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# --- 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:
|
|
||||||
for line in lines:
|
|
||||||
if line.strip() == ".env":
|
|
||||||
f.write(".mam.env\n")
|
|
||||||
else:
|
else:
|
||||||
f.write(line)
|
os.makedirs(".mam", exist_ok=True)
|
||||||
' "$manifest" 2>/dev/null || true
|
with open(".mam/install_state", "w") as f:
|
||||||
else
|
f.write("gitignore_created=1\n")
|
||||||
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
|
fi
|
||||||
|
|
||||||
cat <<EOF >> "$ENV_FILE"
|
echo "===================================================================="
|
||||||
|
echo "🎉 Multi-Agent Mux (MAM) Installation Completed Successfully!"
|
||||||
|
echo "===================================================================="
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
# === Installer-applied active defaults ===
|
--------------------------------------------------------------------------------
|
||||||
MQTT_BROKER=broker.hivemq.com
|
💡 Next Steps:
|
||||||
MQTT_PORT=1883
|
1. Initialize a new isolated session:
|
||||||
MQTT_TLS=0
|
$ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \\
|
||||||
MQTT_CLIENT_ID_PREFIX=mam-agent
|
--workspace "$TARGET_DIR" --agent claude --role developer --isolate
|
||||||
HERDR_SERVER_NAME=default
|
|
||||||
|
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
|
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
|
|
||||||
|
|
||||||
echo "===================================================================="
|
|
||||||
echo "🎉 Installation complete!"
|
|
||||||
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 "===================================================================="
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Executable
+29
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pytest>=8.0
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user