diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index 1874fdf..94bc0f3 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -27,6 +27,10 @@ from typing import Any, Dict, List, Optional, Set, Tuple from lib_py.loop import checkpoint, eager, parser +UNAVAILABLE_MARKER = "!!! CHANGE SET UNAVAILABLE !!!" # diff_collect.sh MAM_DIFF_UNAVAILABLE_MARKER +SELF_REVIEW_PROMPT = ("작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. " + "리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요.") + # ANSI colors matching run_loop.sh _COLOR_INFO = "\033[1;34m[*]\033[0m" _COLOR_SUCCESS = "\033[1;32m[✓]\033[0m" @@ -531,6 +535,11 @@ class LoopRunner: res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src) if res.returncode == 0: return res.stdout + if head_commit: + # the snapshot's evidence is not replaced by anything else (B13): + # the collector's UNAVAILABLE output is passed on as it is + return res.stdout if UNAVAILABLE_MARKER in res.stdout else ( + f"{UNAVAILABLE_MARKER}\nreason: diff collector failed (rc {res.returncode})\n") # Fallback to direct git diff try: if head_commit: @@ -538,6 +547,8 @@ class LoopRunner: else: cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"] res = subprocess.run(cmd, capture_output=True, text=True, cwd=src) + if head_commit and res.returncode != 0: + return f"{UNAVAILABLE_MARKER}\nreason: git diff failed (rc {res.returncode})\n" return res.stdout except Exception: return "" @@ -550,9 +561,11 @@ class LoopRunner: def _context_active(self) -> bool: return self.worktree_given or self.base_ref is not None - def _worktree_context(self, remote: bool = False) -> str: + def _worktree_context(self, remote: bool = False, purpose: str = "work") -> str: """The source-root block put in front of every delegated prompt (D6); - empty without --worktree/--base, so prompts stay byte-identical.""" + empty without --worktree/--base, so prompts stay byte-identical. + purpose: 'work' (creator/planner), 'review' (a verdict job, whose + target is the [리뷰 identity] path) or 'adjudication' (a re-hearing).""" if not self._context_active(): return "" src = self.worktree_root @@ -570,8 +583,21 @@ class LoopRunner: # a remote reviewer (diff-only) cannot reach this host's paths (B6) src_line = ("- 소스 경로: (오케스트레이터 호스트 전용 — 원격 리뷰어는 접근할 수 없음)\n" if remote else f"- 소스 경로: {src}\n") - tail = ("이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n" if remote else - "이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n") + if purpose == "review" and remote: + tail = "이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n" + elif purpose == "review" and self.review_isolation == "worktree": + # one verification target per prompt (loop B review B-2) + tail = ("리뷰 검증은 아래 [리뷰 identity] 의 검증 경로에서만 한다. 소스 경로는 creator 의 작업 트리이며 " + "리뷰어는 거기서 실행·수정하지 않는다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n") + elif purpose == "review": + tail = ("리뷰 검증은 아래 [리뷰 identity] 의 검증 경로(= 위 소스 경로)에서 하며, 거기서 수정·커밋하지 않는다. " + "브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n") + elif purpose == "adjudication" and remote: + tail = "이 잡은 아래 반론의 재심이다. 판정 근거는 직전 리뷰에서 받은 SHA·diff 와 아래 반론 본문이다.\n\n" + elif remote: + tail = "이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n" + else: + tail = "이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n" return ("[워크트리 컨텍스트]\n" + src_line + f"- 브랜치: {branch}\n" @@ -581,9 +607,9 @@ class LoopRunner: + tail) def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str, - remote: bool = False) -> Optional[str]: + remote: bool = False, purpose: str = "work") -> Optional[str]: """delegate_job with the source-root context block in front of the prompt.""" - return self.delegate_job(agent_session, agent_type, role, self._worktree_context(remote) + prompt) + return self.delegate_job(agent_session, agent_type, role, self._worktree_context(remote, purpose) + prompt) def _change_set_state(self) -> str: """'empty' | 'present' | 'unknown' for the source root against base, @@ -653,7 +679,17 @@ class LoopRunner: def _worktree_tree(self) -> Optional[str]: """The source worktree's state as a tree, through a temporary index - (the real index, status and refs are untouched); None on failure.""" + (the real index, status and refs are untouched); None on failure. + A dirty submodule is a failure: the tree records only its gitlink, so + its uncommitted content would be neither reviewed nor pinned.""" + dirty_subs = self._dirty_submodules() + if dirty_subs is None: + log_error(f"review snapshot: cannot check the submodules of {self.worktree_root}") + return None + if dirty_subs: + log_error(f"review snapshot: submodule(s) with uncommitted or untracked content: {' '.join(dirty_subs)}; " + "the snapshot records only their commit - commit or clean them first") + return None fd, idx = tempfile.mkstemp(prefix="mam-review-index-") os.close(fd) os.unlink(idx) @@ -673,6 +709,18 @@ class LoopRunner: except OSError: pass + def _dirty_submodules(self) -> Optional[List[str]]: + """Checked-out submodules (recursively) with uncommitted or untracked + content; None when the check itself fails. Each submodule is asked + directly with --ignore-submodules=none, so no submodule.*.ignore or + diff.ignoreSubmodules setting can hide it.""" + script = ('s=$(git status --porcelain --ignore-submodules=none) || exit 1; ' + '[ -z "$s" ] || printf "%s\\n" "$displaypath"') + r = self._git(self.worktree_root, "submodule", "foreach", "--recursive", "--quiet", script) + if r.returncode != 0: + return None + return [line for line in r.stdout.splitlines() if line.strip()] + def _gitlinks(self, treeish: str) -> Optional[Set[str]]: r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish) if r.returncode != 0: @@ -735,11 +783,18 @@ class LoopRunner: def _close_review_tree(self, path: Optional[str]) -> None: if not path or path not in self._review_paths: return - self._git(self.worktree_root, "worktree", "remove", "--force", path) - self._git(self.worktree_root, "worktree", "prune") + r = self._git(self.worktree_root, "worktree", "remove", "--force", path) if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) + if r.returncode != 0: + # only when remove failed: prune drops this entry (its directory is + # gone now) - and any other stale one, which is why it is not routine + self._git(self.worktree_root, "worktree", "prune") self._review_paths.remove(path) + try: + os.rmdir(os.path.dirname(path)) # TMPDIR/mam-review/, once empty + except OSError: + pass def _close_all_review_trees(self) -> None: for p in list(self._review_paths): @@ -1003,6 +1058,13 @@ class LoopRunner: final_passed_reviewers: Dict[str, Tuple[str, Path]] = {} identity = self._context_active() + # Identity mode: a creator self-review is a verdict like any other, so it + # takes the reviewer path - snapshot diff, evidence check, B10 re-check + # and sidecar (loop B review B-R2/B-R3). A NOT PASS re-reviews, as before. + self_review = identity and not reviewers + if self_review: + log_warn("No reviewers specified. Conducting Creator Self-Review...") + reviewers = [self.creator] for loop_count in range(1, self.max_loop + 1): log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...") @@ -1029,29 +1091,13 @@ class LoopRunner: if not reviewers: # Creator Self-Review log_warn("No reviewers specified. Conducting Creator Self-Review...") - sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요." - sr_remote = self.creator in self._remote_recipients - if identity: - sr_prompt = self._identity_block(snap, review_path, sr_remote) + sr_prompt - sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt, remote=sr_remote) + sr_prompt = SELF_REVIEW_PROMPT + sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt) if not sr_jid: log_error("Failed to register Self-Review job.") return 1 self.wait_for_job(sr_jid) sr_report = self.find_report(sr_jid) - if identity: - if self.review_isolation == "worktree": - self._close_review_tree(review_path) - sr_stale = self._round_is_stale(snap) - sr_verdict = "PASS" if (sr_report and parser.has_verdict(sr_report, "PASS")) else "NOT PASS" - self._record_round(loop_count, snap, review_path, {self.creator: sr_jid}, - {self.creator: sr_verdict}, sr_stale) - if sr_stale: - log_warn("source changed during review; the self-review is discarded, re-reviewing") - if loop_count == self.max_loop: - log_error("the source changed during review in the last iteration; no verdict to accept") - return 1 - continue if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"): log_success("Self-Review PASS.") break @@ -1077,11 +1123,15 @@ class LoopRunner: for rev in reviewers: log_info(f"Requesting code review from Reviewer '{rev}'...") r_type = self.resolve_agent_type(rev, state) - r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}" + if self_review: + r_prompt = f"{SELF_REVIEW_PROMPT} 변경분:\n{changes_diff}" + else: + r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}" r_remote = rev in self._remote_recipients if identity: r_prompt = self._identity_block(snap, review_path, r_remote) + r_prompt - r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt, remote=r_remote) + r_jid = self._dispatch(rev, r_type, "Reviewer", r_prompt, remote=r_remote, + purpose="review" if identity else "work") if not r_jid: log_error(f"Failed to register review job for '{rev}'.") return 1 @@ -1143,6 +1193,13 @@ class LoopRunner: log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.") break + if self_review: + log_warn("Self-Review NOT PASS.") + if loop_count == self.max_loop: + log_error("Reached max loop count. Self-review loop aborted with failures.") + return 1 + continue + if loop_count == self.max_loop: log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.") return 1 @@ -1198,7 +1255,7 @@ class LoopRunner: rt_type = self.resolve_agent_type(rt, state) adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}" adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt, - remote=rt in self._remote_recipients) + remote=rt in self._remote_recipients, purpose="adjudication") if not adj_jid or not self.wait_for_job(adj_jid): log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.") overruled_revs.append(rt) diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 03b9702..e13c2b3 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -200,12 +200,12 @@ sequenceDiagram | **Source root** | `--worktree ` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). | | **Diff base** | `--base ` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. | | **Review only** | `--review-only` | Phase 2(구현 위임)를 건너뛰고 `..HEAD` 를 리뷰합니다. `--base` 필수, `--plan` 과 함께 쓸 수 없습니다. 수정·순응 잡은 여전히 creator 에게 갑니다. | -| **Review isolation** | `--review-isolation worktree\|env` | `--worktree`/`--base` 에서만 의미. `worktree`(기본): 라운드마다 스냅샷의 detached 리뷰 워크트리(TMPDIR)에서 검증합니다. `env`: 소스 루트에서 `PYTHONDONTWRITEBYTECODE=1 … -p no:cacheprovider --basetemp=<밖>` 지침으로 검증합니다. 리뷰 워크트리를 열 수 없으면 조용히 낮추지 않고 exit 1. | +| **Review isolation** | `--review-isolation worktree\|env` | `--worktree`/`--base` 에서만 의미. `worktree`(기본): 라운드마다 스냅샷의 detached 리뷰 워크트리(TMPDIR)에서 검증합니다. `env`: 소스 루트에서 `PYTHONDONTWRITEBYTECODE=1 … -p no:cacheprovider --basetemp=<밖>` 지침으로 검증합니다. 리뷰 워크트리를 열 수 없으면 조용히 낮추지 않고 exit 1. `env` 에서 리뷰어가 ignore 되지 않는 산출물을 소스 루트에 남기면 라운드가 stale 로 판정돼 재리뷰가 반복됩니다(fail-closed) — 산출물은 `--basetemp` 등으로 소스 밖에 둡니다. | | **Remote reviewers** | `--remote-review refuse\|diff-only` | `--worktree`/`--base` 에서만 의미. 원격 리뷰어(`transport: remote`)는 이 호스트의 리뷰 트리에 닿을 수 없으므로 기본으로 **어떤 잡도 위임하기 전에** 거부(exit 1)합니다. `diff-only` 는 스냅샷 SHA 와 diff 만을 근거로 받아들이고 그렇게 기록합니다. | `--worktree` 또는 `--base` 가 있으면 매 리뷰 라운드(셀프 리뷰 포함) 전에 소스 루트의 변경분을 git 종료 코드로 판정합니다. 변경분이 비었거나 판정할 수 없으면 리뷰를 요청하지 않고 exit 1 로 끝납니다(fail-closed). 둘 다 없으면 기존 동작 그대로입니다. -**리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. +**리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. 서브모듈 안의 미커밋·untracked 내용도 스냅샷에는 gitlink 커밋만 남으므로 거부합니다(라운드 시작 시 exit 1, 리뷰 중에 생기면 그 라운드는 stale). `submodule.*.ignore` 설정으로 가려지지 않습니다. 리뷰어가 없으면 creator 셀프 리뷰가 같은 리뷰어 경로(스냅샷 diff·증거 검사·승격 직전 재확인·사이드카)를 거치고, NOT PASS 면 수정 잡 없이 다시 리뷰합니다. 리뷰 잡의 `[워크트리 컨텍스트]` 는 검증 대상을 `[리뷰 identity]` 의 검증 경로 하나로만 지시합니다. 구현이 루프 밖(워크트리의 creator 에게 `delegate-job --type direct`)에서 끝났다면, **구현 위임 전에** base 를 기록해 두고 리뷰만 돌립니다: diff --git a/tests/test_issue18_liveness_contract.py b/tests/test_issue18_liveness_contract.py index db95f29..d2839dd 100644 --- a/tests/test_issue18_liveness_contract.py +++ b/tests/test_issue18_liveness_contract.py @@ -24,8 +24,8 @@ sandbox); each stays under a minute. from __future__ import annotations import json -import re import os +import re import signal import subprocess import sys diff --git a/tests/test_issue21_loop_b.py b/tests/test_issue21_loop_b.py index 9918e01..ff657a6 100644 --- a/tests/test_issue21_loop_b.py +++ b/tests/test_issue21_loop_b.py @@ -25,6 +25,7 @@ REMOTE_ROW = {"name": "reviewer-far-01", "agent": "claude", "role": "reviewer", "transport": "remote"} LOCAL_ROWS = [{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"}, {"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}] +WORK_TAIL = "코드 작업·검증 대상은 위 소스 경로다" def _state(monkeypatch, rows): @@ -195,6 +196,7 @@ def test_l6_env_isolation_reviews_the_source_tree(wt_repo, monkeypatch, tmp_path assert rc == 0 and counts == [n], "env isolation opens no review worktree" prompt = _reviews(calls)[0][1] assert f"- 검증 경로: {os.path.realpath(wt)}" in prompt and "PYTHONDONTWRITEBYTECODE=1" in prompt + assert WORK_TAIL not in prompt and "검증 경로(= 위 소스 경로)" in prompt, "one verification target (B-2)" data = json.loads(_sidecars(main)[0].read_text()) assert (data["evidence"], data["isolation"]) == ("source-tree", "env") @@ -287,6 +289,7 @@ def test_l11_diff_only_remote_reviewer_gets_shas_and_the_snapshot_diff(wt_repo, assert f"- head: {snap['head_sha']}" in remote and f"- tree: {snap['tree_sha']}" in remote assert "diff 만으로" in remote and "+uncommitted change" in remote and "+impl line" in remote assert "- 검증 경로: " in local and "mam-review" in local + assert WORK_TAIL not in local and "[리뷰 identity] 의 검증 경로에서만" in local, "one verification target (B-2)" assert snap["reviewers"]["reviewer-far-01"]["evidence"] == "diff-only" assert snap["reviewers"]["reviewer-claude-01"]["evidence"] == "review-tree" side = {json.loads(p.read_text())["reviewer"]: json.loads(p.read_text()) for p in _sidecars(main)} @@ -397,3 +400,127 @@ def test_l15_truncated_diff_refuses_diff_only_but_not_local(wt_repo, monkeypatch assert rc == 1 and _reviews(calls) == [] and "would have no evidence" in capsys.readouterr().err rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01") assert rc == 0 and len(_reviews(calls)) == 1 + + +# --------------------------------------------------------------------------- +# loop B review follow-up: B-2, B-R1..B-R3, N1, N3, N5 +# --------------------------------------------------------------------------- + +def _add_submodule(wt: Path, tmp_path: Path) -> None: + src = tmp_path / "sm-src" + src.mkdir() + _git(src, "init", "-q") + (src / "x.txt").write_text("original\n") + _git(src, "add", "x.txt") + _git(src, "commit", "-q", "-m", "initial") + _git(wt, "-c", "protocol.file.allow=always", "submodule", "add", "-q", str(src), "sm") + _git(wt, "commit", "-q", "-m", "add sm") + + +@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "during"]) +def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when): + main, wt = wt_repo + base = _impl(wt) + _add_submodule(wt, tmp_path) + if when == "before-ignored": # no ignore setting hides the dirty content + _git(wt, "config", "submodule.sm.ignore", "all") + _git(wt, "config", "diff.ignoreSubmodules", "all") + + def dirty(*_): + (wt / "sm" / "x.txt").write_text("unreviewed work\n") + + if when.startswith("before"): + dirty() + elif when == "untracked": + (wt / "sm" / "new.txt").write_text("untracked in the submodule\n") + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=dirty if when == "during" else None) + assert rc == 1 and _sidecars(main) == [] + err = capsys.readouterr().err + assert "submodule(s) with uncommitted or untracked content: sm" in err + if when == "during": + assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True + else: + assert _reviews(calls) == [], "refused before any review" + + +def test_l17b_clean_submodule_is_reviewed(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + _add_submodule(wt, tmp_path) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 and len(_sidecars(main)) == 1 + + +def test_l18_self_review_is_a_recorded_verdict_and_not_pass_re_reviews(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + report = tmp_path / "pass-report.md" + + def verdicts(r, n, prompt): + report.write_text("[VERDICT: NOT PASS]\n" if n == 1 else "[VERDICT: PASS]\n") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=verdicts, reviewers=None, max_loop=2) + assert rc == 0 + assert [(s, role) for s, role, _ in calls] == [("creator-claude-01", "Reviewer")] * 2, \ + "a NOT PASS self-review is re-reviewed, no correction job" + prompt = _reviews(calls)[1][1] + assert "Self-Review" in prompt and "+impl line" in prompt and "[리뷰 identity]" in prompt + assert r._rounds[0]["verdicts"] == {"creator-claude-01": "NOT PASS"} + (side,) = _sidecars(main) + data = json.loads(side.read_text()) + assert (data["reviewer"], data["loop_id"], data["head_sha"]) == ("creator-claude-01", r.loop_id, + _git(wt, "rev-parse", "HEAD")) + assert data["evidence"] == "review-tree" + + +def test_l18b_self_review_without_options_is_unchanged(wt_repo, monkeypatch, tmp_path): + main, _ = wt_repo + (main / "a.txt").write_text("a\nchanged\n") + rc, calls, r = _drive(monkeypatch, tmp_path, [], reviewers=None) + (review,) = _reviews(calls) + assert rc == 0 and review[0] == "creator-claude-01" + assert review[1].startswith("작업 완료 상태에 대해 스스로 검증") and "변경분" not in review[1] + assert _sidecars(main) == [] + + +def test_l19_remote_self_review_gets_the_snapshot_diff(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + _state(monkeypatch, [dict(LOCAL_ROWS[0], transport="remote")]) + rc, calls, r = _drive(monkeypatch, tmp_path, + ["--worktree", str(wt), "--base", base, "--review-only", "--remote-review", "diff-only"], + reviewers=None) + assert rc == 0 + prompt = _reviews(calls)[0][1] + assert "diff --git" in prompt and "+impl line" in prompt and "검증 경로" not in prompt + data = json.loads(_sidecars(main)[0].read_text()) + assert (data["reviewer"], data["evidence"]) == ("creator-claude-01", "diff-only") + + +def test_l20_snapshot_diff_failure_keeps_the_unavailable_marker(wt_repo): + main, wt = wt_repo + dst = main / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" + dst.mkdir(parents=True) + (dst / "diff_collect.sh").write_text("mam_collect_changes_diff() { return 1; }\n") + from lib_py.loop.runner import UNAVAILABLE_MARKER, LoopRunner + r = LoopRunner(["--creator", "creator-claude-01", "--task", "t", "--worktree", str(wt)]) + r.parse_args() + r.base_commit = _git(wt, "rev-parse", "HEAD") + assert UNAVAILABLE_MARKER in r.collect_changes_diff(r.base_commit) + assert UNAVAILABLE_MARKER not in r.collect_changes_diff(), "the live-tree form keeps its git fallback" + + +def test_l21_closing_a_review_tree_keeps_other_worktree_entries_and_its_parent_dir(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _impl(wt) + other = tmp_path / "detached-disk" + _git(main, "worktree", "add", "-q", "--detach", str(other)) + import shutil + import tempfile + shutil.rmtree(other) # a worktree whose directory is gone (a detached disk) + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 + assert str(other) in _git(main, "worktree", "list", "--porcelain"), "the loop does not prune others' entries" + assert not (Path(tempfile.gettempdir()) / "mam-review" / r.loop_id).exists()