10 Commits
22 changed files with 2168 additions and 278 deletions
+1 -1
View File
@@ -142,7 +142,7 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
새 프로젝트에 이 에이전트 오케스트레이션 모델을 구축할 때의 체크리스트입니다.
- [ ] **가상환경 의존성**: `pyyaml`, `paho-mqtt` 등 필요한 Python 패키지가 `.venv` 또는 `requirements.txt`에 포함되었는가?
- [ ] **환경 설정 파일**: MQTT 브로커 주소 및 보안 Credential이 `.env` 파일에 안전하게 로드되고 공유되는가?
- [ ] **환경 설정 파일**: MQTT 브로커 주소 및 보안 Credential이 `.mam.env` 파일에 안전하게 로드되고 공유되는가?
- [ ] **디렉토리 규약**: 레지스트리 경로(`.mam/jobs/`) 및 로깅 경로(`.mam/delegate_job_logs/`)가 `.gitignore`에 등록되었는가?
- [ ] **스크립트 구비**: `mqtt_common.py`, `publish_event.py`, `job_subscriber.py`, `registry.py` 등의 핵심 모듈이 배치되었는가?
- [ ] **HMAC 활성화**: 새로운 레지스트리 잡 발급 시 난수 기반의 `auth_token`이 정상적으로 주입되고, 서명 기반의 상호 인증이 활성화되는가?
+1 -1
View File
@@ -142,7 +142,7 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
Use this checklist when deploying this agent orchestration model to a new project:
- [ ] **Virtualenv Dependencies**: Are required Python packages like `pyyaml` and `paho-mqtt` included in `.venv` or `requirements.txt`?
- [ ] **Configuration File**: Are the MQTT broker address and security credentials safely loaded and shared via the `.env` file?
- [ ] **Configuration File**: Are the MQTT broker address and security credentials safely loaded and shared via the `.mam.env` file?
- [ ] **Directory Convention**: Are the registry path (`.mam/jobs/`) and logging path (`.mam/delegate_job_logs/`) added to `.gitignore`?
- [ ] **Core Scripts**: Are the core scripts (`mqtt_common.py`, `publish_event.py`, `job_subscriber.py`, and `registry.py`) in place?
- [ ] **HMAC Enablement**: When a new registry job is created, is a random `auth_token` correctly injected, and is signature-based mutual authentication active?
@@ -0,0 +1,321 @@
# 📐 구현 계획서 Rev.2 — `deploy/*` 배포 스크립트 개선 (Job `0d43714b`)
- **작성자**: Planner (`canary-projects-multi-agent-mux-creator-claude`)
- **개정 사유**: Creator `agy`의 이의제기(Job `029f61b1`) 반영
- **선행 문서**: Job `101c90a2` 계획서 Rev.1 (본 문서가 이를 대체함 — 구현 시 **본 문서만** 참조)
- **기준 커밋**: `2ff8b2c` (branch `main`)
---
## 0. 이의제기 판정 (Challenge Adjudication)
`agy`의 지적은 **실재하는 데이터 소실 위험을 정확히 짚었고, 채택합니다.** 다만 원인 귀속과 처방 두 가지에는 실측으로 반박되는 부분이 있어 수정 채택합니다.
| # | `agy`의 주장 / 처방 | 판정 | 근거 |
| :-- | :--- | :---: | :--- |
| ① | 로컬 커스텀 스킬 코드가 무단 덮어쓰기로 소실될 수 있다 | **채택** | E-6/E-7에서 실제 소실 재현. 잔존 사본 0건 |
| ② | "기존 `install.sh`는 파일이 있으면 건너뛰어 안전하게 **보호되었다**" | **반박** | 프레임워크 소유 파일은 이미 `cp -f`**무조건 덮어쓰기**(`install.sh:166`). 보호는 정책이 아니라 fetch 블록이 통째로 스킵된 **부작용**이었음 |
| ③ | "R-1(`MAM_REFRESH=1`)이 이 위험을 **발생시킨다**" | **반박(부분 채택)** | 위험은 **오늘 이미 존재**함 — `install.sh -f`(문서화된 플래그)와 `update.sh` 두 경로에서 재현됨. R-1은 원인이 아니라 **노출 빈도를 넓히는 요인**. 따라서 가드는 R-1의 전제조건으로 **필수**이되, R-1만 고쳐서는 부족 |
| ④ | 처방 A: 덮어쓰기 전 `.agents/skills/.../*.user-bak` 자동 백업 | **반려 → 대체** | `.agents/`는 §5.1에서 **의도적으로 gitignore하지 않기로** 결정한 경로. 백업이 사용자 저장소에 추적 파일로 쌓이고, manifest에 없어 `remove.sh`가 절대 청소하지 못함 → `agy` 본인이 직전 라운드(`c6c43df9`)에서 지적한 **백업 무한 증식**을 재현. `.mam/skill-backups/<ts>/`로 이전 |
| ⑤ | 처방 B: 로컬 변경 감지를 **diff/hash/mtime**로 | **반려 → 대체** | **치명적 오설계.** 수신 파일과 비교하면 "구버전 설치본"과 "사용자 수정"을 구분할 수 없어 **모든 정상 업데이트가 로컬 수정으로 오판**됨 → 갱신이 영구 no-op이 되어 R-1이 고치려던 E-4 버그로 회귀. mtime은 `cp -f`가 매 설치마다 갱신하므로 출처 정보가 아예 없음. **설치기가 마지막에 기록한 해시**와 비교해야 함(§2.2) |
| ⑥ | 처방 C: 기본 보존 + `--overwrite-custom` 플래그 + 안내 문구 | **채택** | 안전한 기본값. 다만 "조용히 건너뛰기"는 버전 불일치를 유발하므로 **항목별 경고 + 상태 기록**을 의무화(§2.4) |
| ⑦ | (미지적) 처방이 `install.sh` 복사 루프에만 적용됨 | **보완 추가** | `update.sh``remove.sh --force`**스킬을 먼저 전부 삭제한 뒤** 재설치한다. install.sh에 가드를 넣어도 이 경로에서는 이미 파일이 없어 아무 효과가 없음(E-7). 3개 경로 전부를 덮어야 함(§3) |
**추가 자기수정(Planner 귀책)**: Rev.1 §5.2가 `gitignore_created``.mam/install_state`에 기록하도록 했으나, `update.sh``.mam`에서 **명시적 allowlist 4종만** 스테이징하므로 업데이트 시 이 상태가 소실됩니다(§4). `agy`가 지적하지 않았지만 같은 계열의 결함이므로 함께 수정합니다.
---
## 1. 신규 실측 근거 (Evidence, Rev.2)
임시 워크스페이스에 정상 설치 후 프레임워크 소유 파일 2개(`scripts/mqtt_common.py`, delegate-job 래퍼)에 로컬 커스터마이즈를 가하고 각 경로를 실행했습니다.
### E-6. 오늘의 `install.sh -f`가 이미 무경고로 파괴함
```
$ bash install.sh -f "$WORK"
exit=0
mqtt_common.py CORP_CA_BUNDLE : 1 → 0 ← 소실
wrapper LOCAL marker : 1 → 0 ← 소실
워크스페이스 내 잔존 사본 : (NONE)
로컬 변경 관련 경고 : 없음
실제 출력된 문구:
"✅ Skills staged into workspace (user documents and custom configs preserved)."
```
마지막 줄이 핵심입니다. 설치기는 **커스텀 설정을 보존했다고 명시적으로 안심시키는 문구를 출력하면서 같은 실행에서 커스텀 코드를 파괴**합니다. 문구의 원래 의도는 `.mam.env`·사용자 문서를 가리키지만, 스킬 수정본을 잃은 사용자에게는 경고가 아니라 **역방향의 오신호**입니다. 즉 `agy`가 지적한 위험은 "R-1이 도입할 미래의 위험"이 아니라 **이미 출시되어 문서화된 플래그에 존재하는 현재의 버그**입니다.
### E-7. 업데이트 경로는 install.sh 가드로 막을 수 없음
`update.sh:151`이 실행하는 명령을 그대로 재현했습니다.
```
$ bash remove.sh --force
exit=0
.agents/skills/.../mqtt_common.py 존재 : NO — deleted
잔존 사본 : (NONE)
```
`remove.sh`가 manifest에 따라 `.agents/skills/**` 28개 파일을 삭제한 **뒤에**`install.sh`가 실행됩니다. 그 시점에 로컬 수정본은 이미 존재하지 않으므로, **복사 루프에 어떤 감지 로직을 넣어도 감지할 대상이 없습니다.** `agy`의 처방을 그대로 구현하면 "가드를 넣었는데도 업데이트 한 번에 코드가 사라진다"는 최악의 결과가 됩니다 — 안전하다고 믿게 만들면서 보호하지 못하는 상태.
### E-8. 감지 기준의 반증
`agy`가 제안한 "기존 파일이 **원본 템플릿과 다른지**" 검사를 그대로 적용하면:
| 상황 | 로컬 파일 vs 수신 템플릿 | 올바른 처리 | `agy` 기준의 판정 |
| :--- | :---: | :--- | :--- |
| 사용자가 수정함 | 다름 | 보존 | 보존 ✅ |
| **구버전이 설치돼 있음(정상 갱신 대상)** | **다름** | **덮어쓰기** | **보존 ❌ → 갱신 영구 실패** |
| 최신본이 이미 설치됨 | 같음 | no-op | no-op ✅ |
2행이 R-1의 **유일한 존재 이유**입니다. 수신 파일과의 비교로는 2행과 1행이 원리적으로 구분되지 않으므로, R-1을 구현하면서 이 기준을 쓰면 E-4(재실행이 조용한 no-op)로 정확히 되돌아갑니다.
---
## 2. 설계 R-1 — 안전 갱신 (Safe Refresh)
### 2.1 원칙
> 갱신은 **설치기가 스스로 쓴 것만** 덮어쓴다. 그 외 모든 것은 사용자 것으로 간주한다.
`.env` 마이그레이션에서 확립한 **증거 기반 소유 판정** 원칙과 동일합니다. 소유 증거는 manifest(경로)만으로 부족하며 **내용 지문**이 필요합니다.
### 2.2 소유 지문 대장 — `.mam/asset_hashes.txt`
설치기가 파일을 쓸 때마다 그 시점의 내용 해시를 기록합니다.
```
<sha256> .agents/skills/lib.sh
<sha256> .agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py
...
```
갱신 시 3-way 판정:
| 조건 | 의미 | 처리 |
| :--- | :--- | :--- |
| `hash(현재) == 대장의 해시` | 설치기가 쓴 그대로 (미변경) | **덮어쓰기** + 대장 갱신 |
| `hash(현재) != 대장의 해시` | 사용자가 수정함 | **보존** + 경고 + 상태 기록 |
| 대장에 항목 없음 (구 설치본) | 출처 불명 | §2.5 부트스트랩 규칙 |
| `hash(현재) == hash(수신)` | 이미 최신 | no-op (백업·경고 불필요) |
대상은 `.agents/skills/**` **28개 파일**(432 KB)뿐이므로 해시 비용은 무시할 수준입니다.
> ⚠️ **구현 함정**: `sha256sum`은 GNU coreutils 전용이며 **stock macOS에는 없습니다**(macOS는 `shasum`). 본 개발 머신에는 coreutils가 설치돼 있어 로컬 테스트는 통과하고 실사용자만 깨지는 전형적 분기가 발생합니다. §2.3의 `python3 hashlib` 인라인을 사용하십시오 — `install.sh`가 이미 python3를 하드 의존성으로 검증합니다(Rev.1 §2.3과 동일한 근거).
### 2.3 백업 위치 — `.mam/skill-backups/<UTC타임스탬프>/<원경로>`
`.user-bak` 인플레이스 방식을 쓰지 않는 이유:
1. `.agents/`**의도적으로 gitignore 대상이 아님**(Rev.1 §5.1) → 백업이 사용자 저장소에 추적 파일로 유입.
2. manifest에 등재되지 않으므로 `remove.sh`**영구히 청소하지 못함** → 언인스톨 후에도 잔재.
3. 갱신할 때마다 누적 → `agy`가 직전 라운드에서 정확히 지적한 **백업 증식** 재현.
`.mam/skill-backups/`는 ① gitignore 관리 블록의 `/.mam/`으로 이미 커버 ② `remove.sh``delete_asset ".mam"`으로 자동 정리 ③ 사용자 트리 무오염을 모두 만족합니다.
**중복 억제(직전 잡 `fe4e0e6f`의 교훈 적용)**: 백업 직전 기존 `skill-backups/*/<같은 경로>` 중 내용이 동일한 사본이 있으면 새로 만들지 않습니다. `cmp` 실패 시에는 **보존 쪽으로 실패**(백업 생성)합니다.
### 2.4 기본 동작과 플래그
```bash
--overwrite-custom # 로컬 수정본까지 덮어쓴다 (백업은 여전히 남김)
MAM_OVERWRITE_CUSTOM=1 # curl | bash 파이프용 환경변수 동치
```
- **기본값 = 보존**. `agy`의 처방 C를 채택합니다.
- 보존 시 **파일 목록을 항목별로 출력**해야 합니다. 총계만 찍으면 사용자는 무엇이 낡았는지 알 수 없습니다.
```
️ Local modifications detected — these files were NOT updated:
.agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py
.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job
Your copies are preserved as-is. Backups: .mam/skill-backups/20260804T120000Z/
⚠️ Mixing customised files with updated framework files can break the backplane.
To take the upstream version (a backup is still made): re-run with --overwrite-custom
```
- **버전 불일치 경고를 의무화**하는 이유: delegate-job 백플레인은 `registry.py`·`mqtt_common.py`·`publish_event.py`·래퍼가 한 벌로 동작합니다. 한 파일만 구버전으로 남으면 "업데이트 성공"이라 표시된 채 이벤트가 실패하는, 진단이 어려운 상태가 됩니다. 조용한 skip은 금지합니다.
- `.mam/version.txt``preserved_local=<n>`을 기록해 사후 진단 가능하게 합니다.
### 2.5 부트스트랩(대장이 없는 기존 설치본)
기존 설치본에는 `asset_hashes.txt`가 없습니다. 여기서 "출처 불명 = 보존"을 택하면 **설치 기반 전체가 첫 갱신에서 no-op**이 되어 R-1이 무력화됩니다. 따라서:
> 대장 없음 + 내용이 수신본과 다름 → **백업 후 덮어쓰기**, 그리고 그 사실을 출력.
신선도(R-1의 목적)와 복구 가능성(agy의 목적)을 동시에 만족하며, 이 1회 이후로는 대장이 존재하므로 정밀 판정(기본 보존)으로 전환됩니다. 내용이 수신본과 같으면 백업도 경고도 만들지 않습니다.
```
️ No asset fingerprints found (installed by an older version).
Backing up current skills to .mam/skill-backups/<ts>/ before refresh.
```
---
## 3. 3개 경로 전수 적용 (`agy` 처방의 결정적 확장)
| # | 경로 | 현재 위험 | Rev.2 처리 |
| :-- | :--- | :--- | :--- |
| P-A | `install.sh` 갱신(`-f` 및 R-1 기본 fetch) | E-6: 무경고 파괴 | §2 안전 갱신 적용 |
| P-B | `update.sh` (문서화된 **주 업데이트 수단**) | E-7: `remove.sh`가 선삭제 → 가드 무효 | **remove.sh 호출 전 스냅샷**(§3.1) |
| P-C | `remove.sh` 단독 실행(언인스톨) | 수정본이 조용히 삭제됨 | 항목별 경고 + 조건부 보존(§3.2) |
### 3.1 P-B — `update.sh` 선스냅샷
`update.sh`는 이미 `.mam.update-tmp` 스테이징 구조를 갖고 있으므로 여기에 얹습니다.
```
1) (remove.sh 호출 전) asset_hashes.txt 기준으로 수정된 프레임워크 파일 산출
2) .mam.update-tmp/skill-backups/<ts>/ 로 복사 ← remove.sh가 지우지 못하는 위치
3) bash "$REMOVER" --force "$TARGET_DIR"
4) 새 install.sh 실행
5) .mam.update-tmp/skill-backups → .mam/skill-backups 로 복원 + 목록 출력
```
**주의**: 이 스냅샷은 "수정본을 자동으로 되살리지 않습니다." 업데이트 후 트리에는 최신 프레임워크가 들어가고, 사용자 수정본은 백업으로만 남습니다. 자동 병합은 3-way merge가 필요해 셸 설치기의 책임 범위를 넘습니다. **출력에서 이 점을 명확히 말해야 합니다** — "백업했다"가 "복원했다"로 오해되면 안 됩니다.
```
💾 3 locally-modified skill file(s) backed up to .mam/skill-backups/<ts>/
The updated framework files are now in place; your changes were NOT re-applied.
Diff and re-apply manually if you still need them.
```
### 3.2 P-C — `remove.sh` 언인스톨
- 삭제 대상 중 로컬 수정 파일이 **있을 때만** 동작합니다(없으면 출력·디렉토리 생성 모두 없음 → 일반 사용자에게 잔재 0).
- 대화형: 확인 프롬프트에 수정 파일 목록을 포함해 사용자가 중단할 수 있게 합니다.
- `-y/--force`: 목록을 출력하고 `<workspace>/.mam-skill-backup.<ts>/`로 보존합니다. `.mam` 내부는 언인스톨 시 삭제되므로 쓸 수 없습니다.
- `--purge-skills` 지정 시에만 보존 없이 삭제합니다.
> 이는 직전 잡(P-1)에서 확립한 "`-y`(비대화 의도)는 삭제 권한이 아니다" 원칙의 직접 적용이며, 루트에 백업을 남기는 것은 `.mam.env.mam-backup`이 이미 따르는 기존 전례와 일관됩니다. 관리 블록에 `/.mam-skill-backup.*/`를 추가합니다(§5).
---
## 4. 자기수정 — `.mam` 잔존 allowlist 결함
`update.sh:94-120``.mam`에서 **다음 4종만** 스테이징합니다: `agent-sessions.*`, `jobs/`, `delegate_job_logs/`, `install_manifest.txt`. `remove.sh``.mam`을 통째로 지우므로 **여기 없는 것은 업데이트 때마다 소멸**합니다.
영향:
| 파일 | 도입 위치 | 소실 시 결과 |
| :--- | :--- | :--- |
| `.mam/install_state` (`gitignore_created`) | Rev.1 §5.2 | 언인스톨 시 `.gitignore` 소유 판정 불가 → 빈 파일 잔존(안전측 실패, 경미) |
| `.mam/asset_hashes.txt` | Rev.2 §2.2 | **업데이트마다 대장 소실 → 매번 부트스트랩 경로 → 정밀 판정이 영구히 동작하지 않음** |
| `.mam/version.txt` | Rev.1 §6.2 | 버전 이력 단절 |
| `.mam/skill-backups/` | Rev.2 §2.3 | 백업이 업데이트로 삭제 |
**조치**: `update.sh`의 스테이징/복원 목록에 위 4개를 추가하고, 나아가 **개별 열거 대신 `.mam` 전체를 복사한 뒤 새 manifest만 새것으로 덮는 방식**으로 바꿀 것을 권고합니다(신규 상태 파일이 추가될 때마다 이 목록을 고쳐야 하는 구조적 취약성 제거). 후자를 택할 경우 `.mam/jobs` 용량이 큰 워크스페이스에서 복사 비용이 늘 수 있으므로, `cp -a`로 한 번에 처리하고 실패 시 기존 트랩(`restore_on_failure`)에 위임합니다.
---
## 5. Rev.1에서 변경되는 항목 요약
| 절 | 변경 |
| :--- | :--- |
| §5.1 관리 블록 | `/.mam-skill-backup.*/` 1행 추가 |
| §5.2 소유권 기록 | `.mam/install_state` 유지, 단 §4에 따라 `update.sh` 잔존 목록에 반드시 포함 |
| §6.1 fetch 기본화 | **§2 안전 갱신 가드 구현이 선행 조건**. 가드 없이 기본값만 바꾸는 커밋은 금지 |
| §6.2 버전 스탬프 | `preserved_local=<n>` 필드 추가 |
| §7 커밋 | C3 분할 및 C12~C14 추가(§6) |
| §8 테스트 | T-D21~T-D28 추가(§7) |
| §11 리스크 | RK-8 신설, RK-5 완화책 보강 |
그 외 R-2(필수 마크다운), R-3(`.mam_deploy`), R-4(`.gitignore`) 설계와 B-1/B-2/B-3 차단 항목은 **Rev.1 그대로 유효**합니다. `agy`도 해당 부분에는 이의를 제기하지 않았습니다.
---
## 6. 개정 커밋 분해
| # | 커밋 | 대상 | 비고 |
| :-- | :--- | :--- | :--- |
| C1 | `feat(deploy): ship only runtime-essential markdown to targets` | `install.sh` | Rev.1 §3.2 |
| C2 | `fix(deploy): align install_mam.sh asset excludes with install.sh` | `install_mam.sh` | Rev.1 §3.3 |
| **C3a** | `feat(deploy): record asset fingerprints on install` | `install.sh` | §2.2 — **대장 기록만**, 판정 로직 없음(동작 무변화) |
| **C3b** | `feat(deploy): preserve locally modified skills on refresh` | `install.sh` | §2.3-2.5 — 백업·보존·`--overwrite-custom` |
| **C3c** | `feat(deploy): fetch latest assets by default and stamp version` | `install.sh` | Rev.1 §6 — **C3b 이후에만 머지 가능** |
| C4 | `refactor(deploy): resolve workspace from script location` | `remove.sh`, `update.sh` | Rev.1 §4.2 |
| C5 | `feat(deploy): support .mam_deploy layout in uninstaller/updater` | `remove.sh`, `update.sh` | Rev.1 §4.3/4.4 |
| C6 | `feat(deploy): install remove.sh/update.sh under .mam_deploy/` | `install.sh` | **C5 이후** |
| C7 | `feat(deploy): manage a .gitignore block for installed artifacts` | `install.sh` | Rev.1 §5.1-5.3 + §5 |
| C8 | `feat(deploy): strip the managed .gitignore block on uninstall` | `remove.sh` | **C7 이후** |
| C9 | `feat(deploy): unify install_mam.sh gitignore and deploy scripts` | `install_mam.sh` | Rev.1 §5.4 |
| **C12** | `fix(deploy): preserve .mam state files across the update cycle` | `update.sh` | §4 — **C3a 이후, C3c 이전** |
| **C13** | `feat(deploy): snapshot modified skills before update removal` | `update.sh` | §3.1 |
| **C14** | `feat(deploy): warn and preserve modified skills on uninstall` | `remove.sh` | §3.2 — GM 판단으로 분리 가능(분리 시 P-C 구멍 잔존 명시) |
| C10 | `test(deploy): cover asset allowlist, layout, gitignore and safe refresh` | `tests/test_deploy_layout.py` | §7 |
| C11 | `docs(deploy): document layout, refresh, gitignore and custom-skill policy` | 문서 4종 | Rev.1 §9 + `--overwrite-custom`·백업 정책 |
**신규 순서 제약 (위반 시 데이터 소실 커밋이 트리에 남음):**
- **C3a → C3b → C3c** — 대장 없이 판정 로직을 넣으면 전량 부트스트랩 경로로 빠지고, 가드 없이 fetch만 기본화하면 **E-6의 파괴를 전 사용자에게 기본값으로 배포**하게 됩니다. C3c를 먼저 머지하는 것은 **금지**입니다.
- **C3a → C12** — 대장을 만들자마자 업데이트가 그것을 지우면 §2.2가 영구히 부트스트랩 모드로 동작합니다.
- **C13은 C3a 이후** — 수정 파일 산출에 대장이 필요합니다.
---
## 7. 추가 테스트 (Rev.1 T-D1~T-D20에 이어서)
| ID | 검증 내용 | 판정 |
| :--- | :--- | :---: |
| T-D21 | 커스터마이즈된 스킬 파일이 있는 상태로 갱신 → **파일 내용 보존**, exit 0, 목록이 stdout에 출력됨 | **B-4** |
| T-D22 | 미변경 스킬 파일은 정상 갱신됨(E-4 회귀 방지가 T-D21에 의해 무력화되지 않았는지) | **B-4** |
| T-D23 | **구버전이 설치된 상태(대장 존재, 사용자 미수정)에서 갱신 시 덮어써짐** — §E-8 2행 오판 방지 | **B-4** |
| T-D24 | `--overwrite-custom` 시 덮어쓰되 `.mam/skill-backups/<ts>/`에 사본 존재 | |
| T-D25 | 백업이 `.agents/` 하위에 **생성되지 않음**(`find .agents -name '*.user-bak'` → 0건) | |
| T-D26 | 동일 내용 2회 갱신 시 백업 디렉토리 **증식하지 않음**(중복 억제) | |
| T-D27 | `update.sh` 1회 실행 후 `.mam/asset_hashes.txt`·`install_state`·`version.txt`·`skill-backups/`**모두 잔존** | **B-5** |
| T-D28 | 수정본이 있는 상태로 `update.sh` 실행 → 최신 프레임워크 적용 + 백업 존재 + "NOT re-applied" 문구 출력 | |
| T-D29 | 대장 없는 구 설치본 갱신 → 백업 생성 후 덮어쓰기, 2회차부터는 정밀 판정 | |
| T-D30 | `remove.sh -y` (수정본 존재) → `.mam-skill-backup.<ts>/` 생성; 수정본 없으면 **디렉토리 미생성** | C14 채택 시 |
| T-D31 | 해시 계산이 `sha256sum`/`shasum` 존재 여부에 의존하지 않음(PATH에서 둘 다 제거해도 통과) | |
**차단 항목 추가**: **B-4**(안전 갱신 3-way 판정 — 실패 시 사용자 코드 소실 또는 갱신 영구 실패), **B-5**(`.mam` 상태 파일 잔존 — 실패 시 B-4가 구조적으로 동작 불능).
**차단 항목은 총 5건: B-1, B-2, B-3, B-4, B-5.**
---
## 8. DoD 게이트 (추가분)
| 게이트 | 조건 |
| :--- | :--- |
| **I. 커스텀 보존** | 수정된 스킬 파일이 `install.sh` 갱신·`install.sh -f`·`update.sh` **3경로 모두**에서 소실되지 않음(원본 보존 또는 백업 존재) |
| **J. 갱신 유효성** | 미수정 파일은 3경로 모두에서 최신본으로 갱신됨 — 보존 로직이 R-1을 무력화하지 않았음을 증명 |
| **K. 무오염** | 갱신·업데이트·언인스톨 후 `.agents/` 하위에 백업/잔재 파일 0건 |
| **L. 문구 정합** | "custom configs preserved" 류 문구가 **실제로 보존된 대상만** 지칭하도록 수정됨(E-6의 오신호 제거) |
게이트 L은 문구 한 줄이지만 별도 항목으로 둡니다. E-6에서 확인했듯 **틀린 안심 문구는 경고가 없는 것보다 나쁩니다.**
---
## 9. 리스크 (개정)
| ID | 리스크 | 영향 | 완화 |
| :-- | :--- | :---: | :--- |
| RK-1~RK-7 | Rev.1과 동일 | — | Rev.1 §11 |
| **RK-8** | 보존 로직이 과도하게 동작해 갱신이 사실상 no-op화(E-4 회귀) | **높음** | 수신본이 아닌 **대장**과 비교(§2.2), T-D22/T-D23이 차단 |
| **RK-9** | 백업했다는 문구를 사용자가 "복원됐다"로 오해 | 중 | §3.1 문구 규정, 게이트 L |
| **RK-10** | C3c(fetch 기본화)를 C3b보다 먼저 머지 | **치명** | §6 순서 제약, B-4 |
| **RK-11** | 버전 스큐(구 파일 1개 + 신 파일 다수)로 백플레인 오작동 | 중 | 항목별 경고 의무화(§2.4), `--overwrite-custom` 안내 |
| RK-5(개정) | fetch 기본화로 오프라인/CI 실패 | 중 | `--no-refresh` + 자산 부재 시 fetch 유지 |
---
## 10. 결론
`agy`의 이의제기는 **채택**합니다. 다만 세 가지를 수정합니다.
1. 이 위험은 R-1이 만드는 것이 아니라 **`install.sh -f``update.sh`에 이미 존재하는 버그**입니다(E-6/E-7). 따라서 가드는 R-1의 부속이 아니라 **선행 조건**이며, R-1을 채택하지 않더라도 독립적으로 고쳐야 합니다.
2. 감지 기준을 **수신 템플릿과의 비교에서 설치 시점 해시 대장과의 비교로** 바꿉니다. 원안대로면 정상 갱신과 사용자 수정이 구분되지 않아 R-1이 무력화됩니다(E-8).
3. 백업 위치를 `.agents/**/*.user-bak`에서 **`.mam/skill-backups/<ts>/`**로 옮기고, 보호 범위를 `install.sh` 복사 루프에서 **install/update/remove 3경로 전체**로 확장합니다. 원안 위치는 gitignore되지 않고 manifest에도 없어 영구 잔재가 되며, 원안 범위는 주 업데이트 경로를 전혀 보호하지 못합니다(E-7).
부수적으로, `agy`가 지적하지 않았으나 같은 계열인 Planner 귀책 결함 1건(`.mam` 상태 파일이 업데이트 시 소멸, §4)을 함께 수정합니다.
---
## 11. 역할 경계
본 문서는 설계 산출물이며 **Planner는 저장소 코드를 일절 수정하지 않았습니다**(`MULTI_AGENT_RULES.md` §4). E-6~E-8 실험은 임시 디렉토리(`/tmp/mam_cust_*`)에서 수행 후 정리했으며, 저장소 워킹트리는 클린 상태입니다. 구현은 Creator, 커밋은 GM 소관입니다.
**차단 항목은 B-1, B-2, B-3, B-4, B-5 5건입니다.**
[AGREEMENT: REACHED]
@@ -0,0 +1,371 @@
# 📐 구현 계획서 Rev.2 — `.env` → `.mam.env` 마이그레이션 최종화 (Finalize)
- **Job ID**: `fe4e0e6f`
- **Role**: Planner
- **목표**: `CURRENT_JOB.md` 기반 마이그레이션 최종화 · 원자적 커밋 · 리뷰어 검증 통과
- **선행 산출물**: `78e83796`(Rev.1) → `6dc9d528`(Rev.2) → `4e8b4839`(리뷰 `[VERDICT: NOT PASS]`) → `1b40c4ee`(최종화 계획 Rev.1) → **본 문서 (Rev.2)**
- **반영 피드백**: Creator `agy` Challenge Report — Job `c6c43df9`
---
## 0. 이의제기 판정 요약 (Challenge Adjudication)
Creator `agy`는 P-1 조치안(`-y` 실행 시 삭제 대신 백업)에 대해 **백업 파일 무한 증식**과 **완전 삭제 불능**을 지적했습니다. 실제 시나리오를 3주기 재현하여 검증했습니다.
| 항목 | 판정 | 근거 |
| :--- | :---: | :--- |
| **진단** — 반복 주기마다 백업 누적 | ✅ **채택** | 3주기 → 백업 3개 생성. 실측 확인 |
| 진단 — 백업 잔재를 타 도구가 오참조 | ❌ **기각** | `*.mam-backup`**읽는 코드는 전무**. `remove.sh`가 쓰기만 함. 전부 `.gitignore` 적용됨 |
| 진단 — `-y`로 완전 삭제 불가 | ⚠️ **부분 채택** | 사실이나 `--purge-env`가 이미 그 역할. 안내 부재가 진짜 문제 |
| **처방 ① 단일 슬롯 덮어쓰기** | 🔴 **기각 — 데이터 손실 재유발** | 아래 §1에서 실측 증명 |
| 처방 ② `--no-backup` 플래그 신설 | ❌ **기각** | `--purge-env`와 의미 중복. 플래그 2개가 같은 일을 하면 P-1의 "권한 붕괴"가 재발 |
| 처방 ② 안내 문구 강화 | ✅ **채택** | stdout 가이드 추가 |
**결론**: `agy`**문제 제기는 타당하나 처방은 위험합니다.** 진단을 채택하되 처방은 교체합니다. 대안으로 **내용 기반 중복 제거(content dedup) + 최초 백업 불변(immutable slot 1)**을 제시합니다.
---
## 1. 🔴 `agy` 처방 ①(단일 슬롯 덮어쓰기)을 기각하는 이유 — 실측
`remove.sh -y``install.sh` 주기를 3회 반복하며 각 백업의 **내용**을 측정했습니다. (P-1 패치를 적용한 사본으로 실행. 리포지토리 코드는 미수정.)
```
CYCLE 0: 사용자 실제 설정 저장 → .mam.env = MQTT_PASSWORD=REAL_USER_SECRET
CYCLE 1: remove.sh -y → backups: .mam.env.mam-backup
reinstall → .mam.env 재생성됨 (설치기 기본값)
CYCLE 2: remove.sh -y → backups: .mam.env.mam-backup .mam.env.mam-backup.20260804173654
CYCLE 3: remove.sh -y → backups: … + .mam.env.mam-backup.20260804173658
```
**핵심 측정 — 각 백업의 내용:**
```
[.mam.env.mam-backup] -> REAL_USER_SECRET 1건 ← 사용자 실제 설정
[.mam.env.mam-backup.20260804173654] -> REAL_USER_SECRET 0건 ← 설치기 생성 기본값
[.mam.env.mam-backup.20260804173658] -> REAL_USER_SECRET 0건 ← 설치기 생성 기본값
cycle-2/3 백업 md5: 10ed588bc64422408fda750b566e9197 (완전 동일)
```
여기서 두 가지가 드러납니다.
**(1) 증식의 실체는 "무가치한 사본의 반복"입니다.**
사용자의 진짜 설정은 **오직 슬롯 1**에만 있습니다. 2주기 이후 백업은 `install.sh`가 방금 만든 기본 설정을 되받아 적은 것이며, 서로 **바이트 단위로 동일**합니다. 즉 증식은 "정보가 늘어나는 것"이 아니라 **같은 쓰레기가 늘어나는 것**입니다. → 내용 기반 중복 제거로 완전히 해결 가능합니다.
**(2) 단일 슬롯 덮어쓰기는 그 유일한 진짜 설정을 파괴합니다.**
`agy`의 처방 ①을 실제로 적용해 보았습니다:
```
BEFORE — 슬롯 1의 REAL_USER_SECRET 보유: 1건
현재 live .mam.env 의 보유: 0건 (설치기 기본값)
$ mv -f .mam.env .mam.env.mam-backup # ← 처방 ①: 단일 슬롯 덮어쓰기
AFTER — 슬롯 1의 REAL_USER_SECRET 보유: 0건
워크스페이스 전체에서 REAL_USER_SECRET 잔존 사본: (NONE — 사용자 설정 소실)
```
**단일 슬롯 덮어쓰기는 P-1이 막으려던 바로 그 비가역 데이터 손실을, 1주기 지연시켜 재현합니다.** 원래 P-1은 "즉시 삭제"였고 처방 ①은 "다음 주기에 삭제"입니다. 손실 시점만 다를 뿐 결과는 동일하며, 오히려 **"백업했다"는 로그가 남아 있어 더 탐지하기 어렵습니다.**
역설적으로, 현재 코드의 타임스탬프 폴백(`remove.sh:182-184`)은 **바로 이 사고를 막고 있던 안전장치**였습니다. 이것을 제거해서는 안 됩니다.
**기각 사유 요약**: 디스크 정리(위생 문제)를 위해 데이터 보존(정확성 문제)을 희생하는 교환입니다. 우선순위가 역전되어 있습니다.
---
## 2. ✅ P-1 조치안 개정 (Revised Remedy)
### 2-1. 삭제 권한 분리 — Rev.1과 동일 (변경 없음)
```bash
should_delete_env=0
if [ $PURGE_ENV -eq 1 ]; then
should_delete_env=1
elif [ $env_created_by_mam -eq 1 ] && [ $FORCE -eq 0 ]; then
if ! read -p "❓ MAM-created '$env_name' found. Delete it? (Saying No preserves it) [y/N]: " -r env_response; then
env_response="n"
fi
if [[ "$env_response" =~ ^[yY](es)?$ ]]; then
should_delete_env=1
fi
fi
```
### 2-2. 🆕 백업 정책 개정 — 내용 기반 중복 제거 + 슬롯 1 불변
`agy`가 제기한 증식 문제를 **데이터 손실 없이** 해소합니다.
```bash
# 원칙: 기존 백업은 절대 덮어쓰지 않는다.
# 동일 내용이 이미 보존돼 있으면 새 사본을 만들지 않는다.
preserve_env() {
local env_name="$1"
local slot existing
# (a) 이미 동일 내용이 보존돼 있으면 중복 생성 없이 정리만 한다
for existing in "${env_name}.mam-backup" "${env_name}".mam-backup.*; do
[ -f "$existing" ] || continue
if cmp -s "$env_name" "$existing"; then
rm -f "$env_name"
echo "️ '$env_name' is already preserved in $existing (no duplicate created)."
return 0
fi
done
# (b) 내용이 다르면 새 슬롯에 보존한다. 슬롯 1은 영구 불변.
slot="${env_name}.mam-backup"
if [ -e "$slot" ]; then
slot="${env_name}.mam-backup.$(date +%Y%m%d%H%M%S)"
# 동일 초 내 재실행 충돌 방지
local n=1
while [ -e "$slot" ]; do
slot="${env_name}.mam-backup.$(date +%Y%m%d%H%M%S)-$n"
n=$((n + 1))
done
fi
mv "$env_name" "$slot"
echo "💾 Backed up $env_name -> $slot"
echo " To remove the configuration entirely, re-run with --purge-env."
}
```
**효과 (측정 기반 예측)**:
| 시나리오 | Rev.1 계획 | **Rev.2 개정안** | `agy` 처방 ① |
| :--- | :---: | :---: | :---: |
| 3주기 반복 후 백업 개수 | 3개 | **1개** | 1개 |
| 사용자 실제 설정 보존 | ✅ | ✅ | 🔴 **소실** |
| 내용이 다른 설정 2종 보존 | ✅ | ✅ | 🔴 소실 |
| 동일 초 내 2회 실행 | ⚠️ 충돌 | ✅ 카운터 | 🔴 소실 |
**주의 — (a)의 `cmp` 실패 시 동작**: `cmp`가 어떤 이유로든 실패하면 `rm`이 실행되지 않고 (b)로 진행해 백업이 생성됩니다. 즉 **판단 불능 시 보존 쪽으로 실패(fail-safe)** 합니다. 이 방향성을 반드시 유지해야 합니다.
### 2-3. 🆕 `--purge-env` 안내 강화 (`agy` 처방 ② 중 채택분)
비대화형 실행 시 stdout에 정리 방법을 명시합니다 (위 `preserve_env` 마지막 2줄). `--no-backup`**신설하지 않습니다**`--purge-env`와 기능이 동일하며, 같은 의미의 플래그를 2개 두는 것이 애초 P-1(`-y``--purge-env`의 권한 붕괴)의 원인이었습니다.
### 2-4. 📌 근본 해법은 별건 (범위 외 · 후속 과제로 등재)
증식의 **진짜 원인**은 백업 정책이 아니라, **백업이 바로 옆에 있는데도 `install.sh`가 기본 설정을 새로 생성한다**는 점입니다(M-1 가드가 `*.mam-backup`을 고려하지 않음). `install.sh`가 백업을 감지해 복원하도록 하면 증식은 발생 자체가 사라지고 재설치 UX도 개선됩니다.
다만 이는 **설치기 동작 변경**으로 별도 설계·검증이 필요하므로 본 마이그레이션 범위에서 제외하고 **후속 과제(FU-1)** 로 등재합니다. 2-2의 dedup만으로 `agy`가 제기한 증식은 실측상 해소됩니다.
---
## 3. 선행 리뷰 7개 항목 — 검증 결과 (변경 없음)
실제 명령 실행으로 확인한 현재 워킹 트리 상태 기준입니다.
| # | 리뷰(`4e8b4839`) 지적 | 상태 | 근거 |
| :--- | :--- | :---: | :--- |
| 1 | `remove.sh:192` 고아 `fi` | ✅ 해결 | `deploy/*.sh` 5개 전부 `bash -n` 통과 |
| 2 | T-8/T-10/T-11/T-12 미구현 | ⚠️ 부분 | T-8·10·12·13 추가. **T-11·14·15 없음** |
| 3 | T-4 무력 테스트 | ✅ 해결 | `patch.object(__file__)` 후 인자 없이 호출 — 실제 경계 탐색 진입 |
| 4 | 문서 13개소 | ⚠️ 거의 | **BOOTSTRAP 2개 `.gitignore` 예시만 잔존** (P-6) |
| 5 | 매니페스트 소유권 재기록 | ✅ 해결 | `install.sh:288-305` |
| 6 | 래퍼 cwd 폴백 | ✅ 해결 | `REPO_ROOT` 우선 + 단계별 경고 |
| 7 | `.tmp` 잔여물 | ✅ 해결 | 없음 |
**`CURRENT_JOB.md`의 파급 범위 오기**: `:23``lib.sh`에 ".env 로딩 로직"이 있다고 기술하나 **사실이 아닙니다.** `lib.sh``.env` 매칭 27건은 전부 `os.environ` 부분 문자열, dotenv 참조는 **0건**. `lib.sh`는 **범위 제외**이며 이 오기를 근거로 수정하면 불필요한 회귀 위험만 발생합니다.
---
## 4. 🔴 머지 차단 결함 (Merge Blockers)
### P-1 — `--force`가 사용자 설정을 백업 없이 삭제 (조치안은 §2로 개정)
**재현:**
```bash
printf '.env\nremove.sh\n' > .mam/install_manifest.txt
printf 'SECRET_KEY=user_secret_data\n' > .env
bash remove.sh --force
# EXITCODE=0 / 남은 파일: (없음) / .env.mam-backup 미생성 → 비가역 소실
```
**근본 원인**: 인자 파서(`remove.sh:17-20`)가 `-y|--yes|--force`를 하나의 `FORCE`로 묶고, 섹션 5가 `FORCE=1`을 삭제 권한으로 해석합니다. 결과적으로 ① 백업 브랜치가 **도달 불가능한 죽은 코드**가 되고, ② `--purge-env`가 **의미상 무의미**해지며, ③ 대화형은 "No"로 보존되는데 **비대화형은 묻지도 않고 삭제** — 가장 위험한 쪽이 기본 동작입니다.
**영향 범위**: `update.sh` 경로는 **안전**합니다(`:86,91`이 `remove.sh --force` 호출 `:151` 이전에 `*.update-tmp`로 이동 → 섹션 5의 `[ -f "$env_name" ] || continue`에 걸림). 피해자는 **`remove.sh -y`를 직접 실행하는 사용자/CI**로 한정됩니다. 한정되지만 비가역입니다.
### P-2 — T-8 단언문이 데이터 손실을 통과 판정
`tests/test_env_migration.py:134`:
```python
self.assertTrue(os.path.exists(".env.mam-backup") or not os.path.exists(".env"))
```
`or not os.path.exists(".env")` 때문에 **`.env`가 삭제되기만 하면 무조건 통과**합니다. 막아야 할 실패 양상이 곧 통과 조건이 되는 논리 역전이며, P-1이 지금까지 발견되지 않은 직접적 원인입니다.
**조치** — 보존 검증과 삭제 검증을 분리하고, §2-2 개정에 맞춰 케이스를 확장합니다.
```python
def test_t8_remove_force_preserves_owned_env(self):
"""T-8 [BLOCKER]: --force must BACK UP owned env, never delete it."""
res = subprocess.run(["bash", "remove.sh", "--force"], capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"remove.sh failed: {res.stderr}")
self.assertTrue(os.path.exists(".env.mam-backup"),
"MAM-owned .env MUST be backed up under --force, never deleted")
with open(".env.mam-backup") as f:
self.assertIn("user_secret_data", f.read())
def test_t8b_purge_env_is_sole_delete_authority(self):
"""T-8b: --purge-env is the ONLY flag authorised to delete."""
subprocess.run(["bash", "remove.sh", "--force", "--purge-env"], check=True)
self.assertFalse(os.path.exists(".env"))
self.assertFalse(os.path.exists(".env.mam-backup"))
```
**핵심 원칙**: 보존 계열 단언에 `or`를 쓰지 않습니다. `or` 대안지는 실패 양상을 흡수합니다.
---
## 5. 🟡 강화 항목 (비차단)
### P-3 — T-7이 이름과 무관한 것을 검증
docstring은 "cwd 상대 경로 로딩 시 경고"를 주장하나 실제로는 `--help` 종료 코드만 봅니다.
**조치**: 임시 디렉터리에 `.env`를 두고 그곳을 cwd로 래퍼 실행 → stderr에 `WARNING`/`deprecated` 포함 단언. 불가하면 docstring을 실제 검증 내용(`smoke: wrapper executes`)으로 정정해 **거짓 안전감을 제거**.
### P-4 — T-10/T-12의 공허한 통과 위험
T-10은 `subprocess.run(...)` 결과를 **어디에도 단언하지 않습니다**. `install.sh`가 초기 실패해도 통과합니다.
**단, 현재는 진짜로 통과합니다** (재현 확인: `EXITCODE=0`, `Preserved without shadowing`, `.mam.env` 미생성). 문제는 미래 회귀를 못 잡는다는 점입니다.
**조치**: `assertEqual(res.returncode, 0)` + **섹션 5 도달 표지 문자열** 단언.
```python
self.assertEqual(res.returncode, 0, f"install.sh failed: {res.stderr}")
self.assertIn("Preserved without shadowing", res.stdout)
self.assertFalse(os.path.exists(".mam.env"))
```
### P-5 — 미구현 테스트 T-11 / T-14 / T-15 (+ 신규 T-16 / T-17)
| ID | 검증 내용 | 방어 대상 |
| :--- | :--- | :--- |
| **T-11** | 구 `update.sh` 전체 시퀀스 E2E → 종료 후 사용자 설정값이 **실제로 로드됨** | E12 섀도잉 |
| T-14 | `update.sh` 중도 실패 → `restore_on_failure`가 **원래 이름**으로 복원 | M-4 트랩 대칭 |
| T-15 | `.mam.env.pre-migrate.bak``.mam.env.update-tmp` 상호 미간섭 | E15 슬롯 충돌 |
| **T-16** 🆕 | `remove.sh -y``install.sh` **3주기 반복 → 백업 파일 정확히 1개** | `agy` 증식 지적 회귀 |
| **T-17** 🆕 | 위 3주기 후 **슬롯 1이 최초 사용자 설정을 그대로 보유** | **처방 ① 재도입 방지 — 데이터 손실 회귀** |
**T-17은 머지 차단**으로 지정합니다. §1에서 실측으로 재현된 비가역 데이터 손실의 회귀 가드이기 때문입니다. (재현된 결함에만 차단을 부여한다는 본 계획서의 일관된 기준에 부합합니다.)
T-11은 Rev.2 명세상 차단이었으나, 코드 검토상 `update.sh:171-176` 복원 분기가 대칭이고 **실동작 결함이 재현되지 않아** 최우선 강화 항목으로 유지합니다. 리뷰어가 이견을 제시하면 원안(차단)으로 복귀합니다.
### P-6 — BOOTSTRAP `.gitignore` 예시의 유령 파일 참조
`BOOTSTRAP.md:126-130` / `BOOTSTRAP.ko.md:126-130``!.env.example`은 rename으로 **더 이상 존재하지 않는 파일**의 예외 규칙이며 실제 `.gitignore`(`:17-22`)와도 불일치합니다. 레거시 2줄(`.env`, `.env.*`)은 구 사용자 보호를 위해 유지가 타당합니다.
**조치**: `!.env.example` 줄 제거 또는 `# legacy — 구 설치 호환용` 주석 병기.
### P-7 — `CURRENT_JOB.md` 처리
세션 UUID·에이전트 상태 등 휘발성 런타임 정보를 담은 untracked 문서이며 §3의 `lib.sh` 오기를 포함합니다.
**권고**: **커밋하지 않고** `.gitignore`에 등재.
---
## 6. 🧩 원자적 커밋 전략
**원칙**: 각 커밋은 단독으로 문법상 유효하고, `git bisect`로 회귀를 단일 커밋까지 좁힐 수 있어야 합니다. 파일이 아니라 **관심사** 기준으로 자릅니다.
| # | 커밋 | 대상 | 메시지(안) |
| :---: | :--- | :--- | :--- |
| **C1** | 템플릿 rename + ignore 규칙 (+P-7) | `.mam.env.example`(staged rename), `.gitignore` | `refactor(config): rename .env.example to .mam.env.example and isolate .mam.env in gitignore` |
| **C2** | dotenv 로더 경계 수정 | `…/scripts/mqtt_common.py` | `fix(config): resolve dotenv via workspace marker and prefer .mam.env over legacy .env` |
| **C3** | 래퍼 env 해석 | `…/multi-agent-mux-delegate-job` | `fix(config): resolve wrapper env from repo root and warn on deprecated .env` |
| **C4** | 생성 스크립트 | `deploy/generate-env.sh` | `feat(deploy): target .mam.env and add --migrate-legacy flag` |
| **C5** | 설치기 (M-1 + M-2) | `deploy/install.sh`, `deploy/install_mam.sh` | `feat(deploy): add shadowing guard and evidence-based legacy env migration` |
| **C6** | **언인스톨러 (P-1 + §2-2 백업 정책)** | `deploy/remove.sh` | `fix(deploy): preserve MAM-owned env under --force and dedupe backups` |
| **C7** | 업데이터 대칭성 | `deploy/update.sh` | `fix(deploy): pre-capture env ownership and keep backup/restore symmetric` |
| **C8** | **테스트 (P-2~P-5, T-16/T-17 포함)** | `tests/test_env_migration.py` | `test: cover .mam.env migration, shadowing, ownership and backup retention` |
| **C9** | 문서 (P-6) | `README{,.ko}.md`, `BOOTSTRAP{,.ko}.md`, `MULTI_AGENT_RULES{,.ko}.md`, `deploy/README.md` | `docs: document .mam.env config file and legacy migration path` |
**순서 제약 (2건, 필수)**
- **C1 → C5**: `install.sh``.mam.env.example`을 참조하므로 rename이 선행해야 합니다.
- **C6 → C8**: C8의 T-8/T-16/T-17은 C6의 수정이 있어야 통과합니다. 역순이면 중간 커밋이 red가 되어 bisect가 오염됩니다.
**커밋 주체**: `MULTI_AGENT_RULES.md` §4에 따라 구현·커밋은 **Creator/GM 권한**입니다. Planner는 설계 자산만 산출하며 코드를 수정하지 않습니다.
---
## 7. ✅ 완료 정의 (DoD) — 리뷰어 검증 게이트
**게이트 A — 정적**
1. `for f in deploy/*.sh; do bash -n "$f"; done` 무오류
2. `git check-ignore -v .mam.env .mam.env.bak .mam.env.update-tmp .mam.env.mam-backup` 전부 매칭
3. `git check-ignore .mam.env.example` **비매칭** — 템플릿은 추적 대상
4. `*.tmp` 잔여물 없음
**게이트 B — 데이터 보존 (P-1 회귀 · 차단)**
5. 매니페스트 `.env` 기재 + `remove.sh -y``.env.mam-backup` 존재 + **원본 내용 보존**
6. `remove.sh --purge-env` → 삭제됨 (의도적 삭제 경로 정상)
7. 매니페스트 **없는** 사용자 소유 `.env` → 어떤 플래그로도 원본 보존
**게이트 B′ — 백업 위생 (`agy` 지적 반영 · 신규)**
8. `remove.sh -y``install.sh` **3주기 반복 → 백업 파일 정확히 1개** (증식 없음)
9. 위 3주기 후 **슬롯 1(`*.mam-backup`)이 최초 사용자 설정을 그대로 보유****차단**
10. 내용이 다른 설정 2종을 연속 보존 시 **둘 다 살아 있음** (dedup이 과잉 삭제하지 않음)
11. `remove.sh -y` stdout에 `--purge-env` 안내 문구 포함
**게이트 C — 섀도잉 방지 (M-1 회귀 · 차단)**
12. `.env.update-tmp`만 있는 상태로 `install.sh``.mam.env` **미생성**, 종료코드 0, 섹션 5 도달 표지 포함
13. 매니페스트 없는 `MQTT_BROKER` 포함 `.env` → 이관 안 됨 (휴리스틱 탈취 방지)
14. 매니페스트 있는 `.env` → 이관 + `chmod 0600` + 매니페스트 항목 치환
**게이트 D — 테스트 품질 (P-2 회귀 · 차단)**
15. `tests/test_env_migration.py` 전량 통과
16. **보존 계열 단언에 `or` 대안지 없음** — 정적 검토. `assertTrue(A or not B)` 금지
17. 각 subprocess 호출 테스트가 `returncode`를 단언
**게이트 E — 회귀**
18. `pytest tests/test_tier1_unit.py tests/test_tier2_component.py tests/test_env_migration.py` 통과
- 기준선 **62 passed / 461s(7분41초)**. 느릴 뿐 회귀 아님. **타임아웃 300초 이상 필요**
19. tier3/tier4는 P-1/P-2 수정 후 최소 1회 완주
**게이트 F — 문서**
20. 잔존 `.env` 참조가 전부 (a) 레거시 호환 로직, (b) 마이그레이션 안내, (c) 명시적 deprecated 표기 중 하나에 해당
---
## 8. ⚠️ 리스크 및 완화
| 리스크 | 심각도 | 완화 |
| :--- | :---: | :--- |
| **`--force`로 사용자 설정 비가역 소실** | **치명 · 비가역** | P-1 권한 분리 + P-2 T-8 재작성. **차단** |
| **단일 슬롯 덮어쓰기로 최초 백업 파괴** | **치명 · 비가역** | §2-2 슬롯 1 불변 + **T-17 차단 가드**. 처방 ① 기각 |
| **테스트가 결함을 통과 판정** | **치명** | P-2 + 게이트 D-16 상시 유지 |
| 백업 파일 증식으로 워크스페이스 오염 | 중간 | §2-2 내용 dedup + T-16. 근본 해법은 FU-1 |
| dedup이 과잉 삭제 (다른 설정을 같다고 오판) | 중간 | `cmp` 실패 시 **보존 쪽 fail-safe** + 게이트 B-10 |
| 동일 초 내 2회 실행으로 백업 충돌 | 낮음 | 타임스탬프 + 카운터 접미사 |
| 공허한 통과로 미래 회귀 미검출 | 높음 | P-4 표지 문자열 단언 |
| C6/C8 순서 역전 시 중간 커밋 red | 중간 | §6 순서 제약 고정 |
| `lib.sh` 오기 근거의 불필요한 수정 | 중간 | §3 명시 — dotenv 참조 0건, **범위 제외** |
---
## 9. 실행 순서 요약
1. **P-1** `remove.sh` 삭제 권한 분리 + **§2-2 백업 dedup/불변 정책** → **C6**
2. **P-2** T-8 재작성 + T-8b 신설 → **C8**
3. **P-3/P-4** T-7 정정, T-10/T-12 단언 보강 → **C8**
4. **P-5** T-11/T-14/T-15 + **T-16/T-17 신설****C8**
5. **P-6** BOOTSTRAP 예시 정리 → **C9**
6. **P-7** `CURRENT_JOB.md` `.gitignore` 등재 → **C1**
7. 게이트 A~F 전량 확인 (특히 **B-9는 차단**)
8. C1 → C9 순서로 원자적 커밋 (C1→C5, C6→C8 제약 준수)
9. 리뷰어 재검증 요청
**후속 과제 (범위 외)**
- **FU-1**: `install.sh`의 M-1 가드가 `*.mam-backup`을 인지하여 기본값 생성 대신 **복원**하도록 개선. 증식의 근본 해소 + 재설치 UX 개선. 별도 설계·검증 필요.
---
## 10. 인수인계
본 리포트는 Job `fe4e0e6f` (Planner: `claude`)의 산출물이며, Creator `agy`의 Challenge(`c6c43df9`)를 반영한 **Rev.2**입니다.
- **`agy`의 증식 진단은 채택했고 실측으로 확인했습니다**(3주기 → 백업 3개). 지적해 준 덕분에 Rev.1에는 없던 백업 위생 게이트(B′)가 추가되었습니다.
- **다만 처방 ①(단일 슬롯 덮어쓰기)은 기각합니다.** 실측 결과 사용자의 진짜 설정은 슬롯 1에만 존재하고 2주기 이후 백업은 설치기 기본값의 동일 사본이므로, 슬롯 1을 덮어쓰면 **P-1이 막으려던 데이터 손실이 1주기 지연되어 그대로 재현**됩니다. 대신 **내용 기반 dedup + 슬롯 1 불변**으로 동일한 위생 효과(백업 1개)를 데이터 손실 없이 달성합니다.
- 처방 ② 중 `--no-backup` 신설은 기각(`--purge-env`와 중복 — 권한 붕괴 재발 위험), **안내 문구 강화는 채택**했습니다.
- **차단 항목은 P-1, P-2, T-17** 3건입니다. 구현 및 커밋은 Creator/GM 소관이며 Planner는 코드를 수정하지 않았습니다.
[AGREEMENT: REACHED]
@@ -0,0 +1,107 @@
# Cross Code Review Report — Job 384b7986
- **Job ID**: 384b7986
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
- **Target**: Improve deployment files in `deploy/*` (4 requirements: latest updates / essential markdowns only / install remove.sh & update.sh into `{workspace}/.mam_deploy/` / generate `.gitignore`)
- **Date**: 2026-08-04
- **Diff scope**: 4 files modified, +516 / 157 (`deploy/install.sh` +373/, `deploy/install_mam.sh` +96/, `deploy/remove.sh` +152/, `deploy/update.sh` +52/). New untracked test suite `tests/test_deploy_layout.py` (5 tests, T-D1→T-D28).
---
## 1. Summary
The changeset refactors the MAM deployment scripts into a Rev.2 layout that satisfies all four stated requirements. The implementation is cohesive, idempotent, and backward-compatible:
1. **Latest updates (safe-refresh)**`deploy/install.sh` now defaults to `REFRESH=1` (was opt-in `FORCE_REFRESH`) and adds a 3-way content-hash reconciliation (`COPY_NEW` / `UPDATE_UNMODIFIED` / `BOOTSTRAP_OVERWRITE` / `PRESERVE_CUSTOM` / `FORCE_OVERWRITE_CUSTOM`) against `.mam/asset_hashes.txt`. User-modified framework skills are preserved and backed up to `.mam/skill-backups/<TS>/` unless `--overwrite-custom` is passed. A `--no-refresh|--offline` flag is added for air-gapped reinstalls. `remove.sh` now backs up locally-modified skills to `.mam-skill-backup.<TS>/` before deletion (mirroring install-side preservation).
2. **Essential markdowns only** — Archive/git fetch excludes `.agents/reports/*`, `.agents/references/*`, `MESSAGING.md`, `BOOTSTRAP.md`, `BOOTSTRAP.ko.md` via `tar --exclude` and `find ... *.tmp|*.log|*.pyc` skip. Default root docs limited to `AGENTS.md` (`MAM_INSTALL_DOCS=minimal`); `INSTALL.md` placed under `.agents/`. The separate `install_mam.sh` rsync gains `--exclude='/references/' --exclude='*.tmp'` (previously only excluded `.git/`, `/reports/`, `*.log`, `__pycache__/`, `*.pyc`). Verified: installed workspace contains no `reports/`, `references/`, `MESSAGING.md`, or `BOOTSTRAP.md`.
3. **`.mam_deploy/` layout** — `install.sh` and `install_mam.sh` both now copy `deploy/remove.sh` and `deploy/update.sh` into `.mam_deploy/` (chmod 0755, registered in manifest). Legacy root-level `remove.sh`/`update.sh` are migrated into `.mam_deploy/` if manifest-owned. `remove.sh` and `update.sh` gain `SCRIPT_DIR`-based auto-resolution: when invoked from inside `.mam_deploy/`, they target the parent workspace; when passed `.mam_deploy` as the target arg, they normalize to its parent. `update.sh` uses dual-resolution (`REMOVER` loop over `.mam_deploy/remove.sh` then `remove.sh`) before invoking the uninstaller.
4. **`.gitignore` generation** — Both installers inject an idempotent managed block (`# >>> MAM managed block ... <<<`) via a Python filter that removes any stale block before re-inserting. Block covers `/.venv/`, `/.mam/`, `/.mam_deploy/`, `/.mam.env`, `/.mam.env.*`, `!/.mam.env.example`, `/.cache/multi-agent-mux-monitor/`, `/.mam-skill-backup.*/`, `CURRENT_JOB.md`. `remove.sh` cleans the block on uninstall and removes the file entirely if it created it (`gitignore_created=1` recorded in `.mam/install_state`). `.gitignore` is **never** written to the install manifest (B-1 gate — explicitly asserted by T-D14).
5. **State preservation across updates (B-5)**`update.sh` now stages and restores `install_state`, `asset_hashes.txt`, `version.txt`, and `skill-backups/` alongside the pre-existing `jobs/`, `delegate_job_logs/`, and `agent-sessions.*` — verified by T-D27/T-D28.
---
## 2. Verification Evidence
### 2.1 Syntax checks — ALL PASS
```
bash -n: 4/4 deploy shell scripts OK
- deploy/install.sh OK
- deploy/install_mam.sh OK
- deploy/remove.sh OK
- deploy/update.sh OK
py_compile: tests/test_deploy_layout.py OK
```
### 2.2 Targeted test suite — 5/5 PASS (13.68s)
```
tests/test_deploy_layout.py::TestDeployLayout
test_td12_td13_td14_gitignore_managed_block PASSED [.gitignore block + manifest exclusion B-1]
test_td1_td2_td3_essential_markdowns_only PASSED [no reports/refs; no MESSAGING/BOOTSTRAP; AGENTS+RULES+INSTALL present]
test_td21_td22_td23_safe_refresh_custom_skills PASSED [local mod preserved on refresh; backup created; stderr warns]
test_td27_td28_update_preserves_mam_state PASSED [asset_hashes.txt + version.txt survive update cycle B-5]
test_td6_td7_td8_mam_deploy_layout_and_removal PASSED [.mam_deploy/{remove,update}.sh present+exec; remove.sh runs from inside .mam_deploy]
============================== 5 passed in 13.68s ==============================
```
### 2.3 Live install inspection (clean temp workspace, `MAM_REPO_URL=. MAM_SKIP_VENV=1`)
```
--- .gitignore (managed block) ---
# >>> MAM managed block (managed by install.sh — do not edit) >>>
/.venv/ /.mam/ /.mam_deploy/ /.mam.env /.mam.env.* !/.mam.env.example
/.cache/multi-agent-mux-monitor/ /.mam-skill-backup.*/ CURRENT_JOB.md
# <<< MAM managed block <<<
--- .mam_deploy/ --- remove.sh (0755) update.sh (0755)
--- root files --- .gitignore .mam.env .mam.env.example AGENTS.md (no MESSAGING/BOOTSTRAP)
--- exclusions verified --- .agents/reports/ absent .agents/references/ absent MESSAGING.md absent BOOTSTRAP.md absent
--- .mam/install_state --- gitignore_created=1
--- .mam/version.txt --- source=<local> commit=2ff8b2c... fetched_at=20260804T131420Z method=local
--- manifest B-1 gate --- .gitignore NOT in manifest (PASS) .mam_deploy/remove.sh in manifest .mam_deploy/update.sh in manifest
```
### 2.4 Pre-existing test status (out of scope)
`tests/test_sanity.py` HANGS (timed out at 30s) — requires live `herdr`/tmux environment. **Pre-existing**, not modified by this changeset. No regression introduced.
---
## 3. Findings
### 3.1 Blocking defects — NONE
No syntax errors, no control-flow breaks, no manifest-corruption paths. All 4 requirement gates are satisfied and covered by passing tests.
### 3.2 Non-blocking follow-ups (informational, do not block merge)
**R-1 (Low) — Stray untracked `.tmp` file not covered by repo `.gitignore`**
A runtime artifact `multi-agent-mux-delegate-job.13436_75009.tmp` exists untracked under `.agents/skills/multi-agent-mux-delegate-job/`. The install-time `find` skip (`*.tmp` at install.sh:188) and `install_mam.sh` rsync `--exclude='*.tmp'` (line 118) correctly prevent it from being *installed* into target workspaces, but the **source repo's own `.gitignore`** has no `*.tmp` rule, so it keeps reappearing as an untracked file across reviews (also flagged in jobs `9c44c6b2` and `54413a8a`). Recommend adding a top-level `*.tmp` ignore to the repo `.gitignore` or cleaning the artifact at source. **Does not affect installed workspaces.**
**R-2 (Low) — `update.sh` legacy-restore ordering hazard on legacy-owned `.env`**
In `update.sh` lines 207209, when `MAM_LEGACY_ENV_OWNED=1` and `ENV_BACKUP_SRC=.env`, the restore does `mv -f "$ENV_BACKUP_TMP" ".mam.env"` — correct file migration. The pre-capture of `MAM_LEGACY_ENV_OWNED` + `export` (lines 8690) is correctly inherited by the child `install.sh`, which reads it in `migrate_legacy_env()` (install.sh:476). **Edge case:** the child installer runs in step 4 *before* the parent restore in step 5. The child sees no `.env` (moved to `.env.update-tmp`) and no `.mam.env`, so it creates a fresh default `.mam.env`. The parent's restore then sees `.mam.env` already exists and falls to the `else` branch (`mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC"` = `.env`), leaving the user's real config at `.env` while a fresh default `.mam.env` shadows it. This only manifests when updating a workspace whose config is still legacy `.env` AND MAM-owned. Recommend either (a) restoring the env backup *before* invoking the child installer, or (b) having the child installer skip env creation when `MAM_LEGACY_ENV_OWNED=1` and a `.env.update-tmp`/`.mam.env.update-tmp` sentinel exists. Not exercised by the current test suite (T-D27/T-D28 use `.mam.env`, not legacy `.env`).
**R-3 (Info) — `remove.sh` deletes `.mam_deploy/update.sh` unconditionally**
`remove.sh:307` calls `delete_asset ".mam_deploy/update.sh"` outside the manifest-ownership loop used for `remove.sh`. In practice `update.sh` is always in the manifest (both installers register it), so this is fine, but it's a minor asymmetry: `remove.sh` self-deletion is guarded by manifest/`FORCE` while `update.sh` is deleted unconditionally. Harmless given current installers always register `update.sh`; a one-line comment would aid future maintainers.
**R-4 (Info) — `install_mam.sh` does not write `install_state` / `asset_hashes.txt` / `version.txt`**
`install_mam.sh` deploys skills via `rsync` but does not populate `.mam/install_state`, `.mam/asset_hashes.txt`, or `.mam/version.txt`. Consequently a subsequent `install.sh --no-refresh` would treat all files as `BOOTSTRAP_OVERWRITE` (no `db_sha`), and `remove.sh`'s `GI_CREATED` lookup would default to 0. The primary installer is `install.sh`; `install_mam.sh` is a secondary path. Not a regression (it never wrote these files before). Documenting the divergence would help.
---
## 4. Gate Checklist
| # | Requirement | Status | Evidence |
|---|-------------|--------|----------|
| 1 | Latest updates (safe refresh) | ✅ PASS | 3-way hash reconciliation; `--no-refresh`; `remove.sh` modified-skill backup; T-D21/D22/D23 |
| 2 | Essential markdowns only | ✅ PASS | `tar --exclude` reports/refs/MESSAGING/BOOTSTRAP; `find` skip; rsync `--exclude='/references/' --exclude='*.tmp'`; live install confirms absence; T-D1/D2/D3 |
| 3 | Install remove.sh & update.sh into `.mam_deploy/` | ✅ PASS | Both installers copy + chmod 0755 + manifest register; legacy migration; `SCRIPT_DIR` auto-resolution; T-D6/D7/D8 |
| 4 | Generate `.gitignore` for installed files | ✅ PASS | Idempotent managed block in both installers; `remove.sh` cleans block + removes if created; `.gitignore` excluded from manifest (B-1); T-D12/D13/D14 |
| — | Syntax validity | ✅ PASS | `bash -n` 4/4; `py_compile` 1/1 |
| — | No regression in pre-existing tests | ✅ PASS | `test_sanity.py` hangs are pre-existing (herdr/tmux env), not touched by this diff |
---
## 5. Verdict
The changeset is well-structured, addresses all four requirements with idempotent and backward-compatible logic, and is backed by a passing 5-test suite covering the critical gates (essential-docs filtering, `.mam_deploy/` layout, `.gitignore` managed block + manifest exclusion, safe-refresh custom-skill preservation, and update-cycle state preservation). The 4 non-blocking follow-ups (R-1 through R-4) are low severity and do not impede merge. No blocking defects found.
[VERDICT: PASS]
@@ -0,0 +1,121 @@
# Cross Code Review Report — Job 54413a8a
- **Job ID**: 54413a8a
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
- **Target**: Finalize `.env``.mam.env` migration (atomic changes + reviewer validation)
- **Date**: 2026-08-04
- **Diff scope**: 16 files, +300 / 119 (`.gitignore`, `.env.example → .mam.env.example`, `mqtt_common.py`, delegate-job wrapper, `deploy/{generate-env,install,install_mam,remove,update}.sh`, `MULTI_AGENT_RULES.{md,ko.md}`, `BOOTSTRAP.{md,ko.md}`, `README{,.ko}.md`, `deploy/README.md`, `tests/test_env_migration.py`)
> **Iteration context**: This is a follow-up review. The prior review (job `3117bdcc`) found a **BLOCKING** syntax error in `deploy/remove.sh` (orphaned `fi` at line 192 after an `if`→`for` refactor). That defect has been **fixed** in this iteration — `bash -n deploy/remove.sh` now passes and the `for env_name ... done` loop is well-formed.
---
## 1. Summary
The changeset completes the `.env``.mam.env` namespace migration with a robust, backward-compatible fallback design:
1. **Runtime loaders** (shell wrapper + `mqtt_common.py`) now prefer `.mam.env`, fall back to `.env` with deprecation warnings, and support an explicit `MAM_ENV_FILE` override.
2. **Boundary-safe workspace resolution**`mqtt_common._load_dotenv()` walks up from the script location and stops at the first directory containing `.agents/` or `.git/`, preventing parent-directory `.env` leakage (T-4).
3. **Installer migration**`deploy/install.sh` gains `migrate_legacy_env()` that renames an MAM-owned `.env``.mam.env` (evidence-based via install manifest) and rewrites the manifest; unowned `.env` is left untouched with a guidance message.
4. **Uninstaller**`deploy/remove.sh` now loops over both `.mam.env` and `.env`, with backup deduplication (content-hash via `cmp -s`) and immutable slot-1 retention (T-16/T-17).
5. **Updater**`deploy/update.sh` pre-captures `MAM_LEGACY_ENV_OWNED`, backs up whichever env file exists, and on restore migrates a legacy-owned `.env` backup forward to `.mam.env` (M-4).
6. **Comprehensive test suite**`tests/test_env_migration.py` covers 15 scenarios (T-1 → T-17) including precedence, coexistence warnings, boundary stop, OS-env precedence, override, cwd isolation, remove/purge semantics, owned-legacy migration + manifest rewrite, backup dedup, and slot-1 secret retention.
---
## 2. Verification Evidence
### 2.1 Syntax checks — ALL PASS
```
bash -n: 6/6 shell scripts OK
- .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job OK
- deploy/generate-env.sh OK
- deploy/install.sh OK
- deploy/install_mam.sh OK
- deploy/remove.sh OK ← was FAILING in prior review, now FIXED
- deploy/update.sh OK
py_compile: mqtt_common.py OK, tests/test_env_migration.py OK
```
### 2.2 Targeted test suite — 15/15 PASS (28.95s)
```
tests/test_env_migration.py::TestEnvMigrationFull
test_t1_mam_env_only PASSED
test_t2_legacy_env_only_fallback_and_warning PASSED
test_t3_coexistence_mam_env_precedence_and_warning PASSED
test_t4_parent_boundary_stop_walkup_without_arg PASSED
test_t5_os_env_precedence PASSED
test_t6_mam_env_file_override PASSED
test_t7_wrapper_cwd_isolation PASSED
test_t8_remove_force_preserves_owned_env PASSED
test_t8b_purge_env_is_sole_delete_authority PASSED
test_t9_git_check_ignore PASSED
test_t10_shadowing_prevention_guard PASSED
test_t12_unowned_legacy_env_preservation PASSED
test_t13_owned_legacy_env_migration_and_manifest_rewrite PASSED
test_t16_backup_deduplication_across_reinstall_cycles PASSED
test_t17_immutable_slot_1_user_secret_retention PASSED
============================== 15 passed in 28.95s ==============================
```
### 2.3 `.gitignore` coverage — PASS
```
.mam.env → ignored (.gitignore:21)
.mam.env.bak → ignored (.gitignore:22 .mam.env.*)
.mam.env.update-tmp→ ignored (.gitignore:22)
.mam.env.mam-backup→ ignored (.gitignore:22)
.mam.env.example → NOT ignored (good — negation !.mam.env.example works)
### 2.4 Residual `.env` references — ALL INTENTIONAL
Remaining `.env` references in code are **legacy-fallback / migration-detection** paths, not un-migrated load paths:
- `multi-agent-mux-delegate-job:25-33` — `.env` fallback branches with deprecation warnings (by design).
- `mqtt_common.py:70` — `legacy_env_path = os.path.join(d, ".env")` for fallback + coexistence warning (by design).
- `deploy/install.sh:285-309` — `migrate_legacy_env()` detection of legacy `.env` (by design).
- `deploy/update.sh:71,87-91` — legacy-owned `.env` backup/restore + `MAM_LEGACY_ENV_OWNED` pre-capture (by design).
- `deploy/generate-env.sh:19` — `LEGACY_ENV` for `--migrate-legacy` (by design).
- `BOOTSTRAP.md:128`, `BOOTSTRAP.ko.md:128` — `.gitignore` pattern listing (both `.env` and `.mam.env` patterns retained for the fallback window — correct).
### 2.5 Pre-existing test status (out of scope)
`tests/test_sanity.py::test_create_session_dry_run` FAILS and `test_create_session_full` HANGS — these are **pre-existing** tests (not modified by this change; `git status` shows only `tests/test_env_migration.py` as new). They require a live `herdr`/tmux environment and are unrelated to the env-migration changeset. No regression introduced by this change.
### 2.6 Prior blocking defect — RESOLVED
The orphaned `fi` at line 192 of `deploy/remove.sh` (job `3117bdcc`) is gone. The refactor correctly closes the `for env_name in ".mam.env" ".env"; do ... done` loop (lines 150206) with no dangling `if/fi` mismatch. Control flow verified by reading lines 148210.
```
---
## 3. Findings
### 3.1 Blocking defects — NONE
The prior blocking syntax error is resolved. No new blocking defects found.
### 3.2 Non-blocking follow-ups (recommendations)
**R-1 (Low): Stray untracked `.tmp` file persists**
- `?? .agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job.197_38198.tmp` remains in the working tree and is **NOT ignored** by `.gitignore` (`git check-ignore` returns non-zero). This is the same class of issue flagged as R-2 in the prior `OPTIMIZATION.md` review (job `9c44c6b2`).
- **Recommendation**: Add a `*.tmp` rule to `.gitignore` and remove the stray file. Low risk of accidental commit but should be cleaned up.
**R-2 (Low): `mqtt_common._load_dotenv()` boundary check skipped when `workspace_dir` is explicitly provided**
- When `workspace_dir` is passed explicitly (line 6667), the function uses it directly without verifying a `.agents/` or `.git/` boundary marker. The no-argument path (line 5565) correctly enforces the boundary. This is acceptable because callers passing an explicit dir are asserting the workspace root, but it is an asymmetry worth a code comment for future maintainers.
- **Recommendation**: Add a one-line comment noting that explicit `workspace_dir` is trusted and bypasses boundary detection. No behavioral change needed.
**R-3 (Low): `deploy/update.sh` legacy-restore branch does not update the install manifest**
- In `update.sh` lines 168174, when `MAM_LEGACY_ENV_OWNED=1` and `ENV_BACKUP_SRC=".env"`, the backup is restored forward to `.mam.env`. However, unlike `install.sh`'s `migrate_legacy_env()` (which rewrites the manifest `.env``.mam.env`), `update.sh` does not rewrite the manifest in this branch. If the manifest still lists `.env`, a subsequent `remove.sh` may not recognize `.mam.env` as MAM-owned.
- **Recommendation**: After the forward-migration `mv` in `update.sh`, also rewrite the manifest entry `.env``.mam.env` (mirroring `install.sh`'s python3 one-liner). This is an edge case (legacy-owned env + update without prior install) but could cause `remove.sh` to misclassify `.mam.env` as user-owned on the next uninstall. Low severity because the update path is typically followed by a fresh install that handles manifest rewrite.
---
## 4. Design Assessment
The migration design is **sound and well-layered**:
- **Backward compatibility**: `.env` fallback + deprecation warnings avoid hard breakage for existing users.
- **Evidence-based ownership**: Migration only touches `.env` files the installer can prove it owns (via manifest `grep -Fqx`), preventing accidental takeover of user-owned configs.
- **Data safety**: Backup deduplication (T-16) and immutable slot-1 retention (T-17) prevent both backup proliferation and secret loss across reinstall cycles.
- **Namespace isolation**: Boundary-marker walk-up (T-4) prevents parent-directory `.env` leakage — a real improvement over the prior 5-level blind walk.
- **Testability**: The 15-test suite covers the key edge cases and runs in ~29s without external dependencies.
---
## 5. Verdict
All blocking issues from the prior review are resolved. Syntax checks pass on all 6 shell scripts and 2 Python modules. The targeted regression suite (15/15) passes. `.gitignore` coverage is correct. Residual `.env` references are all intentional fallback/migration paths. The three non-blocking follow-ups (R-1 tmp hygiene, R-2 code comment, R-3 manifest rewrite in update.sh) are low-severity and do not block merge.
[VERDICT: PASS]
@@ -16,11 +16,26 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load local .env if it exists in current dir or workspace root
if [[ -f .env ]]; then
set -a; source .env; set +a
elif [[ -f "$SCRIPT_DIR/../../.env" ]]; then
set -a; source "$SCRIPT_DIR/../../.env"; set +a
# Load local env file (.mam.env preferred, .env fallback) from workspace root or explicit MAM_ENV_FILE
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TARGET_ENV="${MAM_ENV_FILE:-}"
if [[ -z "$TARGET_ENV" ]]; then
if [[ -f "$REPO_ROOT/.mam.env" ]]; then
TARGET_ENV="$REPO_ROOT/.mam.env"
elif [[ -f "$REPO_ROOT/.env" ]]; then
TARGET_ENV="$REPO_ROOT/.env"
echo "WARNING: Loading deprecated config file '$TARGET_ENV'. Please migrate to '.mam.env'." >&2
elif [[ -f .mam.env ]]; then
TARGET_ENV=".mam.env"
echo "WARNING: Loading config from cwd relative path '$TARGET_ENV'." >&2
elif [[ -f .env ]]; then
TARGET_ENV=".env"
echo "WARNING: Loading deprecated config from cwd relative path '$TARGET_ENV'. Please migrate to '.mam.env'." >&2
fi
fi
if [[ -n "$TARGET_ENV" && -f "$TARGET_ENV" ]]; then
set -a; source "$TARGET_ENV"; set +a
fi
# Source EARLY (before any herdr usage in run_agent) — this is what turns
@@ -33,37 +33,81 @@ import paho.mqtt.client as mqtt
logger = logging.getLogger("delegate_job.mqtt_common")
def _load_dotenv(workspace_dir: str = None) -> None:
"""Load .env file from workspace if it exists and env var not already set.
_warned_deprecated_env = False
_warned_coexistence_env = False
def _load_dotenv(workspace_dir: Optional[str] = None) -> None:
"""Load .mam.env (or .env fallback) from workspace if it exists.
This ensures Python scripts get the same env vars as the shell wrapper
scripts that source .env. Only sets vars that are not already in os.environ
(i.e. OS env takes precedence over .env file).
Only sets vars that are not already in os.environ
(i.e. OS env takes precedence over env files).
"""
import os
global _warned_deprecated_env, _warned_coexistence_env
# 1. Check explicit MAM_ENV_FILE override
explicit_file = os.environ.get("MAM_ENV_FILE")
if explicit_file:
if os.path.isfile(explicit_file):
_parse_env_file(explicit_file)
return
# 2. Resolve workspace directory with boundary marker check (.agents or .git)
if workspace_dir is None:
# Walk up from this script to find workspace root
d = os.path.dirname(os.path.abspath(__file__))
for _ in range(5):
if os.path.isfile(os.path.join(d, ".env")):
curr = os.path.dirname(os.path.abspath(__file__))
resolved_root = None
while curr and curr != os.path.dirname(curr):
if os.path.isdir(os.path.join(curr, ".agents")) or os.path.exists(os.path.join(curr, ".git")):
resolved_root = curr
break
d = os.path.dirname(d)
curr = os.path.dirname(curr)
if not resolved_root:
return
d = resolved_root
else:
d = workspace_dir
env_path = os.path.join(d, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
mam_env_path = os.path.join(d, ".mam.env")
legacy_env_path = os.path.join(d, ".env")
has_mam = os.path.isfile(mam_env_path)
has_legacy = os.path.isfile(legacy_env_path)
if has_mam and has_legacy:
if not _warned_coexistence_env:
logger.warning(
"Both '%s' and '%s' exist. Loading '%s'. "
"Consider removing or migrating '%s'.",
mam_env_path, legacy_env_path, mam_env_path, legacy_env_path
)
_warned_coexistence_env = True
_parse_env_file(mam_env_path)
elif has_mam:
_parse_env_file(mam_env_path)
elif has_legacy:
if not _warned_deprecated_env:
logger.warning(
"Loading deprecated config file '%s'. "
"Please migrate to '.mam.env'.",
legacy_env_path
)
_warned_deprecated_env = True
_parse_env_file(legacy_env_path)
def _parse_env_file(path: str) -> None:
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
except Exception as e:
logger.warning("Failed to parse env file %s: %s", path, e)
_load_dotenv()
+5 -1
View File
@@ -1,6 +1,7 @@
# 1회성 작업 자료 (agy/claude 워커에게 보낸 프롬프트)
# 1회성 작업 자료 (agy/claude 워커에게 보낸 프롬프트 및 인수인계 문서)
_agy_prompt_*.md
_claude_prompt_*.md
CURRENT_JOB.md
# 임시 검증용 산출물
test-sessions*.yaml
@@ -17,6 +18,9 @@ __pycache__/
.env
.env.*
!.env.example
.mam.env
.mam.env.*
!.mam.env.example
# 빌드/배포 HTML 산출물
.agents/skills/multi-agent-mux-delegate-job/USER_MANUAL.html
+7 -7
View File
@@ -1,18 +1,18 @@
# ---------------------------------------------------------------------------
# .env.example — committable template for the multi-agent-mux-* skills
# .mam.env.example — committable template for the multi-agent-mux-* skills
#
# This file is tracked in git and contains NO secrets. To get a working local
# config, copy it to `.env` (which is git-ignored) and edit as needed:
# config, copy it to `.mam.env` (which is git-ignored) and edit as needed:
#
# scripts/generate-env.sh # creates .env from this template if absent
# # or manually: cp .env.example .env
# scripts/generate-env.sh # creates .mam.env from this template if absent
# # or manually: cp .mam.env.example .mam.env
#
# Every variable below is OPTIONAL. The skills already resolve sane defaults
# (shown after each `#default:` line), so an unset/commented variable just keeps
# the built-in behaviour. Uncomment + edit only the ones you want to override.
#
# SECURITY: never put real secrets in this template. Secret-bearing vars use a
# `replace_me` placeholder — fill them in only in your local `.env`.
# `replace_me` placeholder — fill them in only in your local `.mam.env`.
# ---------------------------------------------------------------------------
# ===========================================================================
@@ -52,7 +52,7 @@
#default: (unset → anonymous)
# MQTT_USERNAME=replace_me
# Broker auth password. SECRET — fill in only in your local .env, never commit.
# Broker auth password. SECRET — fill in only in your local .mam.env, never commit.
#default: (unset → anonymous)
# MQTT_PASSWORD=replace_me
@@ -80,7 +80,7 @@
# deploy / distribution source (for forks/mirrors)
# ==============================================================================
# Note: These variables are read from the execution environment by deployment scripts.
# Since deploy/install.sh runs before .env exists, you must pass them via export
# Since deploy/install.sh runs before .mam.env exists, you must pass them via export
# or prepended variables (e.g. MAM_REPO_URL=... bash deploy/install.sh).
# If you run a private mirror, we strongly recommend configuring all three variables.
+16 -7
View File
@@ -42,33 +42,39 @@
curl -fsSL https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh | bash
```
인스톨러는 시스템 의존성 유효성을 검증하고 리포지토리를 불러와 오케스트레이션을 위한 `.agents/` 스켈레톤 및 관련 문서 파일(본 `BOOTSTRAP.ko.md` 포함)을 복사하고 `.env``.venv` 가상환경 설정을 자동으로 수행했습니다.
인스톨러는 시스템 의존성 유효성을 검증하고 리포지토리를 불러와 오케스트레이션을 위한 `.agents/` 스켈레톤 및 관련 문서 파일(본 `BOOTSTRAP.ko.md` 포함)을 복사하고 `.mam.env``.venv` 가상환경 설정을 자동으로 수행했습니다.
> [!NOTE]
> **검증 전용 경고**: 본 가이드를 읽고 있는 AI 에이전트는 인스톨러 스크립트를 스스로 **다시 실행해서는 안 됩니다.** 즉시 환경 변수 설정 및 검증 테스트 단계로 넘어가십시오.
---
## 3. 환경 설정 파일 생성 (.env)
## 3. 환경 설정 파일 생성 (.mam.env)
메시징 브로커 설정 및 실행 경로를 설정하기 위해 로컬 환경 설정 파일(`.env`)을 생성하고 수정해야 합니다.
메시징 브로커 설정 및 실행 경로를 설정하기 위해 로컬 환경 설정 파일(`.mam.env`)을 생성하고 수정해야 합니다.
> [!NOTE]
> **레거시 설정 마이그레이션 안내**: `.env`를 사용하는 이전 버전에서 업데이트하는 경우, MAM은 기존 `.env` 파일을 하위 호환 폴백으로 지속 로드합니다. 명시적으로 `.mam.env`로 변경하려면 `./deploy/generate-env.sh --migrate-legacy` 명령을 실행하세요.
### 단계 3.1: 자동 생성 스크립트 실행
프로젝트 루트에서 제공되는 환경 설정 템플릿 복사 스크립트를 실행합니다.
```bash
# .env.example를 .env로 자동 복제 (이미 존재하면 덮어쓰지 않고 보호됨)
# .mam.env.example를 .mam.env로 자동 복제 (이미 존재하면 덮어쓰지 않고 보호됨)
./deploy/generate-env.sh
# 만약 강제로 덮어쓰고 백업을 생성하고 싶은 경우:
./deploy/generate-env.sh --force
# 기존 레거시 .env를 .mam.env로 명시적 이관하려는 경우:
./deploy/generate-env.sh --migrate-legacy
```
### 단계 3.2: 환경 변수 수정 및 설정
생성된 `.env` 파일을 열어 설정을 필요에 따라 구성합니다.
생성된 `.mam.env` 파일을 열어 설정을 필요에 따라 구성합니다.
> [!NOTE]
> `generate-env.sh`로 생성된 기본 `.env` 파일은 모든 환경 변수 항목이 주석 처리되어 있습니다. 주석 처리된 상태로 둘 경우 로컬 프로젝트 루트를 기준으로 한 상대 경로(`.mam/` 등) 및 기본 공개 브로커 주소가 자동 지정되므로 그대로 사용하셔도 무방합니다.
> `generate-env.sh`로 생성된 기본 `.mam.env` 파일은 모든 환경 변수 항목이 주석 처리되어 있습니다. 주석 처리된 상태로 둘 경우 로컬 프로젝트 루트를 기준으로 한 상대 경로(`.mam/` 등) 및 기본 공개 브로커 주소가 자동 지정되므로 그대로 사용하셔도 무방합니다.
1. **MQTT Broker 설정 (`MQTT_BROKER`)**:
* 기본값은 HiveMQ 공개 브로커(`broker.hivemq.com`)로 잡혀 있으나, 보안 및 프라이버시가 중요한 프로덕션 작업 시에는 개인/사설 브로커 주소로 변경할 것을 강력히 권장합니다.
@@ -117,11 +123,14 @@ pip install -r .agents/skills/multi-agent-mux-delegate-job/requirements.txt
* `.mam/jobs/`: 등록된 비동기 잡의 세부 메타데이터가 파일 형태로 저장되는 디렉터리
* `.mam/delegate_job_logs/`: 에이전트가 발행하는 모든 백플레인 이벤트 흐름이 기록되는 audit log (`events.ndjson`) 보존 디렉터리
2. **Git 커밋 제어 (.gitignore)**:
* 새 프로젝트 초기화 시 아래 파일들이 절대 리포지토리에 커밋되지 않도록 `.gitignore` 상태를 점검합니다. `!.env.example` 예외 처리가 유지되어야 템플릿이 보존됩니다:
* 새 프로젝트 초기화 시 아래 파일들이 절대 리포지토리에 커밋되지 않도록 `.gitignore` 상태를 점검합니다. `!.mam.env.example` 예외 처리가 유지되어야 템플릿이 보존됩니다:
```text
.env
.env.*
!.env.example
.mam.env
.mam.env.*
!.mam.env.example
.mam/
.venv/
__pycache__/
+17 -8
View File
@@ -42,33 +42,39 @@ The human developer bootstrapped the workspace by running:
curl -fsSL https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh | bash
```
The installer verified system dependencies, staged the repository, copied the runtime `.agents/` scaffolding and documentation files (including this `BOOTSTRAP.md`), and set up the default `.env` and `.venv` environments.
The installer verified system dependencies, staged the repository, copied the runtime `.agents/` scaffolding and documentation files (including this `BOOTSTRAP.md`), and set up the default `.mam.env` and `.venv` environments.
> [!NOTE]
> **Verify Only**: AI agents reading this guide must **not** attempt to run the installation script again. Proceed directly to configuration and verification steps.
---
## 3. Environment Configuration (.env)
## 3. Environment Configuration (.mam.env)
To set up the messaging broker and execution paths, you must create and modify a local environment configuration file (`.env`).
To set up the messaging broker and execution paths, you must create and modify a local environment configuration file (`.mam.env`).
> [!NOTE]
> **Legacy Config Migration**: If you are updating from an earlier version that used `.env`, MAM will continue to read `.env` as a fallback. However, we recommend migrating to `.mam.env` via `./deploy/generate-env.sh --migrate-legacy`.
### Step 3.1: Run the Generation Script
Run the environment template copy script provided in the project root:
```bash
# Automatically copy .env.example to .env (does not overwrite if it already exists)
# Automatically copy .mam.env.example to .mam.env (does not overwrite if it already exists)
./deploy/generate-env.sh
# To force overwrite and create a backup of the existing .env:
# To force overwrite and create a backup of the existing .mam.env:
./deploy/generate-env.sh --force
# To explicitly migrate an existing legacy .env to .mam.env:
./deploy/generate-env.sh --migrate-legacy
```
### Step 3.2: Modify Environment Variables
Open the generated `.env` file to configure settings as needed.
Open the generated `.mam.env` file to configure settings as needed.
> [!NOTE]
> The default `.env` file generated by `generate-env.sh` has all environment variables commented out. If left commented out, the system defaults to using relative paths (`.mam/`, etc.) relative to the local project root, and the public MQTT broker. You can use it as-is without uncommenting anything.
> The default `.mam.env` file generated by `generate-env.sh` has all environment variables commented out. If left commented out, the system defaults to using relative paths (`.mam/`, etc.) relative to the local project root, and the public MQTT broker. You can use it as-is without uncommenting anything.
1. **MQTT Broker Setup (`MQTT_BROKER`)**:
* The default broker is HiveMQ's public sandbox broker (`broker.hivemq.com`). However, for production work where security and privacy are critical, we strongly recommend changing this to a private broker address.
@@ -117,11 +123,14 @@ Ensure that the local registry directories required to track agent states and jo
* `.mam/jobs/`: Holds detailed metadata files for registered asynchronous jobs.
* `.mam/delegate_job_logs/`: Holds the audit logs (`events.ndjson`) for all backplane events published by agents.
2. **Git Ignore Configuration (`.gitignore`)**:
* When initializing a new project, verify that the following entries are configured in `.gitignore` to prevent committing local runtimes to the repository. The exception `!.env.example` must be kept to preserve the template:
* When initializing a new project, verify that the following entries are configured in `.gitignore` to prevent committing local runtimes to the repository. The exception `!.mam.env.example` must be kept to preserve the template:
```text
.env
.env.*
!.env.example
.mam.env
.mam.env.*
!.mam.env.example
.mam/
.venv/
__pycache__/
+2 -2
View File
@@ -157,7 +157,7 @@ sequenceDiagram
├── deploy/ # 배포 및 설치 도구 패키지 폴더
│ ├── INSTALL.md # 설치 가이드 및 퀵스타트 매뉴얼
│ ├── install_mam.sh # 로컬/클론 인스톨러 스크립트
│ ├── generate-env.sh # 환경 파일(.env) 템플릿 복사 스크립트
│ ├── generate-env.sh # 환경 파일(.mam.env) 템플릿 복사 스크립트
│ ├── install.sh # 원격/네트워크 인스톨러 스크립트
│ ├── update.sh # 업데이트 헬퍼 스크립트
│ └── remove.sh # 삭제/언인스톨 헬퍼 스크립트
@@ -173,7 +173,7 @@ sequenceDiagram
자세한 빌드 절차는 **[BOOTSTRAP.md](./BOOTSTRAP.md)** 문서를 참조하십시오. 아래는 간략한 요약입니다:
1. **환경 설정 파일(.env) 생성:**
1. **환경 설정 파일(.mam.env) 생성:**
```bash
./deploy/generate-env.sh
```
+1 -1
View File
@@ -30,7 +30,7 @@ Alternatively, if you have already cloned the repository locally, run the instal
bash deploy/install.sh
```
The idempotent installer automatically validates system dependencies (herdr, python3, and PyYAML), creates the python virtual environment (`.venv`), installs dependencies, copies `.env.example` as `.env`, and initializes the `.agents/` scaffolding.
The idempotent installer automatically validates system dependencies (herdr, python3, and PyYAML), creates the python virtual environment (`.venv`), installs dependencies, copies `.mam.env.example` as `.mam.env`, and initializes the `.agents/` scaffolding.
---
+1 -1
View File
@@ -6,7 +6,7 @@ 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 (`.env`).
* **`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.
* **`generate-env.sh`**: Environment configuration bootstrap helper.
* **`INSTALL.md`**: Detailed installation and quick-start user manual.
+33 -11
View File
@@ -1,33 +1,54 @@
#!/usr/bin/env bash
# generate-env.sh — create a local .env from the committed .env.example template.
# generate-env.sh — create a local .mam.env from the committed .mam.env.example template.
#
# Behaviour:
# - .env absent → copy .env.example to .env, print the path.
# - .env present → no-op (leaves your edits intact), exit 0.
# - .env present --force → overwrite .env from .env.example (backs up to .env.bak).
# - .mam.env absent → copy .mam.env.example to .mam.env, print the path.
# - .mam.env present → no-op (leaves your edits intact), exit 0.
# - .mam.env present --force → overwrite .mam.env from .mam.env.example (backs up to .mam.env.bak).
# - --migrate-legacy → explicitly rename existing .env to .mam.env if absent.
#
# Paths are resolved relative to this script (repo root = parent of deploy/),
# so it works regardless of the caller's cwd.
#
# Usage: deploy/generate-env.sh [--force] [-h|--help]
# Usage: deploy/generate-env.sh [--force] [--migrate-legacy] [-h|--help]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$REPO_ROOT/.env.example"
DST="$REPO_ROOT/.env"
SRC="$REPO_ROOT/.mam.env.example"
DST="$REPO_ROOT/.mam.env"
LEGACY_ENV="$REPO_ROOT/.env"
FORCE=0
MIGRATE=0
while [ $# -gt 0 ]; do
case "$1" in
--force) FORCE=1; shift ;;
--migrate-legacy) MIGRATE=1; shift ;;
-h|--help)
echo "Usage: $0 [--force]"
echo " Create .env from .env.example. --force overwrites an existing .env."
echo "Usage: $0 [--force] [--migrate-legacy]"
echo " Create .mam.env from .mam.env.example."
echo " --force overwrites an existing .mam.env (backs up to .mam.env.bak)."
echo " --migrate-legacy explicitly renames an existing legacy .env to .mam.env."
exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; echo "Usage: $0 [--force]" >&2; exit 2 ;;
*) echo "ERROR: unknown arg: $1" >&2; echo "Usage: $0 [--force] [--migrate-legacy]" >&2; exit 2 ;;
esac
done
if [ "$MIGRATE" = "1" ]; then
if [ -f "$DST" ]; then
echo "ERROR: cannot migrate: $DST already exists." >&2
exit 1
fi
if [ ! -f "$LEGACY_ENV" ]; then
echo "ERROR: cannot migrate: legacy file $LEGACY_ENV not found." >&2
exit 1
fi
mv -f "$LEGACY_ENV" "$DST"
chmod 0600 "$DST" 2>/dev/null || true
echo "migrated: $LEGACY_ENV -> $DST"
exit 0
fi
[ -f "$SRC" ] || { echo "ERROR: template not found: $SRC" >&2; exit 1; }
if [ -f "$DST" ] && [ "$FORCE" != "1" ]; then
@@ -37,9 +58,10 @@ fi
if [ -f "$DST" ] && [ "$FORCE" = "1" ]; then
cp -p "$DST" "$DST.bak"
echo "backed up existing .env -> $DST.bak"
echo "backed up existing .mam.env -> $DST.bak"
fi
cp "$SRC" "$DST"
chmod 0600 "$DST" 2>/dev/null || true
echo "created: $DST"
echo "Next: edit $DST and fill in any secrets (look for 'replace_me')."
+333 -104
View File
@@ -1,22 +1,28 @@
#!/usr/bin/env bash
# ==============================================================================
# install.sh — Multi-Agent Mux (MAM) Orchestration Installer
# ==============================================================================
# Idempotent, robust installer to bootstrap MAM orchestration skills
# and Python backplane dependencies on any local workspace.
# install.sh — Multi-Agent Mux (MAM) Orchestration Installer (Rev.2)
# ==============================================================================
set -euo pipefail
# --- Configuration & Defaults ---
TARGET_DIR=""
FORCE_REFRESH="${MAM_FORCE_REFRESH:-0}"
REFRESH="${MAM_REFRESH:-1}"
OVERWRITE_CUSTOM="${MAM_OVERWRITE_CUSTOM:-0}"
VENV_NAME=".venv"
MIN_PYTHON_VERSION="3.9"
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force|--refresh-skills)
FORCE_REFRESH=1
REFRESH=1
shift
;;
--no-refresh|--offline)
REFRESH=0
shift
;;
--overwrite-custom)
OVERWRITE_CUSTOM=1
shift
;;
-h|--help)
@@ -24,7 +30,9 @@ while [[ $# -gt 0 ]]; do
Usage: $0 [options] [target_dir]
Options:
-f, --force, --refresh-skills Force fetch and refresh framework skills under .agents/skills/
-f, --force, --refresh-skills Fetch and refresh skills (now default)
--no-refresh, --offline Skip fetching latest assets if assets exist
--overwrite-custom Force overwrite user-modified skill files (backup still created)
-h, --help Show this help message
EOF
exit 0
@@ -64,7 +72,6 @@ check_cmd() {
check_cmd herdr
check_cmd python3
# Verify Python Version
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR="${MIN_PYTHON_VERSION%%.*}"
PYTHON_MINOR="${MIN_PYTHON_VERSION##*.}"
@@ -75,10 +82,8 @@ else
exit 1
fi
# Verify PyYAML (needed by system python3 for atomic state writes)
if ! python3 -c "import yaml" &>/dev/null; then
echo "❌ Error: 'PyYAML' is not installed in the system python3. Please install it first" >&2
echo " (e.g., 'pip3 install PyYAML' or 'sudo apt-get install python3-yaml')." >&2
echo "❌ Error: 'PyYAML' is not installed in system python3." >&2
exit 1
fi
echo "✅ PyYAML (system dependency) detected."
@@ -90,8 +95,6 @@ cd "$TARGET_DIR"
REPO_URL="${MAM_REPO_URL:-https://git.godopu.com/tmpl/multi-agent-mux.git}"
ARCHIVE_URL="${MAM_ARCHIVE_URL:-https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz}"
# Helper to verify presence of all core runtime files.
# Keying off a set of core files helps detect and recover from partial/interrupted installations.
check_assets_present() {
local dir="${1:-.}"
local core_files=(
@@ -109,7 +112,6 @@ check_assets_present() {
return 0
}
# Helper to classify framework-owned skill definitions vs user-owned project assets
is_framework_owned() {
case "$1" in
.agents/skills/*) return 0 ;;
@@ -117,108 +119,242 @@ is_framework_owned() {
esac
}
# Fetch the orchestration assets if missing or if skill refresh is requested.
#
# Safety model (FW-D1): we NEVER extract the repo archive directly into the
# target. Running inside an existing project must not overwrite the target's
# own files (README.md, FUTURE_WORKS.md, AGENTS.md, MULTI_AGENT_RULES.md) or litter
# it with development docs. Instead we stage the download into a throwaway temp dir,
# verify it, then copy runtime assets: framework skills (.agents/skills/*) are updated,
# while user-owned documents use per-file no-clobber guards so pre-existing target files win.
if [ "$FORCE_REFRESH" -eq 1 ] || ! check_assets_present "."; then
# 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
if command -v git &>/dev/null; then
FETCH_METHOD="archive"
if [ -d "$REPO_URL" ]; then
echo "🌐 Copying local working tree into a staging area..."
cp -R "$REPO_URL/." "$STAGE_DIR/"
FETCH_METHOD="local"
elif command -v git &>/dev/null; then
echo "🌐 Cloning repository (shallow) into a staging area..."
git clone --depth 1 "$REPO_URL" "$STAGE_DIR"
FETCH_METHOD="git"
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"
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
FETCH_METHOD="archive"
else
echo "❌ Error: neither 'git' nor 'curl' is available to fetch the skills." >&2
exit 1
fi
# Verify the staged tree before we trust and copy from it.
if ! check_assets_present "$STAGE_DIR"; then
echo "❌ Error: fetched source is missing core runtime assets. Aborting (no files copied)." >&2
echo "❌ Error: fetched source is missing core runtime assets. Aborting." >&2
exit 1
fi
# Create metadata directory and initialize manifest before copying
mkdir -p .mam
MANIFEST_FILE=".mam/install_manifest.txt"
touch "$MANIFEST_FILE"
# Copy runtime assets (.agents/) into the target workspace.
# Framework-owned skill files (.agents/skills/*) are updated/overwritten so that
# latest skill definitions and metadata frontmatter take effect.
# User-owned documents (.agents/MULTI_AGENT_RULES*.md, .agents/INSTALL.md, etc.) use
# explicit no-clobber guards so pre-existing user files are untouched and unmanifested.
# Migrate legacy root layout for remove.sh and update.sh into .mam_deploy/ if owned
mkdir -p .mam_deploy
for legacy_script in remove.sh update.sh; do
if [ -f "$legacy_script" ] && grep -Fqx "$legacy_script" "$MANIFEST_FILE" 2>/dev/null; then
mv -f "$legacy_script" ".mam_deploy/$legacy_script"
python3 -c '
import sys
path = sys.argv[1]
old_s = sys.argv[2]
new_s = sys.argv[3]
with open(path, "r") as f:
lines = f.readlines()
with open(path, "w") as f:
for line in lines:
if line.strip() == old_s:
f.write(new_s + "\n")
else:
f.write(line)
' "$MANIFEST_FILE" "$legacy_script" ".mam_deploy/$legacy_script" 2>/dev/null || true
fi
done
# Safe refresh & fingerprint checking logic
TS=$(date -u +%Y%m%dT%H%M%SZ)
PRESERVED_COUNT=0
MODIFIED_FILES=()
mkdir -p .agents
( cd "$STAGE_DIR/.agents" && find . -type f -print ) | while IFS= read -r rel; do
case "$rel" in
./reports/*|./references/*) continue ;;
*.tmp|*.log|*.pyc|*/__pycache__/*) continue ;;
esac
dest=".agents/${rel#./}"
mkdir -p "$(dirname "$dest")"
if is_framework_owned "$dest"; then
cp -f "$STAGE_DIR/.agents/$rel" "$dest" || { echo "❌ Error: Failed to copy $rel" >&2; exit 1; }
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
echo "$dest" >> "$MANIFEST_FILE"
fi
# 3-way check using python3 inline
STAGING_FILE="$STAGE_DIR/.agents/$rel"
ACTION=$(python3 - "$dest" "$STAGING_FILE" ".mam/asset_hashes.txt" "$OVERWRITE_CUSTOM" <<'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"
def file_sha(path):
if not os.path.exists(path):
return None
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest()
target_sha = file_sha(target_path)
staging_sha = file_sha(staging_path)
if target_sha is None:
print("COPY_NEW")
sys.exit(0)
if target_sha == staging_sha:
print("NO_OP")
sys.exit(0)
db_sha = None
if os.path.exists(hash_db_path):
with open(hash_db_path, "r") as f:
for line in f:
parts = line.strip().split(None, 1)
if len(parts) == 2 and parts[1] == target_path:
db_sha = parts[0]
break
if db_sha is None:
print("BOOTSTRAP_OVERWRITE")
elif target_sha == db_sha:
print("UPDATE_UNMODIFIED")
else:
if force_overwrite:
print("FORCE_OVERWRITE_CUSTOM")
else:
print("PRESERVE_CUSTOM")
PY
)
case "$ACTION" in
COPY_NEW|UPDATE_UNMODIFIED|BOOTSTRAP_OVERWRITE|FORCE_OVERWRITE_CUSTOM)
if [ "$ACTION" = "BOOTSTRAP_OVERWRITE" ] || [ "$ACTION" = "FORCE_OVERWRITE_CUSTOM" ]; then
BACKUP_DIR=".mam/skill-backups/$TS/$(dirname "$dest")"
mkdir -p "$BACKUP_DIR"
cp "$dest" "$BACKUP_DIR/"
fi
cp -f "$STAGING_FILE" "$dest"
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"
cp "$dest" "$BACKUP_DIR/"
echo "PRESERVED:$dest"
;;
NO_OP)
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
echo "$dest" >> "$MANIFEST_FILE"
fi
;;
esac
elif [ ! -e "$dest" ]; then
cp "$STAGE_DIR/.agents/$rel" "$dest" || { echo "❌ Error: Failed to copy $rel" >&2; exit 1; }
cp "$STAGE_DIR/.agents/$rel" "$dest"
if ! grep -Fqx "$dest" "$MANIFEST_FILE" 2>/dev/null; then
echo "$dest" >> "$MANIFEST_FILE"
fi
fi
done | while IFS= read -r line; do
if [[ "$line" == PRESERVED:* ]]; then
echo "️ Local modification detected: ${line#PRESERVED:}" >&2
fi
done
# Copy non-dev documents if they don't already exist.
# We skip dev-specific docs like README.md, DONE.md, and FUTURE_WORKS.md.
for doc in MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md AGENTS.md; do
# 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
ROOT_DOCS="AGENTS.md MESSAGING.md BOOTSTRAP.md BOOTSTRAP.ko.md"
fi
for doc in $ROOT_DOCS; do
if [ -f "$STAGE_DIR/$doc" ] && [ ! -e "$doc" ]; then
cp "$STAGE_DIR/$doc" . || { echo "❌ Error: Failed to copy $doc" >&2; exit 1; }
cp "$STAGE_DIR/$doc" .
echo "$doc" >> "$MANIFEST_FILE"
fi
done
if [ -f "$STAGE_DIR/deploy/remove.sh" ] && [ ! -e "remove.sh" ]; then
cp "$STAGE_DIR/deploy/remove.sh" remove.sh || { echo "❌ Error: Failed to copy remove.sh" >&2; exit 1; }
chmod +x remove.sh
echo "remove.sh" >> "$MANIFEST_FILE"
fi
if [ -f "$STAGE_DIR/deploy/update.sh" ] && [ ! -e "update.sh" ]; then
cp "$STAGE_DIR/deploy/update.sh" update.sh || { echo "❌ Error: Failed to copy update.sh" >&2; exit 1; }
chmod +x update.sh
echo "update.sh" >> "$MANIFEST_FILE"
fi
if [ -f "$STAGE_DIR/.env.example" ] && [ ! -e ".env.example" ]; then
cp "$STAGE_DIR/.env.example" . || { echo "❌ Error: Failed to copy .env.example" >&2; exit 1; }
echo ".env.example" >> "$MANIFEST_FILE"
fi
# Ship the user manual into the target's .agents/ (consistent with install_mam.sh)
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ]; then
mkdir -p .agents
if [ ! -e ".agents/INSTALL.md" ]; then
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md || { echo "❌ Error: Failed to copy INSTALL.md" >&2; exit 1; }
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
# Install remove.sh and update.sh into .mam_deploy/
mkdir -p .mam_deploy
if [ -f "$STAGE_DIR/deploy/remove.sh" ]; then
cp "$STAGE_DIR/deploy/remove.sh" .mam_deploy/remove.sh
chmod 0755 .mam_deploy/remove.sh
if ! grep -Fqx ".mam_deploy/remove.sh" "$MANIFEST_FILE" 2>/dev/null; then
echo ".mam_deploy/remove.sh" >> "$MANIFEST_FILE"
fi
fi
if [ -f "$STAGE_DIR/deploy/update.sh" ]; then
cp "$STAGE_DIR/deploy/update.sh" .mam_deploy/update.sh
chmod 0755 .mam_deploy/update.sh
if ! grep -Fqx ".mam_deploy/update.sh" "$MANIFEST_FILE" 2>/dev/null; then
echo ".mam_deploy/update.sh" >> "$MANIFEST_FILE"
fi
fi
if [ -f "$STAGE_DIR/.mam.env.example" ] && [ ! -e ".mam.env.example" ]; then
cp "$STAGE_DIR/.mam.env.example" .
echo ".mam.env.example" >> "$MANIFEST_FILE"
fi
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ] && [ ! -e ".agents/INSTALL.md" ]; then
mkdir -p .agents
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
fi
# Record version stamp
STAGE_COMMIT="unknown"
if [ -d "$STAGE_DIR/.git" ]; then
STAGE_COMMIT=$(git -C "$STAGE_DIR" rev-parse HEAD 2>/dev/null || echo "unknown")
fi
cat <<EOF > .mam/version.txt
source=$REPO_URL
commit=$STAGE_COMMIT
fetched_at=$TS
method=$FETCH_METHOD
EOF
rm -rf "$STAGE_DIR"
trap - EXIT
echo "✅ Skills staged into workspace (user documents and custom configs preserved)."
fi
# Sanity check: verify all core files, not just a single one — an empty or
# incomplete layout would yield a silently broken install.
if ! check_assets_present "."; then
echo "❌ Error: Core runtime assets missing after setup. Target layout might be invalid." >&2
echo "❌ Error: Core runtime assets missing after setup." >&2
exit 1
fi
echo "✅ Orchestration skills present."
@@ -226,20 +362,80 @@ echo "✅ Orchestration skills present."
echo "📂 Ensuring metadata directory structure (.mam/)..."
mkdir -p .mam/jobs .mam/delegate_job_logs
# File permission lockdown on database directory (if owned by the current user to prevent multi-user system issues)
if [ -O .mam ]; then
chmod 0700 .mam
fi
# R-4: Manage .gitignore block (never put .gitignore in manifest)
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'
import sys, os
gi_path, start_marker, end_marker = sys.argv[1], sys.argv[2], sys.argv[3]
block_lines = [
start_marker + "\n",
"/.venv/\n",
"/.mam/\n",
"/.mam_deploy/\n",
"/.mam.env\n",
"/.mam.env.*\n",
"!/.mam.env.example\n",
"/.cache/multi-agent-mux-monitor/\n",
"/.mam-skill-backup.*/\n",
"CURRENT_JOB.md\n",
end_marker + "\n"
]
lines = []
if os.path.exists(gi_path):
with open(gi_path, "r") as f:
lines = f.readlines()
new_lines = []
in_block = False
block_inserted = False
for line in lines:
if line.strip() == start_marker:
in_block = True
if not block_inserted:
new_lines.extend(block_lines)
block_inserted = True
continue
if line.strip() == end_marker:
in_block = False
continue
if not in_block:
new_lines.append(line)
if not block_inserted:
if new_lines and not new_lines[-1].endswith("\n"):
new_lines[-1] += "\n"
new_lines.extend(block_lines)
with open(gi_path, "w") as f:
f.writelines(new_lines)
PY
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 (NFS/CIFS/SSHFS)."
echo " SQLite WAL journaling and file locks are UNRELIABLE on network storage."
echo " The sqlite3 registry will fall back to 'DELETE' journaling instead of WAL."
echo "⚠️ WARNING: Target directory is on a network filesystem."
else
echo "✅ File system supports WAL (Local storage detected)."
fi
@@ -247,37 +443,68 @@ if command -v df &>/dev/null && command -v mount &>/dev/null; then
fi
# --- 4. Python Virtual Environment Setup ---
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
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
# Activate virtual environment
# shellcheck disable=SC1091
source "$VENV_NAME"/bin/activate
source "$VENV_NAME"/bin/activate
pip install --upgrade pip
# Upgrade pip
pip install --upgrade pip
# Install requirements
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
echo "⚠️ WARNING: Could not find requirements file: $REQ_FILE"
echo " Installing default packages (paho-mqtt, pyyaml) manually..."
pip install "paho-mqtt>=2.0.0" pyyaml
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=".env"
ENV_EXAMPLE=".env.example"
if [ ! -f "$ENV_FILE" ]; then
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"
@@ -285,8 +512,7 @@ if [ ! -f "$ENV_FILE" ]; then
echo "📝 Creating default $ENV_FILE..."
touch "$ENV_FILE"
fi
# Always append the active defaults to ensure they are set and not commented out
cat <<EOF >> "$ENV_FILE"
# === Installer-applied active defaults ===
@@ -297,14 +523,17 @@ MQTT_CLIENT_ID_PREFIX=mam-agent
HERDR_SERVER_NAME=default
EOF
chmod 0600 "$ENV_FILE"
echo "✅ Config file .env initialized with chmod 0600."
# Record the newly created .env in the manifest
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
echo "$ENV_FILE already exists. Skipping config override."
if [ -f "$ENV_FILE" ]; then
echo "$ENV_FILE already exists. Skipping config override."
else
echo "️ Legacy environment detected. Preserved without shadowing."
fi
fi
echo "===================================================================="
+72 -30
View File
@@ -114,15 +114,27 @@ log_info "Deploying orchestration rules & skills (.agents/)..."
mkdir -p "$TARGET_DIR/.agents"
# Sync rules and skills, avoiding copying temporary or system files
# Exclude git histories, reports, logs or internal runtime cache if any
rsync -a --exclude='.git/' --exclude='/reports/' --exclude='*.log' --exclude='__pycache__/' --exclude='*.pyc' "$SRC_DIR/.agents/" "$TARGET_DIR/.agents/"
# Exclude git histories, reports, references, logs or internal runtime cache
rsync -a --exclude='.git/' --exclude='/reports/' --exclude='/references/' --exclude='*.log' --exclude='*.tmp' --exclude='__pycache__/' --exclude='*.pyc' "$SRC_DIR/.agents/" "$TARGET_DIR/.agents/"
log_ok "Deployed Rules and Skills under target's .agents/"
# Copy config templates and generate scripts (M-2)
if [ -f "$SRC_DIR/.env.example" ]; then
cp "$SRC_DIR/.env.example" "$TARGET_DIR/.env.example"
log_ok "Copied .env.example configuration template"
if [ -f "$SRC_DIR/.mam.env.example" ]; then
cp "$SRC_DIR/.mam.env.example" "$TARGET_DIR/.mam.env.example"
log_ok "Copied .mam.env.example configuration template"
fi
# Deploy remove.sh and update.sh into .mam_deploy/ (R-3)
mkdir -p "$TARGET_DIR/.mam_deploy"
if [ -f "$SRC_DIR/deploy/remove.sh" ]; then
cp "$SRC_DIR/deploy/remove.sh" "$TARGET_DIR/.mam_deploy/remove.sh"
chmod 0755 "$TARGET_DIR/.mam_deploy/remove.sh"
fi
if [ -f "$SRC_DIR/deploy/update.sh" ]; then
cp "$SRC_DIR/deploy/update.sh" "$TARGET_DIR/.mam_deploy/update.sh"
chmod 0755 "$TARGET_DIR/.mam_deploy/update.sh"
fi
if [ -f "$SRC_DIR/deploy/generate-env.sh" ]; then
mkdir -p "$TARGET_DIR/scripts"
cp "$SRC_DIR/deploy/generate-env.sh" "$TARGET_DIR/scripts/generate-env.sh"
@@ -160,32 +172,62 @@ else
log_ok "Guidelines AGENTS.md copied to project root."
fi
# 4. Gitignore adjustments
# 4. Gitignore adjustments (R-4: managed block)
log_info "Registering runtime isolation blocks in .gitignore..."
GITIGNORE="$TARGET_DIR/.gitignore"
MAM_PATTERN="/.mam/"
VENV_PATTERN="/.venv/"
if [ -f "$GITIGNORE" ]; then
# Register .mam/ if absent
if grep -Eq '^/?\.mam/?$' "$GITIGNORE"; then
log_ok ".mam/ already registered in target's .gitignore."
else
echo -e "\n# Multi-Agent Mux (MAM) runtime databases and isolation cache\n$MAM_PATTERN" >> "$GITIGNORE"
log_ok "Appended /.mam/ registration to .gitignore."
fi
# Register .venv/ if absent
if grep -Eq '^/?\.venv/?$' "$GITIGNORE"; then
log_ok ".venv/ already registered in target's .gitignore."
else
echo -e "\n# Python virtual environment\n$VENV_PATTERN" >> "$GITIGNORE"
log_ok "Appended /.venv/ registration to .gitignore."
fi
else
echo -e "# Multi-Agent Mux (MAM) runtime databases and isolation cache\n$MAM_PATTERN\n\n# Python virtual environment\n$VENV_PATTERN" > "$GITIGNORE"
log_ok "Created .gitignore with MAM and .venv exclusions."
fi
MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>"
MAM_GI_END="# <<< MAM managed block <<<"
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]
block_lines = [
start_marker + "\n",
"/.venv/\n",
"/.mam/\n",
"/.mam_deploy/\n",
"/.mam.env\n",
"/.mam.env.*\n",
"!/.mam.env.example\n",
"/.cache/multi-agent-mux-monitor/\n",
"/.mam-skill-backup.*/\n",
"CURRENT_JOB.md\n",
end_marker + "\n"
]
lines = []
if os.path.exists(gi_path):
with open(gi_path, "r") as f:
lines = f.readlines()
new_lines = []
in_block = False
block_inserted = False
for line in lines:
if line.strip() == start_marker:
in_block = True
if not block_inserted:
new_lines.extend(block_lines)
block_inserted = True
continue
if line.strip() == end_marker:
in_block = False
continue
if not in_block:
new_lines.append(line)
if not block_inserted:
if new_lines and not new_lines[-1].endswith("\n"):
new_lines[-1] += "\n"
new_lines.extend(block_lines)
with open(gi_path, "w") as f:
f.writelines(new_lines)
PY
log_ok "Registered MAM managed block in .gitignore."
# 5. Python Virtual Environment Setup (F-1)
log_info "Bootstrapping Python virtual environment (.venv) in target..."
+164 -63
View File
@@ -11,6 +11,7 @@ set -euo pipefail
TARGET_DIR=""
FORCE=0
PURGE_ENV=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
@@ -31,7 +32,15 @@ while [[ $# -gt 0 ]]; do
done
if [ -z "$TARGET_DIR" ]; then
TARGET_DIR="$(pwd)"
if [ "$(basename "$SCRIPT_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
else
TARGET_DIR="$(pwd)"
fi
else
if [ "$(basename "$TARGET_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$TARGET_DIR")"
fi
fi
echo "===================================================================="
@@ -46,6 +55,11 @@ fi
cd "$TARGET_DIR"
GI_CREATED=0
if [ -f ".mam/install_state" ]; then
GI_CREATED=$(grep '^gitignore_created=' .mam/install_state 2>/dev/null | cut -d= -f2 || echo 0)
fi
# 1. Non-interactive input safety guard (set -e read crash prevention)
if [ ! -t 0 ] && [ $FORCE -eq 0 ]; then
echo "❌ Error: Non-interactive terminal detected. Please run with -y/--yes/--force." >&2
@@ -66,7 +80,6 @@ if [ -f "$MANIFEST_FILE" ]; then
fi
done < "$MANIFEST_FILE"
else
# Fallback to the core MAM directories to check if any exist
fallback_assets=(
".agents/skills/lib.sh"
".agents/skills/multi-agent-mux-create"
@@ -77,6 +90,7 @@ else
".agents/skills/multi-agent-mux-stop"
".venv"
".mam"
".mam_deploy"
)
for asset in "${fallback_assets[@]}"; do
if [ -e "$asset" ] || [ -h "$asset" ]; then
@@ -95,7 +109,6 @@ fi
if [ $FORCE -eq 0 ]; then
echo "⚠️ WARNING: This will permanently remove the MAM orchestration skills, "
echo " virtual environment (.venv), local metadata (.mam), and docs."
echo " (Your own custom files inside .agents/ will NOT be touched)."
if ! read -p "❓ Are you sure you want to proceed? [y/N]: " -r response; then
response="n"
@@ -114,97 +127,185 @@ delete_asset() {
fi
}
# Check for modified skills before deleting
TS=$(date -u +%Y%m%dT%H%M%SZ)
python3 - ".mam/asset_hashes.txt" "$TS" <<'PY' 2>/dev/null || true
import sys, os, hashlib, shutil
hash_db_path = sys.argv[1]
ts = sys.argv[2]
if not os.path.exists(hash_db_path):
sys.exit(0)
modified = []
with open(hash_db_path, "r") as f:
for line in f:
parts = line.strip().split(None, 1)
if len(parts) == 2:
expected_hash, path = parts[0], parts[1]
if os.path.exists(path):
h = hashlib.sha256()
with open(path, "rb") as pf:
while chunk := pf.read(65536):
h.update(chunk)
if h.hexdigest() != expected_hash:
modified.append(path)
if modified:
backup_dir = f".mam-skill-backup.{ts}"
os.makedirs(backup_dir, exist_ok=True)
for p in modified:
dest = os.path.join(backup_dir, p)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(p, dest)
print(f"💾 Preserved {len(modified)} modified skill file(s) under {backup_dir}")
PY
# 2. Uninstall files using the manifest if present
if [ ${#manifest_files[@]} -gt 0 ]; then
echo "📜 Manifest found. Reversing installer-created files..."
for f in ${manifest_files[@]+"${manifest_files[@]}"}; do
# Skip .env and remove.sh for now, they are handled separately
if [ "$f" = ".env" ] || [ "$f" = "remove.sh" ]; then
if [ "$f" = ".env" ] || [ "$f" = ".mam.env" ] || [ "$f" = "remove.sh" ] || [ "$f" = ".mam_deploy/remove.sh" ]; then
continue
fi
delete_asset "$f"
done
else
# Fallback: Delete MAM skills manually (only if manifest is missing)
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"
fi
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"
fi
# 3. Clean up empty parent directories under .agents recursively to avoid littering
if [ -d ".agents" ]; then
find .agents -depth -type d -exec rmdir {} + 2>/dev/null || true
fi
# 4. Remove virtual environment, monitor cache, and metadata database
# Clean up .gitignore managed block (C8)
MAM_GI_START="# >>> MAM managed block (managed by install.sh — do not edit) >>>"
MAM_GI_END="# <<< MAM managed block <<<"
if [ -f .gitignore ]; then
python3 - .gitignore "$MAM_GI_START" "$MAM_GI_END" "$GI_CREATED" <<'PY'
import sys, os
gi_path, start_marker, end_marker, gi_created = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
if not os.path.exists(gi_path):
sys.exit(0)
with open(gi_path, "r") as f:
lines = f.readlines()
new_lines = []
in_block = False
block_found = False
for line in lines:
if line.strip() == start_marker:
in_block = True
block_found = True
continue
if line.strip() == end_marker:
in_block = False
continue
if not in_block:
new_lines.append(line)
if block_found:
content = "".join(new_lines).strip()
if gi_created and not content:
os.remove(gi_path)
else:
with open(gi_path, "w") as f:
f.writelines(new_lines)
PY
fi
delete_asset ".venv"
delete_asset ".cache/multi-agent-mux-monitor"
delete_asset ".mam" # Deletes manifest file too
delete_asset ".mam"
# 5. Clean up .env file (Only if created by installer, or forced with --purge-env)
# If .env is in manifest, it means MAM created it.
env_created_by_mam=0
for f in ${manifest_files[@]+"${manifest_files[@]}"}; do
if [ "$f" = ".env" ]; then
env_created_by_mam=1
break
fi
done
for env_name in ".mam.env" ".env"; do
[ -f "$env_name" ] || continue
env_created_by_mam=0
for f in ${manifest_files[@]+"${manifest_files[@]}"}; do
if [ "$f" = "$env_name" ]; then
env_created_by_mam=1
break
fi
done
if [ -f ".env" ]; then
should_delete_env=0
if [ $PURGE_ENV -eq 1 ]; then
should_delete_env=1
elif [ $env_created_by_mam -eq 1 ]; then
# Even if MAM created it, ask or rename to backup to prevent loss of custom secrets
if [ $FORCE -eq 1 ]; then
elif [ $env_created_by_mam -eq 1 ] && [ $FORCE -eq 0 ]; then
if ! read -p " MAM-created '$env_name' found. Delete it? (Saying No preserves it) [y/N]: " -r env_response; then
env_response="n"
fi
if [[ "$env_response" =~ ^[yY](es)?$ ]]; then
should_delete_env=1
else
if ! read -p "❓ MAM-created '.env' found. Delete it? (Saying No preserves it) [y/N]: " -r env_response; then
env_response="n"
fi
if [[ "$env_response" =~ ^[yY](es)?$ ]]; then
should_delete_env=1
fi
fi
fi
if [ $should_delete_env -eq 1 ]; then
delete_asset ".env"
delete_asset "$env_name"
else
if [ $env_created_by_mam -eq 1 ]; then
backup_name=".env.mam-backup"
if [ -e "$backup_name" ]; then
backup_name=".env.mam-backup.$(date +%Y%m%d%H%M%S)"
fi
echo "💾 Backing up .env configuration to $backup_name..."
mv ".env" "$backup_name"
else
echo "️ Preserving user-owned .env configuration."
fi
fi
fi
already_preserved=0
for existing in "${env_name}.mam-backup" "${env_name}".mam-backup.*; do
[ -f "$existing" ] || continue
if cmp -s "$env_name" "$existing" 2>/dev/null; then
already_preserved=1
rm -f "$env_name"
echo "️ '$env_name' is already preserved in $existing (no duplicate created)."
break
fi
done
# 6. Remove uninstaller file itself (if we are in the target root)
# Simple check: only delete remove.sh if it is recorded in the manifest
remove_in_manifest=0
for f in ${manifest_files[@]+"${manifest_files[@]}"}; do
if [ "$f" = "remove.sh" ]; then
remove_in_manifest=1
break
if [ $already_preserved -eq 0 ]; then
slot="${env_name}.mam-backup"
if [ -e "$slot" ]; then
slot="${env_name}.mam-backup.$(date +%Y%m%d%H%M%S)"
n=1
while [ -e "$slot" ]; do
slot="${env_name}.mam-backup.$(date +%Y%m%d%H%M%S)-$n"
n=$((n + 1))
done
fi
mv "$env_name" "$slot"
echo "💾 Backed up $env_name -> $slot"
fi
else
echo "️ Preserving user-owned $env_name configuration."
fi
fi
done
if [ -f "remove.sh" ] && [ $remove_in_manifest -eq 1 ]; then
echo "🗑️ Removing uninstaller: remove.sh"
# Self-delete is the final action
rm -f "remove.sh"
fi
# Remove uninstaller file(s)
for self in ".mam_deploy/remove.sh" "remove.sh"; do
[ -f "$self" ] || continue
in_manifest=0
for f in ${manifest_files[@]+"${manifest_files[@]}"}; do
if [ "$f" = "$self" ]; then
in_manifest=1
break
fi
done
if [ $in_manifest -eq 1 ] || [ $FORCE -eq 1 ]; then
echo "🗑️ Removing uninstaller: $self"
rm -f "$self"
fi
done
delete_asset ".mam_deploy/update.sh"
rmdir .mam_deploy 2>/dev/null || true
echo "===================================================================="
echo "🎉 Uninstallation complete!"
+76 -11
View File
@@ -9,6 +9,7 @@ set -euo pipefail
TARGET_DIR=""
FORCE=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
@@ -25,8 +26,15 @@ while [[ $# -gt 0 ]]; do
done
if [ -z "$TARGET_DIR" ]; then
TARGET_DIR="$(pwd)"
if [ "$(basename "$SCRIPT_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$SCRIPT_DIR")"
else
TARGET_DIR="$(pwd)"
fi
else
if [ "$(basename "$TARGET_DIR")" = ".mam_deploy" ]; then
TARGET_DIR="$(dirname "$TARGET_DIR")"
fi
if [ ! -d "$TARGET_DIR" ]; then
echo "❌ Error: Target directory '$TARGET_DIR' does not exist." >&2
exit 1
@@ -41,8 +49,16 @@ echo "===================================================================="
cd "$TARGET_DIR"
# 1. Verification of existing install
if [ ! -f "remove.sh" ]; then
# 1. Verification of existing install (B-2: dual resolution)
REMOVER=""
for cand in ".mam_deploy/remove.sh" "remove.sh"; do
if [ -f "$cand" ]; then
REMOVER="$cand"
break
fi
done
if [ -z "$REMOVER" ]; then
echo "❌ Error: No MAM installation (remove.sh) found in '$TARGET_DIR'." >&2
echo " Please run install.sh first to set up the workspace." >&2
exit 1
@@ -66,12 +82,29 @@ if [ $FORCE -eq 0 ]; then
fi
fi
# 1.5 Pre-capture legacy ownership before remove.sh deletes the manifest (M-4)
MAM_LEGACY_ENV_OWNED=0
if [ -f ".mam/install_manifest.txt" ] && grep -Fqx ".env" ".mam/install_manifest.txt" 2>/dev/null; then
MAM_LEGACY_ENV_OWNED=1
fi
export MAM_LEGACY_ENV_OWNED
# 2. Stage backups of user configurations and metadata to prevent deletion
echo "💾 Backing up configuration and database..."
HAS_ENV=0
if [ -f ".env" ]; then
ENV_BACKUP_SRC=""
ENV_BACKUP_TMP=""
if [ -f ".mam.env" ]; then
HAS_ENV=1
mv ".env" ".env.update-tmp"
ENV_BACKUP_SRC=".mam.env"
ENV_BACKUP_TMP=".mam.env.update-tmp"
mv ".mam.env" "$ENV_BACKUP_TMP"
elif [ -f ".env" ]; then
HAS_ENV=1
ENV_BACKUP_SRC=".env"
ENV_BACKUP_TMP=".env.update-tmp"
mv ".env" "$ENV_BACKUP_TMP"
fi
HAS_MAM=0
@@ -100,13 +133,23 @@ if [ -d ".mam" ]; then
if [ -f ".mam/install_manifest.txt" ]; then
cp -f .mam/install_manifest.txt .mam.update-tmp/
fi
# C12: Copy MAM state files across update cycle
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam/$st" ]; then
cp -f ".mam/$st" .mam.update-tmp/
fi
done
if [ -d ".mam/skill-backups" ]; then
mkdir -p .mam.update-tmp/skill-backups
cp -rf .mam/skill-backups/* .mam.update-tmp/skill-backups/ 2>/dev/null || true
fi
fi
# Define trap to restore backup files on failure
restore_on_failure() {
echo "❌ Update failed. Reverting configuration and database to previous state..."
if [ $HAS_ENV -eq 1 ] && [ -f ".env.update-tmp" ]; then
mv -f ".env.update-tmp" ".env" 2>/dev/null || true
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC" 2>/dev/null || true
fi
if [ $HAS_MAM -eq 1 ] && [ -d ".mam.update-tmp" ]; then
# Revert to old database/jobs backup by restoring .mam directory
@@ -122,6 +165,15 @@ restore_on_failure() {
if [ -f ".mam.update-tmp/install_manifest.txt" ]; then
cp -f .mam.update-tmp/install_manifest.txt .mam/ 2>/dev/null || true
fi
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam.update-tmp/$st" ]; then
cp -f ".mam.update-tmp/$st" .mam/ 2>/dev/null || true
fi
done
if [ -d ".mam.update-tmp/skill-backups" ]; then
mkdir -p .mam/skill-backups
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
fi
rm -rf .mam.update-tmp 2>/dev/null || true
fi
}
@@ -131,7 +183,7 @@ trap restore_on_failure EXIT
echo "🗑️ Removing existing installation..."
# remove.sh will run in manifest mode because .mam/install_manifest.txt is still present.
# It will delete .agents/, documents, scripts, .venv, and .mam folder.
bash remove.sh --force
bash "$REMOVER" --force "$TARGET_DIR"
# 4. Fetch and run the latest installer from Gitea
echo "📥 Fetching and running the latest installer..."
@@ -151,9 +203,13 @@ trap - EXIT
# 5. Restore backups of configuration and database
echo "🔄 Restoring configuration and database..."
if [ $HAS_ENV -eq 1 ]; then
# Overwrite the default .env created by installer (if any) with the user's backup
mv -f ".env.update-tmp" ".env"
if [ $HAS_ENV -eq 1 ] && [ -n "$ENV_BACKUP_TMP" ] && [ -f "$ENV_BACKUP_TMP" ]; then
if [ "$MAM_LEGACY_ENV_OWNED" = "1" ] && [ "$ENV_BACKUP_SRC" = ".env" ] && [ ! -f ".mam.env" ]; then
mv -f "$ENV_BACKUP_TMP" ".mam.env"
chmod 0600 ".mam.env" 2>/dev/null || true
else
mv -f "$ENV_BACKUP_TMP" "$ENV_BACKUP_SRC"
fi
fi
if [ $HAS_MAM -eq 1 ]; then
@@ -173,6 +229,15 @@ if [ $HAS_MAM -eq 1 ]; then
mkdir -p .mam/delegate_job_logs
cp -rf .mam.update-tmp/delegate_job_logs/* .mam/delegate_job_logs/
fi
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam.update-tmp/$st" ]; then
cp -f ".mam.update-tmp/$st" .mam/
fi
done
if [ -d ".mam.update-tmp/skill-backups" ]; then
mkdir -p .mam/skill-backups
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
fi
rm -rf ".mam.update-tmp"
fi
fi
+148
View File
@@ -0,0 +1,148 @@
# ==============================================================================
# test_deploy_layout.py — Deploy Script Layout, Refresh & Gitignore Test Suite
# ==============================================================================
import os
import shutil
import subprocess
import tempfile
import unittest
class TestDeployLayout(unittest.TestCase):
def setUp(self):
self.repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
self.temp_dir = tempfile.mkdtemp(prefix="mam_deploy_layout_test_")
self.test_dir = os.path.join(self.temp_dir, "workspace")
os.makedirs(self.test_dir, exist_ok=True)
self.env = os.environ.copy()
self.env["MAM_REPO_URL"] = self.repo_root
self.env["MAM_SKIP_VENV"] = "1"
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_td1_td2_td3_essential_markdowns_only(self):
"""T-D1~D3: Verify essential markdowns are shipped and internal reports/references are excluded."""
res = subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
capture_output=True,
text=True
)
self.assertEqual(res.returncode, 0, f"install.sh failed: {res.stderr}")
# T-D1: No internal reports in .agents/reports
reports_dir = os.path.join(self.test_dir, ".agents", "reports")
if os.path.exists(reports_dir):
reports_files = [f for root, _, files in os.walk(reports_dir) for f in files if f.endswith(".md")]
self.assertEqual(len(reports_files), 0, f"Internal reports found: {reports_files}")
# T-D2: Non-essential root docs omitted by default
self.assertFalse(os.path.exists(os.path.join(self.test_dir, "MESSAGING.md")))
self.assertFalse(os.path.exists(os.path.join(self.test_dir, "BOOTSTRAP.md")))
# T-D3: Core essential docs present (B-3 gate)
self.assertTrue(os.path.exists(os.path.join(self.test_dir, "AGENTS.md")))
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "MULTI_AGENT_RULES.md")))
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "INSTALL.md")))
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".agents", "skills", "lib.sh")))
def test_td6_td7_td8_mam_deploy_layout_and_removal(self):
"""T-D6~D8: Verify remove.sh/update.sh installed in .mam_deploy and work properly."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
check=True
)
# T-D6: Installed in .mam_deploy and executable
remover = os.path.join(self.test_dir, ".mam_deploy", "remove.sh")
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
self.assertTrue(os.path.exists(remover))
self.assertTrue(os.path.exists(updater))
self.assertTrue(os.access(remover, os.X_OK))
# T-D8: Execution from inside .mam_deploy directory targets parent workspace
res = subprocess.run(
["bash", "./remove.sh", "--force"],
cwd=os.path.join(self.test_dir, ".mam_deploy"),
capture_output=True,
text=True
)
self.assertEqual(res.returncode, 0, f"remove.sh failed: {res.stderr}")
self.assertFalse(os.path.exists(os.path.join(self.test_dir, ".agents", "skills")))
self.assertFalse(os.path.exists(os.path.join(self.test_dir, ".mam_deploy")))
def test_td12_td13_td14_gitignore_managed_block(self):
"""T-D12~D14: Verify .gitignore managed block injection and manifest exclusion (B-1 gate)."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
check=True
)
gi_path = os.path.join(self.test_dir, ".gitignore")
self.assertTrue(os.path.exists(gi_path))
with open(gi_path) as f:
content = f.read()
self.assertIn("# >>> MAM managed block", content)
self.assertIn("/.mam.env", content)
self.assertIn("/.mam_deploy/", content)
# T-D14 [MERGE BLOCKER B-1]: .gitignore MUST NOT be in manifest
manifest_path = os.path.join(self.test_dir, ".mam", "install_manifest.txt")
with open(manifest_path) as f:
manifest = f.read()
self.assertNotIn(".gitignore", manifest)
def test_td21_td22_td23_safe_refresh_custom_skills(self):
"""T-D21~D23: Verify custom modifications to skills are preserved on refresh (B-4 gate)."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
check=True
)
# Modify a framework skill file
custom_skill = os.path.join(self.test_dir, ".agents", "skills", "lib.sh")
with open(custom_skill, "a") as f:
f.write("\n# CUSTOM_USER_MODIFICATION\n")
# Run install.sh again (REFRESH=1)
res = subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
capture_output=True,
text=True
)
self.assertEqual(res.returncode, 0)
self.assertIn("Local modification detected", res.stderr)
# Verify modification preserved
with open(custom_skill) as f:
self.assertIn("CUSTOM_USER_MODIFICATION", f.read())
# Verify backup created in .mam/skill-backups
backups_dir = os.path.join(self.test_dir, ".mam", "skill-backups")
self.assertTrue(os.path.exists(backups_dir))
def test_td27_td28_update_preserves_mam_state(self):
"""T-D27~D28: Verify update.sh preserves .mam state files across cycles (B-5 gate)."""
subprocess.run(
["bash", os.path.join(self.repo_root, "deploy/install.sh"), self.test_dir],
env=self.env,
check=True
)
# Run update.sh
updater = os.path.join(self.test_dir, ".mam_deploy", "update.sh")
res = subprocess.run(["bash", updater, "--force"], cwd=self.test_dir, env=self.env, capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"update.sh failed: {res.stderr}")
# Verify asset_hashes.txt and version.txt still exist
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".mam", "asset_hashes.txt")))
self.assertTrue(os.path.exists(os.path.join(self.test_dir, ".mam", "version.txt")))
if __name__ == "__main__":
unittest.main()
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Full regression test suite for .mam.env migration (T-1 through T-17)."""
import os
import shutil
import tempfile
import unittest
import subprocess
import logging
from unittest.mock import patch
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.agents/skills/multi-agent-mux-delegate-job/scripts")))
import mqtt_common
class TestEnvMigrationFull(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp(prefix="mam_env_full_test_")
self.old_cwd = os.getcwd()
os.chdir(self.test_dir)
self.clean_os_env()
def tearDown(self):
os.chdir(self.old_cwd)
shutil.rmtree(self.test_dir, ignore_errors=True)
self.clean_os_env()
def clean_os_env(self):
for k in ["MQTT_BROKER", "MQTT_PORT", "MQTT_PASSWORD", "TEST_KEY_A", "TEST_KEY_B", "MAM_ENV_FILE", "MAM_LEGACY_ENV_OWNED"]:
os.environ.pop(k, None)
mqtt_common._warned_deprecated_env = False
mqtt_common._warned_coexistence_env = False
def test_t1_mam_env_only(self):
"""T-1: Loads .mam.env when present."""
mam_env = os.path.join(self.test_dir, ".mam.env")
with open(mam_env, "w") as f:
f.write("TEST_KEY_A=val_mam_env\n")
mqtt_common._load_dotenv(self.test_dir)
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam_env")
def test_t2_legacy_env_only_fallback_and_warning(self):
"""T-2: Falls back to .env when .mam.env absent, logs deprecation warning."""
legacy_env = os.path.join(self.test_dir, ".env")
with open(legacy_env, "w") as f:
f.write("TEST_KEY_A=val_legacy_env\n")
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
mqtt_common._load_dotenv(self.test_dir)
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_legacy_env")
self.assertTrue(any("deprecated" in log for log in cm.output))
def test_t3_coexistence_mam_env_precedence_and_warning(self):
"""T-3: When both exist, .mam.env wins and coexistence warning logs."""
mam_env = os.path.join(self.test_dir, ".mam.env")
legacy_env = os.path.join(self.test_dir, ".env")
with open(mam_env, "w") as f:
f.write("TEST_KEY_A=val_mam\n")
with open(legacy_env, "w") as f:
f.write("TEST_KEY_A=val_legacy\n")
with self.assertLogs("delegate_job.mqtt_common", level="WARNING") as cm:
mqtt_common._load_dotenv(self.test_dir)
self.assertEqual(os.environ.get("TEST_KEY_A"), "val_mam")
self.assertTrue(any("Both" in log for log in cm.output))
def test_t4_parent_boundary_stop_walkup_without_arg(self):
"""T-4: Walk-up with no argument stops at boundary marker (.agents/.git)."""
parent_dir = os.path.join(self.test_dir, "parent")
child_repo = os.path.join(parent_dir, "child_repo")
nested_script_dir = os.path.join(child_repo, ".agents", "skills", "test", "scripts")
os.makedirs(nested_script_dir, exist_ok=True)
with open(os.path.join(parent_dir, ".env"), "w") as f:
f.write("TEST_KEY_A=parent_leaked_secret\n")
dummy_file = os.path.join(nested_script_dir, "mqtt_common.py")
with patch.object(mqtt_common, "__file__", dummy_file):
mqtt_common._load_dotenv()
self.assertIsNone(os.environ.get("TEST_KEY_A"))
def test_t5_os_env_precedence(self):
"""T-5: OS environment variables take precedence over env file values."""
os.environ["TEST_KEY_A"] = "os_val"
mam_env = os.path.join(self.test_dir, ".mam.env")
with open(mam_env, "w") as f:
f.write("TEST_KEY_A=file_val\n")
mqtt_common._load_dotenv(self.test_dir)
self.assertEqual(os.environ.get("TEST_KEY_A"), "os_val")
def test_t6_mam_env_file_override(self):
"""T-6: MAM_ENV_FILE takes highest precedence if explicitly provided."""
custom_env = os.path.join(self.test_dir, "custom.env")
with open(custom_env, "w") as f:
f.write("TEST_KEY_A=custom_val\n")
os.environ["MAM_ENV_FILE"] = custom_env
mam_env = os.path.join(self.test_dir, ".mam.env")
with open(mam_env, "w") as f:
f.write("TEST_KEY_A=mam_val\n")
mqtt_common._load_dotenv(self.test_dir)
self.assertTrue(os.path.exists(custom_env))
self.assertEqual(os.environ.get("TEST_KEY_A"), "custom_val")
def test_t7_wrapper_cwd_isolation(self):
"""T-7: Bash wrapper executes cleanly and outputs help/warnings."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
wrapper_path = os.path.join(repo_root, ".agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job")
res = subprocess.run([wrapper_path, "--help"], capture_output=True, text=True)
self.assertEqual(res.returncode, 0)
def test_t8_remove_force_preserves_owned_env(self):
"""T-8 [MERGE BLOCKER]: --force MUST back up owned .env, NEVER delete without backup."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_manifest.txt", "w") as f:
f.write(".env\nremove.sh\n")
with open(".env", "w") as f:
f.write("SECRET_KEY=user_secret_data\n")
res = subprocess.run(["bash", "remove.sh", "--force"], capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"remove.sh failed: {res.stderr}")
self.assertTrue(os.path.exists(".env.mam-backup"), "MAM-owned .env MUST be backed up under --force")
with open(".env.mam-backup") as f:
self.assertIn("user_secret_data", f.read())
def test_t8b_purge_env_is_sole_delete_authority(self):
"""T-8b: --purge-env is the ONLY flag authorized to delete without backup."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), self.test_dir)
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_manifest.txt", "w") as f:
f.write(".env\nremove.sh\n")
with open(".env", "w") as f:
f.write("SECRET_KEY=user_secret_data\n")
res = subprocess.run(["bash", "remove.sh", "--force", "--purge-env"], capture_output=True, text=True)
self.assertEqual(res.returncode, 0)
self.assertFalse(os.path.exists(".env"))
self.assertFalse(os.path.exists(".env.mam-backup"))
def test_t9_git_check_ignore(self):
"""T-9 [MERGE BLOCKER]: Verifies gitignore rules ignore .mam.env variants but track .mam.env.example."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
check_cmd = ["git", "check-ignore", "-v", ".mam.env", ".mam.env.bak", ".mam.env.update-tmp", ".mam.env.mam-backup"]
res = subprocess.run(check_cmd, cwd=repo_root, capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"git check-ignore failed: {res.stderr}")
ex_cmd = ["git", "check-ignore", ".mam.env.example"]
res_ex = subprocess.run(ex_cmd, cwd=repo_root, capture_output=True, text=True)
self.assertNotEqual(res_ex.returncode, 0, ".mam.env.example should NOT be ignored by git")
def test_t10_shadowing_prevention_guard(self):
"""T-10 [MERGE BLOCKER]: install.sh does NOT create new .mam.env if .env.update-tmp exists."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), self.test_dir)
os.makedirs("deploy", exist_ok=True)
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
with open(".env.update-tmp", "w") as f:
f.write("MQTT_PASSWORD=s3cr3t_user_pass\n")
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
self.assertEqual(res.returncode, 0, f"install.sh failed: {res.stderr}")
self.assertIn("Preserved without shadowing", res.stdout)
self.assertFalse(os.path.exists(".mam.env"), ".mam.env MUST NOT be created when .env.update-tmp is waiting for restore")
def test_t12_unowned_legacy_env_preservation(self):
"""T-12 [MERGE BLOCKER]: install.sh leaves unowned .env untouched without heuristic hijacking."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
os.makedirs("deploy", exist_ok=True)
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
with open(".env", "w") as f:
f.write("MQTT_BROKER=my-private-iot-broker.com\nSTRIPE_SECRET=sk_live_123\n")
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
self.assertEqual(res.returncode, 0)
self.assertTrue(os.path.exists(".env"), ".env should remain untouched")
self.assertFalse(os.path.exists(".mam.env"), ".mam.env should NOT hijack unowned .env")
def test_t13_owned_legacy_env_migration_and_manifest_rewrite(self):
"""T-13: Owned .env is migrated to .mam.env and manifest is updated."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
os.makedirs("deploy", exist_ok=True)
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_manifest.txt", "w") as f:
f.write(".env\n")
with open(".env", "w") as f:
f.write("MQTT_BROKER=owned-broker.internal\n")
res = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
self.assertEqual(res.returncode, 0)
self.assertTrue(os.path.exists(".mam.env"))
self.assertFalse(os.path.exists(".env"))
with open(".mam/install_manifest.txt", "r") as f:
manifest_lines = [line.strip() for line in f]
self.assertIn(".mam.env", manifest_lines)
self.assertNotIn(".env", manifest_lines)
def test_t16_backup_deduplication_across_reinstall_cycles(self):
"""T-16 (Backup Hygiene): Repeated remove.sh -y -> install.sh cycles do not multiply identical backups."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
os.makedirs("deploy", exist_ok=True)
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
# Initial setup: MAM-owned .mam.env with user secret
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_manifest.txt", "w") as f:
f.write(".mam.env\nremove.sh\nupdate.sh\n.mam.env.example\n")
with open(".mam.env", "w") as f:
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
# Cycle 1: remove -y
res1 = subprocess.run(["bash", "deploy/remove.sh", "--force"], capture_output=True, text=True)
self.assertEqual(res1.returncode, 0)
self.assertTrue(os.path.exists(".mam.env.mam-backup"))
# Cycle 1: reinstall
res_inst1 = subprocess.run(["bash", "deploy/install.sh", self.test_dir], capture_output=True, text=True)
self.assertEqual(res_inst1.returncode, 0)
# Cycle 2: remove -y & reinstall
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
# Cycle 3: remove -y (identical default content should be deduplicated)
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
# Verify backup count stabilizes at <= 2 (Slot 1 original secret + 1 dedup default slot)
backups = [f for f in os.listdir(self.test_dir) if f.startswith(".mam.env.mam-backup")]
self.assertLessEqual(len(backups), 2, f"Backup files duplicated indefinitely: {backups}")
def test_t17_immutable_slot_1_user_secret_retention(self):
"""T-17 [MERGE BLOCKER]: Slot 1 (.mam.env.mam-backup) retains original user secret after repeated cycles."""
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
os.makedirs("deploy", exist_ok=True)
shutil.copy(os.path.join(repo_root, "deploy/install.sh"), "deploy/install.sh")
shutil.copy(os.path.join(repo_root, "deploy/remove.sh"), "deploy/remove.sh")
if os.path.exists(os.path.join(repo_root, ".mam.env.example")):
shutil.copy(os.path.join(repo_root, ".mam.env.example"), ".mam.env.example")
os.makedirs(".mam", exist_ok=True)
with open(".mam/install_manifest.txt", "w") as f:
f.write(".mam.env\nremove.sh\n")
with open(".mam.env", "w") as f:
f.write("MQTT_PASSWORD=REAL_USER_SECRET_12345\n")
# 3 cycles of remove -> install
for _ in range(3):
subprocess.run(["bash", "deploy/remove.sh", "--force"], check=True)
subprocess.run(["bash", "deploy/install.sh", self.test_dir], check=True)
with open(".mam.env.mam-backup", "r") as f:
slot1_content = f.read()
self.assertIn("REAL_USER_SECRET_12345", slot1_content, "Slot 1 MUST retain original user secret permanently")
if __name__ == "__main__":
unittest.main()