fix(issue-21): loop B review follow-up - one verification target per review prompt, dirty submodules refused, identity self-review recorded like any verdict

- review jobs' [워크트리 컨텍스트] points at the [리뷰 identity] path only (B-2);
  remote re-adjudication gets a matching sentence (N-4)
- dirty/untracked submodule content fails the snapshot and makes a round stale,
  regardless of submodule ignore settings (B-R1)
- with --worktree/--base and no reviewer, the creator self-review takes the
  reviewer path: snapshot diff, evidence check, pre-promotion re-check and the
  identity sidecar; NOT PASS still re-reviews without a correction job (B-R2/B-R3)
- snapshot diff failures keep the UNAVAILABLE marker (N-1); prune only when
  worktree remove fails (N-3); the empty per-loop review dir is removed (N-5)
- tests L17-L21, B-2 assertions in L6/L11; SKILL.md env stale note; import order

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-14 22:34:12 +00:00
1 parent e6d2168a13
commit a21986ec3a
4 files changed
+217 -33

No files matched your search

+87 -30
View File
@@ -27,6 +27,10 @@ from typing import Any, Dict, List, Optional, Set, Tuple
from lib_py.loop import checkpoint, eager, parser 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 # ANSI colors matching run_loop.sh
_COLOR_INFO = "\033[1;34m[*]\033[0m" _COLOR_INFO = "\033[1;34m[*]\033[0m"
_COLOR_SUCCESS = "\033[1;32m[✓]\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) res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=src)
if res.returncode == 0: if res.returncode == 0:
return res.stdout 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 # Fallback to direct git diff
try: try:
if head_commit: if head_commit:
@@ -538,6 +547,8 @@ class LoopRunner:
else: else:
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"] 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) 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 return res.stdout
except Exception: except Exception:
return "" return ""
@@ -550,9 +561,11 @@ class LoopRunner:
def _context_active(self) -> bool: def _context_active(self) -> bool:
return self.worktree_given or self.base_ref is not None 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); """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(): if not self._context_active():
return "" return ""
src = self.worktree_root src = self.worktree_root
@@ -570,8 +583,21 @@ class LoopRunner:
# a remote reviewer (diff-only) cannot reach this host's paths (B6) # a remote reviewer (diff-only) cannot reach this host's paths (B6)
src_line = ("- 소스 경로: (오케스트레이터 호스트 전용 — 원격 리뷰어는 접근할 수 없음)\n" if remote src_line = ("- 소스 경로: (오케스트레이터 호스트 전용 — 원격 리뷰어는 접근할 수 없음)\n" if remote
else f"- 소스 경로: {src}\n") else f"- 소스 경로: {src}\n")
tail = ("이 잡의 검증 대상은 아래 SHA 와 diff 다.\n\n" if remote else if purpose == "review" and remote:
"이 잡의 코드 작업·검증 대상은 위 소스 경로다. 브리프와 리포트는 브리프에 적힌 절대경로를 그대로 쓴다.\n\n") 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" return ("[워크트리 컨텍스트]\n"
+ src_line + src_line
+ f"- 브랜치: {branch}\n" + f"- 브랜치: {branch}\n"
@@ -581,9 +607,9 @@ class LoopRunner:
+ tail) + tail)
def _dispatch(self, agent_session: str, agent_type: str, role: str, prompt: str, 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.""" """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: def _change_set_state(self) -> str:
"""'empty' | 'present' | 'unknown' for the source root against base, """'empty' | 'present' | 'unknown' for the source root against base,
@@ -653,7 +679,17 @@ class LoopRunner:
def _worktree_tree(self) -> Optional[str]: def _worktree_tree(self) -> Optional[str]:
"""The source worktree's state as a tree, through a temporary index """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-") fd, idx = tempfile.mkstemp(prefix="mam-review-index-")
os.close(fd) os.close(fd)
os.unlink(idx) os.unlink(idx)
@@ -673,6 +709,18 @@ class LoopRunner:
except OSError: except OSError:
pass 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]]: def _gitlinks(self, treeish: str) -> Optional[Set[str]]:
r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish) r = self._git(self.worktree_root, "ls-tree", "-r", "-z", treeish)
if r.returncode != 0: if r.returncode != 0:
@@ -735,11 +783,18 @@ class LoopRunner:
def _close_review_tree(self, path: Optional[str]) -> None: def _close_review_tree(self, path: Optional[str]) -> None:
if not path or path not in self._review_paths: if not path or path not in self._review_paths:
return return
self._git(self.worktree_root, "worktree", "remove", "--force", path) r = self._git(self.worktree_root, "worktree", "remove", "--force", path)
self._git(self.worktree_root, "worktree", "prune")
if os.path.isdir(path): if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True) 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) self._review_paths.remove(path)
try:
os.rmdir(os.path.dirname(path)) # TMPDIR/mam-review/<loop_id>, once empty
except OSError:
pass
def _close_all_review_trees(self) -> None: def _close_all_review_trees(self) -> None:
for p in list(self._review_paths): for p in list(self._review_paths):
@@ -1003,6 +1058,13 @@ class LoopRunner:
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {} final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
identity = self._context_active() 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): for loop_count in range(1, self.max_loop + 1):
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...") log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
@@ -1029,29 +1091,13 @@ class LoopRunner:
if not reviewers: if not reviewers:
# Creator Self-Review # Creator Self-Review
log_warn("No reviewers specified. Conducting Creator Self-Review...") log_warn("No reviewers specified. Conducting Creator Self-Review...")
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요." sr_prompt = SELF_REVIEW_PROMPT
sr_remote = self.creator in self._remote_recipients sr_jid = self._dispatch(self.creator, creator_type, "Reviewer", sr_prompt)
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)
if not sr_jid: if not sr_jid:
log_error("Failed to register Self-Review job.") log_error("Failed to register Self-Review job.")
return 1 return 1
self.wait_for_job(sr_jid) self.wait_for_job(sr_jid)
sr_report = self.find_report(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"): if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"):
log_success("Self-Review PASS.") log_success("Self-Review PASS.")
break break
@@ -1077,11 +1123,15 @@ class LoopRunner:
for rev in reviewers: for rev in reviewers:
log_info(f"Requesting code review from Reviewer '{rev}'...") log_info(f"Requesting code review from Reviewer '{rev}'...")
r_type = self.resolve_agent_type(rev, state) 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 r_remote = rev in self._remote_recipients
if identity: if identity:
r_prompt = self._identity_block(snap, review_path, r_remote) + r_prompt 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: if not r_jid:
log_error(f"Failed to register review job for '{rev}'.") log_error(f"Failed to register review job for '{rev}'.")
return 1 return 1
@@ -1143,6 +1193,13 @@ class LoopRunner:
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.") log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
break 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: if loop_count == self.max_loop:
log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.") log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.")
return 1 return 1
@@ -1198,7 +1255,7 @@ class LoopRunner:
rt_type = self.resolve_agent_type(rt, state) rt_type = self.resolve_agent_type(rt, state)
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}" adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
adj_jid = self._dispatch(rt, rt_type, "Reviewer", adj_prompt, 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): if not adj_jid or not self.wait_for_job(adj_jid):
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.") log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
overruled_revs.append(rt) overruled_revs.append(rt)
+2 -2
View File
@@ -200,12 +200,12 @@ sequenceDiagram
| **Source root** | `--worktree <path>` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). | | **Source root** | `--worktree <path>` (env `MAM_LOOP_WORKTREE`, CLI 우선) | diff 수집·base·head·빈 변경분 판정만 이 git 워크트리 기준으로 합니다. wrapper·레지스트리·체크포인트·loop guard·리포트 승격·cleanup 은 루프를 띄운 본 체크아웃(제어 루트)에 남습니다. 모든 위임 프롬프트 앞에 `[워크트리 컨텍스트]` 블록(경로·브랜치·base·head·커밋 수·미커밋 변경)이 붙습니다. Python 루프 전용(Issue #21). |
| **Diff base** | `--base <ref>` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. | | **Diff base** | `--base <ref>` (CLI 전용, env 없음) | 리뷰 diff 의 base 를 고정합니다. 소스 HEAD 의 조상이어야 하며 아니면 exit 1. 생략 시 Phase 2 시작 시점의 소스 HEAD 입니다. |
| **Review only** | `--review-only` | Phase 2(구현 위임)를 건너뛰고 `<base>..HEAD` 를 리뷰합니다. `--base` 필수, `--plan` 과 함께 쓸 수 없습니다. 수정·순응 잡은 여전히 creator 에게 갑니다. | | **Review only** | `--review-only` | Phase 2(구현 위임)를 건너뛰고 `<base>..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 만을 근거로 받아들이고 그렇게 기록합니다. | | **Remote reviewers** | `--remote-review refuse\|diff-only` | `--worktree`/`--base` 에서만 의미. 원격 리뷰어(`transport: remote`)는 이 호스트의 리뷰 트리에 닿을 수 없으므로 기본으로 **어떤 잡도 위임하기 전에** 거부(exit 1)합니다. `diff-only` 는 스냅샷 SHA 와 diff 만을 근거로 받아들이고 그렇게 기록합니다. |
`--worktree` 또는 `--base` 가 있으면 매 리뷰 라운드(셀프 리뷰 포함) 전에 소스 루트의 변경분을 git 종료 코드로 판정합니다. 변경분이 비었거나 판정할 수 없으면 리뷰를 요청하지 않고 exit 1 로 끝납니다(fail-closed). 둘 다 없으면 기존 동작 그대로입니다. `--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-<jid>.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-<jid>.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 를 기록해 두고 리뷰만 돌립니다: 구현이 루프 밖(워크트리의 creator 에게 `delegate-job --type direct`)에서 끝났다면, **구현 위임 전에** base 를 기록해 두고 리뷰만 돌립니다:
+1 -1
View File
@@ -24,8 +24,8 @@ sandbox); each stays under a minute.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import os import os
import re
import signal import signal
import subprocess import subprocess
import sys import sys
+127
View File
@@ -25,6 +25,7 @@ REMOTE_ROW = {"name": "reviewer-far-01", "agent": "claude", "role": "reviewer",
"transport": "remote"} "transport": "remote"}
LOCAL_ROWS = [{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"}, LOCAL_ROWS = [{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"},
{"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}] {"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"}]
WORK_TAIL = "코드 작업·검증 대상은 위 소스 경로다"
def _state(monkeypatch, rows): 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" assert rc == 0 and counts == [n], "env isolation opens no review worktree"
prompt = _reviews(calls)[0][1] prompt = _reviews(calls)[0][1]
assert f"- 검증 경로: {os.path.realpath(wt)}" in prompt and "PYTHONDONTWRITEBYTECODE=1" in prompt 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()) data = json.loads(_sidecars(main)[0].read_text())
assert (data["evidence"], data["isolation"]) == ("source-tree", "env") 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 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 "diff 만으로" in remote and "+uncommitted change" in remote and "+impl line" in remote
assert "- 검증 경로: " in local and "mam-review" in local 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-far-01"]["evidence"] == "diff-only"
assert snap["reviewers"]["reviewer-claude-01"]["evidence"] == "review-tree" 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)} 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 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") rc, calls, r = _drive(monkeypatch, tmp_path, argv, reviewers="reviewer-claude-01")
assert rc == 0 and len(_reviews(calls)) == 1 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()