From e6d7222d2aba810fd24271e9a2709fff3e9d9822 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:33:01 +0000 Subject: [PATCH 01/21] =?UTF-8?q?Docs:=20cross-refactoring=20=E5=AE=9F?= =?UTF-8?q?=E6=A9=9F=E8=A9=A6=E8=A1=8C=E3=81=AE=E5=AF=BE=E8=B1=A1=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=81=A8=E5=AE=9F=E8=A1=8C=E6=9D=A1=E4=BB=B6=E3=82=92?= =?UTF-8?q?=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- issues/issue-113-cross-refactoring-trial.md | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-trial.md diff --git a/issues/issue-113-cross-refactoring-trial.md b/issues/issue-113-cross-refactoring-trial.md new file mode 100644 index 00000000..e530519b --- /dev/null +++ b/issues/issue-113-cross-refactoring-trial.md @@ -0,0 +1,26 @@ +# cross-refactoring 実機試行(対象: refactor.py と収束ループ共通層) + +`issues/issue-113-cross-refactoring` でリリースした `/ndf:cross-refactoring` を、 +自分自身のスクリプト群に対して実行して検証する。 + +## 対象範囲 + +| パス | 行数 | 内容 | +| --- | --- | --- | +| `plugins/ndf-shared/skills/cross-refactoring/scripts/` | 2,511 | `refactor.py` / `launch-cli.sh` / `prepare-worktrees.sh` | +| `plugins/ndf-shared/skills/cross-review/scripts/lib/` | 1,497 | `monitor.py` / `assignment.py` / `models.py` / `metrics.py` / `statefile.py` | + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| 提案ラウンド上限 | 3(既定) | +| モデル | 各 CLI の既定(kiro は `auto` のため計測集計から分離される) | +| ベースラインテスト | `uv run --with pytest python -m pytest plugins/ndf-shared/skills/cross-refactoring/tests plugins/ndf-shared/skills/cross-review/tests -q` | +| 着手前の状態 | 387 passed | + +## 注意 + +`plugins/ndf-shared/` は編集元であり、`scripts/build-runtime-plugins.sh` で +`ndf-claude` / `ndf-codex` / `ndf-kiro` へ同期する。CI が `--check` で一致を検査するため、 +ラウンド収束後にホストが同期コミットを 1 件加える。 From 0a81b26c31f205d96f533c08f3a2732fde04ebcd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:49:13 +0000 Subject: [PATCH 02/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor=5Fagent=20sentinel=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex の sentinel と result.json による完了判定を名前付き関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 38 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 38 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 38 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 38 ++++++++++++------- 4 files changed, 96 insertions(+), 56 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 401a760f..a83272cd 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,29 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_sentinel_completion( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> bool: + if not ( + agent == "codex" + and _pid_alive(pid) + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" + return True + + def monitor_agent( agent: str, pr: int, @@ -676,20 +699,7 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) + if _check_sentinel_completion(agent, pid, paths, status): _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 401a760f..a83272cd 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,29 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_sentinel_completion( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> bool: + if not ( + agent == "codex" + and _pid_alive(pid) + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" + return True + + def monitor_agent( agent: str, pr: int, @@ -676,20 +699,7 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) + if _check_sentinel_completion(agent, pid, paths, status): _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 401a760f..a83272cd 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,29 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_sentinel_completion( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> bool: + if not ( + agent == "codex" + and _pid_alive(pid) + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" + return True + + def monitor_agent( agent: str, pr: int, @@ -676,20 +699,7 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) + if _check_sentinel_completion(agent, pid, paths, status): _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 401a760f..a83272cd 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,29 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_sentinel_completion( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> bool: + if not ( + agent == "codex" + and _pid_alive(pid) + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" + return True + + def monitor_agent( agent: str, pr: int, @@ -676,20 +699,7 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) + if _check_sentinel_completion(agent, pid, paths, status): _emit_log(log_prefix, agent, status) return status From 5034910ac7f4e57246dfd052be77faff2a38262c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:50:01 +0000 Subject: [PATCH 03/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor=5Fagent=20result=20age=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result.json の経過時間による完了判定を名前付き関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 59 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 59 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 59 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 59 ++++++++++++------- 4 files changed, 156 insertions(+), 80 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index a83272cd..187f64f3 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -636,6 +636,41 @@ def _check_sentinel_completion( return True +def _check_result_age_completion( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> bool: + if not ( + _pid_alive(pid) + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return False + + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return True + + def monitor_agent( agent: str, pr: int, @@ -710,27 +745,11 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 + if _check_result_age_completion( + pid, paths, status, started_wall, cmdline_validated ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index a83272cd..187f64f3 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -636,6 +636,41 @@ def _check_sentinel_completion( return True +def _check_result_age_completion( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> bool: + if not ( + _pid_alive(pid) + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return False + + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return True + + def monitor_agent( agent: str, pr: int, @@ -710,27 +745,11 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 + if _check_result_age_completion( + pid, paths, status, started_wall, cmdline_validated ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index a83272cd..187f64f3 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -636,6 +636,41 @@ def _check_sentinel_completion( return True +def _check_result_age_completion( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> bool: + if not ( + _pid_alive(pid) + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return False + + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return True + + def monitor_agent( agent: str, pr: int, @@ -710,27 +745,11 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 + if _check_result_age_completion( + pid, paths, status, started_wall, cmdline_validated ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index a83272cd..187f64f3 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -636,6 +636,41 @@ def _check_sentinel_completion( return True +def _check_result_age_completion( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> bool: + if not ( + _pid_alive(pid) + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return False + + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return False + + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return False + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return True + + def monitor_agent( agent: str, pr: int, @@ -710,27 +745,11 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 + if _check_result_age_completion( + pid, paths, status, started_wall, cmdline_validated ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From 3a27437d8242cf0ee6ea43e010e8b19639b397a5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:50:58 +0000 Subject: [PATCH 04/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor=5Fagent=20early=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fatal と warn の early error スキャンおよび判定を名前付き関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 70 +++++++++++-------- .../cross-review/scripts/lib/monitor.py | 70 +++++++++++-------- .../cross-review/scripts/lib/monitor.py | 70 +++++++++++-------- .../cross-review/scripts/lib/monitor.py | 70 +++++++++++-------- 4 files changed, 164 insertions(+), 116 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 187f64f3..3227591b 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -671,6 +671,32 @@ def _check_result_age_completion( return True +def _check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + no_early_error: bool, + warned_early_error: bool, +) -> tuple[bool, str | None]: + if no_early_error: + return False, None + + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + return True, None + + if not warned_early_error: + return False, _scan_early_warn(paths.err_log) + return False, None + + def monitor_agent( agent: str, pr: int, @@ -779,35 +805,21 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + early_error, warn_err = _check_early_error( + agent, paths, status, no_early_error, warned_early_error + ) + if early_error: + if alive: + _kill_pid(pid) + _emit_log(log_prefix, agent, status) + return status + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 187f64f3..3227591b 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -671,6 +671,32 @@ def _check_result_age_completion( return True +def _check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + no_early_error: bool, + warned_early_error: bool, +) -> tuple[bool, str | None]: + if no_early_error: + return False, None + + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + return True, None + + if not warned_early_error: + return False, _scan_early_warn(paths.err_log) + return False, None + + def monitor_agent( agent: str, pr: int, @@ -779,35 +805,21 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + early_error, warn_err = _check_early_error( + agent, paths, status, no_early_error, warned_early_error + ) + if early_error: + if alive: + _kill_pid(pid) + _emit_log(log_prefix, agent, status) + return status + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 187f64f3..3227591b 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -671,6 +671,32 @@ def _check_result_age_completion( return True +def _check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + no_early_error: bool, + warned_early_error: bool, +) -> tuple[bool, str | None]: + if no_early_error: + return False, None + + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + return True, None + + if not warned_early_error: + return False, _scan_early_warn(paths.err_log) + return False, None + + def monitor_agent( agent: str, pr: int, @@ -779,35 +805,21 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + early_error, warn_err = _check_early_error( + agent, paths, status, no_early_error, warned_early_error + ) + if early_error: + if alive: + _kill_pid(pid) + _emit_log(log_prefix, agent, status) + return status + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 187f64f3..3227591b 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -671,6 +671,32 @@ def _check_result_age_completion( return True +def _check_early_error( + agent: str, + paths: AgentPaths, + status: AgentStatus, + no_early_error: bool, + warned_early_error: bool, +) -> tuple[bool, str | None]: + if no_early_error: + return False, None + + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + return True, None + + if not warned_early_error: + return False, _scan_early_warn(paths.err_log) + return False, None + + def monitor_agent( agent: str, pr: int, @@ -779,35 +805,21 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + early_error, warn_err = _check_early_error( + agent, paths, status, no_early_error, warned_early_error + ) + if early_error: + if alive: + _kill_pid(pid) + _emit_log(log_prefix, agent, status) + return status + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 From ea3209c60a87051a11bc121a8502b605278338a6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:51:59 +0000 Subject: [PATCH 05/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20commit=20assignment=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用範囲のコミット所有者と重複割当の検証を名前付き関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- 4 files changed, 96 insertions(+), 48 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 082dc773..7871fefe 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,29 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def verify_commit_assignment( + work: str, + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], +) -> tuple[list[str], list[str]]: + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id in entry["items"]: + result = reported.get(item_id) + if result is None: + continue + for sha in _reported_shas(result): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + return sorted(in_range - set(owner_of)), duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -997,18 +1020,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 082dc773..7871fefe 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,29 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def verify_commit_assignment( + work: str, + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], +) -> tuple[list[str], list[str]]: + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id in entry["items"]: + result = reported.get(item_id) + if result is None: + continue + for sha in _reported_shas(result): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + return sorted(in_range - set(owner_of)), duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -997,18 +1020,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 082dc773..7871fefe 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,29 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def verify_commit_assignment( + work: str, + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], +) -> tuple[list[str], list[str]]: + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id in entry["items"]: + result = reported.get(item_id) + if result is None: + continue + for sha in _reported_shas(result): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + return sorted(in_range - set(owner_of)), duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -997,18 +1020,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 082dc773..7871fefe 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,29 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def verify_commit_assignment( + work: str, + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], +) -> tuple[list[str], list[str]]: + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id in entry["items"]: + result = reported.get(item_id) + if result is None: + continue + for sha in _reported_shas(result): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + return sorted(in_range - set(owner_of)), duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -997,18 +1020,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: From 38a87e2566d75b216b50f6367322c09469f35303 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:52:48 +0000 Subject: [PATCH 06/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20abandon=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 割当不正時の項目取消、範囲 revert、起点更新を名前付き関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- 4 files changed, 92 insertions(+), 56 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 7871fefe..525449ef 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -922,6 +922,28 @@ def verify_commit_assignment( return sorted(in_range - set(owner_of)), duplicated +def _abandon_round( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + dry_run: bool, +) -> None: + for item_id in entry["items"]: + item = _find_item(state, item_id) + item["status"] = "abandoned" + item["failure_reason"] = reason + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, dry_run) + if not dry_run: + work = state["worktrees"]["work"] + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1044,22 +1066,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _abandon_round(state, entry, ordered_range, reason, args.dry_run) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1068,7 +1078,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } - state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 7871fefe..525449ef 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -922,6 +922,28 @@ def verify_commit_assignment( return sorted(in_range - set(owner_of)), duplicated +def _abandon_round( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + dry_run: bool, +) -> None: + for item_id in entry["items"]: + item = _find_item(state, item_id) + item["status"] = "abandoned" + item["failure_reason"] = reason + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, dry_run) + if not dry_run: + work = state["worktrees"]["work"] + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1044,22 +1066,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _abandon_round(state, entry, ordered_range, reason, args.dry_run) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1068,7 +1078,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } - state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 7871fefe..525449ef 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -922,6 +922,28 @@ def verify_commit_assignment( return sorted(in_range - set(owner_of)), duplicated +def _abandon_round( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + dry_run: bool, +) -> None: + for item_id in entry["items"]: + item = _find_item(state, item_id) + item["status"] = "abandoned" + item["failure_reason"] = reason + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, dry_run) + if not dry_run: + work = state["worktrees"]["work"] + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1044,22 +1066,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _abandon_round(state, entry, ordered_range, reason, args.dry_run) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1068,7 +1078,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } - state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 7871fefe..525449ef 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -922,6 +922,28 @@ def verify_commit_assignment( return sorted(in_range - set(owner_of)), duplicated +def _abandon_round( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + reason: str, + dry_run: bool, +) -> None: + for item_id in entry["items"]: + item = _find_item(state, item_id) + item["status"] = "abandoned" + item["failure_reason"] = reason + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, dry_run) + if not dry_run: + work = state["worktrees"]["work"] + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1044,22 +1066,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _abandon_round(state, entry, ordered_range, reason, args.dry_run) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1068,7 +1078,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } - state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: From 6e25fa510f39aa87ec17937119ed89f1366c008a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:53:46 +0000 Subject: [PATCH 07/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20settle=20apply=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目ごとの検証、失敗時の取消、成功時の記録を名前付き関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 87 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 87 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 87 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 87 ++++++++++++------- 4 files changed, 216 insertions(+), 132 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 525449ef..f6a9bdd0 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -944,6 +944,50 @@ def _abandon_round( state["phase"] = "propose" +def _verify_and_settle_items( + state: dict[str, Any], + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str], int]: + work = state["worktrees"]["work"] + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + timeout, + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + return applied, failed, reverted + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1084,39 +1128,16 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + applied, failed, reverted = _verify_and_settle_items( + state, + entry, + reported, + in_range, + test_command, + head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + args.dry_run, + ) entry["apply"] = { "applied": applied, diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 525449ef..f6a9bdd0 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -944,6 +944,50 @@ def _abandon_round( state["phase"] = "propose" +def _verify_and_settle_items( + state: dict[str, Any], + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str], int]: + work = state["worktrees"]["work"] + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + timeout, + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + return applied, failed, reverted + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1084,39 +1128,16 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + applied, failed, reverted = _verify_and_settle_items( + state, + entry, + reported, + in_range, + test_command, + head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + args.dry_run, + ) entry["apply"] = { "applied": applied, diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 525449ef..f6a9bdd0 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -944,6 +944,50 @@ def _abandon_round( state["phase"] = "propose" +def _verify_and_settle_items( + state: dict[str, Any], + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str], int]: + work = state["worktrees"]["work"] + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + timeout, + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + return applied, failed, reverted + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1084,39 +1128,16 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + applied, failed, reverted = _verify_and_settle_items( + state, + entry, + reported, + in_range, + test_command, + head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + args.dry_run, + ) entry["apply"] = { "applied": applied, diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 525449ef..f6a9bdd0 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -944,6 +944,50 @@ def _abandon_round( state["phase"] = "propose" +def _verify_and_settle_items( + state: dict[str, Any], + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str], int]: + work = state["worktrees"]["work"] + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + timeout, + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + return applied, failed, reverted + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1084,39 +1128,16 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + applied, failed, reverted = _verify_and_settle_items( + state, + entry, + reported, + in_range, + test_command, + head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + args.dry_run, + ) entry["apply"] = { "applied": applied, From 997b1781e03571c034d6711c83e614667d4134ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:54:48 +0000 Subject: [PATCH 08/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20unassigned=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 申告 SHA を完全 SHA に正規化して未割当コミットを検出する処理を apply と fix で共通化する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 30 ++++++++++++++----- .../cross-refactoring/scripts/refactor.py | 30 ++++++++++++++----- .../cross-refactoring/scripts/refactor.py | 30 ++++++++++++++----- .../cross-refactoring/scripts/refactor.py | 30 ++++++++++++++----- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index f6a9bdd0..42bfcd7e 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,20 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _find_unassigned_commits( + work: str, + reported_shas: list[str], + ordered_range: list[str], +) -> list[str]: + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + def verify_commit_assignment( work: str, entry: dict[str, Any], @@ -919,7 +933,13 @@ def verify_commit_assignment( duplicated.append(full) owner_of.setdefault(full, item_id) - return sorted(in_range - set(owner_of)), duplicated + reported_shas = [ + sha + for result in reported.values() + for sha in _reported_shas(result) + ] + unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) + return unassigned, duplicated def _abandon_round( @@ -1435,13 +1455,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index f6a9bdd0..42bfcd7e 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,20 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _find_unassigned_commits( + work: str, + reported_shas: list[str], + ordered_range: list[str], +) -> list[str]: + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + def verify_commit_assignment( work: str, entry: dict[str, Any], @@ -919,7 +933,13 @@ def verify_commit_assignment( duplicated.append(full) owner_of.setdefault(full, item_id) - return sorted(in_range - set(owner_of)), duplicated + reported_shas = [ + sha + for result in reported.values() + for sha in _reported_shas(result) + ] + unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) + return unassigned, duplicated def _abandon_round( @@ -1435,13 +1455,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index f6a9bdd0..42bfcd7e 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,20 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _find_unassigned_commits( + work: str, + reported_shas: list[str], + ordered_range: list[str], +) -> list[str]: + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + def verify_commit_assignment( work: str, entry: dict[str, Any], @@ -919,7 +933,13 @@ def verify_commit_assignment( duplicated.append(full) owner_of.setdefault(full, item_id) - return sorted(in_range - set(owner_of)), duplicated + reported_shas = [ + sha + for result in reported.values() + for sha in _reported_shas(result) + ] + unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) + return unassigned, duplicated def _abandon_round( @@ -1435,13 +1455,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f6a9bdd0..42bfcd7e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -899,6 +899,20 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _find_unassigned_commits( + work: str, + reported_shas: list[str], + ordered_range: list[str], +) -> list[str]: + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + def verify_commit_assignment( work: str, entry: dict[str, Any], @@ -919,7 +933,13 @@ def verify_commit_assignment( duplicated.append(full) owner_of.setdefault(full, item_id) - return sorted(in_range - set(owner_of)), duplicated + reported_shas = [ + sha + for result in reported.values() + for sha in _reported_shas(result) + ] + unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) + return unassigned, duplicated def _abandon_round( @@ -1435,13 +1455,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), From 2355620fc87f1b859bd2c993b05e3e0f7c5383bd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:55:51 +0000 Subject: [PATCH 09/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20verified=20resolved=20threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解決スレッド ID の型検証と GitHub 実状態との照合を名前付き関数へ抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- 4 files changed, 100 insertions(+), 76 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 42bfcd7e..f91c67e5 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1376,6 +1376,30 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) +def _verified_resolved_threads( + payload: dict[str, Any], + state: dict[str, Any], +) -> set[str]: + raw_claimed = payload.get("resolved_thread_ids") + claimed = { + thread_id + for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(thread_id, str) and thread_id.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return claimed & actual + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1418,25 +1442,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + resolved = _verified_resolved_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 42bfcd7e..f91c67e5 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1376,6 +1376,30 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) +def _verified_resolved_threads( + payload: dict[str, Any], + state: dict[str, Any], +) -> set[str]: + raw_claimed = payload.get("resolved_thread_ids") + claimed = { + thread_id + for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(thread_id, str) and thread_id.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return claimed & actual + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1418,25 +1442,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + resolved = _verified_resolved_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 42bfcd7e..f91c67e5 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1376,6 +1376,30 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) +def _verified_resolved_threads( + payload: dict[str, Any], + state: dict[str, Any], +) -> set[str]: + raw_claimed = payload.get("resolved_thread_ids") + claimed = { + thread_id + for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(thread_id, str) and thread_id.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return claimed & actual + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1418,25 +1442,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + resolved = _verified_resolved_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 42bfcd7e..f91c67e5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1376,6 +1376,30 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) +def _verified_resolved_threads( + payload: dict[str, Any], + state: dict[str, Any], +) -> set[str]: + raw_claimed = payload.get("resolved_thread_ids") + claimed = { + thread_id + for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(thread_id, str) and thread_id.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return claimed & actual + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1418,25 +1442,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + resolved = _verified_resolved_threads(payload, state) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 2bc467825ea16a84d31c1e638a2a3f6c3ef91ab0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:56:43 +0000 Subject: [PATCH 10/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20collect=20fix=20facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正範囲、申告 SHA、git facts、未割当コミットの構築を名前付き関数へ抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 49 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 49 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 49 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 49 +++++++++++-------- 4 files changed, 112 insertions(+), 84 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index f91c67e5..8f84e914 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1400,6 +1400,32 @@ def _verified_resolved_threads( return claimed & actual +def _collect_fix_facts( + state: dict[str, Any], + entry: dict[str, Any], + payload: dict[str, Any], + head_now: str, +) -> tuple[list[str], list[dict[str, Any]], list[str]]: + work = state["worktrees"]["work"] + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) + baseline = state.get("baseline_test") or {} + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, facts, unassigned + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1446,27 +1472,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, facts, unassigned = _collect_fix_facts( + state, entry, payload, head_now ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index f91c67e5..8f84e914 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1400,6 +1400,32 @@ def _verified_resolved_threads( return claimed & actual +def _collect_fix_facts( + state: dict[str, Any], + entry: dict[str, Any], + payload: dict[str, Any], + head_now: str, +) -> tuple[list[str], list[dict[str, Any]], list[str]]: + work = state["worktrees"]["work"] + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) + baseline = state.get("baseline_test") or {} + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, facts, unassigned + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1446,27 +1472,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, facts, unassigned = _collect_fix_facts( + state, entry, payload, head_now ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index f91c67e5..8f84e914 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1400,6 +1400,32 @@ def _verified_resolved_threads( return claimed & actual +def _collect_fix_facts( + state: dict[str, Any], + entry: dict[str, Any], + payload: dict[str, Any], + head_now: str, +) -> tuple[list[str], list[dict[str, Any]], list[str]]: + work = state["worktrees"]["work"] + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) + baseline = state.get("baseline_test") or {} + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, facts, unassigned + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1446,27 +1472,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, facts, unassigned = _collect_fix_facts( + state, entry, payload, head_now ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f91c67e5..8f84e914 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1400,6 +1400,32 @@ def _verified_resolved_threads( return claimed & actual +def _collect_fix_facts( + state: dict[str, Any], + entry: dict[str, Any], + payload: dict[str, Any], + head_now: str, +) -> tuple[list[str], list[dict[str, Any]], list[str]]: + work = state["worktrees"]["work"] + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) + baseline = state.get("baseline_test") or {} + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + return ordered_range, facts, unassigned + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1446,27 +1472,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ordered_range, facts, unassigned = _collect_fix_facts( + state, entry, payload, head_now ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** From a0d04502037e0997936223d7ec13380884ef2232 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:57:29 +0000 Subject: [PATCH 11/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20partition=20fix=20facts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正コミット facts を accepted と problems に分類する検証を名前付き関数へ抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 27 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 27 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 27 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 27 ++++++++++++------- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 8f84e914..319e83be 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1426,6 +1426,22 @@ def _collect_fix_facts( return ordered_range, facts, unassigned +def _partition_fix_facts( + facts: list[dict[str, Any]], +) -> tuple[list[tuple[str, str]], list[str]]: + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1480,17 +1496,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) + accepted, problems = _partition_fix_facts(facts) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) if unassigned: info( diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 8f84e914..319e83be 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1426,6 +1426,22 @@ def _collect_fix_facts( return ordered_range, facts, unassigned +def _partition_fix_facts( + facts: list[dict[str, Any]], +) -> tuple[list[tuple[str, str]], list[str]]: + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1480,17 +1496,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) + accepted, problems = _partition_fix_facts(facts) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) if unassigned: info( diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 8f84e914..319e83be 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1426,6 +1426,22 @@ def _collect_fix_facts( return ordered_range, facts, unassigned +def _partition_fix_facts( + facts: list[dict[str, Any]], +) -> tuple[list[tuple[str, str]], list[str]]: + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1480,17 +1496,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) + accepted, problems = _partition_fix_facts(facts) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) if unassigned: info( diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 8f84e914..319e83be 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1426,6 +1426,22 @@ def _collect_fix_facts( return ordered_range, facts, unassigned +def _partition_fix_facts( + facts: list[dict[str, Any]], +) -> tuple[list[tuple[str, str]], list[str]]: + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1480,17 +1496,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) + accepted, problems = _partition_fix_facts(facts) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) if unassigned: info( From 0184185b3936f2308aff387d561ed69f631a50b2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:58:28 +0000 Subject: [PATCH 12/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20settle=20fix=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証失敗時の修正範囲取消と成功時の項目コミット記録を名前付き関数へ抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 68 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 68 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 68 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 68 +++++++++++-------- 4 files changed, 152 insertions(+), 120 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 319e83be..dc0083be 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1442,6 +1442,41 @@ def _partition_fix_facts( return accepted, problems +def _settle_fix_commits( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + unassigned: list[str], + problems: list[str], + accepted: list[tuple[str, str]], + resolved: set[str], +) -> tuple[bool, set[str]]: + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + work = state["worktrees"]["work"] + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return True, set() + + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, resolved + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1497,36 +1532,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 accepted, problems = _partition_fix_facts(facts) - needs_push = False - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + needs_push, resolved = _settle_fix_commits( + state, entry, ordered_range, unassigned, problems, accepted, resolved + ) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 319e83be..dc0083be 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1442,6 +1442,41 @@ def _partition_fix_facts( return accepted, problems +def _settle_fix_commits( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + unassigned: list[str], + problems: list[str], + accepted: list[tuple[str, str]], + resolved: set[str], +) -> tuple[bool, set[str]]: + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + work = state["worktrees"]["work"] + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return True, set() + + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, resolved + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1497,36 +1532,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 accepted, problems = _partition_fix_facts(facts) - needs_push = False - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + needs_push, resolved = _settle_fix_commits( + state, entry, ordered_range, unassigned, problems, accepted, resolved + ) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 319e83be..dc0083be 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1442,6 +1442,41 @@ def _partition_fix_facts( return accepted, problems +def _settle_fix_commits( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + unassigned: list[str], + problems: list[str], + accepted: list[tuple[str, str]], + resolved: set[str], +) -> tuple[bool, set[str]]: + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + work = state["worktrees"]["work"] + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return True, set() + + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, resolved + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1497,36 +1532,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 accepted, problems = _partition_fix_facts(facts) - needs_push = False - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + needs_push, resolved = _settle_fix_commits( + state, entry, ordered_range, unassigned, problems, accepted, resolved + ) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 319e83be..dc0083be 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1442,6 +1442,41 @@ def _partition_fix_facts( return accepted, problems +def _settle_fix_commits( + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + unassigned: list[str], + problems: list[str], + accepted: list[tuple[str, str]], + resolved: set[str], +) -> tuple[bool, set[str]]: + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + work = state["worktrees"]["work"] + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return True, set() + + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, resolved + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1497,36 +1532,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 accepted, problems = _partition_fix_facts(facts) - needs_push = False - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + needs_push, resolved = _settle_fix_commits( + state, entry, ordered_range, unassigned, problems, accepted, resolved + ) for review in entry["reviews"]: for finding in review["findings"]: From 2ec8e43d327786736268c246cf0b416ecfd9fc2c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 01:59:55 +0000 Subject: [PATCH 13/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20implementation=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当 bucket のモデル警告と成果指標加算を名前付き関数へ抽出する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 63 +++++++++++-------- .../cross-review/scripts/lib/metrics.py | 63 +++++++++++-------- .../cross-review/scripts/lib/metrics.py | 63 +++++++++++-------- .../cross-review/scripts/lib/metrics.py | 63 +++++++++++-------- 4 files changed, 144 insertions(+), 108 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index c2b576b4..338b52bf 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,41 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _aggregate_impl_round( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> dict[str, Any]: + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + return bucket + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -56,33 +91,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) if reviews: diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index c2b576b4..338b52bf 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,41 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _aggregate_impl_round( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> dict[str, Any]: + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + return bucket + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -56,33 +91,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) if reviews: diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index c2b576b4..338b52bf 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,41 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _aggregate_impl_round( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> dict[str, Any]: + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + return bucket + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -56,33 +91,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) if reviews: diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index c2b576b4..338b52bf 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,41 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _aggregate_impl_round( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> dict[str, Any]: + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + return bucket + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -56,33 +91,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) if reviews: From b3f43de050cbef1f486866dce282306b930d99c4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:00:41 +0000 Subject: [PATCH 14/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20first=20approval=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初回レビュー承認率の加算を実装担当集計関数へ含める。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 22 +++++++++---------- .../cross-review/scripts/lib/metrics.py | 22 +++++++++---------- .../cross-review/scripts/lib/metrics.py | 22 +++++++++---------- .../cross-review/scripts/lib/metrics.py | 22 +++++++++---------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index 338b52bf..f4794a4b 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -44,7 +44,7 @@ def _aggregate_impl_round( items_by_id: dict[str, dict[str, Any]], impl: dict[str, dict[str, Any]], unmeasured: list[str], -) -> dict[str, Any]: +) -> None: round_no = entry.get("round") impl_runtime = entry["impl"] impl_model = entry.get("impl_model") or {} @@ -71,7 +71,15 @@ def _aggregate_impl_round( bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - return bucket + reviews = _round_reviews(entry) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 def aggregate(state: dict[str, Any]) -> dict[str, Any]: @@ -91,17 +99,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index 338b52bf..f4794a4b 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -44,7 +44,7 @@ def _aggregate_impl_round( items_by_id: dict[str, dict[str, Any]], impl: dict[str, dict[str, Any]], unmeasured: list[str], -) -> dict[str, Any]: +) -> None: round_no = entry.get("round") impl_runtime = entry["impl"] impl_model = entry.get("impl_model") or {} @@ -71,7 +71,15 @@ def _aggregate_impl_round( bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - return bucket + reviews = _round_reviews(entry) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 def aggregate(state: dict[str, Any]) -> dict[str, Any]: @@ -91,17 +99,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index 338b52bf..f4794a4b 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -44,7 +44,7 @@ def _aggregate_impl_round( items_by_id: dict[str, dict[str, Any]], impl: dict[str, dict[str, Any]], unmeasured: list[str], -) -> dict[str, Any]: +) -> None: round_no = entry.get("round") impl_runtime = entry["impl"] impl_model = entry.get("impl_model") or {} @@ -71,7 +71,15 @@ def _aggregate_impl_round( bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - return bucket + reviews = _round_reviews(entry) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 def aggregate(state: dict[str, Any]) -> dict[str, Any]: @@ -91,17 +99,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 338b52bf..f4794a4b 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -44,7 +44,7 @@ def _aggregate_impl_round( items_by_id: dict[str, dict[str, Any]], impl: dict[str, dict[str, Any]], unmeasured: list[str], -) -> dict[str, Any]: +) -> None: round_no = entry.get("round") impl_runtime = entry["impl"] impl_model = entry.get("impl_model") or {} @@ -71,7 +71,15 @@ def _aggregate_impl_round( bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - return bucket + reviews = _round_reviews(entry) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 def aggregate(state: dict[str, Any]) -> dict[str, Any]: @@ -91,17 +99,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: impl_runtime = entry.get("impl") if not impl_runtime: continue - bucket = _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} From 0dd20395ea5b859d99600a5bf3e3af47ec0fc4fb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:01:44 +0000 Subject: [PATCH 15/21] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20reviewer=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとのモデル警告、時間、指摘、合意率の集計を名前付き関数へ抽出する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 82 ++++++++++--------- .../cross-review/scripts/lib/metrics.py | 82 ++++++++++--------- .../cross-review/scripts/lib/metrics.py | 82 ++++++++++--------- .../cross-review/scripts/lib/metrics.py | 82 ++++++++++--------- 4 files changed, 180 insertions(+), 148 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index f4794a4b..d0dfa9e3 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -82,6 +82,50 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (r for r in entry.get("reviewers", []) if r != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -95,49 +139,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index f4794a4b..d0dfa9e3 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -82,6 +82,50 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (r for r in entry.get("reviewers", []) if r != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -95,49 +139,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index f4794a4b..d0dfa9e3 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -82,6 +82,50 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (r for r in entry.get("reviewers", []) if r != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -95,49 +139,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index f4794a4b..d0dfa9e3 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -82,6 +82,50 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (r for r in entry.get("reviewers", []) if r != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -95,49 +139,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue _aggregate_impl_round(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From 5a01971335a638a37c20502678334acaed744492 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:05:22 +0000 Subject: [PATCH 16/21] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor=5Fagent=20early=20error"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3a27437d8242cf0ee6ea43e010e8b19639b397a5. --- .../cross-review/scripts/lib/monitor.py | 70 ++++++++----------- .../cross-review/scripts/lib/monitor.py | 70 ++++++++----------- .../cross-review/scripts/lib/monitor.py | 70 ++++++++----------- .../cross-review/scripts/lib/monitor.py | 70 ++++++++----------- 4 files changed, 116 insertions(+), 164 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 3227591b..187f64f3 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -671,32 +671,6 @@ def _check_result_age_completion( return True -def _check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - no_early_error: bool, - warned_early_error: bool, -) -> tuple[bool, str | None]: - if no_early_error: - return False, None - - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - return True, None - - if not warned_early_error: - return False, _scan_early_warn(paths.err_log) - return False, None - - def monitor_agent( agent: str, pr: int, @@ -805,21 +779,35 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - early_error, warn_err = _check_early_error( - agent, paths, status, no_early_error, warned_early_error - ) - if early_error: - if alive: - _kill_pid(pid) - _emit_log(log_prefix, agent, status) - return status - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 3227591b..187f64f3 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -671,32 +671,6 @@ def _check_result_age_completion( return True -def _check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - no_early_error: bool, - warned_early_error: bool, -) -> tuple[bool, str | None]: - if no_early_error: - return False, None - - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - return True, None - - if not warned_early_error: - return False, _scan_early_warn(paths.err_log) - return False, None - - def monitor_agent( agent: str, pr: int, @@ -805,21 +779,35 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - early_error, warn_err = _check_early_error( - agent, paths, status, no_early_error, warned_early_error - ) - if early_error: - if alive: - _kill_pid(pid) - _emit_log(log_prefix, agent, status) - return status - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 3227591b..187f64f3 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -671,32 +671,6 @@ def _check_result_age_completion( return True -def _check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - no_early_error: bool, - warned_early_error: bool, -) -> tuple[bool, str | None]: - if no_early_error: - return False, None - - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - return True, None - - if not warned_early_error: - return False, _scan_early_warn(paths.err_log) - return False, None - - def monitor_agent( agent: str, pr: int, @@ -805,21 +779,35 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - early_error, warn_err = _check_early_error( - agent, paths, status, no_early_error, warned_early_error - ) - if early_error: - if alive: - _kill_pid(pid) - _emit_log(log_prefix, agent, status) - return status - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 3227591b..187f64f3 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -671,32 +671,6 @@ def _check_result_age_completion( return True -def _check_early_error( - agent: str, - paths: AgentPaths, - status: AgentStatus, - no_early_error: bool, - warned_early_error: bool, -) -> tuple[bool, str | None]: - if no_early_error: - return False, None - - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - return True, None - - if not warned_early_error: - return False, _scan_early_warn(paths.err_log) - return False, None - - def monitor_agent( agent: str, pr: int, @@ -805,21 +779,35 @@ def monitor_agent( # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - early_error, warn_err = _check_early_error( - agent, paths, status, no_early_error, warned_early_error - ) - if early_error: - if alive: - _kill_pid(pid) - _emit_log(log_prefix, agent, status) - return status - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 From 8e7311a7fefa2b3f384eeb91bfda61471ddf7e8d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:05:22 +0000 Subject: [PATCH 17/21] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor=5Fagent=20result=20age=20com?= =?UTF-8?q?pletion"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5034910ac7f4e57246dfd052be77faff2a38262c. --- .../cross-review/scripts/lib/monitor.py | 59 +++++++------------ .../cross-review/scripts/lib/monitor.py | 59 +++++++------------ .../cross-review/scripts/lib/monitor.py | 59 +++++++------------ .../cross-review/scripts/lib/monitor.py | 59 +++++++------------ 4 files changed, 80 insertions(+), 156 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 187f64f3..a83272cd 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -636,41 +636,6 @@ def _check_sentinel_completion( return True -def _check_result_age_completion( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> bool: - if not ( - _pid_alive(pid) - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return False - - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return True - - def monitor_agent( agent: str, pr: int, @@ -745,11 +710,27 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if _check_result_age_completion( - pid, paths, status, started_wall, cmdline_validated + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - _emit_log(log_prefix, agent, status) - return status + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 187f64f3..a83272cd 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -636,41 +636,6 @@ def _check_sentinel_completion( return True -def _check_result_age_completion( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> bool: - if not ( - _pid_alive(pid) - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return False - - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return True - - def monitor_agent( agent: str, pr: int, @@ -745,11 +710,27 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if _check_result_age_completion( - pid, paths, status, started_wall, cmdline_validated + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - _emit_log(log_prefix, agent, status) - return status + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 187f64f3..a83272cd 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -636,41 +636,6 @@ def _check_sentinel_completion( return True -def _check_result_age_completion( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> bool: - if not ( - _pid_alive(pid) - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return False - - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return True - - def monitor_agent( agent: str, pr: int, @@ -745,11 +710,27 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if _check_result_age_completion( - pid, paths, status, started_wall, cmdline_validated + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - _emit_log(log_prefix, agent, status) - return status + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 187f64f3..a83272cd 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -636,41 +636,6 @@ def _check_sentinel_completion( return True -def _check_result_age_completion( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> bool: - if not ( - _pid_alive(pid) - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return False - - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return True - - def monitor_agent( agent: str, pr: int, @@ -745,11 +710,27 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if _check_result_age_completion( - pid, paths, status, started_wall, cmdline_validated + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - _emit_log(log_prefix, agent, status) - return status + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From 8c40c702c32668e4131388047f0be6d7c3ca81bd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:05:22 +0000 Subject: [PATCH 18/21] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor=5Fagent=20sentinel=20complet?= =?UTF-8?q?ion"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0a81b26c31f205d96f533c08f3a2732fde04ebcd. --- .../cross-review/scripts/lib/monitor.py | 38 +++++++------------ .../cross-review/scripts/lib/monitor.py | 38 +++++++------------ .../cross-review/scripts/lib/monitor.py | 38 +++++++------------ .../cross-review/scripts/lib/monitor.py | 38 +++++++------------ 4 files changed, 56 insertions(+), 96 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index a83272cd..401a760f 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,29 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_sentinel_completion( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> bool: - if not ( - agent == "codex" - and _pid_alive(pid) - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" - return True - - def monitor_agent( agent: str, pr: int, @@ -699,7 +676,20 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if _check_sentinel_completion(agent, pid, paths, status): + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index a83272cd..401a760f 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,29 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_sentinel_completion( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> bool: - if not ( - agent == "codex" - and _pid_alive(pid) - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" - return True - - def monitor_agent( agent: str, pr: int, @@ -699,7 +676,20 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if _check_sentinel_completion(agent, pid, paths, status): + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index a83272cd..401a760f 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,29 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_sentinel_completion( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> bool: - if not ( - agent == "codex" - and _pid_alive(pid) - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" - return True - - def monitor_agent( agent: str, pr: int, @@ -699,7 +676,20 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if _check_sentinel_completion(agent, pid, paths, status): + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) _emit_log(log_prefix, agent, status) return status diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index a83272cd..401a760f 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,29 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_sentinel_completion( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> bool: - if not ( - agent == "codex" - and _pid_alive(pid) - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return False - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" - return True - - def monitor_agent( agent: str, pr: int, @@ -699,7 +676,20 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if _check_sentinel_completion(agent, pid, paths, status): + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) _emit_log(log_prefix, agent, status) return status From 42446e3e8f97649e715693db0f7df7adf93ac843 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:21:22 +0000 Subject: [PATCH 19/21] =?UTF-8?q?Revert:=20=E6=A9=9F=E6=A2=B0=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=82=92=E9=80=9A=E3=81=A3=E3=81=A6=E3=81=84=E3=81=AA?= =?UTF-8?q?=E3=81=84=20Round=201=20=E3=81=AE=E9=81=A9=E7=94=A8=E3=82=92?= =?UTF-8?q?=E5=85=A8=E3=81=A6=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-apply の検証で R1-001 が差分予算超過(実差分 668 行 / 予算 220 行)で 失敗し、続く R1-002 の取り消しが後続項目のコミットと競合して失敗した。 R1-003 以降は判定に到達していない。状態ファイルには適用結果が一切記録されて おらず、どのコミットが検証を通ったか確定できないため、ツリーを着手前 (e6d7222)へ戻す。 検証結果は issues/ の実機検証レポートに記録する。 --- .../cross-refactoring/scripts/refactor.py | 374 +++++++----------- .../cross-review/scripts/lib/metrics.py | 161 ++++---- .../cross-refactoring/scripts/refactor.py | 374 +++++++----------- .../cross-review/scripts/lib/metrics.py | 161 ++++---- .../cross-refactoring/scripts/refactor.py | 374 +++++++----------- .../cross-review/scripts/lib/metrics.py | 161 ++++---- .../cross-refactoring/scripts/refactor.py | 374 +++++++----------- .../cross-review/scripts/lib/metrics.py | 161 ++++---- 8 files changed, 868 insertions(+), 1272 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index dc0083be..082dc773 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -899,115 +899,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _find_unassigned_commits( - work: str, - reported_shas: list[str], - ordered_range: list[str], -) -> list[str]: - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - return sorted(set(ordered_range) - reported_full) - - -def verify_commit_assignment( - work: str, - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], -) -> tuple[list[str], list[str]]: - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id in entry["items"]: - result = reported.get(item_id) - if result is None: - continue - for sha in _reported_shas(result): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - reported_shas = [ - sha - for result in reported.values() - for sha in _reported_shas(result) - ] - unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) - return unassigned, duplicated - - -def _abandon_round( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - dry_run: bool, -) -> None: - for item_id in entry["items"]: - item = _find_item(state, item_id) - item["status"] = "abandoned" - item["failure_reason"] = reason - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, dry_run) - if not dry_run: - work = state["worktrees"]["work"] - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - - -def _verify_and_settle_items( - state: dict[str, Any], - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str], int]: - work = state["worktrees"]["work"] - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - timeout, - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - return applied, failed, reverted - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1106,7 +997,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1130,10 +1032,22 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _abandon_round(state, entry, ordered_range, reason, args.dry_run) + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, args.dry_run) + if not args.dry_run: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1142,22 +1056,46 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } + state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied, failed, reverted = _verify_and_settle_items( - state, - entry, - reported, - in_range, - test_command, - head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - args.dry_run, - ) + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + # **検証に失敗した項目のコミットを Pull Request に残さない。** + # 実装担当は項目ごとに push しているため、状態を `abandoned` にする + # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, args.dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") entry["apply"] = { "applied": applied, @@ -1376,107 +1314,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) -def _verified_resolved_threads( - payload: dict[str, Any], - state: dict[str, Any], -) -> set[str]: - raw_claimed = payload.get("resolved_thread_ids") - claimed = { - thread_id - for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(thread_id, str) and thread_id.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return claimed & actual - - -def _collect_fix_facts( - state: dict[str, Any], - entry: dict[str, Any], - payload: dict[str, Any], - head_now: str, -) -> tuple[list[str], list[dict[str, Any]], list[str]]: - work = state["worktrees"]["work"] - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - baseline = state.get("baseline_test") or {} - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, facts, unassigned - - -def _partition_fix_facts( - facts: list[dict[str, Any]], -) -> tuple[list[tuple[str, str]], list[str]]: - accepted: list[tuple[str, str]] = [] - problems: list[str] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, problems - - -def _settle_fix_commits( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - unassigned: list[str], - problems: list[str], - accepted: list[tuple[str, str]], - resolved: set[str], -) -> tuple[bool, set[str]]: - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - work = state["worktrees"]["work"] - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - info("⚠ 修正を取り消したため、解決の申告は採用しません") - return True, set() - - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, resolved - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1519,22 +1356,101 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - resolved = _verified_resolved_threads(payload, state) + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - ordered_range, facts, unassigned = _collect_fix_facts( - state, entry, payload, head_now + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _partition_fix_facts(facts) - needs_push, resolved = _settle_fix_commits( - state, entry, ordered_range, unassigned, problems, accepted, resolved - ) + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + needs_push = False + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True + info("⚠ 修正を取り消したため、解決の申告は採用しません") + resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index d0dfa9e3..c2b576b4 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -39,93 +39,6 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None -def _aggregate_impl_round( - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - impl_runtime = entry["impl"] - impl_model = entry.get("impl_model") or {} - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, reviewer) == "APPROVE" - for reviewer in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - -def _aggregate_reviewer_round( - entry: dict[str, Any], - reviews: list[dict[str, Any]], - reviewer: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - requested = spec.get("requested") - observed = spec.get("observed") - warning = _models.mismatch_warning(name, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(name, requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - bucket["reviews"] += 1 - findings = [ - finding for finding in review.get("findings", []) - if isinstance(finding, dict) and finding.get("reviewer") == name - ] - bucket["findings"] += len(findings) - bucket["findings_resolved"] += sum( - 1 for finding in findings if finding.get("resolved") - ) - for other in (r for r in entry.get("reviewers", []) if r != name): - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - bucket["verdict_pairs"] += 1 - bucket["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -139,13 +52,83 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): + round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + impl_model = (entry.get("impl_model") or {}) + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + r_requested = spec.get("requested") + r_observed = spec.get("observed") + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index dc0083be..082dc773 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -899,115 +899,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _find_unassigned_commits( - work: str, - reported_shas: list[str], - ordered_range: list[str], -) -> list[str]: - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - return sorted(set(ordered_range) - reported_full) - - -def verify_commit_assignment( - work: str, - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], -) -> tuple[list[str], list[str]]: - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id in entry["items"]: - result = reported.get(item_id) - if result is None: - continue - for sha in _reported_shas(result): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - reported_shas = [ - sha - for result in reported.values() - for sha in _reported_shas(result) - ] - unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) - return unassigned, duplicated - - -def _abandon_round( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - dry_run: bool, -) -> None: - for item_id in entry["items"]: - item = _find_item(state, item_id) - item["status"] = "abandoned" - item["failure_reason"] = reason - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, dry_run) - if not dry_run: - work = state["worktrees"]["work"] - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - - -def _verify_and_settle_items( - state: dict[str, Any], - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str], int]: - work = state["worktrees"]["work"] - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - timeout, - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - return applied, failed, reverted - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1106,7 +997,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1130,10 +1032,22 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _abandon_round(state, entry, ordered_range, reason, args.dry_run) + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, args.dry_run) + if not args.dry_run: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1142,22 +1056,46 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } + state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied, failed, reverted = _verify_and_settle_items( - state, - entry, - reported, - in_range, - test_command, - head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - args.dry_run, - ) + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + # **検証に失敗した項目のコミットを Pull Request に残さない。** + # 実装担当は項目ごとに push しているため、状態を `abandoned` にする + # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, args.dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") entry["apply"] = { "applied": applied, @@ -1376,107 +1314,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) -def _verified_resolved_threads( - payload: dict[str, Any], - state: dict[str, Any], -) -> set[str]: - raw_claimed = payload.get("resolved_thread_ids") - claimed = { - thread_id - for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(thread_id, str) and thread_id.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return claimed & actual - - -def _collect_fix_facts( - state: dict[str, Any], - entry: dict[str, Any], - payload: dict[str, Any], - head_now: str, -) -> tuple[list[str], list[dict[str, Any]], list[str]]: - work = state["worktrees"]["work"] - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - baseline = state.get("baseline_test") or {} - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, facts, unassigned - - -def _partition_fix_facts( - facts: list[dict[str, Any]], -) -> tuple[list[tuple[str, str]], list[str]]: - accepted: list[tuple[str, str]] = [] - problems: list[str] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, problems - - -def _settle_fix_commits( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - unassigned: list[str], - problems: list[str], - accepted: list[tuple[str, str]], - resolved: set[str], -) -> tuple[bool, set[str]]: - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - work = state["worktrees"]["work"] - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - info("⚠ 修正を取り消したため、解決の申告は採用しません") - return True, set() - - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, resolved - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1519,22 +1356,101 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - resolved = _verified_resolved_threads(payload, state) + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - ordered_range, facts, unassigned = _collect_fix_facts( - state, entry, payload, head_now + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _partition_fix_facts(facts) - needs_push, resolved = _settle_fix_commits( - state, entry, ordered_range, unassigned, problems, accepted, resolved - ) + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + needs_push = False + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True + info("⚠ 修正を取り消したため、解決の申告は採用しません") + resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index d0dfa9e3..c2b576b4 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -39,93 +39,6 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None -def _aggregate_impl_round( - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - impl_runtime = entry["impl"] - impl_model = entry.get("impl_model") or {} - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, reviewer) == "APPROVE" - for reviewer in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - -def _aggregate_reviewer_round( - entry: dict[str, Any], - reviews: list[dict[str, Any]], - reviewer: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - requested = spec.get("requested") - observed = spec.get("observed") - warning = _models.mismatch_warning(name, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(name, requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - bucket["reviews"] += 1 - findings = [ - finding for finding in review.get("findings", []) - if isinstance(finding, dict) and finding.get("reviewer") == name - ] - bucket["findings"] += len(findings) - bucket["findings_resolved"] += sum( - 1 for finding in findings if finding.get("resolved") - ) - for other in (r for r in entry.get("reviewers", []) if r != name): - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - bucket["verdict_pairs"] += 1 - bucket["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -139,13 +52,83 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): + round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + impl_model = (entry.get("impl_model") or {}) + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + r_requested = spec.get("requested") + r_observed = spec.get("observed") + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index dc0083be..082dc773 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -899,115 +899,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _find_unassigned_commits( - work: str, - reported_shas: list[str], - ordered_range: list[str], -) -> list[str]: - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - return sorted(set(ordered_range) - reported_full) - - -def verify_commit_assignment( - work: str, - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], -) -> tuple[list[str], list[str]]: - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id in entry["items"]: - result = reported.get(item_id) - if result is None: - continue - for sha in _reported_shas(result): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - reported_shas = [ - sha - for result in reported.values() - for sha in _reported_shas(result) - ] - unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) - return unassigned, duplicated - - -def _abandon_round( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - dry_run: bool, -) -> None: - for item_id in entry["items"]: - item = _find_item(state, item_id) - item["status"] = "abandoned" - item["failure_reason"] = reason - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, dry_run) - if not dry_run: - work = state["worktrees"]["work"] - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - - -def _verify_and_settle_items( - state: dict[str, Any], - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str], int]: - work = state["worktrees"]["work"] - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - timeout, - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - return applied, failed, reverted - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1106,7 +997,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1130,10 +1032,22 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _abandon_round(state, entry, ordered_range, reason, args.dry_run) + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, args.dry_run) + if not args.dry_run: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1142,22 +1056,46 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } + state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied, failed, reverted = _verify_and_settle_items( - state, - entry, - reported, - in_range, - test_command, - head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - args.dry_run, - ) + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + # **検証に失敗した項目のコミットを Pull Request に残さない。** + # 実装担当は項目ごとに push しているため、状態を `abandoned` にする + # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, args.dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") entry["apply"] = { "applied": applied, @@ -1376,107 +1314,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) -def _verified_resolved_threads( - payload: dict[str, Any], - state: dict[str, Any], -) -> set[str]: - raw_claimed = payload.get("resolved_thread_ids") - claimed = { - thread_id - for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(thread_id, str) and thread_id.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return claimed & actual - - -def _collect_fix_facts( - state: dict[str, Any], - entry: dict[str, Any], - payload: dict[str, Any], - head_now: str, -) -> tuple[list[str], list[dict[str, Any]], list[str]]: - work = state["worktrees"]["work"] - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - baseline = state.get("baseline_test") or {} - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, facts, unassigned - - -def _partition_fix_facts( - facts: list[dict[str, Any]], -) -> tuple[list[tuple[str, str]], list[str]]: - accepted: list[tuple[str, str]] = [] - problems: list[str] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, problems - - -def _settle_fix_commits( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - unassigned: list[str], - problems: list[str], - accepted: list[tuple[str, str]], - resolved: set[str], -) -> tuple[bool, set[str]]: - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - work = state["worktrees"]["work"] - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - info("⚠ 修正を取り消したため、解決の申告は採用しません") - return True, set() - - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, resolved - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1519,22 +1356,101 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - resolved = _verified_resolved_threads(payload, state) + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - ordered_range, facts, unassigned = _collect_fix_facts( - state, entry, payload, head_now + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _partition_fix_facts(facts) - needs_push, resolved = _settle_fix_commits( - state, entry, ordered_range, unassigned, problems, accepted, resolved - ) + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + needs_push = False + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True + info("⚠ 修正を取り消したため、解決の申告は採用しません") + resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index d0dfa9e3..c2b576b4 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -39,93 +39,6 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None -def _aggregate_impl_round( - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - impl_runtime = entry["impl"] - impl_model = entry.get("impl_model") or {} - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, reviewer) == "APPROVE" - for reviewer in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - -def _aggregate_reviewer_round( - entry: dict[str, Any], - reviews: list[dict[str, Any]], - reviewer: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - requested = spec.get("requested") - observed = spec.get("observed") - warning = _models.mismatch_warning(name, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(name, requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - bucket["reviews"] += 1 - findings = [ - finding for finding in review.get("findings", []) - if isinstance(finding, dict) and finding.get("reviewer") == name - ] - bucket["findings"] += len(findings) - bucket["findings_resolved"] += sum( - 1 for finding in findings if finding.get("resolved") - ) - for other in (r for r in entry.get("reviewers", []) if r != name): - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - bucket["verdict_pairs"] += 1 - bucket["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -139,13 +52,83 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): + round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + impl_model = (entry.get("impl_model") or {}) + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + r_requested = spec.get("requested") + r_observed = spec.get("observed") + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index dc0083be..082dc773 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -899,115 +899,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _find_unassigned_commits( - work: str, - reported_shas: list[str], - ordered_range: list[str], -) -> list[str]: - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - return sorted(set(ordered_range) - reported_full) - - -def verify_commit_assignment( - work: str, - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], -) -> tuple[list[str], list[str]]: - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id in entry["items"]: - result = reported.get(item_id) - if result is None: - continue - for sha in _reported_shas(result): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - reported_shas = [ - sha - for result in reported.values() - for sha in _reported_shas(result) - ] - unassigned = _find_unassigned_commits(work, reported_shas, list(in_range)) - return unassigned, duplicated - - -def _abandon_round( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - reason: str, - dry_run: bool, -) -> None: - for item_id in entry["items"]: - item = _find_item(state, item_id) - item["status"] = "abandoned" - item["failure_reason"] = reason - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - _revert_item_commits(state, whole_round, dry_run) - if not dry_run: - work = state["worktrees"]["work"] - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - - -def _verify_and_settle_items( - state: dict[str, Any], - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str], int]: - work = state["worktrees"]["work"] - applied: list[str] = [] - failed: list[str] = [] - reverted = 0 - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - timeout, - ) - problem = verify_apply_item(item, facts) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, dry_run) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - return applied, failed, reverted - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1106,7 +997,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - unassigned, duplicated = verify_commit_assignment(work, entry, reported, in_range) + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1130,10 +1032,22 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "残さないため、ラウンドごと取り消します" ) info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _abandon_round(state, entry, ordered_range, reason, args.dry_run) + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + _revert_item_commits(state, whole_round, args.dry_run) + if not args.dry_run: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1142,22 +1056,46 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "duplicated_commits": duplicated, "merged_at": statefile.now(), } + state["phase"] = "propose" if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: _push_with_retry_marker(path, state, entry) sys.exit(2) - applied, failed, reverted = _verify_and_settle_items( - state, - entry, - reported, - in_range, - test_command, - head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - args.dry_run, - ) + applied: list[str] = [] + failed: list[str] = [] + reverted = 0 + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + problem = verify_apply_item(item, facts) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + # **検証に失敗した項目のコミットを Pull Request に残さない。** + # 実装担当は項目ごとに push しているため、状態を `abandoned` にする + # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["commits"] = _reported_shas(got) + reverted += _revert_item_commits(state, item, args.dry_run) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + continue + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") entry["apply"] = { "applied": applied, @@ -1376,107 +1314,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: _push_with_retry_marker(path, state, entry) -def _verified_resolved_threads( - payload: dict[str, Any], - state: dict[str, Any], -) -> set[str]: - raw_claimed = payload.get("resolved_thread_ids") - claimed = { - thread_id - for thread_id in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(thread_id, str) and thread_id.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return claimed & actual - - -def _collect_fix_facts( - state: dict[str, Any], - entry: dict[str, Any], - payload: dict[str, Any], - head_now: str, -) -> tuple[list[str], list[dict[str, Any]], list[str]]: - work = state["worktrees"]["work"] - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - unassigned = _find_unassigned_commits(work, reported_shas, ordered_range) - baseline = state.get("baseline_test") or {} - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - return ordered_range, facts, unassigned - - -def _partition_fix_facts( - facts: list[dict[str, Any]], -) -> tuple[list[tuple[str, str]], list[str]]: - accepted: list[tuple[str, str]] = [] - problems: list[str] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, problems - - -def _settle_fix_commits( - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - unassigned: list[str], - problems: list[str], - accepted: list[tuple[str, str]], - resolved: set[str], -) -> tuple[bool, set[str]]: - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - work = state["worktrees"]["work"] - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - info("⚠ 修正を取り消したため、解決の申告は採用しません") - return True, set() - - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, resolved - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1519,22 +1356,101 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - resolved = _verified_resolved_threads(payload, state) + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - ordered_range, facts, unassigned = _collect_fix_facts( - state, entry, payload, head_now + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _partition_fix_facts(facts) - needs_push, resolved = _settle_fix_commits( - state, entry, ordered_range, unassigned, problems, accepted, resolved - ) + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + needs_push = False + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True + info("⚠ 修正を取り消したため、解決の申告は採用しません") + resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index d0dfa9e3..c2b576b4 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -39,93 +39,6 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None -def _aggregate_impl_round( - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - impl_runtime = entry["impl"] - impl_model = entry.get("impl_model") or {} - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, reviewer) == "APPROVE" - for reviewer in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - -def _aggregate_reviewer_round( - entry: dict[str, Any], - reviews: list[dict[str, Any]], - reviewer: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - round_no = entry.get("round") - reviewer_models = entry.get("reviewer_models") or {} - for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - requested = spec.get("requested") - observed = spec.get("observed") - warning = _models.mismatch_warning(name, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(name, requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - bucket["reviews"] += 1 - findings = [ - finding for finding in review.get("findings", []) - if isinstance(finding, dict) and finding.get("reviewer") == name - ] - bucket["findings"] += len(findings) - bucket["findings_resolved"] += sum( - 1 for finding in findings if finding.get("resolved") - ) - for other in (r for r in entry.get("reviewers", []) if r != name): - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - bucket["verdict_pairs"] += 1 - bucket["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -139,13 +52,83 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): + round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + impl_model = (entry.get("impl_model") or {}) + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - _aggregate_reviewer_round(entry, reviews, reviewer, unmeasured) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + reviewer_models = entry.get("reviewer_models") or {} + for name in entry.get("reviewers", []): + spec = reviewer_models.get(name) or {} + r_requested = spec.get("requested") + r_observed = spec.get("observed") + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From be73a09012ccfce8d6b2ef1e96cd2f2ce71b8f3c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:24:12 +0000 Subject: [PATCH 20/21] =?UTF-8?q?Docs:=20cross-refactoring=20=E5=AE=9F?= =?UTF-8?q?=E6=A9=9F=E6=A4=9C=E8=A8=BC=E3=83=AC=E3=83=9D=E3=83=BC=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 収束ループを Pull Request #118 で実行し、提案の統合までは設計どおり動いたが、 適用結果の検証で失敗した項目を取り消す経路が破綻して進行を続行できなくなった。 発見した 9 件の不具合と、検証に至らなかった範囲を記録する。 --- ...ssue-113-cross-refactoring-trial-report.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-trial-report.md diff --git a/issues/issue-113-cross-refactoring-trial-report.md b/issues/issue-113-cross-refactoring-trial-report.md new file mode 100644 index 00000000..59794bea --- /dev/null +++ b/issues/issue-113-cross-refactoring-trial-report.md @@ -0,0 +1,208 @@ +# cross-refactoring 実機検証レポート + +`/ndf:cross-refactoring` を自分自身のスクリプト群へ適用し、収束ループが実機で成立するかを確かめた。 +**提案フェーズは設計どおり動いたが、適用結果の検証で失敗した項目を取り消す経路が破綻し、 +進行を続行できない状態になった。** 発見した不具合は 9 件で、うち 6 件は収束ループの続行を妨げる。 + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| 対象 | Pull Request #118(Draft) | +| 範囲 | `plugins/ndf-shared/skills/cross-refactoring/scripts` / `plugins/ndf-shared/skills/cross-review/scripts/lib` | +| ホスト | Claude Code(提案・レビューには不参加) | +| 提案・レビュー | codex / gemini / kiro | +| 適用の母集合 | claude / codex / kiro | +| モデル | 各 CLI の既定(kiro は `auto` のため計測集計の対象外) | +| ラウンド上限 | 3 | +| 着手前テスト | 387 件成功(23.6 秒) | + +## 到達点 + +```mermaid +flowchart TD + Init([初期化・作業ディレクトリ・手順書配置]):::ok --> Propose + Propose["提案(3 CLI 並列)
11 件"]:::ok --> Merge + Merge["統合 8 件 → 採用 5 件 / 見送り 3 件"]:::ok --> Apply + Apply["適用(codex)
5 項目・14 コミット"]:::ok --> Verify + Verify["適用結果の機械検証"]:::partial --> Revert + Revert["失敗項目の取り消し"]:::ng --> Stop + Stop["取り消し失敗を握り潰し次ラウンドへ
状態ファイルは空のまま"]:::ng + Review["レビュー・修正・収束判定"]:::untested + + classDef ok fill:#dfd,stroke:#383 + classDef partial fill:#ffd,stroke:#883 + classDef ng fill:#fdd,stroke:#933 + classDef untested fill:#eee,stroke:#999 +``` + +所要時間は提案が 135〜180 秒、適用が 1,066 秒だった。 + +## 設計どおり働いた部分 + +| 機能 | 確認できたこと | +| --- | --- | +| 母集合の決定 | ホストを提案・レビューから除外し、適用は 3 者の輪番で割り当てた | +| 並列起動と監視 | 3 つの外部プロセスを起動し、経過・停滞・異常終了を検知した | +| 提案の統合 | 対象と兆候が同じ提案を 1 件へまとめ、合意数と重要度で優先度を付けた | +| 差分予算の検証 | 見積もりの 3 倍に膨らんだ変更を範囲逸脱として捕まえた(実差分 668 行 / 予算 220 行) | +| 実行主体の記録 | 全 14 コミットに改善項目・ラウンド・実行ランタイム・モデルの 4 項目が入った | + +## 不具合 + +進行を止めるものから順に並べる。 + +### 1. 取り消しが他の改善項目のコミットと競合して失敗する + +検証に失敗した改善項目を取り消そうとして、同じファイルを後から変更した別項目のコミットと競合した。 + +```text +❌ R1-002 のコミット ea3209c を取り消せませんでした: error: could not revert ea3209c... +hint: After resolving the conflicts, mark them with "git add/rm " +``` + +手順書は「取り消しは履歴から新しい順に並べ直す」と定めているが、この並べ替えは**同じ項目に属する +コミットの中でしか働かない**。取り消し対象より新しい別項目のコミットが同じ箇所を触っていると、 +必ず競合する。採用した 5 件のうち 4 件が同一ファイルの隣接領域を変更していた。 + +### 2. 取り消しに失敗しても中断しない + +手順書は「取り消しに失敗したら、着手前の状態まで戻してから中断する」と定めている。 +実機では中断せず「全件失敗」として次の提案ラウンドへ進み、検証を通っていない変更が +Pull Request に残ったまま、その上で新しい提案が始まった。 + +戻した先も着手前ではなく、取り消しが途中まで進んだ地点だった。 + +### 3. 適用結果が状態ファイルへ記録されない + +検証が中断したため、適用の記録が一切残らなかった。 + +```console +$ jq -r '.items[] | "\(.item_id) status=\(.status) commits=\(.commits|length)"' <状態ファイル> +R1-001 status=pending commits=0 +R1-002 status=pending commits=0 +R1-003 status=pending commits=0 +R1-004 status=pending commits=0 +R1-005 status=pending commits=0 + +$ jq -r '.rounds[0].apply' <状態ファイル> +{"applied": [], "failed": [], "base_sha": null, "head_sha": null} +``` + +一方でリポジトリには 14 件の適用コミットと 3 件の取り消しコミットが存在した。 +**どのコミットが検証を通ったのかを状態から復元できず、同じ手順を叩き直しても再開できない。** +再開可能性は収束ループの前提なので、ここが崩れると中断からの復帰手段が無くなる。 + +### 4. 検証を通っていない変更が公開されたまま残る + +実装担当は改善項目ごとに変更を公開する。取り消しは手元にしか無く公開されなかったため、 +Pull Request 側には検証を通っていない 14 件が残り、取り消し 3 件は反映されなかった。 +再送信を促すための印も立っていなかった。 + +### 5. 範囲外のファイル変更を検証しない + +指定した範囲は編集元のみだが、実際には配布物 3 系統も変更されていた。 + +```console +$ git diff --name-status e6d7222 HEAD +M plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +M plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +M plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +M plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +M plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +M plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +M plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +M plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +``` + +このリポジトリでは編集元から配布物を生成する規約があり、実装担当の判断自体は規約に沿う。 +しかし範囲を必須にした目的(提案の発散と変更の肥大を防ぐ)からは外れ、 +差分が 4 倍に膨らんで差分予算の超過を招いた。範囲の指定が検証に反映されていない。 + +### 6. 提案の記録が次のラウンドで上書きされる + +結果ファイルの名前に、提案フェーズだけラウンド番号が入らない。 + +| フェーズ | ファイル名 | +| --- | --- | +| 提案 | `<ランタイム>-propose-rf118-result.json` | +| 適用 | `<ランタイム>-apply-r1-result.json` | +| レビュー | `<ランタイム>-review-r1-result.json` | + +外部プロセスの起動時に同名の結果ファイルを削除する実装のため、 +2 巡目の提案が始まった時点で 1 巡目の提案内容が失われた。 +統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは復元できない**。 + +### 7. 手順書を配置しても gemini が読めない + +生成物を差分に出さないため、配置先へ全件無視の設定を置いている。gemini はこの設定を +読み取りにも適用するため、配置した手順書を一切開けなかった。 + +```text +Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +is ignored by configured ignore patterns. +``` + +手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と書いている +前提が、実機では成立していない。作業ディレクトリ限定で読み取り側の除外を無効にすると解消した。 +設定の項目名は gemini の版で変わるため、新旧どちらの形式でも書く必要がある(0.55.1 で確認)。 + +### 8. 語彙の許容値をプロンプトが列挙していない + +上記を解消して手順書を読ませたところ、gemini は語彙を日本語で出力した。 + +| ランタイム | 兆候 | 手法 | +| --- | --- | --- | +| codex | `long_method` | `extract_method` | +| kiro | `long_method` | `extract_method` | +| gemini | `長すぎるメソッド` | `メソッドの抽出` | + +提案プロンプトは「手順書の語彙に限定する」と述べ、記入例に英字の識別子を 1 つ示すだけで、 +**許容値を列挙していない**。手順書の見出しは日本語なので、読んだ側が日本語を語彙と解釈する。 + +重要度は妥当に判定されていたにもかかわらず、語彙外の降格規則により最低の重要度へ落ち、 +しきい値未満として全件が見送られた。gemini の提案 4 件はいずれも内容としては妥当で、 +うち 1 件は他のランタイムも同じ箇所を指摘していた。 + +### 9. 初期化が外部 CLI の認証状態を確認しない + +初期化は必要な CLI の存在だけを確認する。未認証の CLI は起動から 15 秒で終了し、 +結果ファイルを残さないまま提案・レビューの担当から脱落した。 + +```console +$ kiro-cli whoami +Not logged in +``` + +初期化は成功として扱われるため、参加者が 1 人欠けた構成のまま進行する。 + +## 検証できていない範囲 + +以下は適用フェーズで停止したため実行に至っていない。**動作は未確認であり、 +問題が無いことを示すものではない。** + +- レビューフェーズ(レビュー担当 2 者の並列実行、指摘の投稿、承認判定) +- 指摘の修正と再レビューの繰り返し、修正上限到達時の項目単位の見送り +- 実装担当の輪番(2 巡目の割り当てまでは確認したが実行していない) +- 提案の重複率による収束判定 +- Pull Request 全体を対象とした最終確認との連携 +- 集計値の出力 + +## 修正の方向 + +| 不具合 | 方向 | +| --- | --- | +| 1 | 取り消しの単位を見直す。項目ごとの取り消しを保つなら、範囲内の全項目を新しい順にまとめて戻し、残す項目を積み直す | +| 2 | 取り消しの失敗を握り潰さず、着手前へ戻して進行を止める | +| 3 | 検証の途中経過を逐次記録し、中断しても到達点が状態から読めるようにする | +| 4 | 取り消しの反映が完了するまで印を残し、次の実行で再送信する | +| 5 | 指定した範囲の外を触った変更を検証で捕まえる。生成物の同期が必要な構成では、同期を進行側の責務として分離する | +| 6 | 提案の結果ファイル名にもラウンド番号を入れる | +| 7 | 手順書の配置先で読み取り側の除外を無効にする設定を併せて置く | +| 8 | 許容値をプロンプトへ機械的に列挙する。検証側が語彙集合を持っているので、そこから生成できる | +| 9 | 初期化で各 CLI の認証状態を確認し、未認証なら失敗させる | + +## Pull Request の状態 + +検証を通っていない変更を残さないため、対象範囲を着手前の内容へ戻した。 +現在の内容は着手前と一致し、テストは 387 件成功する。 From 5c70053cf416841b3e6835d05d6c02b8108cf4ca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 02:39:23 +0000 Subject: [PATCH 21/21] =?UTF-8?q?Docs:=20cross-refactoring=20=E4=B8=8D?= =?UTF-8?q?=E5=85=B7=E5=90=88=E4=BF=AE=E6=AD=A3=E3=81=AE=E5=BC=95=E7=B6=99?= =?UTF-8?q?=E3=81=8E=E3=83=A1=E3=83=A2=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 編集対象・着手順・先に決めること・再検証の手順を記録する。 不具合の内容とエビデンスは実機検証レポートを参照する。 --- ...issue-113-cross-refactoring-fix-handoff.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-fix-handoff.md diff --git a/issues/issue-113-cross-refactoring-fix-handoff.md b/issues/issue-113-cross-refactoring-fix-handoff.md new file mode 100644 index 00000000..1c5ef2ff --- /dev/null +++ b/issues/issue-113-cross-refactoring-fix-handoff.md @@ -0,0 +1,97 @@ +# cross-refactoring 不具合修正の引継ぎ + +実機検証で見つかった 9 件を修正するための作業メモ。 +不具合の内容・エビデンス・修正の方向は +[issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) にある。 +ここには**次に何をどの順で触るか**だけを書く。 + +## 編集対象 + +編集元だけを直し、配布物は生成する。 + +``` +plugins/ndf-shared/skills/ +├── cross-refactoring/ +│ ├── scripts/refactor.py # 検証・取り消し・状態記録 +│ ├── scripts/prepare-worktrees.sh # 手順書の配置 +│ ├── prompts/propose.md # 提案の語彙 +│ └── docs/ # 手順書本文(挙動を変えたら追従する) +└── cross-review/scripts/lib/ # 収束ループ共通層 +``` + +```bash +bash scripts/build-runtime-plugins.sh # 配布物を生成する +``` + +## 着手順 + +進行を止める不具合から直す。上 3 つが片付くまで実機での再検証はできない。 + +| 順 | 不具合 | 主な編集先 | +| --- | --- | --- | +| 1 | 取り消しが他項目のコミットと競合する | `refactor.py`(取り消し処理) | +| 2 | 取り消し失敗を握り潰して進行する | `refactor.py`(失敗時の中断)+ 進行スクリプト | +| 3 | 適用結果が状態ファイルへ残らない | `refactor.py`(検証の逐次記録) | +| 4 | 検証未通過の変更が公開されたまま残る | `refactor.py`(再送信の印) | +| 5 | 範囲外の変更を検証しない | `refactor.py`(範囲の検査) | +| 6 | 提案の記録が次ラウンドで上書きされる | 進行スクリプトの命名規則 | +| 7 | gemini が配置した手順書を読めない | `prepare-worktrees.sh` | +| 8 | 語彙の許容値をプロンプトが列挙しない | `propose.md` + 語彙集合の共有 | +| 9 | 初期化が CLI の認証を確認しない | `refactor.py`(初期化の事前確認) | + +1 と 2 は同じ経路にあるため一緒に設計する。取り消しの単位を項目のままにするか、 +ラウンド単位へ変えるかで 3 の記録内容も変わる。 + +## 先に決めること + +- **取り消しの単位**。項目単位を保つなら、範囲内の全項目を新しい順にまとめて戻し、 + 残す項目を積み直す形になる。ラウンド単位へ変えると実装は単純になるが、 + 「合意済みの項目は残す」という設計方針を捨てることになる +- **配布物の同期を誰の責務にするか**。実装担当に同期させると範囲外の変更が生まれ、 + 差分予算にも影響する。進行側が収束後にまとめて生成する形が範囲の指定と整合する + +## 検証済みの回避策 + +不具合 7 の回避は**プラグインキャッシュにだけ**入れてある。編集元へは未反映。 + +``` +~/.claude/plugins/cache/ai-plugins/ndf/8.1.0/skills/cross-refactoring/scripts/prepare-worktrees.sh +``` + +作業ディレクトリの `.gemini/` へ設定を置き、読み取り側の除外を無効にする。 +`.gemini/` ごと無視して差分に出さない。設定の項目名は gemini の版で変わるため、 +`context.fileFiltering` と `fileFiltering` の両方を書く(0.55.1 で読み取り成功を確認)。 + +キャッシュは再インストールで失われる。編集元へ移すこと。 + +## 再検証の手順 + +```bash +# 着手前テスト(実測 387 件成功 / 23.6 秒) +uv run --with pytest python -m pytest \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/tests -q + +# 実機 +/ndf:cross-refactoring \ + --scope plugins/ndf-shared/skills/cross-refactoring/scripts \ + plugins/ndf-shared/skills/cross-review/scripts/lib \ + --baseline-test "<上のテストコマンド>" +``` + +実行前に確認すること。 + +- `kiro-cli whoami` が認証済みを返す(不具合 9 が直るまでは手で確認する) +- 進行を駆動する作業ディレクトリが対象ブランチを掴んでいない + (同じブランチを 2 か所へ展開できないため初期化に失敗する) + +## 未検証の範囲 + +レビューフェーズより後は一度も実行できていない。修正後の再検証では、 +少なくとも次を通すまで確認する。 + +- レビュー担当 2 者の並列実行と指摘の投稿、承認判定 +- 指摘の修正と再レビューの繰り返し、上限到達時の項目単位の見送り +- 実装担当の輪番 +- 提案の重複率による収束判定 +- 集計値の出力