From 4642657eec505e57b0f40703203f56bdb5777a43 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:35:26 +0000 Subject: [PATCH 01/34] =?UTF-8?q?Docs:=20cross-refactoring=204=20=E5=9B=9E?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E5=AE=9F=E6=A9=9F=E8=A9=A6=E8=A1=8C=E8=A8=88?= =?UTF-8?q?=E7=94=BB=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-4th-trial.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-4th-trial.md diff --git a/issues/issue-113-cross-refactoring-4th-trial.md b/issues/issue-113-cross-refactoring-4th-trial.md new file mode 100644 index 00000000..254d3400 --- /dev/null +++ b/issues/issue-113-cross-refactoring-4th-trial.md @@ -0,0 +1,51 @@ +# cross-refactoring 4 回目の実機試行 + +Pull Request #127(NDF v8.5.0)で直した 5 件が実機で成立するかを確かめる。 + +経緯は次の 3 つにある。 + +- [issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) — 1 回目 +- [issue-113-cross-refactoring-retrial.md](issue-113-cross-refactoring-retrial.md) — 2 回目 +- [issue-113-cross-refactoring-re-retrial.md](issue-113-cross-refactoring-re-retrial.md) — 3 回目 + +## 目的 + +3 回目で見つけた 4 件と、投稿の成否を突き合わせない課題を直した。生成物を持つ +リポジトリで進行が止まる不具合 12 は、`--sync-command` を渡す構成でしか踏まないため、 +同じ構成でもう一度通す。 + +## 確かめること + +| # | 直したこと | 何が観測できれば通ったと言えるか | +| --- | --- | --- | +| 12 | 生成物の同期コミット | `Chore: 生成物を同期する(cross-refactoring 進行側)` が積まれ、`git add` が落ちない | +| 13 | 同期の後段の失敗 | 失敗しても作業ツリーが綺麗に戻り、次の実行が清浄性の検査で止まらない | +| 14 | 実装担当のコミット | 手順書の迂回手段でコミットが作れる。作れなくても取り込みが 0 件として続行する | +| 15 | 見送り後の読み取り同期 | 次ラウンドの提案が、取り消しで消えた対象を指さない | +| — | 投稿の成否の突き合わせ | cross-review が申告と GitHub 側の実数を照合する | + +## 対象範囲 + +| パス | 内容 | +| --- | --- | +| `plugins/ndf-shared/skills/cross-refactoring/scripts/` | `refactor.py` / `launch-cli.sh` / `prepare-worktrees.sh` | +| `plugins/ndf-shared/skills/cross-refactoring/tests/` | 現状固定テスト | +| `plugins/ndf-shared/skills/cross-review/scripts/lib/` | 収束ループの共通層 | +| `plugins/ndf-shared/skills/cross-review/tests/` | 現状固定テスト | + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| ホスト | Claude Code(提案・レビューには不参加) | +| 提案・レビュー | codex / gemini / kiro | +| 適用の母集合 | claude / codex / kiro | +| 使用する版 | リポジトリ内の `plugins/ndf-claude`(v8.5.0) | +| ラウンド上限 | 3 | +| 着手前のテスト | 463 passed | + +## 注意 + +`plugins/ndf-shared/` は編集元であり、`scripts/build-runtime-plugins.sh` で +`ndf-claude` / `ndf-codex` / `ndf-kiro` へ同期する。同期は進行側の責務のため +`--sync-command` で渡す。 From fc8e68adb73991e6247d4d7408a2d772c9cfef83 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:42:35 +0000 Subject: [PATCH 02/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用範囲のコミット所有者検証を _validate_range_ownership に抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2aa3c0d2..31fe8057 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,26 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _validate_range_ownership( + work: str, + in_range: set[str], + reported: dict[str, dict[str, Any]], +) -> tuple[dict[str, str], list[str], list[str]]: + """適用範囲のコミット所有者を申告から復元する。""" + 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)) + return owner_of, unassigned, duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1187,18 +1207,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 = _validate_range_ownership(work, in_range, reported) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: From a192ade6eee070b7ee982fe45908e8166b7645a6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:43:34 +0000 Subject: [PATCH 03/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目ごとの適用結果検証を _verify_items に抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 31fe8057..5751e402 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1108,6 +1108,60 @@ def _validate_range_ownership( return owner_of, unassigned, duplicated +def _verify_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """適用結果を項目ごとに検証し、進捗を状態へ残す。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + 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( + state["worktrees"]["work"], _reported_shas(got), in_range, + test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + 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["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + 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']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed, progress + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1273,51 +1327,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - 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, scope) - 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["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - 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']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed, progress = _verify_items( + path, state, entry, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, From 485dde5cef17c1a8efef031f904da8785e18dc74 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:44:54 +0000 Subject: [PATCH 04/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未割当や未知 ID を含む適用範囲の棄却処理を _reject_unassigned_range に抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 132 ++++++++++-------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5751e402..b25d3d35 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1162,6 +1162,74 @@ def _verify_items( return applied, failed, progress +def _reject_unassigned_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + head_sha: str, + unassigned: list[str], + unknown_ids: list[str], + duplicated: list[str], + dry_run: bool, +) -> None: + """検証できない適用範囲をラウンド単位で取り消す。""" + work = state["worktrees"]["work"] + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits(state, whole_round, dry_run) + if not 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, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if dry_run: + info("(dry-run)状態ファイルは更新していません") + return + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1263,68 +1331,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 _, unassigned, duplicated = _validate_range_ownership(work, in_range, reported) if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" + _reject_unassigned_range( + path, state, entry, ordered_range, head_sha, + unassigned, unknown_ids, duplicated, args.dry_run, ) - 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), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _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, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) sys.exit(2) scope = state.get("target_scope") or [] From a55de1aa7adfe141b85298fdea065d9d3bf0ecac Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:45:37 +0000 Subject: [PATCH 05/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出後の検証結果受け取りを必要な値だけに絞り、cmd_merge_apply を orchestration に寄せる。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b25d3d35..04ecee10 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1342,7 +1342,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - applied, failed, progress = _verify_items( + applied, failed, _ = _verify_items( path, state, entry, reported, in_range, scope, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) From 3b7c351ff90f7bd77bab9a30f76c44284faaff89 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:46:39 +0000 Subject: [PATCH 06/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.p?= =?UTF-8?q?y#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result.json を伴う lingering process の早期完了判定を _check_lingering_with_result に抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 95 +++++++++++-------- 1 file changed, 57 insertions(+), 38 deletions(-) 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..46f76912 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,58 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_lingering_with_result( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + cmdline_validated: bool, + started_wall: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """result.json があるまま残っているプロセスを完了扱いで止める。""" + 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 + + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + 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 + return None + + def monitor_agent( agent: str, pr: int, @@ -676,22 +728,11 @@ 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}" - ) - _emit_log(log_prefix, agent, status) - return status + lingering_status = _check_lingering_with_result( + agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix, + ) + if lingering_status is not None: + return lingering_status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -700,28 +741,6 @@ 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 - ): - 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/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 7e88d2d2ccf01ad70354e78a8bacb7706e76bffb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:47:32 +0000 Subject: [PATCH 07/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.p?= =?UTF-8?q?y#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 早期エラーの FATAL 判定と WARN 通知を _check_early_error に抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) 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 46f76912..a596b68e 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -665,6 +665,43 @@ def _check_lingering_with_result( return None +def _check_early_error( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + warned_early_error: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool]: + """早期エラーを検査し、致命なら status を返す。""" + 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 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + _emit_log(log_prefix, agent, status) + return status, warned_early_error + + 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 + return None, warned_early_error + + def monitor_agent( agent: str, pr: int, @@ -770,34 +807,11 @@ def monitor_agent( # 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_status, warned_early_error = _check_early_error( + agent, pid, paths, status, alive, warned_early_error, log_prefix, + ) + if early_status is not None: + return early_status if not alive: # プロセス終了 — result.json を確認 From d3e5bfe3ed89d6f3ff0fbacf50f5b8ff021fe3e9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:48:28 +0000 Subject: [PATCH 08/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.p?= =?UTF-8?q?y#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出済みの lingering result 判定の説明を helper に寄せ、monitor_agent のループ本体を整理する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../skills/cross-review/scripts/lib/monitor.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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 a596b68e..6e3c48f3 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -623,7 +623,12 @@ def _check_lingering_with_result( started_wall: float, log_prefix: str, ) -> Optional[AgentStatus]: - """result.json があるまま残っているプロセスを完了扱いで止める。""" + """result.json があるまま残っているプロセスを完了扱いで止める。 + + codex は `tokens used` sentinel と result.json が揃ったら完了とみなす。 + sentinel 機構を持たない agent は、PID 再利用でないことを確認済みで、 + かつ stale でない result.json が一定時間残っていれば完了扱いにする。 + """ if ( agent == "codex" and alive @@ -761,23 +766,12 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 lingering_status = _check_lingering_with_result( agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix, ) if lingering_status is not None: return lingering_status - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 98027d2cb2ed59483359e7e8b8359fefd55f06a9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:49:20 +0000 Subject: [PATCH 09/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正結果の解決スレッド申告と GitHub 実状態の突き合わせを _reconcile_resolved_threads に抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 04ecee10..afd456ff 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1230,6 +1230,31 @@ def _reject_unassigned_range( statefile.save(path, state) +def _reconcile_resolved_threads( + payload: dict[str, Any], + repo: str, + pr: int, +) -> set[str]: + """修正結果の解決申告を GitHub の実状態と突き合わせる。""" + raw_claimed = payload.get("resolved_thread_ids") + 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(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1752,25 +1777,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 = _reconcile_resolved_threads(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 33e0c8ff248bdbc0e0ce19c39336d83e28a4a648 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:50:05 +0000 Subject: [PATCH 10/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正コミット facts の検証と accepted/problems の集約を _validate_fix_commits に抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index afd456ff..066f84ce 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1255,6 +1255,24 @@ def _reconcile_resolved_threads( return resolved +def _validate_fix_commits( + facts: list[dict[str, Any]], + scope: list[str], +) -> tuple[list[tuple[str, str]], list[str]]: + """修正コミットが手順を満たしているか検証する。""" + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, problems + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1814,16 +1832,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, problems = _validate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From bb6b2076f34981e9bbd16238e4cb3dda07c5c54c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:50:45 +0000 Subject: [PATCH 11/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_merge_fix のコミット収集と検証で使う対象 scope を明示し、抽出後の orchestration を整える。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../ndf-shared/skills/cross-refactoring/scripts/refactor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 066f84ce..6d8ebb9d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1822,6 +1822,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: } unassigned = sorted(set(ordered_range) - reported_full) + scope = state.get("target_scope") or [] facts = collect_commit_facts( work, reported_shas, set(ordered_range), baseline.get("command") or "true", state["head_branch"], @@ -1832,7 +1833,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _validate_fix_commits(facts, state.get("target_scope") or []) + accepted, problems = _validate_fix_commits(facts, scope) if unassigned: info( From bbd7090bfeb75498431f1e4cfed729f174df4eac Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:51:33 +0000 Subject: [PATCH 12/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー結果から状態保存用 record を作る処理を _build_review_record に抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6d8ebb9d..2466c888 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1273,6 +1273,31 @@ def _validate_fix_commits( return accepted, problems +def _build_review_record( + reviews: dict[str, dict[str, Any]], + reviewers: list[str], + round_no: int, +) -> dict[str, Any]: + """LLM レビュー結果から状態ファイル用の正規化 record を作る。""" + record: dict[str, Any] = {"round": round_no, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + return record + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1587,22 +1612,7 @@ def cmd_judge_review(args: argparse.Namespace) -> None: # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) + record = _build_review_record(reviews, reviewers, len(entry["reviews"]) + 1) entry["reviews"].append(record) # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 From b2f8243c3ae11a46ddd2584a5bb8ad5c25c7d625 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:52:19 +0000 Subject: [PATCH 13/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとの所要時間集計を _accumulate_reviewer_durations に抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../skills/cross-refactoring/scripts/refactor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2466c888..4a2c6879 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1298,6 +1298,15 @@ def _build_review_record( return record +def _accumulate_reviewer_durations( + entry: dict[str, Any], + reviews: dict[str, dict[str, Any]], + reviewers: list[str], +) -> None: + """レビュー担当ごとの所要時間を累積し、合計を durations に反映する。""" + _accumulate_reviewer_durations(entry, reviews, reviewers) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 From 9592d0a2f6381ea56692f4d8e17e2540337ff132 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 22:53:58 +0000 Subject: [PATCH 14/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_judge_review の duration 集計を _accumulate_reviewer_durations の呼び出しへ置き換える。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../skills/cross-refactoring/scripts/refactor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 4a2c6879..45afb357 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1304,7 +1304,12 @@ def _accumulate_reviewer_durations( reviewers: list[str], ) -> None: """レビュー担当ごとの所要時間を累積し、合計を durations に反映する。""" - _accumulate_reviewer_durations(entry, reviews, reviewers) + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) def cmd_merge_apply(args: argparse.Namespace) -> None: @@ -1625,12 +1630,7 @@ def cmd_judge_review(args: argparse.Namespace) -> None: entry["reviews"].append(record) # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + _accumulate_reviewer_durations(entry, reviews, reviewers) statefile.save(path, state) def _remember(exit_code: int) -> None: From 30de5ac3ba5f63a069230db819fd107269dd8ea1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 15/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fjudge=5Freview"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9592d0a2f6381ea56692f4d8e17e2540337ff132. --- .../skills/cross-refactoring/scripts/refactor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 45afb357..4a2c6879 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1304,12 +1304,7 @@ def _accumulate_reviewer_durations( reviewers: list[str], ) -> None: """レビュー担当ごとの所要時間を累積し、合計を durations に反映する。""" - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + _accumulate_reviewer_durations(entry, reviews, reviewers) def cmd_merge_apply(args: argparse.Namespace) -> None: @@ -1630,7 +1625,12 @@ def cmd_judge_review(args: argparse.Namespace) -> None: entry["reviews"].append(record) # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - _accumulate_reviewer_durations(entry, reviews, reviewers) + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) statefile.save(path, state) def _remember(exit_code: int) -> None: From df93e57fcc257925a0d812a416e3318cc336bbb0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 16/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fjudge=5Freview"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b2f8243c3ae11a46ddd2584a5bb8ad5c25c7d625. --- .../skills/cross-refactoring/scripts/refactor.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 4a2c6879..2466c888 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1298,15 +1298,6 @@ def _build_review_record( return record -def _accumulate_reviewer_durations( - entry: dict[str, Any], - reviews: dict[str, dict[str, Any]], - reviewers: list[str], -) -> None: - """レビュー担当ごとの所要時間を累積し、合計を durations に反映する。""" - _accumulate_reviewer_durations(entry, reviews, reviewers) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 From 72fb73fbdc7da4f31d0ebf459476bfa4cbf88e5d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 17/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fjudge=5Freview"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bbd7090bfeb75498431f1e4cfed729f174df4eac. --- .../cross-refactoring/scripts/refactor.py | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2466c888..6d8ebb9d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1273,31 +1273,6 @@ def _validate_fix_commits( return accepted, problems -def _build_review_record( - reviews: dict[str, dict[str, Any]], - reviewers: list[str], - round_no: int, -) -> dict[str, Any]: - """LLM レビュー結果から状態ファイル用の正規化 record を作る。""" - record: dict[str, Any] = {"round": round_no, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - return record - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1612,7 +1587,22 @@ def cmd_judge_review(args: argparse.Namespace) -> None: # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record = _build_review_record(reviews, reviewers, len(entry["reviews"]) + 1) + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) entry["reviews"].append(record) # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 From 053e94c0e5615eed03eddfdc1deef9b6fa815d27 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 18/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bb6b2076f34981e9bbd16238e4cb3dda07c5c54c. --- .../ndf-shared/skills/cross-refactoring/scripts/refactor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6d8ebb9d..066f84ce 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1822,7 +1822,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: } unassigned = sorted(set(ordered_range) - reported_full) - scope = state.get("target_scope") or [] facts = collect_commit_facts( work, reported_shas, set(ordered_range), baseline.get("command") or "true", state["head_branch"], @@ -1833,7 +1832,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _validate_fix_commits(facts, scope) + accepted, problems = _validate_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From b04c5ba9a5dfca88d7d292c99d3f700d5f56c018 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 19/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 33e0c8ff248bdbc0e0ce19c39336d83e28a4a648. --- .../cross-refactoring/scripts/refactor.py | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 066f84ce..afd456ff 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1255,24 +1255,6 @@ def _reconcile_resolved_threads( return resolved -def _validate_fix_commits( - facts: list[dict[str, Any]], - scope: list[str], -) -> tuple[list[tuple[str, str]], list[str]]: - """修正コミットが手順を満たしているか検証する。""" - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, problems - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1832,7 +1814,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - accepted, problems = _validate_fix_commits(facts, state.get("target_scope") or []) + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, state.get("target_scope") or []) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) if unassigned: info( From f2cd4de5f54254a2fbf947344745c20010852626 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 20/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 98027d2cb2ed59483359e7e8b8359fefd55f06a9. --- .../cross-refactoring/scripts/refactor.py | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index afd456ff..04ecee10 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1230,31 +1230,6 @@ def _reject_unassigned_range( statefile.save(path, state) -def _reconcile_resolved_threads( - payload: dict[str, Any], - repo: str, - pr: int, -) -> set[str]: - """修正結果の解決申告を GitHub の実状態と突き合わせる。""" - raw_claimed = payload.get("resolved_thread_ids") - 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(repo, pr) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return resolved - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1777,7 +1752,25 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - resolved = _reconcile_resolved_threads(payload, state["repo"], state["current_pr"]) + 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 と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 368944272ac18b2742a04934a970319369d41512 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 21/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d3e5bfe3ed89d6f3ff0fbacf50f5b8ff021fe3e9. --- .../skills/cross-review/scripts/lib/monitor.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 6e3c48f3..a596b68e 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -623,12 +623,7 @@ def _check_lingering_with_result( started_wall: float, log_prefix: str, ) -> Optional[AgentStatus]: - """result.json があるまま残っているプロセスを完了扱いで止める。 - - codex は `tokens used` sentinel と result.json が揃ったら完了とみなす。 - sentinel 機構を持たない agent は、PID 再利用でないことを確認済みで、 - かつ stale でない result.json が一定時間残っていれば完了扱いにする。 - """ + """result.json があるまま残っているプロセスを完了扱いで止める。""" if ( agent == "codex" and alive @@ -766,12 +761,23 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 lingering_status = _check_lingering_with_result( agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix, ) if lingering_status is not None: return lingering_status + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 326a909affc5649afd864e92ddc9afe9e5861bfd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 22/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7e88d2d2ccf01ad70354e78a8bacb7706e76bffb. --- .../cross-review/scripts/lib/monitor.py | 70 ++++++++----------- 1 file changed, 28 insertions(+), 42 deletions(-) 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 a596b68e..46f76912 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -665,43 +665,6 @@ def _check_lingering_with_result( return None -def _check_early_error( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - warned_early_error: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool]: - """早期エラーを検査し、致命なら status を返す。""" - 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 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - _emit_log(log_prefix, agent, status) - return status, warned_early_error - - 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 - return None, warned_early_error - - def monitor_agent( agent: str, pr: int, @@ -807,11 +770,34 @@ def monitor_agent( # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - early_status, warned_early_error = _check_early_error( - agent, pid, paths, status, alive, warned_early_error, log_prefix, - ) - if early_status is not None: - return early_status + 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 0a82bc21bb3bb6e8a435330a3c4efe97dc14ecd4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:26 +0000 Subject: [PATCH 23/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3b7c351ff90f7bd77bab9a30f76c44284faaff89. --- .../cross-review/scripts/lib/monitor.py | 95 ++++++++----------- 1 file changed, 38 insertions(+), 57 deletions(-) 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 46f76912..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,58 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_lingering_with_result( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - cmdline_validated: bool, - started_wall: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """result.json があるまま残っているプロセスを完了扱いで止める。""" - 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 - - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - 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 - return None - - def monitor_agent( agent: str, pr: int, @@ -728,11 +676,22 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - lingering_status = _check_lingering_with_result( - agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix, - ) - if lingering_status is not None: - return lingering_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 # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -741,6 +700,28 @@ 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 + ): + 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/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 28dd7cc45c89b552ca4762a682dea07e261f70bc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:27 +0000 Subject: [PATCH 24/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a55de1aa7adfe141b85298fdea065d9d3bf0ecac. --- plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 04ecee10..b25d3d35 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1342,7 +1342,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - applied, failed, _ = _verify_items( + applied, failed, progress = _verify_items( path, state, entry, reported, in_range, scope, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) From f71130790882a77a9ad0ade5c35b28c5a5cbc12f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:27 +0000 Subject: [PATCH 25/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 485dde5cef17c1a8efef031f904da8785e18dc74. --- .../cross-refactoring/scripts/refactor.py | 132 ++++++++---------- 1 file changed, 61 insertions(+), 71 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b25d3d35..5751e402 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1162,74 +1162,6 @@ def _verify_items( return applied, failed, progress -def _reject_unassigned_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - head_sha: str, - unassigned: list[str], - unknown_ids: list[str], - duplicated: list[str], - dry_run: bool, -) -> None: - """検証できない適用範囲をラウンド単位で取り消す。""" - work = state["worktrees"]["work"] - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not dry_run: - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, dry_run) - if not 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, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if dry_run: - info("(dry-run)状態ファイルは更新していません") - return - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1331,10 +1263,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 _, unassigned, duplicated = _validate_range_ownership(work, in_range, reported) if unassigned or unknown_ids or duplicated: - _reject_unassigned_range( - path, state, entry, ordered_range, head_sha, - unassigned, unknown_ids, duplicated, args.dry_run, + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" ) + 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), + } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _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, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) sys.exit(2) scope = state.get("target_scope") or [] From 6d7b90a6a1aa914cd6e0a8fb8b35decc6e37a11f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:27 +0000 Subject: [PATCH 26/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a192ade6eee070b7ee982fe45908e8166b7645a6. --- .../cross-refactoring/scripts/refactor.py | 98 ++++++++----------- 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5751e402..31fe8057 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1108,60 +1108,6 @@ def _validate_range_ownership( return owner_of, unassigned, duplicated -def _verify_items( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - reported: dict[str, dict[str, Any]], - in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str], list[dict[str, Any]]]: - """適用結果を項目ごとに検証し、進捗を状態へ残す。""" - applied: list[str] = [] - failed: list[str] = [] - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - 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( - state["worktrees"]["work"], _reported_shas(got), in_range, - test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) - 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["out_of_scope"] = bool(got and "対象範囲の外" in problem) - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - 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']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not dry_run: - statefile.save(path, state) - return applied, failed, progress - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1327,15 +1273,51 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) + applied: list[str] = [] + failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - applied, failed, progress = _verify_items( - path, state, entry, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, - ) + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + 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, scope) + 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["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + 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']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, From a66ed1e33711ff8be1c5188b620fc34135f97931 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:01:27 +0000 Subject: [PATCH 27/34] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit fc8e68adb73991e6247d4d7408a2d772c9cfef83. --- .../cross-refactoring/scripts/refactor.py | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 31fe8057..2aa3c0d2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,26 +1088,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _validate_range_ownership( - work: str, - in_range: set[str], - reported: dict[str, dict[str, Any]], -) -> tuple[dict[str, str], list[str], list[str]]: - """適用範囲のコミット所有者を申告から復元する。""" - 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)) - return owner_of, unassigned, duplicated - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1207,7 +1187,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - _, unassigned, duplicated = _validate_range_ownership(work, in_range, reported) + 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: From cf6fd8699edc983052fbb1803ff2fd29d91e96df Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:07:30 +0000 Subject: [PATCH 28/34] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-review/scripts/lib/?= =?UTF-8?q?metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregate の 96 行を 3 つのヘルパーに分割: - _aggregate_impl_round: ラウンドごとの実装担当集計 - _record_first_review: 初回レビュー結果の反映 - _aggregate_reviewer_round: レビュー担当集計 aggregate 本体はラウンド反復と finish 処理だけを残す。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 182 +++++++++++------- 1 file changed, 108 insertions(+), 74 deletions(-) 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..9388a282 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,111 @@ 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: + """1 ラウンド分の実装担当集計を *impl* bucket へ加算する。""" + round_no = entry.get("round") + impl_runtime = entry.get("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) + + +def _record_first_review( + entry: dict[str, Any], + impl: dict[str, dict[str, Any]], +) -> None: + """初回レビュー結果を実装担当 bucket へ反映する。""" + impl_runtime = entry.get("impl") + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + 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 + + +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """1 ラウンド分のレビュー担当集計を *reviewer* bucket へ加算する。""" + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + reviews = _round_reviews(entry) + + 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 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +157,12 @@ 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 - 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, 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 - ) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _record_first_review(entry, impl) + _aggregate_reviewer_round(entry, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From 0f80b22141ee9674696505b9553794adbc0fd4df Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:10:04 +0000 Subject: [PATCH 29/34] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 箇所のインライン push-and-clear シーケンスを _push_with_retry_marker 呼び出しに統合: - cmd_merge_apply: 全件取り消し経路、全項目通過経路 - _apply_drop: 取り消し完了後の push - cmd_abandon_items: 見送り記録後の push Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2aa3c0d2..4ff92d72 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1259,9 +1259,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 残さないと同じ提案が次のラウンドで再び採用される。 _defer_abandoned_items(state, entry) statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) sys.exit(2) applied: list[str] = [] @@ -1340,11 +1338,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1444,9 +1438,7 @@ def _apply_drop( entry["pending_drop"] = [] entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) return applied @@ -1667,9 +1659,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: entry["pending_drop"] = [] state["phase"] = "propose" statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) def cmd_merge_fix(args: argparse.Namespace) -> None: From 8c0e18e99ca8bfe730b14ab520db5faa70eb367d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:11:06 +0000 Subject: [PATCH 30/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON 読み込み・検証・エラーハンドリングを _read_result_lenient に抽出。 cmd_merge_proposals のループ本体を簡素化し、_read_result(致命的)との 使い分けを明確にした。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 4ff92d72..9d0049af 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1006,21 +1006,8 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: state, runtime, stem_for(runtime, "propose", state["id"], entry["round"]), ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) + payload = _read_result_lenient(result, runtime) + if payload is None: proposals[runtime] = [] entry["proposed"][runtime] = 0 continue @@ -2750,6 +2737,29 @@ def _find_item( return None +def _read_result_lenient(path: pathlib.Path, runtime: str) -> Optional[dict[str, Any]]: + """結果ファイルを読む。不在・壊れ・非オブジェクトの場合は None を返す。 + + 1 者の不調で全体を止めたくない箇所(提案のマージなど)向け。致命的な箇所には + `_read_result` を使う。 + """ + if not path.exists(): + info(f"⚠ {runtime} の提案結果がありません: {path}") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return None + return payload + + def _read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** From 927f326c6814b27750023970eb86c9afce11931c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:12:03 +0000 Subject: [PATCH 31/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各ランタイムの提案読み込みループを _load_proposals に抽出。 cmd_merge_proposals は状態確認→読み込み→マージ→保存の流れだけを持つ。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 9d0049af..186b1274 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -976,6 +976,28 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) +def _load_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果を読み込み、ランタイム→項目リストの辞書で返す。""" + proposals: dict[str, list[dict[str, Any]]] = {} + for runtime in state["runtimes"]: + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) + payload = _read_result_lenient(result, runtime) + if payload is None: + proposals[runtime] = [] + entry["proposed"][runtime] = 0 + continue + items = payload.get("items") + proposals[runtime] = [i for i in items if isinstance(i, dict)] \ + if isinstance(items, list) else [] + entry["proposed"][runtime] = len(proposals[runtime]) + return proposals + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -1000,21 +1022,7 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) return - proposals: dict[str, list[dict[str, Any]]] = {} - for runtime in state["runtimes"]: - result = _result_path( - state, runtime, - stem_for(runtime, "propose", state["id"], entry["round"]), - ) - payload = _read_result_lenient(result, runtime) - if payload is None: - proposals[runtime] = [] - entry["proposed"][runtime] = 0 - continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals = _load_proposals(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 0f04ad0605400ffedc0ba385a8d32093583a5945 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:13:07 +0000 Subject: [PATCH 32/34] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.p?= =?UTF-8?q?y#=5Fscan=5Fpatterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _scan_patterns を 3 つのヘルパーに分割: - _read_log_tail_text: 末尾 200KB 読み込み + ANSI 除去 - _matched_line: マッチ位置から行を切り出す - _is_benign_match: benign パターンと引用判定 _scan_patterns は読み込んだ本文に対する走査と最初の non-benign 行の返却だけを行う。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) 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..b7df5831 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -473,6 +473,44 @@ def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: return None +def _read_log_tail_text(path: pathlib.Path) -> Optional[str]: + """err.log の末尾 200KB を読み、ANSI を除去したテキストを返す。 + + ファイルが存在しない・読めない場合は None。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + return _strip_ansi(data) + + +def _matched_line(data: str, m: re.Match[str]) -> tuple[str, int]: + """マッチ位置から行全体を切り出し、(行テキスト, 行の開始オフセット) を返す。""" + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + return data[line_start:line_end], line_start + + +def _is_benign_match( + line: str, match_start_in_line: int, match_end_in_line: int, + benign_patterns: list[re.Pattern[str]], +) -> bool: + """マッチが benign(誤検知)かどうかを判定する。""" + if any(b.search(line) for b in benign_patterns): + return True + if _match_is_quoted(line, match_start_in_line, match_end_in_line): + return True + return False + + def _scan_patterns( path: pathlib.Path, patterns: list[re.Pattern[str]], @@ -492,32 +530,23 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - if not path.exists(): + data = _read_log_tail_text(path) + if data is None: return None - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > 200 * 1024: - f.seek(sz - 200 * 1024) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return None - data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign for pat in patterns: for m in pat.finditer(data): - line_start = data.rfind("\n", 0, m.start()) + 1 - line_end = data.find("\n", m.end()) - line_end = line_end if line_end != -1 else len(data) - line = data[line_start:line_end] + line, line_start = _matched_line(data, m) # benign パターンはマッチ行そのものに当てる。markdown 引用や # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 - if any(b.search(line) for b in benign_patterns): - continue - # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 - if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + if _is_benign_match( + line, + m.start() - line_start, + m.end() - line_start, + benign_patterns, + ): continue return line.strip() return None From 0b45dc91632ff55ea1ed0967b55c988e2ca3b036 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:16:00 +0000 Subject: [PATCH 33/34] =?UTF-8?q?Chore:=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88cross-ref?= =?UTF-8?q?actoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当は対象範囲だけを変更するため、生成物が同期されない。 同期を検査する pre-push を持つリポジトリでも push できるよう、 公開の直前に進行側がまとめて生成する。 --- .../cross-refactoring/scripts/refactor.py | 92 +++++---- .../cross-review/scripts/lib/metrics.py | 182 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 65 +++++-- .../cross-refactoring/scripts/refactor.py | 92 +++++---- .../cross-review/scripts/lib/metrics.py | 182 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 65 +++++-- .../cross-refactoring/scripts/refactor.py | 92 +++++---- .../cross-review/scripts/lib/metrics.py | 182 +++++++++++------- .../cross-review/scripts/lib/monitor.py | 65 +++++-- 9 files changed, 615 insertions(+), 402 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 2aa3c0d2..186b1274 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -976,6 +976,28 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) +def _load_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果を読み込み、ランタイム→項目リストの辞書で返す。""" + proposals: dict[str, list[dict[str, Any]]] = {} + for runtime in state["runtimes"]: + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) + payload = _read_result_lenient(result, runtime) + if payload is None: + proposals[runtime] = [] + entry["proposed"][runtime] = 0 + continue + items = payload.get("items") + proposals[runtime] = [i for i in items if isinstance(i, dict)] \ + if isinstance(items, list) else [] + entry["proposed"][runtime] = len(proposals[runtime]) + return proposals + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -1000,34 +1022,7 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) return - proposals: dict[str, list[dict[str, Any]]] = {} - for runtime in state["runtimes"]: - result = _result_path( - state, runtime, - stem_for(runtime, "propose", state["id"], entry["round"]), - ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) - proposals[runtime] = [] - entry["proposed"][runtime] = 0 - continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals = _load_proposals(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1259,9 +1254,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 残さないと同じ提案が次のラウンドで再び採用される。 _defer_abandoned_items(state, entry) statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) sys.exit(2) applied: list[str] = [] @@ -1340,11 +1333,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1444,9 +1433,7 @@ def _apply_drop( entry["pending_drop"] = [] entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) return applied @@ -1667,9 +1654,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: entry["pending_drop"] = [] state["phase"] = "propose" statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -2760,6 +2745,29 @@ def _find_item( return None +def _read_result_lenient(path: pathlib.Path, runtime: str) -> Optional[dict[str, Any]]: + """結果ファイルを読む。不在・壊れ・非オブジェクトの場合は None を返す。 + + 1 者の不調で全体を止めたくない箇所(提案のマージなど)向け。致命的な箇所には + `_read_result` を使う。 + """ + if not path.exists(): + info(f"⚠ {runtime} の提案結果がありません: {path}") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return None + return payload + + def _read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** 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..9388a282 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,111 @@ 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: + """1 ラウンド分の実装担当集計を *impl* bucket へ加算する。""" + round_no = entry.get("round") + impl_runtime = entry.get("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) + + +def _record_first_review( + entry: dict[str, Any], + impl: dict[str, dict[str, Any]], +) -> None: + """初回レビュー結果を実装担当 bucket へ反映する。""" + impl_runtime = entry.get("impl") + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + 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 + + +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """1 ラウンド分のレビュー担当集計を *reviewer* bucket へ加算する。""" + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + reviews = _round_reviews(entry) + + 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 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +157,12 @@ 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 - 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, 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 - ) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _record_first_review(entry, impl) + _aggregate_reviewer_round(entry, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, 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..b7df5831 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -473,6 +473,44 @@ def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: return None +def _read_log_tail_text(path: pathlib.Path) -> Optional[str]: + """err.log の末尾 200KB を読み、ANSI を除去したテキストを返す。 + + ファイルが存在しない・読めない場合は None。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + return _strip_ansi(data) + + +def _matched_line(data: str, m: re.Match[str]) -> tuple[str, int]: + """マッチ位置から行全体を切り出し、(行テキスト, 行の開始オフセット) を返す。""" + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + return data[line_start:line_end], line_start + + +def _is_benign_match( + line: str, match_start_in_line: int, match_end_in_line: int, + benign_patterns: list[re.Pattern[str]], +) -> bool: + """マッチが benign(誤検知)かどうかを判定する。""" + if any(b.search(line) for b in benign_patterns): + return True + if _match_is_quoted(line, match_start_in_line, match_end_in_line): + return True + return False + + def _scan_patterns( path: pathlib.Path, patterns: list[re.Pattern[str]], @@ -492,32 +530,23 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - if not path.exists(): + data = _read_log_tail_text(path) + if data is None: return None - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > 200 * 1024: - f.seek(sz - 200 * 1024) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return None - data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign for pat in patterns: for m in pat.finditer(data): - line_start = data.rfind("\n", 0, m.start()) + 1 - line_end = data.find("\n", m.end()) - line_end = line_end if line_end != -1 else len(data) - line = data[line_start:line_end] + line, line_start = _matched_line(data, m) # benign パターンはマッチ行そのものに当てる。markdown 引用や # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 - if any(b.search(line) for b in benign_patterns): - continue - # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 - if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + if _is_benign_match( + line, + m.start() - line_start, + m.end() - line_start, + benign_patterns, + ): continue return line.strip() return None diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 2aa3c0d2..186b1274 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -976,6 +976,28 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) +def _load_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果を読み込み、ランタイム→項目リストの辞書で返す。""" + proposals: dict[str, list[dict[str, Any]]] = {} + for runtime in state["runtimes"]: + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) + payload = _read_result_lenient(result, runtime) + if payload is None: + proposals[runtime] = [] + entry["proposed"][runtime] = 0 + continue + items = payload.get("items") + proposals[runtime] = [i for i in items if isinstance(i, dict)] \ + if isinstance(items, list) else [] + entry["proposed"][runtime] = len(proposals[runtime]) + return proposals + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -1000,34 +1022,7 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) return - proposals: dict[str, list[dict[str, Any]]] = {} - for runtime in state["runtimes"]: - result = _result_path( - state, runtime, - stem_for(runtime, "propose", state["id"], entry["round"]), - ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) - proposals[runtime] = [] - entry["proposed"][runtime] = 0 - continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals = _load_proposals(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1259,9 +1254,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 残さないと同じ提案が次のラウンドで再び採用される。 _defer_abandoned_items(state, entry) statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) sys.exit(2) applied: list[str] = [] @@ -1340,11 +1333,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1444,9 +1433,7 @@ def _apply_drop( entry["pending_drop"] = [] entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) return applied @@ -1667,9 +1654,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: entry["pending_drop"] = [] state["phase"] = "propose" statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -2760,6 +2745,29 @@ def _find_item( return None +def _read_result_lenient(path: pathlib.Path, runtime: str) -> Optional[dict[str, Any]]: + """結果ファイルを読む。不在・壊れ・非オブジェクトの場合は None を返す。 + + 1 者の不調で全体を止めたくない箇所(提案のマージなど)向け。致命的な箇所には + `_read_result` を使う。 + """ + if not path.exists(): + info(f"⚠ {runtime} の提案結果がありません: {path}") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return None + return payload + + def _read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** 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..9388a282 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,111 @@ 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: + """1 ラウンド分の実装担当集計を *impl* bucket へ加算する。""" + round_no = entry.get("round") + impl_runtime = entry.get("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) + + +def _record_first_review( + entry: dict[str, Any], + impl: dict[str, dict[str, Any]], +) -> None: + """初回レビュー結果を実装担当 bucket へ反映する。""" + impl_runtime = entry.get("impl") + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + 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 + + +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """1 ラウンド分のレビュー担当集計を *reviewer* bucket へ加算する。""" + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + reviews = _round_reviews(entry) + + 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 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +157,12 @@ 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 - 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, 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 - ) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _record_first_review(entry, impl) + _aggregate_reviewer_round(entry, 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/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 401a760f..b7df5831 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -473,6 +473,44 @@ def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: return None +def _read_log_tail_text(path: pathlib.Path) -> Optional[str]: + """err.log の末尾 200KB を読み、ANSI を除去したテキストを返す。 + + ファイルが存在しない・読めない場合は None。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + return _strip_ansi(data) + + +def _matched_line(data: str, m: re.Match[str]) -> tuple[str, int]: + """マッチ位置から行全体を切り出し、(行テキスト, 行の開始オフセット) を返す。""" + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + return data[line_start:line_end], line_start + + +def _is_benign_match( + line: str, match_start_in_line: int, match_end_in_line: int, + benign_patterns: list[re.Pattern[str]], +) -> bool: + """マッチが benign(誤検知)かどうかを判定する。""" + if any(b.search(line) for b in benign_patterns): + return True + if _match_is_quoted(line, match_start_in_line, match_end_in_line): + return True + return False + + def _scan_patterns( path: pathlib.Path, patterns: list[re.Pattern[str]], @@ -492,32 +530,23 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - if not path.exists(): + data = _read_log_tail_text(path) + if data is None: return None - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > 200 * 1024: - f.seek(sz - 200 * 1024) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return None - data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign for pat in patterns: for m in pat.finditer(data): - line_start = data.rfind("\n", 0, m.start()) + 1 - line_end = data.find("\n", m.end()) - line_end = line_end if line_end != -1 else len(data) - line = data[line_start:line_end] + line, line_start = _matched_line(data, m) # benign パターンはマッチ行そのものに当てる。markdown 引用や # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 - if any(b.search(line) for b in benign_patterns): - continue - # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 - if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + if _is_benign_match( + line, + m.start() - line_start, + m.end() - line_start, + benign_patterns, + ): continue return line.strip() return None diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 2aa3c0d2..186b1274 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -976,6 +976,28 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) +def _load_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果を読み込み、ランタイム→項目リストの辞書で返す。""" + proposals: dict[str, list[dict[str, Any]]] = {} + for runtime in state["runtimes"]: + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) + payload = _read_result_lenient(result, runtime) + if payload is None: + proposals[runtime] = [] + entry["proposed"][runtime] = 0 + continue + items = payload.get("items") + proposals[runtime] = [i for i in items if isinstance(i, dict)] \ + if isinstance(items, list) else [] + entry["proposed"][runtime] = len(proposals[runtime]) + return proposals + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -1000,34 +1022,7 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) return - proposals: dict[str, list[dict[str, Any]]] = {} - for runtime in state["runtimes"]: - result = _result_path( - state, runtime, - stem_for(runtime, "propose", state["id"], entry["round"]), - ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) - proposals[runtime] = [] - entry["proposed"][runtime] = 0 - continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals = _load_proposals(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] @@ -1259,9 +1254,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 残さないと同じ提案が次のラウンドで再び採用される。 _defer_abandoned_items(state, entry) statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) sys.exit(2) applied: list[str] = [] @@ -1340,11 +1333,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1444,9 +1433,7 @@ def _apply_drop( entry["pending_drop"] = [] entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) return applied @@ -1667,9 +1654,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: entry["pending_drop"] = [] state["phase"] = "propose" statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + _push_with_retry_marker(path, state, entry) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -2760,6 +2745,29 @@ def _find_item( return None +def _read_result_lenient(path: pathlib.Path, runtime: str) -> Optional[dict[str, Any]]: + """結果ファイルを読む。不在・壊れ・非オブジェクトの場合は None を返す。 + + 1 者の不調で全体を止めたくない箇所(提案のマージなど)向け。致命的な箇所には + `_read_result` を使う。 + """ + if not path.exists(): + info(f"⚠ {runtime} の提案結果がありません: {path}") + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return None + return payload + + def _read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** 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..9388a282 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,111 @@ 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: + """1 ラウンド分の実装担当集計を *impl* bucket へ加算する。""" + round_no = entry.get("round") + impl_runtime = entry.get("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) + + +def _record_first_review( + entry: dict[str, Any], + impl: dict[str, dict[str, Any]], +) -> None: + """初回レビュー結果を実装担当 bucket へ反映する。""" + impl_runtime = entry.get("impl") + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + 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 + + +def _aggregate_reviewer_round( + entry: dict[str, Any], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """1 ラウンド分のレビュー担当集計を *reviewer* bucket へ加算する。""" + round_no = entry.get("round") + reviewer_models = entry.get("reviewer_models") or {} + reviews = _round_reviews(entry) + + 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 + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +157,12 @@ 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 - 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, 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 - ) + _aggregate_impl_round(entry, items_by_id, impl, unmeasured) + _record_first_review(entry, impl) + _aggregate_reviewer_round(entry, 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/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 401a760f..b7df5831 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -473,6 +473,44 @@ def _pid_cmdline_matches(pid: int, expected: str) -> Optional[bool]: return None +def _read_log_tail_text(path: pathlib.Path) -> Optional[str]: + """err.log の末尾 200KB を読み、ANSI を除去したテキストを返す。 + + ファイルが存在しない・読めない場合は None。 + """ + if not path.exists(): + return None + try: + sz = path.stat().st_size + with path.open("rb") as f: + if sz > 200 * 1024: + f.seek(sz - 200 * 1024) + data = f.read().decode("utf-8", errors="replace") + except OSError: + return None + return _strip_ansi(data) + + +def _matched_line(data: str, m: re.Match[str]) -> tuple[str, int]: + """マッチ位置から行全体を切り出し、(行テキスト, 行の開始オフセット) を返す。""" + line_start = data.rfind("\n", 0, m.start()) + 1 + line_end = data.find("\n", m.end()) + line_end = line_end if line_end != -1 else len(data) + return data[line_start:line_end], line_start + + +def _is_benign_match( + line: str, match_start_in_line: int, match_end_in_line: int, + benign_patterns: list[re.Pattern[str]], +) -> bool: + """マッチが benign(誤検知)かどうかを判定する。""" + if any(b.search(line) for b in benign_patterns): + return True + if _match_is_quoted(line, match_start_in_line, match_end_in_line): + return True + return False + + def _scan_patterns( path: pathlib.Path, patterns: list[re.Pattern[str]], @@ -492,32 +530,23 @@ def _scan_patterns( 扱いしてしまった (例: `Error in: mcpServers.serena\\n...\\nTraceback ...` で Traceback が誤抑制された)。 """ - if not path.exists(): + data = _read_log_tail_text(path) + if data is None: return None - try: - sz = path.stat().st_size - with path.open("rb") as f: - if sz > 200 * 1024: - f.seek(sz - 200 * 1024) - data = f.read().decode("utf-8", errors="replace") - except OSError: - return None - data = _strip_ansi(data) benign_patterns = EARLY_ERROR_BENIGN if benign is None else benign for pat in patterns: for m in pat.finditer(data): - line_start = data.rfind("\n", 0, m.start()) + 1 - line_end = data.find("\n", m.end()) - line_end = line_end if line_end != -1 else len(data) - line = data[line_start:line_end] + line, line_start = _matched_line(data, m) # benign パターンはマッチ行そのものに当てる。markdown 引用や # `Error in: mcpServers.X` のような行単位パターンは「その行」だけを # 評価すれば判定可能で、文脈窓を広げると誤判定の原因になる。 - if any(b.search(line) for b in benign_patterns): - continue - # マッチ部位が backtick / 日本語「」 で引用されている場合も benign。 - if _match_is_quoted(line, m.start() - line_start, m.end() - line_start): + if _is_benign_match( + line, + m.start() - line_start, + m.end() - line_start, + benign_patterns, + ): continue return line.strip() return None From 5a9c4d0e1e256e0adfe1e20d325f38b84113b8b0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 18 Aug 2026 23:38:43 +0000 Subject: [PATCH 34/34] =?UTF-8?q?Docs:=20cross-refactoring=204=20=E5=9B=9E?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E5=AE=9F=E6=A9=9F=E8=A9=A6=E8=A1=8C=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=82=92=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-113-cross-refactoring-4th-trial-report.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-4th-trial-report.md diff --git a/issues/issue-113-cross-refactoring-4th-trial-report.md b/issues/issue-113-cross-refactoring-4th-trial-report.md new file mode 100644 index 00000000..cfeb055f --- /dev/null +++ b/issues/issue-113-cross-refactoring-4th-trial-report.md @@ -0,0 +1,158 @@ +# cross-refactoring 4 回目の実機試行の結果 + +Pull Request #127(NDF v8.5.0)で直した 5 件を実機で確かめた記録である。 +対象は Pull Request #128(Draft のまま)。 + +経緯は次の 3 つにある。 + +- [issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) — 1 回目 +- [issue-113-cross-refactoring-retrial.md](issue-113-cross-refactoring-retrial.md) — 2 回目 +- [issue-113-cross-refactoring-re-retrial.md](issue-113-cross-refactoring-re-retrial.md) — 3 回目 + +## 結果 + +**3 回目に見つけた 4 件のうち、実機で踏める 3 件は直っていた。** +一方でラウンド 2 の再レビューで**進行が終わらない経路**に入り、手で止めた。 +原因は 3 回目より前から存在した欠落で、レビュー結果が 2 回続けて欠けたときにだけ現れる。 + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| ホスト | Claude Code(提案・レビューには不参加) | +| 提案・レビュー | codex / gemini / kiro | +| 適用の母集合 | claude / codex / kiro | +| 使用する版 | プラグインキャッシュの v8.5.0 | +| ラウンド上限 | 3(実際は 2 ラウンド目で停止) | +| 着手前のテスト | 463 passed | + +```bash +/ndf:cross-refactoring 128 \ + --scope plugins/ndf-shared/skills/cross-refactoring/scripts \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/scripts/lib \ + plugins/ndf-shared/skills/cross-review/tests \ + --sync-command "bash scripts/build-runtime-plugins.sh" \ + --baseline-test "uv run --with pytest python -m pytest \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/tests -q" \ + --max-outer-rounds 3 +``` + +## 到達点 + +```mermaid +flowchart TD + Init([初期化・作業ディレクトリ・Skill 配置]):::ok --> P1 + P1["R1 提案(3 CLI 並列)11 件"]:::ok --> M1 + M1["統合 5 件 → 採用 5 件"]:::ok --> A1 + A1["R1 適用(codex)13 コミット / 901 秒"]:::ok --> V1 + V1["検証: 採用 2 / 失敗 3"]:::ok --> D1 + D1["積み直し競合 → ラウンド全件へ退避
取り消し 13 コミット"]:::ok --> P2 + P2["R2 提案(同期後)8 件 → 採用 5 件"]:::ok --> A2 + A2["R2 適用(kiro)5 コミット"]:::ok --> V2 + V2["検証: 採用 5 / 失敗 0"]:::ok --> S2 + S2["生成物を同期(9 ファイル)→ 進行側が push"]:::ok --> RV2 + RV2["R2 レビュー: gemini 承認 / codex 結果なし"]:::ng --> LOOP + LOOP["差し戻し上限 → 変更要求として扱う
修正フェーズ → 起点なしで弾かれる
修正ラウンドが進まず同じ経路を反復"]:::ng --> Stop + Stop([手で停止]):::stop + + classDef ok fill:#dfd,stroke:#383 + classDef ng fill:#fdd,stroke:#933 + classDef stop fill:#eef,stroke:#557 +``` + +## v8.5.0 の修正の確認 + +| # | 直したこと | 観測 | 判定 | +| --- | --- | --- | --- | +| 12 | 生成物の同期コミット | `🔧 生成物を同期しました(bash scripts/build-runtime-plugins.sh / 9 ファイル)` が出て、`Chore: 生成物を同期する(cross-refactoring 進行側)` が push まで届いた | 成立 | +| 13 | 同期の後段の失敗 | 実機では後段が落ちなかったため未到達。旧版と新版を並べた再現で確認した(下記) | 別手段で確認 | +| 14 | 実装担当のコミット | 適用フェーズで codex / kiro とも手順書どおりコミットを作れた。作業ツリーに残骸は出なかった | 成立 | +| 15 | 見送り後の読み取り同期 | ラウンド 1 の全件取り消しで HEAD が `4642657` → `a66ed1e` へ動いた後、提案の直前に読み取り用 3 つとも `a66ed1e` へ同期された。ラウンド 2 の提案は消えた関数を 1 件も指していない | 成立 | + +修正 13 は実機で踏めなかったため、`pre-commit` で `git commit` を必ず落とす作業ツリーを +作り、旧版と新版で `_sync_generated` を呼び分けて比べた。 + +| 版 | 中断後の作業ツリー | +| --- | --- | +| 8.3.0 | `' M generated.txt'` が残る | +| 8.5.0 | 空(`reset --hard` と `clean -fd` で戻る) | + +修正 12 も同じ方法で、旧版が `_worktree_changes()` から `{'enerated.txt': 'M '}` を返して +`git add` が `pathspec ... did not match any files` で落ちること、新版が +`{'generated.txt': ' M'}` を返して通ることを確認した。 + +## 見つけた不具合 + +### 16. レビュー結果が欠け続けると進行が終わらない + +**進行が止まらない。** レビュー結果が 2 回続けて欠けた後、修正フェーズと再レビューを +無限に往復する。実測ではラウンド 2 で 3 巡し、手で停止するまで終わらなかった。 + +`cmd_judge_review` には変更要求を返す出口が 2 つある。 + +| 出口 | `fix_base_sha` の記録 | +| --- | --- | +| 通常の変更要求 | する | +| 差し戻しの上限に達したため変更要求として扱う | **しない** | + +`cmd_merge_fix` は `commits_in_range(work, entry.get("fix_base_sha"), head)` で修正の +範囲を求め、起点が空なら `None` が返る。範囲を確定できないので終了コード 2 で弾かれるが、 +このとき `fix_rounds` は増えない。`cmd_should_abandon` は `fix_rounds` が上限に達したかで +見送りへ移るため、**上限に永久に到達しない**。 + +``` +修正ラウンド 0 / 3 — まだ修正します +❌ 修正の範囲を確定できませんでした(起点 None / HEAD 0b45dc9...)。検証できない修正は採りません +===== レビュー ===== +(以降くり返し) +``` + +この欠落は v8.5.0 で入ったものではない。v8.3.0 でも同じ位置にあり、レビュー結果が +2 回続けて欠ける条件を過去 3 回の試行が満たさなかったため現れていない。 + +**直し方**: 差し戻し上限の出口でも `fix_base_sha` を記録する。あわせて、 +`merge-fix` が範囲を確定できずに終わったときも `fix_rounds` を進め、見送りへ到達させる。 + +### 17. 実装担当に直せない指摘が渡る + +レビュー結果の欠落は、`findings` へ次の 1 件として記録される。 + +```json +{"reviewer": "cross-refactoring", "item_id": null, + "summary": "レビュー結果の形式が 2 回続けて不正だった: codex のレビュー結果がありません", + "resolved": false} +``` + +対象の改善項目が無く、コードのどこを直せば解決するのかも書かれていない。実装担当は +これを受け取っても何もできず、修正フェーズが空回りする。不具合 16 を直しても、 +承認済みの項目が「レビュー担当が動かなかった」という理由だけで見送りへ進む。 + +**直し方**: レビュー担当が結果を残さなかったことは、実装担当への変更要求ではなく +**進行側の問題**として扱う。片方の結果が欠けたまま上限に達したら、残る 1 者の判定で +決めるか、進行を中断して利用者に判断を渡す。 + +## 運用上の観測 + +| 事象 | 内容 | +| --- | --- | +| codex がレビュー結果を残さない | ラウンド 2 で 3 回とも、手順書を読み進めた後に何も投稿せず終了した。`sentinel` は書かれるため監視は「完了」と見なし、`result.json` の不在だけが手掛かりになる | +| 差分予算による失敗 | ラウンド 1 の 2 件が予算超過で落ちた(265 行 / 240 行、183 行 / 180 行)。`long_method` の抽出は見積より膨らみやすい | +| 積み直しの競合 | ラウンド 1 は同じファイルの隣接行を触る項目が重なり、3 回目と同じくラウンド全件へ退避した | + +## 集計 + +| 実装担当 | 担当R | 適用 | 見送り | 予算超過率 | 所要秒 | +| --- | ---: | ---: | ---: | ---: | ---: | +| codex / default | 1 | 0 | 5 | 0.40 | 1800 | +| kiro / default | 1 | 0 | 0 | — | 320 | + +| レビュー担当 | レビュー回数 | 指摘 | 所要秒 | +| --- | ---: | ---: | ---: | +| codex / default | 0 | 0 | 0 | +| gemini / default | 3 | 0 | 480 | +| kiro / default | 0 | 0 | 0 | + +kiro は既定モデル(auto)で動いたため、両ラウンドとも比較用の集計から分離された。 +1 回の実行内の値なので、ランタイムの優劣を読む材料にはならない。