merge: fix/issue-25 (Issue #25: promoted plans are reused only with --plan-file; empty plan reports are not silent; v4.7.1)

Reviews: authoring workspace round 2 @1e82651 5/5 PASS; merge workspace @1e82651 claude/agy/grok/codex 4/4 PASS (identity: base 6fc9194 -> head 1e82651, evidence review-tree). Full suite @1e82651: 1037 passed, 1 skipped. Reports under .agents/reports/. Closes #25.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit was merged in pull request #32.
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-21 15:38:21 +00:00
commit 1e78dae6eb
27 files changed
+927 -57

No files matched your search

@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "planner-reviewer-claude-01",
"job_id": "293e905f",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,139 @@
# 🔍 Review Report — PR #32 병합 전 독립 리뷰 (job `293e905f`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-22
- **대상**: PR #32 `fix/issue-25` **@ `1e82651`**, base `main 6fc9194` (v4.7.0). 5커밋, 4파일 `+486/−31`.
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260921T150212Z-97d845/1` (브리프가 지정한 리뷰 워크트리). 실제로 거기서만 검증했다.
- **identity 확인**: 검증 경로의 `HEAD = 1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd`, `HEAD^{tree} = 4fd82ad1c2682d12c3f5114818f97291af28a032` — 브리프의 `head`·`tree` 와 **일치**한다(항목 5 identity 가 실제로 작동함을 이 잡 자체가 보여준다).
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-32`)·본 체크아웃(`main 6fc9194`) 모두 수정·커밋·checkout 없음. 실험은 전부 scratchpad 임시 디렉터리(uuid4 이름)에서 했고, pytest 는 `--basetemp` 을 검증 경로 밖으로 주었다. 실제 `.mam`·데몬·세션 조작 없음, push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
## 결론 요약
**병합을 막을 결함은 없다.** 핵심 변경(암묵적 계획서 로드 제거, `--plan-file` 명시 재사용, 네 지점 공통 계획 텍스트 검사)은 코드·문서·테스트가 일치하며, 새 테스트 17건 중 **13건이 수정 전 소스에서는 실패**한다(직접 확인). 비차단 지적 3건(E1–E3)을 남긴다.
## 1. 암묵적 로드 제거 (항목 1)
| 확인 | 결과 |
|---|---|
| Python 러너 | `runner.py` 에서 `glob` import 가 사라졌고, 남은 `glob` 사용처는 `find_report` 의 `report-final.md` 탐색(`:568`) 하나뿐이다. `.agents/reports/<planner>/plan-*.md` 를 읽는 코드가 없다 |
| 레거시 셸 러너 | `run_loop.sh` 의 `EXISTING_PLAN_FILE`/`ls -t .../plan-*.md` 블록이 통째로 사라지고 `log_info "No plan loaded; the Creator plans by itself."` 만 남았다 |
| 다른 경로 | 저장소 전체에서 `plan-*.md` 를 읽는 코드는 없다(승격 쪽 **쓰기**만 `runner.py:1368-1372` 에 남는다 — 정상) |
| planner 자동 선택 | `resolve_planner_session`(`run_loop.sh:386`)·`resolve_planner_session` 파이썬 대응은 남아 있으나, 그 결과는 **plan 모드의 위임 대상과 승격 경로**에만 쓰인다. 자기계획 모드에서 planner 이름으로 파일을 찾는 코드는 없다 |
## 2. `--plan-file` (항목 2) — 실측
검증 경로의 러너를 임시 제어 루트(`WORKSPACE_ROOT=<tmp>/ctl`)로 직접 돌려 확인했다.
| 입력 | 결과 |
|---|---|
| 없는 파일 | `[✗] --plan-file <절대경로>: not a readable file with plan text in it.` · **exit 1** |
| 빈 파일 / 공백만 있는 파일 | 같은 메시지 · exit 1 |
| 디렉터리 | 같은 메시지 · exit 1 (`is_file()` 로 걸린다) |
| 읽기 권한 없음(`chmod 000`) | 같은 메시지 · exit 1 (`OSError` → `None`) |
| `--plan` 과 함께 | `ERROR: --plan-file cannot be combined with --plan.` + 사용 예 2줄 · exit 1 |
| 상대경로 | **제어 루트 기준**. 호출 cwd(`<tmp>/elsewhere`)에 `plans/p.md` 가 실제로 있어도 러너는 `<ctl>/plans/p.md` 를 찾고 실패한다 — #25 의 "다른 디렉터리의 동명 파일을 집어오는" 사고를 구조적으로 막는다 |
| 공백 있는 경로(`my plans/p.md`, 상대) | 정상 로드: `Using the plan given with --plan-file: <ctl>/my plans/p.md (modified ... UTC)` (셸을 거치지 않으므로 인용 문제 없음) |
| 절대경로·`~` | `Path.__truediv__` 가 절대경로를 그대로 두고 `expanduser()` 가 앞서 적용된다(P3d 가 고정) |
| `--review-only` 와 함께 | 경고 후 무시(`log_warn`), `current_plan` 을 채우지 않는다 |
| 레거시 셸(`MAM_LOOP_PY=0`) | `ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)` · exit 1 — **동등 구현 대신 명시적 거부**이며, 기본 경로(`MAM_LOOP_PY=1`)는 `exec "$_py" -m lib_py.loop "${MAM_LOOP_ARGV[@]}"`(`run_loop.sh:262`)로 원본 argv 를 그대로 넘기므로 공백 경로도 안전하다 |
읽기가 **잡을 하나도 만들기 전**(parse_args 단계)에 끝나는 것도 확인했다 — 실패 시 레지스트리에 아무 흔적이 없다.
## 3. 계획 텍스트 검사 (항목 3)
`_plan_text()`(`runner.py:303-317`)가 "읽을 수 있는 일반 파일 + 공백 제외 내용 있음"을 단일 판정으로 만들고, **파이썬 러너의 네 지점이 모두 이를 쓴다**: `--plan-file`(`:265`), `--plan` 초기 계획서(`:1025`), 논의 턴 refine(`:1054`), 리뷰 에스컬레이션 refine(`:1284`).
- 빈 초기 계획서 → `log_error` 후 **return 1**. 이때 이미 만든 잡은 `release_guard`/정리 경로가 담당하고, 자기계획으로의 조용한 격하가 사라졌다.
- 빈 refine → 경고 + **이전 계획 유지**. 그래서 "갱신된 다음 계획서에 입각하여…" 프롬프트가 빈 계획을 싣는 일이 없다.
- 에스컬레이션 경로(`[ESCALATE: PLANNER]`)는 `if self.plan_mode and complex_fix`(`:1275`)로 **plan 모드에만** 걸린다. `--plan-file` 로 계획을 실은 실행에서 ESCALATE 판정이 나오면 재계획 없이 일반 수정 라운드로 간다 — 샌드박스에서 직접 구동해 확인했다(Planner 위임 0건, 수정 프롬프트는 "리뷰어들이 지적한 다음 피드백…"). SKILL.md:31 의 "수정 라운드에는 리뷰 피드백만" 서술과 **일치**한다. 다만 이 동작 자체는 E2 로 남긴다.
## 4. 체크포인트 (항목 4)
`.mam/loop-checkpoint.json` 의 `current_plan` 은 **쓰기 전용**이다. `checkpoint.load_checkpoint()` 는 정의되어 있으나 `runner.py` 어디에서도 호출하지 않는다(`grep 'checkpoint\.'` → `save_checkpoint`·`remove_checkpoint` 뿐). 따라서 본 체크아웃 체크포인트에 남아 있는 Issue #6 계획 텍스트가 다음 루프나 재개에서 되살아날 경로는 없다. 계획은 매 실행마다 `--plan-file` 또는 `--plan` 으로만 들어온다.
## 5. 옵션 없을 때의 동작 변화 (항목 5)
- 자기계획 프롬프트(`runner.py:1095`)와 승격 경로(`:1367` `if self.plan_mode and self.current_plan and self.plan_job_id`)는 그대로다. 즉 `--plan-file` 로 실은 계획서는 **승격되지 않는다**(승격은 plan 모드의 planner 산출물만) — 옳은 구분이다.
- 바뀐 것은 "planner 리포트 디렉터리에서 최신 plan-*.md 를 끌어오던" 동작 하나뿐이다. 그 외 프롬프트 문구·옵션 기본값·종료 코드는 동일하다.
- 기존 스위트 정합은 §6 에서 실측했다.
## 6. 테스트 (항목 6)
### 6-1. 검증 경로에서의 실행
```
cd <검증 경로> && PYTHONDONTWRITEBYTECODE=1 <제어 루트 .venv/bin/python> -m pytest -p no:cacheprovider -q -rfEs \
--basetemp=<검증 경로 밖> tests/test_issue25_plan_reuse.py tests/test_loop_cli.py tests/test_issue5_phase3_loop.py \
tests/test_issue21_loop_b.py tests/test_issue21_phase2_loop_a.py tests/test_issue21_acceptance.py
→ 2 failed, 108 passed in 220.98s (skipped 0)
```
**실패 2건은 PR 회귀가 아니라 리뷰 워크트리에 `.venv` 가 없어서 생긴 환경 문제다.**
| 실패 | 원인 | 확인 |
|---|---|---|
| `test_loop_cli.py::test_creator_parses_and_reaches_session_check` | `run_loop.sh` 를 하위 프로세스로 띄우는데, `REPO_ROOT`(= 검증 경로)에 `.venv` 가 없어 `_py` 가 시스템 python 으로 떨어지고 `RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'` 로 죽는다(`run_loop.sh:253`) | `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 만 주고 둘을 다시 돌리면 **2 passed in 0.34s** |
| `test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default` | 동일 | 동일 |
두 시험 모두 PR 이 건드리지 않은 파일이며, 실패 메시지에 계획서·`--plan-file` 은 등장하지 않는다.
### 6-2. 수정 없이는 실패하는가 — 확인함
검증 경로 사본을 만들고 `runner.py`·`run_loop.sh`·`SKILL.md` **세 파일만 base `6fc9194` 판으로 되돌려** 새 테스트를 돌렸다(검증 경로 자체는 건드리지 않았다).
```
13 failed, 4 passed in 1.51s
FAILED P1, P2, P3b, P3c, P3d, P5, P9[empty], P9[whitespace], P10, P11, P7, P7b, P8
```
통과한 4건(P3·P4·P5b·P6)은 옵션 없는 일반 CLI 오류·plan 모드 정상 경로라 수정과 무관한 것들이다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
### 6-3. 격리
| 항목 | 결과 |
|---|---|
| 실제 `.mam/jobs` 전후 | **차이 0** |
| 실제 `.agents/reports` | 새 항목 없음(테스트는 각자 `tmp_path` 안에 제어 루트를 만든다 — `ctl` 픽스처) |
| 검증 경로 tracked 파일 | 변화 없음(`git status --short` 클린, HEAD `1e82651` 유지) |
| 검증 경로 ignored | 내 실행이 `.mam/`(무시됨)을 만들었다. tracked 파일·커밋에는 영향이 없으며, 지우면 리뷰 트리를 수정하는 셈이라 그대로 두었다 → E3 |
| 실행 시간 | 새 파일 `test_issue25_plan_reuse.py` 는 전체 17건이 **1.5 s** 안에 끝난다(브로커·herdr 의존 없음, 모두 stub) |
## 7. 문서 (항목 7)
- 옛 서술("기존에 승격된 계획서가 있다면 이를 로드")은 `.agents`·`docs` 어디에도 남아 있지 않다(전수 grep).
- SKILL.md 의 사용법 문자열·CLI 표·Workflow 예시·Mermaid 다이어그램이 모두 `--plan-file` 기준으로 맞춰졌고, "계획서가 비어 있을 때" 절이 네 지점의 판정을 정확히 설명한다.
- 러너 usage 와 셸 usage 문자열이 동일하다(P7 이 이를 고정한다).
- 배포 사본: 저장소의 `.claude/skills/*` 는 `../../.agents/skills/*` **심링크**라 자동으로 동기화된다. 사용자 홈 `~/.claude/skills` 에는 mux 스킬 사본이 없다. 옛 문구를 가진 사본은 발견되지 않았다.
## 8. 병합 차단 여부 (항목 8)
버전 락스텝 bump 부재(`lib.sh:32`·`eager.py:16`·loop SKILL.md 모두 `4.7.0`, 오케스트레이터가 4.7.1 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
| # | 비차단 지적 | 근거 |
|---|---|---|
| E1 | 레거시 셸 러너(`MAM_LOOP_PY=0`)의 plan 모드 읽기에는 빈 계획서 검사가 없다: `CURRENT_PLAN=$(cat "$PLAN_FILE")`(`run_loop.sh:501`). D13/D14 계약은 파이썬 러너에만 성립하는데 SKILL.md 의 "계획서가 비어 있을 때" 절은 이를 명시하지 않는다 | 후속: 한 줄 거부 또는 문서에 "Python 러너 기준" 명시 |
| E2 | `--plan-file` 로 계획을 실은 실행에서 `[ESCALATE: PLANNER]` 는 재계획 없이 일반 수정 라운드로 내려간다(`runner.py:1275` 가 `plan_mode` 로 게이트). 샌드박스 구동으로 확인(Planner 위임 0건). 이 게이트는 PR 이전부터 있던 것이고 SKILL.md 서술과도 모순되지 않지만, "계획서를 갖고 도는 루프인데 에스컬레이션이 무력화된다"는 점은 문서에 없다 | 문서 보강 권고 |
| E3 | 리뷰 격리 워크트리에는 `.venv` 가 없어, `run_loop.sh` 를 하위 프로세스로 띄우는 스위트는 `AGENT_PYTHON_BIN` 없이는 반드시 실패한다(§6-1). 또한 그 워크트리에서 pytest 를 돌리면 무시되는 `.mam/` 이 생긴다. **PR #32 가 아니라 Issue #21 항목 5 운영 쪽 후속**이다 | 리뷰 브리프의 테스트 명령에 `AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python>` 를 포함하는 것을 권고 |
| E4 | `runner.py:1070` 의 `os.path.getmtime(self.plan_file)` 는 파싱 시점과 Phase 1 사이에 파일이 사라지면 예외로 죽는다(메시지 없는 트레이스백). 극히 좁은 경합 | 사소 |
## 9. 교차검증 가이드
```bash
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
git -C "$RP" rev-parse HEAD HEAD^{tree} # 1e8265... / 4fd82a... = 브리프의 head·tree
# --plan-file 실패 경로 (임시 제어 루트에서만)
C=$(mktemp -d); mkdir -p "$C/ctl" "$C/elsewhere/plans"; : > "$C/ctl/empty.md"; echo x > "$C/elsewhere/plans/p.md"
(cd "$C/elsewhere" && PYTHONPATH="$RP/.agents/skills" WORKSPACE_ROOT="$C/ctl" MAM_STATE_JSON='{"herdr_sessions":[]}' \
"$PY" -m lib_py.loop --creator c --task t --plan-file plans/p.md; echo "rc=$?") # ctl 기준 경로로 실패, rc 1
# 수정 없이는 실패하는지 (사본에서만)
S=$(mktemp -d); rsync -a --exclude .venv --exclude .git --exclude .mam --exclude nats-docker "$RP/" "$S/"
for f in .agents/skills/lib_py/loop/runner.py .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
.agents/skills/multi-agent-mux-loop/SKILL.md; do git -C "$RP" show 6fc9194:"$f" > "$S/$f"; done
(cd "$S" && PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt tests/test_issue25_plan_reuse.py) # 13 failed
# 실패 2건이 환경 문제임을 확인
(cd "$RP" && AGENT_PYTHON_BIN="$PY" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pytest -q -p no:cacheprovider --basetemp=/tmp/bt2 \
tests/test_loop_cli.py::test_creator_parses_and_reaches_session_check \
tests/test_issue5_phase3_loop.py::test_p3_02_run_loop_wrapper_delegates_to_python_default) # 2 passed
```
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "planner-reviewer-codex-01",
"job_id": "08808795",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,32 @@
# PR #32 independent review
Reviewer: planner-reviewer-codex-01. Job: 08808795.
Reviewed the exact detached review-identity path specified by the brief:
`/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1`, at `1e826515`, for the five commits after base `6fc9194`. No checkout, source file, session, monitor, or real registry state was modified.
## Findings
The explicit plan-reuse change is coherent and closes the implicit-load defect.
- With neither `--plan` nor `--plan-file`, Python self-planning logs that no plan is loaded and does not glob `.agents/reports/<planner>/plan-*.md`. The legacy shell runner does the same. `resolve_planner_session` may still identify a planner for other loop bookkeeping, but it no longer supplies a plan through that path.
- `--plan-file` is parsed by both wrappers. Python resolves relative paths against the control root, preserves absolute and `~` paths, requires a readable regular file with non-whitespace content, and rejects bad inputs before registering jobs. It is mutually exclusive with `--plan`; `--planner` still requires `--plan`; review-only accepts a valid file but warns and ignores it because no implementation prompt is produced. The legacy shell runner rejects the option explicitly when `MAM_LOOP_PY=0`, before the loop lock.
- `_plan_text()` is the shared check for explicit files, initial planner reports, discussion refinements, and escalation refinements. An empty initial planner report stops the run rather than silently falling back to self-planning. An empty refine report preserves the existing plan. Correction prompts therefore never claim to carry an empty plan.
- Checkpoint `current_plan` is not a plan-discovery source. It is only recorded for the current run; a later no-option run starts planless, and a named `--plan-file` is read explicitly. Promotion still writes the plan only for an actual `--plan` run.
- Relative plan paths with spaces, missing paths, directories, empty/whitespace files, unreadable files, `--plan` conflicts, review-only handling, and legacy-shell rejection are covered by P1–P11. The tests run in temporary control roots and use isolated reports; they do not write the real `.mam` or `.agents/reports`.
- The loop skill’s old implicit-reuse wording is removed. The CLI usage, workflow examples, option table, and explicit-reuse description agree. No `.claude/skills` deployment copy exists in this checkout, so there is no second copy to synchronize.
The known release prerequisite remains outside this PR: the orchestrator must perform the 4.7.1 lockstep version bump and release entry before merge.
## Verification
All tests used `PYTHONDONTWRITEBYTECODE=1`, the interpreter from the brief, pytest cache disabled, and a basetemp outside the review path.
- `tests/test_issue25_plan_reuse.py`: **17 passed** in 1.70 s.
- `tests/test_issue25_plan_reuse.py`, `tests/test_loop_cli.py`, `tests/test_issue5_phase3_loop.py`, and `tests/test_version_consistency.py`: **39 passed, 2 failed** in 49.25 s when run exactly from the identity path. Both failures occurred before their assertions because `run_loop.sh` could not find `.venv` in the detached snapshot and fell back to system Python, which lacked `paho` (`RuntimeError: Failed to eager load lib_py.loop.autocomplete: No module named 'paho'`). This is a snapshot/setup issue, not a change in the plan-reuse code.
- Re-running the two failed compatibility cases with `AGENT_PYTHON_BIN` pinned to the brief’s main interpreter: **2 passed** in 0.32 s. This reaches and verifies the expected session-registration checks.
- `git diff --check 6fc9194...HEAD`: passed.
The focused plan tests passed without modification, and the only broader failures disappeared when the required interpreter was explicitly supplied. I found no additional merge-blocking defect beyond the known version gate.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "reviewer-agy-01",
"job_id": "41f07cde",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,113 @@
# Pre-Merge Independent Review Report: PR #32 (`fix/issue-25`)
- **Job ID**: `41f07cde`
- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`)
- **Role**: Reviewer
- **Target Worktree Context**:
- **Loop ID**: `loop-20260921T150212Z-97d845`
- **Base Commit**: `6fc9194f896eb2e13e123b8ebe7a9089b629764d` (v4.7.0)
- **Head Commit**: `1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd`
- **Tree SHA**: `4fd82ad1c2682d12c3f5114818f97291af28a032`
- **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1`
---
## 1. Executive Summary
PR #32 resolves Issue #25 ("승격된 계획서 재사용을 --plan-file 로만 허용 — 암묵적 자동 로드 제거") across 5 commits and 4 files (+486 / −31):
- `1e82651` fix(issue-25): a relative --plan-file is taken against the control root, as its usage line says
- `5fa5923` docs(issue-25): the loop skill documents --plan-file and the empty-report handling
- `a334083` test(issue-25): plan reuse is explicit only and empty plan reports are not silent (P1-P11)
- `e551e3b` fix(issue-25): one plan-text check for every load; an empty planner report stops the loop, an empty refine keeps the plan
- `a28711c` fix(issue-25): reuse a promoted plan only with --plan-file; drop the implicit newest-plan load
All checklist criteria have been verified in the isolated verification path. All 17 new tests in `test_issue25_plan_reuse.py` and all 115 baseline regression tests passed with zero regressions.
---
## 2. Detailed Verification Against Checklist Items
### (1) Removal of Implicit Plan Loading
- **Python Runner (`lib_py/loop/runner.py`)**:
- Removed `import glob` and the directory scan `glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md"))`.
- In Phase 1 Self-Planning mode, if `--plan-file` was not supplied, `self.current_plan` remains empty and the runner logs: `"No plan loaded; the Creator plans by itself (reuse one with --plan-file <path>)."`.
- **Legacy Shell Runner (`scripts/run_loop.sh`)**:
- Removed `EXISTING_PLAN_FILE=$(ls -t ".agents/reports/$PLANNER_SESSION"/plan-*.md 2>/dev/null | head -n 1 || true)`.
- In Self-Planning mode, logs `"No plan loaded; the Creator plans by itself."`.
- **Parity & Search Check**:
- Test `test_p8_neither_loop_implementation_globs_for_a_plan_any_more` confirms the string pattern `plan-*.md` does not appear anywhere in `runner.py` or `run_loop.sh`.
- Automatic planner resolution (`resolve_planner_session`) is not called or consulted to retrieve stored plans in Phase 1 self-planning mode.
### (2) `--plan-file <path>` CLI Option Semantics
- **Control Root Resolution**:
- Relative paths are anchored against `self.workspace_root` via `(self.workspace_root / Path(self.plan_file).expanduser()).resolve()`. This guarantees identical path resolution whether the command is run from the repository root, a subdirectory, or an isolated worktree (`test_p3c`).
- Absolute paths and user home expansion (`~`) remain untouched and work as expected (`test_p3d`).
- **Fail-Closed Early Validation**:
- Evaluated in `parse_args()` before any agent delegation or job submission occurs (`test_p3`, `test_p3b`).
- If the path is non-existent, a directory, unreadable, or contains whitespace-only text, the runner logs an error naming the resolved path and immediately exits with rc=1.
- **Mutual Exclusion & Option Combinations**:
- Combining `--plan-file` with `--plan` exits 1 with a clear mutual exclusion error (`test_p4`).
- Specifying `--plan-file` without an argument exits 1 (`test_p4`).
- When combined with `--review-only`, the runner logs a warning and ignores the plan (`self.current_plan = ""`), because Phase 2 implementation is skipped (`test_p5`, `test_p5b`).
- **Legacy Shell Rejection**:
- When invoked with `MAM_LOOP_PY=0`, `run_loop.sh` explicitly detects `--plan-file` and terminates with code 1 before the loop marker/guard is created (`test_p7b`).
### (3) Centralized Plan Text Validation (`e551e3b`)
- **Shared Validation Method**:
- `LoopRunner._plan_text(path: Path) -> Optional[str]` enforces:
1. Regular file check (`path.is_file()`).
2. Safe decoding with error replacement (`read_text(encoding="utf-8", errors="replace")`).
3. Stripped text check (`text if text.strip() else None`).
- **Initial Plan Failure**:
- In `--plan` mode, if the initial planner report is empty or whitespace-only, the runner logs `the planner's report has no plan text: <path>` and exits with code 1 (`test_p9`). This eliminates the regression where an empty plan silently demoted the run to self-planning.
- **Refinement Retention (Debate & Review Escalation)**:
- In Phase 1 interactive debate (`--plan-talk`) and Phase 3 review escalation (`[ESCALATE: PLANNER]`), an empty refinement report logs a warning and preserves the previously valid plan (`test_p10`, `test_p11`).
- This prevents prompt corruption in `correction_prompt = f"갱신된 다음 계획서에 입각하여 ... 계획서:\n{self.current_plan}"`.
### (4) Checkpoint Integrity
- `LoopRunner` persists `current_plan` to `.mam/loop-checkpoint.json` for diagnostic and monitoring visibility during active loops.
- `LoopRunner` never reads `.mam/loop-checkpoint.json` at startup to populate `self.current_plan`. `self.current_plan` is always initialized cleanly (`self.current_plan = ""`).
- Existing checkpoint files in the workspace (such as unrelated historical plan text from prior issues) cannot contaminate new loop runs.
### (5) Baseline Behavior & Creator Prompts
- In self-planning mode without `--plan-file`, the initial Creator prompt retains the exact contract:
`"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"`.
- With `--plan-file <path>`, the prompt receives:
`"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"`.
- Subsequent review corrective rounds strictly provide reviewer feedback without repeating the initial plan (`test_p2`).
- Promotion of newly planned tasks via `--plan` to `.agents/reports/<planner>/plan-<id>.md` remains operational and protected (`test_p6`).
### (6) Test Suite Execution & Isolation (P1–P11)
- All 17 test cases in `tests/test_issue25_plan_reuse.py` operate strictly within isolated `tmp_path` fixtures with stubbed delegations.
- Real `.mam/jobs`, live daemon processes, and global agent sessions remain completely untouched.
- Execution timing: 17 tests passed in 2.02 seconds.
### (7) Documentation Integrity
- `.agents/skills/multi-agent-mux-loop/SKILL.md` was thoroughly updated:
- Completely purged the stale statement ("기존에 승격된 계획서가 있다면 이를 로드").
- Updated Sequence Diagram and Phase 1 documentation to reflect explicit `--plan-file` reuse.
- Added a dedicated section: `### 계획서가 비어 있을 때 (Empty plan reports)`.
- Updated CLI Option mapping table and Workflow examples.
- No dangling references to the old implicit loading mechanism exist across the repository.
### (8) Versioning & Merge Readiness
- Version lockstep bump to `v4.7.1` is reserved for the orchestrator release commit in accordance with release procedures.
- Formatting and git diff checks (`git diff 6fc9194...HEAD --check`) reported 0 issues.
- No merge blockers, regression risks, or side-effects detected.
---
## 3. Test Execution Summary
| Test Suite | Result | Duration | Notes |
| :--- | :--- | :--- | :--- |
| `tests/test_issue25_plan_reuse.py` | **17 / 17 PASS** | 2.02s | P1–P11 test matrix covering all reuse/empty-plan conditions |
| `tests/test_loop_cli.py` | **9 / 9 PASS** | 38.97s | CLI interface and argument validation regressions |
| `tests/test_issue5_phase2a_loop.py` | **91 / 91 PASS** | 2.91s | Core loop state machine, dispatching, and verdict handling |
| `tests/test_issue5_phase3_loop.py` | **12 / 12 PASS** | 0.54s | Runner orchestration, timeouts, and signal handling |
| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.21s | Version consistency checks |
| `git diff 6fc9194...HEAD --check` | **0 errors** | 0.05s | Zero trailing whitespace / formatting errors |
---
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20260921T150212Z-97d845",
"base_sha": "6fc9194f896eb2e13e123b8ebe7a9089b629764d",
"head_sha": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"tree_sha": "4fd82ad1c2682d12c3f5114818f97291af28a032",
"review_commit": "1e826515cc2a07ea1f8da0ff1259e1ea4dde86fd",
"reviewer": "reviewer-grok-01",
"job_id": "06fb4a9e",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-09-21T15:25:44Z"
}
@@ -0,0 +1,59 @@
# 🔍 Review Report — Job 06fb4a9e (PR #32 @ `1e82651`)
- **Reviewer**: `reviewer-grok-01` · 2026-09-22
- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260921T150212Z-97d845/1`
- **identity**: base `6fc9194` head `1e82651` tree `4fd82ad` (일치)
- **소스 워크트리·본 체크아웃**: 읽기 전용. 본 체크아웃 `main` `6fc9194` 유지.
`git diff --check 6fc9194...HEAD` 클린. 5커밋.
## (1) 암묵적 로드 제거
Python: Phase 1 else 가 glob/`plan-*.md` 를 쓰지 않음 (`runner.py:1064-1073`). `import glob` 삭제. `resolve_planner_session` (`:990`) 은 세션 이름만 로그하고 파일을 읽지 않음. `--planner` 는 `--plan` 없이 exit 1 (`:244-248`).
레거시 셸 (`MAM_LOOP_PY=0`): `ls -t .agents/reports/.../plan-*.md` 제거 (`run_loop.sh` Phase 1 else). `plan-*.md` 문자열이 두 구현에 없음 (`test_p8`).
## (2) `--plan-file`
상대경로는 `workspace_root / path` (`runner.py:264`); POSIX 에서 절대경로는 `/` 로 치환되어 그대로. `~` 는 `expanduser`. 없는/빈/공백뿐/디렉터리/읽기 실패 → `_plan_text` None → parse_args `sys.exit(1)`, 잡 없음. `--plan` 과 상호 배타. `--review-only` 는 파일 검사 후 경고·무시(`current_plan` 비움). `--planner` 는 `--plan` 필수라 `--plan-file` 과 함께면 먼저 `--planner` 규칙에 걸린다.
레거시 셸은 `--plan-file` 을 Python 전용으로 **명시 거부** exit 1, 루프 락 전 (`run_loop.sh:127-134`, `test_p7b`). 공백 경로는 pathlib 가 처리; 전용 테스트는 없음(비차단).
## (3) `_plan_text` (e551e3b)
한 함수 (`:304-316`)가 `--plan-file`, 초기 planner 리포트, 논의 refine, 에스컬레이션 refine 에 사용. 빈 초기 계획 → `return 1`, Worker 없음 (`test_p9`). 빈 refine → 경고·이전 계획 유지 (`test_p10`, `test_p11` ESCALATE). 수정 프롬프트가 빈 계획을 싣지 않음.
## (4) 체크포인트
`current_plan` 은 기록만 (`_record_checkpoint`). `load_checkpoint` 를 runner 가 호출하지 않음. 새 실행은 `current_plan=""` 에서 시작. 본 체크아웃 `.mam/loop-checkpoint.json` 의 옛 계획은 우회가 아님.
## (5) 옵션 없음
의도된 변화만: 자기계획 프롬프트. 승격은 `--plan` 성공 시 `plan-{id}.md` 기록 유지 (`test_p6`). 기존 테스트는 검증 경로에서 실행.
## (6) 테스트 P1–P11
전부 `tmp_path`. 검증 경로:
```
PYTHONDONTWRITEBYTECODE=1 <control .venv python> -m pytest -p no:cacheprovider --basetemp=/tmp/mam-review-basetemp-...
tests/test_issue25_plan_reuse.py
```
**17 passed** (P1–P11 + p3b/c/d, p5b, p7/p7b, p8). 수정 전 P1 은 옛 glob 을 단언하므로 실패했을 테스트.
같은 인터프리터로 `test_loop_cli`·`test_issue5_phase3`·`test_issue21_worktree_prereqs`·`test_version_consistency` 포함 시 래퍼 서브프로세스가 검증 트리에 `.venv` 가 없어 `paho` 를 못 찾는 실패 2건이 났고, `DELEGATE_JOB_PYTHON` 을 컨트롤 `.venv` 로 주면 **19 passed**. 제품 회귀가 아니라 스냅샷에 venv 가 없는 환경.
## (7) 문서
SKILL.md 에서 "기존에 승격된 계획서가 있다면 이를 로드" 잔존 없음. usage·CLI 표·Workflow 예시가 `--plan-file` 과 일치. `.claude/skills` 는 이 스냅샷에 없음.
## (8) 락스텝
`MAM_VERSION=4.7.0`. 4.7.1 bump 는 오케스트레이터 예정. 병합 차단 아님.
## 종합
암묵적 최신 계획 로드가 Python·레거시 양쪽에서 제거됐고, 재사용은 `--plan-file` 뿐이다. 빈 리포트는 조용히 계획을 지우지 않는다. 설계 재작업 불필요.
[VERDICT: PASS]
+1 -1
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.7.0"
MAM_VERSION="4.7.1"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.7.0"
FRAMEWORK_VERSION = "4.7.1"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+81 -11
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import argparse
import atexit
import glob
import json
import os
import re
@@ -98,6 +97,7 @@ class LoopRunner:
self.creator: str = ""
self.task: str = ""
self.plan_mode: bool = False
self.plan_file: Optional[str] = None
self.planner_override: Optional[str] = None
self.plan_talk_turns: int = 1
self.reviewer_list: Optional[str] = None
@@ -116,6 +116,7 @@ class LoopRunner:
" --plan Enable Planner agent intervention & design phase",
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
" --plan-talk N Planner-Creator discussion limit turns (default: 1)",
" --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)",
" --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)",
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
" --max-loop N Max execution-review corrective loop runs (default: 3)",
@@ -162,6 +163,12 @@ class LoopRunner:
sys.exit(1)
self.planner_override = args[i + 1]
i += 2
elif arg == "--plan-file":
if i + 1 >= len(args):
self.print_usage()
sys.exit(1)
self.plan_file = args[i + 1]
i += 2
elif arg == "--plan-talk":
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
print("ERROR: --plan-talk requires a positive integer.", file=sys.stderr)
@@ -240,6 +247,33 @@ class LoopRunner:
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
sys.exit(1)
# Issue #25: a promoted plan is reused only when it is named (D2). The
# file is read here, before a single job exists, so a bad path cannot
# surface halfway through a run (D3).
if self.plan_file is not None:
if self.plan_mode:
print("ERROR: --plan-file cannot be combined with --plan.", file=sys.stderr)
print("Use --plan to have the Planner write a new plan, or --plan-file to reuse one:", file=sys.stderr)
print(' run_loop.sh --creator <creator> --plan-file <path> --task "..."', file=sys.stderr)
sys.exit(1)
# A relative path is taken against the control root, as the usage
# line says and as jobs_dir already does (Issue #21 R2): the runner
# never chdirs to workspace_root, so resolving against the caller's
# cwd would look somewhere else entirely - and could find a
# same-named file there. `/` keeps an absolute path as it is.
resolved = (self.workspace_root / Path(self.plan_file).expanduser()).resolve()
text = self._plan_text(resolved)
if text is None:
log_error(f"--plan-file {resolved}: not a readable file with plan text in it.")
sys.exit(1)
self.plan_file = str(resolved)
if self.review_only:
# D6: --review-only delegates no implementation job, so there is
# nothing for a plan to steer.
log_warn(f"--plan-file is ignored with --review-only: {resolved}")
else:
self.current_plan = text
if self.all_reviewers and self.reviewer_list:
log_warn(f"--all-reviewer takes precedence; ignoring --reviewer list ('{self.reviewer_list}').")
@@ -266,6 +300,21 @@ class LoopRunner:
if not self._context_active() and any(a in self.raw_argv for a in ("--review-isolation", "--remote-review")):
log_warn("--review-isolation/--remote-review only apply with --worktree or --base; ignored.")
@staticmethod
def _plan_text(path: Path) -> Optional[str]:
"""Issue #25 (D3): the one check every plan load shares - a plan is a
readable regular file that has text in it. None means 'no plan here';
each caller decides what that means (D13 stops the run, D14 keeps the
plan it already has). A file that cannot be read is not told apart from
an empty one: either way the loop would carry no plan text."""
try:
if not path.is_file():
return None
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
return text if text.strip() else None
@staticmethod
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
@@ -973,7 +1022,13 @@ class LoopRunner:
if not p_file or not p_file.exists():
log_error("Final plan file not found.")
return 1
self.current_plan = p_file.read_text(encoding="utf-8", errors="replace")
plan_text = self._plan_text(p_file)
if plan_text is None:
# D13: the run paid for a planning phase and got nothing back.
# Falling through would silently demote it to self-planning.
log_error(f"the planner's report has no plan text: {p_file}")
return 1
self.current_plan = plan_text
# Interactive turns
creator_type = self.resolve_agent_type(self.creator, state)
@@ -996,17 +1051,26 @@ class LoopRunner:
return 1
ref_file = self.find_report(ref_jid)
if ref_file and ref_file.exists():
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
refined = self._plan_text(ref_file) if ref_file else None
if refined is None:
# D14: overwriting with an empty report loses a plan that is
# still good, and Phase 2 would quietly switch prompts.
log_warn(f"the refine report has no plan text (job {ref_jid}); keeping the previous plan.")
else:
self.current_plan = refined
log_success("Interactive planning completed. Plan finalized.")
else:
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
if planner_session:
plan_files = sorted(glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md")), key=os.path.getmtime, reverse=True)
if plan_files:
log_info(f"Found existing promoted plan at '{plan_files[0]}'. Loading plan...")
self.current_plan = Path(plan_files[0]).read_text(encoding="utf-8", errors="replace")
# Issue #25 (D1): no plan is loaded unless one was named. This used
# to take the newest promoted plan in the planner's report dir,
# keyed on mtime (checkout order) and on a planner session that says
# nothing about this task - so it injected other issues' plans.
if self.current_plan:
mtime = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(self.plan_file)))
log_info(f"Using the plan given with --plan-file: {self.plan_file} (modified {mtime} UTC)")
else:
log_info("No plan loaded; the Creator plans by itself (reuse one with --plan-file <path>).")
# =======================================================================
# PHASE 2: IMPLEMENTATION (CREATION)
@@ -1217,8 +1281,14 @@ class LoopRunner:
log_error("Failed to register plan refinement job.")
return 1
ref_file = self.find_report(ref_jid)
if ref_file and ref_file.exists():
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
refined = self._plan_text(ref_file) if ref_file else None
if refined is None:
log_warn(f"the refine report has no plan text (job {ref_jid}); keeping the previous plan.")
else:
self.current_plan = refined
# D13 stops a plan-mode run whose initial plan is empty and D14
# never overwrites with an empty one, so current_plan is not
# empty here: this prompt cannot claim a plan it does not carry.
correction_prompt = f"갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n{self.current_plan}\n피드백 상세:\n{feedback_aggregate}"
else:
log_info("Applying straight bugfixes based on reviewer feedback...")
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos, windows]
+19 -9
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -27,7 +27,8 @@ metadata:
Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. It supports:
- **Collaborative Planning** (`--plan`, optional `--planner <session>`, and `--plan-talk N`): Planner designs the solution, Creator challenges the plan for N turns to resolve edge cases, then implementation starts. `--planner` selects the planner session; omit it to auto-resolve the first running planner.
- **Creator Self-Planning & Development** (default without `--plan`): Planner 에이전트에게 계획 작성을 위임하지 않고, 기존에 승격된 계획서가 있다면 이를 로드하여 코드를 구현하며, 계획서가 존재하지 않는 경우 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행합니다.
- **Creator Self-Planning & Development** (default without `--plan`): Planner 에이전트에게 계획 작성을 위임하지 않고, 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행합니다. **어떤 계획서도 자동으로 로드되지 않습니다.**
- **Explicit Plan Reuse** (`--plan-file <path>`): 기존 계획서를 재사용하는 유일한 경로입니다. 경로는 제어 루트 기준이며, 읽을 수 없거나 내용이 빈 파일이면 잡을 하나도 만들기 전에 종료합니다(exit 1). 로드된 계획서는 **최초 구현 프롬프트 1회**에만 실립니다 — 리뷰 이후의 수정 라운드에는 리뷰 피드백만 전달됩니다. `--plan` 과는 함께 쓸 수 없습니다.
- **Targeted Peer-Review** (`--reviewer`): Runs custom-selected reviewer agents to verify code changes.
- **Total Peer-Review** (`--all-reviewer`): Enforces a unanimous PASS verdict from all registered reviewer sessions.
- **Rebuttal & Adjudication Protocol** (`--max-rebut N`): Allows Creator to reject invalid reviewer feedback using `[REBUT:]` tags and triggers Reviewer re-adjudication (`[ADJUDICATION:]`) or Planner arbitration (`[ARBITRATION:]`).
@@ -135,10 +136,10 @@ sequenceDiagram
Dev->>Plan: send critiques (Discussion)
Plan-->>Dev: update plan & reach consensus
end
else Use Existing Plan or Creator Self-Plan (No --plan)
alt Existing Plan Found
Loop->>Dev: notify task execution using existing plan
else No Plan Found
else Creator Self-Plan (No --plan)
alt Plan named with --plan-file
Loop->>Dev: notify task execution using the named plan
else No --plan-file
Loop->>Dev: request Creator self-planning and code execution
end
end
@@ -172,7 +173,7 @@ sequenceDiagram
1. **Planning Phase**:
- **Collaborative Planning (`--plan`)**: Planner가 프로젝트 구조를 파악하고 `implementation_plan.md`/`task.md`로 로드맵을 제공하며, Creator와의 피드백 루프를 통해 정제됩니다.
- **Creator Self-Planning (No `--plan`)**: Planner의 개입 없이, 기존 계획서가 있다면 이를 기반으로 하고, 그렇지 않다면 Creator가 독자적으로 설계 및 태스크 단위를 구상한 후 구현에 착수합니다.
- **Creator Self-Planning (No `--plan`)**: Planner의 개입 없이 Creator가 독자적으로 설계 및 태스크 단위를 구상한 후 구현에 착수합니다. `--plan-file <path>` 로 계획서를 명시한 경우에만 그 계획서를 기반으로 합니다.
2. **Execution Phase**: Creator가 배정된 태스크의 코드를 수정합니다. `--plan` 모드 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 Planner에게 계획 수정을 먼저 위임합니다. (Creator 자율 계획 모드에서는 Creator가 직접 설계를 변경하며 진행합니다.) 구현 완료 후 DoD(타입 매핑, 공유 자원 사이드 이펙트 방지, 문서-코드 정합성)를 자체 검증한 뒤 단일 커밋을 작성합니다.
3. **Review Phase**: Creator가 리뷰어 세션에 작업 완료 사실과 변경 범위(`git diff`)를 전달합니다. 리뷰어는 검증 후 리포트 **마지막에 단독 행**으로 판정을 남깁니다:
- **반려 (`[VERDICT: NOT PASS]`)** → 피드백 요약을 Planner에게 전송하여 상위 레벨 계획(Rev.n)을 개시합니다.
@@ -180,6 +181,14 @@ sequenceDiagram
4. 지정되거나 자동 수집된 리뷰어 전원이 PASS를 발행해야 완결되며, `--max-loop N`회 내에 도달하지 못하면 안전을 위해 루프를 중단합니다.
5. 완결 후에도 에이전트 세션은 종료하지 않고, 다음 태스크 지시가 있을 때까지 프롬프트 대기 상태(Standby)로 유지됩니다.
### 계획서가 비어 있을 때 (Empty plan reports)
계획서를 읽는 네 지점(`--plan-file`, `--plan` 초기 계획서, 논의 턴 refine, 리뷰 에스컬레이션 refine)은 같은 판정을 씁니다: **읽을 수 있는 일반 파일이고 공백을 제외한 내용이 있어야** 계획서입니다. 리포트 파일이 존재하기만 하면 통과시키던 과거 동작에서는 빈 리포트가 계획서를 조용히 지웠고, 프롬프트가 자기계획 문구로 바뀐 채 루프가 정상 종료(rc 0)했습니다.
- **`--plan-file` 이 비어 있으면**: 잡을 하나도 만들기 전에 `exit 1`.
- **`--plan` 의 초기 계획서가 비어 있으면**: `exit 1`. 계획 단계를 지불하고 아무것도 받지 못한 실행이 자기계획으로 격하되지 않습니다.
- **refine 결과가 비어 있으면**(논의 턴·리뷰 에스컬레이션 모두): 경고를 남기고 **이전 계획서를 유지**합니다. 덮어쓰지 않으므로, "갱신된 다음 계획서에 입각하여…" 로 시작하는 수정 프롬프트가 빈 계획서를 싣는 일이 없습니다.
---
## CLI Option ↔ Workflow Phase Mapping
@@ -188,7 +197,8 @@ sequenceDiagram
| 워크플로우 단계 | 해당 CLI 옵션 | 설명 |
| :--- | :--- | :--- |
| **Phase 1: Planning** | `--plan` | Planner 에이전트를 기동하여 최초 계획 작성을 강제합니다. (옵션을 지정하지 않을 경우 새 계획서 작성을 생략하며, 기존 계획서가 있는 경우 이를 로드하고, 없는 경우 Creator가 직접 계획 및 설계를 수립하여 즉시 구현에 착수합니다.) |
| **Phase 1: Planning** | `--plan` | Planner 에이전트를 기동하여 최초 계획 작성을 강제합니다. (옵션을 지정하지 않을 경우 새 계획서 작성을 생략하고, Creator가 직접 계획 및 설계를 수립하여 즉시 구현에 착수합니다.) |
| **Phase 1: Plan reuse** | `--plan-file <path>` | 기존 계획서를 재사용합니다. 제어 루트 기준 경로이며 **`--plan` 과 상호 배타**입니다. 지정하지 않으면 어떤 계획서도 로드되지 않습니다. |
| **Phase 1: Planner session** | `--planner <name>` | 계획 단계를 수행할 세션을 명시합니다. **`--plan`과 함께만** 사용합니다. 생략 시 running planner를 자동 탐색합니다. |
| **Phase 1: Debate** | `--plan-talk N` | Planner와 Creator가 상호 대화식 챌린지 루프를 `N`회 돌며 계획을 교차 정제합니다. |
| **Phase 2: Execution** | `--creator <name>` | 코드를 구현할 Creator 세션에 코딩 태스크를 주입합니다. (필수) |
@@ -222,7 +232,7 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
## Workflow
```bash
# 1. Creator Self-Planning & Development + Self-review (direct task execution using existing promoted plan or Creator's own self-plan)
# 1. Creator Self-Planning & Development + Self-review (no plan is loaded; add --plan-file <path> to reuse one)
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--creator "<creator-session-name>" \
--task "Fix typo in deploy/README.md"
@@ -32,6 +32,7 @@ TARGET_AGENT=""
TASK=""
REVIEWER_LIST=""
PLANNER_SESSION_OVERRIDE=""
LOOP_PLAN_FILE=""
LOOP_WORKTREE=""
LOOP_BASE=""
LOOP_REVIEW_ONLY=0
@@ -46,6 +47,7 @@ usage() {
echo " --plan Enable Planner agent intervention & design phase"
echo " --planner <name> Planner session (requires --plan; default: auto-resolve)"
echo " --plan-talk N Planner-Creator discussion limit turns (default: 1)"
echo " --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)"
echo " --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)"
echo " --all-reviewer Enforce PASS verdict from all active reviewer sessions"
echo " --max-loop N Max execution-review corrective loop runs (default: 3)"
@@ -92,6 +94,7 @@ while [[ "$#" -gt 0 ]]; do
exit 1
;;
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
--plan-file) LOOP_PLAN_FILE="$2"; shift 2 ;;
--task) TASK="$2"; shift 2 ;;
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
--base) LOOP_BASE="$2"; shift 2 ;;
@@ -121,11 +124,14 @@ fi
# Issue #21 (D12): the legacy shell loop assumes a cwd-relative .mam/jobs and
# diffs the checkout it runs in; the worktree/base options are Python-only.
# Issue #25 (D9): --plan-file is too - its fail-closed check lives in the
# runner and is not duplicated in a path that is on its way out.
# Checked before the freeze and the loop lock (log_* are not defined yet).
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ]; }; then
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review require the Python loop (MAM_LOOP_PY=1)" >&2
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ] \
|| [ -n "$LOOP_PLAN_FILE" ]; }; then
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)" >&2
exit 1
fi
@@ -560,16 +566,11 @@ if [ "$PLAN_MODE" = true ]; then
log_success "Interactive planning completed. Plan finalized."
else
log_info "=== Phase 1: Self-Planning Mode (Direct Execution) ==="
EXISTING_PLAN_FILE=""
if [ -n "$PLANNER_SESSION" ]; then
# Promoted plans are saved as plan-<job-id>.md by the Phase 3 promotion
# step below, not report-final.md; pick the most recently promoted one.
EXISTING_PLAN_FILE=$(ls -t ".agents/reports/$PLANNER_SESSION"/plan-*.md 2>/dev/null | head -n 1 || true)
fi
if [ -n "$EXISTING_PLAN_FILE" ] && [ -f "$EXISTING_PLAN_FILE" ]; then
log_info "Found existing promoted plan at '$EXISTING_PLAN_FILE'. Loading plan..."
CURRENT_PLAN=$(cat "$EXISTING_PLAN_FILE")
fi
# Issue #25 (D1/D9): no plan is loaded here. This used to take the newest
# promoted plan of the auto-resolved planner, keyed on mtime and on a session
# that says nothing about this task. Reuse is explicit and Python-only now
# (--plan-file), and this path rejects that option above.
log_info "No plan loaded; the Creator plans by itself."
fi
# ===========================================================================
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.7.0
version: 4.7.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md)
[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md)
[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.7.0
Applies to: MAM v4.7.1
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md)
[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.7.0](https://img.shields.io/badge/version-v4.7.0-blue.svg)](VERSIONS.md)
[![Version: v4.7.1](https://img.shields.io/badge/version-v4.7.1-blue.svg)](VERSIONS.md)
---
+27 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.7.0`
- **최신 릴리스 일시**: 2026-09-20 (KST)
- **프레임워크 버전**: `v4.7.1`
- **최신 릴리스 일시**: 2026-09-22 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -22,23 +22,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.0`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.1`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.7.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.7.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.7.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.7.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.7.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.7.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.7.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.7.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.7.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.7.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.7.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.7.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.7.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.7.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.7.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.7.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.7.1` — Issue #25 승격된 계획서 재사용은 `--plan-file` 로만: 암묵적 자동 로드 제거, 빈 계획 리포트는 조용히 넘어가지 않음 (2026-09-22, PR #32)
> **패치 마일스톤 (PATCH Release)**: `run_loop.sh` 를 `--plan` 없이 실행하면 러너가 planner 를 자동으로 고르고 `.agents/reports/<planner>/plan-*.md` 중 mtime 이 가장 최근인 파일을 "이미 수립된 계획서"로 creator 에게 넘기던 문제(Issue #25)를 고쳤다. 저장소에는 다른 이슈의 계획서가 수십 개 커밋돼 있고 체크아웃 직후의 mtime 은 의미가 없어, 무관한 계획이 구현 프롬프트에 실렸다(v4.7.0 병합 루프에서도 Issue #6 계획서가 실린 것을 관측). 옵션이 하나 늘지만 잘못된 기본 동작을 없애는 수정이라 PATCH 로 올린다.
#### 🌟 주요 개선 사항
1. **암묵적 계획 로드 제거 (`lib_py/loop/runner.py`, `multi-agent-mux-loop/scripts/run_loop.sh`)**: `--plan` 없는 실행은 어떤 계획서도 읽지 않는다. Python 러너와 레거시 셸 러너(`MAM_LOOP_PY=0`) 모두에서 mtime 기반 선택을 지웠고, creator 는 스스로 계획한다. 체크포인트의 `current_plan` 은 쓰기 전용이라 옛 계획을 되살리는 경로가 없다.
2. **`--plan-file <path>` (Python 러너 전용)**: 승격된 계획서의 재사용은 파일을 명시할 때만 한다. 상대경로는 **제어 루트 기준**(워크트리에서 돌려도 같음)이고, 잡이 하나도 등록되기 전에 읽어 검사한다(없는 파일·디렉터리·빈 파일·읽기 실패는 exit 1). `--plan` 과 함께 쓸 수 없고, `--review-only` 에서는 경고 후 무시한다. 레거시 셸 러너는 이 옵션을 루프 락 전에 명시적으로 거부한다.
3. **계획 텍스트 검사 하나를 모든 로드 경로에 (`LoopRunner._plan_text`)**: planner 의 첫 리포트가 비어 있으면 루프를 멈춘다(계획 단계에 비용을 쓰고도 조용히 자율 계획으로 강등되던 문제). 토론(refine)·재계획 리포트가 비어 있으면 경고하고 기존 계획을 유지한다.
4. **문서**: loop SKILL.md 의 옵션 표·Workflow·"계획서가 비어 있을 때" 절.
#### ✅ 품질 검증
- **신규 테스트 17개** (`tests/test_issue25_plan_reuse.py`, P1–P11): 옵션 없는 실행이 승격된 계획서를 읽지 않음(Python·셸), `--plan-file` 의 제어 루트 기준 해석·실패 경로·`--plan`/`--review-only` 조합·셸 러너의 거부, 빈 planner 리포트의 중단, 빈 refine 의 기존 계획 유지. 병합 워크스페이스 리뷰에서 17건 중 13건이 수정 전 소스에서 실패함을 확인.
- **리뷰**: 작성 워크스페이스 루프(`--worktree --base --review-only`) 1회차 @5fa5923 claude·agy·opencode PASS, grok·codex NOT PASS(상대경로 `--plan-file` 이 실행 cwd 기준으로 해석) → 1e82651 로 정정, 2회차 5인 전원 PASS. 병합 워크스페이스에서 PR head 를 detached 워크트리에 받아 같은 방식으로 재검토: @1e82651 claude·agy·grok·codex 4인 전원 PASS(1회차, identity 4건 모두 base 6fc9194 → head 1e82651, evidence review-tree). 리포트와 identity 사이드카는 `.agents/reports/<session>/`.
- **전체 스위트 (병합 워크스페이스, 자체 venv 가 있는 워크트리 @1e82651)**: 1037 통과 / 1 skip / 실패 0 (35분 26초, 리뷰 루프와 병행).
- **비차단 후속**: 레거시 셸 러너의 plan 모드 읽기에는 빈 계획서 검사가 없음(E1), `--plan-file` 실행에서 `[ESCALATE: PLANNER]` 가 재계획으로 이어지지 않음(E2, 기존 게이트), 리뷰 격리 워크트리에 `.venv` 가 없어 `run_loop.sh` 를 하위 프로세스로 띄우는 테스트는 `AGENT_PYTHON_BIN` 없이는 실패(E3, #21 항목 5 운영 후속), Phase 1 의 `getmtime` 경합(E4), 공백이 든 경로 전용 테스트 없음.
### 🚀 `v4.7.0` — Issue #21 워크트리 운영 모델 2단계: 워크트리 헬퍼, 리뷰 identity, 수용 사례 A1–A8, #20 테스트 격리, #27, #28 호스트 이식성 (2026-09-20, PR #29)
> **마이너 마일스톤 (MINOR Release)**: 워크트리 운영 모델(#21, `docs/design/worktree-creator-model.md`)의 작업 항목 4·5·7 을 구현했다. 이슈별 워크트리를 만들고 지우는 헬퍼 2개, 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶는 리뷰 identity 와 루프 옵션 2개(`--review-isolation`, `--remote-review`), 수용 사례 8건이 추가되어 MINOR 로 올린다. 항목 8(다중 루프·리뷰어 스케줄러)은 보류로 남아 #21 은 열어 둔다. 함께 #20(테스트가 실제 `.mam` 을 오염), #27(`.gitignore` 관리 블록의 `.venv`), #28(Linux/py3.14 호스트의 테스트 실패 33건)을 닫는다.
+374
View File
@@ -0,0 +1,374 @@
"""Issue #25: a promoted plan is reused only when it is named, and an empty
plan report is never silent.
Without --plan the runner used to glob the auto-resolved planner's report
directory for plan-*.md and feed the newest one to the Creator as "the plan
already agreed on". Both keys are wrong: the directory belongs to a session,
not to this task, and mtime is checkout order. 47 unrelated plans are committed
in this repository (P1).
The same review also found that the three --plan-mode reads had no content
check at all, so an empty planner or refine report silently blanked the plan
and switched the Creator's prompt (P9-P11).
Every test writes only under tmp_path (Issue #20).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNNER_PY = REPO_ROOT / ".agents" / "skills" / "lib_py" / "loop" / "runner.py"
RUN_LOOP_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
PLANNER = "planner-claude-01"
CREATOR = "creator-claude-01"
REVIEWER = "reviewer-claude-01"
OLD_PLAN = "# plan for a completely different issue\n\nOLD-PLAN-MARKER\n"
NEW_PLAN = "# the plan I actually mean\n\nNAMED-PLAN-MARKER\n"
PASS_REPORT = "looks fine\n\n[VERDICT: PASS]\n"
FAIL_REPORT = "line 3 is wrong\n\n[VERDICT: NOT PASS]\n"
FAIL_ESCALATE = "needs a redesign\n\n[ESCALATE: PLANNER]\n\n[VERDICT: NOT PASS]\n"
SELF_PLAN_PROMPT = "계획서가 존재하지 않으므로"
EXISTING_PLAN_PROMPT = "이미 수립된"
CORRECTION_WITH_PLAN = "갱신된 다음 계획서에 입각하여"
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True,
check=True, env={**os.environ, **GIT_ID}).stdout.strip()
@pytest.fixture
def ctl(tmp_path, monkeypatch):
"""A control root whose planner report directory already holds an unrelated
promoted plan - the condition that used to inject it (F1/F3)."""
main = tmp_path / "main"
main.mkdir()
_git(main, "init", "-q")
(main / "a.txt").write_text("a\n")
_git(main, "add", "a.txt")
_git(main, "commit", "-q", "-m", "init")
stale = main / ".agents" / "reports" / PLANNER / "plan-OLD.md"
stale.parent.mkdir(parents=True)
stale.write_text(OLD_PLAN, encoding="utf-8")
os.utime(stale, (2_000_000_000, 2_000_000_000)) # newest by mtime, by a mile
for k, v in GIT_ID.items():
monkeypatch.setenv(k, v)
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
monkeypatch.setenv("MAM_JOBS_DIR", str(main / ".mam" / "jobs"))
monkeypatch.delenv("MAM_LOOP_WORKTREE", raising=False)
monkeypatch.delenv("MAM_LOOP_BASE", raising=False)
# the planner row is what makes the old glob fire at all (F7)
monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": [
{"name": CREATOR, "agent": "claude", "role": "creator", "status": "running"},
{"name": REVIEWER, "agent": "claude", "role": "reviewer", "status": "running"},
{"name": PLANNER, "agent": "claude", "role": "planner", "status": "running"},
]}))
return main
def _runner(*extra):
from lib_py.loop import runner as lr
return lr.LoopRunner(["--creator", CREATOR, "--task", "t25", *extra])
def _drive(monkeypatch, tmp_path, argv, reports=None, on_worker=None, max_loop="1"):
"""run() end to end with the delegation stubbed.
`reports` maps (role, nth-call-of-that-role) to the report body; a body of
None means the report file is missing. Everything else gets a PASS for a
Reviewer and a one-line report otherwise. The existing _drive helpers hand
every job the same non-empty PASS report, which cannot reach the empty-plan
paths at all (F13).
Returns (rc, [(role, prompt)], runner).
"""
from lib_py.loop import runner as lr
monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None)
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25",
"--reviewer", REVIEWER, "--max-loop", max_loop, *argv])
reports = reports or {}
calls, files, nth = [], {}, {}
stub_dir = tmp_path / "stub-reports"
stub_dir.mkdir(exist_ok=True)
def fake_delegate(session, agent_type, role, prompt):
calls.append((role, prompt))
jid = f"j{len(calls)}"
n = nth[role] = nth.get(role, 0) + 1
body = reports.get((role, n), PASS_REPORT if role == "Reviewer" else "done\n")
if body is None:
files[jid] = None
else:
f = stub_dir / f"{jid}.md"
f.write_text(body, encoding="utf-8")
files[jid] = f
if role == "Worker" and on_worker:
on_worker(r)
return jid
monkeypatch.setattr(r, "delegate_job", fake_delegate)
monkeypatch.setattr(r, "wait_for_job", lambda *a, **k: True)
monkeypatch.setattr(r, "find_report", lambda jid: files.get(jid))
return r.run(), calls, r
def _roles(calls, role):
return [p for (ro, p) in calls if ro == role]
# ---------------------------------------------------------------------------
# P1-P2 — reuse is explicit
# ---------------------------------------------------------------------------
def test_p1_no_options_does_not_inject_an_unrelated_plan(ctl, monkeypatch, tmp_path, capsys):
"""The #25 reproduction: a running planner plus a promoted plan in its
report directory must not reach the Creator. Fails before the fix."""
rc, calls, _ = _drive(monkeypatch, tmp_path, [])
assert rc == 0
worker = _roles(calls, "Worker")[0]
assert worker.startswith(SELF_PLAN_PROMPT)
assert "OLD-PLAN-MARKER" not in worker
assert EXISTING_PLAN_PROMPT not in worker
out = capsys.readouterr().out
assert "Found existing promoted plan" not in out
assert "No plan loaded" in out
def test_p2_plan_file_is_the_only_plan_and_lives_one_round(ctl, monkeypatch, tmp_path):
"""The named plan is used, the newer unrelated one is not, and the plan is
not repeated in the correction round (N1/D16 - the behaviour the implicit
load had too)."""
named = tmp_path / "my-plan.md"
named.write_text(NEW_PLAN, encoding="utf-8")
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan-file", str(named)],
reports={("Reviewer", 1): FAIL_REPORT}, max_loop="2")
assert rc == 0
workers = _roles(calls, "Worker")
assert workers[0].startswith(EXISTING_PLAN_PROMPT)
assert "NAMED-PLAN-MARKER" in workers[0]
assert "OLD-PLAN-MARKER" not in workers[0]
assert len(workers) == 2, "a correction round ran"
assert "NAMED-PLAN-MARKER" not in workers[1]
assert workers[1].startswith("리뷰어들이 지적한 다음 피드백")
# ---------------------------------------------------------------------------
# P3-P5 — the option is checked before any job exists
# ---------------------------------------------------------------------------
def test_p3_unusable_plan_files_are_refused_before_any_job(ctl, tmp_path, monkeypatch):
empty, ws, sub = tmp_path / "empty.md", tmp_path / "ws.md", tmp_path / "sub"
empty.write_text("")
ws.write_text(" \n\t\n")
sub.mkdir()
cases = [tmp_path / "nope.md", sub, empty, ws]
if os.getuid() != 0:
locked = tmp_path / "locked.md"
locked.write_text(NEW_PLAN, encoding="utf-8")
locked.chmod(0o000)
cases.append(locked)
from lib_py.loop import runner as lr
for path in cases:
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25", "--plan-file", str(path)])
monkeypatch.setattr(r, "delegate_job", lambda *a, **k: pytest.fail("a job was delegated"))
with pytest.raises(SystemExit) as e:
r.parse_args()
assert e.value.code == 1, path
def test_p3b_the_refusal_names_the_resolved_path(ctl, tmp_path, capsys):
missing = tmp_path / "nope.md"
with pytest.raises(SystemExit):
_runner("--plan-file", str(missing)).parse_args()
assert str(missing.resolve()) in capsys.readouterr().err
def test_p3c_a_relative_plan_file_is_taken_against_the_control_root(ctl, tmp_path, monkeypatch):
"""R1/N1: the usage line promises control-root-relative, and the runner
never chdirs to the control root. Resolving against the caller's cwd looked
somewhere else - and a same-named file there won silently, which is the
very failure #25 is about."""
(ctl / "plans").mkdir()
(ctl / "plans" / "named.md").write_text(NEW_PLAN, encoding="utf-8")
caller = tmp_path / "caller"
(caller / "plans").mkdir(parents=True)
monkeypatch.chdir(caller)
r = _runner("--plan-file", "plans/named.md")
r.parse_args()
assert Path(r.plan_file) == (ctl / "plans" / "named.md").resolve()
assert "NAMED-PLAN-MARKER" in r.current_plan
# the same name in the caller's cwd must not win
(caller / "plans" / "named.md").write_text(OLD_PLAN, encoding="utf-8")
r = _runner("--plan-file", "plans/named.md")
r.parse_args()
assert Path(r.plan_file) == (ctl / "plans" / "named.md").resolve()
assert "NAMED-PLAN-MARKER" in r.current_plan
assert "OLD-PLAN-MARKER" not in r.current_plan
def test_p3d_an_absolute_plan_file_is_left_alone(ctl, tmp_path, monkeypatch):
"""Anchoring the relative case must not rewrite absolute or ~ paths."""
named = tmp_path / "abs-plan.md"
named.write_text(NEW_PLAN, encoding="utf-8")
monkeypatch.chdir(tmp_path)
r = _runner("--plan-file", str(named))
r.parse_args()
assert Path(r.plan_file) == named.resolve()
monkeypatch.setenv("HOME", str(tmp_path))
home_plan = tmp_path / "home-plan.md"
home_plan.write_text(NEW_PLAN, encoding="utf-8")
r = _runner("--plan-file", "~/home-plan.md")
r.parse_args()
assert Path(r.plan_file) == home_plan.resolve()
def test_p4_plan_file_conflicts_with_plan_and_needs_a_value(ctl, tmp_path, monkeypatch):
named = tmp_path / "my-plan.md"
named.write_text(NEW_PLAN, encoding="utf-8")
from lib_py.loop import runner as lr
for argv in (["--plan-file", str(named), "--plan"], ["--plan", "--plan-file", str(named)],
["--plan-file"]):
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25", *argv])
monkeypatch.setattr(r, "delegate_job", lambda *a, **k: pytest.fail("a job was delegated"))
with pytest.raises(SystemExit) as e:
r.parse_args()
assert e.value.code == 1, argv
def _reviewable_worktree(ctl, tmp_path):
"""A linked worktree with one commit to review. The source root has to be
separate from the control root here: --base turns review identity on, and
the loop's own checkpoint under the control root's .mam would otherwise
count as the source changing mid-review."""
wt = tmp_path / "wt"
_git(ctl, "worktree", "add", "-q", "-b", "feat", str(wt))
base = _git(wt, "rev-parse", "HEAD")
(wt / "a.txt").write_text("a\nimplemented\n")
_git(wt, "commit", "-qam", "impl")
return ["--worktree", str(wt), "--base", base, "--review-only"]
def test_p5_plan_file_is_warned_about_and_ignored_with_review_only(ctl, monkeypatch, tmp_path, capsys):
named = tmp_path / "my-plan.md"
named.write_text(NEW_PLAN, encoding="utf-8")
rc, calls, r = _drive(monkeypatch, tmp_path,
["--plan-file", str(named), *_reviewable_worktree(ctl, tmp_path)])
assert rc == 0
assert _roles(calls, "Worker") == [], "Phase 2 is skipped"
assert "NAMED-PLAN-MARKER" not in _roles(calls, "Reviewer")[0]
assert r.current_plan == ""
assert "--plan-file is ignored with --review-only" in capsys.readouterr().out
def test_p5b_the_ignore_warning_needs_the_option(ctl, monkeypatch, tmp_path, capsys):
"""The warning is keyed on the option being given, not on a default value -
unlike the --plan-talk warning it sits next to (N2, runner.py:246)."""
rc, _, _ = _drive(monkeypatch, tmp_path, _reviewable_worktree(ctl, tmp_path))
assert rc == 0
assert "is ignored with --review-only" not in capsys.readouterr().out
# ---------------------------------------------------------------------------
# P6, P9-P11 — --plan mode: compatible, and never silently planless
# ---------------------------------------------------------------------------
def test_p6_plan_mode_still_plans_and_promotes(ctl, monkeypatch, tmp_path):
rc, calls, r = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
reports={("Planner", 1): NEW_PLAN})
assert rc == 0
worker = _roles(calls, "Worker")[0]
assert worker.startswith(EXISTING_PLAN_PROMPT)
assert "NAMED-PLAN-MARKER" in worker
promoted = ctl / ".agents" / "reports" / PLANNER / f"plan-{r.plan_job_id}.md"
assert promoted.is_file() and "NAMED-PLAN-MARKER" in promoted.read_text()
@pytest.mark.parametrize("body", ["", " \n\t\n"], ids=["empty", "whitespace"])
def test_p9_an_empty_initial_plan_stops_the_run(ctl, monkeypatch, tmp_path, capsys, body):
"""D13: paying for a planning phase and getting nothing back must not be a
quiet demotion to self-planning."""
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
reports={("Planner", 1): body})
assert rc == 1
assert _roles(calls, "Worker") == [], "no implementation was delegated"
assert "has no plan text" in capsys.readouterr().err
def test_p10_an_empty_refine_keeps_the_previous_plan(ctl, monkeypatch, tmp_path, capsys):
"""D14: the discussion turn must not blank a plan that is still good."""
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "1"],
reports={("Planner", 1): NEW_PLAN, ("Planner", 2): ""})
assert rc == 0
worker = _roles(calls, "Worker")[1] # [0] is the Creator's challenge turn
assert worker.startswith(EXISTING_PLAN_PROMPT)
assert "NAMED-PLAN-MARKER" in worker
assert "keeping the previous plan" in capsys.readouterr().out
def test_p11_an_empty_escalation_refine_never_claims_an_empty_plan(ctl, monkeypatch, tmp_path, capsys):
"""D14/D15: the correction prompt asserts "follow the updated plan below",
so the plan it carries must never be blank."""
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
reports={("Planner", 1): NEW_PLAN, ("Planner", 2): "",
("Reviewer", 1): FAIL_ESCALATE},
max_loop="2")
assert rc == 0
correction = _roles(calls, "Worker")[1]
assert CORRECTION_WITH_PLAN in correction
assert "NAMED-PLAN-MARKER" in correction, "the prompt claims a plan; it must carry one"
assert "keeping the previous plan" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# P7-P8 — wrapper parity and the legacy shell path
# ---------------------------------------------------------------------------
def test_p7_wrapper_and_runner_agree_on_the_option(tmp_path):
line = " --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)"
sh = subprocess.run(["bash", str(RUN_LOOP_SH), "--help"], capture_output=True, text=True)
assert line in sh.stdout.splitlines()
code = ("import sys; sys.path.insert(0, %r)\n"
"from lib_py.loop import runner as lr\n"
"lr.LoopRunner([]).print_usage()\n" % str(REPO_ROOT / ".agents" / "skills"))
py = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
assert line in py.stdout.splitlines()
def test_p7b_the_legacy_shell_loop_refuses_the_option(tmp_path):
marker = tmp_path / "loop.marker"
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", CREATOR, "--task", "t25",
"--plan-file", str(tmp_path / "x.md")],
capture_output=True, text=True, cwd=str(tmp_path),
env={**os.environ, "MAM_LOOP_PY": "0", "MAM_LOOP_MARKER": str(marker)})
assert res.returncode == 1
assert "--plan-file" in res.stderr
assert not marker.exists(), "refused before the loop lock"
def test_p8_neither_loop_implementation_globs_for_a_plan_any_more():
for path in (RUN_LOOP_SH, RUNNER_PY):
assert "plan-*.md" not in path.read_text(encoding="utf-8"), path