diff --git a/.mam.env.example b/.mam.env.example index a1d88f4..543ccdb 100644 --- a/.mam.env.example +++ b/.mam.env.example @@ -39,6 +39,15 @@ #default: mam- # 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 # =========================================================================== @@ -47,6 +56,18 @@ #default: 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. #default: (unset โ†’ anonymous) # MQTT_USERNAME=replace_me @@ -59,6 +80,22 @@ #default: 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). #default: (unset โ†’ no custom CA) # MQTT_CA_CERTS=/path/to/ca.crt diff --git a/deploy/README.md b/deploy/README.md index 60f47fc..fd2eba4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,12 +6,15 @@ This directory contains packaging templates and installation scripts to deploy t ## ๐Ÿ“ 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_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, and sets up environment bootstrap on target projects. +* **`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`, 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. * **`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). -* **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting on bash scripts, validation on python scripts, and compilation tests). +* **`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, 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. > [!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 If you run a private mirror or fork, you can override the source URLs during installation using environment variables: diff --git a/deploy/gitea-ci.yml b/deploy/gitea-ci.yml index 7a6a003..654da49 100644 --- a/deploy/gitea-ci.yml +++ b/deploy/gitea-ci.yml @@ -33,6 +33,8 @@ jobs: 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-loop/scripts/run_loop.sh + shellcheck .agents/hooks/loop_delegation_guard.sh + shellcheck deploy/lib_ownership.sh shellcheck deploy/install.sh shellcheck deploy/install_mam.sh shellcheck deploy/generate-env.sh @@ -73,3 +75,34 @@ jobs: echo "๐Ÿ” Verifying Python file compilation..." python -m py_compile .agents/skills/multi-agent-mux-delegate-job/scripts/*.py 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 + diff --git a/deploy/install.sh b/deploy/install.sh index d5d87af..a30f691 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -103,6 +103,8 @@ check_assets_present() { ".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py" ".agents/skills/multi-agent-mux-status/scripts/status.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 if [ ! -f "$dir/$f" ]; then @@ -112,19 +114,13 @@ check_assets_present() { 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. if [ "$REFRESH" -eq 1 ] || [ "${MAM_SKIP_VENV:-0}" -eq 1 ] || ! check_assets_present "."; then echo "๐Ÿ“ฅ Staging orchestration assets from Gitea repository..." STAGE_DIR="$(mktemp -d)" trap 'rm -rf "$STAGE_DIR"' EXIT + OWNERSHIP_LIB="$STAGE_DIR/deploy/lib_ownership.sh" FETCH_METHOD="archive" if [ -d "$REPO_URL" ]; then 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 echo "๐ŸŒ Downloading and extracting archive into a staging area..." curl -fsSL "$ARCHIVE_URL" | tar -xz --strip-components=1 -C "$STAGE_DIR" \ - --exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' \ - --exclude='*/MESSAGING.md' --exclude='*/BOOTSTRAP.md' --exclude='*/BOOTSTRAP.ko.md' 2>/dev/null || true + --exclude='*/.agents/reports/*' --exclude='*/.agents/references/*' 2>/dev/null || true FETCH_METHOD="archive" else echo "โŒ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2 exit 1 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 echo "โŒ Error: fetched source is missing core runtime assets. Aborting." >&2 exit 1 @@ -178,8 +180,8 @@ with open(path, "w") as f: # Safe refresh & fingerprint checking logic TS=$(date -u +%Y%m%dT%H%M%SZ) - PRESERVED_COUNT=0 - MODIFIED_FILES=() + FRAMEWORK_LEDGER=".mam/.framework_ledger.tmp" + : > "$FRAMEWORK_LEDGER" mkdir -p .agents ( 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")" if is_framework_owned "$dest"; then - # 3-way check using python3 inline 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 -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): if not os.path.exists(path): @@ -235,6 +241,8 @@ elif target_sha == db_sha: else: if force_overwrite: print("FORCE_OVERWRITE_CUSTOM") + elif is_registry: + print("MERGE_REGISTRY") else: print("PRESERVE_CUSTOM") PY @@ -251,6 +259,81 @@ PY echo "$dest" >> "$MANIFEST_FILE" 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) BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")" mkdir -p "$BACKUP_DIR" @@ -272,29 +355,12 @@ PY done | while IFS= read -r line; do if [[ "$line" == PRESERVED:* ]]; then echo "โ„น๏ธ Local modification detected: ${line#PRESERVED:}" >&2 + elif [[ "$line" == MERGED:* ]]; then + _mam_rest="${line#MERGED:}" + echo "๐Ÿ”€ Registry merged: ${_mam_rest%%:*} (${_mam_rest#*:})" >&2 fi 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) ROOT_DOCS="AGENTS.md" if [ "${MAM_INSTALL_DOCS:-minimal}" = "full" ]; then @@ -330,12 +396,53 @@ PY echo ".mam.env.example" >> "$MANIFEST_FILE" fi - if [ -f "$STAGE_DIR/deploy/INSTALL.md" ] && [ ! -e ".agents/INSTALL.md" ]; then + if [ -f "$STAGE_DIR/deploy/INSTALL.md" ]; then mkdir -p .agents - cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md - echo ".agents/INSTALL.md" >> "$MANIFEST_FILE" + 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" + 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 STAGE_COMMIT="unknown" if [ -d "$STAGE_DIR/.git" ]; then @@ -359,26 +466,79 @@ if ! check_assets_present "."; then fi echo "โœ… Orchestration skills present." -echo "๐Ÿ“‚ Ensuring metadata directory structure (.mam/)..." -mkdir -p .mam/jobs .mam/delegate_job_logs +# --- 3. Python Virtual Environment (.venv) Setup --- +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 - chmod 0700 .mam + 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 -# 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 < "$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_END="# <<< MAM managed block <<<" -if [ "${MAM_SKIP_GITIGNORE:-0}" != "1" ]; then - 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' +python3 - "$GITIGNORE" "$MAM_GI_START" "$MAM_GI_END" <<'PY' import sys, os 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: f.writelines(new_lines) PY + +if [ "${MAM_SKIP_GITIGNORE:-0}" -eq 0 ]; then + python3 -c ' +import os +if os.path.exists(".mam/install_state"): + with open(".mam/install_state", "a") as f: + f.write("gitignore_created=1\n") +else: + os.makedirs(".mam", exist_ok=True) + with open(".mam/install_state", "w") as f: + f.write("gitignore_created=1\n") +' fi -# --- 3. Check Network File System (NFS) Warnings --- -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 +echo "====================================================================" +echo "๐ŸŽ‰ Multi-Agent Mux (MAM) Installation Completed Successfully!" +echo "====================================================================" +cat <=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 ' -import sys -path = sys.argv[1] -with open(path, "r") as f: - lines = f.readlines() -with open(path, "w") as f: - for line in lines: - if line.strip() == ".env": - f.write(".mam.env\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 <> "$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 + 3. Update installed skills in the future: + $ bash .mam_deploy/update.sh +-------------------------------------------------------------------------------- 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 "====================================================================" diff --git a/deploy/install_mam.sh b/deploy/install_mam.sh index bbe83a0..cb98bfc 100755 --- a/deploy/install_mam.sh +++ b/deploy/install_mam.sh @@ -81,6 +81,14 @@ if [ "$SRC_DIR" = "$TARGET_DIR" ]; then exit 1 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 log_info "Verifying host dependencies..." 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/" 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 log_info "Configuring developer guidelines (AGENTS.md)..." AGENTS_FILE="$TARGET_DIR/AGENTS.md" diff --git a/deploy/lib_ownership.sh b/deploy/lib_ownership.sh new file mode 100755 index 0000000..d664ee9 --- /dev/null +++ b/deploy/lib_ownership.sh @@ -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 +} diff --git a/deploy/remove.sh b/deploy/remove.sh index 2c7d875..9d352dd 100644 --- a/deploy/remove.sh +++ b/deploy/remove.sh @@ -84,10 +84,16 @@ else ".agents/skills/lib.sh" ".agents/skills/multi-agent-mux-create" ".agents/skills/multi-agent-mux-delegate-job" + ".agents/skills/multi-agent-mux-loop" ".agents/skills/multi-agent-mux-monitor" ".agents/skills/multi-agent-mux-resume" ".agents/skills/multi-agent-mux-status" ".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" ".mam" ".mam_deploy" @@ -172,14 +178,13 @@ if [ ${#manifest_files[@]} -gt 0 ]; then delete_asset "$f" done else - echo "โš ๏ธ No manifest found. Deleting standard MAM skills..." - delete_asset ".agents/skills/lib.sh" - delete_asset ".agents/skills/multi-agent-mux-create" - delete_asset ".agents/skills/multi-agent-mux-delegate-job" - delete_asset ".agents/skills/multi-agent-mux-monitor" - delete_asset ".agents/skills/multi-agent-mux-resume" - delete_asset ".agents/skills/multi-agent-mux-status" - delete_asset ".agents/skills/multi-agent-mux-stop" + echo "โš ๏ธ No manifest found. Deleting standard MAM assets..." + for asset in ${fallback_assets[@]+"${fallback_assets[@]}"}; do + case "$asset" in + .venv|.mam|.mam_deploy) continue ;; # handled explicitly further down + esac + delete_asset "$asset" + done fi if [ -d ".agents" ]; then diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..039d26e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=8.0 diff --git a/tests/test_deploy_freshness.py b/tests/test_deploy_freshness.py new file mode 100644 index 0000000..fbae2d9 --- /dev/null +++ b/tests/test_deploy_freshness.py @@ -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 < \"$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) diff --git a/tests/test_deploy_layout.py b/tests/test_deploy_layout.py index 3b0a8f1..ad1b288 100644 --- a/tests/test_deploy_layout.py +++ b/tests/test_deploy_layout.py @@ -16,6 +16,7 @@ class TestDeployLayout(unittest.TestCase): self.env = os.environ.copy() self.env["MAM_REPO_URL"] = self.repo_root self.env["MAM_SKIP_VENV"] = "1" + self.env["MAM_INSTALLER_URL"] = "file://" + os.path.join(self.repo_root, "deploy", "install.sh") def tearDown(self): shutil.rmtree(self.temp_dir, ignore_errors=True) diff --git a/tests/test_deploy_registry_merge.py b/tests/test_deploy_registry_merge.py new file mode 100644 index 0000000..2d4f22d --- /dev/null +++ b/tests/test_deploy_registry_merge.py @@ -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